mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
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:
parent
2b63919f67
commit
c07e1dc7a6
5 changed files with 118 additions and 19 deletions
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue