Make the popups more helpful

This commit is contained in:
shamoon committed 2026-11-14 09:34:01 -08:00
1 parent ce3c4e0855
commit e2705768b4
9 files changed
+142 -43

No files matched your search

@@ -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<string, string> = {
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 {
@@ -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.',
})
)
@@ -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()
}
)
@@ -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<BulkOperationResponse>,
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(
@@ -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.')
})
})
@@ -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<Toast> = {}
) {
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 }),
+3 -1
View File
@@ -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
@@ -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()
})
})
@@ -0,0 +1,25 @@
const OPERATION_LABELS: Record<string, string> = {
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
}