Skip to content

The form definition ​

FormDefinition (in vyuh_form_types) is the whole form as immutable data. The renderer, the designer, the engines and the JSON codec all work from it.

text
FormDefinition({
  required String key,
  required String title,
  required Iterable<FormFieldDefinition> fields,
  required FormLayout layout,
  String? description,
  FormActions actions = const FormActions(),
  Iterable<FormRule> rules = const [],
})
MemberTypeMeaning
keyStringStable id of the form.
titleStringShown at the top of the form.
descriptionString?Shown under the title.
fieldsList<FormFieldDefinition>What the form captures, keyed by each field's key. See Field types.
layoutFormLayoutWhere things appear: the layout tree. See Layout.
actionsFormActionsThe form's Cancel, Save draft and Submit buttons: whether each shows and what it says. See Actions.
rulesList<FormRule>Conditions and effects between fields. See Rules and formulas.
schemaVersionint (read only)Version of the definition format. Currently 5 (formSchemaVersion): the tree is under layout, every grid has its own cellSize, and the signature field and a reference's scan setting exist. The codec checks it first and refuses any other version with a KitCodecException at schemaVersion; older definitions are not read, and there is no migration.

All collections are unmodifiable. To change a definition, build a new one, or edit it in the designer through FormKitEditorController.

Fields and the layout tree ​

A definition keeps what is captured apart from where it appears:

  • fields is a flat list. Values, rules and formulas refer to fields by key.
  • layout is a tree of containers (FormStep, FormGroup, RepeatingGroup, FormRow, FreeGrid) and content (CalloutContent, ImageContent, DividerContent, SpacerContent and CustomContent). It refers to a field only through a FieldSlot.
dart
const FieldSlot(id: 'slot_title', fieldKey: 'title')

Every field needs exactly one slot. Node ids (FormNode.id) and field keys are separate namespaces: rules and values use field keys, while the editor selects and moves nodes by id.

Checked at construction ​

