ZuloOne
English
Sign in
Menu

🎨 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:

  1. The palette — decoration and data elements, plus the object's hidden fields.
  2. The canvas — groups and the grid. A click selects, a drag moves.
  3. 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 this panel but 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#

  1. 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".
  2. Kind == 'Base'. An enum is a number.
  3. A command on a dictionary. runCommand looks for a document's commands.
  4. status instead of a command. Only a command changes a subtype (once the record has been saved the subtype select is frozen).
  5. 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.
  6. HTML, to hide a field. visibleWhen / disabledWhen on the field.
  7. A service that is not named in an html element's allowServices. The bridge answers "not allowed".
  8. Different selectionKeys on the tree and on the list — the list is always empty ("select a node").
  9. 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

Where next#

Was this page helpful?