From e783a66f2ab4b4eaf3dd22fe7155eeead5d87ccf Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 10 Jan 2026 17:06:00 -0800 Subject: [PATCH] Organization Filters UI --- .../OrganizationFilters.test.tsx | 125 ++++++++++++++++++ .../organizations/OrganizationFilters.tsx | 102 ++++---------- .../Filters/FilterInput.test.tsx | 59 +++++++++ .../common_components/Filters/FilterInput.tsx | 51 +++++++ .../Filters/FiltersButton.test.tsx | 37 ++++++ .../Filters/FiltersButton.tsx | 25 ++++ .../Filters/ResetFiltersButton.test.tsx | 29 ++++ .../Filters/ResetFiltersButton.tsx | 16 +++ 8 files changed, 367 insertions(+), 77 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.test.tsx create mode 100644 ui/litellm-dashboard/src/components/common_components/Filters/FilterInput.test.tsx create mode 100644 ui/litellm-dashboard/src/components/common_components/Filters/FilterInput.tsx create mode 100644 ui/litellm-dashboard/src/components/common_components/Filters/FiltersButton.test.tsx create mode 100644 ui/litellm-dashboard/src/components/common_components/Filters/FiltersButton.tsx create mode 100644 ui/litellm-dashboard/src/components/common_components/Filters/ResetFiltersButton.test.tsx create mode 100644 ui/litellm-dashboard/src/components/common_components/Filters/ResetFiltersButton.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.test.tsx new file mode 100644 index 0000000000..814625ff6b --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.test.tsx @@ -0,0 +1,125 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import OrganizationFilters, { FilterState } from "./OrganizationFilters"; + +describe("OrganizationFilters", () => { + const defaultFilters: FilterState = { + org_id: "", + org_alias: "", + sort_by: "", + sort_order: "asc", + }; + + it("should render", () => { + const onToggleFilters = vi.fn(); + const onChange = vi.fn(); + const onReset = vi.fn(); + + render( + , + ); + + expect(screen.getByPlaceholderText("Search by Organization Name")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^filters$/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /reset filters/i })).toBeInTheDocument(); + }); + + it("should show additional filters when showFilters is true", () => { + const onToggleFilters = vi.fn(); + const onChange = vi.fn(); + const onReset = vi.fn(); + + render( + , + ); + + expect(screen.getByPlaceholderText("Search by Organization ID")).toBeInTheDocument(); + }); + + it("should call onChange when organization name input changes", async () => { + const user = userEvent.setup(); + const onToggleFilters = vi.fn(); + const onChange = vi.fn(); + const onReset = vi.fn(); + + render( + , + ); + + const input = screen.getByPlaceholderText("Search by Organization Name"); + await user.type(input, "test"); + + await waitFor( + () => { + expect(onChange).toHaveBeenCalledWith("org_alias", expect.any(String)); + }, + { timeout: 500 }, + ); + }); + + it("should call onReset when reset button is clicked", async () => { + const user = userEvent.setup(); + const onToggleFilters = vi.fn(); + const onChange = vi.fn(); + const onReset = vi.fn(); + + render( + , + ); + + const resetButton = screen.getByRole("button", { name: /reset filters/i }); + await user.click(resetButton); + + expect(onReset).toHaveBeenCalledTimes(1); + }); + + it("should show badge on filters button when filters are active", () => { + const onToggleFilters = vi.fn(); + const onChange = vi.fn(); + const onReset = vi.fn(); + + const filtersWithActive: FilterState = { + ...defaultFilters, + org_alias: "test org", + }; + + render( + , + ); + + const filtersButton = screen.getByRole("button", { name: /^filters$/i }); + const badgeWrapper = filtersButton.closest(".ant-badge"); + expect(badgeWrapper).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.tsx index 9b3fa2fc02..5643a4bc51 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.tsx @@ -1,4 +1,7 @@ -import 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 { Search, User } from "lucide-react"; interface OrganizationFiltersProps { filters: FilterState; @@ -22,94 +25,39 @@ const OrganizationFilters = ({ onChange, onReset, }: OrganizationFiltersProps) => { + const hasActiveFilters = !!(filters.org_id || filters.org_alias); + return (
{/* Search and Filter Controls */}
- {/* Organization Alias Search */} -
- onChange("org_alias", e.target.value)} - /> - - - -
+ onChange("org_alias", value)} + icon={Search} + className="w-64" + /> - {/* Filter Button */} - + active={showFilters} + hasActiveFilters={hasActiveFilters} + /> - {/* Reset Filters Button */} - +
{/* Additional Filters */} {showFilters && (
- {/* Organization ID Search */} -
- onChange("org_id", e.target.value)} - /> - - - -
+ onChange("org_id", value)} + icon={User} + className="w-64" + />
)}
diff --git a/ui/litellm-dashboard/src/components/common_components/Filters/FilterInput.test.tsx b/ui/litellm-dashboard/src/components/common_components/Filters/FilterInput.test.tsx new file mode 100644 index 0000000000..e96936e179 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/Filters/FilterInput.test.tsx @@ -0,0 +1,59 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { FilterInput } from "./FilterInput"; + +describe("FilterInput", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + it("should render", () => { + const onChange = vi.fn(); + render(); + expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument(); + }); + + it("should call onChange with debounced value", async () => { + const onChange = vi.fn(); + render(); + + const input = screen.getByPlaceholderText("Search..."); + + act(() => { + fireEvent.change(input, { target: { value: "test" } }); + }); + + expect(onChange).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(onChange).toHaveBeenCalledWith("test"); + }); + + it("should display the value prop", () => { + const onChange = vi.fn(); + render(); + const input = screen.getByPlaceholderText("Search...") as HTMLInputElement; + expect(input.value).toBe("initial value"); + }); + + it("should update local value immediately when typing", async () => { + const onChange = vi.fn(); + render(); + + const input = screen.getByPlaceholderText("Search...") as HTMLInputElement; + + act(() => { + fireEvent.change(input, { target: { value: "a" } }); + }); + + expect(input.value).toBe("a"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/Filters/FilterInput.tsx b/ui/litellm-dashboard/src/components/common_components/Filters/FilterInput.tsx new file mode 100644 index 0000000000..3590619ba2 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/Filters/FilterInput.tsx @@ -0,0 +1,51 @@ +import { cx } from "@/lib/cva.config"; +import { Input } from "antd"; +import debounce from "lodash/debounce"; +import { LucideIcon } from "lucide-react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; + +interface FilterInputProps { + placeholder?: string; + value: string; + onChange: (value: string) => void; + icon?: LucideIcon; + className?: string; + style?: React.CSSProperties; +} + +const DEBOUNCE_DELAY = 300; + +export const FilterInput: React.FC = ({ placeholder, value, onChange, icon: Icon, className }) => { + const [localValue, setLocalValue] = useState(value); + + useEffect(() => { + setLocalValue(value); + }, [value]); + + const debouncedOnChange = useMemo(() => debounce((val: string) => onChange(val), DEBOUNCE_DELAY), [onChange]); + + useEffect(() => { + return () => { + debouncedOnChange.cancel(); + }; + }, [debouncedOnChange]); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const newValue = e.target.value; + setLocalValue(newValue); + debouncedOnChange(newValue); + }, + [debouncedOnChange], + ); + + return ( + : undefined} + className={cx("w-64", className)} + /> + ); +}; diff --git a/ui/litellm-dashboard/src/components/common_components/Filters/FiltersButton.test.tsx b/ui/litellm-dashboard/src/components/common_components/Filters/FiltersButton.test.tsx new file mode 100644 index 0000000000..ccb2c5d9e5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/Filters/FiltersButton.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { FiltersButton } from "./FiltersButton"; + +describe("FiltersButton", () => { + it("should render", () => { + const onClick = vi.fn(); + render(); + expect(screen.getByRole("button", { name: /filters/i })).toBeInTheDocument(); + }); + + it("should call onClick when clicked", async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render(); + + const button = screen.getByRole("button", { name: /filters/i }); + await user.click(button); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("should show badge when hasActiveFilters is true", () => { + const onClick = vi.fn(); + const { container } = render(); + const button = screen.getByRole("button", { name: /filters/i }); + const badgeWrapper = button.closest(".ant-badge"); + expect(badgeWrapper).toBeInTheDocument(); + }); + + it("should render custom label when provided", () => { + const onClick = vi.fn(); + render(); + expect(screen.getByRole("button", { name: /advanced filters/i })).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/Filters/FiltersButton.tsx b/ui/litellm-dashboard/src/components/common_components/Filters/FiltersButton.tsx new file mode 100644 index 0000000000..09782dd446 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/Filters/FiltersButton.tsx @@ -0,0 +1,25 @@ +import { Badge, Button } from "antd"; +import { Filter } from "lucide-react"; +import React from "react"; + +interface FiltersButtonProps { + onClick: () => void; + active: boolean; + hasActiveFilters: boolean; + label?: string; +} + +export const FiltersButton: React.FC = ({ + onClick, + active, + hasActiveFilters, + label = "Filters", +}) => { + return ( + + + + ); +}; diff --git a/ui/litellm-dashboard/src/components/common_components/Filters/ResetFiltersButton.test.tsx b/ui/litellm-dashboard/src/components/common_components/Filters/ResetFiltersButton.test.tsx new file mode 100644 index 0000000000..da37ee1b26 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/Filters/ResetFiltersButton.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { ResetFiltersButton } from "./ResetFiltersButton"; + +describe("ResetFiltersButton", () => { + it("should render", () => { + const onClick = vi.fn(); + render(); + expect(screen.getByRole("button", { name: /reset filters/i })).toBeInTheDocument(); + }); + + it("should call onClick when clicked", async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render(); + + const button = screen.getByRole("button", { name: /reset filters/i }); + await user.click(button); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("should render custom label when provided", () => { + const onClick = vi.fn(); + render(); + expect(screen.getByRole("button", { name: /clear all/i })).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/Filters/ResetFiltersButton.tsx b/ui/litellm-dashboard/src/components/common_components/Filters/ResetFiltersButton.tsx new file mode 100644 index 0000000000..113d03ddc0 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/Filters/ResetFiltersButton.tsx @@ -0,0 +1,16 @@ +import { Button } from "antd"; +import { RotateCcw } from "lucide-react"; +import React from "react"; + +interface ResetFiltersButtonProps { + onClick: () => void; + label?: string; +} + +export const ResetFiltersButton: React.FC = ({ onClick, label = "Reset Filters" }) => { + return ( + + ); +};