Docs
Edit and act

Forms and validation

Connect fields, initial values, validation, and patch submission.

A form catalog describes how a record is edited. Give it a stable ID, define the fields, and connect it to the table’s create or edit form type.

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.

Configuration example

Start from the React quick start or Vue quick start. The following file extends their product catalog. Pass this configuration through getTableConfig in React or config in Vue.

product-form.ts
import { z } from "zod";
import type { FormConfig } from "@/components/ui/yayaw-table/components/forms/types";

export const productForm: FormConfig = {
  id: "products",
  title: "Edit product",
  presentation: "drawer",
  width: "38rem",
  submitMode: "patch",
  fields: [
    {
      name: "name",
      label: "Name",
      type: "text",
      required: true,
      bulkEdit: false,
      schema: z.string().trim().min(1),
    },
    {
      name: "price",
      label: "Price",
      type: "number",
      min: 0,
      schema: z.number().min(0),
    },
    {
      name: "stock",
      label: "Stock",
      type: "number",
      min: 0,
      step: 1,
      schema: z.number().int().min(0),
    },
    {
      name: "status",
      label: "Status",
      type: "select",
      options: [
        { value: "draft", label: "Draft" },
        { value: "active", label: "Active" },
        { value: "archived", label: "Archived" },
      ],
    },
  ],
  blocks: [
    {
      type: "content",
      id: "help",
      text: "Only changed fields are saved.",
      tone: "info",
    },
    { type: "field", name: "name" },
    {
      type: "section",
      id: "inventory",
      title: "Inventory",
      columns: 2,
      blocks: [
        { type: "field", name: "price" },
        { type: "field", name: "stock" },
      ],
    },
    { type: "field", name: "status" },
  ],
};
product-form.ts
import { z } from "zod";
import type { FormConfig } from "@/components/ui/yayaw-table-vue/types";

export const productForm: FormConfig = {
  id: "products",
  title: "Edit product",
  presentation: "drawer",
  width: "38rem",
  submitMode: "patch",
  fields: [
    {
      name: "name",
      label: "Name",
      type: "text",
      required: true,
      bulkEdit: false,
      schema: z.string().trim().min(1),
    },
    {
      name: "price",
      label: "Price",
      type: "number",
      min: 0,
      schema: z.number().min(0),
    },
    {
      name: "stock",
      label: "Stock",
      type: "number",
      min: 0,
      step: 1,
      schema: z.number().int().min(0),
    },
    {
      name: "status",
      label: "Status",
      type: "select",
      options: [
        { value: "draft", label: "Draft" },
        { value: "active", label: "Active" },
        { value: "archived", label: "Archived" },
      ],
    },
  ],
  blocks: [
    {
      type: "content",
      id: "help",
      text: "Only changed fields are saved.",
      tone: "info",
    },
    { type: "field", name: "name" },
    {
      type: "section",
      id: "inventory",
      title: "Inventory",
      columns: 2,
      blocks: [
        { type: "field", name: "price" },
        { type: "field", name: "stock" },
      ],
    },
    { type: "field", name: "status" },
  ],
};
get-form-config.ts
import type {
  FieldValues,
  FormConfig,
} from "@/components/ui/yayaw-table/components/forms/types";
import { productForm } from "./product-form";

/** The registry's generic lookup boundary resolves this known product catalogue. */
export function getFormConfig<T extends FieldValues>(
  formType: string
): FormConfig<T> | undefined {
  const form = formType === "products" ? productForm : undefined;
  const resolved = form;
  return resolved as FormConfig<T> | undefined;
}
get-form-config.ts
import { productForm } from "./product-form";
export const getFormConfig = (formType: string) =>
  formType === "products" ? productForm : undefined;

Connect the catalog

Set form.editFormType: "products" in the table catalog and pass getFormConfig to the table. Provide update through the actions catalog. The administration recipe supplies all three. For creation, use createFormType, initial defaults and a create action that returns the persisted record.

Initialize, validate, transform

defaultValues seeds a create form. loadInitialValues can hydrate an edit form asynchronously and receives an abort signal. Field schemas validate individual controls; a form schema validates the whole record. transform adapts submitted values to the write contract. submitMode: "patch" submits changed declared fields; full submission remains the default.

Conditional fields

FormConfig.rules shows, hides, requires or sets fields from other values, in create, edit and bulk edit forms. The rules have the same shape and comparisons as the Form view's conditional questions: conditions read field names (fieldId), and effects list the fields they act on in then.fieldIds. Record form rules are configured in code; there is no settings panel for them.

product-form-rules.ts
import type { FormConfig } from "@/components/ui/yayaw-table/components/forms/types";
// Vue: import type { FormConfig } from "@/components/ui/yayaw-table-vue/types";
import { productForm } from "./product-form";

const statusIs = (value: string) => ({
  join: "and" as const,
  items: [{ fieldId: "status", operator: "is" as const, value }],
});

export const exampleForm: FormConfig = {
  ...productForm,
  rules: [
    // Active products need a price.
    { id: "active-price", when: statusIs("active"), then: { action: "require", fieldIds: ["price"] } },
    // Archived products no longer track stock.
    { id: "archived-stock", when: statusIs("archived"), then: { action: "hide", fieldIds: ["stock"] } },
  ],
};
  • show hides its fields until one of its rules matches; hide wins over show.

  • require makes a shown field required, on top of its own required flag and schema.

  • set ({ action: "set", fieldIds, value }) writes a value before the form is validated.

  • Hidden fields are neither validated nor submitted, in full and in patch mode, and inline editing refuses a field that its rules hide. A hidden value counts as empty for other conditions.

  • An error already shown under a field is checked again when the values change, so a rule that hides a field or stops requiring it never leaves a stale error blocking Save.

  • The fields of collection items do not inherit the parent form's rules.

  • Rules that cannot act, such as a condition on an unknown field or a comparison that does not fit its type, are dropped when the form loads.

