From 5d21637dd0a71dc14bdc2cb89afb48bf7d371590 Mon Sep 17 00:00:00 2001 From: xykong Date: Wed, 15 Apr 2026 12:49:50 +0800 Subject: [PATCH] fix(ui): add searchable filter to User Usage page User Usage filter only allowed selecting from a static preloaded list, unlike Global Usage which supports debounced text search via useInfiniteUsers. Add UserSingleSelect component (single-select with useInfiniteUsers + 300ms debounce + infinite scroll) and wire it into EntityUsage for entityType === 'user', mirroring the TeamMultiSelect fix in PR #25107. - Add src/components/common_components/user_single_select.tsx - Add src/components/common_components/user_single_select.test.tsx (10 tests) - Update EntityUsage.tsx to render UserSingleSelect for user entity type - Update EntityUsage.test.tsx to mock user_single_select --- .../EntityUsage/EntityUsage.test.tsx | 4 + .../components/EntityUsage/EntityUsage.tsx | 12 +- .../user_single_select.test.tsx | 152 ++++++++++++++++++ .../common_components/user_single_select.tsx | 123 ++++++++++++++ 4 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 ui/litellm-dashboard/src/components/common_components/user_single_select.test.tsx create mode 100644 ui/litellm-dashboard/src/components/common_components/user_single_select.tsx diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx index dc201cccfea..1ae285f7e60 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx @@ -49,6 +49,10 @@ vi.mock("../../../common_components/team_multi_select", () => ({ default: () =>
Team Multi Select
, })); +vi.mock("../../../common_components/user_single_select", () => ({ + default: () =>
User Single Select
, +})); + // Mock useTeams hook vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({ default: vi.fn(() => ({ diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx index 9fa73dd1728..b55900c6784 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx @@ -26,6 +26,7 @@ import { ExportOutlined, LoadingOutlined } from "@ant-design/icons"; import { Alert, Button } from "antd"; import React, { useMemo, useState } from "react"; import TeamMultiSelect from "../../../common_components/team_multi_select"; +import UserSingleSelect from "../../../common_components/user_single_select"; import { ActivityMetrics, processActivityData } from "../../../activity_metrics"; import { UsageExportHeader } from "../../../EntityUsageExport"; import type { EntityType } from "../../../EntityUsageExport/types"; @@ -478,11 +479,20 @@ const EntityUsage: React.FC = ({ accessToken, entityType, enti /> )} + {entityType === "user" && ( +
+ Filter by user + setSelectedTags(value ? [value] : [])} + /> +
+ )} 0} + showFilters={entityType !== "team" && entityType !== "user" && entityList !== null && entityList.length > 0} filterLabel={getFilterLabel(entityType)} filterPlaceholder={getFilterPlaceholder(entityType)} selectedFilters={selectedTags} diff --git a/ui/litellm-dashboard/src/components/common_components/user_single_select.test.tsx b/ui/litellm-dashboard/src/components/common_components/user_single_select.test.tsx new file mode 100644 index 00000000000..34530191885 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/user_single_select.test.tsx @@ -0,0 +1,152 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import UserSingleSelect from "./user_single_select"; + +const mockUseInfiniteUsers = vi.fn(); + +vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({ + useInfiniteUsers: (...args: any[]) => mockUseInfiniteUsers(...args), +})); + +const buildPage = (users: Array<{ user_id: string; user_email: string | null; user_alias: string | null }>) => ({ + users, + page: 1, + page_size: 50, + total: users.length, + total_pages: 1, +}); + +const DEFAULT_HOOK_STATE = { + data: { + pages: [ + buildPage([ + { user_id: "user-1", user_email: "alice@example.com", user_alias: null }, + { user_id: "user-2", user_email: null, user_alias: "Bob" }, + { user_id: "user-3", user_email: "charlie@example.com", user_alias: "Charlie" }, + ]), + ], + }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, +}; + +describe("UserSingleSelect", () => { + it("should render a combobox", () => { + mockUseInfiniteUsers.mockReturnValue(DEFAULT_HOOK_STATE); + render(); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + it("should display user options when opened", async () => { + mockUseInfiniteUsers.mockReturnValue(DEFAULT_HOOK_STATE); + const user = userEvent.setup(); + render(); + + await act(async () => { + await user.click(screen.getByRole("combobox")); + }); + + expect(await screen.findByText("alice@example.com")).toBeInTheDocument(); + expect(screen.getByText("user-1")).toBeInTheDocument(); + }); + + it("should call onChange with user_id when a user is selected", async () => { + mockUseInfiniteUsers.mockReturnValue(DEFAULT_HOOK_STATE); + const onChange = vi.fn(); + const user = userEvent.setup(); + render(); + + await act(async () => { + await user.click(screen.getByRole("combobox")); + }); + + await act(async () => { + await user.click(await screen.findByText("alice@example.com")); + }); + + expect(onChange).toHaveBeenCalledWith("user-1"); + }); + + it("should call onChange with null when selection is cleared", async () => { + mockUseInfiniteUsers.mockReturnValue(DEFAULT_HOOK_STATE); + const onChange = vi.fn(); + const user = userEvent.setup(); + render(); + + const clearButton = document.querySelector(".ant-select-clear"); + if (clearButton) { + await act(async () => { + await user.click(clearButton as Element); + }); + expect(onChange).toHaveBeenCalledWith(null); + } + }); + + it("should pass search input to useInfiniteUsers as debounced value", async () => { + mockUseInfiniteUsers.mockReturnValue({ ...DEFAULT_HOOK_STATE, data: { pages: [] } }); + const user = userEvent.setup(); + render(); + + await act(async () => { + await user.click(screen.getByRole("combobox")); + await user.type(screen.getByRole("combobox"), "alice"); + }); + + await waitFor(() => { + expect(mockUseInfiniteUsers).toHaveBeenCalledWith( + expect.any(Number), + "alice", + ); + }); + }); + + it("should show loading indicator when isLoading is true", () => { + mockUseInfiniteUsers.mockReturnValue({ ...DEFAULT_HOOK_STATE, isLoading: true }); + render(); + expect(document.querySelector(".ant-select-loading")).toBeInTheDocument(); + }); + + it("should show user alias in label when alias is set", async () => { + mockUseInfiniteUsers.mockReturnValue(DEFAULT_HOOK_STATE); + const user = userEvent.setup(); + render(); + + await act(async () => { + await user.click(screen.getByRole("combobox")); + }); + + expect(await screen.findByText("charlie@example.com")).toBeInTheDocument(); + }); + + it("should use custom placeholder when provided", () => { + mockUseInfiniteUsers.mockReturnValue(DEFAULT_HOOK_STATE); + render(); + expect(screen.getByText("Find a user...")).toBeInTheDocument(); + }); + + it("should add ant-select-disabled class when disabled prop is true", () => { + mockUseInfiniteUsers.mockReturnValue(DEFAULT_HOOK_STATE); + const { container } = render(); + expect(container.querySelector(".ant-select-disabled")).toBeTruthy(); + }); + + it("should show fetchNextPage spinner when isFetchingNextPage is true", async () => { + mockUseInfiniteUsers.mockReturnValue({ + ...DEFAULT_HOOK_STATE, + hasNextPage: true, + isFetchingNextPage: true, + }); + const user = userEvent.setup(); + render(); + + await act(async () => { + await user.click(screen.getByRole("combobox")); + }); + + const spinners = document.querySelectorAll(".anticon-loading"); + expect(spinners.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/user_single_select.tsx b/ui/litellm-dashboard/src/components/common_components/user_single_select.tsx new file mode 100644 index 00000000000..8c9874a1d86 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/user_single_select.tsx @@ -0,0 +1,123 @@ +import React, { useMemo, useState, type UIEvent } from "react"; +import { Select, Typography } from "antd"; +import { LoadingOutlined } from "@ant-design/icons"; +import { useDebouncedState } from "@tanstack/react-pacer/debouncer"; +import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers"; + +const { Text } = Typography; + +interface UserSingleSelectProps { + value?: string | null; + onChange?: (value: string | null) => void; + disabled?: boolean; + pageSize?: number; + placeholder?: string; +} + +const SCROLL_THRESHOLD = 0.8; +const DEBOUNCE_MS = 300; + +/** + * A single-select dropdown for users with server-side debounced search and + * infinite scroll. Mirrors TeamMultiSelect but uses single-select mode to + * match the `/user/daily/activity` API contract that accepts one user_id. + */ +const UserSingleSelect: React.FC = ({ + value, + onChange, + disabled, + pageSize = 50, + placeholder = "Search users by email or ID...", +}) => { + const [searchInput, setSearchInput] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", { + wait: DEBOUNCE_MS, + }); + + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + } = useInfiniteUsers(pageSize, debouncedSearch || undefined); + + const userOptions = useMemo(() => { + if (!data?.pages) return []; + const seen = new Set(); + const result: { value: string; label: string; email: string | null }[] = []; + for (const page of data.pages) { + for (const user of page.users) { + if (seen.has(user.user_id)) continue; + seen.add(user.user_id); + result.push({ + value: user.user_id, + label: user.user_alias + ? `${user.user_alias} (${user.user_id})` + : user.user_email + ? `${user.user_email} (${user.user_id})` + : user.user_id, + email: user.user_email ?? null, + }); + } + } + return result; + }, [data]); + + const handlePopupScroll = (e: UIEvent) => { + const target = e.currentTarget; + const scrollRatio = + (target.scrollTop + target.clientHeight) / target.scrollHeight; + if (scrollRatio >= SCROLL_THRESHOLD && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }; + + const handleSearch = (val: string) => { + setSearchInput(val); + setDebouncedSearch(val); + }; + + return ( + + ); +}; + +export default UserSingleSelect;