Merge pull request #32680 from BerriAI/litellm_/elegant-edison-bf44a2

feat(ui): add shared composable DataTable component
This commit is contained in:
yuneng-jiang 2026-07-10 12:32:21 -07:00 committed by GitHub
commit edb889bafb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1838 additions and 315 deletions

View file

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

View file

@ -2324,7 +2324,7 @@
},
"src/components/team/TeamVirtualKeysTable.tsx": {
"no-nested-ternary": {
"count": 2
"count": 1
},
"no-restricted-imports": {
"count": 1

View file

@ -0,0 +1,81 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import WorkflowRuns from "./WorkflowRuns";
vi.mock("@/components/networking", () => ({ proxyBaseUrl: "" }));
interface FakeRun {
run_id: string;
status: string;
workflow_type: string;
created_at: string;
metadata: { title?: string; state?: string } | null;
}
const RUNS: FakeRun[] = [
{
run_id: "run-aaaaaaaa-1111",
status: "completed",
workflow_type: "grill",
created_at: "2026-01-01T00:00:00Z",
metadata: { title: "First run", state: "done" },
},
{
run_id: "run-bbbbbbbb-2222",
status: "running",
workflow_type: "autofix",
created_at: "2026-01-02T00:00:00Z",
metadata: null,
},
];
function mockFetch(runs: FakeRun[]) {
return vi.fn((url: string) => {
if (url.includes("/runs?limit")) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ runs }) });
}
if (url.includes("/events")) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ events: [] }) });
}
if (url.includes("/messages")) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ messages: [] }) });
}
return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) });
});
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("WorkflowRuns (migrated onto shared DataTable)", () => {
it("renders one DataTable row per fetched run", async () => {
vi.stubGlobal("fetch", mockFetch(RUNS));
const { container } = render(<WorkflowRuns accessToken="tok" />);
expect(await screen.findByText("First run")).toBeInTheDocument();
expect(container.querySelectorAll("tr[data-row-id]")).toHaveLength(2);
});
it("opens the detail drawer for the clicked run by firing its detail fetch", async () => {
const user = userEvent.setup();
const fetchSpy = mockFetch(RUNS);
vi.stubGlobal("fetch", fetchSpy);
render(<WorkflowRuns accessToken="tok" />);
await user.click(await screen.findByText("First run"));
await waitFor(() =>
expect(fetchSpy).toHaveBeenCalledWith(expect.stringContaining("run-aaaaaaaa-1111/events"), expect.anything()),
);
});
it("shows the empty state when there are no runs", async () => {
vi.stubGlobal("fetch", mockFetch([]));
render(<WorkflowRuns accessToken="tok" />);
expect(await screen.findByText("No workflow runs yet")).toBeInTheDocument();
});
});

View file

