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:
ryan-crabbe-berri 2026-08-17 15:00:38 -07:00 committed by GitHub
parent 3f4810b8f2
commit 427ed9e2d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 520 additions and 167 deletions

View file

@ -1493,9 +1493,6 @@
"max-lines": {
"count": 1
},
"no-nested-ternary": {
"count": 1
},
"react-hooks/purity": {
"count": 1
},

View file

@ -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();
});
});

View file

@ -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!),
});
};

View file

@ -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,
);
});
});
});
});

View file

@ -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}>

View file

@ -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({

View file

@ -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}
/>

View file

@ -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();
});
});

View file

@ -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">

View file

@ -1,4 +1,3 @@
export { default } from "./EntityUsageExportModal";
export { default as UsageExportHeader } from "./UsageExportHeader";
export type { UsageFilterSelectProps } from "./UsageExportHeader";
export * from "./types";

View file

@ -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");
});
});

View file

@ -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;