Skip to content

Forms

A form body is a plain component. The framework mounts it inside <FormPage>, which owns the breadcrumb, the title, the action bar, the tab row and the save behaviour — so the body declares fields and nothing else. Your first resource builds the smallest one; this page is the rest of the surface.

useForm<S>() returns a BusinessObjectForm<S>: a Form<S> — the typed facade over one fields map, Form<F extends Fieldset> — plus the identity and lifecycle actions that only make sense at the aggregate root.

There are three facades, and every input is a member of one of them:

const f = useForm<typeof SalesOrderSchema>(); // root: Form<S>
<f.Reference name="customer" />
<f.HasMany name="items" columns={[ // row: Form<FieldsAt<S, "items">>
{ field: "product" },
{ field: "quantity", cell: (line) => <line.Decimal label={false} name="quantity" /> },
]} />
const address = f.useStruct("soldToAddress"); // struct: Form<FieldsAt<S, "soldToAddress">>
<address.Text name="city" />

A facade is bound to its fields map, never to where it renders: passing the root f into a child collection’s cell and reading f.useValue((v) => v.currency) is correct and typed. The object you hold decides the scope, and writes from a row or struct facade go to the absolute path — a nested <line.HasMany> works.

The type parameter is any BusinessObjectDef, not only typeof SomeSchema. A hand-written structural def types one form body against several business objects that share a field set — how the sales order and the sales invoice render from one kit (apps/web/src/resources/sales-documents/) — at the cost of extension fields, see Extended schemas.

Every binding to a business object goes through the factory — a screen with no business object behind it is a different surface. Each member accepts only the paths whose field kind it renders, so a typo or a wrong kind is a compile error.

Member Binds to Value type
f.Text string, text string | null
f.Decimal decimal — including amount, quantity, rate Decimal | null
f.Integer integer number | null
f.Boolean boolean boolean
f.Date date Temporal.PlainDate | null
f.DateTime datetime Temporal.Instant | null
f.Select enum string | null
f.CodeList codeList string | null
f.Reference referenceOne, treeParent Identifier | null
f.ReferenceMulti referenceMany Identifier[]

Reads are the in-flight view — the fields plus id, no status: a form holds values that only have to satisfy required at save, so requirable scalars read as T | null throughout.

An input names its field and takes the rest from the field definition: the translated label, the required marker, whether the field is editable at all (access: "system" is not), and the field’s description — author-written prose about what the field means — as an info tooltip on the label. A field that declares no description renders no icon.

name: field.string({
label: "Name",
description: "Name identifying this number series",
}),

Overrides are per input. label takes a React node, or false to drop the label entirely — what a has-many cell does, since the column header already names the field and carries the same tooltip.

helperText is the one string an input does not take from meta: it is copy a form author writes for one form, rendered under the control. It earns its place when the copy could not be a description — because it depends on the other values in the form, or on the record’s state:

// The pattern's tokens are unguessable, so the field shows what it will produce.
<f.Text helperText={<PatternHelp />} name="pattern" />
// Frozen once the product has ledger history — a rule about this record, not the field.
<f.Boolean disabled={locked} helperText={locked && t("...")} name="lotTracked" />

Everything static about what a field means is a description, and everything about the shape of a value is better said by the control than in prose: a percentage takes endAddon="%", an amount endAddon={currency}, and a field the current state forbids takes disabled rather than a sentence explaining that it is ignored.

<FormSection title description> groups related fields and becomes a navigable anchor in the form’s section nav; <FormGrid cols> arranges inputs side by side inside one — two columns by default, up to four, collapsing to a single column on small screens.

Do not nest a grid in a grid — that is the signal to split the section — and reach for md:col-span-N rather than a width override when one field must span.

A form body may render <PageTab>s from @onerp/react/chrome directly. <FormPage> already provides the surrounding <PageTabs> and renders the trigger row in the page header, so the body declares only the panels — one <PageTab label value> per panel. Hidden tabs stay mounted, which is what makes one save bind inputs from every tab, and each input registers under its enclosing tab so the trigger row can show where the unresolved issues are. The mechanics are on Custom screens.

<f.HasMany> is the editable table for owned children — an order’s line items, a partner’s tax registrations. Children are composition: they belong to the parent and commit with it. A column is either a row field — { field }, whose header is the field’s label and whose cell is the input for its kind — or a bespoke { header, cell }. Either takes an optional width, and a field column may still pass cell to render the input its own way:

<f.HasMany
columns={[
{ field: "product" },
{ field: "quantity", cell: (line) => <QuantityCell line={line} /> },
{ header: t("onerp.lineItems.amount"), cell: (line) => <AmountCell line={line} /> },
]}
detail={(line) => <LineItemDetailBody line={line} />}
name="items"
reorder="position"
/>

name is checked against the parent’s hasMany paths, field against the row’s. detail adds a per-row edit dialog, reorder names the field holding row position, bulkActions adds multi-select and bulk delete, and empty overrides the empty state. Because the row facade is itself a Form, nested <line.HasMany> works.

Other shapes — a list of cards, a bespoke inline editor — are not has-many. Build them as components that read the array through f.useValue.

One binding stays inline as a render-prop; anything with a second read, a condition, or its own hook becomes its own component, and a detail body always does. The row facade goes with it as a prop typed Form<FieldsAt<typeof SalesOrderSchema, "items">>.

<RelatedList> embeds records that point at this one and have their own lifecycle — sales prices for a product, contact persons for a partner. It reads the parent id from the form and filters the target resource by [target] = id.

