Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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;
Loading