Commit b0b0183236cf14535a146a4b50be79a9cd62564f

Authored by zichun
1 parent d8856b6a

feat(web): P3.1 dynamic custom field renderer (DynamicExtFields)

Tier 1 customization comes alive in the SPA: custom fields
declared in YAML metadata now render automatically in create
forms without any compile-time knowledge of the field.

New component: DynamicExtFields
  - Fetches custom field declarations from the existing
    /api/v1/_meta/metadata/custom-fields/{entityName} endpoint
  - Renders one input per declared field, type-matched:
    string → text, integer → number (step=1), decimal/money/
    quantity → number (step=0.01), boolean → checkbox,
    date → date picker, dateTime → datetime-local,
    enum → select dropdown, uuid → text
  - Labels resolve from labelTranslations using the active
    locale (i18n integration)
  - Required fields show a red asterisk
  - Values are collected in the ext map and sent with the
    create request

Wired into: CreateItemPage (entityName="Item"),
CreatePartnerPage (entityName="Partner"). Both now show a
"Custom fields" section below the static fields when the
entity has custom field declarations in metadata.

No new backend code — the existing /api/v1/_meta/metadata/
custom-fields endpoint already returns exactly the shape
the component needs. This is P3.1: the runtime form renderer
for Tier 1 customization.
web/src/components/DynamicExtFields.tsx 0 → 100644
  1 +// Dynamic form fields for Tier 1 custom fields (the JSONB `ext` column).
  2 +//
  3 +// Fetches the custom field declarations for an entity from
  4 +// /api/v1/_meta/metadata/custom-fields/{entityName} and renders
  5 +// one input per declared field. The SPA doesn't need to know the
  6 +// entity's custom fields at compile time — a business analyst
  7 +// declares a field in YAML metadata, the framework validates it
  8 +// on every save, and this component renders it automatically.
  9 +//
  10 +// This is P3.1 — the runtime form renderer for Tier 1 customization.
  11 +
  12 +import { useEffect, useState } from 'react'
  13 +import { useLocale } from '@/i18n/LocaleContext'
  14 +
  15 +interface CustomFieldDecl {
  16 + key: string
  17 + targetEntity: string
  18 + type: { kind: string; maxLength?: number; precision?: number; scale?: number; allowedValues?: string[] }
  19 + required: boolean
  20 + labelTranslations: Record<string, string>
  21 +}
  22 +
  23 +interface Props {
  24 + entityName: string
  25 + values: Record<string, unknown>
  26 + onChange: (key: string, value: unknown) => void
  27 +}
  28 +
  29 +export function DynamicExtFields({ entityName, values, onChange }: Props) {
  30 + const [fields, setFields] = useState<CustomFieldDecl[]>([])
  31 + const { locale } = useLocale()
  32 +
  33 + useEffect(() => {
  34 + fetch(`/api/v1/_meta/metadata/custom-fields/${entityName}`)
  35 + .then((r) => r.json())
  36 + .then((data: CustomFieldDecl[]) => setFields(data))
  37 + .catch(() => setFields([]))
  38 + }, [entityName])
  39 +
  40 + if (fields.length === 0) return null
  41 +
  42 + const label = (f: CustomFieldDecl): string => {
  43 + const langKey = locale.replace('-', '_').toLowerCase()
  44 + return (
  45 + f.labelTranslations[locale] ??
  46 + f.labelTranslations[langKey] ??
  47 + f.labelTranslations['en'] ??
  48 + f.labelTranslations['en-US'] ??
  49 + f.key
  50 + )
  51 + }
  52 +
  53 + return (
  54 + <div className="border-t border-slate-200 pt-4 mt-4">
  55 + <div className="text-xs font-semibold uppercase tracking-wide text-slate-400 mb-3">
  56 + Custom fields
  57 + </div>
  58 + <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
  59 + {fields.map((f) => {
  60 + const val = values[f.key]
  61 + return (
  62 + <div key={f.key}>
  63 + <label className="block text-sm font-medium text-slate-700">
  64 + {label(f)}
  65 + {f.required && <span className="text-rose-500 ml-0.5">*</span>}
  66 + </label>
  67 + {renderInput(f, val, (v) => onChange(f.key, v))}
  68 + </div>
  69 + )
  70 + })}
  71 + </div>
  72 + </div>
  73 + )
  74 +}
  75 +
  76 +function renderInput(
  77 + f: CustomFieldDecl,
  78 + value: unknown,
  79 + onChange: (v: unknown) => void,
  80 +) {
  81 + const cls = 'mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm'
  82 +
  83 + switch (f.type.kind) {
  84 + case 'boolean':
  85 + return (
  86 + <input
  87 + type="checkbox"
  88 + checked={Boolean(value)}
  89 + onChange={(e) => onChange(e.target.checked)}
  90 + className="mt-2 rounded border-slate-300"
  91 + />
  92 + )
  93 + case 'integer':
  94 + return (
  95 + <input
  96 + type="number"
  97 + step="1"
  98 + value={value != null ? String(value) : ''}
  99 + onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
  100 + required={f.required}
  101 + className={cls}
  102 + />
  103 + )
  104 + case 'decimal':
  105 + case 'money':
  106 + case 'quantity':
  107 + return (
  108 + <input
  109 + type="number"
  110 + step="0.01"
  111 + value={value != null ? String(value) : ''}
  112 + onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
  113 + required={f.required}
  114 + className={cls}
  115 + />
  116 + )
  117 + case 'date':
  118 + return (
  119 + <input
  120 + type="date"
  121 + value={value != null ? String(value) : ''}
  122 + onChange={(e) => onChange(e.target.value || null)}
  123 + required={f.required}
  124 + className={cls}
  125 + />
  126 + )
  127 + case 'dateTime':
  128 + return (
  129 + <input
  130 + type="datetime-local"
  131 + value={value != null ? String(value) : ''}
  132 + onChange={(e) => onChange(e.target.value || null)}
  133 + required={f.required}
  134 + className={cls}
  135 + />
  136 + )
  137 + case 'enum':
  138 + return (
  139 + <select
  140 + value={value != null ? String(value) : ''}
  141 + onChange={(e) => onChange(e.target.value || null)}
  142 + required={f.required}
  143 + className={cls}
  144 + >
  145 + <option value="">—</option>
  146 + {f.type.allowedValues?.map((v) => (
  147 + <option key={v} value={v}>{v}</option>
  148 + ))}
  149 + </select>
  150 + )
  151 + case 'string':
  152 + case 'uuid':
  153 + default:
  154 + return (
  155 + <input
  156 + type="text"
  157 + maxLength={f.type.maxLength}
  158 + value={value != null ? String(value) : ''}
  159 + onChange={(e) => onChange(e.target.value || null)}
  160 + required={f.required}
  161 + className={cls}
  162 + />
  163 + )
  164 + }
  165 +}