<RelatedList<typeof SalesPriceSchema>
columns={["amount", "validFrom"]}
reference="sales/SalesPrice"
target="product"
/>

Pass the target’s schema as the type parameter — that is what checks columns, against <DataTable>’s column model. Wrap the embed in a <FormSection>: it renders the list, not the heading.

Nothing references an unsaved record, so the embed renders nothing until f.hasOperationalRecord is true — gate the whole section on it rather than shipping an empty panel.

Choose between the two: if the child saves atomically with the parent and has no independent existence, it is <f.HasMany>. If it is a first-class record the user can navigate to, it is <RelatedList>.

Where the target resource declares dialog: true, Add and row clicks open an inline dialog; otherwise they open the target’s own route in a new tab. The embed is for at-a-glance context — limit defaults to 10, with View all as the route to the real list.

f.useValue((v) => v.netAmount) // preferred — selector, re-renders on change
f.useValues() // the whole record at this scope
f.useField("sku") // the field's full descriptor

All three exist on every facade, so the same call works at the root and in a row. useValues() exposes the incomplete InferFields<F["fields"], false> shape of the current fieldset. It does not promise id or status on every facade: has-many rows can carry an id at runtime, while structs do not.

useValue is the read primitive for domain code. It runs the selector against the facade’s live record zustand-style, re-rendering only when the result changes by Object.is, so you can derive across fields in one subscription. Building a fresh object or array in the selector re-renders on every change.

useField(name) returns the whole descriptor rather than the value — the value and its setter, the fieldDef, the translated label and description, the validation issues, editable, and the absolute path. It throws if the path does not resolve, so a stale name fails at the call site rather than rendering blank.

A field with access: "system" is never editable, so a derived figure is a read-out, not a disabled input. Render it with <Money> or <FieldValue> from @onerp/react/fields and take the label off the descriptor; <FieldValue> dispatches on the field’s kind, so a decimal groups and a date formats exactly as it does in a list cell.

<FieldValue field={def} value={v} /> and <FieldLabel field={def} /> take a field def (or a descriptor’s fieldDef); the label and its translations are the def’s own, read in the active locale.

These live on BusinessObjectForm<S> only — they act on the aggregate root.

f.ref // "master/Product"
f.world // "draft" while authoring a drafts: true resource
f.hasOperationalRecord // a row exists AND it is operational
f.save()
f.discardChanges()
f.remove() // delete an archived record, or discard a draft
f.activate() // draft → active; the action bar labels it Release
f.archive()
f.restore()

activate, archive and restore refuse while the form has uncommitted changes; what each state permits is Lifecycle. You rarely call these — the action bar <FormPage> renders already wires every one, with confirmation and error handling.

A composite is an application component that takes a facade and binds through it; the framework ships none. The two in apps/web/src/components:

<PostalAddressFields form={f.useStruct("soldToAddress")} />
<PostalAddress field={f.useField("soldToAddress")} form={f.useStruct("soldToAddress")} label="Sold-to" />
<Quantity form={line} quantity="quantity" product={line.useField("product")} unit={line.useField("unit")} />

<PostalAddress> renders a fixed member set, so its prop is Form<Fieldset<typeof postalAddressFields>>; <Quantity> renders members the caller names, so it is generic over the row’s fieldset.

A form is typed by what the app holds. Fields another domain contributes through defineExtension reach meta at runtime and never merge into the base schema’s type, so a form rendering one names the shape it renders — the base fields plus the extending domain’s field factory, imported type-only so nothing enters the bundle:

apps/web/src/resources/products/product-form.tsx
import type { EuVatProductFields } from "@onerp/eu-vat/product-extensions";
type Product = BusinessObjectDef<
"master/Product",
typeof ProductSchema.fields & EuVatProductFields
>;
const f = useForm<Product>();

Without it, <f.Reference name="taxClassification" …> does not compile. The shared sales-document components do the same once, in document-schema.ts. How the runtime merge works is the meta pipeline.

Fields that exist only at runtime — a tenant’s custom_ extension fields — are in no compiled schema, so no typed input can name them. useGenericForm() is the schema-erased twin: same store, plain string names. Call both in one body and bind the runtime-only fields off the generic facade.

Form-shaped screens that are not business objects

Section titled “Form-shaped screens that are not business objects”

A dialog or a settings panel that collects values without a business object behind it does not go through the factory — there is no schema to bind to. It composes the same primitives the factory’s inputs are built from, exported from @onerp/react/forms:

<FormField error={dialog.fieldErrors.product}>
<FormLabel><LabelWithDescription label={translate("onerp.x.fields.product")} /></FormLabel>
<FormControl>
<ReferencePicker onChange={dialog.setProduct} resource="master/Product" value={dialog.product} />
</FormControl>
<FormError />
</FormField>

<FormField> owns the item context — useFormItem() reads it — and <FormLabel>, <FormControl>, <FormDescription> and <FormError> wire the ids and aria-* attributes between label, control and message; the controls and <LabelWithDescription> are the headless field primitives from @onerp/react/fields.

  • Don’t look for inputs outside a facade. @onerp/react/forms exports no input and no composite; every binding is f.Text, line.Decimal, f.useStruct("address").CodeList — a facade member. The Form* primitives above are for screens with no business object at all.
  • Don’t restate labels. Inputs read them from the field definition; passing label is an override and duplicates the schema.
  • Don’t explain a field with helperText. What the field means belongs in its description, where the tooltip and the AI agent both read it.