// Form designer page. // // Two-panel layout with a property editor on the left and a live // @rjsf preview on the right. Converts a flat DesignerField[] model // into JSON Schema + UI Schema on every keystroke so the preview // always reflects the current state. import { useCallback, useEffect, useMemo, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import Form from '@rjsf/core' import type { RJSFSchema, UiSchema } from '@rjsf/utils' import validator from '@rjsf/validator-ajv8' import { metadata } from '@/api/client' import type { FormDefinition, FormPurpose } from '@/types/api' import { PageHeader } from '@/components/PageHeader' import { ErrorBox } from '@/components/ErrorBox' import { Loading } from '@/components/Loading' import { vibeWidgets } from '@/components/form-widgets' import { vibeTemplates } from '@/components/form-widgets/vibeErpTheme' // ── Types ────────────────────────────────────────────────────────── interface DesignerField { id: string key: string label: string type: 'string' | 'integer' | 'number' | 'boolean' required: boolean width: 1 | 2 | 3 placeholder?: string helpText?: string widgetOverride?: string visibleWhen?: { field: string; equals: string } isSectionDivider?: boolean sectionTitle?: string } const FIELD_TYPES = ['string', 'integer', 'number', 'boolean'] as const const WIDTH_OPTIONS = [1, 2, 3] as const const PURPOSES: FormPurpose[] = ['create', 'edit', 'user-task', 'view'] const WIDGET_OPTIONS = [ '', 'partner-picker', 'item-picker', 'uom-selector', 'location-picker', 'money-input', 'quantity-input', 'textarea', ] as const // ── Helpers ──────────────────────────────────────────────────────── function uid(): string { return crypto.randomUUID() } function slugify(text: string): string { return text .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, '') } function blankField(): DesignerField { return { id: uid(), key: '', label: '', type: 'string', required: false, width: 3, } } function blankSection(): DesignerField { return { id: uid(), key: '', label: '', type: 'string', required: false, width: 3, isSectionDivider: true, sectionTitle: '', } } // ── Schema builders ──────────────────────────────────────────────── function buildJsonSchema(fields: DesignerField[]): RJSFSchema { const properties: Record = {} const required: string[] = [] for (const f of fields) { if (f.isSectionDivider) continue if (!f.key) continue properties[f.key] = { type: f.type, title: f.label || f.key, ...(f.helpText ? { description: f.helpText } : {}), } if (f.required) required.push(f.key) } return { type: 'object', properties, ...(required.length > 0 ? { required } : {}), } as RJSFSchema } function buildUiSchema(fields: DesignerField[]): UiSchema { const schema: Record = { 'ui:order': fields.filter((f) => !f.isSectionDivider && f.key).map((f) => f.key), 'ui:submitButtonOptions': { norender: true }, } for (const f of fields) { if (f.isSectionDivider || !f.key) continue const fieldUi: Record = {} if (f.placeholder) fieldUi['ui:placeholder'] = f.placeholder if (f.widgetOverride) fieldUi['ui:widget'] = f.widgetOverride if (f.visibleWhen?.field && f.visibleWhen?.equals) { fieldUi['ui:visible'] = f.visibleWhen } if (Object.keys(fieldUi).length > 0) schema[f.key] = fieldUi } return schema as UiSchema } /** Reverse a persisted FormDefinition back into DesignerField[]. */ function hydrateFields(def: FormDefinition): DesignerField[] { const jsonSchema = def.jsonSchema as { properties?: Record required?: string[] } const uiSchema = def.uiSchema as Record const order = (uiSchema['ui:order'] as string[] | undefined) ?? Object.keys(jsonSchema.properties ?? {}) const requiredSet = new Set(jsonSchema.required ?? []) return order.map((key) => { const prop = jsonSchema.properties?.[key] const fieldUi = (uiSchema[key] ?? {}) as Record return { id: uid(), key, label: prop?.title ?? key, type: (prop?.type ?? 'string') as DesignerField['type'], required: requiredSet.has(key), width: 3 as const, placeholder: (fieldUi['ui:placeholder'] as string) ?? undefined, helpText: prop?.description ?? undefined, widgetOverride: (fieldUi['ui:widget'] as string) ?? undefined, visibleWhen: fieldUi['ui:visible'] ? (fieldUi['ui:visible'] as { field: string; equals: string }) : undefined, } }) } // ── Component ────────────────────────────────────────────────────── export function FormDesignerPage() { const { slug: routeSlug } = useParams<{ slug: string }>() const navigate = useNavigate() const isEdit = Boolean(routeSlug) // ── Top bar state ── const [title, setTitle] = useState('') const [entityName, setEntityName] = useState('') const [purpose, setPurpose] = useState('create') const [slug, setSlug] = useState('') const [slugTouched, setSlugTouched] = useState(false) const [existingVersion, setExistingVersion] = useState(0) // ── Field list state ── const [fields, setFields] = useState([blankField()]) const [expandedId, setExpandedId] = useState(null) // ── Loading / error state ── const [loading, setLoading] = useState(isEdit) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) // ── Preview form data ── const [previewData, setPreviewData] = useState>({}) // ── Load existing form in edit mode ── useEffect(() => { if (!routeSlug) return setLoading(true) metadata .getForm(routeSlug) .then((def) => { setTitle(def.title) setEntityName(def.entityName) setPurpose(def.purpose) setSlug(def.slug) setSlugTouched(true) setExistingVersion(def.version) const hydrated = hydrateFields(def) setFields(hydrated.length > 0 ? hydrated : [blankField()]) }) .catch((err: unknown) => setError(err instanceof Error ? err : new Error(String(err))), ) .finally(() => setLoading(false)) }, [routeSlug]) // ── Auto-slug from title ── useEffect(() => { if (!slugTouched && title) { setSlug(slugify(title)) } }, [title, slugTouched]) // ── Field mutations ── const updateField = useCallback( (id: string, patch: Partial) => { setFields((prev) => prev.map((f) => (f.id === id ? { ...f, ...patch } : f)), ) }, [], ) const removeField = useCallback((id: string) => { setFields((prev) => { const next = prev.filter((f) => f.id !== id) return next.length > 0 ? next : [blankField()] }) }, []) const moveField = useCallback((id: string, direction: 'up' | 'down') => { setFields((prev) => { const idx = prev.findIndex((f) => f.id === id) if (idx < 0) return prev const target = direction === 'up' ? idx - 1 : idx + 1 if (target < 0 || target >= prev.length) return prev const next = [...prev] ;[next[idx], next[target]] = [next[target], next[idx]] return next }) }, []) const addField = useCallback(() => { const f = blankField() setFields((prev) => [...prev, f]) setExpandedId(f.id) }, []) const addSection = useCallback(() => { const s = blankSection() setFields((prev) => [...prev, s]) setExpandedId(s.id) }, []) // ── Computed schemas ── const jsonSchema = useMemo(() => buildJsonSchema(fields), [fields]) const uiSchema = useMemo(() => buildUiSchema(fields), [fields]) // ── Save ── const handleSave = async () => { if (!slug) { setError(new Error('Slug is required.')) return } if (!entityName) { setError(new Error('Entity name is required.')) return } setSaving(true) setError(null) try { await metadata.saveForm(slug, { slug, entityName, title: title || slug, purpose, jsonSchema: jsonSchema as Record, uiSchema: uiSchema as Record, version: existingVersion + 1, }) navigate('/admin/metadata') } catch (err: unknown) { setError(err instanceof Error ? err : new Error(String(err))) } finally { setSaving(false) } } // ── Collect non-divider field keys for the visibility dropdown ── const fieldKeys = useMemo( () => fields.filter((f) => !f.isSectionDivider && f.key).map((f) => f.key), [fields], ) if (loading) return return (
{/* ── Top bar ── */}
setTitle(e.target.value)} placeholder="My Form" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" />
setEntityName(e.target.value)} placeholder="SalesOrder" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" />
{ setSlugTouched(true) setSlug(e.target.value) }} placeholder="auto-generated" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" />
{error && (
)} {/* ── Two-panel layout ── */}
{/* ── Left panel: field list (3/5 = 60%) ── */}

