Project tasks pagination (#3910)

* Added project tasks search and pagination

* Increased npm versions

* Added CHANGELOG

* Fixed issues

* Fixed styles

* Fixed core tests

* Fixed core tests

* Fixed core tests

* Fixed core tests

* Fixed parameter

* Fixed project update action

* Fixed updating project

* Fixed comments
main
Dmitry Kalinin 4 years ago committed by GitHub
parent 958206c929
commit b5ed09ea94
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add Open Images V6 format (<https://github.com/openvinotoolkit/cvat/pull/3679>) - Add Open Images V6 format (<https://github.com/openvinotoolkit/cvat/pull/3679>)
- Rotated bounding boxes (<https://github.com/openvinotoolkit/cvat/pull/3832>) - Rotated bounding boxes (<https://github.com/openvinotoolkit/cvat/pull/3832>)
- Player option: Smooth image when zoom-in, enabled by default (<https://github.com/openvinotoolkit/cvat/pull/3933>) - Player option: Smooth image when zoom-in, enabled by default (<https://github.com/openvinotoolkit/cvat/pull/3933>)
- Add project tasks paginations (<https://github.com/openvinotoolkit/cvat/pull/3910>)
### Changed ### Changed
- TDB - TDB

@ -1,12 +1,12 @@
{ {
"name": "cvat-core", "name": "cvat-core",
"version": "3.19.0", "version": "3.20.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "cvat-core", "name": "cvat-core",
"version": "3.19.0", "version": "3.20.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^0.21.4", "axios": "^0.21.4",

@ -1,6 +1,6 @@
{ {
"name": "cvat-core", "name": "cvat-core",
"version": "3.19.0", "version": "3.20.0",
"description": "Part of Computer Vision Tool which presents an interface for client-side integration", "description": "Part of Computer Vision Tool which presents an interface for client-side integration",
"main": "babel.config.js", "main": "babel.config.js",
"scripts": { "scripts": {

@ -188,6 +188,7 @@
owner: isString, owner: isString,
assignee: isString, assignee: isString,
search: isString, search: isString,
ordering: isString,
status: isEnum.bind(TaskStatus), status: isEnum.bind(TaskStatus),
mode: isEnum.bind(TaskMode), mode: isEnum.bind(TaskMode),
dimension: isEnum.bind(DimensionType), dimension: isEnum.bind(DimensionType),
@ -196,11 +197,13 @@
checkExclusiveFields(filter, ['id', 'search', 'projectId'], ['page']); checkExclusiveFields(filter, ['id', 'search', 'projectId'], ['page']);
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
for (const field of [ for (const field of [
'name', 'name',
'owner', 'owner',
'assignee', 'assignee',
'search', 'search',
'ordering',
'status', 'status',
'mode', 'mode',
'id', 'id',
@ -209,7 +212,7 @@
'dimension', 'dimension',
]) { ]) {
if (Object.prototype.hasOwnProperty.call(filter, field)) { if (Object.prototype.hasOwnProperty.call(filter, field)) {
searchParams.set(field, filter[field]); searchParams.set(camelToSnake(field), filter[field]);
} }
} }
@ -230,35 +233,20 @@
owner: isString, owner: isString,
search: isString, search: isString,
status: isEnum.bind(TaskStatus), status: isEnum.bind(TaskStatus),
withoutTasks: isBoolean,
}); });
checkExclusiveFields(filter, ['id', 'search'], ['page', 'withoutTasks']); checkExclusiveFields(filter, ['id', 'search'], ['page']);
if (typeof filter.withoutTasks === 'undefined') {
if (typeof filter.id === 'undefined') {
filter.withoutTasks = true;
} else {
filter.withoutTasks = false;
}
}
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
for (const field of ['name', 'assignee', 'owner', 'search', 'status', 'id', 'page', 'withoutTasks']) { for (const field of ['name', 'assignee', 'owner', 'search', 'status', 'id', 'page']) {
if (Object.prototype.hasOwnProperty.call(filter, field)) { if (Object.prototype.hasOwnProperty.call(filter, field)) {
searchParams.set(camelToSnake(field), filter[field]); searchParams.set(camelToSnake(field), filter[field]);
} }
} }
const projectsData = await serverProxy.projects.get(searchParams.toString()); const projectsData = await serverProxy.projects.get(searchParams.toString());
// prettier-ignore
const projects = projectsData.map((project) => { const projects = projectsData.map((project) => {
if (filter.withoutTasks) { project.task_ids = project.tasks;
project.task_ids = project.tasks;
project.tasks = [];
} else {
project.task_ids = project.tasks.map((task) => task.id);
}
return project; return project;
}).map((project) => new Project(project)); }).map((project) => new Project(project));

@ -5,7 +5,6 @@
(() => { (() => {
const PluginRegistry = require('./plugins'); const PluginRegistry = require('./plugins');
const { ArgumentError } = require('./exceptions'); const { ArgumentError } = require('./exceptions');
const { Task } = require('./session');
const { Label } = require('./labels'); const { Label } = require('./labels');
const User = require('./user'); const User = require('./user');
@ -44,7 +43,6 @@
} }
data.labels = []; data.labels = [];
data.tasks = [];
if (Array.isArray(initialData.labels)) { if (Array.isArray(initialData.labels)) {
for (const label of initialData.labels) { for (const label of initialData.labels) {
@ -53,19 +51,6 @@
} }
} }
if (Array.isArray(initialData.tasks)) {
for (const task of initialData.tasks) {
const taskInstance = new Task(task);
data.tasks.push(taskInstance);
}
}
if (!data.task_subsets) {
const subsetsSet = new Set();
for (const task of data.tasks) {
if (task.subset) subsetsSet.add(task.subset);
}
data.task_subsets = Array.from(subsetsSet);
}
if (typeof initialData.training_project === 'object') { if (typeof initialData.training_project === 'object') {
data.training_project = { ...initialData.training_project }; data.training_project = { ...initialData.training_project };
} }
@ -212,17 +197,6 @@
data.labels = [...deletedLabels, ...labels]; data.labels = [...deletedLabels, ...labels];
}, },
}, },
/**
* Tasks related with the project
* @name tasks
* @type {module:API.cvat.classes.Task[]}
* @memberof module:API.cvat.classes.Project
* @readonly
* @instance
*/
tasks: {
get: () => [...data.tasks],
},
/** /**
* Subsets array for related tasks * Subsets array for related tasks
* @name subsets * @name subsets

@ -11,12 +11,11 @@ jest.mock('../../src/server-proxy', () => {
// Initialize api // Initialize api
window.cvat = require('../../src/api'); window.cvat = require('../../src/api');
const { Task } = require('../../src/session');
const { Project } = require('../../src/project'); const { Project } = require('../../src/project');
describe('Feature: get projects', () => { describe('Feature: get projects', () => {
test('get all projects', async () => { test('get all projects', async () => {
const result = await window.cvat.projects.get({ withoutTasks: false }); const result = await window.cvat.projects.get();
expect(Array.isArray(result)).toBeTruthy(); expect(Array.isArray(result)).toBeTruthy();
expect(result).toHaveLength(2); expect(result).toHaveLength(2);
for (const el of result) { for (const el of result) {
@ -33,8 +32,8 @@ describe('Feature: get projects', () => {
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(Project); expect(result[0]).toBeInstanceOf(Project);
expect(result[0].id).toBe(2); expect(result[0].id).toBe(2);
expect(result[0].tasks).toHaveLength(1); // eslint-disable-next-line no-underscore-dangle
expect(result[0].tasks[0]).toBeInstanceOf(Task); expect(result[0]._internalData.task_ids).toHaveLength(1);
}); });
test('get a project by an unknown id', async () => { test('get a project by an unknown id', async () => {

@ -5,8 +5,9 @@
import { Dispatch, ActionCreator } from 'redux'; import { Dispatch, ActionCreator } from 'redux';
import { ActionUnion, createAction, ThunkAction } from 'utils/redux'; import { ActionUnion, createAction, ThunkAction } from 'utils/redux';
import { ProjectsQuery } from 'reducers/interfaces'; import { ProjectsQuery, TasksQuery, CombinedState } from 'reducers/interfaces';
import { getTasksSuccess, updateTaskSuccess } from 'actions/tasks-actions'; import { getTasksAsync } from 'actions/tasks-actions';
import { getCVATStore } from 'cvat-store';
import getCore from 'cvat-core-wrapper'; import getCore from 'cvat-core-wrapper';
const cvat = getCore(); const cvat = getCore();
@ -34,8 +35,8 @@ const projectActions = {
createAction(ProjectsActionTypes.GET_PROJECTS_SUCCESS, { array, previews, count }) createAction(ProjectsActionTypes.GET_PROJECTS_SUCCESS, { array, previews, count })
), ),
getProjectsFailed: (error: any) => createAction(ProjectsActionTypes.GET_PROJECTS_FAILED, { error }), getProjectsFailed: (error: any) => createAction(ProjectsActionTypes.GET_PROJECTS_FAILED, { error }),
updateProjectsGettingQuery: (query: Partial<ProjectsQuery>) => ( updateProjectsGettingQuery: (query: Partial<ProjectsQuery>, tasksQuery: Partial<TasksQuery> = {}) => (
createAction(ProjectsActionTypes.UPDATE_PROJECTS_GETTING_QUERY, { query }) createAction(ProjectsActionTypes.UPDATE_PROJECTS_GETTING_QUERY, { query, tasksQuery })
), ),
createProject: () => createAction(ProjectsActionTypes.CREATE_PROJECT), createProject: () => createAction(ProjectsActionTypes.CREATE_PROJECT),
createProjectSuccess: (projectId: number) => ( createProjectSuccess: (projectId: number) => (
@ -58,10 +59,27 @@ const projectActions = {
export type ProjectActions = ActionUnion<typeof projectActions>; export type ProjectActions = ActionUnion<typeof projectActions>;
export function getProjectsAsync(query: Partial<ProjectsQuery>): ThunkAction { export function getProjectTasksAsync(tasksQuery: Partial<TasksQuery> = {}): ThunkAction<void> {
return async (dispatch: ActionCreator<Dispatch>, getState): Promise<void> => { return (dispatch: ActionCreator<Dispatch>): void => {
const store = getCVATStore();
const state: CombinedState = store.getState();
dispatch(projectActions.updateProjectsGettingQuery({}, tasksQuery));
const query: Partial<TasksQuery> = {
...state.projects.tasksGettingQuery,
page: 1,
...tasksQuery,
};
dispatch(getTasksAsync(query));
};
}
export function getProjectsAsync(
query: Partial<ProjectsQuery>, tasksQuery: Partial<TasksQuery> = {},
): ThunkAction {
return async (dispatch: ActionCreator<Dispatch>): Promise<void> => {
dispatch(projectActions.getProjects()); dispatch(projectActions.getProjects());
dispatch(projectActions.updateProjectsGettingQuery(query)); dispatch(projectActions.updateProjectsGettingQuery(query, tasksQuery));
// Clear query object from null fields // Clear query object from null fields
const filteredQuery: Partial<ProjectsQuery> = { const filteredQuery: Partial<ProjectsQuery> = {
@ -85,38 +103,15 @@ export function getProjectsAsync(query: Partial<ProjectsQuery>): ThunkAction {
const array = Array.from(result); const array = Array.from(result);
// Appropriate tasks fetching proccess needs with retrieving only a single project const previewPromises = array.map((project): string => (project as any).preview().catch(() => ''));
if (Object.keys(filteredQuery).includes('id')) { dispatch(projectActions.getProjectsSuccess(array, await Promise.all(previewPromises), result.count));
const tasks: any[] = [];
const [project] = array;
const taskPreviewPromises: Promise<string>[] = (project as any).tasks.map((task: any): string => {
tasks.push(task);
return (task as any).frames.preview().catch(() => '');
});
const taskPreviews = await Promise.all(taskPreviewPromises); // Appropriate tasks fetching proccess needs with retrieving only a single project
if (Object.keys(filteredQuery).includes('id') && typeof filteredQuery.id === 'number') {
const state = getState(); dispatch(getProjectTasksAsync({
...tasksQuery,
dispatch(projectActions.getProjectsSuccess(array, taskPreviews, result.count)); projectId: filteredQuery.id,
}));
if (!state.tasks.fetching) {
dispatch(
getTasksSuccess(tasks, taskPreviews, tasks.length, {
page: 1,
assignee: null,
id: null,
mode: null,
name: null,
owner: null,
search: null,
status: null,
}),
);
}
} else {
const previewPromises = array.map((project): string => (project as any).preview().catch(() => ''));
dispatch(projectActions.getProjectsSuccess(array, await Promise.all(previewPromises), result.count));
} }
}; };
} }
@ -136,17 +131,14 @@ export function createProjectAsync(data: any): ThunkAction {
} }
export function updateProjectAsync(projectInstance: any): ThunkAction { export function updateProjectAsync(projectInstance: any): ThunkAction {
return async (dispatch: ActionCreator<Dispatch>): Promise<void> => { return async (dispatch, getState): Promise<void> => {
try { try {
const state = getState();
dispatch(projectActions.updateProject()); dispatch(projectActions.updateProject());
await projectInstance.save(); await projectInstance.save();
const [project] = await cvat.projects.get({ id: projectInstance.id }); const [project] = await cvat.projects.get({ id: projectInstance.id });
// TODO: Check case when a project is not available anymore after update
// (assignee changes assignee and project is not public)
dispatch(projectActions.updateProjectSuccess(project)); dispatch(projectActions.updateProjectSuccess(project));
project.tasks.forEach((task: any) => { dispatch(getProjectTasksAsync(state.projects.tasksGettingQuery));
dispatch(updateTaskSuccess(task, task.id));
});
} catch (error) { } catch (error) {
let project = null; let project = null;
try { try {

@ -47,7 +47,9 @@ function getTasks(): AnyAction {
return action; return action;
} }
export function getTasksSuccess(array: any[], previews: string[], count: number, query: TasksQuery): AnyAction { export function getTasksSuccess(
array: any[], previews: string[], count: number, query: Partial<TasksQuery>,
): AnyAction {
const action = { const action = {
type: TasksActionTypes.GET_TASKS_SUCCESS, type: TasksActionTypes.GET_TASKS_SUCCESS,
payload: { payload: {
@ -61,7 +63,7 @@ export function getTasksSuccess(array: any[], previews: string[], count: number,
return action; return action;
} }
function getTasksFailed(error: any, query: TasksQuery): AnyAction { function getTasksFailed(error: any, query: Partial<TasksQuery>): AnyAction {
const action = { const action = {
type: TasksActionTypes.GET_TASKS_FAILED, type: TasksActionTypes.GET_TASKS_FAILED,
payload: { payload: {
@ -73,7 +75,7 @@ function getTasksFailed(error: any, query: TasksQuery): AnyAction {
return action; return action;
} }
export function getTasksAsync(query: TasksQuery): ThunkAction<Promise<void>, {}, {}, AnyAction> { export function getTasksAsync(query: Partial<TasksQuery>): ThunkAction<Promise<void>, {}, {}, AnyAction> {
return async (dispatch: ActionCreator<Dispatch>): Promise<void> => { return async (dispatch: ActionCreator<Dispatch>): Promise<void> => {
dispatch(getTasks()); dispatch(getTasks());
@ -248,7 +250,7 @@ export function exportTaskAsync(taskInstance: any): ThunkAction<Promise<void>, {
downloadAnchor.click(); downloadAnchor.click();
dispatch(exportTaskSuccess(taskInstance.id)); dispatch(exportTaskSuccess(taskInstance.id));
} catch (error) { } catch (error) {
dispatch(exportTaskFailed(taskInstance.id, error)); dispatch(exportTaskFailed(taskInstance.id, error as Error));
} }
}; };
} }

@ -32,7 +32,7 @@ export default function ProjectSubsetField(props: Props): JSX.Element {
useEffect(() => { useEffect(() => {
if (!projectSubsets?.length && projectId) { if (!projectSubsets?.length && projectId) {
core.projects.get({ id: projectId, withoutTasks: true }).then((response: ProjectPartialWithSubsets[]) => { core.projects.get({ id: projectId }).then((response: ProjectPartialWithSubsets[]) => {
if (response.length) { if (response.length) {
const [project] = response; const [project] = response;
setInternalSubsets( setInternalSubsets(

@ -10,7 +10,6 @@ import Title from 'antd/lib/typography/Title';
import Text from 'antd/lib/typography/Text'; import Text from 'antd/lib/typography/Text';
import getCore from 'cvat-core-wrapper'; import getCore from 'cvat-core-wrapper';
import { Project } from 'reducers/interfaces';
import { updateProjectAsync } from 'actions/projects-actions'; import { updateProjectAsync } from 'actions/projects-actions';
import LabelsEditor from 'components/labels-editor/labels-editor'; import LabelsEditor from 'components/labels-editor/labels-editor';
import BugTrackerEditor from 'components/task-page/bug-tracker-editor'; import BugTrackerEditor from 'components/task-page/bug-tracker-editor';
@ -19,7 +18,7 @@ import UserSelector from 'components/task-page/user-selector';
const core = getCore(); const core = getCore();
interface DetailsComponentProps { interface DetailsComponentProps {
project: Project; project: any;
} }
export default function DetailsComponent(props: DetailsComponentProps): JSX.Element { export default function DetailsComponent(props: DetailsComponentProps): JSX.Element {

@ -3,22 +3,25 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import './styles.scss'; import './styles.scss';
import React, { useEffect } from 'react'; import React, { useEffect, useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import { useHistory, useParams } from 'react-router'; import { useHistory, useParams, useLocation } from 'react-router';
import Spin from 'antd/lib/spin'; import Spin from 'antd/lib/spin';
import { Row, Col } from 'antd/lib/grid'; import { Row, Col } from 'antd/lib/grid';
import Result from 'antd/lib/result'; import Result from 'antd/lib/result';
import Button from 'antd/lib/button'; import Button from 'antd/lib/button';
import Title from 'antd/lib/typography/Title'; import Title from 'antd/lib/typography/Title';
import Pagination from 'antd/lib/pagination';
import { PlusOutlined } from '@ant-design/icons'; import { PlusOutlined } from '@ant-design/icons';
import { CombinedState, Task } from 'reducers/interfaces'; import { CombinedState, Task, TasksQuery } from 'reducers/interfaces';
import { getProjectsAsync } from 'actions/projects-actions'; import { getProjectsAsync, getProjectTasksAsync } from 'actions/projects-actions';
import { cancelInferenceAsync } from 'actions/models-actions'; import { cancelInferenceAsync } from 'actions/models-actions';
import TaskItem from 'components/tasks-page/task-item'; import TaskItem from 'components/tasks-page/task-item';
import SearchField from 'components/search-field/search-field';
import MoveTaskModal from 'components/move-task-modal/move-task-modal'; import MoveTaskModal from 'components/move-task-modal/move-task-modal';
import ModelRunnerDialog from 'components/model-runner-modal/model-runner-dialog'; import ModelRunnerDialog from 'components/model-runner-modal/model-runner-dialog';
import { useDidUpdateEffect } from 'utils/hooks';
import DetailsComponent from './details'; import DetailsComponent from './details';
import ProjectTopBar from './top-bar'; import ProjectTopBar from './top-bar';
@ -30,25 +33,54 @@ export default function ProjectPageComponent(): JSX.Element {
const id = +useParams<ParamType>().id; const id = +useParams<ParamType>().id;
const dispatch = useDispatch(); const dispatch = useDispatch();
const history = useHistory(); const history = useHistory();
const { search } = useLocation();
const projects = useSelector((state: CombinedState) => state.projects.current).map((project) => project.instance); const projects = useSelector((state: CombinedState) => state.projects.current).map((project) => project.instance);
const projectsFetching = useSelector((state: CombinedState) => state.projects.fetching); const projectsFetching = useSelector((state: CombinedState) => state.projects.fetching);
const deletes = useSelector((state: CombinedState) => state.projects.activities.deletes); const deletes = useSelector((state: CombinedState) => state.projects.activities.deletes);
const taskDeletes = useSelector((state: CombinedState) => state.tasks.activities.deletes); const taskDeletes = useSelector((state: CombinedState) => state.tasks.activities.deletes);
const tasksActiveInferences = useSelector((state: CombinedState) => state.models.inferences); const tasksActiveInferences = useSelector((state: CombinedState) => state.models.inferences);
const tasks = useSelector((state: CombinedState) => state.tasks.current); const tasks = useSelector((state: CombinedState) => state.tasks.current);
const tasksCount = useSelector((state: CombinedState) => state.tasks.count);
const tasksGettingQuery = useSelector((state: CombinedState) => state.projects.tasksGettingQuery);
const [project] = projects.filter((_project) => _project.id === id); const [project] = projects.filter((_project) => _project.id === id);
const projectSubsets = ['']; const projectSubsets: Array<string> = [];
if (project) projectSubsets.push(...project.subsets); for (const task of tasks) {
if (!projectSubsets.includes(task.instance.subset)) projectSubsets.push(task.instance.subset);
}
const deleteActivity = project && id in deletes ? deletes[id] : null; const deleteActivity = project && id in deletes ? deletes[id] : null;
const onPageChange = useCallback(
(p: number) => {
dispatch(getProjectTasksAsync({
projectId: id,
page: p,
}));
},
[],
);
useEffect(() => { useEffect(() => {
dispatch( const searchParams: Partial<TasksQuery> = {};
getProjectsAsync({ for (const [param, value] of new URLSearchParams(search)) {
id, searchParams[param] = ['page'].includes(param) ? Number.parseInt(value, 10) : value;
}), }
); dispatch(getProjectsAsync({ id }, searchParams));
}, [id, dispatch]); }, []);
useDidUpdateEffect(() => {
const searchParams = new URLSearchParams();
for (const [name, value] of Object.entries(tasksGettingQuery)) {
if (value !== null && typeof value !== 'undefined' && !['projectId', 'ordering'].includes(name)) {
searchParams.append(name, value.toString());
}
}
history.push({
pathname: `/projects/${id}`,
search: `?${searchParams.toString()}`,
});
}, [tasksGettingQuery, id]);
if (deleteActivity) { if (deleteActivity) {
history.push('/projects'); history.push('/projects');
@ -69,14 +101,27 @@ export default function ProjectPageComponent(): JSX.Element {
); );
} }
const paginationDimensions = {
md: 22,
lg: 18,
xl: 16,
xxl: 16,
};
return ( return (
<Row justify='center' align='top' className='cvat-project-page'> <Row justify='center' align='top' className='cvat-project-page'>
<Col md={22} lg={18} xl={16} xxl={14}> <Col md={22} lg={18} xl={16} xxl={14}>
<ProjectTopBar projectInstance={project} /> <ProjectTopBar projectInstance={project} />
<DetailsComponent project={project} /> <DetailsComponent project={project} />
<Row justify='space-between' align='middle' className='cvat-project-page-tasks-bar'> <Row justify='space-between' align='middle' className='cvat-project-page-tasks-bar'>
<Col> <Col className='cvat-project-tasks-title-search'>
<Title level={3}>Tasks</Title> <Title level={3}>Tasks</Title>
<SearchField
query={tasksGettingQuery}
instance='task'
skipFields={['ordering', 'projectId']}
onSearch={(query: TasksQuery) => dispatch(getProjectTasksAsync(query))}
/>
</Col> </Col>
<Col> <Col>
<Button <Button
@ -110,6 +155,19 @@ export default function ProjectPageComponent(): JSX.Element {
))} ))}
</React.Fragment> </React.Fragment>
))} ))}
<Row justify='center'>
<Col {...paginationDimensions}>
<Pagination
className='cvat-project-tasks-pagination'
onChange={onPageChange}
showSizeChanger={false}
total={tasksCount}
pageSize={10}
current={tasksGettingQuery.page}
showQuickJumper
/>
</Col>
</Row>
</Col> </Col>
<MoveTaskModal /> <MoveTaskModal />
<ModelRunnerDialog /> <ModelRunnerDialog />

@ -59,3 +59,16 @@
display: flex; display: flex;
align-items: center; align-items: center;
} }
.cvat-project-tasks-pagination {
display: flex;
justify-content: center;
}
.cvat-project-tasks-title-search {
display: flex;
> * {
margin-right: $grid-unit-size * 2;
}
}

@ -38,12 +38,18 @@ export default function ProjectActionsMenuComponent(props: Props): JSX.Element {
return ( return (
<Menu className='cvat-project-actions-menu'> <Menu className='cvat-project-actions-menu'>
<Menu.Item <Menu.Item
key='project-export'
onClick={() => dispatch(exportActions.openExportModal(projectInstance))} onClick={() => dispatch(exportActions.openExportModal(projectInstance))}
> >
Export project dataset Export project dataset
</Menu.Item> </Menu.Item>
<hr /> <hr />
<Menu.Item onClick={onDeleteProject}>Delete</Menu.Item> <Menu.Item
key='project-delete'
onClick={onDeleteProject}
>
Delete
</Menu.Item>
</Menu> </Menu>
); );
} }

@ -2,7 +2,7 @@
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import React from 'react'; import React, { useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import { Row, Col } from 'antd/lib/grid'; import { Row, Col } from 'antd/lib/grid';
import Pagination from 'antd/lib/pagination'; import Pagination from 'antd/lib/pagination';
@ -18,14 +18,14 @@ export default function ProjectListComponent(): JSX.Element {
const projects = useSelector((state: CombinedState) => state.projects.current); const projects = useSelector((state: CombinedState) => state.projects.current);
const gettingQuery = useSelector((state: CombinedState) => state.projects.gettingQuery); const gettingQuery = useSelector((state: CombinedState) => state.projects.gettingQuery);
function changePage(p: number): void { const changePage = useCallback((p: number) => {
dispatch( dispatch(
getProjectsAsync({ getProjectsAsync({
...gettingQuery, ...gettingQuery,
page: p, page: p,
}), }),
); );
} }, [dispatch, getProjectsAsync, gettingQuery]);
const dimensions = { const dimensions = {
md: 22, md: 22,

@ -14,16 +14,27 @@ interface Query {
interface Props { interface Props {
query: Query; query: Query;
instance: 'task' | 'project' | 'cloudstorage'; instance: 'task' | 'project' | 'cloudstorage';
skipFields?: string[];
onSearch(query: object): void; onSearch(query: object): void;
} }
export default function SearchField(props: Props): JSX.Element { export default function SearchField(props: Props): JSX.Element {
const { onSearch, query, instance } = props; const {
function parse(_query: Query): string { onSearch,
query,
instance,
skipFields,
} = props;
const skip = ['page'];
if (typeof skipFields !== 'undefined') {
skip.push(...skipFields);
}
function parse(_query: Query, _skip: string[]): string {
let searchString = ''; let searchString = '';
for (const field of Object.keys(_query)) { for (const field of Object.keys(_query)) {
const value = _query[field]; const value = _query[field];
if (value !== null && typeof value !== 'undefined' && field !== 'page') { if (value !== null && typeof value !== 'undefined' && !_skip.includes(field)) {
if (field === 'search') { if (field === 'search') {
return _query[field] as string; return _query[field] as string;
} }
@ -47,7 +58,7 @@ export default function SearchField(props: Props): JSX.Element {
.replace(/\s*:+\s*/g, ':') .replace(/\s*:+\s*/g, ':')
.trim(); .trim();
const fields = Object.keys(query).filter((key) => key !== 'page'); const fields = Object.keys(query).filter((key) => !skip.includes(key));
for (const field of fields) { for (const field of fields) {
currentQuery[field] = null; currentQuery[field] = null;
} }
@ -82,7 +93,7 @@ export default function SearchField(props: Props): JSX.Element {
<SearchTooltip instance={instance}> <SearchTooltip instance={instance}>
<Search <Search
className='cvat-search-field' className='cvat-search-field'
defaultValue={parse(query)} defaultValue={parse(query, skip)}
onSearch={handleSearch} onSearch={handleSearch}
size='large' size='large'
placeholder='Search' placeholder='Search'

@ -44,6 +44,7 @@ export interface ProjectsState {
count: number; count: number;
current: Project[]; current: Project[];
gettingQuery: ProjectsQuery; gettingQuery: ProjectsQuery;
tasksGettingQuery: TasksQuery;
activities: { activities: {
creates: { creates: {
id: null | number; id: null | number;
@ -64,6 +65,7 @@ export interface TasksQuery {
name: string | null; name: string | null;
status: string | null; status: string | null;
mode: string | null; mode: string | null;
projectId: number | null;
[key: string]: string | number | null; [key: string]: string | number | null;
} }

@ -23,6 +23,18 @@ const defaultState: ProjectsState = {
name: null, name: null,
status: null, status: null,
}, },
tasksGettingQuery: {
page: 1,
id: null,
search: null,
owner: null,
assignee: null,
name: null,
status: null,
mode: null,
projectId: null,
ordering: 'subset',
},
activities: { activities: {
deletes: {}, deletes: {},
creates: { creates: {
@ -41,6 +53,10 @@ export default (state: ProjectsState = defaultState, action: AnyAction): Project
...defaultState.gettingQuery, ...defaultState.gettingQuery,
...action.payload.query, ...action.payload.query,
}, },
tasksGettingQuery: {
...defaultState.tasksGettingQuery,
...action.payload.tasksQuery,
},
}; };
case ProjectsActionTypes.GET_PROJECTS: case ProjectsActionTypes.GET_PROJECTS:
return { return {

@ -30,6 +30,7 @@ const defaultState: TasksState = {
name: null, name: null,
status: null, status: null,
mode: null, mode: null,
projectId: null,
}, },
activities: { activities: {
loads: {}, loads: {},
@ -74,7 +75,10 @@ export default (state: TasksState = defaultState, action: AnyAction): TasksState
fetching: false, fetching: false,
count: action.payload.count, count: action.payload.count,
current: combinedWithPreviews, current: combinedWithPreviews,
gettingQuery: { ...action.payload.query }, gettingQuery: {
...state.gettingQuery,
...action.payload.query,
},
}; };
} }
case TasksActionTypes.GET_TASKS_FAILED: case TasksActionTypes.GET_TASKS_FAILED:

@ -1,7 +1,7 @@
// Copyright (C) 2021 Intel Corporation // Copyright (C) 2021 Intel Corporation
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import { useRef, useEffect, useState } from 'react'; import React, { useRef, useEffect, useState } from 'react';
// eslint-disable-next-line import/prefer-default-export // eslint-disable-next-line import/prefer-default-export
export function usePrevious<T>(value: T): T | undefined { export function usePrevious<T>(value: T): T | undefined {
@ -12,6 +12,17 @@ export function usePrevious<T>(value: T): T | undefined {
return ref.current; return ref.current;
} }
export function useDidUpdateEffect(effect: React.EffectCallback, deps?: React.DependencyList): void {
const didMountRef = useRef(false);
useEffect(() => {
if (didMountRef.current) {
effect();
} else {
didMountRef.current = true;
}
}, deps);
}
export interface ICardHeightHOC { export interface ICardHeightHOC {
numberOfRows: number; numberOfRows: number;
paddings: number; paddings: number;

@ -499,7 +499,7 @@ class TrainingProjectSerializer(serializers.ModelSerializer):
write_once_fields = ('host', 'username', 'password', 'project_class') write_once_fields = ('host', 'username', 'password', 'project_class')
class ProjectWithoutTaskSerializer(serializers.ModelSerializer): class ProjectSerializer(serializers.ModelSerializer):
labels = LabelSerializer(many=True, source='label_set', partial=True, default=[]) labels = LabelSerializer(many=True, source='label_set', partial=True, default=[])
owner = BasicUserSerializer(required=False) owner = BasicUserSerializer(required=False)
owner_id = serializers.IntegerField(write_only=True, allow_null=True, required=False) owner_id = serializers.IntegerField(write_only=True, allow_null=True, required=False)
@ -513,7 +513,7 @@ class ProjectWithoutTaskSerializer(serializers.ModelSerializer):
model = models.Project model = models.Project
fields = ('url', 'id', 'name', 'labels', 'tasks', 'owner', 'assignee', 'owner_id', 'assignee_id', fields = ('url', 'id', 'name', 'labels', 'tasks', 'owner', 'assignee', 'owner_id', 'assignee_id',
'bug_tracker', 'task_subsets', 'created_date', 'updated_date', 'status', 'training_project', 'dimension') 'bug_tracker', 'task_subsets', 'created_date', 'updated_date', 'status', 'training_project', 'dimension')
read_only_fields = ('created_date', 'updated_date', 'status', 'owner', 'asignee', 'task_subsets', 'dimension') read_only_fields = ('created_date', 'updated_date', 'tasks', 'status', 'owner', 'asignee', 'task_subsets', 'dimension')
ordering = ['-id'] ordering = ['-id']
@ -525,12 +525,6 @@ class ProjectWithoutTaskSerializer(serializers.ModelSerializer):
response['dimension'] = instance.tasks.first().dimension if instance.tasks.count() else None response['dimension'] = instance.tasks.first().dimension if instance.tasks.count() else None
return response return response
class ProjectSerializer(ProjectWithoutTaskSerializer):
tasks = TaskSerializer(many=True, read_only=True)
class Meta(ProjectWithoutTaskSerializer.Meta):
fields = ProjectWithoutTaskSerializer.Meta.fields + ('tasks',)
# pylint: disable=no-self-use # pylint: disable=no-self-use
def create(self, validated_data): def create(self, validated_data):
labels = validated_data.pop('label_set') labels = validated_data.pop('label_set')
@ -583,11 +577,6 @@ class ProjectSerializer(ProjectWithoutTaskSerializer):
raise serializers.ValidationError('All label names must be unique for the project') raise serializers.ValidationError('All label names must be unique for the project')
return value return value
def to_representation(self, instance):
response = serializers.ModelSerializer.to_representation(self, instance) # ignoring subsets here
response['dimension'] = instance.tasks.first().dimension if instance.tasks.count() else None
return response
class ExceptionSerializer(serializers.Serializer): class ExceptionSerializer(serializers.Serializer):
system = serializers.CharField(max_length=255) system = serializers.CharField(max_length=255)
client = serializers.CharField(max_length=255) client = serializers.CharField(max_length=255)

@ -57,7 +57,7 @@ from cvat.apps.engine.serializers import (
AboutSerializer, AnnotationFileSerializer, BasicUserSerializer, AboutSerializer, AnnotationFileSerializer, BasicUserSerializer,
DataMetaSerializer, DataSerializer, ExceptionSerializer, DataMetaSerializer, DataSerializer, ExceptionSerializer,
FileInfoSerializer, JobSerializer, LabeledDataSerializer, FileInfoSerializer, JobSerializer, LabeledDataSerializer,
LogEventSerializer, ProjectSerializer, ProjectSearchSerializer, ProjectWithoutTaskSerializer, LogEventSerializer, ProjectSerializer, ProjectSearchSerializer,
RqStatusSerializer, TaskSerializer, UserSerializer, PluginsSerializer, ReviewSerializer, RqStatusSerializer, TaskSerializer, UserSerializer, PluginsSerializer, ReviewSerializer,
CombinedReviewSerializer, IssueSerializer, CombinedIssueSerializer, CommentSerializer, CombinedReviewSerializer, IssueSerializer, CombinedIssueSerializer, CommentSerializer,
CloudStorageSerializer, BaseCloudStorageSerializer, TaskFileSerializer,) CloudStorageSerializer, BaseCloudStorageSerializer, TaskFileSerializer,)
@ -228,9 +228,7 @@ class ProjectFilter(filters.FilterSet):
openapi.Parameter('status', openapi.IN_QUERY, description="Find all projects with a specific status", openapi.Parameter('status', openapi.IN_QUERY, description="Find all projects with a specific status",
type=openapi.TYPE_STRING, enum=[str(i) for i in StatusChoice]), type=openapi.TYPE_STRING, enum=[str(i) for i in StatusChoice]),
openapi.Parameter('names_only', openapi.IN_QUERY, description="Returns only names and id's of projects.", openapi.Parameter('names_only', openapi.IN_QUERY, description="Returns only names and id's of projects.",
type=openapi.TYPE_BOOLEAN), type=openapi.TYPE_BOOLEAN)]))
openapi.Parameter('without_tasks', openapi.IN_QUERY, description="Returns only projects entities without related tasks",
type=openapi.TYPE_BOOLEAN)],))
@method_decorator(name='create', decorator=swagger_auto_schema(operation_summary='Method creates a new project')) @method_decorator(name='create', decorator=swagger_auto_schema(operation_summary='Method creates a new project'))
@method_decorator(name='retrieve', decorator=swagger_auto_schema(operation_summary='Method returns details of a specific project')) @method_decorator(name='retrieve', decorator=swagger_auto_schema(operation_summary='Method returns details of a specific project'))
@method_decorator(name='destroy', decorator=swagger_auto_schema(operation_summary='Method deletes a specific project')) @method_decorator(name='destroy', decorator=swagger_auto_schema(operation_summary='Method deletes a specific project'))
@ -247,8 +245,6 @@ class ProjectViewSet(auth.ProjectGetQuerySetMixin, viewsets.ModelViewSet):
return TaskSerializer return TaskSerializer
if self.request.query_params and self.request.query_params.get("names_only") == "true": if self.request.query_params and self.request.query_params.get("names_only") == "true":
return ProjectSearchSerializer return ProjectSearchSerializer
if self.request.query_params and self.request.query_params.get("without_tasks") == "true":
return ProjectWithoutTaskSerializer
else: else:
return ProjectSerializer return ProjectSerializer
@ -424,7 +420,7 @@ class TaskViewSet(auth.TaskGetQuerySetMixin, viewsets.ModelViewSet):
serializer_class = TaskSerializer serializer_class = TaskSerializer
search_fields = ("name", "owner__username", "mode", "status") search_fields = ("name", "owner__username", "mode", "status")
filterset_class = TaskFilter filterset_class = TaskFilter
ordering_fields = ("id", "name", "owner", "status", "assignee") ordering_fields = ("id", "name", "owner", "status", "assignee", "subset")
def get_permissions(self): def get_permissions(self):
http_method = self.request.method http_method = self.request.method

Loading…
Cancel
Save