@ -1,7 +1,9 @@
import React, { useState, useEffect, useCallback } from "react";
import { Button, Collapse, Drawer, Empty, Spin, Table, Tooltip, Typography } from "antd";
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 { proxyBaseUrl } from "@/components/networking";
import { DataTable } from "@/components/shared/DataTable";
const { Text } = Typography;
@ -541,48 +543,54 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
fetchRuns();
}, [fetchRuns]);
const columns = [
{
title: "Run",
dataIndex: "run_id",
key: "run",
render: (_: string, run: WorkflowRun) => (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<StatusDot status={run.status} size={7} />
<div>
<div style={{ fontSize: 13, color: "#18181b", fontWeight: 500, lineHeight: 1.4 }}>{runTitle(run)}</div>
<div style={{ fontFamily: "monospace", fontSize: 11, color: "#a1a1aa" }}>{shortId(run.run_id)}</div>
</div>
</div>
),
},
{
title: "Type",
dataIndex: "workflow_type",
key: "workflow_type",
render: (v: string) => <span style={{ fontFamily: "monospace", fontSize: 12, color: "#71717a" }}>{v}</span>,
},
{
title: "Status",
dataIndex: "status",
key: "status",
render: (status: RunStatus, run: WorkflowRun) => {
const state = run.metadata?.state;
return (
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<StatusDot status={status} size={7} />
<span style={{ fontSize: 12, color: "#52525b", textTransform: "capitalize" }}>{state ?? status}</span>
</div>
);
const columns = useMemo<ColumnDef<WorkflowRun, unknown>[]>(
() => [
{
id: "run",
header: "Run",
cell: ({ row }) => {
const run = row.original;
return (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<StatusDot status={run.status} size={7} />
<div>
<div style={{ fontSize: 13, color: "#18181b", fontWeight: 500, lineHeight: 1.4 }}>{runTitle(run)}</div>
<div style={{ fontFamily: "monospace", fontSize: 11, color: "#a1a1aa" }}>{shortId(run.run_id)}</div>
</div>
</div>
);
},
},
},
{
title: "Created",
dataIndex: "created_at",
key: "created_at",
render: (v: string) => <span style={{ fontSize: 12, color: "#a1a1aa" }}>{timeAgo(v)}</span>,
},
];
{
accessorKey: "workflow_type",
header: "Type",
cell: ({ row }) => (
<span style={{ fontFamily: "monospace", fontSize: 12, color: "#71717a" }}>{row.original.workflow_type}</span>
),
},
{
id: "status",
header: "Status",
cell: ({ row }) => {
const run = row.original;
return (
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<StatusDot status={run.status} size={7} />
<span style={{ fontSize: 12, color: "#52525b", textTransform: "capitalize" }}>
{run.metadata?.state ?? run.status}
</span>
</div>
);
},
},
{
accessorKey: "created_at",
header: "Created",
cell: ({ row }) => <span style={{ fontSize: 12, color: "#a1a1aa" }}>{timeAgo(row.original.created_at)}</span>,
},
],
[],
);
return (
<div
@ -619,31 +627,23 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
</Button>
</div>
{/* runs table — matches logs page density */}
<div className="rounded-lg custom-border overflow-x-auto w-full">
<Table
dataSource={runs}
columns={columns}
rowKey="run_id"
loading={loadingRuns}
size="small"
pagination={{ pageSize: 50, hideOnSinglePage: true, size: "small" }}
onRow={(run) => ({
onClick: () => fetchRunDetail(run),
style: { cursor: "pointer" },
})}
locale={{
emptyText: (
<Empty
description={<span style={{ color: "#a1a1aa", fontSize: 13 }}>No workflow runs yet</span>}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
),
}}
className="[&_.ant-table-cell]:py-0.5 [&_.ant-table-thead_.ant-table-cell]:py-1"
style={{ border: "none" }}
/>
</div>
<DataTable
data={runs}
columns={columns}
getRowId={(run) => run.run_id}
isLoading={loadingRuns}
loadingMessage="Loading workflow runs…"
noDataMessage={
<Empty
description={<span style={{ color: "#a1a1aa", fontSize: 13 }}>No workflow runs yet</span>}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
}
paginationMode="client"
pageSizeOptions={[50, 100]}
onRowClick={fetchRunDetail}
size="compact"
/>
{/* detail drawer */}
<Drawer

View file

@ -0,0 +1,431 @@
import type { ColumnDef, ExpandedState } from "@tanstack/react-table";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import { DataTable } from "./DataTable";
import { DataTableSortHeader } from "./DataTableSortHeader";
import { DataTableViewOptions } from "./DataTableViewOptions";
interface Person {
id: string;
name: string;
email: string;
flagged?: boolean;
}
function person(id: string, name: string, flagged = false): Person {
return { id, name, email: `${name.toLowerCase()}@x.io`, flagged };
}
const names = (): (string | null)[] => screen.getAllByTestId("name-cell").map((el) => el.textContent);
const nameCellColumns: ColumnDef<Person, unknown>[] = [
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => <span data-testid="name-cell">{row.original.name}</span>,
},
];
const headerCycleColumns: ColumnDef<Person, unknown>[] = [
{
accessorKey: "name",
header: ({ column }) => <DataTableSortHeader column={column} title="Name" variant="header-cycle" />,
cell: ({ row }) => <span data-testid="name-cell">{row.original.name}</span>,
},
];
const dropdownSortColumns: ColumnDef<Person, unknown>[] = [
{
accessorKey: "name",
header: ({ column }) => <DataTableSortHeader column={column} title="Name" variant="dropdown-tristate" />,
cell: ({ row }) => <span data-testid="name-cell">{row.original.name}</span>,
},
];
const nameEmailColumns: ColumnDef<Person, unknown>[] = [
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => <span data-testid="name-cell">{row.original.name}</span>,
},
{
accessorKey: "email",
header: "Email",
cell: ({ row }) => <span>{row.original.email}</span>,
},
];
const pinnedColumns: ColumnDef<Person, unknown>[] = [
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => <span data-testid="name-cell">{row.original.name}</span>,
meta: { pinned: "left" },
},
{
accessorKey: "email",
header: "Email",
cell: ({ row }) => <span>{row.original.email}</span>,
},
];
const rowClickColumns: ColumnDef<Person, unknown>[] = [
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => <span data-testid="name-cell">{row.original.name}</span>,
},
{
id: "actions",
header: "Actions",
cell: () => (
<div>
<button data-testid="row-button">Act</button>
<input data-testid="row-input" aria-label="row input" />
</div>
),
},
];
const expansionColumns: ColumnDef<Person, unknown>[] = [
{
id: "expander",
header: "",
cell: ({ row }) => (
<button data-testid={`expand-${row.id}`} onClick={() => row.toggleExpanded()}>
toggle
</button>
),
},
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => <span data-testid="name-cell">{row.original.name}</span>,
},
];
const CHARLIE_ALICE_BOB: Person[] = [person("c", "Charlie"), person("a", "Alice"), person("b", "Bob")];
describe("DataTable sorting", () => {
it("client mode reorders rows when the sort header is clicked", async () => {
const user = userEvent.setup();
render(<DataTable data={CHARLIE_ALICE_BOB} columns={headerCycleColumns} sortingMode="client" />);
expect(names()).toEqual(["Charlie", "Alice", "Bob"]);
await user.click(screen.getByTestId("sort-header-name"));
expect(names()).toEqual(["Alice", "Bob", "Charlie"]);
});
it("server mode fires the callback but never reorders locally", async () => {
const user = userEvent.setup();
const onSortingChange = vi.fn();
render(
<DataTable
data={CHARLIE_ALICE_BOB}
columns={headerCycleColumns}
sortingMode="server"
sorting={[{ id: "name", desc: false }]}
onSortingChange={onSortingChange}
/>,
);
// sorting state says ascending, but server mode must render data as given
expect(names()).toEqual(["Charlie", "Alice", "Bob"]);
await user.click(screen.getByTestId("sort-header-name"));
expect(onSortingChange).toHaveBeenCalledTimes(1);
expect(names()).toEqual(["Charlie", "Alice", "Bob"]);
});
it("dropdown-tristate variant sorts ascending, descending, then resets", async () => {
const user = userEvent.setup();
render(<DataTable data={CHARLIE_ALICE_BOB} columns={dropdownSortColumns} sortingMode="client" />);
await user.click(screen.getByTestId("sort-trigger-name"));
await user.click(await screen.findByText("Ascending"));
expect(names()).toEqual(["Alice", "Bob", "Charlie"]);
await user.click(screen.getByTestId("sort-trigger-name"));
await user.click(await screen.findByText("Descending"));
expect(names()).toEqual(["Charlie", "Bob", "Alice"]);
await user.click(screen.getByTestId("sort-trigger-name"));
await user.click(await screen.findByText("Reset"));
expect(names()).toEqual(["Charlie", "Alice", "Bob"]);
});
});
describe("DataTable pagination", () => {
const fivePeople: Person[] = Array.from({ length: 5 }, (_, i) => person(String(i), `P${i}`));
it("client mode slices rows and advances pages", async () => {
const user = userEvent.setup();
render(<DataTable data={fivePeople} columns={nameCellColumns} paginationMode="client" pageSizeOptions={[2]} />);
expect(names()).toEqual(["P0", "P1"]);
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-2 of 5");
await user.click(screen.getByTestId("pagination-next"));
expect(names()).toEqual(["P2", "P3"]);
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 3-4 of 5");
});
it("server mode shows X-Y of Z from rowCount and does NOT slice the given rows", async () => {
const user = userEvent.setup();
const onPaginationChange = vi.fn();
const pageSlice: Person[] = [person("10", "P10"), person("11", "P11"), person("12", "P12")];
render(
<DataTable
data={pageSlice}
columns={nameCellColumns}
paginationMode="server"
pagination={{ pageIndex: 1, pageSize: 10 }}
rowCount={25}
onPaginationChange={onPaginationChange}
/>,
);
expect(names()).toEqual(["P10", "P11", "P12"]);
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 11-20 of 25");
await user.click(screen.getByTestId("pagination-next"));
expect(onPaginationChange).toHaveBeenCalledTimes(1);
});
});
describe("DataTable column visibility", () => {
it("hides a column when toggled off in the view-options menu", async () => {
const user = userEvent.setup();
render(
<DataTable
data={CHARLIE_ALICE_BOB}
columns={nameEmailColumns}
toolbar={(table) => <DataTableViewOptions table={table} />}
/>,
);
expect(screen.getByText("Email")).toBeInTheDocument();
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 user.click(screen.getByTestId("view-option-email"));
await waitFor(() => expect(screen.getByText("Email")).toBeInTheDocument());
});
it("omits columns that opt out of hiding from the menu", async () => {
const user = userEvent.setup();
const columns: ColumnDef<Person, unknown>[] = [
{
accessorKey: "name",
header: "Name",
enableHiding: false,
cell: ({ row }) => <span data-testid="name-cell">{row.original.name}</span>,
},
{
accessorKey: "email",
header: "Email",
cell: ({ row }) => <span>{row.original.email}</span>,
},
];
render(
<DataTable
data={CHARLIE_ALICE_BOB}
columns={columns}
toolbar={(table) => <DataTableViewOptions table={table} />}
/>,
);
await user.click(screen.getByTestId("view-options-trigger"));
expect(await screen.findByTestId("view-option-email")).toBeInTheDocument();
expect(screen.queryByTestId("view-option-name")).toBeNull();
});
});
describe("DataTable pinned columns", () => {
it("applies sticky positioning to a pinned column only", () => {
const { container } = render(<DataTable data={CHARLIE_ALICE_BOB} columns={pinnedColumns} />);
const pinnedHead = container.querySelector<HTMLElement>('th[data-header-id="name"]');
const normalHead = container.querySelector<HTMLElement>('th[data-header-id="email"]');
expect(pinnedHead?.style.position).toBe("sticky");
expect(pinnedHead?.style.left).toBe("0px");
expect(normalHead?.style.position).toBe("");
});
});
describe("DataTable row click guard", () => {
it("fires onRowClick from a plain cell but not from interactive elements", async () => {
const user = userEvent.setup();
const onRowClick = vi.fn();
render(<DataTable data={[person("a", "Alice")]} columns={rowClickColumns} onRowClick={onRowClick} />);
await user.click(screen.getByTestId("name-cell"));
expect(onRowClick).toHaveBeenCalledTimes(1);
expect(onRowClick).toHaveBeenCalledWith(expect.objectContaining({ id: "a" }));
await user.click(screen.getByTestId("row-button"));
expect(onRowClick).toHaveBeenCalledTimes(1);
await user.click(screen.getByTestId("row-input"));
expect(onRowClick).toHaveBeenCalledTimes(1);
});
});
describe("DataTable expansion", () => {
const subComponent = ({ row }: { row: { original: Person } }) => (
<div data-testid="sub-row">details for {row.original.name}</div>
);
it("toggles the sub-row in uncontrolled mode", async () => {
const user = userEvent.setup();
render(
<DataTable
data={[person("a", "Alice")]}
columns={expansionColumns}
getRowId={(row) => row.id}
getRowCanExpand={() => true}
renderSubComponent={subComponent}
/>,
);
expect(screen.queryByTestId("sub-row")).not.toBeInTheDocument();
await user.click(screen.getByTestId("expand-a"));
expect(screen.getByTestId("sub-row")).toBeInTheDocument();
await user.click(screen.getByTestId("expand-a"));
expect(screen.queryByTestId("sub-row")).not.toBeInTheDocument();
});
it("toggles the sub-row in controlled mode driven by parent state", async () => {
const user = userEvent.setup();
const Harness = () => {
const [expanded, setExpanded] = useState<ExpandedState>({});
return (
<DataTable
data={[person("a", "Alice")]}
columns={expansionColumns}
getRowId={(row) => row.id}
expanded={expanded}
onExpandedChange={setExpanded}
getRowCanExpand={() => true}
renderSubComponent={subComponent}
/>
);
};
render(<Harness />);
expect(screen.queryByTestId("sub-row")).not.toBeInTheDocument();
await user.click(screen.getByTestId("expand-a"));
expect(screen.getByTestId("sub-row")).toBeInTheDocument();
await user.click(screen.getByTestId("expand-a"));
expect(screen.queryByTestId("sub-row")).not.toBeInTheDocument();
});
it("stays collapsed in controlled mode when the parent ignores the change", async () => {
const user = userEvent.setup();
const onExpandedChange = vi.fn();
render(
<DataTable
data={[person("a", "Alice")]}
columns={expansionColumns}
getRowId={(row) => row.id}
expanded={{}}
onExpandedChange={onExpandedChange}
getRowCanExpand={() => true}
renderSubComponent={subComponent}
/>,
);
await user.click(screen.getByTestId("expand-a"));
expect(onExpandedChange).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId("sub-row")).not.toBeInTheDocument();
});
});
describe("DataTable row styling and footer", () => {
it("applies rowClassName to the matching row only", () => {
const data = [person("a", "Alice", true), person("b", "Bob", false)];
const { container } = render(
<DataTable
data={data}
columns={nameCellColumns}
getRowId={(row) => row.id}
rowClassName={(row) => (row.original.flagged ? "flagged-row" : "")}
/>,
);
expect(container.querySelector('tr[data-row-id="a"]')?.className).toContain("flagged-row");
expect(container.querySelector('tr[data-row-id="b"]')?.className).not.toContain("flagged-row");
});
it("renders the footer slot inside a tfoot element", () => {
render(
<DataTable
data={CHARLIE_ALICE_BOB}
columns={nameCellColumns}
footer={() => (
<tr data-testid="footer-row">
<td>Total: 3</td>
</tr>
)}
/>,
);
expect(screen.getByTestId("footer-row").closest("tfoot")).not.toBeNull();
});
});
describe("DataTable layout", () => {
it("exposes resize handles with stable selectors only when resizing is enabled", () => {
const { container, rerender } = render(
<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} enableColumnResizing />,
);
expect(container.querySelectorAll("[data-resizer][data-header-id]").length).toBe(2);
rerender(<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} />);
expect(container.querySelectorAll("[data-resizer]").length).toBe(0);
});
it("makes the header sticky and constrains body height when maxBodyHeight is set", () => {
const { container } = render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} maxBodyHeight={240} />);
expect(container.querySelector("thead")?.className).toContain("sticky");
const scroller = container.querySelector('[data-slot="table-container"]')?.parentElement as HTMLElement;
expect(scroller.style.maxHeight).toBe("240px");
});
});
describe("DataTable misconfiguration guards", () => {
it("throws when server sorting is missing required props", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => render(<DataTable data={[]} columns={nameCellColumns} sortingMode="server" />)).toThrow(
/sortingMode='server'/,
);
spy.mockRestore();
});
it("throws when server pagination is missing required props", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => render(<DataTable data={[]} columns={nameCellColumns} paginationMode="server" />)).toThrow(
/paginationMode='server'/,
);
spy.mockRestore();
});
it("throws when both defaultSorting and sorting are provided", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() =>
render(
<DataTable
data={[]}
columns={nameCellColumns}
defaultSorting={[{ id: "name", desc: false }]}
sorting={[{ id: "name", desc: false }]}
/>,
),
).toThrow(/defaultSorting/);
spy.mockRestore();
});
});

