From 1225fbb1bcf2b4b508e132e1dff29d896682c71a Mon Sep 17 00:00:00 2001 From: Boris Sekachev Date: Fri, 11 Mar 2022 22:05:43 +0300 Subject: [PATCH] Enhanced filtration/sorting for tasks/projects/tasks in projects/cloud storages (#4409) --- CHANGELOG.md | 2 +- cvat-core/package-lock.json | 4 +- cvat-core/package.json | 2 +- cvat-core/src/api-implementation.js | 65 ++--- cvat-core/src/common.js | 11 - cvat-core/src/server-proxy.js | 6 +- cvat-ui/package-lock.json | 4 +- cvat-ui/package.json | 2 +- cvat-ui/src/actions/cloud-storage-actions.ts | 30 +- cvat-ui/src/actions/import-actions.ts | 4 +- cvat-ui/src/actions/jobs-actions.ts | 15 +- cvat-ui/src/actions/projects-actions.ts | 38 +-- cvat-ui/src/actions/tasks-actions.ts | 35 +-- cvat-ui/src/assets/empty-tasks-icon.svg | 11 - .../cloud-storages-filter-configuration.ts | 83 ++++++ .../cloud-storages-page.tsx | 143 ++++++---- .../cloud-storages-page/styles.scss | 31 +- .../cloud-storages-page/top-bar.tsx | 78 ++++- .../jobs-page/jobs-filter-configuration.ts | 1 - .../src/components/jobs-page/jobs-page.tsx | 45 ++- cvat-ui/src/components/jobs-page/styles.scss | 127 -------- cvat-ui/src/components/jobs-page/top-bar.tsx | 28 +- .../src/components/models-page/empty-list.tsx | 51 ++-- .../components/models-page/models-page.tsx | 8 +- .../src/components/models-page/styles.scss | 42 +-- .../src/components/models-page/top-bar.tsx | 17 -- .../components/project-page/project-page.tsx | 253 ++++++++++------ .../project-tasks-filter-configuration.ts | 90 ++++++ .../src/components/project-page/styles.scss | 45 ++- .../components/projects-page/empty-list.tsx | 22 +- .../components/projects-page/project-list.tsx | 9 +- .../projects-filter-configuration.ts | 61 ++++ .../projects-page/projects-page.tsx | 106 ++++--- .../src/components/projects-page/styles.scss | 76 +++-- .../src/components/projects-page/top-bar.tsx | 140 ++++++--- .../filtering.tsx | 105 ++++--- .../resource-sorting-filtering/index.ts | 33 +++ .../sorting.tsx | 19 +- .../resource-sorting-filtering/styles.scss | 139 +++++++++ .../components/search-field/search-field.tsx | 103 ------- .../src/components/search-field/styles.scss | 9 - .../search-tooltip/search-tooltip.tsx | 162 ----------- .../src/components/search-tooltip/styles.scss | 19 -- .../src/components/tasks-page/empty-list.tsx | 60 ++-- cvat-ui/src/components/tasks-page/styles.scss | 85 +++--- .../src/components/tasks-page/task-list.tsx | 32 +-- .../tasks-page/tasks-filter-configuration.ts | 103 +++++++ .../src/components/tasks-page/tasks-page.tsx | 270 +++++++++--------- cvat-ui/src/components/tasks-page/top-bar.tsx | 125 +++++--- .../src/containers/tasks-page/tasks-list.tsx | 16 +- .../src/containers/tasks-page/tasks-page.tsx | 50 +--- cvat-ui/src/icons.tsx | 2 - .../src/reducers/cloud-storages-reducer.ts | 9 +- cvat-ui/src/reducers/interfaces.ts | 29 +- cvat-ui/src/reducers/jobs-reducer.ts | 1 + cvat-ui/src/reducers/projects-reducer.ts | 15 +- cvat-ui/src/reducers/tasks-reducer.ts | 11 +- cvat-ui/src/utils/hooks.ts | 15 +- cvat/apps/engine/views.py | 4 +- ...g_project_by_inserting_labels_from_task.js | 1 + .../case_94_move_task_between_projects.js | 4 +- ...ng_more_one_tasks_from_project_per_time.js | 4 +- .../case_61_create_task_set_issue_tracker.js | 5 +- .../case_35_search_task_feature.js | 48 ---- ...create_task_without_necessary_arguments.js | 5 +- .../case_41_add_delete_label_attribute.js | 3 +- ...change_label_name_via_label_constructor.js | 5 +- ..._try_create_task_incorrect_dataset_repo.js | 5 +- .../case_97_export_import_task.js | 1 + .../actions_tasks3/case_105_cloud_storage.js | 3 +- .../case_107_connected_file_share.js | 7 +- ...ate_task_with_files_from_remote_sources.js | 5 +- .../issue_2411_deleting_attributes.js | 5 +- ...aying_attached_files_when_creating_task.js | 7 +- tests/cypress/support/commands.js | 88 +++--- tests/cypress/support/commands_projects.js | 4 +- 76 files changed, 1767 insertions(+), 1534 deletions(-) delete mode 100644 cvat-ui/src/assets/empty-tasks-icon.svg create mode 100644 cvat-ui/src/components/cloud-storages-page/cloud-storages-filter-configuration.ts delete mode 100644 cvat-ui/src/components/models-page/top-bar.tsx create mode 100644 cvat-ui/src/components/project-page/project-tasks-filter-configuration.ts create mode 100644 cvat-ui/src/components/projects-page/projects-filter-configuration.ts rename cvat-ui/src/components/{jobs-page => resource-sorting-filtering}/filtering.tsx (82%) create mode 100644 cvat-ui/src/components/resource-sorting-filtering/index.ts rename cvat-ui/src/components/{jobs-page => resource-sorting-filtering}/sorting.tsx (90%) create mode 100644 cvat-ui/src/components/resource-sorting-filtering/styles.scss delete mode 100644 cvat-ui/src/components/search-field/search-field.tsx delete mode 100644 cvat-ui/src/components/search-field/styles.scss delete mode 100644 cvat-ui/src/components/search-tooltip/search-tooltip.tsx delete mode 100644 cvat-ui/src/components/search-tooltip/styles.scss create mode 100644 cvat-ui/src/components/tasks-page/tasks-filter-configuration.ts delete mode 100644 tests/cypress/integration/actions_tasks2/case_35_search_task_feature.js diff --git a/CHANGELOG.md b/CHANGELOG.md index cce2bb85..d8748546 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## \[2.1.0] - Unreleased ### Added -- TDB +- Advanced filtration and sorting for a list of tasks/projects/cloudstorages () ### Changed - TDB diff --git a/cvat-core/package-lock.json b/cvat-core/package-lock.json index cfea2ff7..7a1cfa40 100644 --- a/cvat-core/package-lock.json +++ b/cvat-core/package-lock.json @@ -1,12 +1,12 @@ { "name": "cvat-core", - "version": "4.2.1", + "version": "5.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "cvat-core", - "version": "4.2.1", + "version": "5.0.0", "license": "MIT", "dependencies": { "axios": "^0.21.4", diff --git a/cvat-core/package.json b/cvat-core/package.json index 098ddd8a..ac402b14 100644 --- a/cvat-core/package.json +++ b/cvat-core/package.json @@ -1,6 +1,6 @@ { "name": "cvat-core", - "version": "4.2.1", + "version": "5.0.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 98bf84de..ecbd57d6 100644 --- a/cvat-core/src/api-implementation.js +++ b/cvat-core/src/api-implementation.js @@ -14,7 +14,6 @@ const config = require('./config'); isString, checkFilter, checkExclusiveFields, - camelToSnake, checkObjectType, } = require('./common'); @@ -171,7 +170,14 @@ const config = require('./config'); } } - const jobsData = await serverProxy.jobs.get(filter); + const searchParams = {}; + for (const key of Object.keys(filter)) { + if (['page', 'sort', 'search', 'filter'].includes(key)) { + searchParams[key] = filter[key]; + } + } + + const jobsData = await serverProxy.jobs.get(searchParams); const jobs = jobsData.results.map((jobData) => new Job(jobData)); jobs.count = jobsData.count; return jobs; @@ -182,32 +188,33 @@ const config = require('./config'); page: isInteger, projectId: isInteger, id: isInteger, + sort: isString, search: isString, filter: isString, ordering: isString, }); checkExclusiveFields(filter, ['id', 'projectId'], ['page']); - const searchParams = {}; - for (const field of [ - 'filter', - 'search', - 'ordering', - 'id', - 'page', - 'projectId', - ]) { - if (Object.prototype.hasOwnProperty.call(filter, field)) { - searchParams[camelToSnake(field)] = filter[field]; + for (const key of Object.keys(filter)) { + if (['page', 'id', 'sort', 'search', 'filter', 'ordering'].includes(key)) { + searchParams[key] = filter[key]; } } - const tasksData = await serverProxy.tasks.get(searchParams); - const tasks = tasksData.map((task) => new Task(task)); + let tasksData = null; + if (filter.projectId) { + if (searchParams.filter) { + const parsed = JSON.parse(searchParams.filter); + searchParams.filter = JSON.stringify({ and: [parsed, { '==': [{ var: 'project_id' }, filter.projectId] }] }); + } else { + searchParams.filter = JSON.stringify({ and: [{ '==': [{ var: 'project_id' }, filter.projectId] }] }); + } + } + tasksData = await serverProxy.tasks.get(searchParams); + const tasks = tasksData.map((task) => new Task(task)); tasks.count = tasksData.count; - return tasks; }; @@ -216,15 +223,15 @@ const config = require('./config'); id: isInteger, page: isInteger, search: isString, + sort: isString, filter: isString, }); checkExclusiveFields(filter, ['id'], ['page']); - const searchParams = {}; - for (const field of ['filter', 'search', 'status', 'id', 'page']) { - if (Object.prototype.hasOwnProperty.call(filter, field)) { - searchParams[camelToSnake(field)] = filter[field]; + for (const key of Object.keys(filter)) { + if (['id', 'page', 'search', 'sort', 'page'].includes(key)) { + searchParams[key] = filter[key]; } } @@ -246,25 +253,19 @@ const config = require('./config'); checkFilter(filter, { page: isInteger, filter: isString, + sort: isString, id: isInteger, search: isString, }); checkExclusiveFields(filter, ['id', 'search'], ['page']); - - const searchParams = new URLSearchParams(); - for (const field of [ - 'filter', - 'search', - 'id', - 'page', - ]) { - if (Object.prototype.hasOwnProperty.call(filter, field)) { - searchParams.set(camelToSnake(field), filter[field]); + const searchParams = {}; + for (const key of Object.keys(filter)) { + if (['page', 'filter', 'sort', 'id', 'search'].includes(key)) { + searchParams[key] = filter[key]; } } - - const cloudStoragesData = await serverProxy.cloudStorages.get(searchParams.toString()); + const cloudStoragesData = await serverProxy.cloudStorages.get(searchParams); const cloudStorages = cloudStoragesData.map((cloudStorage) => new CloudStorage(cloudStorage)); cloudStorages.count = cloudStoragesData.count; return cloudStorages; diff --git a/cvat-core/src/common.js b/cvat-core/src/common.js index bb47060c..e0b2b2b8 100644 --- a/cvat-core/src/common.js +++ b/cvat-core/src/common.js @@ -87,16 +87,6 @@ return true; } - function camelToSnake(str) { - if (typeof str !== 'string') { - throw new ArgumentError('str is expected to be string'); - } - - return ( - str[0].toLowerCase() + str.slice(1, str.length).replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`) - ); - } - class FieldUpdateTrigger { constructor() { let updatedFlags = {}; @@ -136,7 +126,6 @@ checkFilter, checkObjectType, checkExclusiveFields, - camelToSnake, FieldUpdateTrigger, }; })(); diff --git a/cvat-core/src/server-proxy.js b/cvat-core/src/server-proxy.js index ca46f12d..f4be5df1 100644 --- a/cvat-core/src/server-proxy.js +++ b/cvat-core/src/server-proxy.js @@ -1521,13 +1521,15 @@ } } - async function getCloudStorages(filter = '') { + async function getCloudStorages(filter = {}) { const { backendAPI } = config; let response = null; try { - response = await Axios.get(`${backendAPI}/cloudstorages?page_size=12&${filter}`, { + response = await Axios.get(`${backendAPI}/cloudstorages`, { proxy: config.proxy, + params: filter, + page_size: 12, }); } catch (errorData) { throw generateError(errorData); diff --git a/cvat-ui/package-lock.json b/cvat-ui/package-lock.json index 0da910a7..750f47c9 100644 --- a/cvat-ui/package-lock.json +++ b/cvat-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "cvat-ui", - "version": "1.36.0", + "version": "1.37.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "cvat-ui", - "version": "1.36.0", + "version": "1.37.0", "license": "MIT", "dependencies": { "@ant-design/icons": "^4.6.3", diff --git a/cvat-ui/package.json b/cvat-ui/package.json index c5ffeab7..41301fd4 100644 --- a/cvat-ui/package.json +++ b/cvat-ui/package.json @@ -1,6 +1,6 @@ { "name": "cvat-ui", - "version": "1.36.0", + "version": "1.37.0", "description": "CVAT single-page application", "main": "src/index.tsx", "scripts": { diff --git a/cvat-ui/src/actions/cloud-storage-actions.ts b/cvat-ui/src/actions/cloud-storage-actions.ts index f7cb7703..051bf456 100644 --- a/cvat-ui/src/actions/cloud-storage-actions.ts +++ b/cvat-ui/src/actions/cloud-storage-actions.ts @@ -5,7 +5,7 @@ import { Dispatch, ActionCreator } from 'redux'; import { ActionUnion, createAction, ThunkAction } from 'utils/redux'; import getCore from 'cvat-core-wrapper'; -import { CloudStoragesQuery, CloudStorage } from 'reducers/interfaces'; +import { CloudStoragesQuery, CloudStorage, Indexable } from 'reducers/interfaces'; const cvat = getCore(); @@ -103,40 +103,16 @@ export type CloudStorageActions = ActionUnion; export function getCloudStoragesAsync(query: Partial): ThunkAction { return async (dispatch: ActionCreator): Promise => { - function camelToSnake(str: string): string { - return ( - str[0].toLowerCase() + str.slice(1, str.length) - .replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`) - ); - } - dispatch(cloudStoragesActions.getCloudStorages()); dispatch(cloudStoragesActions.updateCloudStoragesGettingQuery(query)); const filteredQuery = { ...query }; for (const key in filteredQuery) { - if (filteredQuery[key] === null) { - delete filteredQuery[key]; + if ((filteredQuery as Indexable)[key] === null) { + delete (filteredQuery as Indexable)[key]; } } - // Temporary hack to do not change UI currently for cloud storages - // Will be redesigned in a different PR - const filter = { - and: ['displayName', 'resource', 'description', 'owner', 'providerType', 'credentialsType'].reduce((acc, filterField) => { - if (filterField in filteredQuery) { - acc.push({ '==': [{ var: camelToSnake(filterField) }, filteredQuery[filterField]] }); - delete filteredQuery[filterField]; - } - - return acc; - }, []), - }; - - if (filter.and.length) { - filteredQuery.filter = JSON.stringify(filter); - } - let result = null; try { result = await cvat.cloudStorages.get(filteredQuery); diff --git a/cvat-ui/src/actions/import-actions.ts b/cvat-ui/src/actions/import-actions.ts index 71c9a8eb..b237e353 100644 --- a/cvat-ui/src/actions/import-actions.ts +++ b/cvat-ui/src/actions/import-actions.ts @@ -1,4 +1,4 @@ -// Copyright (C) 2021 Intel Corporation +// Copyright (C) 2021-2022 Intel Corporation // // SPDX-License-Identifier: MIT @@ -52,7 +52,7 @@ export const importDatasetAsync = (instance: any, format: string, file: File): T } dispatch(importActions.importDatasetSuccess()); - dispatch(getProjectsAsync({ id: instance.id })); + dispatch(getProjectsAsync({ id: instance.id }, getState().projects.tasksGettingQuery)); } ); diff --git a/cvat-ui/src/actions/jobs-actions.ts b/cvat-ui/src/actions/jobs-actions.ts index d49f8c3f..7c333e1a 100644 --- a/cvat-ui/src/actions/jobs-actions.ts +++ b/cvat-ui/src/actions/jobs-actions.ts @@ -4,7 +4,7 @@ import { ActionUnion, createAction, ThunkAction } from 'utils/redux'; import getCore from 'cvat-core-wrapper'; -import { JobsQuery } from 'reducers/interfaces'; +import { Indexable, JobsQuery } from 'reducers/interfaces'; const cvat = getCore(); @@ -30,12 +30,13 @@ export type JobsActions = ActionUnion; export const getJobsAsync = (query: JobsQuery): ThunkAction => async (dispatch) => { try { - // Remove all keys with null values from the query - const filteredQuery: Partial = { ...query }; - if (filteredQuery.page === null) delete filteredQuery.page; - if (filteredQuery.filter === null) delete filteredQuery.filter; - if (filteredQuery.sort === null) delete filteredQuery.sort; - if (filteredQuery.search === null) delete filteredQuery.search; + // We remove all keys with null values from the query + const filteredQuery = { ...query }; + for (const key of Object.keys(query)) { + if ((filteredQuery as Indexable)[key] === null) { + delete (filteredQuery as Indexable)[key]; + } + } dispatch(jobsActions.getJobs(filteredQuery)); const jobs = await cvat.jobs.get(filteredQuery); diff --git a/cvat-ui/src/actions/projects-actions.ts b/cvat-ui/src/actions/projects-actions.ts index eafc7bee..0af733cc 100644 --- a/cvat-ui/src/actions/projects-actions.ts +++ b/cvat-ui/src/actions/projects-actions.ts @@ -5,7 +5,9 @@ import { Dispatch, ActionCreator } from 'redux'; import { ActionUnion, createAction, ThunkAction } from 'utils/redux'; -import { ProjectsQuery, TasksQuery, CombinedState } from 'reducers/interfaces'; +import { + ProjectsQuery, TasksQuery, CombinedState, Indexable, +} from 'reducers/interfaces'; import { getTasksAsync } from 'actions/tasks-actions'; import { getCVATStore } from 'cvat-store'; import getCore from 'cvat-core-wrapper'; @@ -80,17 +82,19 @@ const projectActions = { export type ProjectActions = ActionUnion; export function getProjectTasksAsync(tasksQuery: Partial = {}): ThunkAction { - return (dispatch: ActionCreator): void => { + return (dispatch: ActionCreator, getState: () => CombinedState): void => { const store = getCVATStore(); const state: CombinedState = store.getState(); - dispatch(projectActions.updateProjectsGettingQuery({}, tasksQuery)); + dispatch(projectActions.updateProjectsGettingQuery( + getState().projects.gettingQuery, + tasksQuery, + )); const query: Partial = { ...state.projects.tasksGettingQuery, - page: 1, ...tasksQuery, }; - dispatch(getTasksAsync(query)); + dispatch(getTasksAsync(query, false)); }; } @@ -107,29 +111,13 @@ export function getProjectsAsync( ...query, }; - for (const key in filteredQuery) { - if (filteredQuery[key] === null || typeof filteredQuery[key] === 'undefined') { - delete filteredQuery[key]; + for (const key of Object.keys(filteredQuery)) { + const value = (filteredQuery as Indexable)[key]; + if (value === null || typeof value === 'undefined') { + delete (filteredQuery as Indexable)[key]; } } - // Temporary hack to do not change UI currently for projects - // Will be redesigned in a different PR - const filter = { - and: ['owner', 'assignee', 'name', 'status'].reduce((acc, filterField) => { - if (filterField in filteredQuery) { - acc.push({ '==': [{ var: filterField }, filteredQuery[filterField]] }); - delete filteredQuery[filterField]; - } - - return acc; - }, []), - }; - - if (filter.and.length) { - filteredQuery.filter = JSON.stringify(filter); - } - let result = null; try { result = await cvat.projects.get(filteredQuery); diff --git a/cvat-ui/src/actions/tasks-actions.ts b/cvat-ui/src/actions/tasks-actions.ts index 0f29cf7a..f99e04c5 100644 --- a/cvat-ui/src/actions/tasks-actions.ts +++ b/cvat-ui/src/actions/tasks-actions.ts @@ -4,7 +4,7 @@ import { AnyAction, Dispatch, ActionCreator } from 'redux'; import { ThunkAction } from 'redux-thunk'; -import { TasksQuery, CombinedState } from 'reducers/interfaces'; +import { TasksQuery, CombinedState, Indexable } from 'reducers/interfaces'; import { getCVATStore } from 'cvat-store'; import getCore from 'cvat-core-wrapper'; import { getInferenceStatusAsync } from './models-actions'; @@ -41,10 +41,11 @@ export enum TasksActionTypes { SWITCH_MOVE_TASK_MODAL_VISIBLE = 'SWITCH_MOVE_TASK_MODAL_VISIBLE', } -function getTasks(query: TasksQuery): AnyAction { +function getTasks(query: TasksQuery, updateQuery: boolean): AnyAction { const action = { type: TasksActionTypes.GET_TASKS, payload: { + updateQuery, query, }, }; @@ -74,35 +75,18 @@ function getTasksFailed(error: any): AnyAction { return action; } -export function getTasksAsync(query: TasksQuery): ThunkAction, {}, {}, AnyAction> { +export function getTasksAsync(query: TasksQuery, updateQuery = true): ThunkAction, {}, {}, AnyAction> { return async (dispatch: ActionCreator): Promise => { - dispatch(getTasks(query)); + dispatch(getTasks(query, updateQuery)); - // We need remove all keys with null values from query + // We remove all keys with null values from the query const filteredQuery = { ...query }; - for (const key in filteredQuery) { - if (filteredQuery[key] === null) { - delete filteredQuery[key]; + for (const key of Object.keys(query)) { + if ((filteredQuery as Indexable)[key] === null) { + delete (filteredQuery as Indexable)[key]; } } - // Temporary hack to do not change UI currently for tasks - // Will be redesigned in a different PR - const filter = { - and: ['owner', 'assignee', 'name', 'status', 'mode', 'dimension'].reduce((acc, filterField) => { - if (filterField in filteredQuery) { - acc.push({ '==': [{ var: filterField }, filteredQuery[filterField]] }); - delete filteredQuery[filterField]; - } - - return acc; - }, []), - }; - - if (filter.and.length) { - filteredQuery.filter = JSON.stringify(filter); - } - let result = null; try { result = await cvat.tasks.get(filteredQuery); @@ -115,7 +99,6 @@ export function getTasksAsync(query: TasksQuery): ThunkAction, {}, const promises = array.map((task): string => (task as any).frames.preview().catch(() => '')); dispatch(getInferenceStatusAsync()); - dispatch(getTasksSuccess(array, await Promise.all(promises), result.count)); }; } diff --git a/cvat-ui/src/assets/empty-tasks-icon.svg b/cvat-ui/src/assets/empty-tasks-icon.svg deleted file mode 100644 index 83cd7acd..00000000 --- a/cvat-ui/src/assets/empty-tasks-icon.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - 8EAC7454-72F0-4344-ACCC-8688B016EA51 - Created with sketchtool. - - - - - - \ No newline at end of file diff --git a/cvat-ui/src/components/cloud-storages-page/cloud-storages-filter-configuration.ts b/cvat-ui/src/components/cloud-storages-page/cloud-storages-filter-configuration.ts new file mode 100644 index 00000000..1e100ede --- /dev/null +++ b/cvat-ui/src/components/cloud-storages-page/cloud-storages-filter-configuration.ts @@ -0,0 +1,83 @@ +// Copyright (C) 2022 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import { Config } from 'react-awesome-query-builder'; + +export const config: Partial = { + fields: { + id: { + label: 'ID', + type: 'number', + operators: ['equal', 'between', 'greater', 'greater_or_equal', 'less', 'less_or_equal'], + fieldSettings: { min: 0 }, + valueSources: ['value'], + }, + provider_type: { + label: 'Provider type', + type: 'select', + operators: ['select_equals'], + valueSources: ['value'], + fieldSettings: { + listValues: [ + { value: 'AWS_S3_BUCKET', title: 'AWS S3' }, + { value: 'AZURE_CONTAINER', title: 'Azure' }, + { value: 'GOOGLE_CLOUD_STORAGE', title: 'Google cloud' }, + ], + }, + }, + credentials_type: { + label: 'Credentials type', + type: 'select', + operators: ['select_equals'], + valueSources: ['value'], + fieldSettings: { + listValues: [ + { value: 'KEY_SECRET_KEY_PAIR', title: 'Key & secret key' }, + { value: 'ACCOUNT_NAME_TOKEN_PAIR', title: 'Account name & token' }, + { value: 'ANONYMOUS_ACCESS', title: 'Anonymous access' }, + { value: 'KEY_FILE_PATH', title: 'Key file' }, + ], + }, + }, + resource: { + label: 'Resource name', + type: 'text', + valueSources: ['value'], + operators: ['like'], + }, + display_name: { + label: 'Display name', + type: 'text', + valueSources: ['value'], + operators: ['like'], + }, + description: { + label: 'Description', + type: 'text', + valueSources: ['value'], + operators: ['like'], + }, + owner: { + label: 'Owner', + type: 'text', + valueSources: ['value'], + operators: ['equal'], + }, + updated_date: { + label: 'Last updated', + type: 'datetime', + operators: ['between', 'greater', 'greater_or_equal', 'less', 'less_or_equal'], + }, + }, +}; + +export const localStorageRecentCapacity = 10; +export const localStorageRecentKeyword = 'recentlyAppliedCloudStoragesFilters'; + +export const predefinedFilterValues = { + 'Owned by me': '{"and":[{"==":[{"var":"owner"},""]}]}', + 'AWS storages': '{"and":[{"==":[{"var":"provider_type"},"AWS_S3_BUCKET"]}]}', + 'Azure storages': '{"and":[{"==":[{"var":"provider_type"},"AZURE_CONTAINER"]}]}', + 'Google cloud storages': '{"and":[{"==":[{"var":"provider_type"},"GOOGLE_CLOUD_STORAGE"]}]}', +}; diff --git a/cvat-ui/src/components/cloud-storages-page/cloud-storages-page.tsx b/cvat-ui/src/components/cloud-storages-page/cloud-storages-page.tsx index bd2517e1..41582c6c 100644 --- a/cvat-ui/src/components/cloud-storages-page/cloud-storages-page.tsx +++ b/cvat-ui/src/components/cloud-storages-page/cloud-storages-page.tsx @@ -1,16 +1,17 @@ -// Copyright (C) 2021 Intel Corporation +// Copyright (C) 2021-2022 Intel Corporation // // SPDX-License-Identifier: MIT import './styles.scss'; -import React, { useCallback, useEffect } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import React, { useCallback, useEffect, useState } from 'react'; import { useHistory } from 'react-router'; +import { useDispatch, useSelector } from 'react-redux'; import { Row, Col } from 'antd/lib/grid'; import Spin from 'antd/lib/spin'; -import { CloudStoragesQuery, CombinedState } from 'reducers/interfaces'; +import { CombinedState, Indexable } from 'reducers/interfaces'; import { getCloudStoragesAsync } from 'actions/cloud-storage-actions'; +import { updateHistoryFromQuery } from 'components/resource-sorting-filtering'; import CloudStoragesListComponent from './cloud-storages-list'; import EmptyCloudStorageListComponent from './empty-cloud-storages-list'; import TopBarComponent from './top-bar'; @@ -18,21 +19,40 @@ import TopBarComponent from './top-bar'; export default function StoragesPageComponent(): JSX.Element { const dispatch = useDispatch(); const history = useHistory(); - const { search } = history.location; + const [isMounted, setIsMounted] = useState(false); const totalCount = useSelector((state: CombinedState) => state.cloudStorages.count); - const isFetching = useSelector((state: CombinedState) => state.cloudStorages.fetching); + const fetching = useSelector((state: CombinedState) => state.cloudStorages.fetching); const current = useSelector((state: CombinedState) => state.cloudStorages.current); const query = useSelector((state: CombinedState) => state.cloudStorages.gettingQuery); - const onSearch = useCallback( - (_query: CloudStoragesQuery) => { - if (!isFetching) dispatch(getCloudStoragesAsync(_query)); - }, - [isFetching], - ); + + const queryParams = new URLSearchParams(history.location.search); + const updatedQuery = { ...query }; + for (const key of Object.keys(updatedQuery)) { + (updatedQuery as Indexable)[key] = queryParams.get(key) || null; + if (key === 'page') { + updatedQuery.page = updatedQuery.page ? +updatedQuery.page : 1; + } + } + + useEffect(() => { + dispatch(getCloudStoragesAsync({ ...updatedQuery })); + setIsMounted(true); + }, []); + + useEffect(() => { + if (isMounted) { + // do not update URL from previous query which might exist if we left page of SPA before and returned here + history.replace({ + search: updateHistoryFromQuery(query), + }); + } + }, [query]); const onChangePage = useCallback( (page: number) => { - if (!isFetching && page !== query.page) dispatch(getCloudStoragesAsync({ ...query, page })); + if (!fetching && page !== query.page) { + dispatch(getCloudStoragesAsync({ ...query, page })); + } }, [query], ); @@ -44,60 +64,57 @@ export default function StoragesPageComponent(): JSX.Element { xxl: 16, }; - useEffect(() => { - const searchParams = new URLSearchParams(); - for (const [key, value] of Object.entries(query)) { - if (value !== null && typeof value !== 'undefined') { - searchParams.append(key, value.toString()); - } - } - - history.push({ - pathname: '/cloudstorages', - search: `?${searchParams.toString()}`, - }); - }, [query]); - - useEffect(() => { - const searchParams = { ...query }; - for (const [key, value] of new URLSearchParams(search)) { - if (key in searchParams) { - searchParams[key] = ['page', 'id'].includes(key) ? +value : value; - } - } - onSearch(searchParams); - }, []); - - const searchWasUsed = Object.entries(query).some(([key, value]) => { - if (key === 'page') { - return value && Number.isInteger(value) && value > 1; - } - - return !!value; - }); - - if (isFetching) { - return ( - - - - ); - } + const anySearch = Object.keys(query) + .some((value: string) => value !== 'page' && (query as any)[value] !== null); + const content = current.length ? ( + + ) : ( + + ); return ( - - {current.length ? ( - - ) : ( - - )} + { + dispatch( + getCloudStoragesAsync({ + ...query, + search: _search, + page: 1, + }), + ); + }} + onApplyFilter={(filter: string | null) => { + dispatch( + getCloudStoragesAsync({ + ...query, + filter, + page: 1, + }), + ); + }} + onApplySorting={(sorting: string | null) => { + dispatch( + getCloudStoragesAsync({ + ...query, + sort: sorting, + page: 1, + }), + ); + }} + query={updatedQuery} + /> + { fetching ? ( + + + + ) : content } ); diff --git a/cvat-ui/src/components/cloud-storages-page/styles.scss b/cvat-ui/src/components/cloud-storages-page/styles.scss index 817f11c7..5c76305d 100644 --- a/cvat-ui/src/components/cloud-storages-page/styles.scss +++ b/cvat-ui/src/components/cloud-storages-page/styles.scss @@ -26,16 +26,29 @@ } .cvat-cloud-storages-list-top-bar { - > div:first-child { - .cvat-title { - margin-right: $grid-unit-size; - } - + > div { display: flex; - } - - > div:last-child { - text-align: right; + justify-content: space-between; + + > .cvat-cloudstorages-page-filters-wrapper { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + + > div { + > *:not(:last-child) { + margin-right: $grid-unit-size; + } + + display: flex; + margin-right: $grid-unit-size * 4; + } + + .cvat-cloudstorages-page-tasks-search-bar { + width: $grid-unit-size * 32; + } + } } } diff --git a/cvat-ui/src/components/cloud-storages-page/top-bar.tsx b/cvat-ui/src/components/cloud-storages-page/top-bar.tsx index 52351ef8..8884e5b2 100644 --- a/cvat-ui/src/components/cloud-storages-page/top-bar.tsx +++ b/cvat-ui/src/components/cloud-storages-page/top-bar.tsx @@ -1,42 +1,92 @@ -// Copyright (C) 2021 Intel Corporation +// Copyright (C) 2021-2022 Intel Corporation // // SPDX-License-Identifier: MIT -import React from 'react'; +import React, { useState } from 'react'; import { useHistory } from 'react-router'; import { Row, Col } from 'antd/lib/grid'; import Button from 'antd/lib/button'; -import Text from 'antd/lib/typography/Text'; import { PlusOutlined } from '@ant-design/icons'; -import SearchField from 'components/search-field/search-field'; import { CloudStoragesQuery } from 'reducers/interfaces'; +import Input from 'antd/lib/input'; +import { SortingComponent, ResourceFilterHOC, defaultVisibility } from 'components/resource-sorting-filtering'; + +import { + localStorageRecentKeyword, localStorageRecentCapacity, + predefinedFilterValues, config, +} from './cloud-storages-filter-configuration'; + +const FilteringComponent = ResourceFilterHOC( + config, localStorageRecentKeyword, localStorageRecentCapacity, predefinedFilterValues, +); interface Props { - onSearch(query: CloudStoragesQuery): void; + onApplyFilter(filter: string | null): void; + onApplySorting(sorting: string | null): void; + onApplySearch(search: string | null): void; query: CloudStoragesQuery; } export default function StoragesTopBar(props: Props): JSX.Element { - const { onSearch, query } = props; + const { + query, onApplyFilter, onApplySorting, onApplySearch, + } = props; const history = useHistory(); + const [visibility, setVisibility] = useState(defaultVisibility); return ( - - Cloud Storages - - - + +
+ { + onApplySearch(phrase); + }} + defaultValue={query.search || ''} + className='cvat-cloudstorages-page-tasks-search-bar' + placeholder='Search ...' + /> +
+ ( + setVisibility({ ...defaultVisibility, sorting: visible }) + )} + defaultFields={query.sort?.split(',') || ['-ID']} + sortingFields={['ID', 'Provider type', 'Updated date', 'Display name', 'Resource', 'Credentials type', 'Owner', 'Description']} + onApplySorting={(sorting: string | null) => { + onApplySorting(sorting); + }} + /> + ( + setVisibility({ ...defaultVisibility, predefined: visible }) + )} + onBuilderVisibleChange={(visible: boolean) => ( + setVisibility({ ...defaultVisibility, builder: visible }) + )} + onRecentVisibleChange={(visible: boolean) => ( + setVisibility({ ...defaultVisibility, builder: visibility.builder, recent: visible }) + )} + onApplyFilter={(filter: string | null) => { + onApplyFilter(filter); + }} + /> +
+
+ />
); diff --git a/cvat-ui/src/components/jobs-page/jobs-filter-configuration.ts b/cvat-ui/src/components/jobs-page/jobs-filter-configuration.ts index 95c27730..cf52a617 100644 --- a/cvat-ui/src/components/jobs-page/jobs-filter-configuration.ts +++ b/cvat-ui/src/components/jobs-page/jobs-filter-configuration.ts @@ -118,4 +118,3 @@ export const predefinedFilterValues = { 'Assigned to me': '{"and":[{"==":[{"var":"assignee"},""]}]}', 'Not completed': '{"!":{"or":[{"==":[{"var":"state"},"completed"]},{"==":[{"var":"stage"},"acceptance"]}]}}', }; -export const defaultEnabledFilters = ['Not completed']; diff --git a/cvat-ui/src/components/jobs-page/jobs-page.tsx b/cvat-ui/src/components/jobs-page/jobs-page.tsx index f6d8846e..40f37ea7 100644 --- a/cvat-ui/src/components/jobs-page/jobs-page.tsx +++ b/cvat-ui/src/components/jobs-page/jobs-page.tsx @@ -3,14 +3,18 @@ // SPDX-License-Identifier: MIT import './styles.scss'; -import React from 'react'; +import React, { useEffect, useState } 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 Text from 'antd/lib/typography/Text'; -import { CombinedState } from 'reducers/interfaces'; +import FeedbackComponent from 'components/feedback/feedback'; +import { updateHistoryFromQuery } from 'components/resource-sorting-filtering'; +import { CombinedState, Indexable } from 'reducers/interfaces'; import { getJobsAsync } from 'actions/jobs-actions'; import TopBarComponent from './top-bar'; @@ -18,22 +22,39 @@ import JobsContentComponent from './jobs-content'; function JobsPageComponent(): JSX.Element { const dispatch = useDispatch(); + const history = useHistory(); + const [isMounted, setIsMounted] = useState(false); 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 dimensions = { - md: 22, - lg: 18, - xl: 16, - xxl: 16, - }; + const queryParams = new URLSearchParams(history.location.search); + const updatedQuery = { ...query }; + for (const key of Object.keys(updatedQuery)) { + (updatedQuery as Indexable)[key] = queryParams.get(key) || null; + if (key === 'page') { + updatedQuery.page = updatedQuery.page ? +updatedQuery.page : 1; + } + } + + useEffect(() => { + dispatch(getJobsAsync({ ...updatedQuery })); + setIsMounted(true); + }, []); + + useEffect(() => { + if (isMounted) { + history.replace({ + search: updateHistoryFromQuery(query), + }); + } + }, [query]); const content = count ? ( <> - + { @@ -51,12 +72,12 @@ function JobsPageComponent(): JSX.Element { - ) : ; + ) : No results matched your search...} />; return (
{ dispatch( getJobsAsync({ @@ -88,7 +109,7 @@ function JobsPageComponent(): JSX.Element { { fetching ? ( ) : content } - +
); } diff --git a/cvat-ui/src/components/jobs-page/styles.scss b/cvat-ui/src/components/jobs-page/styles.scss index 298ae8e7..e6125491 100644 --- a/cvat-ui/src/components/jobs-page/styles.scss +++ b/cvat-ui/src/components/jobs-page/styles.scss @@ -114,133 +114,6 @@ } } -.cvat-jobs-filter-dropdown-users { - padding: $grid-unit-size; -} - -.cvat-jobs-page-filters { - display: flex; - align-items: center; - - span[aria-label=down] { - margin-right: $grid-unit-size; - } - - > button { - margin-right: $grid-unit-size; - - &:last-child { - margin-right: 0; - } - } -} - -.cvat-jobs-page-recent-filters-list { - max-width: $grid-unit-size * 64; - - .ant-menu { - border: none; - - .ant-menu-item { - padding: $grid-unit-size; - margin: 0; - line-height: initial; - height: auto; - } - } -} - -.cvat-jobs-page-filters-builder { - background: white; - padding: $grid-unit-size; - border-radius: 4px; - box-shadow: $box-shadow-base; - display: flex; - flex-direction: column; - align-items: flex-end; - - // redefine default awesome react query builder styles below - .query-builder { - margin: $grid-unit-size; - - .group.group-or-rule { - background: none !important; - border: none !important; - } - - .group--actions.group--actions--tr { - opacity: 1 !important; - } - - .group--conjunctions { - div.ant-btn-group { - button.ant-btn { - width: auto !important; - opacity: 1 !important; - margin-right: $grid-unit-size !important; - padding: 0 $grid-unit-size !important; - } - } - } - } -} - -.cvat-jobs-page-sorting-list, -.cvat-jobs-page-predefined-filters-list, -.cvat-jobs-page-recent-filters-list { - background: white; - padding: $grid-unit-size; - border-radius: 4px; - display: flex; - flex-direction: column; - box-shadow: $box-shadow-base; - - .ant-checkbox-wrapper { - margin-bottom: $grid-unit-size; - margin-left: 0; - } -} - -.cvat-jobs-page-sorting-list { - width: $grid-unit-size * 24; -} - -.cvat-sorting-field { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: $grid-unit-size; - - .ant-radio-button-wrapper { - width: $grid-unit-size * 16; - user-select: none; - cursor: move; - } -} - -.cvat-sorting-anchor { - width: 100%; - pointer-events: none; - - &:first-child { - margin-top: $grid-unit-size * 4; - } - - &:last-child { - margin-bottom: $grid-unit-size * 4; - } -} - -.cvat-sorting-dragged-item { - z-index: 10000; -} - -.cvat-jobs-page-filters-space { - justify-content: right; - align-items: center; - display: flex; -} - .cvat-jobs-page-top-bar { > div { display: flex; diff --git a/cvat-ui/src/components/jobs-page/top-bar.tsx b/cvat-ui/src/components/jobs-page/top-bar.tsx index 77c41f66..ca9d43bf 100644 --- a/cvat-ui/src/components/jobs-page/top-bar.tsx +++ b/cvat-ui/src/components/jobs-page/top-bar.tsx @@ -7,30 +7,15 @@ import { Col, Row } from 'antd/lib/grid'; import Input from 'antd/lib/input'; import { JobsQuery } from 'reducers/interfaces'; -import SortingComponent from './sorting'; -import ResourceFilterHOC from './filtering'; +import { SortingComponent, ResourceFilterHOC, defaultVisibility } from 'components/resource-sorting-filtering'; import { - localStorageRecentKeyword, localStorageRecentCapacity, - predefinedFilterValues, defaultEnabledFilters, config, + localStorageRecentKeyword, localStorageRecentCapacity, predefinedFilterValues, config, } from './jobs-filter-configuration'; const FilteringComponent = ResourceFilterHOC( - config, localStorageRecentKeyword, localStorageRecentCapacity, - predefinedFilterValues, defaultEnabledFilters, + config, localStorageRecentKeyword, localStorageRecentCapacity, predefinedFilterValues, ); -const defaultVisibility: { - predefined: boolean; - recent: boolean; - builder: boolean; - sorting: boolean; -} = { - predefined: false, - recent: false, - builder: false, - sorting: false, -}; - interface Props { query: JobsQuery; onApplyFilter(filter: string | null): void; @@ -42,7 +27,7 @@ function TopBarComponent(props: Props): JSX.Element { const { query, onApplyFilter, onApplySorting, onApplySearch, } = props; - const [visibility, setVisibility] = useState(defaultVisibility); + const [visibility, setVisibility] = useState(defaultVisibility); return ( @@ -55,7 +40,7 @@ function TopBarComponent(props: Props): JSX.Element { }} defaultValue={query.search || ''} className='cvat-jobs-page-search-bar' - placeholder='Search ..' + placeholder='Search ...' />
( setVisibility({ ...defaultVisibility, sorting: visible }) )} - defaultFields={query.sort?.split(',') || ['ID']} + defaultFields={query.sort?.split(',') || ['-ID']} sortingFields={['ID', 'Assignee', 'Updated date', 'Stage', 'State', 'Task ID', 'Project ID', 'Task name', 'Project name']} onApplySorting={onApplySorting} /> - - - - - - - - No models deployed yet... - - - - - To annotate your tasks automatically - - - - - deploy a model with - nuclio - - -
+ + + + No models deployed yet... + + + + + To annotate your tasks automatically + + + + + deploy a model with + nuclio + + + + )} + /> ); } diff --git a/cvat-ui/src/components/models-page/models-page.tsx b/cvat-ui/src/components/models-page/models-page.tsx index db5265da..e5d9949d 100644 --- a/cvat-ui/src/components/models-page/models-page.tsx +++ b/cvat-ui/src/components/models-page/models-page.tsx @@ -1,11 +1,10 @@ -// Copyright (C) 2020 Intel Corporation +// Copyright (C) 2020-2022 Intel Corporation // // SPDX-License-Identifier: MIT import './styles.scss'; import React from 'react'; -import TopBarComponent from './top-bar'; import DeployedModelsList from './deployed-models-list'; import EmptyListComponent from './empty-list'; import FeedbackComponent from '../feedback/feedback'; @@ -19,13 +18,14 @@ interface Props { } export default function ModelsPageComponent(props: Props): JSX.Element { - const { interactors, detectors, trackers, reid } = props; + const { + interactors, detectors, trackers, reid, + } = props; const deployedModels = [...detectors, ...interactors, ...trackers, ...reid]; return (
- {deployedModels.length ? : }
diff --git a/cvat-ui/src/components/models-page/styles.scss b/cvat-ui/src/components/models-page/styles.scss index 5d93e670..ecf33ba8 100644 --- a/cvat-ui/src/components/models-page/styles.scss +++ b/cvat-ui/src/components/models-page/styles.scss @@ -13,39 +13,15 @@ width: 100%; > div:nth-child(1) { - margin-bottom: 10px; - - > div:nth-child(1) { - display: flex; - } - - > div:nth-child(2) { - display: flex; - justify-content: flex-end; - } + margin-bottom: $grid-unit-size; } } .cvat-empty-models-list { - /* empty-models icon */ - > div:nth-child(1) { - margin-top: 50px; - } - - /* No models uploaded yet */ - > div:nth-child(2) > div { - margin-top: 20px; - - > span { - font-size: 20px; - color: $text-color; - } - } - - /* To annotate your task automatically */ - > div:nth-child(3) { - margin-top: 10px; - } + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); } .cvat-models-list { @@ -58,8 +34,8 @@ height: auto; border: 1px solid $border-color-1; border-radius: 3px; - margin-bottom: 15px; - padding: 15px; + margin-bottom: $grid-unit-size * 2; + padding: $grid-unit-size * 2; background: $background-color-1; &:hover { @@ -83,7 +59,3 @@ overflow: hidden; } } - -#cvat-create-model-button { - padding: 0 30px; -} diff --git a/cvat-ui/src/components/models-page/top-bar.tsx b/cvat-ui/src/components/models-page/top-bar.tsx deleted file mode 100644 index a1d539ef..00000000 --- a/cvat-ui/src/components/models-page/top-bar.tsx +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (C) 2020 Intel Corporation -// -// SPDX-License-Identifier: MIT - -import React from 'react'; -import { Row, Col } from 'antd/lib/grid'; -import Text from 'antd/lib/typography/Text'; - -export default function TopBarComponent(): JSX.Element { - return ( - - - Models - - - ); -} diff --git a/cvat-ui/src/components/project-page/project-page.tsx b/cvat-ui/src/components/project-page/project-page.tsx index 80b5f5af..fa4ac6df 100644 --- a/cvat-ui/src/components/project-page/project-page.tsx +++ b/cvat-ui/src/components/project-page/project-page.tsx @@ -1,11 +1,11 @@ -// Copyright (C) 2019-2021 Intel Corporation +// Copyright (C) 2019-2022 Intel Corporation // // SPDX-License-Identifier: MIT import './styles.scss'; -import React, { useEffect, useCallback } from 'react'; +import React, { useEffect, useState } from 'react'; import { useSelector, useDispatch } from 'react-redux'; -import { useHistory, useParams, useLocation } from 'react-router'; +import { useHistory, useParams } from 'react-router'; import Spin from 'antd/lib/spin'; import { Row, Col } from 'antd/lib/grid'; import Result from 'antd/lib/result'; @@ -13,19 +13,30 @@ 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 Empty from 'antd/lib/empty'; +import Input from 'antd/lib/input'; -import { CombinedState, Task, TasksQuery } from 'reducers/interfaces'; +import { CombinedState, Task, Indexable } 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 ImportDatasetModal from 'components/import-dataset-modal/import-dataset-modal'; -import { useDidUpdateEffect } from 'utils/hooks'; +import { + SortingComponent, ResourceFilterHOC, defaultVisibility, updateHistoryFromQuery, +} from 'components/resource-sorting-filtering'; import DetailsComponent from './details'; import ProjectTopBar from './top-bar'; +import { + localStorageRecentKeyword, localStorageRecentCapacity, predefinedFilterValues, config, +} from './project-tasks-filter-configuration'; + +const FilteringComponent = ResourceFilterHOC( + config, localStorageRecentKeyword, localStorageRecentCapacity, predefinedFilterValues, +); + interface ParamType { id: string; } @@ -34,7 +45,6 @@ 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); @@ -42,7 +52,24 @@ export default function ProjectPageComponent(): JSX.Element { 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 tasksQuery = useSelector((state: CombinedState) => state.projects.tasksGettingQuery); + const tasksFetching = useSelector((state: CombinedState) => state.tasks.fetching); + const [isMounted, setIsMounted] = useState(false); + const [visibility, setVisibility] = useState(defaultVisibility); + + const queryParams = new URLSearchParams(history.location.search); + const updatedQuery = { ...tasksQuery }; + for (const key of Object.keys(updatedQuery)) { + (updatedQuery as Indexable)[key] = queryParams.get(key) || null; + if (key === 'page') { + updatedQuery.page = updatedQuery.page ? +updatedQuery.page : 1; + } + } + + useEffect(() => { + dispatch(getProjectTasksAsync({ ...updatedQuery, projectId: id })); + setIsMounted(true); + }, []); const [project] = projects.filter((_project) => _project.id === id); const projectSubsets: Array = []; @@ -50,42 +77,25 @@ export default function ProjectPageComponent(): JSX.Element { 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(() => { - const searchParams: Partial = {}; - for (const [param, value] of new URLSearchParams(search)) { - searchParams[param] = ['page'].includes(param) ? Number.parseInt(value, 10) : value; + if (!project) { + dispatch(getProjectsAsync({ id }, updatedQuery)); } - 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()); - } + useEffect(() => { + if (isMounted) { + history.replace({ + search: updateHistoryFromQuery(tasksQuery), + }); } - history.push({ - pathname: `/projects/${id}`, - search: `?${searchParams.toString()}`, - }); - }, [tasksGettingQuery, id]); - - if (deleteActivity) { - history.push('/projects'); - } + }, [tasksQuery]); + + useEffect(() => { + if (project && id in deletes && deletes[id]) { + history.push('/projects'); + } + }, [deletes]); if (projectsFetching) { return ; @@ -102,12 +112,51 @@ export default function ProjectPageComponent(): JSX.Element { ); } - const paginationDimensions = { - md: 22, - lg: 18, - xl: 16, - xxl: 16, - }; + const content = tasksCount ? ( + <> + {projectSubsets.map((subset: string) => ( + + {subset && {subset}} + {tasks + .filter((task) => task.instance.projectId === project.id && task.instance.subset === subset) + .map((task: Task) => ( + + ))} + + + { + dispatch(getProjectTasksAsync({ + ...tasksQuery, + projectId: id, + page, + })); + }} + showSizeChanger={false} + total={tasksCount} + pageSize={10} + current={tasksQuery.page} + showQuickJumper + /> + + + + ) : ( + + ); return ( @@ -115,61 +164,81 @@ export default function ProjectPageComponent(): JSX.Element { - - Tasks - dispatch(getProjectTasksAsync(query))} - /> - - + +
+ { + dispatch(getProjectTasksAsync({ + ...tasksQuery, + page: 1, + projectId: id, + search: _search, + })); + }} + defaultValue={tasksQuery.search || ''} + className='cvat-project-page-tasks-search-bar' + placeholder='Search ...' + /> +
+ ( + setVisibility({ ...defaultVisibility, sorting: visible }) + )} + defaultFields={tasksQuery.sort?.split(',') || ['-ID']} + sortingFields={['ID', 'Owner', 'Status', 'Assignee', 'Updated date', 'Subset', 'Mode', 'Dimension', 'Name']} + onApplySorting={(sorting: string | null) => { + dispatch(getProjectTasksAsync({ + ...tasksQuery, + page: 1, + projectId: id, + sort: sorting, + })); + }} + /> + ( + setVisibility({ ...defaultVisibility, predefined: visible }) + )} + onBuilderVisibleChange={(visible: boolean) => ( + setVisibility({ ...defaultVisibility, builder: visible }) + )} + onRecentVisibleChange={(visible: boolean) => ( + setVisibility({ + ...defaultVisibility, + builder: visibility.builder, + recent: visible, + }) + )} + onApplyFilter={(filter: string | null) => { + dispatch(getProjectTasksAsync({ + ...tasksQuery, + page: 1, + projectId: id, + filter, + })); + }} + /> +
+
- -
- {projectSubsets.map((subset: string) => ( - - {subset && {subset}} - {tasks - .filter((task) => task.instance.projectId === project.id && task.instance.subset === subset) - .map((task: Task) => ( - - ))} - - - + { tasksFetching ? ( + + ) : content } + diff --git a/cvat-ui/src/components/project-page/project-tasks-filter-configuration.ts b/cvat-ui/src/components/project-page/project-tasks-filter-configuration.ts new file mode 100644 index 00000000..e94c0818 --- /dev/null +++ b/cvat-ui/src/components/project-page/project-tasks-filter-configuration.ts @@ -0,0 +1,90 @@ +// Copyright (C) 2022 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import { Config } from 'react-awesome-query-builder'; + +export const config: Partial = { + fields: { + dimension: { + label: 'Dimension', + type: 'select', + operators: ['select_equals'], + valueSources: ['value'], + fieldSettings: { + listValues: [ + { value: '2d', title: '2D' }, + { value: '3d', title: '3D' }, + ], + }, + }, + status: { + label: 'Status', + type: 'select', + valueSources: ['value'], + operators: ['select_equals', 'select_any_in', 'select_not_any_in'], + fieldSettings: { + listValues: [ + { value: 'annotation', title: 'Annotation' }, + { value: 'validation', title: 'Validation' }, + { value: 'completed', title: 'Completed' }, + ], + }, + }, + mode: { + label: 'Data', + type: 'select', + valueSources: ['value'], + fieldSettings: { + listValues: [ + { value: 'interpolation', title: 'Video' }, + { value: 'annotation', title: 'Images' }, + ], + }, + }, + subset: { + label: 'Subset', + type: 'text', + valueSources: ['value'], + operators: ['equal'], + }, + assignee: { + label: 'Assignee', + type: 'text', + valueSources: ['value'], + operators: ['equal'], + }, + owner: { + label: 'Owner', + type: 'text', + valueSources: ['value'], + operators: ['equal'], + }, + updated_date: { + label: 'Last updated', + type: 'datetime', + operators: ['between', 'greater', 'greater_or_equal', 'less', 'less_or_equal'], + }, + id: { + label: 'ID', + type: 'number', + operators: ['equal', 'between', 'greater', 'greater_or_equal', 'less', 'less_or_equal'], + fieldSettings: { min: 0 }, + valueSources: ['value'], + }, + name: { + label: 'Name', + type: 'text', + valueSources: ['value'], + operators: ['like'], + }, + }, +}; + +export const localStorageRecentCapacity = 10; +export const localStorageRecentKeyword = 'recentlyAppliedProjectTasksFilters'; +export const predefinedFilterValues = { + 'Assigned to me': '{"and":[{"==":[{"var":"assignee"},""]}]}', + 'Owned by me': '{"and":[{"==":[{"var":"owner"},""]}]}', + 'Not completed': '{"!":{"and":[{"==":[{"var":"status"},"completed"]}]}}', +}; diff --git a/cvat-ui/src/components/project-page/styles.scss b/cvat-ui/src/components/project-page/styles.scss index ea559f91..7d0995c7 100644 --- a/cvat-ui/src/components/project-page/styles.scss +++ b/cvat-ui/src/components/project-page/styles.scss @@ -7,6 +7,39 @@ .cvat-project-page { overflow-y: auto; height: 100%; + + .cvat-spinner { + position: relative; + } +} + +.cvat-project-page-tasks-bar { + margin: $grid-unit-size * 2 0; + + > div { + display: flex; + justify-content: space-between; + + > .cvat-project-page-tasks-filters-wrapper { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + + > div { + > *:not(:last-child) { + margin-right: $grid-unit-size; + } + + display: flex; + margin-right: $grid-unit-size * 4; + } + + .cvat-project-page-tasks-search-bar { + width: $grid-unit-size * 32; + } + } + } } .cvat-project-details { @@ -38,10 +71,6 @@ } } -.cvat-project-page-tasks-bar { - margin: $grid-unit-size * 2 0; -} - .ant-menu.cvat-project-actions-menu { box-shadow: 0 0 17px rgba(0, 0, 0, 0.2); @@ -65,11 +94,3 @@ display: flex; justify-content: center; } - -.cvat-project-tasks-title-search { - display: flex; - - > * { - margin-right: $grid-unit-size * 2; - } -} diff --git a/cvat-ui/src/components/projects-page/empty-list.tsx b/cvat-ui/src/components/projects-page/empty-list.tsx index 35c84807..58aa1c27 100644 --- a/cvat-ui/src/components/projects-page/empty-list.tsx +++ b/cvat-ui/src/components/projects-page/empty-list.tsx @@ -1,4 +1,4 @@ -// Copyright (C) 2020 Intel Corporation +// Copyright (C) 2020-2022 Intel Corporation // // SPDX-License-Identifier: MIT @@ -6,29 +6,18 @@ import React from 'react'; import { Link } from 'react-router-dom'; import Text from 'antd/lib/typography/Text'; import { Row, Col } from 'antd/lib/grid'; -import Icon from '@ant-design/icons'; - -import { EmptyTasksIcon } from 'icons'; +import Empty from 'antd/lib/empty'; interface Props { - notFound?: boolean; + notFound: boolean; } export default function EmptyListComponent(props: Props): JSX.Element { const { notFound } = props; return (
- - - - - - {notFound ? ( - - - No results matched your search... - - + No results matched your search... ) : ( <> @@ -48,6 +37,7 @@ export default function EmptyListComponent(props: Props): JSX.Element { )} + />
); } diff --git a/cvat-ui/src/components/projects-page/project-list.tsx b/cvat-ui/src/components/projects-page/project-list.tsx index f82b9116..c5302bcb 100644 --- a/cvat-ui/src/components/projects-page/project-list.tsx +++ b/cvat-ui/src/components/projects-page/project-list.tsx @@ -1,4 +1,4 @@ -// Copyright (C) 2020-2021 Intel Corporation +// Copyright (C) 2020-2022 Intel Corporation // // SPDX-License-Identifier: MIT @@ -14,18 +14,19 @@ import ProjectItem from './project-item'; export default function ProjectListComponent(): JSX.Element { const dispatch = useDispatch(); const projectsCount = useSelector((state: CombinedState) => state.projects.count); - const { page } = useSelector((state: CombinedState) => state.projects.gettingQuery); const projects = useSelector((state: CombinedState) => state.projects.current); const gettingQuery = useSelector((state: CombinedState) => state.projects.gettingQuery); + const tasksQuery = useSelector((state: CombinedState) => state.projects.tasksGettingQuery); + const { page } = gettingQuery; const changePage = useCallback((p: number) => { dispatch( getProjectsAsync({ ...gettingQuery, page: p, - }), + }, tasksQuery), ); - }, [dispatch, getProjectsAsync, gettingQuery]); + }, [gettingQuery]); const dimensions = { md: 22, diff --git a/cvat-ui/src/components/projects-page/projects-filter-configuration.ts b/cvat-ui/src/components/projects-page/projects-filter-configuration.ts new file mode 100644 index 00000000..9af7f5bf --- /dev/null +++ b/cvat-ui/src/components/projects-page/projects-filter-configuration.ts @@ -0,0 +1,61 @@ +// Copyright (C) 2022 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import { Config } from 'react-awesome-query-builder'; + +export const config: Partial = { + fields: { + id: { + label: 'ID', + type: 'number', + operators: ['equal', 'between', 'greater', 'greater_or_equal', 'less', 'less_or_equal'], + fieldSettings: { min: 0 }, + valueSources: ['value'], + }, + name: { + label: 'Name', + type: 'text', + valueSources: ['value'], + operators: ['like'], + }, + assignee: { + label: 'Assignee', + type: 'text', + valueSources: ['value'], + operators: ['equal'], + }, + owner: { + label: 'Owner', + type: 'text', + valueSources: ['value'], + operators: ['equal'], + }, + updated_date: { + label: 'Last updated', + type: 'datetime', + operators: ['between', 'greater', 'greater_or_equal', 'less', 'less_or_equal'], + }, + status: { + label: 'Status', + type: 'select', + valueSources: ['value'], + operators: ['select_equals', 'select_any_in', 'select_not_any_in'], + fieldSettings: { + listValues: [ + { value: 'annotation', title: 'Annotation' }, + { value: 'validation', title: 'Validation' }, + { value: 'completed', title: 'Completed' }, + ], + }, + }, + }, +}; + +export const localStorageRecentCapacity = 10; +export const localStorageRecentKeyword = 'recentlyAppliedProjectsFilters'; +export const predefinedFilterValues = { + 'Assigned to me': '{"and":[{"==":[{"var":"assignee"},""]}]}', + 'Owned by me': '{"and":[{"==":[{"var":"owner"},""]}]}', + 'Not completed': '{"!":{"and":[{"==":[{"var":"status"},"completed"]}]}}', +}; diff --git a/cvat-ui/src/components/projects-page/projects-page.tsx b/cvat-ui/src/components/projects-page/projects-page.tsx index 74691b96..793bcea8 100644 --- a/cvat-ui/src/components/projects-page/projects-page.tsx +++ b/cvat-ui/src/components/projects-page/projects-page.tsx @@ -1,68 +1,96 @@ -// Copyright (C) 2020-2021 Intel Corporation +// Copyright (C) 2020-2022 Intel Corporation // // SPDX-License-Identifier: MIT import './styles.scss'; -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; +import { useHistory } from 'react-router'; import { useDispatch, useSelector } from 'react-redux'; -import { useLocation, useHistory } from 'react-router'; import Spin from 'antd/lib/spin'; -import { CombinedState, ProjectsQuery } from 'reducers/interfaces'; -import { getProjectsAsync } from 'actions/projects-actions'; +import { CombinedState, Indexable } from 'reducers/interfaces'; +import { getProjectsAsync, restoreProjectAsync } from 'actions/projects-actions'; import FeedbackComponent from 'components/feedback/feedback'; +import { updateHistoryFromQuery } from 'components/resource-sorting-filtering'; import ImportDatasetModal from 'components/import-dataset-modal/import-dataset-modal'; import EmptyListComponent from './empty-list'; import TopBarComponent from './top-bar'; import ProjectListComponent from './project-list'; export default function ProjectsPageComponent(): JSX.Element { - const { search } = useLocation(); - const history = useHistory(); const dispatch = useDispatch(); - const projectFetching = useSelector((state: CombinedState) => state.projects.fetching); - const projectsCount = useSelector((state: CombinedState) => state.projects.current.length); - const gettingQuery = useSelector((state: CombinedState) => state.projects.gettingQuery); - const isImporting = useSelector((state: CombinedState) => state.projects.restoring); - - const anySearchQuery = !!Array.from(new URLSearchParams(search).keys()).filter((value) => value !== 'page').length; + const history = useHistory(); + const fetching = useSelector((state: CombinedState) => state.projects.fetching); + const count = useSelector((state: CombinedState) => state.projects.current.length); + const query = useSelector((state: CombinedState) => state.projects.gettingQuery); + const tasksQuery = useSelector((state: CombinedState) => state.projects.tasksGettingQuery); + const importing = useSelector((state: CombinedState) => state.projects.restoring); + const [isMounted, setIsMounted] = useState(false); + const anySearch = Object.keys(query).some((value: string) => value !== 'page' && (query as any)[value] !== null); - const getSearchParams = (): Partial => { - const searchParams: Partial = {}; - for (const [param, value] of new URLSearchParams(search)) { - searchParams[param] = ['page', 'id'].includes(param) ? Number.parseInt(value, 10) : value; + const queryParams = new URLSearchParams(history.location.search); + const updatedQuery = { ...query }; + for (const key of Object.keys(updatedQuery)) { + (updatedQuery as Indexable)[key] = queryParams.get(key) || null; + if (key === 'page') { + updatedQuery.page = updatedQuery.page ? +updatedQuery.page : 1; } - - return searchParams; - }; + } useEffect(() => { - const searchParams = new URLSearchParams(); - for (const [name, value] of Object.entries(gettingQuery)) { - if (value !== null && typeof value !== 'undefined') { - searchParams.append(name, value.toString()); - } - } - history.push({ - pathname: '/projects', - search: `?${searchParams.toString()}`, - }); - }, [gettingQuery]); + dispatch(getProjectsAsync({ ...updatedQuery })); + setIsMounted(true); + }, []); useEffect(() => { - if (isImporting === false) { - dispatch(getProjectsAsync(getSearchParams())); + if (isMounted) { + history.replace({ + search: updateHistoryFromQuery(query), + }); } - }, [isImporting]); + }, [query]); - if (projectFetching) { - return ; - } + const content = count ? : ; return (
- - {projectsCount ? : } + { + dispatch( + getProjectsAsync({ + ...query, + search, + page: 1, + }, { ...tasksQuery, page: 1 }), + ); + }} + onApplyFilter={(filter: string | null) => { + dispatch( + getProjectsAsync({ + ...query, + filter, + page: 1, + }, { ...tasksQuery, page: 1 }), + ); + }} + onApplySorting={(sorting: string | null) => { + dispatch( + getProjectsAsync({ + ...query, + sort: sorting, + page: 1, + }, { ...tasksQuery, page: 1 }), + ); + }} + query={updatedQuery} + onImportProject={(file: File) => dispatch(restoreProjectAsync(file))} + importing={importing} + /> + { fetching ? ( +
+ +
+ ) : content }
diff --git a/cvat-ui/src/components/projects-page/styles.scss b/cvat-ui/src/components/projects-page/styles.scss index 6c060128..fa374862 100644 --- a/cvat-ui/src/components/projects-page/styles.scss +++ b/cvat-ui/src/components/projects-page/styles.scss @@ -37,42 +37,68 @@ } } -/* empty-projects icon */ .cvat-empty-projects-list { - > div:nth-child(1) { - margin-top: $grid-unit-size * 6; + .ant-empty { + top: 50%; + left: 50%; + position: absolute; + transform: translate(-50%, -50%); } +} - > div:nth-child(2) { - > div { - margin-top: $grid-unit-size * 3; +.cvat-projects-page-control-buttons-wrapper { + display: flex; + flex-direction: column; + background: $background-color-1; + padding: $grid-unit-size; + border-radius: 4px; + box-shadow: $box-shadow-base; + + > * { + &:not(:first-child) { + margin-top: $grid-unit-size; + } - /* No projects created yet */ - > span { - font-size: 20px; - color: $text-color; + width: 100%; + + .ant-upload { + width: 100%; + + button { + width: 100%; } } } - - /* To get started with your annotation project .. */ - > div:nth-child(3) { - margin-top: $grid-unit-size; - } } .cvat-projects-page-top-bar { - > div:nth-child(1) { - > div:nth-child(1) { + > div { + display: flex; + justify-content: space-between; + + > .cvat-projects-page-filters-wrapper { + display: flex; + justify-content: space-between; + align-items: center; width: 100%; + + > div { + > *:not(:last-child) { + margin-right: $grid-unit-size; + } + + display: flex; + margin-right: $grid-unit-size * 4; + } + + .cvat-projects-page-search-bar { + width: $grid-unit-size * 32; + padding-left: $grid-unit-size * 0.5; + } } } } -.cvat-create-project-button { - padding: 0 $grid-unit-size * 4; -} - .cvat-projects-pagination { display: flex; justify-content: center; @@ -152,14 +178,10 @@ flex-wrap: wrap; } -#cvat-export-project-loading { +.cvat-export-project-loading { margin-left: 10; } -#cvat-import-project-button { - padding: 0 30px; -} - -#cvat-import-project-button-loading { +.cvat-import-project-button-loading { margin-left: 10; } diff --git a/cvat-ui/src/components/projects-page/top-bar.tsx b/cvat-ui/src/components/projects-page/top-bar.tsx index 442fa3fa..d0b8edd8 100644 --- a/cvat-ui/src/components/projects-page/top-bar.tsx +++ b/cvat-ui/src/components/projects-page/top-bar.tsx @@ -2,78 +2,134 @@ // // SPDX-License-Identifier: MIT -import React from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import React, { useState, useEffect } from 'react'; import { useHistory } from 'react-router'; import { Row, Col } from 'antd/lib/grid'; import Button from 'antd/lib/button'; -import Text from 'antd/lib/typography/Text'; +import Dropdown from 'antd/lib/dropdown'; +import Input from 'antd/lib/input'; import { PlusOutlined, UploadOutlined, LoadingOutlined } from '@ant-design/icons'; import Upload from 'antd/lib/upload'; -import SearchField from 'components/search-field/search-field'; -import { CombinedState, ProjectsQuery } from 'reducers/interfaces'; -import { getProjectsAsync, restoreProjectAsync } from 'actions/projects-actions'; +import { usePrevious } from 'utils/hooks'; +import { ProjectsQuery } from 'reducers/interfaces'; +import { SortingComponent, ResourceFilterHOC, defaultVisibility } from 'components/resource-sorting-filtering'; -export default function TopBarComponent(): JSX.Element { +import { + localStorageRecentKeyword, localStorageRecentCapacity, predefinedFilterValues, config, +} from './projects-filter-configuration'; + +const FilteringComponent = ResourceFilterHOC( + config, localStorageRecentKeyword, localStorageRecentCapacity, predefinedFilterValues, +); + +interface Props { + onImportProject(file: File): void; + onApplyFilter(filter: string | null): void; + onApplySorting(sorting: string | null): void; + onApplySearch(search: string | null): void; + query: ProjectsQuery; + importing: boolean; +} + +function TopBarComponent(props: Props): JSX.Element { + const { + importing, query, onApplyFilter, onApplySorting, onApplySearch, onImportProject, + } = props; + const [visibility, setVisibility] = useState(defaultVisibility); + const prevImporting = usePrevious(importing); + + useEffect(() => { + if (prevImporting && !importing) { + onApplyFilter(query.filter); + } + }, [importing]); const history = useHistory(); - const dispatch = useDispatch(); - const query = useSelector((state: CombinedState) => state.projects.gettingQuery); - const isImporting = useSelector((state: CombinedState) => state.projects.restoring); return ( - - - Projects - dispatch(getProjectsAsync(_query))} +
+ { + onApplySearch(phrase); + }} + defaultValue={query.search || ''} + className='cvat-projects-page-search-bar' + placeholder='Search ...' + /> +
+ ( + setVisibility({ ...defaultVisibility, sorting: visible }) + )} + defaultFields={query.sort?.split(',') || ['-ID']} + sortingFields={['ID', 'Assignee', 'Owner', 'Status', 'Name', 'Updated date']} + onApplySorting={onApplySorting} + /> + ( + setVisibility({ ...defaultVisibility, predefined: visible }) + )} + onBuilderVisibleChange={(visible: boolean) => ( + setVisibility({ ...defaultVisibility, builder: visible }) + )} + onRecentVisibleChange={(visible: boolean) => ( + setVisibility({ ...defaultVisibility, builder: visibility.builder, recent: visible }) + )} + onApplyFilter={onApplyFilter} /> - - - - +
+
+
+ + { - dispatch(restoreProjectAsync(file)); + onImportProject(file); return false; }} className='cvat-import-project' > - - - - - - - +
+ )} + > + - , - 5, - ); - } - } - } + useEffect(() => { + dispatch(getTasksAsync({ ...updatedQuery })); + setIsMounted(true); + }, []); - private handlePagination = (page: number): void => { - const { gettingQuery } = this.props; - - // modify query object - const query = { ...gettingQuery }; - query.page = page; - - // update url according to new query object - this.updateURL(query); - }; - - private updateURL = (gettingQuery: TasksQuery): void => { - const { history } = this.props; - let queryString = '?'; - for (const field of Object.keys(gettingQuery)) { - if (gettingQuery[field] !== null) { - queryString += `${field}=${gettingQuery[field]}&`; - } - } - - const oldQueryString = history.location.search; - if (oldQueryString !== queryString) { - history.push({ - search: queryString.slice(0, -1), + useEffect(() => { + if (isMounted) { + history.replace({ + search: updateHistoryFromQuery(query), }); - - // force update if any changes - this.forceUpdate(); } - }; - - public render(): JSX.Element { - const { - tasksFetching, gettingQuery, numberOfVisibleTasks, onImportTask, taskImporting, - } = this.props; + }, [query]); - if (tasksFetching) { - return ; + useEffect(() => { + if (countInvisible) { + message.destroy(); + message.info( + <> + Some tasks are temporary hidden because they are not fully created yet + + , + 5, + ); } - - return ( -
- - {numberOfVisibleTasks ? ( - - ) : ( - - )} - -
- ); - } + }, [countInvisible]); + + const content = count ? ( + <> + + + + { + dispatch(getTasksAsync({ + ...query, + page, + })); + }} + showSizeChanger={false} + total={count} + pageSize={10} + current={query.page} + showQuickJumper + /> + + + + ) : ( + + ); + + return ( +
+ { + dispatch( + getTasksAsync({ + ...query, + search, + page: 1, + }), + ); + }} + onApplyFilter={(filter: string | null) => { + dispatch( + getTasksAsync({ + ...query, + filter, + page: 1, + }), + ); + }} + onApplySorting={(sorting: string | null) => { + dispatch( + getTasksAsync({ + ...query, + sort: sorting, + page: 1, + }), + ); + }} + query={updatedQuery} + onImportTask={(file: File) => dispatch(importTaskAsync(file))} + importing={importing} + /> + { fetching ? ( +
+ +
+ ) : content } + +
+ ); } -export default withRouter(TasksPageComponent); +export default React.memo(TasksPageComponent); diff --git a/cvat-ui/src/components/tasks-page/top-bar.tsx b/cvat-ui/src/components/tasks-page/top-bar.tsx index e52383a2..01b00e7f 100644 --- a/cvat-ui/src/components/tasks-page/top-bar.tsx +++ b/cvat-ui/src/components/tasks-page/top-bar.tsx @@ -1,79 +1,130 @@ -// Copyright (C) 2020-2021 Intel Corporation +// Copyright (C) 2020-2022 Intel Corporation // // SPDX-License-Identifier: MIT -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { useHistory } from 'react-router'; import { Row, Col } from 'antd/lib/grid'; +import Dropdown from 'antd/lib/dropdown'; import { PlusOutlined, UploadOutlined, LoadingOutlined } from '@ant-design/icons'; import Button from 'antd/lib/button'; -import Text from 'antd/lib/typography/Text'; import Upload from 'antd/lib/upload'; +import Input from 'antd/lib/input'; -import SearchField from 'components/search-field/search-field'; +import { SortingComponent, ResourceFilterHOC, defaultVisibility } from 'components/resource-sorting-filtering'; import { TasksQuery } from 'reducers/interfaces'; +import { usePrevious } from 'utils/hooks'; +import { + localStorageRecentKeyword, localStorageRecentCapacity, predefinedFilterValues, config, +} from './tasks-filter-configuration'; + +const FilteringComponent = ResourceFilterHOC( + config, localStorageRecentKeyword, localStorageRecentCapacity, predefinedFilterValues, +); interface VisibleTopBarProps { - onSearch: (query: TasksQuery) => void; - onFileUpload(file: File): void; + onImportTask(file: File): void; + onApplyFilter(filter: string | null): void; + onApplySorting(sorting: string | null): void; + onApplySearch(search: string | null): void; query: TasksQuery; - taskImporting: boolean; + importing: boolean; } export default function TopBarComponent(props: VisibleTopBarProps): JSX.Element { const { - query, onSearch, onFileUpload, taskImporting, + importing, query, onApplyFilter, onApplySorting, onApplySearch, onImportTask, } = props; - + const [visibility, setVisibility] = useState(defaultVisibility); const history = useHistory(); + const prevImporting = usePrevious(importing); + + useEffect(() => { + if (prevImporting && !importing) { + onApplyFilter(query.filter); + } + }, [importing]); return ( - - - Tasks - - - - - +
+ { + onApplySearch(phrase); + }} + defaultValue={query.search || ''} + className='cvat-tasks-page-search-bar' + placeholder='Search ...' + /> +
+ ( + setVisibility({ ...defaultVisibility, sorting: visible }) + )} + defaultFields={query.sort?.split(',') || ['-ID']} + sortingFields={['ID', 'Owner', 'Status', 'Assignee', 'Updated date', 'Subset', 'Mode', 'Dimension', 'Project ID', 'Name', 'Project name']} + onApplySorting={onApplySorting} + /> + ( + setVisibility({ ...defaultVisibility, predefined: visible }) + )} + onBuilderVisibleChange={(visible: boolean) => ( + setVisibility({ ...defaultVisibility, builder: visible }) + )} + onRecentVisibleChange={(visible: boolean) => ( + setVisibility({ ...defaultVisibility, builder: visibility.builder, recent: visible }) + )} + onApplyFilter={onApplyFilter} + /> +
+
+
+ + { - onFileUpload(file); + onImportTask(file); return false; }} className='cvat-import-task' > - - - - - - - +
+ )} + > +