OK extract versions to its own component

This commit is contained in:
shamoon
2026-02-12 21:35:02 -08:00
parent 6a1dfe38a2
commit c8b1ec1259
6 changed files with 517 additions and 463 deletions

View File

@@ -24,89 +24,15 @@
<i-bs width="1.2em" height="1.2em" name="trash"></i-bs><span class="d-none d-lg-inline ps-1" i18n>Delete</span>
</button>
@if (document?.versions?.length > 0) {
<div class="btn-group" ngbDropdown autoClose="outside">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" ngbDropdownToggle>
<i-bs name="file-earmark-diff"></i-bs>
<span class="d-none d-lg-inline ps-1" i18n>Versions</span>
</button>
<div class="dropdown-menu shadow" ngbDropdownMenu>
<div class="px-3 py-2">
@if (versionUploadState === UploadState.Idle) {
<div class="input-group input-group-sm mb-2">
<span class="input-group-text" i18n>Label</span>
<input class="form-control" type="text" [(ngModel)]="newVersionLabel" i18n-placeholder placeholder="Optional" [disabled]="!userIsOwner || !userCanEdit" />
</div>
<input #versionFileInput type="file" class="visually-hidden" (change)="onVersionFileSelected($event)" />
<button class="btn btn-sm btn-outline-secondary w-100" (click)="versionFileInput.click()" [disabled]="!userIsOwner || !userCanEdit">
<i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Add new version</span>
</button>
} @else {
@switch (versionUploadState) {
@case (UploadState.Uploading) {
<div class="small text-muted mt-1 d-flex align-items-center">
<output class="spinner-border spinner-border-sm me-2" aria-hidden="true"></output>
<span i18n>Uploading version...</span>
</div>
}
@case (UploadState.Processing) {
<div class="small text-muted mt-1 d-flex align-items-center">
<output class="spinner-border spinner-border-sm me-2" aria-hidden="true"></output>
<span i18n>Processing version...</span>
</div>
}
@case (UploadState.Failed) {
<div class="small text-danger mt-1 d-flex align-items-center justify-content-between">
<span i18n>Version upload failed.</span>
<button type="button" class="btn btn-link btn-sm p-0 ms-2" (click)="clearVersionUploadStatus()" i18n>Dismiss</button>
</div>
@if (versionUploadError) {
<div class="small text-muted mt-1">{{ versionUploadError }}</div>
}
}
}
}
</div>
<div class="dropdown-divider"></div>
@for (version of document.versions; track version.id) {
<div class="dropdown-item">
<div class="d-flex align-items-center w-100 version-item">
<button type="button" class="btn btn-link link-underline link-underline-opacity-0 d-flex align-items-center flex-grow-1 small ps-0 text-start" (click)="selectVersion(version.id)">
<div class="badge bg-light text-lowercase text-muted">
{{ version.checksum | slice:0:8 }}
</div>
<div class="d-flex flex-column small ms-2">
<div>
@if (version.version_label) {
{{ version.version_label }}
} @else {
<span i18n>ID</span> #{{version.id}}
}
</div>
<div class="text-muted">
{{ version.added | customDate:'short' }}
</div>
</div>
</button>
@if (selectedVersionId === version.id) { <span class="ms-2"></span> }
@if (!version.is_root) {
<pngx-confirm-button
buttonClasses="btn-link btn-sm text-danger ms-2"
iconName="trash"
confirmMessage="Delete this version?"
i18n-confirmMessage
[disabled]="!userIsOwner || !userCanEdit"
(confirm)="deleteVersion(version.id)"
>
<span class="visually-hidden" i18n>Delete version</span>
</pngx-confirm-button>
}
</div>
</div>
}
</div>
</div>
}
<pngx-document-version-dropdown
[documentId]="documentId"
[versions]="document?.versions ?? []"
[selectedVersionId]="selectedVersionId"
[userIsOwner]="userIsOwner"
[userCanEdit]="userCanEdit"
(versionSelected)="onVersionSelected($event)"
(versionsUpdated)="onVersionsUpdated($event)"
/>
<div class="btn-group">
<button (click)="download()" class="btn btn-sm btn-outline-primary" [disabled]="downloading">

