ZuloOne
English
Sign in
Menu

📐 The forms system

Updated

How record/document forms are built, where to customize them and how the forms system is evolving. A form is data (it travels with the model), not code.

← Developer wiki

The designer's element catalogue (how to use each one) — the form designer. Ready-made examples (JSON + DSL) — Forms: cookbook. The catalogue of record and dashboard tiles — widgets.

The basic idea#

By default a form is generated automatically from the object's fields. A designed layout may sit on top of it — all it does is reorder and style the fields that already exist and add decor, without breaking the auto-form (new fields still show up).

Which form opens#

There is one card and one list per object, never a choice between several forms. What a user sees is decided in this order:

  1. their personal layout (Save for me), if they saved one;
  2. else the shared layout (Save shared), the model content everybody sees;
  3. else the automatic form generated from metadata.

A layout never replaces the automatic form: a field the layout does not place shows under Other fields, so a new field is never lost. The card's module, CSS and TypeScript live on the shared, language-neutral layout; another model adds to it through an extension, not by editing it. A screen (standalone form) is a separate form not tied to a record and opens from a menu item.

Where to see it: Developer → Forms → Forms is one page for every form: it edits screens and lists every designed card and list with its owner model, personal copies, languages, code and extensions (GET /api/metadata/form-layouts/catalog); an object not listed there opens its automatic form. The card designer says the same in one line above the canvas — automatic form / shared layout of model X / your own layout — and where Save shared will write.

Shared layouts in layers#

A shared layout is model content, and it is never edited from another model. Under a working model, Save shared writes that model's OWN row for the object (PUT /api/metadata/form-layouts takes the model from the X-ZuloOne-Model header; under «all models» it uses modelId). So a customer working in Local re-arranges the product's Item list for everybody without touching Inventory/Forms/Item.List.form.json: Local/Forms/Item.List.form.json appears next to it.

