Merge remote-tracking branch 'upstream/develop' into dkru/cypress-test-check-email-verification

main
Kruchinin 5 years ago
commit 2d05706212

4
.gitignore vendored

@ -30,3 +30,7 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
.DS_Store .DS_Store
#Ignore Cypress tests temp files
/tests/cypress/fixtures
/tests/cypress/screenshots

@ -21,6 +21,21 @@
}, },
"smartStep": true, "smartStep": true,
}, },
{
"type": "node",
"request": "launch",
"name": "ui.js: test",
"cwd": "${workspaceRoot}/tests",
"runtimeExecutable": "${workspaceRoot}/tests/node_modules/.bin/cypress",
"args": [
"run",
"--headless",
"--browser",
"chrome"
],
"outputCapture": "std",
"console": "internalConsole"
},
{ {
"name": "server: django", "name": "server: django",
"type": "python", "type": "python",

@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- - Added basic projects implementation (<https://github.com/openvinotoolkit/cvat/pull/2255>)
### Changed ### Changed

@ -0,0 +1 @@
webpack.config.js

@ -0,0 +1 @@
webpack.config.js

@ -1497,9 +1497,9 @@
"integrity": "sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw==" "integrity": "sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw=="
}, },
"@types/yargs": { "@types/yargs": {
"version": "15.0.9", "version": "15.0.10",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.10.tgz",
"integrity": "sha512-HmU8SeIRhZCWcnRskCs36Q1Q00KBV6Cqh/ora8WN1+22dY07AZdn6Gel8QZ3t26XYPImtcL8WV/eqjhVmMEw4g==", "integrity": "sha512-z8PNtlhrj7eJNLmrAivM7rjBESG6JwC5xP3RVk12i/8HVP7Xnx/sEmERnRImyEuUaJfO942X0qMOYsoupaJbZQ==",
"requires": { "requires": {
"@types/yargs-parser": "*" "@types/yargs-parser": "*"
} }
@ -1899,9 +1899,9 @@
"integrity": "sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw==" "integrity": "sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw=="
}, },
"@types/yargs": { "@types/yargs": {
"version": "15.0.9", "version": "15.0.10",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.10.tgz",
"integrity": "sha512-HmU8SeIRhZCWcnRskCs36Q1Q00KBV6Cqh/ora8WN1+22dY07AZdn6Gel8QZ3t26XYPImtcL8WV/eqjhVmMEw4g==", "integrity": "sha512-z8PNtlhrj7eJNLmrAivM7rjBESG6JwC5xP3RVk12i/8HVP7Xnx/sEmERnRImyEuUaJfO942X0qMOYsoupaJbZQ==",
"requires": { "requires": {
"@types/yargs-parser": "*" "@types/yargs-parser": "*"
} }
@ -2773,9 +2773,9 @@
"dev": true "dev": true
}, },
"@types/node": { "@types/node": {
"version": "14.14.7", "version": "14.14.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.7.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.9.tgz",
"integrity": "sha512-Zw1vhUSQZYw+7u5dAwNbIA9TuTotpzY/OF7sJM9FqPOF3SPjKnxrjoTktXDZgUjybf4cWVBP7O8wvKdSaGHweg==" "integrity": "sha512-JsoLXFppG62tWTklIoO4knA+oDTYsmqWxHRvd4lpmfQRNhX6osheUOWETP2jMoV/2bEHuMra8Pp3Dmo/stBFcw=="
}, },
"@types/normalize-package-data": { "@types/normalize-package-data": {
"version": "2.4.0", "version": "2.4.0",
@ -3474,9 +3474,9 @@
} }
}, },
"@types/yargs": { "@types/yargs": {
"version": "15.0.9", "version": "15.0.10",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.10.tgz",
"integrity": "sha512-HmU8SeIRhZCWcnRskCs36Q1Q00KBV6Cqh/ora8WN1+22dY07AZdn6Gel8QZ3t26XYPImtcL8WV/eqjhVmMEw4g==", "integrity": "sha512-z8PNtlhrj7eJNLmrAivM7rjBESG6JwC5xP3RVk12i/8HVP7Xnx/sEmERnRImyEuUaJfO942X0qMOYsoupaJbZQ==",
"requires": { "requires": {
"@types/yargs-parser": "*" "@types/yargs-parser": "*"
} }
@ -14283,28 +14283,28 @@
} }
}, },
"jest-config": { "jest-config": {
"version": "26.0.0", "version": "26.6.2",
"resolved": "https://registry.npmjs.org/jest-config/-/jest-config-26.0.0.tgz", "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-26.6.2.tgz",
"integrity": "sha512-P95C7QMlwnHSkmb08c7ioVOVevm/IRTMrWB/nd7NmYV+Mu0/rjJFpaNV3WyOaj+FaFC0Gad6i3F70UNC9V57EA==", "integrity": "sha512-0ApZqPd+L/BUWvNj1GHcptb5jwF23lo+BskjgJV/Blht1hgpu6eIwaYRgHPrS6I6HrxwRfJvlGbzoZZVb3VHTA==",
"requires": { "requires": {
"@babel/core": "^7.1.0", "@babel/core": "^7.1.0",
"@jest/test-sequencer": "^26.0.0", "@jest/test-sequencer": "^26.6.2",
"@jest/types": "^26.0.0", "@jest/types": "^26.6.2",
"babel-jest": "^26.0.0", "babel-jest": "^26.6.2",
"chalk": "^4.0.0", "chalk": "^4.0.0",
"deepmerge": "^4.2.2", "deepmerge": "^4.2.2",
"glob": "^7.1.1", "glob": "^7.1.1",
"graceful-fs": "^4.2.4", "graceful-fs": "^4.2.4",
"jest-environment-jsdom": "^26.0.0", "jest-environment-jsdom": "^26.6.2",
"jest-environment-node": "^26.0.0", "jest-environment-node": "^26.6.2",
"jest-get-type": "^26.0.0", "jest-get-type": "^26.3.0",
"jest-jasmine2": "^26.0.0", "jest-jasmine2": "^26.6.2",
"jest-regex-util": "^26.0.0", "jest-regex-util": "^26.0.0",
"jest-resolve": "^26.0.0", "jest-resolve": "^26.6.2",
"jest-util": "^26.0.0", "jest-util": "^26.6.2",
"jest-validate": "^26.0.0", "jest-validate": "^26.6.2",
"micromatch": "^4.0.2", "micromatch": "^4.0.2",
"pretty-format": "^26.0.0" "pretty-format": "^26.6.2"
}, },
"dependencies": { "dependencies": {
"@jest/console": { "@jest/console": {
@ -14413,9 +14413,9 @@
"integrity": "sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw==" "integrity": "sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw=="
}, },
"@types/yargs": { "@types/yargs": {
"version": "15.0.9", "version": "15.0.10",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.10.tgz",
"integrity": "sha512-HmU8SeIRhZCWcnRskCs36Q1Q00KBV6Cqh/ora8WN1+22dY07AZdn6Gel8QZ3t26XYPImtcL8WV/eqjhVmMEw4g==", "integrity": "sha512-z8PNtlhrj7eJNLmrAivM7rjBESG6JwC5xP3RVk12i/8HVP7Xnx/sEmERnRImyEuUaJfO942X0qMOYsoupaJbZQ==",
"requires": { "requires": {
"@types/yargs-parser": "*" "@types/yargs-parser": "*"
} }
@ -15175,9 +15175,9 @@
"integrity": "sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw==" "integrity": "sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw=="
}, },
"@types/yargs": { "@types/yargs": {
"version": "15.0.9", "version": "15.0.10",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.10.tgz",
"integrity": "sha512-HmU8SeIRhZCWcnRskCs36Q1Q00KBV6Cqh/ora8WN1+22dY07AZdn6Gel8QZ3t26XYPImtcL8WV/eqjhVmMEw4g==", "integrity": "sha512-z8PNtlhrj7eJNLmrAivM7rjBESG6JwC5xP3RVk12i/8HVP7Xnx/sEmERnRImyEuUaJfO942X0qMOYsoupaJbZQ==",
"requires": { "requires": {
"@types/yargs-parser": "*" "@types/yargs-parser": "*"
} }
@ -15405,9 +15405,9 @@
"integrity": "sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw==" "integrity": "sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw=="
}, },
"@types/yargs": { "@types/yargs": {
"version": "15.0.9", "version": "15.0.10",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.10.tgz",
"integrity": "sha512-HmU8SeIRhZCWcnRskCs36Q1Q00KBV6Cqh/ora8WN1+22dY07AZdn6Gel8QZ3t26XYPImtcL8WV/eqjhVmMEw4g==", "integrity": "sha512-z8PNtlhrj7eJNLmrAivM7rjBESG6JwC5xP3RVk12i/8HVP7Xnx/sEmERnRImyEuUaJfO942X0qMOYsoupaJbZQ==",
"requires": { "requires": {
"@types/yargs-parser": "*" "@types/yargs-parser": "*"
} }

@ -39,7 +39,7 @@
"detect-browser": "^5.2.0", "detect-browser": "^5.2.0",
"error-stack-parser": "^2.0.2", "error-stack-parser": "^2.0.2",
"form-data": "^2.5.0", "form-data": "^2.5.0",
"jest-config": "^26.0.0", "jest-config": "^26.6.2",
"js-cookie": "^2.2.0", "js-cookie": "^2.2.0",
"jsonpath": "^1.0.2", "jsonpath": "^1.0.2",
"platform": "^1.3.5", "platform": "^1.3.5",

@ -16,6 +16,7 @@
const { AnnotationFormats } = require('./annotation-formats'); const { AnnotationFormats } = require('./annotation-formats');
const { ArgumentError } = require('./exceptions'); const { ArgumentError } = require('./exceptions');
const { Task } = require('./session'); const { Task } = require('./session');
const { Project } = require('./project');
function implementAPI(cvat) { function implementAPI(cvat) {
cvat.plugins.list.implementation = PluginRegistry.list; cvat.plugins.list.implementation = PluginRegistry.list;
@ -100,6 +101,11 @@
return result; return result;
}; };
cvat.server.installedApps.implementation = async () => {
const result = await serverProxy.server.installedApps();
return result;
};
cvat.users.get.implementation = async (filter) => { cvat.users.get.implementation = async (filter) => {
checkFilter(filter, { checkFilter(filter, {
id: isInteger, id: isInteger,
@ -163,6 +169,7 @@
cvat.tasks.get.implementation = async (filter) => { cvat.tasks.get.implementation = async (filter) => {
checkFilter(filter, { checkFilter(filter, {
page: isInteger, page: isInteger,
projectId: isInteger,
name: isString, name: isString,
id: isInteger, id: isInteger,
owner: isString, owner: isString,
@ -184,8 +191,15 @@
} }
} }
if (
'projectId' in filter
&& (('page' in filter && Object.keys(filter).length > 2) || Object.keys(filter).length > 2)
) {
throw new ArgumentError('Do not use the filter field "projectId" with other');
}
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
for (const field of ['name', 'owner', 'assignee', 'search', 'status', 'mode', 'id', 'page']) { for (const field of ['name', 'owner', 'assignee', 'search', 'status', 'mode', 'id', 'page', 'projectId']) {
if (Object.prototype.hasOwnProperty.call(filter, field)) { if (Object.prototype.hasOwnProperty.call(filter, field)) {
searchParams.set(field, filter[field]); searchParams.set(field, filter[field]);
} }
@ -199,11 +213,47 @@
return tasks; return tasks;
}; };
cvat.server.installedApps.implementation = async () => { cvat.projects.get.implementation = async (filter) => {
const result = await serverProxy.server.installedApps(); checkFilter(filter, {
return result; id: isInteger,
page: isInteger,
name: isString,
assignee: isString,
owner: isString,
search: isString,
status: isEnum.bind(TaskStatus),
});
if ('search' in filter && Object.keys(filter).length > 1) {
if (!('page' in filter && Object.keys(filter).length === 2)) {
throw new ArgumentError('Do not use the filter field "search" with others');
}
}
if ('id' in filter && Object.keys(filter).length > 1) {
if (!('page' in filter && Object.keys(filter).length === 2)) {
throw new ArgumentError('Do not use the filter field "id" with others');
}
}
const searchParams = new URLSearchParams();
for (const field of ['name', 'assignee', 'owner', 'search', 'status', 'id', 'page']) {
if (Object.prototype.hasOwnProperty.call(filter, field)) {
searchParams.set(field, filter[field]);
}
}
const projectsData = await serverProxy.projects.get(searchParams.toString());
// prettier-ignore
const projects = projectsData.map((project) => new Project(project));
projects.count = projectsData.count;
return projects;
}; };
cvat.projects.searchNames.implementation = async (search, limit) => serverProxy.projects.searchNames(search, limit);
return cvat; return cvat;
} }

@ -14,6 +14,7 @@ function build() {
const ObjectState = require('./object-state'); const ObjectState = require('./object-state');
const Statistics = require('./statistics'); const Statistics = require('./statistics');
const { Job, Task } = require('./session'); const { Job, Task } = require('./session');
const { Project } = require('./project');
const { Attribute, Label } = require('./labels'); const { Attribute, Label } = require('./labels');
const MLModel = require('./ml-model'); const MLModel = require('./ml-model');
@ -274,6 +275,60 @@ function build() {
return result; return result;
}, },
}, },
/**
* Namespace is used for getting projects
* @namespace projects
* @memberof module:API.cvat
*/
projects: {
/**
* @typedef {Object} ProjectFilter
* @property {string} name Check if name contains this value
* @property {module:API.cvat.enums.ProjectStatus} status
* Check if status contains this value
* @property {integer} id Check if id equals this value
* @property {integer} page Get specific page
* (default REST API returns 20 projects per request.
* In order to get more, it is need to specify next page)
* @property {string} owner Check if owner user contains this value
* @property {string} search Combined search of contains among all fields
* @global
*/
/**
* Method returns list of projects corresponding to a filter
* @method get
* @async
* @memberof module:API.cvat.projects
* @param {ProjectFilter} [filter={}] project filter
* @returns {module:API.cvat.classes.Project[]}
* @throws {module:API.cvat.exceptions.PluginError}
* @throws {module:API.cvat.exceptions.ServerError}
*/
async get(filter = {}) {
const result = await PluginRegistry.apiWrapper(cvat.projects.get, filter);
return result;
},
/**
* Method returns list of project names with project ids
* corresponding to a search phrase
* used for autocomplete field
* @method searchNames
* @async
* @memberof module:API.cvat.projects
* @param {string} [search = ''] search phrase
* @param {number} [limit = 10] number of returning project names
* @returns {module:API.cvat.classes.Project[]}
* @throws {module:API.cvat.exceptions.PluginError}
* @throws {module:API.cvat.exceptions.ServerError}
*
*/
async searchNames(search = '', limit = 10) {
const result = await PluginRegistry.apiWrapper(cvat.projects.searchNames, search, limit);
return result;
},
},
/** /**
* Namespace is used for getting tasks * Namespace is used for getting tasks
* @namespace tasks * @namespace tasks
@ -291,6 +346,7 @@ function build() {
* @property {integer} page Get specific page * @property {integer} page Get specific page
* (default REST API returns 20 tasks per request. * (default REST API returns 20 tasks per request.
* In order to get more, it is need to specify next page) * In order to get more, it is need to specify next page)
* @property {integer} projectId Check if project_id field contains this value
* @property {string} owner Check if owner user contains this value * @property {string} owner Check if owner user contains this value
* @property {string} assignee Check if assigneed contains this value * @property {string} assignee Check if assigneed contains this value
* @property {string} search Combined search of contains among all fields * @property {string} search Combined search of contains among all fields
@ -717,8 +773,9 @@ function build() {
* @memberof module:API.cvat * @memberof module:API.cvat
*/ */
classes: { classes: {
Task,
User, User,
Project,
Task,
Job, Job,
Log, Log,
Attribute, Attribute,
@ -730,6 +787,7 @@ function build() {
}; };
cvat.server = Object.freeze(cvat.server); cvat.server = Object.freeze(cvat.server);
cvat.projects = Object.freeze(cvat.projects);
cvat.tasks = Object.freeze(cvat.tasks); cvat.tasks = Object.freeze(cvat.tasks);
cvat.jobs = Object.freeze(cvat.jobs); cvat.jobs = Object.freeze(cvat.jobs);
cvat.users = Object.freeze(cvat.users); cvat.users = Object.freeze(cvat.users);

@ -0,0 +1,265 @@
// Copyright (C) 2019-2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
(() => {
const PluginRegistry = require('./plugins');
const serverProxy = require('./server-proxy');
const { ArgumentError } = require('./exceptions');
const { Task } = require('./session');
const { Label } = require('./labels');
const User = require('./user');
/**
* Class representing a project
* @memberof module:API.cvat.classes
*/
class Project {
/**
* In a fact you need use the constructor only if you want to create a project
* @param {object} initialData - Object which is used for initalization
* <br> It can contain keys:
* <br> <li style="margin-left: 10px;"> name
* <br> <li style="margin-left: 10px;"> labels
*/
constructor(initialData) {
const data = {
id: undefined,
name: undefined,
status: undefined,
assignee: undefined,
owner: undefined,
bug_tracker: undefined,
created_date: undefined,
updated_date: undefined,
};
for (const property in data) {
if (Object.prototype.hasOwnProperty.call(data, property) && property in initialData) {
data[property] = initialData[property];
}
}
data.labels = [];
data.tasks = [];
if (Array.isArray(initialData.labels)) {
for (const label of initialData.labels) {
const classInstance = new Label(label);
data.labels.push(classInstance);
}
}
if (Array.isArray(initialData.tasks)) {
for (const task of initialData.tasks) {
const taskInstance = new Task(task);
data.tasks.push(taskInstance);
}
}
Object.defineProperties(
this,
Object.freeze({
/**
* @name id
* @type {integer}
* @memberof module:API.cvat.classes.Project
* @readonly
* @instance
*/
id: {
get: () => data.id,
},
/**
* @name name
* @type {string}
* @memberof module:API.cvat.classes.Project
* @instance
* @throws {module:API.cvat.exceptions.ArgumentError}
*/
name: {
get: () => data.name,
set: (value) => {
if (!value.trim().length) {
throw new ArgumentError('Value must not be empty');
}
data.name = value;
},
},
/**
* @name status
* @type {module:API.cvat.enums.TaskStatus}
* @memberof module:API.cvat.classes.Project
* @readonly
* @instance
*/
status: {
get: () => data.status,
},
/**
* Instance of a user who was assigned for the project
* @name assignee
* @type {module:API.cvat.classes.User}
* @memberof module:API.cvat.classes.Project
* @readonly
* @instance
*/
assignee: {
get: () => data.assignee,
set: (assignee) => {
if (assignee !== null && !(assignee instanceof User)) {
throw new ArgumentError('Value must be a user instance');
}
data.assignee = assignee;
},
},
/**
* Instance of a user who has created the project
* @name owner
* @type {module:API.cvat.classes.User}
* @memberof module:API.cvat.classes.Project
* @readonly
* @instance
*/
owner: {
get: () => data.owner,
},
/**
* @name bugTracker
* @type {string}
* @memberof module:API.cvat.classes.Project
* @instance
* @throws {module:API.cvat.exceptions.ArgumentError}
*/
bugTracker: {
get: () => data.bug_tracker,
set: (tracker) => {
data.bug_tracker = tracker;
},
},
/**
* @name createdDate
* @type {string}
* @memberof module:API.cvat.classes.Task
* @readonly
* @instance
*/
createdDate: {
get: () => data.created_date,
},
/**
* @name updatedDate
* @type {string}
* @memberof module:API.cvat.classes.Task
* @readonly
* @instance
*/
updatedDate: {
get: () => data.updated_date,
},
/**
* After project has been created value can be appended only.
* @name labels
* @type {module:API.cvat.classes.Label[]}
* @memberof module:API.cvat.classes.Project
* @instance
* @throws {module:API.cvat.exceptions.ArgumentError}
*/
labels: {
get: () => [...data.labels],
set: (labels) => {
if (!Array.isArray(labels)) {
throw new ArgumentError('Value must be an array of Labels');
}
if (!Array.isArray(labels) || labels.some((label) => !(label instanceof Label))) {
throw new ArgumentError(
`Each array value must be an instance of Label. ${typeof label} was found`,
);
}
data.labels = [...labels];
},
},
/**
* Tasks linked with the project
* @name tasks
* @type {module:API.cvat.classes.Task[]}
* @memberof module:API.cvat.classes.Project
* @readonly
* @instance
*/
tasks: {
get: () => [...data.tasks],
},
}),
);
}
/**
* Method updates data of a created project or creates new project from scratch
* @method save
* @returns {module:API.cvat.classes.Project}
* @memberof module:API.cvat.classes.Project
* @readonly
* @instance
* @async
* @throws {module:API.cvat.exceptions.ServerError}
* @throws {module:API.cvat.exceptions.PluginError}
*/
async save() {
const result = await PluginRegistry.apiWrapper.call(this, Project.prototype.save);
return result;
}
/**
* Method deletes a task from a server
* @method delete
* @memberof module:API.cvat.classes.Project
* @readonly
* @instance
* @async
* @throws {module:API.cvat.exceptions.ServerError}
* @throws {module:API.cvat.exceptions.PluginError}
*/
async delete() {
const result = await PluginRegistry.apiWrapper.call(this, Project.prototype.delete);
return result;
}
}
module.exports = {
Project,
};
Project.prototype.save.implementation = async function () {
if (typeof this.id !== 'undefined') {
const projectData = {
name: this.name,
assignee_id: this.assignee ? this.assignee.id : null,
bug_tracker: this.bugTracker,
labels: [...this.labels.map((el) => el.toJSON())],
};
await serverProxy.projects.save(this.id, projectData);
return this;
}
const projectSpec = {
name: this.name,
labels: [...this.labels.map((el) => el.toJSON())],
};
if (this.bugTracker) {
projectSpec.bug_tracker = this.bugTracker;
}
const project = await serverProxy.projects.create(projectSpec);
return new Project(project);
};
Project.prototype.delete.implementation = async function () {
const result = await serverProxy.projects.delete(this.id);
return result;
};
})();

@ -312,6 +312,82 @@
} }
} }
async function searchProjectNames(search, limit) {
const { backendAPI, proxy } = config;
let response = null;
try {
response = await Axios.get(
`${backendAPI}/projects?names_only=true&page=1&page_size=${limit}&search=${search}`,
{
proxy,
},
);
} catch (errorData) {
throw generateError(errorData);
}
response.data.results.count = response.data.count;
return response.data.results;
}
async function getProjects(filter = '') {
const { backendAPI, proxy } = config;
let response = null;
try {
response = await Axios.get(`${backendAPI}/projects?page_size=12&${filter}`, {
proxy,
});
} catch (errorData) {
throw generateError(errorData);
}
response.data.results.count = response.data.count;
return response.data.results;
}
async function saveProject(id, projectData) {
const { backendAPI } = config;
try {
await Axios.patch(`${backendAPI}/projects/${id}`, JSON.stringify(projectData), {
proxy: config.proxy,
headers: {
'Content-Type': 'application/json',
},
});
} catch (errorData) {
throw generateError(errorData);
}
}
async function deleteProject(id) {
const { backendAPI } = config;
try {
await Axios.delete(`${backendAPI}/projects/${id}`);
} catch (errorData) {
throw generateError(errorData);
}
}
async function createProject(projectSpec) {
const { backendAPI } = config;
try {
const response = await Axios.post(`${backendAPI}/projects`, JSON.stringify(projectSpec), {
proxy: config.proxy,
headers: {
'Content-Type': 'application/json',
},
});
return response.data;
} catch (errorData) {
throw generateError(errorData);
}
}
async function getTasks(filter = '') { async function getTasks(filter = '') {
const { backendAPI } = config; const { backendAPI } = config;
@ -347,7 +423,12 @@
const { backendAPI } = config; const { backendAPI } = config;
try { try {
await Axios.delete(`${backendAPI}/tasks/${id}`); await Axios.delete(`${backendAPI}/tasks/${id}`, {
proxy: config.proxy,
headers: {
'Content-Type': 'application/json',
},
});
} catch (errorData) { } catch (errorData) {
throw generateError(errorData); throw generateError(errorData);
} }
@ -827,6 +908,17 @@
writable: false, writable: false,
}, },
projects: {
value: Object.freeze({
get: getProjects,
searchNames: searchProjectNames,
save: saveProject,
create: createProject,
delete: deleteProject,
}),
writable: false,
},
tasks: { tasks: {
value: Object.freeze({ value: Object.freeze({
getTasks, getTasks,

@ -871,6 +871,7 @@
const data = { const data = {
id: undefined, id: undefined,
name: undefined, name: undefined,
project_id: undefined,
status: undefined, status: undefined,
size: undefined, size: undefined,
mode: undefined, mode: undefined,
@ -972,6 +973,16 @@
data.name = value; data.name = value;
}, },
}, },
/**
* @name projectId
* @type {integer|null}
* @memberof module:API.cvat.classes.Task
* @readonly
* @instance
*/
projectId: {
get: () => data.project_id,
},
/** /**
* @name status * @name status
* @type {module:API.cvat.enums.TaskStatus} * @type {module:API.cvat.enums.TaskStatus}
@ -1697,7 +1708,7 @@
return this; return this;
}; };
Task.prototype.save.implementation = async function saveTaskImplementation(onUpdate) { Task.prototype.save.implementation = async function (onUpdate) {
// TODO: Add ability to change an owner and an assignee // TODO: Add ability to change an owner and an assignee
if (typeof this.id !== 'undefined') { if (typeof this.id !== 'undefined') {
// If the task has been already created, we update it // If the task has been already created, we update it
@ -1750,6 +1761,9 @@
if (typeof this.overlap !== 'undefined') { if (typeof this.overlap !== 'undefined') {
taskSpec.overlap = this.overlap; taskSpec.overlap = this.overlap;
} }
if (typeof this.projectId !== 'undefined') {
taskSpec.project_id = this.projectId;
}
const taskDataSpec = { const taskDataSpec = {
client_files: this.clientFiles, client_files: this.clientFiles,

@ -0,0 +1,170 @@
// Copyright (C) 2019-2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
// Setup mock for a server
jest.mock('../../src/server-proxy', () => {
const mock = require('../mocks/server-proxy.mock');
return mock;
});
// Initialize api
window.cvat = require('../../src/api');
const { Task } = require('../../src/session');
const { Project } = require('../../src/project');
describe('Feature: get projects', () => {
test('get all projects', async () => {
const result = await window.cvat.projects.get();
expect(Array.isArray(result)).toBeTruthy();
expect(result).toHaveLength(2);
for (const el of result) {
expect(el).toBeInstanceOf(Project);
}
});
test('get project by id', async () => {
const result = await window.cvat.projects.get({
id: 2,
});
expect(Array.isArray(result)).toBeTruthy();
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(Project);
expect(result[0].id).toBe(2);
expect(result[0].tasks).toHaveLength(1);
expect(result[0].tasks[0]).toBeInstanceOf(Task);
});
test('get a project by an unknown id', async () => {
const result = await window.cvat.projects.get({
id: 1,
});
expect(Array.isArray(result)).toBeTruthy();
expect(result).toHaveLength(0);
});
test('get a project by an invalid id', async () => {
expect(
window.cvat.projects.get({
id: '1',
}),
).rejects.toThrow(window.cvat.exceptions.ArgumentError);
});
test('get projects by filters', async () => {
const result = await window.cvat.projects.get({
status: 'completed',
});
expect(Array.isArray(result)).toBeTruthy();
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(Project);
expect(result[0].id).toBe(2);
expect(result[0].status).toBe('completed');
});
test('get projects by invalid filters', async () => {
expect(
window.cvat.projects.get({
unknown: '5',
}),
).rejects.toThrow(window.cvat.exceptions.ArgumentError);
});
});
describe('Feature: save a project', () => {
test('save some changed fields in a project', async () => {
let result = await window.cvat.tasks.get({
id: 2,
});
result[0].bugTracker = 'newBugTracker';
result[0].name = 'New Project Name';
result[0].save();
result = await window.cvat.tasks.get({
id: 2,
});
expect(result[0].bugTracker).toBe('newBugTracker');
expect(result[0].name).toBe('New Project Name');
});
test('save some new labels in a project', async () => {
let result = await window.cvat.projects.get({
id: 6,
});
const labelsLength = result[0].labels.length;
const newLabel = new window.cvat.classes.Label({
name: "My boss's car",
attributes: [
{
default_value: 'false',
input_type: 'checkbox',
mutable: true,
name: 'parked',
values: ['false'],
},
],
});
result[0].labels = [...result[0].labels, newLabel];
result[0].save();
result = await window.cvat.projects.get({
id: 6,
});
expect(result[0].labels).toHaveLength(labelsLength + 1);
const appendedLabel = result[0].labels.filter((el) => el.name === "My boss's car");
expect(appendedLabel).toHaveLength(1);
expect(appendedLabel[0].attributes).toHaveLength(1);
expect(appendedLabel[0].attributes[0].name).toBe('parked');
expect(appendedLabel[0].attributes[0].defaultValue).toBe('false');
expect(appendedLabel[0].attributes[0].mutable).toBe(true);
expect(appendedLabel[0].attributes[0].inputType).toBe('checkbox');
});
test('save new project without an id', async () => {
const project = new window.cvat.classes.Project({
name: 'New Empty Project',
labels: [
{
name: 'car',
attributes: [
{
default_value: 'false',
input_type: 'checkbox',
mutable: true,
name: 'parked',
values: ['false'],
},
],
},
],
bug_tracker: 'bug tracker value',
});
const result = await project.save();
expect(typeof result.id).toBe('number');
});
});
describe('Feature: delete a project', () => {
test('delete a project', async () => {
let result = await window.cvat.projects.get({
id: 6,
});
await result[0].delete();
result = await window.cvat.projects.get({
id: 6,
});
expect(Array.isArray(result)).toBeTruthy();
expect(result).toHaveLength(0);
});
});

@ -166,6 +166,19 @@ describe('Feature: save a task', () => {
const result = await task.save(); const result = await task.save();
expect(typeof result.id).toBe('number'); expect(typeof result.id).toBe('number');
}); });
test('save new task in project', async () => {
const task = new window.cvat.classes.Task({
name: 'New Task',
project_id: 2,
bug_tracker: 'bug tracker value',
image_quality: 50,
z_order: true,
});
const result = await task.save();
expect(result.projectId).toBe(2);
});
}); });
describe('Feature: delete a task', () => { describe('Feature: delete a task', () => {

@ -143,6 +143,174 @@ const shareDummyData = [
}, },
]; ];
const projectsDummyData = {
count: 2,
next: null,
previous: null,
results: [
{
url: 'http://192.168.0.139:7000/api/v1/projects/6',
id: 6,
name: 'Some empty project',
labels: [],
tasks: [],
owner: {
url: 'http://localhost:7000/api/v1/users/2',
id: 2,
username: 'bsekache',
},
assignee: {
url: 'http://localhost:7000/api/v1/users/2',
id: 2,
username: 'bsekache',
},
bug_tracker: '',
created_date: '2020-10-19T20:41:07.808029Z',
updated_date: '2020-10-19T20:41:07.808084Z',
status: 'annotation',
},
{
url: 'http://192.168.0.139:7000/api/v1/projects/1',
id: 2,
name: 'Test project with roads',
labels: [
{
id: 1,
name: 'car',
color: '#2080c0',
attributes: [
{
id: 199,
name: 'color',
mutable: false,
input_type: 'select',
default_value: 'red',
values: ['red', 'black', 'white', 'yellow', 'pink', 'green', 'blue', 'orange'],
},
],
},
],
tasks: [
{
url: 'http://192.168.0.139:7000/api/v1/tasks/2',
id: 2,
name: 'road 1',
project_id: 1,
mode: 'interpolation',
owner: {
url: 'http://localhost:7000/api/v1/users/1',
id: 1,
username: 'admin',
},
assignee: null,
bug_tracker: '',
created_date: '2020-10-12T08:59:59.878083Z',
updated_date: '2020-10-18T21:02:20.831294Z',
overlap: 5,
segment_size: 100,
z_order: false,
status: 'completed',
labels: [
{
id: 1,
name: 'car',
color: '#2080c0',
attributes: [
{
id: 199,
name: 'color',
mutable: false,
input_type: 'select',
default_value: 'red',
values: ['red', 'black', 'white', 'yellow', 'pink', 'green', 'blue', 'orange'],
},
],
},
],
segments: [
{
start_frame: 0,
stop_frame: 99,
jobs: [
{
url: 'http://192.168.0.139:7000/api/v1/jobs/1',
id: 1,
assignee: null,
status: 'completed',
},
],
},
{
start_frame: 95,
stop_frame: 194,
jobs: [
{
url: 'http://192.168.0.139:7000/api/v1/jobs/2',
id: 2,
assignee: null,
status: 'completed',
},
],
},
{
start_frame: 190,
stop_frame: 289,
jobs: [
{
url: 'http://192.168.0.139:7000/api/v1/jobs/3',
id: 3,
assignee: null,
status: 'completed',
},
],
},
{
start_frame: 285,
stop_frame: 384,
jobs: [
{
url: 'http://192.168.0.139:7000/api/v1/jobs/4',
id: 4,
assignee: null,
status: 'completed',
},
],
},
{
start_frame: 380,
stop_frame: 431,
jobs: [
{
url: 'http://192.168.0.139:7000/api/v1/jobs/5',
id: 5,
assignee: null,
status: 'completed',
},
],
},
],
data_chunk_size: 36,
data_compressed_chunk_type: 'imageset',
data_original_chunk_type: 'video',
size: 432,
image_quality: 100,
data: 1,
},
],
owner: {
url: 'http://localhost:7000/api/v1/users/1',
id: 1,
username: 'admin',
},
assignee: null,
bug_tracker: '',
created_date: '2020-10-12T08:21:56.558898Z',
updated_date: '2020-10-12T08:21:56.558982Z',
status: 'completed',
},
],
};
const tasksDummyData = { const tasksDummyData = {
count: 5, count: 5,
next: null, next: null,
@ -2352,6 +2520,7 @@ const frameMetaDummyData = {
module.exports = { module.exports = {
tasksDummyData, tasksDummyData,
projectsDummyData,
aboutDummyData, aboutDummyData,
shareDummyData, shareDummyData,
usersDummyData, usersDummyData,

@ -4,6 +4,7 @@
const { const {
tasksDummyData, tasksDummyData,
projectsDummyData,
aboutDummyData, aboutDummyData,
formatsDummyData, formatsDummyData,
shareDummyData, shareDummyData,
@ -13,6 +14,22 @@ const {
frameMetaDummyData, frameMetaDummyData,
} = require('./dummy-data.mock'); } = require('./dummy-data.mock');
function QueryStringToJSON(query) {
const pairs = [...new URLSearchParams(query).entries()];
const result = {};
for (const pair of pairs) {
const [key, value] = pair;
if (['id'].includes(key)) {
result[key] = +value;
} else {
result[key] = value;
}
}
return JSON.parse(JSON.stringify(result));
}
class ServerProxy { class ServerProxy {
constructor() { constructor() {
async function about() { async function about() {
@ -55,23 +72,65 @@ class ServerProxy {
return null; return null;
} }
async function getTasks(filter = '') { async function getProjects(filter = '') {
function QueryStringToJSON(query) { const queries = QueryStringToJSON(filter);
const pairs = [...new URLSearchParams(query).entries()]; const result = projectsDummyData.results.filter((x) => {
for (const key in queries) {
const result = {}; if (Object.prototype.hasOwnProperty.call(queries, key)) {
for (const pair of pairs) { // TODO: Particular match for some fields is not checked
const [key, value] = pair; if (queries[key] !== x[key]) {
if (['id'].includes(key)) { return false;
result[key] = +value; }
} else {
result[key] = value;
} }
} }
return JSON.parse(JSON.stringify(result)); return true;
});
return result;
}
async function saveProject(id, projectData) {
const object = projectsDummyData.results.filter((project) => project.id === id)[0];
for (const prop in projectData) {
if (
Object.prototype.hasOwnProperty.call(projectData, prop)
&& Object.prototype.hasOwnProperty.call(object, prop)
) {
object[prop] = projectData[prop];
}
}
}
async function createProject(projectData) {
const id = Math.max(...projectsDummyData.results.map((el) => el.id)) + 1;
projectsDummyData.results.push({
id,
url: `http://localhost:7000/api/v1/projects/${id}`,
name: projectData.name,
owner: 1,
assignee: null,
bug_tracker: projectData.bug_tracker,
created_date: '2019-05-16T13:08:00.621747+03:00',
updated_date: '2019-05-16T13:08:00.621797+03:00',
status: 'annotation',
tasks: [],
labels: JSON.parse(JSON.stringify(projectData.labels)),
});
const createdProject = await getProjects(`?id=${id}`);
return createdProject[0];
}
async function deleteProject(id) {
const projects = projectsDummyData.results;
const project = projects.filter((el) => el.id === id)[0];
if (project) {
projects.splice(projects.indexOf(project), 1);
} }
}
async function getTasks(filter = '') {
// Emulation of a query filter // Emulation of a query filter
const queries = QueryStringToJSON(filter); const queries = QueryStringToJSON(filter);
const result = tasksDummyData.results.filter((x) => { const result = tasksDummyData.results.filter((x) => {
@ -108,6 +167,7 @@ class ServerProxy {
id, id,
url: `http://localhost:7000/api/v1/tasks/${id}`, url: `http://localhost:7000/api/v1/tasks/${id}`,
name: taskData.name, name: taskData.name,
project_id: taskData.project_id || null,
size: 5000, size: 5000,
mode: 'interpolation', mode: 'interpolation',
owner: { owner: {
@ -263,6 +323,16 @@ class ServerProxy {
writable: false, writable: false,
}, },
projects: {
value: Object.freeze({
get: getProjects,
save: saveProject,
create: createProject,
delete: deleteProject,
}),
writable: false,
},
tasks: { tasks: {
value: Object.freeze({ value: Object.freeze({
getTasks, getTasks,

@ -1 +1,2 @@
**/3rdparty/*.js **/3rdparty/*.js
webpack.config.js

@ -0,0 +1 @@
webpack.config.js

@ -1,6 +1,6 @@
{ {
"name": "cvat-ui", "name": "cvat-ui",
"version": "1.10.6", "version": "1.10.7",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
@ -1239,9 +1239,9 @@
} }
}, },
"@types/react-redux": { "@types/react-redux": {
"version": "7.1.9", "version": "7.1.11",
"resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.9.tgz", "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.11.tgz",
"integrity": "sha512-mpC0jqxhP4mhmOl3P4ipRsgTgbNofMRXJb08Ms6gekViLj61v1hOZEKWDCyWsdONr6EjEA6ZHXC446wdywDe0w==", "integrity": "sha512-OjaFlmqy0CRbYKBoaWF84dub3impqnLJUrz4u8PRjDzaa4n1A2cVmjMV81shwXyAD5x767efhA8STFGJz/r1Zg==",
"requires": { "requires": {
"@types/hoist-non-react-statics": "^3.3.0", "@types/hoist-non-react-statics": "^3.3.0",
"@types/react": "*", "@types/react": "*",
@ -25957,6 +25957,11 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==" "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA=="
}, },
"lodash-es": {
"version": "4.17.15",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.15.tgz",
"integrity": "sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ=="
},
"lodash._reinterpolate": { "lodash._reinterpolate": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz", "resolved": "https://registry.npmjs.org/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz",
@ -28864,12 +28869,13 @@
} }
}, },
"react-color": { "react-color": {
"version": "2.18.1", "version": "2.19.3",
"resolved": "https://registry.npmjs.org/react-color/-/react-color-2.18.1.tgz", "resolved": "https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz",
"integrity": "sha512-X5XpyJS6ncplZs74ak0JJoqPi+33Nzpv5RYWWxn17bslih+X7OlgmfpmGC1fNvdkK7/SGWYf1JJdn7D2n5gSuQ==", "integrity": "sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==",
"requires": { "requires": {
"@icons/material": "^0.2.4", "@icons/material": "^0.2.4",
"lodash": "^4.17.11", "lodash": "^4.17.15",
"lodash-es": "^4.17.15",
"material-colors": "^1.2.1", "material-colors": "^1.2.1",
"prop-types": "^15.5.10", "prop-types": "^15.5.10",
"reactcss": "^1.2.0", "reactcss": "^1.2.0",

@ -1,6 +1,6 @@
{ {
"name": "cvat-ui", "name": "cvat-ui",
"version": "1.10.6", "version": "1.10.7",
"description": "CVAT single-page application", "description": "CVAT single-page application",
"main": "src/index.tsx", "main": "src/index.tsx",
"scripts": { "scripts": {
@ -52,7 +52,7 @@
"@types/react": "^16.9.55", "@types/react": "^16.9.55",
"@types/react-color": "^3.0.4", "@types/react-color": "^3.0.4",
"@types/react-dom": "^16.9.9", "@types/react-dom": "^16.9.9",
"@types/react-redux": "^7.1.2", "@types/react-redux": "^7.1.11",
"@types/react-router": "^5.0.5", "@types/react-router": "^5.0.5",
"@types/react-router-dom": "^5.1.6", "@types/react-router-dom": "^5.1.6",
"@types/react-share": "^3.0.3", "@types/react-share": "^3.0.3",
@ -68,7 +68,7 @@
"platform": "^1.3.6", "platform": "^1.3.6",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"react": "^16.14.0", "react": "^16.14.0",
"react-color": "^2.18.1", "react-color": "^2.19.3",
"react-cookie": "^4.0.3", "react-cookie": "^4.0.3",
"react-dom": "^16.14.0", "react-dom": "^16.14.0",
"react-hotkeys": "^2.0.0", "react-hotkeys": "^2.0.0",

@ -2,7 +2,9 @@
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import { AnyAction, Dispatch, ActionCreator, Store } from 'redux'; import {
AnyAction, Dispatch, ActionCreator, Store,
} from 'redux';
import { ThunkAction } from 'utils/redux'; import { ThunkAction } from 'utils/redux';
import { import {
@ -234,7 +236,9 @@ export function switchZLayer(cur: number): AnyAction {
export function fetchAnnotationsAsync(): ThunkAction { export function fetchAnnotationsAsync(): ThunkAction {
return async (dispatch: ActionCreator<Dispatch>): Promise<void> => { return async (dispatch: ActionCreator<Dispatch>): Promise<void> => {
try { try {
const { filters, frame, showAllInterpolationTracks, jobInstance } = receiveAnnotationsParameters(); const {
filters, frame, showAllInterpolationTracks, jobInstance,
} = receiveAnnotationsParameters();
const states = await jobInstance.annotations.get(frame, showAllInterpolationTracks, filters); const states = await jobInstance.annotations.get(frame, showAllInterpolationTracks, filters);
const [minZ, maxZ] = computeZRange(states); const [minZ, maxZ] = computeZRange(states);
@ -926,6 +930,10 @@ export function getJobAsync(tid: number, jid: number, initialFrame: number, init
throw new Error(`Task ${tid} doesn't contain the job ${jid}`); throw new Error(`Task ${tid} doesn't contain the job ${jid}`);
} }
if (!task.labels.length && task.projectId) {
throw new Error(`Project ${task.projectId} does not contain any label`);
}
const frameNumber = Math.max(Math.min(job.stopFrame, initialFrame), job.startFrame); const frameNumber = Math.max(Math.min(job.stopFrame, initialFrame), job.startFrame);
const frameData = await job.frames.get(frameNumber); const frameData = await job.frames.get(frameNumber);
// call first getting of frame data before rendering interface // call first getting of frame data before rendering interface
@ -1077,7 +1085,9 @@ export function splitTrack(enabled: boolean): AnyAction {
export function updateAnnotationsAsync(statesToUpdate: any[]): ThunkAction { export function updateAnnotationsAsync(statesToUpdate: any[]): ThunkAction {
return async (dispatch: ActionCreator<Dispatch>): Promise<void> => { return async (dispatch: ActionCreator<Dispatch>): Promise<void> => {
const { jobInstance, filters, frame, showAllInterpolationTracks } = receiveAnnotationsParameters(); const {
jobInstance, filters, frame, showAllInterpolationTracks,
} = receiveAnnotationsParameters();
try { try {
if (statesToUpdate.some((state: any): boolean => state.updateFlags.zOrder)) { if (statesToUpdate.some((state: any): boolean => state.updateFlags.zOrder)) {

@ -0,0 +1,171 @@
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
import { Dispatch, ActionCreator } from 'redux';
import { ActionUnion, createAction, ThunkAction } from 'utils/redux';
import { ProjectsQuery, CombinedState } from 'reducers/interfaces';
import { getTasksSuccess, updateTaskSuccess } from 'actions/tasks-actions';
import { getCVATStore } from 'cvat-store';
import getCore from 'cvat-core-wrapper';
const cvat = getCore();
export enum ProjectsActionTypes {
UPDATE_PROJECTS_GETTING_QUERY = 'UPDATE_PROJECTS_GETTING_QUERY',
GET_PROJECTS = 'GET_PROJECTS',
GET_PROJECTS_SUCCESS = 'GET_PROJECTS_SUCCESS',
GET_PROJECTS_FAILED = 'GET_PROJECTS_FAILED',
CREATE_PROJECT = 'CREATE_PROJECT',
CREATE_PROJECT_SUCCESS = 'CREATE_PROJECT_SUCCESS',
CREATE_PROJECT_FAILED = 'CREATE_PROJECT_FAILED',
UPDATE_PROJECT = 'UPDATE_PROJECT',
UPDATE_PROJECT_SUCCESS = 'UPDATE_PROJECT_SUCCESS',
UPDATE_PROJECT_FAILED = 'UPDATE_PROJECT_FAILED',
DELETE_PROJECT = 'DELETE_PROJECT',
DELETE_PROJECT_SUCCESS = 'DELETE_PROJECT_SUCCESS',
DELETE_PROJECT_FAILED = 'DELETE_PROJECT_FAILED',
}
// prettier-ignore
const projectActions = {
getProjects: () => createAction(ProjectsActionTypes.GET_PROJECTS),
getProjectsSuccess: (array: any[], count: number) => (
createAction(ProjectsActionTypes.GET_PROJECTS_SUCCESS, { array, count })
),
getProjectsFailed: (error: any) => createAction(ProjectsActionTypes.GET_PROJECTS_FAILED, { error }),
updateProjectsGettingQuery: (query: Partial<ProjectsQuery>) => (
createAction(ProjectsActionTypes.UPDATE_PROJECTS_GETTING_QUERY, { query })
),
createProject: () => createAction(ProjectsActionTypes.CREATE_PROJECT),
createProjectSuccess: (projectId: number) => (
createAction(ProjectsActionTypes.CREATE_PROJECT_SUCCESS, { projectId })
),
createProjectFailed: (error: any) => createAction(ProjectsActionTypes.CREATE_PROJECT_FAILED, { error }),
updateProject: () => createAction(ProjectsActionTypes.UPDATE_PROJECT),
updateProjectSuccess: (project: any) => createAction(ProjectsActionTypes.UPDATE_PROJECT_SUCCESS, { project }),
updateProjectFailed: (project: any, error: any) => (
createAction(ProjectsActionTypes.UPDATE_PROJECT_FAILED, { project, error })
),
deleteProject: (projectId: number) => createAction(ProjectsActionTypes.DELETE_PROJECT, { projectId }),
deleteProjectSuccess: (projectId: number) => (
createAction(ProjectsActionTypes.DELETE_PROJECT_SUCCESS, { projectId })
),
deleteProjectFailed: (projectId: number, error: any) => (
createAction(ProjectsActionTypes.DELETE_PROJECT_FAILED, { projectId, error })
),
};
export type ProjectActions = ActionUnion<typeof projectActions>;
export function getProjectsAsync(query: Partial<ProjectsQuery>): ThunkAction {
return async (dispatch: ActionCreator<Dispatch>): Promise<void> => {
dispatch(projectActions.getProjects());
dispatch(projectActions.updateProjectsGettingQuery(query));
// Clear query object from null fields
const filteredQuery: Partial<ProjectsQuery> = {
page: 1,
...query,
};
for (const key in filteredQuery) {
if (filteredQuery[key] === null || typeof filteredQuery[key] === 'undefined') {
delete filteredQuery[key];
}
}
let result = null;
try {
result = await cvat.projects.get(filteredQuery);
} catch (error) {
dispatch(projectActions.getProjectsFailed(error));
return;
}
const array = Array.from(result);
const tasks: any[] = [];
const taskPreviewPromises: Promise<any>[] = [];
for (const project of array) {
taskPreviewPromises.push(
...(project as any).tasks.map((task: any): string => {
tasks.push(task);
return (task as any).frames.preview().catch(() => '');
}),
);
}
const taskPreviews = await Promise.all(taskPreviewPromises);
dispatch(projectActions.getProjectsSuccess(array, result.count));
const store = getCVATStore();
const state: CombinedState = store.getState();
if (!state.tasks.fetching) {
dispatch(
getTasksSuccess(tasks, taskPreviews, tasks.length, {
page: 1,
assignee: null,
id: null,
mode: null,
name: null,
owner: null,
search: null,
status: null,
}),
);
}
};
}
export function createProjectAsync(data: any): ThunkAction {
return async (dispatch: ActionCreator<Dispatch>): Promise<void> => {
const projectInstance = new cvat.classes.Project(data);
dispatch(projectActions.createProject());
try {
const savedProject = await projectInstance.save();
dispatch(projectActions.createProjectSuccess(savedProject.id));
} catch (error) {
dispatch(projectActions.createProjectFailed(error));
}
};
}
export function updateProjectAsync(projectInstance: any): ThunkAction {
return async (dispatch: ActionCreator<Dispatch>): Promise<void> => {
try {
dispatch(projectActions.updateProject());
await projectInstance.save();
const [project] = await cvat.projects.get({ id: projectInstance.id });
dispatch(projectActions.updateProjectSuccess(project));
project.tasks.forEach((task: any) => {
dispatch(updateTaskSuccess(task));
});
} catch (error) {
let project = null;
try {
[project] = await cvat.projects.get({ id: projectInstance.id });
} catch (fetchError) {
dispatch(projectActions.updateProjectFailed(projectInstance, error));
return;
}
dispatch(projectActions.updateProjectFailed(project, error));
}
};
}
export function deleteProjectAsync(projectInstance: any): ThunkAction {
return async (dispatch: ActionCreator<Dispatch>): Promise<void> => {
dispatch(projectActions.deleteProject(projectInstance.id));
try {
await projectInstance.delete();
dispatch(projectActions.deleteProjectSuccess(projectInstance.id));
} catch (error) {
dispatch(projectActions.deleteProjectFailed(projectInstance.id, error));
}
};
}

@ -46,7 +46,7 @@ function getTasks(): AnyAction {
return action; return action;
} }
function getTasksSuccess(array: any[], previews: string[], count: number, query: TasksQuery): AnyAction { export function getTasksSuccess(array: any[], previews: string[], count: number, query: TasksQuery): AnyAction {
const action = { const action = {
type: TasksActionTypes.GET_TASKS_SUCCESS, type: TasksActionTypes.GET_TASKS_SUCCESS,
payload: { payload: {
@ -93,25 +93,11 @@ export function getTasksAsync(query: TasksQuery): ThunkAction<Promise<void>, {},
} }
const array = Array.from(result); const array = Array.from(result);
const previews = []; const promises = array.map((task): string => (task as any).frames.preview().catch(''));
const promises = array.map((task): string => (task as any).frames.preview());
dispatch(getInferenceStatusAsync()); dispatch(getInferenceStatusAsync());
for (const promise of promises) { dispatch(getTasksSuccess(array, await Promise.all(promises), result.count, query));
try {
// a tricky moment
// await is okay in loop in this case, there aren't any performance bottleneck
// because all server requests have been already sent in parallel
// eslint-disable-next-line no-await-in-loop
previews.push(await promise);
} catch (error) {
previews.push('');
}
}
dispatch(getTasksSuccess(array, previews, result.count, query));
}; };
} }
@ -381,6 +367,9 @@ export function createTaskAsync(data: any): ThunkAction<Promise<void>, {}, {}, A
use_cache: data.advanced.useCache, use_cache: data.advanced.useCache,
}; };
if (data.projectId) {
description.project_id = data.projectId;
}
if (data.advanced.bugTracker) { if (data.advanced.bugTracker) {
description.bug_tracker = data.advanced.bugTracker; description.bug_tracker = data.advanced.bugTracker;
} }
@ -445,7 +434,7 @@ function updateTask(): AnyAction {
return action; return action;
} }
function updateTaskSuccess(task: any): AnyAction { export function updateTaskSuccess(task: any): AnyAction {
const action = { const action = {
type: TasksActionTypes.UPDATE_TASK_SUCCESS, type: TasksActionTypes.UPDATE_TASK_SUCCESS,
payload: { task }, payload: { task },

@ -0,0 +1,160 @@
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React, {
useState, useRef, useEffect, Component,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router';
import { Col, Row } from 'antd/lib/grid';
import Text from 'antd/lib/typography/Text';
import Form, { FormComponentProps, WrappedFormUtils } from 'antd/lib/form/Form';
import Button from 'antd/lib/button';
import Input from 'antd/lib/input';
import notification from 'antd/lib/notification';
import patterns from 'utils/validation-patterns';
import { CombinedState } from 'reducers/interfaces';
import LabelsEditor from 'components/labels-editor/labels-editor';
import { createProjectAsync } from 'actions/projects-actions';
type FormRefType = Component<FormComponentProps<any>, any, any> & WrappedFormUtils;
const ProjectNameEditor = Form.create<FormComponentProps>()(
(props: FormComponentProps): JSX.Element => {
const { form } = props;
const { getFieldDecorator } = form;
return (
<Form onSubmit={(e): void => e.preventDefault()}>
<Form.Item hasFeedback label={<span>Name</span>}>
{getFieldDecorator('name', {
rules: [
{
required: true,
message: 'Please, specify a name',
},
],
})(<Input />)}
</Form.Item>
</Form>
);
},
);
const AdvanvedConfigurationForm = Form.create<FormComponentProps>()(
(props: FormComponentProps): JSX.Element => {
const { form } = props;
const { getFieldDecorator } = form;
return (
<Form onSubmit={(e): void => e.preventDefault()}>
<Form.Item
label={<span>Issue tracker</span>}
extra='Attach issue tracker where the project is described'
hasFeedback
>
{getFieldDecorator('bug_tracker', {
rules: [
{
validator: (_, value, callback): void => {
if (value && !patterns.validateURL.pattern.test(value)) {
callback('Issue tracker must be URL');
} else {
callback();
}
},
},
],
})(<Input />)}
</Form.Item>
</Form>
);
},
);
export default function CreateProjectContent(): JSX.Element {
const [projectLabels, setProjectLabels] = useState<any[]>([]);
const shouldShowNotification = useRef(false);
const nameFormRef = useRef<FormRefType>(null);
const advancedFormRef = useRef<FormRefType>(null);
const dispatch = useDispatch();
const history = useHistory();
const newProjectId = useSelector((state: CombinedState) => state.projects.activities.creates.id);
useEffect(() => {
if (Number.isInteger(newProjectId) && shouldShowNotification.current) {
const btn = <Button onClick={() => history.push(`/projects/${newProjectId}`)}>Open project</Button>;
// Clear new project forms
if (nameFormRef.current) nameFormRef.current.resetFields();
if (advancedFormRef.current) advancedFormRef.current.resetFields();
setProjectLabels([]);
notification.info({
message: 'The project has been created',
btn,
});
}
shouldShowNotification.current = true;
}, [newProjectId]);
const onSumbit = (): void => {
interface Project {
[key: string]: any;
}
const projectData: Project = {};
if (nameFormRef.current !== null) {
nameFormRef.current.validateFields((error, value) => {
if (!error) {
projectData.name = value.name;
}
});
}
if (advancedFormRef.current !== null) {
advancedFormRef.current.validateFields((error, values) => {
if (!error) {
for (const [field, value] of Object.entries(values)) {
projectData[field] = value;
}
}
});
}
projectData.labels = projectLabels;
if (!projectData.name) return;
dispatch(createProjectAsync(projectData));
};
return (
<Row type='flex' justify='start' align='middle' className='cvat-create-project-content'>
<Col span={24}>
<ProjectNameEditor ref={nameFormRef} />
</Col>
<Col span={24}>
<Text className='cvat-text-color'>Labels:</Text>
<LabelsEditor
labels={projectLabels}
onSubmit={(newLabels): void => {
setProjectLabels(newLabels);
}}
/>
</Col>
<Col span={24}>
<AdvanvedConfigurationForm ref={advancedFormRef} />
</Col>
<Col span={24}>
<Button type='primary' onClick={onSumbit}>
Submit
</Button>
</Col>
</Row>
);
}

@ -0,0 +1,21 @@
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
import './styles.scss';
import React from 'react';
import { Row, Col } from 'antd/lib/grid';
import Text from 'antd/lib/typography/Text';
import CreateProjectContent from './create-project-content';
export default function CreateProjectPageComponent(): JSX.Element {
return (
<Row type='flex' justify='center' align='top' className='cvat-create-task-form-wrapper'>
<Col md={20} lg={16} xl={14} xxl={9}>
<Text className='cvat-title'>Create a new project</Text>
<CreateProjectContent />
</Col>
</Row>
);
}

@ -0,0 +1,42 @@
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
@import '../../base.scss';
.cvat-create-project-form-wrapper {
text-align: center;
padding-top: $grid-unit-size * 5;
overflow-y: auto;
height: 90%;
position: fixed;
width: 100%;
> div > span {
font-size: $grid-unit-size * 4;
}
}
.cvat-create-project-content {
margin-top: $grid-unit-size * 2;
width: 100%;
height: auto;
border: 1px solid $border-color-1;
border-radius: 3px;
padding: $grid-unit-size * 2;
background: $background-color-1;
text-align: initial;
> div:not(first-child) {
margin-top: $grid-unit-size;
}
> div:nth-child(4) {
display: flex;
justify-content: flex-end;
> button {
width: $grid-unit-size * 15;
}
}
}

@ -13,12 +13,14 @@ import notification from 'antd/lib/notification';
import Text from 'antd/lib/typography/Text'; import Text from 'antd/lib/typography/Text';
import ConnectedFileManager from 'containers/file-manager/file-manager'; import ConnectedFileManager from 'containers/file-manager/file-manager';
import LabelsEditor from 'components/labels-editor/labels-editor';
import { Files } from 'components/file-manager/file-manager';
import BasicConfigurationForm, { BaseConfiguration } from './basic-configuration-form'; import BasicConfigurationForm, { BaseConfiguration } from './basic-configuration-form';
import ProjectSearchField from './project-search-field';
import AdvancedConfigurationForm, { AdvancedConfiguration } from './advanced-configuration-form'; import AdvancedConfigurationForm, { AdvancedConfiguration } from './advanced-configuration-form';
import LabelsEditor from '../labels-editor/labels-editor';
import { Files } from '../file-manager/file-manager';
export interface CreateTaskData { export interface CreateTaskData {
projectId: number | null;
basic: BaseConfiguration; basic: BaseConfiguration;
advanced: AdvancedConfiguration; advanced: AdvancedConfiguration;
labels: any[]; labels: any[];
@ -29,12 +31,14 @@ interface Props {
onCreate: (data: CreateTaskData) => void; onCreate: (data: CreateTaskData) => void;
status: string; status: string;
taskId: number | null; taskId: number | null;
projectId: number | null;
installedGit: boolean; installedGit: boolean;
} }
type State = CreateTaskData; type State = CreateTaskData;
const defaultState = { const defaultState = {
projectId: null,
basic: { basic: {
name: '', name: '',
}, },
@ -63,6 +67,14 @@ class CreateTaskContent extends React.PureComponent<Props & RouteComponentProps,
this.state = { ...defaultState }; this.state = { ...defaultState };
} }
public componentDidMount(): void {
const { projectId } = this.props;
if (projectId) {
this.handleProjectIdChange(projectId);
}
}
public componentDidUpdate(prevProps: Props): void { public componentDidUpdate(prevProps: Props): void {
const { status, history, taskId } = this.props; const { status, history, taskId } = this.props;
@ -87,9 +99,9 @@ class CreateTaskContent extends React.PureComponent<Props & RouteComponentProps,
} }
} }
private validateLabels = (): boolean => { private validateLabelsOrProject = (): boolean => {
const { labels } = this.state; const { projectId, labels } = this.state;
return !!labels.length; return !!labels.length || !!projectId;
}; };
private validateFiles = (): boolean => { private validateFiles = (): boolean => {
@ -102,6 +114,12 @@ class CreateTaskContent extends React.PureComponent<Props & RouteComponentProps,
return !!totalLen; return !!totalLen;
}; };
private handleProjectIdChange = (value: null | number): void => {
this.setState({
projectId: value,
});
};
private handleSubmitBasicConfiguration = (values: BaseConfiguration): void => { private handleSubmitBasicConfiguration = (values: BaseConfiguration): void => {
this.setState({ this.setState({
basic: { ...values }, basic: { ...values },
@ -115,10 +133,10 @@ class CreateTaskContent extends React.PureComponent<Props & RouteComponentProps,
}; };
private handleSubmitClick = (): void => { private handleSubmitClick = (): void => {
if (!this.validateLabels()) { if (!this.validateLabelsOrProject()) {
notification.error({ notification.error({
message: 'Could not create a task', message: 'Could not create a task',
description: 'A task must contain at least one label', description: 'A task must contain at least one label or belong to some project',
}); });
return; return;
} }
@ -167,8 +185,36 @@ class CreateTaskContent extends React.PureComponent<Props & RouteComponentProps,
); );
} }
private renderProjectBlock(): JSX.Element {
const { projectId } = this.state;
return (
<>
<Col span={24}>
<Text className='cvat-text-color'>Project:</Text>
</Col>
<Col span={24}>
<ProjectSearchField onSelect={this.handleProjectIdChange} value={projectId} />
</Col>
</>
);
}
private renderLabelsBlock(): JSX.Element { private renderLabelsBlock(): JSX.Element {
const { labels } = this.state; const { projectId, labels } = this.state;
if (projectId) {
return (
<>
<Col span={24}>
<Text className='cvat-text-color'>Labels:</Text>
</Col>
<Col span={24}>
<Text type='secondary'>Project labels will be used</Text>
</Col>
</>
);
}
return ( return (
<Col span={24}> <Col span={24}>
@ -231,12 +277,13 @@ class CreateTaskContent extends React.PureComponent<Props & RouteComponentProps,
</Col> </Col>
{this.renderBasicBlock()} {this.renderBasicBlock()}
{this.renderProjectBlock()}
{this.renderLabelsBlock()} {this.renderLabelsBlock()}
{this.renderFilesBlock()} {this.renderFilesBlock()}
{this.renderAdvancedBlock()} {this.renderAdvancedBlock()}
<Col span={18}>{loading ? <Alert message={status} /> : null}</Col> <Col span={18}>{loading ? <Alert message={status} /> : null}</Col>
<Col span={6}> <Col span={6} className='cvat-create-task-submit-section'>
<Button loading={loading} disabled={loading} type='primary' onClick={this.handleSubmitClick}> <Button loading={loading} disabled={loading} type='primary' onClick={this.handleSubmitClick}>
Submit Submit
</Button> </Button>

@ -4,6 +4,7 @@
import './styles.scss'; import './styles.scss';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { useLocation } from 'react-router';
import { Row, Col } from 'antd/lib/grid'; import { Row, Col } from 'antd/lib/grid';
import Modal from 'antd/lib/modal'; import Modal from 'antd/lib/modal';
import Text from 'antd/lib/typography/Text'; import Text from 'antd/lib/typography/Text';
@ -21,7 +22,17 @@ interface Props {
} }
export default function CreateTaskPage(props: Props): JSX.Element { export default function CreateTaskPage(props: Props): JSX.Element {
const { error, status, taskId, onCreate, installedGit } = props; const {
error, status, taskId, onCreate, installedGit,
} = props;
const location = useLocation();
let projectId = null;
const params = new URLSearchParams(location.search);
if (params.get('projectId')?.match(/^[1-9]+[0-9]*$/)) {
projectId = +(params.get('projectId') as string);
}
useEffect(() => { useEffect(() => {
if (error) { if (error) {
@ -61,7 +72,13 @@ export default function CreateTaskPage(props: Props): JSX.Element {
<Row type='flex' justify='center' align='top' className='cvat-create-task-form-wrapper'> <Row type='flex' justify='center' align='top' className='cvat-create-task-form-wrapper'>
<Col md={20} lg={16} xl={14} xxl={9}> <Col md={20} lg={16} xl={14} xxl={9}>
<Text className='cvat-title'>Create a new task</Text> <Text className='cvat-title'>Create a new task</Text>
<CreateTaskContent taskId={taskId} status={status} onCreate={onCreate} installedGit={installedGit} /> <CreateTaskContent
taskId={taskId}
projectId={projectId}
status={status}
onCreate={onCreate}
installedGit={installedGit}
/>
</Col> </Col>
</Row> </Row>
); );

@ -0,0 +1,91 @@
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React, { useEffect, useState } from 'react';
import Autocomplete from 'antd/lib/auto-complete';
import getCore from 'cvat-core-wrapper';
import { SelectValue } from 'antd/lib/select';
const core = getCore();
type Props = {
value: number | null;
onSelect: (id: number | null) => void;
};
type Project = {
id: number;
name: string;
};
export default function ProjectSearchField(props: Props): JSX.Element {
const { value, onSelect } = props;
const [searchPhrase, setSearchPhrase] = useState('');
const [projects, setProjects] = useState<Project[]>([]);
const handleSearch = (searchValue: string): void => {
if (searchValue) {
core.projects.searchNames(searchValue).then((result: Project[]) => {
if (result) {
setProjects(result);
}
});
} else {
setProjects([]);
}
setSearchPhrase(searchValue);
onSelect(null);
};
const handleFocus = (open: boolean): void => {
if (!projects.length && open) {
core.projects.searchNames().then((result: Project[]) => {
if (result) {
setProjects(result);
}
});
}
if (!open && !value && searchPhrase) {
setSearchPhrase('');
}
};
const handleSelect = (_value: SelectValue): void => {
setSearchPhrase(projects.filter((proj) => proj.id === +_value)[0].name);
onSelect(_value ? +_value : null);
};
useEffect(() => {
if (value && !projects.filter((project) => project.id === value).length) {
core.projects.get({ id: value }).then((result: Project[]) => {
const [project] = result;
setProjects([...projects, {
id: project.id,
name: project.name,
}]);
setSearchPhrase(project.name);
onSelect(project.id);
});
}
}, [value]);
return (
<Autocomplete
value={searchPhrase}
placeholder='Select project'
onSearch={handleSearch}
onSelect={handleSelect}
className='cvat-project-search-field'
onDropdownVisibleChange={handleFocus}
dataSource={
projects.map((proj) => ({
value: proj.id.toString(),
text: proj.name,
}))
}
/>
);
}

@ -30,9 +30,13 @@
margin-top: 10px; margin-top: 10px;
} }
> div:nth-child(7) > button { .cvat-create-task-submit-section > button {
float: right; float: right;
width: 120px; width: 120px;
} }
.cvat-project-search-field {
width: 100%;
}
} }
} }

@ -14,14 +14,17 @@ import Header from 'components/header/header';
import ResetPasswordPageConfirmComponent from 'components/reset-password-confirm-page/reset-password-confirm-page'; import ResetPasswordPageConfirmComponent from 'components/reset-password-confirm-page/reset-password-confirm-page';
import ResetPasswordPageComponent from 'components/reset-password-page/reset-password-page'; import ResetPasswordPageComponent from 'components/reset-password-page/reset-password-page';
import ShorcutsDialog from 'components/shortcuts-dialog/shortcuts-dialog'; import ShorcutsDialog from 'components/shortcuts-dialog/shortcuts-dialog';
import ProjectsPageComponent from 'components/projects-page/projects-page';
import CreateProjectPageComponent from 'components/create-project-page/create-project-page';
import ProjectPageComponent from 'components/project-page/project-page';
import TasksPageContainer from 'containers/tasks-page/tasks-page';
import LoginWithTokenComponent from 'components/login-with-token/login-with-token'; import LoginWithTokenComponent from 'components/login-with-token/login-with-token';
import AnnotationPageContainer from 'containers/annotation-page/annotation-page';
import CreateTaskPageContainer from 'containers/create-task-page/create-task-page'; import CreateTaskPageContainer from 'containers/create-task-page/create-task-page';
import LoginPageContainer from 'containers/login-page/login-page'; import TaskPageContainer from 'containers/task-page/task-page';
import ModelsPageContainer from 'containers/models-page/models-page'; import ModelsPageContainer from 'containers/models-page/models-page';
import AnnotationPageContainer from 'containers/annotation-page/annotation-page';
import LoginPageContainer from 'containers/login-page/login-page';
import RegisterPageContainer from 'containers/register-page/register-page'; import RegisterPageContainer from 'containers/register-page/register-page';
import TaskPageContainer from 'containers/task-page/task-page';
import TasksPageContainer from 'containers/tasks-page/tasks-page';
import getCore from 'cvat-core-wrapper'; import getCore from 'cvat-core-wrapper';
import React from 'react'; import React from 'react';
import { configure, ExtendedKeyMapOptions, GlobalHotKeys } from 'react-hotkeys'; import { configure, ExtendedKeyMapOptions, GlobalHotKeys } from 'react-hotkeys';
@ -297,6 +300,9 @@ class CVATApplication extends React.PureComponent<CVATAppProps & RouteComponentP
<ShorcutsDialog /> <ShorcutsDialog />
<GlobalHotKeys keyMap={subKeyMap} handlers={handlers}> <GlobalHotKeys keyMap={subKeyMap} handlers={handlers}>
<Switch> <Switch>
<Route exact path='/projects' component={ProjectsPageComponent} />
<Route exact path='/projects/create' component={CreateProjectPageComponent} />
<Route exact path='/projects/:id' component={ProjectPageComponent} />
<Route exact path='/tasks' component={TasksPageContainer} /> <Route exact path='/tasks' component={TasksPageContainer} />
<Route exact path='/tasks/create' component={CreateTaskPageContainer} /> <Route exact path='/tasks/create' component={CreateTaskPageContainer} />
<Route exact path='/tasks/:id' component={TaskPageContainer} /> <Route exact path='/tasks/:id' component={TaskPageContainer} />

@ -137,7 +137,9 @@ function HeaderContainer(props: Props): JSX.Element {
isModelsPluginActive, isModelsPluginActive,
} = props; } = props;
const { CHANGELOG_URL, LICENSE_URL, GITTER_URL, FORUM_URL, GITHUB_URL } = consts; const {
CHANGELOG_URL, LICENSE_URL, GITTER_URL, FORUM_URL, GITHUB_URL,
} = consts;
const history = useHistory(); const history = useHistory();
@ -238,6 +240,18 @@ function HeaderContainer(props: Props): JSX.Element {
<div className='cvat-left-header'> <div className='cvat-left-header'>
<Icon className='cvat-logo-icon' component={CVATLogo} /> <Icon className='cvat-logo-icon' component={CVATLogo} />
<Button
className='cvat-header-button'
type='link'
value='projects'
href='/projects'
onClick={(event: React.MouseEvent): void => {
event.preventDefault();
history.push('/projects');
}}
>
Projects
</Button>
<Button <Button
className='cvat-header-button' className='cvat-header-button'
type='link' type='link'
@ -288,8 +302,6 @@ function HeaderContainer(props: Props): JSX.Element {
href={GITHUB_URL} href={GITHUB_URL}
onClick={(event: React.MouseEvent): void => { onClick={(event: React.MouseEvent): void => {
event.preventDefault(); event.preventDefault();
// false positive
// eslint-disable-next-line security/detect-non-literal-fs-filename
window.open(GITHUB_URL, '_blank'); window.open(GITHUB_URL, '_blank');
}} }}
> >

@ -124,7 +124,7 @@ class LabelForm extends React.PureComponent<Props, {}> {
message: patterns.validateAttributeName.message, message: patterns.validateAttributeName.message,
}, },
], ],
})(<Input disabled={locked} placeholder='Name' />)} })(<Input className='cvat-attribute-name-input' disabled={locked} placeholder='Name' />)}
</Form.Item> </Form.Item>
</Col> </Col>
); );
@ -142,7 +142,7 @@ class LabelForm extends React.PureComponent<Props, {}> {
{form.getFieldDecorator(`type[${key}]`, { {form.getFieldDecorator(`type[${key}]`, {
initialValue: type, initialValue: type,
})( })(
<Select disabled={locked}> <Select className='cvat-attribute-type-input' disabled={locked}>
<Select.Option value={AttributeType.SELECT}>Select</Select.Option> <Select.Option value={AttributeType.SELECT}>Select</Select.Option>
<Select.Option value={AttributeType.RADIO}>Radio</Select.Option> <Select.Option value={AttributeType.RADIO}>Radio</Select.Option>
<Select.Option value={AttributeType.CHECKBOX}>Checkbox</Select.Option> <Select.Option value={AttributeType.CHECKBOX}>Checkbox</Select.Option>
@ -191,7 +191,14 @@ class LabelForm extends React.PureComponent<Props, {}> {
validator, validator,
}, },
], ],
})(<Select mode='tags' dropdownMenuStyle={{ display: 'none' }} placeholder='Attribute values' />)} })(
<Select
className='cvat-attribute-values-input'
mode='tags'
dropdownMenuStyle={{ display: 'none' }}
placeholder='Attribute values'
/>,
)}
</Form.Item> </Form.Item>
</Tooltip> </Tooltip>
); );
@ -207,7 +214,7 @@ class LabelForm extends React.PureComponent<Props, {}> {
{form.getFieldDecorator(`values[${key}]`, { {form.getFieldDecorator(`values[${key}]`, {
initialValue: value, initialValue: value,
})( })(
<Select> <Select className='cvat-attribute-values-input'>
<Select.Option value='false'> False </Select.Option> <Select.Option value='false'> False </Select.Option>
<Select.Option value='true'> True </Select.Option> <Select.Option value='true'> True </Select.Option>
</Select>, </Select>,
@ -264,7 +271,7 @@ class LabelForm extends React.PureComponent<Props, {}> {
validator, validator,
}, },
], ],
})(<Input disabled={locked} placeholder='min;max;step' />)} })(<Input className='cvat-attribute-values-input' disabled={locked} placeholder='min;max;step' />)}
</Form.Item> </Form.Item>
); );
} }
@ -277,7 +284,7 @@ class LabelForm extends React.PureComponent<Props, {}> {
<Form.Item> <Form.Item>
{form.getFieldDecorator(`values[${key}]`, { {form.getFieldDecorator(`values[${key}]`, {
initialValue: value, initialValue: value,
})(<Input placeholder='Default value' />)} })(<Input className='cvat-attribute-values-input' placeholder='Default value' />)}
</Form.Item> </Form.Item>
); );
} }
@ -293,7 +300,13 @@ class LabelForm extends React.PureComponent<Props, {}> {
{form.getFieldDecorator(`mutable[${key}]`, { {form.getFieldDecorator(`mutable[${key}]`, {
initialValue: value, initialValue: value,
valuePropName: 'checked', valuePropName: 'checked',
})(<Checkbox disabled={locked}> Mutable </Checkbox>)} })(
<Checkbox className='cvat-attribute-mutable-checkbox' disabled={locked}>
{' '}
Mutable
{' '}
</Checkbox>,
)}
</Tooltip> </Tooltip>
</Form.Item> </Form.Item>
); );
@ -326,7 +339,13 @@ class LabelForm extends React.PureComponent<Props, {}> {
return ( return (
<Form.Item key={key}> <Form.Item key={key}>
<Row type='flex' justify='space-between' align='middle'> <Row
type='flex'
justify='space-between'
align='middle'
cvat-attribute-id={key}
className='cvat-attribute-inputs-wrapper'
>
{this.renderAttributeNameInput(key, attr)} {this.renderAttributeNameInput(key, attr)}
{this.renderAttributeTypeInput(key, attr)} {this.renderAttributeTypeInput(key, attr)}
<Col span={6}> <Col span={6}>

@ -0,0 +1,85 @@
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import moment from 'moment';
import { Row, Col } from 'antd/lib/grid';
import Title from 'antd/lib/typography/Title';
import Text from 'antd/lib/typography/Text';
import getCore from 'cvat-core-wrapper';
import { Project } from 'reducers/interfaces';
import { updateProjectAsync } from 'actions/projects-actions';
import LabelsEditor from 'components/labels-editor/labels-editor';
import BugTrackerEditor from 'components/task-page/bug-tracker-editor';
import UserSelector from 'components/task-page/user-selector';
const core = getCore();
interface DetailsComponentProps {
project: Project;
}
export default function DetailsComponent(props: DetailsComponentProps): JSX.Element {
const { project } = props;
const dispatch = useDispatch();
const [projectName, setProjectName] = useState(project.name);
return (
<div className='cvat-project-details'>
<Row>
<Col>
<Title
level={4}
editable={{
onChange: (value: string): void => {
setProjectName(value);
project.name = value;
dispatch(updateProjectAsync(project));
},
}}
className='cvat-text-color'
>
{projectName}
</Title>
</Col>
</Row>
<Row type='flex' justify='space-between'>
<Col>
<Text type='secondary'>
{`Project #${project.id} created`}
{project.owner ? ` by ${project.owner.username}` : null}
{` on ${moment(project.createdDate).format('MMMM Do YYYY')}`}
</Text>
<BugTrackerEditor
instance={project}
onChange={(bugTracker): void => {
project.bugTracker = bugTracker;
dispatch(updateProjectAsync(project));
}}
/>
</Col>
<Col>
<Text type='secondary'>Assigned to</Text>
<UserSelector
value={project.assignee}
onSelect={(user) => {
project.assignee = user;
dispatch(updateProjectAsync(project));
}}
/>
</Col>
</Row>
<LabelsEditor
labels={project.labels.map((label: any): string => label.toJSON())}
onSubmit={(labels: any[]): void => {
project.labels = labels.map((labelData): any => new core.classes.Label(labelData));
dispatch(updateProjectAsync(project));
}}
/>
</div>
);
}

@ -0,0 +1,107 @@
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
import './styles.scss';
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
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';
import Button from 'antd/lib/button';
import Title from 'antd/lib/typography/Title';
import { CombinedState, Task } from 'reducers/interfaces';
import { getProjectsAsync } from 'actions/projects-actions';
import { cancelInferenceAsync } from 'actions/models-actions';
import TaskItem from 'components/tasks-page/task-item';
import DetailsComponent from './details';
import ProjectTopBar from './top-bar';
interface ParamType {
id: string;
}
export default function ProjectPageComponent(): JSX.Element {
const id = +useParams<ParamType>().id;
const dispatch = useDispatch();
const history = useHistory();
const projects = useSelector((state: CombinedState) => state.projects.current);
const projectsFetching = useSelector((state: CombinedState) => state.projects.fetching);
const deletes = useSelector((state: CombinedState) => state.projects.activities.deletes);
const taskDeletes = useSelector((state: CombinedState) => state.tasks.activities.deletes);
const tasksActiveInferences = useSelector((state: CombinedState) => state.models.inferences);
const tasks = useSelector((state: CombinedState) => state.tasks.current);
const filteredProjects = projects.filter((project) => project.id === id);
const project = filteredProjects[0];
const deleteActivity = project && id in deletes ? deletes[id] : null;
useEffect(() => {
dispatch(
getProjectsAsync({
id,
}),
);
}, [id, dispatch]);
if (deleteActivity) {
history.push('/projects');
}
if (projectsFetching) {
return <Spin size='large' className='cvat-spinner' />;
}
if (!project) {
return (
<Result
className='cvat-not-found'
status='404'
title='Sorry, but this project was not found'
subTitle='Please, be sure information you tried to get exist and you have access'
/>
);
}
return (
<Row type='flex' justify='center' align='top' className='cvat-project-page'>
<Col md={22} lg={18} xl={16} xxl={14}>
<ProjectTopBar projectInstance={project} />
<DetailsComponent project={project} />
<Row type='flex' justify='space-between' align='middle' className='cvat-project-page-tasks-bar'>
<Col>
<Title level={4}>Tasks</Title>
</Col>
<Col>
<Button
size='large'
type='primary'
icon='plus'
id='cvat-create-task-button'
onClick={() => history.push(`/tasks/create?projectId=${id}`)}
>
Create new task
</Button>
</Col>
</Row>
{tasks
.filter((task) => task.instance.projectId === project.id)
.map((task: Task) => (
<TaskItem
key={task.instance.id}
deleted={task.instance.id in taskDeletes ? taskDeletes[task.instance.id] : false}
hidden={false}
activeInference={tasksActiveInferences[task.instance.id] || null}
cancelAutoAnnotation={() => {
dispatch(cancelInferenceAsync(task.instance.id));
}}
previewImage={task.preview}
taskInstance={task.instance}
/>
))}
</Col>
</Row>
);
}

@ -0,0 +1,56 @@
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
@import '../../base.scss';
.cvat-project-details {
width: 100%;
height: auto;
border: 1px solid $border-color-1;
border-radius: 3px;
padding: $grid-unit-size * 2;
margin: $grid-unit-size * 2 0;
background: $background-color-1;
.ant-row-flex:nth-child(1) {
margin-bottom: $grid-unit-size * 2;
}
.ant-row-flex:nth-child(2) .ant-col:nth-child(2) > span {
margin-right: $grid-unit-size;
}
.cvat-project-details-actions {
display: flex;
align-items: center;
justify-content: flex-end;
}
.cvat-issue-tracker {
margin-top: $grid-unit-size * 2;
margin-bottom: $grid-unit-size * 2;
}
}
.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);
> li:hover {
background-color: $hover-menu-color;
}
.ant-menu-submenu-title {
margin: 0;
width: 13em;
}
}
.cvat-project-top-bar-actions > button {
display: flex;
align-items: center;
}

@ -0,0 +1,44 @@
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React from 'react';
import { useHistory } from 'react-router';
import { Row, Col } from 'antd/lib/grid';
import Button from 'antd/lib/button';
import Dropdown from 'antd/lib/dropdown';
import Icon from 'antd/lib/icon';
import Text from 'antd/lib/typography/Text';
import { Project } from 'reducers/interfaces';
import ActionsMenu from 'components/projects-page/actions-menu';
import { MenuIcon } from 'icons';
interface DetailsComponentProps {
projectInstance: Project;
}
export default function ProjectTopBar(props: DetailsComponentProps): JSX.Element {
const { projectInstance } = props;
const history = useHistory();
return (
<Row className='cvat-task-top-bar' type='flex' justify='space-between' align='middle'>
<Col>
<Button onClick={() => history.push('/projects')} type='link' size='large'>
<Icon type='left' />
Back to projects
</Button>
</Col>
<Col className='cvat-project-top-bar-actions'>
<Dropdown overlay={<ActionsMenu projectInstance={projectInstance.instance} />}>
<Button size='large'>
<Text className='cvat-text-color'>Actions</Text>
<Icon className='cvat-menu-icon' component={MenuIcon} />
</Button>
</Dropdown>
</Col>
</Row>
);
}

@ -0,0 +1,40 @@
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React from 'react';
import { useDispatch } from 'react-redux';
import Modal from 'antd/lib/modal';
import Menu from 'antd/lib/menu';
import { deleteProjectAsync } from 'actions/projects-actions';
interface Props {
projectInstance: any;
}
export default function ProjectActionsMenuComponent(props: Props): JSX.Element {
const { projectInstance } = props;
const dispatch = useDispatch();
const onDeleteProject = (): void => {
Modal.confirm({
title: `The project ${projectInstance.id} will be deleted`,
content: 'All related data (images, annotations) will be lost. Continue?',
onOk: () => {
dispatch(deleteProjectAsync(projectInstance));
},
okButtonProps: {
type: 'danger',
},
okText: 'Delete',
});
};
return (
<Menu className='cvat-project-actions-menu'>
<Menu.Item onClick={onDeleteProject}>Delete</Menu.Item>
</Menu>
);
}

@ -0,0 +1,53 @@
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
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 'antd/lib/icon';
import { EmptyTasksIcon } from 'icons';
interface Props {
notFound?: boolean;
}
export default function EmptyListComponent(props: Props): JSX.Element {
const { notFound } = props;
return (
<div className='cvat-empty-projects-list'>
<Row type='flex' justify='center' align='middle'>
<Col>
<Icon className='cvat-empty-projects-icon' component={EmptyTasksIcon} />
</Col>
</Row>
{notFound ? (
<Row type='flex' justify='center' align='middle'>
<Col>
<Text strong>No results matched your search...</Text>
</Col>
</Row>
) : (
<>
<Row type='flex' justify='center' align='middle'>
<Col>
<Text strong>No projects created yet ...</Text>
</Col>
</Row>
<Row type='flex' justify='center' align='middle'>
<Col>
<Text type='secondary'>To get started with your annotation project</Text>
</Col>
</Row>
<Row type='flex' justify='center' align='middle'>
<Col>
<Link to='/projects/create'>create a new one</Link>
</Col>
</Row>
</>
)}
</div>
);
}

@ -0,0 +1,99 @@
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React from 'react';
import moment from 'moment';
import { useSelector } from 'react-redux';
import { useHistory } from 'react-router';
import Text from 'antd/lib/typography/Text';
import Empty from 'antd/lib/empty';
import Card from 'antd/lib/card';
import Meta from 'antd/lib/card/Meta';
import Dropdown from 'antd/lib/dropdown';
import Button from 'antd/lib/button';
import { CombinedState, Project } from 'reducers/interfaces';
import ProjectActionsMenuComponent from './actions-menu';
interface Props {
projectInstance: Project;
}
export default function ProjectItemComponent(props: Props): JSX.Element {
const { projectInstance } = props;
const history = useHistory();
const ownerName = projectInstance.owner ? projectInstance.owner.username : null;
const updated = moment(projectInstance.updatedDate).fromNow();
const deletes = useSelector((state: CombinedState) => state.projects.activities.deletes);
const deleted = projectInstance.id in deletes ? deletes[projectInstance.id] : false;
let projectPreview = null;
if (projectInstance.tasks.length) {
// prettier-ignore
projectPreview = useSelector((state: CombinedState) => (
state.tasks.current.find((task) => task.instance.id === projectInstance.tasks[0].id)?.preview
));
}
const onOpenProject = (): void => {
history.push(`/projects/${projectInstance.id}`);
};
const style: React.CSSProperties = {};
if (deleted) {
style.pointerEvents = 'none';
style.opacity = 0.5;
}
return (
<Card
cover={
projectPreview ? (
<img
className='cvat-projects-project-item-card-preview'
src={projectPreview}
alt='Preview'
onClick={onOpenProject}
aria-hidden
/>
) : (
<div className='cvat-projects-project-item-card-preview' onClick={onOpenProject} aria-hidden>
<Empty description='No tasks' />
</div>
)
}
size='small'
style={style}
className='cvat-projects-project-item-card'
>
<Meta
title={(
<span onClick={onOpenProject} className='cvat-projects-project-item-title' aria-hidden>
{projectInstance.name}
</span>
)}
description={(
<div className='cvat-porjects-project-item-description'>
<div>
{ownerName && (
<>
<Text type='secondary'>{`Created ${ownerName ? `by ${ownerName}` : ''}`}</Text>
<br />
</>
)}
<Text type='secondary'>{`Last updated ${updated}`}</Text>
</div>
<div>
<Dropdown overlay={<ProjectActionsMenuComponent projectInstance={projectInstance} />}>
<Button type='link' size='large' icon='more' />
</Dropdown>
</div>
</div>
)}
/>
</Card>
);
}

@ -0,0 +1,59 @@
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { Row, Col } from 'antd/lib/grid';
import Pagination from 'antd/lib/pagination';
import { getProjectsAsync } from 'actions/projects-actions';
import { CombinedState } from 'reducers/interfaces';
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 projectInstances = useSelector((state: CombinedState) => state.projects.current);
const gettingQuery = useSelector((state: CombinedState) => state.projects.gettingQuery);
function changePage(p: number): void {
dispatch(
getProjectsAsync({
...gettingQuery,
page: p,
}),
);
}
return (
<>
<Row type='flex' justify='center' align='middle'>
<Col className='cvat-projects-list' md={22} lg={18} xl={16} xxl={14}>
<Row gutter={[8, 8]}>
{projectInstances.map(
(instance: any): JSX.Element => (
<Col xs={8} sm={8} xl={6} key={instance.id}>
<ProjectItem projectInstance={instance} />
</Col>
),
)}
</Row>
</Col>
</Row>
<Row type='flex' justify='center' align='middle'>
<Col md={22} lg={18} xl={16} xxl={14}>
<Pagination
className='cvat-projects-pagination'
onChange={changePage}
total={projectsCount}
pageSize={12}
current={page}
showQuickJumper
/>
</Col>
</Row>
</>
);
}

@ -0,0 +1,67 @@
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
import './styles.scss';
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useLocation, useHistory } from 'react-router';
import Spin from 'antd/lib/spin';
import FeedbackComponent from 'components/feedback/feedback';
import { CombinedState, ProjectsQuery } from 'reducers/interfaces';
import { getProjectsAsync } from 'actions/projects-actions';
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 anySearchQuery = !!Array.from(new URLSearchParams(search).keys()).filter((value) => value !== 'page').length;
useEffect(() => {
const searchParams: Partial<ProjectsQuery> = {};
for (const [param, value] of new URLSearchParams(search)) {
searchParams[param] = ['page', 'id'].includes(param) ? Number.parseInt(value, 10) : value;
}
dispatch(getProjectsAsync(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]);
if (projectFetching) {
return (
<Spin size='large' className='cvat-spinner' />
);
}
return (
<div className='cvat-projects-page'>
<TopBarComponent />
{ projectsCount
? (
<ProjectListComponent />
) : (
<EmptyListComponent notFound={anySearchQuery} />
)}
<FeedbackComponent />
</div>
);
}

@ -0,0 +1,81 @@
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
import './styles.scss';
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Search from 'antd/lib/input/Search';
import { CombinedState, ProjectsQuery } from 'reducers/interfaces';
import { getProjectsAsync } from 'actions/projects-actions';
function getSearchField(gettingQuery: ProjectsQuery): string {
let searchString = '';
for (const field of Object.keys(gettingQuery)) {
if (gettingQuery[field] !== null && field !== 'page') {
if (field === 'search') {
return (gettingQuery[field] as any) as string;
}
// not constant condition
// eslint-disable-next-line
if (typeof (gettingQuery[field] === 'number')) {
searchString += `${field}:${gettingQuery[field]} AND `;
} else {
searchString += `${field}:"${gettingQuery[field]}" AND `;
}
}
}
return searchString.slice(0, -5);
}
export default function ProjectSearchField(): JSX.Element {
const dispatch = useDispatch();
const gettingQuery = useSelector((state: CombinedState) => state.projects.gettingQuery);
const handleSearch = (value: string): void => {
const query = { ...gettingQuery };
const search = value.replace(/\s+/g, ' ').replace(/\s*:+\s*/g, ':').trim();
const fields = Object.keys(query).filter((key) => key !== 'page');
for (const field of fields) {
query[field] = null;
}
query.search = null;
let specificRequest = false;
for (const param of search.split(/[\s]+and[\s]+|[\s]+AND[\s]+/)) {
if (param.includes(':')) {
const [field, fieldValue] = param.split(':');
if (fields.includes(field) && !!fieldValue) {
specificRequest = true;
if (field === 'id') {
if (Number.isInteger(+fieldValue)) {
query[field] = +fieldValue;
}
} else {
query[field] = fieldValue;
}
}
}
}
query.page = 1;
if (!specificRequest && value) {
query.search = value;
}
dispatch(getProjectsAsync(query));
};
return (
<Search
defaultValue={getSearchField(gettingQuery)}
onSearch={handleSearch}
size='large'
placeholder='Search'
/>
);
}

@ -0,0 +1,119 @@
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
@import '../../base.scss';
.cvat-projects-page {
padding-top: $grid-unit-size * 2;
padding-bottom: $grid-unit-size * 5;
height: 100%;
position: fixed;
width: 100%;
> div:nth-child(1) {
padding-bottom: $grid-unit-size;
div > {
span {
color: $text-color;
}
}
}
}
/* empty-projects icon */
.cvat-empty-projects-list {
> div:nth-child(1) {
margin-top: $grid-unit-size * 6;
}
> div:nth-child(2) {
> div {
margin-top: $grid-unit-size * 3;
/* No projects created yet */
> span {
font-size: 20px;
color: $text-color;
}
}
}
/* To get started with your annotation project .. */
> div:nth-child(3) {
margin-top: $grid-unit-size;
}
}
.cvat-projects-top-bar {
> div:nth-child(1) {
display: flex;
> span:nth-child(2) {
width: $grid-unit-size * 25;
margin-left: $grid-unit-size;
}
}
> div:nth-child(2) {
display: flex;
justify-content: flex-end;
}
}
.cvat-create-project-button {
padding: 0 $grid-unit-size * 4;
}
.cvat-projects-pagination {
display: flex;
justify-content: center;
}
.cvat-projects-project-item-title,
.cvat-projects-project-item-card-preview {
cursor: pointer;
}
.cvat-porjects-project-item-description {
display: flex;
justify-content: space-between;
// actions button
> div:nth-child(2) {
display: flex;
align-self: flex-end;
justify-content: center;
> button {
color: $text-color;
width: inherit;
}
}
}
.ant-menu.cvat-project-actions-menu {
box-shadow: 0 0 17px rgba(0, 0, 0, 0.2);
> li:hover {
background-color: $hover-menu-color;
}
.ant-menu-submenu-title {
margin: 0;
width: 13em;
}
}
.cvat-projects-project-item-card {
.ant-empty {
margin: $grid-unit-size;
}
img {
height: 100%;
max-height: $grid-unit-size * 18;
object-fit: cover;
}
}

@ -0,0 +1,36 @@
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React 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 SearchField from './search-field';
export default function TopBarComponent(): JSX.Element {
const history = useHistory();
return (
<Row type='flex' justify='center' align='middle' className='cvat-projects-top-bar'>
<Col md={11} lg={9} xl={8} xxl={7}>
<Text className='cvat-title'>Projects</Text>
<SearchField />
</Col>
<Col md={{ span: 11 }} lg={{ span: 9 }} xl={{ span: 8 }} xxl={{ span: 7 }}>
<Button
size='large'
id='cvat-create-project-button'
className='cvat-create-project-button'
type='primary'
onClick={(): void => history.push('/projects/create')}
icon='plus'
>
Create new project
</Button>
</Col>
</Row>
);
}

@ -21,8 +21,7 @@ export interface ResetPasswordConfirmData {
type ResetPasswordConfirmFormProps = { type ResetPasswordConfirmFormProps = {
fetching: boolean; fetching: boolean;
onSubmit(resetPasswordConfirmData: ResetPasswordConfirmData): void; onSubmit(resetPasswordConfirmData: ResetPasswordConfirmData): void;
} & FormComponentProps & } & FormComponentProps & RouteComponentProps;
RouteComponentProps;
class ResetPasswordConfirmFormComponent extends React.PureComponent<ResetPasswordConfirmFormProps> { class ResetPasswordConfirmFormComponent extends React.PureComponent<ResetPasswordConfirmFormProps> {
private validateConfirmation = (_: any, value: string, callback: Function): void => { private validateConfirmation = (_: any, value: string, callback: Function): void => {

@ -0,0 +1,92 @@
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React, { useState } from 'react';
import Button from 'antd/lib/button';
import Modal from 'antd/lib/modal';
import Text from 'antd/lib/typography/Text';
import { Row, Col } from 'antd/lib/grid';
import patterns from 'utils/validation-patterns';
interface Props {
instance: any;
onChange: (bugTracker: string) => void;
}
export default function BugTrackerEditorComponent(props: Props): JSX.Element {
const { instance, onChange } = props;
const [bugTracker, setBugTracker] = useState(instance.bugTracker);
const [bugTrackerEditing, setBugTrackerEditing] = useState(false);
const instanceType = Array.isArray(instance.tasks) ? 'project' : 'task';
let shown = false;
const onStart = (): void => setBugTrackerEditing(true);
const onChangeValue = (value: string): void => {
if (value && !patterns.validateURL.pattern.test(value)) {
if (!shown) {
Modal.error({
title: `Could not update the ${instanceType} ${instance.id}`,
content: 'Issue tracker is expected to be URL',
onOk: () => {
shown = false;
},
});
shown = true;
}
} else {
setBugTracker(value);
setBugTrackerEditing(false);
onChange(value);
}
};
if (bugTracker) {
return (
<Row className='cvat-issue-tracker'>
<Col>
<Text strong className='cvat-text-color'>
Issue Tracker
</Text>
<br />
<Text editable={{ onChange: onChangeValue }}>{bugTracker}</Text>
<Button
type='ghost'
size='small'
onClick={(): void => {
// false positive
// eslint-disable-next-line
window.open(bugTracker, '_blank');
}}
className='cvat-open-bug-tracker-button'
>
Open the issue
</Button>
</Col>
</Row>
);
}
return (
<Row className='cvat-issue-tracker'>
<Col>
<Text strong className='cvat-text-color'>
Issue Tracker
</Text>
<br />
<Text
editable={{
editing: bugTrackerEditing,
onStart,
onChange: onChangeValue,
}}
>
{bugTrackerEditing ? '' : 'Not specified'}
</Text>
</Col>
</Row>
);
}

@ -7,18 +7,17 @@ import { Row, Col } from 'antd/lib/grid';
import Tag from 'antd/lib/tag'; import Tag from 'antd/lib/tag';
import Icon from 'antd/lib/icon'; import Icon from 'antd/lib/icon';
import Modal from 'antd/lib/modal'; import Modal from 'antd/lib/modal';
import Button from 'antd/lib/button';
import notification from 'antd/lib/notification'; import notification from 'antd/lib/notification';
import Text from 'antd/lib/typography/Text'; import Text from 'antd/lib/typography/Text';
import Title from 'antd/lib/typography/Title'; import Title from 'antd/lib/typography/Title';
import moment from 'moment'; import moment from 'moment';
import getCore from 'cvat-core-wrapper'; import getCore from 'cvat-core-wrapper';
import patterns from 'utils/validation-patterns';
import { getReposData, syncRepos } from 'utils/git-utils'; import { getReposData, syncRepos } from 'utils/git-utils';
import { ActiveInference } from 'reducers/interfaces'; import { ActiveInference } from 'reducers/interfaces';
import AutomaticAnnotationProgress from 'components/tasks-page/automatic-annotation-progress'; import AutomaticAnnotationProgress from 'components/tasks-page/automatic-annotation-progress';
import UserSelector, { User } from './user-selector'; import UserSelector, { User } from './user-selector';
import BugTrackerEditor from './bug-tracker-editor';
import LabelsEditorComponent from '../labels-editor/labels-editor'; import LabelsEditorComponent from '../labels-editor/labels-editor';
const core = getCore(); const core = getCore();
@ -34,8 +33,6 @@ interface Props {
interface State { interface State {
name: string; name: string;
bugTracker: string;
bugTrackerEditing: boolean;
repository: string; repository: string;
repositoryStatus: string; repositoryStatus: string;
} }
@ -57,8 +54,6 @@ export default class DetailsComponent extends React.PureComponent<Props, State>
this.previewWrapperRef = React.createRef<HTMLDivElement>(); this.previewWrapperRef = React.createRef<HTMLDivElement>();
this.state = { this.state = {
name: taskInstance.name, name: taskInstance.name,
bugTracker: taskInstance.bugTracker,
bugTrackerEditing: false,
repository: '', repository: '',
repositoryStatus: '', repositoryStatus: '',
}; };
@ -119,7 +114,6 @@ export default class DetailsComponent extends React.PureComponent<Props, State>
if (prevProps !== this.props) { if (prevProps !== this.props) {
this.setState({ this.setState({
name: taskInstance.name, name: taskInstance.name,
bugTracker: taskInstance.bugTracker,
}); });
} }
} }
@ -194,7 +188,7 @@ export default class DetailsComponent extends React.PureComponent<Props, State>
); );
} }
private renderUsers(): JSX.Element { private renderDescription(): JSX.Element {
const { taskInstance, onTaskUpdate } = this.props; const { taskInstance, onTaskUpdate } = this.props;
const owner = taskInstance.owner ? taskInstance.owner.username : null; const owner = taskInstance.owner ? taskInstance.owner.username : null;
const assignee = taskInstance.assignee ? taskInstance.assignee : null; const assignee = taskInstance.assignee ? taskInstance.assignee : null;
@ -211,7 +205,11 @@ export default class DetailsComponent extends React.PureComponent<Props, State>
return ( return (
<Row className='cvat-task-details-user-block' type='flex' justify='space-between' align='middle'> <Row className='cvat-task-details-user-block' type='flex' justify='space-between' align='middle'>
<Col span={12}>{owner && <Text type='secondary'>{`Created by ${owner} on ${created}`}</Text>}</Col> <Col span={12}>
{owner && (
<Text type='secondary'>{`Task #${taskInstance.id} Created by ${owner} on ${created}`}</Text>
)}
</Col>
<Col span={10}> <Col span={10}>
<Text type='secondary'>Assigned to</Text> <Text type='secondary'>Assigned to</Text>
{assigneeSelect} {assigneeSelect}
@ -294,86 +292,6 @@ export default class DetailsComponent extends React.PureComponent<Props, State>
); );
} }
private renderBugTracker(): JSX.Element {
const { taskInstance, onTaskUpdate } = this.props;
const { bugTracker, bugTrackerEditing } = this.state;
let shown = false;
const onStart = (): void => {
this.setState({
bugTrackerEditing: true,
});
};
const onChangeValue = (value: string): void => {
if (value && !patterns.validateURL.pattern.test(value)) {
if (!shown) {
Modal.error({
title: `Could not update the task ${taskInstance.id}`,
content: 'Issue tracker is expected to be URL',
onOk: () => {
shown = false;
},
});
shown = true;
}
} else {
this.setState({
bugTracker: value,
bugTrackerEditing: false,
});
taskInstance.bugTracker = value;
onTaskUpdate(taskInstance);
}
};
if (bugTracker) {
return (
<Row>
<Col>
<Text strong className='cvat-text-color'>
Issue Tracker
</Text>
<br />
<Text editable={{ onChange: onChangeValue }}>{bugTracker}</Text>
<Button
type='ghost'
size='small'
onClick={(): void => {
// false positive
// eslint-disable-next-line
window.open(bugTracker, '_blank');
}}
className='cvat-open-bug-tracker-button'
>
Open the issue
</Button>
</Col>
</Row>
);
}
return (
<Row>
<Col>
<Text strong className='cvat-text-color'>
Issue Tracker
</Text>
<br />
<Text
editable={{
editing: bugTrackerEditing,
onStart,
onChange: onChangeValue,
}}
>
{bugTrackerEditing ? '' : 'Not specified'}
</Text>
</Col>
</Row>
);
}
private renderLabelsEditor(): JSX.Element { private renderLabelsEditor(): JSX.Element {
const { taskInstance, onTaskUpdate } = this.props; const { taskInstance, onTaskUpdate } = this.props;
@ -393,7 +311,10 @@ export default class DetailsComponent extends React.PureComponent<Props, State>
} }
public render(): JSX.Element { public render(): JSX.Element {
const { activeInference, cancelAutoAnnotation } = this.props; const {
activeInference, cancelAutoAnnotation, taskInstance, onTaskUpdate,
} = this.props;
return ( return (
<div className='cvat-task-details'> <div className='cvat-task-details'>
<Row type='flex' justify='start' align='middle'> <Row type='flex' justify='start' align='middle'>
@ -409,9 +330,17 @@ export default class DetailsComponent extends React.PureComponent<Props, State>
</Row> </Row>
</Col> </Col>
<Col md={16} lg={17} xl={17} xxl={18}> <Col md={16} lg={17} xl={17} xxl={18}>
{this.renderUsers()} {this.renderDescription()}
<Row type='flex' justify='space-between' align='middle'> <Row type='flex' justify='space-between' align='middle'>
<Col span={12}>{this.renderBugTracker()}</Col> <Col span={12}>
<BugTrackerEditor
instance={taskInstance}
onChange={(bugTracker) => {
taskInstance.bugTracker = bugTracker;
onTaskUpdate(taskInstance);
}}
/>
</Col>
<Col span={10}> <Col span={10}>
<AutomaticAnnotationProgress <AutomaticAnnotationProgress
activeInference={activeInference} activeInference={activeInference}
@ -420,7 +349,7 @@ export default class DetailsComponent extends React.PureComponent<Props, State>
</Col> </Col>
</Row> </Row>
{this.renderDatasetRepository()} {this.renderDatasetRepository()}
{this.renderLabelsEditor()} {!taskInstance.projectId && this.renderLabelsEditor()}
</Col> </Col>
</Row> </Row>
</div> </div>

@ -78,8 +78,7 @@
} }
.cvat-task-top-bar { .cvat-task-top-bar {
margin-top: 20px; margin: $grid-unit-size * 2 0;
margin-bottom: 10px;
} }
.cvat-task-preview-wrapper { .cvat-task-preview-wrapper {

@ -3,6 +3,7 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import React from 'react'; import React from 'react';
import { useHistory } from 'react-router';
import { Row, Col } from 'antd/lib/grid'; import { Row, Col } from 'antd/lib/grid';
import Button from 'antd/lib/button'; import Button from 'antd/lib/button';
import Dropdown from 'antd/lib/dropdown'; import Dropdown from 'antd/lib/dropdown';
@ -18,12 +19,27 @@ interface DetailsComponentProps {
export default function DetailsComponent(props: DetailsComponentProps): JSX.Element { export default function DetailsComponent(props: DetailsComponentProps): JSX.Element {
const { taskInstance } = props; const { taskInstance } = props;
const { id } = taskInstance;
const history = useHistory();
return ( return (
<Row className='cvat-task-top-bar' type='flex' justify='space-between' align='middle'> <Row className='cvat-task-top-bar' type='flex' justify='space-between' align='middle'>
<Col> <Col>
<Text className='cvat-title'>{`Task details #${id}`}</Text> {taskInstance.projectId ? (
<Button
onClick={() => history.push(`/projects/${taskInstance.projectId}`)}
type='link'
size='large'
>
<Icon type='left' />
Back to project
</Button>
) : (
<Button onClick={() => history.push('/tasks')} type='link' size='large'>
<Icon type='left' />
Back to tasks
</Button>
)}
</Col> </Col>
<Col> <Col>
<Dropdown overlay={<ActionsMenuContainer taskInstance={taskInstance} />}> <Dropdown overlay={<ActionsMenuContainer taskInstance={taskInstance} />}>

@ -31,6 +31,8 @@ export default function EmptyListComponent(): JSX.Element {
<Row type='flex' justify='center' align='middle'> <Row type='flex' justify='center' align='middle'>
<Col> <Col>
<Link to='/tasks/create'>create a new task</Link> <Link to='/tasks/create'>create a new task</Link>
<Text type='secondary'> or try to </Text>
<Link to='/projects/create'>create a new project</Link>
</Col> </Col>
</Row> </Row>
</div> </div>

@ -11,26 +11,16 @@
height: 100%; height: 100%;
width: 100%; width: 100%;
> div:nth-child(1) { > div:nth-child(2) {
padding-bottom: 10px;
div > {
span {
color: $text-color;
}
}
}
> div:nth-child(3) {
height: 83%; height: 83%;
padding-top: 10px; padding-top: 10px;
} }
> div:nth-child(4) { > div:nth-child(3) {
padding-top: 10px; padding-top: 10px;
} }
> div:nth-child(2) { > div:nth-child(1) {
> div:nth-child(1) { > div:nth-child(1) {
display: flex; display: flex;

@ -3,8 +3,7 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import React from 'react'; import React from 'react';
import { RouteComponentProps } from 'react-router'; import { useHistory } from 'react-router';
import { withRouter } from 'react-router-dom';
import { Row, Col } from 'antd/lib/grid'; import { Row, Col } from 'antd/lib/grid';
import Button from 'antd/lib/button'; import Button from 'antd/lib/button';
import Input from 'antd/lib/input'; import Input from 'antd/lib/input';
@ -15,16 +14,13 @@ interface VisibleTopBarProps {
searchValue: string; searchValue: string;
} }
function TopBarComponent(props: VisibleTopBarProps & RouteComponentProps): JSX.Element { export default function TopBarComponent(props: VisibleTopBarProps): JSX.Element {
const { searchValue, history, onSearch } = props; const { searchValue, onSearch } = props;
const history = useHistory();
return ( return (
<> <>
<Row type='flex' justify='center' align='middle'>
<Col md={22} lg={18} xl={16} xxl={14}>
<Text strong>Default project</Text>
</Col>
</Row>
<Row type='flex' justify='center' align='middle'> <Row type='flex' justify='center' align='middle'>
<Col md={11} lg={9} xl={8} xxl={7}> <Col md={11} lg={9} xl={8} xxl={7}>
<Text className='cvat-title'>Tasks</Text> <Text className='cvat-title'>Tasks</Text>
@ -45,5 +41,3 @@ function TopBarComponent(props: VisibleTopBarProps & RouteComponentProps): JSX.E
</> </>
); );
} }
export default withRouter(TopBarComponent);

@ -21,6 +21,35 @@ export interface AuthState {
allowResetPassword: boolean; allowResetPassword: boolean;
} }
export interface ProjectsQuery {
page: number;
id: number | null;
search: string | null;
owner: string | null;
name: string | null;
status: string | null;
[key: string]: string | number | null | undefined;
}
export type Project = any;
export interface ProjectsState {
initialized: boolean;
fetching: boolean;
count: number;
current: Project[];
gettingQuery: ProjectsQuery;
activities: {
creates: {
id: null | number;
error: string;
};
deletes: {
[projectId: number]: boolean; // deleted (deleting if in dictionary)
};
};
}
export interface TasksQuery { export interface TasksQuery {
page: number; page: number;
id: number | null; id: number | null;
@ -192,6 +221,12 @@ export interface NotificationsState {
resetPassword: null | ErrorState; resetPassword: null | ErrorState;
loadAuthActions: null | ErrorState; loadAuthActions: null | ErrorState;
}; };
projects: {
fetching: null | ErrorState;
updating: null | ErrorState;
deleting: null | ErrorState;
creating: null | ErrorState;
};
tasks: { tasks: {
fetching: null | ErrorState; fetching: null | ErrorState;
updating: null | ErrorState; updating: null | ErrorState;
@ -487,6 +522,7 @@ export interface MetaState {
export interface CombinedState { export interface CombinedState {
auth: AuthState; auth: AuthState;
projects: ProjectsState;
tasks: TasksState; tasks: TasksState;
about: AboutState; about: AboutState;
share: ShareState; share: ShareState;

@ -9,6 +9,7 @@ import { FormatsActionTypes } from 'actions/formats-actions';
import { ModelsActionTypes } from 'actions/models-actions'; import { ModelsActionTypes } from 'actions/models-actions';
import { ShareActionTypes } from 'actions/share-actions'; import { ShareActionTypes } from 'actions/share-actions';
import { TasksActionTypes } from 'actions/tasks-actions'; import { TasksActionTypes } from 'actions/tasks-actions';
import { ProjectsActionTypes } from 'actions/projects-actions';
import { AboutActionTypes } from 'actions/about-actions'; import { AboutActionTypes } from 'actions/about-actions';
import { AnnotationActionTypes } from 'actions/annotation-actions'; import { AnnotationActionTypes } from 'actions/annotation-actions';
import { NotificationsActionType } from 'actions/notification-actions'; import { NotificationsActionType } from 'actions/notification-actions';
@ -29,6 +30,12 @@ const defaultState: NotificationsState = {
resetPassword: null, resetPassword: null,
loadAuthActions: null, loadAuthActions: null,
}, },
projects: {
fetching: null,
updating: null,
deleting: null,
creating: null,
},
tasks: { tasks: {
fetching: null, fetching: null,
updating: null, updating: null,
@ -414,6 +421,72 @@ export default function (state = defaultState, action: AnyAction): Notifications
}, },
}; };
} }
case ProjectsActionTypes.GET_PROJECTS_FAILED: {
return {
...state,
errors: {
...state.errors,
projects: {
...state.errors.projects,
fetching: {
message: 'Could not fetch projects',
reason: action.payload.error.toString(),
},
},
},
};
}
case ProjectsActionTypes.CREATE_PROJECT_FAILED: {
return {
...state,
errors: {
...state.errors,
projects: {
...state.errors.projects,
creating: {
message: 'Could not create the project',
reason: action.payload.error.toString(),
},
},
},
};
}
case ProjectsActionTypes.UPDATE_PROJECT_FAILED: {
const { id: projectId } = action.payload.project;
return {
...state,
errors: {
...state.errors,
projects: {
...state.errors.projects,
updating: {
message:
'Could not update ' +
`<a href="/project/${projectId}" target="_blank">project ${projectId}</a>`,
reason: action.payload.error.toString(),
},
},
},
};
}
case ProjectsActionTypes.DELETE_PROJECT_FAILED: {
const { projectId } = action.payload;
return {
...state,
errors: {
...state.errors,
projects: {
...state.errors.projects,
updating: {
message:
'Could not delete ' +
`<a href="/project/${projectId}" target="_blank">project ${projectId}</a>`,
reason: action.payload.error.toString(),
},
},
},
};
}
case FormatsActionTypes.GET_FORMATS_FAILED: { case FormatsActionTypes.GET_FORMATS_FAILED: {
return { return {
...state, ...state,

@ -0,0 +1,192 @@
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
import { AnyAction } from 'redux';
import { ProjectsActionTypes } from 'actions/projects-actions';
import { BoundariesActionTypes } from 'actions/boundaries-actions';
import { AuthActionTypes } from 'actions/auth-actions';
import { Project, ProjectsState } from './interfaces';
const defaultState: ProjectsState = {
initialized: false,
fetching: false,
count: 0,
current: [],
gettingQuery: {
page: 1,
id: null,
search: null,
owner: null,
name: null,
status: null,
},
activities: {
deletes: {},
creates: {
id: null,
error: '',
},
},
};
export default (state: ProjectsState = defaultState, action: AnyAction): ProjectsState => {
switch (action.type) {
case ProjectsActionTypes.UPDATE_PROJECTS_GETTING_QUERY:
return {
...state,
gettingQuery: {
...defaultState.gettingQuery,
...action.payload.query,
},
};
case ProjectsActionTypes.GET_PROJECTS:
return {
...state,
initialized: false,
fetching: true,
count: 0,
current: [],
};
case ProjectsActionTypes.GET_PROJECTS_SUCCESS: {
return {
...state,
initialized: true,
fetching: false,
count: action.payload.count,
current: action.payload.array,
};
}
case ProjectsActionTypes.GET_PROJECTS_FAILED: {
return {
...state,
initialized: true,
fetching: false,
};
}
case ProjectsActionTypes.CREATE_PROJECT: {
return {
...state,
activities: {
...state.activities,
creates: {
id: null,
error: '',
},
},
};
}
case ProjectsActionTypes.CREATE_PROJECT_FAILED: {
return {
...state,
activities: {
...state.activities,
creates: {
...state.activities.creates,
error: action.payload.error.toString(),
},
},
};
}
case ProjectsActionTypes.CREATE_PROJECT_SUCCESS: {
return {
...state,
activities: {
...state.activities,
creates: {
id: action.payload.projectId,
error: '',
},
},
};
}
case ProjectsActionTypes.UPDATE_PROJECT: {
return {
...state,
};
}
case ProjectsActionTypes.UPDATE_PROJECT_SUCCESS: {
return {
...state,
current: state.current.map(
(project): Project => {
if (project.id === action.payload.project.id) {
return action.payload.project;
}
return project;
},
),
};
}
case ProjectsActionTypes.UPDATE_PROJECT_FAILED: {
return {
...state,
current: state.current.map(
(project): Project => {
if (project.id === action.payload.project.id) {
return action.payload.project;
}
return project;
},
),
};
}
case ProjectsActionTypes.DELETE_PROJECT: {
const { projectId } = action.payload;
const { deletes } = state.activities;
deletes[projectId] = false;
return {
...state,
activities: {
...state.activities,
deletes: {
...deletes,
},
},
};
}
case ProjectsActionTypes.DELETE_PROJECT_SUCCESS: {
const { projectId } = action.payload;
const { deletes } = state.activities;
deletes[projectId] = true;
return {
...state,
activities: {
...state.activities,
deletes: {
...deletes,
},
},
};
}
case ProjectsActionTypes.DELETE_PROJECT_FAILED: {
const { projectId } = action.payload;
const { deletes } = state.activities;
delete deletes[projectId];
return {
...state,
activities: {
...state.activities,
deletes: {
...deletes,
},
},
};
}
case BoundariesActionTypes.RESET_AFTER_ERROR:
case AuthActionTypes.LOGOUT_SUCCESS: {
return { ...defaultState };
}
default:
return state;
}
};

@ -4,6 +4,7 @@
import { combineReducers, Reducer } from 'redux'; import { combineReducers, Reducer } from 'redux';
import authReducer from './auth-reducer'; import authReducer from './auth-reducer';
import projectsReducer from './projects-reducer';
import tasksReducer from './tasks-reducer'; import tasksReducer from './tasks-reducer';
import aboutReducer from './about-reducer'; import aboutReducer from './about-reducer';
import shareReducer from './share-reducer'; import shareReducer from './share-reducer';
@ -19,6 +20,7 @@ import userAgreementsReducer from './useragreements-reducer';
export default function createRootReducer(): Reducer { export default function createRootReducer(): Reducer {
return combineReducers({ return combineReducers({
auth: authReducer, auth: authReducer,
projects: projectsReducer,
tasks: tasksReducer, tasks: tasksReducer,
about: aboutReducer, about: aboutReducer,
share: shareReducer, share: shareReducer,

@ -42,7 +42,7 @@ class TaskData:
self._frame_mapping = {} self._frame_mapping = {}
self._frame_step = db_task.data.get_frame_step() self._frame_step = db_task.data.get_frame_step()
db_labels = self._db_task.label_set.all().prefetch_related( db_labels = (self._db_task.project if self._db_task.project_id else self._db_task).label_set.all().prefetch_related(
'attributespec_set').order_by('pk') 'attributespec_set').order_by('pk')
self._label_mapping = OrderedDict( self._label_mapping = OrderedDict(

@ -93,7 +93,8 @@ class JobAnnotation:
self.ir_data = AnnotationIR() self.ir_data = AnnotationIR()
self.db_labels = {db_label.id:db_label self.db_labels = {db_label.id:db_label
for db_label in db_segment.task.label_set.all()} for db_label in (db_segment.task.project.label_set.all()
if db_segment.task.project_id else db_segment.task.label_set.all())}
self.db_attributes = {} self.db_attributes = {}
for db_label in self.db_labels.values(): for db_label in self.db_labels.values():

@ -4,7 +4,7 @@
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
from django.contrib import admin from django.contrib import admin
from .models import Task, Segment, Job, Label, AttributeSpec from .models import Task, Segment, Job, Label, AttributeSpec, Project
class JobInline(admin.TabularInline): class JobInline(admin.TabularInline):
model = Job model = Job
@ -54,6 +54,20 @@ class SegmentAdmin(admin.ModelAdmin):
JobInline JobInline
] ]
class ProjectAdmin(admin.ModelAdmin):
date_hierarchy = 'updated_date'
readonly_fields = ('created_date', 'updated_date', 'status')
fields = ('name', 'owner', 'created_date', 'updated_date', 'status')
search_fields = ('name', 'owner__username', 'owner__first_name',
'owner__last_name', 'owner__email', 'assignee__username', 'assignee__first_name',
'assignee__last_name')
inlines = [
LabelInline
]
def has_add_permission(self, _request):
return False
class TaskAdmin(admin.ModelAdmin): class TaskAdmin(admin.ModelAdmin):
date_hierarchy = 'updated_date' date_hierarchy = 'updated_date'
readonly_fields = ('created_date', 'updated_date', 'overlap') readonly_fields = ('created_date', 'updated_date', 'overlap')
@ -74,3 +88,4 @@ class TaskAdmin(admin.ModelAdmin):
admin.site.register(Task, TaskAdmin) admin.site.register(Task, TaskAdmin)
admin.site.register(Segment, SegmentAdmin) admin.site.register(Segment, SegmentAdmin)
admin.site.register(Label, LabelAdmin) admin.site.register(Label, LabelAdmin)
admin.site.register(Project, ProjectAdmin)

@ -2,10 +2,15 @@
# #
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
import os
import logging import logging
from cvat.settings.base import LOGGING from cvat.settings.base import LOGGING
from .models import Job, Task from .models import Job, Task, Project
def _get_project(pid):
try:
return Project.objects.get(pk=pid)
except Exception:
raise Exception('{} key must be a project identifier'.format(pid))
def _get_task(tid): def _get_task(tid):
try: try:
@ -19,6 +24,28 @@ def _get_job(jid):
except Exception: except Exception:
raise Exception('{} key must be a job identifier'.format(jid)) raise Exception('{} key must be a job identifier'.format(jid))
class ProjectLoggerStorage:
def __init__(self):
self._storage = dict()
def __getitem__(self, pid):
"""Get ceratain storage object for some project."""
if pid not in self._storage:
self._storage[pid] = self._create_project_logger(pid)
return self._storage[pid]
def _create_project_logger(self, pid):
project = _get_project(pid)
logger = logging.getLogger('cvat.server.project_{}'.format(pid))
server_file = logging.FileHandler(filename=project.get_log_path())
formatter = logging.Formatter(LOGGING['formatters']['standard']['format'])
server_file.setFormatter(formatter)
logger.addHandler(server_file)
return logger
class TaskLoggerStorage: class TaskLoggerStorage:
def __init__(self): def __init__(self):
self._storage = dict() self._storage = dict()
@ -52,6 +79,24 @@ class JobLoggerStorage:
job = _get_job(jid) job = _get_job(jid)
return slogger.task[job.segment.task.id] return slogger.task[job.segment.task.id]
class ProjectClientLoggerStorage:
def __init__(self):
self._storage = dict()
def __getitem__(self, pid):
"""Get logger for exact task by id."""
if pid not in self._storage:
self._storage[pid] = self._create_client_logger(pid)
return self._storage[pid]
def _create_client_logger(self, pid):
project = _get_project(pid)
logger = logging.getLogger('cvat.client.project_{}'.format(pid))
client_file = logging.FileHandler(filename=project.get_client_log_path())
logger.addHandler(client_file)
return logger
class TaskClientLoggerStorage: class TaskClientLoggerStorage:
def __init__(self): def __init__(self):
self._storage = dict() self._storage = dict()
@ -89,12 +134,14 @@ class dotdict(dict):
__delattr__ = dict.__delitem__ __delattr__ = dict.__delitem__
clogger = dotdict({ clogger = dotdict({
'project': ProjectClientLoggerStorage(),
'task': TaskClientLoggerStorage(), 'task': TaskClientLoggerStorage(),
'job': JobClientLoggerStorage(), 'job': JobClientLoggerStorage(),
'glob': logging.getLogger('cvat.client'), 'glob': logging.getLogger('cvat.client'),
}) })
slogger = dotdict({ slogger = dotdict({
'project': ProjectLoggerStorage(),
'task': TaskLoggerStorage(), 'task': TaskLoggerStorage(),
'job': JobLoggerStorage(), 'job': JobLoggerStorage(),
'glob': logging.getLogger('cvat.server'), 'glob': logging.getLogger('cvat.server'),

@ -0,0 +1,24 @@
# Generated by Django 3.1.1 on 2020-09-24 12:44
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('engine', '0032_remove_task_z_order'),
]
operations = [
migrations.AddField(
model_name='label',
name='project',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='engine.project'),
),
migrations.AlterField(
model_name='label',
name='task',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='engine.task'),
),
]

@ -151,10 +151,25 @@ class Project(models.Model):
status = models.CharField(max_length=32, choices=StatusChoice.choices(), status = models.CharField(max_length=32, choices=StatusChoice.choices(),
default=StatusChoice.ANNOTATION) default=StatusChoice.ANNOTATION)
def get_project_dirname(self):
return os.path.join(settings.PROJECTS_ROOT, str(self.id))
def get_project_logs_dirname(self):
return os.path.join(self.get_project_dirname(), 'logs')
def get_client_log_path(self):
return os.path.join(self.get_project_logs_dirname(), "client.log")
def get_log_path(self):
return os.path.join(self.get_project_logs_dirname(), "project.log")
# Extend default permission model # Extend default permission model
class Meta: class Meta:
default_permissions = () default_permissions = ()
def __str__(self):
return self.name
class Task(models.Model): class Task(models.Model):
project = models.ForeignKey(Project, on_delete=models.CASCADE, project = models.ForeignKey(Project, on_delete=models.CASCADE,
null=True, blank=True, related_name="tasks", null=True, blank=True, related_name="tasks",
@ -255,7 +270,8 @@ class Job(models.Model):
default_permissions = () default_permissions = ()
class Label(models.Model): class Label(models.Model):
task = models.ForeignKey(Task, on_delete=models.CASCADE) task = models.ForeignKey(Task, null=True, blank=True, on_delete=models.CASCADE)
project = models.ForeignKey(Project, null=True, blank=True, on_delete=models.CASCADE)
name = SafeCharField(max_length=64) name = SafeCharField(max_length=64)
color = models.CharField(default='', max_length=8) color = models.CharField(default='', max_length=8)
@ -311,7 +327,7 @@ class ShapeType(str, Enum):
POLYGON = 'polygon' # (x0, y0, ..., xn, yn) POLYGON = 'polygon' # (x0, y0, ..., xn, yn)
POLYLINE = 'polyline' # (x0, y0, ..., xn, yn) POLYLINE = 'polyline' # (x0, y0, ..., xn, yn)
POINTS = 'points' # (x0, y0, ..., xn, yn) POINTS = 'points' # (x0, y0, ..., xn, yn)
CUBOID = 'cuboid' CUBOID = 'cuboid' # (x0, y0, ..., x7, y7)
@classmethod @classmethod
def choices(cls): def choices(cls):

@ -74,6 +74,47 @@ class LabelSerializer(serializers.ModelSerializer):
model = models.Label model = models.Label
fields = ('id', 'name', 'color', 'attributes') fields = ('id', 'name', 'color', 'attributes')
@staticmethod
def update_instance(validated_data, parent_instance):
attributes = validated_data.pop('attributespec_set', [])
instance = dict()
if isinstance(parent_instance, models.Project):
instance['project'] = parent_instance
logger = slogger.project[parent_instance.id]
else:
instance['task'] = parent_instance
logger = slogger.task[parent_instance.id]
(db_label, created) = models.Label.objects.get_or_create(name=validated_data['name'],
**instance)
if created:
logger.info("New {} label was created".format(db_label.name))
else:
logger.info("{} label was updated".format(db_label.name))
if not validated_data.get('color', None):
label_names = [l.name for l in
instance[tuple(instance.keys())[0]].label_set.exclude(id=db_label.id).order_by('id')
]
db_label.color = get_label_color(db_label.name, label_names)
else:
db_label.color = validated_data.get('color', db_label.color)
db_label.save()
for attr in attributes:
(db_attr, created) = models.AttributeSpec.objects.get_or_create(
label=db_label, name=attr['name'], defaults=attr)
if created:
logger.info("New {} attribute for {} label was created"
.format(db_attr.name, db_label.name))
else:
logger.info("{} attribute for {} label was updated"
.format(db_attr.name, db_label.name))
# FIXME: need to update only "safe" fields
db_attr.default_value = attr.get('default_value', db_attr.default_value)
db_attr.mutable = attr.get('mutable', db_attr.mutable)
db_attr.input_type = attr.get('input_type', db_attr.input_type)
db_attr.values = attr.get('values', db_attr.values)
db_attr.save()
class JobCommitSerializer(serializers.ModelSerializer): class JobCommitSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = models.JobCommit model = models.JobCommit
@ -155,6 +196,7 @@ class RqStatusSerializer(serializers.Serializer):
message = serializers.CharField(allow_blank=True, default="") message = serializers.CharField(allow_blank=True, default="")
class WriteOnceMixin: class WriteOnceMixin:
"""Adds support for write once fields to serializers. """Adds support for write once fields to serializers.
To use it, specify a list of fields as `write_once_fields` on the To use it, specify a list of fields as `write_once_fields` on the
@ -266,7 +308,7 @@ class DataSerializer(serializers.ModelSerializer):
return db_data return db_data
class TaskSerializer(WriteOnceMixin, serializers.ModelSerializer): class TaskSerializer(WriteOnceMixin, serializers.ModelSerializer):
labels = LabelSerializer(many=True, source='label_set', partial=True) labels = LabelSerializer(many=True, source='label_set', partial=True, required=False)
segments = SegmentSerializer(many=True, source='segment_set', read_only=True) segments = SegmentSerializer(many=True, source='segment_set', read_only=True)
data_chunk_size = serializers.ReadOnlyField(source='data.chunk_size') data_chunk_size = serializers.ReadOnlyField(source='data.chunk_size')
data_compressed_chunk_type = serializers.ReadOnlyField(source='data.compressed_chunk_type') data_compressed_chunk_type = serializers.ReadOnlyField(source='data.compressed_chunk_type')
@ -278,21 +320,27 @@ class TaskSerializer(WriteOnceMixin, serializers.ModelSerializer):
owner_id = serializers.IntegerField(write_only=True, allow_null=True, required=False) owner_id = serializers.IntegerField(write_only=True, allow_null=True, required=False)
assignee = BasicUserSerializer(allow_null=True, required=False) assignee = BasicUserSerializer(allow_null=True, required=False)
assignee_id = serializers.IntegerField(write_only=True, allow_null=True, required=False) assignee_id = serializers.IntegerField(write_only=True, allow_null=True, required=False)
project_id = serializers.IntegerField(required=False)
class Meta: class Meta:
model = models.Task model = models.Task
fields = ('url', 'id', 'name', 'mode', 'owner', 'assignee', 'owner_id', 'assignee_id', fields = ('url', 'id', 'name', 'project_id', 'mode', 'owner', 'assignee', 'owner_id', 'assignee_id',
'bug_tracker', 'created_date', 'updated_date', 'overlap', 'bug_tracker', 'created_date', 'updated_date', 'overlap',
'segment_size', 'status', 'labels', 'segments', 'segment_size', 'status', 'labels', 'segments',
'project', 'data_chunk_size', 'data_compressed_chunk_type', 'data_original_chunk_type', 'size', 'image_quality', 'data') 'data_chunk_size', 'data_compressed_chunk_type', 'data_original_chunk_type', 'size', 'image_quality', 'data')
read_only_fields = ('mode', 'created_date', 'updated_date', 'status', 'data_chunk_size', 'owner', 'asignee', read_only_fields = ('mode', 'created_date', 'updated_date', 'status', 'data_chunk_size', 'owner', 'asignee',
'data_compressed_chunk_type', 'data_original_chunk_type', 'size', 'image_quality', 'data') 'data_compressed_chunk_type', 'data_original_chunk_type', 'size', 'image_quality', 'data')
write_once_fields = ('overlap', 'segment_size') write_once_fields = ('overlap', 'segment_size', 'project_id')
ordering = ['-id'] ordering = ['-id']
# pylint: disable=no-self-use # pylint: disable=no-self-use
def create(self, validated_data): def create(self, validated_data):
labels = validated_data.pop('label_set') if not (validated_data.get("label_set") or validated_data.get("project_id")):
raise serializers.ValidationError('Label set or project_id must be present')
if validated_data.get("label_set") and validated_data.get("project_id"):
raise serializers.ValidationError('Project must have only one of Label set or project_id')
labels = validated_data.pop('label_set', [])
db_task = models.Task.objects.create(**validated_data) db_task = models.Task.objects.create(**validated_data)
label_names = list() label_names = list()
for label in labels: for label in labels:
@ -314,6 +362,12 @@ class TaskSerializer(WriteOnceMixin, serializers.ModelSerializer):
db_task.save() db_task.save()
return db_task return db_task
def to_representation(self, instance):
response = super().to_representation(instance)
if instance.project_id:
response["labels"] = LabelSerializer(many=True).to_representation(instance.project.label_set)
return response
# pylint: disable=no-self-use # pylint: disable=no-self-use
def update(self, instance, validated_data): def update(self, instance, validated_data):
instance.name = validated_data.get('name', instance.name) instance.name = validated_data.get('name', instance.name)
@ -321,63 +375,84 @@ class TaskSerializer(WriteOnceMixin, serializers.ModelSerializer):
instance.assignee_id = validated_data.get('assignee_id', instance.assignee_id) instance.assignee_id = validated_data.get('assignee_id', instance.assignee_id)
instance.bug_tracker = validated_data.get('bug_tracker', instance.bug_tracker = validated_data.get('bug_tracker',
instance.bug_tracker) instance.bug_tracker)
instance.project = validated_data.get('project', instance.project)
labels = validated_data.get('label_set', []) labels = validated_data.get('label_set', [])
for label in labels: for label in labels:
attributes = label.pop('attributespec_set', []) LabelSerializer.update_instance(label, instance)
(db_label, created) = models.Label.objects.get_or_create(task=instance,
name=label['name'])
if created:
slogger.task[instance.id].info("New {} label was created"
.format(db_label.name))
else:
slogger.task[instance.id].info("{} label was updated"
.format(db_label.name))
if not label.get('color', None):
label_names = [l.name for l in
instance.label_set.all().exclude(id=db_label.id).order_by('id')
]
db_label.color = get_label_color(db_label.name, label_names)
else:
db_label.color = label.get('color', db_label.color)
db_label.save()
for attr in attributes:
(db_attr, created) = models.AttributeSpec.objects.get_or_create(
label=db_label, name=attr['name'], defaults=attr)
if created:
slogger.task[instance.id].info("New {} attribute for {} label was created"
.format(db_attr.name, db_label.name))
else:
slogger.task[instance.id].info("{} attribute for {} label was updated"
.format(db_attr.name, db_label.name))
# FIXME: need to update only "safe" fields
db_attr.default_value = attr.get('default_value', db_attr.default_value)
db_attr.mutable = attr.get('mutable', db_attr.mutable)
db_attr.input_type = attr.get('input_type', db_attr.input_type)
db_attr.values = attr.get('values', db_attr.values)
db_attr.save()
instance.save() instance.save()
return instance return instance
def validate_labels(self, value): def validate_labels(self, value):
if not value:
raise serializers.ValidationError('Label set must not be empty')
label_names = [label['name'] for label in value] label_names = [label['name'] for label in value]
if len(label_names) != len(set(label_names)): if len(label_names) != len(set(label_names)):
raise serializers.ValidationError('All label names must be unique for the task') raise serializers.ValidationError('All label names must be unique for the task')
return value return value
class ProjectSearchSerializer(serializers.ModelSerializer):
class Meta:
model = models.Project
fields = ('id', 'name')
read_only_fields = ('name',)
ordering = ['-id']
class ProjectSerializer(serializers.ModelSerializer): class ProjectSerializer(serializers.ModelSerializer):
labels = LabelSerializer(many=True, source='label_set', partial=True, default=[])
tasks = TaskSerializer(many=True, read_only=True)
owner = BasicUserSerializer(required=False)
owner_id = serializers.IntegerField(write_only=True, allow_null=True, required=False)
assignee = BasicUserSerializer(allow_null=True, required=False)
assignee_id = serializers.IntegerField(write_only=True, allow_null=True, required=False)
class Meta: class Meta:
model = models.Project model = models.Project
fields = ('url', 'id', 'name', 'owner', 'assignee', 'bug_tracker', fields = ('url', 'id', 'name', 'labels', 'tasks', 'owner', 'assignee', 'owner_id', 'assignee_id',
'created_date', 'updated_date', 'status') 'bug_tracker', 'created_date', 'updated_date', 'status')
read_only_fields = ('created_date', 'updated_date', 'status') read_only_fields = ('created_date', 'updated_date', 'status', 'owner', 'asignee')
ordering = ['-id'] ordering = ['-id']
# pylint: disable=no-self-use
def create(self, validated_data):
labels = validated_data.pop('label_set')
db_project = models.Project.objects.create(**validated_data)
label_names = list()
for label in labels:
attributes = label.pop('attributespec_set')
if not label.get('color', None):
label['color'] = get_label_color(label['name'], label_names)
label_names.append(label['name'])
db_label = models.Label.objects.create(project=db_project, **label)
for attr in attributes:
models.AttributeSpec.objects.create(label=db_label, **attr)
project_path = db_project.get_project_dirname()
if os.path.isdir(project_path):
shutil.rmtree(project_path)
os.makedirs(db_project.get_project_logs_dirname())
db_project.save()
return db_project
# pylint: disable=no-self-use
def update(self, instance, validated_data):
instance.name = validated_data.get('name', instance.name)
instance.owner_id = validated_data.get('owner_id', instance.owner_id)
instance.assignee_id = validated_data.get('assignee_id', instance.assignee_id)
instance.bug_tracker = validated_data.get('bug_tracker', instance.bug_tracker)
labels = validated_data.get('label_set', [])
for label in labels:
LabelSerializer.update_instance(label, instance)
instance.save()
return instance
def validate_labels(self, value):
if value:
label_names = [label['name'] for label in value]
if len(label_names) != len(set(label_names)):
raise serializers.ValidationError('All label names must be unique for the project')
return value
class ExceptionSerializer(serializers.Serializer): class ExceptionSerializer(serializers.Serializer):
system = serializers.CharField(max_length=255) system = serializers.CharField(max_length=255)
client = serializers.CharField(max_length=255) client = serializers.CharField(max_length=255)

@ -29,7 +29,7 @@ from rest_framework import status
from rest_framework.test import APIClient, APITestCase from rest_framework.test import APIClient, APITestCase
from cvat.apps.engine.models import (AttributeType, Data, Job, Project, from cvat.apps.engine.models import (AttributeType, Data, Job, Project,
Segment, StatusChoice, Task, StorageMethodChoice) Segment, StatusChoice, Task, Label, StorageMethodChoice)
from cvat.apps.engine.prepare import prepare_meta, prepare_meta_for_upload from cvat.apps.engine.prepare import prepare_meta, prepare_meta_for_upload
def create_db_users(cls): def create_db_users(cls):
@ -754,10 +754,13 @@ class ProjectGetAPITestCase(APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["name"], db_project.name) self.assertEqual(response.data["name"], db_project.name)
owner = db_project.owner.id if db_project.owner else None owner = db_project.owner.id if db_project.owner else None
self.assertEqual(response.data["owner"], owner) response_owner = response.data["owner"]["id"] if response.data["owner"] else None
self.assertEqual(response_owner, owner)
assignee = db_project.assignee.id if db_project.assignee else None assignee = db_project.assignee.id if db_project.assignee else None
self.assertEqual(response.data["assignee"], assignee) response_assignee = response.data["assignee"]["id"] if response.data["assignee"] else None
self.assertEqual(response_assignee, assignee)
self.assertEqual(response.data["status"], db_project.status) self.assertEqual(response.data["status"], db_project.status)
self.assertEqual(response.data["bug_tracker"], db_project.bug_tracker)
def _check_api_v1_projects_id(self, user): def _check_api_v1_projects_id(self, user):
for db_project in self.projects: for db_project in self.projects:
@ -835,10 +838,15 @@ class ProjectCreateAPITestCase(APITestCase):
def _check_response(self, response, user, data): def _check_response(self, response, user, data):
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data["name"], data["name"]) self.assertEqual(response.data["name"], data["name"])
self.assertEqual(response.data["owner"], data.get("owner", user.id)) self.assertEqual(response.data["owner"]["id"], data.get("owner_id", user.id))
self.assertEqual(response.data["assignee"], data.get("assignee")) response_assignee = response.data["assignee"]["id"] if response.data["assignee"] else None
self.assertEqual(response_assignee, data.get('assignee_id', None))
self.assertEqual(response.data["bug_tracker"], data.get("bug_tracker", "")) self.assertEqual(response.data["bug_tracker"], data.get("bug_tracker", ""))
self.assertEqual(response.data["status"], StatusChoice.ANNOTATION) self.assertEqual(response.data["status"], StatusChoice.ANNOTATION)
self.assertListEqual(
[label["name"] for label in data.get("labels", [])],
[label["name"] for label in response.data["labels"]]
)
def _check_api_v1_projects(self, user, data): def _check_api_v1_projects(self, user, data):
response = self._run_api_v1_projects(user, data) response = self._run_api_v1_projects(user, data)
@ -857,18 +865,26 @@ class ProjectCreateAPITestCase(APITestCase):
self._check_api_v1_projects(self.admin, data) self._check_api_v1_projects(self.admin, data)
data = { data = {
"owner": self.owner.id, "owner_id": self.owner.id,
"assignee": self.assignee.id, "assignee_id": self.assignee.id,
"name": "new name for the project" "name": "new name for the project"
} }
self._check_api_v1_projects(self.admin, data) self._check_api_v1_projects(self.admin, data)
data = { data = {
"owner": self.admin.id, "owner_id": self.admin.id,
"name": "2" "name": "2"
} }
self._check_api_v1_projects(self.admin, data) self._check_api_v1_projects(self.admin, data)
data = {
"name": "Project with labels",
"labels": [{
"name": "car",
}]
}
self._check_api_v1_projects(self.admin, data)
def test_api_v1_projects_user(self): def test_api_v1_projects_user(self):
data = { data = {
@ -878,8 +894,8 @@ class ProjectCreateAPITestCase(APITestCase):
self._check_api_v1_projects(self.user, data) self._check_api_v1_projects(self.user, data)
data = { data = {
"owner": self.owner.id, "owner_id": self.owner.id,
"assignee": self.assignee.id, "assignee_id": self.assignee.id,
"name": "My import project with data" "name": "My import project with data"
} }
self._check_api_v1_projects(self.user, data) self._check_api_v1_projects(self.user, data)
@ -888,15 +904,15 @@ class ProjectCreateAPITestCase(APITestCase):
def test_api_v1_projects_observer(self): def test_api_v1_projects_observer(self):
data = { data = {
"name": "My Project #1", "name": "My Project #1",
"owner": self.owner.id, "owner_id": self.owner.id,
"assignee": self.assignee.id "assignee_id": self.assignee.id
} }
self._check_api_v1_projects(self.observer, data) self._check_api_v1_projects(self.observer, data)
def test_api_v1_projects_no_auth(self): def test_api_v1_projects_no_auth(self):
data = { data = {
"name": "My Project #2", "name": "My Project #2",
"owner": self.admin.id, "owner_id": self.admin.id,
} }
self._check_api_v1_projects(None, data) self._check_api_v1_projects(None, data)
@ -918,15 +934,16 @@ class ProjectPartialUpdateAPITestCase(APITestCase):
def _check_response(self, response, db_project, data): def _check_response(self, response, db_project, data):
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
name = data.get("name", db_project.name) name = data.get("name", data.get("name", db_project.name))
self.assertEqual(response.data["name"], name) self.assertEqual(response.data["name"], name)
owner = db_project.owner.id if db_project.owner else None response_owner = response.data["owner"]["id"] if response.data["owner"] else None
owner = data.get("owner", owner) db_owner = db_project.owner.id if db_project.owner else None
self.assertEqual(response.data["owner"], owner) self.assertEqual(response_owner, data.get("owner_id", db_owner))
assignee = db_project.assignee.id if db_project.assignee else None response_assignee = response.data["assignee"]["id"] if response.data["assignee"] else None
assignee = data.get("assignee", assignee) db_assignee = db_project.assignee.id if db_project.assignee else None
self.assertEqual(response.data["assignee"], assignee) self.assertEqual(response_assignee, data.get("assignee_id", db_assignee))
self.assertEqual(response.data["status"], db_project.status) self.assertEqual(response.data["status"], data.get("status", db_project.status))
self.assertEqual(response.data["bug_tracker"], data.get("bug_tracker", db_project.bug_tracker))
def _check_api_v1_projects_id(self, user, data): def _check_api_v1_projects_id(self, user, data):
for db_project in self.projects: for db_project in self.projects:
@ -941,14 +958,15 @@ class ProjectPartialUpdateAPITestCase(APITestCase):
def test_api_v1_projects_id_admin(self): def test_api_v1_projects_id_admin(self):
data = { data = {
"name": "new name for the project", "name": "new name for the project",
"owner": self.owner.id, "owner_id": self.owner.id,
"bug_tracker": "https://new.bug.tracker",
} }
self._check_api_v1_projects_id(self.admin, data) self._check_api_v1_projects_id(self.admin, data)
def test_api_v1_projects_id_user(self): def test_api_v1_projects_id_user(self):
data = { data = {
"name": "new name for the project", "name": "new name for the project",
"owner": self.assignee.id, "owner_id": self.assignee.id,
} }
self._check_api_v1_projects_id(self.user, data) self._check_api_v1_projects_id(self.user, data)
@ -1328,6 +1346,16 @@ class TaskPartialUpdateAPITestCase(TaskUpdateAPITestCase):
class TaskCreateAPITestCase(APITestCase): class TaskCreateAPITestCase(APITestCase):
def setUp(self): def setUp(self):
self.client = APIClient() self.client = APIClient()
project = {
"name": "Project for task creation",
"owner": self.user,
}
self.project = Project.objects.create(**project)
label = {
"name": "car",
"project": self.project
}
Label.objects.create(**label)
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -1343,6 +1371,7 @@ class TaskCreateAPITestCase(APITestCase):
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data["name"], data["name"]) self.assertEqual(response.data["name"], data["name"])
self.assertEqual(response.data["mode"], "") self.assertEqual(response.data["mode"], "")
self.assertEqual(response.data["project_id"], data.get("project_id", None))
self.assertEqual(response.data["owner"]["id"], data.get("owner_id", user.id)) self.assertEqual(response.data["owner"]["id"], data.get("owner_id", user.id))
assignee = response.data["assignee"]["id"] if response.data["assignee"] else None assignee = response.data["assignee"]["id"] if response.data["assignee"] else None
self.assertEqual(assignee, data.get("assignee_id", None)) self.assertEqual(assignee, data.get("assignee_id", None))
@ -1396,6 +1425,17 @@ class TaskCreateAPITestCase(APITestCase):
} }
self._check_api_v1_tasks(self.user, data) self._check_api_v1_tasks(self.user, data)
def test_api_vi_tasks_user_project(self):
data = {
"name": "new name for the task",
"project_id": self.project.id,
}
response = self._run_api_v1_tasks(self.user, data)
data["labels"] = [{
"name": "car"
}]
self._check_response(response, self.user, data)
def test_api_v1_tasks_observer(self): def test_api_v1_tasks_observer(self):
data = { data = {
"name": "new name for the task", "name": "new name for the task",

@ -36,13 +36,14 @@ import cvat.apps.dataset_manager.views # pylint: disable=unused-import
from cvat.apps.authentication import auth from cvat.apps.authentication import auth
from cvat.apps.dataset_manager.serializers import DatasetFormatsSerializer from cvat.apps.dataset_manager.serializers import DatasetFormatsSerializer
from cvat.apps.engine.frame_provider import FrameProvider from cvat.apps.engine.frame_provider import FrameProvider
from cvat.apps.engine.models import Job, StatusChoice, Task, StorageMethodChoice from cvat.apps.engine.models import Job, StatusChoice, Task, Project, StorageMethodChoice
from cvat.apps.engine.serializers import ( from cvat.apps.engine.serializers import (
AboutSerializer, AnnotationFileSerializer, BasicUserSerializer, AboutSerializer, AnnotationFileSerializer, BasicUserSerializer,
DataMetaSerializer, DataSerializer, ExceptionSerializer, DataMetaSerializer, DataSerializer, ExceptionSerializer,
FileInfoSerializer, JobSerializer, LabeledDataSerializer, FileInfoSerializer, JobSerializer, LabeledDataSerializer,
LogEventSerializer, ProjectSerializer, RqStatusSerializer, LogEventSerializer, ProjectSerializer, ProjectSearchSerializer,
TaskSerializer, UserSerializer, PluginsSerializer, RqStatusSerializer, TaskSerializer, UserSerializer,
PluginsSerializer,
) )
from cvat.apps.engine.utils import av_scan_paths from cvat.apps.engine.utils import av_scan_paths
@ -192,14 +193,13 @@ class ProjectFilter(filters.FilterSet):
name = filters.CharFilter(field_name="name", lookup_expr="icontains") name = filters.CharFilter(field_name="name", lookup_expr="icontains")
owner = filters.CharFilter(field_name="owner__username", lookup_expr="icontains") owner = filters.CharFilter(field_name="owner__username", lookup_expr="icontains")
status = filters.CharFilter(field_name="status", lookup_expr="icontains") status = filters.CharFilter(field_name="status", lookup_expr="icontains")
assignee = filters.CharFilter(field_name="assignee__username", lookup_expr="icontains")
class Meta: class Meta:
model = models.Project model = models.Project
fields = ("id", "name", "owner", "status", "assignee") fields = ("id", "name", "owner", "status")
@method_decorator(name='list', decorator=swagger_auto_schema( @method_decorator(name='list', decorator=swagger_auto_schema(
operation_summary='Returns a paginated list of projects according to query parameters (10 projects per page)', operation_summary='Returns a paginated list of projects according to query parameters (12 projects per page)',
manual_parameters=[ manual_parameters=[
openapi.Parameter('id', openapi.IN_QUERY, description="A unique number value identifying this project", openapi.Parameter('id', openapi.IN_QUERY, description="A unique number value identifying this project",
type=openapi.TYPE_NUMBER), type=openapi.TYPE_NUMBER),
@ -208,21 +208,24 @@ class ProjectFilter(filters.FilterSet):
openapi.Parameter('owner', openapi.IN_QUERY, description="Find all project where owner name contains a parameter value", openapi.Parameter('owner', openapi.IN_QUERY, description="Find all project where owner name contains a parameter value",
type=openapi.TYPE_STRING), type=openapi.TYPE_STRING),
openapi.Parameter('status', openapi.IN_QUERY, description="Find all projects with a specific status", openapi.Parameter('status', openapi.IN_QUERY, description="Find all projects with a specific status",
type=openapi.TYPE_STRING, enum=[str(i) for i in StatusChoice]), type=openapi.TYPE_STRING, enum=[str(i) for i in StatusChoice])]))
openapi.Parameter('assignee', openapi.IN_QUERY, description="Find all projects where assignee name contains a parameter value",
type=openapi.TYPE_STRING)]))
@method_decorator(name='create', decorator=swagger_auto_schema(operation_summary='Method creates a new project')) @method_decorator(name='create', decorator=swagger_auto_schema(operation_summary='Method creates a new project'))
@method_decorator(name='retrieve', decorator=swagger_auto_schema(operation_summary='Method returns details of a specific project')) @method_decorator(name='retrieve', decorator=swagger_auto_schema(operation_summary='Method returns details of a specific project'))
@method_decorator(name='destroy', decorator=swagger_auto_schema(operation_summary='Method deletes a specific project')) @method_decorator(name='destroy', decorator=swagger_auto_schema(operation_summary='Method deletes a specific project'))
@method_decorator(name='partial_update', decorator=swagger_auto_schema(operation_summary='Methods does a partial update of chosen fields in a project')) @method_decorator(name='partial_update', decorator=swagger_auto_schema(operation_summary='Methods does a partial update of chosen fields in a project'))
class ProjectViewSet(auth.ProjectGetQuerySetMixin, viewsets.ModelViewSet): class ProjectViewSet(auth.ProjectGetQuerySetMixin, viewsets.ModelViewSet):
queryset = models.Project.objects.all().order_by('-id') queryset = models.Project.objects.all().order_by('-id')
serializer_class = ProjectSerializer search_fields = ("name", "owner__username", "status")
search_fields = ("name", "owner__username", "assignee__username", "status")
filterset_class = ProjectFilter filterset_class = ProjectFilter
ordering_fields = ("id", "name", "owner", "status", "assignee") ordering_fields = ("id", "name", "owner", "status", "assignee")
http_method_names = ['get', 'post', 'head', 'patch', 'delete'] http_method_names = ['get', 'post', 'head', 'patch', 'delete']
def get_serializer_class(self):
if self.request.query_params and self.request.query_params.get("names_only") == "true":
return ProjectSearchSerializer
else:
return ProjectSerializer
def get_permissions(self): def get_permissions(self):
http_method = self.request.method http_method = self.request.method
permissions = [IsAuthenticated] permissions = [IsAuthenticated]
@ -241,9 +244,19 @@ class ProjectViewSet(auth.ProjectGetQuerySetMixin, viewsets.ModelViewSet):
return [perm() for perm in permissions] return [perm() for perm in permissions]
def perform_create(self, serializer): def perform_create(self, serializer):
if self.request.data.get('owner', None): def validate_project_limit(owner):
admin_perm = auth.AdminRolePermission()
is_admin = admin_perm.has_permission(self.request, self)
if not is_admin and settings.RESTRICTIONS['project_limit'] is not None and \
Project.objects.filter(owner=owner).count() >= settings.RESTRICTIONS['project_limit']:
raise serializers.ValidationError('The user has the maximum number of projects')
owner = self.request.data.get('owner', None)
if owner:
validate_project_limit(owner)
serializer.save() serializer.save()
else: else:
validate_project_limit(self.request.user)
serializer.save(owner=self.request.user) serializer.save(owner=self.request.user)
@swagger_auto_schema(method='get', operation_summary='Returns information of the tasks of the project with the selected id', @swagger_auto_schema(method='get', operation_summary='Returns information of the tasks of the project with the selected id',

@ -346,6 +346,9 @@ os.makedirs(CACHE_ROOT, exist_ok=True)
TASKS_ROOT = os.path.join(DATA_ROOT, 'tasks') TASKS_ROOT = os.path.join(DATA_ROOT, 'tasks')
os.makedirs(TASKS_ROOT, exist_ok=True) os.makedirs(TASKS_ROOT, exist_ok=True)
PROJECTS_ROOT = os.path.join(DATA_ROOT, 'projects')
os.makedirs(PROJECTS_ROOT, exist_ok=True)
SHARE_ROOT = os.path.join(BASE_DIR, 'share') SHARE_ROOT = os.path.join(BASE_DIR, 'share')
os.makedirs(SHARE_ROOT, exist_ok=True) os.makedirs(SHARE_ROOT, exist_ok=True)
@ -427,6 +430,9 @@ RESTRICTIONS = {
# this setting limits the number of tasks for the user # this setting limits the number of tasks for the user
'task_limit': None, 'task_limit': None,
# this setting limits the number of projects for the user
'project_limit': None,
# this setting reduse task visibility to owner and assignee only # this setting reduse task visibility to owner and assignee only
'reduce_task_visibility': False, 'reduce_task_visibility': False,

@ -28,6 +28,7 @@ context('Create and delete a annotation task', () => {
cy.login(); cy.login();
cy.imageGenerator(imagesFolder, imageFileName, width, height, color, posX, posY, labelName, imagesCount); cy.imageGenerator(imagesFolder, imageFileName, width, height, color, posX, posY, labelName, imagesCount);
cy.createZipArchive(directoryToArchive, archivePath); cy.createZipArchive(directoryToArchive, archivePath);
cy.goToTaskList();
}); });
describe(`Testing "${labelName}"`, () => { describe(`Testing "${labelName}"`, () => {

@ -34,6 +34,7 @@ context('Check if parameters "startFrame", "stopFrame", "frameStep" works as exp
cy.login(); cy.login();
cy.imageGenerator(imagesFolder, imageFileName, width, height, color, posX, posY, labelName, imagesCount); cy.imageGenerator(imagesFolder, imageFileName, width, height, color, posX, posY, labelName, imagesCount);
cy.createZipArchive(directoryToArchive, archivePath); cy.createZipArchive(directoryToArchive, archivePath);
cy.goToTaskList();
}); });
after(() => { after(() => {

@ -18,17 +18,13 @@ context('Dump annotation if cuboid created', () => {
secondY: 450, secondY: 450,
}; };
function save() {
cy.get('button').contains('Save').click({ force: true });
}
before(() => { before(() => {
cy.openTaskJob(taskName); cy.openTaskJob(taskName);
}); });
after('Go to task list', () => { after('Go to task list', () => {
cy.removeAnnotations(); cy.removeAnnotations();
save(); cy.saveJob();
}); });
describe(`Testing issue "${issueId}"`, () => { describe(`Testing issue "${issueId}"`, () => {
@ -38,7 +34,7 @@ context('Dump annotation if cuboid created', () => {
}); });
it('Dump an annotation', () => { it('Dump an annotation', () => {
cy.get('.cvat-annotation-header-left-group').within(() => { cy.get('.cvat-annotation-header-left-group').within(() => {
save(); cy.saveJob();
cy.get('button').contains('Menu').trigger('mouseover', { force: true }); cy.get('button').contains('Menu').trigger('mouseover', { force: true });
}); });
cy.get('.cvat-annotation-menu').within(() => { cy.get('.cvat-annotation-menu').within(() => {

@ -0,0 +1,60 @@
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
/// <reference types="cypress" />
import { taskName, labelName } from '../../support/const';
context('Check hide/unhide functionality from label tab for object and tag with a same label.', () => {
const issueId = '2418';
const createRectangleShape2Points = {
points: 'By 2 Points',
type: 'Shape',
labelName: labelName,
firstX: 260,
firstY: 200,
secondX: 360,
secondY: 250,
};
before(() => {
cy.openTaskJob(taskName);
});
describe(`Testing issue "${issueId}"`, () => {
it('Crearte an object. Create a tag.', () => {
cy.createRectangle(createRectangleShape2Points);
cy.createTag(labelName);
});
it('Go to "Labels" tab.', () => {
cy.get('.cvat-objects-sidebar').within(() => {
cy.contains('Labels').click();
});
});
it('Hide object by label name.', () => {
cy.get('.cvat-objects-sidebar-labels-list').within(() => {
cy.contains(labelName)
.parents('.cvat-objects-sidebar-label-item')
.within(() => {
cy.get('.cvat-label-item-button-hidden')
.click()
.should('have.class', 'cvat-label-item-button-hidden-enabled');
});
});
cy.get('#cvat_canvas_shape_1').should('be.hidden');
});
it('Unhide object by label name.', () => {
cy.get('.cvat-objects-sidebar-labels-list').within(() => {
cy.contains(labelName)
.parents('.cvat-objects-sidebar-label-item')
.within(() => {
cy.get('.cvat-label-item-button-hidden')
.click()
.should('not.have.class', 'cvat-label-item-button-hidden-enabled');
});
});
cy.get('#cvat_canvas_shape_1').should('be.visible');
});
});
});

@ -0,0 +1,56 @@
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
/// <reference types="cypress" />
import { taskName, labelName } from '../../support/const';
context('Check error сannot read property at saving job', () => {
const prId = '2203';
const createRectangleShape2Points = {
points: 'By 2 Points',
type: 'Shape',
labelName: labelName,
firstX: 100,
firstY: 100,
secondX: 300,
secondY: 300,
};
before(() => {
cy.openTaskJob(taskName);
});
after('Remove annotations and save job', () => {
cy.removeAnnotations();
cy.saveJob();
});
describe(`Testing pr "${prId}"`, () => {
it('Create an object in first frame', () => {
cy.createRectangle(createRectangleShape2Points);
});
it('Go to next frame and create an object in second frame', () => {
cy.get('.cvat-player-next-button').click();
cy.createRectangle(createRectangleShape2Points);
});
it('Go to AAM', () => {
cy.changeWorkspace('Attribute annotation', labelName);
});
it('Save job and go to previous frame at saving job', () => {
cy.server().route('PATCH', '/api/v1/jobs/**').as('saveJob');
cy.saveJob();
cy.get('body').type('d');
cy.wait('@saveJob').its('status').should('equal', 200);
});
it('Page with the error is missing', () => {
cy.get('.cvat-global-boundary').should('not.exist');
});
});
});

@ -0,0 +1,45 @@
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
/// <reference types="cypress" />
import { taskName } from '../../support/const';
context('Value must be a user instance.', () => {
const issueId = '2440';
before(() => {
cy.openTask(taskName);
});
describe(`Testing issue "${issueId}"`, () => {
it('Assign a task to a user', () => {
cy.get('.cvat-task-details-user-block').within(() => {
cy.get('.cvat-user-search-field').click();
});
cy.get('.ant-select-dropdown')
.not('.ant-select-dropdown-hidden')
.contains(new RegExp(`^${Cypress.env('user')}$`, 'g'))
.click();
cy.get('.cvat-spinner').should('exist');
});
it('Assign the task to the same user again', () => {
cy.get('.cvat-task-details-user-block').within(() => {
cy.get('.cvat-user-search-field').click();
});
cy.get('.ant-select-dropdown')
.not('.ant-select-dropdown-hidden')
.contains(new RegExp(`^${Cypress.env('user')}$`, 'g'))
.click();
// Before fix:
// The following error originated from your application code, not from Cypress.
// > Value must be a user instance
cy.get('.cvat-spinner').should('exist');
// Remove the user's assignment for next tests.
cy.get('.cvat-task-details-user-block').within(() => {
cy.get('[type="text"]').click().clear().type('{Enter}');
});
});
});
});

@ -39,6 +39,7 @@ context('Multiple users. Assign task, job.', () => {
after(() => { after(() => {
cy.login(); cy.login();
cy.goToTaskList();
cy.getTaskID(taskName).then(($taskID) => { cy.getTaskID(taskName).then(($taskID) => {
cy.deleteTask(taskName, $taskID); cy.deleteTask(taskName, $taskID);
}); });
@ -77,6 +78,7 @@ context('Multiple users. Assign task, job.', () => {
it('First user login and create a task', () => { it('First user login and create a task', () => {
cy.login(); cy.login();
cy.url().should('include', '/tasks'); cy.url().should('include', '/tasks');
cy.goToTaskList();
cy.imageGenerator(imagesFolder, imageFileName, width, height, color, posX, posY, labelName, imagesCount); cy.imageGenerator(imagesFolder, imageFileName, width, height, color, posX, posY, labelName, imagesCount);
cy.createZipArchive(directoryToArchive, archivePath); cy.createZipArchive(directoryToArchive, archivePath);
cy.createAnnotationTask(taskName, labelName, attrName, textDefaultValue, archiveName); cy.createAnnotationTask(taskName, labelName, attrName, textDefaultValue, archiveName);
@ -92,7 +94,7 @@ context('Multiple users. Assign task, job.', () => {
it('Second user login. The task can be opened. Logout', () => { it('Second user login. The task can be opened. Logout', () => {
cy.login(secondUserName, secondUser.password); cy.login(secondUserName, secondUser.password);
cy.url().should('include', '/tasks'); cy.url().should('include', '/tasks');
cy.get('[value="tasks"]').click(); cy.goToTaskList();
cy.contains('strong', taskName).should('exist'); cy.contains('strong', taskName).should('exist');
cy.openTask(taskName); cy.openTask(taskName);
cy.logout(secondUserName); cy.logout(secondUserName);
@ -100,14 +102,14 @@ context('Multiple users. Assign task, job.', () => {
it('Third user login. The task not exist. Logout', () => { it('Third user login. The task not exist. Logout', () => {
cy.login(thirdUserName, thirdUser.password); cy.login(thirdUserName, thirdUser.password);
cy.url().should('include', '/tasks'); cy.url().should('include', '/tasks');
cy.get('[value="tasks"]').click(); cy.goToTaskList();
cy.contains('strong', taskName).should('not.exist'); cy.contains('strong', taskName).should('not.exist');
cy.logout(thirdUserName); cy.logout(thirdUserName);
}); });
it('First user login and assign the job to the third user. Logout', () => { it('First user login and assign the job to the third user. Logout', () => {
cy.login(); cy.login();
cy.url().should('include', '/tasks'); cy.url().should('include', '/tasks');
cy.get('[value="tasks"]').click(); cy.goToTaskList();
cy.openTask(taskName); cy.openTask(taskName);
cy.get('.cvat-task-job-list').within(() => { cy.get('.cvat-task-job-list').within(() => {
cy.get('.cvat-user-search-field').click({ force: true }); cy.get('.cvat-user-search-field').click({ force: true });
@ -118,7 +120,7 @@ context('Multiple users. Assign task, job.', () => {
it('Third user login. The task can be opened.', () => { it('Third user login. The task can be opened.', () => {
cy.login(thirdUserName, thirdUser.password); cy.login(thirdUserName, thirdUser.password);
cy.url().should('include', '/tasks'); cy.url().should('include', '/tasks');
cy.get('[value="tasks"]').click(); cy.goToTaskList();
cy.contains('strong', taskName).should('exist'); cy.contains('strong', taskName).should('exist');
cy.openTask(taskName); cy.openTask(taskName);
cy.logout(thirdUserName); cy.logout(thirdUserName);

@ -74,6 +74,12 @@ Cypress.Commands.add('openTask', (taskName) => {
cy.contains('strong', taskName).parents('.cvat-tasks-list-item').contains('a', 'Open').click({ force: true }); cy.contains('strong', taskName).parents('.cvat-tasks-list-item').contains('a', 'Open').click({ force: true });
}); });
Cypress.Commands.add('saveJob', () => {
cy.get('button')
.contains('Save')
.click({ force: true });
});
Cypress.Commands.add('openJob', (jobNumber = 0) => { Cypress.Commands.add('openJob', (jobNumber = 0) => {
let tdText = ''; let tdText = '';
cy.get('.ant-table-tbody') cy.get('.ant-table-tbody')

@ -36,6 +36,7 @@ export const multiAttrParams = {
it('Prepare to testing', () => { it('Prepare to testing', () => {
cy.visit('/'); cy.visit('/');
cy.login(); cy.login();
cy.goToTaskList();
cy.get('.cvat-tasks-page').should('exist'); cy.get('.cvat-tasks-page').should('exist');
let listItems = []; let listItems = [];
cy.document().then((doc) => { cy.document().then((doc) => {

@ -6,7 +6,7 @@ require('./commands');
require('@cypress/code-coverage/support'); require('@cypress/code-coverage/support');
before(() => { before(() => {
if (Cypress.browser.name === 'firefox') { if (Cypress.browser.family !== 'chromium') {
cy.visit('/'); cy.visit('/');
cy.get('.ant-modal-body').within(() => { cy.get('.ant-modal-body').within(() => {
cy.get('.ant-modal-confirm-title').should('contain', 'Unsupported platform detected'); cy.get('.ant-modal-confirm-title').should('contain', 'Unsupported platform detected');

Loading…
Cancel
Save