mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-11-10 08:21:01 +00:00
Hook up the refresh stuff on ws for bulk operations
This commit is contained in:
5 files changed
+157
-3
No files matched your search
@@ -11,8 +11,10 @@ import { ActivatedRoute, Router } from '@angular/router'
|
|||||||
import { RouterTestingModule } from '@angular/router/testing'
|
import { RouterTestingModule } from '@angular/router/testing'
|
||||||
import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { provideUiTour } from 'ngx-ui-tour-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 { 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 { SavedView } from 'src/app/data/saved-view'
|
||||||
import { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
@@ -21,10 +23,11 @@ import {
|
|||||||
DjangoMessageLevel,
|
DjangoMessageLevel,
|
||||||
DjangoMessagesService,
|
DjangoMessagesService,
|
||||||
} from 'src/app/services/django-messages.service'
|
} 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 { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||||
import {
|
import {
|
||||||
PermissionType,
|
|
||||||
PermissionsService,
|
PermissionsService,
|
||||||
|
PermissionType,
|
||||||
} from 'src/app/services/permissions.service'
|
} from 'src/app/services/permissions.service'
|
||||||
import { RemoteVersionService } from 'src/app/services/rest/remote-version.service'
|
import { RemoteVersionService } from 'src/app/services/rest/remote-version.service'
|
||||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||||
@@ -95,6 +98,7 @@ describe('AppFrameComponent', () => {
|
|||||||
let savedViewSpy
|
let savedViewSpy
|
||||||
let modalService: NgbModal
|
let modalService: NgbModal
|
||||||
let maybeRefreshSpy
|
let maybeRefreshSpy
|
||||||
|
let bulkEvents: Subject<BulkOperationStatus>
|
||||||
let tasksService: TasksService
|
let tasksService: TasksService
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -182,6 +186,10 @@ describe('AppFrameComponent', () => {
|
|||||||
savedViewSpy = jest.spyOn(savedViewService, 'reload')
|
savedViewSpy = jest.spyOn(savedViewService, 'reload')
|
||||||
maybeRefreshSpy = jest.spyOn(savedViewService, 'maybeRefreshDocumentCounts')
|
maybeRefreshSpy = jest.spyOn(savedViewService, 'maybeRefreshDocumentCounts')
|
||||||
|
|
||||||
|
bulkEvents = new Subject<BulkOperationStatus>()
|
||||||
|
jest
|
||||||
|
.spyOn(TestBed.inject(WebsocketStatusService), 'onBulkOperationStatus')
|
||||||
|
.mockReturnValue(bulkEvents.asObservable())
|
||||||
fixture = TestBed.createComponent(AppFrameComponent)
|
fixture = TestBed.createComponent(AppFrameComponent)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|
||||||
@@ -190,6 +198,70 @@ describe('AppFrameComponent', () => {
|
|||||||
fixture.detectChanges()
|
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', () => {
|
it('should initialize the saved view service', () => {
|
||||||
expect(savedViewSpy).toHaveBeenCalled()
|
expect(savedViewSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import {
|
|||||||
import { NgClass } from '@angular/common'
|
import { NgClass } from '@angular/common'
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
|
DestroyRef,
|
||||||
HostListener,
|
HostListener,
|
||||||
inject,
|
inject,
|
||||||
OnInit,
|
OnInit,
|
||||||
signal,
|
signal,
|
||||||
viewChild,
|
viewChild,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
|
||||||
import { FormsModule } from '@angular/forms'
|
import { FormsModule } from '@angular/forms'
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
||||||
import {
|
import {
|
||||||
@@ -24,10 +26,11 @@ import {
|
|||||||
NgbPopoverModule,
|
NgbPopoverModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { TourNgBootstrap } from 'ngx-ui-tour-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 { first } from 'rxjs/operators'
|
||||||
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||||
import { Document } from 'src/app/data/document'
|
import { Document } from 'src/app/data/document'
|
||||||
|
import { PaperlessTaskStatus } from 'src/app/data/paperless-task'
|
||||||
import { SavedView } from 'src/app/data/saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
import {
|
import {
|
||||||
CollapsibleSection,
|
CollapsibleSection,
|
||||||
@@ -41,6 +44,7 @@ import {
|
|||||||
DjangoMessageLevel,
|
DjangoMessageLevel,
|
||||||
DjangoMessagesService,
|
DjangoMessagesService,
|
||||||
} from 'src/app/services/django-messages.service'
|
} 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 { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||||
import {
|
import {
|
||||||
PermissionAction,
|
PermissionAction,
|
||||||
@@ -101,6 +105,8 @@ export class AppFrameComponent
|
|||||||
extends ComponentWithPermissions
|
extends ComponentWithPermissions
|
||||||
implements OnInit, ComponentCanDeactivate
|
implements OnInit, ComponentCanDeactivate
|
||||||
{
|
{
|
||||||
|
private destroyRef = inject(DestroyRef)
|
||||||
|
private documentList = inject(DocumentListViewService)
|
||||||
router = inject(Router)
|
router = inject(Router)
|
||||||
private activatedRoute = inject(ActivatedRoute)
|
private activatedRoute = inject(ActivatedRoute)
|
||||||
private openDocumentsService = inject(OpenDocumentsService)
|
private openDocumentsService = inject(OpenDocumentsService)
|
||||||
@@ -164,6 +170,43 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
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.lastScrollY = window.scrollY
|
||||||
this.detectClassicScrollbars()
|
this.detectClassicScrollbars()
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
|
import { PaperlessTaskStatus } from './paperless-task'
|
||||||
|
|
||||||
export interface BulkOperationResponse {
|
export interface BulkOperationResponse {
|
||||||
result: string
|
result: string
|
||||||
task_id: string
|
task_id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BulkOperationStatus {
|
||||||
|
task_id: string
|
||||||
|
operation: string
|
||||||
|
status: PaperlessTaskStatus
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
@@ -438,6 +438,20 @@ describe('ConsumerStatusService', () => {
|
|||||||
expect(updated).toBeTruthy()
|
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', () => {
|
it('should ignore keep-alive heartbeat messages from the server', () => {
|
||||||
let updated = false
|
let updated = false
|
||||||
let deleted = false
|
let deleted = false
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, inject, signal } from '@angular/core'
|
import { Injectable, inject, signal } from '@angular/core'
|
||||||
import { Subject } from 'rxjs'
|
import { Subject } from 'rxjs'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
|
import { BulkOperationStatus } from '../data/bulk-operation'
|
||||||
import { User } from '../data/user'
|
import { User } from '../data/user'
|
||||||
import { WebsocketDocumentUpdatedMessage } from '../data/websocket-document-updated-message'
|
import { WebsocketDocumentUpdatedMessage } from '../data/websocket-document-updated-message'
|
||||||
import { WebsocketDocumentsDeletedMessage } from '../data/websocket-documents-deleted-message'
|
import { WebsocketDocumentsDeletedMessage } from '../data/websocket-documents-deleted-message'
|
||||||
@@ -12,6 +13,7 @@ export enum WebsocketStatusType {
|
|||||||
DOCUMENTS_DELETED = 'documents_deleted',
|
DOCUMENTS_DELETED = 'documents_deleted',
|
||||||
DOCUMENT_UPDATED = 'document_updated',
|
DOCUMENT_UPDATED = 'document_updated',
|
||||||
HEARTBEAT = 'heartbeat',
|
HEARTBEAT = 'heartbeat',
|
||||||
|
BULK_OPERATION_STATUS = 'bulk_operation_status',
|
||||||
}
|
}
|
||||||
|
|
||||||
// see ProgressStatusOptions in src/documents/plugins/helpers.py
|
// see ProgressStatusOptions in src/documents/plugins/helpers.py
|
||||||
@@ -116,6 +118,13 @@ export class WebsocketStatusService {
|
|||||||
private readonly documentDeletedSubject = new Subject<boolean>()
|
private readonly documentDeletedSubject = new Subject<boolean>()
|
||||||
private readonly documentUpdatedSubject =
|
private readonly documentUpdatedSubject =
|
||||||
new Subject<WebsocketDocumentUpdatedMessage>()
|
new Subject<WebsocketDocumentUpdatedMessage>()
|
||||||
|
private readonly bulkOperationStatusSubject =
|
||||||
|
new Subject<BulkOperationStatus>()
|
||||||
|
|
||||||
|
onBulkOperationStatus() {
|
||||||
|
return this.bulkOperationStatusSubject.asObservable()
|
||||||
|
}
|
||||||
|
|
||||||
private readonly connectionStatusSubject = new Subject<boolean>()
|
private readonly connectionStatusSubject = new Subject<boolean>()
|
||||||
|
|
||||||
private get(taskId: string, filename?: string) {
|
private get(taskId: string, filename?: string) {
|
||||||
@@ -192,6 +201,7 @@ export class WebsocketStatusService {
|
|||||||
| WebsocketProgressMessage
|
| WebsocketProgressMessage
|
||||||
| WebsocketDocumentsDeletedMessage
|
| WebsocketDocumentsDeletedMessage
|
||||||
| WebsocketDocumentUpdatedMessage
|
| WebsocketDocumentUpdatedMessage
|
||||||
|
| BulkOperationStatus
|
||||||
} = JSON.parse(ev.data)
|
} = JSON.parse(ev.data)
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -209,6 +219,12 @@ export class WebsocketStatusService {
|
|||||||
this.handleProgressUpdate(messageData as WebsocketProgressMessage)
|
this.handleProgressUpdate(messageData as WebsocketProgressMessage)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case WebsocketStatusType.BULK_OPERATION_STATUS:
|
||||||
|
this.bulkOperationStatusSubject.next(
|
||||||
|
messageData as BulkOperationStatus
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
case WebsocketStatusType.HEARTBEAT:
|
case WebsocketStatusType.HEARTBEAT:
|
||||||
// keep-alive from the server, see paperless.consumers.StatusConsumer
|
// keep-alive from the server, see paperless.consumers.StatusConsumer
|
||||||
break
|
break
|
||||||
|
|||||||
Reference in new issue
Block a user