mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
Merge pull request #39084 from BerriAI/litellm_ui_datatable_behavior_tests
test(ui): assert DataTable behavior instead of DOM structure
This commit is contained in:
commit
795098c375
3 changed files with 66 additions and 65 deletions
|
|
@ -5,7 +5,7 @@
|
|||
"max-depth": { "max": 70, "target": 30 },
|
||||
"local/no-large-inline-object-arg": { "max": 559, "target": 300 },
|
||||
"local/no-long-condition-chain": { "max": 265, "target": 120 },
|
||||
"testing-library/no-container": { "max": 150, "target": 50 },
|
||||
"testing-library/no-node-access": { "max": 760, "target": 500 },
|
||||
"testing-library/no-container": { "max": 133, "target": 50 },
|
||||
"testing-library/no-node-access": { "max": 723, "target": 500 },
|
||||
"testing-library/prefer-screen-queries": { "max": 221, "target": 0 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { ColumnDef, ExpandedState } from "@tanstack/react-table";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
|
@ -21,6 +21,12 @@ function person(id: string, name: string, flagged = false): Person {
|
|||
|
||||
const names = (): (string | null)[] => screen.getAllByTestId("name-cell").map((el) => el.textContent);
|
||||
|
||||
const heightClassesOf = (el: HTMLElement | undefined): string[] =>
|
||||
(el?.className ?? "")
|
||||
.split(/\s+/)
|
||||
.filter((cls) => cls.startsWith("h-"))
|
||||
.sort();
|
||||
|
||||
const nameCellColumns: ColumnDef<Person, unknown>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
|
|
@ -229,12 +235,10 @@ describe("DataTable sorting", () => {
|
|||
|
||||
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 />);
|
||||
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%");
|
||||
expect(screen.getByRole("table")).toHaveStyle({ minWidth: "100%" });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -354,17 +358,21 @@ describe("DataTable loading", () => {
|
|||
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);
|
||||
const skeletonHeight = heightClassesOf(screen.getAllByRole("row").at(-1));
|
||||
|
||||
rerender(<DataTable data={CHARLIE_ALICE_BOB} columns={nameCellColumns} size="compact" />);
|
||||
expect(document.querySelector("[data-row-id]")?.className).toContain(loadedRowHeight);
|
||||
const loadedHeight = heightClassesOf(screen.getByRole("row", { name: /Charlie/ }));
|
||||
|
||||
expect(loadedHeight).not.toEqual([]);
|
||||
expect(skeletonHeight).toEqual(loadedHeight);
|
||||
});
|
||||
|
||||
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");
|
||||
const { rerender } = render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameCellColumns} isLoading />);
|
||||
const skeletonHeight = heightClassesOf(screen.getAllByRole("row").at(-1));
|
||||
|
||||
rerender(<DataTable data={CHARLIE_ALICE_BOB} columns={nameCellColumns} size="compact" isLoading />);
|
||||
expect(heightClassesOf(screen.getAllByRole("row").at(-1))).not.toEqual(skeletonHeight);
|
||||
});
|
||||
|
||||
it("varies skeleton shape and width per column instead of one fixed bar", () => {
|
||||
|
|
@ -420,7 +428,7 @@ describe("DataTable loading", () => {
|
|||
describe("DataTable column visibility", () => {
|
||||
it("hides a column when toggled off in the view-options menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
render(
|
||||
<DataTable
|
||||
data={CHARLIE_ALICE_BOB}
|
||||
columns={nameEmailColumns}
|
||||
|
|
@ -428,13 +436,13 @@ describe("DataTable column visibility", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('th[data-header-id="email"]')).not.toBeNull();
|
||||
expect(screen.getByRole("columnheader", { name: "Email" })).toBeInTheDocument();
|
||||
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 waitFor(() => expect(screen.queryByRole("columnheader", { name: "Email" })).not.toBeInTheDocument());
|
||||
|
||||
await user.click(screen.getByTestId("view-option-email"));
|
||||
await waitFor(() => expect(container.querySelector('th[data-header-id="email"]')).not.toBeNull());
|
||||
expect(await screen.findByRole("columnheader", { name: "Email" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits columns that opt out of hiding from the menu", async () => {
|
||||
|
|
@ -468,14 +476,10 @@ describe("DataTable column visibility", () => {
|
|||
|
||||
describe("DataTable pinned columns", () => {
|
||||
it("applies sticky positioning to a pinned column only", () => {
|
||||
const { container } = render(<DataTable data={CHARLIE_ALICE_BOB} columns={pinnedColumns} />);
|
||||
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("");
|
||||
expect(screen.getByRole("columnheader", { name: "Name" })).toHaveStyle({ position: "sticky", left: "0px" });
|
||||
expect(screen.getByRole("columnheader", { name: "Email" })).not.toHaveStyle({ position: "sticky" });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -570,7 +574,7 @@ describe("DataTable expansion", () => {
|
|||
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(
|
||||
render(
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={nameCellColumns}
|
||||
|
|
@ -579,8 +583,8 @@ describe("DataTable row styling and footer", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
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");
|
||||
expect(screen.getByRole("row", { name: /Alice/ })).toHaveClass("flagged-row");
|
||||
expect(screen.getByRole("row", { name: /Bob/ })).not.toHaveClass("flagged-row");
|
||||
});
|
||||
|
||||
it("renders the footer slot inside a tfoot element", () => {
|
||||
|
|
@ -596,63 +600,57 @@ describe("DataTable row styling and footer", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("footer-row").closest("tfoot")).not.toBeNull();
|
||||
const rowGroups = screen.getAllByRole("rowgroup");
|
||||
expect(within(rowGroups.at(-1) as HTMLElement).getByText("Total: 3")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
const { rerender } = render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} enableColumnResizing />);
|
||||
expect(screen.getByTestId("column-resizer-name")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("column-resizer-email")).toBeInTheDocument();
|
||||
|
||||
rerender(<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} />);
|
||||
expect(container.querySelectorAll("[data-resizer]").length).toBe(0);
|
||||
expect(screen.queryByTestId("column-resizer-name")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
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" });
|
||||
render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} maxBodyHeight={240} />);
|
||||
expect(screen.getByTestId("data-table-head")).toHaveClass("sticky");
|
||||
expect(screen.getByTestId("data-table-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;
|
||||
render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} fillHeight />);
|
||||
const outer = screen.getByTestId("data-table-root");
|
||||
const frame = screen.getByTestId("data-table-frame");
|
||||
const scroller = screen.getByTestId("data-table-scroller");
|
||||
|
||||
// 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).toHaveClass("max-h-full", "flex-col");
|
||||
expect(outer).not.toHaveClass("flex-1");
|
||||
expect(frame).toHaveClass("flex-col");
|
||||
expect(frame).not.toHaveClass("flex-1");
|
||||
expect(scroller).not.toHaveClass("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).toHaveClass("min-h-0", "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");
|
||||
expect(scroller).toHaveClass("[&_[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");
|
||||
expect(screen.getByTestId("data-table-head")).toHaveClass("sticky", "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;
|
||||
render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} />);
|
||||
const scroller = screen.getByTestId("data-table-scroller");
|
||||
|
||||
expect(scroller.className).toContain("overflow-x-auto");
|
||||
expect(scroller.className).not.toContain("min-h-0");
|
||||
expect(scroller).toHaveClass("overflow-x-auto");
|
||||
expect(scroller).not.toHaveClass("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");
|
||||
expect(screen.getByTestId("data-table-frame")).not.toHaveClass("flex-col");
|
||||
expect(screen.getByTestId("data-table-head")).not.toHaveClass("sticky", "bg-background");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -195,8 +195,7 @@ function DataTableHeadCell<TData>({ header, size, stickyHeader, enableColumnResi
|
|||
)}
|
||||
{canResize && (
|
||||
<div
|
||||
data-resizer
|
||||
data-header-id={header.id}
|
||||
data-testid={`column-resizer-${header.id}`}
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
onDoubleClick={() => column.resetSize()}
|
||||
|
|
@ -589,15 +588,19 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
|
|||
const paginationNode = renderPagination();
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", fill.outer)}>
|
||||
<div className={cn("overflow-hidden rounded-lg border border-border", fill.frame)}>
|
||||
<div data-testid="data-table-root" className={cn("w-full", fill.outer)}>
|
||||
<div data-testid="data-table-frame" className={cn("overflow-hidden rounded-lg border border-border", fill.frame)}>
|
||||
{toolbar !== undefined && <div className="shrink-0 border-b border-border px-4 py-3">{toolbar(table)}</div>}
|
||||
<div
|
||||
data-testid="data-table-scroller"
|
||||
className={cn(stickyHeader ? "overflow-auto" : "overflow-x-auto", fill.body)}
|
||||
style={maxBodyHeight !== undefined ? { maxHeight: maxBodyHeight } : undefined}
|
||||
>
|
||||
<TableRoot className={enableColumnResizing ? "table-fixed" : ""} style={tableStyle}>
|
||||
<TableHeader className={cn(stickyHeader ? "sticky top-0 z-sticky" : "", fill.header)}>
|
||||
<TableHeader
|
||||
data-testid="data-table-head"
|
||||
className={cn(stickyHeader ? "sticky top-0 z-sticky" : "", fill.header)}
|
||||
>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="bg-muted/50">
|
||||
{headerGroup.headers.map((header) => (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue