From 5d318e0f5f28e0c377e0f2aec6822e4cbdc87561 Mon Sep 17 00:00:00 2001 From: Kirill Lakhov Date: Fri, 10 Jun 2022 09:52:31 +0300 Subject: [PATCH] Incremental jobs update (#42) --- CHANGELOG.md | 2 +- cvat-ui/package.json | 2 +- cvat-ui/src/actions/tasks-actions.ts | 28 ++++++------- .../src/components/common/loading-spinner.tsx | 16 ++++++++ cvat-ui/src/components/task-page/details.tsx | 1 + cvat-ui/src/components/task-page/job-list.tsx | 3 +- cvat-ui/src/components/task-page/styles.scss | 5 +++ .../src/components/task-page/task-page.tsx | 20 +++++---- .../src/containers/task-page/task-page.tsx | 14 +++++-- cvat-ui/src/reducers/interfaces.ts | 3 ++ cvat-ui/src/reducers/tasks-reducer.ts | 41 +++++++++++++------ cvat-ui/src/styles.scss | 11 +++++ .../case_69_filters_sorting_jobs.js | 4 +- ...ssue_2440_value_must_be_a_user_instance.js | 4 +- tests/cypress/support/commands.js | 1 + 15 files changed, 110 insertions(+), 45 deletions(-) create mode 100644 cvat-ui/src/components/common/loading-spinner.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fca7f44..62ba1292 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Support of attributes returned by serverless functions () based on () - Project/task backups uploading via chunk uploads () - +- Fixed UX bug when jobs pagination is reset after changing a job () ### Changed - Bumped nuclio version to 1.8.14 () diff --git a/cvat-ui/package.json b/cvat-ui/package.json index 97daf345..5f16c80f 100644 --- a/cvat-ui/package.json +++ b/cvat-ui/package.json @@ -1,6 +1,6 @@ { "name": "cvat-ui", - "version": "1.38.0", + "version": "1.38.1", "description": "CVAT single-page application", "main": "src/index.tsx", "scripts": { diff --git a/cvat-ui/src/actions/tasks-actions.ts b/cvat-ui/src/actions/tasks-actions.ts index f99e04c5..98ae49d2 100644 --- a/cvat-ui/src/actions/tasks-actions.ts +++ b/cvat-ui/src/actions/tasks-actions.ts @@ -441,37 +441,37 @@ export function updateTaskSuccess(task: any, taskID: number): AnyAction { return action; } -function updateJob(): AnyAction { +function updateTaskFailed(error: any, task: any): AnyAction { const action = { - type: TasksActionTypes.UPDATE_JOB, - payload: { }, + type: TasksActionTypes.UPDATE_TASK_FAILED, + payload: { error, task }, }; return action; } -function updateJobSuccess(jobInstance: any): AnyAction { +function updateJob(jobID: number): AnyAction { const action = { - type: TasksActionTypes.UPDATE_JOB_SUCCESS, - payload: { jobInstance }, + type: TasksActionTypes.UPDATE_JOB, + payload: { jobID }, }; return action; } -function updateJobFailed(jobID: number, error: any): AnyAction { +function updateJobSuccess(jobInstance: any, jobID: number): AnyAction { const action = { - type: TasksActionTypes.UPDATE_JOB_FAILED, - payload: { jobID, error }, + type: TasksActionTypes.UPDATE_JOB_SUCCESS, + payload: { jobID, jobInstance }, }; return action; } -function updateTaskFailed(error: any, task: any): AnyAction { +function updateJobFailed(jobID: number, error: any): AnyAction { const action = { - type: TasksActionTypes.UPDATE_TASK_FAILED, - payload: { error, task }, + type: TasksActionTypes.UPDATE_JOB_FAILED, + payload: { jobID, error }, }; return action; @@ -503,9 +503,9 @@ export function updateTaskAsync(taskInstance: any): ThunkAction, C export function updateJobAsync(jobInstance: any): ThunkAction, {}, {}, AnyAction> { return async (dispatch: ActionCreator): Promise => { try { - dispatch(updateJob()); + dispatch(updateJob(jobInstance.id)); const newJob = await jobInstance.save(); - dispatch(updateJobSuccess(newJob)); + dispatch(updateJobSuccess(newJob, newJob.id)); } catch (error) { dispatch(updateJobFailed(jobInstance.id, error)); } diff --git a/cvat-ui/src/components/common/loading-spinner.tsx b/cvat-ui/src/components/common/loading-spinner.tsx new file mode 100644 index 00000000..c6f0e8f5 --- /dev/null +++ b/cvat-ui/src/components/common/loading-spinner.tsx @@ -0,0 +1,16 @@ +// Copyright (C) 2022 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import React from 'react'; +import Spin, { SpinProps } from 'antd/lib/spin'; + +function CVATLoadingSpinner(props: SpinProps): JSX.Element { + return ( +
+ +
+ ); +} + +export default React.memo(CVATLoadingSpinner); diff --git a/cvat-ui/src/components/task-page/details.tsx b/cvat-ui/src/components/task-page/details.tsx index a2d2c55e..179f467e 100644 --- a/cvat-ui/src/components/task-page/details.tsx +++ b/cvat-ui/src/components/task-page/details.tsx @@ -241,6 +241,7 @@ export default class DetailsComponent extends React.PureComponent { + if (taskInstance?.assignee?.id === value?.id) return; taskInstance.assignee = value; onTaskUpdate(taskInstance); }} diff --git a/cvat-ui/src/components/task-page/job-list.tsx b/cvat-ui/src/components/task-page/job-list.tsx index d90fe984..67b113e7 100644 --- a/cvat-ui/src/components/task-page/job-list.tsx +++ b/cvat-ui/src/components/task-page/job-list.tsx @@ -1,4 +1,4 @@ -// Copyright (C) 2020-2021 Intel Corporation +// Copyright (C) 2020-2022 Intel Corporation // // SPDX-License-Identifier: MIT @@ -234,6 +234,7 @@ function JobListComponent(props: Props & RouteComponentProps): JSX.Element { className='cvat-job-assignee-selector' value={jobInstance.assignee} onSelect={(value: User | null): void => { + if (jobInstance?.assignee?.id === value?.id) return; jobInstance.assignee = value; onJobUpdate(jobInstance); }} diff --git a/cvat-ui/src/components/task-page/styles.scss b/cvat-ui/src/components/task-page/styles.scss index c5296c3a..0fa391fa 100644 --- a/cvat-ui/src/components/task-page/styles.scss +++ b/cvat-ui/src/components/task-page/styles.scss @@ -78,6 +78,11 @@ width: 100%; } +.cvat-task-page { + position: relative; + height: 100%; +} + .cvat-task-job-list { width: 100%; height: auto; diff --git a/cvat-ui/src/components/task-page/task-page.tsx b/cvat-ui/src/components/task-page/task-page.tsx index cfd52603..829920eb 100644 --- a/cvat-ui/src/components/task-page/task-page.tsx +++ b/cvat-ui/src/components/task-page/task-page.tsx @@ -1,4 +1,4 @@ -// Copyright (C) 2020-2021 Intel Corporation +// Copyright (C) 2020-2022 Intel Corporation // // SPDX-License-Identifier: MIT @@ -13,6 +13,7 @@ import Result from 'antd/lib/result'; import DetailsContainer from 'containers/task-page/details'; import JobListContainer from 'containers/task-page/job-list'; import ModelRunnerModal from 'components/model-runner-modal/model-runner-dialog'; +import CVATLoadingSpinner from 'components/common/loading-spinner'; import MoveTaskModal from 'components/move-task-modal/move-task-modal'; import { Task } from 'reducers/interfaces'; import TopBarComponent from './top-bar'; @@ -21,6 +22,7 @@ interface TaskPageComponentProps { task: Task | null | undefined; fetching: boolean; updating: boolean; + jobUpdating: boolean; deleteActivity: boolean | null; installedGit: boolean; getTask: () => void; @@ -37,12 +39,13 @@ class TaskPageComponent extends React.PureComponent { } } - public componentDidUpdate(): void { + public componentDidUpdate(prevProps: Props): void { const { - deleteActivity, history, task, fetching, getTask, + deleteActivity, history, task, fetching, getTask, jobUpdating, } = this.props; - if (task === null && !fetching) { + const jobUpdated = prevProps.jobUpdating && !jobUpdating; + if ((task === null && !fetching) || jobUpdated) { getTask(); } @@ -54,7 +57,7 @@ class TaskPageComponent extends React.PureComponent { public render(): JSX.Element { const { task, updating, fetching } = this.props; - if (task === null || fetching) { + if (task === null || (fetching && !updating)) { return ; } @@ -70,10 +73,9 @@ class TaskPageComponent extends React.PureComponent { } return ( - <> - { updating ? : null } +
+ { updating ? : null } { - +
); } } diff --git a/cvat-ui/src/containers/task-page/task-page.tsx b/cvat-ui/src/containers/task-page/task-page.tsx index a290eb07..52c8ecd5 100644 --- a/cvat-ui/src/containers/task-page/task-page.tsx +++ b/cvat-ui/src/containers/task-page/task-page.tsx @@ -1,4 +1,4 @@ -// Copyright (C) 2020 Intel Corporation +// Copyright (C) 2020-2022 Intel Corporation // // SPDX-License-Identifier: MIT @@ -17,6 +17,7 @@ interface StateToProps { task: Task | null | undefined; fetching: boolean; updating: boolean; + jobUpdating: boolean; deleteActivity: boolean | null; installedGit: boolean; } @@ -28,8 +29,10 @@ interface DispatchToProps { function mapStateToProps(state: CombinedState, own: Props): StateToProps { const { list } = state.plugins; const { tasks } = state; - const { gettingQuery, fetching, updating } = tasks; - const { deletes } = tasks.activities; + const { + gettingQuery, fetching, updating, + } = tasks; + const { deletes, jobUpdates } = tasks.activities; const id = +own.match.params.id; @@ -42,8 +45,13 @@ function mapStateToProps(state: CombinedState, own: Props): StateToProps { deleteActivity = deletes[id]; } + const jobIDs = task ? Object.fromEntries(task.instance.jobs.map((job:any) => [job.id])) : {}; + const updatingJobs = Object.keys(jobUpdates); + const jobUpdating = updatingJobs.some((jobID) => jobID in jobIDs); + return { task, + jobUpdating, deleteActivity, fetching, updating, diff --git a/cvat-ui/src/reducers/interfaces.ts b/cvat-ui/src/reducers/interfaces.ts index 06f3adf3..4d3bf3d4 100644 --- a/cvat-ui/src/reducers/interfaces.ts +++ b/cvat-ui/src/reducers/interfaces.ts @@ -116,6 +116,9 @@ export interface TasksState { backups: { [tid: number]: boolean; }; + jobUpdates: { + [jid: number]: boolean, + }; }; } diff --git a/cvat-ui/src/reducers/tasks-reducer.ts b/cvat-ui/src/reducers/tasks-reducer.ts index 7a860c33..c9d1d8e0 100644 --- a/cvat-ui/src/reducers/tasks-reducer.ts +++ b/cvat-ui/src/reducers/tasks-reducer.ts @@ -8,6 +8,7 @@ import { BoundariesActionTypes } from 'actions/boundaries-actions'; import { TasksActionTypes } from 'actions/tasks-actions'; import { AuthActionTypes } from 'actions/auth-actions'; +import { AnnotationActionTypes } from 'actions/annotation-actions'; import { TasksState, Task } from './interfaces'; const defaultState: TasksState = { @@ -38,6 +39,7 @@ const defaultState: TasksState = { error: '', }, backups: {}, + jobUpdates: {}, }, importing: false, }; @@ -55,7 +57,6 @@ export default (state: TasksState = defaultState, action: AnyAction): TasksState fetching: true, hideEmpty: true, count: 0, - current: [], gettingQuery: action.payload.updateQuery ? { ...action.payload.query } : state.gettingQuery, }; case TasksActionTypes.GET_TASKS_SUCCESS: { @@ -70,6 +71,7 @@ export default (state: TasksState = defaultState, action: AnyAction): TasksState ...state, initialized: true, fetching: false, + updating: false, count: action.payload.count, current: combinedWithPreviews, }; @@ -313,25 +315,34 @@ export default (state: TasksState = defaultState, action: AnyAction): TasksState }; } case TasksActionTypes.UPDATE_JOB: { + const { jobID } = action.payload; + const { jobUpdates } = state.activities; + return { ...state, updating: true, + activities: { + ...state.activities, + jobUpdates: { + ...jobUpdates, + ...Object.fromEntries([[jobID, true]]), + }, + }, }; } - case TasksActionTypes.UPDATE_JOB_SUCCESS: { - const { jobInstance } = action.payload; - const idx = state.current.findIndex((task: Task) => task.instance.id === jobInstance.taskId); - const newCurrent = idx === -1 ? - state.current : [...(state.current.splice(idx, 1), state.current)]; + case TasksActionTypes.UPDATE_JOB_SUCCESS: + case TasksActionTypes.UPDATE_JOB_FAILED: { + const { jobID } = action.payload; + const { jobUpdates } = state.activities; + + delete jobUpdates[jobID]; return { ...state, - current: newCurrent, - gettingQuery: state.gettingQuery.id === jobInstance.taskId ? { - ...state.gettingQuery, - id: null, - } : state.gettingQuery, - updating: false, + activities: { + ...state.activities, + jobUpdates: omit(jobUpdates, [jobID]), + }, }; } case TasksActionTypes.HIDE_EMPTY_TASKS: { @@ -350,6 +361,12 @@ export default (state: TasksState = defaultState, action: AnyAction): TasksState }, }; } + case AnnotationActionTypes.CLOSE_JOB: { + return { + ...state, + updating: false, + }; + } case BoundariesActionTypes.RESET_AFTER_ERROR: case AuthActionTypes.LOGOUT_SUCCESS: { return { ...defaultState }; diff --git a/cvat-ui/src/styles.scss b/cvat-ui/src/styles.scss index a22b02c7..2b44d52e 100644 --- a/cvat-ui/src/styles.scss +++ b/cvat-ui/src/styles.scss @@ -17,6 +17,17 @@ hr { transform: translate(-50%, -50%); } +.cvat-spinner-container { + position: absolute; + background: $background-color-1; + opacity: 0.5; + width: 100%; + height: 100%; + z-index: 2; + top: 0; + left: 0; +} + .cvat-not-found { margin: 10% 25%; } diff --git a/tests/cypress/integration/actions_tasks/registration_involved/case_69_filters_sorting_jobs.js b/tests/cypress/integration/actions_tasks/registration_involved/case_69_filters_sorting_jobs.js index ee8e2fdb..773330ca 100644 --- a/tests/cypress/integration/actions_tasks/registration_involved/case_69_filters_sorting_jobs.js +++ b/tests/cypress/integration/actions_tasks/registration_involved/case_69_filters_sorting_jobs.js @@ -49,8 +49,8 @@ context('Filtering, sorting jobs.', () => { cy.get('.cvat-task-jobs-table') .contains('a', `Job #${$job}`) .parents('.cvat-task-jobs-table-row').within(() => { - cy.get('.cvat-job-item-stage').invoke('text').should('equal', stage); - cy.get('.cvat-job-item-state').invoke('text').should('equal', state); + 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-assignee') .find('[type="search"]') .invoke('val') diff --git a/tests/cypress/integration/actions_users/issue_2440_value_must_be_a_user_instance.js b/tests/cypress/integration/actions_users/issue_2440_value_must_be_a_user_instance.js index 70b8c304..7d1e1032 100644 --- a/tests/cypress/integration/actions_users/issue_2440_value_must_be_a_user_instance.js +++ b/tests/cypress/integration/actions_users/issue_2440_value_must_be_a_user_instance.js @@ -1,4 +1,4 @@ -// Copyright (C) 2020 Intel Corporation +// Copyright (C) 2020-2022 Intel Corporation // // SPDX-License-Identifier: MIT @@ -23,7 +23,7 @@ context('Value must be a user instance.', () => { .within(() => { cy.get(`.ant-select-item-option[title="${Cypress.env('user')}"]`).click(); }); - cy.get('.cvat-spinner').should('exist'); + cy.get('.cvat-spinner').should('not.exist'); }); it('Assign the task to the same user again', () => { cy.get('.cvat-task-details-user-block').within(() => { diff --git a/tests/cypress/support/commands.js b/tests/cypress/support/commands.js index 89abc1e0..b8ea0229 100644 --- a/tests/cypress/support/commands.js +++ b/tests/cypress/support/commands.js @@ -698,6 +698,7 @@ Cypress.Commands.add('addNewLabel', (newLabelName, additionalAttrs, labelColor) } } cy.contains('button', 'Done').click(); + cy.get('.cvat-spinner').should('not.exist'); cy.get('.cvat-constructor-viewer').should('be.visible'); cy.contains('.cvat-constructor-viewer-item', new RegExp(`^${newLabelName}$`)).should('exist'); });