fix(ui): add searchable filter to User Usage page

User Usage filter only allowed selecting from a static preloaded list,
unlike Global Usage which supports debounced text search via useInfiniteUsers.

Add UserSingleSelect component (single-select with useInfiniteUsers +
300ms debounce + infinite scroll) and wire it into EntityUsage for
entityType === 'user', mirroring the TeamMultiSelect fix in PR #25107.

- Add src/components/common_components/user_single_select.tsx
- Add src/components/common_components/user_single_select.test.tsx (10 tests)
- Update EntityUsage.tsx to render UserSingleSelect for user entity type
- Update EntityUsage.test.tsx to mock user_single_select
This commit is contained in:
xykong 2026-04-15 12:49:50 +08:00
parent 3e1479c052
commit 5d21637dd0
4 changed files with 290 additions and 1 deletions

View file

@ -49,6 +49,10 @@ vi.mock("../../../common_components/team_multi_select", () => ({
default: () => <div>Team Multi Select</div>,
}));
vi.mock("../../../common_components/user_single_select", () => ({
default: () => <div>User Single Select</div>,
}));
// Mock useTeams hook
vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({
default: vi.fn(() => ({

View file

@ -26,6 +26,7 @@ import { ExportOutlined, LoadingOutlined } from "@ant-design/icons";
import { Alert, Button } from "antd";
import React, { useMemo, useState } from "react";
import TeamMultiSelect from "../../../common_components/team_multi_select";
import UserSingleSelect from "../../../common_components/user_single_select";
import { ActivityMetrics, processActivityData } from "../../../activity_metrics";
import { UsageExportHeader } from "../../../EntityUsageExport";
import type { EntityType } from "../../../EntityUsageExport/types";
@ -478,11 +479,20 @@ const EntityUsage: React.FC<EntityUsageProps> = ({ accessToken, entityType, enti
/>
</div>
)}
{entityType === "user" && (
<div className="mb-4">
<Text className="mb-2">Filter by user</Text>
<UserSingleSelect
value={selectedTags[0] ?? null}
onChange={(value) => setSelectedTags(value ? [value] : [])}
/>
</div>
)}
<UsageExportHeader
dateValue={dateValue}
entityType={entityType}
spendData={spendData}
showFilters={entityType !== "team" && entityList !== null && entityList.length > 0}
showFilters={entityType !== "team" && entityType !== "user" && entityList !== null && entityList.length > 0}
filterLabel={getFilterLabel(entityType)}
filterPlaceholder={getFilterPlaceholder(entityType)}
selectedFilters={selectedTags}

View file

@ -0,0 +1,152 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import UserSingleSelect from "./user_single_select";
const mockUseInfiniteUsers = vi.fn();
vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({
useInfiniteUsers: (...args: any[]) => mockUseInfiniteUsers(...args),
}));
const buildPage = (users: Array<{ user_id: string; user_email: string | null; user_alias: string | null }>) => ({
users,
page: 1,
page_size: 50,
total: users.length,
total_pages: 1,
});
const DEFAULT_HOOK_STATE = {
data: {
pages: [
buildPage([
{ user_id: "user-1", user_email: "alice@example.com", user_alias: null },
{ user_id: "user-2", user_email: null, user_alias: "Bob" },
{ user_id: "user-3", user_email: "charlie@example.com", user_alias: "Charlie" },
]),
],
},
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetchingNextPage: false,
isLoading: false,
};
describe("UserSingleSelect", () => {
it("should render a combobox", () => {
mockUseInfiniteUsers.mockReturnValue(DEFAULT_HOOK_STATE);
render(<UserSingleSelect />);
expect(screen.getByRole("combobox")).toBeInTheDocument();
});
it("should display user options when opened", async () => {
mockUseInfiniteUsers.mockReturnValue(DEFAULT_HOOK_STATE);
const user = userEvent.setup();
render(<UserSingleSelect />);
await act(async () => {
await user.click(screen.getByRole("combobox"));
});
expect(await screen.findByText("alice@example.com")).toBeInTheDocument();
expect(screen.getByText("user-1")).toBeInTheDocument();
});
it("should call onChange with user_id when a user is selected", async () => {
mockUseInfiniteUsers.mockReturnValue(DEFAULT_HOOK_STATE);
const onChange = vi.fn();
const user = userEvent.setup();
render(<UserSingleSelect onChange={onChange} />);
await act(async () => {
await user.click(screen.getByRole("combobox"));
});
await act(async () => {
await user.click(await screen.findByText("alice@example.com"));
});
expect(onChange).toHaveBeenCalledWith("user-1");
});
it("should call onChange with null when selection is cleared", async () => {
mockUseInfiniteUsers.mockReturnValue(DEFAULT_HOOK_STATE);
const onChange = vi.fn();
const user = userEvent.setup();
render(<UserSingleSelect value="user-1" onChange={onChange} />);
const clearButton = document.querySelector(".ant-select-clear");
if (clearButton) {
await act(async () => {
await user.click(clearButton as Element);
});
expect(onChange).toHaveBeenCalledWith(null);
}
});
it("should pass search input to useInfiniteUsers as debounced value", async () => {
mockUseInfiniteUsers.mockReturnValue({ ...DEFAULT_HOOK_STATE, data: { pages: [] } });
const user = userEvent.setup();
render(<UserSingleSelect />);
await act(async () => {
await user.click(screen.getByRole("combobox"));
await user.type(screen.getByRole("combobox"), "alice");
});
await waitFor(() => {
expect(mockUseInfiniteUsers).toHaveBeenCalledWith(
expect.any(Number),
"alice",
);
});
});
it("should show loading indicator when isLoading is true", () => {
mockUseInfiniteUsers.mockReturnValue({ ...DEFAULT_HOOK_STATE, isLoading: true });
render(<UserSingleSelect />);
expect(document.querySelector(".ant-select-loading")).toBeInTheDocument();
});
it("should show user alias in label when alias is set", async () => {
mockUseInfiniteUsers.mockReturnValue(DEFAULT_HOOK_STATE);
const user = userEvent.setup();
render(<UserSingleSelect />);
await act(async () => {
await user.click(screen.getByRole("combobox"));
});
expect(await screen.findByText("charlie@example.com")).toBeInTheDocument();
});
it("should use custom placeholder when provided", () => {
mockUseInfiniteUsers.mockReturnValue(DEFAULT_HOOK_STATE);
render(<UserSingleSelect placeholder="Find a user..." />);
expect(screen.getByText("Find a user...")).toBeInTheDocument();
});
it("should add ant-select-disabled class when disabled prop is true", () => {
mockUseInfiniteUsers.mockReturnValue(DEFAULT_HOOK_STATE);
const { container } = render(<UserSingleSelect disabled />);
expect(container.querySelector(".ant-select-disabled")).toBeTruthy();
});
it("should show fetchNextPage spinner when isFetchingNextPage is true", async () => {
mockUseInfiniteUsers.mockReturnValue({
...DEFAULT_HOOK_STATE,
hasNextPage: true,
isFetchingNextPage: true,
});
const user = userEvent.setup();
render(<UserSingleSelect />);
await act(async () => {
await user.click(screen.getByRole("combobox"));
});
const spinners = document.querySelectorAll(".anticon-loading");
expect(spinners.length).toBeGreaterThanOrEqual(1);
});
});

View file

@ -0,0 +1,123 @@
import React, { useMemo, useState, type UIEvent } from "react";
import { Select, Typography } from "antd";
import { LoadingOutlined } from "@ant-design/icons";
import { useDebouncedState } from "@tanstack/react-pacer/debouncer";
import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers";
const { Text } = Typography;
interface UserSingleSelectProps {
value?: string | null;
onChange?: (value: string | null) => void;
disabled?: boolean;
pageSize?: number;
placeholder?: string;
}
const SCROLL_THRESHOLD = 0.8;
const DEBOUNCE_MS = 300;
/**
* A single-select dropdown for users with server-side debounced search and
* infinite scroll. Mirrors TeamMultiSelect but uses single-select mode to
* match the `/user/daily/activity` API contract that accepts one user_id.
*/
const UserSingleSelect: React.FC<UserSingleSelectProps> = ({
value,
onChange,
disabled,
pageSize = 50,
placeholder = "Search users by email or ID...",
}) => {
const [searchInput, setSearchInput] = useState("");
const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", {
wait: DEBOUNCE_MS,
});
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteUsers(pageSize, debouncedSearch || undefined);
const userOptions = useMemo(() => {
if (!data?.pages) return [];
const seen = new Set<string>();
const result: { value: string; label: string; email: string | null }[] = [];
for (const page of data.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,
email: user.user_email ?? null,
});
}
}
return result;
}, [data]);
const handlePopupScroll = (e: UIEvent<HTMLDivElement>) => {
const target = e.currentTarget;
const scrollRatio =
(target.scrollTop + target.clientHeight) / target.scrollHeight;
if (scrollRatio >= SCROLL_THRESHOLD && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
};
const handleSearch = (val: string) => {
setSearchInput(val);
setDebouncedSearch(val);
};
return (
<Select
showSearch
allowClear
placeholder={placeholder}
value={value ?? undefined}
onChange={(val: string | undefined) => onChange?.(val ?? null)}
disabled={disabled}
filterOption={false}
onSearch={handleSearch}
searchValue={searchInput}
onPopupScroll={handlePopupScroll}
loading={isLoading}
notFoundContent={isLoading ? <LoadingOutlined spin /> : "No users found"}
style={{ width: "100%" }}
popupRender={(menu) => (
<>
{menu}
{isFetchingNextPage && (
<div style={{ textAlign: "center", padding: 8 }}>
<LoadingOutlined spin />
</div>
)}
</>
)}
>
{userOptions.map((opt) => (
<Select.Option key={opt.value} value={opt.value}>
{opt.email ? (
<>
<span className="font-medium">{opt.email}</span>{" "}
<Text type="secondary">({opt.value})</Text>
</>
) : (
<span>{opt.value}</span>
)}
</Select.Option>
))}
</Select>
);
};
export default UserSingleSelect;