mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-11-09 08:01:46 +00:00
Merge branch 'dev' into feature/ocr-templates
This commit is contained in:
516 files changed
+137596
-100899
No files matched your search
+46
@@ -0,0 +1,46 @@
|
||||
<table class="table table-borderless align-baseline">
|
||||
<thead>
|
||||
<tr>
|
||||
<th i18n>Page</th>
|
||||
<th i18n>Type</th>
|
||||
<th i18n>Content</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (barcode of barcodes(); track $index) {
|
||||
<tr>
|
||||
<td>{{ barcode.page }}</td>
|
||||
<td class="text-nowrap">{{ barcode.format }}</td>
|
||||
<td class="text-break">
|
||||
@if (isLink(barcode.value)) {
|
||||
<a
|
||||
[href]="barcode.value"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>{{ barcode.value }}</a
|
||||
>
|
||||
} @else {
|
||||
{{ barcode.value }}
|
||||
}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
(click)="copy($index)"
|
||||
title="Copy content"
|
||||
i18n-title
|
||||
>
|
||||
@if (copiedIndex() === $index) {
|
||||
<i-bs name="clipboard-check"></i-bs>
|
||||
} @else {
|
||||
<i-bs name="clipboard"></i-bs>
|
||||
}
|
||||
<span class="visually-hidden" i18n>Copy content</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { DocumentBarcodesComponent } from './document-barcodes.component'
|
||||
|
||||
const barcodes = [
|
||||
{ page: 1, value: 'ASN00123', format: 'Code128' },
|
||||
{ page: 2, value: 'https://example.com/invoice/4711', format: 'QRCode' },
|
||||
{ page: 2, value: 'javascript:alert(1)', format: 'QRCode' },
|
||||
]
|
||||
|
||||
describe('DocumentBarcodesComponent', () => {
|
||||
let component: DocumentBarcodesComponent
|
||||
let fixture: ComponentFixture<DocumentBarcodesComponent>
|
||||
let clipboard: Clipboard
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
DocumentBarcodesComponent,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(DocumentBarcodesComponent)
|
||||
component = fixture.componentInstance
|
||||
clipboard = TestBed.inject(Clipboard)
|
||||
fixture.componentRef.setInput('barcodes', barcodes)
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should display all barcodes', () => {
|
||||
const rows = fixture.nativeElement.querySelectorAll('tbody tr')
|
||||
expect(rows).toHaveLength(3)
|
||||
expect(rows[0].textContent).toContain('ASN00123')
|
||||
expect(rows[0].textContent).toContain('Code128')
|
||||
})
|
||||
|
||||
it('should only link http(s) values', () => {
|
||||
const links = fixture.nativeElement.querySelectorAll('tbody a')
|
||||
expect(links).toHaveLength(1)
|
||||
expect(links[0].getAttribute('href')).toEqual(
|
||||
'https://example.com/invoice/4711'
|
||||
)
|
||||
expect(links[0].getAttribute('target')).toEqual('_blank')
|
||||
})
|
||||
|
||||
it('should copy a value and show feedback', () => {
|
||||
jest.useFakeTimers()
|
||||
const copySpy = jest.spyOn(clipboard, 'copy').mockReturnValue(true)
|
||||
const buttons = fixture.nativeElement.querySelectorAll('tbody button')
|
||||
buttons[0].click()
|
||||
fixture.detectChanges()
|
||||
expect(copySpy).toHaveBeenCalledWith('ASN00123')
|
||||
expect(component.copiedIndex()).toEqual(0)
|
||||
expect(buttons[0].querySelector('i-bs').getAttribute('name')).toEqual(
|
||||
'clipboard-check'
|
||||
)
|
||||
jest.advanceTimersByTime(3000)
|
||||
fixture.detectChanges()
|
||||
expect(component.copiedIndex()).toBeNull()
|
||||
expect(buttons[0].querySelector('i-bs').getAttribute('name')).toEqual(
|
||||
'clipboard'
|
||||
)
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it('should not show feedback if copying failed', () => {
|
||||
jest.spyOn(clipboard, 'copy').mockReturnValue(false)
|
||||
component.copy(1)
|
||||
expect(component.copiedIndex()).toBeNull()
|
||||
})
|
||||
})
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
import { Component, inject, input, OnDestroy, signal } from '@angular/core'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { DocumentBarcode } from 'src/app/data/document-barcode'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-document-barcodes',
|
||||
templateUrl: './document-barcodes.component.html',
|
||||
imports: [NgxBootstrapIconsModule],
|
||||
})
|
||||
export class DocumentBarcodesComponent implements OnDestroy {
|
||||
private readonly clipboard = inject(Clipboard)
|
||||
|
||||
readonly barcodes = input<DocumentBarcode[]>([])
|
||||
|
||||
readonly copiedIndex = signal<number>(null)
|
||||
private copyTimeout: ReturnType<typeof setTimeout>
|
||||
|
||||
public isLink(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value.trim())
|
||||
return ['http:', 'https:'].includes(url.protocol) && !!url.host
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public copy(index: number) {
|
||||
if (!this.clipboard.copy(this.barcodes()[index].value)) return
|
||||
this.copiedIndex.set(index)
|
||||
clearTimeout(this.copyTimeout)
|
||||
this.copyTimeout = setTimeout(() => this.copiedIndex.set(null), 3000)
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
clearTimeout(this.copyTimeout)
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,4 @@
|
||||
<pngx-page-header [title]="title()" [id]="documentId()">
|
||||
@if (archiveContentRenderType === ContentRenderType.PDF && !useNativePdfViewer) {
|
||||
@if (previewNumPages()) {
|
||||
<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()" (ngModelChange)="previewCurrentPage.set($event)" />
|
||||
<div class="input-group-text" i18n>of {{previewNumPages()}}</div>
|
||||
</div>
|
||||
}
|
||||
<div class="input-group input-group-sm me-md-5 d-none d-md-flex">
|
||||
<button class="btn btn-outline-secondary" (click)="decreaseZoom()" i18n>-</button>
|
||||
<select class="form-select" (change)="setZoom($event.target.value)" [ngModel]="currentZoom">
|
||||
@for (setting of zoomSettings; track setting) {
|
||||
<option [value]="setting">
|
||||
{{ getZoomSettingTitle(setting) }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
<button class="btn btn-outline-secondary" (click)="increaseZoom()" i18n>+</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<button type="button" class="btn btn-sm btn-outline-danger me-md-4" (click)="delete()" [disabled]="!userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" aria-label="Delete" i18n-aria-label>
|
||||
<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>
|
||||
@@ -34,7 +13,7 @@
|
||||
(versionsUpdated)="onVersionsUpdated($event)"
|
||||
/>
|
||||
|
||||
<div class="btn-group">
|
||||
<div class="btn-group ms-4">
|
||||
<button (click)="download()" class="btn btn-sm btn-outline-primary" [disabled]="downloading()" aria-label="Download" i18n-aria-label>
|
||||
@if (downloading()) {
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
@@ -85,7 +64,7 @@
|
||||
<button
|
||||
ngbDropdownItem
|
||||
(click)="runZoneOcr()"
|
||||
[disabled]="!userCanEdit || !document?.document_type"
|
||||
[disabled]="!userCanEdit || !document()?.document_type"
|
||||
*pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }"
|
||||
>
|
||||
<i-bs width="1em" height="1em" name="file-earmark-ruled" class="me-1"></i-bs><span i18n>Run Zone OCR</span>
|
||||
@@ -111,7 +90,7 @@
|
||||
<button class="btn btn-sm btn-outline-primary" id="sendDropdown" ngbDropdownToggle aria-label="Send" i18n-aria-label>
|
||||
<i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container></div>
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow">
|
||||
<div ngbDropdownMenu aria-labelledby="sendDropdown" class="shadow">
|
||||
<button ngbDropdownItem (click)="openShareLinks()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ShareLink }">
|
||||
<i-bs name="link" class="me-1"></i-bs><span i18n>Share Links</span>
|
||||
</button>
|
||||
@@ -130,8 +109,8 @@
|
||||
|
||||
<form [formGroup]='documentForm' (ngSubmit)="save()">
|
||||
|
||||
<div class="btn-toolbar justify-content-end mb-1 pb-3 gap-2 row-gap-2 border-bottom">
|
||||
<div class="btn-group me-auto">
|
||||
<div class="btn-toolbar mb-1 pb-3 gap-2 row-gap-2 border-bottom">
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Close" (click)="close()">
|
||||
<i-bs width="1.2em" height="1.2em" name="x"></i-bs>
|
||||
</button>
|
||||
@@ -144,37 +123,30 @@
|
||||
</div>
|
||||
|
||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||
<div class="d-flex gap-2">
|
||||
<div class="btn-group">
|
||||
<pngx-suggestions-dropdown *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"
|
||||
[disabled]="!userCanEdit || suggestionsLoading()"
|
||||
[loading]="suggestionsLoading()"
|
||||
[suggestions]="suggestions()"
|
||||
[aiEnabled]="aiEnabled"
|
||||
[appliedTags]="documentForm.value.tags"
|
||||
[appliedCorrespondent]="documentForm.value.correspondent"
|
||||
[appliedDocumentType]="documentForm.value.document_type"
|
||||
[appliedStoragePath]="documentForm.value.storage_path"
|
||||
(getSuggestions)="getSuggestions()"
|
||||
(addTag)="createTag($event)"
|
||||
(addDocumentType)="createDocumentType($event)"
|
||||
(addCorrespondent)="createCorrespondent($event)">
|
||||
</pngx-suggestions-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<pngx-custom-fields-dropdown
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"
|
||||
[documentId]="documentId()"
|
||||
[disabled]="!userCanEdit"
|
||||
[existingFields]="document()?.custom_fields"
|
||||
(created)="refreshCustomFields()"
|
||||
(added)="addField($event)">
|
||||
</pngx-custom-fields-dropdown>
|
||||
</div>
|
||||
<div class="btn-group ms-3">
|
||||
<pngx-suggestions-dropdown *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"
|
||||
[disabled]="!userCanEdit || suggestionsLoading()"
|
||||
[loading]="suggestionsLoading()"
|
||||
[suggestions]="suggestions()"
|
||||
[aiEnabled]="aiEnabled"
|
||||
[source]="suggestionSource"
|
||||
[defaultSource]="defaultSuggestionSource"
|
||||
[fetchedSources]="fetchedSuggestionSources()"
|
||||
[appliedTags]="documentForm.value.tags"
|
||||
[appliedCorrespondent]="documentForm.value.correspondent"
|
||||
[appliedDocumentType]="documentForm.value.document_type"
|
||||
[appliedStoragePath]="documentForm.value.storage_path"
|
||||
[appliedTitle]="documentForm.value.title"
|
||||
[appliedCreated]="documentForm.value.created"
|
||||
(getSuggestions)="getSuggestions($event)"
|
||||
(sourceChange)="suggestionSourceOverride.set($event)"
|
||||
(addTag)="createTag($event)"
|
||||
(addDocumentType)="createDocumentType($event)"
|
||||
(addCorrespondent)="createCorrespondent($event)">
|
||||
</pngx-suggestions-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="ps-3">
|
||||
<div class="ms-auto">
|
||||
<ng-container *ngTemplateOutlet="saveButtons"></ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
@@ -301,8 +273,20 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end border-top pt-3">
|
||||
<ng-container *ngTemplateOutlet="saveButtons"></ng-container>
|
||||
<div class="d-flex border-top pt-3">
|
||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||
<pngx-custom-fields-dropdown
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"
|
||||
[documentId]="documentId()"
|
||||
[disabled]="!userCanEdit"
|
||||
[existingFields]="document()?.custom_fields"
|
||||
(created)="refreshCustomFields()"
|
||||
(added)="addField($event)">
|
||||
</pngx-custom-fields-dropdown>
|
||||
</ng-container>
|
||||
<div class="ms-auto">
|
||||
<ng-container *ngTemplateOutlet="saveButtons"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
@@ -339,9 +323,9 @@
|
||||
<td i18n>Original filename</td>
|
||||
<td>{{metadata()?.original_filename}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<tr class="align-middle">
|
||||
<td i18n>Original SHA256 checksum</td>
|
||||
<td>{{metadata()?.original_checksum}}</td>
|
||||
<td><span class="badge bg-light font-monospace text-wrap text-break text-start">{{metadata()?.original_checksum}}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td i18n>Original file size</td>
|
||||
@@ -352,9 +336,9 @@
|
||||
<td>{{metadata()?.original_mime_type}}</td>
|
||||
</tr>
|
||||
@if (metadata()?.has_archive_version) {
|
||||
<tr>
|
||||
<tr class="align-middle">
|
||||
<td i18n>Archive SHA256 checksum</td>
|
||||
<td>{{metadata()?.archive_checksum}}</td>
|
||||
<td><span class="badge bg-light font-monospace text-wrap text-break text-start">{{metadata()?.archive_checksum}}</span></td>
|
||||
</tr>
|
||||
}
|
||||
@if (metadata()?.has_archive_version) {
|
||||
@@ -367,6 +351,10 @@
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (metadata()?.barcodes?.length > 0) {
|
||||
<h6 i18n>Barcodes</h6>
|
||||
<pngx-document-barcodes [barcodes]="metadata().barcodes"></pngx-document-barcodes>
|
||||
}
|
||||
@if (metadata()?.original_metadata?.length > 0) {
|
||||
<pngx-metadata-collapse i18n-title title="Original document metadata" [metadata]="metadata()?.original_metadata"></pngx-metadata-collapse>
|
||||
}
|
||||
@@ -415,7 +403,7 @@
|
||||
<a ngbNavLink i18n>Permissions</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="mb-3">
|
||||
<pngx-permissions-form [users]="users()" formControlName="permissions_form"></pngx-permissions-form>
|
||||
<pngx-permissions-form title="" [users]="users()" formControlName="permissions_form"></pngx-permissions-form>
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
@@ -506,6 +494,29 @@
|
||||
(loadError)="onError($event)"
|
||||
(afterLoadComplete)="pdfPreviewLoaded($event)">
|
||||
</pngx-pdf-viewer>
|
||||
<div class="position-absolute bottom-0 start-50 translate-middle-x z-1 mb-3 d-flex gap-2 p-1 bg-body border rounded shadow">
|
||||
@if (previewNumPages()) {
|
||||
<div class="input-group input-group-sm w-auto flex-nowrap">
|
||||
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages()" [ngModelOptions]="{standalone: true}" [ngModel]="previewCurrentPage()" (ngModelChange)="previewCurrentPage.set($event)" aria-label="Page" i18n-aria-label />
|
||||
<div class="input-group-text" i18n>of {{previewNumPages()}}</div>
|
||||
</div>
|
||||
}
|
||||
<div class="input-group input-group-sm w-auto flex-nowrap">
|
||||
<button class="btn btn-outline-secondary" (click)="decreaseZoom()" aria-label="Zoom out" i18n-aria-label>
|
||||
<i-bs width="1em" height="1em" name="dash"></i-bs>
|
||||
</button>
|
||||
<select class="form-select" (change)="setZoom($event.target.value)" [ngModelOptions]="{standalone: true}" [ngModel]="currentZoom" aria-label="Zoom" i18n-aria-label>
|
||||
@for (setting of zoomSettings; track setting) {
|
||||
<option [value]="setting">
|
||||
{{ getZoomSettingTitle(setting) }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
<button class="btn btn-outline-secondary" (click)="increaseZoom()" aria-label="Zoom in" i18n-aria-label>
|
||||
<i-bs width="1em" height="1em" name="plus"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%"></object>
|
||||
|
||||
@@ -28,8 +28,9 @@ import { Subject, of, throwError } from 'rxjs'
|
||||
import { routes } from 'src/app/app-routing.module'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
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 { DocumentType } from 'src/app/data/document-type'
|
||||
import {
|
||||
FILTER_CORRESPONDENT,
|
||||
@@ -42,7 +43,7 @@ import {
|
||||
} from 'src/app/data/filter-rule-type'
|
||||
import { StoragePath } from 'src/app/data/storage-path'
|
||||
import { Tag } from 'src/app/data/tag'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings'
|
||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||
@@ -59,6 +60,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { TrashService } from 'src/app/services/trash.service'
|
||||
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
||||
@@ -100,13 +102,18 @@ const doc: Document = {
|
||||
custom_fields: [
|
||||
{
|
||||
field: 0,
|
||||
document: 3,
|
||||
created: new Date(),
|
||||
value: 'custom foo bar',
|
||||
},
|
||||
],
|
||||
] as CustomFieldInstance[],
|
||||
}
|
||||
|
||||
// Newest first, as the API returns them: 12 is the latest, 3 is the root
|
||||
const docVersions: DocumentVersionInfo[] = [
|
||||
{ id: 12, is_root: false },
|
||||
{ id: 10, is_root: false },
|
||||
{ id: doc.id, is_root: true },
|
||||
]
|
||||
|
||||
const customFields = [
|
||||
{
|
||||
id: 0,
|
||||
@@ -131,6 +138,7 @@ describe('DocumentDetailComponent', () => {
|
||||
let openDocumentsService: OpenDocumentsService
|
||||
let modalService: NgbModal
|
||||
let toastService: ToastService
|
||||
let trashService: TrashService
|
||||
let documentListViewService: DocumentListViewService
|
||||
let settingsService: SettingsService
|
||||
let customFieldsService: CustomFieldsService
|
||||
@@ -279,6 +287,7 @@ describe('DocumentDetailComponent', () => {
|
||||
documentService = TestBed.inject(DocumentService)
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
trashService = TestBed.inject(TrashService)
|
||||
documentListViewService = TestBed.inject(DocumentListViewService)
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
settingsService.currentUser.set({ id: 1 })
|
||||
@@ -656,6 +665,45 @@ describe('DocumentDetailComponent', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['tag', 'createTag', 'tags', 'suggested_tags'],
|
||||
[
|
||||
'document type',
|
||||
'createDocumentType',
|
||||
'document_type',
|
||||
'suggested_document_types',
|
||||
],
|
||||
[
|
||||
'correspondent',
|
||||
'createCorrespondent',
|
||||
'correspondent',
|
||||
'suggested_correspondents',
|
||||
],
|
||||
])(
|
||||
'should create a %s after ML-only suggestions',
|
||||
(_, method, field, suggestedField) => {
|
||||
initNormally()
|
||||
component.suggestions.set({ tags: [1] })
|
||||
let openModal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||
component[method]('New value')
|
||||
openModal.componentInstance.succeeded.next({
|
||||
id: 12,
|
||||
name: 'New value',
|
||||
is_inbox_tag: false,
|
||||
color: '#ff0000',
|
||||
text_color: '#000000',
|
||||
})
|
||||
|
||||
if (field === 'tags') {
|
||||
expect(component.tagsInput.value).toContain(12)
|
||||
} else {
|
||||
expect(component.documentForm.get(field).value).toBe(12)
|
||||
}
|
||||
expect(component.suggestions()[suggestedField]).toEqual([])
|
||||
}
|
||||
)
|
||||
|
||||
it('should support creating storage path', () => {
|
||||
initNormally()
|
||||
let openModal: NgbModalRef
|
||||
@@ -730,7 +778,7 @@ describe('DocumentDetailComponent', () => {
|
||||
component.title.set('Foo Bar')
|
||||
const closeSpy = jest.spyOn(component, 'close')
|
||||
const patchSpy = jest.spyOn(documentService, 'patch')
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const toastSpy = jest.spyOn(toastService, 'showSuccess')
|
||||
patchSpy.mockImplementation((o) => of(doc))
|
||||
component.save(true)
|
||||
expect(patchSpy).toHaveBeenCalled()
|
||||
@@ -745,7 +793,7 @@ describe('DocumentDetailComponent', () => {
|
||||
component.title.set('Foo Bar')
|
||||
const closeSpy = jest.spyOn(component, 'close')
|
||||
const patchSpy = jest.spyOn(documentService, 'patch')
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const toastSpy = jest.spyOn(toastService, 'showSuccess')
|
||||
patchSpy.mockImplementation((o) => of(doc))
|
||||
component.save()
|
||||
expect(patchSpy).toHaveBeenCalled()
|
||||
@@ -791,7 +839,7 @@ describe('DocumentDetailComponent', () => {
|
||||
component.title.set('Foo Bar')
|
||||
const closeSpy = jest.spyOn(component, 'close')
|
||||
const patchSpy = jest.spyOn(documentService, 'patch')
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const toastSpy = jest.spyOn(toastService, 'showSuccess')
|
||||
patchSpy.mockImplementation(() =>
|
||||
throwError(() => new Error('failed to save'))
|
||||
)
|
||||
@@ -912,12 +960,16 @@ describe('DocumentDetailComponent', () => {
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
const deleteSpy = jest.spyOn(documentService, 'delete')
|
||||
deleteSpy.mockReturnValue(of(true))
|
||||
const notifySpy = jest
|
||||
.spyOn(trashService, 'notifyMovedToTrash')
|
||||
.mockImplementation(() => {})
|
||||
component.delete()
|
||||
expect(modalSpy).toHaveBeenCalled()
|
||||
const modalCloseSpy = jest.spyOn(openModal, 'close')
|
||||
openModal.componentInstance.confirmClicked.next()
|
||||
expect(deleteSpy).toHaveBeenCalled()
|
||||
expect(modalCloseSpy).toHaveBeenCalled()
|
||||
expect(notifySpy).toHaveBeenCalledWith(expect.any(String), [doc.id])
|
||||
})
|
||||
|
||||
it('should allow retry delete if error', () => {
|
||||
@@ -1522,6 +1574,113 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(component.suggestionsLoading()).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should get and merge ML and AI suggestions when source is both', () => {
|
||||
settingsService.set(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE,
|
||||
SuggestionSource.Both
|
||||
)
|
||||
const getSetting = settingsService.get.bind(settingsService)
|
||||
jest
|
||||
.spyOn(settingsService, 'get')
|
||||
.mockImplementation((key) =>
|
||||
key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key)
|
||||
)
|
||||
const suggestionsSpy = jest
|
||||
.spyOn(documentService, 'getSuggestions')
|
||||
.mockReturnValue(of({ tags: [42], dates: ['2024-01-01'] }))
|
||||
const aiSuggestionsSpy = jest
|
||||
.spyOn(documentService, 'getAiSuggestions')
|
||||
.mockReturnValue(
|
||||
of({ title: 'AI title', tags: [42, 43], suggested_tags: ['New'] })
|
||||
)
|
||||
initNormally()
|
||||
expect(suggestionsSpy).toHaveBeenCalled()
|
||||
expect(aiSuggestionsSpy).toHaveBeenCalled()
|
||||
expect(component.suggestions().title).toEqual('AI title')
|
||||
expect(component.suggestions().tags).toEqual([42, 43])
|
||||
expect(component.suggestions().suggested_tags).toEqual(['New'])
|
||||
expect(component.suggestions().dates).toEqual(['2024-01-01'])
|
||||
})
|
||||
|
||||
it('should only fetch sources not yet fetched for the document', () => {
|
||||
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST, false)
|
||||
settingsService.set(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE,
|
||||
SuggestionSource.ML
|
||||
)
|
||||
const getSetting = settingsService.get.bind(settingsService)
|
||||
jest
|
||||
.spyOn(settingsService, 'get')
|
||||
.mockImplementation((key) =>
|
||||
key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key)
|
||||
)
|
||||
const suggestionsSpy = jest
|
||||
.spyOn(documentService, 'getSuggestions')
|
||||
.mockReturnValue(of({ tags: [42] }))
|
||||
const aiSuggestionsSpy = jest
|
||||
.spyOn(documentService, 'getAiSuggestions')
|
||||
.mockReturnValue(of({ tags: [43] }))
|
||||
initNormally()
|
||||
|
||||
component.getSuggestions()
|
||||
expect(suggestionsSpy).toHaveBeenCalledTimes(1)
|
||||
expect(aiSuggestionsSpy).not.toHaveBeenCalled()
|
||||
|
||||
component.getSuggestions(SuggestionSource.Both)
|
||||
expect(suggestionsSpy).toHaveBeenCalledTimes(1)
|
||||
expect(aiSuggestionsSpy).toHaveBeenCalledTimes(1)
|
||||
expect(component.suggestions().tags).toEqual([42, 43])
|
||||
|
||||
component.getSuggestions(SuggestionSource.Both)
|
||||
expect(suggestionsSpy).toHaveBeenCalledTimes(1)
|
||||
expect(aiSuggestionsSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should use the per-document source override and reset it on document change', () => {
|
||||
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST, false)
|
||||
const getSetting = settingsService.get.bind(settingsService)
|
||||
jest
|
||||
.spyOn(settingsService, 'get')
|
||||
.mockImplementation((key) =>
|
||||
key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key)
|
||||
)
|
||||
initNormally()
|
||||
expect(component.suggestionSource).toEqual(SuggestionSource.AI)
|
||||
component.suggestionSourceOverride.set(SuggestionSource.ML)
|
||||
expect(component.suggestionSource).toEqual(SuggestionSource.ML)
|
||||
|
||||
jest
|
||||
.spyOn(documentService, 'get')
|
||||
.mockReturnValueOnce(of(Object.assign({}, doc)))
|
||||
;(component as any).loadDocument(doc.id, true)
|
||||
expect(component.suggestionSourceOverride()).toBeNull()
|
||||
expect(component.fetchedSuggestionSources()).toEqual([])
|
||||
})
|
||||
|
||||
it('should keep suggestions from one source if the other fails', () => {
|
||||
settingsService.set(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE,
|
||||
SuggestionSource.Both
|
||||
)
|
||||
const getSetting = settingsService.get.bind(settingsService)
|
||||
jest
|
||||
.spyOn(settingsService, 'get')
|
||||
.mockImplementation((key) =>
|
||||
key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key)
|
||||
)
|
||||
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||
jest
|
||||
.spyOn(documentService, 'getSuggestions')
|
||||
.mockReturnValue(of({ tags: [42] }))
|
||||
jest
|
||||
.spyOn(documentService, 'getAiSuggestions')
|
||||
.mockReturnValue(throwError(() => new Error('failed')))
|
||||
initNormally()
|
||||
expect(errorSpy).toHaveBeenCalled()
|
||||
expect(component.suggestions().tags).toEqual([42])
|
||||
expect(component.fetchedSuggestionSources()).toEqual([SuggestionSource.ML])
|
||||
})
|
||||
|
||||
it('should show error if needed for get suggestions', () => {
|
||||
const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions')
|
||||
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||
@@ -2045,6 +2204,208 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(saveSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('selectVersion should use the version content as the baseline and ignore stale responses', () => {
|
||||
initNormally()
|
||||
const version10Content = new Subject<Document>()
|
||||
jest
|
||||
.spyOn(documentService, 'get')
|
||||
.mockReturnValueOnce(version10Content)
|
||||
.mockReturnValueOnce(of({ content: 'version 12 content' } as Document))
|
||||
const version10Metadata = new Subject<any>()
|
||||
jest
|
||||
.spyOn(documentService, 'getMetadata')
|
||||
.mockReturnValueOnce(version10Metadata)
|
||||
.mockReturnValueOnce(of({ lang: 'de' }))
|
||||
|
||||
component.selectVersion(10)
|
||||
component.selectVersion(12)
|
||||
version10Content.next({ content: 'version 10 content' } as Document)
|
||||
version10Metadata.next({ lang: 'en' })
|
||||
|
||||
expect(component.documentForm.get('content').value).toEqual(
|
||||
'version 12 content'
|
||||
)
|
||||
expect(component.store.value.content).toEqual('version 12 content')
|
||||
expect(component.metadata().lang).toEqual('de')
|
||||
expect(
|
||||
httpTestingController.expectOne(component.previewUrl()).cancelled
|
||||
).toBeFalsy()
|
||||
expect(
|
||||
httpTestingController.match((req) => req.url.includes('version=10'))[0]
|
||||
?.cancelled
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should confirm before discarding unsaved content edits when switching versions', () => {
|
||||
initNormally()
|
||||
component.document().versions = docVersions
|
||||
jest
|
||||
.spyOn(documentService, 'get')
|
||||
.mockImplementation((id, versionID) =>
|
||||
of({ content: `version ${versionID} content` } as Document)
|
||||
)
|
||||
let openModal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((modals) => (openModal = modals[0]))
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
|
||||
// shared fields carry over between versions, so no confirmation
|
||||
component.documentForm.get('title').setValue('Edited title')
|
||||
component.documentForm.get('title').markAsDirty()
|
||||
component.documentForm.get('content').markAsDirty()
|
||||
component.onVersionSelected(12)
|
||||
expect(modalSpy).not.toHaveBeenCalled()
|
||||
expect(component.selectedVersionId()).toEqual(12)
|
||||
|
||||
component.documentForm.get('content').setValue('edited content')
|
||||
component.documentForm.get('content').markAsDirty()
|
||||
component.onVersionSelected(12) // already selected, nothing to do
|
||||
expect(modalSpy).not.toHaveBeenCalled()
|
||||
component.onVersionSelected(10)
|
||||
expect(modalSpy).toHaveBeenCalledWith(
|
||||
ConfirmDialogComponent,
|
||||
expect.anything()
|
||||
)
|
||||
openModal.componentInstance.cancel()
|
||||
expect(component.selectedVersionId()).toEqual(12)
|
||||
expect(component.documentForm.get('content').value).toEqual(
|
||||
'edited content'
|
||||
)
|
||||
|
||||
component.onVersionSelected(10)
|
||||
openModal.componentInstance.confirmClicked.emit()
|
||||
expect(component.selectedVersionId()).toEqual(10)
|
||||
expect(component.documentForm.get('content').value).toEqual(
|
||||
'version 10 content'
|
||||
)
|
||||
expect(component.documentForm.get('content').dirty).toBeFalsy()
|
||||
expect(component.documentForm.get('title').value).toEqual('Edited title')
|
||||
})
|
||||
|
||||
it('should save unsaved content edits to the current version before switching, and stay if that fails', () => {
|
||||
initNormally()
|
||||
component.document().versions = docVersions
|
||||
component.selectedVersionId.set(12)
|
||||
jest
|
||||
.spyOn(documentService, 'get')
|
||||
.mockReturnValue(of({ content: 'version 10 content' } as Document))
|
||||
const savedDoc = new Subject<Document>()
|
||||
const patchSpy = jest
|
||||
.spyOn(documentService, 'patch')
|
||||
.mockReturnValueOnce(throwError(() => new Error('failed to save')))
|
||||
.mockReturnValueOnce(savedDoc)
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
component.documentForm.get('content').setValue('edited content')
|
||||
component.documentForm.get('content').markAsDirty()
|
||||
|
||||
component.onVersionSelected(10)
|
||||
let modal: NgbModalRef = modalSpy.mock.results[0].value
|
||||
const closeSpy = jest.spyOn(modal, 'close')
|
||||
modal.componentInstance.alternativeClicked.emit()
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
expect(component.selectedVersionId()).toEqual(12)
|
||||
expect(component.documentForm.get('content').value).toEqual(
|
||||
'edited content'
|
||||
)
|
||||
|
||||
component.onVersionSelected(10)
|
||||
modal = modalSpy.mock.results[1].value
|
||||
modal.componentInstance.alternativeClicked.emit()
|
||||
expect(patchSpy).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ content: 'edited content' }),
|
||||
12
|
||||
)
|
||||
component.onVersionSelected(doc.id) // ignored while saving
|
||||
expect(modalSpy).toHaveBeenCalledTimes(2)
|
||||
savedDoc.next(doc)
|
||||
expect(component.selectedVersionId()).toEqual(10)
|
||||
expect(component.documentForm.get('content').value).toEqual(
|
||||
'version 10 content'
|
||||
)
|
||||
})
|
||||
|
||||
it('should switch without confirmation when the selected version was deleted, even while saving', () => {
|
||||
initNormally()
|
||||
component.document().versions = docVersions
|
||||
component.selectedVersionId.set(10)
|
||||
jest
|
||||
.spyOn(documentService, 'get')
|
||||
.mockReturnValue(of({ content: 'version 12 content' } as Document))
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
component.documentForm.get('content').setValue('edited content')
|
||||
component.documentForm.get('content').markAsDirty()
|
||||
component.networkActive.set(true)
|
||||
|
||||
// the version dropdown emits this after deleting the selected version
|
||||
component.onVersionsUpdated(docVersions.filter((v) => v.id !== 10))
|
||||
component.onVersionSelected(12)
|
||||
|
||||
expect(modalSpy).not.toHaveBeenCalled()
|
||||
expect(component.selectedVersionId()).toEqual(12)
|
||||
expect(component.documentForm.get('content').value).toEqual(
|
||||
'version 12 content'
|
||||
)
|
||||
})
|
||||
|
||||
it('should restore the selected version and its unsaved content when returning to a document', () => {
|
||||
initNormally()
|
||||
const openDoc = component.document()
|
||||
openDoc.versions = docVersions
|
||||
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
|
||||
jest
|
||||
.spyOn(documentService, 'get')
|
||||
.mockImplementation((id, versionID) =>
|
||||
of(
|
||||
(versionID
|
||||
? { content: `version ${versionID} content` }
|
||||
: { ...doc, versions: docVersions }) as Document
|
||||
)
|
||||
)
|
||||
component.selectVersion(10)
|
||||
// an edit that happens to match the latest version's content
|
||||
component.documentForm.get('content').setValue(doc.content)
|
||||
openDoc.__changedFields = ['content']
|
||||
|
||||
component['loadDocument'](doc.id)
|
||||
|
||||
expect(component.selectedVersionId()).toEqual(10)
|
||||
expect(component.documentForm.get('content').value).toEqual(doc.content)
|
||||
expect(openDocumentsService.isDirty(openDoc)).toBeTruthy()
|
||||
const patchSpy = jest
|
||||
.spyOn(documentService, 'patch')
|
||||
.mockReturnValue(of(doc))
|
||||
component.save()
|
||||
expect(patchSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ content: doc.content }),
|
||||
10
|
||||
)
|
||||
})
|
||||
|
||||
it('should fall back to the latest version when the remembered version no longer exists', () => {
|
||||
initNormally()
|
||||
const openDoc = component.document()
|
||||
openDoc.versions = docVersions
|
||||
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
|
||||
jest.spyOn(documentService, 'get').mockImplementation((id, versionID) =>
|
||||
of(
|
||||
(versionID
|
||||
? { content: `version ${versionID} content` }
|
||||
: {
|
||||
...doc,
|
||||
content: 'version 12 content',
|
||||
versions: docVersions.filter((v) => v.id !== 10),
|
||||
}) as Document
|
||||
)
|
||||
)
|
||||
component.selectVersion(10)
|
||||
|
||||
component['loadDocument'](doc.id)
|
||||
|
||||
expect(component.selectedVersionId()).toEqual(12)
|
||||
expect(component.documentForm.get('content').value).toEqual(
|
||||
'version 12 content'
|
||||
)
|
||||
})
|
||||
|
||||
it('createDisabled should return true if the user does not have permission to add the specified data type', () => {
|
||||
currentUserCan = false
|
||||
expect(component.createDisabled(DataType.Correspondent)).toBeTruthy()
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { DeviceDetectorService } from 'ngx-device-detector'
|
||||
import { BehaviorSubject, Observable, of, Subject, timer } from 'rxjs'
|
||||
import { BehaviorSubject, merge, Observable, of, Subject, timer } from 'rxjs'
|
||||
import {
|
||||
catchError,
|
||||
debounceTime,
|
||||
@@ -48,7 +48,10 @@ import { DataType } from 'src/app/data/datatype'
|
||||
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'
|
||||
import {
|
||||
DocumentSuggestions,
|
||||
mergeSuggestions,
|
||||
} from 'src/app/data/document-suggestions'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
import { FilterRule } from 'src/app/data/filter-rule'
|
||||
import {
|
||||
@@ -63,7 +66,7 @@ import {
|
||||
import { ObjectWithId } from 'src/app/data/object-with-id'
|
||||
import { StoragePath } from 'src/app/data/storage-path'
|
||||
import { Tag } from 'src/app/data/tag'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings'
|
||||
import { User } from 'src/app/data/user'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
@@ -92,14 +95,15 @@ import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { TrashService } from 'src/app/services/trash.service'
|
||||
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
||||
import { getFilenameFromContentDisposition } from 'src/app/utils/http'
|
||||
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
||||
import * as UTIF from 'utif'
|
||||
import { DocumentDetailFieldID } from '../admin/settings/settings.component'
|
||||
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
||||
import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
||||
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
|
||||
import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
||||
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
|
||||
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||
@@ -132,6 +136,7 @@ import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-li
|
||||
import { SuggestionsDropdownComponent } from '../common/suggestions-dropdown/suggestions-dropdown.component'
|
||||
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
|
||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||
import { DocumentBarcodesComponent } from './document-barcodes/document-barcodes.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'
|
||||
@@ -174,6 +179,7 @@ interface IncomingDocumentUpdate {
|
||||
DateComponent,
|
||||
DocumentLinkComponent,
|
||||
MetadataCollapseComponent,
|
||||
DocumentBarcodesComponent,
|
||||
PermissionsFormComponent,
|
||||
SelectComponent,
|
||||
TagsComponent,
|
||||
@@ -216,6 +222,7 @@ export class DocumentDetailComponent
|
||||
private documentListViewService = inject(DocumentListViewService)
|
||||
private documentTitlePipe = inject(DocumentTitlePipe)
|
||||
private toastService = inject(ToastService)
|
||||
private trashService = inject(TrashService)
|
||||
private settings = inject(SettingsService)
|
||||
private storagePathService = inject(StoragePathService)
|
||||
private permissionsService = inject(PermissionsService)
|
||||
@@ -240,6 +247,10 @@ export class DocumentDetailComponent
|
||||
private readonly autoSuggestSetting = this.settings.getSignal<boolean>(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST
|
||||
)
|
||||
private readonly suggestionSourceSetting =
|
||||
this.settings.getSignal<SuggestionSource>(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_SUGGESTION_SOURCE
|
||||
)
|
||||
private readonly hiddenFieldsSetting = this.settings.getSignal<
|
||||
DocumentDetailFieldID[]
|
||||
>(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
|
||||
@@ -261,6 +272,9 @@ export class DocumentDetailComponent
|
||||
readonly metadata = signal<DocumentMetadata>(undefined)
|
||||
readonly suggestions = signal<DocumentSuggestions>(undefined)
|
||||
readonly suggestionsLoading = signal(false)
|
||||
// per-document, resets on navigation
|
||||
readonly suggestionSourceOverride = signal<SuggestionSource>(null)
|
||||
readonly fetchedSuggestionSources = signal<SuggestionSource[]>([])
|
||||
readonly users = signal<User[]>(undefined)
|
||||
|
||||
readonly title = signal<string>(undefined)
|
||||
@@ -304,6 +318,7 @@ export class DocumentDetailComponent
|
||||
isDirty$: Observable<boolean>
|
||||
unsubscribeNotifier: Subject<any> = new Subject()
|
||||
docChangeNotifier: Subject<any> = new Subject()
|
||||
versionChangeNotifier: Subject<void> = new Subject()
|
||||
private incomingUpdateModal: NgbModalRef
|
||||
private pendingIncomingUpdate: IncomingDocumentUpdate
|
||||
private lastLocalSaveModified: string | null = null
|
||||
@@ -364,6 +379,15 @@ export class DocumentDetailComponent
|
||||
return this.autoSuggestSetting()
|
||||
}
|
||||
|
||||
get defaultSuggestionSource(): SuggestionSource {
|
||||
return this.aiEnabled ? this.suggestionSourceSetting() : SuggestionSource.ML
|
||||
}
|
||||
|
||||
get suggestionSource(): SuggestionSource {
|
||||
if (!this.aiEnabled) return SuggestionSource.ML
|
||||
return this.suggestionSourceOverride() ?? this.defaultSuggestionSource
|
||||
}
|
||||
|
||||
get archiveContentRenderType(): ContentRenderType {
|
||||
const hasArchiveVersion =
|
||||
this.metadata()?.has_archive_version ??
|
||||
@@ -417,7 +441,8 @@ export class DocumentDetailComponent
|
||||
.pipe(
|
||||
first(),
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
takeUntil(this.docChangeNotifier)
|
||||
takeUntil(this.docChangeNotifier),
|
||||
takeUntil(this.versionChangeNotifier)
|
||||
)
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
@@ -533,7 +558,8 @@ export class DocumentDetailComponent
|
||||
.pipe(
|
||||
first(),
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
takeUntil(this.docChangeNotifier)
|
||||
takeUntil(this.docChangeNotifier),
|
||||
takeUntil(this.versionChangeNotifier)
|
||||
)
|
||||
.subscribe({
|
||||
next: (res) => this.previewText.set(res.toString()),
|
||||
@@ -587,6 +613,8 @@ export class DocumentDetailComponent
|
||||
}
|
||||
this.documentId.set(doc.id)
|
||||
this.suggestions.set(null)
|
||||
this.suggestionSourceOverride.set(null)
|
||||
this.fetchedSuggestionSources.set([])
|
||||
const openDocument = this.openDocumentService.getOpenDocument(
|
||||
this.documentId()
|
||||
)
|
||||
@@ -595,6 +623,13 @@ export class DocumentDetailComponent
|
||||
openDocument.duplicate_documents = doc.duplicate_documents
|
||||
this.openDocumentService.save()
|
||||
}
|
||||
// use server versions
|
||||
if (openDocument) {
|
||||
openDocument.versions = doc.versions
|
||||
if (!openDocument.__changedFields?.includes('content')) {
|
||||
openDocument.content = doc.content
|
||||
}
|
||||
}
|
||||
let useDoc = openDocument || doc
|
||||
if (openDocument && forceRemote) {
|
||||
Object.assign(openDocument, doc)
|
||||
@@ -642,7 +677,14 @@ export class DocumentDetailComponent
|
||||
this.documentForm.patchValue({ title: titleValue })
|
||||
this.documentForm.get('title').markAsDirty()
|
||||
})
|
||||
const keepContentEdits =
|
||||
useDoc.__selectedVersionId === this.selectedVersionId() &&
|
||||
!!useDoc.__changedFields?.includes('content')
|
||||
this.setupDirtyTracking(useDoc, doc)
|
||||
// Maybe load the stored version
|
||||
if (useDoc.__selectedVersionId) {
|
||||
this.selectVersion(this.selectedVersionId(), keepContentEdits)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -903,9 +945,11 @@ export class DocumentDetailComponent
|
||||
|
||||
updateComponent(doc: Document) {
|
||||
this.document.set(doc)
|
||||
// Default selected version is the newest version, which the API returns first
|
||||
// Load the selected version, or default to API first (newest)
|
||||
const versions = doc.versions ?? []
|
||||
this.selectedVersionId.set(versions.length ? versions[0].id : doc.id)
|
||||
const selectedVersion =
|
||||
versions.find((v) => v.id === doc.__selectedVersionId) ?? versions[0]
|
||||
this.selectedVersionId.set(selectedVersion?.id ?? doc.id)
|
||||
this.previewLoaded.set(false)
|
||||
this.requiresPassword = false
|
||||
this.updateFormForCustomFields()
|
||||
@@ -940,8 +984,12 @@ export class DocumentDetailComponent
|
||||
}
|
||||
|
||||
// Update file preview and download target to a specific version (by document id)
|
||||
selectVersion(versionId: number) {
|
||||
selectVersion(versionId: number, keepContentEdits: boolean = false) {
|
||||
this.versionChangeNotifier.next()
|
||||
this.selectedVersionId.set(versionId)
|
||||
// remember so the version can be restored when returning to the document
|
||||
this.document().__selectedVersionId = versionId
|
||||
this.openDocumentService.save()
|
||||
this.previewLoaded.set(false)
|
||||
this.previewUrl.set(
|
||||
this.documentsService.getPreviewUrl(
|
||||
@@ -963,20 +1011,20 @@ export class DocumentDetailComponent
|
||||
.pipe(
|
||||
first(),
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
takeUntil(this.docChangeNotifier)
|
||||
takeUntil(this.docChangeNotifier),
|
||||
takeUntil(this.versionChangeNotifier)
|
||||
)
|
||||
.subscribe({
|
||||
next: (doc) => {
|
||||
const content = doc?.content ?? ''
|
||||
this.document().content = content
|
||||
this.documentForm.patchValue(
|
||||
{
|
||||
content,
|
||||
},
|
||||
{
|
||||
emitEvent: false,
|
||||
}
|
||||
)
|
||||
if (keepContentEdits) {
|
||||
this.store.next({ ...this.store.value, content })
|
||||
} else {
|
||||
// Update in-place and avoid the debounce wait
|
||||
this.store.value.content = content
|
||||
this.documentForm.patchValue({ content })
|
||||
this.documentForm.get('content').markAsPristine()
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError(
|
||||
@@ -991,7 +1039,8 @@ export class DocumentDetailComponent
|
||||
.pipe(
|
||||
first(),
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
takeUntil(this.docChangeNotifier)
|
||||
takeUntil(this.docChangeNotifier),
|
||||
takeUntil(this.versionChangeNotifier)
|
||||
)
|
||||
.subscribe({
|
||||
next: (res) => this.previewText.set(res.toString()),
|
||||
@@ -1005,7 +1054,39 @@ export class DocumentDetailComponent
|
||||
}
|
||||
|
||||
onVersionSelected(versionId: number) {
|
||||
this.selectVersion(versionId)
|
||||
if (versionId === this.selectedVersionId()) return
|
||||
// Bail if the selected version was just deleted.
|
||||
const selectedVersionExists = this.document()?.versions?.some(
|
||||
(v) => v.id === this.selectedVersionId()
|
||||
)
|
||||
if (this.networkActive() && selectedVersionExists) return
|
||||
if (
|
||||
!selectedVersionExists ||
|
||||
this.documentForm.get('content').value === this.store.value.content
|
||||
) {
|
||||
this.selectVersion(versionId)
|
||||
return
|
||||
}
|
||||
|
||||
// Confirm any unsaved content changes
|
||||
const modal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Unsaved Changes`
|
||||
modal.componentInstance.messageBold = $localize`You have unsaved changes to the content of this version.`
|
||||
modal.componentInstance.message = $localize`Switching versions will discard them.`
|
||||
modal.componentInstance.btnClass = 'btn-secondary'
|
||||
modal.componentInstance.btnCaption = $localize`Discard and switch`
|
||||
modal.componentInstance.alternativeBtnClass = 'btn-primary'
|
||||
modal.componentInstance.alternativeBtnCaption = $localize`Save and switch`
|
||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||
modal.close()
|
||||
this.selectVersion(versionId)
|
||||
})
|
||||
modal.componentInstance.alternativeClicked.pipe(first()).subscribe(() => {
|
||||
modal.close()
|
||||
this.save(false, () => this.selectVersion(versionId))
|
||||
})
|
||||
}
|
||||
|
||||
onVersionsUpdated(versions: DocumentVersionInfo[]) {
|
||||
@@ -1021,29 +1102,44 @@ export class DocumentDetailComponent
|
||||
return this.documentForm.get('custom_fields') as FormArray
|
||||
}
|
||||
|
||||
getSuggestions() {
|
||||
getSuggestions(source: SuggestionSource = this.suggestionSource) {
|
||||
const sources = (
|
||||
source === SuggestionSource.Both
|
||||
? [SuggestionSource.ML, SuggestionSource.AI]
|
||||
: [source]
|
||||
).filter((s) => !this.fetchedSuggestionSources().includes(s))
|
||||
if (!sources.length) return
|
||||
|
||||
this.suggestionsLoading.set(true)
|
||||
const suggestionsObservable = this.aiEnabled
|
||||
? this.documentsService.getAiSuggestions(this.documentId())
|
||||
: this.documentsService.getSuggestions(this.documentId())
|
||||
suggestionsObservable
|
||||
merge(
|
||||
...sources.map((s) =>
|
||||
(s === SuggestionSource.AI
|
||||
? this.documentsService.getAiSuggestions(this.documentId())
|
||||
: this.documentsService.getSuggestions(this.documentId())
|
||||
).pipe(
|
||||
first(),
|
||||
map((result) => ({ source: s, result })),
|
||||
catchError((error) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error retrieving suggestions.`,
|
||||
error
|
||||
)
|
||||
return of(null)
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
.pipe(
|
||||
first(),
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
takeUntil(this.docChangeNotifier),
|
||||
finalize(() => this.suggestionsLoading.set(false))
|
||||
)
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.suggestions.set(result)
|
||||
},
|
||||
error: (error) => {
|
||||
this.suggestions.set(null)
|
||||
this.toastService.showError(
|
||||
$localize`Error retrieving suggestions.`,
|
||||
error
|
||||
)
|
||||
},
|
||||
.subscribe((response) => {
|
||||
if (!response) return
|
||||
this.fetchedSuggestionSources.update((f) => [...f, response.source])
|
||||
this.suggestions.set(
|
||||
mergeSuggestions(this.suggestions(), response.result)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1060,7 +1156,7 @@ export class DocumentDetailComponent
|
||||
if (this.suggestions()) {
|
||||
this.suggestions.set({
|
||||
...this.suggestions(),
|
||||
suggested_tags: this.suggestions().suggested_tags.filter(
|
||||
suggested_tags: (this.suggestions().suggested_tags ?? []).filter(
|
||||
(tag) => tag !== newTag.name
|
||||
),
|
||||
})
|
||||
@@ -1099,10 +1195,12 @@ export class DocumentDetailComponent
|
||||
this.documentForm.get('document_type').setValue(newDocumentType.id)
|
||||
this.documentForm.get('document_type').markAsDirty()
|
||||
if (this.suggestions()) {
|
||||
this.suggestions().suggested_document_types =
|
||||
this.suggestions().suggested_document_types.filter(
|
||||
(dt) => dt !== newName
|
||||
)
|
||||
this.suggestions.set({
|
||||
...this.suggestions(),
|
||||
suggested_document_types: (
|
||||
this.suggestions().suggested_document_types ?? []
|
||||
).filter((dt) => dt !== newName),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1129,10 +1227,12 @@ export class DocumentDetailComponent
|
||||
this.documentForm.get('correspondent').setValue(newCorrespondent.id)
|
||||
this.documentForm.get('correspondent').markAsDirty()
|
||||
if (this.suggestions()) {
|
||||
this.suggestions().suggested_correspondents =
|
||||
this.suggestions().suggested_correspondents.filter(
|
||||
(c) => c !== newName
|
||||
)
|
||||
this.suggestions.set({
|
||||
...this.suggestions(),
|
||||
suggested_correspondents: (
|
||||
this.suggestions().suggested_correspondents ?? []
|
||||
).filter((c) => c !== newName),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1233,7 +1333,7 @@ export class DocumentDetailComponent
|
||||
return changes
|
||||
}
|
||||
|
||||
save(close: boolean = false) {
|
||||
save(close: boolean = false, savedCallback: () => void = null) {
|
||||
this.networkActive.set(true)
|
||||
;(document.activeElement as HTMLElement)?.dispatchEvent(new Event('change'))
|
||||
this.documentsService
|
||||
@@ -1251,7 +1351,7 @@ export class DocumentDetailComponent
|
||||
this.store.next(newValues)
|
||||
this.openDocumentService.setDirty(this.document(), false)
|
||||
this.openDocumentService.save()
|
||||
this.toastService.showInfo(
|
||||
this.toastService.showSuccess(
|
||||
$localize`Document "${newValues.title}" saved successfully.`
|
||||
)
|
||||
this.networkActive.set(false)
|
||||
@@ -1266,6 +1366,7 @@ export class DocumentDetailComponent
|
||||
this.flushPendingIncomingUpdate()
|
||||
}
|
||||
this.savedViewService.maybeRefreshDocumentCounts()
|
||||
savedCallback?.()
|
||||
},
|
||||
error: (error) => {
|
||||
this.networkActive.set(false)
|
||||
@@ -1278,7 +1379,7 @@ export class DocumentDetailComponent
|
||||
if (!canEdit) {
|
||||
// document was 'given away'
|
||||
this.openDocumentService.setDirty(this.document(), false)
|
||||
this.toastService.showInfo(
|
||||
this.toastService.showSuccess(
|
||||
$localize`Document "${this.document().title}" saved successfully.`
|
||||
)
|
||||
this.close()
|
||||
@@ -1391,6 +1492,10 @@ export class DocumentDetailComponent
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.trashService.notifyMovedToTrash(
|
||||
$localize`Moved "${this.document().title}" to the trash`,
|
||||
[this.document().id]
|
||||
)
|
||||
modal.close()
|
||||
this.close()
|
||||
},
|
||||
@@ -1450,7 +1555,7 @@ export class DocumentDetailComponent
|
||||
}
|
||||
|
||||
runZoneOcr() {
|
||||
this.documentsService.runZoneOcr(this.document.id).subscribe({
|
||||
this.documentsService.runZoneOcr(this.document().id).subscribe({
|
||||
next: (res) => {
|
||||
const results = res.results ?? []
|
||||
if (results.length) {
|
||||
@@ -1473,7 +1578,7 @@ export class DocumentDetailComponent
|
||||
)
|
||||
}
|
||||
this.documentsService
|
||||
.get(this.documentId)
|
||||
.get(this.documentId())
|
||||
.subscribe((doc) => this.updateComponent(doc))
|
||||
},
|
||||
error: (error) => {
|
||||
@@ -1485,8 +1590,8 @@ export class DocumentDetailComponent
|
||||
createOcrTemplate() {
|
||||
this.router.navigate(['/ocr-templates', 'new'], {
|
||||
queryParams: {
|
||||
document_type: this.document.document_type,
|
||||
sample_document: this.document.id,
|
||||
document_type: this.document().document_type,
|
||||
sample_document: this.document().id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
+5
-2
@@ -33,7 +33,9 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
| 'updateVersionLabel'
|
||||
>
|
||||
>
|
||||
let toastService: jest.Mocked<Pick<ToastService, 'showError' | 'showInfo'>>
|
||||
let toastService: jest.Mocked<
|
||||
Pick<ToastService, 'showError' | 'showInfo' | 'showSuccess'>
|
||||
>
|
||||
let finished$: Subject<{ taskId: string }>
|
||||
let failed$: Subject<{ taskId: string; message?: string }>
|
||||
let modalService: jest.Mocked<Pick<NgbModal, 'open'>>
|
||||
@@ -55,6 +57,7 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
toastService = {
|
||||
showError: jest.fn(),
|
||||
showInfo: jest.fn(),
|
||||
showSuccess: jest.fn(),
|
||||
}
|
||||
permissionsService = {
|
||||
currentUserCan: jest.fn().mockReturnValue(true),
|
||||
@@ -416,6 +419,6 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
expect(selectedEmitSpy).toHaveBeenCalledWith(2)
|
||||
expect(component.newVersionLabel).toEqual('')
|
||||
expect(modal.close).toHaveBeenCalled()
|
||||
expect(toastService.showInfo).toHaveBeenCalled()
|
||||
expect(toastService.showSuccess).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
+1
-1
@@ -320,7 +320,7 @@ export class DocumentVersionDropdownComponent
|
||||
}
|
||||
this.newVersionLabel = ''
|
||||
modal.close()
|
||||
this.toastService.showInfo(
|
||||
this.toastService.showSuccess(
|
||||
$localize`Existing document added as a version.`
|
||||
)
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user