feat: add data table and release checks
This commit is contained in:
+25
-1
@@ -6,8 +6,29 @@ The repo is still an internal/private monorepo, so Changesets is used conservati
|
|||||||
|
|
||||||
- track version intent for releasable workspace packages
|
- track version intent for releasable workspace packages
|
||||||
- keep release notes attached to code changes
|
- keep release notes attached to code changes
|
||||||
- avoid auto-committing release files
|
|
||||||
- avoid tagging private packages during early internal iteration
|
- avoid tagging private packages during early internal iteration
|
||||||
|
- let CI verify release intent before package work merges
|
||||||
|
|
||||||
|
## CI behavior
|
||||||
|
|
||||||
|
This repo now has two release-adjacent workflows:
|
||||||
|
|
||||||
|
- `Changeset Status` runs on pull requests and checks whether changes to releasable packages
|
||||||
|
include a `.changeset/*.md` entry.
|
||||||
|
- `Release Version PR` runs on `main` and opens or updates a version PR by running
|
||||||
|
`pnpm changeset version`.
|
||||||
|
|
||||||
|
The PR check intentionally focuses on the releasable packages in scope today:
|
||||||
|
|
||||||
|
- `@ai-ui/ui`
|
||||||
|
- `@ai-ui/tokens`
|
||||||
|
|
||||||
|
If a PR touches those packages but should not create a release note, apply the
|
||||||
|
`no-changeset-needed` label. This is the escape hatch for work such as:
|
||||||
|
|
||||||
|
- docs-only follow-up near a package
|
||||||
|
- test-only changes
|
||||||
|
- internal refactors with no consumer-visible behavior change
|
||||||
|
|
||||||
## What should get a changeset
|
## What should get a changeset
|
||||||
|
|
||||||
@@ -48,4 +69,7 @@ flow is enabled, treat Changesets as the source of truth for:
|
|||||||
- which changes deserve release notes
|
- which changes deserve release notes
|
||||||
- which internal dependency bumps should be coordinated
|
- which internal dependency bumps should be coordinated
|
||||||
|
|
||||||
|
The current automation stops at version intent and version PR creation. It does not publish to a
|
||||||
|
registry, create tags, or assume any package registry credentials exist yet.
|
||||||
|
|
||||||
See `docs/releasing.md` for the expected workflow around creating and consuming changesets.
|
See `docs/releasing.md` for the expected workflow around creating and consuming changesets.
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@ai-ui/ui": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add the first DataTable implementation with sorting, selection, search, pagination, loading, empty states, and row actions.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
name: Changeset Status
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- reopened
|
||||||
|
- synchronize
|
||||||
|
- labeled
|
||||||
|
- unlabeled
|
||||||
|
- ready_for_review
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
changeset-status:
|
||||||
|
if: ${{ !github.event.pull_request.draft }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||||
|
PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10.25.0
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Require a changeset for releasable package changes
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ ",${PR_LABELS}," == *",no-changeset-needed,"* ]]; then
|
||||||
|
echo "Skipping changeset requirement because the PR is labeled no-changeset-needed."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
changed_files="$(git diff --name-only "origin/${BASE_REF}...HEAD")"
|
||||||
|
|
||||||
|
if [[ -z "${changed_files}" ]]; then
|
||||||
|
echo "No changed files detected."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Changed files:"
|
||||||
|
printf '%s\n' "${changed_files}"
|
||||||
|
|
||||||
|
needs_changeset=false
|
||||||
|
has_changeset=false
|
||||||
|
|
||||||
|
while IFS= read -r file; do
|
||||||
|
[[ -z "${file}" ]] && continue
|
||||||
|
|
||||||
|
case "${file}" in
|
||||||
|
.changeset/*.md)
|
||||||
|
if [[ "${file}" != ".changeset/README.md" ]]; then
|
||||||
|
has_changeset=true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
packages/tokens/*)
|
||||||
|
needs_changeset=true
|
||||||
|
;;
|
||||||
|
packages/ui/package.json)
|
||||||
|
needs_changeset=true
|
||||||
|
;;
|
||||||
|
packages/ui/src/test/*|packages/ui/src/**/*.test.ts|packages/ui/src/**/*.test.tsx)
|
||||||
|
;;
|
||||||
|
packages/ui/src/*)
|
||||||
|
needs_changeset=true
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done <<< "${changed_files}"
|
||||||
|
|
||||||
|
if [[ "${needs_changeset}" != "true" ]]; then
|
||||||
|
echo "No releasable package changes detected."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${has_changeset}" != "true" ]]; then
|
||||||
|
echo "::error::Changes to @ai-ui/ui or @ai-ui/tokens require a changeset file under .changeset/."
|
||||||
|
echo "::error::If this PR is intentionally docs-only, test-only, or otherwise non-releasable, apply the no-changeset-needed label."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Validate pending changesets
|
||||||
|
run: pnpm changeset:status --since "origin/${BASE_REF}"
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
name: Release Version PR
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-version-pr
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
version-packages:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10.25.0
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Open or update version PR
|
||||||
|
uses: changesets/action@v1
|
||||||
|
with:
|
||||||
|
version: pnpm changeset version
|
||||||
|
commit: "chore: version packages"
|
||||||
|
title: "chore: version packages"
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -6,12 +6,17 @@ import {
|
|||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
|
DataTable,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
EmptyStateActions,
|
EmptyStateActions,
|
||||||
EmptyStateDescription,
|
EmptyStateDescription,
|
||||||
@@ -59,6 +64,7 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger
|
TooltipTrigger
|
||||||
} from "@ai-ui/ui";
|
} from "@ai-ui/ui";
|
||||||
|
import type { DataTableColumn, DataTableSort } from "@ai-ui/ui";
|
||||||
import type { Meta, StoryObj } from "@storybook/react";
|
import type { Meta, StoryObj } from "@storybook/react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -74,23 +80,101 @@ type ReleaseWorkspaceProps = {
|
|||||||
quietMode?: boolean;
|
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",
|
lane: "Editorial",
|
||||||
|
nextGate: "18:40",
|
||||||
note: "Copy is locked. Waiting on final legal phrasing for the migration footnote.",
|
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"
|
state: "Ready"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
audience: "10% canary",
|
||||||
|
id: "engineering-canary",
|
||||||
lane: "Engineering",
|
lane: "Engineering",
|
||||||
|
nextGate: "19:15",
|
||||||
note: "Canary checks are green. Queue the 10% wave after route owners sign off.",
|
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"
|
state: "Watching"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
audience: "Support queue",
|
||||||
|
id: "support-queue",
|
||||||
lane: "Support",
|
lane: "Support",
|
||||||
|
nextGate: "19:32",
|
||||||
note: "No escalations yet. Macro pack and customer note are staged for handoff.",
|
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"
|
state: "Quiet"
|
||||||
}
|
}
|
||||||
] as const;
|
];
|
||||||
|
|
||||||
const timelineStops = [
|
const timelineStops = [
|
||||||
{
|
{
|
||||||
@@ -110,6 +194,13 @@ const timelineStops = [
|
|||||||
}
|
}
|
||||||
] as const;
|
] 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() {
|
function SignalGlyph() {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-2">
|
<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) {
|
function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
|
||||||
|
const [activeLane, setActiveLane] = useState<RoutingLaneRow | null>(null);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
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 [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 [toastOpen, setToastOpen] = useState(false);
|
||||||
const form = useForm<RoutingValues>({
|
const form = useForm<RoutingValues>({
|
||||||
defaultValues: {
|
defaultValues: toRoutingValues()
|
||||||
lane: "engineering",
|
|
||||||
notifications: true,
|
|
||||||
ownerEmail: "routing@cadence.dev",
|
|
||||||
summary: "Hold the customer note until the support lane stays quiet for one full cycle."
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<ToastProvider swipeDirection="right">
|
<ToastProvider swipeDirection="right">
|
||||||
<TooltipProvider delayDuration={120}>
|
<TooltipProvider delayDuration={120}>
|
||||||
@@ -222,7 +505,7 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
|
|||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDialogOpen(true);
|
queueRoutingGate();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Queue rollout wave
|
Queue rollout wave
|
||||||
@@ -230,7 +513,7 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSheetOpen(true);
|
openRoutingSheet();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Edit routing lane
|
Edit routing lane
|
||||||
@@ -285,8 +568,8 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TabsContent value="overview" className="grid gap-6">
|
<TabsContent value="overview" className="grid gap-6">
|
||||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.08fr)_minmax(18rem,0.92fr)]">
|
<div className="grid gap-6 xl: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)]">
|
<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="grid gap-6 p-6 sm:p-7">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div className="space-y-2">
|
<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.
|
note staged, and avoid turning a calm rollout into a noisy one.
|
||||||
</p>
|
</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) => (
|
{timelineStops.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.title}
|
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">
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3">
|
||||||
<p className="text-sm font-medium text-[var(--color-foreground)]">
|
<p className="min-w-0 text-sm font-medium text-[var(--color-foreground)]">
|
||||||
{item.title}
|
{item.title}
|
||||||
</p>
|
</p>
|
||||||
<span className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
<span className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
||||||
@@ -345,7 +628,7 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
|
|||||||
<EmptyStateActions>
|
<EmptyStateActions>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSheetOpen(true);
|
openRoutingSheet();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Review routing
|
Review routing
|
||||||
@@ -354,7 +637,7 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
|
|||||||
</EmptyStateActions>
|
</EmptyStateActions>
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card className="min-w-0">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Support pulse</CardTitle>
|
<CardTitle>Support pulse</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -404,55 +687,201 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="routing" className="grid gap-6">
|
<TabsContent value="routing" className="grid gap-6">
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.08fr)_minmax(17rem,0.92fr)]">
|
||||||
{reviewerColumns.map((column, index) => (
|
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||||
<Card
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
key={column.lane}
|
<div className="space-y-2">
|
||||||
className={[
|
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
||||||
"relative overflow-hidden",
|
Routing principle
|
||||||
index === 1
|
</p>
|
||||||
? "border-[color-mix(in_oklch,var(--color-primary)_24%,var(--color-border))] bg-[color-mix(in_oklch,var(--color-primary)_6%,var(--color-card))]"
|
<h2 className="text-2xl font-semibold tracking-[var(--tracking-tight)]">
|
||||||
: undefined
|
Keep one owner visible, one fallback explicit, and one customer note staged.
|
||||||
]
|
</h2>
|
||||||
.filter(Boolean)
|
</div>
|
||||||
.join(" ")}
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
>
|
<Badge variant="outline">{visibleRoutingRows.length} visible lanes</Badge>
|
||||||
<CardHeader>
|
<Button
|
||||||
<div className="flex items-center justify-between gap-3">
|
variant="secondary"
|
||||||
<CardTitle>{column.lane}</CardTitle>
|
onClick={() => {
|
||||||
<Badge variant={index === 1 ? "solid" : "outline"}>
|
openRoutingSheet(selectedRoutingRows[0]);
|
||||||
{column.state}
|
}}
|
||||||
</Badge>
|
>
|
||||||
</div>
|
Adjust lane settings
|
||||||
<CardDescription>{column.note}</CardDescription>
|
</Button>
|
||||||
</CardHeader>
|
</div>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
|
|
||||||
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
|
<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="flex flex-wrap items-center justify-between gap-4">
|
<div className="border-b border-[color-mix(in_oklch,var(--color-border)_84%,transparent)] px-6 py-5">
|
||||||
<div className="space-y-2">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
<div className="space-y-2">
|
||||||
Routing principle
|
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
||||||
</p>
|
Routing lanes
|
||||||
<h2 className="text-2xl font-semibold tracking-[var(--tracking-tight)]">
|
</p>
|
||||||
Keep one owner visible, one fallback explicit, and one customer note staged.
|
<h3 className="text-xl font-semibold tracking-[var(--tracking-tight)]">
|
||||||
</h2>
|
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>
|
</div>
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
setSheetOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Adjust lane settings
|
|
||||||
</Button>
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Audience framing</CardTitle>
|
<CardTitle>Audience framing</CardTitle>
|
||||||
@@ -552,13 +981,26 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog onOpenChange={setDialogOpen} open={dialogOpen}>
|
<Dialog
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setDialogOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setDialogTarget(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
open={dialogOpen}
|
||||||
|
>
|
||||||
<DialogContent size="sm">
|
<DialogContent size="sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Queue the 10% wave?</DialogTitle>
|
<DialogTitle>
|
||||||
|
{dialogTarget
|
||||||
|
? `Queue ${dialogTarget.lane} / ${dialogTarget.audience}?`
|
||||||
|
: "Queue the 10% wave?"}
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
This sends the first controlled wave, not the full customer note. Use it only if
|
{dialogTarget
|
||||||
support stays quiet and the routing owner is live.
|
? `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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -567,6 +1009,7 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
|
setDialogTarget(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Keep staged
|
Keep staged
|
||||||
@@ -575,22 +1018,39 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDialogOpen(false);
|
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>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Sheet onOpenChange={setSheetOpen} open={sheetOpen}>
|
<Sheet
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setSheetOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setActiveLane(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
open={sheetOpen}
|
||||||
|
>
|
||||||
<SheetContent side="right" size="lg">
|
<SheetContent side="right" size="lg">
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle>Routing lane settings</SheetTitle>
|
<SheetTitle>
|
||||||
|
{activeLane ? `${activeLane.lane} lane settings` : "Routing lane settings"}
|
||||||
|
</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
Keep the lane owner explicit and the customer note disciplined before the wave is
|
{activeLane
|
||||||
queued.
|
? `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>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
@@ -599,7 +1059,13 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
|
|||||||
className="grid gap-5"
|
className="grid gap-5"
|
||||||
onSubmit={form.handleSubmit(() => {
|
onSubmit={form.handleSubmit(() => {
|
||||||
setSheetOpen(false);
|
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>
|
<FormItem name="ownerEmail" required>
|
||||||
@@ -685,6 +1151,7 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSheetOpen(false);
|
setSheetOpen(false);
|
||||||
|
setActiveLane(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -697,10 +1164,8 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
|
|||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
<Toast onOpenChange={setToastOpen} open={toastOpen} variant="success">
|
<Toast onOpenChange={setToastOpen} open={toastOpen} variant="success">
|
||||||
<ToastTitle>Routing updated</ToastTitle>
|
<ToastTitle>{toastCopy.title}</ToastTitle>
|
||||||
<ToastDescription>
|
<ToastDescription>{toastCopy.description}</ToastDescription>
|
||||||
The lane owner and release note are staged for the next quiet cycle.
|
|
||||||
</ToastDescription>
|
|
||||||
<ToastAction altText="Open desk">Open desk</ToastAction>
|
<ToastAction altText="Open desk">Open desk</ToastAction>
|
||||||
<ToastClose />
|
<ToastClose />
|
||||||
</Toast>
|
</Toast>
|
||||||
@@ -718,7 +1183,7 @@ const meta = {
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
component:
|
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"
|
layout: "fullscreen"
|
||||||
|
|||||||
+51
-8
@@ -8,13 +8,14 @@ The current goal is modest:
|
|||||||
- version `@ai-ui/ui` and `@ai-ui/tokens` deliberately
|
- version `@ai-ui/ui` and `@ai-ui/tokens` deliberately
|
||||||
- keep release notes attached to the changes that caused them
|
- keep release notes attached to the changes that caused them
|
||||||
- avoid inventing ad hoc version bumps when the component system evolves
|
- avoid inventing ad hoc version bumps when the component system evolves
|
||||||
|
- make release intent enforceable in CI before package work merges
|
||||||
|
|
||||||
## Current assumptions
|
## Current assumptions
|
||||||
|
|
||||||
- The repository root is private.
|
- The repository root is private.
|
||||||
- Workspace packages currently use explicit package versions even when they are not yet published.
|
- Workspace packages currently use explicit package versions even when they are not yet published.
|
||||||
- `@ai-ui/docs` is a consumer app, not a releasable package, so it is ignored by Changesets.
|
- `@ai-ui/docs` is a consumer app, not a releasable package, so it is ignored by Changesets.
|
||||||
- Publishing mechanics, registry credentials, and CI release automation are still to be added.
|
- Publishing mechanics and registry credentials are still to be added.
|
||||||
|
|
||||||
Because of that, this baseline is intentionally conservative.
|
Because of that, this baseline is intentionally conservative.
|
||||||
|
|
||||||
@@ -44,6 +45,9 @@ You can usually skip a changeset for:
|
|||||||
- test-only edits
|
- test-only edits
|
||||||
- internal refactors with no consumer-visible behavior change
|
- internal refactors with no consumer-visible behavior change
|
||||||
|
|
||||||
|
If CI would otherwise require a changeset for one of those exceptions, apply the
|
||||||
|
`no-changeset-needed` pull request label and explain the reason in the PR description.
|
||||||
|
|
||||||
## Versioning guidance
|
## Versioning guidance
|
||||||
|
|
||||||
Use semver pragmatically:
|
Use semver pragmatically:
|
||||||
@@ -100,7 +104,7 @@ keep the dependency graph coherent without requiring manual package edits.
|
|||||||
|
|
||||||
### 4. Version the packages
|
### 4. Version the packages
|
||||||
|
|
||||||
When it is time to cut a release, run the Changesets version step:
|
When it is time to cut a release manually, run the Changesets version step:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm changeset version
|
pnpm changeset version
|
||||||
@@ -114,6 +118,10 @@ That step is expected to:
|
|||||||
|
|
||||||
Review the resulting package diffs carefully before merging.
|
Review the resulting package diffs carefully before merging.
|
||||||
|
|
||||||
|
On `main`, the repository also runs a `Release Version PR` workflow that opens or updates a
|
||||||
|
version PR with the same command. That workflow is intentionally limited to version-file updates;
|
||||||
|
it does not publish packages.
|
||||||
|
|
||||||
### 5. Publish or tag
|
### 5. Publish or tag
|
||||||
|
|
||||||
Publishing is not fully wired in this repo yet, so treat this step as pending infrastructure.
|
Publishing is not fully wired in this repo yet, so treat this step as pending infrastructure.
|
||||||
@@ -126,6 +134,45 @@ pnpm changeset publish
|
|||||||
|
|
||||||
But until registry, auth, and CI behavior are explicit, do not assume publish is automated.
|
But until registry, auth, and CI behavior are explicit, do not assume publish is automated.
|
||||||
|
|
||||||
|
## CI workflows
|
||||||
|
|
||||||
|
### Pull request check
|
||||||
|
|
||||||
|
The `Changeset Status` workflow runs on pull requests and enforces a simple rule:
|
||||||
|
|
||||||
|
- if a PR changes `@ai-ui/ui` or `@ai-ui/tokens`, it should usually include a new
|
||||||
|
`.changeset/*.md` file
|
||||||
|
- if the work is intentionally non-releasable, maintainers can apply the
|
||||||
|
`no-changeset-needed` label
|
||||||
|
|
||||||
|
When a changeset is present, the workflow also runs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm changeset:status --since origin/main
|
||||||
|
```
|
||||||
|
|
||||||
|
This validates that pending changesets resolve cleanly against the base branch.
|
||||||
|
|
||||||
|
### Version PR workflow
|
||||||
|
|
||||||
|
The `Release Version PR` workflow runs on pushes to `main` and on manual dispatch. It:
|
||||||
|
|
||||||
|
- installs dependencies
|
||||||
|
- runs `pnpm changeset version`
|
||||||
|
- opens or updates a version PR using `changesets/action`
|
||||||
|
|
||||||
|
This is a versioning skeleton, not a publish pipeline. It is safe to enable before registry
|
||||||
|
credentials or publish commands exist.
|
||||||
|
|
||||||
|
## Future publish prerequisites
|
||||||
|
|
||||||
|
Before turning the version workflow into a publish workflow, the repo still needs:
|
||||||
|
|
||||||
|
- a root publish script, for example `pnpm changeset publish` or a guarded wrapper around it
|
||||||
|
- registry credentials such as `NPM_TOKEN`
|
||||||
|
- a decision on which workspace packages should actually be published versus versioned internally
|
||||||
|
- any extra validation that must run before publish is allowed
|
||||||
|
|
||||||
## Notes for maintainers
|
## Notes for maintainers
|
||||||
|
|
||||||
- Keep `packages/ui/src/index.ts` and package exports aligned with any release-worthy surface.
|
- Keep `packages/ui/src/index.ts` and package exports aligned with any release-worthy surface.
|
||||||
@@ -136,9 +183,5 @@ But until registry, auth, and CI behavior are explicit, do not assume publish is
|
|||||||
|
|
||||||
## Main-thread follow-up still needed
|
## Main-thread follow-up still needed
|
||||||
|
|
||||||
This baseline adds config and process docs only. To make it operational, the repo still needs:
|
This baseline now covers changeset intent checks in PRs and version PR creation on `main`.
|
||||||
|
What remains is the publish decision and any registry-specific automation.
|
||||||
- `@changesets/cli` added to root `devDependencies`
|
|
||||||
- root scripts such as `changeset`, `version-packages`, or `release`
|
|
||||||
- a decision on whether private packages should be published, mirrored internally, or versioned only
|
|
||||||
- CI wiring for version PRs and/or publish jobs
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-toast": "^1.2.15",
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { render, screen, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { Button } from "./button";
|
||||||
|
import { DataTable, type DataTableColumn } from "./data-table";
|
||||||
|
|
||||||
|
type ReleaseRow = {
|
||||||
|
id: string;
|
||||||
|
lane: string;
|
||||||
|
note: string;
|
||||||
|
owner: string;
|
||||||
|
risk: number;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows: ReleaseRow[] = [
|
||||||
|
{
|
||||||
|
id: "legal",
|
||||||
|
lane: "Legal",
|
||||||
|
note: "Footnote needs one more pass before the customer note goes out.",
|
||||||
|
owner: "Ava",
|
||||||
|
risk: 3,
|
||||||
|
status: "Pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "support",
|
||||||
|
lane: "Support",
|
||||||
|
note: "Macro pack is staged and aligned with the migration narrative.",
|
||||||
|
owner: "Nia",
|
||||||
|
risk: 1,
|
||||||
|
status: "Ready"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "engineering",
|
||||||
|
lane: "Engineering",
|
||||||
|
note: "Canary checks are green and ready for the 10% wave.",
|
||||||
|
owner: "Mika",
|
||||||
|
risk: 2,
|
||||||
|
status: "Watching"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "editorial",
|
||||||
|
lane: "Editorial",
|
||||||
|
note: "Copy is locked and the public note stays staged.",
|
||||||
|
owner: "Jun",
|
||||||
|
risk: 4,
|
||||||
|
status: "Staged"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const columns: DataTableColumn<ReleaseRow>[] = [
|
||||||
|
{
|
||||||
|
accessor: "lane",
|
||||||
|
header: "Lane",
|
||||||
|
id: "lane",
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessor: "owner",
|
||||||
|
header: "Owner",
|
||||||
|
id: "owner",
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessor: "status",
|
||||||
|
header: "Status",
|
||||||
|
id: "status"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessor: "risk",
|
||||||
|
align: "end",
|
||||||
|
header: "Risk",
|
||||||
|
id: "risk",
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cell: (row) => row.note,
|
||||||
|
header: "Narrative",
|
||||||
|
id: "note",
|
||||||
|
searchValue: (row) => row.note
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function getBodyRows() {
|
||||||
|
return screen
|
||||||
|
.getAllByRole("row")
|
||||||
|
.filter((row) => row.closest("tbody") !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRenderedLanes() {
|
||||||
|
return getBodyRows().map((row) => within(row).getAllByRole("cell")[0].textContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DataTable", () => {
|
||||||
|
it("renders semantic table slots, toolbar search, and row actions", () => {
|
||||||
|
render(
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
renderRowActions={(row) => <Button size="sm">Open {row.lane}</Button>}
|
||||||
|
rows={rows.slice(0, 2)}
|
||||||
|
toolbarActions={<Button size="sm" variant="secondary">Create lane</Button>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("table").closest('[data-slot="root"]')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("searchbox", { name: "Search rows" })).toHaveAttribute(
|
||||||
|
"data-slot",
|
||||||
|
"input"
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Create lane" }).closest('[data-slot="actions"]')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: /lane/i })).toHaveAttribute(
|
||||||
|
"data-slot",
|
||||||
|
"header"
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Open Legal" }).closest('[data-slot="actions"]')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts rows when a sortable header is activated", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<DataTable columns={columns} rows={rows.slice(0, 3)} />);
|
||||||
|
|
||||||
|
expect(getRenderedLanes()).toEqual(["Legal", "Support", "Engineering"]);
|
||||||
|
|
||||||
|
await user.click(within(screen.getByRole("columnheader", { name: /risk/i })).getByRole("button"));
|
||||||
|
|
||||||
|
expect(getRenderedLanes()).toEqual(["Support", "Engineering", "Legal"]);
|
||||||
|
|
||||||
|
await user.click(within(screen.getByRole("columnheader", { name: /risk/i })).getByRole("button"));
|
||||||
|
|
||||||
|
expect(getRenderedLanes()).toEqual(["Legal", "Engineering", "Support"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports row selection and shows a bulk selection surface", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
enableSelection
|
||||||
|
rows={rows.slice(0, 2)}
|
||||||
|
selectionActions={() => <Button size="sm">Assign owner</Button>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("checkbox", { name: "Select row 1" }));
|
||||||
|
|
||||||
|
expect(getBodyRows()[0]).toHaveAttribute("data-selected", "");
|
||||||
|
expect(screen.getByText("1 selected")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Assign owner" })).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Clear selection" }));
|
||||||
|
|
||||||
|
expect(screen.queryByText("1 selected")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters rows from the built-in search and falls back to the empty state", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<DataTable columns={columns} rows={rows.slice(0, 3)} />);
|
||||||
|
|
||||||
|
await user.type(screen.getByRole("searchbox", { name: "Search rows" }), "migration");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText("Macro pack is staged and aligned with the migration narrative.")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByText("Canary checks are green and ready for the 10% wave.")
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.clear(screen.getByRole("searchbox", { name: "Search rows" }));
|
||||||
|
await user.type(screen.getByRole("searchbox", { name: "Search rows" }), "missing");
|
||||||
|
|
||||||
|
expect(screen.getByText("No matching rows")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
"Try another search, clear filters, or create a new record to repopulate this view."
|
||||||
|
)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("paginates rows and updates the visible range", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
defaultPageSize={2}
|
||||||
|
pageSizeOptions={[2]}
|
||||||
|
rows={rows}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("1-2 of 4")).toBeInTheDocument();
|
||||||
|
expect(getRenderedLanes()).toEqual(["Legal", "Support"]);
|
||||||
|
expect(screen.getByRole("button", { name: "Previous" })).toBeDisabled();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Next" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("3-4 of 4")).toBeInTheDocument();
|
||||||
|
expect(getRenderedLanes()).toEqual(["Engineering", "Editorial"]);
|
||||||
|
expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders loading status without dropping the table chrome", () => {
|
||||||
|
render(<DataTable columns={columns} loading rows={rows} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent("Loading rows");
|
||||||
|
expect(screen.getByRole("columnheader", { name: /lane/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("table").closest('[data-slot="root"]')).toHaveAttribute(
|
||||||
|
"data-loading",
|
||||||
|
""
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports controlled sorting and selection callbacks", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSortingChange = vi.fn();
|
||||||
|
const onSelectionChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
enableSelection
|
||||||
|
onSelectionChange={onSelectionChange}
|
||||||
|
onSortingChange={onSortingChange}
|
||||||
|
rows={rows.slice(0, 2)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(within(screen.getByRole("columnheader", { name: /lane/i })).getByRole("button"));
|
||||||
|
await user.click(screen.getByRole("checkbox", { name: "Select row 2" }));
|
||||||
|
|
||||||
|
expect(onSortingChange).toHaveBeenCalledWith([{ desc: false, id: "lane" }]);
|
||||||
|
expect(onSelectionChange).toHaveBeenCalledWith({ support: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,952 @@
|
|||||||
|
import {
|
||||||
|
type CellContext,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
type HeaderContext,
|
||||||
|
useReactTable,
|
||||||
|
type ColumnDef,
|
||||||
|
type FilterFn,
|
||||||
|
type PaginationState,
|
||||||
|
type RowSelectionState,
|
||||||
|
type SortingState
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
type ComponentPropsWithoutRef,
|
||||||
|
type CSSProperties,
|
||||||
|
type ForwardedRef,
|
||||||
|
type JSX,
|
||||||
|
type ReactNode
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import { Button } from "./button";
|
||||||
|
import { Checkbox } from "./checkbox";
|
||||||
|
import {
|
||||||
|
EmptyState,
|
||||||
|
EmptyStateActions,
|
||||||
|
EmptyStateDescription,
|
||||||
|
EmptyStateHeader,
|
||||||
|
EmptyStateTitle
|
||||||
|
} from "./empty-state";
|
||||||
|
import { Input, type InputProps } from "./input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
|
||||||
|
import { Skeleton } from "./skeleton";
|
||||||
|
import { Spinner } from "./spinner";
|
||||||
|
import { cn } from "../lib/cn";
|
||||||
|
import type { VariantProps } from "../lib/cva";
|
||||||
|
import { createDataAttributes, createSlot } from "../lib/contracts";
|
||||||
|
import {
|
||||||
|
dataTableBodyVariants,
|
||||||
|
dataTableCellVariants,
|
||||||
|
dataTableContentVariants,
|
||||||
|
dataTableHeaderCellVariants,
|
||||||
|
dataTableHeaderVariants,
|
||||||
|
dataTablePaginationVariants,
|
||||||
|
dataTableRootVariants,
|
||||||
|
dataTableRowVariants,
|
||||||
|
dataTableSearchContainerVariants,
|
||||||
|
dataTableSelectionBarVariants,
|
||||||
|
dataTableStatusVariants,
|
||||||
|
dataTableTableVariants,
|
||||||
|
dataTableToolbarVariants
|
||||||
|
} from "./data-table.variants";
|
||||||
|
|
||||||
|
export type DataTableAlignment = "start" | "center" | "end";
|
||||||
|
|
||||||
|
export type DataTableSort = {
|
||||||
|
desc?: boolean;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataTableColumnContext<TData> = {
|
||||||
|
column: DataTableColumn<TData>;
|
||||||
|
sorted: "asc" | "desc" | false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataTableColumn<TData> = {
|
||||||
|
accessor?: keyof TData | ((row: TData) => unknown);
|
||||||
|
align?: DataTableAlignment;
|
||||||
|
cell?: (row: TData) => ReactNode;
|
||||||
|
header: ReactNode | ((column: DataTableColumnContext<TData>) => ReactNode);
|
||||||
|
id: string;
|
||||||
|
searchValue?: (row: TData) => string;
|
||||||
|
searchable?: boolean;
|
||||||
|
sortable?: boolean;
|
||||||
|
width?: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataTableProps<TData> = Omit<ComponentPropsWithoutRef<"div">, "children"> & {
|
||||||
|
columns: DataTableColumn<TData>[];
|
||||||
|
defaultPageIndex?: number;
|
||||||
|
defaultPageSize?: number;
|
||||||
|
defaultSearchValue?: string;
|
||||||
|
defaultSelection?: Record<string, boolean>;
|
||||||
|
defaultSorting?: DataTableSort[];
|
||||||
|
empty?: ReactNode;
|
||||||
|
enableSelection?: boolean;
|
||||||
|
getRowId?: (row: TData, index: number) => string;
|
||||||
|
loading?: boolean;
|
||||||
|
loadingRowCount?: number;
|
||||||
|
onPageIndexChange?: (pageIndex: number) => void;
|
||||||
|
onPageSizeChange?: (pageSize: number) => void;
|
||||||
|
onSearchValueChange?: (searchValue: string) => void;
|
||||||
|
onSelectionChange?: (selection: Record<string, boolean>) => void;
|
||||||
|
onSortingChange?: (sorting: DataTableSort[]) => void;
|
||||||
|
pageIndex?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
pageSizeOptions?: number[];
|
||||||
|
renderRowActions?: (row: TData) => ReactNode;
|
||||||
|
rows: TData[];
|
||||||
|
searchLabel?: string;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
searchValue?: string;
|
||||||
|
selection?: Record<string, boolean>;
|
||||||
|
selectionLabel?: ReactNode | ((rows: TData[]) => ReactNode);
|
||||||
|
selectionActions?: (rows: TData[]) => ReactNode;
|
||||||
|
sorting?: DataTableSort[];
|
||||||
|
tableLabel?: string;
|
||||||
|
toolbarActions?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InternalColumnMeta<TData> = {
|
||||||
|
align: DataTableAlignment;
|
||||||
|
sourceColumn?: DataTableColumn<TData>;
|
||||||
|
width?: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function useControllableState<T>({
|
||||||
|
controlledValue,
|
||||||
|
defaultValue,
|
||||||
|
onChange
|
||||||
|
}: {
|
||||||
|
controlledValue: T | undefined;
|
||||||
|
defaultValue: T;
|
||||||
|
onChange?: (value: T) => void;
|
||||||
|
}) {
|
||||||
|
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
|
||||||
|
const value = controlledValue ?? uncontrolledValue;
|
||||||
|
|
||||||
|
const setValue = (nextValue: T | ((currentValue: T) => T)) => {
|
||||||
|
const resolvedValue =
|
||||||
|
typeof nextValue === "function"
|
||||||
|
? (nextValue as (currentValue: T) => T)(value)
|
||||||
|
: nextValue;
|
||||||
|
|
||||||
|
if (controlledValue === undefined) {
|
||||||
|
setUncontrolledValue(resolvedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange?.(resolvedValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
return [value, setValue] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifySearchValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => stringifySearchValue(item)).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "object") {
|
||||||
|
return Object.values(value as Record<string, unknown>)
|
||||||
|
.map((item) => stringifySearchValue(item))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColumnAccessorValue<TData>(row: TData, column: DataTableColumn<TData>) {
|
||||||
|
if (typeof column.accessor === "function") {
|
||||||
|
return column.accessor(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof column.accessor === "string") {
|
||||||
|
return row[column.accessor];
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColumnSearchText<TData>(row: TData, column: DataTableColumn<TData>) {
|
||||||
|
if (column.searchValue) {
|
||||||
|
return column.searchValue(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stringifySearchValue(getColumnAccessorValue(row, column));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColumnWidthStyle(width?: number | string): CSSProperties | undefined {
|
||||||
|
if (width === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: typeof width === "number" ? `${width}px` : width
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHeaderLabel<TData>(column: DataTableColumn<TData>) {
|
||||||
|
return typeof column.header === "string" ? column.header : column.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataTableInner<TData>(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
columns,
|
||||||
|
defaultPageIndex = 0,
|
||||||
|
defaultPageSize = 5,
|
||||||
|
defaultSearchValue = "",
|
||||||
|
defaultSelection = {},
|
||||||
|
defaultSorting = [],
|
||||||
|
empty,
|
||||||
|
enableSelection = false,
|
||||||
|
getRowId,
|
||||||
|
loading = false,
|
||||||
|
loadingRowCount = 5,
|
||||||
|
onPageIndexChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
onSearchValueChange,
|
||||||
|
onSelectionChange,
|
||||||
|
onSortingChange,
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
pageSizeOptions = [5, 10, 20],
|
||||||
|
renderRowActions,
|
||||||
|
rows,
|
||||||
|
searchLabel = "Search rows",
|
||||||
|
searchPlaceholder = "Search rows",
|
||||||
|
searchValue,
|
||||||
|
selection,
|
||||||
|
selectionLabel,
|
||||||
|
selectionActions,
|
||||||
|
sorting,
|
||||||
|
tableLabel = "Data table",
|
||||||
|
toolbarActions,
|
||||||
|
...props
|
||||||
|
}: DataTableProps<TData>,
|
||||||
|
ref: ForwardedRef<HTMLDivElement>
|
||||||
|
) {
|
||||||
|
const selectionEnabled = enableSelection || selection !== undefined || onSelectionChange !== undefined;
|
||||||
|
const searchableColumns = columns.filter(
|
||||||
|
(column) => (column.searchable ?? false) || column.searchValue || column.accessor
|
||||||
|
);
|
||||||
|
|
||||||
|
const [currentSearchValue, setCurrentSearchValue] = useControllableState({
|
||||||
|
controlledValue: searchValue,
|
||||||
|
defaultValue: defaultSearchValue,
|
||||||
|
onChange: onSearchValueChange
|
||||||
|
});
|
||||||
|
const [currentSorting, setCurrentSorting] = useControllableState<DataTableSort[]>({
|
||||||
|
controlledValue: sorting,
|
||||||
|
defaultValue: defaultSorting,
|
||||||
|
onChange: onSortingChange
|
||||||
|
});
|
||||||
|
const [currentSelection, setCurrentSelection] = useControllableState<Record<string, boolean>>({
|
||||||
|
controlledValue: selection,
|
||||||
|
defaultValue: defaultSelection,
|
||||||
|
onChange: onSelectionChange
|
||||||
|
});
|
||||||
|
const [currentPageIndex, setCurrentPageIndex] = useControllableState({
|
||||||
|
controlledValue: pageIndex,
|
||||||
|
defaultValue: defaultPageIndex,
|
||||||
|
onChange: onPageIndexChange
|
||||||
|
});
|
||||||
|
const [currentPageSize, setCurrentPageSize] = useControllableState({
|
||||||
|
controlledValue: pageSize,
|
||||||
|
defaultValue: defaultPageSize,
|
||||||
|
onChange: onPageSizeChange
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolvedPageSizeOptions = [...new Set([...pageSizeOptions, currentPageSize])].sort(
|
||||||
|
(left, right) => left - right
|
||||||
|
);
|
||||||
|
|
||||||
|
const globalFilterFn: FilterFn<TData> = (row, _columnId, filterValue) => {
|
||||||
|
const query = String(filterValue ?? "").trim().toLowerCase();
|
||||||
|
|
||||||
|
if (query.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return searchableColumns.some((column) =>
|
||||||
|
getColumnSearchText(row.original, column).toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tableColumns: ColumnDef<TData>[] = [
|
||||||
|
...(selectionEnabled
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Checkbox
|
||||||
|
aria-label={`Select row ${row.index + 1}`}
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
row.toggleSelected(Boolean(checked));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
enableGlobalFilter: false,
|
||||||
|
enableHiding: false,
|
||||||
|
enableSorting: false,
|
||||||
|
header: ({ table }) => (
|
||||||
|
<Checkbox
|
||||||
|
aria-label="Select all rows"
|
||||||
|
checked={table.getIsAllPageRowsSelected()}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
table.toggleAllPageRowsSelected(Boolean(checked));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
id: "__select",
|
||||||
|
meta: {
|
||||||
|
align: "center",
|
||||||
|
width: 56
|
||||||
|
} satisfies InternalColumnMeta<TData>
|
||||||
|
} satisfies ColumnDef<TData>
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...columns.map((column) => ({
|
||||||
|
accessorFn: column.accessor
|
||||||
|
? (row: TData) => getColumnAccessorValue(row, column)
|
||||||
|
: undefined,
|
||||||
|
cell: ({ row }: CellContext<TData, unknown>) =>
|
||||||
|
column.cell
|
||||||
|
? column.cell(row.original)
|
||||||
|
: stringifySearchValue(getColumnAccessorValue(row.original, column)),
|
||||||
|
enableGlobalFilter: searchableColumns.includes(column),
|
||||||
|
enableSorting: column.sortable ?? false,
|
||||||
|
header: ({ column: tanstackColumn }: HeaderContext<TData, unknown>) =>
|
||||||
|
typeof column.header === "function"
|
||||||
|
? column.header({
|
||||||
|
column,
|
||||||
|
sorted: tanstackColumn.getIsSorted()
|
||||||
|
})
|
||||||
|
: column.header,
|
||||||
|
id: column.id,
|
||||||
|
meta: {
|
||||||
|
align: column.align ?? "start",
|
||||||
|
sourceColumn: column,
|
||||||
|
width: column.width
|
||||||
|
} satisfies InternalColumnMeta<TData>,
|
||||||
|
sortDescFirst: false
|
||||||
|
})),
|
||||||
|
...(renderRowActions
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div {...createSlot("actions")} className="flex items-center justify-end">
|
||||||
|
{renderRowActions(row.original)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableGlobalFilter: false,
|
||||||
|
enableHiding: false,
|
||||||
|
enableSorting: false,
|
||||||
|
header: () => <span className="sr-only">Row actions</span>,
|
||||||
|
id: "__actions",
|
||||||
|
meta: {
|
||||||
|
align: "end",
|
||||||
|
width: 88
|
||||||
|
} satisfies InternalColumnMeta<TData>
|
||||||
|
} satisfies ColumnDef<TData>
|
||||||
|
]
|
||||||
|
: [])
|
||||||
|
];
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/incompatible-library -- TanStack Table is the intentional row-model engine behind this source-owned component.
|
||||||
|
const table = useReactTable({
|
||||||
|
autoResetPageIndex: true,
|
||||||
|
columns: tableColumns,
|
||||||
|
data: rows,
|
||||||
|
enableRowSelection: selectionEnabled,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
getRowId: (row, index) => {
|
||||||
|
if (getRowId) {
|
||||||
|
return getRowId(row, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateId = (row as { id?: unknown }).id;
|
||||||
|
|
||||||
|
if (typeof candidateId === "string" || typeof candidateId === "number") {
|
||||||
|
return String(candidateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(index);
|
||||||
|
},
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
globalFilterFn,
|
||||||
|
onPaginationChange: (updater) => {
|
||||||
|
const nextValue =
|
||||||
|
typeof updater === "function"
|
||||||
|
? updater({
|
||||||
|
pageIndex: currentPageIndex,
|
||||||
|
pageSize: currentPageSize
|
||||||
|
})
|
||||||
|
: updater;
|
||||||
|
|
||||||
|
setCurrentPageIndex(nextValue.pageIndex);
|
||||||
|
setCurrentPageSize(nextValue.pageSize);
|
||||||
|
},
|
||||||
|
onRowSelectionChange: (updater) => {
|
||||||
|
const nextValue =
|
||||||
|
typeof updater === "function"
|
||||||
|
? updater(currentSelection as RowSelectionState)
|
||||||
|
: updater;
|
||||||
|
|
||||||
|
setCurrentSelection(nextValue);
|
||||||
|
},
|
||||||
|
onSortingChange: (updater) => {
|
||||||
|
const nextValue =
|
||||||
|
typeof updater === "function"
|
||||||
|
? updater(currentSorting as SortingState)
|
||||||
|
: updater;
|
||||||
|
|
||||||
|
setCurrentSorting(nextValue.map((item) => ({ desc: item.desc, id: item.id })));
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
globalFilter: currentSearchValue,
|
||||||
|
pagination: {
|
||||||
|
pageIndex: currentPageIndex,
|
||||||
|
pageSize: currentPageSize
|
||||||
|
} satisfies PaginationState,
|
||||||
|
rowSelection: currentSelection,
|
||||||
|
sorting: currentSorting as SortingState
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalColumns = table.getAllLeafColumns().length;
|
||||||
|
const filteredRowCount = table.getFilteredRowModel().rows.length;
|
||||||
|
const selectedRows = table.getSelectedRowModel().rows.map((row) => row.original);
|
||||||
|
const pageCount = table.getPageCount();
|
||||||
|
const isEmpty = !loading && filteredRowCount === 0;
|
||||||
|
const shouldRenderSearch = searchableColumns.length > 0;
|
||||||
|
const shouldRenderToolbar = shouldRenderSearch || toolbarActions !== undefined;
|
||||||
|
const pageStart =
|
||||||
|
filteredRowCount === 0 ? 0 : currentPageIndex * currentPageSize + 1;
|
||||||
|
const pageEnd = Math.min((currentPageIndex + 1) * currentPageSize, filteredRowCount);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const maxPageIndex = Math.max(pageCount - 1, 0);
|
||||||
|
|
||||||
|
if (currentPageIndex > maxPageIndex) {
|
||||||
|
setCurrentPageIndex(maxPageIndex);
|
||||||
|
}
|
||||||
|
}, [currentPageIndex, pageCount, setCurrentPageIndex]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
{...createSlot("root")}
|
||||||
|
{...createDataAttributes({
|
||||||
|
empty: isEmpty,
|
||||||
|
loading,
|
||||||
|
selected: selectedRows.length > 0
|
||||||
|
})}
|
||||||
|
className={cn(dataTableRootVariants(), className)}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
{shouldRenderToolbar ? (
|
||||||
|
<DataTableToolbar>
|
||||||
|
<div className="flex flex-1 flex-wrap items-center gap-3">
|
||||||
|
{shouldRenderSearch ? (
|
||||||
|
<div className={dataTableSearchContainerVariants()}>
|
||||||
|
<DataTableSearch
|
||||||
|
aria-label={searchLabel}
|
||||||
|
onChange={(event) => {
|
||||||
|
setCurrentSearchValue(event.currentTarget.value);
|
||||||
|
setCurrentPageIndex(0);
|
||||||
|
}}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
value={currentSearchValue}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{toolbarActions ? <DataTableFilters>{toolbarActions}</DataTableFilters> : null}
|
||||||
|
</DataTableToolbar>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{selectionEnabled && selectedRows.length > 0 ? (
|
||||||
|
<DataTableSelectionBar>
|
||||||
|
<div className="text-sm font-medium text-[var(--color-foreground)]">
|
||||||
|
{typeof selectionLabel === "function"
|
||||||
|
? selectionLabel(selectedRows)
|
||||||
|
: selectionLabel ?? `${selectedRows.length} selected`}
|
||||||
|
</div>
|
||||||
|
<div {...createSlot("actions")} className="flex flex-wrap items-center gap-2">
|
||||||
|
{selectionActions?.(selectedRows)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
table.resetRowSelection();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear selection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DataTableSelectionBar>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<DataTableContent loading={loading}>
|
||||||
|
{loading ? <DataTableLoading className="sr-only min-h-0 px-0 py-0" /> : null}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<DataTableTable aria-label={tableLabel}>
|
||||||
|
<DataTableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
const meta = header.column.columnDef.meta as InternalColumnMeta<TData> | undefined;
|
||||||
|
const sortState = header.column.getIsSorted();
|
||||||
|
const align: DataTableAlignment = meta?.align ?? "start";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTableHeaderCell
|
||||||
|
key={header.id}
|
||||||
|
align={align}
|
||||||
|
aria-sort={
|
||||||
|
sortState === "asc"
|
||||||
|
? "ascending"
|
||||||
|
: sortState === "desc"
|
||||||
|
? "descending"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
scope="col"
|
||||||
|
sortable={header.column.getCanSort()}
|
||||||
|
sort={sortState}
|
||||||
|
style={getColumnWidthStyle(meta?.width)}
|
||||||
|
>
|
||||||
|
{header.isPlaceholder ? null : header.column.getCanSort() ? (
|
||||||
|
<button
|
||||||
|
className={[
|
||||||
|
"inline-flex w-full items-center gap-2 rounded-[var(--radius-sm)] px-2 py-1.5",
|
||||||
|
"outline-none transition-colors duration-200 hover:bg-[var(--color-surface)]",
|
||||||
|
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)]",
|
||||||
|
align === "end" ? "justify-end" : align === "center" ? "justify-center" : "justify-start"
|
||||||
|
].join(" ")}
|
||||||
|
onClick={header.column.getToggleSortingHandler()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span>{flexRender(header.column.columnDef.header, header.getContext())}</span>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="text-xs text-[var(--color-muted-foreground)]"
|
||||||
|
>
|
||||||
|
{sortState === "asc" ? "↑" : sortState === "desc" ? "↓" : "↕"}
|
||||||
|
</span>
|
||||||
|
<span className="sr-only">
|
||||||
|
Sort by{" "}
|
||||||
|
{meta?.sourceColumn ? getHeaderLabel(meta.sourceColumn) : header.column.id}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
flexRender(header.column.columnDef.header, header.getContext())
|
||||||
|
)}
|
||||||
|
</DataTableHeaderCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</DataTableHeader>
|
||||||
|
|
||||||
|
<DataTableBody>
|
||||||
|
{loading
|
||||||
|
? Array.from({ length: loadingRowCount }, (_, index) => (
|
||||||
|
<DataTableRow
|
||||||
|
interactive={false}
|
||||||
|
key={`loading-row-${index}`}
|
||||||
|
>
|
||||||
|
{Array.from({ length: totalColumns }, (_, cellIndex) => (
|
||||||
|
<DataTableCell key={`loading-cell-${cellIndex}`}>
|
||||||
|
<Skeleton
|
||||||
|
className={cellIndex === totalColumns - 1 ? "ml-auto w-12" : undefined}
|
||||||
|
/>
|
||||||
|
</DataTableCell>
|
||||||
|
))}
|
||||||
|
</DataTableRow>
|
||||||
|
))
|
||||||
|
: isEmpty
|
||||||
|
? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={totalColumns}>
|
||||||
|
{empty ?? (
|
||||||
|
<DataTableEmpty
|
||||||
|
description="Try another search, clear filters, or create a new record to repopulate this view."
|
||||||
|
title="No matching rows"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
: table.getRowModel().rows.map((row) => (
|
||||||
|
<DataTableRow
|
||||||
|
data-selected={row.getIsSelected() ? "" : undefined}
|
||||||
|
key={row.id}
|
||||||
|
selected={row.getIsSelected()}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => {
|
||||||
|
const meta = cell.column.columnDef.meta as InternalColumnMeta<TData> | undefined;
|
||||||
|
const align: DataTableAlignment = meta?.align ?? "start";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTableCell
|
||||||
|
align={align}
|
||||||
|
key={cell.id}
|
||||||
|
style={getColumnWidthStyle(meta?.width)}
|
||||||
|
>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</DataTableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DataTableRow>
|
||||||
|
))}
|
||||||
|
</DataTableBody>
|
||||||
|
</DataTableTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTablePagination>
|
||||||
|
<div className="text-sm text-[var(--color-muted-foreground)]">
|
||||||
|
{pageStart}-{pageEnd} of {filteredRowCount}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{resolvedPageSizeOptions.length > 1 ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-[var(--color-muted-foreground)]">Rows</span>
|
||||||
|
<Select
|
||||||
|
value={String(currentPageSize)}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setCurrentPageSize(Number(value));
|
||||||
|
setCurrentPageIndex(0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger aria-label="Rows per page" className="w-[5.5rem]">
|
||||||
|
<SelectValue placeholder={String(currentPageSize)} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{resolvedPageSizeOptions.map((option) => (
|
||||||
|
<SelectItem key={option} value={String(option)}>
|
||||||
|
{option}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="text-sm text-[var(--color-muted-foreground)]">
|
||||||
|
Page {Math.min(currentPageIndex + 1, Math.max(pageCount, 1))} of {Math.max(pageCount, 1)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
table.previousPage();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
table.nextPage();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DataTablePagination>
|
||||||
|
</DataTableContent>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataTableComponent = <TData>(
|
||||||
|
props: DataTableProps<TData> & { ref?: ForwardedRef<HTMLDivElement> }
|
||||||
|
) => JSX.Element;
|
||||||
|
|
||||||
|
export const DataTable = forwardRef(DataTableInner) as DataTableComponent;
|
||||||
|
|
||||||
|
export type DataTableToolbarProps = ComponentPropsWithoutRef<"div">;
|
||||||
|
|
||||||
|
export const DataTableToolbar = forwardRef<HTMLDivElement, DataTableToolbarProps>(
|
||||||
|
function DataTableToolbar({ className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
{...createSlot("toolbar")}
|
||||||
|
className={cn(dataTableToolbarVariants(), className)}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type DataTableFiltersProps = ComponentPropsWithoutRef<"div">;
|
||||||
|
|
||||||
|
export const DataTableFilters = forwardRef<HTMLDivElement, DataTableFiltersProps>(
|
||||||
|
function DataTableFilters({ className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
{...createSlot("actions")}
|
||||||
|
className={cn("flex flex-wrap items-center gap-2", className)}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type DataTableSearchProps = Omit<InputProps, "size">;
|
||||||
|
|
||||||
|
export const DataTableSearch = forwardRef<HTMLInputElement, DataTableSearchProps>(
|
||||||
|
function DataTableSearch({ className, type = "search", ...props }, ref) {
|
||||||
|
return <Input {...props} className={className} ref={ref} type={type} />;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type DataTableContentProps = ComponentPropsWithoutRef<"div"> &
|
||||||
|
VariantProps<typeof dataTableContentVariants>;
|
||||||
|
|
||||||
|
export const DataTableContent = forwardRef<HTMLDivElement, DataTableContentProps>(
|
||||||
|
function DataTableContent({ className, loading, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
{...createSlot("content")}
|
||||||
|
{...createDataAttributes({ loading })}
|
||||||
|
className={cn(dataTableContentVariants({ loading }), className)}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type DataTableTableProps = ComponentPropsWithoutRef<"table">;
|
||||||
|
|
||||||
|
export const DataTableTable = forwardRef<HTMLTableElement, DataTableTableProps>(
|
||||||
|
function DataTableTable({ className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<table
|
||||||
|
{...props}
|
||||||
|
{...createSlot("table")}
|
||||||
|
className={cn(dataTableTableVariants(), className)}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type DataTableHeaderProps = ComponentPropsWithoutRef<"thead">;
|
||||||
|
|
||||||
|
export const DataTableHeader = forwardRef<HTMLTableSectionElement, DataTableHeaderProps>(
|
||||||
|
function DataTableHeader({ className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
{...props}
|
||||||
|
{...createSlot("header")}
|
||||||
|
className={cn(dataTableHeaderVariants(), className)}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type DataTableHeaderCellProps = Omit<ComponentPropsWithoutRef<"th">, "align"> &
|
||||||
|
VariantProps<typeof dataTableHeaderCellVariants> & {
|
||||||
|
sort?: "asc" | "desc" | false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DataTableHeaderCell = forwardRef<HTMLTableCellElement, DataTableHeaderCellProps>(
|
||||||
|
function DataTableHeaderCell(
|
||||||
|
{ align = "start", className, sort = false, sortable = false, ...props },
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
{...props}
|
||||||
|
{...createSlot("header")}
|
||||||
|
{...createDataAttributes({
|
||||||
|
sort: sort || undefined
|
||||||
|
})}
|
||||||
|
className={cn(dataTableHeaderCellVariants({ align, sortable }), className)}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type DataTableBodyProps = ComponentPropsWithoutRef<"tbody">;
|
||||||
|
|
||||||
|
export const DataTableBody = forwardRef<HTMLTableSectionElement, DataTableBodyProps>(
|
||||||
|
function DataTableBody({ className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
{...props}
|
||||||
|
className={cn(dataTableBodyVariants(), className)}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type DataTableRowProps = ComponentPropsWithoutRef<"tr"> &
|
||||||
|
VariantProps<typeof dataTableRowVariants>;
|
||||||
|
|
||||||
|
export const DataTableRow = forwardRef<HTMLTableRowElement, DataTableRowProps>(
|
||||||
|
function DataTableRow(
|
||||||
|
{ className, interactive = true, selected = false, ...props },
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
{...props}
|
||||||
|
{...createSlot("row")}
|
||||||
|
{...createDataAttributes({
|
||||||
|
selected
|
||||||
|
})}
|
||||||
|
className={cn(dataTableRowVariants({ interactive, selected }), className)}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type DataTableCellProps = Omit<ComponentPropsWithoutRef<"td">, "align"> &
|
||||||
|
VariantProps<typeof dataTableCellVariants>;
|
||||||
|
|
||||||
|
export const DataTableCell = forwardRef<HTMLTableCellElement, DataTableCellProps>(
|
||||||
|
function DataTableCell({ align = "start", className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
{...props}
|
||||||
|
{...createSlot("cell")}
|
||||||
|
className={cn(dataTableCellVariants({ align }), className)}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type DataTableEmptyProps = ComponentPropsWithoutRef<"div"> & {
|
||||||
|
actions?: ReactNode;
|
||||||
|
description?: ReactNode;
|
||||||
|
title?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DataTableEmpty = forwardRef<HTMLDivElement, DataTableEmptyProps>(
|
||||||
|
function DataTableEmpty(
|
||||||
|
{
|
||||||
|
actions,
|
||||||
|
className,
|
||||||
|
description = "No rows match the current search or filter state.",
|
||||||
|
title = "Nothing to show",
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
{...createSlot("empty")}
|
||||||
|
className={cn(dataTableStatusVariants(), className)}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<EmptyState className="w-full border-none bg-transparent p-0 shadow-none">
|
||||||
|
<EmptyStateHeader>
|
||||||
|
<EmptyStateTitle>{title}</EmptyStateTitle>
|
||||||
|
<EmptyStateDescription>{description}</EmptyStateDescription>
|
||||||
|
</EmptyStateHeader>
|
||||||
|
{actions ? <EmptyStateActions>{actions}</EmptyStateActions> : null}
|
||||||
|
</EmptyState>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type DataTableLoadingProps = ComponentPropsWithoutRef<"div"> & {
|
||||||
|
description?: ReactNode;
|
||||||
|
label?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DataTableLoading = forwardRef<HTMLDivElement, DataTableLoadingProps>(
|
||||||
|
function DataTableLoading(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
description = "Preparing the next set of rows and controls.",
|
||||||
|
label = "Loading rows",
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={cn(dataTableStatusVariants(), className)}
|
||||||
|
ref={ref}
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<div className="grid justify-items-center gap-3 text-center">
|
||||||
|
<Spinner aria-label={String(label)} size="lg" tone="primary" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-[var(--color-foreground)]">{label}</p>
|
||||||
|
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type DataTablePaginationProps = ComponentPropsWithoutRef<"div">;
|
||||||
|
|
||||||
|
export const DataTablePagination = forwardRef<HTMLDivElement, DataTablePaginationProps>(
|
||||||
|
function DataTablePagination({ className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
{...createSlot("pagination")}
|
||||||
|
className={cn(dataTablePaginationVariants(), className)}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type DataTableSelectionBarProps = ComponentPropsWithoutRef<"div">;
|
||||||
|
|
||||||
|
export const DataTableSelectionBar = forwardRef<HTMLDivElement, DataTableSelectionBarProps>(
|
||||||
|
function DataTableSelectionBar({ className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={cn(dataTableSelectionBarVariants(), className)}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { cva } from "../lib/cva";
|
||||||
|
import { getMotionRecipeClassNames } from "../lib/motion";
|
||||||
|
|
||||||
|
export const dataTableRootVariants = cva("grid gap-4 text-[var(--color-foreground)]");
|
||||||
|
|
||||||
|
export const dataTableToolbarVariants = cva(
|
||||||
|
"flex flex-wrap items-center justify-between gap-3"
|
||||||
|
);
|
||||||
|
|
||||||
|
export const dataTableContentVariants = cva(
|
||||||
|
[
|
||||||
|
"overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)]",
|
||||||
|
"bg-[var(--color-card)] shadow-[var(--shadow-sm)]",
|
||||||
|
getMotionRecipeClassNames("transition", "ring")
|
||||||
|
],
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
loading: {
|
||||||
|
false: "",
|
||||||
|
true: "opacity-90"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
loading: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const dataTableTableVariants = cva("min-w-full border-collapse align-middle");
|
||||||
|
|
||||||
|
export const dataTableHeaderVariants = cva(
|
||||||
|
"bg-[color-mix(in_oklch,var(--color-surface)_74%,var(--color-card))]"
|
||||||
|
);
|
||||||
|
|
||||||
|
export const dataTableHeaderCellVariants = cva(
|
||||||
|
[
|
||||||
|
"px-4 py-3 text-sm font-medium uppercase tracking-[var(--tracking-caps)]",
|
||||||
|
"text-[var(--color-muted-foreground)]"
|
||||||
|
],
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
align: {
|
||||||
|
start: "text-left",
|
||||||
|
center: "text-center",
|
||||||
|
end: "text-right"
|
||||||
|
},
|
||||||
|
sortable: {
|
||||||
|
false: "",
|
||||||
|
true: "select-none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
align: "start",
|
||||||
|
sortable: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const dataTableBodyVariants = cva("");
|
||||||
|
|
||||||
|
export const dataTableRowVariants = cva(
|
||||||
|
[
|
||||||
|
"border-t border-[color-mix(in_oklch,var(--color-border)_88%,transparent)]",
|
||||||
|
"transition-colors duration-200"
|
||||||
|
],
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
interactive: {
|
||||||
|
false: "",
|
||||||
|
true: "hover:bg-[color-mix(in_oklch,var(--color-surface)_72%,var(--color-card))]"
|
||||||
|
},
|
||||||
|
selected: {
|
||||||
|
false: "",
|
||||||
|
true: "bg-[color-mix(in_oklch,var(--color-primary)_8%,var(--color-card))]"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
interactive: true,
|
||||||
|
selected: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const dataTableCellVariants = cva(
|
||||||
|
"px-4 py-3 text-sm leading-6 text-[var(--color-card-foreground)]",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
align: {
|
||||||
|
start: "text-left",
|
||||||
|
center: "text-center",
|
||||||
|
end: "text-right"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
align: "start"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const dataTableSearchContainerVariants = cva(
|
||||||
|
"w-full max-w-[22rem] min-w-[14rem]"
|
||||||
|
);
|
||||||
|
|
||||||
|
export const dataTablePaginationVariants = cva(
|
||||||
|
"flex flex-wrap items-center justify-between gap-3 px-4 py-3"
|
||||||
|
);
|
||||||
|
|
||||||
|
export const dataTableSelectionBarVariants = cva(
|
||||||
|
[
|
||||||
|
"flex flex-wrap items-center justify-between gap-3 rounded-[var(--radius-md)]",
|
||||||
|
"border border-[color-mix(in_oklch,var(--color-primary)_24%,var(--color-border))]",
|
||||||
|
"bg-[color-mix(in_oklch,var(--color-primary)_7%,var(--color-card))] px-4 py-3",
|
||||||
|
"shadow-[var(--shadow-xs)]"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const dataTableStatusVariants = cva(
|
||||||
|
"grid min-h-52 place-items-center px-6 py-8"
|
||||||
|
);
|
||||||
@@ -53,6 +53,56 @@ export {
|
|||||||
} from "./components/card.variants";
|
} from "./components/card.variants";
|
||||||
export { Checkbox, type CheckboxProps } from "./components/checkbox";
|
export { Checkbox, type CheckboxProps } from "./components/checkbox";
|
||||||
export { checkboxVariants } from "./components/checkbox.variants";
|
export { checkboxVariants } from "./components/checkbox.variants";
|
||||||
|
export {
|
||||||
|
DataTable,
|
||||||
|
DataTableBody,
|
||||||
|
DataTableCell,
|
||||||
|
DataTableContent,
|
||||||
|
DataTableEmpty,
|
||||||
|
DataTableFilters,
|
||||||
|
DataTableHeader,
|
||||||
|
DataTableHeaderCell,
|
||||||
|
DataTableLoading,
|
||||||
|
DataTablePagination,
|
||||||
|
DataTableRow,
|
||||||
|
DataTableSearch,
|
||||||
|
DataTableSelectionBar,
|
||||||
|
DataTableTable,
|
||||||
|
DataTableToolbar,
|
||||||
|
type DataTableAlignment,
|
||||||
|
type DataTableCellProps,
|
||||||
|
type DataTableColumn,
|
||||||
|
type DataTableColumnContext,
|
||||||
|
type DataTableContentProps,
|
||||||
|
type DataTableEmptyProps,
|
||||||
|
type DataTableFiltersProps,
|
||||||
|
type DataTableHeaderCellProps,
|
||||||
|
type DataTableHeaderProps,
|
||||||
|
type DataTableLoadingProps,
|
||||||
|
type DataTablePaginationProps,
|
||||||
|
type DataTableProps,
|
||||||
|
type DataTableRowProps,
|
||||||
|
type DataTableSearchProps,
|
||||||
|
type DataTableSelectionBarProps,
|
||||||
|
type DataTableSort,
|
||||||
|
type DataTableTableProps,
|
||||||
|
type DataTableToolbarProps
|
||||||
|
} from "./components/data-table";
|
||||||
|
export {
|
||||||
|
dataTableBodyVariants,
|
||||||
|
dataTableCellVariants,
|
||||||
|
dataTableContentVariants,
|
||||||
|
dataTableHeaderCellVariants,
|
||||||
|
dataTableHeaderVariants,
|
||||||
|
dataTablePaginationVariants,
|
||||||
|
dataTableRootVariants,
|
||||||
|
dataTableRowVariants,
|
||||||
|
dataTableSearchContainerVariants,
|
||||||
|
dataTableSelectionBarVariants,
|
||||||
|
dataTableStatusVariants,
|
||||||
|
dataTableTableVariants,
|
||||||
|
dataTableToolbarVariants
|
||||||
|
} from "./components/data-table.variants";
|
||||||
export {
|
export {
|
||||||
Combobox,
|
Combobox,
|
||||||
type ComboboxItem,
|
type ComboboxItem,
|
||||||
|
|||||||
Generated
+22
@@ -157,6 +157,9 @@ importers:
|
|||||||
'@radix-ui/react-tooltip':
|
'@radix-ui/react-tooltip':
|
||||||
specifier: ^1.2.8
|
specifier: ^1.2.8
|
||||||
version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
'@tanstack/react-table':
|
||||||
|
specifier: ^8.21.3
|
||||||
|
version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -1721,6 +1724,17 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vite: ^5.2.0 || ^6 || ^7 || ^8
|
vite: ^5.2.0 || ^6 || ^7 || ^8
|
||||||
|
|
||||||
|
'@tanstack/react-table@8.21.3':
|
||||||
|
resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8'
|
||||||
|
react-dom: '>=16.8'
|
||||||
|
|
||||||
|
'@tanstack/table-core@8.21.3':
|
||||||
|
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
'@testing-library/dom@10.4.0':
|
'@testing-library/dom@10.4.0':
|
||||||
resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==}
|
resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -5144,6 +5158,14 @@ snapshots:
|
|||||||
tailwindcss: 4.2.2
|
tailwindcss: 4.2.2
|
||||||
vite: 6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)
|
vite: 6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)
|
||||||
|
|
||||||
|
'@tanstack/react-table@8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/table-core': 8.21.3
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
|
||||||
|
'@tanstack/table-core@8.21.3': {}
|
||||||
|
|
||||||
'@testing-library/dom@10.4.0':
|
'@testing-library/dom@10.4.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.0
|
'@babel/code-frame': 7.29.0
|
||||||
|
|||||||
Reference in New Issue
Block a user