Forms
Build multi-step data-collection forms that write into one or more records.
A form collects data and writes it into records. Unlike a detail view, which edits one record in place, a form can populate several records at once — an onboarding form that creates a contact and their company, for example.
Each target record is a slot. Fields point at a slot and an attribute of that slot's object, so the same form can lay out fields from different objects side by side. (A form slot is a target record — unrelated to document attributes.)
A minimal form
import { form } from "@stndrds/schema";
export const CONTACT_INTAKE = form("contact-intake", "New contact")
.slot("contact", "contacts", { label: "Contact", mode: "create" })
.step("identity", "Identity")
.field("contact", "firstName", { required: true })
.field("contact", "lastName", { required: true })
.field("contact", "email")
.build();The builder reads top to bottom: form(name, label) opens it, .slot() declares what the form writes into, .step() opens a step, and .field() adds a field to the current step. .build() freezes the definition.
Builder methods
| Method | Effect |
|---|---|
.slot(id, objectName, options) | Declare a target record. Options: label (required), mode, color, icon. |
.step(id, label) | Open a step. Calling it again closes the previous one. |
.description(text) | Help text — on the form before any step, on the step after one. |
.icon(name) | Form icon. |
.status(value) | "draft" (the default), "published", or "archived". Only published forms are offered to users. |
.metadata(object) | Arbitrary key/value data carried with the form. |
.build() | Finalize. Validates slot references, prefill expressions, and free-field types. |
Inside a step you get .field(), .freeField(), .row(), .heading(), .text(), and .separator() — covered below.
Slots
A slot binds a name to an object and says how the record is chosen:
.slot("holder", "contacts", { label: "Account holder", mode: "select", color: "blue", icon: "user" })
.slot("coHolder", "contacts", { label: "Co-holder", mode: "optional", color: "red", icon: "user" })| Mode | Behavior |
|---|---|
create | Always creates a new record. |
select | The user picks an existing record. |
optional | The user can pick an existing record or skip the slot entirely. A skipped slot ignores its fields. |
create_if_not_empty | Creates a new record only if the user fills at least one of the slot's fields; otherwise nothing is written. |
label is required. color and icon identify the slot in the form UI, so fields from different records stay visually distinct.
Steps
Every field lives in a step. One step gives a single-page form; several steps give a wizard.
form("onboarding", "Onboarding")
.slot("contact", "contacts", { label: "Contact", mode: "create" })
.slot("company", "companies", { label: "Company", mode: "create_if_not_empty" })
.step("identity", "Identity")
.description("Who are we onboarding?")
.field("contact", "firstName", { required: true })
.field("contact", "lastName", { required: true })
.step("company", "Company")
.field("company", "name")
.field("company", "website")
.build();Calling .step() again closes the previous step and opens the next — no .done() needed. .description() adds help text under the step title.
Layout
Within a step, four methods shape the layout:
| Method | Effect |
|---|---|
.heading(content, level?) | A heading, level 1, 2, or 3. |
.text(content) | A paragraph of explanatory text. |
.separator() | A horizontal rule. |
.row(id) | Opens a row — fields added inside sit side by side. Close it with .endRow(). |
.heading("1. Identity", 1)
.row("name-row")
.field("holder", "lastName", { required: true })
.field("coHolder", "lastName")
.endRow()
.separator()Fields added directly to the step (outside any row) each take a full row.
Fields
.field(slotId | slotId[], attribute, options?) adds a field bound to an attribute of the slot's object:
.field("contact", "email", { label: "Work email", required: true, tooltip: "We only use this for the contract." })required here is form-level: it makes the field mandatory in this form even if the underlying attribute is optional.
To ask the same question for several slots at once, pass an array of slot ids:
.field(["holder", "coHolder"], "taxCountry")Free fields
A free field collects an answer that isn't stored on any object — a consent checkbox, a one-off question. Declare it with an attribute builder, inline:
.freeField(text({ name: "referral", label: "How did you hear about us?" }))
.freeField(
select({ name: "riskProfile", label: "Risk profile" }).options([
{ value: "prudent", label: "Prudent" },
{ value: "balanced", label: "Balanced" },
{ value: "dynamic", label: "Dynamic" },
]),
{ required: true },
)Free fields can't use every attribute type: relations (single and multi), formulas, rollups, documents, rich text, and user references have no record to attach to, so .build() rejects them.
Prefill expressions
A free field can start pre-filled from slot data with prefillExpression, which interpolates values from the form's slots:
.freeField(text({ name: "fullName", label: "Full name" }), {
prefillExpression: "{{ holder.firstName }} {{ holder.lastName }}",
})The expression resolves once, server-side, when the submission is created — not when the form is rendered on the client. Empty references interpolate as an empty string, so a partially filled slot yields a partial value; if every reference is empty, the field stays empty.
Registration
Forms go in the formRegistry, alongside objects and views:
import { formRegistry } from "@stndrds/schema";
formRegistry.register(CONTACT_INTAKE);Pass the registry to SchemaModule.forRoot({ formRegistry }) — see Server setup.
A form is "draft" until you publish it, and only published forms are offered to users:
form("contact-intake", "New contact").status("published");Filling forms
On the frontend, a set of form hooks drives a submission through its steps: useForms and useFormByName load the definition, useCreateFormSubmission starts one, then useSaveFormStep and useAdvanceFormStep move through it. A submission holds the answers until it is completed, so a user can leave a long form and come back to it.