View file

@ -0,0 +1,495 @@
"use client";
import {
type Cell,
type Column,
type ColumnDef,
type ColumnPinningState,
type ColumnSizingState,
type ExpandedState,
flexRender,
getCoreRowModel,
getExpandedRowModel,
getPaginationRowModel,
getSortedRowModel,
type Header,
type OnChangeFn,
type Row,
type RowData,
type Table,
type TableOptions,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table";
import * as React from "react";
import { Fragment, useState } from "react";
import {
Table as TableRoot,
TableBody,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
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";
const INTERACTIVE_SELECTOR = "button, a, input, select, textarea, [role=checkbox], [data-row-click-exempt]";
const noop = () => {};
export class DataTableConfigError extends Error {
constructor(messages: readonly string[]) {
super(`DataTable misconfiguration:\n- ${messages.join("\n- ")}`);
this.name = "DataTableConfigError";
}
}
export function validateDataTableConfig<TData extends RowData, TValue>(
props: DataTableProps<TData, TValue>,
): readonly string[] {
const serverSortingIncomplete =
props.sortingMode === "server" && (props.sorting === undefined || props.onSortingChange === undefined);
const serverPaginationPropsMissing =
props.pagination === undefined || props.onPaginationChange === undefined || props.rowCount === undefined;
const serverPaginationIncomplete = props.paginationMode === "server" && serverPaginationPropsMissing;
const bothSortingSources = props.defaultSorting !== undefined && props.sorting !== undefined;
return [
serverSortingIncomplete ? "sortingMode='server' requires both `sorting` and `onSortingChange`." : null,
serverPaginationIncomplete
? "paginationMode='server' requires `pagination`, `onPaginationChange`, and `rowCount`."
: null,
bothSortingSources ? "Provide either `defaultSorting` (uncontrolled) or `sorting` (controlled), not both." : null,
].filter((message): message is string => message !== null);
}
function columnDefId<TData, TValue>(column: ColumnDef<TData, TValue>): string | undefined {
if ("id" in column && typeof column.id === "string") {
return column.id;
}
if ("accessorKey" in column && column.accessorKey != null) {
return String(column.accessorKey);
}
return undefined;
}
function derivePinning<TData, TValue>(columns: ColumnDef<TData, TValue>[]): ColumnPinningState {
const collect = (side: ColumnPinnedSide): string[] =>
columns
.filter((column) => column.meta?.pinned === side)
.map(columnDefId)
.filter((id): id is string => id !== undefined);
return { left: collect("left"), right: collect("right") };
}
function buildRowModels<TData>(
sortingMode: SortingMode,
paginationMode: PaginationMode,
getRowCanExpand: ((row: Row<TData>) => boolean) | undefined,
): Partial<TableOptions<TData>> {
return {
...(sortingMode === "client" ? { getSortedRowModel: getSortedRowModel() } : {}),
...(paginationMode === "client" ? { getPaginationRowModel: getPaginationRowModel() } : {}),
...(getRowCanExpand !== undefined ? { getRowCanExpand, getExpandedRowModel: getExpandedRowModel() } : {}),
};
}
function stickyZIndex(isPinned: boolean, isHeader: boolean): number {
if (isPinned && isHeader) {
return 30;
}
if (isHeader) {
return 20;
}
return 10;
}
function pinnedShadow(pinned: false | ColumnPinnedSide): string {
if (pinned === "left") {
return "shadow-[inset_-1px_0_0_var(--color-border)]";
}
if (pinned === "right") {
return "shadow-[inset_1px_0_0_var(--color-border)]";
}
return "";
}
function computeStickyStyle<TData, TValue>(
column: Column<TData, TValue>,
isHeader: boolean,
stickyHeader: boolean,
): { style: React.CSSProperties; className: string } {
const pinned = column.getIsPinned();
const stickyTop = isHeader && stickyHeader;
if (!pinned && !stickyTop) {
return { style: {}, className: "" };
}
const left = pinned === "left" ? column.getStart("left") : undefined;
const right = pinned === "right" ? column.getAfter("right") : undefined;
const style: React.CSSProperties = {
position: "sticky",
zIndex: stickyZIndex(pinned !== false, isHeader),
...(stickyTop ? { top: 0 } : {}),
...(left !== undefined ? { left } : {}),
...(right !== undefined ? { right } : {}),
};
return { style, className: cn(pinned ? "bg-background" : "", pinnedShadow(pinned)) };
}
function widthStyle<TData, TValue>(
column: Column<TData, TValue>,
enableColumnResizing: boolean,
): React.CSSProperties | undefined {
if (enableColumnResizing || column.columnDef.size !== undefined) {
return { width: column.getSize() };
}
return undefined;
}
interface HeadCellProps<TData> {
header: Header<TData, unknown>;
size: DataTableSize;
stickyHeader: boolean;
enableColumnResizing: boolean;
}
function DataTableHeadCell<TData>({ header, size, stickyHeader, enableColumnResizing }: HeadCellProps<TData>) {
const { column } = header;
const meta = column.columnDef.meta;
const sticky = computeStickyStyle(column, true, stickyHeader);
const canResize = enableColumnResizing && column.getCanResize();
return (
<TableHead
data-header-id={header.id}
className={cn(
"relative text-muted-foreground",
size === "compact" ? "h-8 px-2 py-1 text-xs" : "",
meta?.numeric ? "text-right" : "",
meta?.className,
meta?.headerClassName,
sticky.className,
)}
style={{ ...sticky.style, ...widthStyle(column, enableColumnResizing) }}
>
{header.isPlaceholder ? null : (
<div className={cn("flex items-center gap-1", meta?.numeric ? "justify-end" : "")}>
{flexRender(column.columnDef.header, header.getContext())}
</div>
)}
{canResize && (
<div
data-resizer
data-header-id={header.id}
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
onDoubleClick={() => column.resetSize()}
className={cn(
"absolute top-0 right-0 h-full w-1 cursor-col-resize touch-none select-none hover:bg-border",
column.getIsResizing() ? "bg-primary" : "",
)}
/>
)}
</TableHead>
);
}
interface BodyCellProps<TData> {
cell: Cell<TData, unknown>;
size: DataTableSize;
stickyHeader: boolean;
enableColumnResizing: boolean;
}
function DataTableBodyCell<TData>({ cell, size, stickyHeader, enableColumnResizing }: BodyCellProps<TData>) {
const { column } = cell;
const meta = column.columnDef.meta;
const sticky = computeStickyStyle(column, false, stickyHeader);
return (
<TableCell
className={cn(
"overflow-hidden text-ellipsis",
size === "compact" ? "px-2 py-1 text-xs" : "",
meta?.numeric ? "text-right tabular-nums" : "",
meta?.className,
sticky.className,
)}
style={{ ...sticky.style, ...widthStyle(column, enableColumnResizing) }}
>
{flexRender(column.columnDef.cell, cell.getContext())}
</TableCell>
);
}
interface BodyRowProps<TData> {
row: Row<TData>;
size: DataTableSize;
stickyHeader: boolean;
enableColumnResizing: boolean;
onRowClick?: (row: TData) => void;
rowClassName?: (row: Row<TData>) => string;
renderSubComponent?: (props: { row: Row<TData> }) => React.ReactElement;
}
function DataTableBodyRow<TData>({
row,
size,
stickyHeader,
enableColumnResizing,
onRowClick,
rowClassName,
renderSubComponent,
}: BodyRowProps<TData>) {
const clickable = onRowClick !== undefined;
const cells = row.getVisibleCells();
const handleClick = (event: React.MouseEvent<HTMLTableRowElement>) => {
if (onRowClick === undefined) {
return;
}
const target = event.target as HTMLElement | null;
if (target === null || !event.currentTarget.contains(target)) {
return;
}
if (target.closest(INTERACTIVE_SELECTOR) !== null) {
return;
}
onRowClick(row.original);
};
return (
<Fragment>
<TableRow
data-row-id={row.id}
className={cn(clickable ? "cursor-pointer" : "", size === "compact" ? "h-8" : "", rowClassName?.(row))}
onClick={clickable ? handleClick : undefined}
>
{cells.map((cell) => (
<DataTableBodyCell
key={cell.id}
cell={cell}
size={size}
stickyHeader={stickyHeader}
enableColumnResizing={enableColumnResizing}
/>
))}
</TableRow>
{renderSubComponent !== undefined && row.getIsExpanded() && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={cells.length} className="p-0">
{renderSubComponent({ row })}
</TableCell>
</TableRow>
)}
</Fragment>
);
}
function MessageRow({ colSpan, children }: { colSpan: number; children: React.ReactNode }) {
return (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={colSpan} className="h-24 text-center align-middle text-sm text-muted-foreground">
{children}
</TableCell>
</TableRow>
);
}
function useControllable<T>(
controlled: T | undefined,
controlledOnChange: OnChangeFn<T> | undefined,
initial: T,
): { value: T; onChange: OnChangeFn<T> } {
const [internal, setInternal] = useState<T>(initial);
if (controlled !== undefined) {
return { value: controlled, onChange: controlledOnChange ?? noop };
}
return { value: internal, onChange: setInternal };
}
function useDataTableInstance<TData extends RowData, TValue>(props: DataTableProps<TData, TValue>): Table<TData> {
const {
data,
columns,
getRowId,
sortingMode = "none",
sorting,
onSortingChange,
defaultSorting,
enableSortingRemoval = false,
paginationMode = "none",
pagination,
onPaginationChange,
rowCount,
pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
enableColumnResizing = false,
columnResizeMode = "onEnd",
defaultColumnVisibility,
getRowCanExpand,
renderSubComponent,
expanded,
onExpandedChange,
} = props;
const sortingState = useControllable(sorting, onSortingChange, defaultSorting ?? []);
const paginationState = useControllable(pagination, onPaginationChange, {
pageIndex: 0,
pageSize: pageSizeOptions[0] ?? 25,
});
const expandedState = useControllable<ExpandedState>(expanded, onExpandedChange, {});
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(defaultColumnVisibility ?? {});
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>({});
const columnPinning = React.useMemo(() => derivePinning(columns), [columns]);
const expansionGuard = renderSubComponent !== undefined ? getRowCanExpand : undefined;
const tableOptions: TableOptions<TData> = {
data,
columns,
state: {
sorting: sortingState.value,
pagination: paginationState.value,
expanded: expandedState.value,
columnVisibility,
columnSizing,
},
initialState: { columnPinning },
manualSorting: sortingMode === "server",
manualPagination: paginationMode === "server",
enableSortingRemoval,
enableColumnResizing,
columnResizeMode,
onSortingChange: sortingState.onChange,
onPaginationChange: paginationState.onChange,
onExpandedChange: expandedState.onChange,
onColumnVisibilityChange: setColumnVisibility,
onColumnSizingChange: setColumnSizing,
getCoreRowModel: getCoreRowModel(),
...buildRowModels(sortingMode, paginationMode, expansionGuard),
...(getRowId !== undefined ? { getRowId } : {}),
...(paginationMode === "server" && rowCount !== undefined ? { rowCount } : {}),
};
return useReactTable(tableOptions);
}
export function DataTable<TData extends RowData, TValue>(props: DataTableProps<TData, TValue>) {
// Validate once at construction so a misconfig surfaces immediately instead of on every render.
useState<null>(() => {
const errors = validateDataTableConfig(props);
if (errors.length > 0) {
throw new DataTableConfigError(errors);
}
return null;
});
const {
isLoading = false,
loadingMessage = "Loading…",
noDataMessage = "No results",
paginationMode = "none",
rowCount,
pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
enableColumnResizing = false,
onRowClick,
rowClassName,
renderSubComponent,
maxBodyHeight,
size = "default",
toolbar,
paginationSlot,
footer,
} = props;
const table = useDataTableInstance(props);
const rows = table.getRowModel().rows;
const visibleColumnCount = table.getVisibleLeafColumns().length;
const stickyHeader = maxBodyHeight !== undefined;
const tableStyle = enableColumnResizing ? { width: table.getTotalSize() } : undefined;
const renderPagination = (): React.ReactNode => {
if (paginationSlot !== undefined) {
return paginationSlot(table);
}
if (paginationMode === "none") {
return null;
}
const current = table.getState().pagination;
const total = paginationMode === "server" ? rowCount ?? 0 : table.getPrePaginationRowModel().rows.length;
return (
<DataTablePagination
page={current.pageIndex}
pageSize={current.pageSize}
rowCount={total}
onPageChange={(next) => table.setPageIndex(next)}
onPageSizeChange={(next) => table.setPageSize(next)}
pageSizeOptions={pageSizeOptions}
isLoading={isLoading}
/>
);
};
const renderBody = (): React.ReactNode => {
if (isLoading) {
return <MessageRow colSpan={visibleColumnCount}>{loadingMessage}</MessageRow>;
}
if (rows.length === 0) {
return <MessageRow colSpan={visibleColumnCount}>{noDataMessage}</MessageRow>;
}
return rows.map((row) => (
<DataTableBodyRow
key={row.id}
row={row}
size={size}
stickyHeader={stickyHeader}
enableColumnResizing={enableColumnResizing}
onRowClick={onRowClick}
rowClassName={rowClassName}
renderSubComponent={renderSubComponent}
/>
));
};
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>
{renderPagination()}
</div>
);
}

