fix(ui): search all users from the User Usage filter box

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Devin AI 2026-08-14 02:20:42 +00:00
parent 2b63919f67
commit c07e1dc7a6
5 changed files with 118 additions and 19 deletions

View file

@ -25,7 +25,7 @@ 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 type { EntityType } from "@/components/EntityUsageExport/types";
import type { EntityType, FilterSearch } from "@/components/EntityUsageExport/types";
import {
agentDailyActivityCall,
customerDailyActivityCall,
@ -84,6 +84,7 @@ interface EntityUsageProps {
entityList: EntityList[] | null;
premiumUser: boolean;
dateValue: DateRangePickerValue;
filterSearch?: FilterSearch;
}
const ENTITY_FETCH_FNS: Record<EntityType, (...args: any[]) => Promise<any>> = {
@ -107,6 +108,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
entityList,
userRole,
dateValue,
filterSearch,
}) => {
const { teams } = useTeams();
const [selectedTags, setSelectedTags] = useState<string[]>([]);
@ -685,13 +687,16 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
dateValue={dateValue}
entityType={entityType}
spendData={spendData}
showFilters={entityType !== "team" && entityList !== null && entityList.length > 0}
showFilters={
entityType !== "team" && (filterSearch !== undefined || (entityList !== null && entityList.length > 0))
}
filterLabel={getFilterLabel(entityType)}
filterPlaceholder={getFilterPlaceholder(entityType)}
selectedFilters={selectedTags}
onFiltersChange={setSelectedTags}
filterOptions={getAllTags() || undefined}
filterMode={entityType === "user" ? "single" : "multiple"}
filterSearch={filterSearch}
teams={teams || []}
/>
<Tabs defaultValue={tabs[0].key}>

View file

@ -1045,6 +1045,13 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
entityList={userOptions.length > 0 ? userOptions : null}
premiumUser={premiumUser}
dateValue={dateValue}
filterSearch={{
onSearchChange: setSettledUserSearch,
onLoadMore: fetchNextUsersPage,
hasNextPage: hasNextUsersPage,
isLoading: isLoadingUsers,
isFetchingNextPage: isFetchingNextUsersPage,
}}
/>
)}
{/* User Agent Activity Panel */}

View file

@ -1,4 +1,4 @@
import { renderWithProviders, screen } from "../../../tests/test-utils";
import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
import userEvent from "@testing-library/user-event";
import { vi } from "vitest";
import UsageExportHeader from "./UsageExportHeader";
@ -70,4 +70,60 @@ describe("UsageExportHeader", () => {
);
expect(screen.getByText("Team")).toBeInTheDocument();
});
describe("single-select filter with server-side search", () => {
const searchProps = {
...defaultProps,
entityType: "user" as const,
showFilters: true,
filterMode: "single" as const,
filterLabel: "Filter by user",
filterPlaceholder: "Select user to filter...",
};
it("should report the typed query so users outside the loaded page can be found", async () => {
const user = userEvent.setup();
const onSearchChange = vi.fn();
renderWithProviders(
<UsageExportHeader
{...searchProps}
filterOptions={[{ label: "alpha70@example.com (u-70)", value: "u-70" }]}
onFiltersChange={vi.fn()}
filterSearch={{
onSearchChange,
onLoadMore: vi.fn(),
hasNextPage: true,
isLoading: false,
isFetchingNextPage: false,
}}
/>,
);
const input = screen.getByRole("combobox");
await user.click(input);
await user.type(input, "alpha10");
expect(input).toHaveValue("alpha10");
await waitFor(() => expect(onSearchChange).toHaveBeenCalledWith("alpha10"));
});
it("should keep the filter mounted when a search returns no loaded options", () => {
renderWithProviders(
<UsageExportHeader
{...searchProps}
filterOptions={[]}
onFiltersChange={vi.fn()}
filterSearch={{
onSearchChange: vi.fn(),
onLoadMore: vi.fn(),
hasNextPage: false,
isLoading: false,
isFetchingNextPage: false,
}}
/>,
);
expect(screen.getByRole("combobox")).toBeInTheDocument();
});
});
});

View file

@ -15,8 +15,9 @@ import {
ComboboxList,
ComboboxValue,
} from "@/components/ui/combobox";
import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect";
import EntityUsageExportModal from "./EntityUsageExportModal";
import type { EntitySpendData, EntityType } from "./types";
import type { EntitySpendData, EntityType, FilterSearch } from "./types";
import type { Team } from "@/components/key_team_helpers/key_list";
interface UsageExportHeaderProps {
@ -31,6 +32,7 @@ interface UsageExportHeaderProps {
onFiltersChange?: (filters: string[]) => void;
filterOptions?: Array<{ label: string; value: string }>;
filterMode?: "multiple" | "single";
filterSearch?: FilterSearch;
customTitle?: string;
compactLayout?: boolean;
teams?: Team[];
@ -47,13 +49,14 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
onFiltersChange,
filterOptions = [],
filterMode = "multiple",
filterSearch,
customTitle,
compactLayout = false,
teams = [],
}) => {
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
const hasFilters = showFilters && filterOptions.length > 0;
const hasFilters = showFilters && (filterOptions.length > 0 || filterSearch !== undefined);
const optionValues = filterOptions.map((option) => option.value);
const labelOf = (value: string) => filterOptions.find((option) => option.value === value)?.label ?? value;
@ -70,6 +73,39 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
</ComboboxContent>
);
const searchableSingleSelect =
filterSearch === undefined ? null : (
<PaginatedSearchSelect
options={filterOptions}
value={selectedFilters[0] ?? undefined}
onValueChange={(next: string) => onFiltersChange?.(next === "" ? [] : [next])}
onSearchChange={filterSearch.onSearchChange}
onLoadMore={filterSearch.onLoadMore}
hasNextPage={filterSearch.hasNextPage}
isLoading={filterSearch.isLoading}
isFetchingNextPage={filterSearch.isFetchingNextPage}
placeholder={filterPlaceholder}
emptyText="No options found"
/>
);
const singleSelect = searchableSingleSelect ?? (
<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>
);
return (
<>
<div className="mb-4">
@ -83,20 +119,7 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
<div>
{filterLabel && <label className="text-sm font-medium text-gray-700 block mb-2">{filterLabel}</label>}
{filterMode === "single" ? (
<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>
singleSelect
) : (
<Combobox
multiple

View file

@ -17,6 +17,14 @@ export interface EntitySpendData {
};
}
export interface FilterSearch {
onSearchChange: (query: string) => void;
onLoadMore: () => void;
hasNextPage: boolean;
isLoading: boolean;
isFetchingNextPage: boolean;
}
export interface EntityUsageExportModalProps {
isOpen: boolean;
onClose: () => void;