// Minimal standalone DynamicForm. Factored from the dynamic-record-dialog // pattern + ActionFieldDef renderer so callers can reuse the form layout // outside the full record-edit modal. import { useEffect, useMemo, useState } from 'react' import { groupFieldsBySection, type FormLayout } from './form-layout' import { FieldSection, WizardProgress } from './form-layout-ui' import { AssistInterview } from './assist-interview' import { Input, Textarea, Label, Switch, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@asteby/metacore-ui/primitives' import type { ActionFieldDef } from './types' import { buildZodSchema, resolveWidget, isLineItemsField, evaluateBalance, applyOptionWhen, getDependsOn, getVisibleWhen, evaluateVisibleWhen, } from './dynamic-form-schema' import { ScanLine } from 'lucide-react' import { BarcodeScanner } from './barcode-scanner' import { useOptionsResolver, type ResolvedOption } from './use-options-resolver' import { DynamicLineItems } from './dynamic-line-items' import { DynamicSelectField } from './dynamic-select-field' import { DynamicMultiSelectField } from './dynamic-multi-select-field' import { DynamicDateField } from './dynamic-date-field' import { UploadField } from './upload-field' import { IconPickerField } from './icon-picker-field' import { ColorPickerField } from './color-picker-field' export { buildZodSchema, resolveWidget } export { DynamicLineItems } from './dynamic-line-items' export { DynamicSelectField } from './dynamic-select-field' export { DynamicMultiSelectField } from './dynamic-multi-select-field' export { DynamicDateField } from './dynamic-date-field' export { UploadField } from './upload-field' export { IconPickerField } from './icon-picker-field' export { ColorPickerField, DEFAULT_ROLE_COLOR, normalizeHex } from './color-picker-field' export interface DynamicFormProps { fields: ActionFieldDef[] initialValues?: Record onSubmit: (values: Record) => void | Promise onCancel?: () => void submitLabel?: string cancelLabel?: string disabled?: boolean /** * Declarative form layout served on the model metadata (kernel PR #230). * When present the visible fields are grouped by their `section` respecting * `sections` order: `mode:"sections"` stacks (optionally collapsible) * sections; `mode:"steps"` renders a Anterior/Siguiente wizard. Absent → the * legacy flat list (unchanged). */ formLayout?: FormLayout } export function DynamicForm({ fields, initialValues, onSubmit, onCancel, submitLabel = 'Guardar', cancelLabel = 'Cancelar', disabled = false, formLayout, }: DynamicFormProps) { const [values, setValues] = useState>({}) const [errors, setErrors] = useState>({}) const [submitting, setSubmitting] = useState(false) // Readonly fields are system-owned (connection state, sync-written values): // the server writes them, so the form never captures them — hidden outright // instead of rendered disabled, matching the metadata contract // (ColumnDef.Readonly). They are also excluded from defaults/submit so a // create never posts e.g. `status: ""` over the column default. const editableFields = useMemo( () => fields.filter((f) => !(f as { readonly?: boolean }).readonly), [fields], ) // Conditional visibility: a field carrying `visible_when` is rendered — and // validated — only while the referenced sibling field's current value // matches the predicate. Driving BOTH the schema and the render off the same // filtered list means a hidden field never blocks submit (its required-gate // is dropped with it), matching the primitive's contract. Fields with no // `visible_when` are always kept (retrocompat). const visibleFields = useMemo( () => editableFields.filter((f) => evaluateVisibleWhen(getVisibleWhen(f), values)), [editableFields, values], ) const schema = useMemo(() => buildZodSchema(visibleFields), [visibleFields]) // Line-items fields carrying a balance rule gate submit: an unbalanced entry // (Σdebit ≠ Σcredit, or all-zero when require_nonzero) can't be saved. This // is fully declarative — `evaluateBalance` returns undefined for fields with // no rule, so non-balanced forms are unaffected. const balanceBlocked = useMemo(() => { for (const f of visibleFields) { const state = evaluateBalance(f, values[f.key]) if (state && !state.balanced) return true } return false }, [visibleFields, values]) // Group visible fields by their form_layout section (empty sections drop out // because visibleFields is already visibility-filtered). Without a layout // this is a single default group → the render below collapses to the legacy // flat grid, byte-for-byte. const groups = useMemo( () => groupFieldsBySection(visibleFields, formLayout, values), [visibleFields, formLayout, values], ) const isSteps = formLayout?.mode === 'steps' && groups.length > 1 // Wizard step cursor (steps mode only). Clamped whenever the group count // shrinks (a visible_when flip can empty a trailing step's section). const [stepIndex, setStepIndex] = useState(0) useEffect(() => { setStepIndex((i) => Math.min(i, Math.max(groups.length - 1, 0))) }, [groups.length]) useEffect(() => { const defaults: Record = {} for (const f of editableFields) { if (isLineItemsField(f)) { defaults[f.key] = initialValues?.[f.key] ?? f.defaultValue ?? [] continue } defaults[f.key] = initialValues?.[f.key] ?? f.defaultValue ?? (f.type === 'boolean' ? false : '') } setValues(defaults) setErrors({}) }, [editableFields, initialValues]) const update = (k: string, v: any) => setValues((prev: Record) => ({ ...prev, [k]: v })) const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() if (balanceBlocked) return const result = schema.safeParse(values) if (!result.success) { const next: Record = {} for (const issue of result.error.issues) { const key = issue.path[0] if (typeof key === 'string' && !next[key]) next[key] = issue.message } setErrors(next) return } setErrors({}) setSubmitting(true) try { await onSubmit(result.data as Record) } finally { setSubmitting(false) } } // Renders one group's fields into the responsive 2-column grid: scalar // header fields flow through it; line-items grids (and textareas) span full // width so the row table / memo gets room. Shared by every layout mode. const renderGrid = (groupFields: ActionFieldDef[]) => (
{groupFields.map((field) => ( update(field.key, v)} values={values} error={errors[field.key]} initialValues={initialValues} /> ))}
) // ── Steps (wizard) mode ──────────────────────────────────────────────── // One step per section; Anterior/Siguiente navigate, submit only on the // last step. Advancing validates just the current step's fields so a later // step can't be blocked by an earlier untouched one and vice-versa. if (isSteps) { const step = groups[stepIndex] const isLast = stepIndex === groups.length - 1 const goNext = () => { const stepSchema = buildZodSchema(step.fields) const result = stepSchema.safeParse(values) if (!result.success) { const next: Record = {} for (const issue of result.error.issues) { const key = issue.path[0] if (typeof key === 'string' && !next[key]) next[key] = issue.message } setErrors(next) return } setErrors({}) setStepIndex((i) => Math.min(i + 1, groups.length - 1)) } const goBack = () => setStepIndex((i) => Math.max(i - 1, 0)) return (
setStepIndex(i)} /> {step.assist ? ( setValues(prev => ({ ...prev, ...fields }))} /> ) : ( renderGrid(step.fields) )}
{stepIndex > 0 ? ( ) : onCancel ? ( ) : ( )} {isLast ? ( ) : ( )}
) } // ── Sections / flat mode ─────────────────────────────────────────────── // Layout: scalar header fields flow through a responsive 2-column grid; // line-items grids (and textareas) span the full width so the row table / // memo gets room. Mirrors the pro look of the federated journal modal but // stays fully declarative — driven only by field shape. With no layout this // is a single default group rendered without section chrome (unchanged). return (
{groups.map((group) => ( {renderGrid(group.fields)} ))}
{onCancel && ( )}
) } interface FieldRendererProps { field: ActionFieldDef value: any onChange: (v: any) => void /** The form's initial record — used to seed an FK picker's existing label/image. */ initialValues?: Record /** * The full flat map of current form values — read by a STATIC enum select to * gate its options by a sibling field's value (`applyOptionWhen`). */ values?: Record } interface FieldRowProps extends FieldRendererProps { error?: string } // One form field row: label + renderer + inline error. Encapsulated as its own // component so a STATIC enum select whose options are all gated out by a sibling // value (`when`) can hide the ENTIRE row (label included) and run its reset // effect with valid hook ordering. function FieldRow({ field, value, onChange, values, error, initialValues }: FieldRowProps) { const isStaticSelect = resolveWidget(field) === 'select' && !field.ref && Array.isArray(field.options) const effectiveOptions = isStaticSelect ? applyOptionWhen(field.options, values, getDependsOn(field)) : undefined // Reset a selection that the current sibling value no longer permits (e.g. // the parent switched away from the value that made this option valid). useEffect(() => { if (!isStaticSelect || !effectiveOptions) return if (value && !effectiveOptions.some((o) => String(o.value) === String(value))) { onChange('') } }, [isStaticSelect, effectiveOptions, value, onChange]) // No option applies under the current sibling value → hide the whole field. if (isStaticSelect && effectiveOptions && effectiveOptions.length === 0) return null const fullWidth = isLineItemsField(field) || resolveWidget(field) === 'textarea' || resolveWidget(field) === 'richtext' return (
{error && {error}}
) } // seedOptionFromSibling builds a pre-resolved option for an FK field from the // resolved sibling the backend served on the initial record (e.g. a line item's // `product = { value, label, image }` alongside `product_id`). Lets the picker // show the name + thumbnail for an existing value without a lookup. Returns // undefined when the sibling carries nothing renderable. function seedOptionFromSibling( field: ActionFieldDef, value: any, initialValues?: Record, ): ResolvedOption | undefined { if (!field.key.endsWith('_id')) return undefined const sib = initialValues?.[field.key.replace(/_id$/, '')] if (!sib || typeof sib !== 'object') return undefined const label = sib.label ?? sib.name ?? '' if (!label && !sib.image) return undefined const id = String(sib.value ?? sib.id ?? value ?? '') return { id, value: id, label: String(label), name: String(label), image: sib.image, color: sib.color, icon: sib.icon, } } function FieldRenderer({ field, value, onChange, initialValues, effectiveOptions, }: FieldRendererProps & { effectiveOptions?: import('./types').OptionDef[] }) { // Repeatable line-items group → render the row grid. Its value is an array // of row objects rather than a scalar. if (isLineItemsField(field)) { return } const widget = resolveWidget(field) // Async searchable picker (typeahead against /api/options/?q=…). // Preferred for FK fields with large option sets — no UUID typing, no // dumping every row into a plain