mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-11-14 10:02:45 +00:00
Fix delete and undo
This commit is contained in:
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
|
||||
|
||||
+20
-14
@@ -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',
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
+25
-9
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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())
|
||||
|
||||
Reference in new issue
Block a user