ZuloOne
English
Sign in
Menu

📖 Forms: cookbook (examples)

Updated

Practical examples of custom forms, both as JSON (what MetaFormLayout.LayoutJson stores) and as DSL (frontend/src/lib/formDsl.ts). Both forms produce the same result and both travel with the model (binding is by name).

← The forms system · Developer wiki · Designer: elements · Tile catalogue

How to save a layout#

import { form, group, field } from '../lib/formDsl';
import { api } from '../api/api';

const doc = form(
  group('Основное', { columns: 2 }, [field('Name', { span: 2 }), field('Code')]),
);
// the shared (travels with the model) layout of a dictionary card:
await api.saveFormLayout('Dictionary', dictMetaId, JSON.stringify(doc), { level: 'shared', modelId });

Or by hand: the same JSON can be dropped into the workspace bundle as Forms/<name>.form.json — it is imported together with the model.

1. A scan field (barcode) and an enum dropdown#

The Item field is entered with a scanner, Status from a list (an enum is drawn as a Select automatically, by type).

JSON

{
  "version": 4,
  "nodes": [
    { "type": "group", "title": "Приёмка", "columns": 2, "items": [
      { "type": "field", "name": "Item", "widget": "scan", "span": 2 },
      { "type": "field", "name": "Quantity" },
      { "type": "field", "name": "Status" }
    ]}
  ]
}

DSL

form(
  group('Приёмка', { columns: 2 }, [
    field('Item', { widget: 'scan', span: 2 }),
    field('Quantity'),
    field('Status'),          // enum → a Select, by the field's type
  ]),
);

Field widgets: scan, textarea, select, text (or empty = automatic, by type).

2. Action buttons (a command / a status)#

Buttons on a document form fire a command (bound by NAME). Posting a document from the UI (changing the subtype and running the posting entries) is only possible through a command: once the document is saved, the subtype select is frozen. A command with that name has to exist on the document type — the ConfirmPutAway example below only illustrates the markup. The store commands and those of the other areas — commands and subtypes.

A status transition (action.kind: "status") is legacy and duplicates subtypes; do not wire new forms to it.

JSON

{
  "type": "toolbar",
  "variant": "light",
  "items": [
    { "text": "Подтвердить приёмку", "icon": "IconCheck",
      "action": { "kind": "command", "name": "ConfirmPutAway" } },
    { "text": "В черновик",
      "action": { "kind": "status", "name": "Draft" } }
  ]
}

DSL

import { toolbar, command, status } from '../lib/formDsl';

toolbar([
  { text: 'Подтвердить приёмку', icon: 'IconCheck', action: command('ConfirmPutAway') },
  { text: 'В черновик', action: status('Draft') },
]);

How it works: the document form provides a FormRuntimeContext; the button calls runCommand('ConfirmPutAway') — the command is resolved by name among the document's commands and executed (api.executeCommand2) — or changeStatus('Draft'). A command button is visible once the document has been saved.

3. A data-bound alert / badge / label (highlighting from data)#

The text is assembled out of the form's values with the {Field} template, and visibility is governed by the visibleWhen condition (a safe evaluator, not eval).

JSON

{ "type": "alert", "color": "red",
  "bind": "Нехватка: нужно {Quantity}, есть {Available}",
  "visibleWhen": "Quantity > Available" }

DSL

import { alert, badge } from '../lib/formDsl';

alert('', { color: 'red', bind: 'Нехватка: нужно {Quantity}, есть {Available}', visibleWhen: 'Quantity > Available' });
badge('', { color: 'green', bind: 'Статус: {Status}', visibleWhen: "Status == 'Posted'" });

visibleWhen conditions: the comparisons == != < > <= >= (the operands are a field name, a number, a 'string', true/false/null), bare truthiness (IsRush), negation (!IsApproved), and && / || chaining (left to right). It works on label, badge, alert and on a field (type: "field"). A field also has disabledWhen — the control stays on the form but greyed out (like the base price type in 1C: the markup and the base type are unavailable while Kind != 1). In the designer these are Show when and Disable when.

4. A list with rows highlighted from data#