View file

@ -0,0 +1,68 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { DataTablePagination } from "./DataTablePagination";
const baseProps = {
page: 0,
pageSize: 25,
rowCount: 100,
onPageChange: () => {},
onPageSizeChange: () => {},
};
describe("DataTablePagination", () => {
it("renders the current range from plain props", () => {
render(<DataTablePagination {...baseProps} />);
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-25 of 100");
});
it("computes the range for a middle page and clamps the end to rowCount", () => {
render(<DataTablePagination {...baseProps} page={3} pageSize={30} rowCount={100} />);
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 91-100 of 100");
});
it("disables the previous controls on the first page", () => {
render(<DataTablePagination {...baseProps} page={0} />);
expect(screen.getByTestId("pagination-first")).toBeDisabled();
expect(screen.getByTestId("pagination-prev")).toBeDisabled();
expect(screen.getByTestId("pagination-next")).toBeEnabled();
});
it("disables the next controls on the last page", () => {
render(<DataTablePagination {...baseProps} page={3} pageSize={25} rowCount={100} />);
expect(screen.getByTestId("pagination-next")).toBeDisabled();
expect(screen.getByTestId("pagination-last")).toBeDisabled();
expect(screen.getByTestId("pagination-prev")).toBeEnabled();
});
it("advances by one page when next is clicked", async () => {
const user = userEvent.setup();
const onPageChange = vi.fn();
render(<DataTablePagination {...baseProps} page={1} onPageChange={onPageChange} />);
await user.click(screen.getByTestId("pagination-next"));
expect(onPageChange).toHaveBeenCalledWith(2);
});
it("jumps to the last page index when last is clicked", async () => {
const user = userEvent.setup();
const onPageChange = vi.fn();
render(<DataTablePagination {...baseProps} page={0} pageSize={25} rowCount={100} onPageChange={onPageChange} />);
await user.click(screen.getByTestId("pagination-last"));
expect(onPageChange).toHaveBeenCalledWith(3);
});
it("shows an empty state and disables all navigation when there are no rows", () => {
render(<DataTablePagination {...baseProps} rowCount={0} />);
expect(screen.getByTestId("pagination-range")).toHaveTextContent("No results");
expect(screen.getByTestId("pagination-next")).toBeDisabled();
expect(screen.getByTestId("pagination-prev")).toBeDisabled();
});
it("disables navigation while loading", () => {
render(<DataTablePagination {...baseProps} page={1} isLoading />);
expect(screen.getByTestId("pagination-next")).toBeDisabled();
expect(screen.getByTestId("pagination-prev")).toBeDisabled();
});
});

