Merge pull request #32856 from BerriAI/litellm_/data-table-design-exploration-f3f5e9

feat(ui): add filter drawer, column visibility, and search to the shared DataTable
This commit is contained in:
yuneng-jiang 2026-07-11 10:27:41 -07:00 • committed by GitHub
commit 80c5217ddc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 977 additions and 273 deletions

View file

@ -1,7 +1,7 @@
{
"@typescript-eslint/no-explicit-any": 1978,
"complexity": 130,
"local/no-large-inline-object-arg": 509,
"@typescript-eslint/no-explicit-any": 1977,
"complexity": 129,
"local/no-large-inline-object-arg": 508,
"local/no-long-condition-chain": 234,
"max-depth": 59,
"no-console": 16

View file

@ -1,9 +1,16 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Button, Collapse, Drawer, Empty, Spin, Tooltip, Typography } from "antd";
import { ReloadOutlined } from "@ant-design/icons";
import type { ColumnDef } from "@tanstack/react-table";
import type { ColumnDef, ColumnFiltersState } from "@tanstack/react-table";
import { proxyBaseUrl } from "@/components/networking";
import { DataTable } from "@/components/shared/DataTable";
import {
DataTable,
DataTableFilterDrawer,
DataTableFilterField,
DataTableToolbar,
} from "@/components/shared/DataTable";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
const { Text } = Typography;
@ -59,6 +66,15 @@ const STATUS_DOT: Record<RunStatus, string> = {
failed: "#ef4444",
};
const RUN_STATUS_OPTIONS: RunStatus[] = ["pending", "running", "paused", "completed", "failed"];
const STATUS_LABELS: Record<RunStatus, string> = {
pending: "Pending",
running: "Running",
paused: "Paused",
completed: "Completed",
failed: "Failed",
};
const EVENT_COLOR: Record<string, { bar: string; border: string; text: string }> = {
"step.started": { bar: "#f0fdf4", border: "#86efac", text: "#16a34a" },
"step.failed": { bar: "#fef2f2", border: "#fca5a5", text: "#dc2626" },
@ -482,6 +498,9 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
const [messages, setMessages] = useState<WorkflowRunMessage[]>([]);
const [loadingDetail, setLoadingDetail] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState("");
const [filtersOpen, setFiltersOpen] = useState(false);
const fetchRuns = useCallback(async () => {
if (!accessToken) return;
@ -547,7 +566,9 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
() => [
{
id: "run",
accessorFn: (row) => `${runTitle(row)} ${row.run_id}`,
header: "Run",
meta: { title: "Run", skeleton: "twoLine" },
cell: ({ row }) => {
const run = row.original;
return (
@ -564,13 +585,18 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
{
accessorKey: "workflow_type",
header: "Type",
meta: { title: "Type" },
filterFn: "includesString",
cell: ({ row }) => (
<span style={{ fontFamily: "monospace", fontSize: 12, color: "#71717a" }}>{row.original.workflow_type}</span>
),
},
{
id: "status",
accessorKey: "status",
header: "Status",
meta: { title: "Status" },
filterFn: "equalsString",
cell: ({ row }) => {
const run = row.original;
return (
@ -586,6 +612,7 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
{
accessorKey: "created_at",
header: "Created",
meta: { title: "Created" },
cell: ({ row }) => <span style={{ fontSize: 12, color: "#a1a1aa" }}>{timeAgo(row.original.created_at)}</span>,
},
],
@ -603,28 +630,11 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
}}
>
{/* page header */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 20,
}}
>
<div>
<div style={{ fontSize: 18, fontWeight: 600, color: "#18181b" }}>Workflow Runs</div>
<div style={{ fontSize: 13, color: "#71717a", marginTop: 2 }}>
Durable state tracking for agents and automated workflows
</div>
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 18, fontWeight: 600, color: "#18181b" }}>Workflow Runs</div>
<div style={{ fontSize: 13, color: "#71717a", marginTop: 2 }}>
Durable state tracking for agents and automated workflows
</div>
<Button
icon={<ReloadOutlined />}
onClick={fetchRuns}
loading={loadingRuns}
style={{ color: "#71717a", borderColor: "#e4e4e7" }}
>
Refresh
</Button>
</div>
<DataTable
@ -641,8 +651,64 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
}
paginationMode="client"
pageSizeOptions={[50, 100]}
filterMode="client"
columnFilters={columnFilters}
onColumnFiltersChange={setColumnFilters}
globalFilter={globalFilter}
onGlobalFilterChange={setGlobalFilter}
onRowClick={fetchRunDetail}
size="compact"
toolbar={(table) => (
<>
<DataTableToolbar
table={table}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Search runs…"
onRefresh={fetchRuns}
isRefreshing={loadingRuns}
onOpenFilters={() => setFiltersOpen(true)}
/>
<DataTableFilterDrawer
table={table}
open={filtersOpen}
onOpenChange={setFiltersOpen}
title="Filters"
description="Narrow down workflow runs"
>
{({ get, set }) => (
<>
<DataTableFilterField label="Status">
<Select
items={STATUS_LABELS}
value={(get("status") as string) || null}
onValueChange={(value: string | null) => set("status", value ?? "")}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value={null}>All statuses</SelectItem>
{RUN_STATUS_OPTIONS.map((status) => (
<SelectItem key={status} value={status}>
{STATUS_LABELS[status]}
</SelectItem>
))}
</SelectContent>
</Select>
</DataTableFilterField>
<DataTableFilterField label="Type">
<Input
value={(get("workflow_type") as string) ?? ""}
onChange={(event) => set("workflow_type", event.target.value)}
placeholder="Filter by type…"
/>
</DataTableFilterField>
</>
)}
</DataTableFilterDrawer>
</>
)}
/>
{/* detail drawer */}

View file

@ -29,6 +29,16 @@ const nameCellColumns: ColumnDef<Person, unknown>[] = [
},
];
const filterableColumns: ColumnDef<Person, unknown>[] = [
{
accessorKey: "name",
header: "Name",
meta: { title: "Name" },
filterFn: (row, columnId, value) => row.getValue<string>(columnId) === value,
cell: ({ row }) => <span data-testid="name-cell">{row.original.name}</span>,
},
];
const headerCycleColumns: ColumnDef<Person, unknown>[] = [
{
accessorKey: "name",
@ -195,10 +205,110 @@ describe("DataTable pagination", () => {
});
});
describe("DataTable filtering", () => {
it("client mode filters rows by columnFilters", () => {
const { rerender } = render(
<DataTable
data={CHARLIE_ALICE_BOB}
columns={filterableColumns}
filterMode="client"
columnFilters={[]}
onColumnFiltersChange={vi.fn()}
/>,
);
expect(names()).toEqual(["Charlie", "Alice", "Bob"]);
rerender(
<DataTable
data={CHARLIE_ALICE_BOB}
columns={filterableColumns}
filterMode="client"
columnFilters={[{ id: "name", value: "Alice" }]}
onColumnFiltersChange={vi.fn()}
/>,
);
expect(names()).toEqual(["Alice"]);
});
it("client global filter matches substrings across columns", () => {
const { rerender } = render(
<DataTable
data={CHARLIE_ALICE_BOB}
columns={nameEmailColumns}
filterMode="client"
globalFilter=""
onGlobalFilterChange={vi.fn()}
/>,
);
expect(names()).toEqual(["Charlie", "Alice", "Bob"]);
rerender(
<DataTable
data={CHARLIE_ALICE_BOB}
columns={nameEmailColumns}
filterMode="client"
globalFilter="ali"
onGlobalFilterChange={vi.fn()}
/>,
);
expect(names()).toEqual(["Alice"]);
});
it("server mode never filters locally even when columnFilters is set", () => {
render(
<DataTable
data={CHARLIE_ALICE_BOB}
columns={filterableColumns}
filterMode="server"
columnFilters={[{ id: "name", value: "Alice" }]}
onColumnFiltersChange={vi.fn()}
/>,
);
expect(names()).toEqual(["Charlie", "Alice", "Bob"]);
});
it("throws when server filtering is missing required props", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => render(<DataTable data={[]} columns={filterableColumns} filterMode="server" />)).toThrow(
/filterMode='server'/,
);
spy.mockRestore();
});
});
describe("DataTable loading", () => {
it("renders skeleton rows while loading and real rows once loaded", () => {
const { rerender } = render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameCellColumns} isLoading />);
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
expect(screen.queryByTestId("name-cell")).toBeNull();
rerender(<DataTable data={CHARLIE_ALICE_BOB} columns={nameCellColumns} />);
expect(screen.queryAllByTestId("skeleton-row")).toHaveLength(0);
expect(names()).toEqual(["Charlie", "Alice", "Bob"]);
});
it("varies skeleton shape and width per column instead of one fixed bar", () => {
const columns: ColumnDef<Person, unknown>[] = [
{ accessorKey: "name", header: "Name", meta: { skeleton: "twoLine" }, cell: () => null },
{ accessorKey: "email", header: "Email", cell: () => null },
];
render(<DataTable data={CHARLIE_ALICE_BOB} columns={columns} isLoading />);
const firstRow = screen.getAllByTestId("skeleton-row").at(0);
expect(firstRow).toBeDefined();
const bars = Array.from(firstRow?.querySelectorAll('[data-slot="skeleton"]') ?? []);
// twoLine column contributes a main + sub bar (2); the text column contributes 1
expect(bars).toHaveLength(3);
// per-column widths differ instead of every cell sharing one fixed width
expect(new Set(bars.map((bar) => bar.className)).size).toBeGreaterThan(1);
});
});
describe("DataTable column visibility", () => {
it("hides a column when toggled off in the view-options menu", async () => {
const user = userEvent.setup();
render(
const { container } = render(
<DataTable
data={CHARLIE_ALICE_BOB}
columns={nameEmailColumns}
@ -206,13 +316,13 @@ describe("DataTable column visibility", () => {
/>,
);
expect(screen.getByText("Email")).toBeInTheDocument();
expect(container.querySelector('th[data-header-id="email"]')).not.toBeNull();
await user.click(screen.getByTestId("view-options-trigger"));
await user.click(await screen.findByTestId("view-option-email"));
await waitFor(() => expect(screen.queryByText("Email")).not.toBeInTheDocument());
await waitFor(() => expect(container.querySelector('th[data-header-id="email"]')).toBeNull());
await user.click(screen.getByTestId("view-option-email"));
await waitFor(() => expect(screen.getByText("Email")).toBeInTheDocument());
await waitFor(() => expect(container.querySelector('th[data-header-id="email"]')).not.toBeNull());
});
it("omits columns that opt out of hiding from the menu", async () => {

View file

@ -4,12 +4,14 @@ import {
type Cell,
type Column,
type ColumnDef,
type ColumnFiltersState,
type ColumnPinningState,
type ColumnSizingState,
type ExpandedState,
flexRender,
getCoreRowModel,
getExpandedRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type Header,
@ -21,9 +23,11 @@ import {
useReactTable,
type VisibilityState,
} from "@tanstack/react-table";
import { SearchX } from "lucide-react";
import * as React from "react";
import { Fragment, useState } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table as TableRoot,
TableBody,
@ -37,7 +41,7 @@ import { cn } from "@/lib/cva.config";
import "./columnMeta";
import { DataTablePagination, DEFAULT_PAGE_SIZE_OPTIONS } from "./DataTablePagination";
import type { ColumnPinnedSide, DataTableProps, DataTableSize, PaginationMode, SortingMode } from "./types";
import type { ColumnPinnedSide, DataTableProps, DataTableSize, FilterMode, PaginationMode, SortingMode } from "./types";
const INTERACTIVE_SELECTOR = "button, a, input, select, textarea, [role=checkbox], [data-row-click-exempt]";
@ -60,14 +64,22 @@ export function validateDataTableConfig<TData extends RowData, TValue>(
props.pagination === undefined || props.onPaginationChange === undefined || props.rowCount === undefined;
const serverPaginationIncomplete = props.paginationMode === "server" && serverPaginationPropsMissing;
const serverFilteringIncomplete =
props.filterMode === "server" && (props.columnFilters === undefined || props.onColumnFiltersChange === undefined);
const bothSortingSources = props.defaultSorting !== undefined && props.sorting !== undefined;
const bothFilterSources = props.defaultColumnFilters !== undefined && props.columnFilters !== undefined;
return [
serverSortingIncomplete ? "sortingMode='server' requires both `sorting` and `onSortingChange`." : null,
serverPaginationIncomplete
? "paginationMode='server' requires `pagination`, `onPaginationChange`, and `rowCount`."
: null,
serverFilteringIncomplete ? "filterMode='server' requires both `columnFilters` and `onColumnFiltersChange`." : null,
bothSortingSources ? "Provide either `defaultSorting` (uncontrolled) or `sorting` (controlled), not both." : null,
bothFilterSources
? "Provide either `defaultColumnFilters` (uncontrolled) or `columnFilters` (controlled), not both."
: null,
].filter((message): message is string => message !== null);
}
@ -93,9 +105,11 @@ function derivePinning<TData, TValue>(columns: ColumnDef<TData, TValue>[]): Colu
function buildRowModels<TData>(
sortingMode: SortingMode,
paginationMode: PaginationMode,
filterMode: FilterMode,
getRowCanExpand: ((row: Row<TData>) => boolean) | undefined,
): Partial<TableOptions<TData>> {
return {
...(filterMode === "client" ? { getFilteredRowModel: getFilteredRowModel() } : {}),
...(sortingMode === "client" ? { getSortedRowModel: getSortedRowModel() } : {}),
...(paginationMode === "client" ? { getPaginationRowModel: getPaginationRowModel() } : {}),
...(getRowCanExpand !== undefined ? { getRowCanExpand, getExpandedRowModel: getExpandedRowModel() } : {}),
@ -307,6 +321,65 @@ function MessageRow({ colSpan, children }: { colSpan: number; children: React.Re
);
}
function DefaultEmptyState() {
return (
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<SearchX className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">No results</div>
<div className="text-sm text-muted-foreground">No rows match your search or filters.</div>
</div>
);
}
const SKELETON_WIDTHS = ["w-[58%]", "w-[44%]", "w-[70%]", "w-[50%]", "w-[64%]", "w-[48%]"] as const;
function SkeletonCell<TData>({ column, index }: { column: Column<TData, unknown> | undefined; index: number }) {
const meta = column?.columnDef.meta;
const width = SKELETON_WIDTHS[index % SKELETON_WIDTHS.length];
if (meta?.skeleton === "twoLine") {
return (
<div className="flex flex-col gap-2">
<Skeleton className={cn("h-3.5", width)} />
<Skeleton className="h-2.5 w-2/5 opacity-65" />
</div>
);
}
return <Skeleton className={cn("h-3.5", width, meta?.numeric ? "ml-auto" : "")} />;
}
function SkeletonRows<TData>({
rowCount,
columns,
size,
message,
}: {
rowCount: number;
columns: readonly Column<TData, unknown>[];
size: DataTableSize;
message?: string;
}) {
const rowKeys = Array.from({ length: Math.max(rowCount, 1) }, (_, index) => index);
const cells = columns.length > 0 ? columns : [undefined];
return (
<Fragment>
{rowKeys.map((rowKey) => (
<TableRow key={`skeleton-${rowKey}`} className="hover:bg-transparent" data-testid="skeleton-row">
{cells.map((column, columnKey) => (
<TableCell key={column?.id ?? columnKey} className={size === "compact" ? "px-2 py-1" : ""}>
<SkeletonCell column={column} index={columnKey} />
{rowKey === 0 && columnKey === 0 && message !== undefined ? (
<span className="sr-only">{message}</span>
) : null}
</TableCell>
))}
</TableRow>
))}
</Fragment>
);
}
function useControllable<T>(
controlled: T | undefined,
controlledOnChange: OnChangeFn<T> | undefined,
@ -334,6 +407,12 @@ function useDataTableInstance<TData extends RowData, TValue>(props: DataTablePro
onPaginationChange,
rowCount,
pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
filterMode = "none",
columnFilters,
onColumnFiltersChange,
defaultColumnFilters,
globalFilter,
onGlobalFilterChange,
enableColumnResizing = false,
columnResizeMode = "onEnd",
defaultColumnVisibility,
@ -348,6 +427,12 @@ function useDataTableInstance<TData extends RowData, TValue>(props: DataTablePro
pageIndex: 0,
pageSize: pageSizeOptions[0] ?? 25,
});
const filterState = useControllable<ColumnFiltersState>(
columnFilters,
onColumnFiltersChange,
defaultColumnFilters ?? [],
);
const globalFilterState = useControllable<string>(globalFilter, onGlobalFilterChange, "");
const expandedState = useControllable<ExpandedState>(expanded, onExpandedChange, {});
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(defaultColumnVisibility ?? {});
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>({});
@ -360,6 +445,8 @@ function useDataTableInstance<TData extends RowData, TValue>(props: DataTablePro
state: {
sorting: sortingState.value,
pagination: paginationState.value,
columnFilters: filterState.value,
globalFilter: globalFilterState.value,
expanded: expandedState.value,
columnVisibility,
columnSizing,
@ -367,16 +454,19 @@ function useDataTableInstance<TData extends RowData, TValue>(props: DataTablePro
initialState: { columnPinning },
manualSorting: sortingMode === "server",
manualPagination: paginationMode === "server",
manualFiltering: filterMode === "server",
enableSortingRemoval,
enableColumnResizing,
columnResizeMode,
onSortingChange: sortingState.onChange,
onPaginationChange: paginationState.onChange,
onColumnFiltersChange: filterState.onChange,
onGlobalFilterChange: globalFilterState.onChange,
onExpandedChange: expandedState.onChange,
onColumnVisibilityChange: setColumnVisibility,
onColumnSizingChange: setColumnSizing,
getCoreRowModel: getCoreRowModel(),
...buildRowModels(sortingMode, paginationMode, expansionGuard),
...buildRowModels(sortingMode, paginationMode, filterMode, expansionGuard),
...(getRowId !== undefined ? { getRowId } : {}),
...(paginationMode === "server" && rowCount !== undefined ? { rowCount } : {}),
};
@ -397,7 +487,8 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
const {
isLoading = false,
loadingMessage = "Loading…",
noDataMessage = "No results",
skeletonRowCount = 8,
noDataMessage,
paginationMode = "none",
rowCount,
pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
@ -443,10 +534,17 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
const renderBody = (): React.ReactNode => {
if (isLoading) {
return <MessageRow colSpan={visibleColumnCount}>{loadingMessage}</MessageRow>;
return (
<SkeletonRows
rowCount={skeletonRowCount}
columns={table.getVisibleLeafColumns()}
size={size}
message={loadingMessage}
/>
);
}
if (rows.length === 0) {
return <MessageRow colSpan={visibleColumnCount}>{noDataMessage}</MessageRow>;
return <MessageRow colSpan={visibleColumnCount}>{noDataMessage ?? <DefaultEmptyState />}</MessageRow>;
}
return rows.map((row) => (
<DataTableBodyRow
@ -462,34 +560,38 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
));
};
const paginationNode = renderPagination();
return (
<div className="w-full">
{toolbar !== undefined && <div className="w-full">{toolbar(table)}</div>}
<div
className={cn("rounded-lg border border-border", stickyHeader ? "overflow-auto" : "overflow-x-auto")}
style={stickyHeader ? { maxHeight: maxBodyHeight } : undefined}
>
<TableRoot className={enableColumnResizing ? "table-fixed" : ""} style={tableStyle}>
<TableHeader className={stickyHeader ? "sticky top-0 z-20" : ""}>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="bg-muted/50 hover:bg-muted/50">
{headerGroup.headers.map((header) => (
<DataTableHeadCell
key={header.id}
header={header}
size={size}
stickyHeader={stickyHeader}
enableColumnResizing={enableColumnResizing}
/>
))}
</TableRow>
))}
</TableHeader>
<TableBody>{renderBody()}</TableBody>
{footer !== undefined && <TableFooter>{footer(table)}</TableFooter>}
</TableRoot>
<div className="overflow-hidden rounded-lg border border-border">
{toolbar !== undefined && <div className="border-b border-border px-4 py-3">{toolbar(table)}</div>}
<div
className={stickyHeader ? "overflow-auto" : "overflow-x-auto"}
style={stickyHeader ? { maxHeight: maxBodyHeight } : undefined}
>
<TableRoot className={enableColumnResizing ? "table-fixed" : ""} style={tableStyle}>
<TableHeader className={stickyHeader ? "sticky top-0 z-20" : ""}>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="bg-muted/50 hover:bg-muted/50">
{headerGroup.headers.map((header) => (
<DataTableHeadCell
key={header.id}
header={header}
size={size}
stickyHeader={stickyHeader}
enableColumnResizing={enableColumnResizing}
/>
))}
</TableRow>
))}
</TableHeader>
<TableBody>{renderBody()}</TableBody>
{footer !== undefined && <TableFooter>{footer(table)}</TableFooter>}
</TableRoot>
</div>
{paginationNode !== null && <div className="border-t border-border">{paginationNode}</div>}
</div>
{renderPagination()}
</div>
);
}

View file

@ -0,0 +1,98 @@
import type { ColumnDef, ColumnFiltersState } from "@tanstack/react-table";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it } from "vitest";
import { DataTable } from "./DataTable";
import { DataTableFilterDrawer } from "./DataTableFilterDrawer";
import { DataTableToolbar } from "./DataTableToolbar";
interface Person {
id: string;
name: string;
}
const DATA: Person[] = [
{ id: "a", name: "Alice" },
{ id: "b", name: "Bob" },
{ id: "c", name: "Carol" },
];
const columns: ColumnDef<Person, unknown>[] = [
{
accessorKey: "name",
header: "Name",
meta: { title: "Name" },
filterFn: (row, columnId, value) => row.getValue<string>(columnId) === value,
cell: ({ row }) => <span data-testid="name-cell">{row.original.name}</span>,
},
];
const names = (): (string | null)[] => screen.getAllByTestId("name-cell").map((el) => el.textContent);
function Harness({ initialFilters }: { initialFilters?: ColumnFiltersState }) {
const [open, setOpen] = useState(false);
return (
<DataTable
data={DATA}
columns={columns}
filterMode="client"
defaultColumnFilters={initialFilters}
toolbar={(table) => (
<>
<DataTableToolbar table={table} onOpenFilters={() => setOpen(true)} />
<DataTableFilterDrawer table={table} open={open} onOpenChange={setOpen} title="Filters">
{({ get, set }) => (
<input
aria-label="name filter"
data-testid="draft-name"
value={(get("name") as string | undefined) ?? ""}
onChange={(event) => set("name", event.target.value)}
/>
)}
</DataTableFilterDrawer>
</>
)}
/>
);
}
describe("DataTableFilterDrawer", () => {
it("stages edits and only commits them to the table on Apply", async () => {
const user = userEvent.setup();
render(<Harness />);
expect(names()).toEqual(["Alice", "Bob", "Carol"]);
await user.click(screen.getByTestId("datatable-filters-trigger"));
await user.type(await screen.findByTestId("draft-name"), "Bob");
expect(names()).toEqual(["Alice", "Bob", "Carol"]);
expect(screen.queryByTestId("filter-chip-name")).toBeNull();
await user.click(screen.getByTestId("filter-drawer-apply"));
expect(names()).toEqual(["Bob"]);
expect(screen.getByTestId("filter-chip-name")).toHaveTextContent("Bob");
});
it("seeds the draft from committed filters when opened", async () => {
const user = userEvent.setup();
render(<Harness initialFilters={[{ id: "name", value: "Bob" }]} />);
expect(names()).toEqual(["Bob"]);
await user.click(screen.getByTestId("datatable-filters-trigger"));
expect(await screen.findByTestId("draft-name")).toHaveValue("Bob");
});
it("reset clears the committed filters and the draft", async () => {
const user = userEvent.setup();
render(<Harness initialFilters={[{ id: "name", value: "Bob" }]} />);
await user.click(screen.getByTestId("datatable-filters-trigger"));
await user.click(await screen.findByTestId("filter-drawer-reset"));
expect(names()).toEqual(["Alice", "Bob", "Carol"]);
expect(screen.queryByTestId("filter-chip-name")).toBeNull();
expect(screen.getByTestId("draft-name")).toHaveValue("");
});
});

View file

@ -0,0 +1,108 @@
"use client";
import type { ColumnFiltersState, Table } from "@tanstack/react-table";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
export interface FilterDraft {
get: (columnId: string) => unknown;
set: (columnId: string, value: unknown) => void;
}
interface DataTableFilterDrawerProps<TData> {
table: Table<TData>;
open: boolean;
onOpenChange: (open: boolean) => void;
title?: string;
description?: React.ReactNode;
applyLabel?: string;
resetLabel?: string;
children: (draft: FilterDraft) => React.ReactNode;
}
function isEmpty(value: unknown): boolean {
if (Array.isArray(value)) {
return value.length === 0;
}
return value === undefined || value === null || value === "";
}
function toDraft(filters: ColumnFiltersState): Record<string, unknown> {
return Object.fromEntries(filters.map((filter) => [filter.id, filter.value]));
}
function toFilters(draft: Record<string, unknown>): ColumnFiltersState {
return Object.entries(draft)
.filter(([, value]) => !isEmpty(value))
.map(([id, value]) => ({ id, value }));
}
export function DataTableFilterDrawer<TData>({
table,
open,
onOpenChange,
title = "Filters",
description,
applyLabel = "Apply Filters",
resetLabel = "Reset",
children,
}: DataTableFilterDrawerProps<TData>) {
const [draft, setDraft] = React.useState<Record<string, unknown>>(() => toDraft(table.getState().columnFilters));
const [wasOpen, setWasOpen] = React.useState(open);
if (open !== wasOpen) {
setWasOpen(open);
if (open) {
setDraft(toDraft(table.getState().columnFilters));
}
}
const helpers: FilterDraft = {
get: (columnId) => draft[columnId],
set: (columnId, value) => setDraft((previous) => ({ ...previous, [columnId]: value })),
};
const apply = () => {
table.setColumnFilters(toFilters(draft));
onOpenChange(false);
};
const reset = () => {
setDraft({});
table.setColumnFilters([]);
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
{description !== undefined && <SheetDescription>{description}</SheetDescription>}
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4" data-testid="filter-drawer-body">
{children(helpers)}
</div>
<SheetFooter className="flex-row">
<Button variant="outline" className="flex-1" onClick={reset} data-testid="filter-drawer-reset">
{resetLabel}
</Button>
<Button className="flex-1" onClick={apply} data-testid="filter-drawer-apply">
{applyLabel}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
export function DataTableFilterField({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<Label>{label}</Label>
{children}
</div>
);
}

View file

@ -37,7 +37,7 @@ export function DataTablePagination({
const lastPage = Math.max(pageCount - 1, 0);
return (
<div className={cn("flex flex-wrap items-center justify-between gap-4 px-2 py-2", className)}>
<div className={cn("flex flex-wrap items-center justify-between gap-4 px-4 py-2.5", className)}>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Rows per page</span>
<Select
@ -65,6 +65,9 @@ export function DataTablePagination({
<span data-testid="pagination-range" className="text-sm text-muted-foreground tabular-nums">
{rowCount === 0 ? "No results" : `Showing ${start}-${end} of ${rowCount}`}
</span>
<span data-testid="pagination-page" className="text-sm text-muted-foreground tabular-nums">
Page {page + 1} of {Math.max(pageCount, 1)}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"

View file

@ -1,35 +1,106 @@
import type { ColumnDef } from "@tanstack/react-table";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type * as React from "react";
import { describe, expect, it, vi } from "vitest";
import { DataTable } from "./DataTable";
import { DataTableToolbar } from "./DataTableToolbar";
interface Person {
id: string;
name: string;
}
const DATA: Person[] = [
{ id: "a", name: "Alice" },
{ id: "b", name: "Bob" },
];
const columns: ColumnDef<Person, unknown>[] = [
{
accessorKey: "name",
header: "Name",
meta: { title: "Name" },
filterFn: (row, columnId, value) => row.getValue<string>(columnId) === value,
cell: ({ row }) => <span data-testid="name-cell">{row.original.name}</span>,
},
];
const names = (): (string | null)[] => screen.getAllByTestId("name-cell").map((el) => el.textContent);
function Harness({
onOpenFilters,
onRefresh,
children,
}: {
onOpenFilters?: () => void;
onRefresh?: () => void;
children?: React.ReactNode;
}) {
return (
<DataTable
data={DATA}
columns={columns}
filterMode="client"
defaultColumnFilters={[{ id: "name", value: "Alice" }]}
toolbar={(table) => (
<DataTableToolbar table={table} onOpenFilters={onOpenFilters} onRefresh={onRefresh}>
{children}
</DataTableToolbar>
)}
/>
);
}
describe("DataTableToolbar", () => {
it("renders a chip for each active filter with its label and value", () => {
render(<Harness />);
expect(names()).toEqual(["Alice"]);
const chip = screen.getByTestId("filter-chip-name");
expect(chip).toHaveTextContent("Name:");
expect(chip).toHaveTextContent("Alice");
});
it("removes a single filter when its chip remove button is clicked", async () => {
const user = userEvent.setup();
render(<Harness />);
await user.click(screen.getByTestId("filter-chip-remove-name"));
expect(screen.queryByTestId("filter-chip-name")).toBeNull();
expect(names()).toEqual(["Alice", "Bob"]);
});
it("clears every filter via Clear all", async () => {
const user = userEvent.setup();
render(<Harness />);
await user.click(screen.getByTestId("datatable-clear-filters"));
expect(screen.queryByTestId("filter-chip-name")).toBeNull();
expect(names()).toEqual(["Alice", "Bob"]);
});
it("shows the active filter count and fires onOpenFilters", async () => {
const user = userEvent.setup();
const onOpenFilters = vi.fn();
render(<Harness onOpenFilters={onOpenFilters} />);
expect(screen.getByTestId("datatable-filter-count")).toHaveTextContent("1");
await user.click(screen.getByTestId("datatable-filters-trigger"));
expect(onOpenFilters).toHaveBeenCalledTimes(1);
});
it("renders slotted action children", () => {
render(
<DataTableToolbar>
<Harness>
<button data-testid="toolbar-action">Action</button>
</DataTableToolbar>,
</Harness>,
);
expect(screen.getByTestId("toolbar-action")).toBeInTheDocument();
});
it("shows the reset button only when there are active filters", async () => {
it("fires onRefresh when the refresh button is clicked", async () => {
const user = userEvent.setup();
const onResetFilters = vi.fn();
const { rerender } = render(<DataTableToolbar onResetFilters={onResetFilters} hasActiveFilters={false} />);
expect(screen.queryByText("Reset Filters")).toBeNull();
rerender(<DataTableToolbar onResetFilters={onResetFilters} hasActiveFilters />);
await user.click(screen.getByText("Reset Filters"));
expect(onResetFilters).toHaveBeenCalledTimes(1);
});
it("wires the filters toggle button", async () => {
const user = userEvent.setup();
const onToggleFilters = vi.fn();
render(<DataTableToolbar onToggleFilters={onToggleFilters} />);
await user.click(screen.getByText("Filters"));
expect(onToggleFilters).toHaveBeenCalledTimes(1);
const onRefresh = vi.fn();
render(<Harness onRefresh={onRefresh} />);
await user.click(screen.getByTestId("datatable-refresh"));
expect(onRefresh).toHaveBeenCalledTimes(1);
});
});

View file

@ -1,55 +1,128 @@
"use client";
import { Search } from "lucide-react";
import type { Table } from "@tanstack/react-table";
import { RefreshCw, Search, SlidersHorizontal, X } from "lucide-react";
import type * as React from "react";
import { FilterInput } from "@/components/common_components/Filters/FilterInput";
import { FiltersButton } from "@/components/common_components/Filters/FiltersButton";
import { ResetFiltersButton } from "@/components/common_components/Filters/ResetFiltersButton";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/cva.config";
interface DataTableToolbarProps {
import { DataTableViewOptions } from "./DataTableViewOptions";
interface DataTableToolbarProps<TData> {
table: Table<TData>;
searchValue?: string;
onSearchChange?: (value: string) => void;
searchPlaceholder?: string;
filtersActive?: boolean;
hasActiveFilters?: boolean;
onToggleFilters?: () => void;
onResetFilters?: () => void;
onOpenFilters?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
filterLabels?: Record<string, string>;
formatFilterValue?: (columnId: string, value: unknown) => string;
showViewOptions?: boolean;
children?: React.ReactNode;
className?: string;
}
export function DataTableToolbar({
function defaultFormatValue(value: unknown): string {
if (Array.isArray(value)) {
return value.join(", ");
}
return String(value);
}
export function DataTableToolbar<TData>({
table,
searchValue,
onSearchChange,
searchPlaceholder = "Search",
filtersActive = false,
hasActiveFilters = false,
onToggleFilters,
onResetFilters,
onOpenFilters,
onRefresh,
isRefreshing = false,
filterLabels,
formatFilterValue,
showViewOptions = true,
children,
className,
}: DataTableToolbarProps) {
const showReset = onResetFilters !== undefined && hasActiveFilters;
}: DataTableToolbarProps<TData>) {
const filters = table.getState().columnFilters;
const labelFor = (columnId: string): string =>
filterLabels?.[columnId] ?? table.getColumn(columnId)?.columnDef.meta?.title ?? columnId;
const valueFor = (columnId: string, value: unknown): string =>
formatFilterValue?.(columnId, value) ?? defaultFormatValue(value);
return (
<div className={cn("flex flex-wrap items-center justify-between gap-2 pb-3", className)}>
<div className="flex flex-wrap items-center gap-2">
<div className={cn("flex flex-wrap items-center justify-between gap-2", className)}>
<div className="flex flex-1 flex-wrap items-center gap-2">
{onSearchChange !== undefined && (
<FilterInput
value={searchValue ?? ""}
onChange={onSearchChange}
placeholder={searchPlaceholder}
icon={Search}
/>
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchValue ?? ""}
onChange={(event) => onSearchChange(event.target.value)}
placeholder={searchPlaceholder}
className="h-8 w-56 pl-8"
data-testid="datatable-search"
/>
</div>
)}
{onToggleFilters !== undefined && (
<FiltersButton onClick={onToggleFilters} active={filtersActive} hasActiveFilters={hasActiveFilters} />
{filters.map((filter) => (
<Badge key={filter.id} variant="outline" className="gap-1 py-1" data-testid={`filter-chip-${filter.id}`}>
<span className="text-muted-foreground">{labelFor(filter.id)}:</span>
{valueFor(filter.id, filter.value)}
<button
type="button"
aria-label={`Remove ${labelFor(filter.id)} filter`}
data-testid={`filter-chip-remove-${filter.id}`}
onClick={() => table.setColumnFilters((previous) => previous.filter((entry) => entry.id !== filter.id))}
className="ml-0.5 rounded-full text-muted-foreground hover:text-foreground"
>
<X className="size-3" />
</button>
</Badge>
))}
{filters.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => table.setColumnFilters([])}
data-testid="datatable-clear-filters"
>
Clear all
</Button>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
{children}
{onRefresh !== undefined && (
<Button
variant="outline"
size="icon-sm"
onClick={onRefresh}
disabled={isRefreshing}
aria-label="Refresh"
title="Refresh"
data-testid="datatable-refresh"
>
<RefreshCw className={isRefreshing ? "animate-spin" : ""} />
</Button>
)}
{showViewOptions && <DataTableViewOptions table={table} label="Columns" />}
{onOpenFilters !== undefined && (
<Button variant="outline" size="sm" onClick={onOpenFilters} data-testid="datatable-filters-trigger">
<SlidersHorizontal />
Filters
{filters.length > 0 && (
<Badge className="ml-1 h-5 min-w-5 justify-center rounded-full px-1" data-testid="datatable-filter-count">
{filters.length}
</Badge>
)}
</Button>
)}
{showReset && <ResetFiltersButton onClick={onResetFilters} />}
</div>
{children !== undefined && <div className="flex flex-wrap items-center gap-2">{children}</div>}
</div>
);
}

View file

@ -2,7 +2,7 @@
import { Menu } from "@base-ui/react/menu";
import type { Table } from "@tanstack/react-table";
import { Check, SlidersHorizontal } from "lucide-react";
import { Check, Columns3 } from "lucide-react";
import { Button } from "@/components/ui/button";
@ -24,7 +24,7 @@ export function DataTableViewOptions<TData>({ table, label = "View", className }
<Menu.Trigger
render={
<Button variant="outline" size="sm" className={className} data-testid="view-options-trigger">
<SlidersHorizontal />
<Columns3 />
{label}
</Button>
}
@ -44,7 +44,8 @@ export function DataTableViewOptions<TData>({ table, label = "View", className }
<Menu.CheckboxItemIndicator className="absolute left-2 flex size-4 items-center justify-center">
<Check className="size-3.5" />
</Menu.CheckboxItemIndicator>
{column.columnDef.meta?.title ?? column.id}
{column.columnDef.meta?.title ??
(typeof column.columnDef.header === "string" ? column.columnDef.header : column.id)}
</Menu.CheckboxItem>
))}
</Menu.Popup>

View file

@ -1,6 +1,6 @@
import type { RowData } from "@tanstack/react-table";
import type { ColumnPinnedSide } from "./types";
import type { ColumnPinnedSide, DataTableSkeletonShape } from "./types";
declare module "@tanstack/react-table" {
interface ColumnMeta<TData extends RowData, TValue> {
@ -9,5 +9,6 @@ declare module "@tanstack/react-table" {
headerClassName?: string;
title?: string;
pinned?: ColumnPinnedSide;
skeleton?: DataTableSkeletonShape;
}
}

View file

@ -1,6 +1,7 @@
import "./columnMeta";
export { DataTable, DataTableConfigError, validateDataTableConfig } from "./DataTable";
export { DataTableFilterDrawer, DataTableFilterField, type FilterDraft } from "./DataTableFilterDrawer";
export { DataTablePagination, DEFAULT_PAGE_SIZE_OPTIONS } from "./DataTablePagination";
export { DataTableToolbar } from "./DataTableToolbar";
export { DataTableViewOptions } from "./DataTableViewOptions";
@ -11,6 +12,7 @@ export type {
ColumnResizeMode,
DataTableProps,
DataTableSize,
FilterMode,
PaginationMode,
SortingMode,
} from "./types";

View file

@ -1,5 +1,6 @@
import type {
ColumnDef,
ColumnFiltersState,
ExpandedState,
OnChangeFn,
PaginationState,
@ -13,9 +14,11 @@ import type * as React from "react";
export type SortingMode = "none" | "client" | "server";
export type PaginationMode = "none" | "client" | "server";
export type FilterMode = "none" | "client" | "server";
export type ColumnResizeMode = "onEnd" | "onChange";
export type DataTableSize = "compact" | "default";
export type ColumnPinnedSide = "left" | "right";
export type DataTableSkeletonShape = "text" | "twoLine";
export interface DataTableProps<TData extends RowData, TValue> {
data: TData[];
@ -24,6 +27,7 @@ export interface DataTableProps<TData extends RowData, TValue> {
isLoading?: boolean;
loadingMessage?: string;
skeletonRowCount?: number;
noDataMessage?: React.ReactNode;
sortingMode?: SortingMode;
@ -38,6 +42,14 @@ export interface DataTableProps<TData extends RowData, TValue> {
rowCount?: number;
pageSizeOptions?: number[];
filterMode?: FilterMode;
columnFilters?: ColumnFiltersState;
onColumnFiltersChange?: OnChangeFn<ColumnFiltersState>;
defaultColumnFilters?: ColumnFiltersState;
globalFilter?: string;
onGlobalFilterChange?: OnChangeFn<string>;
enableColumnResizing?: boolean;
columnResizeMode?: ColumnResizeMode;
defaultColumnVisibility?: VisibilityState;

View file

@ -583,7 +583,7 @@ describe("TeamInfoView", () => {
await waitFor(() => {
expect(screen.getByRole("button", { name: "Filters" })).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: "Reset Filters" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Columns" })).toBeInTheDocument();
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-1 of 1");
expect(screen.getByTestId("pagination-prev")).toBeInTheDocument();
expect(screen.getByTestId("pagination-next")).toBeInTheDocument();

View file

@ -4,8 +4,6 @@ import { beforeEach, describe, expect, it, vi, MockedFunction } from "vitest";
import { renderWithProviders } from "../../../tests/test-utils";
import { TeamVirtualKeysTable } from "./TeamVirtualKeysTable";
import { KeysResponse, useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import { fetchTeamFilterOptions } from "../key_team_helpers/filter_helpers";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { KeyResponse } from "../key_team_helpers/key_list";
import { Organization } from "../networking";
@ -13,18 +11,6 @@ vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({
useKeys: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: vi.fn(),
}));
vi.mock("../key_team_helpers/filter_helpers", () => ({
fetchTeamFilterOptions: vi.fn().mockResolvedValue({
keyAliases: [],
organizationIds: [],
userIds: [],
}),
}));
vi.mock("../key_team_helpers/fetch_available_models_team_key", () => ({
getModelDisplayName: vi.fn((model: string) => model),
}));
@ -39,7 +25,6 @@ vi.mock("../templates/key_info_view", () => ({
}));
const mockUseKeys = useKeys as MockedFunction<typeof useKeys>;
const mockUseAuthorized = useAuthorized as MockedFunction<typeof useAuthorized>;
const createMockKey = (overrides: Partial<KeyResponse> = {}): KeyResponse =>
({
@ -85,7 +70,6 @@ describe("TeamVirtualKeysTable", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseAuthorized.mockReturnValue({ accessToken: "test-token" } as any);
mockUseKeys.mockReturnValue({
data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 } as KeysResponse,
isPending: false,
@ -262,30 +246,50 @@ describe("TeamVirtualKeysTable", () => {
await waitFor(() => expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.anything()));
});
it("resets the sort order to the default when filters are reset", async () => {
it("maps the User ID drawer filter to a server-side useKeys query and clears it", async () => {
const user = userEvent.setup();
const result = {
mockUseKeys.mockReturnValue({
data: { keys: [createMockKey()], total_count: 1, current_page: 1, total_pages: 1 },
isPending: false,
isFetching: false,
refetch: vi.fn(),
} as unknown as ReturnType<typeof useKeys>;
mockUseKeys.mockReturnValue(result);
} as unknown as ReturnType<typeof useKeys>);
renderWithProviders(<TeamVirtualKeysTable {...defaultProps} />);
await user.click(await screen.findByTestId("sort-header-created_at"));
await user.click(await screen.findByTestId("datatable-filters-trigger"));
const drawerBody = await screen.findByTestId("filter-drawer-body");
const userInput = drawerBody.querySelector("input") as HTMLElement;
await user.type(userInput, "user-42");
await user.click(screen.getByTestId("filter-drawer-apply"));
await waitFor(() =>
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ sortOrder: "asc" })),
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ userID: "user-42" })),
);
await user.click(screen.getByRole("button", { name: "Reset Filters" }));
await user.click(screen.getByTestId("datatable-clear-filters"));
await waitFor(() =>
expect(mockUseKeys).toHaveBeenLastCalledWith(
1,
50,
expect.objectContaining({ sortBy: "created_at", sortOrder: "desc" }),
),
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ userID: undefined })),
);
});
it("maps the search box to a server-side key-alias query", async () => {
const user = userEvent.setup();
mockUseKeys.mockReturnValue({
data: { keys: [createMockKey()], total_count: 1, current_page: 1, total_pages: 1 },
isPending: false,
isFetching: false,
refetch: vi.fn(),
} as unknown as ReturnType<typeof useKeys>);
renderWithProviders(<TeamVirtualKeysTable {...defaultProps} />);
await user.type(await screen.findByTestId("datatable-search"), "check-002");
await waitFor(
() =>
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ selectedKeyAlias: "check-002" })),
{ timeout: 2000 },
);
});
@ -304,7 +308,7 @@ describe("TeamVirtualKeysTable", () => {
});
});
it("should show No keys found when keys array is empty", async () => {
it("should show the empty state when keys array is empty", async () => {
mockUseKeys.mockReturnValue({
data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 } as KeysResponse,
isPending: false,
@ -315,26 +319,7 @@ describe("TeamVirtualKeysTable", () => {
renderWithProviders(<TeamVirtualKeysTable {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("No keys found")).toBeInTheDocument();
});
});
it("should fetch team-scoped filter options for Key Alias, Organization ID, and User ID", async () => {
const mockFetchTeamFilterOptions = vi.mocked(fetchTeamFilterOptions);
mockFetchTeamFilterOptions.mockResolvedValue({
keyAliases: ["alice_key_team1", "charlie_key_team1"],
organizationIds: ["org-123"],
userIds: [
{ id: "user-1", email: "alice@example.com" },
{ id: "user-2", email: "charlie@example.com" },
],
});
// Use unique teamId to avoid cache hit from previous tests (refetchOnMount: false)
renderWithProviders(<TeamVirtualKeysTable {...defaultProps} teamId="team-filter-options-test" />);
await waitFor(() => {
expect(mockFetchTeamFilterOptions).toHaveBeenCalledWith("test-token", "team-filter-options-test");
expect(screen.getByText("No rows match your search or filters.")).toBeInTheDocument();
});
});

View file

@ -1,21 +1,25 @@
"use client";
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells";
import { DataTable, DataTablePagination, DataTableSortHeader } from "@/components/shared/DataTable";
import {
DataTable,
DataTableFilterDrawer,
DataTableFilterField,
DataTableSortHeader,
DataTableToolbar,
} from "@/components/shared/DataTable";
import { Input } from "@/components/ui/input";
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline";
import { ColumnDef, PaginationState, SortingState } from "@tanstack/react-table";
import { ColumnDef, ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
import { Badge, Icon, Text } from "@tremor/react";
import { Popover, Tooltip, Typography } from "antd";
import debounce from "lodash/debounce";
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key";
import { KeyResponse, Team } from "../key_team_helpers/key_list";
import FilterComponent, { FilterOption } from "../molecules/filter";
import { Organization } from "../networking";
import KeyInfoView from "../templates/key_info_view";
import { useQuery } from "@tanstack/react-query";
import { fetchTeamFilterOptions } from "../key_team_helpers/filter_helpers";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
interface TeamVirtualKeysTableProps {
teamId: string;
@ -30,18 +34,41 @@ interface TeamVirtualKeysTableProps {
const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }];
export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVirtualKeysTableProps) {
const { accessToken } = useAuthorized();
const [selectedKey, setSelectedKey] = useState<KeyResponse | null>(null);
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const [tablePagination, setTablePagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 50,
});
const [filters, setFilters] = useState<Record<string, string>>({
"Organization ID": "",
"Key Alias": "",
"User ID": "",
});
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [filtersOpen, setFiltersOpen] = useState(false);
const [searchInput, setSearchInput] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const debouncedSetSearch = useMemo(
() =>
debounce((value: string) => {
setSearchQuery(value);
setTablePagination((prev) => ({ ...prev, pageIndex: 0 }));
}, 300),
[],
);
useEffect(() => () => debouncedSetSearch.cancel(), [debouncedSetSearch]);
const handleSearchChange = useCallback(
(value: string) => {
setSearchInput(value);
debouncedSetSearch(value);
},
[debouncedSetSearch],
);
const getFilterValue = useCallback(
(columnId: string): string | undefined => {
const entry = columnFilters.find((filter) => filter.id === columnId);
return typeof entry?.value === "string" && entry.value.trim() ? entry.value.trim() : undefined;
},
[columnFilters],
);
const sortBy = sorting.length > 0 ? sorting[0].id : "created_at";
const sortOrder = sorting.length > 0 ? (sorting[0].desc ? "desc" : "asc") : "desc";
@ -56,9 +83,8 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
refetch,
} = useKeys(pageIndex + 1, pageSize, {
teamID: teamId,
organizationID: filters["Organization ID"]?.trim() || undefined,
selectedKeyAlias: filters["Key Alias"]?.trim() || undefined,
userID: filters["User ID"]?.trim() || undefined,
selectedKeyAlias: searchQuery.trim() || undefined,
userID: getFilterValue("user_id"),
sortBy: sortBy || undefined,
sortOrder: sortOrder || undefined,
expand: "user",
@ -95,18 +121,6 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
[teamId, teamAlias, organization],
);
const teamFilterOptionsQuery = useQuery({
queryKey: ["teamFilterOptions", teamId, accessToken],
queryFn: async () => fetchTeamFilterOptions(accessToken, teamId),
enabled: !!accessToken && !!teamId,
staleTime: 30000, // 30 seconds - align with useKeys
});
const teamFilterOptions = teamFilterOptionsQuery.data || {
keyAliases: [],
organizationIds: [],
userIds: [],
};
const handleStorageChange = useCallback(() => {
refetch?.();
}, [refetch]);
@ -116,76 +130,17 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
return () => window.removeEventListener("storage", handleStorageChange);
}, [handleStorageChange]);
const handleFilterChange = useCallback((newFilters: Record<string, string>) => {
setFilters((prev) => ({
...prev,
"Organization ID": newFilters["Organization ID"] ?? prev["Organization ID"],
"Key Alias": newFilters["Key Alias"] ?? prev["Key Alias"],
"User ID": newFilters["User ID"] ?? prev["User ID"],
}));
const handleColumnFiltersChange = useCallback<OnChangeFn<ColumnFiltersState>>((updaterOrValue) => {
setColumnFilters(updaterOrValue);
setTablePagination((prev) => ({ ...prev, pageIndex: 0 }));
}, []);
const handleFilterReset = useCallback(() => {
setFilters({
"Organization ID": "",
"Key Alias": "",
"User ID": "",
});
setSorting(DEFAULT_SORTING);
setTablePagination((prev) => ({ ...prev, pageIndex: 0 }));
}, []);
const filterOptions: FilterOption[] = useMemo(
() => [
{
name: "Organization ID",
label: "Organization ID",
isSearchable: true,
searchFn: async (searchText: string) => {
const { organizationIds } = teamFilterOptions;
if (!organizationIds.length) return [];
const lower = searchText.toLowerCase();
const filtered = lower ? organizationIds.filter((id) => id.toLowerCase().includes(lower)) : organizationIds;
return filtered.map((id) => ({ label: id, value: id }));
},
},
{
name: "Key Alias",
label: "Key Alias",
isSearchable: true,
searchFn: async (searchText: string) => {
const { keyAliases } = teamFilterOptions;
const lower = searchText.toLowerCase();
const filtered = lower ? keyAliases.filter((alias) => alias.toLowerCase().includes(lower)) : keyAliases;
return filtered.map((alias) => ({ label: alias, value: alias }));
},
},
{
name: "User ID",
label: "User ID",
isSearchable: true,
searchFn: async (searchText: string) => {
const { userIds } = teamFilterOptions;
const lower = searchText.toLowerCase();
const filtered = lower
? userIds.filter((u) => u.id.toLowerCase().includes(lower) || u.email.toLowerCase().includes(lower))
: userIds;
return filtered.map((u) => ({
label: u.email ? `${u.id} (${u.email})` : u.id,
value: u.id,
}));
},
},
],
[teamFilterOptions],
);
const columns: ColumnDef<KeyResponse>[] = useMemo(
() => [
{
id: "token",
accessorKey: "token",
meta: { title: "Key ID" },
header: ({ column }) => <DataTableSortHeader column={column} title="Key ID" variant="header-cycle" />,
size: 120,
enableSorting: true,
@ -196,6 +151,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "key_alias",
accessorKey: "key_alias",
meta: { title: "Key Alias" },
header: ({ column }) => <DataTableSortHeader column={column} title="Key Alias" variant="header-cycle" />,
size: 150,
enableSorting: true,
@ -268,6 +224,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "created_at",
accessorKey: "created_at",
meta: { title: "Created At" },
header: ({ column }) => <DataTableSortHeader column={column} title="Created At" variant="header-cycle" />,
size: 120,
enableSorting: true,
@ -335,6 +292,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "updated_at",
accessorKey: "updated_at",
meta: { title: "Updated At" },
header: ({ column }) => <DataTableSortHeader column={column} title="Updated At" variant="header-cycle" />,
size: 120,
enableSorting: true,
@ -359,6 +317,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "spend",
accessorKey: "spend",
meta: { title: "Spend (USD)" },
header: ({ column }) => <DataTableSortHeader column={column} title="Spend (USD)" variant="header-cycle" />,
size: 100,
enableSorting: true,
@ -367,6 +326,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "max_budget",
accessorKey: "max_budget",
meta: { title: "Budget (USD)" },
header: ({ column }) => <DataTableSortHeader column={column} title="Budget (USD)" variant="header-cycle" />,
size: 110,
enableSorting: true,
@ -503,27 +463,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
onDelete={refetch}
/>
) : (
<div className="border-b py-4 flex-1 overflow-hidden">
<div className="w-full mb-6">
<FilterComponent
options={filterOptions}
onApplyFilters={handleFilterChange}
initialValues={filters}
onResetFilters={handleFilterReset}
/>
</div>
<div className="w-full mb-4">
<DataTablePagination
page={pageIndex}
pageSize={pageSize}
rowCount={rowCount}
onPageChange={(nextPage) => setTablePagination((prev) => ({ ...prev, pageIndex: nextPage }))}
onPageSizeChange={(nextSize) => setTablePagination({ pageIndex: 0, pageSize: nextSize })}
isLoading={isLoading || isFetching}
/>
</div>
<div className="py-4 flex-1 overflow-hidden">
<DataTable
data={displayKeys}
columns={columns}
@ -534,14 +474,46 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
pagination={tablePagination}
onPaginationChange={setTablePagination}
rowCount={rowCount}
paginationSlot={() => null}
filterMode="server"
columnFilters={columnFilters}
onColumnFiltersChange={handleColumnFiltersChange}
enableColumnResizing
columnResizeMode="onChange"
isLoading={isLoading || isFetching}
loadingMessage="Loading keys..."
noDataMessage="No keys found"
maxBodyHeight="75vh"
size="compact"
toolbar={(table) => (
<>
<DataTableToolbar
table={table}
searchValue={searchInput}
onSearchChange={handleSearchChange}
searchPlaceholder="Search by key alias…"
onRefresh={() => refetch?.()}
isRefreshing={isFetching}
onOpenFilters={() => setFiltersOpen(true)}
filterLabels={{ user_id: "User ID" }}
/>
<DataTableFilterDrawer
table={table}
open={filtersOpen}
onOpenChange={setFiltersOpen}
title="Filters"
description={`Narrow down keys for ${teamAlias ?? "this team"}`}
>
{({ get, set }) => (
<DataTableFilterField label="User ID">
<Input
value={(get("user_id") as string) ?? ""}
onChange={(event) => set("user_id", event.target.value)}
placeholder="Filter by user ID…"
/>
</DataTableFilterField>
)}
</DataTableFilterDrawer>
</>
)}
/>
</div>
)}

View file

@ -0,0 +1,100 @@
"use client";
import * as React from "react";
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog";
import { cn } from "@/lib/cva.config";
import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react";
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean;
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={<Button variant="ghost" className="absolute top-4 right-4" size="icon-sm" />}
>
<XIcon />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="sheet-header" className={cn("flex flex-col gap-1.5 p-4", className)} {...props} />;
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="sheet-footer" className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />;
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title data-slot="sheet-title" className={cn("font-medium text-foreground", className)} {...props} />
);
}
function SheetDescription({ className, ...props }: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription };