diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 188b4572cdd1..5b6023df1a49 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -74,12 +74,15 @@ jobs: - name: Running REST API tests env: API_ABOUT_PAGE: "localhost:8080/api/server/about" + # Access key length should be at least 3, and secret key length at least 8 characters + MINIO_ACCESS_KEY: "minio_access_key" + MINIO_SECRET_KEY: "minio_secret_key" run: | - docker-compose -f docker-compose.yml -f docker-compose.dev.yml -f components/serverless/docker-compose.serverless.yml -f components/analytics/docker-compose.analytics.yml up -d + docker-compose -f docker-compose.yml -f docker-compose.dev.yml -f components/serverless/docker-compose.serverless.yml -f components/analytics/docker-compose.analytics.yml -f tests/rest_api/docker-compose.minio.yml up -d /bin/bash -c 'while [[ "$(curl -s -o /dev/null -w ''%{http_code}'' ${API_ABOUT_PAGE})" != "401" ]]; do sleep 5; done' pip3 install --user -r tests/rest_api/requirements.txt pytest tests/rest_api/ - docker-compose -f docker-compose.yml -f docker-compose.dev.yml -f components/serverless/docker-compose.serverless.yml -f components/analytics/docker-compose.analytics.yml down -v + docker-compose -f docker-compose.yml -f docker-compose.dev.yml -f components/serverless/docker-compose.serverless.yml -f components/analytics/docker-compose.analytics.yml -f tests/rest_api/docker-compose.minio.yml down -v - name: Running unit tests env: HOST_COVERAGE_DATA_DIR: ${{ github.workspace }} diff --git a/.vscode/settings.json b/.vscode/settings.json index a639b2850ea0..76614f368f9a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,13 +18,9 @@ } ], "npm.exclude": "**/.env/**", - "python.linting.enabled": true, - "python.linting.pylintEnabled": true, - "python.linting.pycodestyleEnabled": false, "licenser.license": "Custom", "licenser.customHeader": "Copyright (C) @YEAR@ Intel Corporation\n\nSPDX-License-Identifier: MIT", "files.trimTrailingWhitespace": true, - "python.pythonPath": ".env/bin/python", "sqltools.connections": [ { "previewLimit": 50, @@ -33,9 +29,15 @@ "database": "${workspaceFolder:cvat}/db.sqlite3" } ], + "python.defaultInterpreterPath": "${workspaceFolder}/.env/", + "python.linting.enabled": true, + "python.linting.pylintEnabled": true, + "python.linting.pycodestyleEnabled": false, "python.testing.pytestArgs": [ - "tests" + "--rootdir","${workspaceFolder}/tests/" ], "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true + "python.testing.pytestEnabled": true, + "python.testing.pytestPath": "${workspaceFolder}/.env/bin/pytest", + "python.testing.cwd": "${workspaceFolder}/tests" } diff --git a/CHANGELOG.md b/CHANGELOG.md index b4efddcb706e..43c925bd3949 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,49 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## \[2.0.0] - Unreleased +## \[2.2.0] - Unreleased ### Added -- Handle attributes comming from nuclio detectors () +- TDB + +### Changed +- TDB + +### Deprecated +- TDB + +### Removed +- TDB + +### Fixed +- Revert Open3D to 0.11.2 to prevent depencency conflicts () + +### Security +- TDB + +## \[2.1.0] - 2022-04-08 +### Added +- Task annotations importing via chunk uploads () +- Advanced filtration and sorting for a list of tasks/projects/cloudstorages () +- Project dataset importing via chunk uploads () +- Support paginated list for job commits () + +### Changed +- Added missing geos dependency into Dockerfile () +- Improved helm chart readme () +- Added helm chart support for CVAT 2.X and made ingress compatible with Kubernetes >=1.22 () + +### Fixed +- Permission error occured when accessing the JobCommits () +- job assignee can remove or update any issue created by the task owner () +- Bug: Incorrect point deletion with keyboard shortcut () +- some AI Tools were not sending responses properly () +- Unable to upload annotations () +- Fix build dependencies for Siammask () +- Bug: Exif orientation information handled incorrectly () + +## \[2.0.0] - 2022-03-04 +### Added +- Handle attributes coming from nuclio detectors () - Add additional environment variables for Nuclio configuration () - Add KITTI segmentation and detection format () - Add LFW format () @@ -16,7 +56,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Rotated bounding boxes () - Player option: Smooth image when zoom-in, enabled by default () - Google Cloud Storage support in UI () -- Add project tasks paginations () +- Add project tasks pagination () - Add remove issue button () - Data sorting option () - Options to change font size & position of text labels on the canvas () @@ -39,11 +79,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Advanced filtration and sorting for a list of jobs () ### Changed -- Users don't have access to a task object anymore if they are assigneed only on some jobs of the task () +- Users don't have access to a task object anymore if they are assigned only on some jobs of the task () - Different resources (tasks, projects) are not visible anymore for all CVAT instance users by default () - API versioning scheme: using accept header versioning instead of namespace versioning () - Replaced 'django_sendfile' with 'django_sendfile2' () - Use drf-spectacular instead of drf-yasg for swagger documentation () +- Update development-environment manual to work under MacOS, supported Mac with Apple Silicon () ### Deprecated - Job field "status" is not used in UI anymore, but it has not been removed from the database yet () @@ -78,9 +119,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Annotations search does not work correctly in some corner cases (when use complex properties with width, height) () - Kibana requests are not proxied due to django-revproxy incompatibility with Django >3.2.x () - Content type for getting frame with tasks/{id}/data/ endpoint () +- Bug: Permission error occured when accessing the comments of a specific issue () + ### Security - Updated ELK to 6.8.23 which uses log4j 2.17.1 () +- Added validation for URLs which used as remote data source () ## \[1.7.0] - 2021-11-15 diff --git a/Dockerfile b/Dockerfile index 3b170bdd75a2..1f6410f55dfb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,7 @@ RUN apt-get update && \ apache2-dev \ build-essential \ curl \ + libgeos-dev \ libldap2-dev \ libsasl2-dev \ nasm \ @@ -76,6 +77,7 @@ RUN apt-get update && \ apache2 \ ca-certificates \ libapache2-mod-xsendfile \ + libgeos-dev \ libgomp1 \ libgl1 \ supervisor \ diff --git a/README.md b/README.md index 3948d11d9da0..34791d46ca12 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,10 @@ Prebuilt docker images for CVAT releases are available on Docker Hub: - [cvat_server](https://hub.docker.com/r/openvino/cvat_server) - [cvat_ui](https://hub.docker.com/r/openvino/cvat_ui) +## REST API +The current REST API version is `2.0-alpha`. We focus on its improvement and therefore +REST API may be changed in the next release. + ## LICENSE Code released under the [MIT License](https://opensource.org/licenses/MIT). @@ -140,6 +144,13 @@ connection with your use of FFmpeg. ## Partners +- [ATLANTIS](https://github.com/smhassanerfani/atlantis) is an open-source dataset for semantic segmentation + of waterbody images, depevoped by [iWERS](http://ce.sc.edu/iwers/) group in the + Department of Civil and Environmental Engineering at University of South Carolina, using CVAT. + For developing a semantic segmentation dataset using CVAT, please check + [ATLANTIS published article](https://www.sciencedirect.com/science/article/pii/S1364815222000391), + [ATLANTIS Development Kit](https://github.com/smhassanerfani/atlantis/tree/master/adk) + and [annotation tutorial videos](https://www.youtube.com/playlist?list=PLIfLGY-zZChS5trt7Lc3MfNhab7OWl2BR). - [Onepanel](https://github.com/onepanelio/core) is an open source vision AI platform that fully integrates CVAT with scalable data processing and parallelized training pipelines. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000000..3f9e48cc83d7 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,48 @@ +# Security Policy + +## Supported Versions + +At the moment only the latest release is supported. When you report a security issue, +be sure it can be reproduced in the supported version. + +| Version | Supported | +| ------- | ------------------ | +| 2.0.0 | :white_check_mark: | +| <2.0.0 | :x: | + +## Reporting a Vulnerability + +If you have information about a security issue or vulnerability in the product, please +send an e-mail to [secure@intel.com](mailto:secure@intel.com). Encrypt sensitive information +using our PGP public key. + +Please provide as much information as possible, including: + +- The products and versions affected +- Detailed description of the vulnerability +- Information on known exploits +- A member of the Intel Product Security Team will review your e-mail and contact you to + collaborate on resolving the issue. + +For more information on how Intel works to resolve security issues, see: +[Vulnerability handling guidelines]() + +## Intel® Bug Bounty Program + +Intel Corporation believes that working with skilled security researchers across the globe +is a crucial part of identifying and mitigating security vulnerabilities in Intel products. + +Like other major technology companies, Intel incentivizes security researchers to report +security vulnerabilities in Intel products to us to enable a coordinated response. To +encourage closer collaboration with the security research community on these kinds of issues, +Intel created its Bug Bounty Program. + +If you believe you've found a security vulnerability in an Intel product or technology, we +encourage you to notify us through our program and work with us to mitigate and to coordinate +disclosure of the vulnerability. + +[Intel® Bug Bounty Program Terms]() + +Watch this video, [So You Found a Vulnerability](), +to find out what you can expect when participating in the Intel® Bug Bounty Program. + diff --git a/cvat-canvas/package-lock.json b/cvat-canvas/package-lock.json index 0ab129552269..4c915d045a79 100644 --- a/cvat-canvas/package-lock.json +++ b/cvat-canvas/package-lock.json @@ -1,12 +1,12 @@ { "name": "cvat-canvas", - "version": "2.13.1", + "version": "2.13.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "cvat-canvas", - "version": "2.13.1", + "version": "2.13.2", "license": "MIT", "dependencies": { "@types/polylabel": "^1.0.5", diff --git a/cvat-canvas/package.json b/cvat-canvas/package.json index 776dd7ca4c11..4c5d7da82d56 100644 --- a/cvat-canvas/package.json +++ b/cvat-canvas/package.json @@ -1,6 +1,6 @@ { "name": "cvat-canvas", - "version": "2.13.1", + "version": "2.13.2", "description": "Part of Computer Vision Annotation Tool which presents its canvas library", "main": "src/canvas.ts", "scripts": { diff --git a/cvat-canvas/src/typescript/canvasView.ts b/cvat-canvas/src/typescript/canvasView.ts index 6a4348a34725..4cc1377f54b0 100644 --- a/cvat-canvas/src/typescript/canvasView.ts +++ b/cvat-canvas/src/typescript/canvasView.ts @@ -792,6 +792,13 @@ export class CanvasViewImpl implements CanvasView, Listener { ); if (['polygon', 'polyline', 'points'].includes(state.shapeType)) { + if (state.shapeType === 'points' && (e.altKey || e.ctrlKey)) { + const selectedClientID = +((e.target as HTMLElement).parentElement as HTMLElement).getAttribute('clientID'); + + if (state.clientID !== selectedClientID) { + return; + } + } if (e.altKey) { const { points } = state; this.onEditDone(state, points.slice(0, pointID * 2).concat(points.slice(pointID * 2 + 2))); @@ -860,6 +867,8 @@ export class CanvasViewImpl implements CanvasView, Listener { if (value) { const getGeometry = (): Geometry => this.geometry; + const getController = (): CanvasController => this.controller; + const getActiveElement = (): ActiveElement => this.activeElement; (shape as any).selectize(value, { deepSelect: true, pointSize: (2 * consts.BASE_POINT_SIZE) / this.geometry.scale, @@ -875,7 +884,20 @@ export class CanvasViewImpl implements CanvasView, Listener { 'stroke-width': consts.POINTS_STROKE_WIDTH / getGeometry().scale, }); - circle.on('mouseenter', (): void => { + circle.on('mouseenter', (e: MouseEvent): void => { + const activeElement = getActiveElement(); + if (activeElement !== null && (e.altKey || e.ctrlKey)) { + const [state] = getController().objects.filter( + (_state: any): boolean => _state.clientID === activeElement.clientID, + ); + if (state?.shapeType === 'points') { + const selectedClientID = +((e.target as HTMLElement).parentElement as HTMLElement).getAttribute('clientID'); + if (state.clientID !== selectedClientID) { + return; + } + } + } + circle.attr({ 'stroke-width': consts.POINTS_SELECTED_STROKE_WIDTH / getGeometry().scale, }); diff --git a/cvat-core/package-lock.json b/cvat-core/package-lock.json index dacb7f466f88..133d9482c2f1 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.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "cvat-core", - "version": "4.2.1", + "version": "5.0.1", "license": "MIT", "dependencies": { "axios": "^0.21.4", @@ -3233,9 +3233,9 @@ } }, "node_modules/jest-junit/node_modules/ansi-regex": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", - "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", "dev": true, "engines": { "node": ">=6" @@ -3360,9 +3360,9 @@ } }, "node_modules/jest-junit/node_modules/strip-ansi/node_modules/ansi-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", - "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz", + "integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==", "dev": true, "engines": { "node": ">=4" @@ -4198,9 +4198,9 @@ } }, "node_modules/minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==" }, "node_modules/mixin-deep": { "version": "1.3.2", @@ -6202,9 +6202,9 @@ "deprecated": "Please see https://github.com/lydell/urix#deprecated" }, "node_modules/url-parse": { - "version": "1.5.7", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.7.tgz", - "integrity": "sha512-HxWkieX+STA38EDk7CE9MEryFeHCKzgagxlGvsdS7WBImq9Mk+PGwiT56w82WI3aicwJA8REp42Cxo98c8FZMA==", + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" @@ -8938,9 +8938,9 @@ } }, "ansi-regex": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", - "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", "dev": true }, "ansi-styles": { @@ -9038,9 +9038,9 @@ }, "dependencies": { "ansi-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", - "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz", + "integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==", "dev": true } } @@ -9706,9 +9706,9 @@ } }, "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==" }, "mixin-deep": { "version": "1.3.2", @@ -11282,9 +11282,9 @@ "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=" }, "url-parse": { - "version": "1.5.7", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.7.tgz", - "integrity": "sha512-HxWkieX+STA38EDk7CE9MEryFeHCKzgagxlGvsdS7WBImq9Mk+PGwiT56w82WI3aicwJA8REp42Cxo98c8FZMA==", + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", "requires": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" diff --git a/cvat-core/package.json b/cvat-core/package.json index 098ddd8a2e13..55f6408e92ca 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.1", "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 98bf84de3352..ecbd57d6d8ab 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 bb47060c81c2..e0b2b2b88dbb 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/frames.js b/cvat-core/src/frames.js index fef972858921..532795221239 100644 --- a/cvat-core/src/frames.js +++ b/cvat-core/src/frames.js @@ -66,6 +66,14 @@ value: height, writable: false, }, + /** + * task ID + * @name tid + * @type {integer} + * @memberof module:API.cvat.classes.FrameData + * @readonly + * @instance + */ tid: { value: taskID, writable: false, diff --git a/cvat-core/src/server-proxy.js b/cvat-core/src/server-proxy.js index ca46f12d5f79..3924f7bb7fb5 100644 --- a/cvat-core/src/server-proxy.js +++ b/cvat-core/src/server-proxy.js @@ -45,6 +45,49 @@ }); } + async function chunkUpload(file, uploadConfig) { + const params = enableOrganization(); + const { + endpoint, chunkSize, totalSize, onUpdate, + } = uploadConfig; + let { totalSentSize } = uploadConfig; + return new Promise((resolve, reject) => { + const upload = new tus.Upload(file, { + endpoint, + metadata: { + filename: file.name, + filetype: file.type, + }, + headers: { + Authorization: Axios.defaults.headers.common.Authorization, + }, + chunkSize, + retryDelays: null, + onError(error) { + reject(error); + }, + onBeforeRequest(req) { + const xhr = req.getUnderlyingObject(); + const { org } = params; + req.setHeader('X-Organization', org); + xhr.withCredentials = true; + }, + onProgress(bytesUploaded) { + if (onUpdate && Number.isInteger(totalSentSize) && Number.isInteger(totalSize)) { + const currentUploadedSize = totalSentSize + bytesUploaded; + const percentage = Math.round(currentUploadedSize / totalSize); + onUpdate(percentage); + } + }, + onSuccess() { + if (totalSentSize) totalSentSize += file.size; + resolve(totalSentSize); + }, + }); + upload.start(); + }); + } + function generateError(errorData) { if (errorData.response) { const message = `${errorData.message}. ${JSON.stringify(errorData.response.data) || ''}.`; @@ -569,41 +612,63 @@ } async function importDataset(id, format, file, onUpdate) { - const { backendAPI } = config; + const { backendAPI, origin } = config; + const params = { + ...enableOrganization(), + format, + filename: file.name, + }; + const uploadConfig = { + chunkSize: config.uploadChunkSize * 1024 * 1024, + endpoint: `${origin}${backendAPI}/projects/${id}/dataset/`, + totalSentSize: 0, + totalSize: file.size, + onUpdate: (percentage) => { + onUpdate('The dataset is being uploaded to the server', percentage); + }, + }; const url = `${backendAPI}/projects/${id}/dataset`; - const formData = new FormData(); - formData.append('dataset_file', file); - - return new Promise((resolve, reject) => { - async function requestStatus() { - try { - const response = await Axios.get(`${url}?action=import_status`, { - proxy: config.proxy, - }); - if (response.status === 202) { - if (onUpdate && response.data.message !== '') { - onUpdate(response.data.message, response.data.progress || 0); + try { + await Axios.post(url, + new FormData(), { + params, + proxy: config.proxy, + headers: { 'Upload-Start': true }, + }); + await chunkUpload(file, uploadConfig); + await Axios.post(url, + new FormData(), { + params, + proxy: config.proxy, + headers: { 'Upload-Finish': true }, + }); + return new Promise((resolve, reject) => { + async function requestStatus() { + try { + const response = await Axios.get(url, { + params: { ...params, action: 'import_status' }, + proxy: config.proxy, + }); + if (response.status === 202) { + if (onUpdate && response.data.message) { + onUpdate(response.data.message, response.data.progress || 0); + } + setTimeout(requestStatus, 3000); + } else if (response.status === 201) { + resolve(); + } else { + reject(generateError(response)); } - setTimeout(requestStatus, 3000); - } else if (response.status === 201) { - resolve(); - } else { - reject(generateError(response)); + } catch (error) { + reject(generateError(error)); } - } catch (error) { - reject(generateError(error)); } - } - - Axios.post(`${url}?format=${format}`, formData, { - proxy: config.proxy, - }).then(() => { setTimeout(requestStatus, 2000); - }).catch((error) => { - reject(generateError(error)); }); - }); + } catch (errorData) { + throw generateError(errorData); + } } async function exportTask(id) { @@ -816,42 +881,6 @@ onUpdate('The data are being uploaded to the server..', null); - async function chunkUpload(taskId, file) { - return new Promise((resolve, reject) => { - const upload = new tus.Upload(file, { - endpoint: `${origin}${backendAPI}/tasks/${taskId}/data/`, - metadata: { - filename: file.name, - filetype: file.type, - }, - headers: { - Authorization: `Token ${store.get('token')}`, - }, - chunkSize, - retryDelays: null, - onError(error) { - reject(error); - }, - onBeforeRequest(req) { - const xhr = req.getUnderlyingObject(); - const { org } = params; - req.setHeader('X-Organization', org); - xhr.withCredentials = true; - }, - onProgress(bytesUploaded) { - const currentUploadedSize = totalSentSize + bytesUploaded; - const percentage = currentUploadedSize / totalSize; - onUpdate('The data are being uploaded to the server', percentage); - }, - onSuccess() { - totalSentSize += file.size; - resolve(); - }, - }); - upload.start(); - }); - } - async function bulkUpload(taskId, files) { const fileBulks = files.reduce((fileGroups, file) => { const lastBulk = fileGroups[fileGroups.length - 1]; @@ -891,8 +920,17 @@ proxy: config.proxy, headers: { 'Upload-Start': true }, }); + const uploadConfig = { + endpoint: `${origin}${backendAPI}/tasks/${response.data.id}/data/`, + onUpdate: (percentage) => { + onUpdate('The data are being uploaded to the server', percentage); + }, + chunkSize, + totalSize, + totalSentSize, + }; for (const file of chunkFiles) { - await chunkUpload(response.data.id, file); + uploadConfig.totalSentSize += await chunkUpload(file, uploadConfig); } if (bulkFiles.length > 0) { await bulkUpload(response.data.id, bulkFiles); @@ -1215,38 +1253,56 @@ // Session is 'task' or 'job' async function uploadAnnotations(session, id, file, format) { - const { backendAPI } = config; + const { backendAPI, origin } = config; const params = { ...enableOrganization(), format, + filename: file.name, }; - let annotationData = new FormData(); - annotationData.append('annotation_file', file); - - return new Promise((resolve, reject) => { - async function request() { - try { - const response = await Axios.put( - `${backendAPI}/${session}s/${id}/annotations`, - annotationData, - { - params, - proxy: config.proxy, - }, - ); - if (response.status === 202) { - annotationData = new FormData(); - setTimeout(request, 3000); - } else { - resolve(); + const chunkSize = config.uploadChunkSize * 1024 * 1024; + const uploadConfig = { + chunkSize, + endpoint: `${origin}${backendAPI}/${session}s/${id}/annotations/`, + }; + try { + await Axios.post(`${backendAPI}/${session}s/${id}/annotations`, + new FormData(), { + params, + proxy: config.proxy, + headers: { 'Upload-Start': true }, + }); + await chunkUpload(file, uploadConfig); + await Axios.post(`${backendAPI}/${session}s/${id}/annotations`, + new FormData(), { + params, + proxy: config.proxy, + headers: { 'Upload-Finish': true }, + }); + return new Promise((resolve, reject) => { + async function requestStatus() { + try { + const response = await Axios.put( + `${backendAPI}/${session}s/${id}/annotations`, + new FormData(), + { + params, + proxy: config.proxy, + }, + ); + if (response.status === 202) { + setTimeout(requestStatus, 3000); + } else { + resolve(); + } + } catch (errorData) { + reject(generateError(errorData)); } - } catch (errorData) { - reject(generateError(errorData)); } - } - - setTimeout(request); - }); + setTimeout(requestStatus); + }); + } catch (errorData) { + throw generateError(errorData); + } } // Session is 'task' or 'job' @@ -1521,13 +1577,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 0da910a7651a..ad917a1e9023 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", @@ -50,11 +50,10 @@ "redux-devtools-extension": "^2.13.9", "redux-logger": "^3.0.6", "redux-thunk": "^2.3.0" - }, - "devDependencies": {} + } }, "../cvat-canvas": { - "version": "2.13.1", + "version": "2.13.2", "license": "MIT", "dependencies": { "@types/polylabel": "^1.0.5", @@ -77,7 +76,7 @@ "devDependencies": {} }, "../cvat-core": { - "version": "4.2.1", + "version": "5.0.1", "license": "MIT", "dependencies": { "axios": "^0.21.4", @@ -3813,9 +3812,9 @@ } }, "node_modules/minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", "peer": true }, "node_modules/mississippi": { @@ -9176,9 +9175,9 @@ } }, "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", "peer": true }, "mississippi": { diff --git a/cvat-ui/package.json b/cvat-ui/package.json index c5ffeab7d5f8..18adca5d90fb 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": { @@ -41,7 +41,7 @@ "dotenv-webpack": "^1.8.0", "error-stack-parser": "^2.0.6", "lodash": "^4.17.21", - "moment": "^2.29.1", + "moment": "^2.29.2", "mousetrap": "^1.6.5", "platform": "^1.3.6", "prop-types": "^15.7.2", diff --git a/cvat-ui/src/actions/cloud-storage-actions.ts b/cvat-ui/src/actions/cloud-storage-actions.ts index f7cb770368e1..051bf45634ee 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 71c9a8eb7fcc..b237e35382c8 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 d49f8c3fc299..7c333e1af2c0 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 eafc7beef858..0af733cce409 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 0f29cf7a5d51..f99e04c5f997 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 83cd7acdf7bb..000000000000 --- 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 000000000000..1e100ede0c13 --- /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 bd2517e14bb4..41582c6cd567 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 817f11c7d978..5c76305d69ef 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 52351ef81132..8884e5b2bf19 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/create-cloud-storage-page/cloud-storage-form.tsx b/cvat-ui/src/components/create-cloud-storage-page/cloud-storage-form.tsx index bd34dd95adf6..d4724a238e02 100644 --- a/cvat-ui/src/components/create-cloud-storage-page/cloud-storage-form.tsx +++ b/cvat-ui/src/components/create-cloud-storage-page/cloud-storage-form.tsx @@ -48,6 +48,7 @@ interface CloudStorageForm { prefix?: string; project_id?: string; manifests: string[]; + endpoint_url?: string; } const { Dragger } = Upload; @@ -117,16 +118,20 @@ export default function CreateCloudStorageForm(props: Props): JSX.Element { const location = parsedOptions.get('region') || parsedOptions.get('location'); const prefix = parsedOptions.get('prefix'); const projectId = parsedOptions.get('project_id'); + const endpointUrl = parsedOptions.get('endpoint_url'); + if (location) { setSelectedRegion(location); } if (prefix) { fieldsValue.prefix = prefix; } - if (projectId) { fieldsValue.project_id = projectId; } + if (endpointUrl) { + fieldsValue.endpoint_url = endpointUrl; + } } form.setFieldsValue(fieldsValue); @@ -222,6 +227,10 @@ export default function CreateCloudStorageForm(props: Props): JSX.Element { delete cloudStorageData.project_id; specificAttributes.append('project_id', formValues.project_id); } + if (formValues.endpoint_url) { + delete cloudStorageData.endpoint_url; + specificAttributes.append('endpoint_url', formValues.endpoint_url); + } cloudStorageData.specific_attributes = specificAttributes.toString(); @@ -489,6 +498,14 @@ export default function CreateCloudStorageForm(props: Props): JSX.Element { {credentialsBlok()} + + + ): Promise { - const filter = { displayName: phrase }; + const filter = { + filter: JSON.stringify({ + and: [{ + '==': [{ var: 'display_name' }, phrase], + }], + }), + }; searchCloudStorages(filter).then((list) => { setList(list); }); 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 95c277303c37..cf52a6172db4 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 f6d8846e6ad2..40f37ea7bf94 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 298ae8e7e355..e6125491e7e8 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 77c41f6625b3..ca9d43bf4727 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 db5265dae587..e5d9949d8a2c 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 5d93e670ed99..ecf33ba813a3 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 a1d539ef813a..000000000000 --- 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 80b5f5af01c5..fa4ac6df3c92 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 000000000000..e94c08181274 --- /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 ea559f913ca9..7d0995c71e92 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 35c84807b250..58aa1c27cc37 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 f82b9116d1b8..c5302bcb503b 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 000000000000..9af7f5bf3562 --- /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 74691b96305a..793bcea81c90 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 6c0601281287..fa3748623ac7 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 442fa3fae755..d0b8edd85bde 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 e52383a2b1cf..01b00e7f9960 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' > - - - - - - - +
+ )} + > +