mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(ui): search every user in the Usage page user filter (#37206)
The User Usage view handed EntityUsage a static entityList holding only the first /user/list page, so its filter could only find the 50 most recently created users and anyone beyond that page, including users with spend in the selected period, was unreachable Add a self-contained UserDropdown that owns useInfiniteUsers (server-side search plus load-more, mirroring TeamDropdown) and use it both as the User Usage filterSlot and for the Global Usage user filter. Resolve a selected user that is outside the loaded page by id so its label survives view round-trips. Drop the now dead single-select branch from UsageExportHeader
This commit is contained in:
parent
3f4810b8f2
commit
427ed9e2d1
12 changed files with 520 additions and 167 deletions
|
|
@ -1493,9 +1493,6 @@
|
|||
"max-lines": {
|
||||
"count": 1
|
||||
},
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/purity": {
|
||||
"count": 1
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React, { ReactNode } from "react";
|
||||
import { useInfiniteUsers } from "./useUsers";
|
||||
import { useInfiniteUsers, useUserLookup } from "./useUsers";
|
||||
import { userListCall } from "@/components/networking";
|
||||
import type { UserListResponse } from "@/components/networking";
|
||||
|
||||
|
|
@ -285,3 +285,53 @@ describe("useInfiniteUsers", () => {
|
|||
expect(userListCall).toHaveBeenCalledWith("test-access-token", null, 1, 50, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useUserLookup", () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
vi.clearAllMocks();
|
||||
mockUseAuthorized.mockReturnValue(DEFAULT_AUTH);
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
it("fetches exactly the requested user by id", async () => {
|
||||
vi.mocked(userListCall).mockResolvedValue(buildUserListResponse(1, 1, 1));
|
||||
|
||||
const { result } = renderHook(() => useUserLookup("user-1-0"), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(userListCall).toHaveBeenCalledWith("test-access-token", ["user-1-0"], 1, 1);
|
||||
expect(result.current.data?.user_id).toBe("user-1-0");
|
||||
});
|
||||
|
||||
it("resolves to null when the proxy returns a different user than requested", async () => {
|
||||
vi.mocked(userListCall).mockResolvedValue(buildUserListResponse(1, 1, 1));
|
||||
|
||||
const { result } = renderHook(() => useUserLookup("someone-else"), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data).toBeNull();
|
||||
});
|
||||
|
||||
it("does not query without a user id", async () => {
|
||||
const { result } = renderHook(() => useUserLookup(null), { wrapper });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(result.current.fetchStatus).toBe("idle");
|
||||
expect(userListCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not query for a non-admin role", async () => {
|
||||
mockUseAuthorized.mockReturnValue({ ...DEFAULT_AUTH, userRole: "Internal User" });
|
||||
|
||||
const { result } = renderHook(() => useUserLookup("user-1-0"), { wrapper });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(result.current.fetchStatus).toBe("idle");
|
||||
expect(userListCall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { userListCall, UserListResponse } from "@/components/networking";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { userListCall, UserInfo, UserListResponse } from "@/components/networking";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { createQueryKeys } from "../common/queryKeysFactory";
|
||||
import { all_admin_roles } from "@/utils/roles";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
const infiniteUsersKeys = createQueryKeys("infiniteUsers");
|
||||
const userLookupKeys = createQueryKeys("userLookup");
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 50;
|
||||
|
||||
|
|
@ -36,3 +37,15 @@ export const useInfiniteUsers = (pageSize: number = DEFAULT_PAGE_SIZE, searchEma
|
|||
enabled: Boolean(accessToken) && all_admin_roles.includes(userRole!),
|
||||
});
|
||||
};
|
||||
|
||||
export const useUserLookup = (userId: string | null) => {
|
||||
const { accessToken, userRole } = useAuthorized();
|
||||
return useQuery<UserInfo | null>({
|
||||
queryKey: userLookupKeys.detail(userId ?? ""),
|
||||
queryFn: async () => {
|
||||
const response = await userListCall(accessToken!, [userId!], 1, 1);
|
||||
return response.users.find((user) => user.user_id === userId) ?? null;
|
||||
},
|
||||
enabled: Boolean(accessToken) && Boolean(userId) && all_admin_roles.includes(userRole!),
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers";
|
||||
import * as networking from "@/components/networking";
|
||||
import EntityUsage from "./EntityUsage";
|
||||
|
||||
|
|
@ -61,7 +64,18 @@ vi.mock("@/components/EntityUsageExport/EntityUsageExportModal", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("@/components/EntityUsageExport", () => ({
|
||||
UsageExportHeader: () => <div>Usage Export Header</div>,
|
||||
UsageExportHeader: ({ filterLabel, filterSlot }: { filterLabel?: string; filterSlot?: ReactNode }) => (
|
||||
<div>
|
||||
<span>Usage Export Header</span>
|
||||
<span>{filterLabel}</span>
|
||||
{filterSlot}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({
|
||||
useInfiniteUsers: vi.fn(),
|
||||
useUserLookup: vi.fn(() => ({ data: null })),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/common_components/team_multi_select", () => ({
|
||||
|
|
@ -83,6 +97,16 @@ describe("EntityUsage", () => {
|
|||
const mockCustomerDailyActivityCall = vi.mocked(networking.customerDailyActivityCall);
|
||||
const mockAgentDailyActivityCall = vi.mocked(networking.agentDailyActivityCall);
|
||||
const mockUserDailyActivityCall = vi.mocked(networking.userDailyActivityCall);
|
||||
const mockUseInfiniteUsers = vi.mocked(useInfiniteUsers);
|
||||
|
||||
const infiniteUsersResult = (users: { user_id: string; user_alias: string | null; user_email: string | null }[]) =>
|
||||
({
|
||||
data: { pages: [{ users, page: 1, total_pages: 1, total_count: users.length }], pageParams: [1] },
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
isLoading: false,
|
||||
}) as unknown as ReturnType<typeof useInfiniteUsers>;
|
||||
|
||||
const mockSpendData = {
|
||||
results: [
|
||||
|
|
@ -380,6 +404,13 @@ describe("EntityUsage", () => {
|
|||
mockCustomerDailyActivityCall.mockResolvedValue(mockSpendData);
|
||||
mockAgentDailyActivityCall.mockResolvedValue(mockAgentSpendData);
|
||||
mockUserDailyActivityCall.mockResolvedValue(mockSpendData);
|
||||
mockUseInfiniteUsers.mockClear();
|
||||
mockUseInfiniteUsers.mockReturnValue(
|
||||
infiniteUsersResult([
|
||||
{ user_id: "user-001", user_alias: "Alice", user_email: "alice@example.com" },
|
||||
{ user_id: "user-002", user_alias: null, user_email: "bob@example.com" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("should render with tag entity type and display spend metrics", async () => {
|
||||
|
|
@ -987,4 +1018,68 @@ describe("EntityUsage", () => {
|
|||
expect(screen.getByText("top-models:gpt-4o=70.25")).toBeInTheDocument();
|
||||
expect(screen.getByText(/^top-models:Code Review Agent=/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("user filter (LIT-5654)", () => {
|
||||
const userDropdown = (): HTMLElement => screen.getByTestId("user-dropdown");
|
||||
const userCombobox = (): HTMLElement => within(userDropdown()).getByRole("combobox");
|
||||
|
||||
const renderUserUsage = async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="user" entityList={null} />);
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityCall).toHaveBeenCalled();
|
||||
});
|
||||
};
|
||||
|
||||
it("offers a user filter even when the caller preloaded no user page", async () => {
|
||||
await renderUserUsage();
|
||||
|
||||
expect(userCombobox()).toHaveAttribute("placeholder", "Search users by email…");
|
||||
});
|
||||
|
||||
it("searches every user on the server rather than a preloaded page", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderUserUsage();
|
||||
|
||||
expect(mockUseInfiniteUsers).toHaveBeenCalledWith(50, undefined);
|
||||
|
||||
await user.type(userCombobox(), "alice");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseInfiniteUsers).toHaveBeenCalledWith(50, "alice");
|
||||
});
|
||||
});
|
||||
|
||||
it("refetches daily activity for the picked user and drops the filter when cleared", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderUserUsage();
|
||||
|
||||
expect(mockUserDailyActivityCall).toHaveBeenCalledWith("test-token", expect.any(Date), expect.any(Date), 1, null);
|
||||
|
||||
await user.click(userCombobox());
|
||||
await user.click(await screen.findByText("Alice (user-001)"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityCall).toHaveBeenCalledWith(
|
||||
"test-token",
|
||||
expect.any(Date),
|
||||
expect.any(Date),
|
||||
1,
|
||||
"user-001",
|
||||
);
|
||||
});
|
||||
|
||||
mockUserDailyActivityCall.mockClear();
|
||||
await user.click(userDropdown().querySelector('[data-slot="combobox-clear"]') as HTMLElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityCall).toHaveBeenCalledWith(
|
||||
"test-token",
|
||||
expect.any(Date),
|
||||
expect.any(Date),
|
||||
1,
|
||||
null,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,8 +23,9 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import React, { type ReactNode, useMemo, useState } from "react";
|
||||
import TeamMultiSelect from "@/components/common_components/team_multi_select";
|
||||
import UserDropdown from "@/components/common_components/UserDropdown";
|
||||
import { ActivityMetrics, processActivityData } from "@/components/activity_metrics";
|
||||
import { UsageExportHeader, type UsageFilterSelectProps } from "@/components/EntityUsageExport";
|
||||
import { UsageExportHeader } from "@/components/EntityUsageExport";
|
||||
import type { EntityType } from "@/components/EntityUsageExport/types";
|
||||
import {
|
||||
agentDailyActivityCall,
|
||||
|
|
@ -84,7 +85,6 @@ interface EntityUsageProps {
|
|||
entityList: EntityList[] | null;
|
||||
premiumUser: boolean;
|
||||
dateValue: DateRangePickerValue;
|
||||
filterSelectProps?: UsageFilterSelectProps;
|
||||
}
|
||||
|
||||
const ENTITY_FETCH_FNS: Record<EntityType, (...args: any[]) => Promise<any>> = {
|
||||
|
|
@ -108,7 +108,6 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
entityList,
|
||||
userRole,
|
||||
dateValue,
|
||||
filterSelectProps,
|
||||
}) => {
|
||||
const { teams } = useTeams();
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
|
|
@ -256,6 +255,14 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
return `Select ${entityType} to filter...`;
|
||||
};
|
||||
|
||||
const entityFilterSlots: Partial<Record<EntityType, ReactNode>> = {
|
||||
team: <TeamMultiSelect value={selectedTags} onChange={setSelectedTags} />,
|
||||
user: (
|
||||
<UserDropdown value={selectedTags[0] ?? null} onChange={(userId) => setSelectedTags(userId ? [userId] : [])} />
|
||||
),
|
||||
};
|
||||
const filterSlot = entityFilterSlots[entityType];
|
||||
|
||||
const capitalizedEntityLabel = entityType.charAt(0).toUpperCase() + entityType.slice(1);
|
||||
const showFlatCost = entityType === "team" && hasFlatCost(spendData.metadata);
|
||||
const providerSpend = useMemo(() => getProviderSpend(spendData.results), [spendData.results]);
|
||||
|
|
@ -623,9 +630,6 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
{ key: "endpoints", label: "Endpoint Activity", content: <EndpointUsage userSpendData={spendData} /> },
|
||||
];
|
||||
|
||||
const hasEntityFilterOptions = entityList !== null && entityList.length > 0;
|
||||
const showEntityFilters = entityType !== "team" && (filterSelectProps !== undefined || hasEntityFilterOptions);
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%" }} className="relative">
|
||||
{isFetchingMore && (
|
||||
|
|
@ -684,17 +688,13 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
dateValue={dateValue}
|
||||
entityType={entityType}
|
||||
spendData={spendData}
|
||||
showFilters={showEntityFilters}
|
||||
filterSlot={
|
||||
entityType === "team" ? <TeamMultiSelect value={selectedTags} onChange={setSelectedTags} /> : undefined
|
||||
}
|
||||
filterLabel={entityType === "team" ? "Filter by team" : getFilterLabel(entityType)}
|
||||
showFilters={filterSlot === undefined && entityList !== null && entityList.length > 0}
|
||||
filterSlot={filterSlot}
|
||||
filterLabel={getFilterLabel(entityType)}
|
||||
filterPlaceholder={getFilterPlaceholder(entityType)}
|
||||
selectedFilters={selectedTags}
|
||||
onFiltersChange={setSelectedTags}
|
||||
filterOptions={getAllTags() || undefined}
|
||||
filterMode={entityType === "user" ? "single" : "multiple"}
|
||||
filterSelectProps={filterSelectProps}
|
||||
teams={teams || []}
|
||||
/>
|
||||
<Tabs defaultValue={tabs[0].key}>
|
||||
|
|
|
|||
|
|
@ -47,16 +47,9 @@ vi.mock("@/components/UsagePage/components/EntityUsage/TopKeyView", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("./EntityUsage/EntityUsage", () => ({
|
||||
default: ({
|
||||
entityType,
|
||||
filterSelectProps,
|
||||
}: {
|
||||
entityType?: string;
|
||||
filterSelectProps?: { onSearchChange?: (query: string) => void };
|
||||
}) => (
|
||||
<div>
|
||||
default: ({ entityType, entityList }: { entityType: string; entityList: unknown }) => (
|
||||
<div data-testid="entity-usage" data-entity-type={entityType} data-entity-list={JSON.stringify(entityList ?? null)}>
|
||||
Entity Usage
|
||||
{entityType === "user" && filterSelectProps !== undefined && <span>Searchable user filter</span>}
|
||||
</div>
|
||||
),
|
||||
EntityList: [],
|
||||
|
|
@ -154,6 +147,7 @@ vi.mock("@/app/(dashboard)/hooks/users/useCurrentUser", () => ({
|
|||
|
||||
vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({
|
||||
useInfiniteUsers: vi.fn(),
|
||||
useUserLookup: vi.fn(() => ({ data: null })),
|
||||
}));
|
||||
|
||||
describe("UsagePage", () => {
|
||||
|
|
@ -725,7 +719,7 @@ describe("UsagePage", () => {
|
|||
});
|
||||
|
||||
expect(userSelectCombobox()).toBeInTheDocument();
|
||||
expect(promptsWith("Select user to filter...")).toBe(true);
|
||||
expect(promptsWith("Search users by email…")).toBe(true);
|
||||
});
|
||||
|
||||
it("should format user options with alias when available", async () => {
|
||||
|
|
@ -756,18 +750,6 @@ describe("UsagePage", () => {
|
|||
expect(mockUseInfiniteUsers).toHaveBeenCalledWith(50, undefined);
|
||||
});
|
||||
|
||||
it("should reuse the searchable user filter in the user usage view", async () => {
|
||||
renderWithProviders(<UsagePage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByTestId("usage-view-select"), { target: { value: "user" } });
|
||||
|
||||
expect(await screen.findByText("Searchable user filter")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should deduplicate users across pages", async () => {
|
||||
mockUseInfiniteUsers.mockReturnValue({
|
||||
data: {
|
||||
|
|
@ -828,6 +810,46 @@ describe("UsagePage", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("user usage view", () => {
|
||||
it("should hand EntityUsage no user list so its own filter can search every user", async () => {
|
||||
mockUseInfiniteUsers.mockReturnValue({
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
users: Array.from({ length: 50 }, (_, index) => ({
|
||||
user_id: `user-${index}`,
|
||||
user_alias: null,
|
||||
user_email: `user${index}@example.com`,
|
||||
})),
|
||||
page: 1,
|
||||
total_pages: 4,
|
||||
total_count: 200,
|
||||
},
|
||||
],
|
||||
pageParams: [1],
|
||||
},
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: true,
|
||||
isFetchingNextPage: false,
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useInfiniteUsers>);
|
||||
|
||||
renderWithProviders(<UsagePage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(screen.getByTestId("usage-view-select"), { target: { value: "user" } });
|
||||
});
|
||||
|
||||
const entityUsage = await screen.findByTestId("entity-usage");
|
||||
expect(entityUsage).toHaveAttribute("data-entity-type", "user");
|
||||
expect(entityUsage).toHaveAttribute("data-entity-list", "null");
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-admin user behavior", () => {
|
||||
it("should not render user selector for non-admin users", async () => {
|
||||
mockUseAuthorized.mockReturnValue({
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|||
|
||||
import { BarChart } from "@/components/shared/charts";
|
||||
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
|
@ -22,13 +21,13 @@ import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
|
|||
import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
|
||||
import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers";
|
||||
import { hasCapability } from "@/utils/capabilities";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { all_admin_roles, internalUserRoles } from "@/utils/roles";
|
||||
import { ActivityMetrics, processActivityData } from "@/components/activity_metrics";
|
||||
import CloudZeroExportModal from "@/components/cloudzero_export_modal";
|
||||
import EntityUsageExportModal, { type UsageFilterSelectProps } from "@/components/EntityUsageExport";
|
||||
import UserDropdown from "@/components/common_components/UserDropdown";
|
||||
import EntityUsageExportModal from "@/components/EntityUsageExport";
|
||||
import { Team } from "@/components/key_team_helpers/key_list";
|
||||
import {
|
||||
gatewayDailyActivityCall,
|
||||
|
|
@ -104,45 +103,6 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
const canViewOrganizationUsage = hasCapability(userRole, "viewOrganizationUsage");
|
||||
const canViewAgentUsage = hasCapability(userRole, "viewAgentUsage");
|
||||
|
||||
const [settledUserSearch, setSettledUserSearch] = useState("");
|
||||
|
||||
const {
|
||||
data: usersInfiniteData,
|
||||
fetchNextPage: fetchNextUsersPage,
|
||||
hasNextPage: hasNextUsersPage,
|
||||
isFetchingNextPage: isFetchingNextUsersPage,
|
||||
isLoading: isLoadingUsers,
|
||||
} = useInfiniteUsers(50, settledUserSearch || undefined);
|
||||
|
||||
const userOptions = useMemo(() => {
|
||||
if (!usersInfiniteData?.pages) return [];
|
||||
const seen = new Set<string>();
|
||||
const result: { value: string; label: string }[] = [];
|
||||
for (const page of usersInfiniteData.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,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [usersInfiniteData]);
|
||||
|
||||
const userFilterSelectProps: UsageFilterSelectProps = {
|
||||
onSearchChange: setSettledUserSearch,
|
||||
onLoadMore: () => void fetchNextUsersPage(),
|
||||
hasNextPage: hasNextUsersPage,
|
||||
isLoading: isLoadingUsers,
|
||||
isFetchingNextPage: isFetchingNextUsersPage,
|
||||
emptyText: "No users found",
|
||||
};
|
||||
// For admins: null means global view (all users), a string means filter by that user
|
||||
// For non-admins: always set to their own user ID
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(isAdmin ? null : userID || null);
|
||||
|
|
@ -536,18 +496,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
{isAdmin && usageView === "global" && (
|
||||
<div className="mb-4">
|
||||
<p className="mb-2 text-sm text-foreground">Filter by user</p>
|
||||
<PaginatedSearchSelect
|
||||
options={userOptions}
|
||||
value={selectedUserId ?? undefined}
|
||||
onValueChange={(value) => setSelectedUserId(value === "" ? null : value)}
|
||||
onSearchChange={setSettledUserSearch}
|
||||
onLoadMore={fetchNextUsersPage}
|
||||
hasNextPage={hasNextUsersPage}
|
||||
isLoading={isLoadingUsers}
|
||||
isFetchingNextPage={isFetchingNextUsersPage}
|
||||
placeholder="Select user to filter..."
|
||||
emptyText="No users found"
|
||||
/>
|
||||
<UserDropdown value={selectedUserId} onChange={setSelectedUserId} />
|
||||
</div>
|
||||
)}
|
||||
<Tabs defaultValue="cost">
|
||||
|
|
@ -1050,8 +999,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
entityType="user"
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
entityList={userOptions.length > 0 ? userOptions : null}
|
||||
filterSelectProps={userFilterSelectProps}
|
||||
entityList={null}
|
||||
premiumUser={premiumUser}
|
||||
dateValue={dateValue}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
|
||||
import { renderWithProviders, screen } from "../../../tests/test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { vi } from "vitest";
|
||||
import UsageExportHeader from "./UsageExportHeader";
|
||||
|
|
@ -71,28 +71,17 @@ describe("UsageExportHeader", () => {
|
|||
expect(screen.getByText("Team")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should keep a searchable single filter usable when no options match", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchChange = vi.fn();
|
||||
|
||||
it("should render a caller-supplied filter and its label without any built-in options", () => {
|
||||
renderWithProviders(
|
||||
<UsageExportHeader
|
||||
{...defaultProps}
|
||||
entityType="user"
|
||||
showFilters
|
||||
filterMode="single"
|
||||
filterLabel="User"
|
||||
filterPlaceholder="Select user to filter..."
|
||||
filterOptions={[]}
|
||||
filterSelectProps={{ onSearchChange, onLoadMore: vi.fn() }}
|
||||
onFiltersChange={vi.fn()}
|
||||
filterLabel="Filter by user"
|
||||
filterSlot={<div data-testid="custom-filter" />}
|
||||
/>,
|
||||
);
|
||||
|
||||
const userFilter = screen.getByRole("combobox");
|
||||
await user.click(userFilter);
|
||||
await user.type(userFilter, "alice");
|
||||
|
||||
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith("alice"));
|
||||
expect(screen.getByText("Filter by user")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("custom-filter")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
|
||||
import { Download } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Combobox,
|
||||
|
|
@ -11,7 +10,6 @@ import {
|
|||
ComboboxClear,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
|
|
@ -21,16 +19,6 @@ import EntityUsageExportModal from "./EntityUsageExportModal";
|
|||
import type { EntitySpendData, EntityType } from "./types";
|
||||
import type { Team } from "@/components/key_team_helpers/key_list";
|
||||
|
||||
export interface UsageFilterSelectProps {
|
||||
onSearchChange: (query: string) => void;
|
||||
onLoadMore: () => void;
|
||||
hasNextPage?: boolean;
|
||||
isLoading?: boolean;
|
||||
isFetchingNextPage?: boolean;
|
||||
emptyText?: string;
|
||||
loadingText?: string;
|
||||
}
|
||||
|
||||
interface UsageExportHeaderProps {
|
||||
dateValue: DateRangePickerValue;
|
||||
entityType: EntityType;
|
||||
|
|
@ -42,8 +30,6 @@ interface UsageExportHeaderProps {
|
|||
selectedFilters?: string[];
|
||||
onFiltersChange?: (filters: string[]) => void;
|
||||
filterOptions?: Array<{ label: string; value: string }>;
|
||||
filterMode?: "multiple" | "single";
|
||||
filterSelectProps?: UsageFilterSelectProps;
|
||||
filterSlot?: React.ReactNode;
|
||||
customTitle?: string;
|
||||
compactLayout?: boolean;
|
||||
|
|
@ -60,8 +46,6 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
|
|||
selectedFilters = [],
|
||||
onFiltersChange,
|
||||
filterOptions = [],
|
||||
filterMode = "multiple",
|
||||
filterSelectProps,
|
||||
filterSlot,
|
||||
customTitle,
|
||||
compactLayout = false,
|
||||
|
|
@ -70,8 +54,7 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
|
|||
const anchor = useComboboxAnchor();
|
||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||
|
||||
const hasBuiltInFilter = filterOptions.length > 0 || filterSelectProps !== undefined;
|
||||
const hasFilters = filterSlot != null || (showFilters && hasBuiltInFilter);
|
||||
const hasFilters = filterSlot != null || (showFilters && filterOptions.length > 0);
|
||||
const optionValues = filterOptions.map((option) => option.value);
|
||||
const labelOf = (value: string) => filterOptions.find((option) => option.value === value)?.label ?? value;
|
||||
|
||||
|
|
@ -88,35 +71,7 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
|
|||
</ComboboxContent>
|
||||
);
|
||||
|
||||
const searchableSingleFilter =
|
||||
filterSelectProps !== undefined ? (
|
||||
<PaginatedSearchSelect
|
||||
options={filterOptions}
|
||||
value={selectedFilters[0]}
|
||||
onValueChange={(next) => onFiltersChange?.(next ? [next] : [])}
|
||||
placeholder={filterPlaceholder}
|
||||
{...filterSelectProps}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
const singleFilter = searchableSingleFilter ?? (
|
||||
<Combobox
|
||||
items={optionValues}
|
||||
value={selectedFilters[0] ?? null}
|
||||
onValueChange={(next: string | null) => onFiltersChange?.(next ? [next] : [])}
|
||||
itemToStringLabel={labelOf}
|
||||
>
|
||||
<ComboboxInput
|
||||
className="w-full"
|
||||
placeholder={filterPlaceholder}
|
||||
aria-label={filterPlaceholder}
|
||||
showClear={selectedFilters.length > 0}
|
||||
/>
|
||||
{filterList}
|
||||
</Combobox>
|
||||
);
|
||||
|
||||
const multiFilter = (
|
||||
const builtInFilter = (
|
||||
<Combobox
|
||||
multiple
|
||||
items={optionValues}
|
||||
|
|
@ -140,8 +95,6 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
|
|||
</Combobox>
|
||||
);
|
||||
|
||||
const builtInFilter = filterMode === "single" ? singleFilter : multiFilter;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
export { default } from "./EntityUsageExportModal";
|
||||
export { default as UsageExportHeader } from "./UsageExportHeader";
|
||||
export type { UsageFilterSelectProps } from "./UsageExportHeader";
|
||||
export * from "./types";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,220 @@
|
|||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useInfiniteUsers, useUserLookup } from "@/app/(dashboard)/hooks/users/useUsers";
|
||||
import type { UserInfo } from "@/components/networking";
|
||||
import UserDropdown, { userOptionLabel } from "./UserDropdown";
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({
|
||||
useInfiniteUsers: vi.fn(),
|
||||
useUserLookup: vi.fn(),
|
||||
}));
|
||||
|
||||
const userRow = (userId: string, overrides: Partial<Pick<UserInfo, "user_alias" | "user_email">> = {}) => ({
|
||||
user_id: userId,
|
||||
user_alias: null,
|
||||
user_email: "",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const ALIASED = userRow("user-1", { user_alias: "Alice Admin", user_email: "alice@example.com" });
|
||||
const EMAILED = userRow("user-2", { user_email: "bob@example.com" });
|
||||
const BARE = userRow("user-3");
|
||||
|
||||
const mockUsersResult = (
|
||||
overrides: Partial<{
|
||||
pages: { users: ReturnType<typeof userRow>[] }[];
|
||||
fetchNextPage: () => void;
|
||||
isLoading: boolean;
|
||||
hasNextPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
}> = {},
|
||||
) => {
|
||||
const { pages = [{ users: [ALIASED, EMAILED, BARE] }], ...rest } = overrides;
|
||||
return {
|
||||
data: { pages },
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
isLoading: false,
|
||||
...rest,
|
||||
} as unknown as ReturnType<typeof useInfiniteUsers>;
|
||||
};
|
||||
|
||||
const mockLookupResult = (user: ReturnType<typeof userRow> | null) =>
|
||||
({ data: user }) as unknown as ReturnType<typeof useUserLookup>;
|
||||
|
||||
function setListMetrics(list: HTMLElement, metrics: { scrollTop: number; clientHeight: number; scrollHeight: number }) {
|
||||
Object.defineProperty(list, "scrollTop", { value: metrics.scrollTop, configurable: true });
|
||||
Object.defineProperty(list, "clientHeight", { value: metrics.clientHeight, configurable: true });
|
||||
Object.defineProperty(list, "scrollHeight", { value: metrics.scrollHeight, configurable: true });
|
||||
}
|
||||
|
||||
describe("UserDropdown", () => {
|
||||
const mockUseInfiniteUsers = vi.mocked(useInfiniteUsers);
|
||||
const mockUseUserLookup = vi.mocked(useUserLookup);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseInfiniteUsers.mockReturnValue(mockUsersResult());
|
||||
mockUseUserLookup.mockReturnValue(mockLookupResult(null));
|
||||
});
|
||||
|
||||
const combobox = () => screen.getByRole("combobox");
|
||||
|
||||
it("queries the first page of users with no search term", () => {
|
||||
render(<UserDropdown onChange={vi.fn()} />);
|
||||
|
||||
expect(mockUseInfiniteUsers).toHaveBeenCalledWith(50, undefined);
|
||||
});
|
||||
|
||||
it("forwards the pageSize prop to the users query", () => {
|
||||
render(<UserDropdown onChange={vi.fn()} pageSize={25} />);
|
||||
|
||||
expect(mockUseInfiniteUsers).toHaveBeenCalledWith(25, undefined);
|
||||
});
|
||||
|
||||
it("sends the typed query to the server instead of narrowing the loaded page", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<UserDropdown onChange={vi.fn()} />);
|
||||
|
||||
await user.click(combobox());
|
||||
await user.type(combobox(), "alice");
|
||||
|
||||
await waitFor(() => expect(mockUseInfiniteUsers).toHaveBeenCalledWith(50, "alice"));
|
||||
expect(screen.getByText("bob@example.com (user-2)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels a user by alias, falling back to email and then the bare id", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<UserDropdown onChange={vi.fn()} />);
|
||||
|
||||
await user.click(combobox());
|
||||
|
||||
expect(screen.getByText("Alice Admin (user-1)")).toBeInTheDocument();
|
||||
expect(screen.getByText("bob@example.com (user-2)")).toBeInTheDocument();
|
||||
expect(screen.getByText("user-3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("deduplicates a user that appears on more than one page", async () => {
|
||||
mockUseInfiniteUsers.mockReturnValue(
|
||||
mockUsersResult({
|
||||
pages: [{ users: [ALIASED] }, { users: [ALIASED, EMAILED] }],
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<UserDropdown onChange={vi.fn()} />);
|
||||
|
||||
await user.click(combobox());
|
||||
|
||||
expect(screen.getAllByText("Alice Admin (user-1)")).toHaveLength(1);
|
||||
expect(screen.getByText("bob@example.com (user-2)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("loads the next page once the list is scrolled near the bottom", async () => {
|
||||
const fetchNextPage = vi.fn();
|
||||
mockUseInfiniteUsers.mockReturnValue(mockUsersResult({ fetchNextPage, hasNextPage: true }));
|
||||
const user = userEvent.setup();
|
||||
render(<UserDropdown onChange={vi.fn()} />);
|
||||
|
||||
await user.click(combobox());
|
||||
const list = await screen.findByTestId("paginated-search-select-list");
|
||||
|
||||
setListMetrics(list, { scrollTop: 0, clientHeight: 100, scrollHeight: 1000 });
|
||||
fireEvent.scroll(list);
|
||||
expect(fetchNextPage).not.toHaveBeenCalled();
|
||||
|
||||
setListMetrics(list, { scrollTop: 850, clientHeight: 100, scrollHeight: 1000 });
|
||||
fireEvent.scroll(list);
|
||||
expect(fetchNextPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not load more when there is no next page or one is already in flight", async () => {
|
||||
const fetchNextPage = vi.fn();
|
||||
mockUseInfiniteUsers.mockReturnValue(mockUsersResult({ fetchNextPage, hasNextPage: false }));
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = render(<UserDropdown onChange={vi.fn()} />);
|
||||
|
||||
await user.click(combobox());
|
||||
setListMetrics(await screen.findByTestId("paginated-search-select-list"), {
|
||||
scrollTop: 900,
|
||||
clientHeight: 100,
|
||||
scrollHeight: 1000,
|
||||
});
|
||||
fireEvent.scroll(screen.getByTestId("paginated-search-select-list"));
|
||||
expect(fetchNextPage).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
|
||||
mockUseInfiniteUsers.mockReturnValue(
|
||||
mockUsersResult({ fetchNextPage, hasNextPage: true, isFetchingNextPage: true }),
|
||||
);
|
||||
render(<UserDropdown onChange={vi.fn()} />);
|
||||
|
||||
await user.click(combobox());
|
||||
setListMetrics(await screen.findByTestId("paginated-search-select-list"), {
|
||||
scrollTop: 900,
|
||||
clientHeight: 100,
|
||||
scrollHeight: 1000,
|
||||
});
|
||||
fireEvent.scroll(screen.getByTestId("paginated-search-select-list"));
|
||||
expect(fetchNextPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports the picked user id to onChange", async () => {
|
||||
const onChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<UserDropdown onChange={onChange} />);
|
||||
|
||||
await user.click(combobox());
|
||||
await user.click(screen.getByText("bob@example.com (user-2)"));
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith("user-2");
|
||||
});
|
||||
|
||||
it("reports null to onChange when the selection is cleared", async () => {
|
||||
const onChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<UserDropdown onChange={onChange} value="user-1" />);
|
||||
|
||||
await user.click(document.querySelector('[data-slot="combobox-clear"]') as HTMLElement);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("shows the label of the user passed in value", () => {
|
||||
render(<UserDropdown onChange={vi.fn()} value="user-1" />);
|
||||
|
||||
expect(combobox()).toHaveValue("Alice Admin (user-1)");
|
||||
expect(mockUseUserLookup).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it("looks up a selected user that is not on the loaded page so its label still shows", () => {
|
||||
const OFF_PAGE = userRow("user-99", { user_alias: "Zed Offpage" });
|
||||
mockUseUserLookup.mockReturnValue(mockLookupResult(OFF_PAGE));
|
||||
render(<UserDropdown onChange={vi.fn()} value="user-99" />);
|
||||
|
||||
expect(mockUseUserLookup).toHaveBeenLastCalledWith("user-99");
|
||||
expect(combobox()).toHaveValue("Zed Offpage (user-99)");
|
||||
});
|
||||
|
||||
it("falls back to the bare id while the off-page lookup has not resolved", () => {
|
||||
render(<UserDropdown onChange={vi.fn()} value="user-99" />);
|
||||
|
||||
expect(combobox()).toHaveValue("user-99");
|
||||
});
|
||||
});
|
||||
|
||||
describe("userOptionLabel", () => {
|
||||
it("prefers the alias", () => {
|
||||
expect(userOptionLabel(ALIASED)).toBe("Alice Admin (user-1)");
|
||||
});
|
||||
|
||||
it("falls back to the email", () => {
|
||||
expect(userOptionLabel(EMAILED)).toBe("bob@example.com (user-2)");
|
||||
});
|
||||
|
||||
it("falls back to the bare id", () => {
|
||||
expect(userOptionLabel(BARE)).toBe("user-3");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect";
|
||||
import type { SearchSelectOption } from "@/components/shared/SearchSelect";
|
||||
import { useInfiniteUsers, useUserLookup } from "@/app/(dashboard)/hooks/users/useUsers";
|
||||
import type { UserInfo } from "@/components/networking";
|
||||
|
||||
interface UserDropdownProps {
|
||||
value?: string | null;
|
||||
onChange: (userId: string | null) => void;
|
||||
disabled?: boolean;
|
||||
pageSize?: number;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export const userOptionLabel = (user: Pick<UserInfo, "user_id" | "user_alias" | "user_email">): string => {
|
||||
if (user.user_alias) return `${user.user_alias} (${user.user_id})`;
|
||||
if (user.user_email) return `${user.user_email} (${user.user_id})`;
|
||||
return user.user_id;
|
||||
};
|
||||
|
||||
const UserDropdown: React.FC<UserDropdownProps> = ({ value, onChange, disabled, pageSize = 50, id }) => {
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteUsers(
|
||||
pageSize,
|
||||
search || undefined,
|
||||
);
|
||||
|
||||
const loadedOptions = useMemo<SearchSelectOption[]>(() => {
|
||||
const byId = new Map<string, SearchSelectOption>();
|
||||
for (const user of (data?.pages ?? []).flatMap((page) => page.users)) {
|
||||
if (byId.has(user.user_id)) continue;
|
||||
byId.set(user.user_id, { value: user.user_id, label: userOptionLabel(user) });
|
||||
}
|
||||
return Array.from(byId.values());
|
||||
}, [data]);
|
||||
|
||||
const selectedIsLoaded = loadedOptions.some((option) => option.value === value);
|
||||
const { data: selectedUser } = useUserLookup(value && !selectedIsLoaded ? value : null);
|
||||
|
||||
const options = useMemo<SearchSelectOption[]>(() => {
|
||||
if (!value || selectedIsLoaded || !selectedUser) return loadedOptions;
|
||||
return [{ value: selectedUser.user_id, label: userOptionLabel(selectedUser) }, ...loadedOptions];
|
||||
}, [value, selectedIsLoaded, selectedUser, loadedOptions]);
|
||||
|
||||
return (
|
||||
<div data-testid="user-dropdown">
|
||||
<PaginatedSearchSelect
|
||||
options={options}
|
||||
value={value ?? undefined}
|
||||
onValueChange={(next) => onChange(next === "" ? null : next)}
|
||||
onSearchChange={setSearch}
|
||||
onLoadMore={fetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
isLoading={isLoading}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
placeholder="Search users by email…"
|
||||
emptyText="No users found"
|
||||
loadingText="Loading users…"
|
||||
disabled={disabled}
|
||||
inputId={id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserDropdown;
|
||||
Loading…
Add table
Reference in a new issue