Fix delete and undo

This commit is contained in:
shamoon committed 2026-11-14 00:08:14 -08:00
1 parent d2197bf05e
commit c7fcdbed61
8 files changed
+143 -30

No files matched your search

@@ -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()
})
@@ -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
@@ -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<ToastService, 'showError' | 'showInfo' | 'showSuccess'>
Pick<ToastService, 'show' | 'showError' | 'showInfo' | 'showSuccess'>
>
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<number>()
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',
})
)
})
})
@@ -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<void>()
private readonly documentChange$ = new Subject<void>()
@@ -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(
@@ -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),
})
@@ -351,9 +351,10 @@ export class DocumentService extends AbstractPaperlessService<Document> {
}
deleteDocuments(selection: DocumentSelectionQuery) {
return this.http.post(this.getResourceUrl(null, 'delete'), {
...selection,
})
return this.http.post<BulkOperationResponse>(
this.getResourceUrl(null, 'delete'),
{ ...selection, from_webui: true }
)
}
reprocessDocuments(
@@ -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)
})
})
+44
View File
@@ -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<string, PaperlessTaskStatus>()
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<Results<Document>> {
const httpParams = new HttpParams().set('page', page.toString())