Skip to content

Latest commit

 

History

History
378 lines (315 loc) · 10.7 KB

File metadata and controls

378 lines (315 loc) · 10.7 KB

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

Manual Registration

import { formComponents } from '@object-ui/plugin-form';
import { ComponentRegistry } from '@object-ui/core';

// Register form components
Object.entries(formComponents).forEach(([type, component]) => {
  ComponentRegistry.register(type, component);
});

Schema API

Form

Complete form with fields and validation:

{
  type: 'form',
  fields: FormField[],
  submitLabel?: string,
  cancelLabel?: string,
  onSubmit?: (data) => void,
  onCancel?: () => void,
  className?: string
}

Form Field

Individual form field configuration:

interface FormField {
  name: string;
  type: string;                   // 'input', 'select', 'checkbox', etc.
  label: string;
  placeholder?: string;
  required?: boolean;
  validation?: ValidationRule[];
  defaultValue?: any;
  disabled?: boolean;
  className?: string;
}

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: section 1 becomes the primary pane, the rest stack in the secondary one behind inline section headers.

Examples

Basic Form

const schema = {
  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,
      validation: [
        { type: 'email', 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

const schema = {
  type: 'multi-step-form',
  steps: [
    {
      title: 'Personal Info',
      fields: [
        { name: 'firstName', type: 'input', label: 'First Name', required: true },
        { name: 'lastName', type: 'input', label: 'Last Name', required: true }
      ]
    },
    {
      title: 'Contact Info',
      fields: [
        { name: 'email', type: 'input', inputType: 'email', label: 'Email', required: true },
        { name: 'phone', type: 'input', inputType: 'tel', label: 'Phone' }
      ]
    },
    {
      title: 'Review',
      fields: []
    }
  ],
  onSubmit: (data) => {
    console.log('Multi-step form completed:', data);
  }
};

Form with Validation

const schema = {
  type: 'form',
  fields: [
    {
      name: 'username',
      type: 'input',
      label: 'Username',
      required: true,
      validation: [
        { type: 'minLength', value: 3, message: 'Username must be at least 3 characters' },
        { type: 'maxLength', value: 20, message: 'Username must be less than 20 characters' },
        { type: 'pattern', value: '^[a-zA-Z0-9_]+$', message: 'Only letters, numbers, and underscores' }
      ]
    },
    {
      name: 'password',
      type: 'input',
      inputType: 'password',
      label: 'Password',
      required: true,
      validation: [
        { type: 'minLength', value: 8, message: 'Password must be at least 8 characters' },
        { type: 'pattern', value: '(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])', message: 'Must contain uppercase, lowercase, and number' }
      ]
    }
  ]
};

Integration with Data Sources

Connect forms to backend APIs:

import { createObjectStackAdapter } from '@object-ui/data-objectstack';

const dataSource = createObjectStackAdapter({
  baseUrl: 'https://api.example.com',
  token: 'your-auth-token'
});

const schema = {
  type: 'form',
  dataSource,
  resource: 'users',
  fields: [/* fields */],
  onSubmit: async (data) => {
    await dataSource.create('users', data);
  }
};

TypeScript Support

import type { FormSchema, FormField } from '@object-ui/plugin-form';

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

Compatibility

  • React: 18.x or 19.x
  • Node.js: ≥ 18
  • TypeScript: ≥ 5.0 (strict mode)
  • @objectstack/spec: ^3.3.0
  • @objectstack/client: ^3.3.0
  • Tailwind CSS: ≥ 3.4 (for packages with UI)

Links

License

MIT — see LICENSE.