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 />
|
||||
};
|
||||
Reference in New Issue
Block a user