UNPKG

@spark-web/design-system

Version:

--- title: Design System ---

359 lines (283 loc) 10.7 kB
# Internal admin — details page pattern ## Before using this pattern Read node_modules/@spark-web/design-system/patterns/internal-admin/CLAUDE.md fully before implementing this pattern. The interaction rules, badge tone mapping, and action dropdown rules defined there all apply to this pattern. ## What this pattern is A full page layout for displaying the detail view of a single record in an internal admin interface. The page shows structured data and contextual sub-tables in a two-column layout, with record-level actions surfaced through a header dropdown. ## When to use this pattern Use this pattern when the PRD describes any of the following: - A single-record view reachable by clicking a row on a list page - A page showing a record's fields, history, or associated sub-records - The words "detail", "profile", "view", "record page", or "user/vendor page" --- ## Component docs to read Read these before implementing they own the component-level rules: - `packages/action-dropdown/CLAUDE.md` dropdown construction, ordering, hide vs. disable - `packages/modal-dialog/CLAUDE.md` ContentDialog API, ACCREDITATION_MODAL_CSS, destructive modal anatomy - `packages/data-table/CLAUDE.md` DataTable API, loading/empty states, expandable rows - `packages/tabs/CLAUDE.md` Tabs API, internal-admin background override, null guard - `packages/section-card/CLAUDE.md` SectionCard API (note: portal-hub uses a custom wrapper; see Section 6 below) - `packages/badge/CLAUDE.md` status tone mapping - `packages/columns/CLAUDE.md` responsive two-column layout - `packages/box/CLAUDE.md` flex layout utilities - `packages/stack/CLAUDE.md` vertical stacking and gap --- ## Page structure ``` Outer wrapper Stack paddingX="xlarge" paddingY="xxlarge" gap="xlarge" Header Box spaceBetween row: [Heading level="1" + Badge] | [ActionDropdown] Page-level feedback Alert conditional only for inline (non-modal) action feedback Modals all ContentDialog modals declared here, controlled by openModal state Content Columns template=[1,1] gap="xlarge" collapseBelow="desktop" Left column Stack gap="xlarge" primary record fields + primary sub-tables Right column Stack gap="xlarge" secondary/contextual sections SectionCard (per section) DataTable PAGE_SIZE=5 items; see data-table/CLAUDE.md TablePagination only when total > PAGE_SIZE ``` --- ## Section 1 — Outer wrapper ```tsx <Stack height="full" paddingX="xlarge" paddingY="xxlarge" gap="xlarge"> ``` All spacing uses Spark tokens. This is distinct from list-page spacing (`padding="large"` on a neutral-background Stack) do not mix the two. --- ## Section 2 — Page header ```tsx <Box display="flex" flexDirection={{ mobile: 'column', tablet: 'row' }} justifyContent={{ tablet: 'spaceBetween' }} gap="medium" > <Box display="flex" flexDirection={{ mobile: 'columnReverse', tablet: 'row' }} alignItems={{ mobile: 'start', tablet: 'center' }} gap={{ mobile: 'medium', tablet: 'small' }} > <Heading level="1">{recordTitle}</Heading> <Badge tone={statusTone}>{statusLabel}</Badge> </Box> {actions.length > 0 && ( <Box className={css({ minWidth: 130 })}> <ActionDropdown label="Actions" actions={actions} /> </Box> )} </Box> ``` Rules: - Status badge follows the heading never precedes it - Only render `ActionDropdown` when `actions.length > 0` - No action buttons directly in the header always use `ActionDropdown` --- ## Section 3 — Actions dropdown See `packages/action-dropdown/CLAUDE.md` for full API and ordering rules. Page-level decisions: - **Order**: non-destructive actions first, restore-type actions second, `tone: 'critical'` destructive actions always last - **Hide vs. disable**: hide actions permanently unavailable for the current record state (conditional spread); disable actions temporarily unavailable (e.g. mutation in-flight) - **Direct vs. modal**: direct actions (password reset, restore, activate) call the mutation inline and surface feedback via page-level `actionStatus` Alert; destructive actions (delete, suspend) always open a `ContentDialog` modal first --- ## Section 4 — Page-level feedback ```tsx { actionStatus && ( <Alert tone={actionStatus.isSuccessful ? 'positive' : 'critical'}> <Text>{actionStatus.message}</Text> </Alert> ); } ``` State shape: `{ isSuccessful: boolean; message: string } | undefined` Rendered between the header and content columns. Only used for direct inline mutations. Modal confirmations own their own error Alert inside the `ContentDialog` see `packages/modal-dialog/CLAUDE.md`. --- ## Section 5 — Confirmation modals See `packages/modal-dialog/CLAUDE.md` for ContentDialog API, sizing, form-in-modal anatomy, and the full destructive modal pattern. Declare all modals in the component JSX, controlled by a single `openModal` state union: ```ts const [openModal, setOpenModal] = useState<'delete' | 'suspend' | null>(null); ``` ```tsx { openModal === 'delete' && recordId && ( <DeleteModal isOpen onToggle={() => setOpenModal(null)} recordId={recordId} onSuccess={() => { refetch(); setOpenModal(null); }} /> ); } ``` --- ## Section 6 — Content columns ```tsx <Columns gap="xlarge" template={[1, 1]} collapseBelow="desktop"> <Stack gap="xlarge">{/* left column */}</Stack> <Stack gap="xlarge">{/* right column */}</Stack> </Columns> ``` Always equal columns (`template={[1, 1]}`), always collapse below desktop. --- ## Section 7 — Section cards Each content section is wrapped in a `SectionCard`. **Portal-hub uses a custom wrapper** at `@components/PortalTable/SectionCard` (not `@spark-web/section-card`). The API differs: ```tsx import { SectionCard } from '@components/PortalTable/SectionCard'; <SectionCard label="Section Title">{/* section content */}</SectionCard>; ``` | Prop | Type | Notes | | ---------- | ----------- | ------------------------------------- | | `label` | `string` | Required card header text | | `action` | `ReactNode` | Optional right-side header control | | `controls` | `ReactNode` | Optional additional header controls | Return `null` for sections conditionally hidden never render an empty card. --- ## Section 8 — Section data tables See `packages/data-table/CLAUDE.md` for DataTable API, column definitions, and loading/empty state props. Detail-page-specific rules (differ from list pages): - **`PAGE_SIZE = 5`** always 5 items per section table (not 20 like list pages) - **Pagination threshold**: render `TablePagination` only when `total > PAGE_SIZE` - **Reset page**: reset to 1 when the record context changes (e.g. `userId`) ```tsx const PAGE_SIZE = 5; const [page, setPage] = useState(1); useEffect(() => { setPage(1); }, [recordId]); // Client-side pagination fetch all, slice const allItems = data?.items ?? []; const total = allItems.length; const pageItems = allItems.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); // Server-side pagination skip/take API const { data } = useQuery({ skip: (page - 1) * PAGE_SIZE, take: PAGE_SIZE }); const total = countData?.count ?? 0; const pageItems = data?.items ?? []; ``` ```tsx <Stack gap="large"> <DataTable items={pageItems} columns={columns} isLoading={isLoading} emptyState={ <Text tone="muted" size="small" align="center"> No items. </Text> } /> {total > PAGE_SIZE && ( <TablePagination total={total} pageSize={PAGE_SIZE} dataShowing={pageItems.length} onChange={setPage} current={page} /> )} </Stack> ``` --- ## Section 9 — Tabbed sections See `packages/tabs/CLAUDE.md` for the Tabs API, dynamic tab construction, the required internal-admin background override, and the null guard pattern. Use tabs when a section has multiple sub-views (e.g. Email / SMS history). Each tab panel follows the same PAGE_SIZE=5 and pagination rules as Section 8. --- ## Structural skeleton ```tsx <Stack height="full" paddingX="xlarge" paddingY="xxlarge" gap="xlarge"> {/* Header */} <Box display="flex" flexDirection={{ mobile: 'column', tablet: 'row' }} justifyContent={{ tablet: 'spaceBetween' }} gap="medium" > <Box display="flex" flexDirection={{ mobile: 'columnReverse', tablet: 'row' }} alignItems={{ mobile: 'start', tablet: 'center' }} gap={{ mobile: 'medium', tablet: 'small' }} > <Heading level="1">{recordTitle}</Heading> <Badge tone={statusTone}>{statusLabel}</Badge> </Box> {actions.length > 0 && ( <Box className={css({ minWidth: 130 })}> <ActionDropdown label="Actions" actions={actions} /> </Box> )} </Box> {/* Page-level feedback direct actions only */} {actionStatus && ( <Alert tone={actionStatus.isSuccessful ? 'positive' : 'critical'}> <Text>{actionStatus.message}</Text> </Alert> )} {/* Modals */} {openModal === 'delete' && recordId && ( <DeleteModal isOpen onToggle={() => setOpenModal(null)} recordId={recordId} onSuccess={() => { refetch(); setOpenModal(null); }} /> )} {/* Content */} <Columns gap="xlarge" template={[1, 1]} collapseBelow="desktop"> <Stack gap="xlarge"> <SectionCard label="Details">{/* fields */}</SectionCard> </Stack> <Stack gap="xlarge"> <SectionCard label="History">{/* table + pagination */}</SectionCard> </Stack> </Columns> </Stack> ``` --- ## Do NOTs - NEVER apply list-page spacing to a detail page outer wrapper uses `paddingX="xlarge" paddingY="xxlarge"`, not `padding="large"` - NEVER place a destructive action before non-destructive actions in the dropdown - NEVER call a destructive mutation directly from a dropdown item open a modal - NEVER surface modal errors as page-level Alerts see `modal-dialog/CLAUDE.md` - NEVER use `PAGE_SIZE = 20` on section tables always 5 on detail pages - NEVER render `TablePagination` when `total <= PAGE_SIZE` - NEVER render an empty `SectionCard` for hidden sections return `null` - NEVER render `ActionDropdown` when `actions` is empty gate with `actions.length > 0` - NEVER render `Tabs` inside `SectionCard` without the background override see `tabs/CLAUDE.md` - NEVER use `Container` as the outer page wrapper