A list of an entity's records with per-row highlighting driven by rules (when is the same safe evaluator the data-bound decor uses; the first rule that matches colours the row). You author it through the DSL or JSON.

JSON

{ "type": "list", "height": 320,
  "source": { "entity": "Item", "filter": "[IsRawMaterial]=1" },
  "columns": ["ID", "Name", "Stock"],
  "rowRules": [
    { "when": "Stock < 10", "color": "red" },
    { "when": "Stock < 50", "color": "yellow" }
  ] }

DSL

import { list } from '../lib/formDsl';

list({ entity: 'Item' }, {
  columns: ['ID', 'Name', 'Stock'],
  rowRules: [{ when: 'Stock < 10', color: 'red' }, { when: 'Stock < 50', color: 'yellow' }],
});

4b. A tree plus a dependent list (master → detail)#

The tree is built from the entity's self-parent field and publishes the selected node under selectionKey; the dependent list reads it (selectionKey + selectionField) and filters its own rows. It works in any form (the selection provider lives in LayoutFields).

JSON

{ "type": "tree", "selectionKey": "grp",
  "source": { "entity": "ItemGroup", "parentField": "Parent", "labelField": "Name" } }

{ "type": "list",
  "source": { "entity": "Item", "selectionKey": "grp", "selectionField": "ItemGroup" },
  "columns": ["ID", "Name", "Stock"],
  "rowRules": [{ "when": "Stock < 10", "color": "red" }] }

DSL

import { tree, list } from '../lib/formDsl';

tree({ entity: 'ItemGroup', parentField: 'Parent', labelField: 'Name' }, 'grp');
list({ entity: 'Item', selectionKey: 'grp', selectionField: 'ItemGroup' },
  { columns: ['ID', 'Name', 'Stock'], rowRules: [{ when: 'Stock < 10', color: 'red' }] });

Until a node is picked in the tree, the dependent list shows "Select a node in the tree" instead of rows.

5. A chart (@mantine/charts)#

Bars / line / area / donut out of a query: a grouping plus an aggregate (count/sum). Author through the DSL/JSON; the designer palette may not expose every chart knob yet.

JSON

{ "type": "chart", "chartType": "bar", "height": 240,
  "source": { "entity": "ProductionOrder", "groupBy": "Subtype", "agg": "count" } } }

DSL

import { chart } from '../lib/formDsl';

chart('bar', { entity: 'ProductionOrder', groupBy: 'Subtype', agg: 'count' }, { height: 240 });
chart('donut', { entity: 'Item', groupBy: 'ItemGroup', agg: 'sum', value: 'Stock' });

5b. A service call as the data source (for a list / a chart)#

source can call a service/script by name (the name→metaId resolution happens at run time, as it does for command buttons) instead of querying an entity — the service returns the rows. Portable: the service name is resolved on the customer's instance.

JSON

{ "type": "list",
  "source": { "service": "IStockAvailabilityService", "method": "LowStock", "args": { "threshold": 10 } },
  "rowRules": [{ "when": "Available <= 0", "color": "red" }] }

DSL

list({ service: 'IStockAvailabilityService', method: 'LowStock', args: { threshold: 10 } },
  { rowRules: [{ when: 'Available <= 0', color: 'red' }] });

chart('bar', { service: 'IProductionStats', method: 'ByShop', groupBy: 'Shop', agg: 'sum', value: 'Output' });

The service must return an array of rows (or { data: [...] }). Access is the ordinary permissions on the service. Precedence: if service is set, it is the one called, otherwise entity.

6. A real-time status dashboard#

live: true on a list/chart source → the element is re-queried automatically whenever any row of the table changes (the SignalR TableChanged, on top of the push that fires when a record is saved or a document's status changes). Ideal for a standalone dashboard.

JSON

{ "type": "chart", "chartType": "bar", "height": 240,
  "source": { "entity": "ProductionOrder", "groupBy": "Subtype", "agg": "count", "live": true } } }

DSL

chart('bar', { entity: 'ProductionOrder', groupBy: 'Subtype', agg: 'count', live: true }, { height: 240 });
list({ entity: 'PickTask', live: true }, { columns: ['ID', 'Subtype'] });