On resolve, the highest model layer that has a shared row wins (FormLayoutLayers; a disabled model's row is ignored); within that model the requested language wins over the neutral variant. The response says which model is shown (sharedModelId), every model holding a row for that language (sharedModels, lowest layer first) and the model of the row that carries the code (codeModelId). Reset shared layout under a working model deletes that model's row only, so the lower layout comes back.

The card's code is NOT overridden: module, CSS, TypeScript and extensions stay on the lowest-layer language-neutral row, and a higher layout re-arranges the fields around them. Save shared is off only when the target model is read-only on the stand (a product model without core-dev mode).

The canvas layout (version 5)#

The designer writes version 5: a canvas, like a report.

{ "version": 5, "canvas": { "width": 960, "height": 454, "grid": 8 },
  "attributes": [], "columnHints": [ { "name": "part:Lines.Quantity", "hint": "Must be positive" } ],
  "controls": [
    { "id": "c1", "type": "box", "name": "Head", "title": "Head", "x": 0, "y": 0, "w": 960, "h": 112, "anchor": "ltr",
      "children": [ { "id": "c2", "type": "field", "name": "FromCell", "x": 0, "y": 0, "w": 461, "h": 58 } ] },
    { "id": "c3", "type": "pages", "x": 0, "y": 124, "w": 960, "h": 330, "anchor": "ltr",
      "pages": [ { "title": "Lines", "children": [ { "id": "c4", "type": "region", "key": "part:Lines", "x": 0, "y": 0, "w": 936, "h": 256, "anchor": "ltrb" } ] } ] }
  ] }
  • A control is x, y, w, h in design pixels plus anchor (l t r b, default lt): an anchored edge keeps its distance to that edge when the canvas is larger than the design size (anchorStyle). Optional pins attach an edge to the parent, a sibling, or a guide (canvas.guides); the runtime solver (canvasConstraints.solveTree) recomputes geometry when the window resizes. Controls without pins keep today's absolute layout. Containers: box (children) and pages (pages of children); their children's coordinates are relative to the container's inner area.
  • field renders through the record's or screen's field slot, region through a region slot (a table part, a panel), everything else through the same DecorNode the grid uses; every control carries data-zf={name}.
  • Below 600 px of width the runtime stacks the controls in reading order (readingOrder), unless any control has pins — then solveTree reflows and the stack is skipped.
  • Right-to-left (Arabic) mirrors at run time: anchorStyle(..., rtl) swaps the left and right edges, so the design's left is the reading start. The designer stays left to right.
  • columnHints are edited on a part: region in the designer.
  • An html control is edited in Edit HTML / CSS / JS (Monaco + a live sandbox preview). The three strings (html, css, js) stay in the layout; the sheet already paints the iframe.
  • Fields and regions the canvas does not place still render below it, as "Other fields" does on a grid form.
  • A grid layout opens in the designer converted by canvasFromGrid (frontend/src/components/forms/canvas/canvasModel.ts) and becomes v5 when saved. Field items named part:<Part>.<Column> (table-part column hints) move to columnHints; field items the form cannot render are dropped.
  • Extensions: a fragment's controls keep their coordinates; grid nodes and insert items are stacked below the target (FormLayoutMerge, mirrored by frontend/src/lib/formLayoutMerge.ts).

Code: canvas/CanvasForm.tsx (runtime), canvas/CanvasDesigner.tsx (designer), LayoutFields hands a v5 document to CanvasForm.

Storage and resolution#

  • A layout is stored as JSON in the MetaFormLayout entity (src/ZuloOne.Shared/Metadata/MetaFormLayout.cs), in the LayoutJson field.
  • The keys: ObjectType (Dictionary|Document|Standalone), ObjectMetaId, FormKind (Record, List — the list form's row rules, default view and card composition, see the cookbook — and Standalone), UserMetaId (personal/shared), Language.
  • Resolution at render time: personal (UserMetaId set) → shared (UserMetaId=null, model content) → the auto-form. Plus the variant for the interface language.
  • The API is FormLayoutsController (/api/metadata/form-layouts): Resolve / Save / Reset. Shared layouts travel with the model through the workspace export (Forms/<name>.form.json).

Localized layout text#

Form layouts use the same inline language-object convention as metadata captions. These properties accept either a plain string or { "en", "ru", "uk", "ar" } values:

  • field.label — the label override for a field slot;
  • field.hint — the short hint shown under that field label;
  • separator label;
  • group title;
  • tab title for table-part and panel tabs.
{
  "type": "field",
  "name": "Customer",
  "hint": {
    "en": "Sets the buyer for contract checks.",
    "ru": "Задает покупателя для проверки договора.",
    "uk": "Задає покупця для перевірки договору.",
    "ar": "يحدد المشتري لفحوصات العقد."
  }
}

A plain string is still valid and is treated as the English/base value. The front-end resolver uses the current interface language, then en, then the first non-empty value in the object. Empty strings do not win the fallback. The back end keeps LayoutJson as an opaque JSON string: FormLayoutsController, workspace import/export and Forms/*.form.json do not deserialize field items, so no C# DTO has to know about the union.

The designer writes back the language currently selected in the UI. Editing English on a string keeps a string. Editing another language promotes the value to an object and preserves the previous string as en. This is why shared layouts can carry one record-form geometry while still showing field labels, hints, group titles and tab titles in the user's language.

The limit is deliberate: table-part grid columns do not read layout hints. A grid column header comes from table-part field metadata, while field.hint belongs to a form-layout field slot. Put line-entry help in the tab guide or in a custom line editor, not in a header-field hint.

Sandboxed HTML widgets get the same language context. The generated iframe has <html lang="..." dir="...">, and the bridge exposes ZuloForm.language, ZuloForm.dir and ZuloForm.getLanguage(). Use those values to choose text direction or an internal label; do not read the host DOM from the null-origin sandbox.

Traps

  • Do not localize by duplicating the layout. One shared layout should carry the geometry; inline text carries the language variants.
  • Do not teach C# about every layout text union. The server stores the JSON opaquely so old layouts and new localized values travel through the same path.
  • Do not expect hints in table grids. Grid columns are a region renderer, not field slots.

The renderer and the designer (front end)#

  • The schema types are in frontend/src/api/models.ts (FormLayoutElement, FormLayoutDoc).
  • The engine plus the WYSIWYG designer — frontend/src/components/forms/FormLayoutView.tsx (LayoutFields renders, FormLayoutDesigner is the visual editor).
  • Field widgets — the registry frontend/src/components/DynamicField.tsx (see below).
  • The host for record/document forms — frontend/src/windows/RecordWindows.tsx.
  • A document on its card is one object, as in Mix: the header and every table part (GoodsIssue.Lines and the other bindings) live in the form's memory. "Save" writes the whole graph (header + tableParts); Create/Update/Get return that same graph. Lines can be entered before the header has ever been saved. A PUT without tableParts still leaves the lines alone (third-party clients / header-only). The header's events (OnBeforeSave / insert / update / post) see the same graph: document.Lines and the other collections are hydrated out of tableParts, and line mutations made in a handler go out in the same write (Flush serializes the whole line — ToRecord; an empty WriteBack no longer zeroes out Quantity and the other fields). A header-only PUT puts no graph into the event; use GetDocumentAsync there, as before. A line field in the grid (with no Save) calls POST /api/events/tablepart/field — OnValidateField, then OnFieldChanged on the table part type. Inside the handler the parent document is Owner<SalesOrder>(context) (the header out of the form's memory). The computations (unit, price) are written there, not in the front end. Persist runs those same hooks, and there Owner<T>(context) is the stored header: the document is written before its lines. So fill only the dependent fields that are empty, otherwise Save overwrites a hand-entered unit and price. A field on a dictionary card follows the same path: POST /api/events/dictionary/field (and on Save — DataService). A dictionary's computations belong in the handler too, not in the front end. A document header field is POST /api/events/document/field: the same hooks on TypedDocumentEventHandler, and in the handler header.Lines comes out of the form's memory. A header-only Save puts no graph into the event. All three answers are read as a diff against what was sent, never applied whole (fieldHookChanges in lib/formDataEntry.ts). The answer carries the record as it was when the key was pressed, so spreading it over the live form puts back everything typed while the request was in flight: typing E2E-ITEM-1 into a dictionary whose hook runs on Name left the box reading E2, because the answer to the second keystroke landed after the tenth. A key the hook left alone is echo and the form keeps its own, newer value; a key the hook moved wins — the edited field included, because normalising it is something a hook is entitled to do. A value that only changed shape in transit (a Date turned into its ISO string, a number handed back as a string) does not count as moved.
  • An empty reference arrives from the server as Guid.Empty (0000…0000). The cells (formatValue, ReferenceValue) show blank, not the zero GUID itself.

The workspace tabs (ZuloOneTab in DockviewHost.tsx) sit on a flat strip with no tray: inactive ones are text and an icon, the active one takes the panel background and an accent line underneath. Two lines: the object's short name and a preview (No. 1001 · Delivered / the record's name). Parsing the old Type: number (subtype) titles and the icon palette by kind (the menu's iconColor wins) — frontend/src/workspace/tabChrome.ts. A journal/list has no second line when no preview is set; if the React Query cache already holds the journal's or list's count, the second line reads "{n} documents" / "{n} cards", with no new request. For a record the date is mixed in from the journal cache when there is one. Dirty is a dot in the theme's accent colour, not a " •" in the text. The hover card appears only when the type has placements in the tab widget slot that can handle the current target: a record — record/list, a journal/list — list only. An empty slot, or nothing but record widgets on a journal, means the hover card does not appear. The popover itself is a header (icon, name, "kind · preview"), the compact KPIs of the slot's widgets and an "Open" button — not the full record card. A register totals tab (totals:{id}, not Information) is a built-in card for the calendar year: Balance / Income / Outcome and how fresh the calculation is, with no tab slot.

A menu command that opens (or refocuses) a tab scrolls the strip to it — frontend/src/workspace/revealActiveTab.ts. Dockview's own scrolling (Tabs.setActivePanel) is not enough: it sums bare clientWidth values without chip margins, runs synchronously (the new tab is not laid out yet, and when a menu click comes from another route the whole strip is display:none) and does nothing when the tab is already active. Therefore scheduleRevealTab runs two frames later from onDidActivePanelChange, from focusPanel and when returning to /workspace; scrollLeft is set immediately. The strip uses dockview scrollbars: 'native' (custom overlay rewrites scrollLeft from a deltaY tracker and fights wheel/trackpad). A ResizeObserver in GroupActions repeats the reveal when the strip becomes narrower (the scroll arrows appear one tick after the tab) — but not while the user is scrolling: the chevrons mounting shrinks the strip, and snapping back to the active tab against inertial wheel looks like an infinite scroll. Wheel, pointer and the arrow buttons set a short hold; activation still forces a reveal. The check is frontend/e2e/workspace-tab-reveal.spec.ts.

Layout elements#

How to use every item in the palette (properties, when to reach for it, the mistakes) — the form designer.

Nested groups/cards, columns and spans, tabs (table parts), system panels (status/attachments/details), decor (label, separator, spacer, badge, alert, image, code, toolbar, table). Fields are addressed by name (name) — which is what makes a layout portable.

Horizontal alignment (align: left | center | right) exists on a group (the default for its children) and on an element (an override). An empty value stretches to the full cell, as before. In the designer it is the "Align" segment.

The group border (frame): card (the default) and band are both drawn by a single SurfaceCard — a white panel, radius 16, a soft shadow, like the grid and the record widget. Inside, band keeps the accent caption and a rule running to the edge; plain has no border. The record card (BaseForm chrome=canvas) is no longer one single sheet: groups, line tabs and widget slots (header/side/body/footer) are separate panels on the grey PanelShell canvas.

A dictionary card with a list (DictionaryList) or a link table splits automatically: fields on the left, the collection tabs on the right (panel:details). A new record reserves the right-hand panel ("Save the record to edit related lists"). Documents do not split like that — their table parts are tabs already.

A link table (LinkTable) shows up on the card of the dictionary its column points at. If the link table has isPrimaryKey on the reference, only that side gets the tab (which is how PriceTypeHistory hangs off the item and not off the price type).

Two invariants (hold them through any extension)#

  1. Portability = data. A custom form is a shared MetaFormLayout (owned by the model) and travels with the model. Compiled React components are not a unit of transfer (only the last-resort escape hatch).
  2. Binding by name, not by MetaId. Fields/commands/statuses/services are bound by a stable name → the layout carries over to another instance.

Field widgets (the registry)#

DynamicField picks the control by the field's kind (FieldKind), with the option to override the widget from the layout (field.widget):

  • by kind: reference→a picker, enum→a Select (values out of the EDT; stored as an integer), integer/decimal→numeric, boolean→a switch, date/datetime→date pickers, everything else→text;
  • explicit overrides: scan (barcode/scanner), textarea, select, text.

A new widget is added to the DynamicField.tsx registry and to the choice list (WIDGET_CHOICES) — and becomes available in the designer (the "Input widget" property).

Layout-as-code (DSL)#

A layout can be written as code — the typed builder frontend/src/lib/formDsl.ts (form()/group()/field()/toolbar()/alert()/…) emits the same FormLayoutDoc. It stays data → it travels with the model.

const doc = form(
  group('Приёмка', { columns: 2 }, [
    field('Item', { widget: 'scan', span: 2 }),
    field('Quantity'),
  ]),
);

On a document card, Save / Ctrl+S / OK first commits the open table-part lines (the same commit the "Save row" button runs), and only then writes the header. If a line fails its required fields, the header does not go out. The same holds for the detail lists on a dictionary card.

Standalone forms (a screen outside a record)#

A screen of its own (a store console, a shop-floor dashboard) that is not tied to a record is a shared MetaFormLayout with ObjectType="Standalone", addressed by name. The endpoints: GET/PUT/DELETE /api/metadata/form-layouts/standalone[/{name}] (src/ZuloOne.Core/Controllers/FormLayoutsController.cs). The registry/renderer/JSON editor is frontend/src/pages/tools/StandaloneFormsPage.tsx (the ribbon: Developer → Forms → Forms, the Screens section). It is rendered by the same LayoutFields without the record slots (data/decor elements only: chart/list/tree/…). It belongs to a model (ModelId, chosen on the page) → the workspace export puts it at <Model>/Forms/<name>.form.json (verified with export-all). The back-end tests are src/ZuloOne.Tests/FormLayoutsTests.cs (round-trip, formKind isolation, model binding).

A standalone form can also carry attributes, a C# module, CSS and TypeScript; see form modules.

Custom forms: layers and traps#

Layouts live in MetaFormLayout and ship with the model; new widgets do not need database migrations.

Layer What you get Trap
Field widgets + DSL scan, textarea, widget?, formDsl.ts Not a catalogue tile (MetaWidget) or card slot (MetaObjectWidgetPlacement)
Commands Buttons by command/status name, FormRuntimeContext Metadata on the document still owns the command
Decor bind, visibleWhen, safe expressions Not arbitrary C#
Embedded data Chart, list, tree, service source, designer palette Standalone screens use the same renderer
Live data live: true → TableChanged / SignalR Without live, saves elsewhere do not refresh the element
List form FormKind=List: row rules, summary, card JSON List column/tree authoring is still JSON-heavy — cookbook §8
Server-driven type: dynamic, ClientAction Service must return fragments the renderer knows
Hooks + sandbox Field/document/table-part hooks; html + ZuloForm Top-level await in widget JS fails silently; iframe has no allow-forms
Catalogue MetaWidget, object Widgets tab — widgets Journal card grid shares StandardGrid with the table; tab slot is the workspace hover card
Modules Attributes, C# module, scoped CSS/TS — form modules Extending another model's form is a separate binding path

A field widget (field.widget = scan / textarea) is not the same thing as a catalogue widget (MetaWidget) or a panel slot (MetaObjectWidgetPlacement). The panel is object metadata (the "Widgets" tab in the designer), not a form node.

How to use the catalogue (a preset ≠ a tile on a form; the guides for placing and for authoring) — widgets.

The single catalogue (MetaWidget) is Developer → Metadata → Widgets (/widgets). The Surface field: dashboard | form | both (empty on read = dashboard, not "all surfaces"). Assignments (MetaWidgetAssignment) decide who sees the widget in a form's palette: the object kind (Document/Dictionary) and optionally a concrete type (objectMetaId null = every type of that kind). The palette of the "Widgets" tab is GET /api/metadata/widgets/assigned. Creating a placement requires widgetMetaId; widgetType is always copied off the catalogue. ConfigJson is the envelope { chrome, desktop, form }; the chrome is title, showTitle, density. The tab slot hides the title; density is always compact. Placement order is displayOrder; the slot changes without a delete.

Four types are form-only: field-group (a set of fields through DynamicField), command-buttons (executeCommand2 buttons, as in CommandBar2), related-documents (the link family, not the graph), record-image (a single Image field).

The subtype-stepper widget on a document card is a vertical timeline of subtypes (like "Order Progress"): the steps already passed get a blue circle with a tick, the date and who changed the status (GET /api/documents/{typeId}/{id}/progress, out of the Document.SubtypeChange audit); future ones get an empty outline. When there is no audit yet, the first passed step keeps the header's creation stamp. The step captions are the DisplayCaption of the interface language (subtypeCaption_ru / _ar on the object), not the English code Draft / Picking.

The standard set of types (the Widgets catalogue + the tile settings gear + the object's "Widgets" tab). The dashboard editor and the form editor may differ (DesktopConfigEditor / FormConfigEditor); for types that live on a single surface it is one and the same editor:

Type Where What it draws Settings
count dashboard a number table (a dropdown of dictionaries/documents), filter
total dashboard a number or a chart the totals source, columns, period, chart kind
chart dashboard / card donut / bars / line / area table + the groupBy/sum fields (dropdown), count|sum
script dashboard a table / a number / a chart the report script, the kind, parameters
custom-html dashboard your own HTML/JS markup, CSS, JS, services
subtype-stepper card the document's progress title
document-totals card a summary: subtotal / discount (%) / taxable amount / VAT (%) / total + the amount in words table part + the line fields (dropdown: Name — Caption)
record-kpi card a KPI table + the amount field (dropdown), the {recordId} filter
related-snapshot card a snapshot of the related record: credit limit / outstanding (a bar) + YTD revenue / orders / on time the reference field, the dictionary, the Receivable/Revenue registers, the orders table
attachments / activity / notes card attachments (the card: a type icon + name + size + upload), the internal activity feed (avatar · date · "subtype — who", the full history under "View all"), notes the object's table / field (dropdown)
field-group card the owner's chosen fields, with the same controls the card uses fields: string[]
command-buttons card the record's command buttons (visibility as in CommandBar2) commandMetaIds: string[]
related-documents card the list of the document's link family the link type, the limit, whether to show the subtype
record-image card a single Image field (preview / replace / clear) fieldName

The link family the card shows (GET /api/documents/links/family/{id}, the Related documents window and widget) has two kinds of edge. A link is a DocumentLinks row a script wrote with IDocumentManager.AddLinkAsync. A reference is a document's own header field that holds another document's id: a Guid field that is not a dictionary reference, such as SalesReturn.OriginalInvoice. The document that names the other is its child, and the edge carries viaField with the field caption as its label; the graph draws it dashed. So a return entered by hand against an invoice shows on the invoice's card, not only on the return's. Scripts keep GetDocumentFamilyAsync(id) = recorded links only; the card calls the overload with includeReferences: true. Table-part fields are not read.

Customizations stay data: binding is by name, so layouts travel with the model without rebuilding the front end.

What's not there yet#

  • A form layout cannot localize arbitrary label, badge, alert or toolbar body text yet; those are still plain strings.
  • Table-part grid columns do not show layout hint text.
  • The sandboxed HTML bridge exposes the UI language and direction, but it does not translate widget author strings automatically.

Where next#

Was this page helpful?