diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx index e35c0103f7c..6759a9af63f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx @@ -15,6 +15,7 @@ import { ComboboxItem, ComboboxList, ComboboxValue, + useComboboxAnchor, } from "@/components/ui/combobox"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -68,6 +69,8 @@ interface CachePageProps { // Helper function to deep-parse a JSON string if possible const CacheDashboard: React.FC = ({ accessToken, token, userRole, userID, premiumUser }) => { + const anchor1 = useComboboxAnchor(); + const anchor2 = useComboboxAnchor(); const [selectedApiKeys, setSelectedApiKeys] = useState([]); const [selectedModels, setSelectedModels] = useState([]); @@ -194,7 +197,7 @@ const CacheDashboard: React.FC = ({ accessToken, token, userRole value={selectedApiKeys} onValueChange={(keys: string[]) => setSelectedApiKeys(keys)} > - + }> {(keys: string[]) => keys.map((key) => ( @@ -206,7 +209,7 @@ const CacheDashboard: React.FC = ({ accessToken, token, userRole - + No virtual keys found {(key: string) => ( @@ -224,7 +227,7 @@ const CacheDashboard: React.FC = ({ accessToken, token, userRole value={selectedModels} onValueChange={(models: string[]) => setSelectedModels(models)} > - + }> {(models: string[]) => models.map((model) => ( @@ -236,7 +239,7 @@ const CacheDashboard: React.FC = ({ accessToken, token, userRole - + No models found {(model: string) => ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.tsx index f2d2ab724f4..1497c789cb4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.tsx @@ -13,6 +13,7 @@ import { ComboboxEmpty, ComboboxItem, ComboboxList, + useComboboxAnchor, } from "@/components/ui/combobox"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; @@ -190,6 +191,7 @@ interface CustomCodeModalProps { } const CustomCodeModal: React.FC = ({ visible, onClose, onSuccess, accessToken, editData }) => { + const anchor = useComboboxAnchor(); const isEditMode = !!editData; const [guardrailName, setGuardrailName] = useState(""); const [mode, setMode] = useState(["pre_call"]); @@ -524,7 +526,7 @@ const CustomCodeModal: React.FC = ({ visible, onClose, onS onValueChange={(options: ModeOption[]) => setMode(options.map((option) => option.value))} multiple > - + } className="w-full"> {selectedModeOptions.map((option) => ( {option.label} @@ -535,7 +537,7 @@ const CustomCodeModal: React.FC = ({ visible, onClose, onS placeholder={mode.length === 0 ? "Select modes" : undefined} /> - + No matching modes {(option: ModeOption) => ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/pii_components.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/pii_components.tsx index da5e61b2f3d..6994f43772c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/pii_components.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/pii_components.tsx @@ -13,6 +13,7 @@ import { ComboboxEmpty, ComboboxItem, ComboboxList, + useComboboxAnchor, } from "@/components/ui/combobox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -41,6 +42,7 @@ export interface CategoryFilterProps { } export const CategoryFilter: React.FC = ({ categories, selectedCategories, onChange }) => { + const anchor = useComboboxAnchor(); const categoryNames = categories.map((cat) => cat.category); return ( @@ -50,7 +52,7 @@ export const CategoryFilter: React.FC = ({ categories, sele Filter by category - + } className="mb-4 w-full"> {selectedCategories.map((category) => ( {category} @@ -61,7 +63,7 @@ export const CategoryFilter: React.FC = ({ categories, sele placeholder={selectedCategories.length === 0 ? "Select categories to filter by" : undefined} /> - + No matching categories {(category: string) => ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.tsx index 5b2f8547822..7ddf2622c7a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.tsx @@ -15,6 +15,7 @@ import { ComboboxItem, ComboboxList, ComboboxValue, + useComboboxAnchor, } from "@/components/ui/combobox"; import { Meter, MeterIndicator, MeterTrack } from "@/components/ui/meter"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -91,6 +92,7 @@ const TeamSpendBarList: React.FC<{ data: TeamSpendTotal[] }> = ({ data }) => { }; const UsagePage: React.FC = ({ accessToken, token, userRole, userID, keys, premiumUser }) => { + const anchor = useComboboxAnchor(); const canViewGlobalSpend = hasCapability(userRole, "viewGlobalSpend"); const currentDate = new Date(); const [keySpendData, setKeySpendData] = useState([]); @@ -879,7 +881,7 @@ const UsagePage: React.FC = ({ accessToken, token, userRole, use isItemEqualToValue={(a: TagOption, b: TagOption) => a.value === b.value} itemToStringLabel={(option: TagOption) => option.label} > - + }> {(options: TagOption[]) => options.map((option) => ( @@ -891,7 +893,7 @@ const UsagePage: React.FC = ({ accessToken, token, userRole, use - + No tags found {(option: TagOption) => ( diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx index adacdf16f76..772a5a766e6 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx @@ -14,6 +14,7 @@ import { ComboboxItem, ComboboxList, ComboboxValue, + useComboboxAnchor, } from "@/components/ui/combobox"; import EntityUsageExportModal from "./EntityUsageExportModal"; import type { EntitySpendData, EntityType } from "./types"; @@ -51,6 +52,7 @@ const UsageExportHeader: React.FC = ({ compactLayout = false, teams = [], }) => { + const anchor = useComboboxAnchor(); const [isExportModalOpen, setIsExportModalOpen] = useState(false); const hasFilters = showFilters && filterOptions.length > 0; @@ -58,7 +60,7 @@ const UsageExportHeader: React.FC = ({ const labelOf = (value: string) => filterOptions.find((option) => option.value === value)?.label ?? value; const filterList = ( - + No options found {(value: string) => ( @@ -104,7 +106,7 @@ const UsageExportHeader: React.FC = ({ value={selectedFilters} onValueChange={(next: string[]) => onFiltersChange?.(next)} > - + } className="w-full"> {(selected: string[]) => selected.map((value) => ( diff --git a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx index 55aa1f1ec5f..6c4386aec71 100644 --- a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx +++ b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx @@ -15,6 +15,7 @@ import { ComboboxLabel, ComboboxList, ComboboxValue, + useComboboxAnchor, } from "@/components/ui/combobox"; import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; @@ -120,6 +121,7 @@ const filterModels = ( }; export const ModelSelect = (props: ModelSelectProps) => { + const anchor = useComboboxAnchor(); const { teamID, organizationID, options, context, dataTestId, value = [], onChange, style } = props; const { showAllProxyModelsOverride, includeSpecialOptions } = options || {}; const { data: allProxyModels, isLoading: isLoadingAllProxyModels } = useAllProxyModels(); @@ -234,7 +236,7 @@ export const ModelSelect = (props: ModelSelectProps) => { isItemEqualToValue={(option: ModelOption, selected: ModelOption) => option.value === selected.value} itemToStringLabel={(option: ModelOption) => option.label} > - + } data-testid={dataTestId} style={style} className="w-full"> {(selected: ModelOption[]) => ( <> @@ -260,7 +262,7 @@ export const ModelSelect = (props: ModelSelectProps) => { className="h-5 min-w-24 flex-1 border-0 bg-transparent py-0 text-sm" /> - + No models found {(group: ModelOptionGroup) => ( diff --git a/ui/litellm-dashboard/src/components/TeamSSOSettings.tsx b/ui/litellm-dashboard/src/components/TeamSSOSettings.tsx index cf141097ecf..1009e5cd7b0 100644 --- a/ui/litellm-dashboard/src/components/TeamSSOSettings.tsx +++ b/ui/litellm-dashboard/src/components/TeamSSOSettings.tsx @@ -14,6 +14,7 @@ import { ComboboxItem, ComboboxList, ComboboxValue, + useComboboxAnchor, } from "@/components/ui/combobox"; import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; import { Input } from "@/components/ui/input"; @@ -110,6 +111,7 @@ const DEFAULT_VALUES: SettingsValues = { }; const TeamSSOSettings: React.FC = ({ accessToken }) => { + const anchor = useComboboxAnchor(); const [loading, setLoading] = useState(true); const [values, setValues] = useState(DEFAULT_VALUES); const [isEditing, setIsEditing] = useState(false); @@ -372,7 +374,7 @@ const TeamSSOSettings: React.FC = ({ accessToken }) => { value={editedValues.team_member_permissions || []} onValueChange={(permissions: string[]) => update("team_member_permissions", permissions)} > - + }> {(permissions: string[]) => permissions.map((permission) => ( @@ -388,7 +390,7 @@ const TeamSSOSettings: React.FC = ({ accessToken }) => { aria-label="Team Member Permissions" /> - + {(permission: string) => ( diff --git a/ui/litellm-dashboard/src/components/common_components/team_multi_select.tsx b/ui/litellm-dashboard/src/components/common_components/team_multi_select.tsx index 12a2031798b..da8e99a938d 100644 --- a/ui/litellm-dashboard/src/components/common_components/team_multi_select.tsx +++ b/ui/litellm-dashboard/src/components/common_components/team_multi_select.tsx @@ -12,6 +12,7 @@ import { ComboboxItem, ComboboxList, ComboboxValue, + useComboboxAnchor, } from "@/components/ui/combobox"; import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; @@ -36,6 +37,7 @@ const TeamMultiSelect: React.FC = ({ pageSize = 20, placeholder = "Search teams by alias...", }) => { + const anchor = useComboboxAnchor(); const [search, setSearch] = useState(""); const debouncedSetSearch = useDebouncedCallback(setSearch, { wait: DEBOUNCE_WAIT_MS }); @@ -75,7 +77,7 @@ const TeamMultiSelect: React.FC = ({ onInputValueChange={debouncedSetSearch} disabled={disabled} > - + } className="w-full" aria-busy={isLoading}> {(selected: string[]) => selected.map((teamId) => ( @@ -93,7 +95,7 @@ const TeamMultiSelect: React.FC = ({ /> {value.length > 0 && } - + {isLoading ? : "No teams found"} diff --git a/ui/litellm-dashboard/src/components/public_model_hub.tsx b/ui/litellm-dashboard/src/components/public_model_hub.tsx index ab1a10918e7..b642d28f21a 100644 --- a/ui/litellm-dashboard/src/components/public_model_hub.tsx +++ b/ui/litellm-dashboard/src/components/public_model_hub.tsx @@ -15,6 +15,7 @@ import { ComboboxItem, ComboboxList, ComboboxValue, + useComboboxAnchor, } from "@/components/ui/combobox"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -65,6 +66,7 @@ function PublicHubEmptyState({ title, body }: { title: string; body: string }) { } const PublicModelHub: React.FC = ({ accessToken, isEmbedded = false }) => { + const anchor = useComboboxAnchor(); const [modelHubData, setModelHubData] = useState(null); const [agentHubData, setAgentHubData] = useState(null); const [mcpHubData, setMcpHubData] = useState(null); @@ -622,7 +624,7 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded value={selectedProviders} onValueChange={(values: string[]) => setSelectedProviders(values)} > - + } className="min-h-8 w-full py-1 text-sm"> {(values: string[]) => values.map((provider) => ( @@ -638,7 +640,7 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded className="h-5 min-w-24 flex-1 border-0 bg-transparent py-0 text-sm" /> - + No providers found {(provider: string) => { diff --git a/ui/litellm-dashboard/src/components/search_tools/SearchToolSelector.tsx b/ui/litellm-dashboard/src/components/search_tools/SearchToolSelector.tsx index 56f5954345d..5078950bb9e 100644 --- a/ui/litellm-dashboard/src/components/search_tools/SearchToolSelector.tsx +++ b/ui/litellm-dashboard/src/components/search_tools/SearchToolSelector.tsx @@ -10,6 +10,7 @@ import { ComboboxItem, ComboboxList, ComboboxValue, + useComboboxAnchor, } from "@/components/ui/combobox"; import { cn } from "@/lib/cva.config"; import { fetchSearchTools } from "../networking"; @@ -31,6 +32,7 @@ const SearchToolSelector: React.FC = ({ placeholder = "Select search tools (optional)", disabled = false, }) => { + const anchor = useComboboxAnchor(); const [options, setOptions] = useState([]); const [loading, setLoading] = useState(false); @@ -67,7 +69,7 @@ const SearchToolSelector: React.FC = ({ onValueChange={(selected: string[]) => onChange(selected)} disabled={disabled} > - + } className={cn("w-full", className)} aria-busy={loading}> {(selected: string[]) => selected.map((tool) => ( @@ -85,7 +87,7 @@ const SearchToolSelector: React.FC = ({ /> {value && value.length > 0 && } - + {loading ? "Loading search tools…" : "No search tools found"} {(tool: string) => ( diff --git a/ui/litellm-dashboard/src/components/shared/MultiSelect.test.tsx b/ui/litellm-dashboard/src/components/shared/MultiSelect.test.tsx new file mode 100644 index 00000000000..908c22b5d67 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/MultiSelect.test.tsx @@ -0,0 +1,95 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { MultiSelect, type MultiSelectOption } from "./MultiSelect"; + +const OPTIONS: MultiSelectOption[] = [ + { value: "vs-alpha", label: "alpha-kb (vs-alpha)" }, + { value: "vs-beta", label: "beta-kb (vs-beta)", description: "second store" }, +]; + +const renderMultiSelect = (props: Partial> = {}) => { + const onValueChange = vi.fn(); + render(); + return { onValueChange, input: screen.getByRole("combobox") }; +}; + +const openPopup = async (input: HTMLElement) => { + await userEvent.click(input); + return waitFor(() => { + const popup = document.querySelector("[data-slot='combobox-content']"); + expect(popup).not.toBeNull(); + return popup as HTMLElement; + }); +}; + +const stubWidth = (element: Element, width: number) => + vi.spyOn(element, "getBoundingClientRect").mockReturnValue({ + width, + height: 32, + top: 0, + left: 0, + right: width, + bottom: 32, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect); + +const CHIPS_WIDTH = 300; +const INPUT_WIDTH = 200; + +describe("MultiSelect", () => { + it("anchors the popup to the chips container rather than the inner input", async () => { + const { input } = renderMultiSelect(); + const chips = input.closest("[data-slot='combobox-chips']"); + expect(chips).not.toBeNull(); + stubWidth(chips as Element, CHIPS_WIDTH); + stubWidth(input, INPUT_WIDTH); + + const popup = await openPopup(input); + const positioner = popup.parentElement as HTMLElement; + + expect(positioner.style.getPropertyValue("--anchor-width")).toBe(`${CHIPS_WIDTH}px`); + }); + + it("reports the selected option values", async () => { + const { onValueChange, input } = renderMultiSelect(); + + await openPopup(input); + await userEvent.click(screen.getByText("alpha-kb (vs-alpha)")); + + expect(onValueChange).toHaveBeenCalledWith(["vs-alpha"]); + }); + + it("renders a chip per selected value", () => { + renderMultiSelect({ value: ["vs-alpha", "vs-beta"] }); + + expect(screen.getByLabelText("alpha-kb (vs-alpha)")).toBeInTheDocument(); + expect(screen.getByLabelText("beta-kb (vs-beta)")).toBeInTheDocument(); + }); + + it("labels an unknown selected value with its raw id", () => { + renderMultiSelect({ value: ["vs-deleted"] }); + + expect(screen.getByLabelText("vs-deleted")).toBeInTheDocument(); + }); + + it("offers a typed value only when custom values are allowed", async () => { + const { onValueChange, input } = renderMultiSelect({ allowCustomValues: true }); + + await userEvent.type(input, "vs-typed"); + await userEvent.click(await screen.findByText('Create "vs-typed"')); + + expect(onValueChange).toHaveBeenCalledWith(["vs-typed"]); + }); + + it("does not offer a typed value when custom values are disallowed", async () => { + const { input } = renderMultiSelect(); + + await userEvent.type(input, "vs-typed"); + + expect(screen.queryByText('Create "vs-typed"')).not.toBeInTheDocument(); + expect(await screen.findByText("No options found")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/MultiSelect.tsx b/ui/litellm-dashboard/src/components/shared/MultiSelect.tsx index f084572bc0c..ef23ab2ebd4 100644 --- a/ui/litellm-dashboard/src/components/shared/MultiSelect.tsx +++ b/ui/litellm-dashboard/src/components/shared/MultiSelect.tsx @@ -11,6 +11,7 @@ import { ComboboxItem, ComboboxList, ComboboxValue, + useComboboxAnchor, } from "@/components/ui/combobox"; export interface MultiSelectOption { @@ -52,6 +53,7 @@ export function MultiSelect({ allowCustomValues = false, className, }: MultiSelectProps) { + const anchor = useComboboxAnchor(); const [query, setQuery] = useState(""); const safeOptions = options.filter( (option): option is MultiSelectOption => @@ -89,23 +91,25 @@ export function MultiSelect({ filter={matchesQuery} disabled={disabled || loading} > - + } className={`min-h-8 py-1 text-sm ${className ?? ""}`}> - {(selected: MultiSelectOption[]) => - selected.map((option) => ( - - {option.label} - - )) - } + {(selected: MultiSelectOption[]) => ( + <> + {selected.map((option) => ( + + {option.label} + + ))} + + + )} - - + {emptyText} {(option: MultiSelectOption) => ( diff --git a/ui/litellm-dashboard/src/components/user_agent_activity.tsx b/ui/litellm-dashboard/src/components/user_agent_activity.tsx index ca6c2953dfe..b7c9056764b 100644 --- a/ui/litellm-dashboard/src/components/user_agent_activity.tsx +++ b/ui/litellm-dashboard/src/components/user_agent_activity.tsx @@ -11,6 +11,7 @@ import { ComboboxItem, ComboboxList, ComboboxValue, + useComboboxAnchor, } from "@/components/ui/combobox"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -59,6 +60,7 @@ interface UserAgentActivityProps { } const UserAgentActivity: React.FC = ({ accessToken, userRole, dateValue, onDateChange }) => { + const anchor = useComboboxAnchor(); // Maximum number of categories to show in charts to prevent color palette overflow const MAX_CATEGORIES = 10; @@ -385,7 +387,7 @@ const UserAgentActivity: React.FC = ({ accessToken, user value={selectedTags} onValueChange={(next: string[]) => setSelectedTags(next)} > - + } className="w-full" aria-busy={tagsLoading}> {(selected: string[]) => selected.map((tag) => ( @@ -402,7 +404,7 @@ const UserAgentActivity: React.FC = ({ accessToken, user /> {selectedTags.length > 0 && } - + No user agents found {(tag: string) => {