Form Validation
Who validates (FE, BE, hybrid), where errors appear, and copy-paste recipes for f-ui Formily forms.
Use this page when wiring fill-form validation — required rules, reveal timing, where errors live (summary vs table rollup), async checks, and server issue mapping. Component APIs live on Form (Formily). Table cell vs summary ownership also lives on Object Messaging And Table Chrome.
When To Use
- You need to choose frontend preview vs backend authority vs hybrid.
- You need to know when a field error appears (
submit/touch/always) — walk When Errors Show with the numbered Try steps. - You are composing header fields + Editable Table and need to know whether Form Error Summary stays on — walk Where Errors Appear.
- You are about to set
revealErrors,focusOnInvalid, orapplyFormIssues. - You want a named recipe instead of hunting through Formily API tables.
- You are mixing pretty / readOnly / editable on one Edit form, or wiring an approver inbox (Display until Unlock) — walk Mixed / Approver Lock. Lock mark placement and field vs surface scope: Field Lock Affordance.
The Rule
One error class → one aggregate owner. Two red lists of the same paths is a bug.
Table Lines And Stripes Comparison
Table, EditableTable, and the shared TableView support independent showColumnLines and striped appearance options. Both default to false. Column lines affect body cells only; header resize dividers and pinned boundaries remain independent. Stripes follow the displayed data-row sequence, excluding detail rows, group bands, and virtual spacers; parity continues across groups and restarts on each page.
Compare four combinations using the same 120 rows. Switch between Table, EditableTable, QueryList, and Headless · TableView; select row 2, scroll both axes, and toggle frozen columns or density. QueryList demonstrates paged rows; the other entries also support the virtual switch. Use the site theme switch to compare light and dark. EditableTable keeps quantity edits synchronized across the four panels; its warning/error wash stays independent from neutral row striping.
120 identical rows · four independent appearance combinations
Select row 2 to compare selection with stripes and its warning wash. Scroll sideways: SKU and Unit price stay frozen. Scroll down to check that stripes continue through virtual windows. Editable values stay in sync across all four panels and all four entry points. QueryList uses 20-row pages; Headless uses useDataListQueryPage + TableView. Select rows independently in each panel.
Red: quantity error · Amber: price warning · Neutral alternating backgrounds carry no business meaning. Table mode shows host-owned criticality and cell wash, as do QueryList and Headless; EditableTable uses Formily validation. Use the site theme switch to compare light and dark. Switching entry points resets selection; values are retained.
"use client";
import { TableAppearanceComparisonDemo } from "@/demos/table/table-appearance-comparison-demo";
/** Same shared data-table fixture, initially showing Formily validation. */
export function FormValidationTableAppearanceComparisonDemo() {
return <TableAppearanceComparisonDemo initialEditable />;
}Cell Feedback Appearance Comparison
Compare wash + corner, corner only, and severity icon on the same editable tables. Balanced (wash + corner) is the official default; compact and explicit are optional appearances. All three share values, messages, field borders, and popup behavior.
Start with the safe minimum column widths. Inspect row 1’s mixed messages and long price, focus row 2’s locked price to read five warnings, then unlock it. Edit any quantity and watch the same value update in all three tables. Switch to 12 rows to judge how much visual emphasis each option creates.
Same values. Same messages. Three ways to mark a cell.
Edits stay in sync across all three tables. Row 2 starts locked; its unit price has five warnings. Row 1 has mixed errors and a warning. Row 3 is clean. Focus a field or click a flagged description to inspect it.
Balanced is the recommended default. Compact and explicit remain available. Compare the long price in row 1, the five warnings on locked row 2, and how easy it is to find clean row 3. Use each table’s message button to read the full list and jump to a cell.
"use client";
import { useEffect, useMemo, useState } from "react";
import { onFieldValueChange } from "@formily/core";
import {
CircleAlert,
Info,
Lock,
RotateCcw,
TriangleAlert,
} from "lucide-react";
import { Button } from "@/components/f-ui/button/button";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import {
applyEditableArrayIssues,
type EditableArrayIssue,
} from "@/components/f-ui/editable-table/lib/editable-array-issues";
import { f } from "@/components/f-ui/field-types/catalog";
import { Form } from "@/components/f-ui/formily/form";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
type Line = { id: string; item: string; qty: number; price: number };
type Variant = "balanced" | "compact" | "explicit";
const VARIANTS: { id: Variant; title: string; description: string }[] = [
{
id: "balanced",
title: "A · Wash + corner",
description: "A larger scan cue, with no icon beside the value.",
},
{
id: "compact",
title: "B · Corner only",
description: "The quietest signal. Values keep the same space as A.",
},
{
id: "explicit",
title: "C · Severity icon",
description:
"Different shapes for error and warning, with a reserved icon slot.",
},
];
const INITIAL: Line[] = [
{
id: "mixed",
item: "Precision mounting bracket — anodized aluminum",
qty: 7.5,
price: 12850.75,
},
{
id: "locked",
item: "Replacement filter cartridge — industrial grade",
qty: 120,
price: 2400,
},
{ id: "clean", item: "Standard washer", qty: 12, price: 18 },
{ id: "invalid", item: "Cable assembly — custom length", qty: 0, price: -4 },
];
const cloneLines = () => INITIAL.map((line) => ({ ...line }));
function issuesFor(lines: readonly Line[]): EditableArrayIssue[] {
const issues: EditableArrayIssue[] = [];
const add = (
rowId: string,
colKey: string,
severity: "error" | "warning" | "info",
message: string,
) =>
issues.push({
rowId,
colKey,
severity,
message,
source: "server",
});
for (const line of lines) {
if (line.qty <= 0)
add(line.id, "qty", "error", "Quantity must be greater than zero.");
if (!Number.isInteger(line.qty))
add(line.id, "qty", "error", "Enter a whole number of units.");
if (line.qty % 6 !== 0)
add(line.id, "qty", "error", "Order in packs of 6.");
if (line.id === "mixed" && line.qty > 6)
add(
line.id,
"qty",
"warning",
"This quantity exceeds the suggested order.",
);
if (line.qty > 100)
add(
line.id,
"qty",
"warning",
"Quantity above 100 needs a second reviewer.",
);
if (line.price < 0)
add(line.id, "price", "error", "Unit price cannot be negative.");
if (line.price > 1000) {
add(
line.id,
"price",
"warning",
"Unit price is above the catalog price.",
);
add(line.id, "price", "warning", "Price is above the last purchase.");
}
if (line.id === "locked" && line.price > 1000) {
add(
line.id,
"price",
"warning",
"No current price agreement is attached.",
);
add(line.id, "price", "warning", "Supplier quote is older than 30 days.");
add(line.id, "price", "warning", "A reviewer must confirm the currency.");
}
if (line.id === "mixed")
add(
line.id,
"item",
"info",
"Description was updated by the supplier. The item code is unchanged.",
);
}
return issues;
}
/** One real form, three synchronized arrays; only the public appearance prop differs. */
export function FormValidationCellFeedbackComparisonDemo() {
const [narrow, setNarrow] = useState(true);
const [locked, setLocked] = useState(true);
const [dense, setDense] = useState(false);
const [resetKey, setResetKey] = useState(0);
return (
<div
className="not-prose w-full space-y-4"
data-slot="cell-feedback-comparison"
>
<div className="bg-muted/40 rounded-xl border p-4">
<p className="text-sm font-medium">
Same values. Same messages. Three ways to mark a cell.
</p>
<p className="text-muted-foreground mt-1 text-xs leading-relaxed">
Edits stay in sync across all three tables. Row 2 starts locked; its
unit price has five warnings. Row 1 has mixed errors and a warning.
Row 3 is clean. Focus a field or click a flagged description to
inspect it.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button
variant={narrow ? "secondary" : "outline"}
aria-pressed={narrow}
onClick={() => setNarrow((value) => !value)}
>
{narrow ? "Minimum numeric width · 160 px" : "Comfortable columns"}
</Button>
<Button
variant={locked ? "secondary" : "outline"}
icon={<Lock aria-hidden />}
aria-pressed={locked}
onClick={() => setLocked((value) => !value)}
>
{locked ? "Row 2 locked" : "Row 2 unlocked"}
</Button>
<Button
variant={dense ? "secondary" : "outline"}
aria-pressed={dense}
onClick={() => setDense((value) => !value)}
>
{dense ? "12 rows" : "4 rows"}
</Button>
<Button
variant="ghost"
icon={<RotateCcw aria-hidden />}
onClick={() => setResetKey((value) => value + 1)}
>
Reset values
</Button>
</div>
<div className="text-muted-foreground mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
<span className="inline-flex items-center gap-1">
<CircleAlert className="text-destructive size-3.5" />
Error
</span>
<span className="inline-flex items-center gap-1">
<TriangleAlert className="text-warning size-3.5" />
Warning
</span>
<span className="inline-flex items-center gap-1">
<Info className="text-info size-3.5" />
Note · no wash
</span>
<span>Field borders and message behavior stay identical.</span>
</div>
</div>
<ComparisonTables
key={`${resetKey}-${dense}`}
narrow={narrow}
locked={locked}
dense={dense}
/>
<p className="text-muted-foreground text-xs leading-relaxed">
Balanced is the recommended default. Compact and explicit remain
available. Compare the long price in row 1, the five warnings on locked
row 2, and how easy it is to find clean row 3. Use each table’s message
button to read the full list and jump to a cell.
</p>
</div>
);
}
function ComparisonTables({
narrow,
locked,
dense,
}: {
narrow: boolean;
locked: boolean;
dense: boolean;
}) {
const form = useMemo(() => {
const rows = dense
? Array.from({ length: 3 }, (_, index) =>
cloneLines().map((line) => ({
...line,
id: index === 0 ? line.id : `${line.id}-${index}`,
})),
).flat()
: cloneLines();
// Preserve scenario rules on repeated rows by deriving messages from their base identity below.
let syncing = false;
let feedbackQueued = false;
return createForm<Record<Variant, Line[]>>({
initialValues: Object.fromEntries(
VARIANTS.map(({ id }) => [id, rows.map((line) => ({ ...line }))]),
) as Record<Variant, Line[]>,
effects() {
onFieldValueChange("*.*.*", (field) => {
if (syncing) return;
const [owner, index, key] = field.path.toString().split(".");
if (!VARIANTS.some(({ id }) => id === owner) || !index || !key)
return;
syncing = true;
try {
for (const { id } of VARIANTS) {
if (id !== owner)
field.form.setValuesIn(`${id}.${index}.${key}`, field.value);
}
} finally {
syncing = false;
}
// Formily clears stale server feedback on each value change. Reapply
// the shared fixture after every synchronized field has processed it.
if (!feedbackQueued) {
feedbackQueued = true;
queueMicrotask(() => {
feedbackQueued = false;
for (const { id } of VARIANTS) applyMessages(field.form, id);
});
}
});
},
});
}, [dense]);
useEffect(() => {
for (const { id } of VARIANTS) applyMessages(form, id);
}, [form]);
const columns = useMemo(
() =>
defineEditableColumns<Line>({
item: {
...f.text({ label: "Item description" }),
size: narrow ? 188 : 280,
},
qty: {
...f.number({ label: "Qty" }),
size: narrow ? 88 : 192,
field: {
component: [NumberInput, { surface: "tableCell" }],
readOnly: (row) => locked && baseLine(row as Line).id === "locked",
lockReason:
"Submitted by the requester. Unlock row 2 to adjust this value.",
},
},
price: {
...f.number({ label: "Unit price" }),
size: narrow ? 88 : 216,
field: {
component: [NumberInput, { surface: "tableCell" }],
readOnly: (row) => locked && baseLine(row as Line).id === "locked",
lockReason:
"Submitted by the requester. Unlock row 2 to adjust this value.",
},
},
}),
[narrow, locked],
);
return (
<Form
form={form}
revealErrors="always"
onSubmit={() => undefined}
className="space-y-5"
>
{VARIANTS.map(({ id, title, description }) => (
<section
key={id}
aria-label={title}
data-feedback-variant={id}
className="min-w-0 overflow-hidden rounded-xl border"
>
<div className="bg-muted/20 border-b px-4 py-3">
<p className="m-0 text-sm font-semibold">{title}</p>
<p className="text-muted-foreground m-0 mt-1 text-xs">
{description}
</p>
</div>
<div className="p-3">
<EditableTable<Line>
key={`${id}-${narrow}`}
name={id}
cellFeedbackAppearance={id}
variant="embedded"
editMode="inline"
columns={columns}
getRowId={(row) => row.id}
rollupPlacement="toolbar"
features={{ rowActions: false, footer: false }}
/>
</div>
</section>
))}
</Form>
);
}
function baseLine(line: Line): Line {
return { ...line, id: line.id.replace(/-\d+$/, "") };
}
function applyMessages(
form: ReturnType<typeof createForm<Record<Variant, Line[]>>>,
id: Variant,
) {
const lines = form.values[id] ?? [];
const issues = lines.flatMap((line) =>
issuesFor([baseLine(line)]).map((issue) => ({ ...issue, rowId: line.id })),
);
applyEditableArrayIssues(form, { arrayPath: id, scope: { source: "server" }, issues });
}Appearance And Behavior
Choose feedback presentation with cellFeedbackAppearance="balanced" | "compact" | "explicit". Balanced is the default: a subtle error or warning background and a corner marker. Compact removes the background, useful when business colors already carry meaning. Explicit uses a severity icon in a reserved slot. Info notes have a marker without an error or warning background.
All appearances share the same validation, messages, severity order, reveal timing, lock rules, submission gate, and navigation. Switching appearance keeps the mounted editor and its value. The marker identifies the cell; the message list explains every issue. Locked cells keep neutral control borders and their lock, even when the cell carries an issue background.
Numeric field columns have a default 160px minimum, and other field columns have a 112px minimum. These floors account for padding and suffixes; choose wider columns for long or localized amounts. An explicit column minSize overrides the floor. Read-only numbers retain their complete formatted value in the native title and use an ellipsis when space runs out.
Mixed Cell Validation Lifecycle
Local Formily rules and backend findings share the same cell feedback and table rollup. Their lifetimes remain independent. This demo uses a simulated backend with a short delay and a held-response control so outdated results can be reproduced without timing a click.
- Choose Validate local rules, then Check all rows. Row 1 has a local quantity error; both prices have two backend warnings.
- Choose Update row 1 contract. Its backend limit rises to 200; the empty result clears row 1’s warnings while row 2’s warnings and the local quantity error remain.
- Choose Hold row 1 response, then Check row 1, then Deliver held response. The newer check wins; the held response is ignored.
- Hold another response, choose Use valid row 1, and deliver it. Editing invalidates the old response even if no newer request was made.
- Choose Check row 1. Its empty backend result clears only row 1’s pricing scope; row 2’s warnings remain. Run that check again to confirm empty results are safe and repeatable.
- Choose Reverse rows. Messages follow their named item. Hold a response, choose Reset demo, then deliver it: a response from before reset cannot restore old warnings.
Local quantity rules + simulated backend price warnings
Start with local validation and check both rows. Each price above 100 produces two backend warnings. Update row 1 contract raises its backend limit to 200 and returns an empty result, without editing the draft. Row 1 and row 2 refer to the named items, even after reversing their order.
No backend checks in flight
Recent checks
- Ready. Row 1 has a local quantity rule; both prices need a backend check.
"use client";
import { Button } from "@/components/f-ui/button/button";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { f } from "@/components/f-ui/field-types/catalog";
import { Form } from "@/components/f-ui/formily/form";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import {
useMixedCellValidation,
type MixedValidationLine,
} from "./use-mixed-cell-validation";
const columns = defineEditableColumns<MixedValidationLine>({
item: { ...f.text({ label: "Item" }), size: 260 },
qty: {
...f.number({ label: "Qty" }),
field: {
component: [NumberInput],
required: true,
validator: (value) =>
Number.isInteger(Number(value)) && Number(value) > 0
? ""
: "Quantity must be a positive whole number.",
},
},
price: {
...f.number({ label: "Unit price" }),
field: {
component: [NumberInput],
required: true,
validator: (value) =>
Number(value) >= 0 ? "" : "Unit price cannot be negative.",
},
},
});
export function FormValidationMixedCellValidationDemo() {
const demo = useMixedCellValidation();
return (
<section
className="not-prose w-full space-y-3"
aria-label="Mixed cell validation demo"
data-slot="mixed-cell-validation-demo"
>
<div className="bg-muted/40 space-y-3 rounded-xl border p-4">
<p className="text-sm font-medium">
Local quantity rules + simulated backend price warnings
</p>
<p className="text-muted-foreground text-xs leading-relaxed">
Start with local validation and check both rows. Each price above 100
produces two backend warnings. Update row 1 contract raises its
backend limit to 200 and returns an empty result, without editing the
draft. Row 1 and row 2 refer to the named items, even after reversing
their order.
</p>
<div className="flex flex-wrap gap-2">
<Button onClick={() => void demo.validateLocal()}>
Validate local rules
</Button>
<Button variant="outline" onClick={() => demo.check()}>
Check all rows
</Button>
<Button variant="outline" onClick={() => demo.check(["a"])}>
Check row 1
</Button>
<Button variant="outline" onClick={() => demo.check(["b"])}>
Check row 2
</Button>
<Button variant="outline" onClick={demo.updateContract}>
Update row 1 contract
</Button>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={demo.hold}>
Hold row 1 response
</Button>
<Button
variant="outline"
disabled={!demo.held}
onClick={demo.deliver}
>
Deliver held response
</Button>
<Button variant="ghost" onClick={demo.useValidRow}>
Use valid row 1
</Button>
<Button variant="ghost" onClick={demo.reverse}>
Reverse rows
</Button>
<Button variant="ghost" onClick={() => void demo.reset()}>
Reset demo
</Button>
</div>
<p className="text-muted-foreground text-xs" role="status">
{demo.pending
? `${demo.pending} backend check(s) in flight`
: "No backend checks in flight"}
{demo.held ? " · one response held" : ""}
</p>
</div>
<Form form={demo.form} onSubmit={() => undefined}>
<EditableTable<MixedValidationLine>
name="lines"
variant="embedded"
editMode="inline"
columns={columns}
getRowId={(row) => row.id}
rollupPlacement="toolbar"
features={{ rowActions: false, footer: false }}
/>
</Form>
<div className="bg-muted/30 rounded-lg border px-3 py-2">
<p className="text-muted-foreground mb-1 text-xs font-medium">
Recent checks
</p>
<ol
className="m-0 list-none space-y-1 p-0 text-xs"
aria-label="Validation event log"
>
{demo.events.map((event, index) => (
<li key={`${event}-${index}`}>{event}</li>
))}
</ol>
</div>
</section>
);
}The core identifies findings by source, producer, stable row ID, column, and optional finding ID. A scoped replacement declares which findings it owns, including when it returns none. Server findings invalidate on cell edits by default; row-dependent rules can invalidate on any row edit, and manual findings remain until explicitly replaced or cleared. Formily owns draft values and local validators. Popup state and the balanced/compact/explicit appearance are separate from this lifecycle.
Warning Acceptance And Approval
Accepting a warning records a business decision; the original finding remains a warning. This demo uses a separate simulated backend for reviewer authorization, data revisions, revalidation, and approval records. The generic cell-message core only receives the outstanding-feedback projection. Records survive rechecks and edits while this demo is open; Reset approval demo intentionally starts a fresh session.
- After initial checks finish, row 1 has two price warnings, row 2 has one warning, and row 3 has a hard quantity error. Choose Approve entire document to see why approval is blocked.
- Accept only Row 1 · Contract price. Its quote-age warning remains pending. Expand the new history entry to inspect the reviewer, reason, original warning, revision, and reviewed values.
- Accept Row 1 · Quote age, select row 1 using its table checkbox, and choose Approve selected rows. Only row 1 is approved; row 2 and row 3 are unaffected.
- Accept Row 2 · Contract price, change row 3’s quantity from
0to6, and wait for automatic revalidation. Approve entire document now succeeds and records all three row revisions. - Edit row 1’s unit price from
120to130. Its old acceptances and approval become outdated but stay in history. Automatic revalidation restores the two pending warnings. Whole-document approval also reopens. Returning to120does not revive old decisions. - Accept the new warnings and approve again to create fresh records. Hard errors cannot be accepted; pending or outdated checks cannot grant approval. Editing during a pending decision makes that decision fail its backend revision check.
Awaiting review
Reviewer: Alex Morgan · simulated backendAccept each warning before approving its row. Row 3’s hard error blocks the whole document, but eligible selected rows can be approved. Editing a row automatically rechecks it and reopens its earlier decisions.
Checking the submitted rows with the simulated backend…
- Row 1: Needs recheck · revision 1
- Row 2: Needs recheck · revision 1
- Row 3: Needs recheck · revision 1
Warning decisions
0 warning(s) detected · 0 pending · 0 accepted
No warnings awaiting a decision. Accepted findings remain in the backend result and decision history.
Decision history (0)
Retained while this demo is openNo decisions yet. Closing a message popup does not create an acceptance.
"use client";
import { useId, useState } from "react";
import { Button } from "@/components/f-ui/button/button";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { f } from "@/components/f-ui/field-types/catalog";
import { Form } from "@/components/f-ui/formily/form";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { Input } from "@/components/ui/input";
import {
type ApprovalLine,
type DecisionRecord,
DEMO_REVIEWER,
} from "./warning-approval/approval-service";
import { useWarningApproval } from "./warning-approval/use-warning-approval";
const columns = defineEditableColumns<ApprovalLine>({
item: { ...f.text({ label: "Item" }), size: 250 },
qty: {
...f.number({ label: "Qty" }),
field: {
component: [NumberInput],
required: true,
validator: (value) =>
Number.isInteger(Number(value)) && Number(value) > 0
? ""
: "Quantity must be a positive whole number.",
},
},
price: {
...f.number({ label: "Unit price" }),
field: {
component: [NumberInput],
required: true,
validator: (value) =>
Number.isFinite(Number(value)) && Number(value) >= 0
? ""
: "Unit price must be a nonnegative number.",
},
},
});
const rowLabel = (id: string) => `Row ${{ a: 1, b: 2, c: 3 }[id] ?? id}`;
const decisionLabel = (kind: DecisionRecord["kind"]) =>
kind === "acceptance"
? "Warning accepted"
: kind === "row-approval"
? "Rows approved"
: "Document approved";
export function FormValidationWarningApprovalDemo() {
const [session, setSession] = useState(0);
return (
<ApprovalSession
key={session}
onReset={() => setSession((value) => value + 1)}
/>
);
}
function ApprovalSession({ onReset }: { onReset: () => void }) {
const demo = useWarningApproval();
const reasonId = useId();
const approved = demo.snapshot.rows.filter((row) => row.approved).length;
const warningCount =
demo.snapshot.pendingWarnings.length +
demo.snapshot.acceptedWarnings.length;
const hadApproval = demo.snapshot.records.some(
(record) => record.kind !== "acceptance",
);
return (
<section
className="not-prose w-full space-y-4"
data-slot="warning-approval-demo"
aria-label="Warning acceptance and approval demo"
>
<div className="bg-muted/40 space-y-3 rounded-xl border p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p
className="text-sm font-medium"
data-slot="document-approval-state"
>
{demo.snapshot.documentApproved
? "Document approved"
: approved
? `Partially approved · ${approved} / 3 rows`
: hadApproval
? "Approval reopened"
: "Awaiting review"}
</p>
<span className="text-muted-foreground text-xs">
Reviewer: {DEMO_REVIEWER} · simulated backend
</span>
</div>
<p className="text-muted-foreground text-xs leading-relaxed">
Accept each warning before approving its row. Row 3’s hard error
blocks the whole document, but eligible selected rows can be approved.
Editing a row automatically rechecks it and reopens its earlier
decisions.
</p>
<div className="flex flex-wrap gap-2">
<Button
disabled={demo.busy || demo.selectedIds.length === 0}
onClick={() => demo.approve("rows")}
>
Approve selected rows ({demo.selectedIds.length})
</Button>
<Button
variant="outline"
disabled={demo.busy || demo.snapshot.documentApproved}
onClick={() => demo.approve("document")}
>
Approve entire document
</Button>
<Button
variant="outline"
disabled={demo.pendingAction}
onClick={demo.recheck}
>
Recheck all rows
</Button>
<Button variant="ghost" onClick={onReset}>
Reset approval demo
</Button>
</div>
<p
role="status"
className="text-muted-foreground text-xs"
data-slot="approval-command-status"
>
{demo.checking.length
? `Rechecking ${demo.checking.length} row(s)… `
: ""}
{demo.message}
</p>
</div>
<Form form={demo.form} onSubmit={() => undefined}>
<EditableTable<ApprovalLine>
name="lines"
variant="embedded"
editMode="inline"
columns={columns}
getRowId={(row) => row.id}
rollupPlacement="toolbar"
onSelectionChange={demo.setSelectedIds}
features={{
selection: { batchDelete: false },
rowActions: false,
footer: false,
}}
/>
</Form>
<ul
className="m-0 flex list-none flex-wrap gap-x-5 gap-y-1 p-0 text-xs"
aria-label="Row approval status"
>
{demo.snapshot.rows.map((row) => (
<li key={row.data.id} data-row-status={row.data.id}>
{rowLabel(row.data.id)}:{" "}
<span className="font-medium">
{row.checkedRevision !== row.revision
? "Needs recheck"
: row.approved
? "Approved"
: row.findings.some((finding) => finding.severity === "error")
? "Needs correction"
: demo.snapshot.pendingWarnings.some(
(finding) => finding.rowId === row.data.id,
)
? "Awaiting warning decisions"
: "Ready for approval"}
</span>{" "}
· revision {row.revision}
</li>
))}
</ul>
<div className="space-y-3 rounded-xl border p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-medium">Warning decisions</h3>
<p
className="text-muted-foreground text-xs"
data-slot="warning-decision-counts"
>
{warningCount} warning(s) detected ·{" "}
{demo.snapshot.pendingWarnings.length} pending ·{" "}
{demo.snapshot.acceptedWarnings.length} accepted
</p>
</div>
<div className="space-y-1">
<label htmlFor={reasonId} className="text-xs font-medium">
Acceptance reason
</label>
<Input
id={reasonId}
value={demo.reason}
onChange={(event) => demo.setReason(event.target.value)}
/>
</div>
{demo.snapshot.pendingWarnings.length ? (
<ul
className="m-0 list-none divide-y p-0"
aria-label="Pending warning decisions"
>
{demo.snapshot.pendingWarnings.map((finding) => (
<li
key={`${finding.rowId}-${finding.id}`}
className="flex flex-wrap items-center justify-between gap-2 py-2"
>
<span className="text-sm">
{rowLabel(finding.rowId)} · {finding.label}
</span>
<Button
variant="outline"
disabled={demo.busy}
onClick={() => demo.accept(finding)}
aria-label={`Accept ${rowLabel(finding.rowId)} · ${finding.label}`}
>
Accept warning
</Button>
</li>
))}
</ul>
) : (
<p className="text-muted-foreground text-xs">
{demo.checking.length
? "Waiting for current validation results."
: "No warnings awaiting a decision. Accepted findings remain in the backend result and decision history."}
</p>
)}
</div>
<div className="space-y-2 rounded-xl border p-4">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium">
Decision history ({demo.snapshot.records.length})
</h3>
<span className="text-muted-foreground text-xs">
Retained while this demo is open
</span>
</div>
{!demo.snapshot.records.length ? (
<p className="text-muted-foreground text-xs">
No decisions yet. Closing a message popup does not create an
acceptance.
</p>
) : (
<div
className="scrollbar-thin focus-visible:outline-ring max-h-72 overflow-y-auto rounded-sm focus-visible:outline-2"
tabIndex={0}
role="region"
aria-label="Decision history"
>
<ol className="m-0 list-none divide-y p-0">
{[...demo.snapshot.records].reverse().map((record) => (
<li
key={record.id}
className="space-y-1 py-2 text-xs"
data-decision-current={record.current ? "true" : "false"}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-medium">
{decisionLabel(record.kind)} ·{" "}
{record.kind === "document-approval"
? "Entire document"
: record.rowIds.map(rowLabel).join(", ")}
{record.finding ? ` · ${record.finding.label}` : ""}
</span>
<span className="text-muted-foreground">
{record.current
? "Current"
: "Outdated after data change"}
</span>
</div>
<p className="text-muted-foreground">
{record.actor} ·{" "}
{new Date(record.at).toLocaleTimeString("en-GB")} ·{" "}
{record.rowIds
.map(
(id) =>
`${rowLabel(id)} revision ${record.revisions[id]}`,
)
.join(", ")}
</p>
<details>
<summary className="cursor-pointer underline underline-offset-4">
View decision details
</summary>
<div className="text-muted-foreground space-y-1 pt-1">
<p>Reason: {record.reason || "Reviewed for approval."}</p>
{record.finding ? (
<p>
Original warning: {record.finding.message} Rule
version: {record.finding.ruleVersion}.
</p>
) : null}
{record.reviewedRows.map((row) => (
<p key={row.id}>
{rowLabel(row.id)} at decision: qty {row.qty}, unit
price {row.price}.
</p>
))}
</div>
</details>
</li>
))}
</ol>
</div>
)}
</div>
</section>
);
}Partial approval is atomic within the selected rows. Whole-document approval checks every row. Neither command silently accepts outstanding warnings, and neither uses a UI message-clear operation as approval. Local Formily validators help editing; the simulated backend repeats the hard rules and decides whether each command is eligible.
1. Who Validates
| Class | Use when | Do not |
|---|---|---|
| FE-only | Required, format, length, local cross-field — fully knowable in the browser | Treat as a security boundary |
| BE-only | Authz, concurrency, credentials, payment channel, file content safety | Skip server checks because the UI looked valid |
| Hybrid | Uniqueness, domain blacklist, budgets, anything that needs a round-trip and a good preview | Trust async FE uniqueness alone |
Locks: Server is authoritative for business invariants. Default hybrid = FE gate → submit → map FormIssue[] onto fields + Form Error Summary. Security-sensitive failures stay generic at form root (no “user does not exist” on a field).
2. Where Errors Appear
One error class → one aggregate owner. Field/cell chrome can sit next to that owner. Two red lists of the same paths is a bug.
| Surface | Owns | Does not own |
|---|---|---|
| Field (Form Item) | Header / panel field messages | Table cell paths |
| Form Error Summary | Root / system (path omitted), header fields, array-root (lineItems with no .cell) | Editable Table cell descendants (lineItems.0.sku) |
| Editable Table cell + region rollup | That table’s cell issues + jump | Header fields; other tables’ cells |
| Message Popover | Multi-region / object readiness after Validate or blocked Submit | Draft auto-open; replacing cell chrome |
Full visual language (cell value-state, toolbar, popover): Object Messaging And Table Chrome. Form Error Summary and the table region rollup share the same Primer Banner card (bg-card, 1px border, small status mark, foreground heading). They must not list the same cell paths.
When To Use Form Error Summary
Use it when the operator needs an index of non-table problems after a failed Submit (GOV.UK Error summary twin). One error class → one aggregate owner: Banner = header / array-root / root; rollup = cells; popover = multi-region object.
| Situation | Summary? | How |
|---|---|---|
| Ordinary form, no Editable Table | Yes | Default scope="all" — lists every visible field + root issue, jump links |
| Form Page with header fields and line items | Yes (keep default) | FormPage scope="all"; kit strips cell paths; header + array-root stay |
| Table-only region (no header / array-root / system rules) | No | errorSummary={false} — the rollup is the aggregate |
| System / unknown throw only | Root-only | scope="form" (path == null only — not header fields, not lineItems) |
| Login / credentials | Root-only | One generic root message. Do not mark which field was wrong — Security Sensitive Failures |
| Query Filter search | Root-only | Shell default scope="form" |
| Modal with a table | Root-only by default | Modal default scope="form" + focusOnInvalid="first-field"; cells on the rollup |
| Steps Form step that is an Editable Table | No on that step | errorSummary={false} — rollup owns cells |
| Many header errors | Yes | Banner caps height + thin scroll; jump still reaches the field — Many Header Field Errors |
| Many invalid cells | Array-root only | Banner may show Some rows are invalid once. Cells stay in the rollup |
| Warning | No | Field (and optional Message Popover). Does not block Submit by itself |
| Object page, several regions, footer Validate | Optional | Message Popover is the object index; do not also dump every cell into a page summary |
scope="form" is not “everything except table cells.” It is only issues with no path.
Table Only — No Summary
No header rules. Mount no FormErrorSummary. After Submit, only the rollup lists cell problems.
Try this:
- The demo already submitted empty line 2. Confirm there is no page-level red banner above the table.
- Confirm the rollup under the table lists the bad SKU / Qty.
- Fix the cells. Confirm the rollup disappears.
"use client";
import { useEffect, useMemo } 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 { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { type Line, lineColumns, submitCatch } from "./surface-cases-shared";
/** Cells-only page: no FormErrorSummary — rollup is the aggregate. */
export function FormValidationTableOnlyNoSummaryDemo() {
const form = useMemo(
() =>
createForm<{ lines: Line[] }>({
initialValues: {
lines: [
{ id: "a", sku: "SKU-1", qty: 1 },
{ id: "b", sku: "", qty: 0 },
],
},
}),
[],
);
useEffect(() => {
submitCatch(form);
}, [form]);
return (
<div className="w-full max-w-2xl space-y-4">
<Form
form={form}
onSubmit={() => {
toast.success("Submitted");
}}
>
<EditableTable<Line>
name="lines"
variant="embedded"
columns={lineColumns}
getRowId={(row) => row.id}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</div>
);
}Scope Form Vs All
Toggle scope. Empty Customer is a header path. “Could not reach the server” has no path.
Try this:
- Leave the switch off (
scope="form"). Click Submit empty. Confirm: Customer’s inline error may show after submit, but the summary stays empty (no root issue yet). - Type
Adain Customer. Click Submit. Confirm the summary shows only Could not reach the server. - Turn the switch on (
scope="all"). Clear Customer, Submit. Confirm the summary now lists the Customer required message. Fill Customer and Submit again — root system message and no extra cell dump.
"use client";
import { useMemo, useState } from "react";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
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 { Switch } from "@/components/ui/switch";
/** `scope="form"` keeps only path-less issues — not header fields. */
export function FormValidationSummaryScopeFormDemo() {
const form = useMemo(
() =>
createForm<{ customer: string }>({
initialValues: { customer: "" },
}),
[],
);
const [scope, setScope] = useState<"all" | "form">("form");
return (
<div className="w-full max-w-md space-y-4">
<label className="flex items-center gap-2 text-sm">
<Switch
checked={scope === "all"}
onCheckedChange={(on) => setScope(on ? "all" : "form")}
/>
<span className="text-muted-foreground">
{scope === "all" ? 'scope="all"' : 'scope="form" (root only)'}
</span>
</label>
<Form
key={scope}
form={form}
onSubmit={() => ({
status: "error" as const,
issues: [
{
source: "system" as const,
message: "Could not reach the server",
},
],
})}
>
<FormErrorSummary scope={scope} />
<FormField name="customer" kind="text" label="Customer" required />
<FormActions>
<Button type="submit">
Submit empty
</Button>
</FormActions>
</Form>
</div>
);
}Object Page — Message Popover
Several regions + footer Validate: Message Popover is the object index. Do not also dump every cell into a page summary.
Try this:
- Click Validate (or Submit). Confirm the popover opens with mixed error + warning items.
- Confirm this is a region/object list — not Form Error Summary repeating SKU strings.
Purchase request · PR-1042
Draft edits stay quiet. Click Validate to open the object message list.
"use client";
import { useState } from "react";
import {
MessagePopover,
type MessageItem,
} from "@/components/f-ui/message-popover/message-popover";
import { FooterToolbar } from "@/components/f-ui/page/footer-toolbar";
import { Button } from "@/components/ui/button";
const MESSAGES: MessageItem[] = [
{
id: "e1",
severity: "error",
title: "Missing unit price",
subtitle: "Line 2 · Unit price",
group: "Line items",
description: "Enter a price greater than 0.",
},
{
id: "w1",
severity: "warning",
title: "Qty looks high",
subtitle: "Line 1 · Qty",
group: "Line items",
description: "Confirm quantity on line 1 before submit.",
},
];
/**
* Footer left = Message Popover; finalizing actions on the right.
* Open after Validate / blocked Submit — not on every draft keystroke.
*/
export function ObjectMessagingFooterPopoverDemo() {
const [open, setOpen] = useState(false);
const [items] = useState(MESSAGES);
return (
<div className="overflow-hidden rounded-xl border bg-card">
<div className="space-y-2 border-b px-6 py-3">
<p className="text-sm font-medium">Purchase request · PR-1042</p>
<p className="text-xs text-muted-foreground">
Draft edits stay quiet. Click Validate to open the object message list.
</p>
<Button
type="button"
variant="outline"
onClick={() => setOpen(true)}
>
Validate
</Button>
</div>
<FooterToolbar
className="border-t-0"
extra={
<MessagePopover
items={items}
open={open}
onOpenChange={setOpen}
/>
}
>
<Button type="button" variant="outline">
Save draft
</Button>
<Button
type="button"
onClick={() => setOpen(true)}
>
Submit
</Button>
</FooterToolbar>
</div>
);
}Steps Form — Table Step
A step that is an Editable Table: errorSummary={false}. Next still validates; the rollup owns cells.
Try this:
- Leave the SKU empty. Click Next.
- Confirm there is no Form Error Summary banner. Confirm the rollup lists the cell.
- Fix the SKU, Next, then Submit on Done.
"use client";
import { toast } from "sonner";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { StepsForm } from "@/components/f-ui/steps-form/steps-form";
import { type Line, lineColumns } from "./surface-cases-shared";
export function FormValidationStepsTableDemo() {
return (
<div className="w-full max-w-2xl">
<StepsForm
initialValues={{
lines: [{ id: "s1", sku: "", qty: 0 }],
}}
errorSummary={false}
onFinish={async () => {
toast.success("Queued");
}}
>
<StepsForm.Step name="lines" title="Lines">
<EditableTable<Line>
name="lines"
variant="embedded"
columns={lineColumns}
getRowId={(row) => row.id}
/>
</StepsForm.Step>
<StepsForm.Step name="done" title="Done">
<p className="text-muted-foreground text-sm">
Last step — write API would run on Submit here.
</p>
</StepsForm.Step>
</StepsForm>
</div>
);
}How Each Error Shows
| Error class | Example | After failed Submit you should see |
|---|---|---|
| Header / panel field | Customer required | Form Item chrome and a summary jump link |
| Array-root | “Add at least one line” on lineItems | Summary — not a fake “Row N” rollup line |
| Table cell | Line 2 SKU required | Cell value-state + rollup. Not in Form Error Summary |
| Table cell with several messages | Line Qty not a whole number, not a pack multiple, and above the approval limit | One state on the cell (the most severe). Every message in the focus popup, errors first. One rollup link per message in its own tone. Warnings do not block Submit |
| Root / system | “Could not reach the server” | Summary scope="form" or scope="all" |
| Server field issue on a header | applyFormIssues { path: "customer" } | Immediate Form Item chrome + summary |
| Server field issue on a cell | applyEditableArrayIssues | Cell + rollup — never applyFormIssues under a mounted table |
| Warning | severity: "warning" | Field (and optional Message Popover). Not in Form Error Summary. Does not block Submit by itself |
Table cells do not follow FormItem Konjević timing. They stay hide-while-focused until reveal / Submit.
This demo already failed Submit: header, array-root, cells, root/system, and a warning — each on the locked surface.
Try this:
- In Form Error Summary, find Customer (header), Some rows are invalid (array-root), and the long Could not reach the server line (root). Confirm Memo’s warning is on the field, not in the Banner.
- Confirm SKU / Qty strings are not in the summary — they are under the table rollup.
- Fill Customer. Confirm that summary row drops; the root message and rollup stay until those are fixed.
"use client";
import { useEffect, useMemo } 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 { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
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 { type Line, lineColumns, ALL_CLASS_ISSUES, mergeAfterInvalidSubmit } from "./surface-cases-shared";
/**
* One Submit: header, array-root, cells, root/system, warning — each on its
* locked surface (summary vs rollup).
*/
export function FormValidationAllClassesDemo() {
const form = useMemo(
() =>
createForm<{ customer: string; memo: string; lines: Line[] }>({
initialValues: {
customer: "",
memo: "",
lines: [
{ id: "ok", sku: "SKU-1", qty: 2 },
{ id: "bad", sku: "", qty: 0 },
],
},
}),
[],
);
useEffect(() => {
mergeAfterInvalidSubmit(form, ALL_CLASS_ISSUES);
}, [form]);
return (
<div className="w-full max-w-2xl space-y-4">
<Form
form={form}
onSubmit={() => {
toast.success("Submitted");
}}
>
<FormErrorSummary />
<FormField name="customer" kind="text" label="Customer" required />
<FormField name="memo" kind="text" label="Memo" />
<EditableTable<Line>
name="lines"
variant="embedded"
columns={lineColumns}
getRowId={(row) => row.id}
/>
<FormActions>
<Button type="submit">
Submit again
</Button>
</FormActions>
</Form>
</div>
);
}Array Root Stays In The Summary
Empty table is an array-root rule, not a cell.
Try this:
- Type a Customer name so header validation passes.
- Leave no lines. Click Submit.
- Confirm Form Error Summary lists Add at least one line. Confirm there is no rollup Row N for that message.
- Click Add row, Submit again. Confirm the array-root message is gone and cell errors (if the new row is empty) move to the rollup.
"use client";
import { useMemo } 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 { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
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 { type Line, lineColumns } from "./surface-cases-shared";
/** Array-root “add at least one line” stays in the summary, not a fake Row N. */
export function FormValidationArrayRootDemo() {
const form = useMemo(
() =>
createForm<{ customer: string; lines: Line[] }>({
initialValues: { customer: "", lines: [] },
}),
[],
);
return (
<div className="w-full max-w-2xl space-y-4">
<Form
form={form}
focusOnInvalid="summary"
onSubmit={(values) => {
if (values.lines.length === 0) {
return {
status: "error" as const,
issues: [
{
source: "submit" as const,
path: "lines",
message: "Add at least one line",
},
],
};
}
toast.success("Submitted");
}}
>
<FormErrorSummary />
<FormField name="customer" kind="text" label="Customer" required />
<EditableTable<Line>
name="lines"
variant="embedded"
columns={lineColumns}
getRowId={(row) => row.id}
recordCreator={{
record: () => ({
id: crypto.randomUUID(),
sku: "",
qty: 1,
}),
}}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</div>
);
}Several Messages On One Cell
A line cell can break several rules at once. The cell paints one state, the most severe present, so the ring never mixes colours. Focus the cell to read every message, errors first, each with its own icon. Past three messages, View all N messages expands a scrollable full list. Clicking it or pressing Alt+M preserves input focus and the caret. Tab deliberately enters the popup; Escape collapses the list or dismisses the preview, returning to the editor when necessary. Expand the row: the panel lists every line, with no cap. The same sentence is kept once; when it is both an error and a warning, the error stays. A locked control keeps neutral field chrome; its popup shows stored messages and the lock reason. The rollup still lists that cell, one link per message, and its heading counts errors and warnings separately. Warnings do not add to the error count.
Try this:
- Click Submit. Focus Qty on SKU-100. Confirm two errors and one warning, errors first. Leave the cell. Confirm the red ring stays.
- In the rollup, confirm OLD-7’s two warnings use warning tone.
- Focus SKU on FIVE. Confirm three lines and View all 5 messages. Press Alt+M to see all five while keeping the input focused. Tab into the popup to scroll with the keyboard, then press Escape to return. The expanded row also shows all five, including “Tax code is not on the order”.
- Focus SKU on WARN. Confirm a warning ring and two warnings. Focus SKU on ERR. Confirm two errors and no warning.
- Focus SKU on OLD-9. Confirm the lock reason, and no discontinued message on the cell. Confirm the rollup still lists “This SKU is discontinued” for that row.
- Focus SKU on DUP. Confirm “Listed twice by the catalog” once. Focus SKU on BOTH. Confirm one error, “Needs a buyer”, and no second copy as a warning.
- Click Apply server issues. Focus SKU on SRC. Confirm “Catalog check failed”, “Submitted after the cutoff”, “Server rejected the vendor”, then “And 1 more”. Confirm the rollup’s last SKU link on that row is “Pricing service is down”.
- Change SKU-100 Qty to
96. Confirm that cell’s three links clear. Submit stays blocked while FIVE, ERR, DUP, BOTH, and SRC still have errors.
"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 { applyEditableArrayIssues } from "@/components/f-ui/editable-table/lib/editable-array-issues";
import { f } from "@/components/f-ui/field-types/catalog";
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";
type OrderLine = {
id: string;
sku: string;
qty: number;
price: number;
/** Secondary panel field. Only the FIVE row fills it with five errors. */
fullList: string;
};
const PACK_SIZE = 6;
const FIVE_MESSAGES = [
"Missing contract",
"Outside the buying calendar",
"Vendor is blocked",
"Quantity exceeds the open PO",
"Tax code is not on the order",
] as const;
function rowId(row: unknown): string {
if (typeof row !== "object" || row == null || !("id" in row)) return "";
return String((row as OrderLine).id);
}
function whenRow(id: string, message: string | { type: "warning"; message: string }) {
return (_value: unknown, row?: unknown) => (rowId(row) === id ? message : undefined);
}
const columns = defineEditableColumns<OrderLine>({
sku: {
...f.text({ label: "SKU" }),
size: 140,
field: {
component: [Input],
required: true,
readOnly: (row) => rowId(row) === "locked",
lockReason: (row) =>
rowId(row) === "locked" ? "Warehouse count is in progress" : undefined,
validator: [
(value) =>
String(value ?? "").startsWith("OLD-")
? { type: "warning", message: "This SKU is discontinued" }
: undefined,
...FIVE_MESSAGES.map((message) => whenRow("cap", message)),
whenRow("warnOnly", { type: "warning", message: "Slow-moving SKU" }),
whenRow("warnOnly", { type: "warning", message: "A substitute exists" }),
whenRow("errOnly", "SKU is not on the agreement"),
whenRow("errOnly", "Plant does not stock this SKU"),
whenRow("dedupe", "Listed twice by the catalog"),
whenRow("dedupe", "Listed twice by the catalog"),
whenRow("both", "Needs a buyer"),
whenRow("both", { type: "warning", message: "Needs a buyer" }),
whenRow("sources", "Catalog check failed"),
],
},
},
qty: {
...f.number({ label: "Qty" }),
size: 96,
field: {
component: [NumberInput, { surface: "tableCell" }],
required: true,
validator: [
(v) => (Number.isInteger(Number(v)) ? undefined : "Qty must be a whole number"),
(v) =>
Number(v) % PACK_SIZE === 0
? undefined
: `Qty must be a multiple of the pack size (${PACK_SIZE})`,
(v) =>
Number(v) > 100
? { type: "warning", message: "Qty above 100 needs a second approver" }
: undefined,
],
},
},
price: {
...f.number({ label: "Unit price" }),
size: 112,
field: {
component: [NumberInput, { surface: "tableCell" }],
validator: (v) =>
Number(v) > 1000
? { type: "warning", message: "Unit price is above list price" }
: undefined,
},
},
fullList: {
...f.text({ label: "Full list" }),
tier: "secondary",
field: {
component: [Input],
validator: FIVE_MESSAGES.map((message) => whenRow("cap", message)),
},
},
});
const LINES: OrderLine[] = [
{ id: "mixed", sku: "SKU-100", qty: 100.5, price: 12, fullList: "" },
{ id: "warnings", sku: "OLD-7", qty: 12, price: 1200, fullList: "" },
{ id: "clean", sku: "SKU-102", qty: 6, price: 8, fullList: "" },
{ id: "cap", sku: "FIVE", qty: 6, price: 8, fullList: "five rules" },
{ id: "warnOnly", sku: "WARN", qty: 6, price: 8, fullList: "" },
{ id: "errOnly", sku: "ERR", qty: 6, price: 8, fullList: "" },
{ id: "locked", sku: "OLD-9", qty: 6, price: 8, fullList: "" },
{ id: "dedupe", sku: "DUP", qty: 6, price: 8, fullList: "" },
{ id: "both", sku: "BOTH", qty: 6, price: 8, fullList: "" },
{ id: "sources", sku: "SRC", qty: 6, price: 8, fullList: "" },
];
/** One row per cell-message case: mixed, cap, panel, warning-only, error-only, locked, dedupe, sources. */
export function FormValidationMultiMessageCellDemo() {
const form = useMemo(
() =>
createForm<{ lines: OrderLine[] }>({
initialValues: { lines: LINES },
}),
[],
);
return (
<div className="w-full max-w-3xl space-y-4">
<Form
form={form}
onSubmit={() => {
toast.success("Submitted with warnings for the approver");
}}
>
<EditableTable<OrderLine>
name="lines"
variant="embedded"
columns={columns}
getRowId={(row) => row.id}
editMode="inline"
expand={{ autoExpandOnError: false, expandAll: false }}
features={{ rowActions: false, footer: false }}
/>
<FormActions>
<Button
type="button"
variant="outline"
onClick={() => {
applyEditableArrayIssues(form, {
arrayPath: "lines",
issues: [
{
rowId: "sources",
colKey: "sku",
message: "Submitted after the cutoff",
source: "submit",
severity: "error",
},
{
rowId: "sources",
colKey: "sku",
message: "Server rejected the vendor",
source: "server",
severity: "error",
},
{
rowId: "sources",
colKey: "sku",
message: "Pricing service is down",
source: "system",
severity: "error",
},
],
});
}}
>
Apply server issues
</Button>
<Button type="submit">Submit</Button>
</FormActions>
</Form>
</div>
);
}What The Approver Sees
The same warnings travel with the submitted lines. The approver’s table is display text, so it has no value state: no input-shaped error ring, and the value keeps its own colour. Error and warning cells use a subtle semantic background by default. A flagged cell gets a small triangle in its top-right corner, in the most severe tone, the way a spreadsheet marks a comment. The triangle takes no width, so the value keeps the whole cell at any column width; a long value ends in an ellipsis. The whole cell is the button: click it, or focus it and press Enter, to open every message on that cell. More than one message opens with a count heading. Row height does not change. A flagged row carries a leading bar. The list of every message is a count button in the toolbar, not a banner under the table. Picking a message lands on that cell and opens it. Keyboard focus uses an inset rectangular boundary around the whole cell; it does not turn the value into a rounded input.
Try this:
- Scan the table. Find the corner marks on SKU-100’s Qty, OLD-7’s SKU and Unit price, and Tailspin’s Unit price, Customer, and Invoice. Confirm SKU-102 has none, and every row is one line high.
- Click OLD-7’s Unit price cell. Confirm the list opens with the heading “3 warning(s)”. Click SKU-100’s Qty cell and confirm one message, with no heading.
- Click Tailspin’s Customer cell. Confirm all five messages, with no “more” line.
- Drag the Customer column edge as narrow as it goes. Confirm the column stops while a few characters and an ellipsis still show, and the corner mark stays.
- Click 12 warning(s) in the toolbar. Click “Invoice number is also on row 1”. Confirm the list closes, focus lands on that Invoice cell, and its message opens.
"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,
type EditableArrayIssue,
} from "@/components/f-ui/editable-table/lib/editable-array-issues";
import { f } from "@/components/f-ui/field-types/catalog";
import { Form } from "@/components/f-ui/formily/form";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import type { RowHighlight } from "@/components/f-ui/table/lib/row-highlight";
type SubmittedLine = {
id: string;
sku: string;
qty: number;
price: number;
customer: string;
invoice: string;
};
const columns = defineEditableColumns<SubmittedLine>({
sku: { ...f.text({ label: "SKU" }), size: 120 },
qty: { ...f.number({ label: "Qty" }), size: 96 },
price: { ...f.number({ label: "Unit price" }), size: 112 },
customer: { ...f.text({ label: "Customer" }), size: 160 },
invoice: { ...f.text({ label: "Invoice" }), size: 120 },
});
const LINES: SubmittedLine[] = [
{ id: "a", sku: "SKU-100", qty: 120, price: 12, customer: "Northwind", invoice: "INV-2611" },
{ id: "b", sku: "OLD-7", qty: 12, price: 1200, customer: "Contoso", invoice: "INV-2612" },
{ id: "c", sku: "SKU-102", qty: 6, price: 8, customer: "Fabrikam", invoice: "INV-2613" },
{ id: "d", sku: "SKU-205", qty: 40, price: 7.97, customer: "Tailspin", invoice: "INV-2611" },
];
function warning(rowId: string, colKey: keyof SubmittedLine, message: string): EditableArrayIssue {
return { rowId, colKey, message, source: "server", severity: "warning" };
}
const ISSUES: EditableArrayIssue[] = [
warning("a", "qty", "Qty above 100 needs a second approver"),
warning("b", "sku", "This SKU is discontinued"),
warning("b", "price", "Unit price is above list price"),
warning("b", "price", "Price is above the last purchase"),
warning("b", "price", "No price agreement for this customer"),
warning("d", "price", "Price is below the floor price"),
warning("d", "customer", "Customer is not on the rebate agreement"),
warning("d", "customer", "Customer credit hold"),
warning("d", "customer", "Ship-to differs from the order"),
warning("d", "customer", "Customer has an open dispute"),
warning("d", "customer", "Tax ID is not verified"),
warning("d", "invoice", "Invoice number is also on row 1"),
];
const ROWS_WITH_ISSUES = new Set(ISSUES.map((issue) => issue.rowId));
function rowHighlight(row: SubmittedLine): RowHighlight {
return ROWS_WITH_ISSUES.has(row.id) ? "warning" : "none";
}
/** Submitted lines as the approver reads them: display cells, a mark per flagged cell. */
export function FormValidationMultiMessageApproverDemo() {
const form = useMemo(
() => createForm<{ lines: SubmittedLine[] }>({ initialValues: { lines: LINES } }),
[],
);
useEffect(() => {
applyEditableArrayIssues(form, { arrayPath: "lines", issues: ISSUES });
}, [form]);
return (
<div className="w-full max-w-3xl">
<Form form={form} onSubmit={() => undefined}>
<EditableTable<SubmittedLine>
name="lines"
variant="embedded"
columns={columns}
getRowId={(row) => row.id}
readOnly
rollupPlacement="toolbar"
getRowHighlight={rowHighlight}
features={{ rowActions: false, footer: false }}
/>
</Form>
</div>
);
}Use that display table when nothing on the record can change: a closed or archived document. While an approval is still open, keep the applicant’s cells as fields.
An Approval In Progress
One table holds two kinds of column. Derived and system columns are text, for everyone. Applicant-filled columns keep their field box, for everyone. The approver sees those boxes locked, so shape alone tells a reviewer which values a person typed. Both viewers use one set of columns; only readOnly differs.
Lock fields per column with field.readOnly(row) and lockReason. Do not set the table’s readOnly here: that turns every cell into text, and the field boxes are gone. A locked cell has no value state, so its messages show as a corner mark on the cell, and the lock stays the only glyph in the box. A locked box can still take focus: focus or click it, and one popup lists its messages with the lock reason last. A jump lands on that box and opens the same popup.
When the approver adjusts a value, send a note with applyEditableArrayIssues and severity: "info". A note is not an issue. It does not block submit, it gets no value state, and the rollup does not list it. It shows only as a corner mark in the info tone, and focusing the field opens it.
Your product decides which columns an approver may adjust and what the unlock control is called. It also decides whether a change needs a comment and whether it sends the claim back for re-approval. The table only shows the result.
Try this:
- On Approver, confirm Agreement and Rebate rate are text, and SKU, Qty, and Unit price are boxes, each with a lock.
- Confirm SKU-100’s Qty cell has a warning corner mark and its box shows only the lock. The locked controls keep neutral borders; their cells have a subtle warning background. Click OLD-7’s Unit price box, and confirm the popup reads “2 warning(s)”, lists both, and ends with the lock reason.
- Click Unlock fields. Confirm Qty and Unit price lose their locks, and SKU keeps its lock.
- Change OLD-7’s Qty to
10. Confirm an info corner mark appears on that cell. Focus the field, and confirm it reads “Applicant entered 12”. Confirm the toolbar count does not change. - Switch to Applicant. Confirm the same boxes are there without locks, and the warnings show as each field’s value state.
Rebate claim lines
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { onFieldValueChange } from "@formily/core";
import { LockOpen, Lock } from "lucide-react";
import { Button } from "@/components/f-ui/button/button";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import {
applyEditableArrayIssues,
type EditableArrayIssue,
} from "@/components/f-ui/editable-table/lib/editable-array-issues";
import { f } from "@/components/f-ui/field-types/catalog";
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 { SectionCard } from "@/components/f-ui/section-card/section-card";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
type ClaimLine = {
id: string;
agreement: string;
rate: string;
sku: string;
qty: number;
price: number;
};
type Viewer = "applicant" | "approver";
/** Applicant-filled columns the approver may adjust once unlocked. */
const APPROVER_ADJUSTABLE = new Set<keyof ClaimLine>(["qty", "price"]);
const SUBMITTED: ClaimLine[] = [
{ id: "a", agreement: "RA-2026-014", rate: "3%", sku: "SKU-100", qty: 120, price: 12 },
{ id: "b", agreement: "RA-2026-014", rate: "3%", sku: "OLD-7", qty: 12, price: 1200 },
{ id: "c", agreement: "RA-2026-020", rate: "5%", sku: "SKU-102", qty: 6, price: 8 },
];
const SERVER_WARNINGS: EditableArrayIssue[] = [
{ rowId: "a", colKey: "qty", message: "Qty above 100 needs a second approver", severity: "warning", source: "server" },
{ rowId: "b", colKey: "price", message: "Unit price is above list price", severity: "warning", source: "server" },
{ rowId: "b", colKey: "price", message: "No price agreement for this customer", severity: "warning", source: "server" },
];
/** One note per adjusted cell, naming what the applicant entered. */
function adjustmentNotes(lines: readonly ClaimLine[]): EditableArrayIssue[] {
const notes: EditableArrayIssue[] = [];
for (const line of lines) {
const submitted = SUBMITTED.find((item) => item.id === line.id);
if (!submitted) continue;
for (const key of APPROVER_ADJUSTABLE) {
if (line[key] === submitted[key]) continue;
notes.push({
rowId: line.id,
colKey: key,
message: `Applicant entered ${submitted[key]}`,
severity: "info",
source: "system",
});
}
}
return notes;
}
function lockFor(viewer: Viewer, unlocked: boolean, key: keyof ClaimLine) {
if (viewer === "applicant") return undefined;
if (unlocked && APPROVER_ADJUSTABLE.has(key)) return undefined;
return {
readOnly: () => true,
lockReason: APPROVER_ADJUSTABLE.has(key)
? "Filled by the applicant. Unlock fields to adjust."
: "Only the applicant can change this field.",
};
}
/**
* An approval in progress: derived columns are text, applicant-filled columns
* keep their field box. The approver sees them locked, unlocks the adjustable
* ones, and each adjusted cell notes what the applicant entered.
*/
export function FormValidationApprovalInProgressDemo() {
const [viewer, setViewer] = useState<Viewer>("approver");
const [unlocked, setUnlocked] = useState(false);
const viewerRef = useRef(viewer);
viewerRef.current = viewer;
const form = useMemo(
() =>
createForm<{ lines: ClaimLine[] }>({
initialValues: { lines: SUBMITTED.map((line) => ({ ...line })) },
effects() {
onFieldValueChange("lines.*.*", (field) => {
if (viewerRef.current !== "approver") return;
applyEditableArrayIssues(field.form, {
arrayPath: "lines",
issues: adjustmentNotes(field.form.values.lines ?? []),
});
});
},
}),
[],
);
useEffect(() => {
applyEditableArrayIssues(form, { arrayPath: "lines", issues: SERVER_WARNINGS }); }, [form]);
const columns = useMemo(
() =>
defineEditableColumns<ClaimLine>({
agreement: { ...f.text({ label: "Agreement" }), size: 128 },
rate: { ...f.text({ label: "Rebate rate" }), size: 104 },
sku: {
...f.text({ label: "SKU" }),
size: 128,
field: { component: [Input, { surface: "tableCell" }], ...lockFor(viewer, unlocked, "sku") },
},
qty: {
...f.number({ label: "Qty" }),
size: 120,
field: { component: [NumberInput, { surface: "tableCell" }], ...lockFor(viewer, unlocked, "qty") },
},
price: {
...f.number({ label: "Unit price" }),
size: 152,
field: { component: [NumberInput, { surface: "tableCell" }], ...lockFor(viewer, unlocked, "price") },
},
}),
[viewer, unlocked],
);
return (
<div className="flex w-full max-w-3xl flex-col gap-4">
<Tabs value={viewer} onValueChange={(value) => setViewer(value as Viewer)}>
<TabsList>
<TabsTrigger value="applicant">Applicant</TabsTrigger>
<TabsTrigger value="approver">Approver</TabsTrigger>
</TabsList>
</Tabs>
<SectionCard
title="Rebate claim lines"
tools={
viewer === "approver" ? (
<Button
variant="outline"
icon={unlocked ? <Lock aria-hidden /> : <LockOpen aria-hidden />}
onClick={() => setUnlocked((value) => !value)}
>
{unlocked ? "Lock fields" : "Unlock fields"}
</Button>
) : null
}
>
<Form form={form} onSubmit={() => undefined}>
<EditableTable<ClaimLine>
name="lines"
variant="embedded"
editMode="inline"
columns={columns}
getRowId={(row) => row.id}
rollupPlacement="toolbar"
features={{ rowActions: false, footer: false }}
/>
</Form>
</SectionCard>
</div>
);
}Shell Defaults
| Shell | Form Error Summary | Table rollup | Focus after invalid Submit |
|---|---|---|---|
| Form (primitive) | Host composes | On with Editable Table | false unless you set it |
| Form Page | Default on, scope="all" | On | "summary" |
| Modal Form | Default scope="form" | On | "first-field" |
| Steps Form | Keep on when the step has header/array-root; false when cells-only | On | Shell default |
| Query Filter | scope="form" | — | "first-field" |
Modal With A Table
Dialog summary is root-only. Cell errors must not become a long dialog essay.
Try this:
- Click Open line editor. Click the dialog Submit with the empty SKU.
- Confirm the rollup lists the cell. Confirm the dialog summary does not list SKU required (no root issue).
- Fix the line and Submit. Confirm the dialog can close.
"use client";
import { toast } from "sonner";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { ModalForm } from "@/components/f-ui/modal-form/modal-form";
import { Button } from "@/components/ui/button";
import { type Line, lineColumns } from "./surface-cases-shared";
type ModalLines = { lines: Line[] };
export function FormValidationModalTableDemo() {
return (
<ModalForm<ModalLines>
trigger={<Button variant="outline">Open line editor</Button>}
title="Line items"
description="Dialog default summary is root-only. Cell errors stay on the rollup."
initialValues={{
lines: [{ id: "m1", sku: "", qty: 0 }],
}}
onFinish={async () => {
toast.success("Saved");
}}
>
<EditableTable<Line>
name="lines"
variant="embedded"
columns={lineColumns}
getRowId={(row) => row.id}
/>
</ModalForm>
);
}Header Fields Plus Editable Table
This is the usual create/edit page: customer / dates on top, line items in an embedded table, one Submit.
Try this:
- Leave Customer empty. Leave line 2 SKU empty and Qty
0. Click Submit. - Confirm Form Error Summary lists Customer (header). Confirm it does not list “SKU required” / “must be > 0” — those belong under the table.
- Confirm the rollup under the table lists the bad line(s) and jump links scroll/focus the cell.
- Type a customer name (no extra blur needed once shown). Confirm the summary row for Customer disappears; the rollup still lists line errors until those cells are valid.
- Click a rollup jump. Confirm the cell shows value-state on focus (not a second Input border, not a hover tooltip).
"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 { 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 { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
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";
import { Button } from "@/components/ui/button";
type Line = { id: string; sku: string; qty: number };
type Values = {
customer: string;
lines: Line[];
};
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: 1 }],
required: true,
validator: (v) => (Number(v) > 0 ? undefined : "must be > 0"),
},
},
});
export function FormValidationHeaderPlusEditableTableDemo() {
const form = useMemo(
() =>
createForm<Values>({
initialValues: {
customer: "",
lines: [
{ id: "l1", sku: "SKU-104", qty: 2 },
{ id: "l2", sku: "", qty: 0 },
],
},
}),
[],
);
return (
<div className="w-full max-w-2xl space-y-4">
<Form
form={form}
focusOnInvalid="summary"
onSubmit={() => {
toast.success("Submitted");
}}
>
<FormErrorSummary />
<FormField
name="customer"
kind="text"
label="Customer"
required
/>
<EditableTable<Line>
name="lines"
variant="embedded"
columns={columns}
getRowId={(row) => row.id}
recordCreator={{
record: () => ({
id: crypto.randomUUID(),
sku: "",
qty: 1,
}),
}}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</div>
);
}Capstone with cross-row rules: Editable Table — Complete Order Entry. FormPage wiring: Form Page — Embedded Editable Table.
Extreme Cases
These demos are already in the failed-Submit state (or one click away) so you can see a long list, not imagine it.
Many Header Field Errors
16 empty required fields. The summary is the index: it scrolls (max-height + thin scrollbar), and jump links do not stop at “first three.” Submit stays enabled.
Try this:
- Scroll the summary — every field is listed, not truncated.
- Click a jump link near the bottom (e.g. External ref). Confirm the page moves to that field.
- Fill one field. Confirm that field’s row disappears from the summary. Do not expect Submit to disable.
"use client";
import { useEffect, useMemo } from "react";
import { toast } from "sonner";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
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 { submitCatch, MANY_HEADER_FIELDS } from "./surface-cases-shared";
/** 16 empty required header fields — summary is the index and it scrolls. */
export function FormValidationManyHeaderErrorsDemo() {
const initialValues = useMemo(() => {
const values: Record<string, string> = {};
for (const label of MANY_HEADER_FIELDS) {
values[label.replace(/\s+/g, "")] = "";
}
return values;
}, []);
const form = useMemo(
() => createForm({ initialValues }),
[initialValues],
);
useEffect(() => {
submitCatch(form);
}, [form]);
return (
<div className="w-full max-w-2xl space-y-4">
<Form
form={form}
onSubmit={() => {
toast.success("Submitted");
}}
>
<FormErrorSummary />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{MANY_HEADER_FIELDS.map((label) => (
<FormField
key={label}
name={label.replace(/\s+/g, "")}
kind="text"
label={label}
required
/>
))}
</div>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</div>
);
}Many Invalid Line Cells
24 lines, most invalid. Summary may show the array-root Some rows are invalid (one line). The rollup lists cells and scrolls. It does not dump 40 SKU messages into the page summary.
Try this:
- Confirm Form Error Summary does not repeat every SKU/Qty string.
- Scroll the rollup under the table. Confirm many
Row N · SKU/Qtylinks. - Click a jump for a row that is below the table fold. Confirm the table scrolls and the cell focuses.
- Fill Customer is already set — this is a table-volume problem, not a missing header.
"use client";
import { useEffect, useMemo } 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 { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
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 { type Line, lineColumns, submitCatch, manyLines } from "./surface-cases-shared";
/** 24 lines, most invalid — rollup scrolls; summary does not dump every cell. */
export function FormValidationManyCellErrorsDemo() {
const form = useMemo(
() =>
createForm<{ customer: string; lines: Line[] }>({
initialValues: {
customer: "Northwind",
lines: manyLines(24),
},
}),
[],
);
useEffect(() => {
submitCatch(form);
}, [form]);
return (
<div className="w-full max-w-2xl space-y-4">
<Form
form={form}
onSubmit={() => {
toast.success("Submitted");
}}
>
<FormErrorSummary />
<FormField name="customer" kind="text" label="Customer" required />
<EditableTable<Line>
name="lines"
variant="embedded"
columns={lineColumns}
getRowId={(row) => row.id}
features={{ view: { mode: "all", maxHeight: 220 } }}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</div>
);
}Invalid Rows Hidden By Search
Submit validates the full array, not the visible page.
Try this:
- In the table search box, type
WIRE. Confirm the empty-SKU row disappears from the view. - Click Submit. Confirm it still fails and the rollup still lists the hidden row.
- Click the rollup jump. Confirm search clears and the bad row is focused.
"use client";
import { useMemo } 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 { type Line, lineColumns } from "./surface-cases-shared";
/** Search hides an invalid row — Submit still fails; jump reveals it. */
export function FormValidationHiddenRowSearchDemo() {
const form = useMemo(
() =>
createForm<{ lines: Line[] }>({
initialValues: {
lines: [
{ id: "ok", sku: "WIRE-1", qty: 4 },
{ id: "bad", sku: "", qty: 0 },
{ id: "ok2", sku: "WIRE-2", qty: 2 },
],
},
}),
[],
);
return (
<div className="w-full max-w-2xl space-y-4">
<Form
form={form}
onSubmit={() => {
toast.success("Submitted");
}}
>
<EditableTable<Line>
name="lines"
variant="embedded"
columns={lineColumns}
getRowId={(row) => row.id}
features={{ search: true }}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</div>
);
}Drafting — Rollup Off
showErrorRollup={false} until Validate so a large grid stays quiet.
Try this:
- Leave the switch off. Click Validate. Confirm cells can reveal but no rollup band appears.
- Turn the switch on. Click Validate again. Confirm the rollup appears.
"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 { Switch } from "@/components/ui/switch";
import { type Line, lineColumns } from "./surface-cases-shared";
/** Defer the loud rollup while drafting. */
export function FormValidationDraftRollupDemo() {
const [showRollup, setShowRollup] = useState(false);
const form = useMemo(
() =>
createForm<{ lines: Line[] }>({
initialValues: {
lines: [
{ id: "d1", sku: "", qty: 0 },
{ id: "d2", sku: "", qty: 0 },
],
},
}),
[],
);
return (
<div className="w-full max-w-2xl space-y-4">
<label className="flex items-center gap-2 text-sm">
<Switch checked={showRollup} onCheckedChange={setShowRollup} />
<span className="text-muted-foreground">
showErrorRollup={String(showRollup)}
</span>
</label>
<Form
form={form}
onSubmit={() => {
toast.success("Submitted");
}}
>
<EditableTable<Line>
name="lines"
variant="embedded"
columns={lineColumns}
getRowId={(row) => row.id}
showErrorRollup={showRollup}
/>
<FormActions>
<Button type="submit">
Validate
</Button>
</FormActions>
</Form>
</div>
);
}Two Editable Tables
Each table owns its own rollup.
Try this:
- Confirm two rollups (Charges and Notes), not one merged page list of cells.
- Fix only Charges. Confirm the Notes rollup remains.
"use client";
import { useEffect, useMemo } 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 { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { type Line, lineColumns, submitCatch } from "./surface-cases-shared";
/** Two tables → two rollups. */
export function FormValidationTwoTablesDemo() {
const form = useMemo(
() =>
createForm<{ charges: Line[]; notes: Line[] }>({
initialValues: {
charges: [{ id: "c1", sku: "", qty: 0 }],
notes: [{ id: "n1", sku: "", qty: 0 }],
},
}),
[],
);
useEffect(() => {
submitCatch(form);
}, [form]);
return (
<div className="w-full max-w-2xl space-y-6">
<Form
form={form}
onSubmit={() => {
toast.success("Submitted");
}}
>
<FormErrorSummary />
<p className="text-muted-foreground text-sm font-medium">Charges</p>
<EditableTable<Line>
name="charges"
variant="embedded"
columns={lineColumns}
getRowId={(row) => row.id}
/>
<p className="text-muted-foreground text-sm font-medium">Notes</p>
<EditableTable<Line>
name="notes"
variant="embedded"
columns={lineColumns}
getRowId={(row) => row.id}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</div>
);
}Dual Scream (Forbidden)
Same cell copy must not appear as a page summary and a rollup.
Try this:
- Left: stacked essays listing SKU/Qty and “N row(s) need attention.”
- Right: rollup only (kit). That is the ship rule.
Wrong
There is a problem
- SKU is required
- Qty must be > 0
1 error(s) need attention
Row 2 · SKU: SKU is required
Right
"use client";
import { DesignCompare } from "@/demos/_design/design-compare";
import {
formErrorBannerBodyInnerClassName,
formErrorBannerHeadingClassName,
formErrorBannerLinkClassName,
formErrorBannerRootClassName,
} from "@/components/f-ui/formily/internals/form-error-banner-chrome";
import { cn } from "@/lib/utils";
import { FormValidationTableOnlyNoSummaryDemo } from "./table-only-no-summary-demo";
/** Dual scream vs one owner — wrong is stacked Banners of the same SKU, not old pink Alert chrome. */
export function FormValidationDualScreamCompareDemo() {
return (
<DesignCompare
wrong={
<div className="space-y-2 text-sm">
<div className={formErrorBannerRootClassName} role="presentation">
<p className={formErrorBannerHeadingClassName}>There is a problem</p>
<ul className={cn(formErrorBannerBodyInnerClassName, "space-y-1")}>
<li>
<span className={formErrorBannerLinkClassName}>SKU is required</span>
</li>
<li>
<span className={formErrorBannerLinkClassName}>
Qty must be > 0
</span>
</li>
</ul>
</div>
<div className={formErrorBannerRootClassName}>
<p className={formErrorBannerHeadingClassName}>
1 error(s) need attention
</p>
<p
className={cn(
formErrorBannerBodyInnerClassName,
formErrorBannerLinkClassName,
"text-xs",
)}
>
Row 2 · SKU: SKU is required
</p>
</div>
</div>
}
right={<FormValidationTableOnlyNoSummaryDemo />}
/>
);
}Combined Volume — Headers Plus Cells Plus Root
The usual “the whole page is red” case: many required headers, many invalid lines, plus a root/system message and a warning. Summary scrolls; rollup scrolls; cells stay out of the summary.
Try this:
- Scroll the summary. Count should include the eight headers + root + warning + array-root — not 16× SKU strings.
- Scroll the rollup. Confirm cell links, not a second copy of Customer required.
- Click a jump near the bottom of the summary (e.g. External ref). Confirm the field is reached.
"use client";
import { useEffect, useMemo } 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 { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
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 { type Line, lineColumns, manyLines, ALL_CLASS_ISSUES, mergeAfterInvalidSubmit, VOLUME_HEADERS } from "./surface-cases-shared";
/** Header volume + cell volume + root + warning in one failed Submit. */
export function FormValidationCombinedVolumeDemo() {
const initialValues = useMemo(() => {
const values: Record<string, string | Line[]> = {};
for (const label of VOLUME_HEADERS) {
values[label.replace(/\s+/g, "")] = "";
}
values.memo = "";
values.lines = manyLines(16);
return values;
}, []);
const form = useMemo(
() => createForm({ initialValues }),
[initialValues],
);
useEffect(() => {
mergeAfterInvalidSubmit(form, ALL_CLASS_ISSUES);
}, [form]);
return (
<div className="w-full max-w-2xl space-y-4">
<Form
form={form}
onSubmit={() => {
toast.success("Submitted");
}}
>
<FormErrorSummary />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{VOLUME_HEADERS.map((label) => (
<FormField
key={label}
name={label.replace(/\s+/g, "")}
kind="text"
label={label}
required
/>
))}
</div>
<FormField name="memo" kind="text" label="Memo" />
<EditableTable<Line>
name="lines"
variant="embedded"
columns={lineColumns}
getRowId={(row) => row.id}
features={{ view: { mode: "all", maxHeight: 200 } }}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</div>
);
}Nested Child Table
Parent SKU + nested claim qty. Child errors fold into the parent rollup (expand + jump). Still not a page-summary essay.
Try this:
- Confirm row 1 is expanded and the nested Claim qty is invalid.
- Confirm row 2 empty SKU is on the parent rollup.
- Confirm Form Error Summary does not list
claims.0.qty.
"use client";
import { useEffect, 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 { f } from "@/components/f-ui/field-types/catalog";
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 { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { submitCatch } from "./surface-cases-shared";
type Claim = { id: string; qty: number; note: string };
type NestedLine = {
id: string;
sku: string;
claims: Claim[];
};
const nestedParentColumns = defineEditableColumns<NestedLine>({
sku: {
...f.text({ label: "SKU" }),
size: 140,
field: { component: [Input], required: true },
},
});
const nestedClaimColumns = defineEditableColumns<Claim>({
qty: {
...f.number({ label: "Claim qty" }),
size: 110,
field: {
component: [NumberInput, { surface: "tableCell", min: 1 }],
required: true,
validator: (v) => (Number(v) > 0 ? undefined : "must be > 0"),
},
},
note: {
...f.text({ label: "Note" }),
field: { component: [Input] },
},
});
/** Parent cell + nested child cell — two rollup languages, still not in the page summary. */
export function FormValidationNestedTableErrorsDemo() {
const form = useMemo(
() =>
createForm<{ lines: NestedLine[] }>({
initialValues: {
lines: [
{
id: "p1",
sku: "A-100",
claims: [{ id: "c1", qty: 0, note: "" }],
},
{
id: "p2",
sku: "",
claims: [],
},
],
},
}),
[],
);
useEffect(() => {
submitCatch(form);
}, [form]);
return (
<div className="w-full max-w-2xl space-y-4">
<Form
form={form}
onSubmit={() => {
toast.success("Submitted");
}}
>
<FormErrorSummary />
<EditableTable<NestedLine>
name="lines"
variant="embedded"
columns={nestedParentColumns}
getRowId={(row) => row.id}
nested={{
path: "claims",
columns: nestedClaimColumns,
getChildRowId: (c) => c.id,
defaultExpanded: true,
recordCreator: {
record: () => ({
id: crypto.randomUUID(),
qty: 1,
note: "",
}),
},
}}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</div>
);
}Precedent: GOV.UK Error summary for page field indexes; Fiori for not repeating every table cell at page root. Kit lock: Object Messaging — Validation Surface Matrix.
3. When Errors Show
Pick a policy on <Form revealErrors>. Submit still validates every rule — this only changes when chrome appears. Once a field is showing an error, the message stays until that field is valid (including while focused). New errors after a failed Submit wait for blur.
| Policy | First paint | After an error is showing | Use when |
|---|---|---|---|
submit (default) | Failed Submit (or mapped field issues) | Clears as soon as the value is valid. New invalid fields wait for blur | Long create / edit forms |
touch | Blur of an invalid field (empty required included) | Same clear-as-you-fix | Short forms, settings, where leaving a field is a commit |
always | As soon as the field is invalid — no Submit wait | Live | Password strength, character count. Not the long-form default |
First keystroke / first focus of a fresh field never paints under submit or touch. Hosts that used touch as “show while typing on first entry” must switch to always. Walk every numbered case in this section.
Table cells are a different language (hide while focused). Do not copy these FormItem steps onto Editable Table.
Policy Overview
Toggle submit / touch / always on one required field. Each mode’s helper text is the contract for that policy.
"use client";
import { useMemo, useState } from "react";
import { toast } from "sonner";
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 type { RevealErrorsPolicy } from "@/components/f-ui/formily/internals/reveal-errors";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type Values = { title: string };
const MODES: RevealErrorsPolicy[] = ["submit", "touch", "always"];
export function FormValidationRevealErrorsModesDemo() {
const [mode, setMode] = useState<RevealErrorsPolicy>("submit");
const form = useMemo(
() => createForm<Values>({ initialValues: { title: "" } }),
[mode],
);
return (
<div className="w-full max-w-md space-y-4">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">revealErrors</span>
<Select
value={mode}
onValueChange={(v) => setMode(v as RevealErrorsPolicy)}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MODES.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Form
key={mode}
form={form}
revealErrors={mode}
onSubmit={() => {
toast.success("Submitted");
}}
>
<FormField
name="title"
kind="text"
label="Order title"
required
description={
mode === "submit"
? "Submit empty to see the error, then type a title — it should clear without blurring."
: mode === "touch"
? "Focus, leave empty, blur to see the error. Typing before blur should not paint."
: "always paints as soon as the field is invalid."
}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</div>
);
}Submit Quiet Until Submit
Default policy (revealErrors omitted is the same as "submit"). Typing and leaving the field must not shame incomplete input.
Try this:
- Click Order title, type
x, click outside the field. - Confirm: no red border, no error text, no summary.
- The value is still invalid — Submit in the next demo is what reveals it.
"use client";
import { FormField } from "@/components/f-ui/formily/form-field";
import {
SubmitRow,
TimingForm,
type TitleValues,
} from "./reveal-timing-shared";
/** Default `submit`: typing and blur stay quiet. */
export function FormValidationSubmitQuietUntilSubmitDemo() {
return (
<TimingForm<TitleValues> initialValues={{ title: "" }}>
{(form) => (
<>
<FormField name="title" kind="text" label="Order title" required />
<SubmitRow form={form} />
</>
)}
</TimingForm>
);
}Submit Empty Then Clear On Fix
Failed Submit stamps every invalid field. After that, fixing the value clears chrome immediately — you do not blur again.
Try this:
- Leave Order title empty. Click Submit.
- Confirm: field error and Form Error Summary both show
{label} is required(same string). Do not ship Formily’s generic “The field value is required.” Custom validators keep their own copy. - Click the field (keep focus) and type
Northwind. - Confirm: the error and summary row disappear as soon as the title is valid — no extra blur.
"use client";
import { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
import { FormField } from "@/components/f-ui/formily/form-field";
import {
SubmitRow,
TimingForm,
type TitleValues,
} from "./reveal-timing-shared";
/** Failed Submit paints field + summary; fixing clears without another blur. */
export function FormValidationSubmitEmptyThenClearDemo() {
return (
<TimingForm<TitleValues> initialValues={{ title: "" }}>
{(form) => (
<>
<FormErrorSummary />
<FormField name="title" kind="text" label="Order title" required />
<SubmitRow form={form} />
</>
)}
</TimingForm>
);
}Submit Recovery Waits For Blur
After a failed Submit the form switches to inline recovery: already-red fields stay sticky; a field that was valid at Submit time stays quiet until you leave it.
Try this:
- Leave Order title empty. Leave Warehouse as
Warehouse A. Click Submit. - Confirm: only Order title is red. Warehouse is still quiet.
- Click Warehouse, delete the text, keep focus (do not click away).
- Confirm: Warehouse is still quiet while you are typing it empty.
- Click outside Warehouse. Confirm: the required error appears now.
"use client";
import { FormField } from "@/components/f-ui/formily/form-field";
import {
SubmitRow,
TimingForm,
type PairValues,
} from "./reveal-timing-shared";
/** After a failed Submit, a previously valid field waits for blur. */
export function FormValidationSubmitRecoveryBlurDemo() {
return (
<TimingForm<PairValues>
initialValues={{ title: "", extra: "Warehouse A" }}
>
{(form) => (
<>
<FormField name="title" kind="text" label="Order title" required />
<FormField name="extra" kind="text" label="Warehouse" required />
<SubmitRow form={form} />
</>
)}
</TimingForm>
);
}Successful Submit Does Not Open Recovery
A successful Submit must not flip the form into “show everything on the next keystroke.”
Try this:
- Both fields already have values. Click Submit. Confirm the success toast — no field errors.
- Click Warehouse, delete the text, keep focus.
- Confirm: still no error while focused.
- Click outside Warehouse. Confirm: the required error appears on blur, same as
touch.
"use client";
import { FormField } from "@/components/f-ui/formily/form-field";
import {
SubmitRow,
TimingForm,
type PairValues,
} from "./reveal-timing-shared";
/** Successful Submit does not switch the form into recovery. */
export function FormValidationSuccessfulSubmitNoRecoveryDemo() {
return (
<TimingForm<PairValues>
initialValues={{ title: "Northwind", extra: "Warehouse A" }}
>
{(form) => (
<>
<FormField name="title" kind="text" label="Order title" required />
<FormField name="extra" kind="text" label="Warehouse" required />
<SubmitRow form={form} />
</>
)}
</TimingForm>
);
}Touch Empty Required
revealErrors="touch" first-paints on blur, including an empty required field you focused and left. Focus alone is not a commit.
Try this:
- Click Order title. Do not type. Click Submit (or any area outside the field) so the field blurs.
- Before you blur: confirm there is no error on first focus.
- After you leave the empty field: confirm the required error appears.
- Click the field again and type
Northwindwithout blurring. Confirm the error clears immediately.
"use client";
import { FormField } from "@/components/f-ui/formily/form-field";
import {
SubmitRow,
TimingForm,
type TitleValues,
} from "./reveal-timing-shared";
/** `touch`: focus-only stays quiet; leaving empty required paints. */
export function FormValidationTouchEmptyRequiredDemo() {
return (
<TimingForm<TitleValues>
revealErrors="touch"
initialValues={{ title: "" }}
>
{(form) => (
<>
<FormField name="title" kind="text" label="Order title" required />
<SubmitRow form={form} />
</>
)}
</TimingForm>
);
}Touch Invalid While Focused
Format kinds such as email validate on blur for the first show. Typing not-an-email while focused must not paint yet.
Try this:
- Click Work email. Type
not-an-email. Do not click away. - Confirm: no error yet.
- Click outside the field. Confirm: the email format error appears.
- (Clear-as-you-fix is the next demo.)
"use client";
import { FormField } from "@/components/f-ui/formily/form-field";
import {
SubmitRow,
TimingForm,
type EmailValues,
} from "./reveal-timing-shared";
/** `touch`: invalid value while focused does not paint until blur. */
export function FormValidationTouchInvalidWhileFocusedDemo() {
return (
<TimingForm<EmailValues>
revealErrors="touch"
initialValues={{ workEmail: "" }}
>
{(form) => (
<>
<FormField name="workEmail" kind="email" label="Work email" required />
<SubmitRow form={form} />
</>
)}
</TimingForm>
);
}Touch Email Clears On Input
Once the email error is showing, the kit re-validates on input so a blur-kind does not trap the message until the next blur.
Try this:
- Click Work email, type
bad, click outside. Confirm the format error. - Click the field again. Replace the value with
ada@example.comwithout clicking away. - Confirm: the error disappears as soon as the address is valid.
"use client";
import { FormField } from "@/components/f-ui/formily/form-field";
import {
SubmitRow,
TimingForm,
type EmailValues,
} from "./reveal-timing-shared";
/** Once shown, an email `onBlur` kind clears as soon as the value is valid. */
export function FormValidationTouchEmailClearsOnInputDemo() {
return (
<TimingForm<EmailValues>
revealErrors="touch"
initialValues={{ workEmail: "" }}
>
{(form) => (
<>
<FormField name="workEmail" kind="email" label="Work email" required />
<SubmitRow form={form} />
</>
)}
</TimingForm>
);
}Touch Blur Stamps That Field Only
Leaving one invalid field does not paint siblings. Each field waits for its own blur (or a later failed Submit).
Try this:
- Click Order title, do not type, click Warehouse (title blurs).
- Confirm: Order title is red; Warehouse is still quiet even though it is also empty.
- Click outside Warehouse. Confirm: Warehouse now shows its own required error.
"use client";
import { FormField } from "@/components/f-ui/formily/form-field";
import {
SubmitRow,
TimingForm,
type PairValues,
} from "./reveal-timing-shared";
/** Blur stamps that field only — a sibling stays quiet. */
export function FormValidationTouchBlurThatFieldOnlyDemo() {
return (
<TimingForm<PairValues>
revealErrors="touch"
initialValues={{ title: "", extra: "" }}
>
{(form) => (
<>
<FormField name="title" kind="text" label="Order title" required />
<FormField name="extra" kind="text" label="Warehouse" required />
<SubmitRow form={form} />
</>
)}
</TimingForm>
);
}Always Paints Without Submit
always is for live exceptions (password / character count). Empty required paints without Submit. Do not set this as the default on a long form.
Try this:
- Do not click Submit. Confirm the required error is already visible on Order title.
- Type
Northwind. Confirm it clears immediately. - Delete the title. Confirm the error returns while you are still in the field.
"use client";
import { useEffect, useMemo } from "react";
import { toast } from "sonner";
import { Form as FuiForm } 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 { SubmitRow, type TitleValues } from "./reveal-timing-shared";
/** `always`: invalid chrome without Submit (password / count class). */
export function FormValidationAlwaysPaintsLiveDemo() {
const form = useMemo(
() => createForm<TitleValues>({ initialValues: { title: "" } }),
[],
);
useEffect(() => {
void form.validate();
}, [form]);
return (
<div className="w-full max-w-md space-y-4">
<FuiForm
form={form}
revealErrors="always"
onSubmit={() => {
toast.success("Submitted");
}}
>
<FormField name="title" kind="text" label="Order title" required />
<SubmitRow form={form} />
</FuiForm>
</div>
);
}Reset Clears Shown Errors
Reset wipes sticky chrome and submit-recovery. The next interaction starts from a quiet form again.
Try this:
- Click Order title, click outside so the required error shows (
touch). - Click Reset.
- Confirm: the error is gone. Focus the field again without blurring — still quiet, same as a fresh form.
"use client";
import { FormField } from "@/components/f-ui/formily/form-field";
import {
SubmitRow,
TimingForm,
type TitleValues,
} from "./reveal-timing-shared";
/** Reset clears sticky field chrome and recovery. */
export function FormValidationResetClearsChromeDemo() {
return (
<TimingForm<TitleValues>
revealErrors="touch"
initialValues={{ title: "" }}
>
{(form) => (
<>
<FormField name="title" kind="text" label="Order title" required />
<SubmitRow form={form} showReset />
</>
)}
</TimingForm>
);
}IME Composition
Pinyin / Hangul composition is not a first-show commit. This demo simulates composing without an IME so you can see the gate.
Try this:
- Click Work email, type
bad, click outside so the format error shows (touch). - Click Start composing, then replace the value with
ada@example.comwithout clicking End composing. Confirm the error stays (R8 skipped). - Click End composing, type the same valid address again (or add a character and delete). Confirm the error clears.
"use client";
import { useMemo } from "react";
import { toast } from "sonner";
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 { setFormComposing } from "@/components/f-ui/formily/internals/form-state";
import { Button } from "@/components/ui/button";
export function FormValidationImeSimulateDemo() {
const form = useMemo(
() => createForm<{ workEmail: string }>({ initialValues: { workEmail: "" } }),
[],
);
return (
<div className="w-full max-w-md space-y-4">
<Form
form={form}
revealErrors="touch"
onSubmit={() => {
toast.success("Submitted");
}}
>
<FormField
name="workEmail"
kind="email"
label="Work email"
required
/>
<FormActions>
<Button
type="button"
variant="outline"
onClick={() => setFormComposing(form, true)}
>
Start composing
</Button>
<Button
type="button"
variant="outline"
onClick={() => setFormComposing(form, false)}
>
End composing
</Button>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</div>
);
}Server Field Issues Paint Immediately
Server / submit / system issues bypass the reveal gate. Do not Submit and do not blur — the mapped field paints now. Empty required siblings stay quiet.
Try this:
- Email is already valid. Warehouse is empty. Confirm: no chrome yet (
submitpolicy). - Click Apply server issue (not Submit). Confirm Email shows already registered immediately.
- Confirm Warehouse is still quiet (no fake submit-all).
- Edit Email. Confirm the server chrome clears. Submit still required-checks Warehouse.
"use client";
import { useMemo } from "react";
import { toast } from "sonner";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { applyFormIssues } from "@/components/f-ui/formily/internals/form-issues";
import { Button } from "@/components/ui/button";
/** Server / submit / system issues bypass the reveal gate — no Submit, no blur. */
export function FormValidationServerImmediateDemo() {
const form = useMemo(
() =>
createForm<{ email: string; warehouse: string }>({
initialValues: { email: "ada@example.com", warehouse: "" },
}),
[],
);
return (
<div className="w-full max-w-md space-y-4">
<Form
form={form}
onSubmit={() => {
toast.success("Submitted");
}}
>
<FormErrorSummary />
<FormField name="email" kind="email" label="Email" required />
<FormField name="warehouse" kind="text" label="Warehouse" required />
<FormActions>
<Button
type="button"
variant="outline"
onClick={() => {
applyFormIssues(form, [
{
source: "server",
path: "email",
message: "Email is already registered.",
},
]);
}}
>
Apply server issue
</Button>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</div>
);
}Full map (field + unmounted path after a real Submit): Server Issues Map.
Error Summary And Focus
After a failed Submit, FormErrorSummary lists visible field issues with jump links. Toggle focusOnInvalid between summary, first-field, and false (no auto-focus).
Try this:
- Leave both fields empty. Click Submit empty.
- With
summary: confirm focus lands on the summary heading; jump links move to the field. - Switch to
first-fieldand Submit empty again: confirm the first invalid input is focused. - Switch to
false: Submit empty paints errors but does not move focus.
"use client";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type Focus = "summary" | "first-field" | false;
type Values = { email: string; note: string };
export function FormValidationErrorSummaryFocusDemo() {
const [focusOnInvalid, setFocus] = useState<Focus>("summary");
const form = useMemo(
() => createForm<Values>({ initialValues: { email: "", note: "" } }),
[focusOnInvalid],
);
return (
<div className="w-full max-w-md space-y-4">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">focusOnInvalid</span>
<Select
value={String(focusOnInvalid)}
onValueChange={(v) =>
setFocus(v === "false" ? false : (v as Focus))
}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="summary">summary</SelectItem>
<SelectItem value="first-field">first-field</SelectItem>
<SelectItem value="false">false</SelectItem>
</SelectContent>
</Select>
</div>
<Form
key={String(focusOnInvalid)}
form={form}
focusOnInvalid={focusOnInvalid}
revealErrors="submit"
onSubmit={() => {
toast.success("Submitted");
}}
>
<FormErrorSummary />
<FormField name="email" label="Email" required kind="email" />
<FormField name="note" label="Note" required />
<FormActions>
<Button type="submit">
Submit empty
</Button>
</FormActions>
</Form>
</div>
);
}Server Issues Map
Who validates: BE. Fake API returns a field issue and an unmounted-path issue. Field chrome clears when you edit; summary keeps form-level intent until the next attempt.
Try this:
- Fill Email and Username with anything. Click Submit.
- Confirm: Email shows “already registered”; the summary also lists the captcha (unmounted path) message.
- Edit Email. Confirm: the field error clears; the captcha summary row stays until the next Submit.
"use client";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Input } from "@/components/f-ui/formily/connects/input";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import type { FormIssue } from "@/components/f-ui/formily/internals/form-issue";
import { applyFormIssues } from "@/components/f-ui/formily/internals/form-issues";
import { Button } from "@/components/ui/button";
const API_DELAY_MS = 400;
interface SignupValues {
email: string;
username: string;
}
/** Pretend API: rejects every submit with one field error and one form-level error. */
async function fakeSignup(): Promise<FormIssue[]> {
await new Promise((resolve) => setTimeout(resolve, API_DELAY_MS));
return [
{ source: "server", path: "email", message: "Email is already registered." },
{
source: "server",
path: "captcha",
message: "Captcha session expired — retry the submit.",
},
];
}
export function FormValidationServerIssuesMapDemo() {
const form = useMemo(
() =>
createForm<SignupValues>({
initialValues: { email: "", username: "" },
}),
[],
);
const [attempts, setAttempts] = useState(0);
return (
<div className="w-full max-w-sm space-y-4">
<Form
form={form}
focusOnInvalid="summary"
onSubmit={async () => {
const issues = await fakeSignup();
applyFormIssues(form, issues);
setAttempts((n) => n + 1);
toast.error("Server returned errors");
}}
>
<FormErrorSummary scope="all" />
<FormField
name="email"
label="Email"
required
kind="email"
description="Server rejects every email; edit the field to clear the error"
componentProps={{ placeholder: "you@example.com" }}
/>
<FormField
name="username"
label="Username"
required
component={[Input, { placeholder: "lovelace" }]}
/>
<FormActions>
<Button type="submit">
Sign up{attempts > 0 ? ` (attempt ${attempts + 1})` : ""}
</Button>
</FormActions>
</Form>
</div>
);
}Hybrid Format Then Server
Who validates: Hybrid. Client checks format + @acme.com. blocked@acme.com still fails on the server via applyFormIssues — FE preview is not authority.
Try this:
- Type
me@gmail.comand Submit. Confirm the client@acme.comerror — no server round-trip needed. - Type
ada@acme.comand Submit. Confirm success. - Type
blocked@acme.comand Submit. Confirm the server “blocked by policy” message on the field.
"use client";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import type { FormIssue } from "@/components/f-ui/formily/internals/form-issue";
import { applyFormIssues } from "@/components/f-ui/formily/internals/form-issues";
import { Button } from "@/components/ui/button";
type Values = { workEmail: string };
const BLOCKED = new Set(["blocked@acme.com"]);
async function fakeSave(values: Values): Promise<FormIssue[]> {
await new Promise((r) => setTimeout(r, 350));
if (BLOCKED.has(values.workEmail.toLowerCase())) {
return [
{
source: "server",
path: "workEmail",
code: "email_blocked",
message: "This address is blocked by policy. Use another work email.",
},
];
}
return [];
}
export function FormValidationHybridFormatThenServerDemo() {
const form = useMemo(
() => createForm<Values>({ initialValues: { workEmail: "" } }),
[],
);
const [ok, setOk] = useState(false);
return (
<div className="w-full max-w-sm space-y-4">
<Form
form={form}
focusOnInvalid="first-field"
onSubmit={async (values) => {
const issues = await fakeSave(values);
if (issues.length) {
applyFormIssues(form, issues);
setOk(false);
toast.error("Server rejected the email");
return;
}
setOk(true);
toast.success("Saved");
}}
>
<FormErrorSummary />
<FormField
name="workEmail"
label="Work email"
required
kind="email"
description="FE checks format. Try blocked@acme.com after a valid format — server still rejects."
validator={(value?: string) => {
if (!value) return "";
const domain = value.split("@")[1]?.toLowerCase();
return domain === "acme.com"
? ""
: "Email must be an @acme.com address";
}}
/>
<FormActions>
<Button type="submit">
Save
</Button>
</FormActions>
</Form>
{ok ? (
<p className="text-sm text-muted-foreground">Last save succeeded.</p>
) : null}
</div>
);
}Async Uniqueness Preview
Who validates: Hybrid (preview then server). createAsyncValidator blocks known taken names (admin / root / system) on input. A name the FE thinks is free can still fail on submit — try reserved, which maps a server issue via applyFormIssues. Prefer debounce so the network is not hammered.
Try this:
- Type
adminand wait ~400ms. Confirm “already taken” from the FE preview. - Change to
ada. Confirm the preview error clears. - Type
reservedand Submit. Confirm the server issue — FE thought it was free.
"use client";
import type { FieldValidator } from "@formily/core";
import { useMemo } from "react";
import { toast } from "sonner";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createAsyncValidator } from "@/components/f-ui/formily/internals/create-async-validator";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import type { FormIssue } from "@/components/f-ui/formily/internals/form-issue";
import { applyFormIssues } from "@/components/f-ui/formily/internals/form-issues";
import { Button } from "@/components/ui/button";
type Values = { username: string };
/** FE async preview — known taken names. */
const FE_TAKEN = new Set(["admin", "root", "system"]);
/** BE also rejects this name even when FE preview says free. */
const BE_RESERVED = "reserved";
const checkUsername = createAsyncValidator(
async (value, ctx) => {
await new Promise((r) => setTimeout(r, 400));
if (ctx.signal.aborted) return "";
const username = typeof value === "string" ? value : "";
if (!username) return "";
return FE_TAKEN.has(username.toLowerCase())
? "Username is already taken"
: "";
},
{ debounceMs: 300 },
);
async function fakeReserve(values: Values): Promise<FormIssue[]> {
await new Promise((r) => setTimeout(r, 350));
if (values.username.toLowerCase() === BE_RESERVED) {
return [
{
source: "server",
path: "username",
code: "username_reserved",
message: "This username is reserved. Choose another.",
},
];
}
return [];
}
export function FormValidationAsyncUniquenessDemo() {
const form = useMemo(
() => createForm<Values>({ initialValues: { username: "" } }),
[],
);
return (
<div className="w-full max-w-sm space-y-4">
<Form
form={form}
focusOnInvalid="first-field"
onSubmit={async (values) => {
const issues = await fakeReserve(values);
if (issues.length) {
applyFormIssues(form, issues);
toast.error("Server rejected the username");
return;
}
toast.success(`Reserved @${values.username}`);
}}
>
<FormErrorSummary />
<FormField
name="username"
label="Username"
required
validatingDescription="Checking availability…"
description="Try admin (async blocks). Try reserved (preview free, submit maps server issue)."
validator={
{
triggerType: "onInput",
validator: checkUsername,
} as FieldValidator
}
/>
<FormActions>
<Button type="submit">
Reserve
</Button>
</FormActions>
</Form>
</div>
);
}Required On Submit
Same default as Submit Empty Then Clear On Fix, with the prop omitted (revealErrors default "submit").
Try this:
- Type
xin Order title. Confirm: no error yet. - Click Submit while empty (clear the field first). Confirm the required error.
- Type a title without blurring. Confirm it clears.
"use client";
import { useMemo } from "react";
import { toast } from "sonner";
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";
type Values = { orderTitle: string };
export function FormValidationRequiredSubmitRevealDemo() {
const form = useMemo(
() => createForm<Values>({ initialValues: { orderTitle: "" } }),
[],
);
return (
<div className="w-full max-w-sm space-y-4">
<Form
form={form}
onSubmit={() => {
toast.success("Submitted");
}}
>
<FormField
name="orderTitle"
kind="text"
label="Order title"
required
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</div>
);
}Kind Plus Validator
kind="email" supplies format first; an appended custom validator then requires @acme.com. Try a well-formed personal address vs a work address.
Try this:
- In Personal email, type
not-an-email, blur. Confirm the built-in format error. - In Work email, type
me@gmail.com, Submit. Confirm@acme.comis required. - Type
ada@acme.comin Work email and Submit. Confirm success.
"use client";
import { useMemo, useState } from "react";
import { toast } from "sonner";
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";
interface InviteValues {
personalEmail: string;
workEmail: string;
}
function acmeDomainValidator(value: string | undefined) {
if (!value) return "";
const domain = value.split("@")[1]?.toLowerCase();
return domain === "acme.com"
? ""
: "Email must be an @acme.com address";
}
export function FormValidationKindPlusValidatorDemo() {
const form = useMemo(
() =>
createForm<InviteValues>({
initialValues: { personalEmail: "", workEmail: "" },
}),
[],
);
const [submitted, setSubmitted] = useState<InviteValues | null>(null);
return (
<div className="w-full max-w-sm space-y-4">
<Form form={form} onSubmit={(values) => {
setSubmitted({ ...values });
toast.success("Submitted successfully");
}}>
<FormField
name="personalEmail"
label="Personal email"
kind="email"
componentProps={{ placeholder: "you@example.com" }}
description="kind only — built-in format: email on blur"
/>
<FormField
name="workEmail"
label="Work email"
required
kind="email"
componentProps={{ placeholder: "you@acme.com" }}
description="Format first, then @acme.com — try me@gmail.com vs ada@acme.com"
validator={acmeDomainValidator}
/>
<FormActions>
<Button type="submit">
Send invite
</Button>
</FormActions>
</Form>
{submitted ? (
<pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
<code>{JSON.stringify(submitted, null, 2)}</code>
</pre>
) : null}
</div>
);
}Built-In Rules
Formily rule objects on validator: minLength / maxLength, pattern, and minimum / maximum for numbers. Submit with short codes, bad SKUs, or out-of-range qty.
Try this:
- Type
ABin Code and Submit. Confirm “Use 3–8 characters”. - Type
nopein SKU and Submit. Confirm “Pattern AA-000” (useAB-001). - Set Qty to
0or100and Submit. Confirm “1–99”.
"use client";
import { useMemo } from "react";
import { toast } from "sonner";
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";
type Values = { code: string; qty: number | null; sku: string };
export function FormValidationBuiltinRulesDemo() {
const form = useMemo(
() =>
createForm<Values>({
initialValues: { code: "", qty: null, sku: "" },
}),
[],
);
return (
<div className="w-full max-w-sm space-y-4">
<Form
form={form}
onSubmit={() => {
toast.success("OK");
}}
>
<FormField
name="code"
label="Code"
required
validator={{
minLength: 3,
maxLength: 8,
message: "Use 3–8 characters",
}}
description="minLength / maxLength"
/>
<FormField
name="sku"
label="SKU"
required
validator={{
pattern: /^[A-Z]{2}-\d{3}$/,
message: "Pattern AA-000",
}}
/>
<FormField
name="qty"
label="Qty"
kind="number"
required
validator={{ minimum: 1, maximum: 99, message: "1–99" }}
componentProps={{ min: 1, step: 1 }}
/>
<FormActions>
<Button type="submit">
Validate
</Button>
</FormActions>
</Form>
</div>
);
}Checkbox Must Be True
Formily required alone does not treat false as empty on booleans. Pair kind="checkbox" with a truthy validator (and inlineLabel for the label beside the control).
Try this:
- Leave the checkbox unchecked. Click Continue. Confirm “You must agree”.
- Check it. Confirm the error clears (or Submit succeeds).
- Do not rely on
requiredalone for a boolean — that is the kit gotcha this recipe exists for.
"use client";
import { useMemo } from "react";
import { toast } from "sonner";
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";
type Values = { accept: boolean };
/** Formily `required` does not treat `false` as empty on boolean fields. */
function mustAgree(value: boolean | undefined) {
return value ? "" : "You must agree";
}
export function FormValidationCheckboxMustBeTrueDemo() {
const form = useMemo(
() => createForm<Values>({ initialValues: { accept: false } }),
[],
);
return (
<div className="w-full max-w-sm space-y-4">
<Form
form={form}
onSubmit={() => {
toast.success("Accepted");
}}
>
<FormField
name="accept"
label="I agree to the terms"
required
kind="checkbox"
inlineLabel
validator={mustAgree}
/>
<FormActions>
<Button type="submit">
Continue
</Button>
</FormActions>
</Form>
</div>
);
}Mixed / Approver Lock
Approver inbox (Unlock exists, not yet unlocked) is Display: applicant answers use readPretty, same as request number and created time. Comment stays editable. Unlock remounts applicant fields as controls (Fiori Display → Edit, Salesforce view → pencil). Do not paint that inbox as a wall of readOnly inputs.
An Edit session can still mix three chromes on one surface: Created stays readPretty (never a control). A field this role still cannot change stays a readOnly control. Comment stays editable. Empty pretty values use an em dash; empty locked and editable controls stay blank — Empty Value Placeholder.
A Display page with no Unlock uses form-level readPretty. Full pair: Form (Formily) — When To Use. Lock mark placement and field vs surface scope: Field Lock Affordance. Showcase: /showcases/approver-unlock.
Try this (Edit mixed, demo below):
- Confirm Created is a date string — not a picker. Clearing it in code would show —, not an empty input.
- Tab to SKU. Confirm it is a textbox you cannot type into (not grey-disabled). This is leftover lock in Edit, not the approver inbox.
- Type in Comment. Confirm that is the only writable field. Submit.
"use client";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Input } from "@/components/f-ui/formily/connects/input";
import { Textarea } from "@/components/f-ui/formily/connects/textarea";
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";
interface ReviewValues {
createdAt: Date | null;
sku: string;
region: string;
comment: string;
}
export function FormilyMixedPatternDemo() {
const form = useMemo(
() =>
createForm<ReviewValues>({
initialValues: {
createdAt: new Date(2026, 7, 1),
sku: "SKU-104",
region: "apac",
comment: "",
},
}),
[],
);
const [submitted, setSubmitted] = useState<ReviewValues | null>(null);
return (
<div className="w-full max-w-sm space-y-4">
<Form
form={form}
onSubmit={(values) => {
setSubmitted({ ...values });
toast.success("Submitted successfully");
}}
>
<FormField
name="createdAt"
label="Created"
description="System timestamp — display only"
readPretty
kind="date"
/>
<FormField
name="sku"
label="SKU"
description="Locked in this Edit session — still a textbox. Approver inbox uses display until Unlock, not this chrome."
readOnly
lockReason="Locked for this Edit session."
component={[Input]}
/>
<FormField
name="region"
label="Region"
readOnly
kind="radio"
lockReason="Set by the approval route — reopen the request to change it."
componentProps={{
options: [
{ label: "APAC", value: "apac" },
{ label: "EMEA", value: "emea" },
],
orientation: "horizontal",
}}
/>
<FormField
name="comment"
label="Comment"
description="Writable this session"
component={[Textarea, { placeholder: "Approver note" }]}
/>
<FormActions>
<Button type="submit">
Submit review
</Button>
</FormActions>
</Form>
{submitted ? (
<pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
<code>{JSON.stringify(submitted, null, 2)}</code>
</pre>
) : null}
</div>
);
}Cross-Field Local
Confirm password uses reactions + field.query(".password") for a local match error. Phone becomes required and visible only when Notify by is SMS — same reaction pattern as Formily demos. Keep local match rules on the client; server aggregates (totals, inventory, concurrency) still return path or root issues via applyFormIssues.
Try this:
- Type
secret1in Password andsecret2in Confirm password. Confirm “Passwords must match”. - Make Confirm match Password. Confirm the match error clears.
- Set Notify by to SMS. Confirm Phone appears and is required. Submit empty phone to see that error. Switch back to Email: Phone hides.
"use client";
import { useMemo } from "react";
import { toast } from "sonner";
import { Input } from "@/components/f-ui/formily/connects/input";
import { Select } from "@/components/f-ui/formily/connects/select";
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";
type Values = {
password: string;
confirm: string;
notifyBy: "email" | "sms";
phone?: string;
};
const NOTIFY_OPTIONS = [
{ label: "Email", value: "email" },
{ label: "SMS", value: "sms" },
];
export function FormValidationCrossFieldLocalDemo() {
const form = useMemo(
() =>
createForm<Values>({
initialValues: {
password: "",
confirm: "",
notifyBy: "email",
phone: "",
},
}),
[],
);
return (
<div className="w-full max-w-sm space-y-4">
<Form
form={form}
onSubmit={() => {
toast.success("Account updated");
}}
>
<FormField
name="password"
label="Password"
required
component={[
Input,
{ type: "password", autoComplete: "new-password" },
]}
/>
<FormField
name="confirm"
label="Confirm password"
required
description="Matches password via reactions + field.query"
component={[
Input,
{ type: "password", autoComplete: "new-password" },
]}
reactions={(field) => {
const password = field.query(".password").value() as
| string
| undefined;
field.selfErrors =
field.value && field.value !== password
? ["Passwords must match"]
: [];
}}
/>
<FormField
name="notifyBy"
label="Notify by"
required
component={[Select, { options: NOTIFY_OPTIONS }]}
/>
<FormField
name="phone"
label="Phone"
description="Required only when Notify by is SMS"
component={[Input, { placeholder: "+1 555 0100" }]}
reactions={(field) => {
const sms = field.query(".notifyBy").value() === "sms";
field.visible = sms;
field.required = sms;
}}
/>
<FormActions>
<Button type="submit">
Save
</Button>
</FormActions>
</Form>
</div>
);
}Warning Severity
Who validates: Hybrid (server severity). applyFormIssues with severity: "warning" stays on the field (and Message Popover when the host builds that list). It does not appear in Form Error Summary. This demo still succeeds when only warnings are present; toggle to inject an error and block.
Try this:
- Leave the select on
warning-only. Submit. Confirm a warning on Memo, an empty Banner, and the success toast — warnings do not block. - Switch to
warning-and-error. Submit. Confirm the Banner lists the invoice error only (not the Memo warning) and success does not fire.
"use client";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import type { FormIssue } from "@/components/f-ui/formily/internals/form-issue";
import { applyFormIssues } from "@/components/f-ui/formily/internals/form-issues";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type Values = { invoiceNo: string; memo: string };
type InjectMode = "warning-only" | "warning-and-error";
/**
* Host policy: warnings do not block success. They stay on the field — not
* in Form Error Summary. Mode select injects an error so the Banner lists
* the blocking issue only.
*/
export function FormValidationWarningSeverityDemo() {
const form = useMemo(
() =>
createForm<Values>({
initialValues: { invoiceNo: "INV-1001", memo: "" },
}),
[],
);
const [injectMode, setInjectMode] = useState<InjectMode>("warning-only");
const [lastOk, setLastOk] = useState(false);
return (
<div className="w-full max-w-sm space-y-4">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">on submit</span>
<Select
value={injectMode}
onValueChange={(v) => setInjectMode(v as InjectMode)}
>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="warning-only">warning only</SelectItem>
<SelectItem value="warning-and-error">
warning + field error
</SelectItem>
</SelectContent>
</Select>
</div>
<Form
form={form}
focusOnInvalid="summary"
onSubmit={() => {
const issues: FormIssue[] = [
{
source: "server",
path: "memo",
severity: "warning",
message:
"Memo is empty — invoice will use the default text.",
},
];
if (injectMode === "warning-and-error") {
issues.push({
source: "server",
path: "invoiceNo",
severity: "error",
message: "Invoice number is reserved. Pick another.",
});
applyFormIssues(form, issues);
setLastOk(false);
toast.error("Submit blocked by an error");
return;
}
applyFormIssues(form, issues);
setLastOk(true);
toast.success("Saved (warning only — host allowed submit)");
}}
>
<FormErrorSummary scope="all" />
<FormField
name="invoiceNo"
label="Invoice number"
required
description="Warnings stay on the field; this demo still succeeds when only warnings are present"
/>
<FormField
name="memo"
label="Memo"
description="Leave empty to get a warning after Save"
/>
<FormActions>
<Button type="submit">
Save
</Button>
</FormActions>
</Form>
{lastOk ? (
<p className="text-sm text-muted-foreground">
Last save succeeded. Memo still shows a field warning; the Banner stays empty.
</p>
) : null}
</div>
);
}Hide Helper On Error
Long description plus hideHelperOnError: after Submit on an empty required field, the helper is visually hidden while the error shows (still available to assistive tech).
Try this:
- Read the long helper under Account code.
- Click Submit with the field empty.
- Confirm: the helper is visually gone; the required error is visible instead.
"use client";
import { useMemo } from "react";
import { toast } from "sonner";
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";
type Values = { accountCode: string };
export function FormValidationHideHelperOnErrorDemo() {
const form = useMemo(
() => createForm<Values>({ initialValues: { accountCode: "" } }),
[],
);
return (
<div className="w-full max-w-sm space-y-4">
<Form
form={form}
onSubmit={() => {
toast.success("Saved");
}}
>
<FormField
name="accountCode"
kind="text"
label="Account code"
required
hideHelperOnError
description="Use the 6-character ledger code from Finance (e.g. ACC001). This long helper is hidden visually when the required error shows, but stays in the accessibility tree."
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</div>
);
}Steps Form Hybrid
Per-step fields validate on Next (FE). The write API runs on the last Submit only.
Try this:
- Leave Work email empty. Click Next. Confirm the step error — finish must not run.
- Fill a valid email, go through Access, Submit on Message. Confirm the success toast.
"use client";
import { toast } from "sonner";
import { FormField } from "@/components/f-ui/formily/form-field";
import { StepsForm } from "@/components/f-ui/steps-form/steps-form";
interface InviteWizardValues {
email: string;
role: string;
message: string;
}
/**
* Leave the first step empty and click Next — required errors stay on Basics.
* Fill email, advance, then Back: the email is still there.
*/
export function StepsFormValidationDemo() {
return (
<div className="w-full max-w-lg">
<StepsForm<InviteWizardValues>
initialValues={{ email: "", role: "member", message: "" }}
onFinish={async (values) => {
await new Promise((r) => setTimeout(r, 300));
toast.success(`Invite queued for ${values.email}`);
}}
>
<StepsForm.Step name="basics" title="Basics">
<FormField
kind="email"
name="email"
label="Work email"
required
componentProps={{ placeholder: "name@company.com" }}
/>
</StepsForm.Step>
<StepsForm.Step name="access" title="Access">
<FormField
kind="select"
name="role"
label="Role"
required
componentProps={{
options: [
{ label: "Admin", value: "admin" },
{ label: "Member", value: "member" },
{ label: "Viewer", value: "viewer" },
],
}}
/>
</StepsForm.Step>
<StepsForm.Step name="message" title="Message">
<FormField
kind="textarea"
name="message"
label="Personal note"
componentProps={{
rows: 3,
placeholder: "Optional note in the invite email",
}}
/>
</StepsForm.Step>
</StepsForm>
</div>
);
}Query Filter Search Submit
Search runs form.submit() with submit reveal — typing alone does not paint.
Try this:
- Type
ain Keyword (min length when filled) ornot-an-emailin Email. Click Search. - Confirm chrome after Search, not while typing. Fix and Search again.
Click Search with keyword a or email not-an-email — validation blocks onFinish. Fix the fields, then Search again.
Search attempts: 0 — only increments when validation passes and onFinish runs.
No successful query yet.
"use client";
import { useState } from "react";
import { QueryFilter } from "@/components/f-ui/query-filter/query-filter";
import { FormField } from "@/components/f-ui/formily/form-field";
function minLengthWhenFilled(min: number, message: string) {
return (value: string | undefined) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) return "";
return trimmed.length >= min ? "" : message;
};
}
export function QueryFilterValidationDemo() {
const [submitted, setSubmitted] = useState<Record<string, unknown> | null>(null);
const [attempts, setAttempts] = useState(0);
return (
<div className="w-full space-y-4">
<p className="text-muted-foreground text-xs">
Click <strong>Search</strong> with keyword <code>a</code> or email{" "}
<code>not-an-email</code> — validation blocks <code>onFinish</code>. Fix the
fields, then Search again.
</p>
<QueryFilter
onFinish={(values) => {
setSubmitted(values);
setAttempts((n) => n + 1);
}}
onReset={() => {
setSubmitted(null);
setAttempts(0);
}}
>
<FormField
kind="text"
name="keyword"
label="Keyword"
description="Optional — at least 2 characters when filled"
validator={minLengthWhenFilled(2, "Enter at least 2 characters")}
/>
<FormField
kind="email"
name="customerEmail"
label="Customer email"
description="Optional — format checked on blur"
componentProps={{ placeholder: "buyer@example.com" }}
/>
<FormField
kind="currency"
name="minAmount"
label="Min amount"
description="Optional — must be zero or positive"
componentProps={{ currency: "USD", placeholder: "0.00" }}
validator={(value) => {
if (value === undefined || value === null || value === "") return "";
return Number(value) >= 0 ? "" : "Amount cannot be negative";
}}
/>
</QueryFilter>
<div className="text-muted-foreground space-y-2 text-xs">
<p>
Search attempts: <strong>{attempts}</strong> — only increments when validation
passes and <code>onFinish</code> runs.
</p>
{submitted ? (
<pre className="bg-muted overflow-auto rounded-md p-3">
<code>{JSON.stringify(submitted, null, 2)}</code>
</pre>
) : (
<p>No successful query yet.</p>
)}
</div>
</div>
);
}Editable Table Cells
Do not paint cell errors with applyFormIssues. Use identity-keyed applyEditableArrayIssues, cell chrome, and the region rollup. Array-root rules stay on Form Error Summary. Composition with header fields and large-grid extremes: Where Errors Appear.
Try this:
- Set a line Disc % above 0, leave Disc reason empty, blur, then Submit.
- Focus the invalid reason cell before fixing. Confirm hide-while-focused vs FormItem sticky red.
- Confirm the rollup lists the cell and Form Error Summary does not duplicate that cell path.
"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>
);
}File Upload Required
Pair required with validateFileRequired. Uploading items must not look like “done.” Content safety is BE-only.
Try this:
- Click Submit with no file. Confirm required chrome.
- Add a file and wait until it is done, then Submit. Confirm success.
- Do not treat an in-flight upload as a valid value.
"use client";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import type { FileUploadItem } from "@/components/f-ui/file-upload/file-upload-types";
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 { validateFileRequired } from "@/components/f-ui/formily/validators/file-upload";
import { Button } from "@/components/ui/button";
import { createFakeUpload } from "./fake-upload";
interface AttachmentValues {
attachments: FileUploadItem[];
}
const upload = createFakeUpload();
export function FileUploadFormFieldDemo() {
const form = useMemo(
() =>
createForm<AttachmentValues>({
initialValues: { attachments: [] },
}),
[],
);
const [submitted, setSubmitted] = useState<AttachmentValues | null>(null);
return (
<div className="w-full max-w-sm space-y-4">
<Form
form={form}
onSubmit={(values) => {
setSubmitted({ ...values });
toast.success("Submitted successfully");
}}
>
<FormField
name="attachments"
label="Attachments"
description="Required — upload at least one file. Progress is not an error; submit waits until uploads finish."
kind="file"
required
validator={[{ validator: validateFileRequired }]}
componentProps={{
multiple: true,
upload,
}}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
{submitted ? (
<pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
<code>{JSON.stringify(submitted, null, 2)}</code>
</pre>
) : null}
</div>
);
}Form Page And Modal Defaults
Long pages: FormPage default is scope="all" + focusOnInvalid="summary" + a docked Submit. Dialogs: Modal With A Table. Focus toggle: Error Summary And Focus.
Try this:
- The demo already submitted empty. Confirm the summary lists Order title / Warehouse, not the cell SKU.
- Confirm the docked footer Submit is visible (Form Page chrome).
- Fill the headers. Confirm those summary rows drop; the table rollup remains.
"use client";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { FormPage } from "@/components/f-ui/form-page/form-page";
import { PageFooterSlotProvider } from "@/components/f-ui/page/page-footer-slot";
import { type Line, lineColumns, submitCatch } from "./surface-cases-shared";
export function FormValidationFormPageShellDemo() {
const [slot, setSlot] = useState<HTMLElement | null>(null);
const form = useMemo(
() =>
createForm<{ title: string; warehouse: string; lines: Line[] }>({
initialValues: {
title: "",
warehouse: "",
lines: [{ id: "p1", sku: "", qty: 0 }],
},
}),
[],
);
useEffect(() => {
submitCatch(form);
}, [form]);
return (
<PageFooterSlotProvider target={slot}>
<div className="overflow-hidden rounded-xl border bg-card">
<FormPage
form={form}
footerVisible
density="compact"
className="p-4"
onFinish={() => {
toast.success("Saved");
}}
>
<FormField name="title" kind="text" label="Order title" required />
<FormField name="warehouse" kind="text" label="Warehouse" required />
<EditableTable<Line>
name="lines"
variant="embedded"
columns={lineColumns}
getRowId={(row) => row.id}
/>
</FormPage>
<div ref={setSlot} />
</div>
</PageFooterSlotProvider>
);
}Schema Validators
JSON Schema forms use format + x-validator the same way FormField stacks kind + validator. Timing still follows this page’s revealErrors policy.
Try this:
- Leave Email empty or type
not-an-email, Submit. Confirm format/required chrome. - Switch Role to Admin. Confirm Admin code appears and is required.
"use client";
import { useMemo, useState } from "react";
import { toast } from "sonner";
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 { SchemaField } from "@/components/f-ui/formily/schema-field";
import { Button } from "@/components/ui/button";
const ROLE_OPTIONS = [
{ label: "Member", value: "member" },
{ label: "Admin", value: "admin" },
];
const schema = {
type: "object",
properties: {
email: {
type: "string",
title: "Email",
required: true,
format: "email",
"x-decorator": "FormItem",
"x-component": "EmailInput",
"x-component-props": { placeholder: "you@example.com" },
},
role: {
type: "string",
title: "Role",
"x-decorator": "FormItem",
"x-component": "Select",
"x-component-props": { options: ROLE_OPTIONS },
},
adminCode: {
type: "string",
title: "Admin code",
description: "Visible and required only for admins",
"x-decorator": "FormItem",
"x-component": "Input",
"x-component-props": { placeholder: "1234", autoComplete: "off" },
"x-reactions": {
dependencies: ["role"],
fulfill: {
state: {
visible: '{{$deps[0] === "admin"}}',
required: '{{$deps[0] === "admin"}}',
},
},
},
},
},
};
interface SchemaValues {
email: string;
role: string;
adminCode?: string;
}
export function FormilySchemaDemo() {
const form = useMemo(
() =>
createForm<SchemaValues>({
initialValues: { email: "", role: "member" },
}),
[],
);
const [submitted, setSubmitted] = useState<SchemaValues | null>(null);
return (
<div className="w-full max-w-sm space-y-4">
<Form form={form} onSubmit={(values) => {
setSubmitted({ ...values });
toast.success("Submitted successfully");
}}>
<SchemaField schema={schema} />
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
{submitted ? (
<pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
<code>{JSON.stringify(submitted, null, 2)}</code>
</pre>
) : null}
</div>
);
}Security Sensitive Failures
Login / OTP: one root issue. Do not say which of email or password was wrong.
Try this:
- Type any email and password. Click Sign in.
- Confirm the summary shows Email or password is incorrect.
- Confirm neither field is singled out as “wrong password” / “unknown user.”
"use client";
import { useMemo } from "react";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
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";
export function FormValidationSecurityRootDemo() {
const form = useMemo(
() =>
createForm<{ email: string; password: string }>({
initialValues: { email: "", password: "" },
}),
[],
);
return (
<div className="w-full max-w-md space-y-4">
<Form
form={form}
onSubmit={() => ({
status: "error" as const,
issues: [
{
source: "server" as const,
message: "Email or password is incorrect.",
},
],
})}
>
<FormErrorSummary title="Sign in failed" />
<FormField name="email" kind="email" label="Email" required />
<FormField
name="password"
kind="password"
label="Password"
required
/>
<FormActions>
<Button type="submit">
Sign in
</Button>
</FormActions>
</Form>
</div>
);
}I18n And Issue Codes
Built-in format / required copy follows the docs site language (en vs /zh/docs/...). Custom and server copy: keep a stable code for logic; show a localized message. Toggle below only swaps the custom Account code strings.
Try this:
- Confirm Email’s built-in required/format string matches the site language.
- Flip the switch. Confirm Account code copy switches en ↔ zh. Email built-in does not follow this switch.
- Type
!!!in Account code. Confirm the pattern message in the selected custom language.
"use client";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
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 { Switch } from "@/components/ui/switch";
import { submitCatch } from "./surface-cases-shared";
const I18N_COPY = {
en: {
required: "Account code is required",
pattern: "Use letters and digits only",
},
zh: {
required: "必须填写账号代码",
pattern: "只能使用字母和数字",
},
} as const;
export function FormValidationI18nDemo() {
const [lang, setLang] = useState<"en" | "zh">("en");
const copy = I18N_COPY[lang];
const form = useMemo(
() =>
createForm<{ email: string; code: string }>({
initialValues: { email: "", code: "" },
}),
[lang],
);
useEffect(() => {
submitCatch(form);
}, [form]);
return (
<div className="w-full max-w-md space-y-4">
<label className="flex items-center gap-2 text-sm">
<Switch
checked={lang === "zh"}
onCheckedChange={(on) => setLang(on ? "zh" : "en")}
/>
<span className="text-muted-foreground">
Custom copy: {lang === "zh" ? "zh" : "en"} (email format follows the
docs site language)
</span>
</label>
<Form
key={lang}
form={form}
onSubmit={() => {
toast.success("Submitted");
}}
>
<FormErrorSummary />
<FormField name="email" kind="email" label="Email" required />
<FormField
name="code"
kind="text"
label="Account code"
required
validator={(value) => {
if (!value) return copy.required;
if (!/^[A-Za-z0-9]+$/.test(String(value))) return copy.pattern;
return undefined;
}}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</div>
);
}4. Shell Defaults
| Shell | Cookbook action |
|---|---|
| Form (Formily) | Baseline for all independent demos above |
| Form Page | Long page; default focusOnInvalid="summary" + FormErrorSummary; see Form Page And Modal Defaults |
| Modal Form | Dialog lifecycle; revealErrors / focusOnInvalid (default first-field) |
| Steps Form / Modal Steps Form | Per-step FE; write API on last Submit — Steps Form Hybrid |
| Query Filter | Search runs form.submit() — Query Filter Search Submit |
| Editable Table | Cells + rollup via applyEditableArrayIssues — Editable Table Cells |
| Inline Edit | Single-field save loop; not a primary cookbook recipe — use Formily for full fill forms |
5. Anti-Patterns
| Forbidden | Do instead |
|---|---|
| Summary-only field errors | Summary and field chrome |
| Dual-list table cell errors | One aggregate owner (rollup or popover) |
applyFormIssues under Editable Table cells | applyEditableArrayIssues |
| FE as security boundary | Always re-validate on the server |
| Enumerating users via field errors | Generic root message |
revealErrors="always" as the long-form default | "submit" (or "touch" when justified) |
| Hand-rolled mini forms | Formily |
editable={false} as a session lock | readOnly / readPretty / form readOnly — Formily maps editable={false} to pretty |
Treating server message as a stable API | Prefer code for logic; show message |
| Calling the write API after client validation failed | Gate on successful submit |
6. FAQ
What is the default reveal policy? revealErrors="submit" — field errors after Submit (omit the prop). Use "touch" when blur feedback is justified; avoid "always" on long forms. touch means blur-then-show, not “first keystroke.” Hosts that need live-as-you-type on first entry should use always. Walk every case in When Errors Show.
Is client validation enough? No — UX preview only. The server remains authoritative for business invariants.
When do I use async validators? For previews such as username taken (createAsyncValidator); still re-check on submit (hybrid: preview then server). Debounce so the network is not hammered.
Can I list Editable Table cell errors in Form Error Summary and the table rollup? No — pick one aggregate owner. Cell paths use applyEditableArrayIssues + rollup; array-root / header stay on the summary. Decision tree: Where Errors Appear. See Object Messaging.
When do I turn Form Error Summary off? When the only errors are table cells (no header, array-root, or system issues) — errorSummary={false} and let the rollup own the list. Keep the Form Page default on when the page also has header or “at least one line” rules; the kit strips cell paths for you.
How do I show errors on a huge form? Header: Form Error Summary is the index (jump links, focus summary first). Lines: region rollup, never flatten 50 cell messages into the page summary. Hidden rows still fail Submit — Extreme Cases.
Warning vs error — does a warning block Submit? severity: "warning" does not appear in Form Error Summary. It does not block host success by itself. Inject an error when the submit must fail.
May I hand-roll a tiny Input + Button form? No — every fill-and-submit surface uses Formily (Form / FormField / shells). Read-only confirmations and non-field actions are exempt.
How do I stop double Submit? FormActions / Form Page disable the primary while submitting. Idempotency and “already processed” responses are host / backend concerns — do not rely on the button alone.
Where is table validation documented? Editable Table + Object Messaging.
Current Limitations
- Async validator typing — when wiring
createAsyncValidatoras{ triggerType, validator }, TypeScript may require anas FieldValidatorcast. Debounce and abort still work at runtime; cast only the prop until the helper’s return type matches Formily.
Form Layout
Choose vertical fields, two-column field grids, control widths, and FormActions placement — without confusing “two columns” with Ant labelCol.
Field Lock Affordance
How locked fill-in fields show a lock mark and optional reason across forms, Editable Table, and Inline Edit — without looking like system facts or security.