f-ui
Design

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.

SurfaceProComponentsTypical trigger
ListProTable inside PageContainerApp nav / menu
Quick detailProDescriptions in a DrawerRow name link or “View”
CreateModalForm / DrawerForm or dedicated create routePage header primary (f-ui diverges from ProTable toolBarRender)
EditSame form with initialValues from recordRow “Edit” or detail footer
Full detailPageContainer + tabs + descriptionsDeep 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 Prof-uiWhen
PageContainerPageContainer (page shell)Every full-page route
ProTable + searchQueryList + TableDefault browse / ops list
List rowsQueryList view="list"Card/row-first lists
ProDescriptions (read-only)Descriptions + schema kindsDetail panels
Drawer + descriptionsSheet / Drawer; body follows Adaptive Sheet BodyQuick peek without leaving list
ModalForm / short formModal Form≤ ~8 fields, no line-item grid
ProForm full pageForm Page + FormPanel (Formily)Multi-section create/edit
Line items / editable gridEditable Table variant="embedded"Order lines, invoice rows
Result / 404Result + PageContainerPost-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-edit

Examples

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-2401Acme CorpConfirmed
ORD-2402Globex IncShipped

Create sits in the table toolbar — competes with find/view chrome

Right

Orders

ORD-2401Acme CorpConfirmed
ORD-2402Globex IncShipped

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 policyReady
Return checklistPending

Floating tool stack — solid Refresh, vertical stack, no shared baseline

Right

Documents
Shipping policyReady
Return checklistPending

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
ORD-2402
ORD-2403

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

ConfirmedShipped

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 />} />;
}

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

Tag

Opened as a full route

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 />} />;
}

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

Confirmed
Order #
ORD-2401
Customer
Acme Corp
Status
Confirmed

Attachments

packing-slip.pdf2026-08-01
label.pdf2026-08-02

Loose-child Add on the page header — wrong proximity for Related Lists

Right

ORD-2401

Confirmed
Order #
ORD-2401
Customer
Acme Corp
Status
Confirmed

Attachments

2
packing-slip.pdf2026-08-01
label.pdf2026-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

Onboarding guide

Left the list — filters and selection are gone

Title
Onboarding guide
Owner
Ada
Status
Ready

Full-page navigate for a quick peek — list context is lost

Right

Documents

Onboarding guideAdaReady
API changelogLinDraft
Release notesMeiReady

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

Header

Line items

Attachments

Activity

God Form — parent + all nested writes share one dirty/submit boundary

Right

Edit order

Form Page — header + tight lines only

Header

Line items

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

ScenarioPage typef-ui stackDraft / guardLive showcase
Orders table browseListPageContainer + QueryFilter + TableView/showcases/orders
Orders row listListPageContainer + QueryFilter + List rows/showcases/orders-list
Sidebar filter panelList variantStaged filter panel + table/showcases/orders-sidebar-filters
Order detail (scalars only)Descriptions-only detailPageContainer + one Descriptions card/showcases/orders-detail-basic
Order detail (read hub)Object Page Hub — multi-card Overview(+Lines) + AttachmentsPageContainer + Descriptions cards + embedded Lines + Related List tab + footer/showcases/orders-detail
Order detail (loose only)Rich parent + Related ListsField cards + Attachments + Activity Related Lists/showcases/orders-detail-related
Knowledge base documentsDocument library + List↔Detail SheetThin PageHeader + QueryList; Upload documents = ModalForm + FormField kind="file"; row click → Sheet Preview (MarkdownRenderer) + Properties/showcases/knowledge-base-documents
Approvals inboxList (work queue)PageContainer + QueryList + StatusTag + strategy labels/showcases/approvals
Approval instanceObject Page Hub + stagesDescriptions + lines + Stepper + assignees + approval record + Approve/Reject/showcases/approvals-detail
Complaints inboxList (case queue)QueryList + severity / SLA KPIs/showcases/complaints
Complaint case hubCase workspaceHub tabs + lifecycle Stepper + dept tasks + activity + Resolve/showcases/complaints-detail
New sales orderCreateFormPage mode="create" + FormPanel sections + embedded EditableTablepersistence + warnOnUnsavedChanges/showcases/orders-form-create
Edit sales orderEditFormPage mode="edit" + same layout, loaded valuesSame/showcases/orders-form-edit
Missing routeResultPageContainer + 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:

