Form plugin for Object UI - Advanced form components with validation, multi-step forms, and field-level control.
- Form Builder - Create complex forms from schemas
- Validation - Built-in validation with error messages
- Multi-Step Forms - Wizard-style multi-step forms
- Field Types - Support for all standard field types
- Form State - Automatic form state management
- Customizable - Tailwind CSS styling support
pnpm add @object-ui/plugin-form// In your app entry point (e.g., App.tsx or main.tsx)
import '@object-ui/plugin-form';
// Now you can use form types in your schemas
const schema = {
type: 'form',
fields: [
{ name: 'email', type: 'input', label: 'Email', required: true },
{ name: 'password', type: 'input', inputType: 'password', label: 'Password', required: true }
]
};There is no component map to iterate: registration is a side effect of importing
the package entry, which makes five ComponentRegistry.register(...) calls. These
are the schema types those calls claim, read off the calls themselves in
src/index.tsx:
| Namespaced type | Bare-name fallback | Component behind it |
|---|---|---|
plugin-form:object-form |
object-form |
ObjectForm — metadata-driven form over one record |
view:form |
none | the same renderer under the view protocol |
plugin-form:embeddable-form |
embeddable-form |
EmbeddableForm — standalone public form |
plugin-form:object-master-detail-form |
object-master-detail-form |
MasterDetailForm — parent + child line items in one submit |
record:line_items |
none | LineItemsPanel — child grid bound to the record on the page |
ComponentRegistry.register also registers a namespaced type under its bare name
for backwards compatibility, unless the call passes skipFallback: true
(@object-ui/core, src/registry/Registry.ts). Two calls here do: bare form
stays the basic @object-ui/components form, and bare line_items is left to
whoever else claims it.
The form-analytics node key is RETIRED (objectui#10859 batch 8): no schema
produced it, and objectui validate refused it at type. FormAnalytics is still
exported (see below); mount the component directly.
The package entry exports these — components, their prop/schema types, the
layout helpers, the two create-payload rules a second form renderer needs
(see required + a runtime default)
and the section-group resolver a third one needs
(see Resolving sections[].group outside this package),
with sectionEntryName to read the entries it returns
(see What a section's fields entries draw).
There is no aggregate map among them:
import {
ObjectForm,
TabbedForm,
WizardForm,
SplitForm,
DrawerForm,
ModalForm,
EmbeddableForm,
MasterDetailForm,
LineItemsPanel,
FormAnalytics,
FormSectionContainer,
applyAutoLayout,
applyAutoColSpan,
inferColumns,
inferModalSize,
isWideFieldType,
isAutoGeneratedFieldType,
containerGridColsFor,
filterCreateModeFields,
filterSystemFields,
filterAutoGeneratedFields,
deriveDetail,
deriveColumns,
deriveFormFields,
findRelationshipField,
resolveInlineMode,
omitServerResolvedDefaults,
isRequiredInForm,
resolveSectionGroupReferences,
sectionEntryName,
} from '@object-ui/plugin-form';
import type {
ObjectFormComponentProps,
ObjectFormProps, // deprecated alias of ObjectFormComponentProps
FormSectionContainerProps,
TabbedFormProps,
TabbedFormSchema,
FormSectionConfig,
WizardFormProps,
WizardFormSchema,
SplitFormProps,
SplitFormSchema,
DrawerFormProps,
DrawerFormSchema,
ModalFormProps,
ModalFormSchema,
EmbeddableFormProps,
EmbeddableFormConfig,
EmbeddableFormTexts,
FormAnalyticsProps,
FormSubmissionMetric,
MasterDetailFormProps,
MasterDetailFormSchema,
MasterDetailDetailConfig,
LineItemsPanelSchema,
DerivedDetail,
InlineMode,
ChildObjectSchemaLike,
FieldDefaultsSchemaLike,
ResolveSectionGroupsOptions,
} from '@object-ui/plugin-form';To put one of these components on a schema type of your own, register the exported component:
import { ComponentRegistry } from '@object-ui/core';
import { ObjectForm } from '@object-ui/plugin-form';
ComponentRegistry.register('my-form', ObjectForm, { namespace: 'my-app' });The renderers this package registers for itself are internal wrappers rather
than these exported components. SchemaRenderer hands a registered component its
schema (plus the schema's own props), but never a dataSource — that travels
on SchemaRendererContext — so each wrapper reads it off the context first.
ObjectForm takes dataSource as a prop, optional because inline fields need no
adapter, so a custom-key registration either supplies one or wraps the component
the same way. "Optional" is not "absent is fine for every form", though — see
What a form submits to.
Two form schemas reach a renderer, and both are declared in
@object-ui/types — this package imports them and declares neither. The
tables below name the keys and point at the declaration; the declaration is the
contract. (Restating an interface inside this README is what let this section
drift away from the code in the first place — objectui#5075.)
| Schema | type |
Declared in | Rendered by |
|---|---|---|---|
FormSchema |
'form' |
packages/types/src/form.ts |
the basic form in @object-ui/components — bare form is deliberately not claimed by this plugin (skipFallback: true, see the table above) |
ObjectFormSchema |
'object-form' |
packages/types/src/objectql.ts |
ObjectForm here, through plugin-form:object-form / object-form |
Both type slots are string literals. A schema whose type names something
no registration claims does not fall back to a form — it renders the
unknown-component placeholder.
| Key | Type | Notes |
|---|---|---|
type |
'form' |
literal, not a free string |
fields |
FormField[] |
optional — a form may render children instead |
defaultValues |
Record<string, any> |
form-level initial values. There is no field-level defaultValue |
submitLabel / cancelLabel |
string |
button text |
showCancel / showActions |
boolean |
action-row composition |
mobileStickyActions |
boolean |
pin the action row on small viewports |
layout |
'vertical' | 'horizontal' |
label placement |
columns |
number |
grid width (1–4) |
validationMode |
'onSubmit' | 'onBlur' | 'onChange' | 'onTouched' | 'all' |
when the rules run |
resetOnSubmit / disabled |
boolean |
|
mode |
— | retired (objectui#10286): nothing read it, and validation now refuses it. Use disabled for a read-only basic form, or object-form for create / edit / view |
objectName |
string |
enables metadata field locators data-testid="field:{objectName}.{field}" (ADR-0054 C4) |
previousValues |
Record<string, any> |
edit-mode hosts only — the persisted record, as evaluation context for previous / readonlyWhen. Never sent anywhere |
fieldContainerClass |
string |
class for the field grid inside the <form> |
fieldTabs / defaultFieldTab / fieldTabsPosition |
see Tabbed field layout | |
fieldPanes / fieldPanesOrientation / fieldPanesResizable |
see Split field layout | |
actions |
SchemaNode[] |
extra nodes in the action row |
children |
SchemaNode | SchemaNode[] |
custom body instead of fields |
onSubmit / onChange / onDirtyChange / onCancel |
callbacks | TypeScript-authored schemas only — a JSON metadata document cannot carry a function. Metadata pages go through the object-form route instead |
className, id, hidden, … |
— | inherited from BaseSchema |
FormSchema extends BaseSchema, which declares no index signature
(objectui#8347), so an invented or misspelled key in a form schema literal
annotated with its type is a compile error. That is why every example below is
annotated with its real type: an un-annotated const schema = { … } type-checks
whatever is written in it. (fields entries are FormFields, which still carry
their own index signature — see below.)
FormField (packages/types/src/form.ts): name is the only required
key:
| Key | Type | What it does |
|---|---|---|
name |
string |
required — the submit key |
id |
string |
stable render key; falls back to name |
label |
string |
optional — with none, no label element is rendered at all (validation messages fall back to name). The object-bound paths always fill it from the object field |
description |
string |
help text under the control |
type |
string |
optional, defaults to 'input'. Built-ins: input, textarea, checkbox, switch, select; any other value resolves through the registry (field:<type> first, then the bare name) |
inputType |
string |
HTML input type for type: 'input' — 'email', 'password', 'tel', … |
widget |
string |
widget override; wins over type (spec FormField.widget) |
placeholder |
string |
|
required |
boolean |
the presence rule. validation.required does not make a field required — see below |
disabled |
boolean |
not interactive, muted |
readonly |
boolean |
shown plainly, not editable — deliberately distinct from disabled |
hidden |
boolean |
field is not rendered at all |
options |
SelectOption[] | RadioOption[] |
for select / radio fields |
validation |
FieldValidationRules |
an object keyed by rule name — see below |
condition |
FieldCondition |
legacy { field, equals, notEquals, in } matcher (custom is retired, objectui#7759: nothing ran it) |
visibleWhen / readonlyWhen / requiredWhen |
string | { dialect?, source } |
CEL predicates over the live record, evaluated by @objectstack/formula — the same engine and dialect the server uses. Fail open |
visibleOn |
string | { dialect?, source } |
view-level visibility predicate (spec FormField.visibleOn) |
dependsOn |
DependsOnInput |
cascading parent(s): a bare name, a list of names, or { field, param } entries |
span |
'auto' | 'full' |
relative width, independent of the column count (preferred) |
colSpan |
number |
legacy column span (1–4), clamped to the current column count |
className |
string |
the classes of the element the field's type renders (its control, or a section-divider row's divider), joined with that element's own classes; never the field row: span / colSpan for width, FormSchema.fieldContainerClass for the grid |
field |
Record<string, any> |
the resolved object-field metadata object, stashed by the object-bound paths so widgets can read precision, currency, reference, … In the spec form-view vocabulary field is a string (the referenced field name); that shape ends at normalizeSectionField and never reaches a runtime FormField |
FormField declares its own [key: string]: any, so an invented key on a field
still type-checks. One that a reader might expect, and that is not declared:
Not a FormField key |
Write this instead |
|---|---|
defaultValue |
FormSchema.defaultValues at form level. An object-bound form seeds from the object field's own declared defaultValue — see What a create form opens with |
There is no ValidationRule type in this repo, under any spelling.
FieldValidationRules (packages/types/src/form.ts) is not an array of
{ type, value, message } entries:
| Rule | Type | Notes |
|---|---|---|
required |
string | boolean |
supplies the required message only. Whether the field is required is decided by required / requiredWhen on the field |
minLength / maxLength |
{ value: number; message: string } |
message is not optional when you author the rule by hand |
min / max |
{ value: number; message: string } |
numeric range |
pattern |
{ value: RegExp; message: string } |
pass a RegExp in a hand-authored schema: react-hook-form only applies a pattern whose value instanceof RegExp, and nothing compiles a string into one. An object field has no pattern key: a regex check on an object is its format validation rule, which the server runs on save |
validate |
(value) => boolean | string | Promise<boolean | string> |
custom check; TypeScript-authored schemas only |
There is no email rule name — an email check is a pattern, which is exactly
what buildValidationRules emits for an object field of type email.
Why the array spelling fails silently. The only reader of this key is the
basic form renderer, which spreads it into the rule object handed to
react-hook-form — const rules: any = { ...validation }
(renderFormField in packages/components/src/renderers/form/form.tsx). Spreading an array
into an object literal produces numeric keys ({ '0': …, '1': … }), which
react-hook-form does not recognise: every rule is dropped, nothing throws, and
the form looks validated while validating nothing.
A create form has no persisted record, so its opening values come from the
object schema's declared defaults: a field's defaultValue, or the option its
option list marks default: true. Every object-form container
(ObjectForm, ModalForm, DrawerForm, TabbedForm, SplitForm,
WizardForm) resolves them the same way, through schemaDefaults:
| Declared | Create form opens with | Why |
|---|---|---|
defaultValue: 'draft' (any static literal) |
draft, preselected and submittable |
the value is known; making the user pick it is busywork, and on a status-like field every wrong option is one click away |
defaultValue: 'NOW()' / 'current_user' (a runtime token) |
empty, with Set automatically when saved. under it | the token is an instruction, not a value. The server resolves it at insert — but only for fields that arrive empty, so seeding the literal text would suppress it |
defaultValue: cel\today()`` (an Expression envelope) |
empty, with Set automatically when saved. under it | same reason: the server evaluates it per insert |
an option's default: true, and no defaultValue |
that option, preselected and submittable | the server stores the marked option when the field is omitted; see below |
| nothing | empty | no default is invented |
initialData / initialValues passed by the caller always outrank a schema
default — a lookup prefill or a "duplicate this record" seed is the more
specific instruction.
The "set on save" hint (objectui#12108). A field left empty for the server
is not left unexplained: the form renderer draws Set automatically when saved.
(locale key form.serverDefaultHint) in the field's description slot, after any
description the field declares, and the control's aria-describedby already
names that element. It is drawn exactly while the submit would omit the key — the
field is server-owned (isServerOwnedValue from @object-ui/core, the classifier
the required suppression below reads) and its value is empty by
isMissingForRequired, the predicate omitServerResolvedDefaults drops a key by.
So a value the user types removes it, and the hint never names the value, since the
client cannot evaluate the expression. It is not drawn:
- on an edit form, where the default was resolved at insert;
- on a control that takes no input (disabled or read-only). The containers send
previousValuesin edit mode only, so the renderer reads amode: 'view'form as create, and this is what keeps the hint off it.
A boolean is no exception (objectui#12121). The renderer starts an unsupplied
two-state control at false (cloud#972) only when the field is not
server-owned, so a boolean with a runtime default opens unchecked with the hint
under it, and an untouched one is omitted from the submit. A value the user
sets, false included, is submitted as usual.
An option's default: true is read the way the server's insert path reads it
(ObjectQL.applyFieldDefaults, which falls back to the marked option), so the
form preselects exactly what omitting the field would store:
- only when the field declares no
defaultValue— a field-level default, static or runtime, always wins, and the flag is then not seeded at all; - a single-valued field takes the first marked option;
- a multi-valued field (
@objectstack/spec'sisMultiValueField:multiselect, orselectwithmultiple: true) takes every marked option, as an array.
Edit forms are never seeded. An edit form shows the row as the server holds it; folding a default in over a column the record leaves unset would arm a silent write of a value the user never chose, on the next save of any other field.
A field may declare both, and it is coherent authoring — storage-level required, with the value guaranteed by the producer:
remind_at: Field.datetime({ required: true, defaultValue: 'NOW()' }),But the control opens empty (see the table), so enforcing required on it
refused the submit with nothing sensible for the user to type. In create
mode a runtime defaultValue therefore suppresses the client-side required
rule, and the field is omitted from the payload — omitted, not sent empty,
because applyFieldDefaults resolves the declaration only for a field that
arrives absent or null, and a blank string is neither.
The conditional spelling reaches the same verdict. A requiredWhen
predicate says "required in this state", but NOW() / current_user resolve
at insert regardless of state, so the producer's guarantee covers the
conditional claim exactly as it covers the unconditional one:
remind_at: Field.datetime({ requiredWhen: 'record.status == "scheduled"', defaultValue: 'NOW()' }),| Mode | Declared | Left empty | Effect |
|---|---|---|---|
| create | required / requiredWhen TRUE + a runtime default |
yes | submits; the key is absent, and the server resolves it |
| create | required / requiredWhen TRUE + a runtime default |
no (user typed) | submits the typed value — it outranks the default |
| create | required / requiredWhen TRUE + a static literal |
user cleared the seeded control | refused: they removed a value that was really there |
| create | requiredWhen TRUE, no default |
yes | refused: nobody else supplies this value |
| edit | required / requiredWhen TRUE, anything |
user blanked it | refused: the token was resolved at insert, so this is a real removal |
The required marker and aria-required go with the rule in the create case,
since one verdict drives all three — in that mode the user genuinely is not
required to provide the value. The hint above says that the server supplies it;
showing what it will supply, as a non-authoritative preview, is a separate
follow-up.
The two halves of that verdict are importable, so a host with its own form renderer applies the same rule rather than re-deriving it (objectui#6059):
declare const field: { required?: unknown; defaultValue?: unknown };
declare const isCreateForm: boolean;
declare const values: Record<string, unknown>;
declare const objectSchema: FieldDefaultsSchemaLike;
import {
isRequiredInForm,
omitServerResolvedDefaults,
type FieldDefaultsSchemaLike,
} from '@object-ui/plugin-form';
// Should this create form enforce `required` on the field?
const required = isRequiredInForm(field, isCreateForm);
// Drop the keys the producer must resolve, immediately before the insert.
const payload = isCreateForm ? omitServerResolvedDefaults(values, objectSchema) : values;They are published as a pair on purpose: excusing a server-owned field from
required and then submitting the key anyway is not half a fix, it is no fix.
Both are pure functions over plain data — no React, no registry — and
omitServerResolvedDefaults is create-only, so the caller keeps the mode
gate, as the example shows.
Every consumer reads one predicate, isRuntimeDefault in @object-ui/core
(re-exported for this package's own use by src/schemaDefaults.ts, not from the
package entry listed above) — which is what keeps a form from seeding a field it
also refuses to submit. The static half is decided at the producer, in
isRequiredInForm; the conditional half cannot be, because requiredWhen is
resolved downstream against the live record, so it is suppressed inside the one
evaluator that resolves it — resolveFieldRuleState, reading
isServerOwnedValue. Display and validation share that single verdict, so a
field can never lose its asterisk while still refusing the submit.
A form section declares its members exactly one way: it enumerates fields, or
it points group at one of the object's declared fieldGroups and inherits that
group's members and its presentation (objectstack#13855, ADR-0085 §5 — the
spec range that carries it is the @objectstack/spec entry in this package's own
package.json). ObjectForm resolves the reference once, above its routing
fork, so all six layouts inherit it. ModalForm resolves its own sections
through the same call as well, because hosts mount it directly rather than
through ObjectForm — the console's record create / edit dialog and
action-opened modals do (objectui#11542). DrawerForm mounted directly does
not resolve them; reach it through ObjectForm with formType: 'drawer'.
A host with its own section builder resolves it with the same function instead of deriving sections itself (objectui#8641):
import {
resolveSectionGroupReferences,
type ResolveSectionGroupsOptions,
} from '@object-ui/plugin-form';
import type { ObjectFormSection } from '@object-ui/types';
// What the host's own builder produced. A `{ group: 'x' }` section is still
// unresolved here — `ObjectFormSection` declares `group` and leaves `fields`
// optional, which is the whole shape this call takes in.
declare const authoredSections: ObjectFormSection[];
declare const objectDef: unknown; // `{ fields, fieldGroups }`, or null while loading
const sections = resolveSectionGroupReferences(authoredSections, {
objectName: 'ticket',
formType: 'simple',
objectDef,
});Every { group: 'x' } section comes back as the section that group declares —
label, members, description and collapse state — and everything else comes back
untouched: with no reference in the list the input array is returned by
identity, so the call cannot perturb an existing form and is safe inside a
useMemo. A reference that resolves to nothing yields an empty section (never a
dropped one — a sections array that empties out stops being a sectioned form at
all) and is reported once, naming the object and the key.
⛔ Nothing about the assembly belongs to the caller. Declared order, the
empty-group drop, the ungrouped trailing bucket and the collapse / visibleWhen
passthrough all come from deriveFieldGroupLayout through this package's single
adapter onto the section shape — the same code path the no-sections field-group
fallback uses, so a group authored by reference and one derived by the fallback
are the same section by construction. A host reaching for deriveFieldGroupLayout
directly would have to re-spell the collapse enum onto its own boolean pair and
pass visibleWhen through by hand, which is the duplication this export exists to
prevent.
A section's fields takes three entry shapes, and every formType draws them
the same way: a field name and the form view's { field, … } entry (which
overrides that object field) are resolved against the object schema, while an
inline runtime FormField keyed by name carries its own definition and is
drawn as it stands.
On the default (simple) form the two named shapes resolve against the form's
parent field pool — top-level fields when given, else the object's fields,
plus customFields — so a named member the pool does not hold is dropped
(reported once when the object declares it: fields and sections
intersect). An inline entry names nothing to resolve, so it is drawn whatever
the pool holds, exactly as the other five layouts draw it (objectui#11615). A
simple form whose sections list only inline entries is therefore a
self-contained collector, like the inline wizard below — see
What a form submits to.
Code that reads a section's entries — a host holding the sections
resolveSectionGroupReferences returns, say — names each one with
sectionEntryName(entry): the string itself, the { field } entry's field,
the inline entry's name (undefined when the entry names nothing). Narrowing
by hand and reading .name off every object entry reads undefined off a
{ field } entry, and ObjectFormSection.fields now types that read as an
error.
A sectioned form renders as ONE grid, and two keys decide its shape:
| Key | Meaning |
|---|---|
the form view's columns |
how wide the grid is (1–4) — spec FormView.columns |
a section's columns |
how densely THAT section fills the grid (via per-field colSpan) |
The view's columns wins; without it the grid takes the widest section's
density, and without either it is single-column. Every sectioned host resolves it
the same way — ObjectForm (simple), ModalForm, TabbedForm, SplitForm,
WizardForm — so one piece of metadata lays out identically wherever it is
rendered. (WizardForm has no widest-section fallback: its steps never share a
viewport, so each step keeps its own authored width.)
The grid is applied to the field container inside the form, never wrapped
around the <form> (which would put the whole form in cell 1 and leave the other
columns empty — #2128).
className and gridClassName on a form-view section do nothing. In JSON
metadata, authoring them is not a parse error — the renderer simply does not
read them. In TypeScript they are a compile error: ObjectFormSection in
@object-ui/types does not declare either key (objectui#7200), matching the
spec's strict FormSectionSchema.
This is deliberate. Both keys sit on the SDUI-only side of the authorable
boundary: @objectstack/spec does not declare either on the form-view/section
surface, and the authorable-surface ledger carries no entry for them. The
renderer nevertheless reached them through as any at seven sites until
objectstack#13626 (maintainer ruling 2026-09-01) retired those reads — the
boundary had been declared on one side and crossed on the other.
Declaring the keys instead was weighed and rejected: it would invite free Tailwind strings into authored metadata, which is the class the boundary exists to keep out, and per ADR-0065 / ADR-0080 utility classNames in runtime metadata are never scanned by the build-time Tailwind — so they silently produce no CSS even when they are read. If per-view styling becomes a real product need it will get an explicit controlled token surface rather than two leaked keys.
What still works: the form ROOT className (FormSchema.className) is a
different key on a different node and is unaffected; section layout stays
authorable through columns (above); and a host application styles sections
through its own CSS.
Note for contributors:
ObjectFormSectionin@object-ui/typesno longer declares either key (objectui#7200), so an uncastclassName: s.classNamefails to compile — but a cast read (the shape the seven retired sites had) still would, and JSON metadata carries whatever an author wrote. The non-consumption is therefore pinned behaviourally, insrc/__tests__/sectionStyleKeysRetired-13626.test.tsx, across all seven arms; the declaration side is pinned in@object-ui/types'object-form-section-style-keys-undeclared.test.ts.
A sectioned form is one form. Instead of rendering a form per section — which strands every section but the first outside the submit, and (in tabs) lets the inactive panel unmount with its values — declare tabs on the single form and let the renderer distribute the fields:
{
type: 'form',
fields: [/* every tab's fields, in one flat list */],
fieldTabs: [
{ key: 'basics', label: 'Basics', fields: ['subject', 'status'] },
{ key: 'detail', label: 'Detail', description: 'Anything else', fields: ['description'] },
{ key: 'billing', label: 'Billing', fields: ['vat_id'], visibleWhen: 'record.status == "won"' },
],
defaultFieldTab?: 'basics', // defaults to the first tab
fieldTabsPosition?: 'top', // 'top' | 'bottom' | 'left' | 'right'
}- All panels are force-mounted and only CSS-hidden, so a tab the user leaves keeps its values and its validation (react-hook-form skips unmounted fields, which is how a required field on an unopened tab used to reach the server).
- A failed submit activates the tab holding the first offending field and
marks every tab with a rejected field (
data-erroron the trigger) — for both client-side rules and serverfields[]rejections. - Fields no tab claims render above the tab strip rather than disappearing.
- Needs at least two tabs, and is ignored when the form uses
children.
ModalForm (contentLayout: 'tabbed') and TabbedForm are built on this, and
both forward a section's visibleWhen onto the tab they synthesise — see the
support table below.
A tab may carry a visibleWhen predicate — the same slot, vocabulary and
engine as the field-level rule (string | { dialect?, source }, a CEL
predicate over the live record, evaluated by @objectstack/formula with the
host predicate scope bound, so it can read current_user / data /
features exactly as a field rule can — there is no app root, objectui#8155).
Like every conditional rule in this
system it fails open: a predicate that cannot be evaluated leaves the tab
visible rather than hiding data behind a broken expression.
When the predicate resolves FALSE the renderer draws neither the tab's trigger nor its panel — a hidden tab disappears from the tab strip entirely, it is not merely an empty panel.
Submit semantics are deliberately counter-intuitive — visibility gates drawing, and nothing else:
- A hidden tab's values still submit. Hiding a tab does not remove its fields' values from the record: whatever they hold (loaded from the record, seeded by defaults, or typed before the tab hid) is carried in the payload. A visibility rule is not a data filter — dropping the values would turn a cosmetic predicate into a silent data-loss door.
- A hidden tab's fields skip client-side validation. A user is never blocked by an error pointing at a control they cannot see. The flip side: a required field on a hidden tab sails past the client — the server-side contract is the loud floor for genuinely-required data, and a submit missing it fails there, visibly.
- Stale errors clear. A tab hiding mid-session (its predicate flipping on
a keystroke or a scope change) clears its fields' leftover validation
errors, the same hygiene a field's own
visibleWhenapplies.
These are not tab-specific rules: they are the ruled hidden-group semantics
every section visibleWhen follows, inherited through the same mechanism a
field's own FALSE predicate uses (the fields simply stop being drawn; the form
keeps their values and skips unmounted controls at validation).
Two mechanics worth knowing:
- Selection is derived over the visible tabs. If the predicate hides the
ACTIVE tab, the renderer re-selects deterministically — the user's own pick
if still visible, else
defaultFieldTab, else the first visible tab — so the form never shows an empty panel. The pick itself is kept: the tab the user chose becomes active again the moment its predicate re-admits it. - Whether the tabbed layout engages at all is judged on the DECLARED tabs (the "needs at least two tabs" rule above counts declarations, not verdicts). A predicate hiding all but one tab filters what is drawn; it never collapses the strip into the flat layout mid-interaction.
Where visibleWhen on a tab works today — this key landed renderer-first,
and only where the renderer actually evaluates it:
| Authoring surface | Carries the predicate? |
|---|---|
type: 'form' with fieldTabs[].visibleWhen (as above) |
Yes — evaluated by the form renderer |
ModalForm / formType: 'modal' with contentLayout: 'tabbed' |
Yes — the section's visibleWhen is copied onto its tab |
TabbedForm / formType: 'tabbed' sections |
Yes — the section's visibleWhen is copied onto the tab TabbedForm synthesises; a single-section form, which draws no tab strip, gates the group through the flat layout's own section-divider mechanism instead |
WizardForm / formType: 'wizard' steps |
No — steps are not tabs; nothing evaluates a step-level predicate |
The remaining No row is deliberate, not an oversight: declaring the key on a surface whose renderer ignores it would make the metadata lie (#6111), so the key stops at the boundary until each surface enforces it.
⛔ A wizard step has no predicate slot at all, and that is enforced by the
type. A step predicate is not the tab predicate under another name — steps are
mounted one at a time and keyed by index, so a step predicate would be
step-boundary reactive where a tab's is live-record reactive, and it would need
navigation, indicator, isLastStep, final-gate and re-selection semantics that
none of the tab machinery supplies. So WizardStepConfig is declared
independently of FormSectionConfig (maintainer ruling 2026-08-30, #6237) and
simply does not carry the key: visibleWhen on a wizard step literal is a
compile error, not a silently ignored key. Track #6237 for the step
contract.
That row also reports itself rather than failing silently. Authoring a
section visibleWhen on formType: 'wizard' logs a console warning naming the
layout and the sections whose predicate is being dropped — untyped JSON reaches
the renderer too, so the compile-time half cannot be the only one. Nothing warns
on the five arms that honour the key.
allowSkip lets the user jump to any step from the indicator instead of walking
through them in order. It is navigation freedom, not an exemption from the
object's rules:
- Next validates the step you are leaving, as always.
- The final submit checks every step's required fields — not just the last
one's — and if something is outstanding it sends the user to the first step
that has one, marks that step's indicator (
data-error="true"), and names the fields in a toast. Nothing is sent. - Conditional rules are respected: a field whose
visibleWhenis false, or whoserequiredWhenis false, is not demanded. The check runs on the same canonical engine as the renderer and the server's rule-validator, so all three agree.
This matters because react-hook-form only validates the fields currently mounted, and a wizard mounts one step at a time — so a required field on a step nobody opened used to be absent from the payload with nothing on screen saying so (#2959's validation half, in a wizard).
The same rule for side-by-side panels: the <form> wraps the whole panel group
and each pane holds only fields, so one react-hook-form instance spans the
divider.
{
type: 'form',
fields: [/* every pane's fields, in one flat list */],
fieldPanes: [
{ key: 'primary', fields: ['subject'], defaultSize: 50 },
{ key: 'secondary', fields: ['status', 'priority'], defaultSize: 50, minSize: 20 },
],
fieldPanesOrientation?: 'horizontal', // 'horizontal' | 'vertical'
fieldPanesResizable?: true, // false pins the divider
}- A submit from anywhere carries every pane's values, and a field rule
(
visibleWhen/requiredWhen/ …) in one pane can read a field in another — neither is possible with a form per panel. defaultSize/minSizeare percentages of the group.- Each pane is its own
@container, so a multi-column group inside it collapses as the divider is dragged narrower. - Fields no pane claims render above the panel group rather than disappearing.
- Needs at least two panes; ignored when the form uses
childrenorfieldTabs.
SplitForm is built on this. Each section declares its panel via pane: 'primary' | 'secondary' (spec FormSection.pane) — explicit per-section
placement, so reordering sections never silently moves them across the divider.
When omitted, the legacy positional rule applies: the first section becomes the
primary pane, the rest stack in the secondary one behind inline section headers.
(The spec rejects pane on non-split form types at parse.)
import type { FormSchema } from '@object-ui/types';
const schema: FormSchema = {
type: 'form',
fields: [
{
name: 'name',
type: 'input',
label: 'Full Name',
placeholder: 'Enter your name',
required: true
},
{
name: 'email',
type: 'input',
inputType: 'email',
label: 'Email Address',
required: true,
// Rule name → rule. Not an array (see Schema API above), and there is no
// 'email' rule: the email check is the pattern the metadata path builds
// for a field of type `email`.
validation: {
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'Invalid email format'
}
}
},
{
name: 'country',
type: 'select',
label: 'Country',
options: [
{ label: 'United States', value: 'us' },
{ label: 'Canada', value: 'ca' },
{ label: 'United Kingdom', value: 'uk' }
]
},
{
name: 'subscribe',
type: 'checkbox',
label: 'Subscribe to newsletter'
}
],
submitLabel: 'Register',
onSubmit: (data) => {
console.log('Form submitted:', data);
}
};A multi-step form is an object-form with formType: 'wizard', and its
steps are its sections — one step per section. There is no
multi-step-form schema type (no registration anywhere claims that name) and no
steps key on any form schema, so a schema written that way renders the
unknown-component placeholder and the fields inside steps are never read.
import type { ObjectFormBlockNode } from '@object-ui/types';
// The props go in the node's `properties` bag — the members of
// `@objectstack/spec`'s `ComponentPropsMap['object-form']` row.
const schema: ObjectFormBlockNode = {
type: 'object-form',
properties: {
objectName: 'contacts', // the object whose fields the steps list
mode: 'create',
formType: 'wizard', // routes to WizardForm — needs at least one section
sections: [
{
name: 'personal',
label: 'Personal Info',
fields: ['first_name', 'last_name'] // field NAMES, resolved from the object schema
},
{
name: 'contact',
label: 'Contact Info',
fields: ['email', 'phone']
}
],
allowSkip: false, // see "Wizard steps and allowSkip" above
showStepIndicator: true
}
};A section's fields accepts three shapes — a field name, a spec
FormFieldSchema object (whose identity key is field), or an inline runtime
FormField object. The inline shape is what lets a wizard run with no data
source at all, which is the closest equivalent of the old snippet; note that
WizardForm reports a data-source-less submit through onSuccess (there is no
onSubmit on this schema). Every step's fields have to be the inline shape
for that to hold — one bare name among them means the form needed object
metadata it could not fetch, and the submit is refused rather than confirmed
(again, What a form submits to):
import { WizardForm } from '@object-ui/plugin-form';
import type { WizardFormSchema } from '@object-ui/plugin-form';
const wizard: WizardFormSchema = {
type: 'object-form',
formType: 'wizard',
objectName: 'contacts',
mode: 'create',
sections: [
{
name: 'personal',
label: 'Personal Info',
fields: [
{ name: 'firstName', type: 'input', label: 'First Name', required: true },
{ name: 'lastName', type: 'input', label: 'Last Name', required: true }
]
},
{
name: 'contact',
label: 'Contact Info',
fields: [
{ name: 'email', type: 'input', inputType: 'email', label: 'Email', required: true },
{ name: 'phone', type: 'input', inputType: 'tel', label: 'Phone' }
]
}
],
onSuccess: (data) => {
console.log('Multi-step form completed:', data);
}
};
<WizardForm schema={wizard} /> // dataSource omitted: every step lists inline fieldsWizardFormSchema declares no index signature, so an invented key on this
type is a real compile error — and since objectui#8347 removed BaseSchema's
[key: string]: any, the same holds for ObjectFormSchema.
One more route exists and is worth knowing about rather than reinventing: a flat
object-form can be turned into a stepper on small viewports with
mobile: { stepper: true | 'auto', stepperFieldsPerStep, stepperMinFields },
which feeds the same WizardForm.
import type { FormSchema } from '@object-ui/types';
const schema: FormSchema = {
type: 'form',
validationMode: 'onBlur',
fields: [
{
name: 'username',
type: 'input',
label: 'Username',
required: true, // presence: this is the key that decides it
validation: {
required: 'Pick a username', // the MESSAGE for the rule above, nothing more
minLength: { value: 3, message: 'Username must be at least 3 characters' },
maxLength: { value: 20, message: 'Username must be less than 20 characters' },
pattern: {
value: /^[a-zA-Z0-9_]+$/, // a RegExp, not a string
message: 'Only letters, numbers, and underscores'
}
}
},
{
name: 'password',
type: 'input',
inputType: 'password',
label: 'Password',
required: true,
validation: {
minLength: { value: 8, message: 'Password must be at least 8 characters' },
pattern: {
value: /(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])/,
message: 'Must contain uppercase, lowercase, and number'
},
validate: (value) =>
String(value).toLowerCase() !== 'password' || 'Pick something less guessable'
}
}
]
};Each rule appears once, under its own name — an object, not a list. A second
minLength cannot exist, which is the point: the shape the renderer hands
react-hook-form is one rule per kind.
A form has somewhere to put the values when it has either a dataSource (it
writes through the adapter) or a declared submitHandler (the host owns the
write and needs no adapter of its own). With neither, exactly one shape is still
legitimate: fields authored inline, where the author's onSuccess is the
write. That is
- a non-empty
customFields, or sectionswhose fields are all inline runtimeFormFieldobjects.
Anything else — a form naming fields the object schema would have to resolve,
with no adapter to fetch it and no host seam to hand the values to — refuses
the submit with DataSource is required for form submission (inline mode not configured). The error reaches schema.onError and is rethrown.
This is uniform across all six renderers (simple, tabbed, wizard, split,
drawer, modal). It used to be uniform in the other direction: the five
variant renderers answered an adapter-less submit by calling onSuccess and
returning, which reported a save that never happened and, through
MasterDetailForm, produced a success toast over a form it then cleared
(objectui#6300). A declared submitHandler is consulted first, so a host that
said it owns the write is never bypassed for want of an adapter it never needed.
An object-form in mode: 'edit' with a recordId reads the record with
dataSource.findOne, and its save writes only the fields that differ from
that read (objectui#10156). Every layout does this: the simple form and the
tabbed, wizard, split, modal and drawer variants (objectui#10563). So
does a simple form whose mobile stepper option shows it one step at a time
through the wizard, and the parent operation of a master-detail form, whose
header is a simple or a tabbed form. A master-detail child row already worked
this way (objectui#10108), and all of them use the same comparison.
A master-detail edit reads each collection's lines in their stored order: ascending by the child's sort field (the one its line grid stamps with each line's position), the record id breaking ties (objectui#12286). The grid renumbers that field only when a line is added, removed, duplicated or moved; a cell edit leaves every line's position as it was read. So editing one line sends one update, and moving a line sends only the lines whose position changed.
Before that comparison, every layout strips what a form never writes: the
server-owned columns (record identity, audit provenance, ownership and tenancy
— the roster in sanitize.ts, plus any field the object marks system),
computed, formula and read-only columns, keys the object does not declare, and
every field the caller's field-level security refuses. A create is stripped the
same way. Every layout also renders a field the caller may read but not edit as
a disabled input, so nothing is typed into a field the save would leave out.
Every layout likewise disables every field of a managed object (ADR-0092 D4)
whose userActions do not open the form's mode, create on a create form and
edit on an edit form, and of any object whose create or update the
server's effective API operations deny (objectui#10612). While that lock holds,
every layout shows one notice above the fields that names the object, by its
translated label, and the missing permission (form.noPermissionToCreate or
form.noPermissionToEdit, objectui#11000); a field locked on its own shows none.
The submit button of the simple, tabbed, split, modal and drawer layouts
stays on screen and the lock does not disable it; the server's write guard is
what refuses the save.
A wizard disables Next and its final submit button, and its step indicator does
not jump forward, while Cancel and Back stay usable.
The record's owner is the one exception, and only on an edit
(objectui#12205). owner_id is in the server-owned roster, so a form never
echoes it back: the platform refuses an echoed owner from every role without
the transfer grant (objectui#10108). The spec declares it system but not
readonly, because ownership is transferable from a form. So an edit save adds
it back when, and only when, its value differs from the record the form read: a
new owner is sent as its id, and a cleared one as null. Field-level security,
a readonly declaration on the object and the unknown-key strip still keep it
off the wire. A create never sends it, and neither does a form with no record
read of its own (listed below). The server decides whether the caller may make
the change, and the form shows a refusal as its error. The business-unit and
tenant columns are not reassignable from a form and are never sent. The
master-detail form's batch still strips the owner from the header operation, so
an owner changed in a master-detail header is not saved.
The comparison sends every field it cannot prove unchanged, because a field wrongly judged unchanged would lose the user's edit while the server still answers 200:
nullandundefinedcount as the same value.''is not a blank here, sonulland''are different.- A number and a numeric string are different (
5and'5'). - A lookup id and the expanded lookup object are different.
- A
Dateand a date string are different. So are two date strings written in different formats. - Objects and arrays are equal only when they serialize identically. Reordering their keys or elements makes them different.
A field the form changed by itself after the read is a change, so it is sent. That covers a cascade clear or a value cleared when its field was hidden.
Some saves still send the full payload:
- A save with nothing changed. It sends every field, as it always has.
That keeps it a real request, with the same concurrency guard and a real
server record for
onSuccess. - A form with no record read of its own. This includes a create, a record
given as
initialDataor through inlinecustomFields, and a save made while a new record is still loading. Each of these sends every field.
After a successful save, the form treats the fields it just wrote as saved. A form that stays open therefore compares its next save with the record as it stands now, not as it was first read.
The concurrency guard is unchanged by this rule. The update carries
ifMatch = the updated_at the form read. Once a form that stays open has
saved the record, its next save carries the updated_at that save returned
instead, so the form is not refused over its own earlier save. A 409 still
offers Keep editing or Overwrite. Overwrite now resends only the
changed fields, so it no longer rewrites fields this user never touched.
submitHandler gets the same payload in edit mode. Normally that is
the changed fields; after a save with nothing changed, it is the full payload.
A host that needs the whole record must read it itself.
The adapter is not a schema key. A schema is a serialisable document; a live adapter is an object with methods, so it cannot travel in one. Both form routes read the adapter from React context, which the host installs once above the whole tree — the same rule Registering a component under your own key states for custom registrations.
This is the route that actually reads and writes a record. An object-form
node names its object with objectName and its intent with mode, both
in its properties bag, whose members are @objectstack/spec's
ComponentPropsMap['object-form'] row; objectui validate refuses a prop
written flat on the node by name. SchemaRenderer hoists the bag onto the node,
and ObjectFormSchema (packages/types/src/objectql.ts) is the node as
ObjectForm then reads it, with both keys required. The adapter arrives on the
context, which ObjectFormRenderer reads at src/index.tsx before handing
ObjectForm its dataSource prop.
import { SchemaRendererProvider, SchemaRenderer } from '@object-ui/react';
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
import '@object-ui/plugin-form';
import type { ObjectFormBlockNode } from '@object-ui/types';
const dataSource = createObjectStackAdapter({
baseUrl: 'https://api.example.com',
token: 'your-auth-token',
});
const schema: ObjectFormBlockNode = {
type: 'object-form',
properties: {
objectName: 'users',
mode: 'create',
fields: ['name', 'email'],
submitText: 'Create user',
},
};
export const App = () => (
<SchemaRendererProvider dataSource={dataSource}>
<SchemaRenderer schema={schema} />
</SchemaRendererProvider>
);The object comes from objectName; there is no resource key. For mode: 'edit'
or 'view', add the recordId of the record being opened. Note that this
mode vocabulary is 'create' | 'edit' | 'view' and it lives on object-form
only — the basic form node has no mode (retired by objectui#10286, see
Schema API).
A bare form never fetches or saves by itself: it has no object name and no
query, so there is nothing for it to call an adapter with. It collects values
and hands them to your onSubmit, which the renderer awaits
(await onSubmitProp(formData) in the submit handler of
packages/components/src/renderers/form/form.tsx). Any persistence is
whatever that function does:
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
import type { FormSchema } from '@object-ui/types';
const dataSource = createObjectStackAdapter({
baseUrl: 'https://api.example.com',
token: 'your-auth-token',
});
const schema: FormSchema = {
type: 'form',
fields: [
{ name: 'name', type: 'input', label: 'Full Name', required: true },
{ name: 'email', type: 'input', inputType: 'email', label: 'Email', required: true },
],
submitLabel: 'Create user',
// The adapter reaches this call through the CLOSURE, not through the schema.
onSubmit: async (data) => {
await dataSource.create('users', data);
},
};onSubmit is a function, so this route is TypeScript-authored schemas only —
a JSON metadata document cannot carry one. Metadata pages take the object-form
route above.
What the adapter on the context still does for a bare form is supply the
field widgets: the renderer reads it
(const contextDataSource = schemaCtx?.dataSource ?? null) and passes it down
per field (dataSource: contextDataSource), which is how a lookup or cascading select loads its
options. Nothing else about the form is wired to it.
Neither of these is a key of either form schema, and writing them changes nothing:
| Written on a form schema | What actually happens |
|---|---|
dataSource |
Discarded. The basic form drops it in both directions — the built-in branches hand their control an allowlist (toFormControlDomProps) that does not name it, and the renderer's own props destructure takes it off the node — so it never reaches a widget and never reaches the DOM. The adapter the fields receive is the context one |
resource |
Never read. It is not declared on FormSchema or ObjectFormSchema at all. It used to exist elsewhere in the protocol — on CRUDSchema, where CRUDBuilder set it — but objectui#5373 retired both under ADR-0049, so today the key names nothing anywhere in this package's surface, and no form renderer reads it under any spelling |
Both survived compilation while FormSchema and ObjectFormSchema extended a
BaseSchema that declared [key: string]: any: an invented key was not a type
error, it was simply never read. objectui#8347 removed that signature, so both are
compile errors in an annotated literal now. That is also why the
older version of this section looked like it worked: its onSubmit genuinely ran
and genuinely saved, but through the adapter its closure captured. The dataSource
and resource keys sitting beside it in the same object were inert. Delete them and
that example behaves identically — which is the test for whether a key is doing
anything.
A top-level
dataSourcedoes mean something on a schema node, but it is not an adapter: it is the spec's element binding (PageComponentSchema.dataSource, objectstack#6953) — a descriptor such as{ object: 'users' }— resolved byuseElementDataSource(packages/react/src/hooks/useElementDataSource.ts).object-formpasses through that gate, and honours the binding'sobjectkey only. Handing that slot a live adapter is rejected on purpose: the predicate refuses any value carrying afindmethod (isElementDataSourceConfiginpackages/core/src/data-scope/element-data-source.ts), so an adapter written there is ignored rather than mistaken for a binding. Pass adapters through the provider above.
FormSchema and FormField are protocol types, so they live in
@object-ui/types alongside the rest of the JSON contract. This package imports
them and does not re-export them — the form types on its own entry are the
per-container ones (TabbedFormSchema, WizardFormSchema, ModalFormSchema, …)
listed under Public exports.
import type { FormSchema, FormField } from '@object-ui/types';
const emailField: FormField = {
name: 'email',
type: 'input',
inputType: 'email',
label: 'Email',
required: true
};
const loginForm: FormSchema = {
type: 'form',
fields: [emailField],
submitLabel: 'Sign In'
};The plugin includes these field components:
- Text input
- Email input
- Password input
- Number input
- Textarea
- Select dropdown
- Checkbox
- Radio group
- Date picker
- File upload
MIT — see LICENSE.