mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
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
This commit is contained in:
parent
3e1479c052
commit
5d21637dd0
4 changed files with 290 additions and 1 deletions
|
|
@ -49,6 +49,10 @@ vi.mock("../../../common_components/team_multi_select", () => ({
|
|||
default: () => <div>Team Multi Select</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../../../common_components/user_single_select", () => ({
|
||||
default: () => <div>User Single Select</div>,
|
||||
}));
|
||||
|
||||
// Mock useTeams hook
|
||||
vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({
|
||||
default: vi.fn(() => ({
|
||||
|
|
|
|||
|
|
@ -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<EntityUsageProps> = ({ accessToken, entityType, enti
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
{entityType === "user" && (
|
||||
<div className="mb-4">
|
||||
<Text className="mb-2">Filter by user</Text>
|
||||
<UserSingleSelect
|
||||
value={selectedTags[0] ?? null}
|
||||
onChange={(value) => setSelectedTags(value ? [value] : [])}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<UsageExportHeader
|
||||
dateValue={dateValue}
|
||||
entityType={entityType}
|
||||
spendData={spendData}
|
||||
showFilters={entityType !== "team" && entityList !== null && entityList.length > 0}
|
||||
showFilters={entityType !== "team" && entityType !== "user" && entityList !== null && entityList.length > 0}
|
||||
filterLabel={getFilterLabel(entityType)}
|
||||
filterPlaceholder={getFilterPlaceholder(entityType)}
|
||||
selectedFilters={selectedTags}
|
||||
|
|
|
|||
|
|
@ -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(<UserSingleSelect />);
|
||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display user options when opened", async () => {
|
||||
mockUseInfiniteUsers.mockReturnValue(DEFAULT_HOOK_STATE);
|
||||
const user = userEvent.setup();
|
||||
render(<UserSingleSelect />);
|
||||
|
||||
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(<UserSingleSelect onChange={onChange} />);
|
||||
|
||||
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(<UserSingleSelect value="user-1" onChange={onChange} />);
|
||||
|
||||
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(<UserSingleSelect />);
|
||||
|
||||
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(<UserSingleSelect />);
|
||||
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(<UserSingleSelect />);
|
||||
|
||||
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(<UserSingleSelect placeholder="Find a user..." />);
|
||||
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(<UserSingleSelect disabled />);
|
||||
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(<UserSingleSelect />);
|
||||
|
||||
await act(async () => {
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
});
|
||||
|
||||
const spinners = document.querySelectorAll(".anticon-loading");
|
||||
expect(spinners.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<UserSingleSelectProps> = ({
|
||||
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<string>();
|
||||
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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<Select
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder={placeholder}
|
||||
value={value ?? undefined}
|
||||
onChange={(val: string | undefined) => onChange?.(val ?? null)}
|
||||
disabled={disabled}
|
||||
filterOption={false}
|
||||
onSearch={handleSearch}
|
||||
searchValue={searchInput}
|
||||
onPopupScroll={handlePopupScroll}
|
||||
loading={isLoading}
|
||||
notFoundContent={isLoading ? <LoadingOutlined spin /> : "No users found"}
|
||||
style={{ width: "100%" }}
|
||||
popupRender={(menu) => (
|
||||
<>
|
||||
{menu}
|
||||
{isFetchingNextPage && (
|
||||
<div style={{ textAlign: "center", padding: 8 }}>
|
||||
<LoadingOutlined spin />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{userOptions.map((opt) => (
|
||||
<Select.Option key={opt.value} value={opt.value}>
|
||||
{opt.email ? (
|
||||
<>
|
||||
<span className="font-medium">{opt.email}</span>{" "}
|
||||
<Text type="secondary">({opt.value})</Text>
|
||||
</>
|
||||
) : (
|
||||
<span>{opt.value}</span>
|
||||
)}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserSingleSelect;
|
||||
Loading…
Add table
Reference in a new issue