Docs
Recipes

Order editor

Choose a customer and edit nested order lines.

Keep the product data and configuration from the quick start. Copy the order-specific files below. This recipe uses a customer table picker and a collection of lines; each line may contain warehouse allocations.

Order data and validation

orders.ts
import { z } from "zod";

export const customers = [
  { id: "customer-1", name: "Atelier North" },
  { id: "customer-2", name: "Studio South" },
];
const lineSchema = z.object({
  id: z.string(),
  productId: z.string().min(1),
  quantity: z.number().int().positive(),
  allocations: z.array(
    z.object({
      id: z.string(),
      warehouse: z.string().min(1),
      quantity: z.number().int().positive(),
    })
  ),
});
export const orderSchema = z.object({
  reference: z.string().trim().min(1),
  customerId: z.string().min(1),
  lines: z.array(lineSchema).min(1),
});
export interface Order extends Record<string, unknown> {
  customerId: string;
  id: string;
  lines: z.infer<typeof lineSchema>[];
  reference: string;
}
export const orders: Order[] = [
  {
    id: "order-1",
    reference: "SO-1001",
    customerId: "customer-1",
    lines: [{ id: "line-1", productId: "desk", quantity: 2, allocations: [] }],
  },
];

export function updateOrder(id: string, patch: Record<string, unknown>) {
  const row = orders.find((order) => order.id === id);
  if (!row) {
    return { success: false, error: "Order no longer exists." };
  }
  const parsed = orderSchema.safeParse({ ...row, ...patch });
  if (!parsed.success) {
    return {
      success: false,
      error: parsed.error.issues[0]?.message ?? "Invalid order.",
    };
  }
  if (!customers.some((customer) => customer.id === parsed.data.customerId)) {
    return { success: false, error: "Unknown customer." };
  }
  Object.assign(row, parsed.data);
  return { success: true, data: structuredClone(row) };
}

order-config.ts

order-config.ts
import { defineTableConfig } from "@/components/ui/yayaw-table/config/helpers";
import { productConfig } from "./product-config";

export const orderConfig = defineTableConfig({
  id: "orders",
  translations: { namespace: "orders", keys: {} },
  columns: {
    definitions: [
      { id: "reference", header: "Reference", type: "text" },
      { id: "customerId", header: "Customer", type: "text" },
    ],
    mandatory: ["reference"],
    visible: ["reference", "customerId"],
    order: ["reference", "customerId"],
  },
  table: { ...productConfig.table, allowEdit: true, rowClickMode: "edit" },
  form: { editFormType: "orders" },
});
export const customerConfig = defineTableConfig({
  id: "customers",
  translations: { namespace: "customers", keys: {} },
  columns: {
    definitions: [{ id: "name", header: "Customer", type: "text" }],
    mandatory: ["name"],
    visible: ["name"],
    order: ["name"],
  },
  table: {
    ...productConfig.table,
    enableRowSelection: true,
    enableMultiRowSelection: false,
    syncUrl: false,
  },
});
order-config.ts
import { defineTableConfig } from "@/components/ui/yayaw-table-vue/config";
import { productConfig } from "./product-config";

export const orderConfig = defineTableConfig({
  id: "orders",
  translations: { namespace: "orders", keys: {} },
  columns: {
    definitions: [
      { id: "reference", header: "Reference", type: "text" },
      { id: "customerId", header: "Customer", type: "text" },
    ],
    mandatory: ["reference"],
    visible: ["reference", "customerId"],
    order: ["reference", "customerId"],
  },
  table: { ...productConfig.table, allowEdit: true },
  form: { editFormType: "orders" },
});
export const customerConfig = defineTableConfig({
  id: "customers",
  translations: { namespace: "customers", keys: {} },
  columns: {
    definitions: [{ id: "name", header: "Customer", type: "text" }],
    mandatory: ["name"],
    visible: ["name"],
    order: ["name"],
  },
  table: {
    ...productConfig.table,
    enableRowSelection: true,
    enableMultiRowSelection: false,
    syncUrl: false,
  },
});

order-form.ts

order-form.ts
import type { FormConfig } from "@/components/ui/yayaw-table/components/forms/types";
import { customers, orderSchema } from "../shared/orders";
import { customerConfig } from "./order-config";
import { renderProductSelect } from "./product-select";

