CRUD Page Patterns
Choose list, detail, create, and edit surfaces the way Ant Design Pro does — mapped to f-ui PageContainer, QueryList, Descriptions, and FormPage.
Use this playbook when wiring a business entity lifecycle (list → detail → create/edit). It follows the same mental model as Ant Design Pro and ProComponents: one shared field schema, multiple surfaces, and explicit page types instead of one generic “form page” for everything.
When To Use
- Wiring list → detail → create/edit for a business entity (not multi-party approval — that is Approval And Case Patterns).
- Choosing Modal Form vs Form Page (few fields vs sections / line items).
- Placing Create in the page header vs a region Add on an Object Page card.
- Choosing Object Page Hub, Descriptions-only detail, drawer peek, or a collection-scoped list.
Ant Design Pro Baseline
Ant Design Pro’s default CRUD stack (see ListTableList block and ProTable intro) looks like this:
f-ui follows Pro’s schema-reuse and surface types, but places resource-index Create in the page header — not the table toolbar. See Action Placement below.
| Surface | ProComponents | Typical trigger |
|---|---|---|
| List | ProTable inside PageContainer | App nav / menu |
| Quick detail | ProDescriptions in a Drawer | Row name link or “View” |
| Create | ModalForm / DrawerForm or dedicated create route | Page header primary (f-ui diverges from ProTable toolBarRender) |
| Edit | Same form with initialValues from record | Row “Edit” or detail footer |
| Full detail | PageContainer + tabs + descriptions | Deep link / “Open” |
Pro’s key idea: reuse column/schema config across Table, Descriptions, and Form (ProSchema). f-ui mirrors that with defineDataListSchema, defineEditableColumns, and shared kind renderers.
f-ui Mapping
| Ant Design Pro | f-ui | When |
|---|---|---|
PageContainer | PageContainer (page shell) | Every full-page route |
ProTable + search | QueryList + Table | Default browse / ops list |
| List rows | QueryList view="list" | Card/row-first lists |
ProDescriptions (read-only) | Descriptions + schema kinds | Detail panels |
Drawer + descriptions | Sheet / Drawer; body follows Adaptive Sheet Body | Quick peek without leaving list |
ModalForm / short form | Modal Form | ≤ ~8 fields, no line-item grid |
ProForm full page | Form Page + FormPanel (Formily) | Multi-section create/edit |
| Line items / editable grid | Editable Table variant="embedded" | Order lines, invoice rows |
| Result / 404 | Result + PageContainer | Post-submit or missing entity |
Decision Tree
What is the user doing?
├─ Browse, filter, batch actions on many records?
│ └─ Full-page list → QueryList + TableView (or `view="list"`)
│ Showcase: /showcases/orders · /showcases/orders-list
│
├─ Read one record (no editing)?
│ ├─ Quick peek, keep list visible? → Sheet / Drawer — Adaptive Sheet Body (below)
│ │ Markdown peek: /showcases/knowledge-base-documents (row click)
│ ├─ Primary destination, scalars only (no child collections)? → Descriptions-only detail
│ │ Showcase: /showcases/orders-detail-basic
│ ├─ Thin parent (≤ ~3–5 fields) + child collection is the job? → Collection-scoped list
│ │ Showcase: /showcases/knowledge-base-documents
│ └─ Rich parent + 1:N children?
│ ├─ Tight lines (+ optional loose)? → Object Page Hub
│ │ Showcase: /showcases/orders-detail
│ └─ Loose Related Lists only? → Multi Related Lists detail
│ Showcase: /showcases/orders-detail-related
│
├─ Create a new record?
│ ├─ Few fields, opened from page header? → Modal Form (/docs/components/modal-form) (NOT FormPage)
│ └─ Sections, validations, line items, draft save? → FormPage mode="create"
│ Showcase: /showcases/orders-form-create
│
└─ Change an existing record?
├─ Single cell / one column? → Table inline edit or small dialog
└─ Same as create but loaded? → FormPage mode="edit" + API initialValues
Showcase: /showcases/orders-form-editExamples
Action Placement
On a resource-index list, Create belongs in the page header (PageContainer.extra). Left side parks Create in the table toolbar next to search; right side keeps the toolbar as find/view chrome only.
Wrong
Orders
| ORD-2401 | Acme Corp | Confirmed |
| ORD-2402 | Globex Inc | Shipped |
Create sits in the table toolbar — competes with find/view chrome
Right
Orders
| ORD-2401 | Acme Corp | Confirmed |
| ORD-2402 | Globex Inc | Shipped |
Resource-index Create lives in `PageContainer.extra`; toolbar is find/view only
"use client";
import { Columns3Icon, PlusIcon, RefreshCwIcon, SearchIcon } from "lucide-react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { DataListProvider } from "@/components/f-ui/data-list-chrome/layout/data-list-provider";
import { f } from "@/components/f-ui/field-types/catalog";
import { PageContainer } from "@/components/f-ui/page/page-container";
import { Table } from "@/components/f-ui/table/table";
import { Button } from "@/components/ui/button";
import { DesignCompare } from "@/demos/_design/design-compare";
type OrderRow = {
id: string;
orderNumber: string;
customer: string;
status: "confirmed" | "shipped";
};
const ROWS: OrderRow[] = [
{ id: "1", orderNumber: "ORD-2401", customer: "Acme Corp", status: "confirmed" },
{ id: "2", orderNumber: "ORD-2402", customer: "Globex Inc", status: "shipped" },
];
const schema = defineDataListSchema<OrderRow>({
orderNumber: f.text({ label: "Order #" }),
customer: f.text({ label: "Customer" }),
status: f.enum({
render: "status",
label: "Status",
variants: {
confirmed: { label: "Confirmed", tone: "success" },
shipped: { label: "Shipped", tone: "info" },
},
}),
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function MiniOrdersTable({ listCode }: { listCode: string }) {
const handle = useDataList({
schema,
listCode,
data: ROWS,
getRowId: (r) => r.id,
defaultPageSize: "all",
});
return (
<DataListProvider dataList={handle}>
<div className="overflow-hidden rounded-lg border bg-card">
<Table dataList={handle} />
</div>
</DataListProvider>
);
}
function WrongSketch() {
return (
<div className="space-y-3">
<p className="text-sm font-medium">Orders</p>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" className="gap-1.5">
<SearchIcon className="size-3.5" />
Search
</Button>
<Button type="button" variant="ghost" className="gap-1.5">
<RefreshCwIcon className="size-3.5" />
Refresh
</Button>
<Button type="button" className="gap-1.5">
<PlusIcon className="size-3.5" />
Create
</Button>
</div>
<MiniOrdersTable listCode="crud-action-wrong" />
<p className="text-xs text-muted-foreground">
Create sits in the table toolbar — competes with find/view chrome
</p>
</div>
);
}
function RightSketch() {
return (
<div className="space-y-3">
<PageContainer
className="h-auto min-h-0 overflow-visible rounded-lg border"
fixedHeader={false}
surface="plain"
title="Orders"
extra={
<Button type="button" className="gap-1.5">
<PlusIcon className="size-3.5" />
Create
</Button>
}
>
<div className="space-y-2 px-0 pb-0">
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" className="gap-1.5">
<SearchIcon className="size-3.5" />
Search
</Button>
<Button type="button" variant="ghost" className="gap-1.5">
<Columns3Icon className="size-3.5" />
Columns
</Button>
<Button type="button" variant="ghost" className="gap-1.5">
<RefreshCwIcon className="size-3.5" />
Refresh
</Button>
</div>
<MiniOrdersTable listCode="crud-action-right" />
</div>
</PageContainer>
<p className="text-xs text-muted-foreground">
Resource-index Create lives in `PageContainer.extra`; toolbar is find/view only
</p>
</div>
);
}
export function CrudPageActionPlacementDemo() {
return (
<QueryClientProvider client={queryClient}>
<DesignCompare wrong={<WrongSketch />} right={<RightSketch />} />
</QueryClientProvider>
);
}Region Table Toolbar
Any card that owns a table needs one title + tools row. Left side is the floating tool stack (solid Refresh, vertical stack); right side uses horizontal region chrome.
Wrong
Documents
| Shipping policy | Ready |
| Return checklist | Pending |
Floating tool stack — solid Refresh, vertical stack, no shared baseline
Right
| Shipping policy | Ready |
| Return checklist | Pending |
One row — title left; ghost Refresh, outline Columns, Add right
"use client";
import { Columns3Icon, PlusIcon, RefreshCwIcon } from "lucide-react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { DataListProvider } from "@/components/f-ui/data-list-chrome/layout/data-list-provider";
import { DataListToolbar } from "@/components/f-ui/data-list-chrome/data-list-toolbar";
import { f } from "@/components/f-ui/field-types/catalog";
import { Table } from "@/components/f-ui/table/table";
import { Button } from "@/components/ui/button";
import { DesignCompare } from "@/demos/_design/design-compare";
type DocRow = {
id: string;
title: string;
status: "ready" | "pending";
};
const ROWS: DocRow[] = [
{ id: "1", title: "Shipping policy", status: "ready" },
{ id: "2", title: "Return checklist", status: "pending" },
];
const schema = defineDataListSchema<DocRow>({
title: f.text({ label: "Title" }),
status: f.enum({
render: "status",
label: "Status",
variants: {
ready: { label: "Ready", tone: "success" },
pending: { label: "Pending", tone: "warning" },
},
}),
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function DocsTable({ listCode }: { listCode: string }) {
const handle = useDataList({
schema,
listCode,
data: ROWS,
getRowId: (r) => r.id,
defaultPageSize: "all",
});
return (
<DataListProvider dataList={handle}>
<Table dataList={handle} />
</DataListProvider>
);
}
function WrongSketch() {
return (
<div className="space-y-3">
<div className="rounded-xl border bg-card p-4">
<div className="mb-6 flex items-start justify-between gap-4">
<h3 className="text-sm font-medium text-muted-foreground">Documents</h3>
<div className="flex flex-col items-stretch gap-2">
<Button type="button">
<RefreshCwIcon className="size-4" />
Refresh
</Button>
<Button type="button" variant="outline">
<Columns3Icon className="size-4" />
Columns
</Button>
<Button type="button" variant="secondary">
<PlusIcon className="size-4" />
Add
</Button>
</div>
</div>
<DocsTable listCode="crud-region-wrong" />
</div>
<p className="text-xs text-muted-foreground">
Floating tool stack — solid Refresh, vertical stack, no shared baseline
</p>
</div>
);
}
function RightSketch() {
return (
<div className="space-y-3">
<div className="rounded-xl border bg-card p-4">
<DataListToolbar
title="Documents"
tools={
<>
<Button type="button" variant="ghost" className="gap-1.5">
<RefreshCwIcon className="size-4" />
Refresh
</Button>
<Button type="button" variant="outline" className="gap-1.5">
<Columns3Icon className="size-4" />
Columns
</Button>
</>
}
actions={
<Button type="button" className="gap-1.5">
<PlusIcon className="size-4" />
Add
</Button>
}
/>
<div className="mt-3">
<DocsTable listCode="crud-region-right" />
</div>
</div>
<p className="text-xs text-muted-foreground">
One row — title left; ghost Refresh, outline Columns, Add right
</p>
</div>
);
}
export function CrudPageRegionToolbarDemo() {
return (
<QueryClientProvider client={queryClient}>
<DesignCompare wrong={<WrongSketch />} right={<RightSketch />} />
</QueryClientProvider>
);
}List Vs Detail Hub
List↔Detail peeks are not Object Page Hubs. Left side keeps children off-screen in a peer split; right side opens a hub with Overview, tight lines, and loose Related Lists.
Wrong
Orders
ORD-2401
Customer · Acme Corp
Amount · USD 1,240.00
Lines and attachments live on other routes — Hub never appears
List↔Detail split for a rich 1:N object — children stay invisible
Right
Orders / ORD-2401
Sales order
Overview
Customer, ship-to, amount KPI
Line items
Embedded tight table on the hub
Attachments
Loose Related List — own toolbar Add
List opens an Object Page Hub — parent scalars + child collections
"use client";
import { StatusTag } from "@/components/f-ui/status-tag";
import { Button } from "@/components/ui/button";
import { DesignCompare } from "@/demos/_design/design-compare";
/** Wrong: peer split view cosplaying as an Object Page Hub for 1:N. */
function WrongSketch() {
return (
<div className="space-y-3">
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] gap-2 rounded-xl border bg-card p-3">
<div className="space-y-2 border-r pr-2">
<p className="text-xs font-medium text-muted-foreground">Orders</p>
{["ORD-2401", "ORD-2402", "ORD-2403"].map((id, i) => (
<div
key={id}
className={`rounded-md px-2 py-1.5 text-xs ${
i === 0 ? "bg-muted font-medium" : "text-muted-foreground"
}`}
>
{id}
</div>
))}
</div>
<div className="space-y-2">
<p className="text-sm font-medium">ORD-2401</p>
<p className="text-xs text-muted-foreground">Customer · Acme Corp</p>
<p className="text-xs text-muted-foreground">Amount · USD 1,240.00</p>
<p className="text-xs text-destructive">
Lines and attachments live on other routes — Hub never appears
</p>
</div>
</div>
<p className="text-xs text-muted-foreground">
List↔Detail split for a rich 1:N object — children stay invisible
</p>
</div>
);
}
/** Right: list navigates to an Object Page Hub with related regions. */
function RightSketch() {
return (
<div className="space-y-3">
<div className="rounded-xl border bg-card p-3">
<div className="mb-3 flex items-start justify-between gap-2 border-b pb-3">
<div>
<p className="text-xs text-muted-foreground">Orders / ORD-2401</p>
<p className="text-sm font-semibold">Sales order</p>
<div className="mt-1.5 flex flex-wrap gap-1.5">
<StatusTag tone="success">Confirmed</StatusTag>
<StatusTag tone="info">Shipped</StatusTag>
</div>
</div>
<Button type="button" variant="outline">
Edit
</Button>
</div>
<div className="space-y-2">
<div className="rounded-lg border bg-muted/20 p-2">
<p className="text-sm font-medium text-muted-foreground">Overview</p>
<p className="mt-1 text-xs text-muted-foreground">
Customer, ship-to, amount KPI
</p>
</div>
<div className="rounded-lg border bg-muted/20 p-2">
<p className="text-sm font-medium text-muted-foreground">Line items</p>
<p className="mt-1 text-xs text-muted-foreground">
Embedded tight table on the hub
</p>
</div>
<div className="rounded-lg border bg-muted/20 p-2">
<p className="text-sm font-medium text-muted-foreground">Attachments</p>
<p className="mt-1 text-xs text-muted-foreground">
Loose Related List — own toolbar Add
</p>
</div>
</div>
</div>
<p className="text-xs text-muted-foreground">
List opens an Object Page Hub — parent scalars + child collections
</p>
</div>
);
}
export function CrudPageListVsDetailDemo() {
return <DesignCompare wrong={<WrongSketch />} right={<RightSketch />} />;
}Modal Form Vs Form Page
Short header Create (a few fields, no line grid) opens Modal Form. Left side wastes a full Form Page route on two fields; right side keeps the list and opens a modal.
Wrong
Create tag
Full Form Page for two fields
Form Page for a short header Create — overkill route + footer chrome
Right
Tags
≤ ~8 fields → Modal Form
Header Create opens Modal Form — reserve Form Page for sections / lines
"use client";
import { useMemo } from "react";
import { PlusIcon } from "lucide-react";
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 { FormPanel } from "@/components/f-ui/formily/form-panel";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { ModalForm } from "@/components/f-ui/modal-form/modal-form";
import { Button } from "@/components/ui/button";
import { DesignCompare } from "@/demos/_design/design-compare";
type TagValues = {
label: string;
notes: string;
};
function WrongSketch() {
const form = useMemo(
() =>
createForm<TagValues>({
initialValues: { label: "", notes: "" },
}),
[],
);
return (
<div className="space-y-3">
<div className="rounded-xl border bg-card">
<div className="border-b px-4 py-3">
<p className="text-sm font-semibold">Create tag</p>
<p className="text-xs text-muted-foreground">Full Form Page for two fields</p>
</div>
<Form form={form} className="space-y-4 p-4" onSubmit={() => undefined}>
<FormPanel title="Tag" description="Opened as a full route">
<FormField kind="text" name="label" label="Label" required />
<FormField
kind="textarea"
name="notes"
label="Notes"
componentProps={{ rows: 2 }}
/>
</FormPanel>
<FormActions>
<Button type="submit">Create</Button>
<Button type="button" variant="outline">
Cancel
</Button>
</FormActions>
</Form>
</div>
<p className="text-xs text-muted-foreground">
Form Page for a short header Create — overkill route + footer chrome
</p>
</div>
);
}
function RightSketch() {
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2 rounded-xl border bg-card px-4 py-3">
<div>
<p className="text-sm font-medium">Tags</p>
<p className="text-xs text-muted-foreground">≤ ~8 fields → Modal Form</p>
</div>
<ModalForm<TagValues>
title="New tag"
description="Closes back to the list on success"
width={420}
trigger={
<Button type="button" className="gap-1.5">
<PlusIcon className="size-4" />
Create
</Button>
}
initialValues={{ label: "", notes: "" }}
submitter={{ submitText: "Add tag" }}
onFinish={async () => undefined}
>
<FormField kind="text" name="label" label="Label" required />
<FormField
kind="textarea"
name="notes"
label="Notes"
componentProps={{ rows: 2 }}
/>
</ModalForm>
</div>
<p className="text-xs text-muted-foreground">
Header Create opens Modal Form — reserve Form Page for sections / lines
</p>
</div>
);
}
export function CrudPageModalVsFormPageDemo() {
return <DesignCompare wrong={<WrongSketch />} right={<RightSketch />} />;
}Related List Region
Loose children (attachments) get region Create — not page-header Create. Left side puts Add attachment in extra; right side keeps Edit on the page and Add on the Related List toolbar.
Wrong
ORD-2401
- Order #
- ORD-2401
- Customer
- Acme Corp
- Status
- Confirmed
Attachments
| packing-slip.pdf | 2026-08-01 |
| label.pdf | 2026-08-02 |
Loose-child Add on the page header — wrong proximity for Related Lists
Right
ORD-2401
- Order #
- ORD-2401
- Customer
- Acme Corp
- Status
- Confirmed
Attachments
2| packing-slip.pdf | 2026-08-01 |
| label.pdf | 2026-08-02 |
Related List owns title + tools + Add on one region toolbar row
"use client";
import { PlusIcon, RefreshCwIcon } from "lucide-react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { DataListProvider } from "@/components/f-ui/data-list-chrome/layout/data-list-provider";
import { descriptionsFieldsFromSchema } from "@/components/f-ui/descriptions/lib/descriptions-field";
import { Descriptions } from "@/components/f-ui/descriptions/descriptions";
import { f } from "@/components/f-ui/field-types/catalog";
import { FormField } from "@/components/f-ui/formily/form-field";
import { ModalForm } from "@/components/f-ui/modal-form/modal-form";
import { PageContainer } from "@/components/f-ui/page/page-container";
import { RelatedList } from "@/components/f-ui/related-list/related-list";
import { StatusTag } from "@/components/f-ui/status-tag";
import { Table } from "@/components/f-ui/table/table";
import { Button } from "@/components/ui/button";
import { DesignCompare } from "@/demos/_design/design-compare";
type OrderHeader = {
orderNumber: string;
customer: string;
status: "confirmed";
};
type AttachmentRow = {
id: string;
name: string;
uploadedAt: string;
};
const HEADER: OrderHeader = {
orderNumber: "ORD-2401",
customer: "Acme Corp",
status: "confirmed",
};
const ATTACHMENTS: AttachmentRow[] = [
{ id: "1", name: "packing-slip.pdf", uploadedAt: "2026-08-01" },
{ id: "2", name: "label.pdf", uploadedAt: "2026-08-02" },
];
const headerSchema = defineDataListSchema<OrderHeader>({
orderNumber: f.text({ label: "Order #" }),
customer: f.text({ label: "Customer" }),
status: f.enum({
render: "status",
label: "Status",
variants: {
confirmed: { label: "Confirmed", tone: "success" },
},
}),
});
const attachmentSchema = defineDataListSchema<AttachmentRow>({
name: f.text({ label: "File" }),
uploadedAt: f.date({ label: "Uploaded" }),
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function AttachmentsRegion({
listCode,
showRegionAdd,
}: {
listCode: string;
showRegionAdd: boolean;
}) {
const handle = useDataList({
schema: attachmentSchema,
listCode,
data: ATTACHMENTS,
getRowId: (r) => r.id,
defaultPageSize: "all",
});
if (!showRegionAdd) {
return (
<div className="rounded-xl border bg-card p-3">
<p className="mb-2 text-sm font-medium text-muted-foreground">Attachments</p>
<div className="overflow-hidden rounded-lg border">
<DataListProvider dataList={handle}>
<Table dataList={handle} />
</DataListProvider>
</div>
</div>
);
}
return (
<RelatedList
title="Attachments"
count={ATTACHMENTS.length}
tools={
<Button type="button" variant="ghost" className="gap-1.5">
<RefreshCwIcon className="size-4" />
Refresh
</Button>
}
actions={
<ModalForm<{ name: string }>
title="Add attachment"
trigger={
<Button type="button" className="gap-1.5">
<PlusIcon className="size-4" />
Add attachment
</Button>
}
initialValues={{ name: "" }}
submitter={{ submitText: "Add" }}
onFinish={async () => undefined}
>
<FormField kind="text" name="name" label="File name" required />
</ModalForm>
}
>
<DataListProvider dataList={handle}>
<Table dataList={handle} />
</DataListProvider>
</RelatedList>
);
}
function WrongSketch() {
const fields = descriptionsFieldsFromSchema(headerSchema);
return (
<div className="space-y-3">
<PageContainer
className="h-auto min-h-0 overflow-visible rounded-lg border"
fixedHeader={false}
surface="plain"
title="ORD-2401"
tags={<StatusTag tone="success">Confirmed</StatusTag>}
extra={
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline">
Edit
</Button>
<Button type="button" className="gap-1.5">
<PlusIcon className="size-4" />
Add attachment
</Button>
</div>
}
>
<div className="space-y-3">
<Descriptions record={HEADER} fields={fields} column={1} size="small" />
<AttachmentsRegion listCode="crud-related-wrong" showRegionAdd={false} />
</div>
</PageContainer>
<p className="text-xs text-muted-foreground">
Loose-child Add on the page header — wrong proximity for Related Lists
</p>
</div>
);
}
function RightSketch() {
const fields = descriptionsFieldsFromSchema(headerSchema);
return (
<div className="space-y-3">
<PageContainer
className="h-auto min-h-0 overflow-visible rounded-lg border"
fixedHeader={false}
surface="plain"
title="ORD-2401"
tags={<StatusTag tone="success">Confirmed</StatusTag>}
extra={
<Button type="button" variant="outline">
Edit
</Button>
}
>
<div className="space-y-3">
<Descriptions record={HEADER} fields={fields} column={1} size="small" />
<AttachmentsRegion listCode="crud-related-right" showRegionAdd />
</div>
</PageContainer>
<p className="text-xs text-muted-foreground">
Related List owns title + tools + Add on one region toolbar row
</p>
</div>
);
}
export function CrudPageRelatedListDemo() {
return (
<QueryClientProvider client={queryClient}>
<DesignCompare wrong={<WrongSketch />} right={<RightSketch />} />
</QueryClientProvider>
);
}Drawer Peek
For a quick look while browsing, keep the list mounted. Left side navigates to a full detail page; right side peeks in a Sheet — try Peek row.
Wrong
Right
Documents
| Onboarding guide | Ada | Ready |
| API changelog | Lin | Draft |
| Release notes | Mei | Ready |
Sheet / Drawer keeps the resource list visible for List↔Detail peeks
"use client";
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { DataListProvider } from "@/components/f-ui/data-list-chrome/layout/data-list-provider";
import { descriptionsFieldsFromSchema } from "@/components/f-ui/descriptions/lib/descriptions-field";
import { Descriptions } from "@/components/f-ui/descriptions/descriptions";
import { f } from "@/components/f-ui/field-types/catalog";
import { PageContainer } from "@/components/f-ui/page/page-container";
import { Table } from "@/components/f-ui/table/table";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { DesignCompare } from "@/demos/_design/design-compare";
type DocRow = {
id: string;
title: string;
owner: string;
status: "ready" | "draft";
};
const ROWS: DocRow[] = [
{ id: "1", title: "Onboarding guide", owner: "Ada", status: "ready" },
{ id: "2", title: "API changelog", owner: "Lin", status: "draft" },
{ id: "3", title: "Release notes", owner: "Mei", status: "ready" },
];
const schema = defineDataListSchema<DocRow>({
title: f.text({ label: "Title" }),
owner: f.text({ label: "Owner" }),
status: f.enum({
render: "status",
label: "Status",
variants: {
ready: { label: "Ready", tone: "success" },
draft: { label: "Draft", tone: "neutral" },
},
}),
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function DocsList({ listCode }: { listCode: string }) {
const handle = useDataList({
schema,
listCode,
data: ROWS,
getRowId: (r) => r.id,
defaultPageSize: "all",
});
return (
<DataListProvider dataList={handle}>
<div className="overflow-hidden rounded-lg border bg-card">
<Table dataList={handle} />
</div>
</DataListProvider>
);
}
function WrongSketch() {
const fields = descriptionsFieldsFromSchema(schema);
const record = ROWS[0];
return (
<div className="space-y-3">
<PageContainer
className="h-auto min-h-0 overflow-visible rounded-lg border"
fixedHeader={false}
surface="plain"
backHref="#"
title={record.title}
subTitle="Left the list — filters and selection are gone"
>
<Descriptions record={record} fields={fields} column={1} size="small" />
</PageContainer>
<p className="text-xs text-muted-foreground">
Full-page navigate for a quick peek — list context is lost
</p>
</div>
);
}
function RightSketch() {
const [open, setOpen] = useState(false);
const fields = descriptionsFieldsFromSchema(schema);
const record = ROWS[0];
return (
<div className="space-y-3">
<div className="space-y-2 rounded-xl border bg-card p-3">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium">Documents</p>
<Button type="button" variant="outline" onClick={() => setOpen(true)}>
Peek row
</Button>
</div>
<DocsList listCode="crud-drawer-peek" />
</div>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="right" className="sm:max-w-md">
<SheetHeader>
<SheetTitle>{record.title}</SheetTitle>
<SheetDescription>
Quick peek — list stays mounted behind the sheet
</SheetDescription>
</SheetHeader>
<div className="mt-4 px-4">
<Descriptions record={record} fields={fields} column={1} size="small" />
</div>
</SheetContent>
</Sheet>
<p className="text-xs text-muted-foreground">
Sheet / Drawer keeps the resource list visible for List↔Detail peeks
</p>
</div>
);
}
export function CrudPageDrawerPeekDemo() {
return (
<QueryClientProvider client={queryClient}>
<DesignCompare wrong={<WrongSketch />} right={<RightSketch />} />
</QueryClientProvider>
);
}Anti God Form
Do not one-scroll parent + every nested write. Left side is a God Form (attachments and activity share Submit); right side keeps tight lines on Form Page and loose collections on Related Lists.
Wrong
Edit order (god form)
One scroll — header, lines, attachments, activity
God Form — parent + all nested writes share one dirty/submit boundary
Right
Edit order
Form Page — header + tight lines only
Attachments
Loose Related List — independent CRUD, not part of form dirty
Split along object boundaries — tight lines on Form Page; loose lists alone
"use client";
import { useMemo } from "react";
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 { FormPanel } from "@/components/f-ui/formily/form-panel";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { DesignCompare } from "@/demos/_design/design-compare";
type GodFormValues = {
customer: string;
shipTo: string;
lineSku: string;
lineQty: string;
attachmentName: string;
activityNote: string;
};
type OrderFormValues = {
customer: string;
shipTo: string;
lineSku: string;
lineQty: string;
};
function WrongSketch() {
const form = useMemo(
() =>
createForm<GodFormValues>({
initialValues: {
customer: "Acme Corp",
shipTo: "Building A",
lineSku: "SKU-100",
lineQty: "2",
attachmentName: "packing-slip.pdf",
activityNote: "Called warehouse",
},
}),
[],
);
return (
<div className="space-y-3">
<div className="max-h-80 overflow-y-auto rounded-xl border bg-card">
<div className="border-b px-4 py-3">
<p className="text-sm font-semibold">Edit order (god form)</p>
<p className="text-xs text-muted-foreground">
One scroll — header, lines, attachments, activity
</p>
</div>
<Form form={form} className="space-y-4 p-4" onSubmit={() => undefined}>
<FormPanel title="Header">
<FormField kind="text" name="customer" label="Customer" />
<FormField kind="text" name="shipTo" label="Ship to" />
</FormPanel>
<FormPanel title="Line items">
<FormField kind="text" name="lineSku" label="SKU" />
<FormField kind="text" name="lineQty" label="Qty" />
</FormPanel>
<FormPanel title="Attachments">
<FormField kind="text" name="attachmentName" label="File name" />
</FormPanel>
<FormPanel title="Activity">
<FormField
kind="textarea"
name="activityNote"
label="Note"
componentProps={{ rows: 2 }}
/>
</FormPanel>
<FormActions>
<Button type="submit">Save everything</Button>
</FormActions>
</Form>
</div>
<p className="text-xs text-muted-foreground">
God Form — parent + all nested writes share one dirty/submit boundary
</p>
</div>
);
}
function RightSketch() {
const form = useMemo(
() =>
createForm<OrderFormValues>({
initialValues: {
customer: "Acme Corp",
shipTo: "Building A",
lineSku: "SKU-100",
lineQty: "2",
},
}),
[],
);
return (
<div className="space-y-3">
<div className="rounded-xl border bg-card">
<div className="border-b px-4 py-3">
<p className="text-sm font-semibold">Edit order</p>
<p className="text-xs text-muted-foreground">
Form Page — header + tight lines only
</p>
</div>
<Form form={form} className="space-y-4 p-4" onSubmit={() => undefined}>
<FormPanel title="Header">
<FormField kind="text" name="customer" label="Customer" />
<FormField kind="text" name="shipTo" label="Ship to" />
</FormPanel>
<FormPanel title="Line items">
<FormField kind="text" name="lineSku" label="SKU" />
<FormField kind="text" name="lineQty" label="Qty" />
</FormPanel>
<FormActions>
<Button type="submit">Save</Button>
<Button type="button" variant="outline">
Cancel
</Button>
</FormActions>
</Form>
</div>
<div className="rounded-xl border bg-card p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-sm font-medium text-muted-foreground">Attachments</p>
<Button type="button">
Add attachment
</Button>
</div>
<p className="text-xs text-muted-foreground">
Loose Related List — independent CRUD, not part of form dirty
</p>
</div>
<p className="text-xs text-muted-foreground">
Split along object boundaries — tight lines on Form Page; loose lists alone
</p>
</div>
);
}
export function CrudPageAntiGodFormDemo() {
return <DesignCompare wrong={<WrongSketch />} right={<RightSketch />} />;
}Scenario Matrix
| Scenario | Page type | f-ui stack | Draft / guard | Live showcase |
|---|---|---|---|---|
| Orders table browse | List | PageContainer + QueryFilter + TableView | — | /showcases/orders |
| Orders row list | List | PageContainer + QueryFilter + List rows | — | /showcases/orders-list |
| Sidebar filter panel | List variant | Staged filter panel + table | — | /showcases/orders-sidebar-filters |
| Order detail (scalars only) | Descriptions-only detail | PageContainer + one Descriptions card | — | /showcases/orders-detail-basic |
| Order detail (read hub) | Object Page Hub — multi-card Overview(+Lines) + Attachments | PageContainer + Descriptions cards + embedded Lines + Related List tab + footer | — | /showcases/orders-detail |
| Order detail (loose only) | Rich parent + Related Lists | Field cards + Attachments + Activity Related Lists | — | /showcases/orders-detail-related |
| Knowledge base documents | Document library + List↔Detail Sheet | Thin PageHeader + QueryList; Upload documents = ModalForm + FormField kind="file"; row click → Sheet Preview (MarkdownRenderer) + Properties | — | /showcases/knowledge-base-documents |
| Approvals inbox | List (work queue) | PageContainer + QueryList + StatusTag + strategy labels | — | /showcases/approvals |
| Approval instance | Object Page Hub + stages | Descriptions + lines + Stepper + assignees + approval record + Approve/Reject | — | /showcases/approvals-detail |
| Complaints inbox | List (case queue) | QueryList + severity / SLA KPIs | — | /showcases/complaints |
| Complaint case hub | Case workspace | Hub tabs + lifecycle Stepper + dept tasks + activity + Resolve | — | /showcases/complaints-detail |
| New sales order | Create | FormPage mode="create" + FormPanel sections + embedded EditableTable | persistence + warnOnUnsavedChanges | /showcases/orders-form-create |
| Edit sales order | Edit | FormPage mode="edit" + same layout, loaded values | Same | /showcases/orders-form-edit |
| Missing route | Result | PageContainer + Result | — | /showcases/result |
List showcases expose Create in the page header (PageContainer.extra), not the table toolbar.
On a resource-index list, optional headline KPIs belong in the exclusive-area stats slot — filter above, statistics below, then the list body. See List Page Statistics.
Descriptions Layout And Header KPIs
Detail pages use two components — do not merge them:
| Surface | Component | Layout / type | Value weight |
|---|---|---|---|
| Body / section fields (Contact, Fulfillment, …) | Descriptions | Kit default vertical; opt-in layout="horizontal" for dense short scalars | Body (text-sm class) — never hero |
| Header Amount / Quantity / Balance | Statistic / StatisticGroup in extraContent | Label above value | Large (~text-2xl) — few metrics only |
Orders Hub dogfood: horizontal Descriptions in Overview cards + Statistic Amount/Quantity in the header — intentional, not a kit-default flip. See /showcases/orders-detail.
Do not style every Descriptions value like a Statistic. Do not hand-roll text-2xl number stacks next to Descriptions — use @f-ui/statistic.
Parent And Child Surfaces
Scalar-only detail is incomplete for 1:N business objects. An order is not just header fields — it has lines and often attachments. Use the vocabulary below so List↔Detail (peer browse) is never confused with Parent→Children (object hub).
| Term | Means | Example |
|---|---|---|
| Object Page / Record Page | One business object as a page hub | Order detail with sections and related collections |
| Related Lists | Child (or related) collections on a record page | Attachments on an order; Contacts on an Account |
| Header / Line | Transactional parent + atomic children | Invoice header + line positions |
| List–Detail / Split View | Peer list ↔ one selected peer | Orders index + side panel |
| Form Array / Line Grid | Edit children inside the parent form | Create/edit sales order lines |
| Aggregate Root | Consistency boundary for parent + children writes | Order Submit with all lines |
Naming Trap: List↔Detail ≠ Parent→Children
Industry casually says “master–detail” for both (1) which peer am I looking at, and (2) what belongs to this object. Wrong merge → a Split View where an Object Page Hub was needed, or a God Form that tries to be both browse and compose. Real products nest them: list of Orders → Order Object Page with Lines + Attachments.
Parent Weight And Collection Pages
Before picking Object Page Hub, ask: is the parent a rich business object, or a thin container?
| Parent weight | Child job | Choose | Showcase |
|---|---|---|---|
| Many / decision-critical fields | Supporting | Object Page Hub (or multi Related Lists) | /showcases/orders-detail · /showcases/orders-detail-related |
≤ ~3–5 thin labels (name / description / id) | Primary (browse/filter/CRUD the collection) | Collection-scoped list — PageHeader = scope identity; body = QueryList/Table | /showcases/knowledge-base-documents |
| Rich parent, related set large | Supporting but large | Hub preview (~5) + View all → full collection route (Cloudscape Details as a hub) | Documented follow-up |
Industry anchors for collection-first: Salesforce Content Libraries / Files home, SharePoint document library, Drive folder. Primary header action is Upload (files via File Upload), not “Create article.” Salesforce Knowledge articles (authored HTML/rich text) are a different product surface — do not copy that CTA onto a library showcase.
Files on an Account/Case stay Hub-related lists because the parent is rich.
Library In Hub Costume
Do not force a thin knowledge base into Descriptions-heavy Hub chrome with a full documents table as a “Related List.” If users spend their time in the documents list, ship a collection page. Feeling that “this is just a list page” is the correct signal.
When To Copy Which Showcase
| Scenario | Copy |
|---|---|
| Scalar-only record, no children | /showcases/orders-detail-basic |
| Rich aggregate: KPIs + tight lines + optional loose tab | /showcases/orders-detail |
| Rich parent + only loose Related Lists | /showcases/orders-detail-related (one-row region toolbar) |
| Thin container + documents/files as the job | /showcases/knowledge-base-documents — Upload (not Create article) |
| Quick peek, keep list visible | /showcases/knowledge-base-documents (Sheet Preview / MarkdownRenderer + Properties on row click) |
| Multi-party / multi-department approval | /showcases/approvals → /showcases/approvals-detail — see Approval And Case Patterns |
| Complaint / feedback case workspace | /showcases/complaints → /showcases/complaints-detail |
| Line-item approval (line policy + validation triad) | Compose from orders detail + Approval And Case Patterns — StatusTag on lines, Message Popover for checks |
Body Card Granularity
Follow Ant Design Detail Page: one card = one topic / one async boundary. Strongly related short fields may share a card with dividers; long text and Related Lists get their own cards.
| Complexity | Layout |
|---|---|
| Basic (scalars only) | One card; groups separated by Separator — /showcases/orders-detail-basic |
| Advanced / Hub | Multi-card: short field groups · long text · each child collection |
Chrome (padding, title weights, dividers, toolbar height) is locked on Object Page Cards. This section only picks how many cards.
Object Page Hub
Default read surface when a rich record has child collections. Showcase: /showcases/orders-detail.
Anatomy
| Region | Content | Role |
|---|---|---|
| PageHeader | Identity, status tags, Share/Edit, header Descriptions + KPI Statistics | Object identity |
| Tab: Overview | Card A short field groups · Card B addresses/notes · Card C embedded tight Lines | Scalars + children that reconcile with header KPIs |
| Tab: Attachments (example) | Related List composition — list + Add Modal + Delete (own card) | Loose children |
| FooterToolbar | Approve / Reject (object-level) | Page-subject actions |
When Hub Vs Descriptions-Only Vs Collection
| Choose | When |
|---|---|
| Descriptions-only detail | Primary destination, scalars only — no child collections |
| Object Page Hub | Rich parent + 1:N children (tight lines, loose related lists, or both) |
| Collection-scoped list | Thin parent; child collection is the primary task |
Use a Sheet / Drawer for a quick peek that keeps the list visible — that is List↔Detail, not a Hub. Dogfood: /showcases/knowledge-base-documents (row click / View → Sheet).
Adaptive Sheet Body (List↔Detail)
When the peeked peer has a processed markdown body, lay out content vs metadata by weight — do not default every peek to Descriptions-only:
| Meta + body | Sheet body |
|---|---|
| Thin meta (≤ ~3–5 fields) + body | Stacked: slim meta above MarkdownRenderer |
| Rich meta + body | Tabs: Preview (default) | Properties |
| No body | Properties / meta only |
Anti-pattern: a document peek that shows only Descriptions when a processed body exists.
Boundary: Document Preview is for PDF / DOCX / image. Processed markdown bodies use MarkdownRenderer — do not nest Document Preview for .md.
Hierarchy And Tabs
- Max ~2 visible hierarchy levels on one screen. Deeper nesting → navigate to a child Object Page (do not stack expandable editable tables).
- Tabs only for weakly correlated modules (Attachments, Activity, Settings).
- Do not park tight line items behind a tab when operators must reconcile them against header KPIs — prefer an embedded section on Overview (as in /showcases/orders-detail: Amount in the header, Lines on Overview).
- Body section titles (Contact, Line items, …) use sentence case — never CSS
uppercase/ ALL CAPS. Full rule (Title Case vs sentence case, buttons, SAP exception): Sentence Case.
Tight Vs Loose Children
| Tight (Order ↔ Lines) | Loose (Order ↔ Attachments) | |
|---|---|---|
| Lifecycle | Child meaningless without parent; usually same submit | Child CRUD independent; parent can exist first |
| Read | Hub embedded section table | Related List (own toolbar) |
| Write | FormPage + EditableTable; one Submit | List Add/Edit/Delete; own mutations |
| Dirty | Same form dirty / draft / leave guard | Not part of parent form dirty |
| Empty CTA | “No line items” → Edit order | “No attachments” → Add attachment |
Orders showcase mapping: Lines → single transaction; Attachments → independent CRUD.
Commit Models
- Single transaction — parent + children one Save (default for order lines).
- Independent CRUD — each Related List persists itself (default for attachments).
- Draft then compose — persist parent draft, add loose children, then Submit parent (documented third model; no dedicated showcase path in Wave B).
Mixing models without labeling draft vs committed confuses operators — name which path each child collection uses.
How To Show Children
| Child profile | Read (detail) | Write (create/edit) |
|---|---|---|
| Tight, modest rows, same submit | Embedded table section on Object Page | EditableTable on FormPage |
| Tight, large / filterable | Paginated related-style table; optional “open full” | FormPage sections or dedicated lines editor route |
| Loose, first-class objects | Related List preview + child Object Pages | Parent create first; Add from list toolbar |
| Many related types (3+) | Hub: tabs or stacked Related Lists | Independent CRUD per list; page Edit for header |
| Deep tree / folders | Tree table or navigate into node pages | Avoid nested editable grids beyond 2 levels |
/showcases/orders-detail covers row 1 (Lines) plus Attachments. /showcases/orders-detail-related covers row 4 (stacked Related Lists). Thin-parent collections use /showcases/knowledge-base-documents, not Hub.
Region Table Toolbar
Scope: any card / section that owns a table — Hub child sections, embedded collections, Documents cards, Related Lists, etc. Not a Related-List-only rule. Axis: the table is a region inside the page, not the page itself (resource index uses list chrome + page-header Create — see Action Placement).
┌ rounded-xl border p-6 gap-4 ─────────────────────────────────┐
│ Documents [ghost Refresh] [Columns] [Add] │
│ │
│ Title Status Updated … │
└──────────────────────────────────────────────────────────────┘The blank line is gap-4, not a CSS border-b. Full shell: Object Page Cards.
| Do | Don't |
|---|---|
Title left + tools right on one row (flex items-center justify-between) | Stack Refresh / Columns / Add in a vertical column in the corner |
Tools as a horizontal cluster (flex gap-2) — ghost/outline icon or compact buttons | Full-width primary Refresh above a secondary Columns with a dead whitespace band |
| Refresh / column manager = low emphasis (ghost / outline); region Add may be the only solid primary in that row | Make Refresh look like the page primary CTA |
Use DataListToolbar / QueryList tools slot when possible | Invent a second layout language beside the list chrome |
| Same control height for Columns / Expand / Refresh (Button default) | Mix size="sm" beside default tools; mix SelectTrigger size="sm" (h-7) with default Buttons (h-8) on that row |
| Same-table view filters → segmented (≤3) or Select (≥4) | A row of independent Buttons that look like tabs |
More on filters, Message Popover, and cell messages: Object Messaging And Table Chrome.
Anti-pattern name: Floating tool stack — screenshot-classic right-rail of stacked buttons floating above the table with no shared baseline to the section title.
Anti-Pattern: Floating Tool Stack
Wrong: section title alone on the left; Refresh (often solid primary) and Columns stacked vertically in the top-right with empty space between the title and the table.
Right: one row — Documents · ghost Refresh · outline Columns · Add — then the table. Live demos: Table — Region Table Toolbar and /showcases/orders-detail-related.
Which painter (List vs Related List vs QueryList view vs Table), and where checkboxes / batch live: List Surfaces.
Related List Regions
Install Related List for Object Page child collections. It implements Region Table Toolbar (title + tools + region Add on one row) and owns region Loading / Empty / Error.
Compose the body with:
- List for presentational item rows (members, activity lines)
- Table (
defineDataListSchema+useDataList+TableView) for columnar related rows
Also:
- Toolbar Create for loose children — on the region toolbar, not the page header
- Own Loading / Empty / Error via Related List
status(Empty + Result + Skeleton) — never Empty while pending; Error ≠ Empty; do not wrap the same body in a second triad - Tight children: Create/Edit via FormPage, not a Related List Add on the detail page (detail Lines stay read-only + CTA to edit)
Live demos: /showcases/orders-detail-related and /showcases/orders-detail (Attachments tab).
Anti-Patterns
| Anti-pattern | Do this instead |
|---|---|
| God Form | Split along object boundaries; do not one-scroll parent + all nested writes |
| CRUD Tunnel | Peer browse that list→detail→back kills filters; industry fix is Split View — documented only, not built in Wave B |
| Fake related list | Scalar-only detail while children live elsewhere — use an Object Page Hub (or a collection page if the parent is thin) |
| Library in Hub costume | Thin parent + full child table dressed as Hub Descriptions — use a collection-scoped list |
| Create article on a file library | Blank “Create document” for SharePoint-style libraries — use Upload + File Upload; Create is for authored articles |
| Floating tool stack | Refresh / Columns / Add stacked vertically in a corner above the table — use one title+tools row (DataListToolbar) |
| Tabs as dumping ground | Do not park tight lines behind a tab when KPIs need reconciliation |
| >2 nested expandable editable tables | Navigate to a child Object Page at depth 2 |
| Grain confusion | No separately editable header total vs sum(lines) — derive Amount from lines |
| Error = Empty | Related region Error + Retry; never Empty while pending or on failure |
| Two truths for lines | Detail inline edit and FormPage FormArray — forbidden; detail stays read-only |
Action Placement
Button variant / icon / semantic rules for every surface: Button And Action Emphasis.
Where actions live
Hard rules for where CRUD actions live. Axis: is the table the page, or a region inside the page?
| Page shape | Create / Add | Edit | Import / Export (all) | Bulk |
|---|---|---|---|---|
| Resource index (title = entity list) | PageHeader primary (PageContainer.extra) — Create or Upload when the collection is files | Row / overflow → detail or edit route — never list page header | PageHeader secondary | Selection toolbar morph |
| Embedded tight table (order lines on FormPage / Hub Overview) | FormPage create/edit path — not Related List Add on detail | FormPage (detail Lines read-only + Edit CTA) | Rare | That table’s selection bar on the form |
| Loose Related List (attachments, child objects) | Region toolbar Create / Add | Row inline / overflow / Modal | Rare | That list’s selection bar |
| Detail / object | Loose Related List Add only (region toolbar) | PageHeader Edit | Rare | — |
| Form create / edit | — | — | — | Save / Submit in footer |
Resource Index Toolbar
Resting toolbar: search, filters, column/density/view, refresh. Not Create, Import, or Export.
On selection, the toolbar morphs to a bulk bar. Create stays in the page header.
Empty States And Create
| Empty subtype | Primary next action |
|---|---|
| First-use (no records) | May repeat Create (teaches location); header Create remains |
| Filtered / no results | Clear or edit filters — not Create |
| Error | Retry — not Create |
Do not put a third Create in the table toolbar. Aligns with the workspace async container states rule (Loading / Empty / Error).
One Primary
At most one emphasized primary on a resource index: Create (when allowed). Hide Create when the role can never create; disable when temporarily unavailable.
Row Actions Column
See the dedicated playbook: Row Actions Column.
Summary: no visible row operations → do not render an Actions column. Never leave an empty Actions header on Approved / audit tables.
Create vs Edit (Form Page)
Ant Design Pro treats create and edit as the same form component with different initialValues and submit action. f-ui does the same with Form Page mode:
| Create | Edit | |
|---|---|---|
| Route | /entities/new | /entities/:id/edit |
FormPage mode | "create" | "edit" |
| Initial values | Empty defaults + one blank row if needed | Loaded from API |
| Primary button | “Create” (footer uses action.submit.create) | “Save” (action.submit.edit) |
| Order # / id | Often empty until first save | Read-only or pre-filled |
| Persistence | Draft auto-save helps long creates | Draft auto-save + unsaved guard |
| Navigation | FormPageUnsavedGuard + list back link | Same |
Do not merge create and edit into one showcase route — keep two reference pages so integrators can copy the right mode, copy, and initial state without conditional spaghetti.
Draft save vs Submit (Ant Design Pro)
| Action | Validates? | f-ui |
|---|---|---|
| Auto-save / Save draft / Save now | No (except optional storability gate) | persistence.persistDraft(form.values); minimal preconditions via persistence.draftValidate |
| Create / Submit primary button | Yes (full) | form.submit(onFinish) |
Drafts intentionally accept partial, invalid data — only Submit runs full validation. If an entity genuinely cannot be stored until a minimal field set exists (tenant, title), name those paths in persistence.draftValidate: auto-save then defers silently until they are valid, and a manual Save draft reveals just those fields. Keep that set to the storability minimum — not the full required set — so a draft never saves cleanly only to fail Submit. See Form Page — Draft Preconditions.
Orders showcases wire both: silent auto-save plus Save (showManualSaveWhenAutoSave) and Create order / Submit order for the validated commit (mockApi.submitOrder). Manual Save triggers onPersistSuccess({ source: "manual" }) — the showcase uses that for a toast.
When To Enable Auto-Save
Auto-save is a draft mechanism, not a commit. It saves unvalidated work-in-progress so users do not lose it — it never runs onFinish. The industry consensus (GitHub Primer — Saving, GitLab Pajamas — Saving, NN/g — autosave trade-offs) is: enable it when the cost of losing work is high, and keep it invisible until it has something useful to say.
Enable auto-save when most of these hold:
- Long or multi-section form — sales order, article, config with many
FormPanels. Short modal forms (name + status) do not need it. - High data-loss risk — line-item grids, long free-text, work spanning minutes. A quick 3-field dialog does not.
- Draft is a valid state — the backend can store an incomplete/invalid record (a
draftstatus or a drafts table). If the API rejects partial payloads, auto-save has nowhere to write. - Idempotent, cheap persist —
persistDraftupserts one draft; it must not fan out emails, charge cards, or trip validation.
Create and edit both qualify — the split is about form weight, not mode:
| Create | Edit | |
|---|---|---|
| Draft target | New draft row; first save assigns an id (onDraftCreated → upgrade the route to /orders/:id/edit) | Existing record’s draft field / revision |
| Risk without it | Whole new record lost on reload | In-progress edits lost |
| Watch out | Do not create a “ghost” record on every keystroke — debounce, and only persist once there is real content | Do not auto-commit; keep draft separate from the published value until Submit |
Do not auto-save when:
- The form is short and opened from a page header (Modal/Drawer form) — a single Save is clearer. Embedded-table or row-action Modals still apply when proximity matters.
- Persisting has side effects or the “draft” concept does not exist server-side.
- Fields are sensitive (card numbers, passwords, one-time secrets) — exclude them (see below) or skip auto-save entirely.
Excluding sensitive fields
Pass persistence.excludeFields (dot paths, e.g. ["payment.cardNumber"]) to keep specific fields out of the draft payload and out of dirty / auto-save detection. Editing an excluded field never schedules a save. Everything else in the form still auto-saves normally.
Excluded fields commit only via Submit — never auto-save or Save draft. The unsaved-changes guard still warns when one has in-session edits (so you don't silently lose a typed card number), but offers only Leave / Stay, not Save draft & leave. See Form Page — Persistence.
When To Use Form Page (and When Not To)
Use Form Page when the Ant Design Pro docs would use a full-page ProForm or a heavy DrawerForm:
- Multiple
FormPanelsections (header, customer, lines, shipping). - Embedded Editable Table line items.
- Derived fields (totals, conditional required fields).
- Draft persistence (
persistence.persistDraft+ optionalautoSave). - Leave guard (
warnOnUnsavedChanges+ router guard child).
Do not use Form Page when Pro would use ModalForm only:
- Simple entity (name + status + notes).
- Opened from page header (resource-index Create); closes back to list on success. Embedded table / row action Modals remain valid where proximity applies.
- No line-item grid, no multi-step draft.
Use Modal Form for a pre-wired modal shell — or compose a Drawer + Formily FormField list yourself (DrawerForm is not shipped yet).
Edit: Loading the Record
Ant Design Pro fetches the record, then feeds it to the form as initialValues. In f-ui, prefer building the form after the record resolves so initialValues are correct from the first render:
Default first paint for edit hydrate is a page-shaped skeleton (or Form Page loading for the same-instance path) — not a spinner-only blank page. See Page Loading Rules.
- Preferred: fetch →
createForm({ initialValues: record })→ renderFormPage. While the request is in flight, render your own skeleton (or aPageContainerwith a loading state) and mountFormPageonly once data is ready. This keeps the persistence baseline and unsaved-changes guard honest. - Same-instance: if the
formmust exist before data arrives (e.g. it is created higher up), passFormPage'sloadingprop. Whileloadingistrueit renders a field skeleton and suppresses the footer + unsaved guard; flip it tofalseonce you have calledform.setValues(record)/form.setInitialValues(record).
Do not let the unsaved-changes guard arm before the record is loaded — an empty-then-hydrated form would otherwise look “dirty.”
Shared Schema (ProSchema Equivalent)
Align with Pro’s “define once, use everywhere”:
- List + detail fields —
defineDataListSchema+descriptionsFieldsFromSchema(see orders detail showcase). - Line columns —
defineEditableColumnsfor EditableTable; keepkind, validators, and footers in one place. - Display — the
f.*field type (f.enum({ render: "status" }),f.currency,…) drives Table, Descriptions, and read-only cells consistently — see Field Types.
Recommended Route Shape
For an entity orders:
/orders → list (QueryList)
/orders/:id → detail (Object Page Hub) — optional; drawer-only apps may skip
/orders/new → create (FormPage mode=create)
/orders/:id/edit → edit (FormPage mode=edit)Wire list row actions with semantic href (or router.push for commands) to /orders/new and /orders/:id/edit. Use guarded navigation (Form Page — Warn On Unsaved Changes) on form routes.
Back Links and Host Link Provider
Prefer navigable back affordances with PageContainer backHref="/orders" (or the same prop on PageHeader). backHref alone mounts the page header with a real link; pass onBack only when you need an optional side effect alongside navigation (analytics, closing a drawer). Do not use onBack alone when the destination is a route — that loses open-in-new-tab and modifier-click behavior.
SPA hosts must mount LinkProvider in the app shell (next to I18nProvider). Without it, backHref is a native <a> and detail Back reloads the document — sidebar, session, and the list page remount. That is not a Query List or keep-alive gap. Kit keep-alive is out of scope; the host router owns hide-not-unmount. Override locally with tableOptions.linkComponent (or the matching List / Descriptions prop) when one surface needs a different Link. Pass linkComponent="a" only for an intentional document link.
Row actions: href for navigation; onClick (and confirmed / destructive actions) stay commands, never links. Confirmed and destructive ops must not carry href. Row/batch confirm awaits the mutation Promise and closes on success — see Confirm.
Lifecycle Walkthrough (Orders Showcases)
Open these in order to see the full Ant Design Pro–style flow:
- List — /showcases/orders: filter, select, row actions; Create in the page header.
- Detail (Object Page Hub) — /showcases/orders-detail: multi-card Overview (fields + embedded read-only Lines) and Attachments tab; Amount KPI derived from lines.
- Create — /showcases/orders-form-create: blank form, draft auto-save, guard; tight lines via EditableTable, single Submit.
- Edit — /showcases/orders-form-edit: loaded record, status locks, line rules.
Also compare: /showcases/orders-detail-basic (scalars only), /showcases/orders-detail-related (loose-only), /showcases/knowledge-base-documents (thin parent collection).
Legacy URL /showcases/orders-form redirects to the edit showcase.
Related Docs
- Form Page — persistence, footer modes, guards.
- Editable Table — embedded line items on form pages.
- Page And Region Status — Loading (page skeleton) / Empty / Result for page and region surfaces.
- Empty — successful no-content placeholder.
- Result — load failure and exception outcomes.
- Table — browse list and row actions.
- Modal Form — loose child Add on Related List toolbars.
- Tag Selection — status vs label vs plain text on list/detail cells.
- Row Actions Column — omit empty Actions chrome.