📐 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.
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:
- their personal layout (Save for me), if they saved one;
- else the shared layout (Save shared), the model content everybody sees;
- 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, hin design pixels plusanchor(l t r b, defaultlt): an anchored edge keeps its distance to that edge when the canvas is larger than the design size (anchorStyle). Optionalpinsattach 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) andpages(pages of children); their children's coordinates are relative to the container's inner area. fieldrenders through the record's or screen's field slot,regionthrough a region slot (a table part, a panel), everything else through the sameDecorNodethe grid uses; every control carriesdata-zf={name}.- Below 600 px of width the runtime stacks the controls in reading order
(
readingOrder), unless any control haspins— thensolveTreereflows 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. columnHintsare edited on apart:region in the designer.- An
htmlcontrol 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 namedpart:<Part>.<Column>(table-part column hints) move tocolumnHints; field items the form cannot render are dropped. - Extensions: a fragment's
controlskeep their coordinates; gridnodesandinsertitems are stacked below the target (FormLayoutMerge, mirrored byfrontend/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
MetaFormLayoutentity (src/ZuloOne.Shared/Metadata/MetaFormLayout.cs), in theLayoutJsonfield. - The keys:
ObjectType(Dictionary|Document|Standalone),ObjectMetaId,FormKind(Record,List— the list form's row rules, default view and card composition, see the cookbook — andStandalone),UserMetaId(personal/shared),Language. - Resolution at render time: personal (
UserMetaIdset) → 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
titlefor 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(LayoutFieldsrenders,FormLayoutDesigneris 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.Linesand 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 withouttablePartsstill leaves the lines alone (third-party clients / header-only). The header's events (OnBeforeSave/ insert / update / post) see the same graph:document.Linesand the other collections are hydrated out oftableParts, and line mutations made in a handler go out in the same write (Flush serializes the whole line —ToRecord; an emptyWriteBackno longer zeroes outQuantityand the other fields). A header-only PUT puts no graph into the event; useGetDocumentAsyncthere, as before. A line field in the grid (with no Save) callsPOST /api/events/tablepart/field—OnValidateField, thenOnFieldChangedon the table part type. Inside the handler the parent document isOwner<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 thereOwner<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 isPOST /api/events/document/field: the same hooks onTypedDocumentEventHandler, and in the handlerheader.Linescomes 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 (fieldHookChangesinlib/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: typingE2E-ITEM-1into a dictionary whose hook runs onNameleft the box readingE2, 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 (aDateturned 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)#
- 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). - 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 aDocumentLinksrow a script wrote withIDocumentManager.AddLinkAsync. A reference is a document's own header field that holds another document's id: aGuidfield that is not a dictionary reference, such asSalesReturn.OriginalInvoice. The document that names the other is its child, and the edge carriesviaFieldwith 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 keepGetDocumentFamilyAsync(id)= recorded links only; the card calls the overload withincludeReferences: 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,alertor toolbar body text yet; those are still plain strings. - Table-part grid columns do not show layout
hinttext. - The sandboxed HTML bridge exposes the UI language and direction, but it does not translate widget author strings automatically.
Where next#
- In-app help — guides, tours and field hints on the screen.
- Document references and labels — why generic GUIDs can still show readable document names.
- The map (GeoMap)
- The information register (Information)