Form modules
Updated
A form module is the C# code behind a designed form, like the form module of a 1C managed form. The form declares attributes (state that is not a record field), the module handles the form's events and commands, and the form can carry its own CSS and TypeScript. Everything ships with the form's model.
The layout itself (groups, fields, decor) — the forms system. Questions to the user from code — asking the user from a command.
What a form is made of#
| Part | Where it is stored | Workspace file |
|---|---|---|
| Layout and attributes | MetaFormLayout.LayoutJson |
<Model>/Forms/<Name>.form.json |
| CSS | MetaFormLayout.Css |
<Model>/Forms/<Name>.form.css |
| TypeScript | MetaFormLayout.ClientScript |
<Model>/Forms/<Name>.form.ts |
| Module | a MetaScript with ScriptType = "FormModule", linked by MetaFormLayout.ModuleScriptMetaId |
<Model>/Forms/<Name>Form.script.json + <Name>Form.cs |
A module, CSS and a script belong to the shared layout only. A personal layout may rearrange fields, but it never carries code.
The module class is <Name>Form: the form name with non-alphanumerics
dropped, plus Form. The form StockPicker gets StockPickerForm. Class
names are unique across the workspace, so two forms cannot share a name.
Attributes#
Attributes live in the layout JSON under attributes:
{
"attributes": [
{ "name": "Warehouse", "caption": { "en": "Warehouse", "ru": "Склад" }, "type": "Reference", "ref": "Store" },
{ "name": "OnDate", "type": "DateTime" },
{ "name": "Lines", "type": "Table", "columns": [
{ "name": "Item", "type": "Reference", "ref": "Item" },
{ "name": "Qty", "type": "Decimal", "scale": 3 }
] }
],
"nodes": []
}
| Type | C# type on State |
Notes |
|---|---|---|
String |
string? |
length limits the input |
Integer / Long |
int / long |
|
Decimal |
decimal |
scale = digits after the point |
Boolean |
bool |
|
DateTime |
DateTime? |
|
Guid |
Guid? |
|
Reference |
Guid? |
ref = a dictionary or document by name |
Enum |
the generated enum | ref = the enum by name |
Table |
List<<Name>Row> |
columns = one level of attributes, no table inside a table |
The save refuses the layout, naming the attribute, when:
- a name is not PascalCase Latin, or is used twice;
- a type is unknown;
refis missing on aReference/Enum, or names an object that does not exist;- a table column is itself a table;
- the name is reserved:
State,FormMetaId,StateObject,StateType.
A ref to an object of another model needs that model in the form model's
dependencies, the same rule as a reference field. Workspace validation names
the form, the attribute target and both models. With strict dependencies it
blocks the import; otherwise it reports a dependency violation.
The module#
The platform generates one half of the partial class: the base
FormModuleBase, the identity FormMetaId, and a typed State with one
property per attribute. You write the other half.
public partial class StockPickerForm
{
public override async Task OnCreateAsync(FormContext ctx)
{
State.OnDate = DateTime.Today;
ctx.Element("Post").Enabled = false;
}
public override async Task OnChangeAsync(string attribute, FormContext ctx)
{
if (attribute == nameof(State.Warehouse)) State.Lines.Clear();
ctx.Element("Post").Enabled = State.Lines.Count > 0;
}
[FormCommand]
public async Task Fill(FormContext ctx)
{
var stock = GetService<IStockAvailabilityService>();
// … fill State.Lines from the service …
ctx.Message($"{State.Lines.Count} lines", "success");
}
}
| Event | When it runs |
|---|---|
OnCreateAsync(ctx) |
the form opens |
OnChangeAsync(attribute, ctx) |
the user changed a field bound to an attribute |
OnRowChangeAsync(table, row, column, ctx) |
the user changed a cell of a table attribute |
OnCloseAsync(ctx) |
the form is closing |
A command is a public Task Method(FormContext ctx) marked
[FormCommand]. Only these methods are callable from a button or from the
form's script. Any other public method is refused with 400, even if its
signature matches.
What FormContext gives a handler:
ctx.Element("Name")withVisible,Enabled,Caption,AddClass(...),RemoveClass(...). The name is the element'sdata-zfhook in the layout.ctx.Message(text, level)with levelinfo,success,warningorerror.ctx.Actions: client actions, the sameClientActionfactories a command uses (OpenDocument,ShowTable,Redirect…).ctx.Close(result)asks the host to close the form and hands it the result.ctx.Confirm,ctx.Choose,ctx.Ask: questions to the user, with the same two-run mechanics as commands. See asking the user from a command.ctx.UserId,ctx.UserName,ctx.Language,ctx.RecordId.
GetService<T>() works in a module as in any other script.
The round trip#
The server is stateless. Every event is one request, and the client posts the whole form state with it:
POST /api/forms/{name}/events
{ "event": "change", "attribute": "Qty", "state": { "Qty": 4, "Price": 2.5 } }
The response is the new state with element patches, messages and client actions:
{ "state": { "Qty": 4, "Price": 2.5, "Total": 10 },
"patches": [ { "element": "Post", "enabled": true } ],
"messages": [], "clientActions": [], "awaitingAnswer": false, "close": false }
The state binding is lenient. Numbers may arrive as strings, an empty string means "no value", and unknown keys are dropped. Attribute names go back exactly as declared, not camelCased.
The cost is one request per event, so keep OnChangeAsync cheap.
| Endpoint | Who | What |
|---|---|---|
POST /api/forms/{name}/events |
anyone with Execute on the form |
one round trip; request size up to 1 MB |
GET /api/forms/{name}/module/members |
Developer | the overridden events and the [FormCommand] methods |
POST /api/forms/{name}/module |
Developer | create the module (or return the existing one) |
The Execute permission is checked in the permission matrix as object Form
with the form's MetaId. As for other objects, an empty matrix allows
everyone.
Warning
A module that does not compile answers every event with 422 and the compiler error. The same error fails the model in
POST /api/metadata/models/{id}/compile, for exampleCS1061onState.Quantitywhen the attribute is calledQty.
CSS#
The CSS applies inside the form only. At run time it is wrapped in
[data-zf-form="<Name>"] { … }. A standalone form's root also gets
contain: layout paint. A record card does not: containment would clip the
drop-downs of its table-part grids. Nothing is injected for a form without CSS.
Style elements by their data-zf hook, for example
[data-zf="Total"] { font-weight: 700; }. Theme variables
(var(--mantine-color-blue-6)) work as they are.
The save and the workspace import refuse CSS that contains any of these:
@import;url(...)other thandata:or a root-relative/path;expression(,behavior:or-moz-binding:;position: fixed, which could cover the app around the form;</style;- an external address in a string (
image-set("https://…"), a fontsrc); - more than 64 KB.
Comments and CSS escapes are removed before the check, so @im/**/port and
@\69mport are refused too. The rules are implemented twice, in
FormCssPolicy.cs for the server and in formCss.ts for the editor. Both
are tested against the same cases file,
src/ZuloOne.Tests/Fixtures/form-css-cases.json.
Record forms#
A dictionary or document card can have a module too. It lives on the card's shared, language-neutral layout, the same row that carries the card's CSS and script. A personal layout or a language variant may rearrange fields, but the code always comes from that row.
- Create it with Module in the card's layout designer, after the
shared layout is saved once. The endpoint is
POST /api/forms/record/{objectType}/{objectMetaId}/module. - The record stays the card's.
ctx.Recordholds the card's current field values, unsaved edits included:ctx.Record.Get<string>("Subtype"). It is read-only, and the module never saves the record, so validation and posting are not bypassed. - Two layers, one change, in order. When a field changes, the object's own
field hook (
OnFieldChanged) runs first and its result is applied. Then the card sends the module achangewith the field name as the attribute. The object hook also guards imports and APIs where no form exists, so the module adds to it and never replaces it. - Attributes sit beside the record fields. An attribute named like a record field is refused on save.
- Patches,
[FormCommand]buttons, CSS and the script work as on a standalone form. Round trips go toPOST /api/forms/record/{objectType}/{objectMetaId}/eventswithrecordin the body. - CSS and script of a card: CSS and script in the card's layout
designer, or
PUT /api/metadata/form-layouts/code { objectType, objectMetaId, css, clientScript }. They are stored on the same row as the module, and the layout is not resent. - Resetting the shared layout deletes its module and its extensions too.
Extending another model's form#
A model can add to a form it does not own, the way it adds fields to another model's dictionary. The extension is its own row in the extending model, and the owner's form is never edited.
-
Create it on the Screens page: open the form, pick the model in Extend in model… and press Extend. The model must depend on the form's model, and the form's own model cannot extend it. The endpoint is
POST /api/forms/extensions { targetMetaId, modelId }. -
The fragment is a small layout document:
{ "attributes": [ { "name": "Note", "type": "String" } ], "nodes": [ { "type": "group", "name": "Extra", "items": [ { "type": "field", "name": "Note" } ] } ], "insert": [ { "group": "Head", "items": [ { "type": "button", "name": "StampBtn", "text": "Stamp", "command": "Stamp" } ] } ] }attributesare added to the form's, and a name already used by the form or another extension is refused.nodesare appended as new groups.insertputs items into a named group of the form; a group that does not exist is skipped.allowServicesis merged with the form's list. -
The layer module is
<Owner>Form_<Model>, declared[ExtensionOf("<FormName>")]. ItsStatehas the form's attributes plus this extension's, and every link of the chain shares the same values.[ExtensionOf("StockPicker")] public partial class StockPickerForm_Warehouse { public override async Task OnCreateAsync(FormContext ctx) { await nextAsync(ctx); // the owner's OnCreateAsync runs here State.Note = "opened with " + State.Qty; } [FormCommand] public async Task Fill(FormContext ctx) { await nextAsync(ctx); State.Qty *= 10; } [FormCommand] public Task Stamp(FormContext ctx) { State.Note = "stamped"; return Task.CompletedTask; } }The outermost layer runs first, and
nextAsyncgoes down to the owner. An event override, or a command the owner also has, must callnextor carry[Replace]; otherwise the event answers 422 withZOCOC003. A command only the layer declares is its own. A layer without[ExtensionOf("<FormName>")]is refused withZOCOC004. The model compile and the script editor's Check run the same two rules, so a broken layer turns the extending modelFailedbefore anyone opens the form. The compile reads the lower commands from source: the owner's module and every layer below this one. -
What the client gets.
GET /api/metadata/form-layouts/standalone/{name}(and the record-form resolve) returns the form's ownlayoutJsonfor editing, plusmergedLayoutJson,mergedCssandscriptsfor running. Each extension's script runs in its own<script>in the same sandbox, after the form's. -
Files. The extension ships with the extending model:
<Model>/Forms/<Form>.<Model>.form.jsonwith its.form.css/.form.tssidecars and the layer pair<Owner>Form_<Model>.script.json+.cs. Workspace validation checks that the extending model depends on the form's model, the same rule as any reference. Deleting the form deletes its extensions with their layers.
Record forms are extended the same way: the target is the card's shared, language-neutral layout. In the card designer, Extensions lists them, creates one in a model that depends on the card's model, and edits its attributes, JSON fragment, CSS and TypeScript; Module there opens the layer module. The card must have a saved shared layout first.
The workspace#
The live sync carries all four files both ways, like any script pair. If CSS breaks the policy, the import refuses it. The row stays unchanged, and the next export rewrites the file from the database. An empty CSS or script is not written to disk.
Bump the model's modelVersion in the same commit as any form change: minor
for a new form or attribute, patch for a code or CSS fix.
Workspace CI type-checks every */Forms/*.form.ts with tsc --noEmit
against the zuloform.d.ts that the pinned Core serves. The step is skipped
when no form has a script. After installing the new tree over the previous
release, the upgrade gate compares every staged form (layout, CSS, script,
module link and module code) with the database, so a create-only install of a
changed form fails the build.
Testing a form#
An integration test runs a round trip through the same service as the endpoint:
var state = new Dictionary<string, object?>
{
["FromCell"] = from, ["ToCell"] = to,
["Lines"] = new List<Dictionary<string, object?>> { new() { ["Item"] = item, ["Qty"] = 15m } },
};
var check = await Db.RunFormEventAsync("StockPicker", "command", state, command: "CheckStock");
Assert.AreEqual(10m, Convert.ToDecimal(check.Rows("Lines")[0]["OnHand"]), "on hand in the source cell");
var asked = await Db.RunFormEventAsync("StockPicker", "command", check.State, command: "CreateTransfer");
Assert.IsTrue(asked.AwaitingAnswer, "a short line asks first");
```n
`FormRunResult` has `State`, `Rows(table)`, `Enabled(element)`, `Messages`,
`AwaitingAnswer`, `QuestionKey` and `OpenedDocumentId`. Pass `answers:` to answer
a question, as for commands.
## The sample
`Inventory/Forms/StockPicker` is a working example of every part: two cell
attributes and a lines table, a module that reads on-hand stock with
`IStockAvailabilityService` and creates a draft `StockTransfer` after asking
about short lines, a little CSS, a TypeScript check that refuses the same
cell on both sides, a menu item under Inventory → Documents, and the test
`Inventory/Tests/StockPickerFormTest`.
## In the browser
`StandaloneFormHost` (`frontend/src/components/forms/StandaloneFormHost.tsx`)
runs a form with a module:
- **Attributes become fields.** A layout node `{ "type": "field", "name": "Qty" }`
places the attribute `Qty`, with the same input a record field of that type
gets. A `Reference` gets the dictionary lookup, an `Enum` a list of its
values, a `Table` an editable grid with add and delete row.
- **Round trips.** `create` is sent when the form opens. A field change is sent
as `change` 300 ms after the last keystroke. A command first sends the
changes still waiting, so it always sees the latest values. Round trips go
one at a time. A value typed while a request is in flight is kept over the
server's copy.
- **Command buttons.** `{ "type": "button", "text": "Fill", "command": "Fill" }`
runs the `[FormCommand]` method `Fill`.
- **Patches.** Every rendered element carries `data-zf="<name>"`: a field by
its field name, any other element or group by its `name` property. The
module's patches hide, disable, re-caption and add classes by that name.
- **Questions** from `ctx.Confirm`, `ctx.Choose` and `ctx.Ask` open the same
dialog as for commands, and the event is re-posted with the answer.
### The client script
The TypeScript runs in a hidden `sandbox="allow-scripts"` frame with a null
origin. It cannot read the app's page, cookies or session token. The types are
stripped in the browser by `sucrase`, which loads only for forms that have a
script, and the result is cached per text.
```ts
form.on('change', (e) => {
if (e.attribute === 'Qty' && Number(e.value) < 0) {
form.message('Quantity cannot be negative', 'warning');
form.attrs.Qty = 0;
return false; // the server never sees the negative value
}
});
form.on('serverResult', () => {
form.element('Total')Number(form.attrs.Total) > 100 ? 'addClass' : 'removeClass';
});
| API | What it does |
|---|---|
form.attrs.X |
read an attribute; a write is sent to the server as a change |
form.on('open' | 'change' | 'command' | 'serverResult', h) |
a change or command handler that returns false cancels the round trip |
form.element(name) |
visible, enabled, caption, addClass(), removeClass() |
form.command(name) |
run a [FormCommand] method |
form.message(text, level) |
show a message |
form.callService(name, method, args) |
call a service listed in the layout's root allowServices; any other service is refused |
form.language, form.dir |
the UI language and text direction |
The full declaration is zuloform.d.ts, embedded in Core
(src/ZuloOne.Core/Resources/Forms/zuloform.d.ts) and served at
GET /api/forms/zuloform.d.ts. The export writes it to
.generated/Forms/zuloform.d.ts when any form has a script.
A handler that does not answer within 2 seconds is skipped: the round trip goes on, and the form shows the error under itself and in the browser console. A script error does not stop the form; it keeps working without the script.
The designer#
Developer → Forms → Forms (a screen in its Screens section) builds a form without hand-written JSON:
| Tab | What you do there |
|---|---|
| Preview | the real run time with the unsaved CSS and script; Restart preview opens it again |
| Attributes | add, rename, type, reference target, table columns |
| Commands and module | create or open the module; a new command name plus Enter adds a [FormCommand] stub and opens the module |
| CSS | the CSS with the refused rules listed live |
| Script (TypeScript) | the TypeScript editor; it knows form and this form's attributes |
| JSON | the raw layout, for what the panels do not cover |
Designer opens the layout designer on the form. Attributes are its
fields. The Command button element binds to a module command, and the
Name (CSS and module) property sets an element's data-zf hook. Saving an
unedited layout keeps the stored text, so opening and saving never makes a
diff in the workspace.
Renaming an attribute does not rewrite the module: the next compile reports the old name with its line.
What's not there yet#
- A record card's extension has no visual canvas: its groups and
insertitems are written in the JSON tab of the Extensions dialog. - The designer has no Events tab per element. An event handler is the
module's
OnChangeAsync/OnRowChangeAsyncoverride, written in the module. - A form cannot yet be opened as a modal that returns a result to the caller.
ctx.Close(result)reaches the host, but the screen tab does not close by itself.
Where next#
- The forms system: layouts, elements and standalone forms.
- Asking the user from a command:
Confirm,ChooseandAsk, used the same way in a form module. - What a script can do: services and APIs available to the module.