Editable Table
Edit a Formily ArrayField as a data table — quick start, column recipes, troubleshooting, row or inline editing, validation feedback, and real-world order-entry patterns.
Plus Registry
Editable Table ships on the authenticated @f-ui-plus registry. Configure FUI_PLUS_REGISTRY_TOKEN as described in Installation — Plus Registry.
Renamed
Editable Table is the public name for what was Form Array Table. Imports: EditableTable / useEditableTable / defineEditableColumns. The frozen form-array-table package and its registry alias have been removed — migrate remaining installs to @f-ui-plus/editable-table.
Editable Table edits a Formily ArrayField as a table inside a parent Form — line items, tasks, approval rows. Array values live in form.values; table chrome reuses shared table primitives bundled with this component. Use it when rows are part of one submit; for server-driven browse lists, use Table instead.
How to read this page
Quick Start → Examples (Level 1 in order) → Define Your Columns → Core Concepts → Recipes / Troubleshooting.
When to Use
| Scenario | Why Editable Table |
|---|---|
| Repeating field groups in one parent form | Each row is a slice of form.values; submit validates the whole form once. |
| Per-row commit with Save / Cancel | Row mode keeps drafts out of form.values until the user explicitly saves. |
| Always-live cells in a draft application | Inline mode binds cells straight to form.values — no per-row Save gate. |
| Cross-row rules after commit | Totals, freight policy, and header reactions run on the committed array (parent effects). |
| Dense grids with paging or virtual scroll | features.view slices or virtualizes the view while submit validation still scans every row. |
| Wide line-item rows (many columns, stay in the grid) | Pin identity columns + horizontal scroll + column manager — see Wide Columns: Flat Matrix. |
| Wide rows where secondary fields can leave the matrix | Column tier: "secondary" + expandable row detail — see Wide Rows: Column Tiers & Row Detail. |
| Parent rows that own child entities (claims, splits, sub-lines) | Master–detail nested — see Nested Sub-Rows (Master–Detail). |
| Line items inside a FormPage | editMode="inline" + variant="embedded" — always-live cells, grid chrome off, submit validates the whole form. |
When Not to Use
| Scenario | Prefer instead |
|---|---|
| Server-driven list with JSON Logic filters and URL sync | Table + list adapter |
| Single scalar field or a short static list | ArrayField + ObjectField rows in Form |
| Spreadsheet-style browse with occasional cell edit | Table + row actions |
| Records edited only on a separate detail route | A dedicated page — stay-on-the-page editing is this component's thesis |
Installing
Editable Table is a Plus component — install through the authenticated @f-ui-plus registry with FUI_PLUS_REGISTRY_TOKEN configured.
pnpm dlx shadcn@latest add https://ui.isaacfei.com/api/plus/r/editable-table.jsonnpx shadcn@latest add https://ui.isaacfei.com/api/plus/r/editable-table.jsonyarn dlx shadcn@latest add https://ui.isaacfei.com/api/plus/r/editable-table.jsonbunx shadcn@latest add https://ui.isaacfei.com/api/plus/r/editable-table.jsonWith the @f-ui-plus namespace registered in components.json, shadcn add @f-ui-plus/editable-table also works and pulls formily automatically.
Usage
Wrap the table in <Form> under an ArrayField path. Define columns once with defineEditableColumns — display kind for read formatting, optional field for edit wiring.
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
const columns = defineEditableColumns({
sku: { kind: "text", label: "SKU", field: { required: true } },
qty: { kind: "number", label: "Qty", field: true },
});
<EditableTable
name="lineItems"
columns={columns}
getRowId={(row) => row.id}
recordCreator={{ record: () => ({ id: crypto.randomUUID(), qty: 1 }) }}
/>field: true (or field: { required: true } without component) infers the edit control from kind — number → connected NumberInput, text → Input, currency → CurrencyInput. Do not hand-wire Input + type: "number".
In row mode (default), click Edit on a row to change values; Save commits into form.values. Cancel discards the draft. Submit the parent form to persist everything. In inline mode, cells are always live — set editMode="inline" and skip per-row Save.
Quick Start (5 Minutes)
Follow this checklist the first time you wire a table into a form:
- Install the Plus registry item (see Installing) — it pulls
formilyautomatically. - Wrap your page in
<Form form={form} onSubmit={…}>. - Define columns once with
defineEditableColumns— see Define Your Columns. - Pass
name(theArrayFieldpath),columns, andgetRowId(stable id per row — required for selection, duplicate, and error jump links). - Choose edit mode:
editMode="inline"+variant="embedded"for the common case — line items inside a form, always-live cells, no per-row Save. Use row mode (below) only for remote per-row persistence. - Add rows with
recordCreator.record— new rows open in edit mode until Save (row mode) or append live (inline). - Submit the parent
<Form>— validation runs over every row inform.values, including rows hidden by search or on another page.
Minimal copy-paste:
const form = createForm<{ lines: Line[] }>({
initialValues: { lines: [{ id: "1", sku: "A", qty: 1 }] },
});
<Form form={form} onSubmit={async (values) => api.save(values)}>
<EditableTable
name="lines"
variant="embedded"
editMode="inline"
columns={columns}
getRowId={(row) => row.id}
recordCreator={{ record: () => ({ id: crypto.randomUUID(), qty: 1 }) }}
/>
<FormActions><Button type="submit">Save order</Button></FormActions>
</Form>Continue with Row Mode — Operational Grid.
Examples
Demos and source code
Each example renders a live preview with a Code tab — that tab is the full demo source from src/demos/editable-table/. Prose on this page explains what to try; the code tab is what you copy into your app.
Learning path
Start at Level 1 in order. Open Complete Order Entry only after Cell Validation Feedback and Cross-Row Header Effects — it combines those patterns.
Level 1 — Get It Working
Row Mode — Operational Grid
Problem: Each row is an independently-committed draft in an operational grid (search, paging, batch actions) — not just a slice of one form submit.
This demo proves: Default row mode keeps drafts out of form.values until Save commits a row. This is the operational-grid case — for line items in a form, prefer inline + embedded (see Quick Start). For per-row server onSave / onDelete, see Remote Persistence.
Try it:
- Edit a row, change qty, Save — then submit the form.
- Search
tape— the view filters; array order stays the same until Save. - Add from catalog — bulk append via the toolbar.
"use client";
import { useCallback, useMemo, useState } from "react";
import { isArrayField } from "@formily/core";
import { toast } from "sonner";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { Input } from "@/components/f-ui/formily/connects/input";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import {
BASIC_DEMO_SEED,
CATALOG_LINES,
lineColumns,
type LineItem,
} from "./editable-table-demo-shared";
type OrderValues = { orderNo: string; lineItems: LineItem[] };
const getRowId = (r: LineItem) => r.id;
export function EditableTableDemo() {
const orderForm = useMemo(
() =>
createForm<OrderValues>({
initialValues: { orderNo: "SO-0042", lineItems: BASIC_DEMO_SEED },
}),
[],
);
const [savedOrder, setSavedOrder] = useState<OrderValues | null>(null);
const addFromCatalog = useCallback(() => {
const field = orderForm.query("lineItems").take();
if (!field || !isArrayField(field)) return;
for (const line of CATALOG_LINES) {
field.push({ id: crypto.randomUUID(), ...line });
}
}, [orderForm]);
return (
<Form
form={orderForm}
onSubmit={async (v) => {
setSavedOrder(v);
toast.success("Submitted successfully");
}}
>
<FormField name="orderNo" label="Order #" component={[Input]} />
<EditableTable<LineItem>
name="lineItems"
tableCode="demo-order-lines"
columns={lineColumns}
getRowId={getRowId}
recordCreator={{
record: () => ({ id: crypto.randomUUID(), qty: 1, price: 0 }),
}}
toolbar={{
trailing: (
<Button type="button" variant="outline" onClick={addFromCatalog}>
Add from catalog
</Button>
),
}}
features={{
index: { mode: "view" },
view: { mode: "offset", pageSize: 5 },
sorting: true,
search: true,
selection: { batchDelete: true },
columns: { manager: true },
footer: true,
}}
/>
<FormActions>
<Button type="submit">
Save order
</Button>
</FormActions>
{savedOrder ? (
<pre className="bg-muted mt-3 rounded-md p-3 text-xs">
<code>{JSON.stringify(savedOrder, null, 2)}</code>
</pre>
) : null}
</Form>
);
}Single Edit Session
Problem: Only one row should be editable at a time.
This demo proves: editable.type: "single" blocks a second edit and submit until Save/Cancel. Look for editable={{ type: "single" }} in the Code tab.
Try it:
- Edit row 1, then try to edit row 2 — blocked.
- Submit while row 1 is still editing — blocked.
- Save row 1 — then you can edit row 2.
"use client";
import { useMemo } from "react";
import { toast } from "sonner";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { f } from "@/components/f-ui/field-types/catalog";
type Task = { id: string; title: string; hours: number };
const columns = defineEditableColumns<Task>({
title: {
...f.text({ label: "Task" }),
field: { component: [Input], required: true },
},
hours: {
...f.number({
label: "Hours",
}),
size: 100,
field: {
component: [NumberInput, { surface: "tableCell" }],
required: true,
validator: (v) => (Number(v) > 0 ? undefined : "must be > 0"),
},
},
});
export function EditableTablePendingEditDemo() {
const form = useMemo(
() =>
createForm<{ tasks: Task[] }>({
initialValues: {
tasks: [
{ id: "t1", title: "Wireframe", hours: 4 },
{ id: "t2", title: "Build", hours: 8 },
],
},
}),
[],
);
return (
<Form
form={form}
onSubmit={async () => {
toast.success("Submitted");
}}
>
<EditableTable<Task>
name="tasks"
columns={columns}
getRowId={(r) => r.id}
editable={{ type: "single" }}
recordCreator={{
record: () => ({ id: crypto.randomUUID(), hours: 1 }),
}}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
);
}Inline Edit Mode
Problem: The table is one section of a large draft form, not an operational grid.
This demo proves: editMode="inline" binds cells straight to form.values; Save draft persists without form.validate().
variant="embedded" turns off grid affordances (search, sort, batch-delete, column manager). Opt back in per-feature via features (e.g. features={{ search: true }}). Summary footer is opt-in (features.footer) — leave it off on wide scroll matrices.
Try it:
- Leave a field empty and click Save draft — no validation.
- Click Submit with the same empty field — validation blocks.
- Compare with Row Mode — Operational Grid — row mode needs Save per row.
"use client";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { f } from "@/components/f-ui/field-types/catalog";
type DraftLine = { id: string; item: string; qty: number };
const columns = defineEditableColumns<DraftLine>({
item: {
...f.text({ label: "Item" }),
field: { component: [Input], required: true },
},
qty: {
...f.number({
label: "Qty",
}),
size: 100,
field: {
component: [NumberInput, { surface: "tableCell" }],
required: true,
validator: (v) => (Number(v) > 0 ? undefined : "must be > 0"),
},
footer: (rows) => rows.reduce((s, r) => s + Number(r.qty || 0), 0),
},
});
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
export function EditableTableDraftApplicationDemo() {
const [savingDraft, setSavingDraft] = useState(false);
const form = useMemo(
() =>
createForm<{ lines: DraftLine[] }>({
initialValues: {
lines: [{ id: "d1", item: "", qty: 1 }],
},
}),
[],
);
const saveDraft = async () => {
setSavingDraft(true);
await delay(600);
setSavingDraft(false);
toast.success(
`Draft saved (${form.values.lines?.length ?? 0} lines, no validation)`,
);
};
const onSubmit = async (values: { lines: DraftLine[] }) => {
await delay(400);
toast.success(`Submitted ${values.lines.length} lines`);
};
return (
<Form form={form} onSubmit={onSubmit}>
<EditableTable<DraftLine>
name="lines"
tableCode="demo-draft-lines"
columns={columns}
editMode="inline"
variant="embedded"
features={{
index: { mode: "view" },
}}
getRowId={(r) => r.id}
recordCreator={{
record: () => ({ id: crypto.randomUUID(), item: "", qty: 1 }),
}}
/>
<FormActions>
<Button
type="button"
variant="outline"
disabled={savingDraft}
onClick={saveDraft}
>
{savingDraft ? "Saving…" : "Save draft"}
</Button>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
);
}Typed Field Columns
Problem: Read formatting and edit controls must not drift (currency, status tags, booleans).
This demo proves: kind formats read and edit the same way; field: true infers the control. Compare plan, fee, and active column definitions in the Code tab.
Try it:
- Toggle Row vs Inline — same column
kindin both modes. - Edit Plan — status tag in read matches the select in edit.
- See
field: trueon fee and active columns in the Code tab.
"use client";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { f } from "@/components/f-ui/field-types/catalog";
type Member = {
id: string;
name: string;
plan: "trial" | "pro" | "enterprise";
fee: number;
active: boolean;
};
const PLAN_VARIANTS = {
trial: { label: "Trial", tone: "warning" },
pro: { label: "Pro", tone: "info" },
enterprise: { label: "Enterprise", tone: "success" },
};
const columns = defineEditableColumns<Member>({
name: { ...f.text({ label: "Name" }), size: 150, field: { required: true } },
plan: {
...f.enum({ render: "status", label: "Plan", variants: PLAN_VARIANTS }),
size: 130,
field: { required: true },
},
fee: {
...f.currency({ label: "Monthly fee", currency: "USD", measure: "none" }),
size: 130,
field: true,
},
active: { ...f.boolean({ label: "Active" }), size: 90, field: true },
});
const SEED: Member[] = [
{ id: "m1", name: "Ada Lovelace", plan: "pro", fee: 49, active: true },
{
id: "m2",
name: "Alan Turing",
plan: "enterprise",
fee: 199,
active: false,
},
];
export function EditableTableTypedFieldsDemo() {
const [mode, setMode] = useState<"row" | "inline">("row");
const form = useMemo(
() =>
createForm<{ members: Member[] }>({ initialValues: { members: SEED } }),
[],
);
return (
<>
<div className="mb-3 flex items-center gap-2">
<span className="text-muted-foreground text-sm">Edit mode</span>
<div className="inline-flex gap-1">
<Button
type="button"
variant={mode === "row" ? "default" : "outline"}
onClick={() => setMode("row")}
>
Row
</Button>
<Button
type="button"
variant={mode === "inline" ? "default" : "outline"}
onClick={() => setMode("inline")}
>
Inline
</Button>
</div>
</div>
<Form
form={form}
density="compact"
onSubmit={async (v) => {
toast.success(`Submitted ${v.members.length} members`);
}}
>
<EditableTable<Member>
key={mode}
name="members"
editMode={mode}
tableCode="demo-typed-fields"
columns={columns}
getRowId={(r) => r.id}
recordCreator={{
record: () => ({
id: crypto.randomUUID(),
name: "",
plan: "trial",
fee: 0,
active: false,
}),
}}
features={{ sorting: true }}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</>
);
}Wide Rows: Column Tiers & Row Detail
Problem: Line-item rows exceed comfortable scan width (10+ columns) — horizontal scroll hides critical fields.
This demo proves: Mark low-frequency columns with tier: "secondary" so they render in an expandable row-detail panel instead of the matrix. Primary columns stay in the table (~7 or fewer for scan). Expand/collapse uses the shared table __expand column (buildExpandColumn from Table); Editable Table adds EditableRowDetail for secondary fields and toolbar Expand all / Collapse all via expand={{ expandAll: true }}.
Try it:
- Scan the primary grid — SKU, qty, price stay visible without horizontal scroll.
- Click a row chevron — secondary fields (tax code, notes, …) open in the detail panel beneath the row.
- Use Expand all in the toolbar — every row opens; Collapse all closes them.
- Edit a secondary field inline — panel inputs use the same Formily wiring as primary cells.
Virtual row-detail rendering is intentionally deferred in v1 — keep tiered-detail workflows on view.mode: "all" or view.mode: "offset".
For child entities under a parent row (not extra fields of the same entity), use Nested Sub-Rows (Master–Detail) instead — nested and tier: "secondary" cannot share one chevron.
"use client";
import { useMemo } from "react";
import { toast } from "sonner";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { Input } from "@/components/f-ui/formily/connects/input";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { f } from "@/components/f-ui/field-types/catalog";
type WorkOrderLine = {
id: string;
sku: string;
item: string;
qty: number;
unitPrice: number;
status: "draft" | "review" | "approved";
warehouse: string;
requestedBy: string;
neededBy: Date | null;
taxCode: string;
notes: string;
};
const STATUS_VARIANTS = {
draft: { label: "Draft", tone: "warning" },
review: { label: "Review", tone: "info" },
approved: { label: "Approved", tone: "success" },
};
const columns = defineEditableColumns<WorkOrderLine>({
sku: {
...f.text({ label: "SKU" }),
size: 130,
field: { component: [Input], required: true },
},
item: {
...f.text({ label: "Item" }),
size: 180,
field: { component: [Input], required: true },
},
qty: { ...f.number({ label: "Qty" }), size: 90, field: true },
unitPrice: {
...f.currency({ label: "Unit price", currency: "USD", measure: "none" }),
size: 120,
field: true,
},
status: {
...f.enum({ render: "status", label: "Status", variants: STATUS_VARIANTS }),
size: 130,
field: { required: true },
},
warehouse: {
...f.text({ label: "Warehouse" }),
tier: "secondary",
field: { component: [Input], required: true },
},
requestedBy: {
...f.text({ label: "Requested by" }),
tier: "secondary",
field: { component: [Input], required: true },
},
neededBy: {
...f.date({ label: "Needed by" }),
tier: "secondary",
field: true,
},
taxCode: {
...f.text({ label: "Tax code" }),
tier: "secondary",
field: { component: [Input] },
},
notes: {
...f.text({ label: "Notes" }),
tier: "secondary",
field: { component: [Input] },
},
});
const SEED: WorkOrderLine[] = [
{
id: "w1",
sku: "A-100",
item: "Motor controller",
qty: 3,
unitPrice: 249.5,
status: "review",
warehouse: "SEA-1",
requestedBy: "Ivy Chen",
neededBy: new Date(2026, 6, 12),
taxCode: "TX-01",
notes: "Confirm firmware rev before ship",
},
{
id: "w2",
sku: "B-220",
item: "Harness kit",
qty: 8,
unitPrice: 74.0,
status: "draft",
warehouse: "SFO-2",
requestedBy: "Jae Park",
neededBy: new Date(2026, 6, 19),
taxCode: "",
notes: "",
},
];
export function EditableTableColumnTiersDemo() {
const form = useMemo(
() =>
createForm<{ lines: WorkOrderLine[] }>({
initialValues: { lines: SEED },
}),
[],
);
return (
<Form
form={form}
density="compact"
onSubmit={async (values) => {
toast.success(`Submitted ${values.lines.length} lines`);
}}
>
<EditableTable<WorkOrderLine>
name="lines"
variant="embedded"
editMode="inline"
tableCode="demo-column-tiers"
columns={columns}
getRowId={(row) => row.id}
expand={{ expandAll: true, defaultExpanded: true }}
recordCreator={{
record: () => ({
id: crypto.randomUUID(),
sku: "",
item: "",
qty: 1,
unitPrice: 0,
status: "draft",
warehouse: "SEA-1",
requestedBy: "",
neededBy: null,
taxCode: "",
notes: "",
}),
}}
features={{ index: { mode: "view" }, footer: true }}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
);
}Wide Columns: Flat Matrix (Scroll + Pin)
Problem: Real line items often keep all fields in the grid (14+ columns) — no expand, no secondary panel. Operators compare across rows while scrolling horizontally. Toy demos with 4–7 columns never surface pin / scroll / ComboBox density.
This demo proves: Fourteen columns stay in the matrix. SKU declares pinned: "left" (identity stays put while you scroll); Actions stay right-pinned. ComboBox / Select cells use short code labels so the frozen SKU column stays readable. Column manager can hide or re-pin. Picking a SKU still carries out item / price / UoM / tax.
Try it:
- Note SKU stays visible while you scroll the table horizontally past Warehouse → Notes.
- Open a SKU ComboBox — pick BRG-55; Item and Unit price update; cell still shows the short code
BRG-55. - Edit Warehouse / Requester / Cost ctr ComboBoxes mid-scroll — popovers clear the table chrome.
- Columns → hide Notes or pin Warehouse left; Reset restores author pins.
"use client";
import { useMemo } from "react";
import { toast } from "sonner";
import { isField, onFieldInputValueChange } from "@formily/core";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { ComboBox } from "@/components/f-ui/formily/connects/combo-box";
import { CurrencyInput } from "@/components/f-ui/formily/connects/currency-input";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { Select } from "@/components/f-ui/formily/connects/select";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { f } from "@/components/f-ui/field-types/catalog";
/**
* Flat wide matrix — 14 columns, no expand / no secondary tier.
* Identity (SKU) pins left; Actions pin right; everything else scrolls.
* ComboBox labels stay short so cells stay scannable under horizontal scroll.
*/
type ProcurementLine = {
id: string;
sku: string;
item: string;
qty: number;
unitPrice: number;
discountPct: number;
lineTotal: number;
status: "draft" | "review" | "approved";
uom: string;
warehouse: string;
taxCode: string;
batchNo: string;
neededBy: Date | null;
requestedBy: string;
shipVia: string;
costCenter: string;
notes: string;
};
const STATUS_VARIANTS = {
draft: { label: "Draft", tone: "warning" },
review: { label: "Review", tone: "info" },
approved: { label: "Approved", tone: "success" },
};
const SKU_MASTER: {
sku: string;
item: string;
unitPrice: number;
uom: string;
taxCode: string;
}[] = [
{ sku: "MC-4100", item: "Motor controller rev C", unitPrice: 249.5, uom: "EA", taxCode: "TX-01" },
{ sku: "HK-220", item: "Harness kit 24-pin", unitPrice: 74, uom: "KIT", taxCode: "TX-01" },
{ sku: "BR-90", item: "Bearing race 90mm", unitPrice: 12.4, uom: "EA", taxCode: "TX-02" },
{ sku: "SK-12", item: "Seal kit (matched)", unitPrice: 6.8, uom: "KIT", taxCode: "TX-02" },
{ sku: "PSU-48", item: "48V PSU industrial", unitPrice: 410, uom: "EA", taxCode: "TX-01" },
{ sku: "CAB-5M", item: "Shielded cable 5m", unitPrice: 18.25, uom: "EA", taxCode: "TX-03" },
{ sku: "FAN-120", item: "Cooling fan 120mm", unitPrice: 22, uom: "EA", taxCode: "TX-01" },
{ sku: "LBL-500", item: "Thermal label roll", unitPrice: 31.5, uom: "RL", taxCode: "TX-03" },
{ sku: "BRG-55", item: "Bearing 55mm sealed", unitPrice: 9.9, uom: "EA", taxCode: "TX-02" },
{ sku: "GASK-8", item: "Gasket set M8", unitPrice: 4.25, uom: "KIT", taxCode: "TX-03" },
];
/** Cell labels = codes only; long names live in Item after pick. */
const SKU_OPTIONS = SKU_MASTER.map((row) => ({ value: row.sku, label: row.sku }));
const WAREHOUSE_OPTIONS = [
{ value: "SEA-1", label: "SEA-1" },
{ value: "SFO-2", label: "SFO-2" },
{ value: "DAL-3", label: "DAL-3" },
{ value: "ORD-4", label: "ORD-4" },
];
const SHIP_VIA_OPTIONS = [
{ value: "Ground", label: "Ground" },
{ value: "Air", label: "Air" },
{ value: "Ocean", label: "Ocean" },
{ value: "Will-call", label: "Will-call" },
];
const COST_CENTER_OPTIONS = [
{ value: "OPS-240", label: "OPS-240" },
{ value: "ENG-110", label: "ENG-110" },
{ value: "FAC-050", label: "FAC-050" },
{ value: "QA-030", label: "QA-030" },
];
const REQUESTER_OPTIONS = [
{ value: "Ivy Chen", label: "Ivy Chen" },
{ value: "Jae Park", label: "Jae Park" },
{ value: "Sam Ortiz", label: "Sam Ortiz" },
{ value: "Mina Cho", label: "Mina Cho" },
];
function lineNet(row: Pick<ProcurementLine, "qty" | "unitPrice" | "discountPct">) {
const gross = Number(row.qty) * Number(row.unitPrice);
const disc = Number(row.discountPct) || 0;
return gross * (1 - disc / 100);
}
const columns = defineEditableColumns<ProcurementLine>({
sku: {
...f.text({ label: "SKU", sortKey: "sku" }),
size: 110,
minSize: 96,
pinned: "left",
field: {
component: [
ComboBox,
{ options: SKU_OPTIONS, placeholder: "SKU…", creatable: true },
],
required: true,
},
},
item: {
...f.text({ label: "Item", sortKey: "item" }),
size: 160,
field: { component: [Input], required: true },
},
qty: {
...f.number({ label: "Qty", sortKey: "qty" }),
size: 72,
field: {
component: [NumberInput, { surface: "tableCell" }],
required: true,
validator: (v) => (Number(v) > 0 ? undefined : "needs > 0"),
},
},
unitPrice: {
...f.currency({
label: "Unit price",
currency: "USD",
sortKey: "unitPrice",
measure: "none",
}),
size: 100,
field: {
component: [CurrencyInput, { currency: "USD", surface: "tableCell", measure: "none" }],
},
},
discountPct: {
...f.number({ label: "Disc %", sortKey: "discountPct" }),
size: 72,
field: {
component: [NumberInput, { surface: "tableCell", min: 0, max: 50 }],
},
},
lineTotal: {
...f.currency({
label: "Line total",
currency: "USD",
measure: "none",
accessor: (row) => lineNet(row),
}),
size: 100,
field: {
component: [Input],
readPretty: () => true,
reactions: (field) => {
const qty = Number(field.query(".qty").value()) || 0;
const unitPrice = Number(field.query(".unitPrice").value()) || 0;
const discountPct = Number(field.query(".discountPct").value()) || 0;
if (isField(field)) {
field.value = lineNet({ qty, unitPrice, discountPct });
}
},
},
},
status: {
...f.enum({ render: "status", label: "Status", variants: STATUS_VARIANTS }),
size: 110,
field: { required: true },
},
uom: {
...f.text({ label: "UoM" }),
size: 64,
field: {
component: [Input],
readPretty: () => true,
},
},
warehouse: {
...f.text({ label: "Warehouse" }),
size: 100,
field: {
component: [ComboBox, { options: WAREHOUSE_OPTIONS, placeholder: "Whse…" }],
required: true,
},
},
taxCode: {
...f.text({ label: "Tax" }),
size: 72,
field: {
component: [Input],
readPretty: () => true,
},
},
batchNo: {
...f.text({ label: "Batch" }),
size: 96,
field: { component: [Input] },
},
neededBy: {
...f.date({ label: "Needed by" }),
size: 130,
field: true,
},
requestedBy: {
...f.text({ label: "Requester" }),
size: 120,
field: {
component: [
ComboBox,
{ options: REQUESTER_OPTIONS, placeholder: "Who…", creatable: true },
],
required: true,
},
},
shipVia: {
...f.text({ label: "Ship via" }),
size: 100,
field: {
component: [Select, { options: SHIP_VIA_OPTIONS, placeholder: "Via" }],
},
},
costCenter: {
...f.text({ label: "Cost ctr" }),
size: 96,
field: {
component: [ComboBox, { options: COST_CENTER_OPTIONS, placeholder: "CC…" }],
},
},
notes: {
...f.text({ label: "Notes" }),
size: 160,
field: { component: [Input] },
},
});
const CATALOG: Omit<ProcurementLine, "id" | "lineTotal">[] = [
{
sku: "MC-4100",
item: "Motor controller rev C",
qty: 3,
unitPrice: 249.5,
discountPct: 0,
status: "review",
uom: "EA",
warehouse: "SEA-1",
taxCode: "TX-01",
batchNo: "B-8821",
neededBy: new Date(2026, 6, 12),
requestedBy: "Ivy Chen",
shipVia: "Ground",
costCenter: "OPS-240",
notes: "Confirm firmware rev before ship",
},
{
sku: "HK-220",
item: "Harness kit 24-pin",
qty: 8,
unitPrice: 74,
discountPct: 5,
status: "draft",
uom: "KIT",
warehouse: "SFO-2",
taxCode: "TX-01",
batchNo: "",
neededBy: new Date(2026, 6, 19),
requestedBy: "Jae Park",
shipVia: "Air",
costCenter: "ENG-110",
notes: "",
},
{
sku: "BR-90",
item: "Bearing race 90mm",
qty: 20,
unitPrice: 12.4,
discountPct: 0,
status: "approved",
uom: "EA",
warehouse: "SEA-1",
taxCode: "TX-02",
batchNo: "B-9010",
neededBy: new Date(2026, 7, 1),
requestedBy: "Sam Ortiz",
shipVia: "Ground",
costCenter: "OPS-240",
notes: "Pair with seal kit SK-12",
},
{
sku: "SK-12",
item: "Seal kit (matched)",
qty: 20,
unitPrice: 6.8,
discountPct: 0,
status: "approved",
uom: "KIT",
warehouse: "SEA-1",
taxCode: "TX-02",
batchNo: "B-9011",
neededBy: new Date(2026, 7, 1),
requestedBy: "Sam Ortiz",
shipVia: "Ground",
costCenter: "OPS-240",
notes: "",
},
{
sku: "PSU-48",
item: "48V PSU industrial",
qty: 2,
unitPrice: 410,
discountPct: 10,
status: "review",
uom: "EA",
warehouse: "DAL-3",
taxCode: "TX-01",
batchNo: "B-7702",
neededBy: new Date(2026, 6, 28),
requestedBy: "Mina Cho",
shipVia: "Air",
costCenter: "ENG-110",
notes: "Needs UL sticker on carton",
},
{
sku: "CAB-5M",
item: "Shielded cable 5m",
qty: 15,
unitPrice: 18.25,
discountPct: 0,
status: "draft",
uom: "EA",
warehouse: "SFO-2",
taxCode: "TX-03",
batchNo: "",
neededBy: new Date(2026, 7, 8),
requestedBy: "Ivy Chen",
shipVia: "Ground",
costCenter: "OPS-240",
notes: "Cut to length at dock",
},
{
sku: "FAN-120",
item: "Cooling fan 120mm",
qty: 6,
unitPrice: 22,
discountPct: 0,
status: "draft",
uom: "EA",
warehouse: "DAL-3",
taxCode: "TX-01",
batchNo: "B-6610",
neededBy: new Date(2026, 7, 15),
requestedBy: "Jae Park",
shipVia: "Ground",
costCenter: "FAC-050",
notes: "",
},
{
sku: "LBL-500",
item: "Thermal label roll",
qty: 4,
unitPrice: 31.5,
discountPct: 0,
status: "review",
uom: "RL",
warehouse: "SEA-1",
taxCode: "TX-03",
batchNo: "",
neededBy: new Date(2026, 6, 30),
requestedBy: "Mina Cho",
shipVia: "Ground",
costCenter: "OPS-240",
notes: "Compatible with Zebra ZT410",
},
];
const SEED: ProcurementLine[] = CATALOG.map((row, i) => ({
...row,
id: `pl-${i + 1}`,
lineTotal: lineNet(row),
}));
function emptyLine(): ProcurementLine {
return {
id: crypto.randomUUID(),
sku: "",
item: "",
qty: 1,
unitPrice: 0,
discountPct: 0,
lineTotal: 0,
status: "draft",
uom: "EA",
warehouse: "SEA-1",
taxCode: "TX-01",
batchNo: "",
neededBy: null,
requestedBy: "",
shipVia: "Ground",
costCenter: "",
notes: "",
};
}
export function EditableTableWideColumnsDemo() {
const form = useMemo(
() =>
createForm<{ lines: ProcurementLine[] }>({
initialValues: { lines: SEED },
effects() {
onFieldInputValueChange("lines.*.sku", (field) => {
if (!isField(field)) return;
const hit = SKU_MASTER.find((row) => row.sku === String(field.value ?? ""));
if (!hit) return;
const index = field.index as number;
field.form.setValuesIn(`lines.${index}.item`, hit.item);
field.form.setValuesIn(`lines.${index}.unitPrice`, hit.unitPrice);
field.form.setValuesIn(`lines.${index}.uom`, hit.uom);
field.form.setValuesIn(`lines.${index}.taxCode`, hit.taxCode);
});
},
}),
[],
);
return (
<Form
form={form}
density="compact"
onSubmit={async (values) => {
toast.success(`Submitted ${values.lines.length} lines`);
}}
>
<EditableTable<ProcurementLine>
name="lines"
variant="embedded"
editMode="inline"
tableCode="demo-wide-flat-matrix"
columns={columns}
getRowId={(row) => row.id}
recordCreator={{ record: emptyLine }}
features={{
index: { mode: "view" },
search: true,
columns: { manager: true },
}}
/>
<FormActions>
<Button type="submit">Submit</Button>
</FormActions>
</Form>
);
}Level 2 — Validation and Rules
Cell Validation Feedback
Problem: Field B must appear and become required when field A has a value on the same row.
This demo proves: field.reactions + validator on discountReason handle same-row rules; errors reveal on blur, not first keystroke.
Validation timing
Live cell errors follow punish late, reward early: tint on blur or submit, not the first keystroke. Focus a tinted cell to read the value-state message (Fiori-style) — not a hover tooltip.
Try it:
- Set Disc % to
10— Disc reason appears. - Tab away from empty reason — red tint on blur.
- Submit empty — rollup lists the row with a jump link.
"use client";
import { isField } from "@formily/core";
import { useMemo } from "react";
import { toast } from "sonner";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { f } from "@/components/f-ui/field-types/catalog";
type Line = {
id: string;
sku: string;
discountPct: number;
discountReason: string;
};
const columns = defineEditableColumns<Line>({
sku: {
...f.text({ label: "SKU" }),
size: 120,
field: { component: [Input], readOnly: () => true },
},
discountPct: {
...f.number({
label: "Disc %",
}),
size: 90,
field: {
component: [NumberInput, { surface: "tableCell", min: 0, max: 50 }],
validator: (v) => {
const n = Number(v);
if (Number.isNaN(n) || n < 0 || n > 50) return "0–50%";
return undefined;
},
},
},
discountReason: {
...f.text({
label: "Disc reason",
}),
size: 180,
field: {
component: [Input, { placeholder: "Reason for discount" }],
reactions: (field) => {
const lines = field.form.values.lines as Line[] | undefined;
const row = lines?.[field.index as number];
const discounted = Number(row?.discountPct) > 0;
field.display = discounted ? "visible" : "none";
if (isField(field)) field.required = discounted;
},
validator: (value, row) => {
const line = row as Line | undefined;
if (Number(line?.discountPct) > 0 && !String(value ?? "").trim()) {
return "Required when discount > 0";
}
return undefined;
},
},
},
});
export function EditableTableCellValidationDemo() {
const form = useMemo(
() =>
createForm<{ lines: Line[] }>({
initialValues: {
lines: [
{ id: "l1", sku: "SKU-001", discountPct: 0, discountReason: "" },
{ id: "l2", sku: "SKU-002", discountPct: 0, discountReason: "" },
],
},
}),
[],
);
return (
<Form
form={form}
onSubmit={async () => {
toast.success("Submitted");
}}
>
<EditableTable<Line>
name="lines"
columns={columns}
getRowId={(r) => r.id}
editMode="inline"
features={{ footer: false, rowActions: false }}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
);
}Identity-Keyed Host Validation
Problem: Host Validate / server / AI paints an error on a line, then the operator inserts or removes another line — chrome jumps to the wrong row because Formily paths use array indices.
This demo proves: applyEditableArrayIssues stores issues by rowId + colKey. After Insert row above, the kit rematerializes feedback onto the same row identity (SKU-B), not onto the new row that inherited the old index.
Anti-pattern: writing form.query("lines.1.qty").take().selfErrors = … or path-based applyFormIssues under the array once — that paint dies or sticks after structure change. Prefer the identity bridge (see Painting Validation on Rows).
Try it:
- Paint issue on SKU-B — Qty on SKU-B turns red (currently index 1).
- Insert row above — SKU-NEW lands at index 0; red tint must stay on SKU-B (now index 2).
- Status line under the toolbar reports Pass / Fail.
- Clear issues empties the identity bag for this table.
"use client";
import { isField } from "@formily/core";
import { useMemo, useState } from "react";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import {
applyEditableArrayIssues,
clearEditableArrayIssues,
} from "@/components/f-ui/editable-table/lib/editable-array-issues";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { f } from "@/components/f-ui/field-types/catalog";
type Line = {
id: string;
sku: string;
qty: number;
};
const columns = defineEditableColumns<Line>({
sku: {
...f.text({ label: "SKU" }),
size: 140,
field: { component: [Input] },
},
qty: {
...f.number({ label: "Qty" }),
size: 100,
field: {
component: [NumberInput, { surface: "tableCell", min: 0 }],
},
},
});
/**
* Host Validate paints by **row id**. After insert/reorder, chrome stays on
* the same SKU — not on whatever row inherited the old array index.
*/
export function EditableTableArrayFeedbackIdentityDemo() {
const form = useMemo(
() =>
createForm<{ lines: Line[] }>({
initialValues: {
lines: [
{ id: "a", sku: "SKU-A", qty: 2 },
{ id: "b", sku: "SKU-B", qty: 99 },
{ id: "c", sku: "SKU-C", qty: 1 },
],
},
}),
[],
);
const [status, setStatus] = useState(
"Paint an issue on SKU-B, then insert a row above — the red tint must stay on SKU-B.",
);
const paintOnSkuB = () => {
applyEditableArrayIssues(form, {
arrayPath: "lines",
issues: [
{
rowId: "b",
colKey: "qty",
message: "Qty exceeds available stock",
source: "system",
},
],
});
const field = form.query("lines.1.qty").take();
const ok = isField(field) && (field.selfErrors?.[0] ?? "").includes("stock");
setStatus(
ok
? "Issue painted on SKU-B (index 1). Next: Insert row above."
: "Paint failed — check that the table is mounted.",
);
};
const insertAbove = () => {
const lines = (form.values.lines ?? []) as Line[];
form.setValues({
...form.values,
lines: [
{ id: `z-${Date.now()}`, sku: "SKU-NEW", qty: 0 },
...lines,
],
});
// After structure change, SKU-B should be at index 2 with the same chrome.
queueMicrotask(() => {
const at0 = form.query("lines.0.qty").take();
const at2 = form.query("lines.2.qty").take();
const wrong =
isField(at0) && (at0.selfErrors?.length ?? 0) > 0;
const right =
isField(at2) &&
(at2.selfErrors?.[0] ?? "").includes("stock");
if (right && !wrong) {
setStatus(
"Pass: chrome stayed on SKU-B (now index 2). Index 0 (SKU-NEW) is clean.",
);
} else if (wrong) {
setStatus(
"Fail: chrome stuck on index 0 after insert — that is the index-paint bug.",
);
} else {
setStatus(
"No chrome on SKU-B after insert — re-run Paint issue on SKU-B first.",
);
}
});
};
const clearIssues = () => {
clearEditableArrayIssues(form, { arrayPath: "lines" });
setStatus("Cleared identity-keyed issues for this table.");
};
return (
<Form form={form}>
<p className="text-muted-foreground mb-3 text-sm" role="status">
{status}
</p>
<EditableTable<Line>
name="lines"
columns={columns}
getRowId={(r) => r.id}
editMode="inline"
features={{ footer: false, rowActions: false }}
/>
<FormActions>
<Button type="button" onClick={paintOnSkuB}>
Paint issue on SKU-B
</Button>
<Button type="button" variant="outline" onClick={insertAbove}>
Insert row above
</Button>
<Button type="button" variant="ghost" onClick={clearIssues}>
Clear issues
</Button>
</FormActions>
</Form>
);
}Cross-Row Header Effects
Problem: A header field must react when any table row matches a rule.
This demo proves: Parent form effects and onFieldValueChange("lines.*.qty", …) drive header fields — not cell reactions.
Try it:
- Manager approval starts hidden.
- Set any Qty above 25 — the field appears and becomes required.
- Drop all qty to 25 or below — field hides and clears.
"use client";
import { onFieldValueChange } from "@formily/core";
import { useMemo } from "react";
import { toast } from "sonner";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { f } from "@/components/f-ui/field-types/catalog";
type Line = { id: string; item: string; qty: number };
const BULK_QTY = 25;
const columns = defineEditableColumns<Line>({
item: {
...f.text({ label: "Item" }),
field: { component: [Input], required: true },
},
qty: {
...f.number({
label: "Qty",
}),
size: 100,
field: {
component: [NumberInput, { surface: "tableCell" }],
required: true,
validator: (v) => (Number(v) > 0 ? undefined : "must be > 0"),
},
},
});
export function EditableTableCrossRowDemo() {
const form = useMemo(
() =>
createForm<{ managerApproval: string; lines: Line[] }>({
initialValues: {
managerApproval: "",
lines: [
{ id: "l1", item: "Steel bracket", qty: 10 },
{ id: "l2", item: "Rubber gasket", qty: 4 },
],
},
effects(form) {
const syncApprovalGate = () => {
const lines = (form.values?.lines ?? []) as Line[];
const needsApproval = lines.some(
(row) => Number(row.qty) > BULK_QTY,
);
form.setFieldState("managerApproval", (state) => {
state.display = needsApproval ? "visible" : "none";
state.required = needsApproval;
if (!needsApproval) state.value = "";
});
};
onFieldValueChange("lines.*.qty", syncApprovalGate);
onFieldValueChange("lines", syncApprovalGate);
syncApprovalGate();
},
}),
[],
);
return (
<Form
form={form}
onSubmit={async () => {
toast.success("Submitted");
}}
>
<FormField
name="managerApproval"
label="Manager approval"
description={`Required when any line qty > ${BULK_QTY}`}
component={[Input, { placeholder: "MGMT-OK" }]}
/>
<EditableTable<Line>
name="lines"
columns={columns}
getRowId={(r) => r.id}
editMode="inline"
recordCreator={{
record: () => ({ id: crypto.randomUUID(), item: "", qty: 1 }),
}}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
);
}Submit Validation
Problem: Invalid rows hidden by search or paging must still block submit.
This demo proves: Submit scans the full form.values array; rollup jump links find hidden rows.
Error surfaces (kit-enforced):
| Surface | Role |
|---|---|
| Cell value state | Local chrome + message |
EditableErrorRollup | Under-table list + jump — same Banner card as Form Error Summary, cell paths only |
FormErrorSummary (scope="all") | Root + header + array-root (lineItems); kit drops owned cell descendants (lineItems.*) |
Editable Table registers its ArrayField as the cell-error owner (descendants name.*). Parent FormErrorSummary scope="all" drops those cell issues (dev console.warn when strips happen) and keeps header / array-root paths. Keep FormPage’s default summary when the page also has header or array-root rules. Pure cell errors only: errorSummary={false} (rollup owns aggregate) or scope="form" for system/root (path == null) only — scope="form" does not list header or array-root issues. See Object Messaging And Table Chrome.
Try it:
- Validate & submit — row 2 fails (empty task, zero hours).
- Search
wire— row 2 hides from the view but still fails validation. - Click a rollup jump link — search clears and the table scrolls to the row.
"use client";
import { useMemo } from "react";
import { toast } from "sonner";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { f } from "@/components/f-ui/field-types/catalog";
type Task = { id: string; title: string; hours: number };
const columns = defineEditableColumns<Task>({
title: {
...f.text({ label: "Task" }),
field: { component: [Input], required: true },
},
hours: {
...f.number({
label: "Hours",
}),
size: 100,
field: {
component: [NumberInput, { surface: "tableCell" }],
required: true,
validator: (v) => (Number(v) > 0 ? undefined : "must be > 0"),
},
},
});
export function EditableTableValidationDemo() {
const form = useMemo(
() =>
createForm<{ tasks: Task[] }>({
initialValues: {
tasks: [
{ id: "t1", title: "Wireframe", hours: 4 },
{ id: "t2", title: "", hours: 0 },
{ id: "t3", title: "Review", hours: 2 },
],
},
}),
[],
);
return (
<Form
form={form}
onSubmit={async () => {
toast.success("Submitted successfully");
}}
>
<EditableTable<Task>
name="tasks"
columns={columns}
getRowId={(r) => r.id}
recordCreator={{
record: () => ({ id: crypto.randomUUID(), hours: 1 }),
}}
features={{ search: true, sorting: true }}
/>
<FormActions>
<Button type="submit">
Validate & submit
</Button>
</FormActions>
</Form>
);
}Level 3 — Real Forms
Complete Order Entry
Problem: You need header cards, line items, cross-row totals, and per-row rules in one form.
This demo proves: Capstone — effects, reactions, and validators together. Patterns from Cell Validation Feedback and Cross-Row Header Effects appear here.
Business rules
| Area | Rule | Mechanism |
|---|---|---|
| Line subtotal | Qty × price net of discount | field.reactions on the subtotal column (editor form — live while editing) |
| Order total | Net of line discounts + freight | Parent effects on lineItems array change (post-commit) |
| Freight waiver | Freight becomes $0 and read-only when line subtotal ≥ $1,000 | effects |
| PO number | Required when order total ≥ $500 | reactions |
| Manager approval | Shown and required when any line qty > 25 or order total ≥ $1,000 | reactions |
| Ship date | Required when status is Confirmed or Shipped; cannot be before order date | setFieldState + validator |
| Disc reason | Column appears and is required when that row's Disc % > 0 | reactions + row validator |
| Shipped lock | Header fields and every line cell become read-only | effects |
| Confirmed | Cannot remove the last line | rowActionsPolicy |
Try it:
- Disc reason — Set Disc % to
10, blur empty reason, then submit. - Manager approval — Set Qty to
30on a line. - Freight waiver — Raise a line subtotal past $1,000.
- PO number — With order total ≥ $500, PO number becomes required.
- Ship date — Set Status to Confirmed with Ship date before Order date.
- Status → Shipped — entire form locks read-only.
"use client";
import {
isArrayField,
isField,
onFieldValueChange,
type Field,
} from "@formily/core";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import { formatMoneyForDisplay } from "@/components/f-ui/currency-format/currency-format";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { CurrencyInput } from "@/components/f-ui/formily/connects/currency-input";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { CATALOG_LINES, ORDER_SEED } from "./editable-table-demo-shared";
import { f } from "@/components/f-ui/field-types/catalog";
const ORDER_STATUS = [
{ label: "Draft", value: "draft" },
{ label: "Confirmed", value: "confirmed" },
{ label: "Shipped", value: "shipped" },
];
const PO_REQUIRED_THRESHOLD = 500;
const FREIGHT_WAIVER_SUBTOTAL = 1_000;
const BULK_QTY_THRESHOLD = 25;
const MANAGER_APPROVAL_TOTAL = 1_000;
const INITIAL_LINES = ORDER_SEED.map((line) => ({
...line,
discountPct: 0,
discountReason: "",
}));
const INITIAL_FREIGHT = 25;
type OrderLineItem = {
id: string;
sku: string;
name: string;
qty: number;
price: number;
discountPct: number;
discountReason: string;
};
type SalesOrderValues = {
orderNo: string;
status: "draft" | "confirmed" | "shipped";
orderDate: Date | null;
shipDate: Date | null;
poNumber: string;
managerApproval: string;
customerName: string;
customerEmail: string;
customerPhone: string;
shipTo: string;
freight: number;
notes: string;
orderTotal: number;
lineItems: OrderLineItem[];
};
type OrderForm = ReturnType<typeof createForm<SalesOrderValues>>;
function startOfDay(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
function lineGross(row: OrderLineItem) {
return Number(row.qty) * Number(row.price);
}
function lineNet(row: OrderLineItem) {
const gross = lineGross(row);
const pct = Math.min(50, Math.max(0, Number(row.discountPct) || 0));
return gross * (1 - pct / 100);
}
function lineSubtotal(lines: OrderLineItem[]) {
return lines.reduce((sum, row) => sum + lineNet(row), 0);
}
function hasBulkLine(lines: OrderLineItem[]) {
return lines.some((row) => Number(row.qty) > BULK_QTY_THRESHOLD);
}
function recalcOrderTotal(form: OrderForm) {
const values = form.values;
if (!values) return;
const subtotal = lineSubtotal(values.lineItems ?? []);
const freight = Number(values.freight) || 0;
form.setValuesIn("orderTotal", subtotal + freight);
}
function syncFreightPolicy(form: OrderForm) {
const values = form.values;
if (!values) return;
const waived =
lineSubtotal(values.lineItems ?? []) >= FREIGHT_WAIVER_SUBTOTAL;
const locked = waived || values.status === "shipped";
form.setFieldState("freight", (state) => {
state.readOnly = locked;
state.description = waived
? "Waived — line subtotal is $1,000+"
: values.status === "shipped"
? "Locked after shipment"
: "";
});
if (waived && Number(values.freight) !== 0) {
form.setValuesIn("freight", 0);
}
}
function syncHeaderReadOnly(form: OrderForm, shipped: boolean) {
const lockablePaths = [
"orderNo",
"orderDate",
"shipDate",
"poNumber",
"managerApproval",
"customerName",
"customerEmail",
"customerPhone",
"shipTo",
"notes",
] as const;
for (const path of lockablePaths) {
form.setFieldState(path, (state) => {
state.readOnly = shipped;
});
}
form.setFieldState("freight", (state) => {
if (shipped) {
state.readOnly = true;
state.description = "Locked after shipment";
}
});
if (!shipped) syncFreightPolicy(form);
}
function validateShipDate(
value: Date | null | undefined,
_rule: unknown,
ctx: { field?: Field },
) {
if (!value || !ctx.field) return "";
const orderDate = ctx.field.query("orderDate").value() as Date | null;
if (!orderDate) return "";
if (startOfDay(value) < startOfDay(orderDate)) {
return "Ship date cannot be before order date";
}
return "";
}
const orderEntryColumns = defineEditableColumns<OrderLineItem>({
sku: {
...f.text({ label: "SKU", sortKey: "sku" }),
size: 120,
field: { component: [Input], required: true },
},
name: {
...f.text({ label: "Name", sortKey: "name" }),
field: { component: [Input] },
},
qty: {
...f.number({
label: "Qty",
sortKey: "qty",
}),
size: 90,
field: {
component: [NumberInput, { surface: "tableCell" }],
required: true,
validator: (v) => (Number(v) > 0 ? undefined : "needs > 0"),
},
},
price: {
...f.currency({ label: "Price", currency: "USD", sortKey: "price", measure: "none" }),
size: 110,
field: {
component: [CurrencyInput, { currency: "USD", surface: "tableCell", measure: "none" }],
},
},
discountPct: {
...f.number({
label: "Disc %",
sortKey: "discountPct",
}),
size: 90,
field: {
component: [NumberInput, { surface: "tableCell", min: 0, max: 50 }],
validator: (v) => {
const n = Number(v);
if (Number.isNaN(n) || n < 0 || n > 50) return "0–50%";
return undefined;
},
},
},
discountReason: {
...f.text({
label: "Disc reason",
}),
size: 140,
field: {
// Placeholder is a neutral hint — deliberately worded differently from the
// validator message so an empty cell's gray placeholder is never mistaken
// for an (un-flagged) validation error. The red cell tint + "Required when
// discount > 0" message appear once the empty required field is blurred or
// the form is submitted.
component: [Input, { placeholder: "Reason for discount" }],
reactions: (field) => {
const lines = field.form.values.lineItems as
| OrderLineItem[]
| undefined;
const row = lines?.[field.index as number];
const discounted = Number(row?.discountPct) > 0;
field.display = discounted ? "visible" : "none";
if (isField(field)) {
field.required = discounted;
}
},
validator: (value, row) => {
const line = row as OrderLineItem | undefined;
if (Number(line?.discountPct) > 0 && !String(value ?? "").trim()) {
return "Required when discount > 0";
}
return undefined;
},
},
},
subtotal: {
...f.currency({
label: "Subtotal",
currency: "USD",
measure: "none",
accessor: (row) => lineNet(row),
}),
size: 120,
field: {
component: [Input],
readPretty: () => true,
reactions: (field) => {
const qty = Number(field.query(".qty").value()) || 0;
const price = Number(field.query(".price").value()) || 0;
const discountPct = Number(field.query(".discountPct").value()) || 0;
if (isField(field)) {
field.value = lineNet({ qty, price, discountPct } as OrderLineItem);
}
},
},
footer: (rows) =>
formatMoneyForDisplay(
rows.reduce((sum, row) => sum + lineNet(row as OrderLineItem), 0),
"USD",
undefined,
{ measure: "none" },
),
},
});
export function EditableTableOrderEntryDemo() {
const [isShipped, setIsShipped] = useState(false);
const orderForm = useMemo(
() =>
createForm<SalesOrderValues>({
initialValues: {
orderNo: "SO-2026-0042",
status: "draft",
orderDate: new Date(2026, 5, 17),
shipDate: null,
poNumber: "",
managerApproval: "",
customerName: "Northwind Traders",
customerEmail: "orders@northwind.example",
customerPhone: "+1 555-0100",
shipTo: "123 Harbor Way\nSeattle, WA 98101",
freight: INITIAL_FREIGHT,
notes: "",
orderTotal: lineSubtotal(INITIAL_LINES) + INITIAL_FREIGHT,
lineItems: INITIAL_LINES,
},
effects(form) {
form.setFieldState("orderTotal", (state) => {
state.pattern = "readPretty";
});
const syncShipDateRequired = (status: SalesOrderValues["status"]) => {
form.setFieldState("shipDate", (state) => {
state.required = status === "confirmed" || status === "shipped";
});
};
const syncStatusRules = (status: SalesOrderValues["status"]) => {
syncShipDateRequired(status);
const shipped = status === "shipped";
setIsShipped(shipped);
syncHeaderReadOnly(form, shipped);
};
syncStatusRules(form.values?.status ?? "draft");
onFieldValueChange("status", (field) => {
syncStatusRules(field.value as SalesOrderValues["status"]);
});
const onLinesOrPricingChange = () => {
syncFreightPolicy(form);
recalcOrderTotal(form);
};
recalcOrderTotal(form);
syncFreightPolicy(form);
onFieldValueChange("freight", () => recalcOrderTotal(form));
// Inline cells write directly to form.values — totals and freight update live.
onFieldValueChange("lineItems.*.qty", onLinesOrPricingChange);
onFieldValueChange("lineItems.*.price", onLinesOrPricingChange);
onFieldValueChange("lineItems.*.discountPct", onLinesOrPricingChange);
onFieldValueChange("orderDate", () => {
form.validate("shipDate").catch(() => undefined);
});
},
}),
[],
);
const [savedOrder, setSavedOrder] = useState<SalesOrderValues | null>(null);
const addFromCatalog = useCallback(() => {
const field = orderForm.query("lineItems").take();
if (!field || !isArrayField(field)) return;
for (const line of CATALOG_LINES) {
field.push({
id: crypto.randomUUID(),
...line,
discountPct: 0,
discountReason: "",
});
}
}, [orderForm]);
const rowActionsPolicy = useCallback(
(
_row: OrderLineItem,
_meta: { editable: boolean; hasErrors: boolean; isNew: boolean },
) => {
const count = orderForm.values?.lineItems?.length ?? 0;
const confirmed = orderForm.values?.status === "confirmed";
return {
remove:
confirmed && count <= 1
? { disabled: "Confirmed orders must keep at least one line" }
: undefined,
};
},
[orderForm],
);
return (
<Form
form={orderForm}
density="compact"
onSubmit={async (v) => {
setSavedOrder(v);
toast.success("Submitted successfully");
}}
>
<div className="grid grid-cols-1 gap-[var(--fui-form-field-gap)] xl:grid-cols-2">
<Card className="h-fit">
<CardHeader>
<CardTitle>Order</CardTitle>
<CardDescription>
Header fields, approvals, and derived total
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-[var(--fui-form-field-gap)] md:grid-cols-2">
<FormField
name="orderNo"
label="Order #"
required
kind="text"
componentProps={{ placeholder: "SO-0000" }}
/>
<FormField
name="status"
label="Status"
required
kind="select"
componentProps={{ options: ORDER_STATUS }}
description="Shipped locks the order for editing"
/>
<FormField
name="orderDate"
label="Order date"
required
kind="date"
/>
<FormField
name="shipDate"
label="Ship date"
kind="date"
description="Required when confirmed; must be on or after order date"
validator={{
triggerType: "onInput",
validator: validateShipDate,
}}
/>
<FormField
name="poNumber"
label="PO number"
kind="text"
componentProps={{ placeholder: "Customer PO" }}
reactions={(field) => {
const total = Number(field.query("orderTotal").value()) || 0;
const required = total >= PO_REQUIRED_THRESHOLD;
field.required = required;
field.description = required
? `Required for orders $${PO_REQUIRED_THRESHOLD}+`
: "Optional below $500";
}}
/>
<FormField
name="managerApproval"
label="Manager approval"
kind="text"
componentProps={{ placeholder: "MGMT-OK" }}
reactions={(field) => {
field.query("lineItems.*.qty").value();
field.query("lineItems.*.price").value();
field.query("lineItems.*.discountPct").value();
field.query("orderTotal").value();
const lines = (field.query("lineItems").value() ??
[]) as OrderLineItem[];
const total = Number(field.query("orderTotal").value()) || 0;
const needsApproval =
hasBulkLine(lines) || total >= MANAGER_APPROVAL_TOTAL;
field.visible = needsApproval;
field.required = needsApproval;
field.description = hasBulkLine(lines)
? `Required when any line qty > ${BULK_QTY_THRESHOLD}`
: needsApproval
? `Required when order total ≥ $${MANAGER_APPROVAL_TOTAL}`
: "";
}}
/>
<div className="md:col-span-2">
<FormField
name="orderTotal"
label="Order total"
kind="currency"
componentProps={{ currency: "USD" }}
description="Net of line discounts plus freight"
/>
</div>
</div>
</CardContent>
</Card>
<Card className="h-fit">
<CardHeader>
<CardTitle>Customer</CardTitle>
<CardDescription>Bill-to contact</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-[var(--fui-form-field-gap)]">
<FormField
name="customerName"
label="Company"
required
kind="text"
componentProps={{ placeholder: "Acme Corp" }}
/>
<FormField
name="customerEmail"
label="Email"
required
kind="email"
componentProps={{ placeholder: "billing@acme.example" }}
/>
<FormField
name="customerPhone"
label="Phone"
kind="phone"
componentProps={{ placeholder: "+1 555-0100" }}
/>
</div>
</CardContent>
</Card>
<Card className="xl:col-span-2">
<CardHeader>
<CardTitle>Line items</CardTitle>
<CardDescription>
Set Disc % on a row to reveal Disc reason; try qty > 25 to
surface manager approval
</CardDescription>
</CardHeader>
<CardContent className="space-y-0">
<EditableTable<OrderLineItem>
name="lineItems"
variant="embedded"
editMode="inline"
tableCode="demo-order-entry-lines"
columns={orderEntryColumns}
getRowId={(r) => r.id}
readOnly={isShipped}
rowActionsPolicy={rowActionsPolicy}
recordCreator={{
record: () => ({
id: crypto.randomUUID(),
qty: 1,
price: 0,
discountPct: 0,
discountReason: "",
}),
}}
toolbar={{
trailing: (
<Button
type="button"
variant="outline"
onClick={addFromCatalog}
disabled={isShipped}
>
Add from catalog
</Button>
),
}}
features={{
index: { mode: "view" },
}}
/>
</CardContent>
</Card>
<Card className="h-fit">
<CardHeader>
<CardTitle>Shipping</CardTitle>
<CardDescription>Ship-to address and freight</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-[var(--fui-form-field-gap)]">
<FormField
name="shipTo"
label="Ship to"
required
kind="textarea"
componentProps={{
rows: 3,
placeholder: "Street, city, postal code",
}}
/>
<FormField
name="freight"
label="Freight"
kind="currency"
componentProps={{ currency: "USD" }}
/>
</div>
</CardContent>
</Card>
<Card className="h-fit">
<CardHeader>
<CardTitle>Notes</CardTitle>
<CardDescription>Internal memo on the order</CardDescription>
</CardHeader>
<CardContent>
<FormField
name="notes"
label="Notes"
kind="textarea"
componentProps={{
rows: 4,
placeholder: "Optional instructions for fulfillment",
}}
/>
</CardContent>
</Card>
</div>
<FormActions>
<Button type="submit">
Save order
</Button>
</FormActions>
{savedOrder ? (
<pre className="bg-muted mt-3 overflow-auto rounded-md p-3 text-xs">
<code>{JSON.stringify(savedOrder, null, 2)}</code>
</pre>
) : null}
</Form>
);
}Specialized — Pick When You Need It
Remote Persistence
Problem: Each row must hit the server before it commits or leaves the array.
This demo proves: editable.onSave / onDelete. Return a row to swap the client id. Reject to keep saveError. Duplicate is CREATE (meta.isNew).
Try it:
- Edit → Save (~800ms) → row commits.
- Duplicate a row — clone sits under the source. Save — Id becomes
srv-…. - Arm next Save failure, then Save — row stays editing until retry succeeds.
"use client";
import { useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { f } from "@/components/f-ui/field-types/catalog";
type RemoteLine = { id: string; sku: string; qty: number };
const columns = defineEditableColumns<RemoteLine>({
id: {
...f.text({ label: "Id" }),
size: 140,
},
sku: {
...f.text({ label: "SKU" }),
field: { component: [Input], required: true },
},
qty: {
...f.number({
label: "Qty",
}),
size: 90,
field: {
component: [NumberInput, { surface: "tableCell" }],
required: true,
validator: (v) => (Number(v) > 0 ? undefined : "needs > 0"),
},
},
});
const delay = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
export function EditableTableRemotePersistenceDemo() {
const [simulateFailure, setSimulateFailure] = useState(false);
const failNextSave = useRef(false);
const failNextDelete = useRef(false);
const form = useMemo(
() =>
createForm<{ lines: RemoteLine[] }>({
initialValues: {
lines: [
{ id: "r1", sku: "SKU-100", qty: 2 },
{ id: "r2", sku: "SKU-200", qty: 5 },
],
},
}),
[],
);
const onSave = useCallback(
async (
_rowId: string,
row: RemoteLine,
_origin: RemoteLine | undefined,
meta: { isNew: boolean },
) => {
await delay(800);
if (failNextSave.current) {
failNextSave.current = false;
throw new Error(
meta.isNew ? "Server rejected new line" : "Save failed — retry",
);
}
if (meta.isNew) {
const saved = { ...row, id: `srv-${crypto.randomUUID().slice(0, 8)}` };
toast.success(`Line created (${saved.id})`);
return saved;
}
toast.success("Line saved on server");
},
[],
);
const onDelete = useCallback(async () => {
await delay(600);
if (failNextDelete.current) {
failNextDelete.current = false;
throw new Error("Delete failed — retry");
}
toast.success("Line deleted on server");
}, []);
const armSaveFailure = useCallback(() => {
failNextSave.current = true;
toast.message("Next Save will fail");
}, []);
const armDeleteFailure = useCallback(() => {
failNextDelete.current = true;
toast.message("Next Delete will fail");
}, []);
return (
<>
<div className="mb-3 flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Switch
id="simulate-failure"
checked={simulateFailure}
onCheckedChange={setSimulateFailure}
/>
<Label htmlFor="simulate-failure">Simulate server errors</Label>
</div>
{simulateFailure ? (
<>
<Button
type="button"
variant="outline"
onClick={armSaveFailure}
>
Arm next Save failure
</Button>
<Button
type="button"
variant="outline"
onClick={armDeleteFailure}
>
Arm next Delete failure
</Button>
</>
) : null}
</div>
<Form form={form}>
<p className="text-muted-foreground mb-3 text-xs leading-relaxed">
Rows persist on Save / Delete. Duplicate, then Save — the Id column shows the
server id. Parent submit is not the persist path.
</p>
<EditableTable<RemoteLine>
name="lines"
tableCode="demo-remote-lines"
columns={columns}
getRowId={(r) => r.id}
recordCreator={{
record: () => ({ id: crypto.randomUUID(), sku: "", qty: 1 }),
}}
editable={{ onSave, onDelete }}
features={{ sorting: true }}
/>
</Form>
</>
);
}Row Edit Gates
Problem: Approved rows must not be editable; pending rows still can be.
This demo proves: rowEditable locks whole rows — no Edit, muted read cells.
Try it:
- Approved row — no Edit button (whole row locked via
rowEditable). - Pending row — click Edit.
- Rejected row — Remove is hidden (never-enable; do not gray it).
"use client";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { Input } from "@/components/f-ui/formily/connects/input";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { approvalColumns, type ApprovalLine } from "./editable-table-demo-shared";
type ApprovalValues = { batchId: string; lines: ApprovalLine[] };
export function EditableTableApprovalDemo() {
const approvalForm = useMemo(
() =>
createForm<ApprovalValues>({
initialValues: {
batchId: "AP-2024-17",
lines: [
{ id: "ok", item: "Steel bracket", amount: 420, status: "approved", note: "Passed QA" },
{ id: "wait", item: "Rubber gasket", amount: 85, status: "pending", note: "" },
{ id: "bad", item: "", amount: 40, status: "rejected", note: "Missing spec sheet" },
{ id: "hold", item: "Copper bushing", amount: 120, status: "pending", note: "Awaiting quote" },
],
},
}),
[],
);
const [savedApproval, setSavedApproval] = useState<ApprovalValues | null>(null);
return (
<Form
form={approvalForm}
onSubmit={async (v) => {
setSavedApproval(v);
toast.success("Submitted successfully");
}}
>
<FormField name="batchId" label="Batch #" component={[Input]} readPretty />
<EditableTable<ApprovalLine>
name="lines"
tableCode="demo-approval-lines"
columns={approvalColumns}
getRowId={(r) => r.id}
rowEditable={(r) => r.status !== "approved"}
// Approved rows hide Remove via `rowEditable` (no actions on locked rows).
// Rejected lines cannot be removed — hide, do not gray (never-enable).
rowActionsPolicy={(row) =>
row.status === "rejected" ? { remove: { hidden: true } } : undefined
}
recordCreator={{
record: () => ({
id: crypto.randomUUID(),
status: "pending",
amount: 0,
note: "",
}),
}}
features={{ sorting: true }}
/>
<FormActions>
<Button type="submit">
Submit batch
</Button>
</FormActions>
{savedApproval ? (
<pre className="bg-muted mt-3 rounded-md p-3 text-xs">
<code>{JSON.stringify(savedApproval, null, 2)}</code>
</pre>
) : null}
</Form>
);
}Mixed Column Gates
Problem: One column locks on an approved row while siblings stay editable.
This demo proves: field.readOnly per column; f.enum({ render: "status" }) without field stays display-only.
Try it:
- Approved row — Amount locked, Note editable.
- Pending row — edit any cell.
- Submit with a rejected row and empty Note — the rollup owns the required message; Edit that row to see the value-state ring. Do not fake this with
cellClassName.
"use client";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { mixedGateColumns, type ApprovalLine } from "./editable-table-demo-shared";
type Values = { lines: ApprovalLine[] };
export function EditableTableMixedGatesDemo() {
const form = useMemo(
() =>
createForm<Values>({
initialValues: {
lines: [
{ id: "a", item: "Steel bracket", amount: 420, status: "approved", note: "Passed QA" },
{ id: "b", item: "Rubber gasket", amount: 85, status: "pending", note: "" },
{ id: "c", item: "Copper bushing", amount: 120, status: "rejected", note: "" },
],
},
}),
[],
);
const [saved, setSaved] = useState<Values | null>(null);
return (
<Form
form={form}
onSubmit={async (v) => {
setSaved(v);
toast.success("Submitted successfully");
}}
>
<EditableTable<ApprovalLine>
name="lines"
columns={mixedGateColumns}
getRowId={(r) => r.id}
features={{ sorting: true }}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
{saved ? (
<pre className="bg-muted mt-3 rounded-md p-3 text-xs">
<code>{JSON.stringify(saved, null, 2)}</code>
</pre>
) : null}
</Form>
);
}Mixed Pattern
Problem: Qty is fillable but locked this session; Subtotal is computed knowledge. Those must not look the same.
This demo proves: field.readOnly keeps a Qty control (blank when empty); field.readPretty keeps Subtotal as display (em dash when empty). SKU and Price stay editable. Same pair as Form (Formily) — Mixed Pattern.
Try it:
- Line 1 — Qty is a readonly number control; Subtotal is formatted text.
- Line 2 — empty Qty stays blank; empty Subtotal is —.
- Change Price on either row — Subtotal updates; Qty still cannot be typed.
"use client";
import { isField } from "@formily/core";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { formatMoneyForDisplay } from "@/components/f-ui/currency-format/currency-format";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { CurrencyInput } from "@/components/f-ui/formily/connects/currency-input";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { f } from "@/components/f-ui/field-types/catalog";
import { Button } from "@/components/ui/button";
type Line = {
id: string;
sku: string;
qty: number | null;
price: number;
subtotal: number | null;
};
type Values = { lines: Line[] };
function lineNet(qty: number | null, price: number) {
if (qty == null) return null;
return qty * price;
}
const columns = defineEditableColumns<Line>({
sku: {
...f.text({ label: "SKU" }),
size: 120,
field: { component: [Input], required: true },
},
qty: {
...f.number({ label: "Qty" }),
size: 96,
field: {
component: [NumberInput, { surface: "tableCell", min: 0, step: 1 }],
readOnly: () => true,
},
},
price: {
...f.currency({ label: "Price", currency: "USD", measure: "none" }),
size: 110,
field: {
component: [CurrencyInput, { currency: "USD", surface: "tableCell", measure: "none" }],
},
},
subtotal: {
...f.currency({
label: "Subtotal",
currency: "USD",
measure: "none",
accessor: (row) => lineNet(row.qty, row.price),
}),
size: 120,
field: {
component: [
CurrencyInput,
{ currency: "USD", surface: "tableCell", measure: "none" },
],
readPretty: () => true,
reactions: (field) => {
if (!isField(field)) return;
const qty = field.query(".qty").value() as number | null;
const price = Number(field.query(".price").value()) || 0;
field.value = lineNet(qty, price);
},
},
footer: (rows) =>
formatMoneyForDisplay(
rows.reduce((sum, row) => sum + (lineNet(row.qty, row.price) ?? 0), 0),
"USD",
undefined,
{ measure: "none" },
),
},
});
export function EditableTableMixedPatternDemo() {
const form = useMemo(
() =>
createForm<Values>({
initialValues: {
lines: [
{
id: "l1",
sku: "SKU-104",
qty: 2,
price: 19.9,
subtotal: 39.8,
},
{
id: "l2",
sku: "SKU-200",
qty: null,
price: 8.5,
subtotal: null,
},
],
},
}),
[],
);
const [saved, setSaved] = useState<Values | null>(null);
return (
<Form
form={form}
onSubmit={async (values) => {
setSaved(values);
toast.success("Submitted successfully");
}}
>
<EditableTable<Line>
name="lines"
columns={columns}
getRowId={(row) => row.id}
editMode="inline"
features={{ sorting: true, footer: true }}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
{saved ? (
<pre className="bg-muted mt-3 rounded-md p-3 text-xs">
<code>{JSON.stringify(saved, null, 2)}</code>
</pre>
) : null}
</Form>
);
}Virtual Scroll
Problem: 50+ rows must scroll smoothly without mounting every <tr>.
This demo proves: features.view.virtual window the DOM; footer and search still see the full filtered set.
Try it:
- Scroll — only visible rows mount.
- Search or sort — footer still sums all matching rows.
- Use offset paging instead when users expect page numbers.
"use client";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { Input } from "@/components/f-ui/formily/connects/input";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { lineColumns, ORDER_SEED, type LineItem } from "./editable-table-demo-shared";
const VIRTUAL_SCROLL_SEED: LineItem[] = Array.from({ length: 55 }, (_, i) => {
const base = ORDER_SEED[i % ORDER_SEED.length]!;
return {
id: `vs-${i + 1}`,
sku: `SKU-${String(i + 1).padStart(3, "0")}`,
name: base.name,
qty: base.qty + (i % 5),
price: base.price,
};
});
type OrderValues = { orderNo: string; lineItems: LineItem[] };
export function EditableTableVirtualScrollDemo() {
const orderForm = useMemo(
() =>
createForm<OrderValues>({
initialValues: { orderNo: "SO-9001", lineItems: VIRTUAL_SCROLL_SEED },
}),
[],
);
const [savedOrder, setSavedOrder] = useState<OrderValues | null>(null);
return (
<Form
form={orderForm}
onSubmit={async (v) => {
setSavedOrder(v);
toast.success("Submitted successfully");
}}
>
<FormField name="orderNo" label="Order #" component={[Input]} />
<EditableTable<LineItem>
name="lineItems"
tableCode="demo-order-lines-virtual"
columns={lineColumns}
getRowId={(r) => r.id}
recordCreator={{
record: () => ({ id: crypto.randomUUID(), qty: 1, price: 0 }),
}}
features={{
index: { mode: "view" },
view: { mode: "virtual", maxHeight: "min(360px, 50vh)" },
sorting: true,
search: true,
footer: true,
}}
/>
<FormActions>
<Button type="submit">
Save order
</Button>
</FormActions>
{savedOrder ? (
<pre className="bg-muted mt-3 rounded-md p-3 text-xs">
<code>{JSON.stringify(savedOrder, null, 2)}</code>
</pre>
) : null}
</Form>
);
}Large Dataset (1k–2k Rows)
Problem: ~500–2000 already-persisted rows must stay interactive; occasional edit and Duplicate then Save.
This demo proves: editMode="row" + onSave / onDelete + features.view.virtual (or offset). Persist unit is one row. Virtualization is DOM-only. Duplicate default "after".
For app-viewport mount / wide-matrix QA (not this persist recipe), use the showcase: Large Lines (Submit pick list is a Formily stress harness).
Try it:
- Leave 2000 + virtual — scroll, search a SKU, sort Qty.
- Edit a row → Save — toast for that row, not the whole array.
- Duplicate — clone lands under the source; Save — new
srv-*id stays in that slot. - Compare offset pages (20) vs virtual scroll.
- Select rows — footer batch bar Delete selected.
Seeded 2,000 rows in 120 ms · virtual
Persist unit is one row (Save / Delete / Duplicate then Save). Virtualization only cuts DOM — it does not submit the array. Duplicate inserts under the source row.
"use client";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { lineColumns, ORDER_SEED, type LineItem } from "./editable-table-demo-shared";
type RowCount = 500 | 1000 | 2000;
type ViewMode = "virtual" | "offset";
type OrderValues = { lineItems: LineItem[] };
const ROW_COUNTS: RowCount[] = [500, 1000, 2000];
const delay = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
let serverSeq = 0;
function buildLines(count: number): LineItem[] {
return Array.from({ length: count }, (_, i) => {
const base = ORDER_SEED[i % ORDER_SEED.length]!;
return {
id: `lg-${i + 1}`,
sku: `SKU-${String(i + 1).padStart(4, "0")}`,
name: `${base.name} #${i + 1}`,
qty: (i % 20) + 1,
price: base.price,
};
});
}
function createOrderForm(count: RowCount) {
const started = performance.now();
const form = createForm<OrderValues>({
initialValues: { lineItems: buildLines(count) },
});
return { form, seedMs: Math.round(performance.now() - started) };
}
/**
* Persisted-grid recipe at 500–2000 rows — virtual (or offset) view + row
* Save / Delete. Remounts the form when size or view changes so timings stay
* comparable.
*/
export function EditableTableLargeDatasetDemo() {
const [rowCount, setRowCount] = useState<RowCount>(2000);
const [viewMode, setViewMode] = useState<ViewMode>("virtual");
const [session, setSession] = useState(0);
const { form, seedMs } = useMemo(() => createOrderForm(rowCount), [rowCount, session]);
const remount = (patch?: {
rowCount?: RowCount;
viewMode?: ViewMode;
}) => {
if (patch?.rowCount != null) setRowCount(patch.rowCount);
if (patch?.viewMode != null) setViewMode(patch.viewMode);
setSession((n) => n + 1);
};
const onSave = useCallback(
async (_rowId: string, row: LineItem, _origin: LineItem | undefined, meta: { isNew: boolean }) => {
await delay(200);
if (meta.isNew) {
const saved = { ...row, id: `srv-${++serverSeq}` };
toast.success(`Created ${saved.id}`);
return saved;
}
toast.success(`Saved ${row.id}`);
},
[],
);
const onDelete = useCallback(async (_rowId: string, row: LineItem) => {
await delay(150);
toast.success(`Deleted ${row.id}`);
}, []);
const viewFeature =
viewMode === "virtual"
? ({ mode: "virtual", maxHeight: "min(560px, 70vh)" } as const)
: ({ mode: "offset", pageSize: 20 } as const);
return (
<div className="flex flex-col gap-3">
<div className="bg-muted/40 flex flex-wrap items-end gap-3 rounded-lg border p-3">
<div className="flex min-w-36 flex-col gap-1.5">
<Label htmlFor="fat-large-rows">Rows</Label>
<Select
value={String(rowCount)}
onValueChange={(v) => remount({ rowCount: Number(v) as RowCount })}
>
<SelectTrigger id="fat-large-rows" size="sm" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ROW_COUNTS.map((n) => (
<SelectItem key={n} value={String(n)}>
{n.toLocaleString()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex min-w-36 flex-col gap-1.5">
<Label htmlFor="fat-large-view">View</Label>
<Select
value={viewMode}
onValueChange={(v) => remount({ viewMode: v as ViewMode })}
>
<SelectTrigger id="fat-large-view" size="sm" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="virtual">Virtual scroll</SelectItem>
<SelectItem value="offset">Offset pages (20)</SelectItem>
</SelectContent>
</Select>
</div>
<Button
type="button"
variant="outline"
onClick={() => remount()}
>
Remount
</Button>
<p className="text-muted-foreground w-full text-xs tabular-nums sm:ml-auto sm:w-auto">
Seeded {rowCount.toLocaleString()} rows in {seedMs} ms · {viewMode}
</p>
</div>
<p className="text-muted-foreground text-xs leading-relaxed">
Persist unit is one row (Save / Delete / Duplicate then Save). Virtualization
only cuts DOM — it does not submit the array. Duplicate inserts under the
source row.
</p>
<Form
key={`${session}-${rowCount}-${viewMode}`}
form={form}
>
<EditableTable<LineItem>
name="lineItems"
tableCode={`demo-large-lines-${viewMode}`}
columns={lineColumns}
editMode="row"
getRowId={(r) => r.id}
recordCreator={{
record: () => ({
id: crypto.randomUUID(),
sku: "",
name: "",
qty: 1,
price: 0,
}),
}}
editable={{ onSave, onDelete }}
features={{
index: { mode: "view" },
sorting: true,
search: true,
footer: true,
selection: { batchDelete: true },
view: viewFeature,
}}
/>
</Form>
</div>
);
}Read-Only Review
Problem: Users may browse and sort but must not edit.
This demo proves: readOnly — sort and footer work; no add or row actions.
Try it:
- Confirm no Edit or Add controls.
- Click a column header — sort works.
- Check footer subtotal.
"use client";
import { useMemo } from "react";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { ORDER_SEED, lineColumns, type LineItem } from "./editable-table-demo-shared";
export function EditableTableReadonlyDemo() {
const form = useMemo(
() =>
createForm<{ lineItems: LineItem[] }>({
initialValues: { lineItems: ORDER_SEED.slice(0, 4) },
}),
[],
);
return (
<Form form={form} onSubmit={async () => undefined}>
<EditableTable<LineItem>
name="lineItems"
columns={lineColumns}
getRowId={(r) => r.id}
readOnly
features={{ sorting: true, footer: true }}
/>
</Form>
);
}Pattern Boundary
Problem: Operators mix fillable, locked, computed, and display columns in one grid.
This demo proves: the decision test — Qty is editable, Price is readOnly until a customer is chosen, Subtotal is readPretty, SKU has no field. Pretty empty is —, locked Price stays blank.
"use client";
import { isField } from "@formily/core";
import { useMemo } from "react";
import { formatMoneyForDisplay } from "@/components/f-ui/currency-format/currency-format";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { CurrencyInput } from "@/components/f-ui/formily/connects/currency-input";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { Form } from "@/components/f-ui/formily/form";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { f } from "@/components/f-ui/field-types/catalog";
type Line = {
id: string;
sku: string;
qty: number | null;
price: number | null;
subtotal: number | null;
};
function net(qty: number | null, price: number | null) {
if (qty == null || price == null) return null;
return qty * price;
}
export function EditableTablePatternBoundaryDemo() {
const form = useMemo(
() =>
createForm<{ customer: string; lines: Line[] }>({
initialValues: {
customer: "",
lines: [
{ id: "a", sku: "SKU-1", qty: 2, price: null, subtotal: null },
{ id: "b", sku: "", qty: null, price: 12, subtotal: null },
],
},
}),
[],
);
const columns = useMemo(
() =>
defineEditableColumns<Line>({
sku: {
...f.text({ label: "SKU" }),
size: 110,
},
qty: {
...f.number({ label: "Qty" }),
size: 88,
field: {
component: [NumberInput, { surface: "tableCell", min: 0, step: 1 }],
required: true,
},
},
price: {
...f.currency({ label: "Price", currency: "USD", measure: "none" }),
size: 110,
field: {
component: [CurrencyInput, { currency: "USD", surface: "tableCell", measure: "none" }],
readOnly: () => !String(form.values.customer ?? ""),
lockReason: () =>
String(form.values.customer ?? "")
? undefined
: "Choose a customer before editing price",
},
},
subtotal: {
...f.currency({
label: "Subtotal",
currency: "USD",
measure: "none",
accessor: (row) => net(row.qty, row.price),
}),
size: 120,
field: {
component: [
CurrencyInput,
{ currency: "USD", surface: "tableCell", measure: "none" },
],
readPretty: () => true,
reactions: (field) => {
if (!isField(field)) return;
const qty = field.query(".qty").value() as number | null;
const price = field.query(".price").value() as number | null;
field.value = net(qty, price);
},
},
footer: (rows) =>
formatMoneyForDisplay(
rows.reduce((sum, row) => sum + (net(row.qty, row.price) ?? 0), 0),
"USD",
undefined,
{ measure: "none" },
),
},
}),
[form],
);
return (
<Form form={form} onSubmit={async () => undefined}>
<p className="text-muted-foreground mb-3 text-sm">
Decision test: can this role change it now → <strong>editable</strong>.
Locked this session → <strong>readOnly</strong>. Never a control here →{" "}
<strong>readPretty</strong> or omit <code>field</code>. Empty pretty
cells show —, locked controls stay blank.
</p>
<FormField
name="customer"
kind="text"
label="Customer"
componentProps={{ placeholder: "Type a name to unlock Price" }}
/>
<EditableTable<Line>
name="lines"
columns={columns}
getRowId={(r) => r.id}
editMode="inline"
variant="embedded"
/>
</Form>
);
}Display Vs Edit
Problem: Display mode must not grow input-shaped error rings, and must not paint a wall of ghost Number Inputs.
This demo proves: Display is pretty text (field.readPretty); table readOnly only hides add/actions. Unlock remounts controls inside one field box. Qty does not grow or show steppers.
"use client";
import { useMemo, useState } from "react";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { applyEditableArrayIssues } from "@/components/f-ui/editable-table/lib/editable-array-issues";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { Form } from "@/components/f-ui/formily/form";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { f } from "@/components/f-ui/field-types/catalog";
import { Button } from "@/components/ui/button";
type Line = { id: string; sku: string; qty: number };
export function EditableTableDisplayVsEditDemo() {
const form = useMemo(
() =>
createForm<{ lines: Line[] }>({
initialValues: {
lines: [
{ id: "ok", sku: "SKU-1", qty: 2 },
{ id: "bad", sku: "", qty: 0 },
],
},
}),
[],
);
const [mode, setMode] = useState<"display" | "edit">("display");
const columns = useMemo(
() =>
defineEditableColumns<Line>({
sku: {
...f.text({ label: "SKU" }),
size: 140,
grow: true,
field: {
component: [Input],
required: true,
readPretty: () => mode === "display",
},
},
qty: {
...f.number({ label: "Qty" }),
size: 96,
field: {
component: [NumberInput, { surface: "tableCell", min: 0 }],
required: true,
readPretty: () => mode === "display",
},
},
}),
[mode],
);
return (
<Form form={form} onSubmit={async () => undefined}>
<div className="mb-3 flex items-center gap-2">
<Button
type="button"
variant={mode === "display" ? "default" : "outline"}
onClick={() => {
setMode("display");
form.setPattern("readPretty");
}}
>
Display
</Button>
<Button
type="button"
variant={mode === "edit" ? "default" : "outline"}
onClick={() => {
setMode("edit");
form.setPattern("editable");
applyEditableArrayIssues(form, {
arrayPath: "lines",
issues: [
{ rowId: "bad", colKey: "sku", message: "Required", source: "server" },
],
});
}}
>
Unlock
</Button>
</div>
<EditableTable<Line>
name="lines"
columns={columns}
getRowId={(r) => r.id}
editMode="inline"
variant="embedded"
readOnly={mode === "display"}
/>
</Form>
);
}Severity Routing
Problem: Host issues on locked cells used to live only on the cell.
This demo proves: an editable cell paints + focus message; a locked cell is counted in the rollup only.
"use client";
import { useEffect, useMemo } from "react";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { applyEditableArrayIssues } from "@/components/f-ui/editable-table/lib/editable-array-issues";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { Form } from "@/components/f-ui/formily/form";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { f } from "@/components/f-ui/field-types/catalog";
type Line = { id: string; sku: string; qty: number };
const columns = defineEditableColumns<Line>({
sku: {
...f.text({ label: "SKU" }),
size: 140,
field: { component: [Input], required: true },
},
qty: {
...f.number({ label: "Qty" }),
size: 96,
field: {
component: [NumberInput, { surface: "tableCell", min: 0 }],
readOnly: (row) => (row as Line).id === "locked",
lockReason: (row) =>
(row as Line).id === "locked" ? "Pending warehouse count" : undefined,
},
},
});
export function EditableTableSeverityRoutingDemo() {
const form = useMemo(
() =>
createForm<{ lines: Line[] }>({
initialValues: {
lines: [
{ id: "edit", sku: "SKU-1", qty: 2 },
{ id: "locked", sku: "SKU-2", qty: 40 },
],
},
}),
[],
);
useEffect(() => {
applyEditableArrayIssues(form, {
arrayPath: "lines",
issues: [
{
rowId: "edit",
colKey: "sku",
message: "Unknown SKU in catalog",
source: "server",
severity: "error",
},
{
rowId: "locked",
colKey: "qty",
message: "Count exceeds remaining allocation",
source: "server",
severity: "warning",
},
],
});
}, [form]);
return (
<Form form={form} onSubmit={async () => undefined}>
<p className="text-muted-foreground mb-3 text-sm">
Same payload, two destinations: the editable SKU paints a value-state
ring (message on focus). The locked Qty is counted in the rollup only —
no ring.
</p>
<EditableTable<Line>
name="lines"
columns={columns}
getRowId={(r) => r.id}
editMode="inline"
variant="embedded"
/>
</Form>
);
}Extra Copy On Display Cells
Problem: Browse cells need a host suffix (Bulk, SLA, source marker) without replacing kind, and without wrapping the NumberInput while the row is editing.
This demo proves: renderCell wraps formatted Qty on display. Edit that row — the control has no Bulk suffix. Save — the suffix returns. Edit stays field.component.
Try it:
- Row BRK-04 Qty shows 12 Bulk.
- Edit that row — type in Qty; there is no Bulk label on the input.
- Save — Bulk is back on the formatted number.
"use client";
import { useMemo } from "react";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { f } from "@/components/f-ui/field-types/catalog";
type Line = { id: string; sku: string; qty: number };
const columns = defineEditableColumns<Line>({
sku: {
...f.text({ label: "SKU" }),
size: 140,
grow: true,
field: true,
},
qty: {
...f.number({ label: "Qty" }),
size: 140,
field: true,
renderCell: (formatted, row) =>
row.qty >= 10 ? (
<span className="inline-flex items-center gap-1">
{formatted}
<span className="text-muted-foreground text-xs">Bulk</span>
</span>
) : (
formatted
),
},
});
export function EditableTableCellWrapDemo() {
const form = useMemo(
() =>
createForm<{ lines: Line[] }>({
initialValues: {
lines: [
{ id: "1", sku: "BRK-04", qty: 12 },
{ id: "2", sku: "GSK-11", qty: 4 },
{ id: "3", sku: "BSH-02", qty: 1 },
],
},
}),
[],
);
return (
<Form form={form} onSubmit={async () => undefined}>
<EditableTable<Line>
name="lines"
columns={columns}
getRowId={(r) => r.id}
variant="embedded"
/>
</Form>
);
}Cell Wash Without A Ring
Problem: A price over a host threshold should wash the cell, not grow a fake error ring.
This demo proves: cellClassName paints GSK-11 ($180). Rows under $100 return undefined and stay unpainted. Do not put kit ring classes on this seam.
Try it:
- GSK-11 Price has a warning wash; the other prices do not.
- Edit GSK-11 — the wash is cell paint, not a value-state ring. Focus does not open an error popover for the wash.
- Change Price below $100 and Save — wash clears.
"use client";
import { useMemo } from "react";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { f } from "@/components/f-ui/field-types/catalog";
type Line = { id: string; sku: string; price: number };
const columns = defineEditableColumns<Line>({
sku: {
...f.text({ label: "SKU" }),
size: 140,
grow: true,
field: true,
},
price: {
...f.currency({ label: "Price", currency: "USD", measure: "none" }),
size: 140,
field: true,
cellClassName: (row) => (row.price > 100 ? "bg-warning/10" : undefined),
},
});
export function EditableTableCellWashDemo() {
const form = useMemo(
() =>
createForm<{ lines: Line[] }>({
initialValues: {
lines: [
{ id: "1", sku: "BRK-04", price: 42 },
{ id: "2", sku: "GSK-11", price: 180 },
{ id: "3", sku: "BSH-02", price: 18 },
],
},
}),
[],
);
return (
<Form form={form} onSubmit={async () => undefined}>
<EditableTable<Line>
name="lines"
columns={columns}
getRowId={(r) => r.id}
variant="embedded"
/>
</Form>
);
}Change Review
Problem: An approver must see which cells changed, at a glance.
This demo proves: one composed job — not a separate API. kind formats Qty / Price, renderCell wraps with CellCriticalityValue (host-owned reason), getRowHighlight paints the row bar, the rollup lists jump targets. See the single-layer proofs above first.
Try it:
- Qty on Steel bracket is warning +
8 → 12reason; Price on Gasket is error + threshold copy. - Click a rollup item — jump to that cell.
- Compare with Extra Copy and Cell Wash — those are the same layers, one job each.
"use client";
import { useEffect, useMemo } from "react";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { applyEditableArrayIssues } from "@/components/f-ui/editable-table/lib/editable-array-issues";
import { Form } from "@/components/f-ui/formily/form";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { f } from "@/components/f-ui/field-types/catalog";
import { CellCriticalityValue } from "@/components/f-ui/table/lib/cell-criticality";
import type { RowHighlight } from "@/components/f-ui/table/lib/row-highlight";
type Line = {
id: string;
item: string;
qty: number;
priorQty: number;
price: number;
source: string;
};
const columns = defineEditableColumns<Line>({
item: {
...f.text({ label: "Item" }),
size: 140,
},
qty: {
...f.number({ label: "Qty" }),
size: 120,
renderCell: (formatted, row) => {
const changed = row.qty !== row.priorQty;
return (
<CellCriticalityValue
criticality={changed ? "warning" : "info"}
reason={
changed ? (
<span>
{row.priorQty}
{" → "}
{row.qty}
</span>
) : undefined
}
>
{formatted}
</CellCriticalityValue>
);
},
},
price: {
...f.currency({ label: "Price", currency: "USD", measure: "none" }),
size: 140,
renderCell: (formatted, row) => {
const over = row.price > 100;
return (
<CellCriticalityValue
criticality={over ? "error" : "success"}
reason={over ? "Over $100 threshold" : undefined}
>
{formatted}
</CellCriticalityValue>
);
},
},
source: {
...f.text({ label: "Source" }),
size: 160,
renderCell: (formatted, row) => (
<CellCriticalityValue
criticality={row.source === "estimated" ? "info" : "success"}
>
{formatted}
</CellCriticalityValue>
),
},
});
export function EditableTableChangeReviewDemo() {
const form = useMemo(
() =>
createForm<{ lines: Line[] }>({
initialValues: {
lines: [
{
id: "a",
item: "Steel bracket",
qty: 12,
priorQty: 8,
price: 42,
source: "po",
},
{
id: "b",
item: "Gasket",
qty: 4,
priorQty: 4,
price: 180,
source: "estimated",
},
{
id: "c",
item: "Bushing",
qty: 1,
priorQty: 1,
price: 18,
source: "po",
},
],
},
}),
[],
);
useEffect(() => {
applyEditableArrayIssues(form, {
arrayPath: "lines",
issues: [
{
rowId: "a",
colKey: "qty",
message: "Applicant changed quantity",
source: "system",
severity: "warning",
},
{
rowId: "b",
colKey: "price",
message: "Over $100 threshold",
source: "system",
severity: "error",
},
],
});
}, [form]);
return (
<Form form={form} onSubmit={async () => undefined}>
<p className="text-muted-foreground mb-3 text-sm">
One composed example of cell customization: <code>renderCell</code> wraps
formatted values, <code>getRowHighlight</code> paints the row, rollup jumps.
Reason copy is host-owned.
</p>
<EditableTable<Line>
name="lines"
columns={columns}
getRowId={(r) => r.id}
readOnly
variant="embedded"
getRowHighlight={(row): RowHighlight => {
if (row.price > 100) return "error";
if (row.qty !== row.priorQty) return "warning";
return "none";
}}
/>
</Form>
);
}Lock Reason
Problem: A locked cell must explain why, without looking invalid.
This demo proves: muted lock mark + reason on focus — never destructive tokens. Placement, field vs surface scope, and hard bans: Field Lock Affordance.
"use client";
import { useMemo } from "react";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { Select } from "@/components/f-ui/formily/connects/select";
import { Form } from "@/components/f-ui/formily/form";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { f } from "@/components/f-ui/field-types/catalog";
type Line = {
id: string;
sku: string;
shipVia: string;
qty: number;
lock: "permission" | "posted" | "dependency";
};
// Field-scoped reasons only. "Another user is editing this row" is a row lock
// (spec L14) and belongs to row chrome, not to a field mark.
const REASON: Record<Line["lock"], string> = {
permission: "You do not have permission to edit this line",
posted: "Locked once the line is posted to the ledger",
dependency: "Unlocks after the customer is confirmed",
};
const SHIP_VIA = [
{ label: "Air", value: "air" },
{ label: "Ground", value: "ground" },
];
const columns = defineEditableColumns<Line>({
sku: {
...f.text({ label: "SKU" }),
size: 140,
field: {
component: [Input],
readOnly: () => true,
lockReason: (row) => REASON[(row as Line).lock],
},
},
// Composite archetype: the trailing lock replaces the chevron rather than
// sitting beside it (spec L19).
shipVia: {
...f.text({ label: "Ship via" }),
size: 110,
field: {
component: [Select, { options: SHIP_VIA, surface: "tableCell" }],
readOnly: () => true,
lockReason: () => "Carrier is set by the shipping plan",
},
},
qty: {
...f.number({ label: "Qty" }),
size: 96,
field: {
component: [NumberInput, { surface: "tableCell", min: 0 }],
},
},
});
export function EditableTableLockReasonDemo() {
const form = useMemo(
() =>
createForm<{ lines: Line[] }>({
initialValues: {
lines: [
{ id: "a", sku: "SKU-1", shipVia: "air", qty: 2, lock: "permission" },
{ id: "b", sku: "SKU-2", shipVia: "ground", qty: 4, lock: "posted" },
{
id: "c",
sku: "SKU-3",
shipVia: "air",
qty: 1,
lock: "dependency",
},
],
},
}),
[],
);
return (
<Form form={form} onSubmit={async () => undefined}>
<p className="text-muted-foreground mb-3 text-sm">
Locked cells stay muted, never red, and Qty stays writable so the
difference is scannable in one pass. Focus a locked control to read why.
Ship via is a locked composite: the lock takes the chevron’s place
instead of crowding beside it.
</p>
<EditableTable<Line>
name="lines"
columns={columns}
getRowId={(r) => r.id}
editMode="inline"
variant="embedded"
/>
</Form>
);
}Headless Usage (Specialized)
Problem: You need a custom shell (drawer, split pane) without bundled chrome.
This demo proves: useEditableTable inside ArrayField + EditableTableContext — same state machine as the bundled table.
Try it:
- Open the Code tab —
HeadlessTablewiresuseEditableTable,TableView, andEditableErrorRollup. - Scroll the table and paginate.
- Click Add from catalog — calls
fat.addRows.
→ Full hook API: Headless Usage below.
Define Your Columns
defineEditableColumns<RowType>({ … }) is the single source of truth for headers, read formatting, edit wiring, and footer math.
| Property | Required? | What it does |
|---|---|---|
kind | Yes | Read/display format — text, number, currency, date, status, boolean, etc. Same renderer in browse and edit. |
label | Yes | Column header text. |
field | No | When present, mounts a Field (editable, readOnly, or readPretty). Omit for display-only columns (status tags, computed accessors with no reactions). |
field.component | No | Formily connect tuple, e.g. [Input] or [NumberInput, { step: 0.5 }]. Omit with field: true to infer from kind (number → NumberInput, currency → CurrencyInput). |
field.validator | No | Per-cell rules. Receives (value, row) for cross-field checks inside a row. |
field.reactions | No | Formily reactions — display, required, derived value while the field is mounted. |
field.readOnly | No | (row) => boolean — session-lock this control on an otherwise editable row (pattern="readOnly"). |
field.readPretty | No | (row) => boolean — display / computed while still mounting a Field for reactions (pattern="readPretty"). |
accessor | No | (row) => value when the displayed value is not a plain property (computed subtotal, joined label). |
renderCell | No | (formatted, row) => ReactNode — wrap the already-formatted display node. Does not replace kind. Display only; skipped when masked / pending / mask. |
cellClassName | No | string or (row, meta) => string | undefined — paint the cell. Never rebuild a value-state ring with it. |
footer | No | (rows) => ReactNode | number — summary cell when features.footer is on. |
tier | No | "primary" (default) — matrix column. "secondary" — renders in the expandable row-detail panel when expand is enabled. |
size | No | Default column width hint for resize. |
grow | No | Absorb leftover width. Implicit default skips number / currency (identity / notes grow instead). Explicit grow: true still wins. |
Three column shapes you'll use daily:
// 1. Editable scalar — kind formats read text; field: true (or { required }) infers the control
sku: { kind: "text", label: "SKU", field: { required: true } },
qty: { kind: "number", label: "Qty", decimals: 2, field: true }, // → NumberInput step 0.01
// 2. Display-only — no field; always read text (status tag, rollup)
status: f.enum({ render: "status", label: "Status", variants: PLAN_VARIANTS, accessor: (r) => r.plan }),
// 3. Computed read + optional edit on siblings
subtotal: {
kind: "currency",
label: "Subtotal",
accessor: (row) => row.qty * row.price,
field: { readPretty: () => true, reactions: (f) => { /* live while editing */ } },
},Where to put cross-row logic
Inside one row (show field B when column A has a value): field.reactions + validator on column B — see Cell Validation Feedback.
Across rows or into header fields (order total, manager approval): parent form effects on the committed array — see Cross-Row Header Effects. In row mode, header totals update after each Save, not on every keystroke in the draft editor.
Cell Customization
Four layers, same as Table. Do not invent a fifth render that replaces kind (enum already owns render: "status" | "label").
| Layer | API | Job |
|---|---|---|
| 1 Get | accessor | Which value |
| 2 Format | kind | Currency, date, status tag, … |
| 3 Content | renderCell(formatted, row) | Wrap the formatted display node |
| 4 Paint | cellClassName | Class on the cell. Function may receive meta (editable / locked / hasCellError) |
| Host wants | Use |
|---|---|
| Extra words / badge on browse | renderCell — Extra Copy On Display Cells |
| Semantic colour + icon | renderCell + CellCriticalityValue |
| Cell wash, not a ring | cellClassName — Cell Wash Without A Ring |
| Approver scan | Compose the layers + getRowHighlight + rollup — Change Review |
| Fix this value now | Editable value-state ring (not cellClassName) |
| Why this control is locked (the lock itself is automatic) | field.lockReason |
Do
- Keep
field.componentas the edit control.renderCellnever wraps it. - Return
undefinedfromcellClassNamewhen the row should not paint. - Skip wrapping when the cell is masked / pending /
mask.
Don't
- Put
editableTableCellWarningClass(orborder-destructive) oncellClassName. - Expect Bulk / criticality chrome on the NumberInput while the row is editing.
- Treat Change Review as a kit prop — it is one composition.
Browse-only Table scenarios (status + host badge, combined scan): Table — Cell Customization.
Nested Sub-Rows (Master–Detail)
Use nested when each parent row owns a different child entity (claims, split quantities, sub-lines) with its own column schema. The chevron opens an inset child Editable Table — not a progressive-disclosure panel of leftover parent fields.
| Need | Pattern |
|---|---|
| Extra fields of the same entity | tier: "secondary" + row-detail expand — see Wide Rows: Column Tiers & Row Detail |
| Child entities that belong to / sum into the parent | nested on Editable Table (this section) |
One Chevron Owner
nested and tier: "secondary" are mutually exclusive on the same table instance. Passing both throws in development — only one pattern may own the expand chevron. Promote scan fields to primary, or open identity in a drawer, instead of stacking both expand modes.
Model A (always-array): keep children on parent.claims[] (or similar) at all times. Unsplit = [] or one child; split = two or more. Edit claim fields on children only. Parent columns may show derived remaining / Σ via accessor / footer — the kit does not ship a mutating rollup that writes back into parent fields.
Try it:
- Expand a parent — populated lines show the child claim grid; the empty line shows Empty + Add sub-row.
- Add or edit allocation qty — parent Remaining updates from
capacity − Σ allocations.qty(display only). - Note the nested region is an inset under the row — no Card chrome.
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
<EditableTable
name="lines"
columns={parentColumns}
getRowId={(row) => row.id}
nested={{
path: "claims",
columns: claimColumns,
recordCreator: {
record: () => ({ id: crypto.randomUUID(), qty: 1, note: "" }),
},
}}
/>"use client";
import { useMemo } from "react";
import { toast } from "sonner";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { Input } from "@/components/f-ui/formily/connects/input";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { f } from "@/components/f-ui/field-types/catalog";
type Claim = {
id: string;
qty: number;
note: string;
};
type CatalogLine = {
id: string;
sku: string;
item: string;
/** Static capacity — host owns this; kit does not mutate it. */
capacity: number;
claims: Claim[];
};
function claimedQty(row: CatalogLine): number {
return (row.claims ?? []).reduce((sum, c) => sum + (Number(c.qty) || 0), 0);
}
function remainingQty(row: CatalogLine): number {
return Number(row.capacity) - claimedQty(row);
}
const parentColumns = defineEditableColumns<CatalogLine>({
sku: {
...f.text({ label: "SKU" }),
size: 120,
field: { component: [Input], required: true },
},
item: {
...f.text({ label: "Item" }),
size: 180,
field: { component: [Input], required: true },
},
capacity: {
...f.number({ label: "Capacity" }),
size: 100,
field: true,
},
remaining: {
...f.number({
label: "Remaining",
accessor: (row) => remainingQty(row),
}),
size: 110,
footer: (rows) =>
rows.reduce((sum, row) => sum + remainingQty(row as CatalogLine), 0),
},
});
const claimColumns = defineEditableColumns<Claim>({
qty: {
...f.number({ label: "Claim qty" }),
size: 110,
field: { required: true },
},
note: {
...f.text({ label: "Note" }),
field: { component: [Input] },
},
});
const SEED: CatalogLine[] = [
{
id: "l1",
sku: "A-100",
item: "Motor controller",
capacity: 10,
claims: [
{ id: "c1", qty: 4, note: "Batch A" },
{ id: "c2", qty: 2, note: "Batch B" },
],
},
{
id: "l2",
sku: "B-220",
item: "Harness kit",
capacity: 8,
claims: [],
},
];
export function EditableTableNestedDemo() {
const form = useMemo(
() =>
createForm<{ lines: CatalogLine[] }>({
initialValues: { lines: SEED },
}),
[],
);
return (
<Form
form={form}
density="compact"
onSubmit={async (values) => {
toast.success(`Submitted ${values.lines.length} lines`);
}}
>
<EditableTable<CatalogLine>
name="lines"
variant="embedded"
editMode="inline"
tableCode="demo-nested-sub-rows"
columns={parentColumns}
getRowId={(row) => row.id}
nested={{
path: "claims",
columns: claimColumns,
getChildRowId: (c) => c.id,
defaultExpanded: true,
recordCreator: {
record: () => ({
id: crypto.randomUUID(),
qty: 1,
note: "",
}),
},
}}
recordCreator={{
record: () => ({
id: crypto.randomUUID(),
sku: "",
item: "",
capacity: 1,
claims: [],
}),
}}
features={{ index: { mode: "view" }, footer: true }}
/>
<FormActions>
<Button type="submit">Submit</Button>
</FormActions>
</Form>
);
}Core Concepts
Shared View Tier
Editable Table keeps Formily ArrayField as the data and edit engine, but renders through the same data-source-agnostic view primitives as Table (src/components/f-ui/table/):
| Primitive | Role in Editable Table |
|---|---|
TableView | Matrix body — sort headers, selection, optional virtual scrollport |
buildExpandColumn | Chevron column + expand state — shared with Table expandable |
build-row-actions-column | Idle-row Edit / Duplicate / Remove + overflow menu |
use-column-manager | Hide / reorder / pin when features.columns.manager is on |
renderCellByKind | Read-pretty cells (EditableFieldCell delegates here) |
data-list-table i18n bundle | Shared view chrome — actions.*, columns.*, density.*, … |
The Plus registry item declares a dependency on table. Editable cells stay package-owned; only browse chrome and layout are shared.
System column IDs (for column manager + tableCode persistence):
| ID | Column |
|---|---|
__expand | Expand chevron when secondary columns or expand.enabled (order: after selection, before index/data) |
__index | Optional ordinal column (features.index) |
__actions | Trailing row-actions column (Edit / Duplicate / Remove) |
Row actions layout matches the data-list table: Edit and Duplicate stay inline; Remove is placement: "trailing" with variant: "destructive" (right-aligned action cell). Custom rowActions items and overflow extras collapse into a MoreVertical menu. Shared confirm copy (actions.confirmTitle, actions.confirm, actions.cancel) comes from the data-list-table bundle; form-specific remove strings (row.confirmTitle, …) override when you pass removeRowConfirm.
Internationalization
All keys use dotted paths. Two bundles cooperate:
| Bundle | Host id | Examples |
|---|---|---|
data-list-table (shared view) | "data-list-table" | actions.more, columns.trigger, selection.selectRow |
editable-table (form domain) | "editable-table" | row.edit, validation.pendingEditsBlock, batchDelete.trigger, footer.totalAllRows |
Built-in strings resolve through useEditableTableI18n() (form keys) and useTableI18n() (view keys). Override either via I18nProvider — pass the matching component name to your host t.
Two Data Planes
Row mode separates what is saved from what is being typed:
flowchart LR
subgraph committed [Committed plane]
A["form.values.lineItems"]
end
subgraph draft [Draft plane - row mode only]
B["editorForm per row"]
end
Browse["Browse cells"] --> A
Edit["Click Edit"] --> B
B -->|Save| A
B -->|Cancel| X["Discarded"]
Inline["Inline mode"] --> A| Plane | What it is | When it updates |
|---|---|---|
| Committed | The Formily ArrayField at name — saved rows only | On Save, Delete, or direct array mutations (addRows, bulk catalog push) |
| Draft | One transient createForm() per editing row (flat ${colKey} paths) | While a row is in edit mode; disposed on Save/Cancel |
Read cells mount no inputs. Editing cells mount their row's editor form via a nested FormProvider. Within-row derived values (e.g. line subtotal) can use field.reactions in the editor form; cross-row totals belong in parent-form effects keyed on the committed array (onFieldValueChange("lineItems", …)), not per-field keystroke listeners.
Inline mode (editMode="inline") has no draft plane — every editable cell writes directly to form.values.
Choosing an Edit Mode
| Row (default) | Inline | |
|---|---|---|
| Mental model | Operational grid — commit one row at a time | Repeating field blocks inside a big form |
| Cells | Read text at rest; click Edit (or a cell) to open a row editor | Always-live inputs bound to form.values |
| Submit | Blocked while any row is mid-edit | No pending-edit gate |
| Remote save | editable.onSave / onDelete per row. onSave may return the saved row (server id / etag). | Use parent-form submit — row hooks are ignored |
| Best for | Already-persisted catalog, Duplicate then Save | Document line items (order create, rebate Apply) |
What features Actually Does
| Flag | Behavior |
|---|---|
view | Row viewport strategy. Default { mode: "all" } (every row mounts). offset — client-side page slices over the filtered/sorted view; renders EditablePagination. virtual — scrollport with TanStack Virtual via shared TableView; only visible rows mount (set maxHeight or fillHeight, not both). Footer and Add row sit outside the vertical scroll. |
sorting | Header click cycles sort on the view (does not reorder form.values). |
search | Toolbar global search over row values — not Data Table column filters or JSON Logic. Editing rows stay visible while open (editing-row freeze). |
selection | Row checkboxes; { batchDelete: true } docks Delete selected in the page footer batch bar (FooterToolbar, same pattern as list pages). Checkboxes are disabled while a row is editing. |
columns | { manager: true } shows the column manager (hide/reorder/pin). Persist with tableCode. |
footer | Column summary row (Sum / 合计) — Ant Design Table.Summary / Element Plus summary-row. Aligned with column widths and text-end on numeric columns. Aggregates the filtered full committed set (not the current offset page; drafts excluded). With fillHeight or virtual, the summary pins outside the vertical scrollport and stays inside shared horizontal scroll (Ant Summary fixed / Element Plus sticky footer). Without a height-constrained body, it stays an inline <tfoot>. |
index | Optional read-only ordinal column. { mode: "view" } numbers the current view order (page slice or virtual window); { mode: "array" } uses the true array index. |
rowActions | Built-in Edit/Duplicate/Delete (read mode) and Save/Cancel (edit mode). false hides both; { duplicate?: boolean, remove?: boolean, duplicatePosition?: "after" | "top" | "bottom" } toggles individually. Default both on; duplicatePosition defaults to "after" (built-in Duplicate only — Add still uses recordCreator.position). |
Breaking: Duplicate Placement
Built-in Duplicate now inserts after the source row by default (array order). Previously it followed Add (recordCreator.position, typically the end). Set features.rowActions.duplicatePosition: "bottom" (or "top") to restore the old placement.
Keyboard
Excel-style cell navigation (default, no props). Matches AG Grid / spreadsheet expectations for operational grids.
| Key | Behavior |
|---|---|
| ↑ ↓ ← → | Move focus to the adjacent editable cell (skips locked / hidden). In a single-line text field, ← → only leave the cell when the caret is at the start / end. |
| Enter | Finish the current cell and move to the same column on the next row. In row edit mode, Enter also Saves the draft first (stays put if validation fails). |
| Shift+Enter | Same column on the previous row (row mode: Save first). |
| Esc | Inline: blur the control. Row editing: Cancel the draft. |
| Tab | Unchanged — browser / form tab order. |
Open Select / Date / Combobox overlays keep their own arrow / Enter handling. Multiline textareas still use Enter for newlines.
Recipes — "I Need To…"
Each recipe links to a live demo. Copy the pattern, then adapt column keys and validators.
…require field B when field A has a value (same row)
Use field.reactions to toggle display / required, plus a validator that reads the full row.
→ Demo: Cell Validation Feedback
…show a header field when any table row matches a rule
Use parent effects + onFieldValueChange("lines.*.qty", …) or onFieldValueChange("lines", …) and form.setFieldState("headerField", …).
→ Demo: Cross-Row Header Effects
…save each row to the server before it appears "committed"
Row mode + editable.onSave / editable.onDelete. onSave may return the saved row (server id / etag). Duplicate is CREATE (meta.isNew). Reject the promise to keep the row editing with saveError.
→ Demo: Remote Persistence
…bulk-add rows from a catalog picker
toolbar.trailing button → form.query("lineItems").take() + field.push(…) or headless addRows([…]).
→ Demo: Row Mode — Operational Grid (Add from catalog)
…block submit while someone is mid-edit
Default in row mode. Use editable: { type: "single" } when only one draft may be open.
→ Demo: Single Edit Session
…validate on submit including hidden / paged rows
Parent form submit scans the full array. Rollup links call jumpToRow (clears search, scrolls virtual/offset view).
→ Demo: Submit Validation
…paint host Validate / server errors so they survive insert and remove
Use applyEditableArrayIssues with rowId + colKey. Never stash chrome on lines.N.* by index alone.
→ Demo: Identity-Keyed Host Validation · Painting Validation on Rows
…let users save an incomplete draft, then validate on final submit
editMode="inline" + a Save draft button that persists form.values without calling form.validate().
→ Demo: Inline Edit Mode
…lock approved rows but keep pending rows editable
rowEditable={(row) => row.status !== "approved"} + rowActionsPolicy to hide Remove when the line can never be deleted.
→ Demo: Row Edit Gates
…lock one column on a row while siblings stay editable
field.readOnly={(row) => row.status === "approved"} on that column only.
→ Demo: Mixed Column Gates
…show 50+ rows without lag
features.view: { mode: "virtual", maxHeight: "min(360px, 50vh)" } — or offset paging when users expect page numbers.
→ Demo: Virtual Scroll
…fit 10+ columns without horizontal scroll
Mark infrequent fields with tier: "secondary"; enable expand={{ expandAll: true }} for toolbar expand-all. Requires getRowId. Non-virtual view modes only in v1.
→ Demo: Wide Rows: Column Tiers & Row Detail
…nest child rows under a parent (master–detail)
Pass nested={{ path, columns, recordCreator }}. Children live on parent[path][] (Model A). Derived parent remaining / Σ use read-only accessor / footer — no kit rollup mutator. Do not combine with tier: "secondary".
→ Demo: Nested Sub-Rows (Master–Detail)
…group consecutive rows under a band
Pass Table rowGrouping (getGroupKey + renderBand). Editable Table forwards it to the matrix body; bands are not ArrayField rows. stickyBands defaults to true (band sticks under the header while group leaves scroll in a maxHeight / fill scrollport). Unsupported with view.mode: "virtual" in v1 — use a scrollport when you need bands. Read Table docs for when sticky helps, DOM cost (one mini-table per group), and stickyBands: false.
→ See Table — Row Grouping
…keep all columns in the grid (scroll + pin)
Declare pinned: "left" on identity columns (e.g. SKU). Index / Actions already pin. Horizontal scroll is expected — freeze the columns operators must keep in view. Use the column manager to hide low-value fields.
→ Demo: Wide Columns: Flat Matrix (Scroll + Pin)
…read-only review with sort and totals
readOnly on the table — sorting and features.footer still work.
→ Demo: Read-Only Review
…wrap formatted cells or paint a cell
renderCell wraps the already-formatted display node (not the editing control). cellClassName paints the cell. For attention, wrap with CellCriticalityValue inside renderCell — do not fake a ring.
→ Extra Copy On Display Cells · Cell Wash Without A Ring · composed: Change Review · browse Table: Cell Customization
…custom layout (drawer, split pane, no bundled chrome)
useEditableTable + EditableTableContext + compose TableView yourself.
→ Demo: Headless Usage
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| I don't know where to start | Page is long | Quick Start → Examples Level 1 |
| Header total does not update while I type in a row | Row mode keeps drafts out of form.values | Move the total to parent effects on onFieldValueChange("lineItems", …) (fires after Save), or use editMode="inline" for live values |
| Manager approval / freight rule never fires | Cross-row logic in field.reactions on a table cell | Move to parent effects or header FormField reactions that query("lineItems") |
| Error shows on first keystroke | Custom validator with triggerType: "onInput" on a fresh field | Use default blur timing; cell chrome already punishes late |
| Focus jumps out of cell on first keystroke | A custom cell or wrapper changes React type when errors or table options update | Built-in field, display, index, and action cells use stable module-level component types. Keep custom TanStack cell component references stable too |
| Value-state message steals focus | Custom popover without onOpenAutoFocus preventDefault | Built-in cells use non-modal PopoverAnchor + prevent auto-focus — keep focus in the input |
| Submit says row 5 is invalid but I don't see it | Search or offset paging hid the row | Click the rollup jump link, or clear search — submit always scans the full array |
| "Finish the current row first" on submit | A row is still in edit mode (draft not saved) | Save or Cancel every open row before submit |
onSave never runs | editMode="inline" | Remote hooks are row-mode only — use parent submit or inline draft save |
| Column manager resets on refresh | Missing tableCode | Pass a stable tableCode string per table instance |
| Footer total ignores my edits | Editing a draft row in row mode | Footer aggregates committed rows only — Save first |
| Duplicate creates a weird row | Unstable getRowId | Return a real id from your row object; do not rely on array index |
| New row vanishes on Cancel | Expected — cache draft discarded | Use recordCreator.newRecordType: "dataSource" only if you intend immediate array push |
Compared to Ant Design Pro EditableProTable
| Ant Design Pro | Editable Table |
|---|---|
value / onChange on the table | Rows live in Formily form.values under name |
recordCreatorProps | recordCreator |
editable.type single / multiple | editable.type — same mental model |
onSave / onDelete per row | editable.onSave / editable.onDelete |
editableKeys controlled | editable.editableKeys + onEditableChange |
valueType per column | Column kind + optional field |
formItemProps rules | field.validator, field.required, field.reactions |
ProComponents editable: false on column | Omit field, or field.readPretty when reactions need a Field |
| Search / toolbar slots | features.search, toolbar.leading / trailing |
| No first-class parent form | Native <Form> submit + header FormFields beside the table |
Editable Table intentionally matches Pro's operational grid ergonomics (read at rest, explicit Save, single-edit gate) while staying Formily-native for header fields, effects, and one-shot form submit.
Glossary
| Term | Meaning |
|---|---|
| Committed row | A row in form.values at name — what submit validates and what footers sum |
| Draft row | Row-mode only: transient editor form until Save/Cancel |
| View | What you see after sort, search, and paging/virtual slice — may differ from array order |
| Pending edit | At least one draft row open — blocks parent submit in row mode |
| Rollup | EditableErrorRollup list under the table with jump-to-row links |
| kind | Read formatter key (like Pro valueType) |
| Cache row | New row from Add that exists only in the edit session until Save |
Headless Usage
useEditableTable is the view-model inside Formily ArrayField: rows, sort, pagination, validation, and addRow / addRows. The demo uses native <button> for toolbar actions and TableView / EditablePagination as the data surface — not EditableTableToolbar or EditableTableAddRow.
"use client";
import { useMemo } from "react";
import { ArrayField, observer } from "@formily/react";
import { TableView } from "@/components/f-ui/table/table-parts/table-view";
import { EditablePagination } from "@/components/f-ui/editable-table/editable-table-parts/editable-pagination";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import {
EditableTableContext,
type EditableTableContextValue,
} from "@/components/f-ui/editable-table/editable-table-context";
import { EditableErrorRollup } from "@/components/f-ui/editable-table/editable-table-parts/editable-error-rollup";
import { useEditableTable } from "@/components/f-ui/editable-table/use-editable-table";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { Form } from "@/components/f-ui/formily/form";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { f } from "@/components/f-ui/field-types/catalog";
type LineItem = { id: string; sku: string; qty: number };
/** Templates only — mint a fresh id on each add so catalog can be reused. */
const CATALOG_LINES: Omit<LineItem, "id">[] = [
{ sku: "SKU-100", qty: 10 },
{ sku: "SKU-200", qty: 5 },
];
const columns = defineEditableColumns<LineItem>({
sku: {
...f.text({ label: "SKU" }),
field: { component: [Input], required: true },
},
qty: {
...f.number({
label: "Qty",
}),
size: 90,
field: {
component: [NumberInput, { surface: "tableCell" }],
required: true,
validator: (v) => (Number(v) > 0 ? undefined : "needs > 0"),
},
},
});
const HeadlessTable = observer(function HeadlessTable() {
const fat = useEditableTable<LineItem>({
name: "lineItems",
tableCode: "demo-order-lines-headless",
columns,
getRowId: (r) => r.id,
features: { view: { mode: "offset", pageSize: 5 } },
});
const ctx = useMemo<EditableTableContextValue<LineItem>>(
() => ({
name: "lineItems",
editMode: fat.editMode,
readOnly: false,
columns: fat.columns,
getRowId: fat.getRowId,
indexOf: fat.indexOf,
isRowEditable: fat.isRowEditable,
isNewRow: fat.isNewRow,
removeRow: fat.removeRow,
duplicateRow: fat.duplicateRow,
getRowOrdinal: fat.getRowOrdinal,
rowActionsFeature: fat.rowActionsFeature,
errorsStore: fat.errorsStore,
hasRowErrors: (rowId) => fat.errorsStore.getSnapshot().has(rowId),
isEditing: () => false,
editType: "single",
editorFormFor: () => undefined,
editorEntryFor: () => undefined,
startEdit: () => {},
autoFocusCell: null,
clearAutoFocusCell: () => {},
saveRow: () => {},
cancelRow: () => {},
deleteRow: () => {},
rowStatus: () => ({ kind: "idle" }),
editingKeys: fat.editableKeys,
expandEnabled: fat.expandEnabled,
isRowExpanded: fat.expand.isExpanded,
toggleRowExpanded: fat.expand.toggle,
rowDetailAriaLabel: "Toggle row details",
}),
[fat],
);
return (
<EditableTableContext.Provider value={ctx as EditableTableContextValue}>
<div>
<button
type="button"
onClick={() =>
fat.addRows(
CATALOG_LINES.map((line) => ({ ...line, id: crypto.randomUUID() })),
)
}
>
Add from catalog
</button>
<TableView<LineItem>
table={fat.table}
sort={fat.sort}
onSortColumn={fat.onSortColumn}
getRowClassName={(row) => fat.getRowClassName(row)}
/>
<EditablePagination
pagination={fat.pagination}
pageSizeOptions={fat.pageSizeOptions}
onChange={fat.onPageChange}
/>
<button
type="button"
onClick={() =>
fat.addRow({ id: crypto.randomUUID(), sku: "", qty: 1 })
}
>
Add line
</button>
<EditableErrorRollup
items={fat.rollupItems}
onJump={(rowId, colKey) => {
void fat.navigateToError({ rowId, colKey });
}}
/>
</div>
</EditableTableContext.Provider>
);
});
export function EditableTableHeadlessDemo() {
const form = useMemo(
() =>
createForm<{ lineItems: LineItem[] }>({
initialValues: {
lineItems: [{ id: "a", sku: "SKU-001", qty: 2 }],
},
}),
[],
);
return (
<Form form={form} onSubmit={async () => undefined}>
<ArrayField name="lineItems">{() => <HeadlessTable />}</ArrayField>
</Form>
);
}Composition
EditableTable
├── FormilyArrayField
├── EditableTableToolbar (add / search / batch delete / column manager / toolbar slots)
├── EditableTableViewStatus (virtual view — row count above scrollport)
├── TableView (sort headers, resize, optional virtual scrollport + tableFooter)
├── EditablePagination (when features.view.mode is offset)
└── EditableErrorRollupRead cells (EditableFieldCell) render formatted display text — no inputs. Editing cells (EditableEditCell in row mode, EditableInlineCell in inline mode) mount Formily fields. Row actions (EditableRowActions) branch between read mode (Edit/Duplicate/Delete) and edit mode (Save/Cancel).
Form Table vs. Browse Table
f-ui ships two table editing paradigms. They share tokens but render differently on purpose:
| Paradigm | Component | Editing model | Cell control |
|---|---|---|---|
| Form table | EditableTable | Row: click-to-edit, commit-gated draft. Inline: always live. | Borderless (surface="tableCell") — grid lines carry structure |
| Browse table | DataTable + InlineEdit | Plain text at rest; click toggles one cell | Bordered (compact) — border signals edit mode |
Editable Table primary cells use borderless controls (surface="tableCell") — grid lines carry structure. Editable primary cells show a subtle field box affordance so inputs read as editable at rest. Secondary-tier fields in the row-detail panel use panel surface styling (no double border). Composite controls (CurrencyInput, Select) opt into the borderless surface via acceptsTableCellSurface on their Formily connect. Cell-attention tokens (editableTableCellErrorClass, editableTableCellWarningClass, editableTableCellLockedClass) live in editable-table/lib/table-cell-chrome.ts.
Validation
Panel-form recipes (reveal modes, summaries, async uniqueness, server issue mapping) live on Form Validation. This page owns table cell chrome, the region rollup, and identity-keyed host paint.
Validation is two-tier. Timing follows mainstream inline-form guidance (reward early, punish late): do not flash errors while the user is still typing into a fresh field; do reveal errors on blur and keep them live while fixing.
| Tier | When | What the user sees |
|---|---|---|
| Live (cell) | Blur on an invalid editable field, or after a submit attempt | Value-state ring (errors) or ring + tint (warnings); message on focus (+ aria-description when blurred). Never a full-cell destructive wash on a field box. |
| Submit-time (data) | Parent form submit or validateAll() | Rollup below the table with jump links; scans rows hidden by search or paging |
| Host-applied | applyEditableArrayIssues | Same rollup (errors and warnings). Cell chrome only if pattern === "editable" |
| Object readiness | Host Validate / Run check / Submit | Prefer Message Popover for multi-region issues — not hover tooltips |
| Per-row Save | Row mode only, before commit | Editor form validates; invalid rows stay editing with saveError on remote failure |
Pending row edits block parent submit until every row is Saved or Cancelled. Inline mode has no pending-edit gate.
Per-row Save validates the editor form before committing. Cancel discards the draft without touching the committed row.
Painting Validation on Rows
Wire paths stay index-shaped (lines.1.qty) — that is Formily ArrayField. Ownership must be the stable getRowId.
| Do | Don’t |
|---|---|
applyEditableArrayIssues(form, { arrayPath: "lines", issues: [{ rowId, colKey, message, source }] }) | field.selfErrors = ["…"] once at lines.1.qty |
clearEditableArrayIssues(form, { arrayPath: "lines" }) before a full re-Validate replace | Assume React key={row.id} moves Formily feedback |
Keep getRowId stable (or accept a new function identity — the kit holds it in a ref) | Path-only applyFormIssues under a mounted EditableTable for cell paint |
Import helpers from @/components/f-ui/editable-table/lib/editable-array-issues (no package barrel).
What the kit does: on ArrayField structure change (insert / remove / reorder / whole-array setValues), Editable Table clears sticky feedback under that array and rematerializes the identity bag onto current indices in the same turn — so Validate chrome does not jump to a neighbor line.
Dev warning: applyFormIssues with a path under a mounted Editable Table logs a console warn in development and points at this bridge.
Nested tables: child issue bags use absolute paths that include the parent index (e.g. lines.0.claims). Nested issues survive parent edits only while that parent row’s index stays stable; a parent insert/reorder before that row remounts the child at a new path — re-apply nested issues. Flat tables are fully covered.
→ Interactive proof: Identity-Keyed Host Validation
Field Semantics
Lock, hide, and highlight map onto Formily. Following Ant Design Pro's EditableProTable, browse rows render read display only (no mounted field); a field mounts when the row enters edit (row mode) or always (inline mode). Within an editing row, an editable column mounts at pattern="editable", a session-locked fillable column at pattern="readOnly" (same control), and a computed/display column at pattern="readPretty" (or omit field).
| Question | Pattern |
|---|---|
| Can this role change it in this session? | editable |
| Could it be a control here, but is locked right now (permission, dependency, concurrent lock, in-flight save)? | readOnly |
| Is it never a control here (computed, subtotal, generated ID, audit, workflow status), or is the surface in display mode? | readPretty (or omit field) |
| Is it inapplicable until a dependency is satisfied? | disabled |
Fiori: Read Only — do not use if a UI element can never become editable, or if the page (or part of it) is in display mode. Display Only — do not use if a page is editable and a UI element is currently not editable.
| Need | Use |
|---|---|
| Whole table Display (inbox until Unlock) | field.readPretty on applicant columns; table readOnly only to hide add/actions |
| Whole table locked Edit | readOnly — mounted controls, not pretty |
| Whole row locked | rowEditable={(row) => …} — no Edit button |
| Only some columns locked on an editable row | field.readOnly={(row) => …} — the cell is marked automatically; add field.lockReason for the why |
| Always-display status / derived value | Column with no field (f.enum({ render: "status" }), accessor) |
| Within-row derived value while editing | field.readPretty + field.reactions (or omit field when no Field is needed) |
| Cross-row derived value | Parent-form effects on committed array changes (post-save in row mode; live in inline) |
| Hide / compute / cross-field rules | field.reactions (Formily display / required / value) |
| Fix this value now | Automatic value-state ring on editable cells only (message on focus) |
| Host / backend cell message | applyEditableArrayIssues — rollup always; cell chrome only if the field is editable |
| Display-row attention | getRowHighlight leading-edge bar |
| Display-cell attention | renderCell + CellCriticalityValue (colour + icon; host reason) |
| Non-severity paint | cellClassName — first-class layer 4; never rebuild the ring with it |
| Disable built-in duplicate/remove per row | rowActionsPolicy — locked rows hide built-in actions; policy fine-tunes editable rows |
Row lock vs. policy: rowEditable controls whether cells render as read text or allow Edit. rowActionsPolicy fine-tunes duplicate/remove on rows that remain editable.
Every read surface formats through one renderer keyed by column kind (the f-ui analogue of Ant Design Pro's valueType).
API Reference
Props
name | Formily ArrayField path (e.g. "lineItems"). |
columns | defineEditableColumns map — display kind + optional field, renderCell, cellClassName, and footer. |
getRowId | Stable row id for selection, duplicate, and error rollup. Defaults to an internal key on the row object. |
rowFilter | (row) => boolean — optional view predicate (e.g. wire QueryFilter onFinish here). Applied before toolbar search; does not mutate form.values. Keep the function identity stable (useCallback) — a new reference resets offset page to 1. jumpToRow / error jump pages against the filtered ordered list when the target is still visible; if QueryFilter hides the row, the table suspends rowFilter until the next filter apply so the row can be revealed. |
readOnly | When true, the whole table is read-only (default false). |
rowEditable | (row, index) => boolean — per-row edit gate (default: editable when !readOnly). Locked rows show read text and no Edit button. |
getRowClassName | (row, meta) => string — row styling seam. meta: { editable, hasErrors, isNew }. |
getRowHighlight | (row) => "error" | "warning" | "success" | "new" | "none" — display-mode leading-edge bar. Overridden by editing / new / busy washes. |
tableCode | Persistence key for column order/visibility (column manager). |
features | { view?, sorting?, search?, selection?, columns?, footer?, index?, rowActions? } — see table above. view defaults to { mode: "all" }. offset accepts { pageSize?: number, pageSizeOptions?: … } (default page size 20). virtual accepts { maxHeight?, fillHeight?, overscan?, estimateRowHeight?, tableScrollbar? } — set maxHeight or fillHeight, not both. search is global search only. columns: { manager: true } requires tableCode for localStorage persistence. index defaults off; pass { mode?: "view" | "array", label?, size? }. rowActions defaults both duplicate and remove on; optional duplicatePosition?: "after" | "top" | "bottom" (default "after") applies to built-in Duplicate only — Add still uses recordCreator.position. |
recordCreator | { position?: "top" | "bottom", newRecordType?: "cache" | "dataSource", record?: () => Partial<TData>, maxLength?: number } — toolbar add button; new rows open in edit mode as cache drafts until Save. |
variant | "default" | "embedded" (default "default"). "embedded" turns off grid chrome (search/sort/batch-delete/column manager) — use inside Form Page / Modal Form. Summary footer is opt-in via features.footer. |
editMode | "row" | "inline" (default "row"). "inline" = always-live cells bound to form.values, no per-row Save, no submit gate. Row-only editable props are ignored in inline mode. |
editable | { type?: "single" | "multiple", editableKeys?, onEditableChange?, onSave?, onDelete?, onCancel?, onOperationError? } — remote persistence hooks and controlled edit keys. onSave returns Promise<void | TData>: returning the row replaces the client draft (id swap); undefined keeps today's draft commit. Rejecting Save/Delete keeps a retryable saveError/deleteError row state and calls onOperationError({ kind, rowId, error }). |
rowActions | (row) => RowActionItem[] — custom actions per row (e.g. view detail), merged with the built-in duplicate/remove and collapsed into the overflow menu as needed. Each item: { key, label, icon?, onClick, disabled?, destructive? }. |
rowActionsPolicy | (row, meta) => { duplicate?, remove? } — per-row built-in action controls. Each action accepts { hidden?: boolean, disabled?: boolean | string }. hidden when this row can never run the op; string disabled when the block is temporary and obvious (tooltip / aria-description). |
removeRowConfirm | false deletes immediately; otherwise a RowConfirmPopover (Popconfirm) confirms single-row remove. Title/description accept static strings or (ctx) => string with { row, rowId, index }. |
batchDeleteConfirm | Confirm copy for footer batch delete when features.selection.batchDelete is on. Uses an AlertDialog; context is { count }. Pass false to skip confirmation. |
footerActions | Persistent right-side actions in the page footer bar (e.g. Submit). Shown with batch delete in FooterToolbar. |
footerExtra | Left-side footer chrome when the bar is visible and nothing is selected. |
toolbar | { leading?, trailing? } — inject custom controls beside the built-in search / column-manager toolbar (e.g. a bulk-add picker that calls addRows). |
expand | { enabled?, defaultExpanded?, autoExpandOnError?, expandAll? } — row-detail expand behavior. Auto-enables when any column has tier: "secondary". expandAll: true (default) shows toolbar Expand all / Collapse all. Requires getRowId. Non-virtual view modes only. Mutually exclusive with nested. |
nested | { path, columns, recordCreator?, getChildRowId?, rowExpandable?, empty?, features?, autoExpandOnError?, expandAll?, defaultExpanded? } — master–detail child Editable Table under each parent row. path is the relative array key (e.g. "claims"). Mutually exclusive with tier: "secondary" / expand. Max two levels. Non-virtual view modes only. See Nested Sub-Rows (Master–Detail). |
rowGrouping | Forwards Table rowGrouping (getGroupKey + renderBand + optional stickyBands) onto the non-virtual body. Bands are view chrome — not ArrayField rows. Unsupported with features.view.mode: "virtual" in v1 (bands ignored; dev error). Sticky / perf notes: Table — Row Grouping. |
Slots
EditableTable does not expose classNames in v1. Style rows via getRowClassName, or compose headless parts.
| Slot | Purpose |
|---|---|
toolbar.leading | Content before the add button (e.g. bulk-add from a picker dialog). |
toolbar.trailing | Content after search / batch delete. |
rowActions(row) | Per-row actions alongside duplicate and remove. |
EditableRowActions | Public row-actions cell (headless). Reads EditableTableContext; renders custom rowActions, Edit/Duplicate/Delete (read) or Save/Cancel (edit). Single-row remove uses RowConfirmPopover when removeRowConfirm is set; batch delete uses AlertDialog via the toolbar. |
Hook
useEditableTable<TData>(options) returns:
table | TanStack Table instance over the current view rows (page slice or full ordered set in virtual mode). |
sort, onSortColumn | View sort state — pass to TableView. |
pagination, onPageChange | Client offset paging over the filtered/sorted view (inert when view.mode is not offset). |
viewMode, virtualConfig | Resolved view mode; virtualConfig is present when view.mode is "virtual". |
search, onSearchChange | Global search term when features.search is on. |
columnManager | Column manager UI state when features.columns enables it. |
rows | Current committed array value from Formily. |
footerRows | Filtered full committed row set for footer aggregators (ignores offset paging and drafts). |
compiled | { tableSchema, validators, footers }. |
isRowEditable, isNewRow | Row editability and new-row detection by stable id. |
getRowClassName | Resolved row class (error, new, muted, plus custom). |
getRowOrdinal, rowActionsFeature | Index column ordinal and resolved built-in action toggles — pass into headless context when composing EditableRowActions. |
editableKeys, isEditing, editType | Current editing-row state. |
startEdit, saveRow, cancelRow, deleteRow, finishEdit | Per-row editing state machine. finishEdit disposes a session without committing (headless cleanup). |
editorFormFor, editorEntryFor | Access the per-row editor form and metadata. |
rowStatus | { kind: "idle" | "saving" | "saveError" | "deleting" | "deleteError", message? } per row. |
hasPendingEdits, blockedMessageVisible | Submit gate helpers. |
addRow, addRows, duplicateRow, removeRow, removeSelected | Array mutations by stable id. addRows appends N rows (bulk picker). duplicateRow clones into a cache draft until save. removeRow removes from the array and clears editing state when headless. |
validateAll, jumpToRow, focusFirstError, navigateToError, errors | Submit-time validation and reveal/focus navigation. errors is a readonly snapshot; never mutate it in place. |
Built-in strings resolve through useEditableTableI18n() (form-domain keys) and useTableI18n() (shared view keys) — see Shared View Tier.
Identity Issue Helpers
Import from @/components/f-ui/editable-table/lib/editable-array-issues (deep path — no barrel).
applyEditableArrayIssues(form, { arrayPath, issues, mode? }) | Store identity-keyed issues (rowId + colKey + message + optional severity / source) and paint current Formily indices. Default mode: "replace" replaces issues for the sources present in the payload (same ergonomics as Formily applyFormIssues). |
clearEditableArrayIssues(form, { arrayPath, rowId?, colKey?, source? }) | Clear bag entries (and rematerialize / clear Formily paint) for the filter. |
getEditableArrayIssues(form, arrayPath) | Read the current identity bag for that ArrayField path. |
EditableArrayIssue: { rowId: string; colKey: string; message: string; severity?: "error" \| "warning"; source?: "server" \| "submit" \| "system" }. Default source for host Validate is system.
Requires a mounted Editable Table for that arrayPath so the kit can resolve getRowId → index. See Painting Validation on Rows.