Skip to content

Latest commit

 

History

History

README.md

@object-ui/plugin-form

Form plugin for Object UI - Advanced form components with validation, multi-step forms, and field-level control.

Features

  • 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

Installation

pnpm add @object-ui/plugin-form

Usage

Automatic Registration (Side-Effect Import)

// 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 }
  ]
};

What the side-effect import registers

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.

Public exports

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';

Registering a component under your own key

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.

Schema API

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.

Form (type: 'form')

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.

Form Field

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.

validation is an object keyed by rule name

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.

What a create form opens with

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.

required (or requiredWhen) + a runtime default

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.

Column width of a sectioned form

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).

Tabbed field layout (fieldTabs)

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-error on the trigger) — for both client-side rules and server fields[] 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.

Wizard steps and allowSkip

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 visibleWhen is false, or whose requiredWhen is 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).

Split field layout (fieldPanes)

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 / minSize are 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 children or fieldTabs.

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.)

Examples

Basic Form

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);
  }
};

Multi-Step Form

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 fields

WizardFormSchema 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.

Form with Validation

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.

What a form submits to

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
  • sections whose fields are all inline runtime FormField objects.

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.

Integration with Data Sources

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.

The metadata route — object-form

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).

The TypeScript route — basic form

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.

Keys that look like wiring but are not

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 dataSource does 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 by useElementDataSource (packages/react/src/hooks/useElementDataSource.ts:139). object-form passes through that gate, and honours the binding's object key only. Handing that slot a live adapter is rejected on purpose: the predicate refuses any value carrying a find method (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.

TypeScript Support

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'
};

Field Components

The plugin includes these field components:

  • Text input
  • Email input
  • Password input
  • Number input
  • Textarea
  • Select dropdown
  • Checkbox
  • Radio group
  • Date picker
  • File upload

Links

License

MIT — see LICENSE.