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
@@ -1,7 +1,7 @@
<pngx-page-header [(title)]="title" [id]="documentId">
@if (archiveContentRenderType === ContentRenderType.PDF && !useNativePdfViewer) {
@if (previewNumPages) {
<div class="input-group input-group-sm d-none d-md-flex">
<div class="input-group input-group-sm ms-2 d-none d-md-flex">
<div class="input-group-text" i18n>Page</div>
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages" [(ngModel)]="previewCurrentPage" />
<div class="input-group-text" i18n>of {{previewNumPages}}</div>
@@ -24,6 +24,16 @@
<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>
<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">
@if (downloading) {
@@ -294,6 +294,27 @@ describe('DocumentDetailComponent', () => {
component = fixture.componentInstance
})
function initNormally() {
jest
.spyOn(activatedRoute, 'paramMap', 'get')
.mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' })))
jest
.spyOn(documentService, 'get')
.mockReturnValueOnce(of(Object.assign({}, doc)))
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(null)
jest
.spyOn(openDocumentsService, 'openDocument')
.mockReturnValueOnce(of(true))
jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
of({
count: customFields.length,
all: customFields.map((f) => f.id),
results: customFields,
})
)
fixture.detectChanges()
}
it('should load four tabs via url params', () => {
jest
.spyOn(activatedRoute, 'paramMap', 'get')
@@ -354,6 +375,117 @@ describe('DocumentDetailComponent', () => {
expect(component.document).toEqual(doc)
})
it('should redirect to root when opening a version document id', () => {
const navigateSpy = jest.spyOn(router, 'navigate')
jest
.spyOn(activatedRoute, 'paramMap', 'get')
.mockReturnValue(of(convertToParamMap({ id: 10, section: 'details' })))
jest
.spyOn(documentService, 'get')
.mockReturnValueOnce(throwError(() => ({ status: 404 }) as any))
const getRootSpy = jest
.spyOn(documentService, 'getRootId')
.mockReturnValue(of({ root_id: 3 }))
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(null)
jest
.spyOn(openDocumentsService, 'openDocument')
.mockReturnValueOnce(of(true))
jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
of({
count: customFields.length,
all: customFields.map((f) => f.id),
results: customFields,
})
)
fixture.detectChanges()
httpTestingController.expectOne(component.previewUrl).flush('preview')
expect(getRootSpy).toHaveBeenCalledWith(10)
expect(navigateSpy).toHaveBeenCalledWith(['documents', 3, 'details'], {
replaceUrl: true,
})
})
it('should navigate to 404 when root lookup fails', () => {
const navigateSpy = jest.spyOn(router, 'navigate')
jest
.spyOn(activatedRoute, 'paramMap', 'get')
.mockReturnValue(of(convertToParamMap({ id: 10, section: 'details' })))
jest
.spyOn(documentService, 'get')
.mockReturnValueOnce(throwError(() => ({ status: 404 }) as any))
jest
.spyOn(documentService, 'getRootId')
.mockReturnValue(throwError(() => new Error('boom')))
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(null)
jest
.spyOn(openDocumentsService, 'openDocument')
.mockReturnValueOnce(of(true))
jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
of({
count: customFields.length,
all: customFields.map((f) => f.id),
results: customFields,
})
)
fixture.detectChanges()
httpTestingController.expectOne(component.previewUrl).flush('preview')
expect(navigateSpy).toHaveBeenCalledWith(['404'], { replaceUrl: true })
})
it('should not render a delete button for the root/original version', () => {
const docWithVersions = {
...doc,
versions: [
{
id: doc.id,
added: new Date('2024-01-01T00:00:00Z'),
version_label: 'Original',
checksum: 'aaaa',
is_root: true,
},
{
id: 10,
added: new Date('2024-01-02T00:00:00Z'),
version_label: 'Edited',
checksum: 'bbbb',
is_root: false,
},
],
} as Document
jest
.spyOn(activatedRoute, 'paramMap', 'get')
.mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' })))
jest.spyOn(documentService, 'get').mockReturnValueOnce(of(docWithVersions))
jest
.spyOn(documentService, 'getMetadata')
.mockReturnValue(of({ has_archive_version: true } as any))
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(null)
jest
.spyOn(openDocumentsService, 'openDocument')
.mockReturnValueOnce(of(true))
jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
of({
count: customFields.length,
all: customFields.map((f) => f.id),
results: customFields,
})
)
fixture.detectChanges()
httpTestingController.expectOne(component.previewUrl).flush('preview')
fixture.detectChanges()
const deleteButtons = fixture.debugElement.queryAll(
By.css('pngx-confirm-button')
)
expect(deleteButtons.length).toEqual(1)
})
it('should fall back to details tab when duplicates tab is active but no duplicates', () => {
initNormally()
component.activeNavID = component.DocumentDetailNavIDs.Duplicates
@@ -532,6 +664,18 @@ describe('DocumentDetailComponent', () => {
expect(navigateSpy).toHaveBeenCalledWith(['404'], { replaceUrl: true })
})
it('discard should request the currently selected version', () => {
initNormally()
const getSpy = jest.spyOn(documentService, 'get')
getSpy.mockClear()
getSpy.mockReturnValueOnce(of(doc))
component.selectedVersionId = 10
component.discard()
expect(getSpy).toHaveBeenCalledWith(component.documentId, 10)
})
it('should 404 on invalid id', () => {
const navigateSpy = jest.spyOn(router, 'navigate')
jest
@@ -584,6 +728,18 @@ describe('DocumentDetailComponent', () => {
)
})
it('save should target currently selected version', () => {
initNormally()
component.selectedVersionId = 10
const patchSpy = jest.spyOn(documentService, 'patch')
patchSpy.mockReturnValue(of(doc))
component.save()
expect(patchSpy).toHaveBeenCalled()
expect(patchSpy.mock.calls[0][1]).toEqual(10)
})
it('should show toast error on save if error occurs', () => {
currentUserHasObjectPermissions = true
initNormally()
@@ -1036,7 +1192,32 @@ describe('DocumentDetailComponent', () => {
const metadataSpy = jest.spyOn(documentService, 'getMetadata')
metadataSpy.mockReturnValue(of({ has_archive_version: true }))
initNormally()
expect(metadataSpy).toHaveBeenCalled()
expect(metadataSpy).toHaveBeenCalledWith(doc.id, null)
})
it('should pass metadata version only for non-latest selected versions', () => {
const metadataSpy = jest.spyOn(documentService, 'getMetadata')
metadataSpy.mockReturnValue(of({ has_archive_version: true }))
initNormally()
httpTestingController.expectOne(component.previewUrl).flush('preview')
expect(metadataSpy).toHaveBeenCalledWith(doc.id, null)
metadataSpy.mockClear()
component.document.versions = [
{ id: doc.id, is_root: true },
{ id: 10, is_root: false },
] as any
jest.spyOn(documentService, 'getPreviewUrl').mockReturnValue('preview-root')
jest.spyOn(documentService, 'getThumbUrl').mockReturnValue('thumb-root')
jest
.spyOn(documentService, 'get')
.mockReturnValue(of({ content: 'root' } as Document))
component.selectVersion(doc.id)
httpTestingController.expectOne('preview-root').flush('root')
expect(metadataSpy).toHaveBeenCalledWith(doc.id, doc.id)
})
it('should show an error if failed metadata retrieval', () => {
@@ -1441,26 +1622,88 @@ describe('DocumentDetailComponent', () => {
expect(closeSpy).toHaveBeenCalled()
})
function initNormally() {
jest
.spyOn(activatedRoute, 'paramMap', 'get')
.mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' })))
it('selectVersion should update preview and handle preview failures', () => {
const previewSpy = jest.spyOn(documentService, 'getPreviewUrl')
initNormally()
httpTestingController.expectOne(component.previewUrl).flush('preview')
previewSpy.mockReturnValueOnce('preview-version')
jest.spyOn(documentService, 'getThumbUrl').mockReturnValue('thumb-version')
jest
.spyOn(documentService, 'get')
.mockReturnValueOnce(of(Object.assign({}, doc)))
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(null)
.mockReturnValue(of({ content: 'version-content' } as Document))
component.selectVersion(10)
httpTestingController.expectOne('preview-version').flush('version text')
expect(component.previewUrl).toBe('preview-version')
expect(component.thumbUrl).toBe('thumb-version')
expect(component.previewText).toBe('version text')
expect(component.documentForm.get('content').value).toBe('version-content')
const pdfSource = component.pdfSource as { url: string; password?: string }
expect(pdfSource.url).toBe('preview-version')
expect(pdfSource.password).toBeUndefined()
previewSpy.mockReturnValueOnce('preview-error')
component.selectVersion(11)
httpTestingController
.expectOne('preview-error')
.error(new ErrorEvent('fail'))
expect(component.previewText).toContain('An error occurred loading content')
})
it('selectVersion should show toast if version content retrieval fails', () => {
initNormally()
httpTestingController.expectOne(component.previewUrl).flush('preview')
jest.spyOn(documentService, 'getPreviewUrl').mockReturnValue('preview-ok')
jest.spyOn(documentService, 'getThumbUrl').mockReturnValue('thumb-ok')
jest
.spyOn(openDocumentsService, 'openDocument')
.mockReturnValueOnce(of(true))
jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
of({
count: customFields.length,
all: customFields.map((f) => f.id),
results: customFields,
})
.spyOn(documentService, 'getMetadata')
.mockReturnValue(of({ has_archive_version: true } as any))
const contentError = new Error('content failed')
jest
.spyOn(documentService, 'get')
.mockReturnValue(throwError(() => contentError))
const toastSpy = jest.spyOn(toastService, 'showError')
component.selectVersion(10)
httpTestingController.expectOne('preview-ok').flush('preview text')
expect(toastSpy).toHaveBeenCalledWith(
'Error retrieving version content',
contentError
)
fixture.detectChanges()
}
})
it('onVersionSelected should delegate to selectVersion', () => {
const selectVersionSpy = jest
.spyOn(component, 'selectVersion')
.mockImplementation(() => {})
component.onVersionSelected(42)
expect(selectVersionSpy).toHaveBeenCalledWith(42)
})
it('onVersionsUpdated should sync open document versions and save', () => {
component.documentId = doc.id
component.document = { ...doc, versions: [] } as Document
const updatedVersions = [
{ id: doc.id, is_root: true },
{ id: 10, is_root: false },
] as any
const openDoc = { ...doc, versions: [] } as Document
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
const saveSpy = jest.spyOn(openDocumentsService, 'save')
component.onVersionsUpdated(updatedVersions)
expect(component.document.versions).toEqual(updatedVersions)
expect(openDoc.versions).toEqual(updatedVersions)
expect(saveSpy).toHaveBeenCalled()
})
it('createDisabled should return true if the user does not have permission to add the specified data type', () => {
currentUserCan = false
@@ -1554,6 +1797,70 @@ describe('DocumentDetailComponent', () => {
expect(urlRevokeSpy).toHaveBeenCalled()
})
it('should include version in download and print only for non-latest selected version', () => {
initNormally()
component.document.versions = [
{ id: doc.id, is_root: true },
{ id: 10, is_root: false },
] as any
const getDownloadUrlSpy = jest
.spyOn(documentService, 'getDownloadUrl')
.mockReturnValueOnce('download-latest')
.mockReturnValueOnce('print-latest')
.mockReturnValueOnce('download-non-latest')
.mockReturnValueOnce('print-non-latest')
component.selectedVersionId = 10
component.download()
expect(getDownloadUrlSpy).toHaveBeenNthCalledWith(1, doc.id, false, null)
httpTestingController
.expectOne('download-latest')
.error(new ProgressEvent('failed'))
component.printDocument()
expect(getDownloadUrlSpy).toHaveBeenNthCalledWith(2, doc.id, false, null)
httpTestingController
.expectOne('print-latest')
.error(new ProgressEvent('failed'))
component.selectedVersionId = doc.id
component.download()
expect(getDownloadUrlSpy).toHaveBeenNthCalledWith(3, doc.id, false, doc.id)
httpTestingController
.expectOne('download-non-latest')
.error(new ProgressEvent('failed'))
component.printDocument()
expect(getDownloadUrlSpy).toHaveBeenNthCalledWith(4, doc.id, false, doc.id)
httpTestingController
.expectOne('print-non-latest')
.error(new ProgressEvent('failed'))
})
it('should omit version in download and print when no version is selected', () => {
initNormally()
component.document.versions = [] as any
;(component as any).selectedVersionId = undefined
const getDownloadUrlSpy = jest
.spyOn(documentService, 'getDownloadUrl')
.mockReturnValueOnce('download-no-version')
.mockReturnValueOnce('print-no-version')
component.download()
expect(getDownloadUrlSpy).toHaveBeenNthCalledWith(1, doc.id, false, null)
httpTestingController
.expectOne('download-no-version')
.error(new ProgressEvent('failed'))
component.printDocument()
expect(getDownloadUrlSpy).toHaveBeenNthCalledWith(2, doc.id, false, null)
httpTestingController
.expectOne('print-no-version')
.error(new ProgressEvent('failed'))
})
it('should download a file with the correct filename', () => {
const mockBlob = new Blob(['test content'], { type: 'text/plain' })
const mockResponse = new HttpResponse({
@@ -36,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'
@@ -120,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 {
@@ -177,6 +178,7 @@ enum ContentRenderType {
TextAreaComponent,
RouterModule,
PngxPdfViewerComponent,
DocumentVersionDropdownComponent,
],
})
export class DocumentDetailComponent
@@ -184,6 +186,7 @@ export class DocumentDetailComponent
implements OnInit, OnDestroy, DirtyComponent
{
PdfRenderMode = PdfRenderMode
documentsService = inject(DocumentService)
private route = inject(ActivatedRoute)
private tagService = inject(TagService)
@@ -235,6 +238,9 @@ export class DocumentDetailComponent
tiffURL: string
tiffError: string
// Versioning
selectedVersionId: number
correspondents: Correspondent[]
documentTypes: DocumentType[]
storagePaths: StoragePath[]
@@ -312,13 +318,19 @@ export class DocumentDetailComponent
}
get archiveContentRenderType(): ContentRenderType {
return this.document?.archived_file_name
const hasArchiveVersion =
this.metadata?.has_archive_version ?? !!this.document?.archived_file_name
return hasArchiveVersion
? this.getRenderType('application/pdf')
: this.getRenderType(this.document?.mime_type)
: this.getRenderType(
this.metadata?.original_mime_type || this.document?.mime_type
)
}
get originalContentRenderType(): ContentRenderType {
return this.getRenderType(this.document?.mime_type)
return this.getRenderType(
this.metadata?.original_mime_type || this.document?.mime_type
)
}
get showThumbnailOverlay(): boolean {
@@ -348,16 +360,46 @@ export class DocumentDetailComponent
}
private updatePdfSource() {
if (!this.previewUrl) {
this.pdfSource = undefined
return
}
this.pdfSource = {
url: this.previewUrl,
password: this.password || undefined,
password: this.password,
}
}
private loadMetadataForSelectedVersion() {
const selectedVersionId = this.getSelectedNonLatestVersionId()
this.documentsService
.getMetadata(this.documentId, selectedVersionId)
.pipe(
first(),
takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier)
)
.subscribe({
next: (result) => {
this.metadata = result
this.tiffURL = null
this.tiffError = null
if (this.archiveContentRenderType === ContentRenderType.TIFF) {
this.tryRenderTiff()
}
if (
this.archiveContentRenderType !== ContentRenderType.PDF ||
this.useNativePdfViewer
) {
this.previewLoaded = true
}
},
error: (error) => {
this.metadata = {} // allow display to fallback to <object> tag
this.toastService.showError(
$localize`Error retrieving metadata`,
error
)
},
})
}
get isRTL() {
if (!this.metadata || !this.metadata.lang) return false
else {
@@ -433,7 +475,11 @@ export class DocumentDetailComponent
}
private loadDocument(documentId: number): void {
this.previewUrl = this.documentsService.getPreviewUrl(documentId)
let redirectedToRoot = false
this.selectedVersionId = documentId
this.previewUrl = this.documentsService.getPreviewUrl(
this.selectedVersionId
)
this.updatePdfSource()
this.http
.get(this.previewUrl, { responseType: 'text' })
@@ -449,11 +495,29 @@ export class DocumentDetailComponent
err.message ?? err.toString()
}`),
})
this.thumbUrl = this.documentsService.getThumbUrl(documentId)
this.thumbUrl = this.documentsService.getThumbUrl(this.selectedVersionId)
this.documentsService
.get(documentId)
.pipe(
catchError(() => {
catchError((error) => {
if (error?.status === 404) {
// if not found, check if there's root document that exists and redirect if so
return this.documentsService.getRootId(documentId).pipe(
map((result) => {
const rootId = result?.root_id
if (rootId && rootId !== documentId) {
const section =
this.route.snapshot.paramMap.get('section') || 'details'
redirectedToRoot = true
this.router.navigate(['documents', rootId, section], {
replaceUrl: true,
})
}
return null
}),
catchError(() => of(null))
)
}
// 404 is handled in the subscribe below
return of(null)
}),
@@ -464,6 +528,9 @@ export class DocumentDetailComponent
.subscribe({
next: (doc) => {
if (!doc) {
if (redirectedToRoot) {
return
}
this.router.navigate(['404'], { replaceUrl: true })
return
}
@@ -680,36 +747,15 @@ export class DocumentDetailComponent
updateComponent(doc: Document) {
this.document = doc
// Default selected version is the newest version
const versions = doc.versions ?? []
this.selectedVersionId = versions.length
? Math.max(...versions.map((version) => version.id))
: doc.id
this.previewLoaded = false
this.requiresPassword = false
this.updateFormForCustomFields()
if (this.archiveContentRenderType === ContentRenderType.TIFF) {
this.tryRenderTiff()
}
this.documentsService
.getMetadata(doc.id)
.pipe(
first(),
takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier)
)
.subscribe({
next: (result) => {
this.metadata = result
if (
this.archiveContentRenderType !== ContentRenderType.PDF ||
this.useNativePdfViewer
) {
this.previewLoaded = true
}
},
error: (error) => {
this.metadata = {} // allow display to fallback to <object> tag
this.toastService.showError(
$localize`Error retrieving metadata`,
error
)
},
})
this.loadMetadataForSelectedVersion()
if (
this.permissionsService.currentUserHasObjectPermissions(
PermissionAction.Change,
@@ -738,6 +784,78 @@ export class DocumentDetailComponent
}
}
// Update file preview and download target to a specific version (by document id)
selectVersion(versionId: number) {
this.selectedVersionId = versionId
this.previewLoaded = false
this.previewUrl = this.documentsService.getPreviewUrl(
this.documentId,
false,
this.selectedVersionId
)
this.updatePdfSource()
this.thumbUrl = this.documentsService.getThumbUrl(
this.documentId,
this.selectedVersionId
)
this.loadMetadataForSelectedVersion()
this.documentsService
.get(this.documentId, this.selectedVersionId, 'content')
.pipe(
first(),
takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier)
)
.subscribe({
next: (doc) => {
const content = doc?.content ?? ''
this.document.content = content
this.documentForm.patchValue(
{
content,
},
{
emitEvent: false,
}
)
},
error: (error) => {
this.toastService.showError(
$localize`Error retrieving version content`,
error
)
},
})
// For text previews, refresh content
this.http
.get(this.previewUrl, { responseType: 'text' })
.pipe(
first(),
takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier)
)
.subscribe({
next: (res) => (this.previewText = res.toString()),
error: (err) =>
(this.previewText = $localize`An error occurred loading content: ${
err.message ?? err.toString()
}`),
})
}
onVersionSelected(versionId: number) {
this.selectVersion(versionId)
}
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 {
return this.documentForm.get('custom_fields') as FormArray
}
@@ -906,7 +1024,7 @@ export class DocumentDetailComponent
discard() {
this.documentsService
.get(this.documentId)
.get(this.documentId, this.selectedVersionId)
.pipe(
first(),
takeUntil(this.unsubscribeNotifier),
@@ -956,7 +1074,7 @@ export class DocumentDetailComponent
this.networkActive = true
;(document.activeElement as HTMLElement)?.dispatchEvent(new Event('change'))
this.documentsService
.patch(this.getChangedFields())
.patch(this.getChangedFields(), this.selectedVersionId)
.pipe(first())
.subscribe({
next: (docValues) => {
@@ -1011,7 +1129,7 @@ export class DocumentDetailComponent
this.networkActive = true
this.store.next(this.documentForm.value)
this.documentsService
.patch(this.getChangedFields())
.patch(this.getChangedFields(), this.selectedVersionId)
.pipe(
switchMap((updateResult) => {
this.savedViewService.maybeRefreshDocumentCounts()
@@ -1154,11 +1272,24 @@ export class DocumentDetailComponent
})
}
private getSelectedNonLatestVersionId(): number | null {
const versions = this.document?.versions ?? []
if (!versions.length || !this.selectedVersionId) {
return null
}
const latestVersionId = Math.max(...versions.map((version) => version.id))
return this.selectedVersionId === latestVersionId
? null
: this.selectedVersionId
}
download(original: boolean = false) {
this.downloading = true
const selectedVersionId = this.getSelectedNonLatestVersionId()
const downloadUrl = this.documentsService.getDownloadUrl(
this.documentId,
original
original,
selectedVersionId
)
this.http
.get(downloadUrl, { observe: 'response', responseType: 'blob' })
@@ -1590,9 +1721,11 @@ export class DocumentDetailComponent
}
printDocument() {
const selectedVersionId = this.getSelectedNonLatestVersionId()
const printUrl = this.documentsService.getDownloadUrl(
this.document.id,
false
false,
selectedVersionId
)
this.http
.get(printUrl, { responseType: 'blob' })
@@ -1640,7 +1773,7 @@ export class DocumentDetailComponent
const modal = this.modalService.open(ShareLinksDialogComponent)
modal.componentInstance.documentId = this.document.id
modal.componentInstance.hasArchiveVersion =
!!this.document?.archived_file_name
this.metadata?.has_archive_version ?? !!this.document?.archived_file_name
}
get emailEnabled(): boolean {
@@ -1653,7 +1786,7 @@ export class DocumentDetailComponent
})
modal.componentInstance.documentIds = [this.document.id]
modal.componentInstance.hasArchiveVersion =
!!this.document?.archived_file_name
this.metadata?.has_archive_version ?? !!this.document?.archived_file_name
}
private tryRenderTiff() {
@@ -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
}
}
+12
View File
@@ -161,6 +161,18 @@ export interface Document extends ObjectWithPermissions {
duplicate_documents?: Document[]
// Versioning
root_document?: number
versions?: DocumentVersionInfo[]
// Frontend only
__changedFields?: string[]
}
export interface DocumentVersionInfo {
id: number
added?: Date
version_label?: string
checksum?: string
is_root: boolean
}
@@ -165,6 +165,14 @@ describe(`DocumentService`, () => {
expect(req.request.method).toEqual('GET')
})
it('should call appropriate api endpoint for versioned metadata', () => {
subscription = service.getMetadata(documents[0].id, 123).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/${documents[0].id}/metadata/?version=123`
)
expect(req.request.method).toEqual('GET')
})
it('should call appropriate api endpoint for getting selection data', () => {
const ids = [documents[0].id]
subscription = service.getSelectionData(ids).subscribe()
@@ -233,11 +241,22 @@ describe(`DocumentService`, () => {
)
})
it('should return the correct preview URL for a specific version', () => {
const url = service.getPreviewUrl(documents[0].id, false, 123)
expect(url).toEqual(
`${environment.apiBaseUrl}${endpoint}/${documents[0].id}/preview/?version=123`
)
})
it('should return the correct thumb URL for a single document', () => {
let url = service.getThumbUrl(documents[0].id)
expect(url).toEqual(
`${environment.apiBaseUrl}${endpoint}/${documents[0].id}/thumb/`
)
url = service.getThumbUrl(documents[0].id, 123)
expect(url).toEqual(
`${environment.apiBaseUrl}${endpoint}/${documents[0].id}/thumb/?version=123`
)
})
it('should return the correct download URL for a single document', () => {
@@ -249,6 +268,22 @@ describe(`DocumentService`, () => {
expect(url).toEqual(
`${environment.apiBaseUrl}${endpoint}/${documents[0].id}/download/?original=true`
)
url = service.getDownloadUrl(documents[0].id, false, 123)
expect(url).toEqual(
`${environment.apiBaseUrl}${endpoint}/${documents[0].id}/download/?version=123`
)
url = service.getDownloadUrl(documents[0].id, true, 123)
expect(url).toEqual(
`${environment.apiBaseUrl}${endpoint}/${documents[0].id}/download/?original=true&version=123`
)
})
it('should pass optional get params for version and fields', () => {
subscription = service.get(documents[0].id, 123, 'content').subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/${documents[0].id}/?full_perms=true&version=123&fields=content`
)
expect(req.request.method).toEqual('GET')
})
it('should set search query', () => {
@@ -283,12 +318,77 @@ describe(`DocumentService`, () => {
expect(req.request.body.remove_inbox_tags).toEqual(true)
})
it('should pass selected version to patch when provided', () => {
subscription = service.patch(documents[0], 123).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/${documents[0].id}/?version=123`
)
expect(req.request.method).toEqual('PATCH')
})
it('should call appropriate api endpoint for getting audit log', () => {
subscription = service.getHistory(documents[0].id).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/${documents[0].id}/history/`
)
})
it('should call appropriate api endpoint for getting root document id', () => {
subscription = service.getRootId(documents[0].id).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/${documents[0].id}/root/`
)
expect(req.request.method).toEqual('GET')
req.flush({ root_id: documents[0].id })
})
it('should call appropriate api endpoint for getting document versions', () => {
subscription = service.getVersions(documents[0].id).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/${documents[0].id}/?fields=id,versions`
)
expect(req.request.method).toEqual('GET')
})
it('should call appropriate api endpoint for deleting a document version', () => {
subscription = service.deleteVersion(documents[0].id, 10).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/${documents[0].id}/versions/10/`
)
expect(req.request.method).toEqual('DELETE')
req.flush({ result: 'OK', current_version_id: documents[0].id })
})
it('should call appropriate api endpoint for updating a document version label', () => {
subscription = service
.updateVersionLabel(documents[0].id, 10, 'Updated label')
.subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/${documents[0].id}/versions/10/`
)
expect(req.request.method).toEqual('PATCH')
expect(req.request.body).toEqual({ version_label: 'Updated label' })
req.flush({ id: 10, version_label: 'Updated label', is_root: false })
})
it('should call appropriate api endpoint for uploading a new version', () => {
const file = new File(['hello'], 'test.pdf', { type: 'application/pdf' })
subscription = service
.uploadVersion(documents[0].id, file, 'Label')
.subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/${documents[0].id}/update_version/`
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toBeInstanceOf(FormData)
const body = req.request.body as FormData
expect(body.get('version_label')).toEqual('Label')
expect(body.get('document')).toBeInstanceOf(File)
req.flush('task-id')
})
})
it('should construct sort fields respecting permissions', () => {
@@ -7,6 +7,7 @@ import {
DOCUMENT_SORT_FIELDS,
DOCUMENT_SORT_FIELDS_FULLTEXT,
Document,
DocumentVersionInfo,
} from 'src/app/data/document'
import { DocumentMetadata } from 'src/app/data/document-metadata'
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
@@ -155,44 +156,119 @@ export class DocumentService extends AbstractPaperlessService<Document> {
}).pipe(map((response) => response.results.map((doc) => doc.id)))
}
get(id: number): Observable<Document> {
get(
id: number,
versionID: number = null,
fields: string = null
): Observable<Document> {
const params: { full_perms: boolean; version?: string; fields?: string } = {
full_perms: true,
}
if (versionID) {
params.version = versionID.toString()
}
if (fields) {
params.fields = fields
}
return this.http.get<Document>(this.getResourceUrl(id), {
params: {
full_perms: true,
},
params,
})
}
getPreviewUrl(id: number, original: boolean = false): string {
getPreviewUrl(
id: number,
original: boolean = false,
versionID: number = null
): string {
let url = new URL(this.getResourceUrl(id, 'preview'))
if (this._searchQuery) url.hash = `#search="${this.searchQuery}"`
if (original) {
url.searchParams.append('original', 'true')
}
if (versionID) {
url.searchParams.append('version', versionID.toString())
}
return url.toString()
}
getThumbUrl(id: number): string {
return this.getResourceUrl(id, 'thumb')
getThumbUrl(id: number, versionID: number = null): string {
let url = new URL(this.getResourceUrl(id, 'thumb'))
if (versionID) {
url.searchParams.append('version', versionID.toString())
}
return url.toString()
}
getDownloadUrl(id: number, original: boolean = false): string {
let url = this.getResourceUrl(id, 'download')
getDownloadUrl(
id: number,
original: boolean = false,
versionID: number = null
): string {
let url = new URL(this.getResourceUrl(id, 'download'))
if (original) {
url += '?original=true'
url.searchParams.append('original', 'true')
}
return url
if (versionID) {
url.searchParams.append('version', versionID.toString())
}
return url.toString()
}
uploadVersion(documentId: number, file: File, versionLabel?: string) {
const formData = new FormData()
formData.append('document', file, file.name)
if (versionLabel) {
formData.append('version_label', versionLabel)
}
return this.http.post<string>(
this.getResourceUrl(documentId, 'update_version'),
formData
)
}
getVersions(documentId: number): Observable<Document> {
return this.http.get<Document>(this.getResourceUrl(documentId), {
params: {
fields: 'id,versions',
},
})
}
getRootId(documentId: number) {
return this.http.get<{ root_id: number }>(
this.getResourceUrl(documentId, 'root')
)
}
deleteVersion(rootDocumentId: number, versionId: number) {
return this.http.delete<{ result: string; current_version_id: number }>(
this.getResourceUrl(rootDocumentId, `versions/${versionId}`)
)
}
updateVersionLabel(
rootDocumentId: number,
versionId: number,
versionLabel: string | null
): Observable<DocumentVersionInfo> {
return this.http.patch<DocumentVersionInfo>(
this.getResourceUrl(rootDocumentId, `versions/${versionId}`),
{ version_label: versionLabel }
)
}
getNextAsn(): Observable<number> {
return this.http.get<number>(this.getResourceUrl(null, 'next_asn'))
}
patch(o: Document): Observable<Document> {
patch(o: Document, versionID: number = null): Observable<Document> {
o.remove_inbox_tags = !!this.settingsService.get(
SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS
)
return super.patch(o)
this.clearCache()
return this.http.patch<Document>(this.getResourceUrl(o.id), o, {
params: versionID ? { version: versionID.toString() } : {},
})
}
uploadDocument(formData) {
@@ -203,8 +279,15 @@ export class DocumentService extends AbstractPaperlessService<Document> {
)
}
getMetadata(id: number): Observable<DocumentMetadata> {
return this.http.get<DocumentMetadata>(this.getResourceUrl(id, 'metadata'))
getMetadata(
id: number,
versionID: number = null
): Observable<DocumentMetadata> {
let url = new URL(this.getResourceUrl(id, 'metadata'))
if (versionID) {
url.searchParams.append('version', versionID.toString())
}
return this.http.get<DocumentMetadata>(url.toString())
}
bulkEdit(ids: number[], method: string, args: any) {
@@ -89,6 +89,13 @@ export class FileStatus {
}
}
export enum UploadState {
Idle = 'idle',
Uploading = 'uploading',
Processing = 'processing',
Failed = 'failed',
}
@Injectable({
providedIn: 'root',
})
+6
View File
@@ -59,6 +59,7 @@ import {
chevronDoubleLeft,
chevronDoubleRight,
chevronRight,
circle,
clipboard,
clipboardCheck,
clipboardCheckFill,
@@ -79,9 +80,11 @@ import {
eye,
fileEarmark,
fileEarmarkCheck,
fileEarmarkDiff,
fileEarmarkFill,
fileEarmarkLock,
fileEarmarkMinus,
fileEarmarkPlus,
fileEarmarkRichtext,
fileText,
files,
@@ -278,6 +281,7 @@ const icons = {
chevronDoubleLeft,
chevronDoubleRight,
chevronRight,
circle,
clipboard,
clipboardCheck,
clipboardCheckFill,
@@ -298,9 +302,11 @@ const icons = {
eye,
fileEarmark,
fileEarmarkCheck,
fileEarmarkDiff,
fileEarmarkFill,
fileEarmarkLock,
fileEarmarkMinus,
fileEarmarkPlus,
fileEarmarkRichtext,
files,
fileText,