Fuck it, make it a modal like literally everything else, drop the routes

This commit is contained in:
shamoon committed 2026-10-20 09:14:52 -07:00
1 parent 1d2832ac30
commit 2d54927be8
7 files changed
+213 -84

No files matched your search

-27
View File
@@ -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,
@@ -1,15 +1,12 @@
<pngx-page-header [title]="pageTitle" [id]="template.id">
<a routerLink="/ocr-templates" class="btn btn-sm btn-outline-secondary" title="Close" i18n-title aria-label="Close" i18n-aria-label>
<i-bs width="1.2em" height="1.2em" name="x"></i-bs><span class="d-none d-lg-inline ps-1" i18n>Close</span>
</a>
<button type="button" class="btn btn-sm 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>
</button>
</pngx-page-header>
<div class="modal-header">
<h4 class="modal-title" id="ocr-template-editor-title">{{ dialogTitle }}</h4>
@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>
</div>
<div class="modal-body">
<div class="row" [attr.inert]="saving ? '' : null" [attr.aria-busy]="saving">
<div class="col-lg-4 mb-3">
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-underline flex-nowrap flex-md-wrap overflow-auto">
@@ -343,3 +340,13 @@
</div>
</div>
</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) {
<span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span>
}
<span i18n>Save</span>
</button>
</div>
@@ -1,5 +1,7 @@
:host {
display: block;
display: flex;
flex-direction: column;
min-height: 0;
}
.zone-preview-scroll {
@@ -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<string>()
@@ -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', () => {
@@ -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<ResizeHandle, string> = {
standalone: true,
imports: [
DragDropModule,
PageHeaderComponent,
NumberComponent,
TextComponent,
SelectComponent,
SwitchComponent,
CommonModule,
FormsModule,
RouterModule,
NgbNavModule,
NgbPopoverModule,
NgbTypeaheadModule,
@@ -145,10 +144,8 @@ const RESIZE_CURSOR: Record<ResizeHandle, string> = {
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<void>()
private readonly customDateFormatZones = new WeakSet<OcrTemplateZone>()
@Input() object: OcrTemplate
@Output() succeeded = new EventEmitter<OcrTemplate>()
@ViewChild('zoneOverlay') overlayRef: ElementRef<SVGSVGElement>
imageRef: ElementRef<HTMLImageElement>
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<string>): Observable<Document[]> =>
@@ -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
@@ -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<OcrTemplatesComponent>
let component: OcrTemplatesComponent
let options: NgbModalOptions
const editor = {
object: undefined as OcrTemplate,
saving: false,
isDirty: false,
succeeded: new EventEmitter<OcrTemplate>(),
}
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<OcrTemplate>()
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)
})
})
@@ -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) {