🎨 The form designer: the elements and how to use them
Updated
Reference for the visual designer of a record card
(FormLayoutDesigner). For every palette item: what it is, when to drag it
onto the canvas, which properties to fill in, and how it behaves at runtime.
← Developer wiki · The forms system · Cookbook (JSON/DSL)
The short instructions for a user (open / save) — the layout
designer. The type schema —
frontend/src/api/models.ts (FormLayoutElement). The rendering —
FormLayoutView.tsx. The canvas (the Designer toggle) is
canvas/CanvasDesigner.tsx: drag snaps to neighbours and writes pins;
cyan edge dots draw a pin to a sibling, the parent, or a guide (click the
rubber band to remove it); click a ruler to drop a millimetre-labelled
guide and drag it (pins to that guide follow); three-or-more selection
gets equal spacing; two-or-more in one container packs into two columns.
Alt while dragging clears pins (absolute again).
A layout is data (MetaFormLayout.LayoutJson), and its bindings are by
name. A shared layout travels out as Forms/<Name>.form.json.
The widget catalogue tiles (attachments, document progress, order totals) are not elements of this palette. They are placed on the Widgets tab of a document type or of a dictionary. The guide — widgets.
How the designer is put together#
A window of three columns:
- The palette — decoration and data elements, plus the object's hidden fields.
- The canvas — groups and the grid. A click selects, a drag moves.
- The properties — the panel of the selected node.
The object's own fields are not dragged out of the palette as new ones — they are already there (the automatic form, or a saved layout). A hidden field is brought back from the "Hidden fields" block.
Ctrl+Z / Ctrl+Y — the history of layout edits, not of the record.
Saving:
| Button | Who sees it | Where |
|---|---|---|
| Save for me | only you | personal, above the shared one |
| Save shared | everyone | the model / Forms/*.form.json |
| Reset | — | the level below, or the automatic form |
The resolution when a card opens: personal → shared → the automatic form out of the metadata. A field that was not in the JSON appears anyway (the "other fields" group).
The root of the document is nodes[]: group first, then tabs / panel.
Inside a group — items[] (fields, decoration, nested groups).
Common cell properties#
Almost every element has them (a nested group too):
| Property in the UI | JSON | Meaning |
|---|---|---|
| Width, columns | span |
How many of the group's columns to take. On a separator the default is the whole row. |
| Start column | col |
Which column to begin at (1-based). Empty means the flow, left to right. |
| Align | align |
left / center / right. Empty means stretch to the cell. |
align on a group is the default for its children. On an element it is
an override. For "left" to be visible the cell has to have room to spare (one
wide column). A shrunk block is ≈ 28rem.
Expressions (visibleWhen / disabledWhen / bind)#
A safe evaluator, without eval (frontend/src/lib/formExpr.ts). The
values are the current form's.
Comparisons: == != < > <= >=. An operand is a field name, a number,
'a string', true / false / null. Bare truthiness: IsRush.
Negation: !IsApproved. && / || join left to right, with no
precedence.
Examples: Kind == 1, Quantity > Available, Kind == 1 && !IsDisabled.
bind is the {FieldName} template in the text of a label, a badge or an
alert. An empty condition = always visible.
An enum in a condition is an integer (the EDT's value), not a caption:
Kind == 0, not Kind == 'Base'.
The data source (source) — list / chart / tree#
One contract (SourceProps in the designer):
| Field | When |
|---|---|
| Entity | The name of a dictionary or a document (Item, ProductionOrder). |
| Filter | The query filter for the entity (the API query syntax), e.g. [IsRawMaterial]=1. |
| Service + Method + args | Instead of an entity: a service by name (IStockAvailabilityService), a method, the arguments. If service is set, entity is ignored. The service has to return an array of rows or { data: [...] }. |
| Selection key / field | Master→detail: the tree publishes a key, the list reads it and filters a column on it. |
| Live | Re-query on TableChanged (SignalR). For a dashboard. |
A chart also has groupBy, agg (count/sum) and value (the field to
sum).
Group (group)#
A container for the grid. It exists at the root of the form and as a palette element (a nested card).
When: a section of fields ("General", "Price assignment order"), a nested block inside a wide group.
| Property | JSON | How to use it |
|---|---|---|
| Group title | title |
Empty means no header (the top of a price type). |
| Title style | titleStyle |
heading (the default) or caption (a small caps subheading). |
| Icon | icon |
A Tabler key from the ribbon's catalogue (IconPackage, …). |
| Columns | columns |
1…12. One field = 1 column until a span is set. |
| Collapsible | collapsible |
A chevron; the body goes into a Collapse. |
| Group border | frame |
card — a card; plain — no border; band — a tinted band plus a rule under the heading (a 1C-style section). |
| Field captions | labels |
top or left. A field may override it. |
| Border style | borderStyle |
solid / dashed / dotted / none (only on card). |
| Align | align |
The default for the children. |
The usual pattern for a dictionary's header: one column, frame: "plain",
labels: "left", align: "left". A section of rules is frame: "band".
Do not put fields at the root of nodes without a group — the engine expects
a group.
Field (field)#
A slot for a metadata field. It does not create a column in the database
— it only puts an already existing field on the form. The binding is by
name (Name, Kind, MarkupPercent).
When: always, if the field belongs on the card. To hide it use the "Hide field" button; do not delete it from the metadata.
| Property | JSON | How to use it |
|---|---|---|
| Label (override) | label |
An override of the caption on this form. |
| Hint under the field | hint |
Grey text under the control. |
| Caption position | labelPos |
top / left / hidden, otherwise as the group's. |
| Input widget | widget |
Empty = by FieldKind. Explicitly: scan, textarea, select, text, dictionary (a text field holding a dictionary NAME, picked from the tenant's dictionaries by caption). |
| Show when | visibleWhen |
Hide the slot. |
| Disable when | disabledWhen |
The slot is visible, the input greyed out. For instance Kind != 1 on a base price type. |
The automatic widgets: a reference → a picker, an enum → a Select (stored as an integer), a number, a date, a boolean → a switch. A boolean with its caption on the left is centred on the track, not the way a text input is.
scan is a field that a scanner writes to (the item on a goods receipt).
textarea is long text. select makes sense when the type is not an enum
but the values are a list anyway.
A document is not posted by a field's button — only by toolbar → command.
Label (label)#
Static or data-bound text. It is not a field's caption.
When: an explanation under a section ("prices are set in the dictionary…"), a hint about the selected kind.
| Property | JSON |
|---|---|
| Text | text — the fallback, if bind is empty or the field is |
| Text binding | bind — the {Field} template |
| Show when | visibleWhen |
| Size / bold / dimmed | size, bold, dimmed |
| Font | fontFamily: default / monospace / serif |
| Icon | icon |
Two labels with different visibleWhen (Kind == 0 / Kind == 1) is the
usual trick, rather than one paragraph covering every case.
Separator (separator)#
A horizontal line. By default span is all of the group's columns.
When: to cut a block off visually inside a group without starting a new
group. label is the caption on the line.
Do not confuse it with a band group: a band is a section with a background,
a separator is only the line.
Empty space (spacer)#
A vertical gap. The property is Height, px (height). height: 0 is all
but invisible — use 8–24.
When: air between blocks, when you do not want a new group.
Image (image)#
A URL (https://… / data:) or a file upload of ≤ 512 KB (an inline data
URL). Width / height / corner radius. An unsafe src is not drawn.
When: a logo, a diagram on a card. Heavy photographs are better as a URL than as a data URL inside the layout's JSON.
Badge (badge) and alert (alert)#
A short status label vs a coloured panel with a title.
Both have text, bind, visibleWhen, color
(gray/blue/green/red/yellow/orange/violet) and variant (light / filled
/ outline). An alert also has title.
When: a badge for "Posted" or "Draft" in a row of fields. An alert for
"Short: {Quantity} needed, {Available} on hand" when Quantity > Available.
Not for input. Not instead of validation on the server.
Code block (code)#
A monospaced <pre>. Only text (no bind).
When: to show a formula, a sample barcode, a fragment of JSON on a help form. It is not executed.
Toolbar (toolbar)#
A row of buttons. variant is common to all of them. A button has text,
icon and optionally action.
action.kind:
| kind | name | What it does |
|---|---|---|
command |
the name of a document command | runCommand → api.executeCommand2. This is how a document is posted. |
status |
the name of a status | legacy, it duplicates the subtypes. Not to be used on new forms. |
A button without an action is just a caption (it does nothing). A command
is visible once the document has been saved. The command's name has to exist
on the type.
When: "Confirm the receipt", "Post" on a document card. Do not put a store's commands on a dictionary — the runtime will not find the command.
Table (table)#
A static grid: the columns (one per line) and the rows (cell | cell). This
is not a document's table part, and not a list.
When: a cheat sheet, a rate grid, a comparison on a form. The data does not come from the database. Live rows are the list element.
Chart (chart)#
Columns / line / area / donut (@mantine/charts). The source is
entity+groupBy, or a service. agg is count or sum (plus the value
field). height defaults to 220.
When: how many orders there are per subtype, the production output total per shop floor. For a single number a badge or an alert is enough.
live: true is for a dashboard that refreshes itself. On an ordinary record
card it is usually off.
List (list)#
Read-only: a table of the rows of an entity or of a service. It does not edit records and it does not replace the journal grid or a table part.
| Property | JSON | How to use it |
|---|---|---|
| Columns | columns |
Field names, comma-separated. Empty means all but the system ones. |
| Height / limit | height, limit |
The scroll and take. |
| Row highlight | rowRules |
{ when, color }; the first rule that matches colours the row. when is the same language as visibleWhen, but over a row of the list. |
The pairing with a tree: on the list source.selectionKey is the tree's key
and source.selectionField is the column to filter on. Until a node is
selected it says "Select a node in the tree".
When: the balances for the current item, a customer's orders on the customer's card. Editable rows on a header are a document's table part or a link table (the details panel), not this element.
Tree (tree)#
A self-parent hierarchy. source.entity is required, plus parentField
(often Parent) and labelField (often Name). The Selection key is
the name of a slot in the form's context (grp, say): a click writes the
node's id, and every list or chart with the same key re-reads.
When: the item groups on the left, the group's items on the right. Without a dependent list or chart, a tree is rarely useful.
The key has to match character for character on the tree and on the list.
HTML/JS (html)#
An <iframe sandbox="allow-scripts"> sandbox. The HTML/CSS/JS are stored in
the layout. In the designer, select the widget and press Edit HTML / CSS / JS:
Monaco tabs for HTML, CSS, and JS, and a live preview of the same sandbox on
the right. Apply writes the three strings back; the widget on the sheet already
paints with that CSS (clicks inside the iframe wait until the form is open —
the designer's overlay does not pass pointer events through).
The back end is reachable only through the ZuloForm bridge, and
services only through the allowServices allowlist (names, comma-separated).
The token does not reach the iframe.
The bridge:
async function run() {
const form = await ZuloForm.getData();
await ZuloForm.setField('Quantity', 1);
await ZuloForm.runCommand('ConfirmPutAway');
const rows = await ZuloForm.query('Item', { filter: '[Stock]<10', take: 50 });
const res = await ZuloForm.callService('IStockAvailabilityService', 'HasSufficientStock', { item: form.Item });
}
ZuloForm.onChange(() => { /* the form's values have changed */ });
run();
// after a service changed rows the card shows: re-read its grids
if (ZuloForm.refresh) ZuloForm.refresh();
The JS is a classic script, not a module: a top-level await is a syntax
error and the widget silently stays empty. Wrap the calls in an
async function. The frame has no allow-forms, so use a button click, not a
<form> submit.
When: whatever the palette does not have (your own widget, a canvas, a calculator). Not the first choice: a label plus a condition is cheaper and safer. The JS cannot reach the file system or the network directly. It can use only the bridge.
Dynamic block (dynamic)#
It calls a service by name (service, method; the method defaults to the
one in the contract). The service returns an array of layout elements,
and the same engine draws them.
When: the contents of the block depend on the data (a shop-floor monitor, different tiles per shift). The contract is stable: what changes is the function's return value, not the form's JSON.
The service has to return valid FormLayoutElement[] (a badge, a chart, a
field by name…). An error in the service means an empty block or a message,
not a broken card.
Tabs (tabs) and panels (panel)#
Not from the "new element" palette — these are the object's regions, which the designer shows below the groups.
- Tabs are a document's table parts. They can be renamed, hidden, reordered. A table part's columns are not drawn here.
- Panels are the system ones (
panel:details— a dictionary's lists and link tables on the right). Hiding one removes the region from the card. The tiles for attachments, document progress and totals are not thispanelbut the widgets catalogue.
On a dictionary that has collections the host splits the card itself: the
fields on the left, panel:details on the right. On a new record the right
side says "save it first". A link table is edited inline, in the grid.
Do not put a list in place of panel:details if you want the same link table
the metadata has: the panel is already bound to the object.
What to choose#
| Task | Element |
|---|---|
| An object's field on a form | field (it is already there; hide it or bring it back) |
| A section with a heading | group (band / card / plain) |
| An explanation, hidden by kind | label + visibleWhen / bind |
| "Cannot post: short" | alert + bind + visibleWhen |
| A status on one line | badge |
| A post button / a command | toolbar + action.kind: command |
| A rule inside a group | separator |
| Live rows out of the database (read) | list |
| A hierarchy plus the details | tree + list on one selectionKey |
| An aggregate per group | chart |
| A cheat-sheet grid | table (static) |
| The server decides the contents | dynamic |
| Your own UI | html (the last resort) |
| This record's lists and link tables | the panel:details region, not a list |
| A tile on a card (attachments, progress, totals) | the widgets catalogue, not the form palette |
The usual mistakes#
- A new field through the designer. The field goes into the object's metadata first, and only then appears on the automatic form or in "other fields".
Kind == 'Base'. An enum is a number.- A command on a dictionary.
runCommandlooks for a document's commands. statusinstead of a command. Only a command changes a subtype (once the record has been saved the subtype select is frozen).- A list in place of a table part. A table part is a tab of a region; a list writes no movements and saves no document lines.
- HTML, to hide a field.
visibleWhen/disabledWhenon thefield. - A service that is not named in an html element's
allowServices. The bridge answers "not allowed". - Different selectionKeys on the tree and on the list — the list is always empty ("select a node").
- Attachments or document progress through this designer. Those are the catalogue's tiles: the Widgets tab on the object, not the layout palette. The guide — widgets.
Examples of JSON/DSL per scenario — the cookbook. The invariants and capability traps — the forms system.
Same topic for other readers: User