Build such a chart in a standalone form (Screens (forms)) — change a document's status in another session and the dashboard updates without a reload.

7. A custom HTML/JS widget (sandbox + bridge)#

Your own HTML+JS has access to the form's data and to the back end through a bridge, and runs inside an isolated <iframe sandbox>. It is stored as strings in the layout → it travels with the model. In the designer it is the HTML/JS widget palette item (Edit HTML / CSS / JS opens the editor with a live preview, plus an allow-list of services).

The element

{ "type": "html", "allowServices": ["IStockAvailabilityService"],
  "html": "<ul id='list'></ul>", "js": "/* see below */" }

The JS inside the widget (only the ZuloForm bridge is available)

async function render() {
  const form = await ZuloForm.getData();               // the form's values
  const res  = await ZuloForm.callService(             // the back end over the bridge
    'IStockAvailabilityService', 'HasSufficientStock',
    { item: form.Item, qty: form.Quantity });
  document.getElementById('list').textContent =
    res.ok ? 'Enough' : 'Short';
}
ZuloForm.onChange(() => render());                     // reacting to form changes
render();

⚠️ No top-level await. The widget's JS runs as a classic <script> wrapped in try { … }, not as a module. A bare await at the top level is a syntax error, so the whole script does not run and the widget stays empty with no message. Put the work in an async function and call it.

⚠️ No <form> submit. The frame is sandbox="allow-scripts" without allow-forms, so the browser blocks a form submission. Use a <button type="button"> with a click handler.

Colours: use the host's tokens. The frame gets the host's theme as CSS variables — var(--mantine-color-body), --mantine-color-text, --mantine-color-dimmed, --mantine-color-default, --mantine-color-default-border, --mantine-color-orange-6, … — and they follow a theme switch live: the host posts the new values into the frame, and the frame is not reloaded, so a half-typed value survives. A widget with hard-coded colours stays in one palette.

The theme's accent is --mantine-primary-color-filled (with -filled-hover, -light, -light-color and -contrast for the text on it). Paint the main button, checkboxes (accent-color) and focus rings with it, not with a fixed palette colour: a preset like Corporate Navy is blue, and an orange Save button in it looks foreign.

body { --acc: var(--mantine-primary-color-filled, var(--mantine-color-orange-6)); }
#save { background: var(--acc); color: var(--mantine-primary-color-contrast, #fff); }
input[type=checkbox] { accent-color: var(--acc); }

The bridge: getData()/onChange()/setField()/callService()/query()/runCommand()/refresh(). refresh() re-reads the card's table parts and link-table grids: call it after a service changed rows the card shows (the item card's price board does, after setting a price, so Price history shows the new row). Older platforms have no refresh — guard with if (ZuloForm.refresh). Back-end calls are proxied by the host (the token never reaches the sandbox; access follows the allowServices allow-list). More detail — the forms system.

8. The list form: row highlighting + a summary#

On a dictionary list and on a document journal the List appearance button (the dashboard icon in the action pane) opens the editor: field, comparison and colour, with a live row example on the right. For a dictionary it also holds the default view (table or cards) and the card contents — image, title, subtitle, badge and extra fields, with a preview of the card itself. The first matching rule colours both the table row and the card. It is stored as a FormKind="List" layout. The summary strip above the grid, if the layout already had one, is kept on save.

Save for me / Save shared#

The editor saves at the same two levels as the record-form designer, and resolution is the same: the caller's personal layout wins over the model's shared one.

Button Row Travels with the model
Save for me UserMetaId = the caller, ModelId empty no
Save shared UserMetaId null, ModelId = the working model yes (Forms/<name>.form.json)

Warning

A shared list layout is model content, so WorkingModelGuard makes it read-only once another model owns it: saving answers 409 — "MetaFormLayout 'Item.List' belongs to another model". The dialog reads sharedModelId from getFormLayout and turns Save shared off with the owner's name in the tooltip, leaving Save for me — which writes a personal row with an empty ModelId the guard skips. That is why a plain user can switch a shipped dictionary to tiles without touching anybody's model.

