From 7c87c7af37772481d43756ebec4b5fa8eb075ace Mon Sep 17 00:00:00 2001 From: kurihada Date: Thu, 19 Mar 2026 22:47:23 +0800 Subject: [PATCH] Add DataTable Storybook coverage --- .../src/components/data-table.stories.tsx | 441 ++++++++++++++++++ apps/docs/src/contracts.stories.tsx | 4 +- tests/e2e/storybook-smoke.spec.ts | 20 + 3 files changed, 463 insertions(+), 2 deletions(-) create mode 100644 apps/docs/src/components/data-table.stories.tsx diff --git a/apps/docs/src/components/data-table.stories.tsx b/apps/docs/src/components/data-table.stories.tsx new file mode 100644 index 0000000..60119ae --- /dev/null +++ b/apps/docs/src/components/data-table.stories.tsx @@ -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[] = [ + { + accessor: "lane", + cell: (row) => ( +
+
+ {row.lane} + + {row.audience} + +
+

+ {row.nextGate} next gate +

+
+ ), + header: "Lane", + id: "lane", + sortable: true, + width: "18rem" + }, + { + accessor: "owner", + cell: (row) => ( +
+ {row.owner} + {row.ownerEmail} +
+ ), + header: "Owner", + id: "owner", + sortable: true, + width: "15rem" + }, + { + accessor: "state", + cell: (row) => ( +
+ + {row.state} + + + Risk {row.signalScore} + +
+ ), + header: "Signal", + id: "state", + sortable: true, + width: "12rem" + }, + { + accessor: "note", + cell: (row) => ( +

+ {row.note} +

+ ), + header: "Routing note", + id: "note", + width: "34rem" + } +]; + +function SignalGlyph() { + return ( +
+
+ + + +
+
+ + +
+
+ ); +} + +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 ( + + + + + + No visible routing lanes + The current desk view is intentionally sparse. + + Clear the search or switch the lane filter if you need a wider handoff view. + + + + + + + ); +} + +function DataTablePlayground() { + const [filter, setFilter] = useState("all"); + const [searchValue, setSearchValue] = useState(""); + const [selection, setSelection] = useState>({}); + const [sorting, setSorting] = useState([{ 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 ( +
+
+
+

+ Standalone workflow +

+

+ One table for owners, notes, signals, and the next gate. +

+

+ This page isolates the DataTable contract from the larger release workspace while still + exercising built-in search, sorting, pagination, selection, and toolbar actions. +

+
+
+

+ Last action +

+

{activity}

+
+
+ +
+ } + enableSelection + getRowId={(row) => row.id} + onSearchValueChange={setSearchValue} + onSelectionChange={setSelection} + onSortingChange={setSorting} + pageSize={3} + pageSizeOptions={[3, 5]} + renderRowActions={(row) => ( + + )} + rows={visibleRows} + searchLabel="Search routing lanes" + searchPlaceholder="Search lanes, owners, and notes" + searchValue={searchValue} + selection={selection} + selectionActions={(selectedRows) => ( + <> + + + + )} + selectionLabel={(selectedRows) => + `${selectedRows.length} lane${selectedRows.length === 1 ? "" : "s"} selected for a routing handoff.` + } + sorting={sorting} + tableLabel="Routing lanes" + toolbarActions={ + <> + + + + } + /> +
+
+ ); +} + +function DataTableLoadingState() { + return ( +
+ row.id} + loading + loadingRowCount={3} + pageSize={3} + pageSizeOptions={[3]} + rows={routingRows} + searchLabel="Search routing lanes" + tableLabel="Routing lanes" + /> +
+ ); +} + +function DataTableEmptyStateDemo() { + return ( +
+ undefined} />} + pageSize={3} + pageSizeOptions={[3]} + rows={[]} + searchLabel="Search routing lanes" + tableLabel="Routing lanes" + toolbarActions={ + + } + /> +
+ ); +} + +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; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = {}; + +export const LoadingState: Story = { + render: () => +}; + +export const EmptyStateExample: Story = { + name: "Empty State", + render: () => +}; diff --git a/apps/docs/src/contracts.stories.tsx b/apps/docs/src/contracts.stories.tsx index 9a2a205..5e2e6f0 100644 --- a/apps/docs/src/contracts.stories.tsx +++ b/apps/docs/src/contracts.stories.tsx @@ -64,8 +64,8 @@ function ContractsOverview() { Component authoring now follows one repeatable contract.

- Phase 2 does not ship real components yet. It defines the shared state, - slot, variant, and motion conventions that every future component will use. + Phase 2 now ships real components and codifies the shared state, slot, + variant, and motion conventions they follow.

diff --git a/tests/e2e/storybook-smoke.spec.ts b/tests/e2e/storybook-smoke.spec.ts index a452bf9..3271438 100644 --- a/tests/e2e/storybook-smoke.spec.ts +++ b/tests/e2e/storybook-smoke.spec.ts @@ -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"'); }); +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 }) => { await page.goto("/iframe.html?id=components-dialog--playground&viewMode=story"); await page.getByRole("button", { name: "Open approval dialog" }).click();