The constructor calls validate(), so an invalid definition cannot exist. Every design error is a FormatException with a message that names the problem. (The codec reports the same message as a KitCodecException at the root of the document.) Among the checks:

  • missing key or title
  • duplicate or blank field keys or labels; empty or duplicate node ids
  • a slot for an unknown field, a field slotted twice, or a field with no slot
  • a nested FormLayout; a repeating group inside another repeating group
  • a gap outside gapScale; initiallyCollapsed on a group that is not collapsible; invalid minItems / maxItems
  • a row that is empty, has a column count other than 6 or 12, more than 6 cells, a span below the element's minimum, or spans that overflow
  • a free grid with overlapping, out-of-bounds or undersized placements
  • field-specific problems: a choice field with no choices or with blank or duplicate choice values, a blank reference target, maxStars out of range, a formula that does not compile, a formula dependency cycle
  • validation settings that contradict each other, or two required validations on one field
  • validations with an empty or duplicate id on one field
  • rules with an empty or duplicate id. Everything else about a rule (unknown fields, operators that don't fit, cycles) is a design issue, so a form with a stale rule still loads and can be fixed in the designer
dart
try {
  final form = FormKit.standard.decodeString(source);
} on KitCodecException catch (error) {
  // error.path says where, such as fields[3].validations[1].config;
  // error.message names the field, node or rule at fault.
  print('${error.path}: ${error.message}');
}

Decoding runs the same checks, so a decoded definition is valid or decode throws KitCodecException (see JSON with the kit).

Actions ​

Every form has an action bar, whether it is one step or several. actions says which of its buttons the definition shows and what each says:

dart
final withDraft = FormDefinition(
  key: 'deviation_report',
  title: 'Deviation report',
  fields: [TextFormField(key: 'title', label: 'Title')],
  layout: FormLayout(
    id: 'layout',
    children: const [FieldSlot(id: 'slot_title', fieldKey: 'title')],
  ),
  actions: const FormActions(
    draft: FormActionSetting(label: 'Keep for later'),
    submit: FormActionSetting(label: 'File report'),
  ),
);
ButtonDefaultShows when
Cancel (cancel)shownthe definition shows it and the host gave FormView(onCancel:)
Save draft (draft)hiddenthe definition shows it and the host gave FormView(onSaveDraft:)
Submit (submit)shown, alwaysalways: it is what ends the form. Only its label can change

A FormActionSetting is visible and an optional label; a blank label is the button's default. A form whose Submit is hidden is refused when built. A stepped form's Back, Skip and Next are on its FormStepper, not here. In JSON, actions is written only when it differs from the defaults, and only the buttons that differ. See Filling forms for how the bar behaves.

Worked example: deviation intake ​

The concept pages share one example. It has:

  • a Deviation group with a 12-column row (Title, Severity, Discovered) and a full-width Batch number;
  • a repeating group of Affected materials, 1 to 10 entries, each with a per-entry formula line_cost;
  • a free grid with a multiline description, a CAPA owner and a star rating;
  • a rule that shows the CAPA owner and makes it required only for major or critical deviations.
dart
import 'package:vyuh_expression/vyuh_expression.dart' as ex;
import 'package:vyuh_form_types/vyuh_form_types.dart';

final deviationIntake = FormDefinition(
  key: 'deviation_intake',
  title: 'Deviation intake',
  description: 'Log a deviation within 24 hours of discovery.',
  fields: [
    TextFormField(
      key: 'title',
      label: 'Title',
      hint: 'e.g. Temperature excursion in cold room 2',
      required: true,
      validations: const [LengthValidation(max: 120)],
    ),
    ChoiceFormField(
      key: 'severity',
      label: 'Severity',
      required: true,
      choices: const [
        FormChoice(value: 'minor', label: 'Minor'),
        FormChoice(value: 'major', label: 'Major'),
        FormChoice(value: 'critical', label: 'Critical'),
      ],
    ),
    DateTimeFormField(key: 'discovered_at', label: 'Discovered', required: true),
    TextFormField(
      key: 'batch',
      label: 'Batch number',
      hint: 'e.g. B-2026-0412',
      validations: const [
        PatternValidation(r'^B-\d{4}-\d{4}$', message: 'Use the B-YYYY-NNNN format.'),
      ],
    ),

    // Inside the repeating group: one value scope per entry.
    TextFormField(key: 'material', label: 'Material', required: true),
    NumberFormField(
      key: 'qty',
      label: 'Quantity',
      decimal: true,
      validations: const [NumericRangeValidation(min: 0, includeMin: false)],
    ),
    NumberFormField(key: 'unit_cost', label: 'Unit cost', decimal: true),
    FormulaFormField(
      key: 'line_cost',
      label: 'Line cost',
      expression: r'$qty * $unit_cost',
      outputType: ValueType.rational,
    ),

    // In the free grid.
    TextFormField(
      key: 'what_happened',
      label: 'What happened',
      inputKind: TextInputKind.multiline,
      required: true,
    ),
    TextFormField(
      key: 'capa_owner',
      label: 'CAPA owner',
      // Its id is `required`; it runs only while the rule below applies it.
      validations: const [RequiredValidation(message: 'Name a CAPA owner for major deviations.')],
    ),
    RatingFormField(key: 'confidence', label: 'Confidence in root cause'),
  ],
  layout: FormLayout(
    id: 'layout',
    rowGap: 24,
    children: [
      FormGroup(
        id: 'deviation',
        title: 'Deviation',
        children: [
          FormRow(
            id: 'deviation_row',
            columns: 12,
            cells: const [
              FormCell(child: FieldSlot(id: 'slot_title', fieldKey: 'title'), span: 6),
              FormCell(child: FieldSlot(id: 'slot_severity', fieldKey: 'severity'), span: 3),
              FormCell(child: FieldSlot(id: 'slot_discovered', fieldKey: 'discovered_at'), span: 3),
            ],
          ),
          const FieldSlot(id: 'slot_batch', fieldKey: 'batch'),
        ],
      ),
      RepeatingGroup(
        id: 'materials',
        title: 'Affected materials',
        minItems: 1,
        maxItems: 10,
        children: [
          FormRow(
            id: 'material_row',
            columns: 12,
            columnGap: 12,
            cells: const [
              FormCell(child: FieldSlot(id: 'slot_material', fieldKey: 'material'), span: 4),
              FormCell(child: FieldSlot(id: 'slot_qty', fieldKey: 'qty'), span: 2),
              FormCell(child: FieldSlot(id: 'slot_unit_cost', fieldKey: 'unit_cost'), span: 3),
              FormCell(child: FieldSlot(id: 'slot_line_cost', fieldKey: 'line_cost'), span: 3),
            ],
          ),
        ],
      ),
      FreeGrid(
        id: 'assessment',
        columns: 12,
        rows: 4,
        placements: const [
          GridPlacement(
            child: FieldSlot(id: 'slot_what', fieldKey: 'what_happened'),
            x: 0, y: 0, width: 8, height: 4,
          ),
          GridPlacement(
            child: FieldSlot(id: 'slot_capa', fieldKey: 'capa_owner'),
            x: 8, y: 0, width: 4, height: 2,
          ),
          GridPlacement(
            child: FieldSlot(id: 'slot_confidence', fieldKey: 'confidence'),
            x: 8, y: 2, width: 4, height: 2,
          ),
        ],
      ),
    ],
  ),
  rules: [
    FormRule(
      id: 'capa_for_major',
      title: 'CAPA for major deviations',
      when: ex.OneOf<String>(
        ex.FieldValue<String>(ex.NamedField<String>('severity')),
        [ex.Literal('major'), ex.Literal('critical')],
      ), // or severity.isAnyOf([...]) with the DSL
      effects: const [
        Show('slot_capa'),
        ApplyValidation('capa_owner', 'required'),
      ],
    ),
  ],
);

Values ​

FormView reads and writes a Map<String, Object?> keyed by field key. A repeating group stores its entries under the group's id as a list of entry maps:

dart
final values = <String, Object?>{
  'title': 'Temperature excursion in cold room 2',
  'severity': 'major',
  'discovered_at': DateTime(2026, 9, 27, 14, 30),
  'materials': [
    {
      'material': 'Insulin vials',
      // A decimal NumberFormField holds a Rational; a whole one an int.
      'qty': Rational.parse('40'),
      'unit_cost': Rational.parse('12.50'),
    },
  ],
};

Formula results are computed for display. FormView never writes them back through onChanged.

JSON with the kit ​

The kit saves and loads a definition, so the same object that knows an app's validator, rule and field types reads them back: there is no second place to register a custom type. Built-in types need no setup, and FormKit.standard is the kit of those alone. A form that uses custom fields or content or an app's validator or rule kinds is saved and loaded by the app's kit.

dart
final kit = FormKit.standard;

final json = kit.encodeString(deviationIntake);
final restored = kit.decodeString(json);

// Map form, for storage adapters that take structured JSON.
final Map<String, Object?> map = kit.encode(deviationIntake);
final again = kit.decode(map);
MethodReturns
kit.encodeString(form)String
kit.decodeString(source)FormDefinition
kit.encode(form)Map<String, Object?>
kit.decode(json)FormDefinition

The kit's codec, kit.codec, is a DefinitionCodec<FormDefinition> of vyuh_kit_types with schema version 5, the same contract as the dashboard, checklist and report codecs. encodeString writes the JSON indented by two spaces; the document is the same as ever, and jsonEncode(kit.encode(form)) is its compact text.

A document that cannot be read is a KitCodecException whose path says where, written with . between properties and [i] for list positions: layout.children[2].cells[0].child, fields[3].validations[1].config, rules[0].when.expr.operands[1].operand.op. The path is empty when the whole document is at fault (text that is not JSON, or a form that reads but breaks the checks above). schemaVersion is checked before anything else, so a document from a newer app is refused for its version, not for the keys it added.

FaultPathMessage
A schemaVersion other than 5schemaVersionUnsupported schemaVersion 4
A value of the wrong shapethe valueExpected a string
An unknown enum valuethe valueUnknown value 'upside-down'
A field type the kit does not registerfields[1].typeUnknown form field type: acme.signature. The kit used has the extensions vyuh.form; register the type in one of them.
A content type the kit does not registerlayout.children[0].typeIdUnknown form content type: acme.banner. ...

A validator or effect of an app that the kit does not register is not refused: it is kept as it was saved, written back unchanged, and reported as a design issue.

An excerpt of the encoded form:

json
{
  "schemaVersion": 5,
  "key": "deviation_intake",
  "title": "Deviation intake",
  "description": "Log a deviation within 24 hours of discovery.",
  "fields": [
    {
      "type": "text", "key": "title", "label": "Title",
      "hint": "e.g. Temperature excursion in cold room 2",
      "helpText": null, "readOnly": false, "inputKind": "singleLine",
      "validations": [
        { "id": "required", "type": "required", "message": null, "warning": false },
        { "id": "length", "type": "length", "message": null, "warning": false, "min": null, "max": 120 }
      ]
    }
  ],
  "layout": {
    "id": "layout", "type": "layout", "rowGap": 24,
    "children": [
      { "id": "deviation", "type": "group", "title": "Deviation", "collapsible": false,
        "initiallyCollapsed": false, "rowGap": 16, "children": [
          { "id": "deviation_row", "type": "row", "columns": 12, "stackBelow": 600.0, "columnGap": 16,
            "cells": [ { "span": 6, "child": { "id": "slot_title", "type": "field", "fieldKey": "title" } } ] }
      ] }
    ]
  },
  "rules": []
}

The type discriminators:

KindValues
Fieldstext, number, money, quantity, duration, boolean, choice, reference, rating, dateTime, dateRange, dateTimeRange, attachment, signature, formula, or a registered custom typeId
Nodesfield, callout, image, divider, spacer, custom, layout, step, group, repeatingGroup, row, freeGrid

The tree is under "layout", and its top node keeps "type": "layout" like every other node carries its type. A freeGrid saves its cellSize by name (small, medium or large); left out, it is medium.