Docs
Reference

Form reference

Build create/edit forms with grouped fields, native option arrays, validation, and nested collection support

Forms, Sections, Multi-select, and Collection Fields

Yayaw Table can render create, edit, and bulk-edit forms from configuration. You provide a FormConfig through getFormConfig, and the table opens the matching form in the built-in drawer or modal when users create or edit rows.

Most fields map to one primitive value: text, number, select, switch, textarea, url, value-type, or custom. multiSelect edits an array of string or number option values, and collection edits an array of objects as a structured UI. Both are designed for JSON-like data that would otherwise force every consuming app to build its own mini editor.

Use the root TableConfig.presentation option for a shared view/create/edit/bulk surface, with an optional mobile choice. The default is a right drawer on every viewport. See shared record presentation for configuration and migration.

Shared React and Vue form contract

Both editions generate standard fields from column definitions when no matching form is registered. A catalogue FormConfig customizes that fallback. React form instances, factories, custom renderers, and custom collection editors remain supported; Vue uses Vue components and VNodes for custom rendering.

The shared configuration supports hidden and disabled booleans or context predicates, field defaultValue, field-level schema, a root schema, transform, and submitMode: "patch". Context includes tableId, tableType, formType, mode, row, initialData, and current values. Use setFieldValue for sibling updates.

const productForm = {
  id: "product",
  submitMode: "patch",
  fields: [
    { name: "name", label: "Name", type: "text", required: true },
    { name: "active", label: "Active", type: "switch", defaultValue: true },
    {
      name: "reason", label: "Reason", type: "text",
      hidden: ({ values }) => values?.active !== false,
    },
    {
      name: "lines", label: "Lines", type: "collection",
      collectionMode: "inline",
      itemFields: [
        { name: "label", label: "Label", type: "text", required: true },
        { name: "quantity", label: "Quantity", type: "number", min: 0 },
      ],
    },
  ],
};

itemFields generates nested editors recursively, including nested collections. Use collectionMode to choose inline or dialog editing; existing renderItemForm implementations remain available. Validation errors retain their item paths and block submission.

Table picker

Use the declarative tablePicker field when a relation needs the native table experience instead of a finite select. React and Vue render a nested read-only table with local or server data, search, filters, sorting, pagination, saved views, and controlled row selection. Mutating actions and exports are suppressed in picker mode; list, aggregate, and views remain available.

createTablePickerField({
  name: "mediaIds",
  label: "Media",
  tablePicker: {
    tableType: "media-picker",
    config: mediaPickerConfig,
    actions: { list: listMedia },
    getRowId: (row) => String(row.id),
    parseValue: Number,
  },
})

Selection survives search, filter, sort, grouping, and pagination changes. parseValue preserves typed relation IDs; selected values are strings when it is omitted. Set multiple: false for a scalar field, selectOnRowClick: false to require the checkbox, or maxHeight to limit the scrolling body. A nested picker uses syncUrl: false by default, so its state cannot overwrite the page table's query parameters. Use optionDependencies when changes to sibling form values should rebuild a context-derived picker configuration.

Initialization, options, and submission

  • loadInitialValues(row, context, signal) can load an edit record asynchronously. Loading failures can be retried. Closing, changing the form identity, or retrying cancels obsolete requests; late results cannot replace a newer draft.

  • options(context) supports asynchronous options. optionDependencies reloads options only when declared dependencies change; optionsScope separates tenant or permission contexts.

  • searchOptions(query, context, signal) supports searchMinLength and searchDebounceMs. resolveOptions(values, context, signal) hydrates existing labels. createOption(label, context, signal) returns the persisted option. Respect the abort signal and enforce permissions on the server.

  • Field schemas run before the optional root schema. Submission preparation omits hidden/disabled fields and, in edit patch mode, unchanged declared values. transform(values, context) receives the prepared payload; it must accept partial objects in patch mode.

  • Explicit false, 0, "", [], and null remain meaningful values. A schema can reject them, but the table does not silently discard them. Action fieldErrors keep the draft open and attach errors to fields.

Use form.resolveEditFormType(row) when different records need different catalogues. See Vue forms for native Vue examples and Bulk actions for checked-field patches and partial retries.

