fix(ui): make per-user usage filter searchable

Reuse the Global Usage user search and pagination behavior in the Per User report, including empty-result handling.

Co-Authored-By: Codex
This commit is contained in:
Daniel Meismer 2026-08-13 12:16:36 -04:00
parent 09889e1986
commit 19eae00d71
6 changed files with 100 additions and 25 deletions

View file

@ -37,7 +37,7 @@ import { Alert, Button, Tooltip } from "antd";
import React, { type ReactNode, useMemo, useState } from "react";
import TeamMultiSelect from "@/components/common_components/team_multi_select";
import { ActivityMetrics, processActivityData } from "@/components/activity_metrics";
import { UsageExportHeader } from "@/components/EntityUsageExport";
import { UsageExportHeader, type UsageFilterSelectProps } from "@/components/EntityUsageExport";
import type { EntityType } from "@/components/EntityUsageExport/types";
import {
agentDailyActivityCall,
@ -97,6 +97,7 @@ interface EntityUsageProps {
entityList: EntityList[] | null;
premiumUser: boolean;
dateValue: DateRangePickerValue;
filterSelectProps?: UsageFilterSelectProps;
}
const ENTITY_FETCH_FNS: Record<EntityType, (...args: any[]) => Promise<any>> = {
@ -120,6 +121,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
entityList,
userRole,
dateValue,
filterSelectProps,
}) => {
const { teams } = useTeams();
const [selectedTags, setSelectedTags] = useState<string[]>([]);
@ -678,13 +680,17 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
dateValue={dateValue}
entityType={entityType}
spendData={spendData}
showFilters={entityType !== "team" && entityList !== null && entityList.length > 0}
showFilters={
entityType !== "team" &&
(filterSelectProps?.showSearch === true || (entityList !== null && entityList.length > 0))
}
filterLabel={getFilterLabel(entityType)}
filterPlaceholder={getFilterPlaceholder(entityType)}
selectedFilters={selectedTags}
onFiltersChange={setSelectedTags}
filterOptions={getAllTags() || undefined}
filterMode={entityType === "user" ? "single" : "multiple"}
filterSelectProps={filterSelectProps}
teams={teams || []}
/>
<TabGroup>

View file

@ -46,7 +46,18 @@ vi.mock("@/components/UsagePage/components/EntityUsage/TopKeyView", () => ({
}));
vi.mock("./EntityUsage/EntityUsage", () => ({
default: () => <div>Entity Usage</div>,
default: ({
entityType,
filterSelectProps,
}: {
entityType?: string;
filterSelectProps?: { showSearch?: boolean };
}) => (
<div>
Entity Usage
{entityType === "user" && filterSelectProps?.showSearch && <span>Searchable user filter</span>}
</div>
),
EntityList: [],
}));
@ -76,6 +87,7 @@ vi.mock("./UsageViewSelect/UsageViewSelect", async () => {
React.createElement("option", { value: "customer" }, "Customer Usage"),
tagOption,
React.createElement("option", { value: "agent" }, "Agent Usage"),
React.createElement("option", { value: "user" }, "User Usage"),
React.createElement("option", { value: "user-agent-activity" }, "User Agent Activity"),
);
};
@ -924,6 +936,18 @@ 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: {

View file

@ -38,7 +38,7 @@ 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 from "@/components/EntityUsageExport";
import EntityUsageExportModal, { type UsageFilterSelectProps } from "@/components/EntityUsageExport";
import { Team } from "@/components/key_team_helpers/key_list";
import {
gatewayDailyActivityCall,
@ -161,6 +161,26 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
}
};
const userFilterSelectProps: UsageFilterSelectProps = {
showSearch: true,
filterOption: false,
onSearch: handleUserSearchChange,
searchValue: userSearchInput,
onPopupScroll: handleUserPopupScroll,
loading: isLoadingUsers,
notFoundContent: isLoadingUsers ? <LoadingOutlined spin /> : "No users found",
popupRender: (menu) => (
<>
{menu}
{isFetchingNextUsersPage && (
<div style={{ textAlign: "center", padding: 8 }}>
<LoadingOutlined spin />
</div>
)}
</>
),
};
// 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);
@ -565,29 +585,13 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<div className="mb-4">
<Text className="mb-2">Filter by user</Text>
<Select
showSearch
{...userFilterSelectProps}
allowClear
style={{ width: "100%" }}
placeholder="Select user to filter..."
value={selectedUserId}
onChange={(value) => setSelectedUserId(value ?? null)}
filterOption={false}
onSearch={handleUserSearchChange}
searchValue={userSearchInput}
onPopupScroll={handleUserPopupScroll}
loading={isLoadingUsers}
notFoundContent={isLoadingUsers ? <LoadingOutlined spin /> : "No users found"}
options={userOptions}
popupRender={(menu) => (
<>
{menu}
{isFetchingNextUsersPage && (
<div style={{ textAlign: "center", padding: 8 }}>
<LoadingOutlined spin />
</div>
)}
</>
)}
/>
</div>
)}
@ -1057,6 +1061,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
userID={userID}
userRole={userRole}
entityList={userOptions.length > 0 ? userOptions : null}
filterSelectProps={userFilterSelectProps}
premiumUser={premiumUser}
dateValue={dateValue}
/>

