Merge pull request #19970 from BerriAI/litellm_ui_column_sort_component

[Feature] UI - Tables: Reusable Table Sort Component
This commit is contained in:
yuneng-jiang 2026-01-28 18:02:34 -08:00 • committed by GitHub
commit 632e8cf2f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 269 additions and 56 deletions

View file

@ -77,11 +77,11 @@ const AllModelsTab = ({
const sortBy = useMemo(() => {
if (sorting.length === 0) return undefined;
const sort = sorting[0];
// Map column IDs to server-side field names
// The server expects field names like "model_name", "created_at", etc.
const columnIdToServerField: Record<string, string> = {
input_cost: "costs", // Map input_cost column to "costs" for server-side sorting
model_info_db_model: "status", // Map model_info.db_model column to "status" for server-side sorting
model_info_created_by: "created_at", // Map model_info.created_by column to "created_at" for server-side sorting
model_info_updated_at: "updated_at", // Map model_info.updated_at column to "updated_at" for server-side sorting
};
return columnIdToServerField[sort.id] || sort.id;
}, [sorting]);

View file

@ -37,31 +37,3 @@ body {
.custom-border {
border: 1px solid var(--neutral-border);
}
/* Custom dropdown styles */
.ant-dropdown-menu-item {
padding: 0 !important;
}
.ant-dropdown-menu-item > div {
transition: all 0.2s ease;
}
/* Don't apply hover to user info section */
.ant-dropdown-menu-item[data-menu-id$="user-info"]:hover {
background-color: transparent !important;
cursor: default;
}
.ant-dropdown-menu-item[data-menu-id$="user-info"] > div {
cursor: default;
}
.ant-dropdown-menu {
padding: 4px !important;
min-width: 280px !important;
}
.ant-dropdown-menu-item-divider {
margin: 4px 0;
}

View file

@ -0,0 +1,148 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { TableHeaderSortDropdown } from "./TableHeaderSortDropdown";
describe("TableHeaderSortDropdown", () => {
it("should render", () => {
const onSortChange = vi.fn();
render(<TableHeaderSortDropdown sortState={false} onSortChange={onSortChange} />);
expect(screen.getByRole("button")).toBeInTheDocument();
});
it("should open dropdown menu when button is clicked", async () => {
const user = userEvent.setup();
const onSortChange = vi.fn();
render(<TableHeaderSortDropdown sortState={false} onSortChange={onSortChange} />);
const button = screen.getByRole("button");
await user.click(button);
await waitFor(() => {
expect(screen.getByText("Ascending")).toBeInTheDocument();
expect(screen.getByText("Descending")).toBeInTheDocument();
expect(screen.getByText("Reset")).toBeInTheDocument();
});
});
it("should call onSortChange with asc when ascending option is clicked", async () => {
const user = userEvent.setup();
const onSortChange = vi.fn();
render(<TableHeaderSortDropdown sortState={false} onSortChange={onSortChange} />);
const button = screen.getByRole("button");
await user.click(button);
await waitFor(() => {
expect(screen.getByText("Ascending")).toBeInTheDocument();
});
const ascendingOption = screen.getByText("Ascending");
await user.click(ascendingOption);
expect(onSortChange).toHaveBeenCalledTimes(1);
expect(onSortChange).toHaveBeenCalledWith("asc");
});
it("should call onSortChange with desc when descending option is clicked", async () => {
const user = userEvent.setup();
const onSortChange = vi.fn();
render(<TableHeaderSortDropdown sortState={false} onSortChange={onSortChange} />);
const button = screen.getByRole("button");
await user.click(button);
await waitFor(() => {
expect(screen.getByText("Descending")).toBeInTheDocument();
});
const descendingOption = screen.getByText("Descending");
await user.click(descendingOption);
expect(onSortChange).toHaveBeenCalledTimes(1);
expect(onSortChange).toHaveBeenCalledWith("desc");
});
it("should call onSortChange with false when reset option is clicked", async () => {
const user = userEvent.setup();
const onSortChange = vi.fn();
render(<TableHeaderSortDropdown sortState="asc" onSortChange={onSortChange} />);
const button = screen.getByRole("button");
await user.click(button);
await waitFor(() => {
expect(screen.getByText("Reset")).toBeInTheDocument();
});
const resetOption = screen.getByText("Reset");
await user.click(resetOption);
expect(onSortChange).toHaveBeenCalledTimes(1);
expect(onSortChange).toHaveBeenCalledWith(false);
});
it("should highlight ascending option when sort state is asc", async () => {
const user = userEvent.setup();
const onSortChange = vi.fn();
render(<TableHeaderSortDropdown sortState="asc" onSortChange={onSortChange} />);
const button = screen.getByRole("button");
await user.click(button);
await waitFor(() => {
const ascendingOption = screen.getByText("Ascending");
const menuItem = ascendingOption.closest(".ant-dropdown-menu-item");
expect(menuItem).toHaveClass("ant-dropdown-menu-item-selected");
});
});
it("should highlight descending option when sort state is desc", async () => {
const user = userEvent.setup();
const onSortChange = vi.fn();
render(<TableHeaderSortDropdown sortState="desc" onSortChange={onSortChange} />);
const button = screen.getByRole("button");
await user.click(button);
await waitFor(() => {
const descendingOption = screen.getByText("Descending");
const menuItem = descendingOption.closest(".ant-dropdown-menu-item");
expect(menuItem).toHaveClass("ant-dropdown-menu-item-selected");
});
});
it("should not highlight any option when sort state is false", async () => {
const user = userEvent.setup();
const onSortChange = vi.fn();
render(<TableHeaderSortDropdown sortState={false} onSortChange={onSortChange} />);
const button = screen.getByRole("button");
await user.click(button);
await waitFor(() => {
expect(screen.getByText("Ascending")).toBeInTheDocument();
});
const ascendingOption = screen.getByText("Ascending");
const menuItem = ascendingOption.closest(".ant-dropdown-menu-item");
expect(menuItem).not.toHaveClass("ant-dropdown-menu-item-selected");
});
it("should stop event propagation when button is clicked", async () => {
const user = userEvent.setup();
const onSortChange = vi.fn();
const onParentClick = vi.fn();
render(
<div onClick={onParentClick}>
<TableHeaderSortDropdown sortState={false} onSortChange={onSortChange} />
</div>,
);
const button = screen.getByRole("button");
await user.click(button);
expect(onParentClick).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,85 @@
import React from "react";
import { Button, Dropdown, MenuProps } from "antd";
import { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon, XIcon } from "@heroicons/react/outline";
export type SortState = "asc" | "desc" | false;
interface TableHeaderSortDropdownProps {
/**
* Current sort state: "asc", "desc", or false for neutral
*/
sortState: SortState;
/**
* Callback when sort state changes
* @param newState - The new sort state: "asc", "desc", or false
*/
onSortChange: (newState: SortState) => void;
/**
* Optional column ID for identification
*/
columnId?: string;
}
export const TableHeaderSortDropdown: React.FC<TableHeaderSortDropdownProps> = ({
sortState,
onSortChange,
}) => {
const handleMenuClick: MenuProps["onClick"] = ({ key }) => {
if (key === "asc") {
onSortChange("asc");
} else if (key === "desc") {
onSortChange("desc");
} else if (key === "reset") {
onSortChange(false);
}
};
const menuItems: MenuProps["items"] = [
{
key: "asc",
label: "Ascending",
icon: <ChevronUpIcon className="h-4 w-4" />,
},
{
key: "desc",
label: "Descending",
icon: <ChevronDownIcon className="h-4 w-4" />,
},
{
key: "reset",
label: "Reset",
icon: <XIcon className="h-4 w-4" />,
},
];
// Determine which icon to display based on current sort state
const renderIcon = () => {
if (sortState === "asc") {
return <ChevronUpIcon className="h-4 w-4" />;
} else if (sortState === "desc") {
return <ChevronDownIcon className="h-4 w-4" />;
} else {
return <SwitchVerticalIcon className="h-4 w-4" />;
}
};
return (
<Dropdown
menu={{
items: menuItems,
onClick: handleMenuClick,
selectable: true,
selectedKeys: sortState ? [sortState] : [],
}}
trigger={["click"]}
autoAdjustOverflow
>
<Button
type="text"
onClick={(e) => e.stopPropagation()}
icon={renderIcon()}
className={sortState ? "text-blue-500 hover:text-blue-600" : "text-gray-400 hover:text-blue-500"}
/>
</Dropdown>
);
};

View file

@ -12,7 +12,7 @@ import {
} from "@tanstack/react-table";
import React from "react";
import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } from "@tremor/react";
import { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/outline";
import { TableHeaderSortDropdown, SortState } from "../common_components/TableHeaderSortDropdown/TableHeaderSortDropdown";
// Extend the column meta type to include className
declare module "@tanstack/react-table" {
@ -102,17 +102,15 @@ export function AllModelsDataTable<TData, TValue>({
{headerGroup.headers.map((header) => (
<TableHeaderCell
key={header.id}
className={`py-1 h-8 relative ${
header.id === "actions"
? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)] w-[120px] ml-8"
: ""
} ${header.column.columnDef.meta?.className || ""}`}
className={`py-1 h-8 relative ${header.id === "actions"
? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)] w-[120px] ml-8"
: ""
} ${header.column.columnDef.meta?.className || ""}`}
style={{
width: header.id === "actions" ? 120 : header.getSize(),
position: header.id === "actions" ? "sticky" : "relative",
right: header.id === "actions" ? 0 : "auto",
}}
onClick={header.column.getCanSort() ? header.column.getToggleSortingHandler() : undefined}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center">
@ -120,26 +118,37 @@ export function AllModelsDataTable<TData, TValue>({
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</div>
{header.id !== "actions" && header.column.getCanSort() && (
<div className="w-4">
{header.column.getIsSorted() ? (
{
asc: <ChevronUpIcon className="h-4 w-4 text-blue-500" />,
desc: <ChevronDownIcon className="h-4 w-4 text-blue-500" />,
}[header.column.getIsSorted() as string]
) : (
<SwitchVerticalIcon className="h-4 w-4 text-gray-400" />
)}
</div>
{header.id !== "actions" && header.column.getCanSort() && onSortingChange && (
<TableHeaderSortDropdown
sortState={
header.column.getIsSorted() === false
? false
: (header.column.getIsSorted() as SortState)
}
onSortChange={(newState) => {
// Convert SortState to TanStack SortingState
// Only allow one column to be sorted at a time
if (newState === false) {
onSortingChange([]);
} else {
onSortingChange([
{
id: header.column.id,
desc: newState === "desc",
},
]);
}
}}
columnId={header.column.id}
/>
)}
</div>
{header.column.getCanResize() && (
<div
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={`absolute right-0 top-0 h-full w-2 cursor-col-resize select-none touch-none ${
header.column.getIsResizing() ? "bg-blue-500" : "hover:bg-blue-200"
}`}
className={`absolute right-0 top-0 h-full w-2 cursor-col-resize select-none touch-none ${header.column.getIsResizing() ? "bg-blue-500" : "hover:bg-blue-200"
}`}
/>
)}
</TableHeaderCell>
@ -162,11 +171,10 @@ export function AllModelsDataTable<TData, TValue>({
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={`py-0.5 ${
cell.column.id === "actions"
? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)] w-[120px] ml-8"
: ""
} ${cell.column.columnDef.meta?.className || ""}`}
className={`py-0.5 ${cell.column.id === "actions"
? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)] w-[120px] ml-8"
: ""
} ${cell.column.columnDef.meta?.className || ""}`}
style={{
width: cell.column.id === "actions" ? 120 : cell.column.getSize(),
position: cell.column.id === "actions" ? "sticky" : "relative",