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.
The factory
Section titled “The factory”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.
Typed inputs
Section titled “Typed inputs”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.
What an input reads from the schema
Section titled “What an input reads from the schema”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.
Layout
Section titled “Layout”<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.
Child collections
Section titled “Child collections”<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.
Passing a facade down
Section titled “Passing a facade down”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">>.
Related records
Section titled “Related records”<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.
Reading values
Section titled “Reading values”f.useValue((v) => v.netAmount) // preferred — selector, re-renders on changef.useValues() // the whole record at this scopef.useField("sku") // the field's full descriptorAll 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.
Identity and lifecycle
Section titled “Identity and lifecycle”These live on BusinessObjectForm<S> only — they act on the aggregate root.
f.ref // "master/Product"f.world // "draft" while authoring a drafts: true resourcef.hasOperationalRecord // a row exists AND it is operationalf.save()f.discardChanges()f.remove() // delete an archived record, or discard a draftf.activate() // draft → active; the action bar labels it Releasef.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.
Composites
Section titled “Composites”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.
Extended schemas
Section titled “Extended schemas”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:
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.
Anti-patterns
Section titled “Anti-patterns”- Don’t look for inputs outside a facade.
@onerp/react/formsexports no input and no composite; every binding isf.Text,line.Decimal,f.useStruct("address").CodeList— a facade member. TheForm*primitives above are for screens with no business object at all. - Don’t restate labels. Inputs read them from the field definition; passing
labelis an override and duplicates the schema. - Don’t explain a field with
helperText. What the field means belongs in itsdescription, where the tooltip and the AI agent both read it.