diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx index fa0e672026e..c4799594465 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx @@ -18,6 +18,7 @@ import { type OnChangeFn, type Row, type RowData, + type RowSelectionState, type Table, type TableOptions, useReactTable, @@ -70,6 +71,8 @@ export function validateDataTableConfig( const bothSortingSources = props.defaultSorting !== undefined && props.sorting !== undefined; const bothFilterSources = props.defaultColumnFilters !== undefined && props.columnFilters !== undefined; + const controlledSelectionIncomplete = props.rowSelection !== undefined && props.onRowSelectionChange === undefined; + return [ serverSortingIncomplete ? "sortingMode='server' requires both `sorting` and `onSortingChange`." : null, serverPaginationIncomplete @@ -80,6 +83,9 @@ export function validateDataTableConfig( bothFilterSources ? "Provide either `defaultColumnFilters` (uncontrolled) or `columnFilters` (controlled), not both." : null, + controlledSelectionIncomplete + ? "Controlled `rowSelection` requires `onRowSelectionChange`; without it selection changes are dropped." + : null, ].filter((message): message is string => message !== null); } @@ -448,6 +454,9 @@ function useDataTableInstance(props: DataTablePro renderSubComponent, expanded, onExpandedChange, + enableRowSelection, + rowSelection, + onRowSelectionChange, } = props; const sortingState = useControllable(sorting, onSortingChange, defaultSorting ?? []); @@ -462,6 +471,7 @@ function useDataTableInstance(props: DataTablePro ); const globalFilterState = useControllable(globalFilter, onGlobalFilterChange, ""); const expandedState = useControllable(expanded, onExpandedChange, {}); + const rowSelectionState = useControllable(rowSelection, onRowSelectionChange, {}); const [columnVisibility, setColumnVisibility] = useState(defaultColumnVisibility ?? {}); const [columnSizing, setColumnSizing] = useState({}); const columnPinning = React.useMemo(() => derivePinning(columns), [columns]); @@ -476,6 +486,7 @@ function useDataTableInstance(props: DataTablePro columnFilters: filterState.value, globalFilter: globalFilterState.value, expanded: expandedState.value, + rowSelection: rowSelectionState.value, columnVisibility, columnSizing, }, @@ -491,11 +502,13 @@ function useDataTableInstance(props: DataTablePro onColumnFiltersChange: filterState.onChange, onGlobalFilterChange: globalFilterState.onChange, onExpandedChange: expandedState.onChange, + onRowSelectionChange: rowSelectionState.onChange, onColumnVisibilityChange: setColumnVisibility, onColumnSizingChange: setColumnSizing, getCoreRowModel: getCoreRowModel(), ...buildRowModels(sortingMode, paginationMode, filterMode, expansionGuard), ...(getRowId !== undefined ? { getRowId } : {}), + ...(enableRowSelection !== undefined ? { enableRowSelection } : {}), ...(paginationMode === "server" && rowCount !== undefined ? { rowCount } : {}), }; diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableRowSelection.test.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableRowSelection.test.tsx new file mode 100644 index 00000000000..2464fb93309 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableRowSelection.test.tsx @@ -0,0 +1,137 @@ +import type { ColumnDef, RowSelectionState } 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 } from "vitest"; + +import { createSelectionColumn, DataTable, validateDataTableConfig } from "./index"; + +interface Model { + id: string; + name: string; +} + +const data: Model[] = [ + { id: "m1", name: "Alpha" }, + { id: "m2", name: "Beta" }, + { id: "m3", name: "Gamma" }, +]; + +const columns: ColumnDef[] = [ + createSelectionColumn({ rowAriaLabel: (row) => `Select ${row.original.name}` }), + { id: "name", accessorKey: "name", header: "Name", enableSorting: false }, +]; + +const selectAll = () => screen.getByTestId("datatable-select-all"); +const rowBox = (id: string) => screen.getByTestId(`datatable-select-row-${id}`); +const selectedCount = () => screen.getByTestId("count"); + +function ControlledHarness() { + const [rowSelection, setRowSelection] = useState({}); + + return ( + <> + + {Object.keys(rowSelection) + .filter((key) => rowSelection[key]) + .sort() + .join(",")} + + + row.id} + rowSelection={rowSelection} + onRowSelectionChange={setRowSelection} + /> + + ); +} + +describe("DataTable row selection", () => { + it("supports uncontrolled per-row toggle, select-all, and indeterminate", async () => { + const user = userEvent.setup(); + + render( + row.id} + toolbar={(table) => {table.getSelectedRowModel().rows.length}} + />, + ); + + expect(selectedCount()).toHaveTextContent("0"); + + await user.click(rowBox("m1")); + expect(selectedCount()).toHaveTextContent("1"); + expect(selectAll()).toHaveAttribute("aria-checked", "mixed"); + + await user.click(selectAll()); + expect(selectedCount()).toHaveTextContent("3"); + expect(selectAll()).toHaveAttribute("aria-checked", "true"); + + await user.click(selectAll()); + expect(selectedCount()).toHaveTextContent("0"); + }); + + it("keys controlled selection by getRowId so the parent can map back to entities", async () => { + const user = userEvent.setup(); + render(); + + await user.click(rowBox("m2")); + expect(screen.getByTestId("keys")).toHaveTextContent("m2"); + + await user.click(rowBox("m3")); + expect(screen.getByTestId("keys")).toHaveTextContent("m2,m3"); + }); + + it("lets the parent clear the selection, the pattern an external pager needs", async () => { + const user = userEvent.setup(); + render(); + + await user.click(selectAll()); + expect(screen.getByTestId("keys")).toHaveTextContent("m1,m2,m3"); + + await user.click(screen.getByTestId("clear")); + expect(screen.getByTestId("keys")).toBeEmptyDOMElement(); + expect(rowBox("m1")).toHaveAttribute("aria-checked", "false"); + }); + + it("respects an enableRowSelection predicate", async () => { + const user = userEvent.setup(); + + render( + row.id} + enableRowSelection={(row) => row.original.id !== "m2"} + toolbar={(table) => {table.getSelectedRowModel().rows.length}} + />, + ); + + expect(rowBox("m2")).toHaveAttribute("aria-disabled", "true"); + + await user.click(rowBox("m2")); + expect(selectedCount()).toHaveTextContent("0"); + + await user.click(rowBox("m1")); + expect(selectedCount()).toHaveTextContent("1"); + }); + + it("rejects controlled rowSelection without onRowSelectionChange", () => { + const errors = validateDataTableConfig({ data, columns, rowSelection: { m1: true } }); + + expect(errors).toContain( + "Controlled `rowSelection` requires `onRowSelectionChange`; without it selection changes are dropped.", + ); + }); + + it("does not complain when selection is left uncontrolled", () => { + expect(validateDataTableConfig({ data, columns })).toHaveLength(0); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableSelectionColumn.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableSelectionColumn.tsx new file mode 100644 index 00000000000..da32a01ab0e --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableSelectionColumn.tsx @@ -0,0 +1,53 @@ +"use client"; + +import type { ColumnDef, Row, RowData, Table } from "@tanstack/react-table"; + +import { Checkbox } from "@/components/ui/checkbox"; + +interface SelectionColumnOptions { + rowAriaLabel?: (row: Row) => string; +} + +function SelectAllCheckbox({ table }: { table: Table }) { + const allSelected = table.getIsAllPageRowsSelected(); + const someSelected = table.getIsSomePageRowsSelected(); + + return ( + table.toggleAllPageRowsSelected(Boolean(checked))} + /> + ); +} + +function SelectRowCheckbox({ row, label }: { row: Row; label: string }) { + return ( + row.toggleSelected(Boolean(checked))} + /> + ); +} + +export function createSelectionColumn( + options: SelectionColumnOptions = {}, +): ColumnDef { + const { rowAriaLabel } = options; + + return { + id: "select", + size: 44, + enableSorting: false, + enableHiding: false, + enableResizing: false, + meta: { title: "Select", className: "w-11", headerClassName: "w-11" }, + header: ({ table }) => , + cell: ({ row }) => , + }; +} diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/index.ts b/ui/litellm-dashboard/src/components/shared/DataTable/index.ts index 1ee1eed1258..62ddd1b0742 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/index.ts +++ b/ui/litellm-dashboard/src/components/shared/DataTable/index.ts @@ -3,6 +3,7 @@ import "./columnMeta"; export { DataTable, DataTableConfigError, validateDataTableConfig } from "./DataTable"; export { DataTableFilterDrawer, DataTableFilterField, type FilterDraft } from "./DataTableFilterDrawer"; export { DataTablePagination, DEFAULT_PAGE_SIZE_OPTIONS } from "./DataTablePagination"; +export { createSelectionColumn } from "./DataTableSelectionColumn"; export { DataTableToolbar } from "./DataTableToolbar"; export { DataTableViewOptions } from "./DataTableViewOptions"; export { diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/types.ts b/ui/litellm-dashboard/src/components/shared/DataTable/types.ts index 672ab512ef4..40f3a4df204 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/types.ts +++ b/ui/litellm-dashboard/src/components/shared/DataTable/types.ts @@ -6,6 +6,7 @@ import type { PaginationState, Row, RowData, + RowSelectionState, SortingState, Table, VisibilityState, @@ -59,6 +60,10 @@ export interface DataTableProps { expanded?: ExpandedState; onExpandedChange?: OnChangeFn; + enableRowSelection?: boolean | ((row: Row) => boolean); + rowSelection?: RowSelectionState; + onRowSelectionChange?: OnChangeFn; + onRowClick?: (row: TData) => void; rowClassName?: (row: Row) => string; diff --git a/ui/litellm-dashboard/src/components/ui/checkbox.tsx b/ui/litellm-dashboard/src/components/ui/checkbox.tsx new file mode 100644 index 00000000000..93f419e79a7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"; + +import { cn } from "@/lib/cva.config"; +import { CheckIcon } from "lucide-react"; + +function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) { + return ( + + + + + + ); +} + +export { Checkbox };