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 six 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:form-analytics |
form-analytics |
FormAnalytics — submission dashboard |
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 package entry exports these — components, their prop/schema types, and the layout helpers. 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,
} 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,
} 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 |
'edit' | 'read' | 'disabled' |
whole-form mode |
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 [key: string]: any
(packages/types/src/base.ts), so an invented or misspelled key on a form schema
is not a compile error — it is simply never read. That is why every example
below is annotated with its real type and checked against these key tables:
an un-annotated const schema = { … } type-checks whatever is written in it.
FormField (packages/types/src/form.ts) declares 23 keys, and name is the
only required one:
| 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, custom } matcher |
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 |
field |
Record<string, any> |
the resolved object-field metadata object, stashed by the object-bound paths so widgets can read precision, currency, reference_to, … 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 also declares [key: string]: any, so an invented key type-checks
here too. Two that a reader might expect, and that are 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 |
className |
span / colSpan for width, FormSchema.fieldContainerClass for the grid. (An undeclared key still rides the props spread down to whichever component the field resolves to, so a field-level className can visibly land on a built-in control — but nothing in the contract promises that, and a registered widget honours it only if it happens to spread its leftover props. The renderer reads it explicitly on exactly one pseudo-field, type: 'section-divider', where it styles the inline section header.) |
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: string | RegExp; message: string } |
pass a RegExp in a hand-authored schema: react-hook-form only applies a pattern whose value instanceof RegExp, and it is the object-metadata path (buildValidationRules in @object-ui/fields) that compiles a declared string into one |
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 }
(packages/components/src/renderers/form/form.tsx:1652). 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 field defaultValues. 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 | 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 | same reason: the server evaluates it per insert |
an option's default: true |
empty | 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.
Only the field-level defaultValue is read, never a select option's
default: true, even though @objectstack/spec's SelectOptionSchema declares
that key. The server's insert path resolves defaultValue and nothing else, so
a form that also seeded from option-level default would preselect values the
server would never have applied on its own — a renderer-side second default
contract (AGENTS.md #0.1). If option-level default is meant to mean "the
initial value", that belongs at the producer.
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. Showing what the server will supply, as a
non-authoritative preview, is a separate follow-up.
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 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).
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'] },
],
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.
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 { ObjectFormSchema } from '@object-ui/types';
const schema: ObjectFormSchema = {
type: 'object-form',
objectName: 'contacts', // required
mode: 'create', // required
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 — unlike ObjectFormSchema, which inherits
BaseSchema's [key: string]: any.
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.
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. ObjectFormSchema
names its object with objectName and its intent with mode, and both
are required (packages/types/src/objectql.ts); 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 { ObjectFormSchema } from '@object-ui/types';
const dataSource = createObjectStackAdapter({
baseUrl: 'https://api.example.com',
token: 'your-auth-token',
});
const schema: ObjectFormSchema = {
type: 'object-form',
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' — the basic form's mode is a
different key with a different vocabulary ('edit' | 'read' | 'disabled', 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
(packages/components/src/renderers/form/form.tsx:1428). Any persistence is
whatever that function does:
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 at form.tsx:1004
(const contextDataSource = schemaCtx?.dataSource ?? null) and passes it down
per field at :2061, 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 strips it in both directions — dataSource: _dataSource at form.tsx:304 (stripRendererOnlyProps) and :2168 — 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 survive compilation for the reason Schema API gives: FormSchema
and ObjectFormSchema extend BaseSchema, which declares [key: string]: any, so an
invented key is not a type error — it is simply never read. 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:139).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 (packages/core/src/data-scope/element-data-source.ts:131), 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.