Docyrus Dynamic Form Design
A dynamic form is a saved layout attached to one data source. It decides which of the data source's fields appear on the record create / edit / view screen, in what order and grid, grouped into which panels, which are required, and what runs when a value changes or the form is submitted. It is stored as a layout JSON document on a form row and rendered by useDocyrusFormView (or useDynamicFormView for a backend-free host).
The host app fetches the form and hands its layout to the renderer, which translates it into the field list, grid, per-field overrides, and automations. The document must therefore be self-contained — nothing outside it tells the renderer what the form should do.
Read this first, then author the JSON against references/form-layout-schema.md.
The three rules that prevent most broken forms
- A form references fields; it never defines them. Every field node must bind to an existing field on the data source, by
fieldConfig.slug(preferred) ordataSourceFieldId. A binding that resolves to nothing is silently dropped from the rendered form. List the real slugs first withstudio list-fields. To add a field, use docyrus-data-source-design — not this skill. - Fields you omit do not render. A saved layout is a whitelist: the form shows exactly the fields it lists, in its own order. Omitting a required-in-the-database column produces a form that cannot be submitted successfully.
requiredand JSONata rules always run; the other tokens are opt-in.minLength:/maxLength:/pattern:/min:/max:are enforced only where the host app turns them on (validationTokens: 'form' | 'all', default'off'). Write them — they are the right place for single-field constraints and they are enforced the moment a host opts in — but when a rule must hold today, put it incustomValidationsas well. See Validation.
Workflow
- Resolve the target and read the real field slugs.
docyrus whoami --json docyrus apps list --json docyrus studio list-fields --appSlug crm --dataSourceSlug contact --json - Read what already exists — never design blind against a data source that already has forms.
docyrus studio list-forms --appSlug crm --dataSourceSlug contact --json docyrus studio get-form --appSlug crm --dataSourceSlug contact --formId <id> --json - Design the layout document. Follow references/form-layout-schema.md; pick field types from references/field-type-catalog.md; express conditional behavior with references/actions-and-expressions.md. Start from references/examples/form-layout.full.example.json and delete what the user does not need.
- Validate the JSON against references/schemas/form-layout.schema.json, then run the checklist in Validate.
- Save it with
create-form(new) orupdate-form(existing) — see CRUD commands. Keep the layout in a file: pass it as--layout "$(cat form.layout.json)", or put the whole record (name, title, layout, …) in one file and use--from-file. Do not hand-type a layout into a shell flag. - Read it back with
get-formand confirm the layout round-tripped and every slug still resolves.
Layout document in one screen
The saved layout is a single JSON object. Field order is the array order; sections are nodes that carry nested fields.
{
"gridColumns": 2, // 1–4. Non-numeric or missing → 1. Always set it.
"labelAlign": "top", // "top" | "left"
"labelWidth": "md", // "sm" | "md" | "lg" (only with labelAlign "left")
"fieldSize": "md", // "sm" | "md" | "lg"
"fieldVariant": "outline", // "outline" | "filled"
"fields": [
{
"id": "n1",
"componentType": "field-text",
"dataSourceFieldId": "full_name", // slug or field id
"columnSpan": 2, // ≥ gridColumns → full width (grids of 2+ only)
"fieldConfig": {
"slug": "full_name", // the binding that actually matters
"type": "field-text",
"validations": ["required"]
}
},
{
"id": "sec-contact", // section = nested `fields`, no binding
"componentType": "panel",
"label": "Contact",
"columnSpan": 2, // panel width in the form grid
"gridColumns": 1, // the panel's own inner grid
"fields": [ /* field nodes */ ]
}
],
"formActions": [], // lifecycle automations (see references)
"formCustomValidations": [] // submit-time cross-field rules
}
The complete key-by-key contract — including every accepted alias, the exact binding-resolution order, and what each key does at render time — is references/form-layout-schema.md.
Validation: what actually runs
Field errors render under the field; form errors render as a banner.
| Rule | Where it lives | Applies to | Runs at submit |
|---|---|---|---|
required |
fieldConfig.validations: ["required"] |
any value — empty string, empty array and null all count as missing | always |
computedRequired |
fieldConfig.computedRequired (JSONata / QB JSON) |
conditional required | always |
Field customValidations |
fieldConfig.customValidations[] (JSONata → true) |
anything, incl. other fields via values |
always |
Form formCustomValidations |
layout root (JSONata → true) |
cross-field rules; runs after every field rule passes | always |
minLength:N / maxLength:N |
fieldConfig.validations tokens |
strings and arrays (character count / item count) | when enabled |
pattern:RE |
fieldConfig.validations token |
strings; raw unanchored regex, everything after the first colon | when enabled |
min:N / max:N |
fieldConfig.validations tokens |
numbers, and numeric-typed fields whose input returns a string | when enabled |
"When enabled" means the host app passes validationTokens: 'form' (enforce what the form declares) or 'all' (also enforce the data source's own tokens) to the form-view hook. The default is 'off', which keeps a stale bound on an old data-source field from blocking records that already violate it. A form's tokens are always stored and always shown by the builder's preview — the switch only governs runtime enforcement. If you cannot confirm the host has opted in and a constraint is not optional, mirror it in customValidations.
Order per field: required → tokens → custom validations; the first failure wins. An empty optional value is never failed by a token — only required looks at emptiness. A token that does not fit the value's shape (a pattern on a number, a min on text) is skipped, and unknown tokens or malformed bounds are ignored.
Use a custom validation when a token cannot express the rule:
"customValidations": [
{ "id": "cv-1", "expression": "value >= values.min_amount", "message": "Below the configured minimum." }
]
Conditional behavior
Two mechanisms, both JSONata (or Query Builder JSON) over the form's values:
- Computed keys — declarative, per field:
computedHidden,computedRequired,computedLabel,computedDescription,computedFormula(writes the field's value). - Actions — imperative blocks:
fieldActionson a field (fires when that field changes) andformActionsat the root (onFormLoad/onFormBeforeSubmit/onFormAfterSubmit). A block runs itsconditionalItemstop-to-bottom, first truthy wins, elseelseActions, thenunconditionalActionsalways.
Prefer a computed key when the rule is "this field's state depends on the values"; use an action when one change must write other fields. Full semantics, the 8 step methods, and worked expressions: references/actions-and-expressions.md.
CRUD commands
All five are docyrus studio subcommands scoped to a data source. Selectors: --appId | --appSlug and --dataSourceId | --dataSourceSlug; the form itself is addressed by --formId (forms have no slug). Add --json for machine-readable output. Write commands accept individual flags or --data / --from-file JSON; flags merge over the JSON.
# List every form saved on a data source (find the default and any existing ids)
docyrus studio list-forms --appSlug crm --dataSourceSlug contact --json
# Read one form, including its full layout document
docyrus studio get-form --appSlug crm --dataSourceSlug contact --formId <formId> --json
# Create a form. Keep the layout in a file — it is too big for a shell flag.
docyrus studio create-form --appSlug crm --dataSourceSlug contact \
--name "Contact form" --title "Contact" --isDefault true \
--layout "$(cat contact-form.layout.json)" --json
# Same, passing the whole record as one payload file
docyrus studio create-form --appSlug crm --dataSourceSlug contact \
--from-file contact-form.json --json
# Update — send the FULL layout you want stored; it replaces, it does not deep-merge
docyrus studio update-form --appSlug crm --dataSourceSlug contact --formId <formId> \
--layout "$(cat contact-form.layout.json)" --json
# Rename / re-flag without touching the layout
docyrus studio update-form --appSlug crm --dataSourceSlug contact --formId <formId> \
--name "Contact form (v2)" --isDefault true --json
# Delete
docyrus studio delete-form --appSlug crm --dataSourceSlug contact --formId <formId> --json
Record fields writable on create/update: --name, --title, --description, --subtopic, --color, --icon, --layout, --isDefault, --status; update-form additionally takes --archived. A --from-file payload uses those same names as camelCase JSON keys ({ "name": …, "layout": { … } }), and individual flags merge over it.
--status is a numeric status code. Rather than guessing it, read an existing form on the tenant with list-forms and copy the value a working form uses.
⚠️
update-form --layoutreplaces the stored document. To change one field,get-formfirst, edit the returned layout, and send the whole thing back.
⚠️
--isDefault truemarks the form as the data source's default — the one apps pick when no specific form is requested. Setting it on a second form is how you switch defaults; checklist-formsfirst and confirm with the user before moving a default that already exists.
Validate before you save
Run through this list — each item maps to a failure that is silent at save time and visible only when a user opens the form:
- Every binding resolves. Each
fieldConfig.slugappears instudio list-fieldsoutput. Unresolvable nodes vanish. - No duplicate slug across the layout. A field listed twice renders twice against one value.
gridColumnsis a number 1–4. A missing or string value collapses the form to one column.- Every
columnSpan≤gridColumns(or intentionally full-width). - Sections have no binding — a node with nested
fieldsplus adataSourceFieldId/fieldConfigis read as a field, and its children are lost. - Non-
requiredconstraints arecustomValidations, not tokens (see the table above). - Every expression references real slugs. A typo evaluates to
undefined— usually falsy, so acomputedRequiredsilently never fires and aformCustomValidationsrule blocks every submit. - Action
fieldSlugtargets exist in the layout. A step pointing at a field the form does not render does nothing. - Read back with
get-formand diff against what you sent.
Reference material
| File | Read it when |
|---|---|
| references/form-layout-schema.md | Authoring or editing any layout JSON — the authoritative key-by-key contract, binding resolution, sections, and the legacy format |
| references/field-type-catalog.md | Choosing type / componentType per field, or wiring enum-backed and relation fields |
| references/actions-and-expressions.md | Adding conditional visibility, auto-fill, lifecycle automation, or submit validation |
| references/schemas/form-layout.schema.json | Machine-validating a layout document before saving |
| references/examples/form-layout.full.example.json | A complete example exercising sections, spans, computed keys, actions, and validations |
| references/examples/form-layout.minimal.example.json | The smallest correct form — a starting point for simple asks |