export const orderForm: FormConfig = {
  id: "orders",
  title: "Edit order",
  presentation: "modal",
  width: "64rem",
  schema: orderSchema,
  fields: [
    { name: "reference", label: "Reference", type: "text", bulkEdit: false },
    {
      name: "customerId",
      label: "Customer",
      type: "tablePicker",
      tablePicker: {
        tableType: "customers",
        config: customerConfig,
        data: customers,
        getRowId: (row) => String(row.id),
        multiple: false,
        syncUrl: false,
        maxHeight: "18rem",
      },
    },
    {
      name: "lines",
      label: "Order lines",
      type: "collection",
      collectionMode: "dialog",
      addLabel: "Add line",
      getItemKey: (line) => String(line.id),
      createItem: () => ({
        id: crypto.randomUUID(),
        productId: "",
        quantity: 1,
        allocations: [],
      }),
      columns: [
        { id: "productId", header: "Product" },
        { id: "quantity", header: "Quantity" },
      ],
      validateItem: (line) =>
        Number(line.quantity) > 0 && line.productId
          ? []
          : ["Choose a product and a positive quantity."],
      validateItems: (lines) =>
        lines.length ? [] : ["Add at least one line."],
      itemFields: [
        {
          name: "productId",
          label: "Product",
          type: "custom",
          renderField: renderProductSelect,
        },
        {
          name: "quantity",
          label: "Quantity",
          type: "number",
          min: 1,
          step: 1,
        },
        {
          name: "allocations",
          label: "Warehouse allocations",
          type: "collection",
          collectionMode: "inline",
          getItemKey: (allocation) => String(allocation.id),
          createItem: () => ({
            id: crypto.randomUUID(),
            warehouse: "",
            quantity: 1,
          }),
          itemFields: [
            { name: "warehouse", label: "Warehouse", type: "text" },
            { name: "quantity", label: "Quantity", type: "number", min: 1 },
          ],
        },
      ],
    },
  ],
};
order-form.ts
import type { FormConfig } from "@/components/ui/yayaw-table-vue/types";
import { customers, orderSchema } from "../shared/orders";
import { products } from "../shared/products";
import { customerConfig } from "./order-config";

export const orderForm: FormConfig = {
  id: "orders",
  title: "Edit order",
  presentation: "modal",
  width: "64rem",
  schema: orderSchema,
  fields: [
    { name: "reference", label: "Reference", type: "text", bulkEdit: false },
    {
      name: "customerId",
      label: "Customer",
      type: "tablePicker",
      tablePicker: {
        tableType: "customers",
        config: customerConfig,
        data: customers,
        getRowId: (row) => String(row.id),
        multiple: false,
        syncUrl: false,
        maxHeight: "18rem",
      },
    },
    {
      name: "lines",
      label: "Order lines",
      type: "collection",
      collectionMode: "dialog",
      addLabel: "Add line",
      getItemKey: (line) => String(line.id),
      createItem: () => ({
        id: crypto.randomUUID(),
        productId: "",
        quantity: 1,
        allocations: [],
      }),
      columns: [
        { id: "productId", header: "Product" },
        { id: "quantity", header: "Quantity" },
      ],
      validateItem: (line) =>
        Number(line.quantity) > 0 && line.productId
          ? []
          : ["Choose a product and a positive quantity."],
      validateItems: (lines) =>
        lines.length ? [] : ["Add at least one line."],
      itemFields: [
        {
          name: "productId",
          label: "Product",
          type: "select",
          options: products.map((product) => ({
            value: product.id,
            label: product.name,
          })),
        },
        {
          name: "quantity",
          label: "Quantity",
          type: "number",
          min: 1,
          step: 1,
        },
        {
          name: "allocations",
          label: "Warehouse allocations",
          type: "collection",
          collectionMode: "inline",
          getItemKey: (allocation) => String(allocation.id),
          createItem: () => ({
            id: crypto.randomUUID(),
            warehouse: "",
            quantity: 1,
          }),
          itemFields: [
            { name: "warehouse", label: "Warehouse", type: "text" },
            { name: "quantity", label: "Quantity", type: "number", min: 1 },
          ],
        },
      ],
    },
  ],
};

get-order-form.ts