View file

@ -0,0 +1,113 @@
"use client";
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/cva.config";
export const DEFAULT_PAGE_SIZE_OPTIONS = [25, 50, 100];
export interface DataTablePaginationProps {
page: number;
pageSize: number;
rowCount: number;
onPageChange: (page: number) => void;
onPageSizeChange: (pageSize: number) => void;
pageSizeOptions?: number[];
isLoading?: boolean;
className?: string;
}
export function DataTablePagination({
page,
pageSize,
rowCount,
onPageChange,
onPageSizeChange,
pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
isLoading = false,
className,
}: DataTablePaginationProps) {
const pageCount = pageSize > 0 ? Math.ceil(rowCount / pageSize) : 0;
const start = rowCount === 0 ? 0 : page * pageSize + 1;
const end = Math.min((page + 1) * pageSize, rowCount);
const canPrev = page > 0 && !isLoading;
const canNext = page < pageCount - 1 && !isLoading;
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="flex items-center gap-2 text-sm text-muted-foreground">
<span>Rows per page</span>
<Select
value={String(pageSize)}
onValueChange={(value) => {
if (typeof value === "string") {
onPageSizeChange(Number(value));
}
}}
>
<SelectTrigger size="sm" data-testid="pagination-page-size" className="w-[4.5rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map((option) => (
<SelectItem key={option} value={String(option)}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-4">
<span data-testid="pagination-range" className="text-sm text-muted-foreground tabular-nums">
{rowCount === 0 ? "No results" : `Showing ${start}-${end} of ${rowCount}`}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon-sm"
data-testid="pagination-first"
aria-label="Go to first page"
disabled={!canPrev}
onClick={() => onPageChange(0)}
>
<ChevronsLeft />
</Button>
<Button
variant="outline"
size="icon-sm"
data-testid="pagination-prev"
aria-label="Go to previous page"
disabled={!canPrev}
onClick={() => onPageChange(page - 1)}
>
<ChevronLeft />
</Button>
<Button
variant="outline"
size="icon-sm"
data-testid="pagination-next"
aria-label="Go to next page"
disabled={!canNext}
onClick={() => onPageChange(page + 1)}
>
<ChevronRight />
</Button>
<Button
variant="outline"
size="icon-sm"
data-testid="pagination-last"
aria-label="Go to last page"
disabled={!canNext}
onClick={() => onPageChange(lastPage)}
>
<ChevronsRight />
</Button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,112 @@
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
type OnChangeFn,
type SortingState,
useReactTable,
} 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, vi } from "vitest";
import { DataTableSortHeader, type DataTableSortVariant } from "./DataTableSortHeader";
interface Item {
name: string;
}
interface HarnessProps {
variant: DataTableSortVariant;
canSort?: boolean;
onSortingChange?: OnChangeFn<SortingState>;
}
function SortHeaderHarness({ variant, canSort = true, onSortingChange }: HarnessProps) {
const [sorting, setSorting] = useState<SortingState>([]);
const columns: ColumnDef<Item, unknown>[] = [
{
accessorKey: "name",
enableSorting: canSort,
header: ({ column }) => <DataTableSortHeader column={column} title="Name" variant={variant} />,
},
];
const options = {
data: [{ name: "x" }],
columns,
state: { sorting },
onSortingChange: (updater: SortingState | ((prev: SortingState) => SortingState)) => {
setSorting(updater);
onSortingChange?.(updater);
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
};
const table = useReactTable(options);
return (
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id}>{flexRender(header.column.columnDef.header, header.getContext())}</th>
))}
</tr>
))}
</thead>
</table>
);
}
describe("DataTableSortHeader", () => {
it("renders a plain label and no button when the column cannot sort", () => {
render(<SortHeaderHarness variant="header-cycle" canSort={false} />);
expect(screen.queryByTestId("sort-header-name")).toBeNull();
expect(screen.getByText("Name")).toBeInTheDocument();
});
it("header-cycle indicator advances none -> asc -> desc on click", async () => {
const user = userEvent.setup();
render(<SortHeaderHarness variant="header-cycle" />);
const indicator = () => screen.getByTestId("sort-header-name").querySelector("[data-sort-indicator]");
expect(indicator()).toHaveAttribute("data-sort-indicator", "none");
await user.click(screen.getByTestId("sort-header-name"));
expect(indicator()).toHaveAttribute("data-sort-indicator", "asc");
await user.click(screen.getByTestId("sort-header-name"));
expect(indicator()).toHaveAttribute("data-sort-indicator", "desc");
});
it("dropdown-tristate sets ascending, descending, and reset from the menu", async () => {
const user = userEvent.setup();
render(<SortHeaderHarness variant="dropdown-tristate" />);
await user.click(screen.getByTestId("sort-trigger-name"));
await user.click(await screen.findByText("Descending"));
expect(screen.getByTestId("sort-trigger-name").querySelector('[data-sort-indicator="desc"]')).not.toBeNull();
await user.click(screen.getByTestId("sort-trigger-name"));
await user.click(await screen.findByText("Ascending"));
expect(screen.getByTestId("sort-trigger-name").querySelector('[data-sort-indicator="asc"]')).not.toBeNull();
await user.click(screen.getByTestId("sort-trigger-name"));
await user.click(await screen.findByText("Reset"));
expect(screen.getByTestId("sort-trigger-name").querySelector('[data-sort-indicator="none"]')).not.toBeNull();
});
it("dropdown-tristate trigger stops the click from reaching an outer handler", async () => {
const user = userEvent.setup();
const onOuterClick = vi.fn();
render(
<div onClick={onOuterClick}>
<SortHeaderHarness variant="dropdown-tristate" />
</div>,
);
await user.click(screen.getByTestId("sort-trigger-name"));
expect(onOuterClick).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,96 @@
"use client";
import { Menu } from "@base-ui/react/menu";
import type { Column, SortDirection } from "@tanstack/react-table";
import { ChevronDown, ChevronsUpDown, ChevronUp, X } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/cva.config";
export type DataTableSortVariant = "header-cycle" | "dropdown-tristate";
interface DataTableSortHeaderProps<TData, TValue> {
column: Column<TData, TValue>;
title: React.ReactNode;
variant?: DataTableSortVariant;
className?: string;
}
function SortIndicator({ sorted }: { sorted: false | SortDirection }) {
if (sorted === "asc") {
return <ChevronUp className="size-3.5" data-sort-indicator="asc" />;
}
if (sorted === "desc") {
return <ChevronDown className="size-3.5" data-sort-indicator="desc" />;
}
return <ChevronsUpDown className="size-3.5 text-muted-foreground" data-sort-indicator="none" />;
}
const MENU_ITEM_CLASS =
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground";
export function DataTableSortHeader<TData, TValue>({
column,
title,
variant = "header-cycle",
className,
}: DataTableSortHeaderProps<TData, TValue>) {
const sorted = column.getIsSorted();
if (!column.getCanSort()) {
return <span className={cn("font-medium", className)}>{title}</span>;
}
if (variant === "dropdown-tristate") {
return (
<div className={cn("flex items-center gap-1", className)}>
<span className="font-medium">{title}</span>
<Menu.Root>
<Menu.Trigger
render={
<button
type="button"
data-testid={`sort-trigger-${column.id}`}
aria-label={`Sort options for ${column.id}`}
onClick={(event) => event.stopPropagation()}
className={cn(
"inline-flex size-6 items-center justify-center rounded-md hover:bg-muted",
sorted ? "text-primary" : "text-muted-foreground",
)}
>
<SortIndicator sorted={sorted} />
</button>
}
/>
<Menu.Portal>
<Menu.Positioner side="bottom" align="start" sideOffset={4} className="isolate z-50">
<Menu.Popup className="min-w-[9rem] rounded-md bg-popover p-1 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden">
<Menu.Item className={MENU_ITEM_CLASS} onClick={() => column.toggleSorting(false)}>
<ChevronUp className="size-3.5" /> Ascending
</Menu.Item>
<Menu.Item className={MENU_ITEM_CLASS} onClick={() => column.toggleSorting(true)}>
<ChevronDown className="size-3.5" /> Descending
</Menu.Item>
<Menu.Item className={MENU_ITEM_CLASS} onClick={() => column.clearSorting()}>
<X className="size-3.5" /> Reset
</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
</div>
);
}
return (
<button
type="button"
data-testid={`sort-header-${column.id}`}
onClick={column.getToggleSortingHandler()}
className={cn("flex items-center gap-1 font-medium select-none hover:text-foreground", className)}
>
<span>{title}</span>
<SortIndicator sorted={sorted} />
</button>
);
}

View file

@ -0,0 +1,35 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { DataTableToolbar } from "./DataTableToolbar";
describe("DataTableToolbar", () => {
it("renders slotted action children", () => {
render(
<DataTableToolbar>
<button data-testid="toolbar-action">Action</button>
</DataTableToolbar>,
);
expect(screen.getByTestId("toolbar-action")).toBeInTheDocument();
});
it("shows the reset button only when there are active filters", 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);
});
});

View file

@ -0,0 +1,55 @@
"use client";
import { Search } 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 { cn } from "@/lib/cva.config";
interface DataTableToolbarProps {
searchValue?: string;
onSearchChange?: (value: string) => void;
searchPlaceholder?: string;
filtersActive?: boolean;
hasActiveFilters?: boolean;
onToggleFilters?: () => void;
onResetFilters?: () => void;
children?: React.ReactNode;
className?: string;
}
export function DataTableToolbar({
searchValue,
onSearchChange,
searchPlaceholder = "Search",
filtersActive = false,
hasActiveFilters = false,
onToggleFilters,
onResetFilters,
children,
className,
}: DataTableToolbarProps) {
const showReset = onResetFilters !== undefined && hasActiveFilters;
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">
{onSearchChange !== undefined && (
<FilterInput
value={searchValue ?? ""}
onChange={onSearchChange}
placeholder={searchPlaceholder}
icon={Search}
/>
)}
{onToggleFilters !== undefined && (
<FiltersButton onClick={onToggleFilters} active={filtersActive} hasActiveFilters={hasActiveFilters} />
)}
{showReset && <ResetFiltersButton onClick={onResetFilters} />}
</div>
{children !== undefined && <div className="flex flex-wrap items-center gap-2">{children}</div>}
</div>
);
}

View file

@ -0,0 +1,55 @@
"use client";
import { Menu } from "@base-ui/react/menu";
import type { Table } from "@tanstack/react-table";
import { Check, SlidersHorizontal } from "lucide-react";
import { Button } from "@/components/ui/button";
interface DataTableViewOptionsProps<TData> {
table: Table<TData>;
label?: string;
className?: string;
}
export function DataTableViewOptions<TData>({ table, label = "View", className }: DataTableViewOptionsProps<TData>) {
const hideableColumns = table.getAllLeafColumns().filter((column) => column.getCanHide());
if (hideableColumns.length === 0) {
return null;
}
return (
<Menu.Root>
<Menu.Trigger
render={
<Button variant="outline" size="sm" className={className} data-testid="view-options-trigger">
<SlidersHorizontal />
{label}
</Button>
}
/>
<Menu.Portal>
<Menu.Positioner side="bottom" align="end" sideOffset={4} className="isolate z-50">
<Menu.Popup className="min-w-[12rem] rounded-md bg-popover p-1 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden">
{hideableColumns.map((column) => (
<Menu.CheckboxItem
key={column.id}
checked={column.getIsVisible()}
onCheckedChange={(checked) => column.toggleVisibility(checked)}
closeOnClick={false}
data-testid={`view-option-${column.id}`}
className="relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-7 capitalize outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground"
>
<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}
</Menu.CheckboxItem>
))}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
);
}

