mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
feat(ui): add shared composable DataTable component
Phase 0 of the dashboard table-standardization effort: one composable DataTable built on TanStack react-table and the shadcn-style primitives in components/ui/table.tsx (Base UI, Tailwind v4), plus its behavioral test suite. No existing tables are migrated in this change. The component owns the TanStack instance and a shadcn shell, and exposes composable slots (toolbar, pagination, footer) plus DataTableToolbar, DataTablePagination, DataTableViewOptions, and DataTableSortHeader. Sorting and pagination each use a single mode enum (none/client/server) so server modes only surface state via callbacks and never reorder or slice locally. columnMeta.ts defines the canonical ColumnMeta augmentation. The rendering shell imports only components/ui/table primitives; no tremor or antd.
This commit is contained in:
parent
8519d7fc24
commit
f4e1e8eb68
12 changed files with 1551 additions and 0 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,497 @@
|
|||
"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 } from "./DataTablePagination";
|
||||
import type { ColumnPinnedSide, DataTableProps, DataTableSize, PaginationMode, SortingMode } from "./types";
|
||||
|
||||
const DEFAULT_PAGE_SIZE_OPTIONS = [25, 50, 100];
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue