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 2b4c3d63a7..358d794c82 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 @@ -11,8 +11,10 @@ import { ActivatedRoute, Router } from '@angular/router' import { RouterTestingModule } from '@angular/router/testing' import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap' import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap' -import { of, throwError } from 'rxjs' +import { of, Subject, throwError } from 'rxjs' import { routes } from 'src/app/app-routing.module' +import { BulkOperationStatus } from 'src/app/data/bulk-operation' +import { PaperlessTaskStatus } from 'src/app/data/paperless-task' import { SavedView } from 'src/app/data/saved-view' import { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' @@ -21,10 +23,11 @@ import { DjangoMessageLevel, DjangoMessagesService, } from 'src/app/services/django-messages.service' +import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { OpenDocumentsService } from 'src/app/services/open-documents.service' import { - PermissionType, PermissionsService, + PermissionType, } from 'src/app/services/permissions.service' import { RemoteVersionService } from 'src/app/services/rest/remote-version.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service' @@ -95,6 +98,7 @@ describe('AppFrameComponent', () => { let savedViewSpy let modalService: NgbModal let maybeRefreshSpy + let bulkEvents: Subject let tasksService: TasksService beforeEach(async () => { @@ -182,6 +186,10 @@ describe('AppFrameComponent', () => { savedViewSpy = jest.spyOn(savedViewService, 'reload') maybeRefreshSpy = jest.spyOn(savedViewService, 'maybeRefreshDocumentCounts') + bulkEvents = new Subject() + jest + .spyOn(TestBed.inject(WebsocketStatusService), 'onBulkOperationStatus') + .mockReturnValue(bulkEvents.asObservable()) fixture = TestBed.createComponent(AppFrameComponent) component = fixture.componentInstance @@ -190,6 +198,70 @@ describe('AppFrameComponent', () => { fixture.detectChanges() }) + it('refreshes and notifies on completion without client-side task tracking', () => { + const list = TestBed.inject(DocumentListViewService) + const reload = jest.spyOn(list, 'reload').mockImplementation(() => {}) + jest.spyOn(list, 'reduceSelectionToFilter').mockImplementation(() => {}) + const tasksReload = jest + .spyOn(tasksService, 'reload') + .mockImplementation(() => {}) + const success = jest.spyOn(toastService, 'showSuccess') + const refresh = jest + .spyOn(openDocumentsService, 'refreshDocument') + .mockImplementation(() => {}) + jest + .spyOn(openDocumentsService, 'getOpenDocuments') + .mockReturnValue([document]) + tasksReload.mockClear() + maybeRefreshSpy.mockClear() + bulkEvents.next({ + task_id: 'bulk-task', + operation: 'set_permissions', + status: PaperlessTaskStatus.Started, + }) + expect(reload).not.toHaveBeenCalled() + bulkEvents.next({ + task_id: 'bulk-task', + operation: 'set_permissions', + status: PaperlessTaskStatus.Success, + }) + expect(reload).toHaveBeenCalledTimes(1) + expect(refresh).toHaveBeenCalledWith(document.id) + expect(maybeRefreshSpy).toHaveBeenCalledTimes(1) + expect(tasksReload).toHaveBeenCalledTimes(1) + expect(success).toHaveBeenCalledWith('Bulk operation completed.') + fixture.destroy() + bulkEvents.next({ + task_id: 'bulk-task', + operation: 'set_permissions', + status: PaperlessTaskStatus.Success, + }) + expect(reload).toHaveBeenCalledTimes(1) + }) + + it('refreshes and reports failed or revoked bulk operations', () => { + const list = TestBed.inject(DocumentListViewService) + const reload = jest.spyOn(list, 'reload').mockImplementation(() => {}) + jest.spyOn(list, 'reduceSelectionToFilter').mockImplementation(() => {}) + jest.spyOn(tasksService, 'reload').mockImplementation(() => {}) + const error = jest.spyOn(toastService, 'showError') + const success = jest.spyOn(toastService, 'showSuccess') + for (const status of [ + PaperlessTaskStatus.Failure, + PaperlessTaskStatus.Revoked, + ]) { + bulkEvents.next({ + task_id: 'bulk-task', + operation: 'set_permissions', + status, + error: 'Could not update', + }) + } + expect(reload).toHaveBeenCalledTimes(2) + expect(error).toHaveBeenCalledWith(expect.any(String), 'Could not update') + 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 54caff0582..1d331a6fff 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 @@ -8,12 +8,14 @@ import { import { NgClass } from '@angular/common' import { Component, + DestroyRef, HostListener, inject, OnInit, signal, viewChild, } from '@angular/core' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' import { FormsModule } from '@angular/forms' import { ActivatedRoute, Router, RouterModule } from '@angular/router' import { @@ -24,10 +26,11 @@ import { NgbPopoverModule, } from '@ng-bootstrap/ng-bootstrap' import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap' -import { Observable } from 'rxjs' +import { filter, Observable } from 'rxjs' import { first } from 'rxjs/operators' import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component' import { Document } from 'src/app/data/document' +import { PaperlessTaskStatus } from 'src/app/data/paperless-task' import { SavedView } from 'src/app/data/saved-view' import { CollapsibleSection, @@ -41,6 +44,7 @@ import { DjangoMessageLevel, DjangoMessagesService, } from 'src/app/services/django-messages.service' +import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { OpenDocumentsService } from 'src/app/services/open-documents.service' import { PermissionAction, @@ -101,6 +105,8 @@ export class AppFrameComponent extends ComponentWithPermissions implements OnInit, ComponentCanDeactivate { + private destroyRef = inject(DestroyRef) + private documentList = inject(DocumentListViewService) router = inject(Router) private activatedRoute = inject(ActivatedRoute) private openDocumentsService = inject(OpenDocumentsService) @@ -164,6 +170,43 @@ export class AppFrameComponent } ngOnInit(): void { + this.websocketStatusService + .onBulkOperationStatus() + .pipe( + filter(({ status }) => + [ + PaperlessTaskStatus.Success, + PaperlessTaskStatus.Failure, + PaperlessTaskStatus.Revoked, + ].includes(status) + ), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(({ status, error }) => { + this.documentList.reload() + this.documentList.reduceSelectionToFilter() + this.openDocumentsService.getOpenDocuments().forEach((doc) => { + this.openDocumentsService.refreshDocument(doc.id) + }) + this.savedViewService.maybeRefreshDocumentCounts() + if ( + this.permissionsService.currentUserCan( + PermissionAction.View, + PermissionType.PaperlessTask + ) + ) { + this.tasksService.reload() + } + if (status === PaperlessTaskStatus.Success) { + this.toastService.showSuccess($localize`Bulk operation completed.`) + } else { + this.toastService.showError( + $localize`Bulk operation failed. Some changes may already have been applied.`, + error + ) + } + }) + this.lastScrollY = window.scrollY this.detectClassicScrollbars() diff --git a/src-ui/src/app/data/bulk-operation.ts b/src-ui/src/app/data/bulk-operation.ts index 5619a1bfba..4c569a86c5 100644 --- a/src-ui/src/app/data/bulk-operation.ts +++ b/src-ui/src/app/data/bulk-operation.ts @@ -1,4 +1,13 @@ +import { PaperlessTaskStatus } from './paperless-task' + export interface BulkOperationResponse { result: string task_id: string } + +export interface BulkOperationStatus { + task_id: string + operation: string + status: PaperlessTaskStatus + error?: string +} diff --git a/src-ui/src/app/services/websocket-status.service.spec.ts b/src-ui/src/app/services/websocket-status.service.spec.ts index 7e1bfd424e..9366eee994 100644 --- a/src-ui/src/app/services/websocket-status.service.spec.ts +++ b/src-ui/src/app/services/websocket-status.service.spec.ts @@ -438,6 +438,20 @@ describe('ConsumerStatusService', () => { expect(updated).toBeTruthy() }) + it('forwards bulk operation completion events', () => { + const received = jest.fn() + websocketStatusService.onBulkOperationStatus().subscribe(received) + const data = { + task_id: 'bulk-task', + operation: 'set_permissions', + status: 'success', + } + websocketStatusService.connect() + server.send({ type: WebsocketStatusType.BULK_OPERATION_STATUS, data }) + websocketStatusService.disconnect() + expect(received).toHaveBeenCalledWith(data) + }) + it('should ignore keep-alive heartbeat messages from the server', () => { let updated = false let deleted = false diff --git a/src-ui/src/app/services/websocket-status.service.ts b/src-ui/src/app/services/websocket-status.service.ts index d3f90e6a50..e26b849fe6 100644 --- a/src-ui/src/app/services/websocket-status.service.ts +++ b/src-ui/src/app/services/websocket-status.service.ts @@ -1,6 +1,7 @@ import { Injectable, inject, signal } from '@angular/core' import { Subject } from 'rxjs' import { environment } from 'src/environments/environment' +import { BulkOperationStatus } from '../data/bulk-operation' import { User } from '../data/user' import { WebsocketDocumentUpdatedMessage } from '../data/websocket-document-updated-message' import { WebsocketDocumentsDeletedMessage } from '../data/websocket-documents-deleted-message' @@ -12,6 +13,7 @@ export enum WebsocketStatusType { DOCUMENTS_DELETED = 'documents_deleted', DOCUMENT_UPDATED = 'document_updated', HEARTBEAT = 'heartbeat', + BULK_OPERATION_STATUS = 'bulk_operation_status', } // see ProgressStatusOptions in src/documents/plugins/helpers.py @@ -116,6 +118,13 @@ export class WebsocketStatusService { private readonly documentDeletedSubject = new Subject() private readonly documentUpdatedSubject = new Subject() + private readonly bulkOperationStatusSubject = + new Subject() + + onBulkOperationStatus() { + return this.bulkOperationStatusSubject.asObservable() + } + private readonly connectionStatusSubject = new Subject() private get(taskId: string, filename?: string) { @@ -192,6 +201,7 @@ export class WebsocketStatusService { | WebsocketProgressMessage | WebsocketDocumentsDeletedMessage | WebsocketDocumentUpdatedMessage + | BulkOperationStatus } = JSON.parse(ev.data) switch (type) { @@ -209,6 +219,12 @@ export class WebsocketStatusService { this.handleProgressUpdate(messageData as WebsocketProgressMessage) break + case WebsocketStatusType.BULK_OPERATION_STATUS: + this.bulkOperationStatusSubject.next( + messageData as BulkOperationStatus + ) + break + case WebsocketStatusType.HEARTBEAT: // keep-alive from the server, see paperless.consumers.StatusConsumer break