Add DataTable Storybook coverage
This commit is contained in:
@@ -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 />
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user