diff --git a/src-ui/src/app/components/document-detail/document-detail.component.html b/src-ui/src/app/components/document-detail/document-detail.component.html index 3dce7e61a..5bc5accb6 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.html +++ b/src-ui/src/app/components/document-detail/document-detail.component.html @@ -24,89 +24,15 @@ Delete - @if (document?.versions?.length > 0) { -
- - -
- } +
+ +
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 new file mode 100644 index 000000000..1035f34ae --- /dev/null +++ b/src-ui/src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.spec.ts @@ -0,0 +1,192 @@ +import { DatePipe } from '@angular/common' +import { SimpleChange } from '@angular/core' +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' +import { Subject, of, throwError } from 'rxjs' +import { DocumentVersionInfo } from 'src/app/data/document' +import { DocumentService } from 'src/app/services/rest/document.service' +import { SettingsService } from 'src/app/services/settings.service' +import { ToastService } from 'src/app/services/toast.service' +import { + UploadState, + WebsocketStatusService, +} from 'src/app/services/websocket-status.service' +import { DocumentVersionDropdownComponent } from './document-version-dropdown.component' + +describe('DocumentVersionDropdownComponent', () => { + let component: DocumentVersionDropdownComponent + let fixture: ComponentFixture + let documentService: jest.Mocked< + Pick + > + let toastService: jest.Mocked> + let finished$: Subject<{ taskId: string }> + let failed$: Subject<{ taskId: string; message?: string }> + + beforeEach(async () => { + finished$ = new Subject<{ taskId: string }>() + failed$ = new Subject<{ taskId: string; message?: string }>() + documentService = { + deleteVersion: jest.fn(), + getVersions: jest.fn(), + uploadVersion: jest.fn(), + } + toastService = { + showError: jest.fn(), + showInfo: jest.fn(), + } + + await TestBed.configureTestingModule({ + imports: [ + DocumentVersionDropdownComponent, + NgxBootstrapIconsModule.pick(allIcons), + ], + providers: [ + DatePipe, + { + provide: DocumentService, + useValue: documentService, + }, + { + provide: SettingsService, + useValue: { + get: () => null, + }, + }, + { + provide: ToastService, + useValue: toastService, + }, + { + provide: WebsocketStatusService, + useValue: { + onDocumentConsumptionFinished: () => finished$, + onDocumentConsumptionFailed: () => failed$, + }, + }, + ], + }).compileComponents() + + fixture = TestBed.createComponent(DocumentVersionDropdownComponent) + component = fixture.componentInstance + component.documentId = 3 + component.selectedVersionId = 3 + component.versions = [ + { + id: 3, + is_root: true, + checksum: 'aaaa', + }, + { + id: 10, + is_root: false, + checksum: 'bbbb', + }, + ] + fixture.detectChanges() + }) + + it('selectVersion should emit the selected id', () => { + const emitSpy = jest.spyOn(component.versionSelected, 'emit') + component.selectVersion(10) + expect(emitSpy).toHaveBeenCalledWith(10) + }) + + it('deleteVersion should refresh versions and select fallback when deleting current selection', () => { + const updatedVersions: DocumentVersionInfo[] = [ + { id: 3, is_root: true, checksum: 'aaaa' }, + { id: 20, is_root: false, checksum: 'cccc' }, + ] + component.selectedVersionId = 10 + documentService.deleteVersion.mockReturnValue( + of({ result: 'deleted', current_version_id: 3 }) + ) + documentService.getVersions.mockReturnValue( + of({ id: 3, versions: updatedVersions } as any) + ) + const versionsEmitSpy = jest.spyOn(component.versionsUpdated, 'emit') + const selectedEmitSpy = jest.spyOn(component.versionSelected, 'emit') + + component.deleteVersion(10) + + expect(documentService.deleteVersion).toHaveBeenCalledWith(3, 10) + expect(documentService.getVersions).toHaveBeenCalledWith(3) + expect(versionsEmitSpy).toHaveBeenCalledWith(updatedVersions) + expect(selectedEmitSpy).toHaveBeenCalledWith(3) + }) + + it('deleteVersion should show an error toast on failure', () => { + const error = new Error('delete failed') + documentService.deleteVersion.mockReturnValue(throwError(() => error)) + + component.deleteVersion(10) + + expect(toastService.showError).toHaveBeenCalledWith( + 'Error deleting version', + error + ) + }) + + it('onVersionFileSelected should upload and update versions after websocket success', () => { + const versions: DocumentVersionInfo[] = [ + { id: 3, is_root: true, checksum: 'aaaa' }, + { id: 20, is_root: false, checksum: 'cccc' }, + ] + const file = new File(['test'], 'new-version.pdf', { + type: 'application/pdf', + }) + const input = document.createElement('input') + Object.defineProperty(input, 'files', { value: [file] }) + component.newVersionLabel = ' Updated scan ' + documentService.uploadVersion.mockReturnValue( + of({ task_id: 'task-1' } as any) + ) + documentService.getVersions.mockReturnValue(of({ id: 3, versions } as any)) + const versionsEmitSpy = jest.spyOn(component.versionsUpdated, 'emit') + const selectedEmitSpy = jest.spyOn(component.versionSelected, 'emit') + + component.onVersionFileSelected({ target: input } as Event) + finished$.next({ taskId: 'task-1' }) + + expect(documentService.uploadVersion).toHaveBeenCalledWith( + 3, + file, + 'Updated scan' + ) + expect(toastService.showInfo).toHaveBeenCalled() + expect(documentService.getVersions).toHaveBeenCalledWith(3) + expect(versionsEmitSpy).toHaveBeenCalledWith(versions) + expect(selectedEmitSpy).toHaveBeenCalledWith(20) + expect(component.newVersionLabel).toEqual('') + expect(component.versionUploadState).toEqual(UploadState.Idle) + expect(component.versionUploadError).toBeNull() + }) + + it('onVersionFileSelected should set failed state after websocket failure', () => { + const file = new File(['test'], 'new-version.pdf', { + type: 'application/pdf', + }) + const input = document.createElement('input') + Object.defineProperty(input, 'files', { value: [file] }) + documentService.uploadVersion.mockReturnValue(of('task-1')) + + component.onVersionFileSelected({ target: input } as Event) + failed$.next({ taskId: 'task-1', message: 'processing failed' }) + + expect(component.versionUploadState).toEqual(UploadState.Failed) + expect(component.versionUploadError).toEqual('processing failed') + expect(documentService.getVersions).not.toHaveBeenCalled() + }) + + it('ngOnChanges should clear upload status on document switch', () => { + component.versionUploadState = UploadState.Failed + component.versionUploadError = 'something failed' + + component.ngOnChanges({ + documentId: new SimpleChange(3, 4, false), + }) + + expect(component.versionUploadState).toEqual(UploadState.Idle) + expect(component.versionUploadError).toBeNull() + }) +}) 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 new file mode 100644 index 000000000..1a3c84d31 --- /dev/null +++ b/src-ui/src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts @@ -0,0 +1,203 @@ +import { SlicePipe } from '@angular/common' +import { + Component, + EventEmitter, + inject, + Input, + OnChanges, + OnDestroy, + Output, + SimpleChanges, +} from '@angular/core' +import { FormsModule } from '@angular/forms' +import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' +import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' +import { merge, of, Subject } from 'rxjs' +import { + filter, + first, + map, + switchMap, + take, + takeUntil, + tap, +} from 'rxjs/operators' +import { DocumentVersionInfo } from 'src/app/data/document' +import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' +import { DocumentService } from 'src/app/services/rest/document.service' +import { ToastService } from 'src/app/services/toast.service' +import { + UploadState, + WebsocketStatusService, +} from 'src/app/services/websocket-status.service' +import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component' + +@Component({ + selector: 'pngx-document-version-dropdown', + templateUrl: './document-version-dropdown.component.html', + imports: [ + FormsModule, + NgbDropdownModule, + NgxBootstrapIconsModule, + ConfirmButtonComponent, + SlicePipe, + CustomDatePipe, + ], +}) +export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy { + UploadState = UploadState + + @Input() documentId: number + @Input() versions: DocumentVersionInfo[] = [] + @Input() selectedVersionId: number + @Input() userCanEdit: boolean = false + @Input() userIsOwner: boolean = false + + @Output() versionSelected = new EventEmitter() + @Output() versionsUpdated = new EventEmitter() + + newVersionLabel: string = '' + versionUploadState: UploadState = UploadState.Idle + versionUploadError: string | null = null + + private readonly documentsService = inject(DocumentService) + private readonly toastService = inject(ToastService) + private readonly websocketStatusService = inject(WebsocketStatusService) + private readonly destroy$ = new Subject() + private readonly documentChange$ = new Subject() + + ngOnChanges(changes: SimpleChanges): void { + if (changes.documentId && !changes.documentId.firstChange) { + this.documentChange$.next() + this.clearVersionUploadStatus() + } + } + + ngOnDestroy(): void { + this.documentChange$.next() + this.documentChange$.complete() + this.destroy$.next() + this.destroy$.complete() + } + + selectVersion(versionId: number): void { + this.versionSelected.emit(versionId) + } + + deleteVersion(versionId: number): void { + const wasSelected = this.selectedVersionId === versionId + this.documentsService + .deleteVersion(this.documentId, versionId) + .pipe( + switchMap((result) => + this.documentsService + .getVersions(this.documentId) + .pipe(map((doc) => ({ doc, result }))) + ), + first(), + takeUntil(this.destroy$) + ) + .subscribe({ + next: ({ doc, result }) => { + if (doc?.versions) { + this.versionsUpdated.emit(doc.versions) + } + + if (wasSelected || this.selectedVersionId === versionId) { + const fallbackId = result?.current_version_id ?? this.documentId + this.versionSelected.emit(fallbackId) + } + }, + error: (error) => { + this.toastService.showError($localize`Error deleting version`, error) + }, + }) + } + + onVersionFileSelected(event: Event): void { + const input = event.target as HTMLInputElement + if (!input?.files || input.files.length === 0) return + const uploadDocumentId = this.documentId + const file = input.files[0] + input.value = '' + const label = this.newVersionLabel?.trim() + this.versionUploadState = UploadState.Uploading + this.versionUploadError = null + this.documentsService + .uploadVersion(uploadDocumentId, file, label) + .pipe( + first(), + tap(() => { + this.toastService.showInfo( + $localize`Uploading new version. Processing will happen in the background.` + ) + this.newVersionLabel = '' + this.versionUploadState = UploadState.Processing + }), + map((taskId) => + typeof taskId === 'string' + ? taskId + : (taskId as { task_id?: string })?.task_id + ), + switchMap((taskId) => { + if (!taskId) { + this.versionUploadState = UploadState.Failed + this.versionUploadError = $localize`Missing task ID.` + return of(null) + } + return merge( + this.websocketStatusService.onDocumentConsumptionFinished().pipe( + filter((status) => status.taskId === taskId), + map(() => ({ state: 'success' as const })) + ), + this.websocketStatusService.onDocumentConsumptionFailed().pipe( + filter((status) => status.taskId === taskId), + map((status) => ({ + state: 'failed' as const, + message: status.message, + })) + ) + ).pipe(takeUntil(merge(this.destroy$, this.documentChange$)), take(1)) + }), + switchMap((result) => { + if (result?.state !== 'success') { + if (result?.state === 'failed') { + this.versionUploadState = UploadState.Failed + this.versionUploadError = + result.message || $localize`Upload failed.` + } + return of(null) + } + return this.documentsService.getVersions(uploadDocumentId) + }), + takeUntil(this.destroy$), + takeUntil(this.documentChange$) + ) + .subscribe({ + next: (doc) => { + if (uploadDocumentId !== this.documentId) return + if (doc?.versions) { + this.versionsUpdated.emit(doc.versions) + this.versionSelected.emit( + Math.max(...doc.versions.map((version) => version.id)) + ) + this.clearVersionUploadStatus() + } + }, + error: (error) => { + if (uploadDocumentId !== this.documentId) return + this.versionUploadState = UploadState.Failed + this.versionUploadError = error?.message || $localize`Upload failed.` + this.toastService.showError( + $localize`Error uploading new version`, + error + ) + }, + }) + } + + clearVersionUploadStatus(): void { + this.versionUploadState = UploadState.Idle + this.versionUploadError = null + } +}