mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
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:
commit
80c5217ddc
17 changed files with 977 additions and 273 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
100
ui/litellm-dashboard/src/components/ui/sheet.tsx
Normal file
100
ui/litellm-dashboard/src/components/ui/sheet.tsx
Normal 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 };
|
||||
Loading…
Add table
Reference in a new issue