Form blocks

Set TableConfig.form.blocks to organize automatically generated fields without declaring them again. A registered FormConfig.blocks takes precedence over table blocks; blocks take precedence over sections. Existing forms without blocks keep their section layout. Bulk editing retains its per-field checkboxes and does not render create/edit blocks.

form: {
  blocks: [
    { type: "content", id: "help", title: "Product", text: "Review before saving.", tone: "info" },
    {
      type: "section", id: "identity", title: "Identity", columns: 2,
      blocks: [
        { type: "field", name: "name" },
        { type: "field", name: "price" },
        { type: "custom", id: "preview", span: "full" },
      ],
    },
    {
      type: "actions", id: "tools",
      actions: [{
        id: "normalize", label: "Normalize name", variant: "outline",
        onClick: (context) => context.setFieldValue("name", String(context.values?.name ?? "").trim()),
      }],
    },
  ],
}
BlockConfiguration
fieldname references an existing declared or generated field.
sectionStable id, optional title/description, columns: 1 | 2 | 3, nested blocks.
contentStable id, text, optional title, tone: "default" | "info" | "warning". Text is escaped.
actionsStable id and an actions array.
customStable id, native render(context) callback, or Vue form-{id} slot.

Every block accepts span: 1 | 2 | 3 | "full"; spans are bounded by the parent columns. Narrow form containers collapse to a single column. Each field renders once: duplicate and unknown field references are ignored, and fields omitted from the layout are appended. The field catalogue still owns visibility, permissions, validation and submission; content and buttons add no payload keys.

Actions have id, label, onClick(context, signal), optional labelKey, hidden, disabled, validate, and variant (default, outline, secondary, destructive; outline by default). Hidden/disabled predicates receive the context. validate: true validates before running without implicitly submitting. A pending action disables its button and ignores repeated clicks. Errors appear inline and the next click retries. Closing or changing the record, disabling or hiding the action aborts its signal and prevents late field writes.

The action/custom context extends the field context with current values, setFieldValue(name, value), validate(): Promise<boolean>, submit(): Promise<void>, disabled, isSubmitting, and isValidating. Async code should respect the supplied AbortSignal. Custom buttons should use type="button" and the supplied disabled state. React render callbacks return a React node; Vue callbacks return VNodes and can be replaced by the named slot shown in Vue forms.

Use titleKey, descriptionKey, textKey, and action labelKey with FormConfig.translations.keys or provider translations. Layout definitions containing callbacks live in application code. Persist field values and view snapshots separately.

Multi-select Fields

Use type: "multiSelect" when the form value is an array of primitive option values selected from a finite list. Yayaw Table renders each option as a checkbox, writes a new array through TanStack Form, and preserves the configured option order.

{
  type: "multiSelect",
  name: "capabilities",
  label: "Capabilities",
  options: [
    { label: "Native tables", value: "native_tables" },
    { label: "Runtime API", value: "runtime_api" },
  ],
}

Use multiSelect for tag-like capability sets, supported regions, enabled modules, or other finite arrays where every selected value is a string or number. Use collection instead when each array item has multiple fields or needs its own editor.

Form Sections

Use sections when a generated admin form needs clear groups without custom React. Sections reference existing field names, so the form data shape and validation schema stay unchanged.

defineFormConfig({
  id: "article",
  schema: ArticleSchema,
  defaultValues: {},
  fields: [
    { type: "text", name: "title", label: "Title" },
    { type: "textarea", name: "summary", label: "Summary" },
    { type: "switch", name: "published", label: "Published" },
  ],
  sections: [
    {
      id: "content",
      title: "Content",
      description: "Editorial fields shown to authors.",
      fields: ["title", "summary"],
    },
    {
      id: "publishing",
      title: "Publishing",
      fields: ["published"],
    },
  ],
});

Fields not referenced by any section still render after the declared sections, preserving the original field order. Unknown field names are ignored by Yayaw Table, while Yayaw dynamic-data model validation reports unknown section references before deployment.

What Collection Fields Enable

Use type: "collection" when a form value is an array and users need to manage its items visually.

