From b0b0183236cf14535a146a4b50be79a9cd62564f Mon Sep 17 00:00:00 2001 From: zichun Date: Fri, 10 Apr 2026 11:41:54 +0800 Subject: [PATCH] feat(web): P3.1 dynamic custom field renderer (DynamicExtFields) --- web/src/components/DynamicExtFields.tsx | 165 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/pages/CreateItemPage.tsx | 11 ++++++++++- web/src/pages/CreatePartnerPage.tsx | 11 ++++++++++- 3 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 web/src/components/DynamicExtFields.tsx diff --git a/web/src/components/DynamicExtFields.tsx b/web/src/components/DynamicExtFields.tsx new file mode 100644 index 0000000..f8d19db --- /dev/null +++ b/web/src/components/DynamicExtFields.tsx @@ -0,0 +1,165 @@ +// Dynamic form fields for Tier 1 custom fields (the JSONB `ext` column). +// +// Fetches the custom field declarations for an entity from +// /api/v1/_meta/metadata/custom-fields/{entityName} and renders +// one input per declared field. The SPA doesn't need to know the +// entity's custom fields at compile time — a business analyst +// declares a field in YAML metadata, the framework validates it +// on every save, and this component renders it automatically. +// +// This is P3.1 — the runtime form renderer for Tier 1 customization. + +import { useEffect, useState } from 'react' +import { useLocale } from '@/i18n/LocaleContext' + +interface CustomFieldDecl { + key: string + targetEntity: string + type: { kind: string; maxLength?: number; precision?: number; scale?: number; allowedValues?: string[] } + required: boolean + labelTranslations: Record +} + +interface Props { + entityName: string + values: Record + onChange: (key: string, value: unknown) => void +} + +export function DynamicExtFields({ entityName, values, onChange }: Props) { + const [fields, setFields] = useState([]) + const { locale } = useLocale() + + useEffect(() => { + fetch(`/api/v1/_meta/metadata/custom-fields/${entityName}`) + .then((r) => r.json()) + .then((data: CustomFieldDecl[]) => setFields(data)) + .catch(() => setFields([])) + }, [entityName]) + + if (fields.length === 0) return null + + const label = (f: CustomFieldDecl): string => { + const langKey = locale.replace('-', '_').toLowerCase() + return ( + f.labelTranslations[locale] ?? + f.labelTranslations[langKey] ?? + f.labelTranslations['en'] ?? + f.labelTranslations['en-US'] ?? + f.key + ) + } + + return ( +
+
+ Custom fields +
+
+ {fields.map((f) => { + const val = values[f.key] + return ( +
+ + {renderInput(f, val, (v) => onChange(f.key, v))} +
+ ) + })} +
+
+ ) +} + +function renderInput( + f: CustomFieldDecl, + value: unknown, + onChange: (v: unknown) => void, +) { + const cls = 'mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm' + + switch (f.type.kind) { + case 'boolean': + return ( + onChange(e.target.checked)} + className="mt-2 rounded border-slate-300" + /> + ) + case 'integer': + return ( + onChange(e.target.value ? Number(e.target.value) : null)} + required={f.required} + className={cls} + /> + ) + case 'decimal': + case 'money': + case 'quantity': + return ( + onChange(e.target.value ? Number(e.target.value) : null)} + required={f.required} + className={cls} + /> + ) + case 'date': + return ( + onChange(e.target.value || null)} + required={f.required} + className={cls} + /> + ) + case 'dateTime': + return ( + onChange(e.target.value || null)} + required={f.required} + className={cls} + /> + ) + case 'enum': + return ( + + ) + case 'string': + case 'uuid': + default: + return ( + onChange(e.target.value || null)} + required={f.required} + className={cls} + /> + ) + } +} diff --git a/web/src/pages/CreateItemPage.tsx b/web/src/pages/CreateItemPage.tsx index 6016f99..e1fbd4f 100644 --- a/web/src/pages/CreateItemPage.tsx +++ b/web/src/pages/CreateItemPage.tsx @@ -4,6 +4,7 @@ import { catalog } from '@/api/client' import type { Uom } from '@/types/api' import { PageHeader } from '@/components/PageHeader' import { ErrorBox } from '@/components/ErrorBox' +import { DynamicExtFields } from '@/components/DynamicExtFields' const ITEM_TYPES = ['GOOD', 'SERVICE', 'DIGITAL'] as const @@ -15,6 +16,7 @@ export function CreateItemPage() { const [itemType, setItemType] = useState('GOOD') const [baseUomCode, setBaseUomCode] = useState('') const [uoms, setUoms] = useState([]) + const [ext, setExt] = useState>({}) const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) @@ -30,10 +32,12 @@ export function CreateItemPage() { setError(null) setSubmitting(true) try { + const extPayload = Object.keys(ext).length > 0 ? ext : undefined await catalog.createItem({ code, name, itemType, baseUomCode, description: description || null, - }) + ...(extPayload ? { ext: extPayload } : {}), + } as Parameters[0]) navigate('/items') } catch (err: unknown) { setError(err instanceof Error ? err : new Error(String(err))) @@ -81,6 +85,11 @@ export function CreateItemPage() { setDescription(e.target.value)} className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> + setExt((prev) => ({ ...prev, [k]: v }))} + /> {error && }