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) {