mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
Make numeric the one alignment token for the three shared table wrappers (DataTable, MemberTable, SimpleTable) via NUMERIC_CELL_CLASS, and flag every money, cost, and bare-count column that was still left-aligned. Raw ui/table usages that render spend or budgets get the same class on their header and cell.
685 lines
25 KiB
TypeScript
685 lines
25 KiB
TypeScript
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 { DataTableMultiSortHeader, 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 filterableColumns: ColumnDef<Person, unknown>[] = [
|
|
{
|
|
accessorKey: "name",
|
|
header: "Name",
|
|
meta: { title: "Name" },
|
|
filterFn: (row, columnId, value) => row.getValue<string>(columnId) === value,
|
|
cell: ({ row }) => <span data-testid="name-cell">{row.original.name}</span>,
|
|
},
|
|
];
|
|
|
|
const headerCycleColumns: ColumnDef<Person, unknown>[] = [
|
|
{
|
|
accessorKey: "name",
|
|
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 multiSortColumns: ColumnDef<Person, unknown>[] = [
|
|
{
|
|
id: "spend",
|
|
accessorKey: "name",
|
|
header: ({ table }) => (
|
|
<DataTableMultiSortHeader
|
|
table={table}
|
|
fields={[
|
|
{ id: "spend", label: "Spend" },
|
|
{ id: "max_budget", label: "Budget" },
|
|
]}
|
|
/>
|
|
),
|
|
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 numericColumns: ColumnDef<Person, unknown>[] = [
|
|
{
|
|
accessorKey: "name",
|
|
header: "Name",
|
|
cell: ({ row }) => <span data-testid="name-cell">{row.original.name}</span>,
|
|
},
|
|
{
|
|
id: "spend",
|
|
header: ({ column }) => <DataTableSortHeader column={column} title="Spend" />,
|
|
meta: { numeric: true },
|
|
cell: () => <span>$1.50</span>,
|
|
},
|
|
];
|
|
|
|
const CHARLIE_ALICE_BOB: Person[] = [person("c", "Charlie"), person("a", "Alice"), person("b", "Bob")];
|
|
|
|
describe("DataTable numeric columns", () => {
|
|
it("right-aligns the header and cells of a numeric column only", () => {
|
|
render(<DataTable data={[person("a", "Alice")]} columns={numericColumns} sortingMode="client" />);
|
|
|
|
const spendHeader = screen.getByText("Spend").closest("th");
|
|
expect(spendHeader).toHaveClass("text-right", "tabular-nums");
|
|
expect(spendHeader?.querySelector("div")).toHaveClass("justify-end");
|
|
expect(screen.getByText("$1.50").closest("td")).toHaveClass("text-right", "tabular-nums");
|
|
expect(screen.getByText("Name").closest("th")).not.toHaveClass("text-right");
|
|
expect(screen.getByTestId("name-cell").closest("td")).not.toHaveClass("text-right");
|
|
});
|
|
});
|
|
|
|
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"]);
|
|
});
|
|
|
|
it("multi-sort header emits the chosen field id (not the column id) as the sort key", async () => {
|
|
const user = userEvent.setup();
|
|
const onSortingChange = vi.fn();
|
|
render(
|
|
<DataTable
|
|
data={CHARLIE_ALICE_BOB}
|
|
columns={multiSortColumns}
|
|
sortingMode="server"
|
|
sorting={[]}
|
|
onSortingChange={onSortingChange}
|
|
/>,
|
|
);
|
|
|
|
await user.click(screen.getByTestId("sort-trigger-spend"));
|
|
await user.click(await screen.findByText("Budget descending"));
|
|
expect(onSortingChange).toHaveBeenLastCalledWith([{ id: "max_budget", desc: true }]);
|
|
|
|
await user.click(screen.getByTestId("sort-trigger-spend"));
|
|
await user.click(await screen.findByText("Spend ascending"));
|
|
expect(onSortingChange).toHaveBeenLastCalledWith([{ id: "spend", desc: false }]);
|
|
});
|
|
|
|
it("multi-sort header reflects the active field and direction, and Reset clears it", async () => {
|
|
const user = userEvent.setup();
|
|
const onSortingChange = vi.fn();
|
|
render(
|
|
<DataTable
|
|
data={CHARLIE_ALICE_BOB}
|
|
columns={multiSortColumns}
|
|
sortingMode="server"
|
|
sorting={[{ id: "max_budget", desc: true }]}
|
|
onSortingChange={onSortingChange}
|
|
/>,
|
|
);
|
|
|
|
await user.click(screen.getByTestId("sort-trigger-spend"));
|
|
// The header trigger shows the active (descending) indicator while sorted by a field it owns.
|
|
expect(screen.getByTestId("sort-trigger-spend").querySelector("[data-sort-indicator='desc']")).not.toBeNull();
|
|
|
|
await user.click(await screen.findByText("Reset"));
|
|
expect(onSortingChange).toHaveBeenLastCalledWith([]);
|
|
});
|
|
});
|
|
|
|
describe("DataTable layout", () => {
|
|
it("stretches the table to fill the container when resizing is on, so hidden columns leave no right-side gap", () => {
|
|
const { container } = render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameCellColumns} enableColumnResizing />);
|
|
|
|
const table = container.querySelector("table");
|
|
expect(table).not.toBeNull();
|
|
// width pins the natural column total (horizontal scroll on overflow); minWidth:100% fills the gap on underflow.
|
|
expect(table?.style.minWidth).toBe("100%");
|
|
});
|
|
});
|
|
|
|
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 filtering", () => {
|
|
it("client mode filters rows by columnFilters", () => {
|
|
const { rerender } = render(
|
|
<DataTable
|
|
data={CHARLIE_ALICE_BOB}
|
|
columns={filterableColumns}
|
|
filterMode="client"
|
|
columnFilters={[]}
|
|
onColumnFiltersChange={vi.fn()}
|
|
/>,
|
|
);
|
|
expect(names()).toEqual(["Charlie", "Alice", "Bob"]);
|
|
|
|
rerender(
|
|
<DataTable
|
|
data={CHARLIE_ALICE_BOB}
|
|
columns={filterableColumns}
|
|
filterMode="client"
|
|
columnFilters={[{ id: "name", value: "Alice" }]}
|
|
onColumnFiltersChange={vi.fn()}
|
|
/>,
|
|
);
|
|
expect(names()).toEqual(["Alice"]);
|
|
});
|
|
|
|
it("client global filter matches substrings across columns", () => {
|
|
const { rerender } = render(
|
|
<DataTable
|
|
data={CHARLIE_ALICE_BOB}
|
|
columns={nameEmailColumns}
|
|
filterMode="client"
|
|
globalFilter=""
|
|
onGlobalFilterChange={vi.fn()}
|
|
/>,
|
|
);
|
|
expect(names()).toEqual(["Charlie", "Alice", "Bob"]);
|
|
|
|
rerender(
|
|
<DataTable
|
|
data={CHARLIE_ALICE_BOB}
|
|
columns={nameEmailColumns}
|
|
filterMode="client"
|
|
globalFilter="ali"
|
|
onGlobalFilterChange={vi.fn()}
|
|
/>,
|
|
);
|
|
expect(names()).toEqual(["Alice"]);
|
|
});
|
|
|
|
it("server mode never filters locally even when columnFilters is set", () => {
|
|
render(
|
|
<DataTable
|
|
data={CHARLIE_ALICE_BOB}
|
|
columns={filterableColumns}
|
|
filterMode="server"
|
|
columnFilters={[{ id: "name", value: "Alice" }]}
|
|
onColumnFiltersChange={vi.fn()}
|
|
/>,
|
|
);
|
|
expect(names()).toEqual(["Charlie", "Alice", "Bob"]);
|
|
});
|
|
});
|
|
|
|
describe("DataTable loading", () => {
|
|
it("renders skeleton rows while loading and real rows once loaded", () => {
|
|
const { rerender } = render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameCellColumns} isLoading />);
|
|
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
|
|
expect(screen.queryByTestId("name-cell")).not.toBeInTheDocument();
|
|
|
|
rerender(<DataTable data={CHARLIE_ALICE_BOB} columns={nameCellColumns} />);
|
|
expect(screen.queryAllByTestId("skeleton-row")).toHaveLength(0);
|
|
expect(names()).toEqual(["Charlie", "Alice", "Bob"]);
|
|
});
|
|
|
|
it("gives compact skeleton rows the same height as loaded rows so loading does not shrink the table", () => {
|
|
const { rerender } = render(
|
|
<DataTable data={CHARLIE_ALICE_BOB} columns={nameCellColumns} size="compact" isLoading />,
|
|
);
|
|
const skeletonRow = screen.getAllByTestId("skeleton-row").at(0);
|
|
const loadedRowHeight = "h-8";
|
|
expect(skeletonRow?.className).toContain(loadedRowHeight);
|
|
|
|
rerender(<DataTable data={CHARLIE_ALICE_BOB} columns={nameCellColumns} size="compact" />);
|
|
expect(document.querySelector("[data-row-id]")?.className).toContain(loadedRowHeight);
|
|
});
|
|
|
|
it("does not force the compact height on default-size skeleton rows", () => {
|
|
render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameCellColumns} isLoading />);
|
|
expect(screen.getAllByTestId("skeleton-row").at(0)?.className).not.toContain("h-8");
|
|
});
|
|
|
|
it("varies skeleton shape and width per column instead of one fixed bar", () => {
|
|
const columns: ColumnDef<Person, unknown>[] = [
|
|
{ accessorKey: "name", header: "Name", meta: { skeleton: "twoLine" }, cell: () => null },
|
|
{ accessorKey: "email", header: "Email", cell: () => null },
|
|
];
|
|
render(<DataTable data={CHARLIE_ALICE_BOB} columns={columns} isLoading />);
|
|
|
|
const firstRow = screen.getAllByTestId("skeleton-row").at(0);
|
|
expect(firstRow).toBeDefined();
|
|
const bars = Array.from(firstRow?.querySelectorAll('[data-slot="skeleton"]') ?? []);
|
|
|
|
// twoLine column contributes a main + sub bar (2); the text column contributes 1
|
|
expect(bars).toHaveLength(3);
|
|
// per-column widths differ instead of every cell sharing one fixed width
|
|
expect(new Set(bars.map((bar) => bar.className)).size).toBeGreaterThan(1);
|
|
});
|
|
|
|
it("renders shape-specific skeletons for badge, chips, and meter columns", () => {
|
|
const columns: ColumnDef<Person, unknown>[] = [
|
|
{ id: "badge", header: "Badge", meta: { skeleton: "badge" }, cell: () => null },
|
|
{ id: "chips", header: "Chips", meta: { skeleton: "chips" }, cell: () => null },
|
|
{ id: "meter", header: "Meter", meta: { skeleton: "meter" }, cell: () => null },
|
|
];
|
|
render(<DataTable data={CHARLIE_ALICE_BOB} columns={columns} isLoading />);
|
|
|
|
const firstRow = screen.getAllByTestId("skeleton-row").at(0);
|
|
const cells = Array.from(firstRow?.querySelectorAll("td") ?? []);
|
|
const barsIn = (cell: Element | undefined) => cell?.querySelectorAll('[data-slot="skeleton"]').length ?? 0;
|
|
|
|
// badge = a single pill, chips = three pills, meter = value bar + track bar
|
|
expect(barsIn(cells[0])).toBe(1);
|
|
expect(cells[0]?.querySelector('[data-slot="skeleton"]')?.className).toContain("rounded-full");
|
|
expect(barsIn(cells[1])).toBe(3);
|
|
expect(barsIn(cells[2])).toBe(2);
|
|
});
|
|
|
|
it("uses a column's renderSkeleton override when provided", () => {
|
|
const columns: ColumnDef<Person, unknown>[] = [
|
|
{
|
|
id: "custom",
|
|
header: "Custom",
|
|
meta: { renderSkeleton: () => <div data-testid="custom-skeleton">loading</div> },
|
|
cell: () => null,
|
|
},
|
|
];
|
|
render(<DataTable data={CHARLIE_ALICE_BOB} columns={columns} isLoading />);
|
|
expect(screen.getAllByTestId("custom-skeleton").length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe("DataTable column visibility", () => {
|
|
it("hides a column when toggled off in the view-options menu", async () => {
|
|
const user = userEvent.setup();
|
|
const { container } = render(
|
|
<DataTable
|
|
data={CHARLIE_ALICE_BOB}
|
|
columns={nameEmailColumns}
|
|
toolbar={(table) => <DataTableViewOptions table={table} />}
|
|
/>,
|
|
);
|
|
|
|
expect(container.querySelector('th[data-header-id="email"]')).not.toBeNull();
|
|
await user.click(screen.getByTestId("view-options-trigger"));
|
|
await user.click(await screen.findByTestId("view-option-email"));
|
|
await waitFor(() => expect(container.querySelector('th[data-header-id="email"]')).toBeNull());
|
|
|
|
await user.click(screen.getByTestId("view-option-email"));
|
|
await waitFor(() => expect(container.querySelector('th[data-header-id="email"]')).not.toBeNull());
|
|
});
|
|
|
|
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")).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
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).toHaveStyle({ maxHeight: "240px" });
|
|
});
|
|
|
|
it("caps fillHeight at the parent's height instead of stretching to it, so a short table stays short", () => {
|
|
const { container } = render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} fillHeight />);
|
|
const scroller = container.querySelector('[data-slot="table-container"]')?.parentElement as HTMLElement;
|
|
const frame = scroller.parentElement as HTMLElement;
|
|
const outer = frame.parentElement as HTMLElement;
|
|
|
|
// A ceiling, not a stretch: flex-1 here would hold the footer at the bottom on a two-row table.
|
|
expect(outer.className).toContain("max-h-full");
|
|
expect(outer.className).not.toContain("flex-1");
|
|
expect(frame.className).not.toContain("flex-1");
|
|
expect(scroller.className).not.toContain("flex-1");
|
|
|
|
expect(outer.className).toContain("flex-col");
|
|
expect(frame.className).toContain("flex-col");
|
|
expect(scroller.className).toContain("min-h-0");
|
|
expect(scroller.className).toContain("overflow-auto");
|
|
expect(scroller).toHaveStyle({ maxHeight: "" });
|
|
// Without this the Table primitive's own overflow container captures the sticky header.
|
|
expect(scroller.className).toContain("[&_[data-slot=table-container]]:overflow-visible");
|
|
|
|
const thead = container.querySelector("thead") as HTMLElement;
|
|
expect(thead.className).toContain("sticky");
|
|
// Rows pass under the header, so the semi-transparent row tint alone would let them show through.
|
|
expect(thead.className).toContain("bg-background");
|
|
});
|
|
|
|
it("leaves the default layout untouched when neither height mode is set", () => {
|
|
const { container } = render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} />);
|
|
const scroller = container.querySelector('[data-slot="table-container"]')?.parentElement as HTMLElement;
|
|
|
|
expect(scroller.className).toContain("overflow-x-auto");
|
|
expect(scroller.className).not.toContain("min-h-0");
|
|
expect(scroller).toHaveStyle({ maxHeight: "" });
|
|
expect((scroller.parentElement as HTMLElement).className).not.toContain("flex-col");
|
|
expect(container.querySelector("thead")?.className).not.toContain("sticky");
|
|
expect(container.querySelector("thead")?.className).not.toContain("bg-background");
|
|
});
|
|
});
|