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