diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx new file mode 100644 index 00000000000..df42c156975 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx @@ -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[] = [ + { + accessorKey: "name", + header: "Name", + cell: ({ row }) => {row.original.name}, + }, +]; + +const headerCycleColumns: ColumnDef[] = [ + { + accessorKey: "name", + header: ({ column }) => , + cell: ({ row }) => {row.original.name}, + }, +]; + +const dropdownSortColumns: ColumnDef[] = [ + { + accessorKey: "name", + header: ({ column }) => , + cell: ({ row }) => {row.original.name}, + }, +]; + +const nameEmailColumns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + cell: ({ row }) => {row.original.name}, + }, + { + accessorKey: "email", + header: "Email", + cell: ({ row }) => {row.original.email}, + }, +]; + +const pinnedColumns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + cell: ({ row }) => {row.original.name}, + meta: { pinned: "left" }, + }, + { + accessorKey: "email", + header: "Email", + cell: ({ row }) => {row.original.email}, + }, +]; + +const rowClickColumns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + cell: ({ row }) => {row.original.name}, + }, + { + id: "actions", + header: "Actions", + cell: () => ( +
+ + +
+ ), + }, +]; + +const expansionColumns: ColumnDef[] = [ + { + id: "expander", + header: "", + cell: ({ row }) => ( + + ), + }, + { + accessorKey: "name", + header: "Name", + cell: ({ row }) => {row.original.name}, + }, +]; + +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(); + + 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( + , + ); + + // 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(); + + 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(); + + 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( + , + ); + + 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( + } + />, + ); + + 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[] = [ + { + accessorKey: "name", + header: "Name", + enableHiding: false, + cell: ({ row }) => {row.original.name}, + }, + { + accessorKey: "email", + header: "Email", + cell: ({ row }) => {row.original.email}, + }, + ]; + render( + } + />, + ); + + 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(); + + const pinnedHead = container.querySelector('th[data-header-id="name"]'); + const normalHead = container.querySelector('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(); + + 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 } }) => ( +
details for {row.original.name}
+ ); + + it("toggles the sub-row in uncontrolled mode", async () => { + const user = userEvent.setup(); + render( + 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({}); + return ( + row.id} + expanded={expanded} + onExpandedChange={setExpanded} + getRowCanExpand={() => true} + renderSubComponent={subComponent} + /> + ); + }; + render(); + + 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( + 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( + 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( + ( + + Total: 3 + + )} + />, + ); + + 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( + , + ); + expect(container.querySelectorAll("[data-resizer][data-header-id]").length).toBe(2); + + rerender(); + expect(container.querySelectorAll("[data-resizer]").length).toBe(0); + }); + + it("makes the header sticky and constrains body height when maxBodyHeight is set", () => { + const { container } = render(); + 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()).toThrow( + /sortingMode='server'/, + ); + spy.mockRestore(); + }); + + it("throws when server pagination is missing required props", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => render()).toThrow( + /paginationMode='server'/, + ); + spy.mockRestore(); + }); + + it("throws when both defaultSorting and sorting are provided", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => + render( + , + ), + ).toThrow(/defaultSorting/); + spy.mockRestore(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx new file mode 100644 index 00000000000..7655454f381 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx @@ -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( + props: DataTableProps, +): 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(column: ColumnDef): 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(columns: ColumnDef[]): 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( + sortingMode: SortingMode, + paginationMode: PaginationMode, + getRowCanExpand: ((row: Row) => boolean) | undefined, +): Partial> { + 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( + column: Column, + 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( + column: Column, + enableColumnResizing: boolean, +): React.CSSProperties | undefined { + if (enableColumnResizing || column.columnDef.size !== undefined) { + return { width: column.getSize() }; + } + return undefined; +} + +interface HeadCellProps { + header: Header; + size: DataTableSize; + stickyHeader: boolean; + enableColumnResizing: boolean; +} + +function DataTableHeadCell({ header, size, stickyHeader, enableColumnResizing }: HeadCellProps) { + const { column } = header; + const meta = column.columnDef.meta; + const sticky = computeStickyStyle(column, true, stickyHeader); + const canResize = enableColumnResizing && column.getCanResize(); + + return ( + + {header.isPlaceholder ? null : ( +
+ {flexRender(column.columnDef.header, header.getContext())} +
+ )} + {canResize && ( +
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" : "", + )} + /> + )} + + ); +} + +interface BodyCellProps { + cell: Cell; + size: DataTableSize; + stickyHeader: boolean; + enableColumnResizing: boolean; +} + +function DataTableBodyCell({ cell, size, stickyHeader, enableColumnResizing }: BodyCellProps) { + const { column } = cell; + const meta = column.columnDef.meta; + const sticky = computeStickyStyle(column, false, stickyHeader); + + return ( + + {flexRender(column.columnDef.cell, cell.getContext())} + + ); +} + +interface BodyRowProps { + row: Row; + size: DataTableSize; + stickyHeader: boolean; + enableColumnResizing: boolean; + onRowClick?: (row: TData) => void; + rowClassName?: (row: Row) => string; + renderSubComponent?: (props: { row: Row }) => React.ReactElement; +} + +function DataTableBodyRow({ + row, + size, + stickyHeader, + enableColumnResizing, + onRowClick, + rowClassName, + renderSubComponent, +}: BodyRowProps) { + const clickable = onRowClick !== undefined; + const cells = row.getVisibleCells(); + + const handleClick = (event: React.MouseEvent) => { + 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 ( + + + {cells.map((cell) => ( + + ))} + + {renderSubComponent !== undefined && row.getIsExpanded() && ( + + + {renderSubComponent({ row })} + + + )} + + ); +} + +function MessageRow({ colSpan, children }: { colSpan: number; children: React.ReactNode }) { + return ( + + + {children} + + + ); +} + +function useControllable( + controlled: T | undefined, + controlledOnChange: OnChangeFn | undefined, + initial: T, +): { value: T; onChange: OnChangeFn } { + const [internal, setInternal] = useState(initial); + if (controlled !== undefined) { + return { value: controlled, onChange: controlledOnChange ?? noop }; + } + return { value: internal, onChange: setInternal }; +} + +function useDataTableInstance(props: DataTableProps): Table { + 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(expanded, onExpandedChange, {}); + const [columnVisibility, setColumnVisibility] = useState(defaultColumnVisibility ?? {}); + const [columnSizing, setColumnSizing] = useState({}); + const columnPinning = React.useMemo(() => derivePinning(columns), [columns]); + const expansionGuard = renderSubComponent !== undefined ? getRowCanExpand : undefined; + + const tableOptions: TableOptions = { + 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(props: DataTableProps) { + // Validate once at construction so a misconfig surfaces immediately instead of on every render. + useState(() => { + 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 ( + table.setPageIndex(next)} + onPageSizeChange={(next) => table.setPageSize(next)} + pageSizeOptions={pageSizeOptions} + isLoading={isLoading} + /> + ); + }; + + const renderBody = (): React.ReactNode => { + if (isLoading) { + return {loadingMessage}; + } + if (rows.length === 0) { + return {noDataMessage}; + } + return rows.map((row) => ( + + )); + }; + + return ( +
+ {toolbar !== undefined &&
{toolbar(table)}
} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + {renderBody()} + {footer !== undefined && {footer(table)}} + +
+ {renderPagination()} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTablePagination.test.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTablePagination.test.tsx new file mode 100644 index 00000000000..e5bd4a55b53 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTablePagination.test.tsx @@ -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(); + 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(); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 91-100 of 100"); + }); + + it("disables the previous controls on the first page", () => { + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + expect(screen.getByTestId("pagination-next")).toBeDisabled(); + expect(screen.getByTestId("pagination-prev")).toBeDisabled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTablePagination.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTablePagination.tsx new file mode 100644 index 00000000000..5a30b12f27f --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTablePagination.tsx @@ -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 ( +
+
+ Rows per page + +
+ +
+ + {rowCount === 0 ? "No results" : `Showing ${start}-${end} of ${rowCount}`} + +
+ + + + +
+
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableSortHeader.test.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableSortHeader.test.tsx new file mode 100644 index 00000000000..a6164307a78 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableSortHeader.test.tsx @@ -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; +} + +function SortHeaderHarness({ variant, canSort = true, onSortingChange }: HarnessProps) { + const [sorting, setSorting] = useState([]); + const columns: ColumnDef[] = [ + { + accessorKey: "name", + enableSorting: canSort, + header: ({ column }) => , + }, + ]; + 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.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + +
{flexRender(header.column.columnDef.header, header.getContext())}
+ ); +} + +describe("DataTableSortHeader", () => { + it("renders a plain label and no button when the column cannot sort", () => { + render(); + 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(); + 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(); + + 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( +
+ +
, + ); + + await user.click(screen.getByTestId("sort-trigger-name")); + expect(onOuterClick).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableSortHeader.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableSortHeader.tsx new file mode 100644 index 00000000000..1cf09ce4f47 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableSortHeader.tsx @@ -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 { + column: Column; + title: React.ReactNode; + variant?: DataTableSortVariant; + className?: string; +} + +function SortIndicator({ sorted }: { sorted: false | SortDirection }) { + if (sorted === "asc") { + return ; + } + if (sorted === "desc") { + return ; + } + return ; +} + +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({ + column, + title, + variant = "header-cycle", + className, +}: DataTableSortHeaderProps) { + const sorted = column.getIsSorted(); + + if (!column.getCanSort()) { + return {title}; + } + + if (variant === "dropdown-tristate") { + return ( +
+ {title} + + event.stopPropagation()} + className={cn( + "inline-flex size-6 items-center justify-center rounded-md hover:bg-muted", + sorted ? "text-primary" : "text-muted-foreground", + )} + > + + + } + /> + + + + column.toggleSorting(false)}> + Ascending + + column.toggleSorting(true)}> + Descending + + column.clearSorting()}> + Reset + + + + + +
+ ); + } + + return ( + + ); +} diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableToolbar.test.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableToolbar.test.tsx new file mode 100644 index 00000000000..5d1f5340f5d --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableToolbar.test.tsx @@ -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( + + + , + ); + 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(); + expect(screen.queryByText("Reset Filters")).toBeNull(); + + rerender(); + 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(); + await user.click(screen.getByText("Filters")); + expect(onToggleFilters).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableToolbar.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableToolbar.tsx new file mode 100644 index 00000000000..80b4ecc7edd --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableToolbar.tsx @@ -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 ( +
+
+ {onSearchChange !== undefined && ( + + )} + {onToggleFilters !== undefined && ( + + )} + {showReset && } +
+ {children !== undefined &&
{children}
} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableViewOptions.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableViewOptions.tsx new file mode 100644 index 00000000000..ab56aafe7b5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableViewOptions.tsx @@ -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 { + table: Table; + label?: string; + className?: string; +} + +export function DataTableViewOptions({ table, label = "View", className }: DataTableViewOptionsProps) { + const hideableColumns = table.getAllLeafColumns().filter((column) => column.getCanHide()); + + if (hideableColumns.length === 0) { + return null; + } + + return ( + + + + {label} + + } + /> + + + + {hideableColumns.map((column) => ( + 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" + > + + + + {column.columnDef.meta?.title ?? column.id} + + ))} + + + + + ); +} diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/columnMeta.ts b/ui/litellm-dashboard/src/components/shared/DataTable/columnMeta.ts new file mode 100644 index 00000000000..46e72226038 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/columnMeta.ts @@ -0,0 +1,13 @@ +import type { RowData } from "@tanstack/react-table"; + +import type { ColumnPinnedSide } from "./types"; + +declare module "@tanstack/react-table" { + interface ColumnMeta { + numeric?: boolean; + className?: string; + headerClassName?: string; + title?: string; + pinned?: ColumnPinnedSide; + } +} diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/index.ts b/ui/litellm-dashboard/src/components/shared/DataTable/index.ts new file mode 100644 index 00000000000..49a4430bbee --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/index.ts @@ -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"; diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/types.ts b/ui/litellm-dashboard/src/components/shared/DataTable/types.ts new file mode 100644 index 00000000000..8fa6f21c4d3 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/types.ts @@ -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 { + data: TData[]; + columns: ColumnDef[]; + getRowId?: (row: TData, index: number, parent?: Row) => string; + + isLoading?: boolean; + loadingMessage?: string; + noDataMessage?: React.ReactNode; + + sortingMode?: SortingMode; + sorting?: SortingState; + onSortingChange?: OnChangeFn; + defaultSorting?: SortingState; + enableSortingRemoval?: boolean; + + paginationMode?: PaginationMode; + pagination?: PaginationState; + onPaginationChange?: OnChangeFn; + rowCount?: number; + pageSizeOptions?: number[]; + + enableColumnResizing?: boolean; + columnResizeMode?: ColumnResizeMode; + defaultColumnVisibility?: VisibilityState; + + getRowCanExpand?: (row: Row) => boolean; + renderSubComponent?: (props: { row: Row }) => React.ReactElement; + expanded?: ExpandedState; + onExpandedChange?: OnChangeFn; + + onRowClick?: (row: TData) => void; + + rowClassName?: (row: Row) => string; + + maxBodyHeight?: number | string; + size?: DataTableSize; + + toolbar?: (table: Table) => React.ReactNode; + paginationSlot?: (table: Table) => React.ReactNode; + footer?: (table: Table) => React.ReactNode; +}