Feature: document file versions (#12061)

This commit is contained in:
shamoon
2026-02-26 16:46:54 +00:00
committed by GitHub
parent c9278f88ca
commit ceee769e26
35 changed files with 4068 additions and 365 deletions
@@ -0,0 +1,134 @@
<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 mb-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>
@for (version of versions; track version.id) {
<div class="dropdown-item border-top px-0">
<div class="d-flex align-items-center w-100 py-2 version-item">
<div class="btn btn-link link-underline link-underline-opacity-0 d-flex align-items-center small text-start p-0 version-link"
(click)="selectVersion(version.id)"
>
<div class="check mx-3">
@if (selectedVersionId === version.id) {
<i-bs name="check-circle"></i-bs>
} @else {
<i-bs class="text-muted" name="circle"></i-bs>
}
</div>
<div class="d-flex flex-column">
<div class="input-group input-group-sm mb-1">
@if (isEditingVersion(version.id)) {
<input
class="form-control"
type="text"
[(ngModel)]="versionLabelDraft"
i18n-placeholder
placeholder="Version label"
[disabled]="savingVersionLabelId !== null"
(keydown.enter)="submitEditedVersionLabel(version, $event)"
(keydown.escape)="cancelEditingVersion($event)"
(click)="$event.stopPropagation()"
/>
} @else {
<span class="input-group-text version-label">
@if (version.version_label) {
{{ version.version_label }}
} @else {
<span i18n>Version</span>&nbsp;#{{ version.id }}
}
</span>
}
@if (canEditLabels) {
<button
type="button"
class="btn btn-outline-secondary"
[disabled]="savingVersionLabelId !== null"
(click)="isEditingVersion(version.id) ? submitEditedVersionLabel(version, $event) : beginEditingVersion(version, $event)"
>
@if (isEditingVersion(version.id)) {
<i-bs width=".8rem" height=".8rem" name="check-lg"></i-bs>
} @else {
<i-bs width=".8rem" height=".8rem" name="pencil"></i-bs>
}
</button>
}
</div>
<div class="d-flex text-muted small align-items-center mt-1">
{{ version.added | customDate:'short' }}
<div class="badge bg-light text-muted ms-auto">
{{ version.checksum | slice:0:8 }}
</div>
</div>
</div>
</div>
@if (!version.is_root) {
<pngx-confirm-button
buttonClasses="btn btn-sm btn-link text-danger mx-1"
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>
@@ -0,0 +1,17 @@
.version-item {
.check {
width: 1rem;
height: 100%;
}
> .version-link {
.flex-column {
width: 260px;
}
.input-group .version-label, .input-group input {
width: 140px;
flex: 1 1 auto;
}
}
}
@@ -0,0 +1,324 @@
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' | 'updateVersionLabel'
>
>
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(),
updateVersionLabel: 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('beginEditingVersion should set active row and draft label', () => {
component.userCanEdit = true
component.userIsOwner = true
const version = {
id: 10,
is_root: false,
checksum: 'bbbb',
version_label: 'Current',
} as DocumentVersionInfo
component.beginEditingVersion(version)
expect(component.editingVersionId).toEqual(10)
expect(component.versionLabelDraft).toEqual('Current')
})
it('submitEditedVersionLabel should close editor without save if unchanged', () => {
const version = {
id: 10,
is_root: false,
checksum: 'bbbb',
version_label: 'Current',
} as DocumentVersionInfo
const saveSpy = jest.spyOn(component, 'saveVersionLabel')
component.editingVersionId = 10
component.versionLabelDraft = ' Current '
component.submitEditedVersionLabel(version)
expect(saveSpy).not.toHaveBeenCalled()
expect(component.editingVersionId).toBeNull()
expect(component.versionLabelDraft).toEqual('')
})
it('submitEditedVersionLabel should call saveVersionLabel when changed', () => {
const version = {
id: 10,
is_root: false,
checksum: 'bbbb',
version_label: 'Current',
} as DocumentVersionInfo
const saveSpy = jest
.spyOn(component, 'saveVersionLabel')
.mockImplementation(() => {})
component.editingVersionId = 10
component.versionLabelDraft = ' Updated '
component.submitEditedVersionLabel(version)
expect(saveSpy).toHaveBeenCalledWith(10, 'Updated')
expect(component.editingVersionId).toBeNull()
})
it('saveVersionLabel should update the version and emit versionsUpdated', () => {
documentService.updateVersionLabel.mockReturnValue(
of({
id: 10,
version_label: 'Updated',
is_root: false,
} as any)
)
const emitSpy = jest.spyOn(component.versionsUpdated, 'emit')
component.saveVersionLabel(10, 'Updated')
expect(documentService.updateVersionLabel).toHaveBeenCalledWith(
3,
10,
'Updated'
)
expect(emitSpy).toHaveBeenCalledWith([
{ id: 3, is_root: true, checksum: 'aaaa' },
{ id: 10, is_root: false, checksum: 'bbbb', version_label: 'Updated' },
])
expect(component.savingVersionLabelId).toBeNull()
})
it('saveVersionLabel should show error toast on failure', () => {
const error = new Error('save failed')
documentService.updateVersionLabel.mockReturnValue(throwError(() => error))
component.saveVersionLabel(10, 'Updated')
expect(toastService.showError).toHaveBeenCalledWith(
'Error updating version label',
error
)
expect(component.savingVersionLabelId).toBeNull()
})
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('onVersionFileSelected should fail when backend response has no task id', () => {
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({} as any))
component.onVersionFileSelected({ target: input } as Event)
expect(component.versionUploadState).toEqual(UploadState.Failed)
expect(component.versionUploadError).toEqual('Missing task ID.')
expect(documentService.getVersions).not.toHaveBeenCalled()
})
it('onVersionFileSelected should show error when upload request fails', () => {
const file = new File(['test'], 'new-version.pdf', {
type: 'application/pdf',
})
const input = document.createElement('input')
Object.defineProperty(input, 'files', { value: [file] })
const error = new Error('upload failed')
documentService.uploadVersion.mockReturnValue(throwError(() => error))
component.onVersionFileSelected({ target: input } as Event)
expect(component.versionUploadState).toEqual(UploadState.Failed)
expect(component.versionUploadError).toEqual('upload failed')
expect(toastService.showError).toHaveBeenCalledWith(
'Error uploading new version',
error
)
})
it('ngOnChanges should clear upload status on document switch', () => {
component.versionUploadState = UploadState.Failed
component.versionUploadError = 'something failed'
component.editingVersionId = 10
component.versionLabelDraft = 'draft'
component.ngOnChanges({
documentId: new SimpleChange(3, 4, false),
})
expect(component.versionUploadState).toEqual(UploadState.Idle)
expect(component.versionUploadError).toBeNull()
expect(component.editingVersionId).toBeNull()
expect(component.versionLabelDraft).toEqual('')
})
})
@@ -0,0 +1,281 @@
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,
finalize,
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',
styleUrls: ['./document-version-dropdown.component.scss'],
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
savingVersionLabelId: number | null = null
editingVersionId: number | null = null
versionLabelDraft: string = ''
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()
this.cancelEditingVersion()
}
}
ngOnDestroy(): void {
this.documentChange$.next()
this.documentChange$.complete()
this.destroy$.next()
this.destroy$.complete()
}
selectVersion(versionId: number): void {
this.versionSelected.emit(versionId)
}
get canEditLabels(): boolean {
return this.userIsOwner && this.userCanEdit
}
isEditingVersion(versionId: number): boolean {
return this.editingVersionId === versionId
}
beginEditingVersion(version: DocumentVersionInfo, event?: Event): void {
event?.preventDefault()
event?.stopPropagation()
if (!this.canEditLabels || this.savingVersionLabelId !== null) return
this.editingVersionId = version.id
this.versionLabelDraft = version.version_label ?? ''
}
cancelEditingVersion(event?: Event): void {
event?.preventDefault()
event?.stopPropagation()
this.editingVersionId = null
this.versionLabelDraft = ''
}
submitEditedVersionLabel(version: DocumentVersionInfo, event?: Event): void {
event?.preventDefault()
event?.stopPropagation()
if (this.savingVersionLabelId !== null) return
const nextLabel = this.versionLabelDraft?.trim() || null
const currentLabel = version.version_label?.trim() || null
if (nextLabel === currentLabel) {
this.cancelEditingVersion()
return
}
this.saveVersionLabel(version.id, nextLabel)
this.cancelEditingVersion()
}
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)
},
})
}
saveVersionLabel(versionId: number, versionLabel: string | null): void {
if (this.savingVersionLabelId !== null) return
this.savingVersionLabelId = versionId
this.documentsService
.updateVersionLabel(this.documentId, versionId, versionLabel)
.pipe(
first(),
finalize(() => {
if (this.savingVersionLabelId === versionId) {
this.savingVersionLabelId = null
}
}),
takeUntil(this.destroy$)
)
.subscribe({
next: (updatedVersion) => {
const updatedVersions = this.versions.map((version) =>
version.id === versionId
? {
...version,
version_label: updatedVersion.version_label,
}
: version
)
this.versionsUpdated.emit(updatedVersions)
},
error: (error) => {
this.toastService.showError(
$localize`Error updating version label`,
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
}
}