feat: add data table and release checks
This commit is contained in:
@@ -6,12 +6,17 @@ import {
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
DataTable,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
EmptyStateActions,
|
||||
EmptyStateDescription,
|
||||
@@ -59,6 +64,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "@ai-ui/ui";
|
||||
import type { DataTableColumn, DataTableSort } from "@ai-ui/ui";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useState } from "react";
|
||||
@@ -74,23 +80,101 @@ type ReleaseWorkspaceProps = {
|
||||
quietMode?: boolean;
|
||||
};
|
||||
|
||||
const reviewerColumns = [
|
||||
type RoutingLaneState = "Ready" | "Watching" | "Quiet" | "Holding";
|
||||
|
||||
type RoutingLaneRow = {
|
||||
audience: string;
|
||||
id: string;
|
||||
lane: "Editorial" | "Engineering" | "Support";
|
||||
nextGate: string;
|
||||
note: string;
|
||||
owner: string;
|
||||
ownerEmail: string;
|
||||
signalScore: number;
|
||||
state: RoutingLaneState;
|
||||
};
|
||||
|
||||
type RoutingFilter = "all" | "holding" | "quiet" | "watching";
|
||||
|
||||
const routingLaneRows: RoutingLaneRow[] = [
|
||||
{
|
||||
audience: "Narrative lock",
|
||||
id: "editorial-copy-lock",
|
||||
lane: "Editorial",
|
||||
nextGate: "18:40",
|
||||
note: "Copy is locked. Waiting on final legal phrasing for the migration footnote.",
|
||||
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. Queue the 10% wave after route owners sign off.",
|
||||
owner: "Dorian Vale",
|
||||
ownerEmail: "dorian.vale@cadence.dev",
|
||||
signalScore: 14,
|
||||
state: "Watching"
|
||||
},
|
||||
{
|
||||
audience: "Support queue",
|
||||
id: "support-queue",
|
||||
lane: "Support",
|
||||
nextGate: "19:32",
|
||||
note: "No escalations yet. Macro pack and customer note are staged for handoff.",
|
||||
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. Keep the fallback owner present during the first quiet cycle.",
|
||||
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 external note unpublished until the queue stays quiet for one more pass.",
|
||||
owner: "Lia Sato",
|
||||
ownerEmail: "lia.sato@cadence.dev",
|
||||
signalScore: 13,
|
||||
state: "Watching"
|
||||
},
|
||||
{
|
||||
audience: "Rollback watch",
|
||||
id: "engineering-rollback-watch",
|
||||
lane: "Engineering",
|
||||
nextGate: "20:18",
|
||||
note: "Guardrails are live. Stay quiet unless conversion drops or route owners lose visibility.",
|
||||
owner: "Dorian Vale",
|
||||
ownerEmail: "dorian.vale@cadence.dev",
|
||||
signalScore: 6,
|
||||
state: "Quiet"
|
||||
}
|
||||
] as const;
|
||||
];
|
||||
|
||||
const timelineStops = [
|
||||
{
|
||||
@@ -110,6 +194,13 @@ const timelineStops = [
|
||||
}
|
||||
] as const;
|
||||
|
||||
const routingFilterOptions: Array<{ label: string; value: RoutingFilter }> = [
|
||||
{ label: "All lanes", value: "all" },
|
||||
{ label: "Watching", value: "watching" },
|
||||
{ label: "Quiet", value: "quiet" },
|
||||
{ label: "Holding", value: "holding" }
|
||||
];
|
||||
|
||||
function SignalGlyph() {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
@@ -157,19 +248,211 @@ function MetricPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function getLaneBadgeClassName(state: RoutingLaneState) {
|
||||
switch (state) {
|
||||
case "Watching":
|
||||
return "border-[color-mix(in_oklch,var(--color-primary)_28%,var(--color-border))] bg-[color-mix(in_oklch,var(--color-primary)_12%,var(--color-card))] text-[var(--color-foreground)]";
|
||||
case "Quiet":
|
||||
return "border-[var(--color-border)] bg-[color-mix(in_oklch,var(--color-surface)_88%,white_12%)] text-[var(--color-muted-foreground)]";
|
||||
case "Holding":
|
||||
return "border-[color-mix(in_oklch,var(--color-accent)_28%,var(--color-border))] bg-[color-mix(in_oklch,var(--color-accent)_14%,var(--color-card))] text-[var(--color-foreground)]";
|
||||
case "Ready":
|
||||
default:
|
||||
return "border-[var(--color-border)] bg-[var(--color-card)] text-[var(--color-foreground)]";
|
||||
}
|
||||
}
|
||||
|
||||
function getLaneBadgeVariant(state: RoutingLaneState) {
|
||||
switch (state) {
|
||||
case "Watching":
|
||||
return "solid" as const;
|
||||
case "Quiet":
|
||||
return "outline" as const;
|
||||
case "Holding":
|
||||
return undefined;
|
||||
case "Ready":
|
||||
default:
|
||||
return "outline" as const;
|
||||
}
|
||||
}
|
||||
|
||||
function toRoutingValues(row?: RoutingLaneRow): RoutingValues {
|
||||
return {
|
||||
lane: row?.lane.toLowerCase() ?? "engineering",
|
||||
notifications: row ? row.state !== "Quiet" : true,
|
||||
ownerEmail: row?.ownerEmail ?? "routing@cadence.dev",
|
||||
summary:
|
||||
row?.note ??
|
||||
"Hold the customer note until the support lane stays quiet for one full cycle."
|
||||
};
|
||||
}
|
||||
|
||||
function RoutingRowActions({
|
||||
onOpenSettings,
|
||||
onQueueGate,
|
||||
onSendDigest,
|
||||
row
|
||||
}: {
|
||||
onOpenSettings: (row: RoutingLaneRow) => void;
|
||||
onQueueGate: (row: RoutingLaneRow) => void;
|
||||
onSendDigest: (row: RoutingLaneRow) => void;
|
||||
row: RoutingLaneRow;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="ghost">
|
||||
Actions
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" size="sm">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
onOpenSettings(row);
|
||||
}}
|
||||
>
|
||||
Open lane settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
onQueueGate(row);
|
||||
}}
|
||||
>
|
||||
Queue next gate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
onSendDigest(row);
|
||||
}}
|
||||
>
|
||||
Send digest
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
|
||||
const [activeLane, setActiveLane] = useState<RoutingLaneRow | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [dialogTarget, setDialogTarget] = useState<RoutingLaneRow | null>(null);
|
||||
const [routingFilter, setRoutingFilter] = useState<RoutingFilter>(quietMode ? "quiet" : "all");
|
||||
const [routingLoading, setRoutingLoading] = useState(false);
|
||||
const [routingSearch, setRoutingSearch] = useState("");
|
||||
const [routingSelection, setRoutingSelection] = useState<Record<string, boolean>>({});
|
||||
const [routingSorting, setRoutingSorting] = useState<DataTableSort[]>([
|
||||
{ desc: false, id: "lane" }
|
||||
]);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [toastCopy, setToastCopy] = useState({
|
||||
description: "The lane owner and release note are staged for the next quiet cycle.",
|
||||
title: "Routing updated"
|
||||
});
|
||||
const [toastOpen, setToastOpen] = useState(false);
|
||||
const form = useForm<RoutingValues>({
|
||||
defaultValues: {
|
||||
lane: "engineering",
|
||||
notifications: true,
|
||||
ownerEmail: "routing@cadence.dev",
|
||||
summary: "Hold the customer note until the support lane stays quiet for one full cycle."
|
||||
}
|
||||
defaultValues: toRoutingValues()
|
||||
});
|
||||
|
||||
const visibleRoutingRows = routingLaneRows.filter((row) => {
|
||||
if (routingFilter === "all") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return row.state.toLowerCase() === routingFilter;
|
||||
});
|
||||
const selectedRoutingRows = routingLaneRows.filter((row) => routingSelection[row.id]);
|
||||
const selectedRoutingCount = selectedRoutingRows.length;
|
||||
const visibleWatchCount = visibleRoutingRows.filter(
|
||||
(row) => row.state === "Watching" || row.state === "Holding"
|
||||
).length;
|
||||
const nextOwner = selectedRoutingRows[0]?.owner ?? visibleRoutingRows[0]?.owner ?? "Routing lead";
|
||||
|
||||
function showToast(title: string, description: string) {
|
||||
setToastCopy({ description, title });
|
||||
setToastOpen(true);
|
||||
}
|
||||
|
||||
function openRoutingSheet(row?: RoutingLaneRow) {
|
||||
setActiveLane(row ?? null);
|
||||
form.reset(toRoutingValues(row));
|
||||
setSheetOpen(true);
|
||||
}
|
||||
|
||||
function queueRoutingGate(row?: RoutingLaneRow) {
|
||||
setDialogTarget(row ?? null);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function refreshRoutingSignals() {
|
||||
setRoutingLoading(true);
|
||||
|
||||
window.setTimeout(() => {
|
||||
setRoutingLoading(false);
|
||||
showToast(
|
||||
"Signals refreshed",
|
||||
"Fresh lane telemetry confirms the desk is still readable enough for the next quiet cycle."
|
||||
);
|
||||
}, 900);
|
||||
}
|
||||
|
||||
const routingColumns: DataTableColumn<RoutingLaneRow>[] = [
|
||||
{
|
||||
accessor: "lane",
|
||||
cell: (row: RoutingLaneRow) => (
|
||||
<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 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
|
||||
},
|
||||
{
|
||||
accessor: "owner",
|
||||
cell: (row: RoutingLaneRow) => (
|
||||
<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
|
||||
},
|
||||
{
|
||||
accessor: "state",
|
||||
cell: (row: RoutingLaneRow) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className={getLaneBadgeClassName(row.state)} variant={getLaneBadgeVariant(row.state)}>
|
||||
{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
|
||||
},
|
||||
{
|
||||
accessor: "note",
|
||||
cell: (row: RoutingLaneRow) => (
|
||||
<p className="max-w-[34rem] text-sm leading-6 text-[var(--color-muted-foreground)]">
|
||||
{row.note}
|
||||
</p>
|
||||
),
|
||||
header: "Routing note",
|
||||
id: "note"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<ToastProvider swipeDirection="right">
|
||||
<TooltipProvider delayDuration={120}>
|
||||
@@ -222,7 +505,7 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDialogOpen(true);
|
||||
queueRoutingGate();
|
||||
}}
|
||||
>
|
||||
Queue rollout wave
|
||||
@@ -230,7 +513,7 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setSheetOpen(true);
|
||||
openRoutingSheet();
|
||||
}}
|
||||
>
|
||||
Edit routing lane
|
||||
@@ -285,8 +568,8 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
|
||||
</div>
|
||||
|
||||
<TabsContent value="overview" className="grid gap-6">
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.08fr)_minmax(18rem,0.92fr)]">
|
||||
<article className="overflow-hidden rounded-[calc(var(--radius-lg)+0.15rem)] border border-[var(--color-border)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-card)_88%,white_12%),var(--color-card))] shadow-[var(--shadow-sm)]">
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.08fr)_minmax(18rem,0.92fr)]">
|
||||
<article className="min-w-0 overflow-hidden rounded-[calc(var(--radius-lg)+0.15rem)] border border-[var(--color-border)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-card)_88%,white_12%),var(--color-card))] shadow-[var(--shadow-sm)]">
|
||||
<div className="grid gap-6 p-6 sm:p-7">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
@@ -306,14 +589,14 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
|
||||
note staged, and avoid turning a calm rollout into a noisy one.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-3">
|
||||
{timelineStops.map((item) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className="grid gap-3 rounded-[var(--radius-md)] border border-[color-mix(in_oklch,var(--color-border)_84%,transparent)] bg-[color-mix(in_oklch,var(--color-background)_74%,white_26%)] p-4"
|
||||
className="grid min-w-0 gap-3 rounded-[var(--radius-md)] border border-[color-mix(in_oklch,var(--color-border)_84%,transparent)] bg-[color-mix(in_oklch,var(--color-background)_74%,white_26%)] p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-medium text-[var(--color-foreground)]">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3">
|
||||
<p className="min-w-0 text-sm font-medium text-[var(--color-foreground)]">
|
||||
{item.title}
|
||||
</p>
|
||||
<span className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
||||
@@ -345,7 +628,7 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
|
||||
<EmptyStateActions>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSheetOpen(true);
|
||||
openRoutingSheet();
|
||||
}}
|
||||
>
|
||||
Review routing
|
||||
@@ -354,7 +637,7 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
|
||||
</EmptyStateActions>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<Card>
|
||||
<Card className="min-w-0">
|
||||
<CardHeader>
|
||||
<CardTitle>Support pulse</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -404,55 +687,201 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="routing" className="grid gap-6">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{reviewerColumns.map((column, index) => (
|
||||
<Card
|
||||
key={column.lane}
|
||||
className={[
|
||||
"relative overflow-hidden",
|
||||
index === 1
|
||||
? "border-[color-mix(in_oklch,var(--color-primary)_24%,var(--color-border))] bg-[color-mix(in_oklch,var(--color-primary)_6%,var(--color-card))]"
|
||||
: undefined
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<CardTitle>{column.lane}</CardTitle>
|
||||
<Badge variant={index === 1 ? "solid" : "outline"}>
|
||||
{column.state}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription>{column.note}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.08fr)_minmax(17rem,0.92fr)]">
|
||||
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
||||
Routing principle
|
||||
</p>
|
||||
<h2 className="text-2xl font-semibold tracking-[var(--tracking-tight)]">
|
||||
Keep one owner visible, one fallback explicit, and one customer note staged.
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge variant="outline">{visibleRoutingRows.length} visible lanes</Badge>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
openRoutingSheet(selectedRoutingRows[0]);
|
||||
}}
|
||||
>
|
||||
Adjust lane settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-7 text-[var(--color-muted-foreground)]">
|
||||
The routing desk stops being narrative-only here. Search narrows the live
|
||||
lane list, sorting reshapes the order of attention, selection supports a
|
||||
quiet multi-lane brief, and row actions keep deeper edits in overlays
|
||||
instead of forcing the table to become an app inside itself.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-1">
|
||||
<MetricPanel eyebrow="Needs eyes" tone="accent" value={String(visibleWatchCount)}>
|
||||
Watching and holding lanes should stay visible until the next quiet cycle clears.
|
||||
</MetricPanel>
|
||||
<MetricPanel eyebrow="Selected lanes" value={String(selectedRoutingCount)}>
|
||||
{selectedRoutingCount > 0
|
||||
? `${nextOwner} currently anchors the first selected lane.`
|
||||
: "Use row selection to brief multiple owners without leaving the desk."}
|
||||
</MetricPanel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
||||
Routing principle
|
||||
</p>
|
||||
<h2 className="text-2xl font-semibold tracking-[var(--tracking-tight)]">
|
||||
Keep one owner visible, one fallback explicit, and one customer note staged.
|
||||
</h2>
|
||||
<section className="overflow-hidden rounded-[calc(var(--radius-lg)+0.15rem)] border border-[var(--color-border)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-card)_90%,white_10%),var(--color-card))] shadow-[var(--shadow-sm)]">
|
||||
<div className="border-b border-[color-mix(in_oklch,var(--color-border)_84%,transparent)] px-6 py-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
||||
Routing lanes
|
||||
</p>
|
||||
<h3 className="text-xl font-semibold tracking-[var(--tracking-tight)]">
|
||||
One table for owners, notes, gates, and the next quiet-cycle decision.
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge>Search, sort, select, paginate</Badge>
|
||||
<Badge variant="outline">Row actions stay out of the grid</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setSheetOpen(true);
|
||||
}}
|
||||
>
|
||||
Adjust lane settings
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<DataTable
|
||||
columns={routingColumns}
|
||||
empty={
|
||||
<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 view before queueing.
|
||||
</EmptyStateDescription>
|
||||
</EmptyStateHeader>
|
||||
<EmptyStateActions>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setRoutingFilter("all");
|
||||
setRoutingSearch("");
|
||||
setRoutingSelection({});
|
||||
}}
|
||||
>
|
||||
Reset table view
|
||||
</Button>
|
||||
</EmptyStateActions>
|
||||
</EmptyState>
|
||||
}
|
||||
getRowId={(row: RoutingLaneRow) => row.id}
|
||||
loading={routingLoading}
|
||||
onSearchValueChange={setRoutingSearch}
|
||||
onSelectionChange={setRoutingSelection}
|
||||
onSortingChange={setRoutingSorting}
|
||||
pageSize={4}
|
||||
pageSizeOptions={[4]}
|
||||
searchLabel="Search routing lanes"
|
||||
searchPlaceholder="Search lanes, owners, notes"
|
||||
selectionLabel={(selectedRows) =>
|
||||
`${selectedRows.length} lane${selectedRows.length === 1 ? "" : "s"} selected for a quiet-cycle brief.`
|
||||
}
|
||||
selectionActions={() => (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
openRoutingSheet(selectedRoutingRows[0]);
|
||||
}}
|
||||
>
|
||||
Reassign first owner
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
showToast(
|
||||
"Digest queued",
|
||||
`${selectedRoutingCount} routing lane${selectedRoutingCount === 1 ? "" : "s"} will be summarized for the next quiet cycle.`
|
||||
);
|
||||
}}
|
||||
>
|
||||
Queue digest
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
enableSelection
|
||||
renderRowActions={(row: RoutingLaneRow) => (
|
||||
<RoutingRowActions
|
||||
onOpenSettings={openRoutingSheet}
|
||||
onQueueGate={queueRoutingGate}
|
||||
onSendDigest={(targetRow) => {
|
||||
showToast(
|
||||
`${targetRow.lane} digest sent`,
|
||||
`${targetRow.owner} now has the latest routing note and quiet-cycle gate in their inbox.`
|
||||
);
|
||||
}}
|
||||
row={row}
|
||||
/>
|
||||
)}
|
||||
rows={visibleRoutingRows}
|
||||
searchValue={routingSearch}
|
||||
selection={routingSelection}
|
||||
sorting={routingSorting}
|
||||
toolbarActions={
|
||||
<>
|
||||
<Select
|
||||
value={routingFilter}
|
||||
onValueChange={(value) => {
|
||||
setRoutingFilter(value as RoutingFilter);
|
||||
setRoutingSelection({});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label="Routing lane filter" 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
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setRoutingFilter(quietMode ? "quiet" : "all");
|
||||
setRoutingSearch("");
|
||||
setRoutingSelection({});
|
||||
setRoutingSorting([{ desc: false, id: "lane" }]);
|
||||
}}
|
||||
>
|
||||
Reset desk
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
refreshRoutingSignals();
|
||||
}}
|
||||
>
|
||||
Refresh signals
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="audience" className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_19rem]">
|
||||
<TabsContent
|
||||
value="audience"
|
||||
className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_19rem]"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Audience framing</CardTitle>
|
||||
@@ -552,13 +981,26 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog onOpenChange={setDialogOpen} open={dialogOpen}>
|
||||
<Dialog
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen(open);
|
||||
if (!open) {
|
||||
setDialogTarget(null);
|
||||
}
|
||||
}}
|
||||
open={dialogOpen}
|
||||
>
|
||||
<DialogContent size="sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Queue the 10% wave?</DialogTitle>
|
||||
<DialogTitle>
|
||||
{dialogTarget
|
||||
? `Queue ${dialogTarget.lane} / ${dialogTarget.audience}?`
|
||||
: "Queue the 10% wave?"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This sends the first controlled wave, not the full customer note. Use it only if
|
||||
support stays quiet and the routing owner is live.
|
||||
{dialogTarget
|
||||
? `This hands ${dialogTarget.owner} the ${dialogTarget.nextGate} gate. Use it only if the note is readable, the owner is live, and the desk is still quiet enough to stay disciplined.`
|
||||
: "This sends the first controlled wave, not the full customer note. Use it only if support stays quiet and the routing owner is live."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -567,6 +1009,7 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setDialogOpen(false);
|
||||
setDialogTarget(null);
|
||||
}}
|
||||
>
|
||||
Keep staged
|
||||
@@ -575,22 +1018,39 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDialogOpen(false);
|
||||
setToastOpen(true);
|
||||
showToast(
|
||||
dialogTarget ? `${dialogTarget.lane} gate queued` : "Wave queued",
|
||||
dialogTarget
|
||||
? `${dialogTarget.owner} now owns the ${dialogTarget.nextGate} gate for the ${dialogTarget.audience.toLowerCase()} pass.`
|
||||
: "The lane owner and release note are staged for the next quiet cycle."
|
||||
);
|
||||
setDialogTarget(null);
|
||||
}}
|
||||
>
|
||||
Queue wave
|
||||
{dialogTarget ? "Queue gate" : "Queue wave"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Sheet onOpenChange={setSheetOpen} open={sheetOpen}>
|
||||
<Sheet
|
||||
onOpenChange={(open) => {
|
||||
setSheetOpen(open);
|
||||
if (!open) {
|
||||
setActiveLane(null);
|
||||
}
|
||||
}}
|
||||
open={sheetOpen}
|
||||
>
|
||||
<SheetContent side="right" size="lg">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Routing lane settings</SheetTitle>
|
||||
<SheetTitle>
|
||||
{activeLane ? `${activeLane.lane} lane settings` : "Routing lane settings"}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Keep the lane owner explicit and the customer note disciplined before the wave is
|
||||
queued.
|
||||
{activeLane
|
||||
? `Update the owner, note, and digest posture for ${activeLane.audience.toLowerCase()} before the next gate.`
|
||||
: "Keep the lane owner explicit and the customer note disciplined before the wave is queued."}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
@@ -599,7 +1059,13 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
|
||||
className="grid gap-5"
|
||||
onSubmit={form.handleSubmit(() => {
|
||||
setSheetOpen(false);
|
||||
setToastOpen(true);
|
||||
showToast(
|
||||
activeLane ? `${activeLane.lane} lane updated` : "Routing updated",
|
||||
activeLane
|
||||
? `${activeLane.owner} now has a refreshed routing brief for ${activeLane.audience.toLowerCase()}.`
|
||||
: "The lane owner and release note are staged for the next quiet cycle."
|
||||
);
|
||||
setActiveLane(null);
|
||||
})}
|
||||
>
|
||||
<FormItem name="ownerEmail" required>
|
||||
@@ -685,6 +1151,7 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSheetOpen(false);
|
||||
setActiveLane(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
@@ -697,10 +1164,8 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
|
||||
</Sheet>
|
||||
|
||||
<Toast onOpenChange={setToastOpen} open={toastOpen} variant="success">
|
||||
<ToastTitle>Routing updated</ToastTitle>
|
||||
<ToastDescription>
|
||||
The lane owner and release note are staged for the next quiet cycle.
|
||||
</ToastDescription>
|
||||
<ToastTitle>{toastCopy.title}</ToastTitle>
|
||||
<ToastDescription>{toastCopy.description}</ToastDescription>
|
||||
<ToastAction altText="Open desk">Open desk</ToastAction>
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
@@ -718,7 +1183,7 @@ const meta = {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Release Workspace is a realistic composition story that shows how Cadence UI behaves when the design system stops being a component shelf and becomes an actual operations surface. It combines layered decisions, contextual overlays, empty states, routing controls, and transient feedback in one believable release desk."
|
||||
"Release Workspace is a realistic composition story that shows how Cadence UI behaves when the design system stops being a component shelf and becomes an actual operations surface. It now uses a real routing table workflow to test search, sorting, selection, pagination, row actions, and overlay handoffs inside one believable release desk."
|
||||
}
|
||||
},
|
||||
layout: "fullscreen"
|
||||
|
||||
Reference in New Issue
Block a user