Comparisons by field type

Field typeCompared asComparisons (operator)
text, textarea and other text fieldsTextis, isNot, contains, notContains, startsWith
number, currency, percentNumbereq, neq, lt, lte, gt, gte, between
date, datetimeDate, by dayon, before, after, between, inLast, inNext (a number of days)
select, radio, select-with-add-new, tagSingle choiceis, isNot, isAnyOf, isNoneOf
multiSelect, tagsMultiple choicecontainsAny, containsAll, containsNone
boolean, checkbox, switchYes/noisChecked, isUnchecked

Every type but yes/no also accepts isEmpty and isNotEmpty. between takes [from, to], where one end may be null; the list comparisons take an array of option values.

Bulk edit and mixed values

In bulk edit, conditions read the value set in the bulk draft, or else the value that every selected row shares. When the selected rows hold different values for a field that is not in the draft, that field is "mixed" and any condition on it counts as not matching:

  • A field hidden only because of mixed values is listed, disabled, in Add a field, with a note such as "Depends on Category, whose values differ across the selection. Set Category first.".

  • A field already added whose rules read mixed values shows "Values of Category differ across the selection: the condition is treated as not met." under it.

Adding the mixed field to the draft gives it one value for every row, and the conditions then read that value. The notes have built-in English and French text.

Migrating from the hidden flag

Fields keep their hidden option, true or a function (context) => boolean. When the form loads, it is converted into a rule with a code-only condition that receives the full context and the values as given, so existing forms behave as before. These code conditions are never saved as JSON. Code that reads hidden outside a form, such as a cell renderer, keeps calling the function directly.

A hidden function that only reads other values can become a rule:

// Before: a predicate on the field.
{ name: "stock", label: "Stock", type: "number", hidden: ({ values }) => values?.status === "archived" }

// After: a declarative rule on the form.
rules: [
  {
    id: "archived-stock",
    when: { join: "and", items: [{ fieldId: "status", operator: "is", value: "archived" }] },
    then: { action: "hide", fieldIds: ["stock"] },
  },
],

Declarative rules are worth the move: they treat hidden values as empty, so chains settle; they can require or set fields; and in bulk edit they read the values the selection shares and explain mixed values, while a legacy predicate keeps running row by row. Keep a function when the decision depends on something other than the values, such as the row, the mode or the user.

Choose the next feature

Continue with layout, asynchronous options, table pickers, collections, inline editing, or bulk editing. The form reference retains detailed field contracts and examples.

Previous guide sections

Forms, Sections, Multi-select, and Collection Fields

Read the detailed reference.

Shared React and Vue form contract

Read the detailed reference.

Table picker

Read the detailed reference.

Initialization, options, and submission

Read the detailed reference.

Form blocks

Read the detailed reference.

Multi-select Fields

Read the detailed reference.

Form Sections

Read the detailed reference.

What Collection Fields Enable

Read the detailed reference.

Mental Model

Read the detailed reference.

Quick Example

Read the detailed reference.

API Reference

Read the detailed reference.

Multiple Item Types

Read the detailed reference.

Nested Collections

Read the detailed reference.

Validation Layers

Read the detailed reference.

Read the detailed reference.

Translation and Labels

Read the detailed reference.

When to Use Custom Instead

Read the detailed reference.

Common Pitfalls

Read the detailed reference.

Read the detailed reference.

Generated fields and JSON

Read the detailed reference.

Record consultation

Read the detailed reference.

Append-only history and undo

Read the detailed reference.

Test record behavior in the Yayaw example

Read the detailed reference.

A compact two-field modal

Return exampleForm from the existing getFormConfig resolver for products; keep the same update action. Each variant below is a complete file extending the product form above.

modal-product-form.ts
import type { FormConfig } from "@/components/ui/yayaw-table/components/forms/types";
import { productForm } from "./product-form";

export const exampleForm: FormConfig = {
  ...productForm,
  presentation: "modal",
  width: "32rem",
  blocks: undefined,
  fields: productForm.fields.filter((field) =>
    ["name", "price"].includes(field.name)
  ),
};
modal-product-form.ts
import type { FormConfig } from "@/components/ui/yayaw-table-vue/types";
import { productForm } from "./product-form";

export const exampleForm: FormConfig = {
  ...productForm,
  presentation: "modal",
  width: "32rem",
  blocks: undefined,
  fields: productForm.fields.filter((field) =>
    ["name", "price"].includes(field.name)
  ),
};

Require an explicit stock value

Keep the integer and non-negative schema, and additionally require a value. Zero is valid; a blank or fractional stock is not. A failed action retains the user’s draft.

required-stock-form.ts
import type { FormConfig } from "@/components/ui/yayaw-table/components/forms/types";
import { productForm } from "./product-form";

export const exampleForm: FormConfig = {
  ...productForm,
  fields: productForm.fields.map((field) =>
    field.name === "stock" ? { ...field, required: true } : field
  ),
};
required-stock-form.ts
import type { FormConfig } from "@/components/ui/yayaw-table-vue/types";
import { productForm } from "./product-form";

export const exampleForm: FormConfig = {
  ...productForm,
  fields: productForm.fields.map((field) =>
    field.name === "stock" ? { ...field, required: true } : field
  ),
};