View File

@@ -30,7 +30,7 @@ import {
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { DeviceDetectorService } from 'ngx-device-detector'
import { Subject, of, throwError } from 'rxjs'
import { of, throwError } from 'rxjs'
import { routes } from 'src/app/app-routing.module'
import { Correspondent } from 'src/app/data/correspondent'
import { CustomFieldDataType } from 'src/app/data/custom-field'
@@ -65,10 +65,6 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.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 { environment } from 'src/environments/environment'
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
@@ -131,24 +127,6 @@ const customFields = [
},
]
function createFileInput(file?: File) {
const input = document.createElement('input')
input.type = 'file'
const files = file
? ({
0: file,
length: 1,
item: () => file,
} as unknown as FileList)
: ({
length: 0,
item: () => null,
} as unknown as FileList)
Object.defineProperty(input, 'files', { value: files })
input.value = ''
return input
}
describe('DocumentDetailComponent', () => {
let component: DocumentDetailComponent
let fixture: ComponentFixture<DocumentDetailComponent>
@@ -164,7 +142,6 @@ describe('DocumentDetailComponent', () => {
let deviceDetectorService: DeviceDetectorService
let httpTestingController: HttpTestingController
let componentRouterService: ComponentRouterService
let websocketStatusService: WebsocketStatusService
let currentUserCan = true
let currentUserHasObjectPermissions = true
@@ -314,7 +291,6 @@ describe('DocumentDetailComponent', () => {
fixture = TestBed.createComponent(DocumentDetailComponent)
httpTestingController = TestBed.inject(HttpTestingController)
componentRouterService = TestBed.inject(ComponentRouterService)
websocketStatusService = TestBed.inject(WebsocketStatusService)
component = fixture.componentInstance
})
@@ -1677,208 +1653,6 @@ describe('DocumentDetailComponent', () => {
expect(component.previewText).toContain('An error occurred loading content')
})
it('deleteVersion should update versions, fall back, and surface errors', () => {
initNormally()
httpTestingController.expectOne(component.previewUrl).flush('preview')
component.document.versions = [
{
id: 3,
added: new Date(),
version_label: 'Original',
checksum: 'aaaa',
is_root: true,
},
{
id: 10,
added: new Date(),
version_label: 'Edited',
checksum: 'bbbb',
is_root: false,
},
]
component.selectedVersionId = 10
const openDoc = { ...doc, versions: [] } as Document
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
const saveSpy = jest.spyOn(openDocumentsService, 'save')
const deleteSpy = jest.spyOn(documentService, 'deleteVersion')
const versionsSpy = jest.spyOn(documentService, 'getVersions')
const selectSpy = jest
.spyOn(component, 'selectVersion')
.mockImplementation(() => {})
const errorSpy = jest.spyOn(toastService, 'showError')
deleteSpy.mockReturnValueOnce(of({ result: 'ok', current_version_id: 99 }))
versionsSpy.mockReturnValueOnce(
of({ id: doc.id, versions: [{ id: 99, is_root: false }] } as Document)
)
component.deleteVersion(10)
expect(component.document.versions).toEqual([{ id: 99, is_root: false }])
expect(openDoc.versions).toEqual([{ id: 99, is_root: false }])
expect(saveSpy).toHaveBeenCalled()
expect(selectSpy).toHaveBeenCalledWith(99)
component.selectedVersionId = 3
deleteSpy.mockReturnValueOnce(
of({ result: 'ok', current_version_id: null })
)
versionsSpy.mockReturnValueOnce(
of({
id: doc.id,
versions: [
{ id: 7, is_root: false },
{ id: 9, is_root: false },
],
} as Document)
)
component.deleteVersion(3)
expect(selectSpy).toHaveBeenCalledWith(component.documentId)
deleteSpy.mockReturnValueOnce(throwError(() => new Error('nope')))
component.deleteVersion(10)
expect(errorSpy).toHaveBeenCalled()
})
it('onVersionFileSelected should cover upload flows and reset status', () => {
initNormally()
httpTestingController.expectOne(component.previewUrl).flush('preview')
const uploadSpy = jest.spyOn(documentService, 'uploadVersion')
const versionsSpy = jest.spyOn(documentService, 'getVersions')
const infoSpy = jest.spyOn(toastService, 'showInfo')
const errorSpy = jest.spyOn(toastService, 'showError')
const finishedSpy = jest.spyOn(
websocketStatusService,
'onDocumentConsumptionFinished'
)
const failedSpy = jest.spyOn(
websocketStatusService,
'onDocumentConsumptionFailed'
)
const selectSpy = jest
.spyOn(component, 'selectVersion')
.mockImplementation(() => {})
const openDoc = { ...doc, versions: [] } as Document
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
const saveSpy = jest.spyOn(openDocumentsService, 'save')
component.onVersionFileSelected({ target: createFileInput() } as any)
expect(uploadSpy).not.toHaveBeenCalled()
const fileMissing = new File(['data'], 'version.pdf', {
type: 'application/pdf',
})
component.newVersionLabel = ' label '
uploadSpy.mockReturnValueOnce(of({}))
component.onVersionFileSelected({
target: createFileInput(fileMissing),
} as any)
expect(uploadSpy).toHaveBeenCalledWith(
component.documentId,
fileMissing,
'label'
)
expect(component.newVersionLabel).toBe('')
expect(component.versionUploadState).toBe(UploadState.Failed)
expect(component.versionUploadError).toBe('Missing task ID.')
expect(infoSpy).toHaveBeenCalled()
const finishedFail$ = new Subject<any>()
const failedFail$ = new Subject<any>()
finishedSpy.mockReturnValueOnce(finishedFail$ as any)
failedSpy.mockReturnValueOnce(failedFail$ as any)
uploadSpy.mockReturnValueOnce(of('task-1'))
component.onVersionFileSelected({
target: createFileInput(
new File(['data'], 'version.pdf', { type: 'application/pdf' })
),
} as any)
expect(component.versionUploadState).toBe(UploadState.Processing)
failedFail$.next({ taskId: 'task-1', message: 'nope' })
expect(component.versionUploadState).toBe(UploadState.Failed)
expect(component.versionUploadError).toBe('nope')
expect(versionsSpy).not.toHaveBeenCalled()
const finishedOk$ = new Subject<any>()
const failedOk$ = new Subject<any>()
finishedSpy.mockReturnValueOnce(finishedOk$ as any)
failedSpy.mockReturnValueOnce(failedOk$ as any)
uploadSpy.mockReturnValueOnce(of({ task_id: 'task-2' }))
const versions = [
{ id: 7, is_root: false },
{ id: 12, is_root: false },
] as any
versionsSpy.mockReturnValueOnce(of({ id: doc.id, versions } as Document))
component.onVersionFileSelected({
target: createFileInput(
new File(['data'], 'version.pdf', { type: 'application/pdf' })
),
} as any)
finishedOk$.next({ taskId: 'task-2' })
expect(component.document.versions).toEqual(versions)
expect(openDoc.versions).toEqual(versions)
expect(saveSpy).toHaveBeenCalled()
expect(selectSpy).toHaveBeenCalledWith(12)
expect(component.versionUploadState).toBe(UploadState.Idle)
expect(component.versionUploadError).toBeNull()
component.versionUploadState = UploadState.Failed
component.versionUploadError = 'boom'
component.clearVersionUploadStatus()
expect(component.versionUploadState).toBe(UploadState.Idle)
expect(component.versionUploadError).toBeNull()
uploadSpy.mockReturnValueOnce(throwError(() => new Error('upload blew up')))
component.onVersionFileSelected({
target: createFileInput(
new File(['data'], 'version.pdf', { type: 'application/pdf' })
),
} as any)
expect(component.versionUploadState).toBe(UploadState.Failed)
expect(component.versionUploadError).toBe('upload blew up')
expect(errorSpy).toHaveBeenCalled()
})
it('should clear and isolate version upload state on document change', () => {
initNormally()
httpTestingController.expectOne(component.previewUrl).flush('preview')
component.versionUploadState = UploadState.Failed
component.versionUploadError = 'boom'
component.docChangeNotifier.next(999)
expect(component.versionUploadState).toBe(UploadState.Idle)
expect(component.versionUploadError).toBeNull()
const uploadSpy = jest.spyOn(documentService, 'uploadVersion')
const versionsSpy = jest.spyOn(documentService, 'getVersions')
const finished$ = new Subject<any>()
const failed$ = new Subject<any>()
jest
.spyOn(websocketStatusService, 'onDocumentConsumptionFinished')
.mockReturnValueOnce(finished$ as any)
jest
.spyOn(websocketStatusService, 'onDocumentConsumptionFailed')
.mockReturnValueOnce(failed$ as any)
uploadSpy.mockReturnValueOnce(of('task-stale'))
component.onVersionFileSelected({
target: createFileInput(
new File(['data'], 'version.pdf', { type: 'application/pdf' })
),
} as any)
expect(component.versionUploadState).toBe(UploadState.Processing)
component.docChangeNotifier.next(1000)
failed$.next({ taskId: 'task-stale', message: 'stale-error' })
expect(component.versionUploadState).toBe(UploadState.Idle)
expect(component.versionUploadError).toBeNull()
expect(versionsSpy).not.toHaveBeenCalled()
})
it('createDisabled should return true if the user does not have permission to add the specified data type', () => {
currentUserCan = false
expect(component.createDisabled(DataType.Correspondent)).toBeTruthy()

View File

@@ -1,4 +1,4 @@
import { AsyncPipe, NgTemplateOutlet, SlicePipe } from '@angular/common'
import { AsyncPipe, NgTemplateOutlet } from '@angular/common'
import { HttpClient, HttpResponse } from '@angular/common/http'
import { Component, inject, OnDestroy, OnInit, ViewChild } from '@angular/core'
import {
@@ -20,7 +20,7 @@ import {
import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { DeviceDetectorService } from 'ngx-device-detector'
import { BehaviorSubject, merge, Observable, of, Subject, timer } from 'rxjs'
import { BehaviorSubject, Observable, of, Subject, timer } from 'rxjs'
import {
catchError,
debounceTime,
@@ -29,7 +29,6 @@ import {
first,
map,
switchMap,
take,
takeUntil,
tap,
} from 'rxjs/operators'
@@ -37,7 +36,7 @@ import { Correspondent } from 'src/app/data/correspondent'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { CustomFieldInstance } from 'src/app/data/custom-field-instance'
import { DataType } from 'src/app/data/datatype'
import { Document } from 'src/app/data/document'
import { Document, DocumentVersionInfo } from 'src/app/data/document'
import { DocumentMetadata } from 'src/app/data/document-metadata'
import { DocumentNote } from 'src/app/data/document-note'
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
@@ -81,15 +80,10 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.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 { getFilenameFromContentDisposition } from 'src/app/utils/http'
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
import * as UTIF from 'utif'
import { DocumentDetailFieldID } from '../admin/settings/settings.component'
import { ConfirmButtonComponent } from '../common/confirm-button/confirm-button.component'
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
@@ -126,6 +120,7 @@ import { SuggestionsDropdownComponent } from '../common/suggestions-dropdown/sug
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { DocumentHistoryComponent } from './document-history/document-history.component'
import { DocumentVersionDropdownComponent } from './document-version-dropdown/document-version-dropdown.component'
import { MetadataCollapseComponent } from './metadata-collapse/metadata-collapse.component'
enum DocumentDetailNavIDs {
@@ -183,8 +178,7 @@ enum ContentRenderType {
TextAreaComponent,
RouterModule,
PngxPdfViewerComponent,
ConfirmButtonComponent,
SlicePipe,
DocumentVersionDropdownComponent,
],
})
export class DocumentDetailComponent
@@ -192,7 +186,6 @@ export class DocumentDetailComponent
implements OnInit, OnDestroy, DirtyComponent
{
PdfRenderMode = PdfRenderMode
UploadState = UploadState
documentsService = inject(DocumentService)
private route = inject(ActivatedRoute)
@@ -215,7 +208,6 @@ export class DocumentDetailComponent
private componentRouterService = inject(ComponentRouterService)
private deviceDetectorService = inject(DeviceDetectorService)
private savedViewService = inject(SavedViewService)
private readonly websocketStatusService = inject(WebsocketStatusService)
@ViewChild('inputTitle')
titleInput: TextComponent
@@ -247,10 +239,7 @@ export class DocumentDetailComponent
// Versioning
selectedVersionId: number
newVersionLabel: string = ''
pdfSource: PdfSource
versionUploadState: UploadState = UploadState.Idle
versionUploadError: string | null = null
correspondents: Correspondent[]
documentTypes: DocumentType[]
@@ -297,7 +286,6 @@ export class DocumentDetailComponent
public readonly DocumentDetailFieldID = DocumentDetailFieldID
@ViewChild('nav') nav: NgbNav
@ViewChild('versionFileInput') versionFileInput
@ViewChild('pdfPreview') set pdfPreview(element) {
// this gets called when component added or removed from DOM
if (
@@ -674,10 +662,6 @@ export class DocumentDetailComponent
this.loadDocument(documentId)
})
this.docChangeNotifier
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => this.clearVersionUploadStatus())
this.route.paramMap
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((paramMap) => {
@@ -859,41 +843,17 @@ export class DocumentDetailComponent
})
}
deleteVersion(versionId: number) {
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.unsubscribeNotifier)
)
.subscribe({
next: ({ doc, result }) => {
if (doc?.versions) {
this.document.versions = doc.versions
const openDoc = this.openDocumentService.getOpenDocument(
this.documentId
)
if (openDoc) {
openDoc.versions = doc.versions
this.openDocumentService.save()
}
}
onVersionSelected(versionId: number) {
this.selectVersion(versionId)
}
if (wasSelected) {
const fallbackId = result?.current_version_id ?? this.documentId
this.selectVersion(fallbackId)
}
},
error: (error) => {
this.toastService.showError($localize`Error deleting version`, error)
},
})
onVersionsUpdated(versions: DocumentVersionInfo[]) {
this.document.versions = versions
const openDoc = this.openDocumentService.getOpenDocument(this.documentId)
if (openDoc) {
openDoc.versions = versions
this.openDocumentService.save()
}
}
get customFieldFormFields(): FormArray {
@@ -1312,104 +1272,6 @@ export class DocumentDetailComponent
})
}
onVersionFileSelected(event: Event) {
const input = event.target as HTMLInputElement
if (!input?.files || input.files.length === 0) return
const uploadDocumentId = this.documentId
const file = input.files[0]
// Reset input to allow re-selection of the same file later
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.unsubscribeNotifier, this.docChangeNotifier)),
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.unsubscribeNotifier),
takeUntil(this.docChangeNotifier)
)
.subscribe({
next: (doc) => {
if (uploadDocumentId !== this.documentId) return
if (doc?.versions) {
this.document.versions = doc.versions
const openDoc = this.openDocumentService.getOpenDocument(
this.documentId
)
if (openDoc) {
openDoc.versions = doc.versions
this.openDocumentService.save()
}
this.selectVersion(
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() {
this.versionUploadState = UploadState.Idle
this.versionUploadError = null
}
private getSelectedNonLatestVersionId(): number | null {
const versions = this.document?.versions ?? []
if (!versions.length || !this.selectedVersionId) {

View File

@@ -0,0 +1,97 @@
<div class="btn-group" ngbDropdown autoClose="outside">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" ngbDropdownToggle>
<i-bs name="file-earmark-diff"></i-bs>
<span class="d-none d-lg-inline ps-1" i18n>Versions</span>
</button>
<div class="dropdown-menu shadow" ngbDropdownMenu>
<div class="px-3 py-2">
@if (versionUploadState === UploadState.Idle) {
<div class="input-group input-group-sm mb-2">
<span class="input-group-text" i18n>Label</span>
<input
class="form-control"
type="text"
[(ngModel)]="newVersionLabel"
i18n-placeholder
placeholder="Optional"
[disabled]="!userIsOwner || !userCanEdit"
/>
</div>
<input
#versionFileInput
type="file"
class="visually-hidden"
(change)="onVersionFileSelected($event)"
/>
<button
class="btn btn-sm btn-outline-secondary w-100"
(click)="versionFileInput.click()"
[disabled]="!userIsOwner || !userCanEdit"
>
<i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Add new version</span>
</button>
} @else {
@switch (versionUploadState) {
@case (UploadState.Uploading) {
<div class="small text-muted mt-1 d-flex align-items-center">
<output class="spinner-border spinner-border-sm me-2" aria-hidden="true"></output>
<span i18n>Uploading version...</span>
</div>
}
@case (UploadState.Processing) {
<div class="small text-muted mt-1 d-flex align-items-center">
<output class="spinner-border spinner-border-sm me-2" aria-hidden="true"></output>
<span i18n>Processing version...</span>
</div>
}
@case (UploadState.Failed) {
<div class="small text-danger mt-1 d-flex align-items-center justify-content-between">
<span i18n>Version upload failed.</span>
<button type="button" class="btn btn-link btn-sm p-0 ms-2" (click)="clearVersionUploadStatus()" i18n>Dismiss</button>
</div>
@if (versionUploadError) {
<div class="small text-muted mt-1">{{ versionUploadError }}</div>
}
}
}
}
</div>
<div class="dropdown-divider"></div>
@for (version of versions; track version.id) {
<div class="dropdown-item">
<div class="d-flex align-items-center w-100 version-item">
<button type="button" class="btn btn-link link-underline link-underline-opacity-0 d-flex align-items-center flex-grow-1 small ps-0 text-start" (click)="selectVersion(version.id)">
<div class="badge bg-light text-lowercase text-muted">
{{ version.checksum | slice:0:8 }}
</div>
<div class="d-flex flex-column small ms-2">
<div>
@if (version.version_label) {
{{ version.version_label }}
} @else {
<span i18n>ID</span> #{{version.id}}
}
</div>
<div class="text-muted">
{{ version.added | customDate:'short' }}
</div>
</div>
</button>
@if (selectedVersionId === version.id) { <span class="ms-2"></span> }
@if (!version.is_root) {
<pngx-confirm-button
buttonClasses="btn-link btn-sm text-danger ms-2"
iconName="trash"
confirmMessage="Delete this version?"
i18n-confirmMessage
[disabled]="!userIsOwner || !userCanEdit"
(confirm)="deleteVersion(version.id)"
>
<span class="visually-hidden" i18n>Delete version</span>
</pngx-confirm-button>
}
</div>
</div>
}
</div>
</div>

View File

@@ -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<DocumentVersionDropdownComponent>
let documentService: jest.Mocked<
Pick<DocumentService, 'deleteVersion' | 'getVersions' | 'uploadVersion'>
>
let toastService: jest.Mocked<Pick<ToastService, 'showError' | 'showInfo'>>
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()
})
})

View File

@@ -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<number>()
@Output() versionsUpdated = new EventEmitter<DocumentVersionInfo[]>()
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<void>()
private readonly documentChange$ = new Subject<void>()
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
}
}