Add DataTable Storybook coverage

This commit is contained in:
2026-03-19 22:47:23 +08:00
parent 3f77070802
commit 7c87c7af37
3 changed files with 463 additions and 2 deletions
@@ -0,0 +1,441 @@
import {
Badge,
Button,
DataTable,
EmptyState,
EmptyStateActions,
EmptyStateDescription,
EmptyStateEyebrow,
EmptyStateHeader,
EmptyStateMedia,
EmptyStateTitle,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@ai-ui/ui";
import type { DataTableColumn, DataTableSort } from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
type RoutingLaneState = "Ready" | "Watching" | "Quiet" | "Holding";
type RoutingFilter = "all" | "holding" | "quiet" | "ready" | "watching";
type RoutingLaneRow = {
audience: string;
id: string;
lane: "Editorial" | "Engineering" | "Support";
nextGate: string;
note: string;
owner: string;
ownerEmail: string;
signalScore: number;
state: RoutingLaneState;
};
const routingRows: RoutingLaneRow[] = [
{
audience: "Narrative lock",
id: "editorial-copy-lock",
lane: "Editorial",
nextGate: "18:40",
note: "Copy is locked and the migration footnote is waiting on the final legal sentence.",
owner: "Mae Kurata",
ownerEmail: "mae.kurata@cadence.dev",
signalScore: 8,
state: "Ready"
},
{
audience: "10% canary",
id: "engineering-canary",
lane: "Engineering",
nextGate: "19:15",
note: "Canary checks are green, but the first wave should wait for the routing digest.",
owner: "Dorian Vale",
ownerEmail: "dorian.vale@cadence.dev",
signalScore: 14,
state: "Watching"
},
{
audience: "Support queue",
id: "support-queue",
lane: "Support",
nextGate: "19:32",
note: "Digest pack is staged and the customer macro is ready for the next quiet pass.",
owner: "Lia Sato",
ownerEmail: "lia.sato@cadence.dev",
signalScore: 5,
state: "Quiet"
},
{
audience: "Legal footnote",
id: "editorial-legal-note",
lane: "Editorial",
nextGate: "19:05",
note: "One migration sentence still needs counsel review before the customer note can publish.",
owner: "Mae Kurata",
ownerEmail: "mae.kurata@cadence.dev",
signalScore: 17,
state: "Holding"
},
{
audience: "Wave brief",
id: "engineering-wave-brief",
lane: "Engineering",
nextGate: "19:44",
note: "Rollback thresholds are staged and the launch brief is ready for the first wave.",
owner: "Dorian Vale",
ownerEmail: "dorian.vale@cadence.dev",
signalScore: 11,
state: "Ready"
},
{
audience: "Customer note",
id: "support-customer-note",
lane: "Support",
nextGate: "20:05",
note: "Keep the public note unpublished until the queue stays quiet for one more pass.",
owner: "Lia Sato",
ownerEmail: "lia.sato@cadence.dev",
signalScore: 13,
state: "Watching"
}
];
const routingFilterOptions: Array<{ label: string; value: RoutingFilter }> = [
{ label: "All lanes", value: "all" },
{ label: "Ready", value: "ready" },
{ label: "Watching", value: "watching" },
{ label: "Quiet", value: "quiet" },
{ label: "Holding", value: "holding" }
];
const routingColumns: DataTableColumn<RoutingLaneRow>[] = [
{
accessor: "lane",
cell: (row) => (
<div className="grid gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-[var(--color-foreground)]">{row.lane}</span>
<Badge size="sm" variant="outline">
{row.audience}
</Badge>
</div>
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
{row.nextGate} next gate
</p>
</div>
),
header: "Lane",
id: "lane",
sortable: true,
width: "18rem"
},
{
accessor: "owner",
cell: (row) => (
<div className="grid gap-1">
<span className="text-sm font-medium text-[var(--color-foreground)]">{row.owner}</span>
<span className="text-xs text-[var(--color-muted-foreground)]">{row.ownerEmail}</span>
</div>
),
header: "Owner",
id: "owner",
sortable: true,
width: "15rem"
},
{
accessor: "state",
cell: (row) => (
<div className="flex flex-wrap items-center gap-3">
<Badge size="sm" tone={getStateTone(row.state)} variant="solid">
{row.state}
</Badge>
<span className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Risk {row.signalScore}
</span>
</div>
),
header: "Signal",
id: "state",
sortable: true,
width: "12rem"
},
{
accessor: "note",
cell: (row) => (
<p className="max-w-[34rem] text-sm leading-6 text-[var(--color-muted-foreground)]">
{row.note}
</p>
),
header: "Routing note",
id: "note",
width: "34rem"
}
];
function SignalGlyph() {
return (
<div className="grid gap-2">
<div className="grid grid-cols-[0.8rem_2.8rem_1.5rem] gap-2">
<span className="h-3 rounded-[var(--radius-full)] bg-[var(--color-primary)]" />
<span className="h-3 rounded-[var(--radius-full)] bg-[var(--color-surface-strong)]" />
<span className="h-3 rounded-[var(--radius-full)] bg-[var(--color-accent)]" />
</div>
<div className="grid gap-2">
<span className="h-2 rounded-[var(--radius-full)] bg-[var(--color-border-strong)]" />
<span className="h-2 w-16 rounded-[var(--radius-full)] bg-[var(--color-border)]" />
</div>
</div>
);
}
function getFilterLabel(value: RoutingFilter) {
return routingFilterOptions.find((option) => option.value === value)?.label ?? "All lanes";
}
function getStateTone(state: RoutingLaneState) {
switch (state) {
case "Ready":
return "success";
case "Watching":
return "primary";
case "Holding":
return "warning";
case "Quiet":
default:
return "neutral";
}
}
function RoutingEmptyState({ onReset }: { onReset: () => void }) {
return (
<EmptyState className="min-h-72 justify-center border-0 bg-transparent shadow-none" tone="subtle">
<EmptyStateMedia>
<SignalGlyph />
</EmptyStateMedia>
<EmptyStateHeader>
<EmptyStateEyebrow>No visible routing lanes</EmptyStateEyebrow>
<EmptyStateTitle>The current desk view is intentionally sparse.</EmptyStateTitle>
<EmptyStateDescription>
Clear the search or switch the lane filter if you need a wider handoff view.
</EmptyStateDescription>
</EmptyStateHeader>
<EmptyStateActions>
<Button onClick={onReset}>Reset table view</Button>
</EmptyStateActions>
</EmptyState>
);
}
function DataTablePlayground() {
const [filter, setFilter] = useState<RoutingFilter>("all");
const [searchValue, setSearchValue] = useState("");
const [selection, setSelection] = useState<Record<string, boolean>>({});
const [sorting, setSorting] = useState<DataTableSort[]>([{ desc: false, id: "lane" }]);
const [activity, setActivity] = useState(
"Search, sort, filter, and select lanes without leaving the desk."
);
const visibleRows = routingRows.filter((row) => {
if (filter === "all") {
return true;
}
return row.state.toLowerCase() === filter;
});
function resetView() {
setFilter("all");
setSearchValue("");
setSelection({});
setSorting([{ desc: false, id: "lane" }]);
setActivity("The routing desk is back to its default view.");
}
return (
<div className="grid gap-4 text-[var(--color-foreground)]">
<div className="grid gap-3 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-4 shadow-[var(--shadow-sm)] lg:grid-cols-[minmax(0,1fr)_18rem]">
<div className="space-y-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Standalone workflow
</p>
<h2 className="text-xl font-semibold tracking-[var(--tracking-tight)]">
One table for owners, notes, signals, and the next gate.
</h2>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
This page isolates the DataTable contract from the larger release workspace while still
exercising built-in search, sorting, pagination, selection, and toolbar actions.
</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-background)] px-4 py-3">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Last action
</p>
<p className="mt-2 text-sm leading-6 text-[var(--color-foreground)]">{activity}</p>
</div>
</div>
<div className="w-full max-w-[1120px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-4 shadow-[var(--shadow-sm)]">
<DataTable
columns={routingColumns}
empty={<RoutingEmptyState onReset={resetView} />}
enableSelection
getRowId={(row) => row.id}
onSearchValueChange={setSearchValue}
onSelectionChange={setSelection}
onSortingChange={setSorting}
pageSize={3}
pageSizeOptions={[3, 5]}
renderRowActions={(row) => (
<Button
size="sm"
variant="ghost"
onClick={() => {
setActivity(`Opened ${row.lane} lane for the next routing pass.`);
}}
>
Open {row.lane}
</Button>
)}
rows={visibleRows}
searchLabel="Search routing lanes"
searchPlaceholder="Search lanes, owners, and notes"
searchValue={searchValue}
selection={selection}
selectionActions={(selectedRows) => (
<>
<Button
size="sm"
variant="secondary"
onClick={() => {
setActivity(
`Queued a digest for ${selectedRows.length} lane${selectedRows.length === 1 ? "" : "s"}.`
);
}}
>
Queue digest
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setSelection({});
setActivity("Selection cleared after the handoff review.");
}}
>
Clear handoff
</Button>
</>
)}
selectionLabel={(selectedRows) =>
`${selectedRows.length} lane${selectedRows.length === 1 ? "" : "s"} selected for a routing handoff.`
}
sorting={sorting}
tableLabel="Routing lanes"
toolbarActions={
<>
<Select
value={filter}
onValueChange={(value) => {
setFilter(value as RoutingFilter);
setSelection({});
setActivity(`Filtered the desk to ${getFilterLabel(value as RoutingFilter)}.`);
}}
>
<SelectTrigger aria-label="Filter lanes" className="w-[11rem]">
<SelectValue placeholder="Filter lanes" />
</SelectTrigger>
<SelectContent>
{routingFilterOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="secondary"
onClick={resetView}
>
Reset view
</Button>
</>
}
/>
</div>
</div>
);
}
function DataTableLoadingState() {
return (
<div className="w-full max-w-[1120px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-4 shadow-[var(--shadow-sm)]">
<DataTable
columns={routingColumns}
getRowId={(row) => row.id}
loading
loadingRowCount={3}
pageSize={3}
pageSizeOptions={[3]}
rows={routingRows}
searchLabel="Search routing lanes"
tableLabel="Routing lanes"
/>
</div>
);
}
function DataTableEmptyStateDemo() {
return (
<div className="w-full max-w-[1120px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-4 shadow-[var(--shadow-sm)]">
<DataTable
columns={routingColumns}
empty={<RoutingEmptyState onReset={() => undefined} />}
pageSize={3}
pageSizeOptions={[3]}
rows={[]}
searchLabel="Search routing lanes"
tableLabel="Routing lanes"
toolbarActions={
<Button size="sm" variant="secondary">
Create lane
</Button>
}
/>
</div>
);
}
const meta = {
id: "components-data-table",
title: "Components/DataTable",
component: DataTablePlayground,
parameters: {
docs: {
description: {
component:
"A production-style table primitive with built-in search, sorting, pagination, row selection, and external toolbar actions. This standalone page isolates the component from the larger workspace scene while keeping a realistic routing-desk narrative."
}
},
layout: "padded"
},
tags: ["autodocs"]
} satisfies Meta<typeof DataTablePlayground>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const LoadingState: Story = {
render: () => <DataTableLoadingState />
};
export const EmptyStateExample: Story = {
name: "Empty State",
render: () => <DataTableEmptyStateDemo />
};
+2 -2
View File
@@ -64,8 +64,8 @@ function ContractsOverview() {
Component authoring now follows one repeatable contract. Component authoring now follows one repeatable contract.
</h1> </h1>
<p className="text-[var(--text-lg)] leading-[var(--leading-loose)] text-[var(--color-muted-foreground)]"> <p className="text-[var(--text-lg)] leading-[var(--leading-loose)] text-[var(--color-muted-foreground)]">
Phase 2 does not ship real components yet. It defines the shared state, Phase 2 now ships real components and codifies the shared state, slot,
slot, variant, and motion conventions that every future component will use. variant, and motion conventions they follow.
</p> </p>
</header> </header>
+20
View File
@@ -31,6 +31,26 @@ test("storybook button, select, and reduced-motion form stories stay interactive
await expect(page.locator("pre code").last()).toContainText('"role": "legal"'); await expect(page.locator("pre code").last()).toContainText('"role": "legal"');
}); });
test("storybook data table story stays interactive", async ({ page }) => {
await page.goto("/iframe.html?id=components-data-table--playground&viewMode=story");
const table = page.getByRole("table", { name: "Routing lanes" });
await expect(table).toBeVisible();
await expect(page.getByRole("searchbox", { name: "Search routing lanes" })).toBeVisible();
const sortableHeader = page.getByRole("columnheader", { name: /owner/i });
await sortableHeader.getByRole("button").click();
await expect(sortableHeader).toHaveAttribute("aria-sort", "ascending");
await page.getByRole("checkbox", { name: /select row/i }).first().click();
await expect(page.getByRole("button", { name: "Clear selection" })).toBeVisible();
const nextButton = page.getByRole("button", { name: "Next" });
await expect(nextButton).toBeEnabled();
await nextButton.click();
await expect(page.getByRole("button", { name: "Previous" })).toBeEnabled();
});
test("storybook overlay stories stay interactive", async ({ page }) => { test("storybook overlay stories stay interactive", async ({ page }) => {
await page.goto("/iframe.html?id=components-dialog--playground&viewMode=story"); await page.goto("/iframe.html?id=components-dialog--playground&viewMode=story");
await page.getByRole("button", { name: "Open approval dialog" }).click(); await page.getByRole("button", { name: "Open approval dialog" }).click();