The presentation (table / cards) also lives in a per-user preference that the presentation ribbon button toggles without saving anything; a layout save pushes its own value into that preference so the list changes immediately.

JSON

{
  "rowRules": [
    { "when": "Stock < 10", "color": "red" },
    { "when": "Stock < 50", "color": "yellow" }
  ],
  "presentation": "cards",
  "cards": {
    "imageField": "Photo",
    "titleField": "Name",
    "subtitleField": "Description",
    "badgeField": "IsSellable",
    "lines": ["DefaultSalePrice"]
  },
  "summary": { "version": 4, "nodes": [
    { "type": "chart", "chartType": "bar",
      "source": { "entity": "Item", "groupBy": "ItemGroup", "agg": "count", "live": true } }
  ] }
}

9. A server-driven block (type:dynamic)#

The element calls a service by name and draws the layout fragment that comes back. The function's return value decides at runtime which elements there are and what shape they take. It is portable because it is bound by the service name.

JSON

{ "type": "dynamic", "service": "IShopMonitor", "method": "Render", "args": { "shop": "A" } }

The service returns an array of layout elements, e.g. [{ "type":"badge","text":"OK","color":"green" }, { "type":"chart", "chartType":"bar", "source":{…} }].

DSL

import { dynamic } from '../lib/formDsl';
dynamic('IShopMonitor', { method: 'Render', args: { shop: 'A' } });

11. A band section and a dictionary card split left and right#

A group with frame: "band" is a caption with a rule on a tinted strip. align pushes short fields to the left (otherwise they stretch across the whole card). If a dictionary has a list or a link table, the engine puts the fields on the left by itself and panel:details — the collection tabs — on the right. The example is the Item card with the PriceTypeHistory link table. The group properties and align in the designer — the designer's elements.

JSON

{
  "version": 4,
  "nodes": [
    { "type": "group", "title": "", "columns": 1, "frame": "plain", "labels": "left", "align": "left",
      "items": [{ "type": "field", "name": "Name" }, { "type": "field", "name": "ID" }] },
    { "type": "group", "title": "Порядок назначения цен", "columns": 1, "frame": "band", "labels": "left", "align": "left",
      "items": [{ "type": "field", "name": "Kind" }] },
    { "type": "panel", "key": "panel:details" }
  ]
}

DSL

form(
  group('', { columns: 1, frame: 'plain', labels: 'left', align: 'left' }, [field('Name'), field('ID')]),
  group('Порядок назначения цен', { columns: 1, frame: 'band', labels: 'left', align: 'left' }, [field('Kind')]),
);

The details panel is not compulsory in the DSL: the dictionary host adds it itself when there are collections.

12. Canvas pins (version 5)#

A v5 canvas form stores x,y,w,h. Optional pins attach an edge to the parent, a sibling id, or guide:x:N / guide:y:N. No pins → today's absolute layout, including the phone stack. Any pin → solveTree on resize.

The designer writes these: snap, cyan edge-dot drag, Pack in two columns. Edit them by hand only when a layout is generated.

{
  "version": 5,
  "canvas": { "width": 960, "height": 400, "grid": 8, "guides": { "x": [480], "y": [] } },
  "controls": [
    { "id": "a", "type": "field", "name": "Code", "x": 0, "y": 0, "w": 460, "h": 40,
      "pins": [
        { "edge": "l", "to": "parent", "other": "l", "offset": 0 },
        { "edge": "r", "to": "guide:x:480", "other": "l", "offset": -12 }
      ] },
    { "id": "b", "type": "field", "name": "Name", "x": 492, "y": 0, "w": 468, "h": 40,
      "pins": [
        { "edge": "l", "to": "a", "other": "r", "offset": 12 },
        { "edge": "r", "to": "parent", "other": "r", "offset": 0 }
      ] }
  ]
}

Portability (the rule behind every example)#

Everything above is data in a shared MetaFormLayout (owned by the model). The bindings are by name (field/command/status/service). That is why a layout travels with the model (Forms/*.form.json) and works on another instance with no front-end rebuild.

Where next#

Was this page helpful?