DynamicExtFields.tsx 4.71 KB
// 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<string, string>
}

interface Props {
  entityName: string
  values: Record<string, unknown>
  onChange: (key: string, value: unknown) => void
}

export function DynamicExtFields({ entityName, values, onChange }: Props) {
  const [fields, setFields] = useState<CustomFieldDecl[]>([])
  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 (
    <div className="border-t border-slate-200 pt-4 mt-4">
      <div className="text-xs font-semibold uppercase tracking-wide text-slate-400 mb-3">
        Custom fields
      </div>
      <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
        {fields.map((f) => {
          const val = values[f.key]
          return (
            <div key={f.key}>
              <label className="block text-sm font-medium text-slate-700">
                {label(f)}
                {f.required && <span className="text-rose-500 ml-0.5">*</span>}
              </label>
              {renderInput(f, val, (v) => onChange(f.key, v))}
            </div>
          )
        })}
      </div>
    </div>
  )
}

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 (
        <input
          type="checkbox"
          checked={Boolean(value)}
          onChange={(e) => onChange(e.target.checked)}
          className="mt-2 rounded border-slate-300"
        />
      )
    case 'integer':
      return (
        <input
          type="number"
          step="1"
          value={value != null ? String(value) : ''}
          onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
          required={f.required}
          className={cls}
        />
      )
    case 'decimal':
    case 'money':
    case 'quantity':
      return (
        <input
          type="number"
          step="0.01"
          value={value != null ? String(value) : ''}
          onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
          required={f.required}
          className={cls}
        />
      )
    case 'date':
      return (
        <input
          type="date"
          value={value != null ? String(value) : ''}
          onChange={(e) => onChange(e.target.value || null)}
          required={f.required}
          className={cls}
        />
      )
    case 'dateTime':
      return (
        <input
          type="datetime-local"
          value={value != null ? String(value) : ''}
          onChange={(e) => onChange(e.target.value || null)}
          required={f.required}
          className={cls}
        />
      )
    case 'enum':
      return (
        <select
          value={value != null ? String(value) : ''}
          onChange={(e) => onChange(e.target.value || null)}
          required={f.required}
          className={cls}
        >
          <option value="">—</option>
          {f.type.allowedValues?.map((v) => (
            <option key={v} value={v}>{v}</option>
          ))}
        </select>
      )
    case 'string':
    case 'uuid':
    default:
      return (
        <input
          type="text"
          maxLength={f.type.maxLength}
          value={value != null ? String(value) : ''}
          onChange={(e) => onChange(e.target.value || null)}
          required={f.required}
          className={cls}
        />
      )
  }
}