Good fits:

  • Navigation menus stored as JSON, with links, groups, toggles, and nested links.

  • Feature lists where each feature has a label, icon, enabled flag, and description.

  • Repeated pricing rules, FAQ entries, contact methods, gallery items, or localized content blocks.

  • Any “array of records” field where a plain JSON textarea would be too risky for users.

The built-in collection UI gives you:

  • A table-like overview of items using configurable columns.

  • An empty state that explains there are no items yet.

  • One add button or multiple add actions, such as “Add link”, “Add group”, and “Add theme toggle”.

  • Edit, delete, move up, and move down row actions.

  • An internal item dialog with Cancel and Save actions.

  • Row-level errors from validateItem.

  • Global collection errors from validateItems.

  • Submit blocking when collection validation fails.

  • Compatibility with Zod schema validation on the same form value.

  • Nested collection editing through the exported CollectionEditor component.

The collection field is generic. Yayaw Table does not need to know what a menu link, feature, or FAQ item means. You define item shapes with createItem / createActions, render item-specific controls with renderItemForm, and enforce business rules with validators.

Mental Model

The form value is always the source of truth.

  1. CollectionField reads the current form value from TanStack Form.

  2. If the value is not an array, it is normalized to [].

  3. Every add, edit, delete, or reorder action creates a new array.

  4. Item edits create new item objects instead of mutating the existing item.

  5. The new array is written through fieldApi.handleChange.

  6. On submit, Yayaw Table validates the same form value with collection validators and the configured Zod schema.

This means there is no hidden internal JSON state to synchronize with your app. If the form value is { items_json: [...] }, the collection editor is just a safer UI for that array.

Quick Example

This creates a small features editor with add, edit, delete, reorder, row validation, and submit blocking.

import { defineFormConfig } from "@/components/ui/yayaw-table/components/forms";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { z } from "zod";

const FeatureSchema = z.object({
  features: z.array(
    z.object({
      label: z.string().min(1),
      enabled: z.boolean(),
    })
  ),
});

export const featureForm = defineFormConfig({
  id: "features",
  schema: FeatureSchema,
  defaultValues: {
    features: [],
  },
  fields: [
    {
      type: "collection",
      name: "features",
      label: "Features",
      description: "Manage the product feature list.",
      addLabel: "Add feature",
      itemLabel: "feature",
      emptyLabel: "No features yet.",
      columns: [
        { id: "label", header: "Label" },
        {
          id: "enabled",
          header: "Enabled",
          render: (item) => (item.enabled ? "Yes" : "No"),
        },
      ],
      createItem: () => ({ label: "", enabled: true }),
      renderItemForm: ({ item, onChange, disabled }) => (
        <div className="space-y-3">
          <Input
            disabled={disabled}
            onChange={(event) =>
              onChange({ ...item, label: event.target.value })
            }
            value={String(item.label ?? "")}
          />
          <Switch
            checked={item.enabled === true}
            disabled={disabled}
            onCheckedChange={(enabled) => onChange({ ...item, enabled })}
          />
        </div>
      ),
      validateItem: (item) =>
        typeof item.label === "string" && item.label.length > 0
          ? []
          : ["Label is required"],
      validateItems: (items) =>
        items.length > 0 ? [] : ["Add at least one feature"],
    },
  ],
});

API Reference

CollectionFieldDefinition<TFormValues> extends the normal field definition with array-editor options.

PropertyPurpose
type: "collection"Selects the native collection field.
nameForm path for the array value.
label / descriptionField copy shown above the editor.
disabledDisables collection actions and item form controls.
addLabelLabel for the default add button.
itemLabelHuman-readable item name used in counts, dialog titles, and validation messages.
emptyLabelOptional empty-state text.
columnsOverview columns. A column can read item[column.id] or provide a custom render.
createItem(items)Creates the default new item from the current items.
createActionsOptional list of named add actions for multiple item types.
getItemKey(item, index)Optional stable React key. Use IDs when items have them.
renderItemFormRenders the dialog body for creating or editing one item.
validateItemReturns row-level errors for one item.
validateItemsReturns global errors for the whole array.
labelsOverrides built-in labels such as Actions, Cancel, Save, Edit, Delete, Move up, Move down.
labelKeysTranslation keys for the same built-in labels.