View file

@ -70,4 +70,29 @@ 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 onSearch = vi.fn();
renderWithProviders(
<UsageExportHeader
{...defaultProps}
entityType="user"
showFilters
filterMode="single"
filterLabel="User"
filterPlaceholder="Select user to filter..."
filterOptions={[]}
filterSelectProps={{ showSearch: true, filterOption: false, onSearch }}
onFiltersChange={vi.fn()}
/>,
);
const userFilter = screen.getByRole("combobox");
await user.click(userFilter);
await user.type(userFilter, "alice");
expect(onSearch).toHaveBeenLastCalledWith("alice");
});
});

View file

@ -1,11 +1,23 @@
import type { DateRangePickerValue } from "@tremor/react";
import { Button, Text } from "@tremor/react";
import { Select } from "antd";
import { Select, type SelectProps } from "antd";
import React, { useState } from "react";
import EntityUsageExportModal from "./EntityUsageExportModal";
import type { EntitySpendData, EntityType } from "./types";
import type { Team } from "@/components/key_team_helpers/key_list";
export type UsageFilterSelectProps = Pick<
SelectProps<string>,
| "filterOption"
| "loading"
| "notFoundContent"
| "onPopupScroll"
| "onSearch"
| "popupRender"
| "searchValue"
| "showSearch"
>;
interface UsageExportHeaderProps {
dateValue: DateRangePickerValue;
entityType: EntityType;
@ -18,6 +30,7 @@ interface UsageExportHeaderProps {
onFiltersChange?: (filters: string[]) => void;
filterOptions?: Array<{ label: string; value: string }>;
filterMode?: "multiple" | "single";
filterSelectProps?: UsageFilterSelectProps;
customTitle?: string;
compactLayout?: boolean;
teams?: Team[];
@ -34,16 +47,16 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
onFiltersChange,
filterOptions = [],
filterMode = "multiple",
filterSelectProps,
customTitle,
compactLayout = false,
teams = [],
}) => {
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
const hasFilters = showFilters && (filterOptions.length > 0 || filterSelectProps?.showSearch === true);
// Determine grid layout based on what's visible
const getGridCols = () => {
const hasFilters = showFilters && filterOptions.length > 0;
if (hasFilters) return "grid-cols-[1fr_auto]";
return "grid-cols-[auto]";
};
@ -57,10 +70,11 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
* vertical drift when the right column has a label above the input.
*/}
<div className={`grid ${getGridCols()} items-end gap-4`}>
{showFilters && filterOptions.length > 0 && (
{hasFilters && (
<div>
{filterLabel && <Text className="mb-2">{filterLabel}</Text>}
<Select
{...filterSelectProps}
mode={filterMode === "single" ? undefined : "multiple"}
style={{ width: "100%" }}
placeholder={filterPlaceholder}

View file

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