mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
Merge pull request #19970 from BerriAI/litellm_ui_column_sort_component
[Feature] UI - Tables: Reusable Table Sort Component
This commit is contained in:
commit
632e8cf2f6
5 changed files with 269 additions and 56 deletions
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue