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.
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.