Proper zoneless stuff

This commit is contained in:
shamoon committed 2026-10-20 09:35:39 -07:00
1 parent 2afec3e0b7
commit 0572b54579
6 files changed
+259 -112

No files matched your search

@@ -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>
@@ -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()
})
})
@@ -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