Skip to content

FormBuilder Internals

The FormBuilder is the core component that transforms spec definitions into rendered forms. Understanding its internals helps when debugging, extending, or optimizing form behavior.


┌─────────────────────────────────────────────────────────┐
│ Form.ce.vue │
│ (Custom Element - entry point) │
├─────────────────────────────────────────────────────────┤
│ FormBuilder.vue │
│ (Form layout, validation, submission) │
├─────────────────────────────────────────────────────────┤
│ FieldIterator.vue │
│ (Recursively renders fields based on type) │
├─────────────────────────────────────────────────────────┤
│ FieldText │ FieldSelect │ FieldNumber │ FieldArray │
│ (Individual field components) │
└─────────────────────────────────────────────────────────┘

FilePurpose
src/ui/FormBuilder/FormBuilder.vueMain form component
src/ui/FormBuilder/FieldIterator.vueRecursive field renderer
src/ui/FormBuilder/lib/parser.tsSpec parsing and validation
src/form/store/workspace.tsForm instance management
src/ui/types.tsTypeScript type definitions

Extracts default values from a spec definition.

function parseDefaults(field: Field, withNested = false): Record<string, any>

What it does:

  1. Recursively traverses the spec
  2. Collects default values from each field
  3. Creates a nested object matching the spec structure

Example:

const spec = {
type: 'collection',
spec: [
{ type: 'text', name: 'name', default: 'John' },
{ type: 'number', name: 'age', default: 25 },
{
type: 'collection',
name: 'settings',
spec: [
{ type: 'boolean', name: 'notifications', default: true },
],
},
],
};
const defaults = parseDefaults(spec);
// {
// name: 'John',
// age: 25,
// settings: {
// notifications: true
// }
// }

Creates a Yup validation schema from a spec.

function generateSchema(
field: Field,
isRoot = false
): Yup.Schema

What it does:

  1. Recursively processes each field
  2. Applies validation rules based on field type and properties
  3. Returns a composed Yup schema

Field Type to Yup Mapping:

Field TypeYup TypeValidations Applied
textYup.string().required() if required
emailYup.string().email(), .required()
numberYup.number().min(), .max(), .required()
booleanYup.boolean().required()
selectYup.mixed().required(), .oneOf()
collectionYup.object()Nested validation
arrayYup.array()Item validation

Example:

const spec = {
type: 'collection',
spec: [
{ type: 'email', name: 'email', required: true },
{ type: 'number', name: 'age', min: 18, max: 120 },
],
};
const schema = generateSchema(spec, true);
// Equivalent to:
// Yup.object({
// email: Yup.string().email().required(),
// age: Yup.number().min(18).max(120),
// })

The FieldIterator component recursively renders fields:

<!-- FieldIterator.vue (simplified) -->
<template>
<template v-for="field in fields" :key="field.name">
<!-- Skip hidden fields -->
<template v-if="!field.hidden">
<!-- Collection: render children -->
<div v-if="field.type === 'collection'">
<FieldIterator :fields="field.spec" :path="getPath(field)" />
</div>
<!-- Array: render add/remove UI -->
<FieldArrayComponent v-else-if="field.type === 'array'" />
<!-- Select: render dropdown -->
<FieldSelectComponent v-else-if="field.type === 'select'" />
<!-- Text/Number/etc: render input -->
<FieldInputComponent v-else />
</template>
</template>
</template>

The useFormWorkspace composable manages form instances:

interface FormInstance {
spec: Field; // Current spec definition
data: Record<string, any>; // Current form values
initialValues: Record<string, any>; // Original values
}

Creates or updates a form instance:

function initForm(id: string, config: { spec: Field; data: Record<string, any> }): void

Updates a specific field value:

function updateData(id: string, path: string, value: any): void

Dynamically replaces a field’s spec:

function setSpecInPath(id: string, path: string, newSpec: Field): void

How it works:

  1. Parses the dot-notation path ("user.address.city")
  2. Recursively traverses the spec tree
  3. Replaces the target field with the new spec
  4. Updates corresponding initial values

┌──────────────────────────────────────────────────────────┐
│ 1. User clicks Submit │
├──────────────────────────────────────────────────────────┤
│ 2. FormBuilder.onSubmit() called │
├──────────────────────────────────────────────────────────┤
│ 3. ValidateSpecialInputError() │
│ - Checks custom input validations │
├──────────────────────────────────────────────────────────┤
│ 4. VeeValidate validate() │
│ - Runs Yup schema against form data │
├──────────────────────────────────────────────────────────┤
│ 5. Check results │
│ ├─ Has errors → emit('error', errors) │
│ └─ No errors → emit('submit', data, meta) │
└──────────────────────────────────────────────────────────┘

When a select has nested options, the FormBuilder:

  1. Watches for value changes on the select
  2. Finds the matching option
  3. Injects the nested fields into the form
  4. Manages the nested field values
// Option with nested fields
{
label: 'Credit Card',
value: 'credit_card',
nested: [
{ type: 'text', name: 'cardNumber' },
{ type: 'text', name: 'expiry' },
],
}
// When selected, form structure becomes:
{
paymentMethod: 'credit_card',
cardNumber: '',
expiry: '',
}

Fields can use template for computed values:

{
type: 'text',
name: 'fullName',
template: '{{firstName}} {{lastName}}',
readonly: true,
}

The FormBuilder evaluates templates using the current form data.


  1. Create the field component:
FieldColor.vue
<template>
<input
type="color"
:value="modelValue"
@input="emit('update:modelValue', $event.target.value)"
/>
</template>
<script setup>
defineProps(['modelValue']);
defineEmits(['update:modelValue']);
</script>
  1. Register in FieldIterator:
<FieldColor
v-else-if="field.type === 'color'"
v-model="formData[field.name]"
/>
  1. Add type to the TypeScript definition:
type FieldColor = FieldBase & {
type: 'color';
presets?: string[];
};
type Field = /* existing types */ | FieldColor;
  1. Add Yup validation in parser:
case 'color':
return Yup.string().matches(/^#[0-9a-f]{6}$/i, 'Invalid color');

  1. Reactive spec: Changes to spec trigger full re-render. Use setSpecInPath for targeted updates.

  2. Large arrays: Array fields with many items can slow rendering. Consider pagination.

  3. Debounced changes: The onChange event is debounced to prevent excessive updates.

  4. Schema caching: Generated Yup schemas are cached per form instance.