renderItemForm receives a plain controlled item:

renderItemForm: (props: {
  item: Record<string, unknown>;
  index: number | null;
  disabled?: boolean;
  onChange: (item: Record<string, unknown>) => void;
}) => ReactNode;

Call onChange({ ...item, field: nextValue }) whenever a control changes. Avoid mutating item directly.

Multiple Item Types

Use createActions when users need to create different item shapes from the same array.

const createLink = () => ({
  type: "link",
  label: "",
  href: "",
  placement: "primary",
  variant: "default",
  description: "",
  external: false,
});

const createGroup = () => ({
  type: "group",
  label: "",
  description: "",
  placement: "primary",
  items: [],
});

{
  type: "collection",
  name: "items_json",
  label: "Menu items",
  addLabel: "Add item",
  itemLabel: "menu item",
  columns: [
    { id: "label", header: "Label" },
    { id: "type", header: "Type" },
    { id: "placement", header: "Placement" },
  ],
  createItem: createLink,
  createActions: [
    { label: "Add link", createItem: createLink },
    { label: "Add group", createItem: createGroup },
    {
      label: "Add theme toggle",
      createItem: () => ({ type: "themeToggle", placement: "utility" }),
    },
    {
      label: "Add language toggle",
      createItem: () => ({ type: "languageToggle", placement: "utility" }),
    },
  ],
  renderItemForm: ({ item, onChange }) => {
    if (item.type === "group") {
      return <GroupItemForm item={item} onChange={onChange} />;
    }

    if (item.type === "link") {
      return <LinkItemForm item={item} onChange={onChange} />;
    }

    return <ToggleItemForm item={item} onChange={onChange} />;
  },
}

This is enough to model a menu where top-level items can be links, groups, theme toggles, or language toggles. The collection field only orchestrates the editor; your item forms decide which controls each item type needs.

Nested Collections

Nested collections are supported by using the lower-level CollectionEditor inside an item form. This is useful for one-level menu groups: the top-level collection edits menu items, and a group item contains a nested collection for group.items.

import { CollectionEditor } from "@/components/ui/yayaw-table/components/forms";
import { Input } from "@/components/ui/input";

function GroupItemForm({
  item,
  onChange,
}: {
  item: Record<string, unknown>;
  onChange: (item: Record<string, unknown>) => void;
}) {
  return (
    <div className="space-y-4">
      <Input
        onChange={(event) => onChange({ ...item, label: event.target.value })}
        value={String(item.label ?? "")}
      />
      <CollectionEditor
        addLabel="Add nested link"
        columns={[
          { id: "label", header: "Label" },
          { id: "href", header: "Href" },
        ]}
        createItem={() => ({
          type: "link",
          label: "",
          href: "",
          placement: "primary",
          variant: "default",
          external: false,
        })}
        emptyLabel="No nested links yet."
        itemLabel="nested link"
        label="Nested links"
        onChange={(items) => onChange({ ...item, items })}
        renderItemForm={({ item: nestedItem, onChange: onNestedChange }) => (
          <div className="space-y-3">
            <Input
              onChange={(event) =>
                onNestedChange({
                  ...nestedItem,
                  label: event.target.value,
                })
              }
              value={String(nestedItem.label ?? "")}
            />
            <Input
              onChange={(event) =>
                onNestedChange({
                  ...nestedItem,
                  href: event.target.value,
                })
              }
              value={String(nestedItem.href ?? "")}
            />
          </div>
        )}
        validateItem={(nestedItem) =>
          nestedItem.type === "link" ? [] : ["Only links are allowed"]
        }
        value={item.items}
      />
    </div>
  );
}

The important detail is the nested onChange: it writes the next nested array back into the parent item with onChange({ ...item, items }).

Validation Layers

Collections have three validation layers that can work together:

  1. validateItem(item, index) returns errors for one row. These errors are shown under the row and in the item dialog.

  2. validateItems(items) returns errors for the whole array, such as “Add at least one item”.

  3. The form schema still validates the final value with Zod.

validateItem and validateItems are attached to TanStack Form as field validators. If either returns errors, form.handleSubmit() does not call the submit action.

