Warn about losing changes, disable while zone ocr running

This commit is contained in:
shamoon committed 2026-10-20 08:36:22 -07:00
1 parent a51e6a71f4
commit d10d303980
3 files changed
+149 -33

No files matched your search

@@ -64,7 +64,7 @@
<button
ngbDropdownItem
(click)="runZoneOcr()"
[disabled]="!userCanEdit || !document()?.document_type"
[disabled]="!userCanEdit || !document()?.document_type || networkActive()"
*pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }"
>
<i-bs width="1em" height="1em" name="file-earmark-ruled" class="me-1"></i-bs><span i18n>Run Zone OCR</span>
@@ -99,7 +99,7 @@
<div class="row">
<div class="col-md-6 col-xl-5 mb-4">
<form [formGroup]='documentForm' (ngSubmit)="save()">
<form [formGroup]='documentForm' (ngSubmit)="save()" [attr.inert]="zoneOcrRunning() ? '' : null" [attr.aria-busy]="zoneOcrRunning()">
<div class="btn-toolbar mb-1 pb-3 gap-2 row-gap-2 border-bottom">
<div class="btn-group">
@@ -1923,7 +1923,73 @@ describe('DocumentDetailComponent', () => {
'Filled 1 of 2 fields. Failed to match zones: Due'
)
expect(notifySpy).toHaveBeenCalledWith(doc.id)
expect(loadSpy).toHaveBeenCalledWith(doc.id, true)
expect(loadSpy).toHaveBeenCalledWith(doc.id, true, expect.any(Function))
expect(component.zoneOcrRunning()).toBe(true)
loadSpy.mock.calls[0][2]()
expect(component.zoneOcrRunning()).toBe(false)
expect(component.networkActive()).toBe(false)
})
it('asks before running zone OCR on a dirty document and leaves edits intact on cancel', () => {
component.documentId.set(doc.id)
component.document.set({ ...doc, __changedFields: ['title'] })
component.documentForm.patchValue({ title: 'Unsaved title' })
const runSpy = jest.spyOn(documentService, 'runZoneOcr')
const modalSpy = jest.spyOn(modalService, 'open')
component.runZoneOcr()
const modal = modalSpy.mock.results[0].value
expect(modal.componentInstance.message).toBe(
'Running Zone OCR will discard them.'
)
expect(runSpy).not.toHaveBeenCalled()
modal.componentInstance.cancel()
expect(runSpy).not.toHaveBeenCalled()
expect(component.documentForm.get('title').value).toBe('Unsaved title')
expect(component.zoneOcrRunning()).toBe(false)
})
it('runs zone OCR only after confirmation and prevents duplicate requests', () => {
component.documentId.set(doc.id)
component.document.set({ ...doc, __changedFields: ['title'] })
const pending = new Subject<any>()
const runSpy = jest
.spyOn(documentService, 'runZoneOcr')
.mockReturnValue(pending)
const modalSpy = jest.spyOn(modalService, 'open')
component.runZoneOcr()
modalSpy.mock.results[0].value.componentInstance.confirmClicked.emit()
expect(runSpy).toHaveBeenCalledWith(doc.id)
expect(component.zoneOcrRunning()).toBe(true)
expect(component.networkActive()).toBe(true)
const saveSpy = jest.spyOn(documentService, 'patch')
component.save()
expect(saveSpy).not.toHaveBeenCalled()
component.runZoneOcr()
expect(runSpy).toHaveBeenCalledTimes(1)
pending.error(new Error('failed'))
expect(component.zoneOcrRunning()).toBe(false)
expect(component.networkActive()).toBe(false)
expect(component.document().__changedFields).toEqual(['title'])
})
it('does not reload a different document when zone OCR finishes', () => {
component.documentId.set(doc.id)
component.document.set({ ...doc, __changedFields: [] })
const pending = new Subject<any>()
jest.spyOn(documentService, 'runZoneOcr').mockReturnValue(pending)
const loadSpy = jest.spyOn(component as any, 'loadDocument')
component.runZoneOcr()
component.documentId.set(99)
pending.next({ results: [] })
expect(loadSpy).not.toHaveBeenCalled()
expect(component.zoneOcrRunning()).toBe(false)
pending.complete()
})
it('should show an error when zone OCR fails', () => {
@@ -1938,6 +2004,8 @@ describe('DocumentDetailComponent', () => {
expect(errorSpy).toHaveBeenCalledWith('Zone OCR failed', expect.any(Error))
expect(loadSpy).not.toHaveBeenCalled()
expect(component.zoneOcrRunning()).toBe(false)
expect(component.networkActive()).toBe(false)
})
it('should change preview element by render type', () => {
@@ -266,6 +266,7 @@ export class DocumentDetailComponent
readonly error = signal<any>(undefined)
readonly networkActive = signal(false)
readonly zoneOcrRunning = signal(false)
readonly documentId = signal<number>(undefined)
readonly document = signal<Document>(undefined)
@@ -543,7 +544,11 @@ export class DocumentDetailComponent
)
}
private loadDocument(documentId: number, forceRemote: boolean = false): void {
private loadDocument(
documentId: number,
forceRemote: boolean = false,
onComplete?: () => void
): void {
let redirectedToRoot = false
this.closeIncomingUpdateModal()
this.pendingIncomingUpdate = null
@@ -600,7 +605,8 @@ export class DocumentDetailComponent
}),
first(),
takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier)
takeUntil(this.docChangeNotifier),
finalize(() => onComplete?.())
)
.subscribe({
next: (doc) => {
@@ -1334,6 +1340,7 @@ export class DocumentDetailComponent
}
save(close: boolean = false, savedCallback: () => void = null) {
if (this.zoneOcrRunning()) return
this.networkActive.set(true)
;(document.activeElement as HTMLElement)?.dispatchEvent(new Event('change'))
this.documentsService
@@ -1555,35 +1562,76 @@ export class DocumentDetailComponent
}
runZoneOcr() {
this.documentsService.runZoneOcr(this.document().id).subscribe({
next: (res) => {
const results = res.results ?? []
if (results.length) {
const failed = results.filter(
(r) =>
r.value === null ||
r.value === undefined ||
`${r.value}`.trim() === ''
)
const filled = results.length - failed.length
let msg = $localize`Filled ${filled} of ${results.length} fields`
if (failed.length) {
const names = failed.map((r) => r.zone).join(', ')
msg = `${msg}. ${$localize`Failed to match zones: ${names}`}`
}
this.toastService.showInfo(msg)
} else {
this.toastService.showInfo(
$localize`Zone OCR ran but no results extracted.`
)
}
this.docChangeNotifier.next(this.documentId())
this.loadDocument(this.documentId(), true)
},
error: (error) => {
this.toastService.showError($localize`Zone OCR failed`, error)
},
if (this.networkActive()) return
const documentId = this.documentId()
if (!this.hasLocalEdits(this.document())) {
this.executeZoneOcr(documentId)
return
}
const modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Unsaved Changes`
modal.componentInstance.messageBold = $localize`You have unsaved changes.`
modal.componentInstance.message = $localize`Running Zone OCR will discard them.`
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Run Zone OCR`
modal.componentInstance.confirmClicked
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
modal.close()
if (this.documentId() === documentId && !this.networkActive()) {
this.executeZoneOcr(documentId)
}
})
}
private executeZoneOcr(documentId: number) {
this.zoneOcrRunning.set(true)
this.networkActive.set(true)
const finish = () => {
this.zoneOcrRunning.set(false)
this.networkActive.set(false)
this.flushPendingIncomingUpdate()
}
this.documentsService
.runZoneOcr(documentId)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (res) => {
if (this.documentId() !== documentId) {
finish()
return
}
const results = res.results ?? []
if (results.length) {
const failed = results.filter(
(r) =>
r.value === null ||
r.value === undefined ||
`${r.value}`.trim() === ''
)
const filled = results.length - failed.length
let msg = $localize`Filled ${filled} of ${results.length} fields`
if (failed.length) {
const names = failed.map((r) => r.zone).join(', ')
msg = `${msg}. ${$localize`Failed to match zones: ${names}`}`
}
this.toastService.showInfo(msg)
} else {
this.toastService.showInfo(
$localize`Zone OCR ran but no results extracted.`
)
}
this.docChangeNotifier.next(documentId)
this.loadDocument(documentId, true, finish)
},
error: (error) => {
finish()
this.toastService.showError($localize`Zone OCR failed`, error)
},
})
}
private getSelectedNonLatestVersionId(): number | null {