SurfaceComponentLayout / typeValue weight
Body / section fields (Contact, Fulfillment, …)DescriptionsKit default vertical; opt-in layout="horizontal" for dense short scalarsBody (text-sm class) — never hero
Header Amount / Quantity / BalanceStatistic / StatisticGroup in extraContentLabel above valueLarge (~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).

TermMeansExample
Object Page / Record PageOne business object as a page hubOrder detail with sections and related collections
Related ListsChild (or related) collections on a record pageAttachments on an order; Contacts on an Account
Header / LineTransactional parent + atomic childrenInvoice header + line positions
List–Detail / Split ViewPeer list ↔ one selected peerOrders index + side panel
Form Array / Line GridEdit children inside the parent formCreate/edit sales order lines
Aggregate RootConsistency boundary for parent + children writesOrder 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 weightChild jobChooseShowcase
Many / decision-critical fieldsSupportingObject 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 largeSupporting but largeHub 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

ScenarioCopy
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-documentsUpload (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.

ComplexityLayout
Basic (scalars only)One card; groups separated by Separator/showcases/orders-detail-basic
Advanced / HubMulti-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

RegionContentRole
PageHeaderIdentity, status tags, Share/Edit, header Descriptions + KPI StatisticsObject identity
Tab: OverviewCard A short field groups · Card B addresses/notes · Card C embedded tight LinesScalars + children that reconcile with header KPIs
Tab: Attachments (example)Related List composition — list + Add Modal + Delete (own card)Loose children
FooterToolbarApprove / Reject (object-level)Page-subject actions

When Hub Vs Descriptions-Only Vs Collection

ChooseWhen
Descriptions-only detailPrimary destination, scalars only — no child collections
Object Page HubRich parent + 1:N children (tight lines, loose related lists, or both)
Collection-scoped listThin 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 + bodySheet body
Thin meta (≤ ~3–5 fields) + bodyStacked: slim meta above MarkdownRenderer
Rich meta + bodyTabs: Preview (default) | Properties
No bodyProperties / 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)
LifecycleChild meaningless without parent; usually same submitChild CRUD independent; parent can exist first
ReadHub embedded section tableRelated List (own toolbar)
WriteFormPage + EditableTable; one SubmitList Add/Edit/Delete; own mutations
DirtySame form dirty / draft / leave guardNot 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

  1. Single transaction — parent + children one Save (default for order lines).
  2. Independent CRUD — each Related List persists itself (default for attachments).
  3. 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 profileRead (detail)Write (create/edit)
Tight, modest rows, same submitEmbedded table section on Object PageEditableTable on FormPage
Tight, large / filterablePaginated related-style table; optional “open full”FormPage sections or dedicated lines editor route
Loose, first-class objectsRelated List preview + child Object PagesParent create first; Add from list toolbar
Many related types (3+)Hub: tabs or stacked Related ListsIndependent CRUD per list; page Edit for header
Deep tree / foldersTree table or navigate into node pagesAvoid 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.

