// 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, useRef, 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 { getOptionFilter } from './option-filter' 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' import { getFieldWidget, useFieldWidgetRegistryVersion } from './field-widget-registry' 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 /** * Para formularios dentro de un modal con alto acotado: el cuerpo de campos * hace scroll propio y la barra de botones (Guardar/Cancelar) queda fija * abajo, así nunca queda fuera de pantalla con muchos campos. El contenedor * debe ser flex-col con alto máximo (p.ej. `max-h-[90dvh]`). */ scrollableBody?: boolean } // Valores iniciales del formulario: el registro (edición) gana sobre el default // del campo. Se calcula de forma síncrona en el primer render para que ningún // widget (p.ej. un Select de Radix) monte con '' y emita un cambio espurio que // pise el valor real del registro. function seedValues(fields: ActionFieldDef[], initialValues?: Record): Record { const defaults: Record = {} for (const f of fields) { if (isLineItemsField(f)) { defaults[f.key] = initialValues?.[f.key] ?? f.defaultValue ?? [] continue } defaults[f.key] = initialValues?.[f.key] ?? f.defaultValue ?? (f.type === 'boolean' ? false : '') } return defaults } const isReadonlyField = (f: ActionFieldDef) => !!(f as { readonly?: boolean; readOnly?: boolean }).readonly || !!(f as { readonly?: boolean; readOnly?: boolean }).readOnly export function DynamicForm({ fields, initialValues, onSubmit, onCancel, submitLabel = 'Guardar', cancelLabel = 'Cancelar', disabled = false, formLayout, scrollableBody = false, }: DynamicFormProps) { 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) => !isReadonlyField(f)), [fields], ) const [values, setValues] = useState>(() => seedValues(editableFields, initialValues)) // Campos que el usuario (o un reset dependiente) cambió desde la siembra. // En edición, un select que sigue vacío y nadie tocó no viaja en el PATCH. const touched = useRef>(new Set()) // 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]) // Re-siembra cuando cambia el registro o los campos DESPUÉS del montaje (el // primer render ya quedó sembrado por el inicializador de useState). const seededFrom = useRef({ editableFields, initialValues }) useEffect(() => { const prev = seededFrom.current if (prev.editableFields === editableFields && prev.initialValues === initialValues) return seededFrom.current = { editableFields, initialValues } touched.current = new Set() setValues(seedValues(editableFields, initialValues)) setErrors({}) }, [editableFields, initialValues]) const update = (k: string, v: any) => { touched.current.add(k) 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({}) const data = { ...(result.data as Record) } // Edición: un select vacío que el usuario no tocó no se manda — el update // es parcial, así que omitirlo conserva lo que tenga el registro en vez de // pisarlo con ''. if (initialValues) { for (const f of visibleFields) { if (resolveWidget(f) !== 'select') continue if (touched.current.has(f.key)) continue if (data[f.key] === '' || data[f.key] == null) delete data[f.key] } } setSubmitting(true) try { await onSubmit(data) } 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} /> ))}
) // scrollableBody: el cuerpo hace scroll y el footer de botones queda fijo. const formClass = scrollableBody ? 'flex min-h-0 flex-1 flex-col gap-4' : 'grid gap-4' const bodyClass = scrollableBody ? '-mx-1 grid min-h-0 flex-1 gap-4 overflow-y-auto px-1' : 'grid gap-4' const footerExtra = scrollableBody ? ' shrink-0 border-t' : '' // ── 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, values, effectiveOptions, error, }: FieldRendererProps & { effectiveOptions?: import('./types').OptionDef[]; error?: string }) { // Host-registered widget (`widget: ""`) wins over every built-in and // type-based branch. Unregistered names fall through untouched. useFieldWidgetRegistryVersion() const HostWidget = getFieldWidget(field.widget) if (HostWidget) { return } // 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