View file

@ -0,0 +1,13 @@
import type { RowData } from "@tanstack/react-table";
import type { ColumnPinnedSide } from "./types";
declare module "@tanstack/react-table" {
interface ColumnMeta<TData extends RowData, TValue> {
numeric?: boolean;
className?: string;
headerClassName?: string;
title?: string;
pinned?: ColumnPinnedSide;
}
}

View file

@ -0,0 +1,16 @@
import "./columnMeta";
export { DataTable, DataTableConfigError, validateDataTableConfig } from "./DataTable";
export { DataTablePagination, DEFAULT_PAGE_SIZE_OPTIONS } from "./DataTablePagination";
export { DataTableToolbar } from "./DataTableToolbar";
export { DataTableViewOptions } from "./DataTableViewOptions";
export { DataTableSortHeader, type DataTableSortVariant } from "./DataTableSortHeader";
export type { DataTablePaginationProps } from "./DataTablePagination";
export type {
ColumnPinnedSide,
ColumnResizeMode,
DataTableProps,
DataTableSize,
PaginationMode,
SortingMode,
} from "./types";

View file

@ -0,0 +1,60 @@
import type {
ColumnDef,
ExpandedState,
OnChangeFn,
PaginationState,
Row,
RowData,
SortingState,
Table,
VisibilityState,
} from "@tanstack/react-table";
import type * as React from "react";
export type SortingMode = "none" | "client" | "server";
export type PaginationMode = "none" | "client" | "server";
export type ColumnResizeMode = "onEnd" | "onChange";
export type DataTableSize = "compact" | "default";
export type ColumnPinnedSide = "left" | "right";
export interface DataTableProps<TData extends RowData, TValue> {
data: TData[];
columns: ColumnDef<TData, TValue>[];
getRowId?: (row: TData, index: number, parent?: Row<TData>) => string;
isLoading?: boolean;
loadingMessage?: string;
noDataMessage?: React.ReactNode;
sortingMode?: SortingMode;
sorting?: SortingState;
onSortingChange?: OnChangeFn<SortingState>;
defaultSorting?: SortingState;
enableSortingRemoval?: boolean;
paginationMode?: PaginationMode;
pagination?: PaginationState;
onPaginationChange?: OnChangeFn<PaginationState>;
rowCount?: number;
pageSizeOptions?: number[];
enableColumnResizing?: boolean;
columnResizeMode?: ColumnResizeMode;
defaultColumnVisibility?: VisibilityState;
getRowCanExpand?: (row: Row<TData>) => boolean;
renderSubComponent?: (props: { row: Row<TData> }) => React.ReactElement;
expanded?: ExpandedState;
onExpandedChange?: OnChangeFn<ExpandedState>;
onRowClick?: (row: TData) => void;
rowClassName?: (row: Row<TData>) => string;
maxBodyHeight?: number | string;
size?: DataTableSize;
toolbar?: (table: Table<TData>) => React.ReactNode;
paginationSlot?: (table: Table<TData>) => React.ReactNode;
footer?: (table: Table<TData>) => React.ReactNode;
}