Use collection validators for UI-specific and business-specific messages, and keep Zod as the final structural contract. For example:

validateItem: (item) => {
  if (item.type === "link" && !item.href) {
    return ["Href is required"];
  }

  if (item.type === "group") {
    const nestedItems = Array.isArray(item.items) ? item.items : [];
    return nestedItems.every(
      (nestedItem) =>
        typeof nestedItem === "object" &&
        nestedItem !== null &&
        "href" in nestedItem
    )
      ? []
      : ["Group items must be links"];
  }

  return [];
},
validateItems: (items) =>
  items.length > 0 ? [] : ["Add at least one menu item"],

Collection fields work in the same CatalogueForm surfaces as other fields. They can be used in the default right-side drawer or in a wider modal layout:

defineTableConfig({
  form: {
    layout: {
      mode: "modal",
      width: "80vw",
    },
  },
});

Use a modal layout when item forms contain multiple controls, nested collections, or wide overview columns.

Translation and Labels

Collection fields use sensible English defaults for built-in labels:

  • Actions

  • Cancel

  • Save

  • Edit

  • Delete

  • Move up

  • Move down

Override them per field with labels:

{
  type: "collection",
  labels: {
    actions: "Row actions",
    save: "Save item",
    cancel: "Cancel item edit",
  },
  // ...
}

Or use labelKeys when your app resolves labels from translations:

{
  type: "collection",
  labelKeys: {
    actions: "menu.fields.items.actions",
    save: "menu.fields.items.save",
    cancel: "menu.fields.items.cancel",
  },
  // ...
}

When to Use Custom Instead

Keep using type: "custom" when a field is not an array editor, when it needs a completely different layout, or when it integrates a domain-specific component that already owns its UX.

Use type: "collection" when the data is still an array of item records and the app only needs to define:

  • How new items are created.

  • Which columns summarize items.

  • Which item form controls are shown.

  • Which rules make an item or the whole array invalid.

That split keeps the generic add/edit/delete/reorder behavior in Yayaw Table while leaving business-specific fields in the consuming app.

Common Pitfalls

  • Mutating item or items in place. Always create a new object or array before calling onChange.

  • Forgetting a stable getItemKey when items have durable IDs. Index fallback works, but IDs keep row identity clearer.

  • Encoding business rules only in Zod. Use validateItem / validateItems too when the user needs row-level feedback before submit.

  • Putting deeply nested business trees into one editor. Nested collections are best for one clear sub-list, such as group links.

  • Rebuilding a collection with type: "custom" when the built-in collection behavior already covers the array workflow.

Generated fields and JSON

Declare standard data types on the columns once; the type matrix lists their generated editors. Register a form only when you need additional schema, permission, layout, relation or custom-field behavior. type: "json" is also available directly in a form catalogue. It preserves an invalid draft, shows a validation error, and submits the parsed JSON value after correction. Multi-select options preserve string, number and boolean values, including unknown existing selections.

Record consultation

With the default rowClickMode, a row click opens the record view and its row-actions menu carries a read-only Info entry; fields are derived from the columns unless you pass details. Explicit edit, link, and none row-click modes retain their behavior. Pass details={false} (:details="false" in Vue) to remove the built-in consultation surface entirely. The same RecordDetails component can live in a drawer, modal, or any page with presentation: "drawer" | "modal" | "inline" or the responsive object. In a table, the root TableConfig.presentation option controls consultation and forms together.

import type { RecordDetailsConfig } from "@/components/ui/yayaw-table/utils/record-details";

const details: RecordDetailsConfig = {
  presentation: "drawer",
  title: row => String(row.name),
  updatedAt: row => row.updatedAt as string,
  updatedBy: row => row.updatedBy as string,
  activity: row => row.audit as DetailActivity[],
  sections: [{
    id: "overview",
    title: "Overview",
    fields: [
      { id: "name", label: "Name", type: "text" },
      { id: "budget", label: "Budget", type: "number",
        numberFormat: { style: "currency", currency: "EUR" } },
      { id: "active", label: "Active", type: "boolean" },
    ],
  }],
};

