mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-20 23:08:14 +00:00
Proper zoneless stuff
This commit is contained in:
6 files changed
+259
-112
No files matched your search
+14
-14
@@ -3,11 +3,11 @@
|
||||
@if (template.id) {
|
||||
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{ template.id }}</span>
|
||||
}
|
||||
<button type="button" class="btn-close" (click)="activeModal.dismiss()" [disabled]="saving" aria-label="Close" i18n-aria-label></button>
|
||||
<button type="button" class="btn-close" (click)="activeModal.dismiss()" [disabled]="saving()" aria-label="Close" i18n-aria-label></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body d-flex flex-column">
|
||||
<div class="row gx-3 editor-layout flex-grow-1 flex-lg-nowrap" [attr.inert]="saving ? '' : null" [attr.aria-busy]="saving">
|
||||
<div class="row gx-3 editor-layout flex-grow-1 flex-lg-nowrap" [attr.inert]="saving() ? '' : null" [attr.aria-busy]="saving()">
|
||||
<div class="col-lg-4 mb-3 mb-lg-0 overflow-auto">
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-underline flex-nowrap flex-md-wrap overflow-auto">
|
||||
<li ngbNavItem="settings">
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pngx-input-select [(ngModel)]="template.document_type" [items]="documentTypes" bindLabel="name" bindValue="id" title="Document type" i18n-title></pngx-input-select>
|
||||
<pngx-input-select [(ngModel)]="template.document_type" [items]="documentTypes()" bindLabel="name" bindValue="id" title="Document type" i18n-title></pngx-input-select>
|
||||
|
||||
</ng-template>
|
||||
</li>
|
||||
@@ -162,7 +162,7 @@
|
||||
} @else {
|
||||
<pngx-ocr-template-zone-list
|
||||
[zones]="template.zones"
|
||||
[customFields]="customFields"
|
||||
[customFields]="customFields()"
|
||||
(zoneSelected)="selectZone($event)"
|
||||
(zoneRemoved)="removeZone($event)"
|
||||
></pngx-ocr-template-zone-list>
|
||||
@@ -223,11 +223,11 @@
|
||||
</div>
|
||||
}
|
||||
<div #previewContainer class="position-relative flex-grow-1 overflow-hidden">
|
||||
<div class="h-100 overflow-auto border" [attr.aria-busy]="previewStatus === 'loading'">
|
||||
@if (previewStatus === 'error') {
|
||||
<div class="h-100 overflow-auto border" [attr.aria-busy]="previewStatus() === 'loading'">
|
||||
@if (previewStatus() === 'error') {
|
||||
<div class="py-5 px-3 text-center" role="alert">
|
||||
<i-bs name="exclamation-triangle" width="32" height="32" class="text-warning"></i-bs>
|
||||
<p class="my-3">{{ previewError }}</p>
|
||||
<p class="my-3">{{ previewError() }}</p>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" (click)="loadPreview()"><i-bs name="arrow-clockwise" class="me-1"></i-bs><span i18n>Retry</span></button>
|
||||
</div>
|
||||
} @else if (pageImageUrl) {
|
||||
@@ -304,7 +304,7 @@
|
||||
</svg>
|
||||
}
|
||||
</div>
|
||||
@if (previewStatus === 'loading') {
|
||||
@if (previewStatus() === 'loading') {
|
||||
<div class="p-5 d-flex justify-content-center align-items-center" role="status">
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden" i18n>Loading page...</span>
|
||||
@@ -318,15 +318,15 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (previewDocId && previewStatus !== 'error') {
|
||||
@if (previewDocId && previewStatus() !== 'error') {
|
||||
<div class="position-absolute bottom-0 start-50 translate-middle-x z-1 mb-3">
|
||||
<div cdkDrag [cdkDragBoundary]="previewContainer" class="d-flex gap-2 p-1 bg-body border rounded shadow">
|
||||
<span cdkDragHandle class="d-flex align-items-center text-secondary" title="Move preview controls" i18n-title aria-label="Move preview controls" i18n-aria-label>
|
||||
<i-bs name="grip-vertical"></i-bs>
|
||||
</span>
|
||||
<div class="input-group input-group-sm w-auto flex-nowrap">
|
||||
<input class="form-control flex-grow-0 preview-page-input" type="number" min="1" [max]="previewPageCount" [(ngModel)]="previewPageDisplay" [disabled]="previewStatus === 'loading'" aria-label="Page" i18n-aria-label />
|
||||
<span class="input-group-text" i18n>of {{ previewPageCount ?? '?' }}</span>
|
||||
<input class="form-control flex-grow-0 preview-page-input" type="number" min="1" [max]="previewPageCount()" [(ngModel)]="previewPageDisplay" [disabled]="previewStatus() === 'loading'" aria-label="Page" i18n-aria-label />
|
||||
<span class="input-group-text" i18n>of {{ previewPageCount() ?? '?' }}</span>
|
||||
</div>
|
||||
<div class="input-group input-group-sm w-auto flex-nowrap" role="group" aria-label="Zoom" i18n-aria-label>
|
||||
<button type="button" class="btn btn-outline-secondary" aria-label="Zoom out" i18n-aria-label [disabled]="!imageLoaded || zoom <= 0.5" (click)="zoomOut()"><i-bs name="dash"></i-bs></button>
|
||||
@@ -341,9 +341,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="activeModal.dismiss()" [disabled]="saving" i18n>Cancel</button>
|
||||
<button type="button" class="btn btn-primary" (click)="save()" [disabled]="saving || !isDirty">
|
||||
@if (saving) {
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="activeModal.dismiss()" [disabled]="saving()" i18n>Cancel</button>
|
||||
<button type="button" class="btn btn-primary" (click)="save()" [disabled]="saving() || !isDirty">
|
||||
@if (saving()) {
|
||||
<span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span>
|
||||
}
|
||||
<span i18n>Save</span>
|
||||
|
||||
+98
-9
@@ -4,11 +4,13 @@ import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { of, Subject, throwError } from 'rxjs'
|
||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import { Document } from 'src/app/data/document'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
import {
|
||||
OcrTemplate,
|
||||
OcrTemplateZone,
|
||||
OcrZoneTestResult,
|
||||
} from 'src/app/data/ocr-template'
|
||||
import { Results } from 'src/app/data/results'
|
||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
@@ -83,7 +85,19 @@ describe('OcrTemplateEditorComponent', () => {
|
||||
],
|
||||
})
|
||||
.overrideComponent(OcrTemplateEditorComponent, {
|
||||
set: { template: '{{ searchStatus() }}', imports: [] },
|
||||
set: {
|
||||
template: `
|
||||
<span class="search-status">{{ searchStatus() }}</span>
|
||||
<span class="document-type">{{ documentTypes()[0]?.name }}</span>
|
||||
<span class="field">{{ fieldItems()[3]?.name }}</span>
|
||||
<span class="preview-status">{{ previewStatus() }}</span>
|
||||
<span class="preview-error">{{ previewError() }}</span>
|
||||
<span class="page-count">{{ previewPageCount() }}</span>
|
||||
<span class="preview-document">{{ documentFormatter(previewDocModel()) }}</span>
|
||||
<button [disabled]="saving()">Save</button>
|
||||
`,
|
||||
imports: [],
|
||||
},
|
||||
})
|
||||
.compileComponents()
|
||||
fixture = TestBed.createComponent(OcrTemplateEditorComponent)
|
||||
@@ -118,7 +132,7 @@ describe('OcrTemplateEditorComponent', () => {
|
||||
})
|
||||
component.imageRef = new ElementRef(image)
|
||||
component.overlayRef = new ElementRef(overlay)
|
||||
component.previewStatus = 'ready'
|
||||
component.previewStatus.set('ready')
|
||||
}
|
||||
|
||||
function pointer(x: number, y: number, pointerId = 1): PointerEvent {
|
||||
@@ -217,7 +231,82 @@ describe('OcrTemplateEditorComponent', () => {
|
||||
pending.next({ results: [] })
|
||||
await fixture.whenStable()
|
||||
|
||||
expect(fixture.nativeElement.textContent).toBe('empty')
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.search-status').textContent
|
||||
).toBe('empty')
|
||||
})
|
||||
|
||||
it('renders asynchronously loaded metadata without manual change detection', async () => {
|
||||
const types = new Subject<Results<DocumentType>>()
|
||||
const fields = new Subject<Results<CustomField>>()
|
||||
jest
|
||||
.spyOn(TestBed.inject(DocumentTypeService), 'listAll')
|
||||
.mockReturnValue(types)
|
||||
jest
|
||||
.spyOn(TestBed.inject(CustomFieldsService), 'listAll')
|
||||
.mockReturnValue(fields)
|
||||
fixture.detectChanges()
|
||||
|
||||
types.next({
|
||||
count: 1,
|
||||
all: [2],
|
||||
results: [{ id: 2, name: 'Invoice' } as DocumentType],
|
||||
})
|
||||
fields.next({
|
||||
count: 1,
|
||||
all: [5],
|
||||
results: [
|
||||
{ id: 5, name: 'Total', data_type: CustomFieldDataType.Monetary },
|
||||
],
|
||||
})
|
||||
await fixture.whenStable()
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.document-type').textContent
|
||||
).toBe('Invoice')
|
||||
expect(fixture.nativeElement.querySelector('.field').textContent).toBe(
|
||||
'Total'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders asynchronous preview metadata and errors without manual change detection', async () => {
|
||||
fixture.detectChanges()
|
||||
const pending = new Subject<Document>()
|
||||
documents.get.mockReturnValue(pending)
|
||||
component.previewDocId = 5
|
||||
component.loadPreview()
|
||||
pending.next({ id: 5, title: 'Invoice', page_count: 3 } as Document)
|
||||
await fixture.whenStable()
|
||||
|
||||
expect(fixture.nativeElement.querySelector('.page-count').textContent).toBe(
|
||||
'3'
|
||||
)
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.preview-document').textContent
|
||||
).toBe('#5 Invoice')
|
||||
|
||||
pending.error(new Error('Offline'))
|
||||
await fixture.whenStable()
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.preview-status').textContent
|
||||
).toBe('error')
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.preview-error').textContent
|
||||
).toBe('Could not load the selected document.')
|
||||
})
|
||||
|
||||
it('re-enables saving after an asynchronous failure without manual change detection', async () => {
|
||||
fixture.detectChanges()
|
||||
const pending = new Subject<OcrTemplate>()
|
||||
templates.create.mockReturnValue(pending)
|
||||
component.save()
|
||||
await fixture.whenStable()
|
||||
expect(fixture.nativeElement.querySelector('button').disabled).toBe(true)
|
||||
|
||||
pending.error(new Error('Save failed'))
|
||||
await fixture.whenStable()
|
||||
expect(fixture.nativeElement.querySelector('button').disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('clears the selection on a click in empty space without adding a zone', () => {
|
||||
@@ -421,7 +510,7 @@ describe('OcrTemplateEditorComponent', () => {
|
||||
component.onOverlayPointerDown(pointer(500, 500))
|
||||
|
||||
component.save()
|
||||
expect(component.saving).toBe(true)
|
||||
expect(component.saving()).toBe(true)
|
||||
expect(component.drawingRect()).toBeNull()
|
||||
component.save()
|
||||
expect(templates.create).toHaveBeenCalledTimes(1)
|
||||
@@ -452,7 +541,7 @@ describe('OcrTemplateEditorComponent', () => {
|
||||
expect(component.template.zones[0].x).toBe(100)
|
||||
|
||||
pending.error(new Error('Save failed'))
|
||||
expect(component.saving).toBe(false)
|
||||
expect(component.saving()).toBe(false)
|
||||
expect(component.isDirty).toBe(true)
|
||||
component.onOverlayPointerDown(pointer(150, 150))
|
||||
component.onOverlayPointerUp(pointer(250, 250))
|
||||
@@ -526,19 +615,19 @@ describe('OcrTemplateEditorComponent', () => {
|
||||
component.loadPreview()
|
||||
pending.next({ id: 1, page_count: 99 } as Document)
|
||||
|
||||
expect(component.previewPageCount).toBe(3)
|
||||
expect(component.previewPageCount()).toBe(3)
|
||||
})
|
||||
|
||||
it('can retry a failed preview and clear the selected document', () => {
|
||||
component.previewDocId = 1
|
||||
component.loadPreview()
|
||||
component.onImageError()
|
||||
expect(component.previewStatus).toBe('error')
|
||||
expect(component.previewStatus()).toBe('error')
|
||||
|
||||
component.loadPreview()
|
||||
expect(component.previewStatus).toBe('loading')
|
||||
expect(component.previewStatus()).toBe('loading')
|
||||
component.clearPreviewDoc()
|
||||
expect(component.previewStatus).toBe('empty')
|
||||
expect(component.previewStatus()).toBe('empty')
|
||||
expect(component.pageImageUrl).toBeNull()
|
||||
})
|
||||
})
|
||||
+54
-63
@@ -1,8 +1,8 @@
|
||||
import { DragDropModule } from '@angular/cdk/drag-drop'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
HostListener,
|
||||
@@ -151,7 +151,6 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
private readonly documentService = inject(DocumentService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
private readonly modalService = inject(NgbModal)
|
||||
private readonly changeDetector = inject(ChangeDetectorRef)
|
||||
private readonly destroy$ = new Subject<void>()
|
||||
private readonly cancelZoneTest$ = new Subject<void>()
|
||||
private readonly customDateFormatZones = new WeakSet<OcrTemplateZone>()
|
||||
@@ -190,25 +189,16 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
zones: [],
|
||||
}
|
||||
|
||||
private _customFields: CustomField[] = []
|
||||
readonly fieldItems = signal<{ id: ZoneFieldSelection; name: string }[]>([
|
||||
...OCR_BUILTIN_TARGETS,
|
||||
])
|
||||
|
||||
get customFields(): CustomField[] {
|
||||
return this._customFields
|
||||
}
|
||||
|
||||
set customFields(fields: CustomField[]) {
|
||||
this._customFields = fields
|
||||
this.fieldItems.set([
|
||||
readonly customFields = signal<CustomField[]>([])
|
||||
readonly fieldItems = computed<{ id: ZoneFieldSelection; name: string }[]>(
|
||||
() => [
|
||||
...OCR_BUILTIN_TARGETS,
|
||||
...fields
|
||||
.filter((cf) => OCR_SUPPORTED_FIELD_TYPES.includes(cf.data_type))
|
||||
.map((cf) => ({ id: cf.id, name: cf.name })),
|
||||
])
|
||||
}
|
||||
documentTypes: DocumentType[] = []
|
||||
...this.customFields()
|
||||
.filter((field) => OCR_SUPPORTED_FIELD_TYPES.includes(field.data_type))
|
||||
.map((field) => ({ id: field.id, name: field.name })),
|
||||
]
|
||||
)
|
||||
readonly documentTypes = signal<DocumentType[]>([])
|
||||
transformOptions = TRANSFORM_OPTIONS
|
||||
ocrLanguageOptions = OCR_LANGUAGE_OPTIONS
|
||||
dateTransform = OCR_ZONE_TRANSFORM.Date
|
||||
@@ -218,21 +208,23 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
{ id: CUSTOM_DATE_FORMAT_CHOICE, name: $localize`Custom...` },
|
||||
]
|
||||
isNew = true
|
||||
saving = false
|
||||
readonly saving = signal(false)
|
||||
|
||||
previewDocId: number | null = null
|
||||
previewPage = 0
|
||||
previewPageCount: number | null = null
|
||||
readonly previewPageCount = signal<number | null>(null)
|
||||
private pageCountForDoc: number | null = null
|
||||
pageImageUrl: string | null = null
|
||||
previewStatus: 'empty' | 'loading' | 'ready' | 'error' = 'empty'
|
||||
previewError = ''
|
||||
readonly previewStatus = signal<'empty' | 'loading' | 'ready' | 'error'>(
|
||||
'empty'
|
||||
)
|
||||
readonly previewError = signal('')
|
||||
readonly searchStatus = signal<
|
||||
'idle' | 'loading' | 'ready' | 'empty' | 'error'
|
||||
>('idle')
|
||||
zoom = 1
|
||||
previewDocModel: Document | string = ''
|
||||
private correspondentNames = new Map<number, string>()
|
||||
readonly previewDocModel = signal<Document | string>('')
|
||||
private readonly correspondentNames = signal(new Map<number, string>())
|
||||
|
||||
public get previewPageDisplay(): number {
|
||||
return this.previewPage + 1
|
||||
@@ -251,7 +243,7 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
private readonly zoneTest = signal<ZoneTest | null>(null)
|
||||
|
||||
get imageLoaded(): boolean {
|
||||
return this.previewStatus === 'ready'
|
||||
return this.previewStatus() === 'ready'
|
||||
}
|
||||
|
||||
get zoneTestResult(): OcrZoneTestResult | null {
|
||||
@@ -296,24 +288,23 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
.listAll()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((r) => {
|
||||
this.customFields = r.results
|
||||
this.changeDetector.markForCheck()
|
||||
this.customFields.set(r.results)
|
||||
})
|
||||
|
||||
this.documentTypeService
|
||||
.listAll()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((r) => {
|
||||
this.documentTypes = r.results
|
||||
this.changeDetector.markForCheck()
|
||||
this.documentTypes.set(r.results)
|
||||
})
|
||||
|
||||
this.correspondentService
|
||||
.listAll()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((r) => {
|
||||
this.correspondentNames = new Map(r.results.map((c) => [c.id, c.name]))
|
||||
this.changeDetector.markForCheck()
|
||||
this.correspondentNames.set(
|
||||
new Map(r.results.map((c) => [c.id, c.name]))
|
||||
)
|
||||
})
|
||||
|
||||
if (this.object) {
|
||||
@@ -364,7 +355,7 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
if (!doc) return ''
|
||||
if (typeof doc === 'string') return doc
|
||||
const corr = doc.correspondent
|
||||
? this.correspondentNames.get(doc.correspondent)
|
||||
? this.correspondentNames().get(doc.correspondent)
|
||||
: null
|
||||
return corr
|
||||
? `#${doc.id} ${doc.title} (${corr})`
|
||||
@@ -374,7 +365,7 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
onPreviewDocSelected(event: NgbTypeaheadSelectItemEvent<Document>) {
|
||||
event.preventDefault()
|
||||
const doc: Document = event.item
|
||||
this.previewDocModel = doc
|
||||
this.previewDocModel.set(doc)
|
||||
this.searchStatus.set('idle')
|
||||
this.previewDocId = doc.id
|
||||
if (!this.template.document_type && doc.document_type) {
|
||||
@@ -387,14 +378,14 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
clearPreviewDoc() {
|
||||
this.stopOverlayInteraction()
|
||||
this.clearZoneTest()
|
||||
this.previewDocModel = ''
|
||||
this.previewDocModel.set('')
|
||||
this.previewDocId = null
|
||||
this.previewPageCount = null
|
||||
this.previewPageCount.set(null)
|
||||
this.pageCountForDoc = null
|
||||
this.previewPage = 0
|
||||
this.pageImageUrl = null
|
||||
this.previewStatus = 'empty'
|
||||
this.previewError = ''
|
||||
this.previewStatus.set('empty')
|
||||
this.previewError.set('')
|
||||
this.searchStatus.set('idle')
|
||||
}
|
||||
|
||||
@@ -402,29 +393,29 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
if (!this.previewDocId) return
|
||||
this.stopOverlayInteraction()
|
||||
this.clearZoneTest()
|
||||
this.previewStatus = 'loading'
|
||||
this.previewError = ''
|
||||
this.previewStatus.set('loading')
|
||||
this.previewError.set('')
|
||||
const documentId = this.previewDocId
|
||||
if (this.pageCountForDoc !== documentId) {
|
||||
this.pageCountForDoc = documentId
|
||||
this.previewPageCount = null
|
||||
this.previewPageCount.set(null)
|
||||
this.documentService
|
||||
.get(documentId)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (doc) => {
|
||||
if (this.previewDocId !== documentId) return
|
||||
this.previewPageCount = doc?.page_count ?? null
|
||||
if (doc && !this.previewDocModel) this.previewDocModel = doc
|
||||
this.changeDetector.markForCheck()
|
||||
this.previewPageCount.set(doc?.page_count ?? null)
|
||||
if (doc && !this.previewDocModel()) this.previewDocModel.set(doc)
|
||||
},
|
||||
error: () => {
|
||||
if (this.previewDocId !== documentId) return
|
||||
this.previewPageCount = null
|
||||
this.previewPageCount.set(null)
|
||||
this.pageCountForDoc = null
|
||||
this.previewStatus = 'error'
|
||||
this.previewError = $localize`Could not load the selected document.`
|
||||
this.changeDetector.markForCheck()
|
||||
this.previewStatus.set('error')
|
||||
this.previewError.set(
|
||||
$localize`Could not load the selected document.`
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -436,7 +427,8 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
|
||||
goToPage(page: number) {
|
||||
if (!Number.isFinite(page)) return
|
||||
const max = this.previewPageCount ? this.previewPageCount - 1 : page
|
||||
const pageCount = this.previewPageCount()
|
||||
const max = pageCount ? pageCount - 1 : page
|
||||
const clamped = Math.max(0, Math.min(Math.trunc(page), max))
|
||||
if (clamped === this.previewPage) return
|
||||
this.previewPage = clamped
|
||||
@@ -456,7 +448,7 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
zonePage(zone: OcrTemplateZone): number {
|
||||
return getZonePage(zone, this.previewPage, this.previewPageCount)
|
||||
return getZonePage(zone, this.previewPage, this.previewPageCount())
|
||||
}
|
||||
|
||||
isLastPageZone(zone: OcrTemplateZone): boolean {
|
||||
@@ -468,12 +460,12 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private isOnCurrentPage(zone: OcrTemplateZone): boolean {
|
||||
return isZoneOnPage(zone, this.previewPage, this.previewPageCount)
|
||||
return isZoneOnPage(zone, this.previewPage, this.previewPageCount())
|
||||
}
|
||||
|
||||
onImageLoad() {
|
||||
if (this.previewStatus !== 'loading') return
|
||||
this.previewStatus = 'ready'
|
||||
if (this.previewStatus() !== 'loading') return
|
||||
this.previewStatus.set('ready')
|
||||
const img = this.imageRef.nativeElement
|
||||
this.template.source_width = img.naturalWidth
|
||||
this.template.source_height = img.naturalHeight
|
||||
@@ -481,8 +473,8 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
|
||||
onImageError() {
|
||||
this.stopOverlayInteraction()
|
||||
this.previewStatus = 'error'
|
||||
this.previewError = $localize`Could not load this page.`
|
||||
this.previewStatus.set('error')
|
||||
this.previewError.set($localize`Could not load this page.`)
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
@@ -491,7 +483,7 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
onOverlayPointerDown(event: PointerEvent) {
|
||||
if (this.saving) return
|
||||
if (this.saving()) return
|
||||
if (event.button !== 0 || this.overlayInteraction.kind !== 'idle') return
|
||||
const point = this.svgPointFromEvent(event)
|
||||
if (!this.imageLoaded || !point) return
|
||||
@@ -689,7 +681,7 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
point,
|
||||
this.template.zones,
|
||||
this.previewPage,
|
||||
this.previewPageCount,
|
||||
this.previewPageCount(),
|
||||
this.imageNaturalSize()
|
||||
)
|
||||
}
|
||||
@@ -857,9 +849,9 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
save() {
|
||||
if (this.saving) return
|
||||
if (this.saving()) return
|
||||
this.stopOverlayInteraction()
|
||||
this.saving = true
|
||||
this.saving.set(true)
|
||||
this.pruneCombineFormats()
|
||||
this.template.sample_document = this.previewDocId
|
||||
const obs = this.isNew
|
||||
@@ -873,9 +865,8 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
this.activeModal.close(saved)
|
||||
},
|
||||
error: (e) => {
|
||||
this.saving = false
|
||||
this.saving.set(false)
|
||||
this.toastService.showError($localize`Error saving OCR template.`, e)
|
||||
this.changeDetector.markForCheck()
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1014,7 +1005,7 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
if (zone && OCR_SUPPORTED_FIELD_TYPES.includes(field.data_type)) {
|
||||
this.setZoneField(zone, field.id)
|
||||
}
|
||||
this.customFields = r.results
|
||||
this.customFields.set(r.results)
|
||||
})
|
||||
})
|
||||
modal.componentInstance.failed
|
||||
|
||||
@@ -21,14 +21,14 @@
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@if (loading() && templates.length === 0) {
|
||||
@if (loading() && templates().length === 0) {
|
||||
<li class="list-group-item">
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
<ng-container i18n>Loading...</ng-container>
|
||||
</li>
|
||||
}
|
||||
|
||||
@for (t of templates; track t.id) {
|
||||
@for (t of templates(); track t.id) {
|
||||
<li class="list-group-item">
|
||||
<div class="row fade" [class.show]="show()">
|
||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editTemplate(t)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.OcrTemplate)">{{t.name}}</button></div>
|
||||
@@ -36,7 +36,7 @@
|
||||
<div class="col d-flex align-items-center d-none d-sm-flex"><code>{{t.zones?.length || 0}}</code></div>
|
||||
<div class="col d-flex align-items-center">
|
||||
<div class="form-check form-switch mb-0">
|
||||
<input type="checkbox" class="form-check-input cursor-pointer" [id]="t.id+'_enable'" [(ngModel)]="t.enabled" (change)="toggleTemplate(t)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }">
|
||||
<input type="checkbox" class="form-check-input cursor-pointer" [id]="t.id+'_enable'" [ngModel]="t.enabled" (ngModelChange)="toggleTemplate(t, $event)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }">
|
||||
<label class="form-check-label cursor-pointer" [for]="t.id+'_enable'">
|
||||
<code> @if(t.enabled) { <ng-container i18n>Enabled</ng-container> } @else { <span i18n class="text-muted">Disabled</span> }</code>
|
||||
</label>
|
||||
@@ -69,7 +69,7 @@
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
@if (!loading() && templates.length === 0) {
|
||||
@if (!loading() && templates().length === 0) {
|
||||
<li class="list-group-item" [class.show]="show()" i18n>No OCR templates defined.</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -2,6 +2,7 @@ import { EventEmitter, signal, WritableSignal } from '@angular/core'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { NgbModal, NgbModalOptions } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { of, Subject } from 'rxjs'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
import { OcrTemplate } from 'src/app/data/ocr-template'
|
||||
import { DirtyFormGuard } from 'src/app/guards/dirty-form.guard'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
@@ -18,18 +19,19 @@ describe('OcrTemplatesComponent', () => {
|
||||
let options: NgbModalOptions
|
||||
const editor = {
|
||||
object: undefined as OcrTemplate,
|
||||
saving: false,
|
||||
saving: signal(false),
|
||||
isDirty: false,
|
||||
succeeded: new EventEmitter<OcrTemplate>(),
|
||||
}
|
||||
const modal = { open: jest.fn() }
|
||||
const dirtyFormGuard = { confirmChanges: jest.fn() }
|
||||
const templates = { delete: jest.fn() }
|
||||
const templates = { delete: jest.fn(), patch: jest.fn(), listAll: jest.fn() }
|
||||
const documentTypes = { listAll: jest.fn() }
|
||||
const toast = { showError: jest.fn() }
|
||||
|
||||
beforeEach(async () => {
|
||||
editor.object = undefined
|
||||
editor.saving = false
|
||||
editor.saving.set(false)
|
||||
editor.isDirty = false
|
||||
editor.succeeded = new EventEmitter<OcrTemplate>()
|
||||
modal.open.mockReset().mockImplementation((_, modalOptions) => {
|
||||
@@ -38,6 +40,9 @@ describe('OcrTemplatesComponent', () => {
|
||||
})
|
||||
dirtyFormGuard.confirmChanges.mockReset().mockReturnValue(of(true))
|
||||
templates.delete.mockReset()
|
||||
templates.patch.mockReset()
|
||||
templates.listAll.mockReset().mockReturnValue(of({ results: [] }))
|
||||
documentTypes.listAll.mockReset().mockReturnValue(of({ results: [] }))
|
||||
toast.showError.mockReset()
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [OcrTemplatesComponent],
|
||||
@@ -46,12 +51,21 @@ describe('OcrTemplatesComponent', () => {
|
||||
{ provide: DirtyFormGuard, useValue: dirtyFormGuard },
|
||||
{ provide: PermissionsService, useValue: {} },
|
||||
{ provide: OcrTemplateService, useValue: templates },
|
||||
{ provide: DocumentTypeService, useValue: {} },
|
||||
{ provide: DocumentTypeService, useValue: documentTypes },
|
||||
{ provide: ToastService, useValue: toast },
|
||||
],
|
||||
})
|
||||
.overrideComponent(OcrTemplatesComponent, {
|
||||
set: { template: '', imports: [] },
|
||||
set: {
|
||||
template: `
|
||||
@for (template of templates(); track template.id) {
|
||||
<span class="name">{{ template.name }}</span>
|
||||
<span class="document-type">{{ getDocumentTypeName(template) }}</span>
|
||||
<input type="checkbox" [checked]="template.enabled" />
|
||||
}
|
||||
`,
|
||||
imports: [],
|
||||
},
|
||||
})
|
||||
.compileComponents()
|
||||
fixture = TestBed.createComponent(OcrTemplatesComponent)
|
||||
@@ -89,11 +103,57 @@ describe('OcrTemplatesComponent', () => {
|
||||
expect(await options.beforeDismiss()).toBe(true)
|
||||
expect(dirtyFormGuard.confirmChanges).toHaveBeenCalledTimes(2)
|
||||
|
||||
editor.saving = true
|
||||
editor.saving.set(true)
|
||||
expect(options.beforeDismiss()).toBe(false)
|
||||
expect(dirtyFormGuard.confirmChanges).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('renders asynchronously loaded templates and type names without manual change detection', async () => {
|
||||
const pendingTemplates = new Subject<{ results: OcrTemplate[] }>()
|
||||
const pendingTypes = new Subject<{ results: DocumentType[] }>()
|
||||
templates.listAll.mockReturnValue(pendingTemplates)
|
||||
documentTypes.listAll.mockReturnValue(pendingTypes)
|
||||
fixture.detectChanges()
|
||||
|
||||
pendingTemplates.next({
|
||||
results: [{ id: 3, name: 'Invoices', document_type: 2 } as OcrTemplate],
|
||||
})
|
||||
await fixture.whenStable()
|
||||
expect(fixture.nativeElement.querySelector('.name').textContent).toBe(
|
||||
'Invoices'
|
||||
)
|
||||
|
||||
pendingTypes.next({ results: [{ id: 2, name: 'Invoice' } as DocumentType] })
|
||||
await fixture.whenStable()
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.document-type').textContent
|
||||
).toBe('Invoice')
|
||||
})
|
||||
|
||||
it('renders toggle rollback after an asynchronous failure without manual change detection', async () => {
|
||||
const template = { id: 3, name: 'Invoices', enabled: true } as OcrTemplate
|
||||
templates.listAll.mockReturnValue(of({ results: [template] }))
|
||||
const pending = new Subject<OcrTemplate>()
|
||||
templates.patch.mockReturnValue(pending)
|
||||
fixture.detectChanges()
|
||||
|
||||
component.toggleTemplate(template, false)
|
||||
await fixture.whenStable()
|
||||
expect(fixture.nativeElement.querySelector('input').checked).toBe(false)
|
||||
expect(templates.patch).toHaveBeenCalledWith({
|
||||
...template,
|
||||
enabled: false,
|
||||
})
|
||||
expect(template.enabled).toBe(true)
|
||||
|
||||
const error = new Error('Update failed')
|
||||
pending.error(error)
|
||||
await fixture.whenStable()
|
||||
|
||||
expect(fixture.nativeElement.querySelector('input').checked).toBe(true)
|
||||
expect(toast.showError).toHaveBeenCalledWith(expect.any(String), error)
|
||||
})
|
||||
|
||||
describe('deletion', () => {
|
||||
const template = { id: 3, name: 'Invoices' } as OcrTemplate
|
||||
let dialog: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChangeDetectorRef, Component, OnInit, inject } from '@angular/core'
|
||||
import { Component, OnInit, inject, signal } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
@@ -35,21 +35,19 @@ export class OcrTemplatesComponent
|
||||
private readonly modalService = inject(NgbModal)
|
||||
private readonly dirtyFormGuard = inject(DirtyFormGuard)
|
||||
private readonly toastService = inject(ToastService)
|
||||
private readonly changeDetector = inject(ChangeDetectorRef)
|
||||
permissionsService = inject(PermissionsService)
|
||||
|
||||
public templates: OcrTemplate[] = []
|
||||
private documentTypeNames: Map<number, string> = new Map()
|
||||
readonly templates = signal<OcrTemplate[]>([])
|
||||
private readonly documentTypeNames = signal(new Map<number, string>())
|
||||
|
||||
ngOnInit() {
|
||||
this.documentTypeService
|
||||
.listAll()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((r) => {
|
||||
this.documentTypeNames = new Map(
|
||||
r.results.map((dt) => [dt.id, dt.name])
|
||||
this.documentTypeNames.set(
|
||||
new Map(r.results.map((dt) => [dt.id, dt.name]))
|
||||
)
|
||||
this.changeDetector.markForCheck()
|
||||
})
|
||||
this.reload()
|
||||
}
|
||||
@@ -60,7 +58,7 @@ export class OcrTemplatesComponent
|
||||
.listAll()
|
||||
.pipe(
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
tap((r) => (this.templates = r.results)),
|
||||
tap((r) => this.templates.set(r.results)),
|
||||
delay(100)
|
||||
)
|
||||
.subscribe(() => {
|
||||
@@ -71,7 +69,8 @@ export class OcrTemplatesComponent
|
||||
|
||||
getDocumentTypeName(t: OcrTemplate): string {
|
||||
return (
|
||||
this.documentTypeNames.get(t.document_type) ?? `${t.document_type ?? ''}`
|
||||
this.documentTypeNames().get(t.document_type) ??
|
||||
`${t.document_type ?? ''}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -89,7 +88,7 @@ export class OcrTemplatesComponent
|
||||
ariaLabelledBy: 'ocr-template-editor-title',
|
||||
beforeDismiss: () => {
|
||||
const editor: OcrTemplateEditorComponent = modal.componentInstance
|
||||
if (editor.saving) return false
|
||||
if (editor.saving()) return false
|
||||
return editor.isDirty
|
||||
? firstValueFrom(this.dirtyFormGuard.confirmChanges())
|
||||
: true
|
||||
@@ -101,13 +100,21 @@ export class OcrTemplatesComponent
|
||||
.subscribe(() => this.reload())
|
||||
}
|
||||
|
||||
toggleTemplate(t: OcrTemplate) {
|
||||
// ngModel has already flipped t.enabled; restore it if persistence fails.
|
||||
const enabled = t.enabled
|
||||
this.service.patch(t).subscribe({
|
||||
toggleTemplate(t: OcrTemplate, enabled: boolean) {
|
||||
this.templates.update((templates) =>
|
||||
templates.map((template) =>
|
||||
template.id === t.id ? { ...template, enabled } : template
|
||||
)
|
||||
)
|
||||
this.service.patch({ ...t, enabled }).subscribe({
|
||||
error: (error) => {
|
||||
t.enabled = !enabled
|
||||
this.changeDetector.markForCheck()
|
||||
this.templates.update((templates) =>
|
||||
templates.map((template) =>
|
||||
template.id === t.id
|
||||
? { ...template, enabled: t.enabled }
|
||||
: template
|
||||
)
|
||||
)
|
||||
this.toastService.showError(
|
||||
$localize`Error updating OCR template.`,
|
||||
error
|
||||
|
||||
Reference in new issue
Block a user