View file

@ -536,7 +536,7 @@ describe("TeamInfoView", () => {
await user.click(virtualKeysTab);
await waitFor(() => {
expect(screen.getByText("Page 1 of 1")).toBeInTheDocument();
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-5 of 5");
});
});
@ -584,9 +584,9 @@ describe("TeamInfoView", () => {
expect(screen.getByRole("button", { name: "Filters" })).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: "Reset Filters" })).toBeInTheDocument();
expect(screen.getByText("Page 1 of 1")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Previous" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Next" })).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

@ -163,7 +163,7 @@ describe("TeamVirtualKeysTable", () => {
expect(screen.getByText("bob_key_team1")).toBeInTheDocument();
});
it("should show Page X of Y when multiple pages exist", async () => {
it("should show the current range from total_count when multiple pages exist", async () => {
mockUseKeys.mockReturnValue({
data: {
keys: [createMockKey()],
@ -179,7 +179,7 @@ describe("TeamVirtualKeysTable", () => {
renderWithProviders(<TeamVirtualKeysTable {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Page 1 of 3")).toBeInTheDocument();
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-50 of 100");
});
});
@ -203,17 +203,92 @@ describe("TeamVirtualKeysTable", () => {
renderWithProviders(<TeamVirtualKeysTable {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Page 1 of 3")).toBeInTheDocument();
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-50 of 100");
});
const nextButton = screen.getByRole("button", { name: "Next" });
await user.click(nextButton);
await user.click(screen.getByTestId("pagination-next"));
await waitFor(() => {
expect(mockUseKeys).toHaveBeenLastCalledWith(2, 50, expect.objectContaining({ teamID: "team-1" }));
});
});
it("routes a sort-header click to useKeys as a server-side sort", async () => {
const user = userEvent.setup();
mockUseKeys.mockReturnValue({
data: { keys: [createMockKey()], total_count: 1, current_page: 1, total_pages: 1 } as KeysResponse,
isPending: false,
isFetching: false,
refetch: vi.fn(),
} as unknown as ReturnType<typeof useKeys>);
renderWithProviders(<TeamVirtualKeysTable {...defaultProps} />);
await waitFor(() => expect(screen.getByTestId("sort-header-created_at")).toBeInTheDocument());
await user.click(screen.getByTestId("sort-header-created_at"));
await waitFor(() =>
expect(mockUseKeys).toHaveBeenLastCalledWith(
1,
50,
expect.objectContaining({ sortBy: "created_at", sortOrder: "asc" }),
),
);
});
it("resets to the first page when the sort changes", async () => {
const user = userEvent.setup();
mockUseKeys.mockImplementation(
(page: number) =>
({
data: {
keys: [createMockKey({ token: `sk-p${page}`, key_alias: `page${page}_key` })],
total_count: 100,
current_page: page,
total_pages: 2,
},
isPending: false,
isFetching: false,
refetch: vi.fn(),
}) as unknown as ReturnType<typeof useKeys>,
);
renderWithProviders(<TeamVirtualKeysTable {...defaultProps} />);
await user.click(await screen.findByTestId("pagination-next"));
await waitFor(() => expect(mockUseKeys).toHaveBeenLastCalledWith(2, 50, expect.anything()));
await user.click(screen.getByTestId("sort-header-created_at"));
await waitFor(() => expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.anything()));
});
it("resets the sort order to the default when filters are reset", async () => {
const user = userEvent.setup();
const result = {
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);
renderWithProviders(<TeamVirtualKeysTable {...defaultProps} />);
await user.click(await screen.findByTestId("sort-header-created_at"));
await waitFor(() =>
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ sortOrder: "asc" })),
);
await user.click(screen.getByRole("button", { name: "Reset Filters" }));
await waitFor(() =>
expect(mockUseKeys).toHaveBeenLastCalledWith(
1,
50,
expect.objectContaining({ sortBy: "created_at", sortOrder: "desc" }),
),
);
});
it("should show Loading keys when isPending", async () => {
mockUseKeys.mockReturnValue({
data: undefined,

View file

@ -1,20 +1,11 @@
// TO-DO: Standardize tables eventually
"use client";
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells";
import { ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline";
import {
ColumnDef,
flexRender,
getCoreRowModel,
PaginationState,
SortingState,
useReactTable,
} from "@tanstack/react-table";
import { Badge, Icon, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Text } from "@tremor/react";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Popover, Skeleton, Tooltip, Typography } from "antd";
import { DataTable, DataTablePagination, DataTableSortHeader } from "@/components/shared/DataTable";
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline";
import { ColumnDef, PaginationState, SortingState } from "@tanstack/react-table";
import { Badge, Icon, Text } from "@tremor/react";
import { Popover, Tooltip, Typography } from "antd";
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";
@ -36,10 +27,12 @@ interface TeamVirtualKeysTableProps {
* TeamVirtualKeysTable variant of VirtualKeysTable scoped to a single team.
* Displays all virtual keys belonging to the team with same format and styling.
*/
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>([{ id: "created_at", desc: true }]);
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const [tablePagination, setTablePagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 50,
@ -48,8 +41,6 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
"Organization ID": "",
"Key Alias": "",
"User ID": "",
"Sort By": "created_at",
"Sort Order": "desc",
});
const sortBy = sorting.length > 0 ? sorting[0].id : "created_at";
@ -83,7 +74,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
}));
}, [keys?.keys, organization?.organization_id]);
const pageCount = keys?.total_pages ?? 0;
const rowCount = keys?.total_count ?? 0;
const [expandedAccordions, setExpandedAccordions] = useState<Record<string, boolean>>({});
const currentTeam: Team = useMemo(
@ -125,18 +116,14 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
return () => window.removeEventListener("storage", handleStorageChange);
}, [handleStorageChange]);
const handleFilterChange = useCallback((newFilters: Record<string, string>, skipDebounce = false) => {
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"],
"Sort By": newFilters["Sort By"] ?? prev["Sort By"] ?? "created_at",
"Sort Order": newFilters["Sort Order"] ?? prev["Sort Order"] ?? "desc",
}));
if (!skipDebounce) {
setTablePagination((prev) => ({ ...prev, pageIndex: 0 }));
}
setTablePagination((prev) => ({ ...prev, pageIndex: 0 }));
}, []);
const handleFilterReset = useCallback(() => {
@ -144,9 +131,8 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
"Organization ID": "",
"Key Alias": "",
"User ID": "",
"Sort By": "created_at",
"Sort Order": "desc",
});
setSorting(DEFAULT_SORTING);
setTablePagination((prev) => ({ ...prev, pageIndex: 0 }));
}, []);
@ -200,8 +186,8 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "token",
accessorKey: "token",
header: "Key ID",
size: 100,
header: ({ column }) => <DataTableSortHeader column={column} title="Key ID" variant="header-cycle" />,
size: 120,
enableSorting: true,
cell: (info) => (
<IdCell value={info.getValue() as string | null} onClick={() => setSelectedKey(info.row.original)} />
@ -210,7 +196,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "key_alias",
accessorKey: "key_alias",
header: "Key Alias",
header: ({ column }) => <DataTableSortHeader column={column} title="Key Alias" variant="header-cycle" />,
size: 150,
enableSorting: true,
cell: (info) => {
@ -282,7 +268,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "created_at",
accessorKey: "created_at",
header: "Created At",
header: ({ column }) => <DataTableSortHeader column={column} title="Created At" variant="header-cycle" />,
size: 120,
enableSorting: true,
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" />,
@ -291,7 +277,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
id: "created_by",
accessorKey: "created_by",
header: "Created By",
size: 70,
size: 130,
enableSorting: false,
cell: (info) => {
const userId = info.getValue() as string | null;
@ -349,7 +335,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "updated_at",
accessorKey: "updated_at",
header: "Updated At",
header: ({ column }) => <DataTableSortHeader column={column} title="Updated At" variant="header-cycle" />,
size: 120,
enableSorting: true,
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Never" />,
@ -357,17 +343,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "last_active",
accessorKey: "last_active",
header: () => (
<span className="flex items-center gap-1">
Last Active
<Popover
content="This is a new field and is not backfilled. Only new key usage will update this value."
trigger="hover"
>
<InfoCircleOutlined className="text-gray-400 text-xs cursor-help" />
</Popover>
</span>
),
header: "Last Active",
size: 130,
enableSorting: false,
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Unknown" />,
@ -383,7 +359,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "spend",
accessorKey: "spend",
header: "Spend (USD)",
header: ({ column }) => <DataTableSortHeader column={column} title="Spend (USD)" variant="header-cycle" />,
size: 100,
enableSorting: true,
cell: (info) => <MoneyCell value={info.getValue() as number | null} decimals={4} />,
@ -391,7 +367,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{
id: "max_budget",
accessorKey: "max_budget",
header: "Budget (USD)",
header: ({ column }) => <DataTableSortHeader column={column} title="Budget (USD)" variant="header-cycle" />,
size: 110,
enableSorting: true,
cell: (info) => (
@ -511,39 +487,10 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
[expandedAccordions],
);
const handleSortingChange = useCallback(
(updaterOrValue: React.SetStateAction<SortingState>) => {
const newSorting = typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue;
setSorting(newSorting);
if (newSorting?.length > 0) {
const sortState = newSorting[0];
handleFilterChange(
{
"Sort By": sortState.id,
"Sort Order": sortState.desc ? "desc" : "asc",
},
true,
);
}
},
[sorting, handleFilterChange],
);
const table = useReactTable({
data: displayKeys,
columns,
columnResizeMode: "onChange",
columnResizeDirection: "ltr",
state: { sorting, pagination: tablePagination },
onSortingChange: handleSortingChange,
onPaginationChange: setTablePagination,
getCoreRowModel: getCoreRowModel(),
// getSortedRowModel not needed — manualSorting: true delegates sorting to the server
enableSorting: true,
manualSorting: true, // Server sorts via useKeys. Avoid redundant client-side sort
manualPagination: true,
pageCount: pageCount,
});
const handleSortingChange = useCallback((updaterOrValue: React.SetStateAction<SortingState>) => {
setSorting(updaterOrValue);
setTablePagination((prev) => ({ ...prev, pageIndex: 0 }));
}, []);
return (
<div className="w-full h-full overflow-hidden">
@ -566,165 +513,36 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
/>
</div>
<div className="flex items-center justify-end w-full mb-4">
<div className="inline-flex items-center gap-2">
{isLoading || isFetching ? (
<Skeleton.Node active style={{ width: 74, height: 20 }} />
) : (
<span className="text-sm text-gray-700">
Page {pageIndex + 1} of {table.getPageCount()}
</span>
)}
{isLoading || isFetching ? (
<Skeleton.Button active size="small" style={{ width: 84, height: 30 }} />
) : (
<button
onClick={() => table.previousPage()}
disabled={isLoading || isFetching || !table.getCanPreviousPage()}
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
)}
{isLoading || isFetching ? (
<Skeleton.Button active size="small" style={{ width: 58, height: 30 }} />
) : (
<button
onClick={() => table.nextPage()}
disabled={isLoading || isFetching || !table.getCanNextPage()}
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
)}
</div>
</div>
<div className="h-[75vh] overflow-auto">
<div className="rounded-lg custom-border relative">
<div className="overflow-x-auto">
<Table className="[&_td]:py-0.5 [&_th]:py-1" style={{ width: table.getCenterTotalSize() }}>
<TableHead>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHeaderCell
key={header.id}
data-header-id={header.id}
className={`py-1 h-8 relative hover:bg-gray-50 ${
header.id === "actions"
? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]"
: ""
}`}
style={{
width: header.getSize(),
position: "relative",
cursor: header.column.getCanSort() ? "pointer" : "default",
}}
onMouseEnter={() => {
const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`);
if (resizer) (resizer as HTMLElement).style.opacity = "0.5";
}}
onMouseLeave={() => {
const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`);
if (resizer && !header.column.getIsResizing())
(resizer as HTMLElement).style.opacity = "0";
}}
onClick={header.column.getCanSort() ? header.column.getToggleSortingHandler() : undefined}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</div>
{header.id !== "actions" && header.column.getCanSort() && (
<div className="w-4">
{header.column.getIsSorted() ? (
{
asc: <ChevronUpIcon className="h-4 w-4 text-blue-500" />,
desc: <ChevronDownIcon className="h-4 w-4 text-blue-500" />,
}[header.column.getIsSorted() as string]
) : (
<SwitchVerticalIcon className="h-4 w-4 text-gray-400" />
)}
</div>
)}
<div
onDoubleClick={() => header.column.resetSize()}
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={`resizer ${table.options.columnResizeDirection} ${
header.column.getIsResizing() ? "isResizing" : ""
}`}
style={{
position: "absolute",
right: 0,
top: 0,
height: "100%",
width: "5px",
background: header.column.getIsResizing() ? "#3b82f6" : "transparent",
cursor: "col-resize",
userSelect: "none",
touchAction: "none",
opacity: header.column.getIsResizing() ? 1 : 0,
}}
/>
</div>
</TableHeaderCell>
))}
</TableRow>
))}
</TableHead>
<TableBody>
{isLoading || isFetching ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>Loading keys...</p>
</div>
</TableCell>
</TableRow>
) : displayKeys.length > 0 ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="h-8">
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
width: cell.column.getSize(),
maxWidth: "8-x",
whiteSpace: "pre-wrap",
overflow: "hidden",
}}
className={`py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap ${
cell.column.id === "models" &&
Array.isArray(cell.getValue()) &&
(cell.getValue() as string[]).length > 3
? "px-0"
: ""
}`}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>No keys found</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</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>
<DataTable
data={displayKeys}
columns={columns}
sortingMode="server"
sorting={sorting}
onSortingChange={handleSortingChange}
paginationMode="server"
pagination={tablePagination}
onPaginationChange={setTablePagination}
rowCount={rowCount}
paginationSlot={() => null}
enableColumnResizing
columnResizeMode="onChange"
isLoading={isLoading || isFetching}
loadingMessage="Loading keys..."
noDataMessage="No keys found"
maxBodyHeight="75vh"
size="compact"
/>
</div>
)}
</div>