Fields

{fields.map((field, idx) => ( setExpandedId(expandedId === field.id ? null : field.id) } onUpdate={(patch) => updateField(field.id, patch)} onRemove={() => removeField(field.id)} onMove={(dir) => moveField(field.id, dir)} /> ))}
{/* ── Right panel: live preview (2/5 = 40%) ── */}

Live Preview

{Object.keys(jsonSchema.properties ?? {}).length === 0 ? (

Add at least one field with a key to see the preview.

) : (
setPreviewData( (e.formData as Record) ?? {}, ) } formContext={{ formData: previewData }} /> )}
) } // ── FieldRow sub-component ───────────────────────────────────────── interface FieldRowProps { field: DesignerField index: number total: number isExpanded: boolean fieldKeys: string[] onToggleExpand: () => void onUpdate: (patch: Partial) => void onRemove: () => void onMove: (direction: 'up' | 'down') => void } function FieldRow({ field, index, total, isExpanded, fieldKeys, onToggleExpand, onUpdate, onRemove, onMove, }: FieldRowProps) { if (field.isSectionDivider) { return (
Section onUpdate({ sectionTitle: e.target.value })} placeholder="Section title" className="flex-1 rounded-md border border-slate-300 px-2 py-1 text-sm" />
) } return (
{/* ── Compact row ── */}
onUpdate({ key: e.target.value.replace(/\s+/g, '_') })} placeholder="field_key" className="w-28 rounded-md border border-slate-300 px-2 py-1 text-sm font-mono" /> onUpdate({ label: e.target.value })} placeholder="Label" className="flex-1 rounded-md border border-slate-300 px-2 py-1 text-sm" />
{/* ── Expanded detail panel ── */} {isExpanded && (
onUpdate({ label: e.target.value })} className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" />
onUpdate({ placeholder: e.target.value || undefined }) } className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" />
onUpdate({ helpText: e.target.value || undefined }) } className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" />
Show when equals onUpdate({ visibleWhen: field.visibleWhen?.field ? { field: field.visibleWhen.field, equals: e.target.value } : undefined, }) } className="flex-1 rounded-md border border-slate-300 px-2 py-1 text-sm" placeholder="value" disabled={!field.visibleWhen?.field} />
)}
) } // ── Reorder buttons ──────────────────────────────────────────────── function ReorderButtons({ index, total, onMove, }: { index: number total: number onMove: (direction: 'up' | 'down') => void }) { return (
) }