... ...
web/src/pages/CreateItemPage.tsx
... ... @@ -4,6 +4,7 @@ import { catalog } from &#39;@/api/client&#39;
4 4 import type { Uom } from '@/types/api'
5 5 import { PageHeader } from '@/components/PageHeader'
6 6 import { ErrorBox } from '@/components/ErrorBox'
  7 +import { DynamicExtFields } from '@/components/DynamicExtFields'
7 8  
8 9 const ITEM_TYPES = ['GOOD', 'SERVICE', 'DIGITAL'] as const
9 10  
... ... @@ -15,6 +16,7 @@ export function CreateItemPage() {
15 16 const [itemType, setItemType] = useState<string>('GOOD')
16 17 const [baseUomCode, setBaseUomCode] = useState('')
17 18 const [uoms, setUoms] = useState<Uom[]>([])
  19 + const [ext, setExt] = useState<Record<string, unknown>>({})
18 20 const [submitting, setSubmitting] = useState(false)
19 21 const [error, setError] = useState<Error | null>(null)
20 22  
... ... @@ -30,10 +32,12 @@ export function CreateItemPage() {
30 32 setError(null)
31 33 setSubmitting(true)
32 34 try {
  35 + const extPayload = Object.keys(ext).length > 0 ? ext : undefined
33 36 await catalog.createItem({
34 37 code, name, itemType, baseUomCode,
35 38 description: description || null,
36   - })
  39 + ...(extPayload ? { ext: extPayload } : {}),
  40 + } as Parameters<typeof catalog.createItem>[0])
37 41 navigate('/items')
38 42 } catch (err: unknown) {
39 43 setError(err instanceof Error ? err : new Error(String(err)))
... ... @@ -81,6 +85,11 @@ export function CreateItemPage() {
81 85 <input type="text" value={description} onChange={(e) => setDescription(e.target.value)}
82 86 className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" />
83 87 </div>
  88 + <DynamicExtFields
  89 + entityName="Item"
  90 + values={ext}
  91 + onChange={(k, v) => setExt((prev) => ({ ...prev, [k]: v }))}
  92 + />
84 93 {error && <ErrorBox error={error} />}
85 94 <button type="submit" className="btn-primary" disabled={submitting}>
86 95 {submitting ? 'Creating...' : 'Create Item'}
... ...
web/src/pages/CreatePartnerPage.tsx
... ... @@ -3,6 +3,7 @@ import { useNavigate } from &#39;react-router-dom&#39;
3 3 import { partners } from '@/api/client'
4 4 import { PageHeader } from '@/components/PageHeader'
5 5 import { ErrorBox } from '@/components/ErrorBox'
  6 +import { DynamicExtFields } from '@/components/DynamicExtFields'
6 7  
7 8 const PARTNER_TYPES = ['CUSTOMER', 'SUPPLIER', 'BOTH'] as const
8 9  
... ... @@ -13,6 +14,7 @@ export function CreatePartnerPage() {
13 14 const [type, setType] = useState<string>('CUSTOMER')
14 15 const [email, setEmail] = useState('')
15 16 const [phone, setPhone] = useState('')
  17 + const [ext, setExt] = useState<Record<string, unknown>>({})
16 18 const [submitting, setSubmitting] = useState(false)
17 19 const [error, setError] = useState<Error | null>(null)
18 20  
... ... @@ -21,11 +23,13 @@ export function CreatePartnerPage() {
21 23 setError(null)
22 24 setSubmitting(true)
23 25 try {
  26 + const extPayload = Object.keys(ext).length > 0 ? ext : undefined
24 27 await partners.create({
25 28 code, name, type,
26 29 email: email || null,
27 30 phone: phone || null,
28   - })
  31 + ...(extPayload ? { ext: extPayload } : {}),
  32 + } as Parameters<typeof partners.create>[0])
29 33 navigate('/partners')
30 34 } catch (err: unknown) {
31 35 setError(err instanceof Error ? err : new Error(String(err)))
... ... @@ -71,6 +75,11 @@ export function CreatePartnerPage() {
71 75 className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" />
72 76 </div>
73 77 </div>
  78 + <DynamicExtFields
  79 + entityName="Partner"
  80 + values={ext}
  81 + onChange={(k, v) => setExt((prev) => ({ ...prev, [k]: v }))}
  82 + />
74 83 {error && <ErrorBox error={error} />}
75 84 <button type="submit" className="btn-primary" disabled={submitting}>
76 85 {submitting ? 'Creating...' : 'Create Partner'}
... ...