diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx index a5ec9d35787..a5553fa5f56 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx @@ -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 = { 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]); diff --git a/ui/litellm-dashboard/src/app/globals.css b/ui/litellm-dashboard/src/app/globals.css index a702982678e..0d453e284ef 100644 --- a/ui/litellm-dashboard/src/app/globals.css +++ b/ui/litellm-dashboard/src/app/globals.css @@ -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; -} diff --git a/ui/litellm-dashboard/src/components/common_components/TableHeaderSortDropdown/TableHeaderSortDropdown.test.tsx b/ui/litellm-dashboard/src/components/common_components/TableHeaderSortDropdown/TableHeaderSortDropdown.test.tsx new file mode 100644 index 00000000000..58395371bbe --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/TableHeaderSortDropdown/TableHeaderSortDropdown.test.tsx @@ -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(); + expect(screen.getByRole("button")).toBeInTheDocument(); + }); + + it("should open dropdown menu when button is clicked", async () => { + const user = userEvent.setup(); + const onSortChange = vi.fn(); + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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( +
+ +
, + ); + + const button = screen.getByRole("button"); + await user.click(button); + + expect(onParentClick).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/TableHeaderSortDropdown/TableHeaderSortDropdown.tsx b/ui/litellm-dashboard/src/components/common_components/TableHeaderSortDropdown/TableHeaderSortDropdown.tsx new file mode 100644 index 00000000000..cbdc8beaaa4 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/TableHeaderSortDropdown/TableHeaderSortDropdown.tsx @@ -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 = ({ + 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: , + }, + { + key: "desc", + label: "Descending", + icon: , + }, + { + key: "reset", + label: "Reset", + icon: , + }, + ]; + + // Determine which icon to display based on current sort state + const renderIcon = () => { + if (sortState === "asc") { + return ; + } else if (sortState === "desc") { + return ; + } else { + return ; + } + }; + + return ( + +