<DataTable {...tableProps} details={details} onRevertActivity={revertActivity} />

Import DetailActivity from the same module. Omit sections to derive fields from column accessors, types, and option labels. Explicit sections can include fields absent from the table. Use hidden to omit a field from both information and before/after diffs; table column visibility is independent of this projection. The host must only supply authorized fields and audit data.

All table and built-in form data types are supported: text, multiline text, number, boolean, date/date-time, choices, relations, images, links, email, telephone, files, collections, code/JSON, dynamic values, and custom fields. Passwords are masked; 0, false, and missing values stay distinct. Relations accept objects with label/name or IDs resolved through supplied options; the host loads remote options. Files use { name, url }; collections retain every item's data. Standalone React accepts renderField(field, value, row); return undefined for the default renderer. Vue provides #detail-<field-id> slots in both the table and standalone component. English/French labels are built in; customize them with locale and details.labels.

Edit opens the existing catalogue form. Delete requires the action handler and table/row permissions, opens a confirmation modal naming the record, and initially focuses Cancel. Pending actions block duplicate submissions. Failure keeps the confirmation open for retry; success closes the record and refreshes the table. Standalone React uses onClose, onEdit, onDelete, onDeleted, and onReverted; standalone Vue uses canEdit, canDelete, onDelete, and close, edit, deleted, reverted events. Mount standalone records with a stable record key.

Append-only history and undo

The application owns the audit log. Supply activity(row) events with id, actor: { name }, at, action, and optional changes: [{ field, before, after }]. updatedAt and updatedBy are independent record metadata. Neither renderer fetches or persists history.

Providing onRevertActivity(row, event) enables undo; it returns { success: boolean, error?: string }. details.canRevert(event, row) can restrict it further; reversible: false disables it for an event. Creation without field changes and undo events are not offered undo.

An undo restores values and appends a new event with reverts: originalEvent.id, the authenticated actor, and inverse before/after changes. Preserve the original line: the UI marks it Undone. It blocks repeat undo and supplied newer events affecting the same fields. The server must still check permissions, the current record version, and conflicts, then restore values and append the audit event in one transaction. Never trust a client-supplied actor or remove the original event. A failed undo keeps the original intact and displays its error. Refresh the canonical row and log before resolving the callback, or through onReverted / @reverted; successful callbacks alone cannot create the new audit line in the UI.

The shared 30-field campaign example covers every current data type and three presentations. Its Edit, Delete, Reset, and Undo actions use fictional in-memory data. In the Table repository, see examples/record-details-react.tsx and run bun run vue:dev, then open /?example=record-details for the Vue example.

Test record behavior in the Yayaw example

The live example opens product details through the View action or the Activate row interaction. Choose Drawer, Modal, or Inline under Record presentation. The existing edit/delete permissions also apply inside the record view; deleting always requires confirmation.

The playground includes URL-backed controls for record behavior:

ControlQuery parameterDefault
Record detailsexcfg-details1
Presentationexcfg-detail-presentationdrawer (modal / inline)
Empty activityexcfg-empty-activity0
Undo changesexcfg-undo1
First product read-onlyexcfg-lock-record0
Simulate an errorexcfg-mutation-error0
Simulate a slow responseexcfg-mutation-delay0

A checked toggle writes 1; an unchecked toggle writes 0. Copy the current URL to share a configuration. Existing CMS-authored edit/delete toggles keep their configured query keys; pages without them inherit excfg-edit and excfg-delete. Reset settings restores the configured defaults and clears these overrides. Reset example data restores the products and their fictional history.

Each product starts with an attributed price change that can be undone immediately. Edits, inline edits, and bulk edits append before/after values and update the author and timestamp. Undo appends a new event referencing the original, preserves that original, and rejects repeated or stale changes. The empty-activity flag changes only the preview, preserving stored demo events. Slow-response mode delays mutations by 1.5 seconds. Error mode leaves the data untouched: close the overlay if needed, turn the flag off, and retry.

These controls are intentionally UI-only playground state, not managed feature flags or production authorization. Products, audit entries, and mutations stay in memory and reset on reload. Existing published CMS page revisions inherit the new controls without overwriting their authored content.