// 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} /> ) } }