mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-03 09:32:17 +00:00
Feature: document file versions (#12061)
This commit is contained in:
+134
@@ -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> #{{ 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>
|
||||
+17
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+324
@@ -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('')
|
||||
})
|
||||
})
|
||||
+281
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user