From d2d434535beb44cc94bc0362283c419c45473966 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:36:17 -0700 Subject: [PATCH] Remove the slop quick create CF stuff --- .../ocr-template-editor.component.html | 34 +----- .../ocr-template-editor.component.spec.ts | 56 +++++++++- .../ocr-template-editor.component.ts | 102 +++++++----------- src-ui/src/app/data/ocr-template.ts | 13 +++ .../app/services/rest/ocr-template.service.ts | 17 --- src/documents/models.py | 2 +- src/documents/tests/test_api_ocr_templates.py | 52 --------- src/documents/views.py | 56 ---------- 8 files changed, 107 insertions(+), 225 deletions(-) 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 a460b7dcc..235a9af1d 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 @@ -77,46 +77,16 @@ - @if (showQuickCreate) { -
-
-
Create Custom Field
-
- - -
-
- - -
-
- - -
-
-
- } - @if (isFieldShared(zone)) {
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 64fb73868..132faa3ee 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,7 +1,9 @@ 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 { 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 { CorrespondentService } from 'src/app/services/rest/correspondent.service' @@ -39,6 +41,7 @@ describe('OcrTemplateEditorComponent', () => { testZone: jest.fn(), getPageImageUrl: (id: number, page: number) => `/preview/${id}/${page}`, } + const modal = { open: jest.fn() } beforeEach(async () => { documents.list.mockReset().mockReturnValue(of({ results: [] })) @@ -56,10 +59,17 @@ describe('OcrTemplateEditorComponent', () => { ...[CustomFieldsService, DocumentTypeService, CorrespondentService].map( (provide) => ({ provide, - useValue: { listAll: () => of({ results: [] }) }, + useValue: { + listAll: () => of({ results: [] }), + clearCache: jest.fn(), + }, }) ), - { provide: ToastService, useValue: {} }, + { provide: NgbModal, useValue: modal }, + { + provide: ToastService, + useValue: { showSuccess: jest.fn(), showError: jest.fn() }, + }, ], }) .overrideComponent(OcrTemplateEditorComponent, { @@ -216,6 +226,48 @@ describe('OcrTemplateEditorComponent', () => { expect(component.selectedZone).toBeNull() }) + it('creates a custom field in the dialog and assigns it to the zone', () => { + const succeeded = new Subject() + const dialog = { + dialogMode: { set: jest.fn() }, + object: null, + succeeded, + failed: new Subject(), + } + modal.open.mockReturnValue({ componentInstance: dialog }) + const created = { + id: 5, + name: 'Total', + data_type: CustomFieldDataType.Monetary, + } + TestBed.inject(CustomFieldsService).listAll = () => + of({ + count: 2, + all: [5, 6], + results: [ + created, + { id: 6, name: 'Status', data_type: CustomFieldDataType.Select }, + ], + }) + component.template.zones = [zone()] + component.selectZone(0) + + component.createField() + expect(dialog.object).toEqual({ name: 'Invoice number' }) + succeeded.next(created) + + expect(component.template.zones[0]).toMatchObject({ + target: 'custom_field', + custom_field: 5, + }) + expect(component.fieldItems().map((item) => item.id)).toEqual([ + 'title', + 'asn', + 'created', + 5, + ]) + }) + it('hides results when extraction settings or geometry change', () => { const selected = zone() component.template.zones = [selected] 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 cd40be1b0..aad10933b 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 @@ -13,6 +13,7 @@ import { import { FormsModule } from '@angular/forms' import { ActivatedRoute, RouterModule } from '@angular/router' import { + NgbModal, NgbNavModule, NgbPopoverModule, NgbTypeaheadModule, @@ -31,12 +32,14 @@ import { takeUntil, tap, } from 'rxjs' +import { EditDialogMode } from 'src/app/components/common/edit-dialog/edit-dialog.component' +import { CustomFieldEditDialogComponent } from 'src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component' import { NumberComponent } from 'src/app/components/common/input/number/number.component' 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, CustomFieldDataType } from 'src/app/data/custom-field' +import { CustomField } from 'src/app/data/custom-field' import { Document } from 'src/app/data/document' import { DocumentType } from 'src/app/data/document-type' import { @@ -47,6 +50,7 @@ import { isOcrBuiltinTarget, OCR_BUILTIN_TARGETS, OCR_LANGUAGE_OPTIONS, + OCR_SUPPORTED_FIELD_TYPES, OCR_ZONE_TARGET, OCR_ZONE_TRANSFORM, OcrBuiltinTarget, @@ -155,6 +159,7 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy { private readonly correspondentService = inject(CorrespondentService) private readonly documentService = inject(DocumentService) private readonly toastService = inject(ToastService) + private readonly modalService = inject(NgbModal) private readonly changeDetector = inject(ChangeDetectorRef) private readonly destroy$ = new Subject() private readonly cancelZoneTest$ = new Subject() @@ -192,10 +197,10 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy { } private _customFields: CustomField[] = [] - /** Items for the zone field select: built-in targets, then custom fields. */ - fieldItems: { id: ZoneFieldSelection; name: string }[] = [ + /** Items for the zone field select: built-in targets, then custom fields OCR can write to. */ + readonly fieldItems = signal<{ id: ZoneFieldSelection; name: string }[]>([ ...OCR_BUILTIN_TARGETS, - ] + ]) get customFields(): CustomField[] { return this._customFields @@ -203,10 +208,12 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy { set customFields(fields: CustomField[]) { this._customFields = fields - this.fieldItems = [ + this.fieldItems.set([ ...OCR_BUILTIN_TARGETS, - ...fields.map((cf) => ({ id: cf.id, name: cf.name })), - ] + ...fields + .filter((cf) => OCR_SUPPORTED_FIELD_TYPES.includes(cf.data_type)) + .map((cf) => ({ id: cf.id, name: cf.name })), + ]) } documentTypes: DocumentType[] = [] transformOptions = TRANSFORM_OPTIONS @@ -266,21 +273,6 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy { return this.isCurrentZoneTest() && this.zoneTest().result === null } - showQuickCreate = false - quickCreateName = '' - quickCreateType = CustomFieldDataType.String - quickCreateForZoneIndex: number | null = null - quickCreateTypes = [ - { id: CustomFieldDataType.String, name: $localize`String` }, - { id: CustomFieldDataType.Integer, name: $localize`Integer` }, - { id: CustomFieldDataType.Float, name: $localize`Float` }, - { id: CustomFieldDataType.Date, name: $localize`Date` }, - { id: CustomFieldDataType.Monetary, name: $localize`Monetary` }, - { id: CustomFieldDataType.Boolean, name: $localize`Boolean` }, - { id: CustomFieldDataType.Url, name: $localize`URL` }, - { id: CustomFieldDataType.LongText, name: $localize`Long Text` }, - ] - get selectedZone(): OcrTemplateZone | null { return this.selectedZoneIndex !== null ? (this.template.zones[this.selectedZoneIndex] ?? null) @@ -1092,50 +1084,30 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy { return dt ? dt.name : `Type #${id}` } - openQuickCreate(zoneIndex: number | null, name?: string) { - if (zoneIndex === null) return - this.quickCreateForZoneIndex = zoneIndex - this.quickCreateName = name || this.template.zones[zoneIndex]?.name || '' - this.quickCreateType = CustomFieldDataType.String - this.showQuickCreate = true - } - - cancelQuickCreate() { - this.showQuickCreate = false - this.quickCreateForZoneIndex = null - } - - submitQuickCreate() { - if (!this.quickCreateName.trim()) return - - this.templateService - .quickCreateField(this.quickCreateName.trim(), this.quickCreateType) + createField(name?: string) { + const zone = this.selectedZone + const modal = this.modalService.open(CustomFieldEditDialogComponent) + modal.componentInstance.dialogMode.set(EditDialogMode.CREATE) + modal.componentInstance.object = { name: name || zone?.name } + modal.componentInstance.succeeded .pipe(takeUntil(this.destroy$)) - .subscribe({ - next: (result) => { - this.customFieldsService.clearCache() - this.customFieldsService - .listAll() - .pipe(takeUntil(this.destroy$)) - .subscribe((r) => { - this.customFields = r.results - if (this.quickCreateForZoneIndex !== null) { - this.template.zones[this.quickCreateForZoneIndex].custom_field = - result.id - this.template.zones[this.quickCreateForZoneIndex].target = - OCR_ZONE_TARGET.CustomField - } - this.showQuickCreate = false - this.quickCreateForZoneIndex = null - this.changeDetector.markForCheck() - }) - }, - error: (err) => { - this.toastService.showError( - $localize`Failed to create custom field.`, - err - ) - }, + .subscribe((field: CustomField) => { + this.toastService.showSuccess($localize`Saved field "${field.name}".`) + this.customFieldsService.clearCache() + this.customFieldsService + .listAll() + .pipe(takeUntil(this.destroy$)) + .subscribe((r) => { + if (zone && OCR_SUPPORTED_FIELD_TYPES.includes(field.data_type)) { + this.setZoneField(zone, field.id) + } + this.customFields = r.results + }) + }) + modal.componentInstance.failed + .pipe(takeUntil(this.destroy$)) + .subscribe((e) => { + this.toastService.showError($localize`Error saving field.`, e) }) } diff --git a/src-ui/src/app/data/ocr-template.ts b/src-ui/src/app/data/ocr-template.ts index 1e0fdef70..6f2b9f1ab 100644 --- a/src-ui/src/app/data/ocr-template.ts +++ b/src-ui/src/app/data/ocr-template.ts @@ -1,3 +1,4 @@ +import { CustomFieldDataType } from './custom-field' import { ObjectWithId } from './object-with-id' export type OcrZoneTarget = 'custom_field' | 'title' | 'asn' | 'created' @@ -42,6 +43,18 @@ export function isOcrBuiltinTarget(value: unknown): value is OcrBuiltinTarget { ) } +/** Custom field types zone OCR can write to. See models.py OCR_SUPPORTED_FIELD_TYPES. */ +export const OCR_SUPPORTED_FIELD_TYPES: CustomFieldDataType[] = [ + CustomFieldDataType.String, + CustomFieldDataType.Url, + CustomFieldDataType.Date, + CustomFieldDataType.Integer, + CustomFieldDataType.Float, + CustomFieldDataType.Monetary, + CustomFieldDataType.LongText, + CustomFieldDataType.Boolean, +] + export const OCR_BUILTIN_TARGETS = [ { id: OCR_ZONE_TARGET.Title, name: $localize`Title` }, { id: OCR_ZONE_TARGET.Asn, name: $localize`Archive serial number` }, diff --git a/src-ui/src/app/services/rest/ocr-template.service.ts b/src-ui/src/app/services/rest/ocr-template.service.ts index 8f81145e6..05fce8e45 100644 --- a/src-ui/src/app/services/rest/ocr-template.service.ts +++ b/src-ui/src/app/services/rest/ocr-template.service.ts @@ -7,13 +7,6 @@ import { } from '../../data/ocr-template' import { AbstractPaperlessService } from './abstract-paperless-service' -export interface QuickCreateFieldResult { - id: number - name: string - data_type: string - created: boolean -} - @Injectable({ providedIn: 'root' }) export class OcrTemplateService extends AbstractPaperlessService { constructor() { @@ -34,14 +27,4 @@ export class OcrTemplateService extends AbstractPaperlessService { { document: docId, zone } ) } - - quickCreateField( - name: string, - dataType: string - ): Observable { - return this.http.post( - `${this.baseUrl}${this.resourceName}/quick-create-field/`, - { name, data_type: dataType } - ) - } } diff --git a/src/documents/models.py b/src/documents/models.py index 01eb6e6f4..6ec45c927 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -2381,7 +2381,7 @@ class OcrTemplateZone(models.Model): # Custom field data types that zone OCR can extract into. DOCUMENTLINK and # SELECT are excluded (they reference other objects, not free text). Single -# source of truth for the serializer, the quick-create endpoint and the engine. +# source of truth for the serializer and the engine. OCR_SUPPORTED_FIELD_TYPES = frozenset( { CustomField.FieldDataType.STRING, diff --git a/src/documents/tests/test_api_ocr_templates.py b/src/documents/tests/test_api_ocr_templates.py index da0e54377..d1dc30d8f 100644 --- a/src/documents/tests/test_api_ocr_templates.py +++ b/src/documents/tests/test_api_ocr_templates.py @@ -395,55 +395,3 @@ class TestOcrTemplatesAPI(DirectoriesMixin, APITestCase): resp.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN), ) - - # --- Quick create field --- - - def test_quick_create_field(self): - """Creating a custom field inline from the template editor.""" - resp = self.client.post( - f"{self.ENDPOINT}quick-create-field/", - data=json.dumps({"name": "New Field", "data_type": "string"}), - content_type="application/json", - ) - self.assertEqual(resp.status_code, status.HTTP_201_CREATED) - data = resp.json() - self.assertEqual(data["name"], "New Field") - self.assertEqual(data["data_type"], "string") - self.assertTrue(data["created"]) - self.assertTrue(CustomField.objects.filter(name="New Field").exists()) - - def test_quick_create_field_existing(self): - """If a field with the same name exists, return it without creating.""" - resp = self.client.post( - f"{self.ENDPOINT}quick-create-field/", - data=json.dumps({"name": "Invoice Number", "data_type": "string"}), - content_type="application/json", - ) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - data = resp.json() - self.assertEqual(data["id"], self.custom_field_text.pk) - self.assertFalse(data["created"]) - - def test_quick_create_field_empty_name_rejected(self): - resp = self.client.post( - f"{self.ENDPOINT}quick-create-field/", - data=json.dumps({"name": "", "data_type": "string"}), - content_type="application/json", - ) - self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) - - def test_quick_create_field_unsupported_type_rejected(self): - resp = self.client.post( - f"{self.ENDPOINT}quick-create-field/", - data=json.dumps({"name": "Bad Field", "data_type": "documentlink"}), - content_type="application/json", - ) - self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) - - def test_quick_create_field_select_type_rejected(self): - resp = self.client.post( - f"{self.ENDPOINT}quick-create-field/", - data=json.dumps({"name": "Bad Field", "data_type": "select"}), - content_type="application/json", - ) - self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/src/documents/views.py b/src/documents/views.py index f37ef17af..dde0032ac 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -151,7 +151,6 @@ from documents.matching import match_correspondents from documents.matching import match_document_types from documents.matching import match_storage_paths from documents.matching import match_tags -from documents.models import OCR_SUPPORTED_FIELD_TYPES from documents.models import Correspondent from documents.models import CustomField from documents.models import CustomFieldInstance @@ -5953,58 +5952,3 @@ class OcrTemplateViewSet(ModelViewSet): "regex_match": regex_match, }, ) - - @action(detail=False, methods=["post"], url_path="quick-create-field") - def quick_create_field(self, request): - """Create a custom field inline from the template editor. - - Accepts: {"name": "Invoice Number", "data_type": "string"} - Returns the created field so the frontend can immediately use it. - """ - name = request.data.get("name", "").strip() - data_type = request.data.get("data_type", "").strip() - - if not name: - return Response( - {"error": "Field name is required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - if data_type not in OCR_SUPPORTED_FIELD_TYPES: - return Response( - { - "error": f"Unsupported data type '{data_type}'. " - f"Supported: {', '.join(sorted(OCR_SUPPORTED_FIELD_TYPES))}", - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Check if field already exists - existing = CustomField.objects.filter(name=name).first() - if existing: - return Response( - { - "id": existing.pk, - "name": existing.name, - "data_type": existing.data_type, - "created": False, - }, - ) - - # Check user has permission to create custom fields - if not request.user.has_perm("documents.add_customfield"): - return Response( - {"error": "You don't have permission to create custom fields"}, - status=status.HTTP_403_FORBIDDEN, - ) - - field = CustomField.objects.create(name=name, data_type=data_type) - return Response( - { - "id": field.pk, - "name": field.name, - "data_type": field.data_type, - "created": True, - }, - status=status.HTTP_201_CREATED, - )