From c7fcdbed619febd7590b4f83761de41eeb03d257 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sat, 14 Nov 2026 00:08:14 -0800 Subject: [PATCH] Fix delete and undo --- .../app-frame/app-frame.component.spec.ts | 17 +++++++ .../app-frame/app-frame.component.ts | 11 +++-- ...ocument-version-dropdown.component.spec.ts | 34 ++++++++------ .../document-version-dropdown.component.ts | 34 ++++++++++---- .../bulk-editor/bulk-editor.component.ts | 2 +- .../src/app/services/rest/document.service.ts | 7 +-- src-ui/src/app/services/trash.service.spec.ts | 24 ++++++++++ src-ui/src/app/services/trash.service.ts | 44 +++++++++++++++++++ 8 files changed, 143 insertions(+), 30 deletions(-) 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 c4bbd2c2d8..9d78858122 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 @@ -35,6 +35,7 @@ import { SearchService } from 'src/app/services/rest/search.service' import { SettingsService } from 'src/app/services/settings.service' import { TasksService } from 'src/app/services/tasks.service' import { ToastService } from 'src/app/services/toast.service' +import { TrashService } from 'src/app/services/trash.service' import { UploadDocumentsService } from 'src/app/services/upload-documents.service' import { WebsocketStatusService } from 'src/app/services/websocket-status.service' import { environment } from 'src/environments/environment' @@ -270,6 +271,22 @@ describe('AppFrameComponent', () => { expect(success).not.toHaveBeenCalled() }) + it('defers the delete success toast to TrashService', () => { + const success = jest.spyOn(toastService, 'showSuccess') + const trashService = TestBed.inject(TrashService) + const handleStatus = jest.spyOn(trashService, 'handleDeleteTaskStatus') + bulkEvents.next({ + task_id: 'delete-1', + operation: 'delete', + status: PaperlessTaskStatus.Success, + }) + expect(handleStatus).toHaveBeenCalledWith( + 'delete-1', + PaperlessTaskStatus.Success + ) + expect(success).not.toHaveBeenCalled() + }) + it('should initialize the saved view service', () => { expect(savedViewSpy).toHaveBeenCalled() }) diff --git a/src-ui/src/app/components/app-frame/app-frame.component.ts b/src-ui/src/app/components/app-frame/app-frame.component.ts index beae9bb4f6..52f10df01c 100644 --- a/src-ui/src/app/components/app-frame/app-frame.component.ts +++ b/src-ui/src/app/components/app-frame/app-frame.component.ts @@ -59,6 +59,7 @@ import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { SettingsService } from 'src/app/services/settings.service' import { TasksService } from 'src/app/services/tasks.service' import { ToastService } from 'src/app/services/toast.service' +import { TrashService } from 'src/app/services/trash.service' import { UploadDocumentsService } from 'src/app/services/upload-documents.service' import { WebsocketStatusService } from 'src/app/services/websocket-status.service' import { environment } from 'src/environments/environment' @@ -115,6 +116,7 @@ export class AppFrameComponent settingsService = inject(SettingsService) tasksService = inject(TasksService) private readonly toastService = inject(ToastService) + private readonly trashService = inject(TrashService) private modalService = inject(NgbModal) permissionsService = inject(PermissionsService) private djangoMessagesService = inject(DjangoMessagesService) @@ -182,7 +184,7 @@ export class AppFrameComponent ), takeUntilDestroyed(this.destroyRef) ) - .subscribe(({ status, error }) => { + .subscribe(({ task_id, operation, status, error }) => { this.documentList.reload(null, false) this.documentList.reduceSelectionToFilter() // maybe someday we can optimize to only refresh affected documents @@ -198,11 +200,14 @@ export class AppFrameComponent ) { this.tasksService.reload() } - if (status === PaperlessTaskStatus.Success) { + if (operation === 'delete') { + this.trashService.handleDeleteTaskStatus(task_id, status) + } + if (status === PaperlessTaskStatus.Success && operation !== 'delete') { this.toastService.showSuccess($localize`Bulk operation completed.`) } else if (status === PaperlessTaskStatus.Revoked) { this.toastService.showError($localize`Bulk operation revoked.`, error) - } else { + } else if (status === PaperlessTaskStatus.Failure) { this.toastService.showError( $localize`Bulk operation failed. Some changes may already have been applied.`, error diff --git a/src-ui/src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.spec.ts b/src-ui/src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.spec.ts index 8335f84d07..3b7aa29724 100644 --- a/src-ui/src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.spec.ts +++ b/src-ui/src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.spec.ts @@ -2,6 +2,7 @@ import { DatePipe } from '@angular/common' import { SimpleChange, signal } from '@angular/core' import { ComponentFixture, TestBed } from '@angular/core/testing' import { By } from '@angular/platform-browser' +import { Router } from '@angular/router' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { Subject, of, throwError } from 'rxjs' import { DocumentVersionInfo } from 'src/app/data/document' @@ -33,7 +34,7 @@ describe('DocumentVersionDropdownComponent', () => { > > let toastService: jest.Mocked< - Pick + Pick > let finished$: Subject<{ taskId: string }> let failed$: Subject<{ taskId: string; message?: string }> @@ -54,6 +55,7 @@ describe('DocumentVersionDropdownComponent', () => { } modalService = { open: jest.fn() } toastService = { + show: jest.fn(), showError: jest.fn(), showInfo: jest.fn(), showSuccess: jest.fn(), @@ -88,6 +90,10 @@ describe('DocumentVersionDropdownComponent', () => { provide: PermissionsService, useValue: permissionsService, }, + { + provide: Router, + useValue: { navigate: jest.fn() }, + }, { provide: WebsocketStatusService, useValue: { @@ -377,7 +383,7 @@ describe('DocumentVersionDropdownComponent', () => { expect(component.versionLabelDraft).toEqual('') }) - it('addExistingDocumentAsVersion should merge with a label and refresh versions', () => { + it('addExistingDocumentAsVersion should queue with a label and wait to refresh versions', () => { const confirmClicked = new Subject() const modal = { componentInstance: { @@ -388,14 +394,9 @@ describe('DocumentVersionDropdownComponent', () => { close: jest.fn(), } modalService.open.mockReturnValue(modal as any) - documentService.mergeDocumentsAsVersions.mockReturnValue(of({} as any)) - // Newest first, as the API returns them. The merged document has a lower id - // than the root, which is the whole point of merging an existing document. - const versions: DocumentVersionInfo[] = [ - { id: 2, is_root: false, checksum: 'cccc' }, - { id: 3, is_root: true, checksum: 'aaaa' }, - ] - documentService.getVersions.mockReturnValue(of({ id: 3, versions } as any)) + documentService.mergeDocumentsAsVersions.mockReturnValue( + of({ result: 'OK', task_id: 'versions-1' }) + ) component.newVersionLabel = ' Imported ' const versionsEmitSpy = jest.spyOn(component.versionsUpdated, 'emit') const selectedEmitSpy = jest.spyOn(component.versionSelected, 'emit') @@ -410,11 +411,16 @@ describe('DocumentVersionDropdownComponent', () => { 'Imported' ) expect(documentService.updateVersionLabel).not.toHaveBeenCalled() - expect(documentService.getVersions).toHaveBeenCalledWith(3) - expect(versionsEmitSpy).toHaveBeenCalledWith(versions) - expect(selectedEmitSpy).toHaveBeenCalledWith(2) + expect(documentService.getVersions).not.toHaveBeenCalled() + expect(versionsEmitSpy).not.toHaveBeenCalled() + expect(selectedEmitSpy).not.toHaveBeenCalled() expect(component.newVersionLabel).toEqual('') expect(modal.close).toHaveBeenCalled() - expect(toastService.showSuccess).toHaveBeenCalled() + expect(toastService.show).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'Bulk operation queued.', + actionName: 'View task', + }) + ) }) }) diff --git a/src-ui/src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts b/src-ui/src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts index 7ee1897300..2752a05eb5 100644 --- a/src-ui/src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts +++ b/src-ui/src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts @@ -11,6 +11,7 @@ import { SimpleChanges, } from '@angular/core' import { FormsModule } from '@angular/forms' +import { Router } from '@angular/router' import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap' import { merge, of, Subject } from 'rxjs' import { @@ -27,6 +28,11 @@ import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon import { DocumentVersionInfo } from 'src/app/data/document' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' +import { + PermissionAction, + PermissionsService, + PermissionType, +} from 'src/app/services/permissions.service' import { DocumentService } from 'src/app/services/rest/document.service' import { ToastService } from 'src/app/services/toast.service' import { @@ -77,6 +83,8 @@ export class DocumentVersionDropdownComponent private readonly toastService = inject(ToastService) private readonly websocketStatusService = inject(WebsocketStatusService) private readonly modalService = inject(NgbModal) + private readonly router = inject(Router) + private readonly permissionsService = inject(PermissionsService) private readonly destroy$ = new Subject() private readonly documentChange$ = new Subject() @@ -305,24 +313,32 @@ export class DocumentVersionDropdownComponent versionLabel ) .pipe( - switchMap(() => this.documentsService.getVersions(this.documentId)), first(), finalize(() => dialog.buttonsEnabled.set(true)), takeUntil(this.destroy$), takeUntil(this.documentChange$) ) .subscribe({ - next: (document) => { - if (document?.versions?.length) { - this.versionsUpdated.emit(document.versions) - // The API returns versions newest first - this.versionSelected.emit(document.versions[0].id) - } + next: ({ task_id }) => { this.newVersionLabel = '' modal.close() - this.toastService.showSuccess( - $localize`Existing document added as a version.` + const canViewTasks = this.permissionsService.currentUserCan( + PermissionAction.View, + PermissionType.PaperlessTask ) + this.toastService.show({ + content: $localize`Bulk operation queued.`, + delay: 5000, + ...(canViewTasks + ? { + actionName: $localize`View task`, + action: () => + this.router.navigate(['/tasks'], { + queryParams: { task_id }, + }), + } + : {}), + }) }, error: (error) => { this.toastService.showError( 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 705424429e..3eea261f20 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 @@ -368,8 +368,8 @@ export class BulkEditorComponent } request.pipe(first()).subscribe({ next: ({ task_id }) => { - onQueued?.(task_id) this.handleOperationQueued(modal, task_id) + onQueued?.(task_id) }, error: (error) => this.handleOperationError(modal, error), }) diff --git a/src-ui/src/app/services/rest/document.service.ts b/src-ui/src/app/services/rest/document.service.ts index 01faf53c6b..9bf09c079c 100644 --- a/src-ui/src/app/services/rest/document.service.ts +++ b/src-ui/src/app/services/rest/document.service.ts @@ -351,9 +351,10 @@ export class DocumentService extends AbstractPaperlessService { } deleteDocuments(selection: DocumentSelectionQuery) { - return this.http.post(this.getResourceUrl(null, 'delete'), { - ...selection, - }) + return this.http.post( + this.getResourceUrl(null, 'delete'), + { ...selection, from_webui: true } + ) } reprocessDocuments( diff --git a/src-ui/src/app/services/trash.service.spec.ts b/src-ui/src/app/services/trash.service.spec.ts index 5cdee2deae..f65372d2f9 100644 --- a/src-ui/src/app/services/trash.service.spec.ts +++ b/src-ui/src/app/services/trash.service.spec.ts @@ -5,6 +5,7 @@ import { HttpTestingController, } from '@angular/common/http/testing' import { environment } from 'src/environments/environment' +import { PaperlessTaskStatus } from '../data/paperless-task' import { DocumentListViewService } from './document-list-view.service' import { Toast, ToastService } from './toast.service' import { TrashService } from './trash.service' @@ -105,4 +106,27 @@ describe('TrashService', () => { expect(toast.content).toEqual('Moved 500 document(s) to the trash') expect(toast.action).toBeUndefined() }) + + it('offers Undo only after the matching delete task succeeds', () => { + let toasts: Toast[] = [] + toastService.getToasts().subscribe((t) => (toasts = [...t])) + service.trackMovedToTrash('delete-1', 2, [1, 2]) + expect(toasts).toHaveLength(0) + service.handleDeleteTaskStatus('other-delete', PaperlessTaskStatus.Success) + expect(toasts).toHaveLength(0) + service.handleDeleteTaskStatus('delete-1', PaperlessTaskStatus.Success) + expect(toasts).toHaveLength(1) + expect(toasts[0].actionName).toBe('Undo') + }) + + it('handles completion before the queue response and does not offer Undo on failure', () => { + let toasts: Toast[] = [] + toastService.getToasts().subscribe((t) => (toasts = [...t])) + service.handleDeleteTaskStatus('delete-1', PaperlessTaskStatus.Success) + service.trackMovedToTrash('delete-1', 1, [1]) + expect(toasts[0].actionName).toBe('Undo') + service.trackMovedToTrash('delete-2', 1, [2]) + service.handleDeleteTaskStatus('delete-2', PaperlessTaskStatus.Failure) + expect(toasts).toHaveLength(1) + }) }) diff --git a/src-ui/src/app/services/trash.service.ts b/src-ui/src/app/services/trash.service.ts index 4db55c6588..f66a8c51be 100644 --- a/src-ui/src/app/services/trash.service.ts +++ b/src-ui/src/app/services/trash.service.ts @@ -3,6 +3,7 @@ import { Injectable, inject } from '@angular/core' import { Observable } from 'rxjs' import { environment } from 'src/environments/environment' import { Document } from '../data/document' +import { PaperlessTaskStatus } from '../data/paperless-task' import { Results } from '../data/results' import { DocumentListViewService } from './document-list-view.service' import { ToastLevel, ToastService } from './toast.service' @@ -14,6 +15,49 @@ export class TrashService { private http = inject(HttpClient) private toastService = inject(ToastService) private documentListViewService = inject(DocumentListViewService) + private pendingMoves = new Map< + string, + { count: number; documents?: number[] } + >() + private completedMoves = new Map() + + public trackMovedToTrash( + taskId: string, + count: number, + documents?: number[] + ) { + const status = this.completedMoves.get(taskId) + if (status !== undefined) { + this.completedMoves.delete(taskId) + if (status === PaperlessTaskStatus.Success) { + this.notifyMovedToTrash( + $localize`Moved ${count} document(s) to the trash`, + documents + ) + } + } else { + this.pendingMoves.set(taskId, { count, documents }) + } + } + + public handleDeleteTaskStatus(taskId: string, status: PaperlessTaskStatus) { + const move = this.pendingMoves.get(taskId) + if (move) { + this.pendingMoves.delete(taskId) + if (status === PaperlessTaskStatus.Success) { + this.notifyMovedToTrash( + $localize`Moved ${move.count} document(s) to the trash`, + move.documents + ) + } + } else { + // in case the task finishes fast + this.completedMoves.set(taskId, status) + if (this.completedMoves.size > 100) { + this.completedMoves.delete(this.completedMoves.keys().next().value) + } + } + } public getTrash(page: number = 1): Observable> { const httpParams = new HttpParams().set('page', page.toString())