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