diff --git a/CHANGELOG.md b/CHANGELOG.md index d1cddd82c2bc..daa88fef68ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add Open Images V6 format () - Rotated bounding boxes () - Player option: Smooth image when zoom-in, enabled by default () +- Add project tasks paginations () ### Changed - TDB diff --git a/cvat-core/package-lock.json b/cvat-core/package-lock.json index e5919ad949c3..52b293a4a39d 100644 --- a/cvat-core/package-lock.json +++ b/cvat-core/package-lock.json @@ -1,12 +1,12 @@ { "name": "cvat-core", - "version": "3.19.0", + "version": "3.20.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "cvat-core", - "version": "3.19.0", + "version": "3.20.0", "license": "MIT", "dependencies": { "axios": "^0.21.4", diff --git a/cvat-core/package.json b/cvat-core/package.json index fe39347d31bb..7d6c09d6363a 100644 --- a/cvat-core/package.json +++ b/cvat-core/package.json @@ -1,6 +1,6 @@ { "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", "main": "babel.config.js", "scripts": { diff --git a/cvat-core/src/api-implementation.js b/cvat-core/src/api-implementation.js index d947f9fc6c67..c571cdff38db 100644 --- a/cvat-core/src/api-implementation.js +++ b/cvat-core/src/api-implementation.js @@ -188,6 +188,7 @@ owner: isString, assignee: isString, search: isString, + ordering: isString, status: isEnum.bind(TaskStatus), mode: isEnum.bind(TaskMode), dimension: isEnum.bind(DimensionType), @@ -196,11 +197,13 @@ checkExclusiveFields(filter, ['id', 'search', 'projectId'], ['page']); const searchParams = new URLSearchParams(); + for (const field of [ 'name', 'owner', 'assignee', 'search', + 'ordering', 'status', 'mode', 'id', @@ -209,7 +212,7 @@ 'dimension', ]) { if (Object.prototype.hasOwnProperty.call(filter, field)) { - searchParams.set(field, filter[field]); + searchParams.set(camelToSnake(field), filter[field]); } } @@ -230,35 +233,20 @@ owner: isString, search: isString, status: isEnum.bind(TaskStatus), - withoutTasks: isBoolean, }); - checkExclusiveFields(filter, ['id', 'search'], ['page', 'withoutTasks']); - - if (typeof filter.withoutTasks === 'undefined') { - if (typeof filter.id === 'undefined') { - filter.withoutTasks = true; - } else { - filter.withoutTasks = false; - } - } + checkExclusiveFields(filter, ['id', 'search'], ['page']); 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)) { searchParams.set(camelToSnake(field), filter[field]); } } const projectsData = await serverProxy.projects.get(searchParams.toString()); - // prettier-ignore const projects = projectsData.map((project) => { - if (filter.withoutTasks) { - project.task_ids = project.tasks; - project.tasks = []; - } else { - project.task_ids = project.tasks.map((task) => task.id); - } + project.task_ids = project.tasks; return project; }).map((project) => new Project(project)); diff --git a/cvat-core/src/project.js b/cvat-core/src/project.js index 7e324498b95f..acfb21eef253 100644 --- a/cvat-core/src/project.js +++ b/cvat-core/src/project.js @@ -5,7 +5,6 @@ (() => { const PluginRegistry = require('./plugins'); const { ArgumentError } = require('./exceptions'); - const { Task } = require('./session'); const { Label } = require('./labels'); const User = require('./user'); @@ -44,7 +43,6 @@ } data.labels = []; - data.tasks = []; if (Array.isArray(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') { data.training_project = { ...initialData.training_project }; } @@ -212,17 +197,6 @@ 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 * @name subsets diff --git a/cvat-core/tests/api/projects.js b/cvat-core/tests/api/projects.js index 3e03cfa62d99..af6390dc2822 100644 --- a/cvat-core/tests/api/projects.js +++ b/cvat-core/tests/api/projects.js @@ -11,12 +11,11 @@ jest.mock('../../src/server-proxy', () => { // Initialize api window.cvat = require('../../src/api'); -const { Task } = require('../../src/session'); const { Project } = require('../../src/project'); describe('Feature: get projects', () => { 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(result).toHaveLength(2); for (const el of result) { @@ -33,8 +32,8 @@ describe('Feature: get projects', () => { expect(result).toHaveLength(1); expect(result[0]).toBeInstanceOf(Project); expect(result[0].id).toBe(2); - expect(result[0].tasks).toHaveLength(1); - expect(result[0].tasks[0]).toBeInstanceOf(Task); + // eslint-disable-next-line no-underscore-dangle + expect(result[0]._internalData.task_ids).toHaveLength(1); }); test('get a project by an unknown id', async () => { diff --git a/cvat-ui/package-lock.json b/cvat-ui/package-lock.json index a408dbd9d2ad..8e1d2cb17938 100644 --- a/cvat-ui/package-lock.json +++ b/cvat-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "cvat-ui", - "version": "1.27.0", + "version": "1.27.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "cvat-ui", - "version": "1.27.0", + "version": "1.27.1", "license": "MIT", "dependencies": { "@ant-design/icons": "^4.6.3", diff --git a/cvat-ui/package.json b/cvat-ui/package.json index ac2cf462d3f1..20fe291eff2e 100644 --- a/cvat-ui/package.json +++ b/cvat-ui/package.json @@ -1,6 +1,6 @@ { "name": "cvat-ui", - "version": "1.27.0", + "version": "1.27.1", "description": "CVAT single-page application", "main": "src/index.tsx", "scripts": { diff --git a/cvat-ui/src/actions/projects-actions.ts b/cvat-ui/src/actions/projects-actions.ts index 7a8c8b0cd744..2a1abf477e0e 100644 --- a/cvat-ui/src/actions/projects-actions.ts +++ b/cvat-ui/src/actions/projects-actions.ts @@ -5,8 +5,9 @@ import { Dispatch, ActionCreator } from 'redux'; import { ActionUnion, createAction, ThunkAction } from 'utils/redux'; -import { ProjectsQuery } from 'reducers/interfaces'; -import { getTasksSuccess, updateTaskSuccess } from 'actions/tasks-actions'; +import { ProjectsQuery, TasksQuery, CombinedState } from 'reducers/interfaces'; +import { getTasksAsync } from 'actions/tasks-actions'; +import { getCVATStore } from 'cvat-store'; import getCore from 'cvat-core-wrapper'; const cvat = getCore(); @@ -34,8 +35,8 @@ const projectActions = { createAction(ProjectsActionTypes.GET_PROJECTS_SUCCESS, { array, previews, count }) ), getProjectsFailed: (error: any) => createAction(ProjectsActionTypes.GET_PROJECTS_FAILED, { error }), - updateProjectsGettingQuery: (query: Partial) => ( - createAction(ProjectsActionTypes.UPDATE_PROJECTS_GETTING_QUERY, { query }) + updateProjectsGettingQuery: (query: Partial, tasksQuery: Partial = {}) => ( + createAction(ProjectsActionTypes.UPDATE_PROJECTS_GETTING_QUERY, { query, tasksQuery }) ), createProject: () => createAction(ProjectsActionTypes.CREATE_PROJECT), createProjectSuccess: (projectId: number) => ( @@ -58,10 +59,27 @@ const projectActions = { export type ProjectActions = ActionUnion; -export function getProjectsAsync(query: Partial): ThunkAction { - return async (dispatch: ActionCreator, getState): Promise => { +export function getProjectTasksAsync(tasksQuery: Partial = {}): ThunkAction { + return (dispatch: ActionCreator): void => { + const store = getCVATStore(); + const state: CombinedState = store.getState(); + dispatch(projectActions.updateProjectsGettingQuery({}, tasksQuery)); + const query: Partial = { + ...state.projects.tasksGettingQuery, + page: 1, + ...tasksQuery, + }; + + dispatch(getTasksAsync(query)); + }; +} + +export function getProjectsAsync( + query: Partial, tasksQuery: Partial = {}, +): ThunkAction { + return async (dispatch: ActionCreator): Promise => { dispatch(projectActions.getProjects()); - dispatch(projectActions.updateProjectsGettingQuery(query)); + dispatch(projectActions.updateProjectsGettingQuery(query, tasksQuery)); // Clear query object from null fields const filteredQuery: Partial = { @@ -85,38 +103,15 @@ export function getProjectsAsync(query: Partial): ThunkAction { const array = Array.from(result); - // Appropriate tasks fetching proccess needs with retrieving only a single project - if (Object.keys(filteredQuery).includes('id')) { - const tasks: any[] = []; - const [project] = array; - const taskPreviewPromises: Promise[] = (project as any).tasks.map((task: any): string => { - tasks.push(task); - return (task as any).frames.preview().catch(() => ''); - }); + const previewPromises = array.map((project): string => (project as any).preview().catch(() => '')); + dispatch(projectActions.getProjectsSuccess(array, await Promise.all(previewPromises), result.count)); - const taskPreviews = await Promise.all(taskPreviewPromises); - - const state = getState(); - - dispatch(projectActions.getProjectsSuccess(array, taskPreviews, result.count)); - - 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)); + // Appropriate tasks fetching proccess needs with retrieving only a single project + if (Object.keys(filteredQuery).includes('id') && typeof filteredQuery.id === 'number') { + dispatch(getProjectTasksAsync({ + ...tasksQuery, + projectId: filteredQuery.id, + })); } }; } @@ -136,17 +131,14 @@ export function createProjectAsync(data: any): ThunkAction { } export function updateProjectAsync(projectInstance: any): ThunkAction { - return async (dispatch: ActionCreator): Promise => { + return async (dispatch, getState): Promise => { try { + const state = getState(); dispatch(projectActions.updateProject()); await projectInstance.save(); 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)); - project.tasks.forEach((task: any) => { - dispatch(updateTaskSuccess(task, task.id)); - }); + dispatch(getProjectTasksAsync(state.projects.tasksGettingQuery)); } catch (error) { let project = null; try { diff --git a/cvat-ui/src/actions/tasks-actions.ts b/cvat-ui/src/actions/tasks-actions.ts index e5b954f6c710..c584c141c169 100644 --- a/cvat-ui/src/actions/tasks-actions.ts +++ b/cvat-ui/src/actions/tasks-actions.ts @@ -47,7 +47,9 @@ function getTasks(): AnyAction { 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, +): AnyAction { const action = { type: TasksActionTypes.GET_TASKS_SUCCESS, payload: { @@ -61,7 +63,7 @@ export function getTasksSuccess(array: any[], previews: string[], count: number, return action; } -function getTasksFailed(error: any, query: TasksQuery): AnyAction { +function getTasksFailed(error: any, query: Partial): AnyAction { const action = { type: TasksActionTypes.GET_TASKS_FAILED, payload: { @@ -73,7 +75,7 @@ function getTasksFailed(error: any, query: TasksQuery): AnyAction { return action; } -export function getTasksAsync(query: TasksQuery): ThunkAction, {}, {}, AnyAction> { +export function getTasksAsync(query: Partial): ThunkAction, {}, {}, AnyAction> { return async (dispatch: ActionCreator): Promise => { dispatch(getTasks()); @@ -248,7 +250,7 @@ export function exportTaskAsync(taskInstance: any): ThunkAction, { downloadAnchor.click(); dispatch(exportTaskSuccess(taskInstance.id)); } catch (error) { - dispatch(exportTaskFailed(taskInstance.id, error)); + dispatch(exportTaskFailed(taskInstance.id, error as Error)); } }; } diff --git a/cvat-ui/src/components/create-task-page/project-subset-field.tsx b/cvat-ui/src/components/create-task-page/project-subset-field.tsx index 035c9cd4731b..0ff70eb60575 100644 --- a/cvat-ui/src/components/create-task-page/project-subset-field.tsx +++ b/cvat-ui/src/components/create-task-page/project-subset-field.tsx @@ -32,7 +32,7 @@ export default function ProjectSubsetField(props: Props): JSX.Element { useEffect(() => { 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) { const [project] = response; setInternalSubsets( diff --git a/cvat-ui/src/components/project-page/details.tsx b/cvat-ui/src/components/project-page/details.tsx index 4718d3a19acc..6f96aee710a3 100644 --- a/cvat-ui/src/components/project-page/details.tsx +++ b/cvat-ui/src/components/project-page/details.tsx @@ -10,7 +10,6 @@ import Title from 'antd/lib/typography/Title'; import Text from 'antd/lib/typography/Text'; import getCore from 'cvat-core-wrapper'; -import { Project } from 'reducers/interfaces'; import { updateProjectAsync } from 'actions/projects-actions'; import LabelsEditor from 'components/labels-editor/labels-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(); interface DetailsComponentProps { - project: Project; + project: any; } export default function DetailsComponent(props: DetailsComponentProps): JSX.Element { diff --git a/cvat-ui/src/components/project-page/project-page.tsx b/cvat-ui/src/components/project-page/project-page.tsx index ef44d03122cf..56847ea46dfe 100644 --- a/cvat-ui/src/components/project-page/project-page.tsx +++ b/cvat-ui/src/components/project-page/project-page.tsx @@ -3,22 +3,25 @@ // SPDX-License-Identifier: MIT import './styles.scss'; -import React, { useEffect } from 'react'; +import React, { useEffect, useCallback } from 'react'; 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 { Row, Col } from 'antd/lib/grid'; import Result from 'antd/lib/result'; import Button from 'antd/lib/button'; import Title from 'antd/lib/typography/Title'; +import Pagination from 'antd/lib/pagination'; import { PlusOutlined } from '@ant-design/icons'; -import { CombinedState, Task } from 'reducers/interfaces'; -import { getProjectsAsync } from 'actions/projects-actions'; +import { CombinedState, Task, TasksQuery } from 'reducers/interfaces'; +import { getProjectsAsync, getProjectTasksAsync } from 'actions/projects-actions'; import { cancelInferenceAsync } from 'actions/models-actions'; 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 ModelRunnerDialog from 'components/model-runner-modal/model-runner-dialog'; +import { useDidUpdateEffect } from 'utils/hooks'; import DetailsComponent from './details'; import ProjectTopBar from './top-bar'; @@ -30,25 +33,54 @@ export default function ProjectPageComponent(): JSX.Element { const id = +useParams().id; const dispatch = useDispatch(); const history = useHistory(); + const { search } = useLocation(); const projects = useSelector((state: CombinedState) => state.projects.current).map((project) => project.instance); const projectsFetching = useSelector((state: CombinedState) => state.projects.fetching); const deletes = useSelector((state: CombinedState) => state.projects.activities.deletes); const taskDeletes = useSelector((state: CombinedState) => state.tasks.activities.deletes); const tasksActiveInferences = useSelector((state: CombinedState) => state.models.inferences); 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 projectSubsets = ['']; - if (project) projectSubsets.push(...project.subsets); + const projectSubsets: Array = []; + 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 onPageChange = useCallback( + (p: number) => { + dispatch(getProjectTasksAsync({ + projectId: id, + page: p, + })); + }, + [], + ); + useEffect(() => { - dispatch( - getProjectsAsync({ - id, - }), - ); - }, [id, dispatch]); + const searchParams: Partial = {}; + for (const [param, value] of new URLSearchParams(search)) { + searchParams[param] = ['page'].includes(param) ? Number.parseInt(value, 10) : value; + } + dispatch(getProjectsAsync({ id }, searchParams)); + }, []); + + 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) { history.push('/projects'); @@ -69,14 +101,27 @@ export default function ProjectPageComponent(): JSX.Element { ); } + const paginationDimensions = { + md: 22, + lg: 18, + xl: 16, + xxl: 16, + }; + return ( - + Tasks + dispatch(getProjectTasksAsync(query))} + />