diff --git a/src-ui/src/app/app-routing.module.ts b/src-ui/src/app/app-routing.module.ts
index 28831a424..ea005f94e 100644
--- a/src-ui/src/app/app-routing.module.ts
+++ b/src-ui/src/app/app-routing.module.ts
@@ -13,7 +13,6 @@ import { DocumentDetailComponent } from './components/document-detail/document-d
import { DocumentListComponent } from './components/document-list/document-list.component'
import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component'
import { MailComponent } from './components/manage/mail/mail.component'
-import { OcrTemplateEditorComponent } from './components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component'
import { OcrTemplatesComponent } from './components/manage/ocr-templates/ocr-templates.component'
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
import { ShareLinksComponent } from './components/manage/share-links/share-links.component'
@@ -289,32 +288,6 @@ export const routes: Routes = [
componentName: 'OcrTemplatesComponent',
},
},
- {
- path: 'ocr-templates/new',
- component: OcrTemplateEditorComponent,
- canDeactivate: [DirtyFormGuard],
- canActivate: [PermissionsGuard],
- data: {
- requiredPermission: {
- action: PermissionAction.Add,
- type: PermissionType.OcrTemplate,
- },
- componentName: 'OcrTemplateEditorComponent',
- },
- },
- {
- path: 'ocr-templates/:id',
- component: OcrTemplateEditorComponent,
- canDeactivate: [DirtyFormGuard],
- canActivate: [PermissionsGuard],
- data: {
- requiredPermission: {
- action: PermissionAction.Change,
- type: PermissionType.OcrTemplate,
- },
- componentName: 'OcrTemplateEditorComponent',
- },
- },
{
path: 'mail',
component: MailComponent,
diff --git a/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.html b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.html
index a61dbd2d5..586d7883f 100644
--- a/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.html
+++ b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.html
@@ -1,15 +1,12 @@
-
-
- Close
-
-
-
+
+
+
+
diff --git a/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.scss b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.scss
index 0cd025772..a0c7efa42 100644
--- a/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.scss
+++ b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.scss
@@ -1,5 +1,7 @@
:host {
- display: block;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
}
.zone-preview-scroll {
diff --git a/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.spec.ts b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.spec.ts
index c5d80b286..10c21d0d2 100644
--- a/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.spec.ts
+++ b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.spec.ts
@@ -1,11 +1,14 @@
import { ElementRef } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
-import { ActivatedRoute } from '@angular/router'
-import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+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 { OcrTemplateZone, OcrZoneTestResult } from 'src/app/data/ocr-template'
+import {
+ OcrTemplate,
+ OcrTemplateZone,
+ OcrZoneTestResult,
+} from 'src/app/data/ocr-template'
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'
@@ -40,22 +43,23 @@ describe('OcrTemplateEditorComponent', () => {
const templates = {
testZone: jest.fn(),
create: jest.fn(),
+ update: jest.fn(),
getPageImageUrl: (id: number, page: number) => `/preview/${id}/${page}`,
}
const modal = { open: jest.fn() }
+ const activeModal = { close: jest.fn(), dismiss: jest.fn() }
beforeEach(async () => {
documents.list.mockReset().mockReturnValue(of({ results: [] }))
documents.get.mockReset().mockReturnValue(of({ id: 1, page_count: 3 }))
templates.testZone.mockReset()
templates.create.mockReset()
+ templates.update.mockReset()
+ activeModal.close.mockReset()
await TestBed.configureTestingModule({
imports: [OcrTemplateEditorComponent],
providers: [
- {
- provide: ActivatedRoute,
- useValue: { snapshot: { paramMap: new Map() } },
- },
+ { provide: NgbActiveModal, useValue: activeModal },
{ provide: DocumentService, useValue: documents },
{ provide: OcrTemplateService, useValue: templates },
...[CustomFieldsService, DocumentTypeService, CorrespondentService].map(
@@ -124,6 +128,46 @@ describe('OcrTemplateEditorComponent', () => {
) as PointerEvent
}
+ it('edits a copy of the supplied template and restores its sample document', () => {
+ const original: OcrTemplate = {
+ ...component.template,
+ id: 3,
+ name: 'Invoices',
+ sample_document: 5,
+ zones: [zone()],
+ combine_formats: { '1': '{Invoice number}' },
+ }
+ component.object = original
+ fixture.detectChanges()
+
+ expect(component.isNew).toBe(false)
+ expect(component.previewDocId).toBe(5)
+ expect(component.pageImageUrl).toBe('/preview/5/0')
+ expect(component.isDirty).toBe(false)
+ component.template.name = 'Edited'
+ component.template.zones[0].x = 500
+ component.template.combine_formats['1'] = 'Edited format'
+ expect(original.name).toBe('Invoices')
+ expect(original.zones[0].x).toBe(100)
+ expect(original.combine_formats['1']).toBe('{Invoice number}')
+ })
+
+ it('updates an existing template, notifies the list, and closes the dialog', () => {
+ component.object = { ...component.template, id: 3, name: 'Invoices' }
+ fixture.detectChanges()
+ component.template.name = 'Renamed'
+ const saved = { ...component.template }
+ templates.update.mockReturnValue(of(saved))
+ const succeeded = jest.spyOn(component.succeeded, 'emit')
+
+ component.save()
+
+ expect(templates.update).toHaveBeenCalledWith(component.template)
+ expect(templates.create).not.toHaveBeenCalled()
+ expect(succeeded).toHaveBeenCalledWith(saved)
+ expect(activeModal.close).toHaveBeenCalledWith(saved)
+ })
+
it('searches across document metadata even when a template document type is set', () => {
jest.useFakeTimers()
const term$ = new Subject()
@@ -355,7 +399,7 @@ describe('OcrTemplateEditorComponent', () => {
it('tracks unsaved changes against the last loaded or saved state', () => {
fixture.detectChanges()
- expect(component.isDirty$()).toBe(false)
+ expect(component.isDirty).toBe(false)
component.template.combine_formats = { '1': '' }
expect(component.isDirty).toBe(false)
@@ -390,9 +434,7 @@ describe('OcrTemplateEditorComponent', () => {
pending.next({ ...component.template, id: 3 })
pending.complete()
expect(component.saving).toBe(false)
- component.onOverlayPointerDown(pointer(500, 500))
- component.onOverlayPointerUp(pointer(700, 700))
- expect(component.template.zones).toHaveLength(1)
+ expect(activeModal.close).toHaveBeenCalledWith(component.template)
})
it('stops an active move and unlocks editing without losing changes after a failed save', () => {
diff --git a/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.ts b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.ts
index bcba56396..27a8ff319 100644
--- a/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.ts
+++ b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.ts
@@ -4,23 +4,25 @@ import {
ChangeDetectorRef,
Component,
ElementRef,
+ EventEmitter,
HostListener,
inject,
+ Input,
OnDestroy,
OnInit,
+ Output,
signal,
ViewChild,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
-import { ActivatedRoute, RouterModule } from '@angular/router'
import {
+ NgbActiveModal,
NgbModal,
NgbNavModule,
NgbPopoverModule,
NgbTypeaheadModule,
NgbTypeaheadSelectItemEvent,
} from '@ng-bootstrap/ng-bootstrap'
-import { DirtyComponent } from '@ngneat/dirty-check-forms'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import {
catchError,
@@ -40,7 +42,6 @@ import { NumberComponent } from 'src/app/components/common/input/number/number.c
import { SelectComponent } from 'src/app/components/common/input/select/select.component'
import { SwitchComponent } from 'src/app/components/common/input/switch/switch.component'
import { TextComponent } from 'src/app/components/common/input/text/text.component'
-import { PageHeaderComponent } from 'src/app/components/common/page-header/page-header.component'
import { CustomField } from 'src/app/data/custom-field'
import { Document } from 'src/app/data/document'
import { DocumentType } from 'src/app/data/document-type'
@@ -128,14 +129,12 @@ const RESIZE_CURSOR: Record = {
standalone: true,
imports: [
DragDropModule,
- PageHeaderComponent,
NumberComponent,
TextComponent,
SelectComponent,
SwitchComponent,
CommonModule,
FormsModule,
- RouterModule,
NgbNavModule,
NgbPopoverModule,
NgbTypeaheadModule,
@@ -145,10 +144,8 @@ const RESIZE_CURSOR: Record = {
templateUrl: './ocr-template-editor.component.html',
styleUrls: ['./ocr-template-editor.component.scss'],
})
-export class OcrTemplateEditorComponent
- implements OnInit, OnDestroy, DirtyComponent
-{
- private readonly route = inject(ActivatedRoute)
+export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
+ readonly activeModal = inject(NgbActiveModal)
private readonly templateService = inject(OcrTemplateService)
private readonly customFieldsService = inject(CustomFieldsService)
private readonly documentTypeService = inject(DocumentTypeService)
@@ -161,6 +158,9 @@ export class OcrTemplateEditorComponent
private readonly cancelZoneTest$ = new Subject()
private readonly customDateFormatZones = new WeakSet()
+ @Input() object: OcrTemplate
+ @Output() succeeded = new EventEmitter()
+
@ViewChild('zoneOverlay') overlayRef: ElementRef
imageRef: ElementRef
private previewResizeObserver: ResizeObserver | null = null
@@ -281,8 +281,6 @@ export class OcrTemplateEditorComponent
return this.editorState() !== this.savedState
}
- readonly isDirty$ = () => this.isDirty
-
private editorState(): string {
const formats = Object.entries(this.template.combine_formats ?? {}).filter(
([, format]) => format
@@ -298,7 +296,7 @@ export class OcrTemplateEditorComponent
this.savedState = this.editorState()
}
- get pageTitle(): string {
+ get dialogTitle(): string {
return this.isNew
? $localize`New OCR Template`
: $localize`Edit OCR Template`
@@ -329,24 +327,19 @@ export class OcrTemplateEditorComponent
this.changeDetector.markForCheck()
})
- const id = this.route.snapshot.paramMap.get('id')
- if (id) {
+ if (this.object) {
this.isNew = false
- this.templateService
- .get(parseInt(id))
- .pipe(takeUntil(this.destroy$))
- .subscribe((t) => {
- this.template = t
- this.changeDetector.markForCheck()
- if (t.sample_document) {
- this.previewDocId = t.sample_document
- this.loadPreview()
- }
- this.markSaved()
- })
- } else {
- this.markSaved()
+ this.template = {
+ ...this.object,
+ zones: this.object.zones.map((zone) => ({ ...zone })),
+ combine_formats: { ...this.object.combine_formats },
+ }
+ if (this.template.sample_document) {
+ this.previewDocId = this.template.sample_document
+ this.loadPreview()
+ }
}
+ this.markSaved()
}
searchDocuments = (text$: Observable): Observable =>
@@ -923,6 +916,8 @@ export class OcrTemplateEditorComponent
this.markSaved()
this.toastService.showInfo($localize`OCR template saved.`)
this.changeDetector.markForCheck()
+ this.succeeded.emit(saved)
+ this.activeModal.close(saved)
},
error: (e) => {
this.saving = false
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
new file mode 100644
index 000000000..6fb766fab
--- /dev/null
+++ b/src-ui/src/app/components/manage/ocr-templates/ocr-templates.component.spec.ts
@@ -0,0 +1,91 @@
+import { EventEmitter } from '@angular/core'
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { NgbModal, NgbModalOptions } from '@ng-bootstrap/ng-bootstrap'
+import { of } from 'rxjs'
+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'
+import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
+import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
+import { ToastService } from 'src/app/services/toast.service'
+import { OcrTemplateEditorComponent } from './ocr-template-editor/ocr-template-editor.component'
+import { OcrTemplatesComponent } from './ocr-templates.component'
+
+describe('OcrTemplatesComponent', () => {
+ let fixture: ComponentFixture
+ let component: OcrTemplatesComponent
+ let options: NgbModalOptions
+ const editor = {
+ object: undefined as OcrTemplate,
+ saving: false,
+ isDirty: false,
+ succeeded: new EventEmitter(),
+ }
+ const modal = { open: jest.fn() }
+ const dirtyFormGuard = { confirmChanges: jest.fn() }
+
+ beforeEach(async () => {
+ editor.object = undefined
+ editor.saving = false
+ editor.isDirty = false
+ editor.succeeded = new EventEmitter()
+ modal.open.mockReset().mockImplementation((_, modalOptions) => {
+ options = modalOptions
+ return { componentInstance: editor }
+ })
+ dirtyFormGuard.confirmChanges.mockReset().mockReturnValue(of(true))
+ await TestBed.configureTestingModule({
+ imports: [OcrTemplatesComponent],
+ providers: [
+ { provide: NgbModal, useValue: modal },
+ { provide: DirtyFormGuard, useValue: dirtyFormGuard },
+ { provide: PermissionsService, useValue: {} },
+ { provide: OcrTemplateService, useValue: {} },
+ { provide: DocumentTypeService, useValue: {} },
+ { provide: ToastService, useValue: {} },
+ ],
+ })
+ .overrideComponent(OcrTemplatesComponent, {
+ set: { template: '', imports: [] },
+ })
+ .compileComponents()
+ fixture = TestBed.createComponent(OcrTemplatesComponent)
+ component = fixture.componentInstance
+ })
+
+ it('opens creation in a dialog and refreshes the list after saving', () => {
+ const reload = jest.spyOn(component, 'reload').mockImplementation(() => {})
+ component.createTemplate()
+
+ expect(modal.open).toHaveBeenCalledWith(
+ OcrTemplateEditorComponent,
+ expect.any(Object)
+ )
+ expect(editor.object).toBeUndefined()
+ expect(options.backdrop).toBe('static')
+ expect(options.beforeDismiss()).toBe(true)
+ editor.succeeded.emit({ id: 3 } as OcrTemplate)
+ expect(reload).toHaveBeenCalledTimes(1)
+ })
+
+ it('passes the selected template to the same editor dialog', () => {
+ const template = { id: 3, name: 'Invoices' } as OcrTemplate
+ component.editTemplate(template)
+
+ expect(editor.object).toBe(template)
+ })
+
+ it('confirms dismissal only when dirty and never dismisses while saving', async () => {
+ component.createTemplate()
+ editor.isDirty = true
+ dirtyFormGuard.confirmChanges.mockReturnValue(of(false))
+ expect(await options.beforeDismiss()).toBe(false)
+ dirtyFormGuard.confirmChanges.mockReturnValue(of(true))
+ expect(await options.beforeDismiss()).toBe(true)
+ expect(dirtyFormGuard.confirmChanges).toHaveBeenCalledTimes(2)
+
+ editor.saving = true
+ expect(options.beforeDismiss()).toBe(false)
+ expect(dirtyFormGuard.confirmChanges).toHaveBeenCalledTimes(2)
+ })
+})
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 e6a1dde06..bf7910b17 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,11 +1,11 @@
import { ChangeDetectorRef, Component, OnInit, inject } from '@angular/core'
import { FormsModule } from '@angular/forms'
-import { Router } from '@angular/router'
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
-import { delay, takeUntil, tap } from 'rxjs'
+import { delay, firstValueFrom, takeUntil, tap } from 'rxjs'
import { OcrTemplate } from 'src/app/data/ocr-template'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { DirtyFormGuard } from 'src/app/guards/dirty-form.guard'
import { PermissionsService } from 'src/app/services/permissions.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
@@ -13,6 +13,7 @@ import { ToastService } from 'src/app/services/toast.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
+import { OcrTemplateEditorComponent } from './ocr-template-editor/ocr-template-editor.component'
@Component({
selector: 'pngx-ocr-templates',
@@ -31,8 +32,8 @@ export class OcrTemplatesComponent
{
private readonly service = inject(OcrTemplateService)
private readonly documentTypeService = inject(DocumentTypeService)
- private readonly router = inject(Router)
private readonly modalService = inject(NgbModal)
+ private readonly dirtyFormGuard = inject(DirtyFormGuard)
private readonly toastService = inject(ToastService)
private readonly changeDetector = inject(ChangeDetectorRef)
permissionsService = inject(PermissionsService)
@@ -75,11 +76,29 @@ export class OcrTemplatesComponent
}
createTemplate() {
- this.router.navigate(['/ocr-templates', 'new'])
+ this.editTemplate()
}
- editTemplate(t: OcrTemplate) {
- this.router.navigate(['/ocr-templates', t.id])
+ editTemplate(t?: OcrTemplate) {
+ const modal = this.modalService.open(OcrTemplateEditorComponent, {
+ backdrop: 'static',
+ keyboard: false,
+ size: 'xl',
+ fullscreen: 'lg',
+ scrollable: true,
+ ariaLabelledBy: 'ocr-template-editor-title',
+ beforeDismiss: () => {
+ const editor: OcrTemplateEditorComponent = modal.componentInstance
+ if (editor.saving) return false
+ return editor.isDirty
+ ? firstValueFrom(this.dirtyFormGuard.confirmChanges())
+ : true
+ },
+ })
+ modal.componentInstance.object = t
+ modal.componentInstance.succeeded
+ .pipe(takeUntil(this.unsubscribeNotifier))
+ .subscribe(() => this.reload())
}
toggleTemplate(t: OcrTemplate) {