Added a page with jobs (#4258)
parent
bec253f022
commit
7f86a5d801
@ -0,0 +1,48 @@
|
||||
// Copyright (C) 2022 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { ActionUnion, createAction, ThunkAction } from 'utils/redux';
|
||||
import getCore from 'cvat-core-wrapper';
|
||||
import { JobsQuery } from 'reducers/interfaces';
|
||||
|
||||
const cvat = getCore();
|
||||
|
||||
export enum JobsActionTypes {
|
||||
GET_JOBS = 'GET_JOBS',
|
||||
GET_JOBS_SUCCESS = 'GET_JOBS_SUCCESS',
|
||||
GET_JOBS_FAILED = 'GET_JOBS_FAILED',
|
||||
}
|
||||
|
||||
interface JobsList extends Array<any> {
|
||||
count: number;
|
||||
}
|
||||
|
||||
const jobsActions = {
|
||||
getJobs: (query: Partial<JobsQuery>) => createAction(JobsActionTypes.GET_JOBS, { query }),
|
||||
getJobsSuccess: (jobs: JobsList, previews: string[]) => (
|
||||
createAction(JobsActionTypes.GET_JOBS_SUCCESS, { jobs, previews })
|
||||
),
|
||||
getJobsFailed: (error: any) => createAction(JobsActionTypes.GET_JOBS_FAILED, { error }),
|
||||
};
|
||||
|
||||
export type JobsActions = ActionUnion<typeof jobsActions>;
|
||||
|
||||
export const getJobsAsync = (query: JobsQuery): ThunkAction => async (dispatch) => {
|
||||
try {
|
||||
// Remove all keys with null values from the query
|
||||
const filteredQuery: Partial<JobsQuery> = { ...query };
|
||||
for (const [key, value] of Object.entries(filteredQuery)) {
|
||||
if (value === null) {
|
||||
delete filteredQuery[key];
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(jobsActions.getJobs(filteredQuery));
|
||||
const jobs = await cvat.jobs.get(filteredQuery);
|
||||
const previewPromises = jobs.map((job: any) => (job as any).frames.preview().catch(() => ''));
|
||||
dispatch(jobsActions.getJobsSuccess(jobs, await Promise.all(previewPromises)));
|
||||
} catch (error) {
|
||||
dispatch(jobsActions.getJobsFailed(error));
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,103 @@
|
||||
// Copyright (C) 2022 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useHistory } from 'react-router';
|
||||
import Card from 'antd/lib/card';
|
||||
import Empty from 'antd/lib/empty';
|
||||
import Descriptions from 'antd/lib/descriptions';
|
||||
import { MoreOutlined } from '@ant-design/icons';
|
||||
import Dropdown from 'antd/lib/dropdown';
|
||||
import Menu from 'antd/lib/menu';
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import { MenuInfo } from 'rc-menu/lib/interface';
|
||||
|
||||
import { useCardHeightHOC } from 'utils/hooks';
|
||||
|
||||
const useCardHeight = useCardHeightHOC({
|
||||
containerClassName: 'cvat-jobs-page',
|
||||
siblingClassNames: ['cvat-jobs-page-pagination', 'cvat-jobs-page-top-bar'],
|
||||
paddings: 40,
|
||||
numberOfRows: 3,
|
||||
});
|
||||
|
||||
interface Props {
|
||||
job: any;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
function JobCardComponent(props: Props): JSX.Element {
|
||||
const { job, preview } = props;
|
||||
const [expanded, setExpanded] = useState<boolean>(false);
|
||||
const history = useHistory();
|
||||
const height = useCardHeight();
|
||||
const onClick = (): void => {
|
||||
history.push(`/tasks/${job.taskId}/jobs/${job.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
onMouseEnter={() => setExpanded(true)}
|
||||
onMouseLeave={() => setExpanded(false)}
|
||||
style={{ height }}
|
||||
className='cvat-job-page-list-item'
|
||||
cover={(
|
||||
<>
|
||||
{preview ? (
|
||||
<img
|
||||
className='cvat-jobs-page-job-item-card-preview'
|
||||
src={preview}
|
||||
alt='Preview'
|
||||
onClick={onClick}
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<div className='cvat-jobs-page-job-item-card-preview' onClick={onClick} aria-hidden>
|
||||
<Empty description='Preview not found' />
|
||||
</div>
|
||||
)}
|
||||
<div className='cvat-job-page-list-item-id'>
|
||||
ID:
|
||||
{` ${job.id}`}
|
||||
</div>
|
||||
<div className='cvat-job-page-list-item-dimension'>{job.dimension.toUpperCase()}</div>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<Descriptions column={1} size='small'>
|
||||
<Descriptions.Item label='Stage'>{job.stage}</Descriptions.Item>
|
||||
<Descriptions.Item label='State'>{job.state}</Descriptions.Item>
|
||||
{ expanded ? (
|
||||
<Descriptions.Item label='Size'>{job.stopFrame - job.startFrame + 1}</Descriptions.Item>
|
||||
) : null}
|
||||
{ expanded && job.assignee ? (
|
||||
<Descriptions.Item label='Assignee'>{job.assignee.username}</Descriptions.Item>
|
||||
) : null}
|
||||
</Descriptions>
|
||||
<Dropdown overlay={(
|
||||
<Menu onClick={(action: MenuInfo) => {
|
||||
if (action.key === 'task') {
|
||||
history.push(`/tasks/${job.taskId}`);
|
||||
} else if (action.key === 'project') {
|
||||
history.push(`/projects/${job.projectId}`);
|
||||
} else if (action.key === 'bug_tracker') {
|
||||
// false alarm
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
window.open(job.bugTracker, '_blank', 'noopener noreferrer');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Menu.Item key='task' disabled={job.taskId === null}>Go to the task</Menu.Item>
|
||||
<Menu.Item key='project' disabled={job.projectId === null}>Go to the project</Menu.Item>
|
||||
<Menu.Item key='bug_tracker' disabled={!job.bugTracker}>Go to the bug tracker</Menu.Item>
|
||||
</Menu>
|
||||
)}
|
||||
>
|
||||
<MoreOutlined className='cvat-job-card-more-button' />
|
||||
</Dropdown>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(JobCardComponent);
|
||||
@ -0,0 +1,32 @@
|
||||
// Copyright (C) 2022 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Col, Row } from 'antd/lib/grid';
|
||||
import { CombinedState } from 'reducers/interfaces';
|
||||
import JobCard from './job-card';
|
||||
|
||||
function JobsContentComponent(): JSX.Element {
|
||||
const jobs = useSelector((state: CombinedState) => state.jobs.current);
|
||||
const previews = useSelector((state: CombinedState) => state.jobs.previews);
|
||||
const dimensions = {
|
||||
md: 22,
|
||||
lg: 18,
|
||||
xl: 16,
|
||||
xxl: 16,
|
||||
};
|
||||
|
||||
return (
|
||||
<Row justify='center' align='middle'>
|
||||
<Col className='cvat-jobs-page-list' {...dimensions}>
|
||||
{jobs.map((job: any, idx: number): JSX.Element => (
|
||||
<JobCard preview={previews[idx]} job={job} key={job.id} />
|
||||
))}
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(JobsContentComponent);
|
||||
@ -0,0 +1,116 @@
|
||||
// Copyright (C) 2022 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import './styles.scss';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useHistory } from 'react-router';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import Spin from 'antd/lib/spin';
|
||||
import { Col, Row } from 'antd/lib/grid';
|
||||
import Pagination from 'antd/lib/pagination';
|
||||
import Empty from 'antd/lib/empty';
|
||||
|
||||
import { CombinedState } from 'reducers/interfaces';
|
||||
import { getJobsAsync } from 'actions/jobs-actions';
|
||||
|
||||
import TopBarComponent from './top-bar';
|
||||
import JobsContentComponent from './jobs-content';
|
||||
|
||||
function JobsPageComponent(): JSX.Element {
|
||||
const dispatch = useDispatch();
|
||||
const query = useSelector((state: CombinedState) => state.jobs.query);
|
||||
const fetching = useSelector((state: CombinedState) => state.jobs.fetching);
|
||||
const count = useSelector((state: CombinedState) => state.jobs.count);
|
||||
const history = useHistory();
|
||||
|
||||
useEffect(() => {
|
||||
// get relevant query parameters from the url and fetch jobs according to them
|
||||
const { location } = history;
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const copiedQuery = { ...query };
|
||||
for (const key of Object.keys(copiedQuery)) {
|
||||
if (searchParams.has(key)) {
|
||||
const value = searchParams.get(key);
|
||||
if (value) {
|
||||
copiedQuery[key] = key === 'page' ? +value : value;
|
||||
}
|
||||
} else {
|
||||
copiedQuery[key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(getJobsAsync(copiedQuery));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// when query is updated, set relevant search params to url
|
||||
const searchParams = new URLSearchParams();
|
||||
const { location } = history;
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value) {
|
||||
searchParams.set(key, value.toString());
|
||||
}
|
||||
}
|
||||
|
||||
history.push(`${location.pathname}?${searchParams.toString()}`);
|
||||
}, [query]);
|
||||
|
||||
if (fetching) {
|
||||
return (
|
||||
<div className='cvat-jobs-page'>
|
||||
<Spin size='large' className='cvat-spinner' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dimensions = {
|
||||
md: 22,
|
||||
lg: 18,
|
||||
xl: 16,
|
||||
xxl: 16,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='cvat-jobs-page'>
|
||||
<TopBarComponent
|
||||
query={query}
|
||||
onChangeFilters={(filters: Record<string, string | null>) => {
|
||||
dispatch(
|
||||
getJobsAsync({
|
||||
...query,
|
||||
...filters,
|
||||
page: 1,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{count ? (
|
||||
<>
|
||||
<JobsContentComponent />
|
||||
<Row justify='space-around' about='middle'>
|
||||
<Col {...dimensions}>
|
||||
<Pagination
|
||||
className='cvat-jobs-page-pagination'
|
||||
onChange={(page: number) => {
|
||||
dispatch(getJobsAsync({
|
||||
...query,
|
||||
page,
|
||||
}));
|
||||
}}
|
||||
showSizeChanger={false}
|
||||
total={count}
|
||||
pageSize={12}
|
||||
current={query.page}
|
||||
showQuickJumper
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
) : <Empty />}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(JobsPageComponent);
|
||||
@ -0,0 +1,142 @@
|
||||
// Copyright (C) 2022 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
@import '../../base.scss';
|
||||
|
||||
.cvat-jobs-page {
|
||||
padding-top: $grid-unit-size * 2;
|
||||
padding-bottom: $grid-unit-size;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
.cvat-jobs-page-top-bar {
|
||||
> div:nth-child(1) {
|
||||
> div:nth-child(1) {
|
||||
width: 100%;
|
||||
|
||||
> div:nth-child(1) {
|
||||
display: flex;
|
||||
|
||||
span {
|
||||
margin-right: $grid-unit-size;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> div:nth-child(1) {
|
||||
div > {
|
||||
.cvat-title {
|
||||
color: $text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> div:nth-child(2) {
|
||||
&.ant-empty {
|
||||
position: absolute;
|
||||
top: 40%;
|
||||
left: 50%;
|
||||
}
|
||||
|
||||
padding-bottom: $grid-unit-size;
|
||||
padding-top: $grid-unit-size;
|
||||
}
|
||||
|
||||
.cvat-job-page-list-item {
|
||||
width: 25%;
|
||||
border-width: $grid-unit-size / 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.ant-card-cover {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
padding: $grid-unit-size;
|
||||
|
||||
.ant-descriptions-item {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.cvat-job-page-list-item-id {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.cvat-job-page-list-item-dimension {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.cvat-jobs-page-job-item-card-preview {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
object-fit: cover;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cvat-job-page-list-item-dimension {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
margin: $grid-unit-size;
|
||||
width: $grid-unit-size * 4;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
opacity: 0.5;
|
||||
padding: $grid-unit-size;
|
||||
}
|
||||
|
||||
.cvat-job-page-list-item-id {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin: $grid-unit-size $grid-unit-size $grid-unit-size 0;
|
||||
width: fit-content;
|
||||
background: white;
|
||||
border-radius: 0 4px 4px 0;
|
||||
padding: $grid-unit-size;
|
||||
opacity: 0.5;
|
||||
transition: 0.15s all ease;
|
||||
box-shadow: $box-shadow-base;
|
||||
}
|
||||
}
|
||||
|
||||
.cvat-jobs-page-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cvat-jobs-page-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cvat-job-card-more-button {
|
||||
position: absolute;
|
||||
bottom: $grid-unit-size * 2;
|
||||
right: $grid-unit-size;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.cvat-jobs-page-filters {
|
||||
.ant-table-cell {
|
||||
width: $grid-unit-size * 15;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
.ant-table-tbody {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,104 @@
|
||||
// Copyright (C) 2022 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import React from 'react';
|
||||
import { Col, Row } from 'antd/lib/grid';
|
||||
import Text from 'antd/lib/typography/Text';
|
||||
import Table from 'antd/lib/table';
|
||||
import { FilterValue, TablePaginationConfig } from 'antd/lib/table/interface';
|
||||
import { JobsQuery } from 'reducers/interfaces';
|
||||
import Input from 'antd/lib/input';
|
||||
import Button from 'antd/lib/button';
|
||||
|
||||
interface Props {
|
||||
onChangeFilters(filters: Record<string, string | null>): void;
|
||||
query: JobsQuery;
|
||||
}
|
||||
|
||||
function TopBarComponent(props: Props): JSX.Element {
|
||||
const { query, onChangeFilters } = props;
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'Stage',
|
||||
dataIndex: 'stage',
|
||||
key: 'stage',
|
||||
filteredValue: query.stage?.split(',') || null,
|
||||
className: `${query.stage ? 'cvat-jobs-page-filter cvat-jobs-page-filter-active' : 'cvat-jobs-page-filter'}`,
|
||||
filters: [
|
||||
{ text: 'annotation', value: 'annotation' },
|
||||
{ text: 'validation', value: 'validation' },
|
||||
{ text: 'acceptance', value: 'acceptance' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'State',
|
||||
dataIndex: 'state',
|
||||
key: 'state',
|
||||
filteredValue: query.state?.split(',') || null,
|
||||
className: `${query.state ? 'cvat-jobs-page-filter cvat-jobs-page-filter-active' : 'cvat-jobs-page-filter'}`,
|
||||
filters: [
|
||||
{ text: 'new', value: 'new' },
|
||||
{ text: 'in progress', value: 'in progress' },
|
||||
{ text: 'completed', value: 'completed' },
|
||||
{ text: 'rejected', value: 'rejected' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Assignee',
|
||||
dataIndex: 'assignee',
|
||||
key: 'assignee',
|
||||
filteredValue: query.assignee ? [query.assignee] : null,
|
||||
className: `${query.assignee ? 'cvat-jobs-page-filter cvat-jobs-page-filter-active' : 'cvat-jobs-page-filter'}`,
|
||||
filterDropdown: (
|
||||
<div>
|
||||
<Input.Search
|
||||
defaultValue={query.assignee || ''}
|
||||
placeholder='Filter by assignee'
|
||||
onSearch={(value: string) => {
|
||||
onChangeFilters({ assignee: value });
|
||||
}}
|
||||
enterButton
|
||||
/>
|
||||
<Button
|
||||
type='link'
|
||||
onClick={() => {
|
||||
onChangeFilters({ assignee: null });
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Row className='cvat-jobs-page-top-bar' justify='center' align='middle'>
|
||||
<Col md={22} lg={18} xl={16} xxl={16}>
|
||||
<Row justify='space-between' align='bottom'>
|
||||
<Col>
|
||||
<Text className='cvat-title'>Jobs</Text>
|
||||
</Col>
|
||||
<Table
|
||||
onChange={(_: TablePaginationConfig, filters: Record<string, FilterValue | null>) => {
|
||||
const processed = Object.fromEntries(
|
||||
Object.entries(filters)
|
||||
.map(([key, values]) => (
|
||||
[key, typeof values === 'string' || values === null ? values : values.join(',')]
|
||||
)),
|
||||
);
|
||||
onChangeFilters(processed);
|
||||
}}
|
||||
className='cvat-jobs-page-filters'
|
||||
columns={columns}
|
||||
size='small'
|
||||
/>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(TopBarComponent);
|
||||
@ -0,0 +1,52 @@
|
||||
// Copyright (C) 2022 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { JobsActions, JobsActionTypes } from 'actions/jobs-actions';
|
||||
import { JobsState } from './interfaces';
|
||||
|
||||
const defaultState: JobsState = {
|
||||
fetching: false,
|
||||
count: 0,
|
||||
query: {
|
||||
page: 1,
|
||||
state: null,
|
||||
stage: null,
|
||||
assignee: null,
|
||||
},
|
||||
current: [],
|
||||
previews: [],
|
||||
};
|
||||
|
||||
export default (state: JobsState = defaultState, action: JobsActions): JobsState => {
|
||||
switch (action.type) {
|
||||
case JobsActionTypes.GET_JOBS: {
|
||||
return {
|
||||
...state,
|
||||
fetching: true,
|
||||
query: {
|
||||
...defaultState.query,
|
||||
...action.payload.query,
|
||||
},
|
||||
};
|
||||
}
|
||||
case JobsActionTypes.GET_JOBS_SUCCESS: {
|
||||
return {
|
||||
...state,
|
||||
fetching: false,
|
||||
count: action.payload.jobs.count,
|
||||
current: action.payload.jobs,
|
||||
previews: action.payload.previews,
|
||||
};
|
||||
}
|
||||
case JobsActionTypes.GET_JOBS_FAILED: {
|
||||
return {
|
||||
...state,
|
||||
fetching: false,
|
||||
};
|
||||
}
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
Loading…
Reference in New Issue