import { reactive, ref, computed, shallowRef, watch } from 'vue' import { useForm, type FieldRules, type UseFormReturn } from './useForm' import { getFieldRegistryEntry } from './formBuilderRegistry' import { buildInitialValues, buildRules, collectSections, evaluateCondition, flattenFields, replaceArray, replaceRecord, } from './formBuilderSchema' import type { FormSchema, FormFieldSchema, FormSectionSchema, FormOptionInput, FormEnhancements, UseFormBuilderReturn, } from '../types/form-builder' export { evaluateCondition } from './formBuilderSchema' // --------------------------------------------------------------------------- // useFormBuilder // --------------------------------------------------------------------------- /** * Drive a `FormSchema` as reactive form state. * * Builds initial values from schema defaults and `initialData`, derives * validation rules from `FieldValidation` descriptors and enhancement * validators, evaluates `FieldCondition` expressions for field/section * visibility, and wires wizard step navigation when `schema.steps` is set. * * @param schema - Declarative form or wizard schema. * @param initialData - Values that override schema defaults. * @param enhancements - TypeScript-only callbacks (dynamic options, validators, * submit handler, transform, field-change watcher). */ /** Drives a declarative FormSchema as reactive state with conditional fields, wizard steps, and validation. */ export function useFormBuilder = Record>( schema: FormSchema, initialData?: Partial, enhancements?: FormEnhancements, ): UseFormBuilderReturn { const currentSchema = shallowRef(schema) const fields = reactive(flattenFields(schema)) const sections = reactive(collectSections(schema)) const rules = reactive>>(buildRules(fields, enhancements)) // -- Create form ----------------------------------------------------------- const form = useForm( buildInitialValues(fields, initialData) as T, rules as Partial>, ) // -- Visibility ------------------------------------------------------------ function isFieldVisible(name: string): boolean { const enhancement = enhancements?.fields?.[name as keyof T] if (enhancement?.visible) { return enhancement.visible(form.data as T) } const field = fields.find((f) => f.name === name) if (field?.condition) { return evaluateCondition(field.condition, form.data as Record) } return true } function isSectionVisible(id: string): boolean { const section = sections.find((s) => s.id === id) if (section?.condition) { return evaluateCondition(section.condition, form.data as Record) } return true } // -- Resolved field props -------------------------------------------------- function getResolvedFieldProps(field: FormFieldSchema): Record { const entry = getFieldRegistryEntry(field.type) const formProps = form.getFieldProps(field.name as keyof T) const merged: Record = { ...entry.defaults, ...(field.props ?? {}), } // Dynamic enhancement props const enhancement = enhancements?.fields?.[field.name as keyof T] if (enhancement?.props) { Object.assign(merged, enhancement.props(form.data as T)) } // Dynamic options const options = getFieldOptions(field.name) if (options) { merged.options = options } // Form field bindings if (entry.vModel) { merged.modelValue = formProps.modelValue merged['onUpdate:modelValue'] = formProps['onUpdate:modelValue'] } merged.onBlur = formProps.onBlur // Error as boolean for components that use boolean error prop const errorMsg = formProps.error if (errorMsg) { merged.error = true } // Schema-level props if (field.placeholder) merged.placeholder = field.placeholder if (field.size) merged.size = field.size if (field.disabled) merged.disabled = true if (field.readonly) merged.readonly = true // Radio group needs a name prop if (field.type === 'radio' && !merged.name) { merged.name = field.name } return merged } function getFieldOptions(name: string): FormOptionInput[] | undefined { const enhancement = enhancements?.fields?.[name as keyof T] if (enhancement?.options) { return enhancement.options(form.data as T) } const field = fields.find((f) => f.name === name) if (field?.props?.options) { return field.props.options as FormOptionInput[] } return undefined } // -- Wizard state ---------------------------------------------------------- const currentStep = ref(0) const isCurrentStepValid = computed(() => { if (!currentSchema.value.steps) return form.isValid.value const step = currentSchema.value.steps[currentStep.value] if (!step) return true for (const section of step.sections) { if (!isSectionVisible(section.id)) continue for (const field of section.fields) { if (!isFieldVisible(field.name)) continue if (!form.validateField(field.name)) return false } } return true }) function goNext(): boolean { if (!currentSchema.value.steps) return false // Touch and validate all visible fields in current step const step = currentSchema.value.steps[currentStep.value] if (!step) return false let valid = true for (const section of step.sections) { if (!isSectionVisible(section.id)) continue for (const field of section.fields) { if (!isFieldVisible(field.name)) continue form.setFieldTouched(field.name, true) if (!form.validateField(field.name)) valid = false } } if (!valid) return false if (currentStep.value < currentSchema.value.steps.length - 1) { currentStep.value++ } return true } function goBack(): void { if (currentStep.value > 0) { currentStep.value-- } } function goToStep(index: number): void { if (!currentSchema.value.steps) return if (index >= 0 && index < currentSchema.value.steps.length) { currentStep.value = index } } // -- Validate (skip hidden fields) ----------------------------------------- function validate(): boolean { let allValid = true for (const field of fields) { if (!isFieldVisible(field.name)) continue form.setFieldTouched(field.name, true) if (!form.validateField(field.name)) { allValid = false } } return allValid } // -- Submit ---------------------------------------------------------------- async function submit(): Promise { if (!validate()) return // Build submission data excluding hidden fields let submitData = {} as Record for (const field of fields) { if (isFieldVisible(field.name)) { submitData[field.name] = (form.data as Record)[field.name] } } if (enhancements?.transform) { submitData = enhancements.transform(submitData as T) as Record } if (enhancements?.onSubmit) { form.isSubmitting.value = true try { await enhancements.onSubmit(submitData as T) } finally { form.isSubmitting.value = false } } } // -- Reset ----------------------------------------------------------------- function reset(values?: Partial): void { form.reset(values) currentStep.value = 0 } function updateSchema(nextSchema: FormSchema, nextData?: Partial): void { currentSchema.value = nextSchema const nextFields = flattenFields(nextSchema) replaceArray(fields, nextFields) replaceArray(sections, collectSections(nextSchema)) replaceRecord(rules, buildRules(nextFields, enhancements)) form.replaceState(buildInitialValues(nextFields, nextData) as T) currentStep.value = 0 } // -- onFieldChange wiring -------------------------------------------------- if (enhancements?.onFieldChange) { const callback = enhancements.onFieldChange watch( () => ({ ...(form.data as Record) }), (newData, oldData) => { if (!oldData) return for (const key of Object.keys(newData)) { if (newData[key] !== oldData[key]) { callback(key as keyof T, newData[key], newData as T) } } }, { deep: true }, ) } return { form: form as UseFormReturn, rules: rules as Partial>, isFieldVisible, isSectionVisible, fields, getResolvedFieldProps, getFieldOptions, currentStep, isCurrentStepValid, goNext, goBack, goToStep, validate, reset, submit, updateSchema, } }