| title | Plugin Form |
|---|
import { InteractiveDemo } from '@/app/components/InteractiveDemo'; import { PluginLoader } from '@/app/components/PluginLoader';
Advanced form components with validation, multi-step forms, and comprehensive field support.
npm install @object-ui/plugin-form<PluginLoader plugins={['form']}>
Each preview below is an object-form node drawn by this plugin — the JSON
in the Code tab is the whole example, and the fields on screen were built from
the users object's own metadata rather than written into that JSON. Both the
metadata and the record come from the docs site's demo data source, because
dataSource is not a schema key: it is the prop the registered renderer pulls
off SchemaRendererProvider context (see
Registration is a side effect of the import
below), so in your own app these same nodes read whatever object your data
source serves.
A plain form node with its fields written out inline is the
@object-ui/components renderer, not this plugin — that is what these two
examples used to be, and they now live in the catalog under
components-form-form.
- Form Builder - Create complex forms from schemas
- Validation - Built-in validation with custom rules
- Multi-Step Forms - Wizard-style multi-step forms
- Field Types - All standard HTML5 input types
- Form State - Automatic state management
- Error Handling - Field-level and form-level errors
{
type: 'form',
fields: FormField[],
submitLabel?: string,
cancelLabel?: string,
onSubmit?: (data) => void,
onCancel?: () => void,
className?: string
}
FormField here means the one declared by @object-ui/types
(packages/types/src/form.ts) — @objectstack/spec ships its own copy of this
name (json-schema/ui/FormField.json) with a different key set, so read this
table against the @object-ui/types copy rather than the same-named schema in
the spec. This page does not redeclare it, because a locally written
interface compiles no matter how far it has drifted from the real one.
name is its only required key: type and label are both optional.
| Key | Type | What it does |
|---|---|---|
name |
string |
required — the key the value is submitted under |
id |
string |
stable render key; falls back to name |
label |
string |
optional. With none, no label element is rendered at all, and validation messages fall back to name |
description |
string |
help text rendered 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 when it can render — a registered field:<widget>, a built-in control, or a native email / password input. Any other name falls back to type, as the spec's FieldSchema.widget says ("Degrades to the type renderer when unregistered") |
placeholder |
string |
placeholder text |
required |
boolean |
the presence rule. validation.required does not make a field required — it only supplies the message |
disabled |
boolean |
not interactive, muted |
readonly |
boolean |
shown plainly, not editable — deliberately distinct from disabled |
hidden |
boolean |
the field is not rendered at all |
options |
SelectOption[] | RadioOption[] |
choices 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 enforces. They fail open on screen; a visibleWhen that cannot be evaluated refuses the submit, naming the field and the rule |
visibleOn |
string | { dialect?, source } |
view-level visibility predicate: the runtime slot a form view field's visibleWhen is carried into, ANDed with the field's own visibleWhen above. A form view field writes visibleWhen; the view's old visibleOn spelling is retired in @objectstack/spec 18 and is not read |
dependsOn |
DependsOnInput |
cascading parent(s): a bare name, a list of names, or { field, param } entries |
span |
'auto' | 'full' |
relative field 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, … |
FormField also declares [key: string]: any, so a misspelled or invented key
is not a compile error — it is simply outside the contract. One that a
reader might expect here, and that is not a declared member:
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 instead |
An undeclared key still rides the props spread down to whichever component the
field resolves to. A field-level defaultValue reaches the widget the same way
and changes nothing: the control is bound to react-hook-form, whose initial
values come from the form.
There is no ValidationRule type in this repository. A near-spelling does exist —
ObjectValidationRule, the object-level rule union — and it does not type this
key. (DesignerValidationRule, a second one, left the package with
objectui#11434.) The type of the key
is FieldValidationRules (packages/types/src/form.ts), and it 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 } |
text length |
min / max |
{ value: number; message: string } |
numeric range |
pattern |
{ value: RegExp; message: string } |
hand-authored schemas must pass a RegExp: react-hook-form applies a pattern only when its value is instanceof RegExp, and nothing compiles a string into one, so a pattern string written straight into a plain form schema never runs. 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 it hands to
react-hook-form — const rules: any = { ...validation }
(packages/components/src/renderers/form/form.tsx:1652). Spreading an array
into an object literal produces numeric keys ({ '0': …, '1': … }), and
react-hook-form recognises none of them: every rule is dropped, nothing throws,
and the form looks validated while validating nothing.
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: with no data source and no submitHandler, its
onSuccess receives the collected values, as an inline wizard's does.
Code that reads a section's entries names each one with sectionEntryName(entry)
from @object-ui/plugin-form: the string itself, the { field } entry's
field, the inline entry's name (undefined when the entry names nothing).
Reading .name off every object entry reads undefined off a { field }
entry, and ObjectFormSection.fields types that read as an error.
A sectioned form renders as ONE grid. The form view's columns (spec
FormView.columns) sets how wide that grid is; a section's columns sets how
densely that section fills it. The view's value wins; without it the grid takes
the widest section's density, and without either it is single-column.
ObjectForm (simple), ModalForm, TabbedForm, SplitForm and WizardForm all
resolve it that way, so the same metadata lays out identically in every host
(WizardForm has no widest-section fallback — its steps never share a viewport,
so each keeps its own authored width). The grid is applied to the field container
inside the form, never wrapped around the <form>.
One default differs: a dialog. ModalForm, whether ObjectForm opens it with
formType: 'modal' or a host mounts it directly, draws two fields per row from
the container's @md tier up wherever nothing declares columns: the flat
field list, the object's fieldGroups, and every section that declares no
columns in a form that declares none. Such a section draws two per row beside
any other section's count; when the counts do not all fit one grid, the form
switches to a twelve-track grid so each section keeps its own count. A declared
count, 1 included, is drawn as written. Every field takes one cell; a wide
kind (textarea, rich text, markdown, html) and a span: 'full' field take the
row. Below @md, as on a phone, it is one column.
Until @objectstack/spec drops FormSectionSchema.columns' default(1), the
spec's parse writes columns: 1 into a section that omits it, so a section from
a defineView or defineStack stack reaches the dialog declared and draws one
column.
A dialog with no modalSize is bounded: lg for a short form or a single
column, xl otherwise, and never full. Declare modalSize: 'full' for the
95vw dialog. The console's New and Edit dialog on a field-heavy object (twelve
or more authorable fields) stays within that bound on desktop too: the
record-surface derivation still answers a full-screen modal for it, and the
console bounds that answer until the spec derives the bound itself. On a phone
the dialog is a full-screen sheet whatever its size.
A sectioned form stays one form: declare the tabs on it and the renderer distributes the fields into panels, instead of rendering a form per section (which strands every section but the first outside the submit, and lets an inactive tab unmount along with its values).
{
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'
}
- Panels are force-mounted and only CSS-hidden, so a tab the user leaves keeps its values and its validation.
- A failed submit activates the tab holding the first offending field and marks
every tab with a rejected field — client rules and server
fields[]alike. - Fields no tab claims render above the tab strip rather than disappearing.
- Needs at least two tabs; ignored when the form uses
children.
ModalForm (contentLayout: 'tabbed') and TabbedForm build on this, and both
forward a section's visibleWhen onto the tab they synthesise — see the support
table at the end of the next section.
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 drawing
mechanism a field's own FALSE predicate uses (the fields simply stop being
drawn, and unmounted controls skip validation). The value half does not
carry across: a field hidden by its own visibleWhen / visibleOn has its
value cleared (objectui#6958), while a field hidden by its section's or its
tab's predicate keeps it, exactly as stated above. When that clear empties
a field that held a value, the form raises one notice naming the cleared fields
by their labels and saying they no longer apply given the current values
(objectui#8070). A field that was already empty (an unchecked two-state control
counts as empty) is not named, the first render clears and names nothing, and
a later submit refusal replaces the notice.
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 (objectui#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,
objectui#6237) and simply does not carry the key: visibleWhen on a wizard step
literal is a compile error, not a silently ignored key. Track objectui#6237
for the step contract.
That row also reports itself rather than failing silently — untyped JSON
reaches the renderer too, so the compile-time half cannot be the only one.
Authoring a section visibleWhen on formType: 'wizard' logs a console warning
naming the layout and the sections whose predicate is being dropped:
[ObjectForm] Section \visibleWhen` is not yet supported on this layout: the `wizard` layout's steps drop the predicate, so section(s) pay render unconditionally. …`
It changes no behaviour — the predicate is still dropped on that one arm — it only stops the drop from being invisible. Nothing warns on the five arms that honour the key.
The stacked layouts (the default layout, formType: 'modal' and
formType: 'drawer', with explicit sections or with sections derived from
the object's fieldGroups) follow one rule for a section's divider row:
- A section's own settings apply whether or not it has a heading. Its
visibleWhenhides the whole group, fields included, on these three layouts. A section with nolabelis never un-gated there. (The split layout still draws and gates a section's row only when it has alabelor adescription, and the wizard drops a step predicate, as described above.) - The divider row is drawn when the section has a
labelor adescription. With adescriptionalone it is a blurb-only row: no heading, just the blurb. - A section without a row is still its own block. When it follows a
section that has one, its fields start below a rule of their own instead of
continuing under that heading. This is how the fields that belong to no
declared
fieldGroupsentry render: after the groups, apart from the last one, with no heading and no placeholder title. - The collapse control sits on that row, so
collapsible/collapsedwork on a titled row and on a blurb-only row alike (collapsed: trueimpliescollapsible). - A section that declares
collapsibleorcollapsedbut has neither alabelnor adescriptionhas no row to hold the control. It renders open and a console warning says so ("collapsible section has no heading or description to carry its control"). Its fields are never hidden without a control to bring them back.
A modal with contentLayout: 'tabbed' and more than one explicit section draws
tabs instead of rows, and tabs do not collapse. With a single section, or with
sections derived from fieldGroups, it renders stacked and follows the rules
above.
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: the final submit checks every step's required fields, and if
something is outstanding it returns 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 (visibleWhen / requiredWhen
are evaluated on the same canonical engine as the renderer and the server).
Those conditional rules are the fields' rules. A wizard step carries no
visibleWhen of its own today — see the support table under
Conditional tabs.
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.
Side-by-side panels follow the same rule: 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 in one pane can read a field in another — neither works with a form per panel.
defaultSize/minSizeare percentages of the group.- Each pane is its own
@container, so a multi-column group 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 builds on this. Each section declares its panel via pane: 'primary' | 'secondary' (spec FormSection.pane) — explicit placement that survives
reordering. When omitted, the legacy rule applies: section 1 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 '@object-ui/plugin-form';That single import is the whole of registration — there is no components map to
iterate over. Importing the entry runs the five ComponentRegistry.register(...)
calls in packages/plugin-form/src/index.tsx, which claim exactly these schema
types:
| Namespaced key | Bare-name fallback | Renderer behind it |
|---|---|---|
plugin-form:object-form |
object-form |
ObjectForm — metadata-driven form over one record |
view:form |
none — skipFallback: true |
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 plus child line items in one submit |
record:line_items |
none — skipFallback: true |
LineItemsPanel — child grid bound to the record on the page |
ComponentRegistry.register publishes namespace:type, and — unless the call
passes skipFallback: true — the bare type as a back-compat fallback
(packages/core/src/registry/Registry.ts:194, fallback at :226). The two
skipFallback calls here are deliberate: 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; mount the component directly where you need the submission dashboard.
To serve one of this package's components under a key of your own, register the exported component — that is what a manual registration is here:
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, but never a dataSource — that travels on
SchemaRendererContext — so each wrapper resolves it from the context first.
ObjectForm takes dataSource as a prop (optional, because inline
customFields need no adapter), so a custom-key registration either supplies
one or wraps the component the same way.
The components are on the package's export surface — ObjectForm,
TabbedForm, WizardForm, SplitForm, DrawerForm, ModalForm,
EmbeddableForm, MasterDetailForm, LineItemsPanel, FormAnalytics,
FormSectionContainer — alongside the layout helpers (applyAutoLayout,
inferColumns, filterCreateModeFields, …) and the per-container schema types
(TabbedFormSchema, WizardFormSchema, ModalFormSchema, …). There is no
aggregate map among them.
An object-form in mode: 'edit' reads its record with findOne, and its save
writes only the fields that differ from that read. That covers every layout — the
simple form and the tabbed, wizard, split, modal and drawer variants —
as well as a simple form whose mobile stepper option routes it through the
wizard, and the parent operation of a master-detail form. Every layout also
strips what a form never writes, on a create as on an edit: server-owned,
computed and read-only columns, keys the object does not declare, and fields the
caller's field-level security refuses — which every layout renders disabled. A field asks the question of the form's mode: a create form asks whether the caller may set it on a NEW record (the server's insert rule — a field the permission set does not mention falls back to the object's allowCreate), and an edit form whether they may change it on an existing one (allowEdit), so a role that may create but not edit can fill a create form. Every layout also disables every field of a managed object whose userActions do not open the form's mode, of an object whose create or update the server's effective API operations deny, and of an object whose create (create form) or update (edit form) grant the caller does not hold. While that lock holds, the form shows one notice naming the object and the missing permission, and a wizard disables Next and its final submit button. A field whose sameness cannot be proven is sent: 5 and '5', null and
'', and a lookup id and its expanded object all count as different. A save with
nothing changed still sends the full payload, as it always has. The record's
owner (owner_id) is the one server-owned column an edit writes: it is sent when
its value differs from the read, a cleared owner as null, and never when it is
untouched, on a create, or by a master-detail form's batch. The server decides
whether the caller may make the change. The ifMatch
concurrency guard is unchanged, and its Overwrite choice now resends only the
changed fields. A form that stays open after a save sends the updated_at that
save returned as its next ifMatch, so its second save is not refused over its
own first one. A host submitHandler receives the same payload in edit mode.
The package README has the full rule, under "What an edit save writes".
A record:line_items panel offers adding a line (its Add button, entry row and
Duplicate) only when the caller may create the CHILD object, and removing one
only when they may delete it — the relatedNew and relatedRowDelete rows of the
affordance-to-grant map in @object-ui/core, the same rows a related list reads
for the same two writes. Its cells ask each column's field question, as the form
layouts do. The child's own policy is read too: the panel hands the child
object's schema to those two rows, so a child whose userActions close create or
delete offers neither. With no permission provider mounted both read open.
A MasterDetailForm's line grids read the same two rows on the child object.
Remove asks the delete grant on an edit form only: a create form saves the parent
and one create per line, so a line removed before that first save is a draft,
never a server delete, and stays removable for a caller who may only create.
An edit MasterDetailForm reads each collection's lines in their stored order:
ascending by the child's sort field (the field its grid stamps with each line's
position), the record id breaking ties, so lines that share a position, or have
none, open in the same order every time. Its save sends a line only when one of
its values differs from that read: editing a cell sends that line alone, and
moving a line sends only the lines whose position changed.
One validation object per field, keyed by rule name. The annotation is part of
the example: an un-annotated const schema = { … } type-checks whatever is
written in it, while const signUpForm: FormSchema makes a wrong validation
shape a compile error, and a misspelled form-level key too (FormSchema
carries no index signature since objectui#8347; a FormField entry still
carries its own).
import type { FormSchema } from '@object-ui/types';
const signUpForm: FormSchema = {
type: 'form',
fields: [
{
name: 'username',
type: 'input',
label: 'Username',
required: true, // the presence rule lives here, not in `validation`
validation: {
minLength: { value: 3, message: 'Min 3 characters' },
maxLength: { value: 20, message: 'Max 20 characters' },
},
},
{
name: 'email',
type: 'input',
inputType: 'email',
label: 'Email',
required: true,
validation: {
// No `email` rule name exists — an email check is a `pattern`, and its
// value has to be a RegExp for react-hook-form to apply it at all.
pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: 'Enter a valid email address' },
},
},
{
name: 'password',
type: 'input',
inputType: 'password',
label: 'Password',
required: true,
validation: {
minLength: { value: 8, message: 'Min 8 characters' },
required: 'Choose a password', // message only — `required: true` above is the rule
},
},
],
submitLabel: 'Sign Up',
};Spelled as an array instead — validation: [{ type: 'minLength', value: 3, … }]
— every rule is dropped in silence: the field above accepts ab and the form
submits it with no message shown. Under the annotation that spelling is a
compile error (TS2559, "no properties in common with type
FieldValidationRules") rather than a runtime surprise; a JSON metadata
document, which cannot be annotated, has only this page to go by.
The same shape as JSON metadata — the length and range rules carry over unchanged:
{
"type": "form",
"fields": [
{
"name": "username",
"type": "input",
"label": "Username",
"required": true,
"validation": {
"minLength": { "value": 3, "message": "Min 3 characters" },
"maxLength": { "value": 20, "message": "Max 20 characters" }
}
}
],
"submitLabel": "Sign Up"
}pattern and validate are the two rules a JSON document cannot express on
this path: JSON has no RegExp literal and no function, and a pattern string
reaches react-hook-form as a string, which it ignores. An object-bound form
(object-form) does not have that limitation — it compiles the object field's
own declared pattern into a RegExp before the form ever sees it.
Multi-step is a mode of the object-bound form, not a component of its own — there is
no multi-step-form type. The registered type is object-form, and formType: 'wizard'
turns its sections into steps; WizardFormSchema (exported by
@object-ui/plugin-form) declares the shape. Because the wizard resolves its fields from
the object's own metadata, a section lists field names rather than field definitions.
Like every object-form prop, they go in the node's properties bag — the members of
@objectstack/spec's ComponentPropsMap['object-form'] row. objectui validate refuses a
prop written flat on the node by name, as the spec's own page component does.
{
"type": "object-form",
"properties": {
"objectName": "contact",
"mode": "create",
"formType": "wizard",
"showStepIndicator": true,
"sections": [
{
"name": "personal",
"label": "Personal Info",
"fields": ["first_name", "last_name"]
},
{
"name": "contact_details",
"label": "Contact Info",
"fields": ["email", "phone"]
}
]
}
}The plugin supports these field types:
- input - Text, email, password, number, tel, url, etc.
- textarea - Multi-line text input
- select - Dropdown select
- checkbox - Single checkbox
- radio-group - Radio button group
- date-picker - Date selection
- file-upload - File upload
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,
SplitFormSchema, DrawerFormSchema, MasterDetailFormSchema).
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'
};MIT