Remove the slop quick create CF stuff

This commit is contained in:
shamoon committed 2026-10-19 22:36:17 -07:00
1 parent 1d75c0c99b
commit d2d434535b
8 files changed
+107 -225

No files matched your search

@@ -77,46 +77,16 @@
<pngx-input-text [(ngModel)]="zone.name" title="Name" i18n-title></pngx-input-text>
<pngx-input-select
[items]="fieldItems"
[items]="fieldItems()"
[ngModel]="zoneFieldValue(zone)"
(ngModelChange)="setZoneField(zone, $event)"
(createNew)="openQuickCreate(selectedZoneIndex, $event)"
(createNew)="createField($event)"
title="Field"
i18n-title
hint="Where the extracted value is written."
i18n-hint
></pngx-input-select>
@if (showQuickCreate) {
<div class="card mb-3 border-primary">
<div class="card-body">
<h6 class="card-title" i18n>Create Custom Field</h6>
<div class="mb-2">
<label class="form-label small" i18n>Field Name</label>
<input type="text" class="form-control form-control-sm"
[(ngModel)]="quickCreateName" placeholder="e.g. Invoice Number" />
</div>
<div class="mb-2">
<label class="form-label small" i18n>Field Type</label>
<select class="form-select form-select-sm" [(ngModel)]="quickCreateType">
@for (t of quickCreateTypes; track t.id) {
<option [ngValue]="t.id">{{ t.name }}</option>
}
</select>
</div>
<div class="d-flex gap-2">
<button class="btn btn-primary btn-sm" (click)="submitQuickCreate()"
[disabled]="!quickCreateName.trim()" i18n>
Create & Assign
</button>
<button class="btn btn-outline-secondary btn-sm" (click)="cancelQuickCreate()" i18n>
Cancel
</button>
</div>
</div>
</div>
}
@if (isFieldShared(zone)) {
<div class="card mb-3 border-info">
<div class="card-body">
@@ -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<CustomField>()
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]
@@ -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<void>()
private readonly cancelZoneTest$ = new Subject<void>()
@@ -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)
})
}
+13
View File
@@ -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` },
@@ -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<OcrTemplate> {
constructor() {
@@ -34,14 +27,4 @@ export class OcrTemplateService extends AbstractPaperlessService<OcrTemplate> {
{ document: docId, zone }
)
}
quickCreateField(
name: string,
dataType: string
): Observable<QuickCreateFieldResult> {
return this.http.post<QuickCreateFieldResult>(
`${this.baseUrl}${this.resourceName}/quick-create-field/`,
{ name, data_type: dataType }
)
}
}
+1 -1
View File
@@ -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,
@@ -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)
-56
View File
@@ -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,
)