+ @if (previewStatus() === 'error') {
-
{{ previewError }}
+
{{ previewError() }}
} @else if (pageImageUrl) {
@@ -304,7 +304,7 @@
}
- @if (previewStatus === 'loading') {
+ @if (previewStatus() === 'loading') {
Loading page...
@@ -318,15 +318,15 @@
}
- @if (previewDocId && previewStatus !== 'error') {
+ @if (previewDocId && previewStatus() !== 'error') {
- @if (loading() && templates.length === 0) {
+ @if (loading() && templates().length === 0) {
Loading...
}
- @for (t of templates; track t.id) {
+ @for (t of templates(); track t.id) {
@@ -36,7 +36,7 @@
{{t.zones?.length || 0}}
-
+
@@ -69,7 +69,7 @@
}
- @if (!loading() && templates.length === 0) {
+ @if (!loading() && templates().length === 0) {
No OCR templates defined.
}
diff --git a/src-ui/src/app/components/manage/ocr-templates/ocr-templates.component.spec.ts b/src-ui/src/app/components/manage/ocr-templates/ocr-templates.component.spec.ts
index 31be7a773..1bdfe884c 100644
--- a/src-ui/src/app/components/manage/ocr-templates/ocr-templates.component.spec.ts
+++ b/src-ui/src/app/components/manage/ocr-templates/ocr-templates.component.spec.ts
@@ -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
(),
}
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()
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) {
+ {{ template.name }}
+ {{ getDocumentTypeName(template) }}
+
+ }
+ `,
+ 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()
+ 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: {
diff --git a/src-ui/src/app/components/manage/ocr-templates/ocr-templates.component.ts b/src-ui/src/app/components/manage/ocr-templates/ocr-templates.component.ts
index d1589866b..a270b7c6a 100644
--- a/src-ui/src/app/components/manage/ocr-templates/ocr-templates.component.ts
+++ b/src-ui/src/app/components/manage/ocr-templates/ocr-templates.component.ts
@@ -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 = new Map()
+ readonly templates = signal([])
+ private readonly documentTypeNames = signal(new Map())
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