From 252b2a793ff4342cfd95f4e5df078434ef27874e Mon Sep 17 00:00:00 2001 From: Boris Sekachev Date: Mon, 8 Jul 2024 15:44:09 +0300 Subject: [PATCH] Extended client plugins system, refactored job finishing (#8102) --- ...0240701_131217_boris_immediate_feedback.md | 4 + ...0240701_131317_boris_immediate_feedback.md | 4 + ...0240701_131408_boris_immediate_feedback.md | 4 + cvat-ui/src/actions/annotation-actions.ts | 31 +++-- cvat-ui/src/actions/plugins-actions.ts | 12 +- .../annotations-actions-modal.tsx | 8 +- .../single-shape-sidebar.tsx | 68 +++++------ .../top-bar/annotation-menu.tsx | 115 +++++------------- .../components/job-item/job-actions-menu.tsx | 20 +-- cvat-ui/src/components/job-item/job-item.tsx | 77 +++++++----- cvat-ui/src/components/job-item/styles.scss | 24 ++-- cvat-ui/src/components/plugins-entrypoint.tsx | 25 +++- cvat-ui/src/reducers/index.ts | 33 +++-- cvat-ui/src/reducers/plugins-reducer.ts | 48 +++++++- .../CVAT-annotation-Interface/navbar.md | 2 +- .../case_69_filters_sorting_jobs.js | 16 ++- .../task_changes_status_after_initial_save.js | 59 +++++---- .../actions_users/issue_1810_login_logout.js | 3 +- .../case_28_review_pipeline_feature.js | 28 +++-- .../cypress/e2e/features/ground_truth_jobs.js | 8 +- .../e2e/features/single_object_annotation.js | 7 +- tests/cypress/support/commands.js | 28 ++++- 22 files changed, 347 insertions(+), 277 deletions(-) create mode 100644 changelog.d/20240701_131217_boris_immediate_feedback.md create mode 100644 changelog.d/20240701_131317_boris_immediate_feedback.md create mode 100644 changelog.d/20240701_131408_boris_immediate_feedback.md diff --git a/changelog.d/20240701_131217_boris_immediate_feedback.md b/changelog.d/20240701_131217_boris_immediate_feedback.md new file mode 100644 index 000000000000..4843d7a44aa7 --- /dev/null +++ b/changelog.d/20240701_131217_boris_immediate_feedback.md @@ -0,0 +1,4 @@ +### Removed + +- Renew the job button in annotation menu was removed + () diff --git a/changelog.d/20240701_131317_boris_immediate_feedback.md b/changelog.d/20240701_131317_boris_immediate_feedback.md new file mode 100644 index 000000000000..facca20e5b48 --- /dev/null +++ b/changelog.d/20240701_131317_boris_immediate_feedback.md @@ -0,0 +1,4 @@ +### Changed + +- "Finish the job" button on annotation view now only sets state to 'completed'. + The job stage keeps unchanged () diff --git a/changelog.d/20240701_131408_boris_immediate_feedback.md b/changelog.d/20240701_131408_boris_immediate_feedback.md new file mode 100644 index 000000000000..16ca24c1166a --- /dev/null +++ b/changelog.d/20240701_131408_boris_immediate_feedback.md @@ -0,0 +1,4 @@ +### Added + +- User now may update a job state from the corresponding task page + () diff --git a/cvat-ui/src/actions/annotation-actions.ts b/cvat-ui/src/actions/annotation-actions.ts index d8e9d103266a..6cdc5832b127 100644 --- a/cvat-ui/src/actions/annotation-actions.ts +++ b/cvat-ui/src/actions/annotation-actions.ts @@ -11,8 +11,8 @@ import { RectDrawingMethod, CuboidDrawingMethod, Canvas, CanvasMode as Canvas2DMode, } from 'cvat-canvas-wrapper'; import { - getCore, MLModel, JobType, Job, QualityConflict, ObjectState, - JobState, JobStage, + getCore, MLModel, JobType, Job, + QualityConflict, ObjectState, JobState, } from 'cvat-core-wrapper'; import logger, { EventScope } from 'cvat-logger'; import { getCVATStore } from 'cvat-store'; @@ -1001,7 +1001,6 @@ export function getJobAsync({ export function updateCurrentJobAsync( jobFieldsToUpdate: { state?: JobState; - stage?: JobStage; }, ): ThunkAction { return async (dispatch: ThunkDispatch) => { @@ -1019,7 +1018,7 @@ export function updateCurrentJobAsync( }; } -export function saveAnnotationsAsync(afterSave?: () => void): ThunkAction { +export function saveAnnotationsAsync(): ThunkAction { return async (dispatch: ThunkDispatch): Promise => { const { jobInstance } = receiveAnnotationsParameters(); @@ -1045,10 +1044,6 @@ export function saveAnnotationsAsync(afterSave?: () => void): ThunkAction { payload: {}, }); - if (typeof afterSave === 'function') { - afterSave(); - } - dispatch(fetchAnnotationsAsync()); } catch (error) { dispatch({ @@ -1057,6 +1052,26 @@ export function saveAnnotationsAsync(afterSave?: () => void): ThunkAction { error, }, }); + + throw error; + } + }; +} + +export function finishCurrentJobAsync(): ThunkAction { + return async (dispatch: ThunkDispatch, getState) => { + const state = getState(); + const beforeCallbacks = state.plugins.callbacks.annotationPage.header.menu.beforeJobFinish; + const { jobInstance } = receiveAnnotationsParameters(); + + await dispatch(saveAnnotationsAsync()); + + for await (const callback of beforeCallbacks) { + await callback(); + } + + if (jobInstance.state !== JobState.COMPLETED) { + await dispatch(updateCurrentJobAsync({ state: JobState.COMPLETED })); } }; } diff --git a/cvat-ui/src/actions/plugins-actions.ts b/cvat-ui/src/actions/plugins-actions.ts index 1c4857bacd7a..ccaa976a37dc 100644 --- a/cvat-ui/src/actions/plugins-actions.ts +++ b/cvat-ui/src/actions/plugins-actions.ts @@ -1,5 +1,5 @@ // Copyright (C) 2020-2022 Intel Corporation -// Copyright (C) 2023 CVAT.ai Corporation +// Copyright (C) 2023-2024 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -17,6 +17,8 @@ export enum PluginsActionTypes { ADD_PLUGIN = 'ADD_PLUGIN', ADD_UI_COMPONENT = 'ADD_UI_COMPONENT', REMOVE_UI_COMPONENT = 'REMOVE_UI_COMPONENT', + ADD_UI_CALLBACK = 'ADD_UI_CALLBACK', + REMOVE_UI_CALLBACK = 'REMOVE_UI_CALLBACK', } export const pluginActions = { @@ -39,6 +41,14 @@ export const pluginActions = { removeUIComponent: (path: string, component: React.Component) => createAction( PluginsActionTypes.REMOVE_UI_COMPONENT, { path, component }, ), + addUICallback: ( + path: string, + callback: CallableFunction, + ) => createAction(PluginsActionTypes.ADD_UI_CALLBACK, { path, callback }), + removeUICallback: ( + path: string, + callback: CallableFunction, + ) => createAction(PluginsActionTypes.REMOVE_UI_CALLBACK, { path, callback }), }; export type PluginActions = ActionUnion; diff --git a/cvat-ui/src/components/annotation-page/annotations-actions/annotations-actions-modal.tsx b/cvat-ui/src/components/annotation-page/annotations-actions/annotations-actions-modal.tsx index f7799995cbb7..d3069c62dd5f 100644 --- a/cvat-ui/src/components/annotation-page/annotations-actions/annotations-actions-modal.tsx +++ b/cvat-ui/src/components/annotation-page/annotations-actions/annotations-actions-modal.tsx @@ -348,11 +348,9 @@ function AnnotationsActionsModalContent(props: { onClose: () => void; }): JSX.El className='cvat-action-runner-save-job-recommendation' type='link' onClick={() => { - storage.dispatch( - saveAnnotationsAsync(() => { - dispatch(reducerActions.setJobSavedFlag(true)); - }), - ); + storage.dispatch(saveAnnotationsAsync()).then(() => { + dispatch(reducerActions.setJobSavedFlag(true)); + }); }} > Click to save the job diff --git a/cvat-ui/src/components/annotation-page/single-shape-workspace/single-shape-sidebar/single-shape-sidebar.tsx b/cvat-ui/src/components/annotation-page/single-shape-workspace/single-shape-sidebar/single-shape-sidebar.tsx index f3124445e78e..9d5df937b6ff 100644 --- a/cvat-ui/src/components/annotation-page/single-shape-workspace/single-shape-sidebar/single-shape-sidebar.tsx +++ b/cvat-ui/src/components/annotation-page/single-shape-workspace/single-shape-sidebar/single-shape-sidebar.tsx @@ -15,18 +15,16 @@ import Checkbox, { CheckboxChangeEvent } from 'antd/lib/checkbox'; import InputNumber from 'antd/lib/input-number'; import Select from 'antd/lib/select'; import Alert from 'antd/lib/alert'; -import Modal from 'antd/lib/modal'; import Button from 'antd/lib/button'; +import message from 'antd/lib/message'; import { CombinedState, NavigationType, ObjectType } from 'reducers'; import { Canvas, CanvasMode } from 'cvat-canvas-wrapper'; -import { - Job, JobState, Label, LabelType, -} from 'cvat-core-wrapper'; +import { Job, Label, LabelType } from 'cvat-core-wrapper'; import { ActionUnion, createAction } from 'utils/redux'; import { - rememberObject, changeFrameAsync, saveAnnotationsAsync, setNavigationType, - removeObjectAsync, updateCurrentJobAsync, + rememberObject, changeFrameAsync, setNavigationType, + removeObjectAsync, finishCurrentJobAsync, } from 'actions/annotation-actions'; import LabelSelector from 'components/label-selector/label-selector'; import GlobalHotKeys from 'utils/mousetrap-react'; @@ -47,15 +45,6 @@ function cancelCurrentCanvasOp(state: CombinedState): void { } } -function showSubmittedInfo(): void { - Modal.info({ - closable: false, - title: 'Annotations submitted', - content: 'You may close the window', - className: 'cvat-single-shape-annotation-submit-success-modal', - }); -} - function makeMessage(label: Label, labelType: State['labelType'], pointsCount: number): JSX.Element { let readableShape = ''; if (labelType === LabelType.POINTS) { @@ -77,8 +66,8 @@ function makeMessage(label: Label, labelType: State['labelType'], pointsCount: n } export const actionCreators = { - switchAutoNextFrame: () => ( - createAction(ReducerActionType.SWITCH_AUTO_NEXT_FRAME) + switchAutoNextFrame: (autoNextFrame: boolean) => ( + createAction(ReducerActionType.SWITCH_AUTO_NEXT_FRAME, { autoNextFrame }) ), switchAutoSaveOnFinish: () => ( createAction(ReducerActionType.SWITCH_AUTOSAVE_ON_FINISH) @@ -129,7 +118,7 @@ const reducer = (state: State, action: ActionUnion): Stat if (action.type === ReducerActionType.SWITCH_AUTO_NEXT_FRAME) { return { ...state, - autoNextFrame: !state.autoNextFrame, + autoNextFrame: action.payload.autoNextFrame, }; } @@ -236,18 +225,22 @@ function SingleShapeSidebar(): JSX.Element { const getNextFrame = useCallback(() => { if (frame + 1 > jobInstance.stopFrame) { - return Promise.resolve(null); + dispatch(actionCreators.setNextFrame(null)); + return; } - return jobInstance.annotations.search(frame + 1, jobInstance.stopFrame, { + jobInstance.annotations.search(frame + 1, jobInstance.stopFrame, { allowDeletedFrames: false, ...(navigationType === NavigationType.EMPTY ? { generalFilters: { isEmptyFrame: true, }, } : {}), - }) as Promise; - }, [jobInstance, navigationType, frame]); + }).then((_frame: number | null) => { + dispatch(actionCreators.setNextFrame(_frame)); + }); + // implicitly depends on annotations because may use notEmpty filter + }, [jobInstance, navigationType, frame, annotations]); const finishOnThisFrame = useCallback((forceSave = false): void => { if (typeof state.nextFrame === 'number') { @@ -255,21 +248,18 @@ function SingleShapeSidebar(): JSX.Element { } else if ((forceSave || state.saveOnFinish) && !savingRef.current) { savingRef.current = true; - const patchJob = (): void => { - appDispatch(updateCurrentJobAsync({ - state: JobState.COMPLETED, - })).then(showSubmittedInfo).finally(() => { - savingRef.current = false; + appDispatch(finishCurrentJobAsync()).then(() => { + message.open({ + duration: 1, + type: 'success', + content: 'You tagged the job as completed', + className: 'cvat-annotation-job-finished-success', }); - }; - - if (jobInstance.annotations.hasUnsavedChanges()) { - appDispatch(saveAnnotationsAsync(patchJob)).catch(() => { - savingRef.current = false; - }); - } else { - patchJob(); - } + }).finally(() => { + appDispatch(setNavigationType(NavigationType.REGULAR)); + dispatch(actionCreators.switchAutoNextFrame(false)); + savingRef.current = false; + }); } }, [state.saveOnFinish, state.nextFrame, jobInstance]); @@ -292,9 +282,7 @@ function SingleShapeSidebar(): JSX.Element { }, []); useEffect(() => { - getNextFrame().then((_frame: number | null) => { - dispatch(actionCreators.setNextFrame(_frame)); - }); + getNextFrame(); }, [getNextFrame]); useEffect(() => { @@ -540,7 +528,7 @@ function SingleShapeSidebar(): JSX.Element { checked={state.autoNextFrame} onChange={(): void => { (window.document.activeElement as HTMLInputElement)?.blur(); - dispatch(actionCreators.switchAutoNextFrame()); + dispatch(actionCreators.switchAutoNextFrame(!state.autoNextFrame)); }} > Automatically go to the next frame diff --git a/cvat-ui/src/components/annotation-page/top-bar/annotation-menu.tsx b/cvat-ui/src/components/annotation-page/top-bar/annotation-menu.tsx index 23be048a1f3f..f845b30233df 100644 --- a/cvat-ui/src/components/annotation-page/top-bar/annotation-menu.tsx +++ b/cvat-ui/src/components/annotation-page/top-bar/annotation-menu.tsx @@ -19,14 +19,13 @@ import Icon from '@ant-design/icons'; import { MenuProps } from 'antd/lib/menu'; import { MainMenuIcon } from 'icons'; -import { Job, JobStage, JobState } from 'cvat-core-wrapper'; +import { Job, JobState } from 'cvat-core-wrapper'; import CVATTooltip from 'components/common/cvat-tooltip'; import AnnotationsActionsModalContent from 'components/annotation-page/annotations-actions/annotations-actions-modal'; import { CombinedState } from 'reducers'; import { - saveAnnotationsAsync, updateCurrentJobAsync, - setForceExitAnnotationFlag as setForceExitAnnotationFlagAction, + updateCurrentJobAsync, finishCurrentJobAsync, removeAnnotationsAsync as removeAnnotationsAsyncAction, } from 'actions/annotation-actions'; import { exportActions } from 'actions/export-actions'; @@ -39,7 +38,6 @@ export enum Actions { RUN_ACTIONS = 'run_actions', OPEN_TASK = 'open_task', FINISH_JOB = 'finish_job', - RENEW_JOB = 'renew_job', } function AnnotationMenuComponent(): JSX.Element { @@ -47,58 +45,22 @@ function AnnotationMenuComponent(): JSX.Element { const history = useHistory(); const jobInstance = useSelector((state: CombinedState) => state.annotation.job.instance as Job); const [jobState, setJobState] = useState(jobInstance.state); - const { stage: jobStage, stopFrame } = jobInstance; - - const checkUnsavedChanges = useCallback((callback: () => void) => { - if (jobInstance.annotations.hasUnsavedChanges()) { - Modal.confirm({ - title: 'The job has unsaved annotations', - content: 'Would you like to save changes before continue?', - className: 'cvat-modal-content-save-job', - okButtonProps: { - children: 'Save', - }, - cancelButtonProps: { - children: 'No', - }, - onOk: () => { - dispatch(saveAnnotationsAsync(callback)); - }, - onCancel: () => { - // do not ask leave confirmation - dispatch(setForceExitAnnotationFlagAction(true)); - setTimeout(() => { - callback(); - }); - }, - }); - } else { - callback(); - } - }, [jobInstance]); + const { stopFrame } = jobInstance; const exportDataset = useCallback(() => { dispatch(exportActions.openExportDatasetModal(jobInstance)); }, [jobInstance]); - const renewJob = useCallback(() => { - dispatch(updateCurrentJobAsync({ - state: JobState.NEW, - stage: JobStage.ANNOTATION, - })).then(() => { - message.info('Job renewed', 2); - setJobState(jobInstance.state); - }); - }, [jobInstance]); - const finishJob = useCallback(() => { - dispatch(updateCurrentJobAsync({ - state: JobState.COMPLETED, - stage: JobStage.ACCEPTANCE, - })).then(() => { - history.push(`/tasks/${jobInstance.taskId}`); + dispatch(finishCurrentJobAsync()).then(() => { + message.open({ + duration: 1, + type: 'success', + content: 'You tagged the job as completed', + className: 'cvat-annotation-job-finished-success', + }); }); - }, [jobInstance]); + }, []); const openTask = useCallback(() => { history.push(`/tasks/${jobInstance.taskId}`); @@ -117,14 +79,12 @@ function AnnotationMenuComponent(): JSX.Element { const changeJobState = useCallback((state: JobState) => () => { Modal.confirm({ - title: 'Do you want to change current job state?', - content: `Job state will be switched to "${state}". Continue?`, + title: 'Would you like to update current job state?', + content: `Job state will be switched to "${state}"`, okText: 'Continue', cancelText: 'Cancel', className: 'cvat-modal-content-change-job-state', - onOk: () => { - checkUnsavedChanges(() => changeState(state)); - }, + onOk: () => changeState(state), }); }, [changeState]); @@ -266,39 +226,20 @@ function AnnotationMenuComponent(): JSX.Element { }], }); - if ([JobStage.ANNOTATION, JobStage.VALIDATION].includes(jobStage)) { - menuItems.push({ - key: Actions.FINISH_JOB, - label: 'Finish the job', - onClick: () => { - Modal.confirm({ - title: 'The job stage is going to be switched', - content: 'Stage will be changed to "acceptance". Would you like to continue?', - okText: 'Continue', - cancelText: 'Cancel', - className: 'cvat-modal-content-finish-job', - onOk: () => { - checkUnsavedChanges(finishJob); - }, - }); - }, - }); - } else { - menuItems.push({ - key: Actions.RENEW_JOB, - label: 'Renew the job', - onClick: () => { - Modal.confirm({ - title: 'Do you want to renew the job?', - content: 'Stage will be set to "in progress", state will be set to "annotation". Would you like to continue?', - okText: 'Continue', - cancelText: 'Cancel', - className: 'cvat-modal-content-renew-job', - onOk: renewJob, - }); - }, - }); - } + menuItems.push({ + key: Actions.FINISH_JOB, + label: 'Finish the job', + onClick: () => { + Modal.confirm({ + title: 'Would you like to finish the job?', + content: 'It will save annotations and set the job state to "completed"', + okText: 'Continue', + cancelText: 'Cancel', + className: 'cvat-modal-content-finish-job', + onOk: finishJob, + }); + }, + }); return ( @@ -78,10 +66,6 @@ function JobActionsMenu(props: Props): JSX.Element { Import annotations Export annotations View analytics - {[JobStage.ANNOTATION, JobStage.VALIDATION].includes(job.stage) ? - Finish the job : null} - {job.stage === JobStage.ACCEPTANCE ? - Renew the job : null} - + {`Job #${job.id}`} { - job.type === JobType.GROUND_TRUTH && ( + job.type === JobType.GROUND_TRUTH ? ( Ground truth + ) : ( + + }> + + + ) } @@ -143,7 +149,7 @@ function JobItem(props: Props): JSX.Element { - + @@ -165,11 +171,6 @@ function JobItem(props: Props): JSX.Element { Stage: - - }> - - - + + + + State: + + + + - + - - State: - - {`${job.state.charAt(0).toUpperCase() + job.state.slice(1)}`} - + + Duration: + {`${moment.duration(now.diff(created)).humanize()}`} - - Duration: - {`${moment.duration(now.diff(created)).humanize()}`} + + Frame count: + + {`${job.frameCount} (${frameCountPercentRepresentation}%)`} + - - { job.type !== JobType.GROUND_TRUTH && ( @@ -227,15 +253,6 @@ function JobItem(props: Props): JSX.Element { ) } - - - - Frame count: - - {`${job.frameCount} (${frameCountPercentRepresentation}%)`} - - - diff --git a/cvat-ui/src/components/job-item/styles.scss b/cvat-ui/src/components/job-item/styles.scss index 16a69e55cf37..39a137000ea7 100644 --- a/cvat-ui/src/components/job-item/styles.scss +++ b/cvat-ui/src/components/job-item/styles.scss @@ -10,6 +10,10 @@ margin-bottom: $grid-unit-size; background: $background-color-1; + .cvat-job-item-issues-summary-icon { + margin-left: $grid-unit-size; + } + &:hover { transition: box-shadow $box-shadow-transition; box-shadow: $box-shadow-base; @@ -27,15 +31,19 @@ } .cvat-job-item-selects { - padding-top: $grid-unit-size; - .cvat-job-item-select { - >div:first-child { + width: $grid-unit-size * 16; + + > :first-child { margin-bottom: $grid-unit-size; } + + .ant-select { + width: 100%; + } } - .cvat-job-assignee-selector { + .cvat-job-item-select:not(:last-child) { margin-right: $grid-unit-size; } } @@ -63,14 +71,6 @@ .cvat-job-item-dates-info { margin-top: $grid-unit-size; } - - .cvat-job-assignee-selector { - max-width: $grid-unit-size * 16; - - @media screen and (width <= 1620px) { - max-width: $grid-unit-size * 12; - } - } } .ant-menu.cvat-job-item-menu { diff --git a/cvat-ui/src/components/plugins-entrypoint.tsx b/cvat-ui/src/components/plugins-entrypoint.tsx index eda58d85a6dd..1108ef951f0b 100644 --- a/cvat-ui/src/components/plugins-entrypoint.tsx +++ b/cvat-ui/src/components/plugins-entrypoint.tsx @@ -1,4 +1,4 @@ -// Copyright (C) 2023 CVAT.ai Corporation +// Copyright (C) 2023-2024 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -9,11 +9,19 @@ import { useDispatch } from 'react-redux'; import { PluginsActionTypes, pluginActions } from 'actions/plugins-actions'; import { getCore, CVATCore, APIWrapperEnterOptions } from 'cvat-core-wrapper'; import { modelsActions } from 'actions/models-actions'; +import { changeFrameAsync, updateCurrentJobAsync } from 'actions/annotation-actions'; +import { getCVATStore } from 'cvat-store'; const core = getCore(); export type PluginActionCreators = { getModelsSuccess: typeof modelsActions['getModelsSuccess'], + changeFrameAsync: typeof changeFrameAsync, + addUIComponent: typeof pluginActions['addUIComponent'], + removeUIComponent: typeof pluginActions['removeUIComponent'], + addUICallback: typeof pluginActions['addUICallback'], + removeUICallback: typeof pluginActions['removeUICallback'], + updateCurrentJobAsync: typeof updateCurrentJobAsync, }; export type ComponentBuilder = ({ @@ -22,12 +30,20 @@ export type ComponentBuilder = ({ REMOVE_ACTION, actionCreators, core, + store, }: { dispatch: Dispatch, + /** + * @deprecated Please, use actionCreators.addUIComponent instead + */ REGISTER_ACTION: PluginsActionTypes.ADD_UI_COMPONENT, + /** + * @deprecated Please, use actionCreators.removeUIComponent instead + */ REMOVE_ACTION: PluginsActionTypes.REMOVE_UI_COMPONENT, actionCreators: PluginActionCreators, core: CVATCore, + store: ReturnType }) => { name: string; destructor: CallableFunction; @@ -51,9 +67,16 @@ function PluginEntrypoint(): null { REGISTER_ACTION: PluginsActionTypes.ADD_UI_COMPONENT, REMOVE_ACTION: PluginsActionTypes.REMOVE_UI_COMPONENT, actionCreators: { + changeFrameAsync, + updateCurrentJobAsync, getModelsSuccess: modelsActions.getModelsSuccess, + addUICallback: pluginActions.addUICallback, + removeUICallback: pluginActions.removeUICallback, + addUIComponent: pluginActions.addUIComponent, + removeUIComponent: pluginActions.removeUIComponent, }, core, + store: getCVATStore(), }); dispatch(pluginActions.addPlugin(name, destructor, globalStateDidUpdate)); diff --git a/cvat-ui/src/reducers/index.ts b/cvat-ui/src/reducers/index.ts index c23db391f0e2..6ed20a30c195 100644 --- a/cvat-ui/src/reducers/index.ts +++ b/cvat-ui/src/reducers/index.ts @@ -251,10 +251,19 @@ export interface PluginsState { globalStateDidUpdate?: CallableFunction; }; }; + callbacks: { + annotationPage: { + header: { + menu: { + beforeJobFinish: (() => Promise)[]; + }; + }; + }; + }; components: { header: { userMenu: { - items: PluginComponent[], + items: PluginComponent[]; }; }; loginPage: { @@ -262,18 +271,18 @@ export interface PluginsState { }; modelsPage: { topBar: { - items: PluginComponent[], - }, + items: PluginComponent[]; + }; modelItem: { menu: { - items: PluginComponent[], - }, + items: PluginComponent[]; + }; topBar:{ menu: { - items: PluginComponent[], - } - }, - } + items: PluginComponent[]; + }; + }; + }; }; projectActions: { items: PluginComponent[]; @@ -294,12 +303,12 @@ export interface PluginsState { }; settings: { player: PluginComponent[]; - } + }; about: { links: { items: PluginComponent[]; - } - } + }; + }; router: PluginComponent[]; loggedInModals: PluginComponent[]; } diff --git a/cvat-ui/src/reducers/plugins-reducer.ts b/cvat-ui/src/reducers/plugins-reducer.ts index 97531f43b217..1ad69517ef9c 100644 --- a/cvat-ui/src/reducers/plugins-reducer.ts +++ b/cvat-ui/src/reducers/plugins-reducer.ts @@ -1,5 +1,5 @@ // Copyright (C) 2020-2022 Intel Corporation -// Copyright (C) 2023 CVAT.ai Corporation +// Copyright (C) 2023-2024 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -14,6 +14,15 @@ const defaultState: PluginsState = { MODELS: false, }, current: {}, + callbacks: { + annotationPage: { + header: { + menu: { + beforeJobFinish: [], + }, + }, + }, + }, components: { header: { userMenu: { @@ -68,9 +77,9 @@ const defaultState: PluginsState = { }, }; -function findContainerFromPath(path: string, state: PluginsState): PluginComponent[] { +function findContainerFromPath(path: string, state: PluginsState, prefix: 'components' | 'callbacks'): unknown[] { const pathSegments = path.split('.'); - let updatedStateSegment: any = state.components; + let updatedStateSegment: any = state[prefix]; for (const pathSegment of pathSegments) { if (Array.isArray(updatedStateSegment[pathSegment])) { updatedStateSegment[pathSegment] = [...updatedStateSegment[pathSegment]]; @@ -79,7 +88,7 @@ function findContainerFromPath(path: string, state: PluginsState): PluginCompone } updatedStateSegment = updatedStateSegment[pathSegment]; if (typeof updatedStateSegment === 'undefined') { - throw new Error('Could not add plugin component. Path is not supported by the core application'); + throw new Error('The specified plugins path is not supported by the core application'); } } @@ -123,7 +132,7 @@ export default function (state: PluginsState = defaultState, action: PluginActio components: { ...state.components }, }; - const container = findContainerFromPath(path, updatedState); + const container = findContainerFromPath(path, updatedState, 'components') as PluginComponent[]; container.push({ component, data: { @@ -146,7 +155,7 @@ export default function (state: PluginsState = defaultState, action: PluginActio components: { ...state.components }, }; - const container = findContainerFromPath(path, updatedState); + const container = findContainerFromPath(path, updatedState, 'components') as PluginComponent[]; const index = container.findIndex((el) => el.component === component); if (index !== -1) { container.splice(index, 1); @@ -154,6 +163,33 @@ export default function (state: PluginsState = defaultState, action: PluginActio return updatedState; } + case PluginsActionTypes.ADD_UI_CALLBACK: { + const { path, callback } = action.payload; + const updatedState = { + ...state, + components: { ...state.components }, + }; + + const container = findContainerFromPath(path, updatedState, 'callbacks') as CallableFunction[]; + container.push(callback); + + return updatedState; + } + case PluginsActionTypes.REMOVE_UI_CALLBACK: { + const { path, callback } = action.payload; + const updatedState = { + ...state, + components: { ...state.components }, + }; + + const container = findContainerFromPath(path, updatedState, 'callbacks') as CallableFunction[]; + const index = container.findIndex((_callback) => _callback === callback); + if (index !== -1) { + container.splice(index, 1); + } + + return updatedState; + } case PluginsActionTypes.ADD_PLUGIN: { const { name, destructor, globalStateDidUpdate } = action.payload; return { diff --git a/site/content/en/docs/manual/basics/CVAT-annotation-Interface/navbar.md b/site/content/en/docs/manual/basics/CVAT-annotation-Interface/navbar.md index 4288ebe4849b..74a6af04120b 100644 --- a/site/content/en/docs/manual/basics/CVAT-annotation-Interface/navbar.md +++ b/site/content/en/docs/manual/basics/CVAT-annotation-Interface/navbar.md @@ -33,7 +33,7 @@ and access other features listed in the table below: | **Run actions** | Run annotation actions on the annotated dataset. [Annotations action](/docs/enterprise/shapes-converter/) is a feature that allows you to modify a bulk of annotations on many frames. It supports only `shape` objects.                                                                                                                                                                                                                                                                       | | **Open the task** | Opens a page with details about the task.                                                                                                                                                                                                                                                                                                                                                                                                                                                     | | **Change job state** | Changes the state of the job:
  • **New**: The job is newly created and has not been started yet. It is waiting for annotation work to begin.
  • **In Progress**: The job is currently being worked on. Annotations are being added or edited.
  • **Rejected**: The job has been reviewed and deemed unsatisfactory or incorrect. It requires revisions and further work.
  • **Completed**: The job has been finished, reviewed, and approved. No further changes are necessary.
      | -| **Finish the job** / **Renew the job** | Changes the [**Job stage**](/docs/manual/advanced/iam_user_roles/#job-stage) and state to **Acceptance** and **Completed** / **Annotation** and **New**, correspondingly.                                                                                                                                                                                                                                                                                                                     | +| **Finish the job** | Saves annotations and sets **job state** to **Completed**. | diff --git a/tests/cypress/e2e/actions_tasks/registration_involved/case_69_filters_sorting_jobs.js b/tests/cypress/e2e/actions_tasks/registration_involved/case_69_filters_sorting_jobs.js index bc605602d456..a1a82bd820e8 100644 --- a/tests/cypress/e2e/actions_tasks/registration_involved/case_69_filters_sorting_jobs.js +++ b/tests/cypress/e2e/actions_tasks/registration_involved/case_69_filters_sorting_jobs.js @@ -51,7 +51,7 @@ context('Filtering, sorting jobs.', () => { .contains('a', `Job #${$job}`) .parents('.cvat-job-item').within(() => { cy.get('.cvat-job-item-stage .ant-select-selection-item').should('have.text', stage); - cy.get('.cvat-job-item-state').should('have.text', state); + cy.get('.cvat-job-item-state .ant-select-selection-item').should('have.text', state); cy.get('.cvat-job-assignee-selector') .find('input') .invoke('val') @@ -137,9 +137,7 @@ context('Filtering, sorting jobs.', () => { cy.getJobIDFromIdx(0).then((jobID) => cy.setJobStage(jobID, 'validation')); // The second job - status "completed" - cy.openJob(1); - cy.setJobState('completed'); - cy.interactMenu('Open the task'); + cy.getJobIDFromIdx(1).then((jobID) => cy.setJobState(jobID, 'completed')); }); after(() => { @@ -153,9 +151,9 @@ context('Filtering, sorting jobs.', () => { describe(`Testing "${labelName}".`, () => { it('Check all statuses.', () => { checkJobsTableRowCount(3); - checkContentsRow(0, 'validation', 'New', secondUserName); // The 1st job - checkContentsRow(1, 'annotation', 'Completed', secondUserName); // The 2nd job - checkContentsRow(2, 'annotation', 'New', ''); // The 3th job + checkContentsRow(0, 'validation', 'new', secondUserName); // The 1st job + checkContentsRow(1, 'annotation', 'completed', secondUserName); // The 2nd job + checkContentsRow(2, 'annotation', 'new', ''); // The 3th job }); it('Filtering jobs by stage (annotation, validation, acceptance).', () => { @@ -200,7 +198,7 @@ context('Filtering, sorting jobs.', () => { checkJobsTableRowCount(1); testSetJobFilter({ column: 'Assignee', menuItem: secondUserName }); checkJobsTableRowCount(1); - checkContentsRow(0, 'validation', 'New', secondUserName); + checkContentsRow(0, 'validation', 'new', secondUserName); testSetJobFilter({ reset: true }); checkJobsTableRowCount(3); }); @@ -224,7 +222,7 @@ context('Filtering, sorting jobs.', () => { sortState.push(element.text()); }); cy.get('.cvat-job-item-state').then(() => { - expect(sortState).to.deep.equal(['Completed', 'New', 'New']); + expect(sortState).to.deep.equal(['completed', 'new', 'new']); }); testSetJobSorting({ column: 'State', reset: true }); }); diff --git a/tests/cypress/e2e/actions_tasks/task_changes_status_after_initial_save.js b/tests/cypress/e2e/actions_tasks/task_changes_status_after_initial_save.js index 006f2fad7453..3b08d1de3bf9 100644 --- a/tests/cypress/e2e/actions_tasks/task_changes_status_after_initial_save.js +++ b/tests/cypress/e2e/actions_tasks/task_changes_status_after_initial_save.js @@ -8,19 +8,6 @@ context('Task status updated after initial save.', () => { const labelName = 'car'; const taskName = 'Test task status updated after initial save'; - const attrName = 'Dummy attribute'; - const textDefaultValue = 'Test'; - const imagesCount = 1; - const imageFileName = `image_${labelName}`; - const width = 800; - const height = 800; - const posX = 10; - const posY = 10; - const color = 'gray'; - const archiveName = `${imageFileName}.zip`; - const archivePath = `cypress/fixtures/${archiveName}`; - const imagesFolder = `cypress/fixtures/${imageFileName}`; - const directoryToArchive = imagesFolder; const rectangleData = { points: 'By 2 Points', type: 'Shape', @@ -31,34 +18,56 @@ context('Task status updated after initial save.', () => { secondY: 450, }; + let taskID = null; + let jobID = null; + const serverFiles = ['images/image_1.jpg']; + before(() => { + cy.headlessLogout(); cy.visit('auth/login'); cy.login(); - cy.imageGenerator(imagesFolder, imageFileName, width, height, color, posX, posY, labelName, imagesCount); - cy.createZipArchive(directoryToArchive, archivePath); - cy.createAnnotationTask(taskName, labelName, attrName, textDefaultValue, archiveName); + cy.url().should('contain', '/tasks'); + cy.headlessCreateTask({ + labels: [{ name: labelName, attributes: [], type: 'any' }], + name: taskName, + project_id: null, + source_storage: { location: 'local' }, + target_storage: { location: 'local' }, + }, { + server_files: serverFiles, + image_quality: 70, + use_zip_chunks: true, + use_cache: true, + sorting_method: 'lexicographical', + }).then((response) => { + taskID = response.taskID; + [jobID] = response.jobIDs; + }); }); after(() => { - cy.deleteTask(taskName); - }); - - afterEach(() => { - cy.goToTaskList(); + if (taskID) { + cy.headlessDeleteTask(taskID); + } }); describe(`Testing "${labelName}"`, () => { it('State of the created task should be "new".', () => { - cy.openTask(taskName); - cy.get('.cvat-job-item .cvat-job-item-state').invoke('text').should('equal', 'New'); + cy.intercept('GET', `/tasks/${taskID}`).as('visitTaskPage'); + cy.visit(`/tasks/${taskID}`); + cy.wait('@visitTaskPage'); + cy.get('.cvat-job-item .cvat-job-item-state .ant-select-selection-item').invoke('text').should('equal', 'new'); }); it('Create object, save annotation, state should be "in progress"', () => { - cy.openTaskJob(taskName); + cy.intercept('GET', `/tasks/${taskID}/jobs/${jobID}`).as('visitAnnotationView'); + cy.visit(`/tasks/${taskID}/jobs/${jobID}`); + cy.wait('@visitAnnotationView'); + cy.get('.cvat-canvas-container').should('exist').and('be.visible'); cy.createRectangle(rectangleData); cy.saveJob(); cy.interactMenu('Open the task'); - cy.get('.cvat-job-item .cvat-job-item-state').invoke('text').should('equal', 'In progress'); + cy.get('.cvat-job-item .cvat-job-item-state .ant-select-selection-item').invoke('text').should('equal', 'in progress'); }); }); }); diff --git a/tests/cypress/e2e/actions_users/issue_1810_login_logout.js b/tests/cypress/e2e/actions_users/issue_1810_login_logout.js index 63bba2c28e1d..391c04b1a1f8 100644 --- a/tests/cypress/e2e/actions_users/issue_1810_login_logout.js +++ b/tests/cypress/e2e/actions_users/issue_1810_login_logout.js @@ -20,8 +20,7 @@ context('When clicking on the Logout button, get the user session closed.', () = } before(() => { - cy.clearAllCookies(); - cy.clearAllLocalStorage(); + cy.headlessLogout(); cy.visit('auth/login'); }); diff --git a/tests/cypress/e2e/actions_users/registration_involved/case_28_review_pipeline_feature.js b/tests/cypress/e2e/actions_users/registration_involved/case_28_review_pipeline_feature.js index d5675f40f843..c77e00df3ad1 100644 --- a/tests/cypress/e2e/actions_users/registration_involved/case_28_review_pipeline_feature.js +++ b/tests/cypress/e2e/actions_users/registration_involved/case_28_review_pipeline_feature.js @@ -56,14 +56,16 @@ context('Review pipeline feature', () => { let jobIDs = null; before(() => { + cy.headlessLogout(); + cy.visit('auth/login'); cy.get('.cvat-login-form-wrapper').should('exist').and('be.visible'); // register additional users - cy.clearCookies(); + cy.headlessLogout(); for (const user of Object.values(additionalUsers)) { cy.headlessCreateUser(user); - cy.clearCookies(); + cy.headlessLogout(); } // create main task @@ -137,9 +139,9 @@ context('Review pipeline feature', () => { // Annotator updates job state, both times update is successfull, logout // check: https://github.com/cvat-ai/cvat/pull/7158 cy.intercept('PATCH', `/api/jobs/${jobIDs[0]}`).as('updateJobState'); - cy.setJobState('completed'); + cy.updateJobStateOnAnnotationView('completed'); cy.wait('@updateJobState').its('response.statusCode').should('equal', 200); - cy.setJobState('completed'); + cy.updateJobStateOnAnnotationView('completed'); cy.wait('@updateJobState').its('response.statusCode').should('equal', 200); cy.logout(); @@ -147,7 +149,7 @@ context('Review pipeline feature', () => { cy.login(); cy.openTask(taskSpec.name); cy.get('.cvat-job-item').first().within(() => { - cy.get('.cvat-job-item-state').should('have.text', 'Completed'); + cy.get('.cvat-job-item-state .ant-select-selection-item').should('have.text', 'completed'); cy.get('.cvat-job-item-stage .ant-select-selection-item').should('have.text', 'annotation'); }); cy.setJobStage(jobIDs[0], 'validation'); @@ -231,7 +233,7 @@ context('Review pipeline feature', () => { cy.get('.cvat-notification-notice-save-annotations-failed').should('not.exist'); // Finally, the reviewer rejects the job, logouts - cy.setJobState('rejected'); + cy.updateJobStateOnAnnotationView('rejected'); cy.logout(); // Requester logins and assignes the job to the annotator, sets job stage to annotation @@ -239,7 +241,7 @@ context('Review pipeline feature', () => { cy.get('.cvat-tasks-page').should('exist').and('be.visible'); cy.openTask(taskSpec.name); cy.get('.cvat-job-item').first().within(() => { - cy.get('.cvat-job-item-state').should('have.text', 'Rejected'); + cy.get('.cvat-job-item-state .ant-select-selection-item').should('have.text', 'rejected'); cy.get('.cvat-job-item-stage .ant-select-selection-item').should('have.text', 'validation'); }); cy.setJobStage(jobIDs[0], 'annotation'); @@ -333,7 +335,7 @@ context('Review pipeline feature', () => { } } - cy.setJobState('completed'); + cy.updateJobStateOnAnnotationView('completed'); cy.logout(); // Requester logins, removes all the issues, finishes the job @@ -358,15 +360,19 @@ context('Review pipeline feature', () => { }); } - // check: https://github.com/cvat-ai/cvat/issues/7206 cy.interactMenu('Finish the job'); + cy.get('.cvat-modal-content-finish-job').within(() => { cy.contains('button', 'Continue').click(); }); + + cy.interactMenu('Open the task'); cy.get('.cvat-job-item').first().within(() => { - cy.get('.cvat-job-item-state').should('have.text', 'Completed'); - cy.get('.cvat-job-item-stage .ant-select-selection-item').should('have.text', 'acceptance'); + cy.get('.cvat-job-item-state .ant-select-selection-item').should('have.text', 'completed'); }); + + cy.setJobStage(jobIDs[0], 'acceptance'); + cy.setJobState(jobIDs[0], 'completed'); }); }); }); diff --git a/tests/cypress/e2e/features/ground_truth_jobs.js b/tests/cypress/e2e/features/ground_truth_jobs.js index 555ca5d6ddb3..098f41282394 100644 --- a/tests/cypress/e2e/features/ground_truth_jobs.js +++ b/tests/cypress/e2e/features/ground_truth_jobs.js @@ -324,11 +324,11 @@ context('Ground truth jobs', () => { }); cy.saveJob(); - cy.interactMenu('Finish the job'); - cy.get('.cvat-modal-content-finish-job').within(() => { - cy.contains('button', 'Continue').click(); - }); + cy.interactMenu('Open the task'); + // job index is 2 because one gt job has been removed + cy.getJobIDFromIdx(2).then((gtJobID) => cy.setJobStage(gtJobID, 'acceptance')); + cy.getJobIDFromIdx(2).then((gtJobID) => cy.setJobState(gtJobID, 'completed')); cy.get('.cvat-job-item').contains('a', `Job #${jobID}`).click(); cy.changeWorkspace('Review'); diff --git a/tests/cypress/e2e/features/single_object_annotation.js b/tests/cypress/e2e/features/single_object_annotation.js index 2b1ad294e1bd..1ebbe9f6b6b8 100644 --- a/tests/cypress/e2e/features/single_object_annotation.js +++ b/tests/cypress/e2e/features/single_object_annotation.js @@ -97,9 +97,8 @@ context('Single object annotation mode', { scrollBehavior: false }, () => { } cy.wait('@submitJob').its('response.statusCode').should('equal', 200); - - cy.get('.cvat-single-shape-annotation-submit-success-modal').should('exist'); - cy.get('.cvat-single-shape-annotation-submit-success-modal').within(() => { cy.contains('OK').click(); }); + cy.get('.cvat-annotation-job-finished-success').should('exist'); + cy.get('.cvat-annotation-job-finished-success').should('not.exist'); } function changeLabel(labelName) { @@ -226,7 +225,7 @@ context('Single object annotation mode', { scrollBehavior: false }, () => { cy.get('[type="checkbox"]').uncheck(); }); clickPoints(polygonShape); - cy.get('.cvat-single-shape-annotation-submit-success-modal').should('not.exist'); + cy.get('.cvat-annotation-job-finished-success').should('not.exist'); // Navigate only on empty frames cy.get('.cvat-player-previous-button-empty').click(); diff --git a/tests/cypress/support/commands.js b/tests/cypress/support/commands.js index af2e79492948..2e000ca8c079 100644 --- a/tests/cypress/support/commands.js +++ b/tests/cypress/support/commands.js @@ -331,6 +331,13 @@ Cypress.Commands.add('headlessDeleteProject', (projectID) => { }); }); +Cypress.Commands.add('headlessDeleteTask', (taskID) => { + cy.window().then(async ($win) => { + const [task] = await $win.cvat.tasks.get({ id: taskID }); + await task.delete(); + }); +}); + Cypress.Commands.add('headlessCreateUser', (userSpec) => { cy.request({ method: 'POST', @@ -351,6 +358,11 @@ Cypress.Commands.add('headlessCreateUser', (userSpec) => { return cy.wrap(); }); +Cypress.Commands.add('headlessLogout', () => { + cy.clearAllCookies(); + cy.clearAllLocalStorage(); +}); + Cypress.Commands.add('openTask', (taskName, projectSubsetFieldValue) => { cy.contains('strong', new RegExp(`^${taskName}$`)) .parents('.cvat-tasks-list-item') @@ -1141,7 +1153,7 @@ Cypress.Commands.add('interactMenu', (choice) => { cy.get('.cvat-spinner').should('not.exist'); }); -Cypress.Commands.add('setJobState', (choice) => { +Cypress.Commands.add('updateJobStateOnAnnotationView', (choice) => { cy.interactMenu('Change job state'); cy.get('.cvat-annotation-menu-job-state-submenu') .should('not.have.class', 'ant-zoom-big').within(() => { @@ -1156,6 +1168,20 @@ Cypress.Commands.add('setJobState', (choice) => { cy.get('.cvat-spinner').should('not.exist'); }); +Cypress.Commands.add('setJobState', (jobID, state) => { + cy.get('.cvat-task-job-list') + .contains('a', `Job #${jobID}`) + .parents('.cvat-job-item') + .find('.cvat-job-item-state').click(); + cy.get('.ant-select-dropdown') + .should('be.visible') + .not('.ant-select-dropdown-hidden') + .within(() => { + cy.get(`[title="${state}"]`).click(); + }); + cy.get('.cvat-spinner').should('not.exist'); +}); + Cypress.Commands.add('setJobStage', (jobID, stage) => { cy.get('.cvat-task-job-list') .contains('a', `Job #${jobID}`)