DoDon'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 buttonsFull-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 rowMake Refresh look like the page primary CTA
Use DataListToolbar / QueryList tools slot when possibleInvent 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.

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-patternDo this instead
God FormSplit along object boundaries; do not one-scroll parent + all nested writes
CRUD TunnelPeer browse that list→detail→back kills filters; industry fix is Split View — documented only, not built in Wave B
Fake related listScalar-only detail while children live elsewhere — use an Object Page Hub (or a collection page if the parent is thin)
Library in Hub costumeThin parent + full child table dressed as Hub Descriptions — use a collection-scoped list
Create article on a file libraryBlank “Create document” for SharePoint-style libraries — use Upload + File Upload; Create is for authored articles
Floating tool stackRefresh / Columns / Add stacked vertically in a corner above the table — use one title+tools row (DataListToolbar)
Tabs as dumping groundDo not park tight lines behind a tab when KPIs need reconciliation
>2 nested expandable editable tablesNavigate to a child Object Page at depth 2
Grain confusionNo separately editable header total vs sum(lines) — derive Amount from lines
Error = EmptyRelated region Error + Retry; never Empty while pending or on failure
Two truths for linesDetail 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 shapeCreate / AddEditImport / Export (all)Bulk
Resource index (title = entity list)PageHeader primary (PageContainer.extra) — Create or Upload when the collection is filesRow / overflow → detail or edit route — never list page headerPageHeader secondarySelection toolbar morph
Embedded tight table (order lines on FormPage / Hub Overview)FormPage create/edit path — not Related List Add on detailFormPage (detail Lines read-only + Edit CTA)RareThat table’s selection bar on the form
Loose Related List (attachments, child objects)Region toolbar Create / AddRow inline / overflow / ModalRareThat list’s selection bar
Detail / objectLoose Related List Add only (region toolbar)PageHeader EditRare
Form create / editSave / 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 subtypePrimary next action
First-use (no records)May repeat Create (teaches location); header Create remains
Filtered / no resultsClear or edit filters — not Create
ErrorRetry — 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:

CreateEdit
Route/entities/new/entities/:id/edit
FormPage mode"create""edit"
Initial valuesEmpty defaults + one blank row if neededLoaded from API
Primary button“Create” (footer uses action.submit.create)“Save” (action.submit.edit)
Order # / idOften empty until first saveRead-only or pre-filled
PersistenceDraft auto-save helps long createsDraft auto-save + unsaved guard
NavigationFormPageUnsavedGuard + list back linkSame

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)

ActionValidates?f-ui
Auto-save / Save draft / Save nowNo (except optional storability gate)persistence.persistDraft(form.values); minimal preconditions via persistence.draftValidate
Create / Submit primary buttonYes (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 draft status or a drafts table). If the API rejects partial payloads, auto-save has nowhere to write.
  • Idempotent, cheap persistpersistDraft upserts 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:

CreateEdit
Draft targetNew draft row; first save assigns an id (onDraftCreated → upgrade the route to /orders/:id/edit)Existing record’s draft field / revision
Risk without itWhole new record lost on reloadIn-progress edits lost
Watch outDo not create a “ghost” record on every keystroke — debounce, and only persist once there is real contentDo 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 FormPanel sections (header, customer, lines, shipping).
  • Embedded Editable Table line items.
  • Derived fields (totals, conditional required fields).
  • Draft persistence (persistence.persistDraft + optional autoSave).
  • 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 }) → render FormPage. While the request is in flight, render your own skeleton (or a PageContainer with a loading state) and mount FormPage only once data is ready. This keeps the persistence baseline and unsaved-changes guard honest.
  • Same-instance: if the form must exist before data arrives (e.g. it is created higher up), pass FormPage's loading prop. While loading is true it renders a field skeleton and suppresses the footer + unsaved guard; flip it to false once you have called form.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”:

  1. List + detail fieldsdefineDataListSchema + descriptionsFieldsFromSchema (see orders detail showcase).
  2. Line columnsdefineEditableColumns for EditableTable; keep kind, validators, and footers in one place.
  3. Display — the f.* field type (f.enum({ render: "status" }), f.currency, ) drives Table, Descriptions, and read-only cells consistently — see Field Types.

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.

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:

  1. List/showcases/orders: filter, select, row actions; Create in the page header.
  2. Detail (Object Page Hub)/showcases/orders-detail: multi-card Overview (fields + embedded read-only Lines) and Attachments tab; Amount KPI derived from lines.
  3. Create/showcases/orders-form-create: blank form, draft auto-save, guard; tight lines via EditableTable, single Submit.
  4. 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.

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

On this page