get-order-form.ts
import type {
  FieldValues,
  FormConfig,
  FormConfigContext,
} from "@/components/ui/yayaw-table/components/forms/types";
import { isolatedTablePicker } from "./isolated-table-picker";
import { orderForm } from "./order-form";

/** The lookup resolves the order catalogue for the registry's generic form boundary. */
export function getFormConfig<T extends FieldValues>(
  formType: string,
  context?: FormConfigContext<T>
): FormConfig<T> | undefined {
  if (formType !== "orders") {
    return;
  }
  if (!context) {
    throw new Error("The order form requires the table's form context.");
  }
  return {
    ...orderForm,
    fields: orderForm.fields.map((field) =>
      field.type === "tablePicker" ? isolatedTablePicker(field, context) : field
    ),
  } as FormConfig<T>;
}

The verified React registry shares form atoms between table instances. Scope the native table picker with Jotai so its nested table cannot reopen the parent form. The field API still writes the customer ID into the parent order.

isolated-table-picker.tsx
import { Provider } from "jotai";
import { TablePickerField } from "@/components/ui/yayaw-table/components/forms/fields/table-picker-field";
import type {
  BaseFieldProps,
  CustomFieldDefinition,
  FormConfigContext,
  TablePickerFieldDefinition,
} from "@/components/ui/yayaw-table/components/forms/types";

/** Scope the nested table's form atoms while keeping the parent's field API. */
export function isolatedTablePicker(
  definition: TablePickerFieldDefinition,
  context: FormConfigContext
): CustomFieldDefinition {
  return {
    ...definition,
    type: "custom",
    renderField: ({ field }: BaseFieldProps) => (
      <Provider>
        <TablePickerField
          context={context}
          field={definition}
          fieldApi={field}
        />
      </Provider>
    ),
  };
}

The product field demonstrates a custom native control inside the collection dialog. It keeps focus and selection within the nested modal; a portalled dropdown needs a portal container belonging to that modal, rather than the parent order form.

product-select.tsx
import type { BaseFieldProps } from "@/components/ui/yayaw-table/components/forms/types";
import { products } from "../shared/products";

/** A native control keeps focus inside the nested collection dialog. */
export function renderProductSelect({ field }: BaseFieldProps) {
  return (
    <label className="grid gap-2 text-sm">
      Product
      <select
        className="h-9 rounded-md border bg-background px-3"
        onBlur={field.handleBlur}
        onChange={(event) => field.handleChange(event.target.value)}
        value={String(field.state.value ?? "")}
      >
        <option value="">Choose a product</option>
        {products.map((product) => (
          <option key={product.id} value={product.id}>
            {product.name}
          </option>
        ))}
      </select>
    </label>
  );
}
get-order-form.ts
import { orderForm } from "./order-form";
export const getFormConfig = (formType: string) =>
  formType === "orders" ? orderForm : undefined;

order-actions.ts

order-actions.ts
import type { TableActions } from "@/components/ui/yayaw-table/providers/table-provider";
import { orders, updateOrder } from "../shared/orders";
import { listRecords } from "./list-records";

const orderActions: TableActions = {
  list: (params) => listRecords(orders, params),
  update: (id, patch) => Promise.resolve(updateOrder(id, patch)),
};
export const getOrderActions = (tableType: string) =>
  tableType === "orders" ? orderActions : undefined;
order-actions.ts
import type { TableActions } from "@/components/ui/yayaw-table-vue/types";
import { orders, updateOrder } from "../shared/orders";
import { listRecords } from "./list-records";

const orderActions: TableActions = {
  list: (params) => listRecords(orders, params),
  update: (id, patch) => Promise.resolve(updateOrder(id, patch)),
};
export const getOrderActions = (tableType: string) =>
  tableType === "orders" ? orderActions : undefined;

Entry component

Expand ↗

Sample data. Changes stay in this preview.

Expand ↗

Sample data. Changes stay in this preview.

Verify the result

  1. Open order SO-1001 for editing.

  2. Choose Studio South in the customer picker.

  3. Add a line, choose a product and set quantity to 2.

  4. Add a nested warehouse allocation, then save the line and order.

  5. Reopen: the customer and nested values remain.

  6. Test quantity 0 and an empty line collection: validation rejects them.

  7. Cancel an item dialog and verify it did not modify the parent form.

Use the existing playground to explore the library’s controls.