From bf0870758f0484854c3add8389dcf91f212d64d1 Mon Sep 17 00:00:00 2001 From: cursor Date: Fri, 24 Apr 2026 05:51:12 +0000 Subject: [PATCH] feat(ui): migrate molecules/filter to shadcn Co-authored-by: yuneng-jiang --- .../src/components/molecules/filter.test.tsx | 13 +- .../src/components/molecules/filter.tsx | 220 ++++++++++++++---- ui/litellm-dashboard/tests/setupTests.ts | 18 ++ 3 files changed, 202 insertions(+), 49 deletions(-) diff --git a/ui/litellm-dashboard/src/components/molecules/filter.test.tsx b/ui/litellm-dashboard/src/components/molecules/filter.test.tsx index 1a90c4a069f..c5fcbb2ee63 100644 --- a/ui/litellm-dashboard/src/components/molecules/filter.test.tsx +++ b/ui/litellm-dashboard/src/components/molecules/filter.test.tsx @@ -279,7 +279,10 @@ describe("FilterComponent", () => { const modelSection = modelLabel.closest("div"); const modelSelect = within(modelSection!).getByRole("combobox"); await user.click(modelSelect); - await user.type(modelSelect, "test"); + + // shadcn Popover renders a search textbox once open; type into that. + const searchInput = await screen.findByPlaceholderText("Search Model..."); + await user.type(searchInput, "test"); expect(mockSearchFn).not.toHaveBeenCalled(); @@ -329,7 +332,9 @@ describe("FilterComponent", () => { const modelSection = modelLabel.closest("div"); const modelSelect = within(modelSection!).getByRole("combobox"); await user.click(modelSelect); - await user.type(modelSelect, "test"); + + const searchInput = await screen.findByPlaceholderText("Search Model..."); + await user.type(searchInput, "test"); await waitFor( () => { @@ -378,7 +383,9 @@ describe("FilterComponent", () => { const modelSection = modelLabel.closest("div"); const modelSelect = within(modelSection!).getByRole("combobox"); await user.click(modelSelect); - await user.type(modelSelect, "test"); + + const searchInput = await screen.findByPlaceholderText("Search Model..."); + await user.type(searchInput, "test"); await waitFor( () => { diff --git a/ui/litellm-dashboard/src/components/molecules/filter.tsx b/ui/litellm-dashboard/src/components/molecules/filter.tsx index dbd0a6819da..390a9c219f3 100644 --- a/ui/litellm-dashboard/src/components/molecules/filter.tsx +++ b/ui/litellm-dashboard/src/components/molecules/filter.tsx @@ -1,9 +1,21 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Select } from "antd"; -import { Filter } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import { Filter, Loader2, X } from "lucide-react"; import debounce from "lodash/debounce"; -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; export interface FilterOptionCustomComponentProps { value?: string; @@ -33,6 +45,122 @@ interface FilterComponentProps { onResetFilters: () => void; } +// Searchable combobox — shadcn Popover + async results list. Mirrors antd's +// Select in showSearch mode closely enough to preserve UX: +// - typing triggers debounced search via option.searchFn +// - selecting a result closes the popover and sets the value +// - clear button (x) on the trigger resets the value +interface SearchableSelectProps { + option: FilterOption; + value: string; + loading: boolean; + results: Array<{ label: string; value: string }>; + onOpenChange: (open: boolean) => void; + onSearch: (text: string) => void; + onSelect: (value: string) => void; +} + +const SearchableSelect: React.FC = ({ + option, + value, + loading, + results, + onOpenChange, + onSearch, + onSelect, +}) => { + const [open, setOpen] = useState(false); + const [searchText, setSearchText] = useState(""); + const selectedOption = results.find((r) => r.value === value); + const displayLabel = selectedOption?.label ?? value; + + const handleOpenChange = (next: boolean) => { + setOpen(next); + onOpenChange(next); + if (!next) setSearchText(""); + }; + + return ( + + + + + +
+ { + setSearchText(e.target.value); + onSearch(e.target.value); + }} + placeholder={`Search ${option.label || option.name}...`} + className="h-8 border-0 focus-visible:ring-0 focus-visible:ring-offset-0 shadow-none" + /> +
+
+ {loading ? ( +
+ + Loading... +
+ ) : results.length === 0 ? ( +
+ No results found +
+ ) : ( + results.map((r) => ( + + )) + )} +
+
+
+ ); +}; + const FilterComponent: React.FC = ({ options, onApplyFilters, @@ -48,13 +176,16 @@ const FilterComponent: React.FC = ({ const [searchLoadingMap, setSearchLoadingMap] = useState<{ [key: string]: boolean; }>({}); - const [searchInputValueMap, setSearchInputValueMap] = useState<{ - [key: string]: string; - }>({}); const [initialOptionsLoaded, setInitialOptionsLoaded] = useState<{ [key: string]: boolean; }>({}); + // Keep internal state in sync with externally provided initialValues + const initialValuesRef = useRef(initialValues); + useEffect(() => { + initialValuesRef.current = initialValues; + }, [initialValues]); + // eslint-disable-next-line react-hooks/exhaustive-deps const debouncedSearch = useCallback( debounce(async (value: string, option: FilterOption) => { @@ -83,7 +214,6 @@ const FilterComponent: React.FC = ({ setInitialOptionsLoaded((prev) => ({ ...prev, [option.name]: true })); try { - // Load initial options with empty search to get some default results const results = await option.searchFn(""); setSearchOptionsMap((prev) => ({ ...prev, [option.name]: results })); } catch (error) { @@ -96,7 +226,6 @@ const FilterComponent: React.FC = ({ [initialOptionsLoaded], ); - // Load initial options when filters are shown useEffect(() => { if (showFilters) { options.forEach((option) => { @@ -125,14 +254,12 @@ const FilterComponent: React.FC = ({ onResetFilters(); }; - // Handle dropdown open to load initial options - const handleDropdownVisibleChange = (open: boolean, option: FilterOption) => { + const handleDropdownOpenChange = (open: boolean, option: FilterOption) => { if (open && option.isSearchable && !initialOptionsLoaded[option.name]) { loadInitialOptions(option); } }; - // Define the order of filters const orderedFilters = [ "Team ID", "Status", @@ -149,10 +276,7 @@ const FilterComponent: React.FC = ({ return (
- @@ -164,7 +288,9 @@ const FilterComponent: React.FC = ({ {showFilters && (
{orderedFilters.map((filterName) => { - const option = options.find((opt) => opt.label === filterName || opt.name === filterName); + const option = options.find( + (opt) => opt.label === filterName || opt.name === filterName, + ); if (!option) return null; return ( @@ -173,41 +299,43 @@ const FilterComponent: React.FC = ({ {option.label || option.name} {option.isSearchable ? ( - handleFilterChange(option.name, value)} - allowClear + value={tempValues[option.name] || ""} + onValueChange={(value) => + handleFilterChange(option.name, value === "__clear__" ? "" : value) + } > - {option.options.map((opt) => ( - - {opt.label} - - ))} + + + + + + + (Any) + + + {option.options.map((opt) => ( + + {opt.label} + + ))} + ) : option.customComponent ? ( (() => { diff --git a/ui/litellm-dashboard/tests/setupTests.ts b/ui/litellm-dashboard/tests/setupTests.ts index 5f85a80e580..e9e3f7fe2dc 100644 --- a/ui/litellm-dashboard/tests/setupTests.ts +++ b/ui/litellm-dashboard/tests/setupTests.ts @@ -112,3 +112,21 @@ global.ResizeObserver = class ResizeObserver { unobserve() {} disconnect() {} }; + +// Radix UI primitives (used by shadcn Select/Popover/Dropdown) call +// element.hasPointerCapture / setPointerCapture / releasePointerCapture in +// their pointer-event handlers. JSDOM doesn't implement these, so stub them +// here to prevent "target.hasPointerCapture is not a function" exceptions +// from bubbling up during user-event interactions. +if (!HTMLElement.prototype.hasPointerCapture) { + HTMLElement.prototype.hasPointerCapture = () => false; +} +if (!HTMLElement.prototype.setPointerCapture) { + HTMLElement.prototype.setPointerCapture = () => {}; +} +if (!HTMLElement.prototype.releasePointerCapture) { + HTMLElement.prototype.releasePointerCapture = () => {}; +} +if (!HTMLElement.prototype.scrollIntoView) { + HTMLElement.prototype.scrollIntoView = () => {}; +}