diff --git a/src-ui/src/app/components/admin/tasks/tasks.component.ts b/src-ui/src/app/components/admin/tasks/tasks.component.ts index 730b7d8f50..80268a80ae 100644 --- a/src-ui/src/app/components/admin/tasks/tasks.component.ts +++ b/src-ui/src/app/components/admin/tasks/tasks.component.ts @@ -33,6 +33,7 @@ import { IfPermissionsDirective } from 'src/app/directives/if-permissions.direct import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { TasksService } from 'src/app/services/tasks.service' import { ToastService } from 'src/app/services/toast.service' +import { getDocumentOperationLabel } from 'src/app/utils/document-operation' import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' @@ -56,27 +57,6 @@ const FILTER_TARGETS = [ { id: TaskFilterTargetID.TaskId, name: $localize`Task ID` }, ] -const OPERATION_LABELS: Record = { - set_correspondent: $localize`Set correspondent`, - set_document_type: $localize`Set document type`, - set_storage_path: $localize`Set storage path`, - add_tag: $localize`Add tag`, - remove_tag: $localize`Remove tag`, - modify_tags: $localize`Update tags`, - modify_custom_fields: $localize`Update custom fields`, - set_permissions: $localize`Set permissions`, - delete: $localize`Move to trash`, - reprocess: $localize`Document reprocessing`, - run_workflow: $localize`Run workflow`, - rotate: $localize`Rotate documents`, - merge: $localize`Merge documents`, - merge_as_versions: $localize`Merge as versions`, - split: $localize`Split documents`, - delete_pages: $localize`Delete pages`, - edit_pdf: $localize`Edit PDF`, - remove_password: $localize`Remove password`, -} - const SECTION_LABELS = { [TaskSection.All]: $localize`All`, [TaskSection.NeedsAttention]: $localize`Needs attention`, @@ -453,8 +433,7 @@ export class TasksComponent } private taskOperationLabel(task: PaperlessTask): string | null { - const label = OPERATION_LABELS[task.input_data?.operation] - return typeof label === 'string' ? label : null + return getDocumentOperationLabel(task.input_data?.operation) } taskDocumentCount(task: PaperlessTask): number | null { diff --git a/src-ui/src/app/components/app-frame/app-frame.component.spec.ts b/src-ui/src/app/components/app-frame/app-frame.component.spec.ts index e70442c913..0ae6ac2360 100644 --- a/src-ui/src/app/components/app-frame/app-frame.component.spec.ts +++ b/src-ui/src/app/components/app-frame/app-frame.component.spec.ts @@ -234,7 +234,7 @@ describe('AppFrameComponent', () => { expect(success).toHaveBeenCalledWith( expect.objectContaining({ id: 'task-bulk-task', - content: 'Operation completed.', + content: "Operation 'Set permissions' completed.", }) ) fixture.destroy() @@ -271,13 +271,13 @@ describe('AppFrameComponent', () => { expect(error).toHaveBeenCalledWith( expect.objectContaining({ content: - 'Operation failed. Some changes may already have been applied.', + "Operation 'Set permissions' failed. Some changes may already have been applied.", error: 'Could not update', }) ) expect(error).toHaveBeenCalledWith( expect.objectContaining({ - content: 'Operation revoked.', + content: "Operation 'Set permissions' revoked.", error: 'Task expired before execution.', }) ) diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts index 77e97cded9..8b448ab531 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts @@ -14,6 +14,7 @@ import { Correspondent } from 'src/app/data/correspondent' import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' import { DocumentType } from 'src/app/data/document-type' import { FILTER_TITLE } from 'src/app/data/filter-rule-type' +import { PaperlessTaskStatus } from 'src/app/data/paperless-task' import { Results } from 'src/app/data/results' import { StoragePath } from 'src/app/data/storage-path' import { Tag } from 'src/app/data/tag' @@ -39,6 +40,7 @@ import { TagService } from 'src/app/services/rest/tag.service' import { UserService } from 'src/app/services/rest/user.service' import { WorkflowService } from 'src/app/services/rest/workflow.service' import { SettingsService } from 'src/app/services/settings.service' +import { TaskNotificationService } from 'src/app/services/task-notification.service' import { ToastService } from 'src/app/services/toast.service' import { TrashService } from 'src/app/services/trash.service' import { environment } from 'src/environments/environment' @@ -1557,7 +1559,17 @@ describe('BulkEditorComponent', () => { expect(documentListViewService.selected.size).toEqual(0) jest.advanceTimersByTime(400) expect(toastSpy).toHaveBeenCalledWith( - expect.objectContaining({ content: 'Bulk operation queued.' }) + expect.objectContaining({ + content: "Bulk operation 'Merge as versions' queued.", + }) + ) + TestBed.inject(TaskNotificationService).complete({ + task_id: 'versions-1', + operation: 'merge_as_versions', + status: PaperlessTaskStatus.Success, + }) + expect(toastSpy.mock.calls.at(-1)[0].content).toBe( + "Bulk operation 'Merge as versions' completed." ) jest.useRealTimers() }) @@ -1650,13 +1662,22 @@ describe('BulkEditorComponent', () => { ) }) - it.each([true, false])( - 'shows queued feedback and gates the task link on permission (%s)', - (canViewTasks) => { + it.each<[boolean, number]>([ + [true, 2], + [false, 2], + [true, 1], + ])( + 'names queued work and gates the task link (permission %s, documents %s)', + (canViewTasks, count) => { jest.useFakeTimers() jest .spyOn(permissionsService, 'currentUserCan') .mockReturnValue(canViewTasks) + jest + .spyOn(documentListViewService, 'selected', 'get') + .mockReturnValue( + new Set(Array.from({ length: count }, (_, index) => index + 3)) + ) const queued = jest.spyOn(toastService, 'show') let modal: NgbModalRef modalService.activeInstances.subscribe((instances) => { @@ -1677,7 +1698,8 @@ describe('BulkEditorComponent', () => { expect(queued).not.toHaveBeenCalled() jest.advanceTimersByTime(400) const notification = queued.mock.calls.at(-1)[0] - expect(notification.content).toBe('Bulk operation queued.') + const prefix = count > 1 ? 'Bulk operation' : 'Operation' + expect(notification.content).toBe(`${prefix} \'Set permissions\' queued.`) if (canViewTasks) { expect(notification.actionName).toBe('View task') notification.action() @@ -1691,6 +1713,14 @@ describe('BulkEditorComponent', () => { expect(reload).not.toHaveBeenCalled() expect(documentListViewService.selected.size).toBe(0) expect(documentListViewService.allSelected).toBe(false) + TestBed.inject(TaskNotificationService).complete({ + task_id: 'queued-operation', + operation: 'set_permissions', + status: PaperlessTaskStatus.Success, + }) + const completed = queued.mock.calls.at(-1)[0] + expect(completed.content).toBe(`${prefix} \'Set permissions\' completed.`) + expect(completed.action).toBeUndefined() jest.useRealTimers() } ) diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts index 99bce13a34..60bba04d28 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -53,6 +53,7 @@ import { SettingsService } from 'src/app/services/settings.service' import { TaskNotificationService } from 'src/app/services/task-notification.service' import { ToastService } from 'src/app/services/toast.service' import { TrashService } from 'src/app/services/trash.service' +import { getDocumentOperationLabel } from 'src/app/utils/document-operation' import { flattenTags } from 'src/app/utils/flatten-tags' import { queryParamsFromFilterRules } from 'src/app/utils/query-params' import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component' @@ -348,7 +349,8 @@ export class BulkEditorComponent .bulkEdit(overrideSelection ?? this.getSelectionQuery(), method, args) .pipe(first()) .subscribe({ - next: ({ task_id }) => this.handleOperationQueued(modal, task_id), + next: ({ task_id }) => + this.handleOperationQueued(modal, task_id, method), error: (error) => this.handleOperationError(modal, error), }) } @@ -356,22 +358,33 @@ export class BulkEditorComponent private handleOperationQueued( modal: NgbModalRef, taskId: string, + operation: string, onQueued?: (taskId: string) => void ) { + const bulk = this.getSelectionSize() > 1 modal?.close() this.list.selectNone() if (onQueued) { onQueued(taskId) } else { + const label = getDocumentOperationLabel(operation) this.taskNotifications.showQueued( taskId, - $localize`Bulk operation queued.` + bulk + ? $localize`Bulk operation '${label}' queued.` + : $localize`Operation '${label}' queued.`, + { + content: bulk + ? $localize`Bulk operation '${label}' completed.` + : $localize`Operation '${label}' completed.`, + } ) } } private executeDocumentAction( modal: NgbModalRef, + operation: string, request: Observable, onQueued?: (taskId: string) => void ) { @@ -380,7 +393,7 @@ export class BulkEditorComponent } request.pipe(first()).subscribe({ next: ({ task_id }) => { - this.handleOperationQueued(modal, task_id, onQueued) + this.handleOperationQueued(modal, task_id, operation, onQueued) }, error: (error) => this.handleOperationError(modal, error), }) @@ -953,6 +966,7 @@ export class BulkEditorComponent const count = this.getSelectionSize() this.executeDocumentAction( modal, + 'delete', this.documentService.deleteDocuments(this.getSelectionQuery()), (taskId) => this.trashService.trackMovedToTrash(taskId, count, documents) ) @@ -995,6 +1009,7 @@ export class BulkEditorComponent modal.componentInstance.buttonsEnabled.set(false) this.executeDocumentAction( modal, + 'reprocess', this.documentService.reprocessDocuments( this.getSelectionQuery(), modal.componentInstance.remoteOcr @@ -1035,6 +1050,7 @@ export class BulkEditorComponent rotateDialog.buttonsEnabled.set(false) this.executeDocumentAction( modal, + 'rotate', this.documentService.rotateDocuments( this.getSelectionQuery(), rotateDialog.degrees @@ -1068,6 +1084,7 @@ export class BulkEditorComponent mergeDialog.buttonsEnabled.set(false) this.executeDocumentAction( modal, + 'merge', this.documentService.mergeDocuments(mergeDialog.documentIDs(), args) ) }) @@ -1090,6 +1107,7 @@ export class BulkEditorComponent .subscribe(() => { this.executeDocumentAction( modal, + 'merge_as_versions', this.documentService.mergeDocumentsAsVersions( mergeDialog.documentIDs(), mergeDialog.rootDocumentID() @@ -1118,7 +1136,7 @@ export class BulkEditorComponent dialog.selection = this.getSelectionQuery() dialog.selectionCount = this.getSelectionSize() dialog.succeeded.subscribe(({ task_id }) => - this.handleOperationQueued(null, task_id) + this.handleOperationQueued(null, task_id, 'modify_custom_fields') ) dialog.failed.subscribe((error) => { this.toastService.showError( diff --git a/src-ui/src/app/services/task-notification.service.spec.ts b/src-ui/src/app/services/task-notification.service.spec.ts index 6399c38d40..d193849b98 100644 --- a/src-ui/src/app/services/task-notification.service.spec.ts +++ b/src-ui/src/app/services/task-notification.service.spec.ts @@ -77,7 +77,7 @@ describe('TaskNotificationService', () => { service.complete(result()) jest.advanceTimersByTime(1000) expect(popups.map((toast) => toast.content)).toEqual([ - 'Operation completed.', + "Operation 'Run workflow' completed.", ]) }) @@ -132,7 +132,7 @@ describe('TaskNotificationService', () => { expect(notifications).toHaveLength(2) expect(notifications.map((toast) => toast.content)).toEqual([ 'Second queued', - 'Operation completed.', + "Operation 'Run workflow' completed.", ]) }) @@ -145,4 +145,26 @@ describe('TaskNotificationService', () => { true ) }) + + it('identifies concurrent operations even when completion arrives before their queue responses', () => { + service.complete({ + task_id: 'tags', + operation: 'modify_tags', + status: PaperlessTaskStatus.Success, + }) + service.complete({ + task_id: 'correspondent', + operation: 'set_correspondent', + status: PaperlessTaskStatus.Success, + }) + expect(notifications.map((toast) => toast.content)).toEqual([ + "Operation 'Set correspondent' completed.", + "Operation 'Update tags' completed.", + ]) + }) + + it('keeps the generic fallback for unknown operation names', () => { + service.complete({ ...result(), operation: 'future_operation' }) + expect(notifications[0].content).toBe('Operation completed.') + }) }) diff --git a/src-ui/src/app/services/task-notification.service.ts b/src-ui/src/app/services/task-notification.service.ts index 1e760a1208..1d48317843 100644 --- a/src-ui/src/app/services/task-notification.service.ts +++ b/src-ui/src/app/services/task-notification.service.ts @@ -2,6 +2,7 @@ import { inject, Injectable, OnDestroy } from '@angular/core' import { Router } from '@angular/router' import { BulkOperationStatus } from '../data/bulk-operation' import { PaperlessTaskStatus } from '../data/paperless-task' +import { getDocumentOperationLabel } from '../utils/document-operation' import { PermissionAction, PermissionsService, @@ -76,12 +77,23 @@ export class TaskNotificationService implements OnDestroy { success: Partial = {} ) { const succeeded = result.status === PaperlessTaskStatus.Success + const operation = getDocumentOperationLabel(result.operation) + let content: string + if (succeeded) { + content = operation + ? $localize`Operation '${operation}' completed.` + : $localize`Operation completed.` + } else if (result.status === PaperlessTaskStatus.Revoked) { + content = operation + ? $localize`Operation '${operation}' revoked.` + : $localize`Operation revoked.` + } else { + content = operation + ? $localize`Operation '${operation}' failed. Some changes may already have been applied.` + : $localize`Operation failed. Some changes may already have been applied.` + } this.show(result.task_id, { - content: succeeded - ? $localize`Operation completed.` - : result.status === PaperlessTaskStatus.Revoked - ? $localize`Operation revoked.` - : $localize`Operation failed. Some changes may already have been applied.`, + content, delay: succeeded ? 5000 : 10000, level: succeeded ? ToastLevel.Success : ToastLevel.Error, ...(succeeded ? success : { error: result.error }), diff --git a/src-ui/src/app/services/trash.service.ts b/src-ui/src/app/services/trash.service.ts index 93a0a36369..ce8c7f6e83 100644 --- a/src-ui/src/app/services/trash.service.ts +++ b/src-ui/src/app/services/trash.service.ts @@ -24,7 +24,9 @@ export class TrashService { ) { this.taskNotifications.showQueued( taskId, - $localize`Bulk operation queued.`, + count > 1 + ? $localize`Bulk move to trash queued.` + : $localize`Move to trash queued.`, this.movedToTrashToast( $localize`Moved ${count} document(s) to the trash`, documents diff --git a/src-ui/src/app/utils/document-operation.spec.ts b/src-ui/src/app/utils/document-operation.spec.ts new file mode 100644 index 0000000000..da5456dd1e --- /dev/null +++ b/src-ui/src/app/utils/document-operation.spec.ts @@ -0,0 +1,11 @@ +import { getDocumentOperationLabel } from './document-operation' + +describe('Document Operation Utils', () => { + it('should retrieve the document operation label correctly for an existing operation, null otherwise', () => { + const result = getDocumentOperationLabel('modify_custom_fields') + expect(result).toBe('Update custom fields') + + const resultNull = getDocumentOperationLabel('non_existing_operation') + expect(resultNull).toBeNull() + }) +}) diff --git a/src-ui/src/app/utils/document-operation.ts b/src-ui/src/app/utils/document-operation.ts new file mode 100644 index 0000000000..4bffe51aaf --- /dev/null +++ b/src-ui/src/app/utils/document-operation.ts @@ -0,0 +1,25 @@ +const OPERATION_LABELS: Record = { + set_correspondent: $localize`Set correspondent`, + set_document_type: $localize`Set document type`, + set_storage_path: $localize`Set storage path`, + add_tag: $localize`Add tag`, + remove_tag: $localize`Remove tag`, + modify_tags: $localize`Update tags`, + modify_custom_fields: $localize`Update custom fields`, + set_permissions: $localize`Set permissions`, + delete: $localize`Move to trash`, + reprocess: $localize`Document reprocessing`, + run_workflow: $localize`Run workflow`, + rotate: $localize`Rotate documents`, + merge: $localize`Merge documents`, + merge_as_versions: $localize`Merge as versions`, + split: $localize`Split documents`, + delete_pages: $localize`Delete pages`, + edit_pdf: $localize`Edit PDF`, + remove_password: $localize`Remove password`, +} + +export function getDocumentOperationLabel(operation?: string): string | null { + const label = operation ? OPERATION_LABELS[operation] : null + return label ?? null +}