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.
FormDefinition({
required String key,
required String title,
required Iterable<FormFieldDefinition> fields,
required FormLayout layout,
String? description,
FormActions actions = const FormActions(),
Iterable<FormRule> rules = const [],
})| Member | Type | Meaning |
|---|---|---|
key | String | Stable id of the form. |
title | String | Shown at the top of the form. |
description | String? | Shown under the title. |
fields | List<FormFieldDefinition> | What the form captures, keyed by each field's key. See Field types. |
layout | FormLayout | Where things appear: the layout tree. See Layout. |
actions | FormActions | The form's Cancel, Save draft and Submit buttons: whether each shows and what it says. See Actions. |
rules | List<FormRule> | Conditions and effects between fields. See Rules and formulas. |
schemaVersion | int (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:
fieldsis a flat list. Values, rules and formulas refer to fields bykey.layoutis a tree of containers (FormStep,FormGroup,RepeatingGroup,FormRow,FreeGrid) and content (CalloutContent,ImageContent,DividerContent,SpacerContentandCustomContent). It refers to a field only through aFieldSlot.
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;initiallyCollapsedon a group that is not collapsible; invalidminItems/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,maxStarsout 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
idon 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
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:
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'),
),
);| Button | Default | Shows when |
|---|---|---|
Cancel (cancel) | shown | the definition shows it and the host gave FormView(onCancel:) |
Save draft (draft) | hidden | the definition shows it and the host gave FormView(onSaveDraft:) |
Submit (submit) | shown, always | always: 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.
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:
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.
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);| Method | Returns |
|---|---|
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.
| Fault | Path | Message |
|---|---|---|
A schemaVersion other than 5 | schemaVersion | Unsupported schemaVersion 4 |
| A value of the wrong shape | the value | Expected a string |
| An unknown enum value | the value | Unknown value 'upside-down' |
| A field type the kit does not register | fields[1].type | Unknown 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 register | layout.children[0].typeId | Unknown 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:
{
"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:
| Kind | Values |
|---|---|
| Fields | text, number, money, quantity, duration, boolean, choice, reference, rating, dateTime, dateRange, dateTimeRange, attachment, signature, formula, or a registered custom typeId |
| Nodes | field, 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.