mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Merge pull request #38574 from BerriAI/litellm_combobox_server_search_hardening
fix(ui): stop server-searched comboboxes from clobbering picks and queries
This commit is contained in:
commit
3c41392893
13 changed files with 396 additions and 205 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import TeamMultiSelect from "./team_multi_select";
|
||||
|
|
@ -58,9 +59,9 @@ describe("TeamMultiSelect", () => {
|
|||
await user.click(combobox());
|
||||
|
||||
expect(screen.getByText("Alpha Team")).toBeInTheDocument();
|
||||
expect(screen.getByText("(team-1)")).toBeInTheDocument();
|
||||
expect(screen.getByText("team-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Beta Team")).toBeInTheDocument();
|
||||
expect(screen.getByText("(team-2)")).toBeInTheDocument();
|
||||
expect(screen.getByText("team-2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("deduplicates a team that appears on more than one page", async () => {
|
||||
|
|
@ -116,6 +117,29 @@ describe("TeamMultiSelect", () => {
|
|||
expect(screen.getByText(/No teams found/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps a picked team's alias on its chip once a later search drops it from the loaded page", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
function Controlled() {
|
||||
const [value, setValue] = useState<string[]>([]);
|
||||
return <TeamMultiSelect value={value} onChange={setValue} />;
|
||||
}
|
||||
const { rerender } = render(<Controlled />);
|
||||
|
||||
await user.click(combobox());
|
||||
const matches = screen.getAllByText("Beta Team");
|
||||
await user.click(matches[matches.length - 1]);
|
||||
|
||||
mockUseInfiniteTeams.mockReturnValue(
|
||||
mockTeamsResult({ pages: [{ teams: [team("team-3", "Gamma Team")] }] }) as never,
|
||||
);
|
||||
rerender(<Controlled />);
|
||||
|
||||
const chips = document.querySelector('[data-slot="combobox-chips"]') as HTMLElement;
|
||||
expect(within(chips).getByText("Beta Team")).toBeInTheDocument();
|
||||
expect(within(chips).queryByText("team-2")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes the page size and organization filter through to the teams query", () => {
|
||||
render(<TeamMultiSelect pageSize={25} organizationId="org-7" />);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,22 +1,7 @@
|
|||
import React, { useMemo, useState, type UIEvent } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
ComboboxChips,
|
||||
ComboboxChipsInput,
|
||||
ComboboxClear,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
} from "@/components/ui/combobox";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { PaginatedMultiSelect } from "@/components/shared/PaginatedMultiSelect";
|
||||
import type { SearchSelectOption } from "@/components/shared/SearchSelect";
|
||||
import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import { Team } from "../key_team_helpers/key_list";
|
||||
|
||||
interface TeamMultiSelectProps {
|
||||
value?: string[];
|
||||
|
|
@ -27,8 +12,6 @@ interface TeamMultiSelectProps {
|
|||
placeholder?: string;
|
||||
}
|
||||
|
||||
const SCROLL_THRESHOLD = 0.8;
|
||||
|
||||
const TeamMultiSelect: React.FC<TeamMultiSelectProps> = ({
|
||||
value = [],
|
||||
onChange,
|
||||
|
|
@ -37,9 +20,7 @@ const TeamMultiSelect: React.FC<TeamMultiSelectProps> = ({
|
|||
pageSize = 20,
|
||||
placeholder = "Search teams by alias...",
|
||||
}) => {
|
||||
const anchor = useComboboxAnchor();
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSetSearch = useDebouncedCallback(setSearch, { wait: DEBOUNCE_WAIT_MS });
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteTeams(
|
||||
pageSize,
|
||||
|
|
@ -47,68 +28,40 @@ const TeamMultiSelect: React.FC<TeamMultiSelectProps> = ({
|
|||
organizationId,
|
||||
);
|
||||
|
||||
const teamById = useMemo(
|
||||
const options = useMemo<SearchSelectOption[]>(
|
||||
() =>
|
||||
new Map<string, Team>(
|
||||
(data?.pages ?? []).flatMap((page) => page.teams).map((team) => [team.team_id, team] as const),
|
||||
Array.from(
|
||||
new Map(
|
||||
(data?.pages ?? [])
|
||||
.flatMap((page) => page.teams)
|
||||
.map(
|
||||
(team) =>
|
||||
[
|
||||
team.team_id,
|
||||
{ label: team.team_alias || team.team_id, value: team.team_id, sublabel: team.team_id },
|
||||
] as const,
|
||||
),
|
||||
).values(),
|
||||
),
|
||||
[data],
|
||||
);
|
||||
const teamIds = useMemo(() => Array.from(teamById.keys()), [teamById]);
|
||||
|
||||
const aliasOf = (teamId: string) => teamById.get(teamId)?.team_alias ?? teamId;
|
||||
|
||||
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
|
||||
const target = event.currentTarget;
|
||||
if (target.scrollHeight === 0) return;
|
||||
const scrollRatio = (target.scrollTop + target.clientHeight) / target.scrollHeight;
|
||||
if (scrollRatio >= SCROLL_THRESHOLD && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
multiple
|
||||
items={teamIds}
|
||||
<PaginatedMultiSelect
|
||||
options={options}
|
||||
value={value}
|
||||
onValueChange={(next: string[]) => onChange?.(next)}
|
||||
filter={null}
|
||||
onInputValueChange={debouncedSetSearch}
|
||||
onSearchChange={setSearch}
|
||||
onLoadMore={fetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
isLoading={isLoading}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
placeholder={placeholder}
|
||||
emptyText="No teams found"
|
||||
loadingText="Loading teams..."
|
||||
clearAllLabel="Clear all teams"
|
||||
disabled={disabled}
|
||||
>
|
||||
<ComboboxChips render={<div ref={anchor} />} className="w-full" aria-busy={isLoading}>
|
||||
<ComboboxValue>
|
||||
{(selected: string[]) =>
|
||||
selected.map((teamId) => (
|
||||
<ComboboxChip key={teamId} aria-label={aliasOf(teamId)}>
|
||||
{aliasOf(teamId)}
|
||||
</ComboboxChip>
|
||||
))
|
||||
}
|
||||
</ComboboxValue>
|
||||
<ComboboxChipsInput placeholder={placeholder} aria-label={placeholder} disabled={disabled} />
|
||||
{value.length > 0 && <ComboboxClear aria-label="Clear all teams" disabled={disabled} />}
|
||||
</ComboboxChips>
|
||||
<ComboboxContent anchor={anchor}>
|
||||
<ComboboxEmpty>
|
||||
{isLoading ? <Loader2 className="size-4 animate-spin text-muted-foreground" /> : "No teams found"}
|
||||
</ComboboxEmpty>
|
||||
<ComboboxList onScroll={handleScroll}>
|
||||
{(teamId: string) => (
|
||||
<ComboboxItem key={teamId} value={teamId}>
|
||||
<span className="font-medium">{aliasOf(teamId)}</span>{" "}
|
||||
<span className="text-muted-foreground">({teamId})</span>
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex justify-center py-2">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -199,6 +199,52 @@ describe("UserSearchModal submit payload", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("UserSearchModal search lifecycle", () => {
|
||||
const directory = [
|
||||
{ user_id: "u-jones", user_email: "alice.jones@example.com" },
|
||||
{ user_id: "u-smith", user_email: "alice.smith@example.com" },
|
||||
{ user_id: "u-bob", user_email: "bob@example.com" },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(userFilterUICall).mockReset();
|
||||
vi.mocked(userFilterUICall).mockImplementation((_accessToken, params) => {
|
||||
const query = params.get("user_email") ?? "";
|
||||
return Promise.resolve(directory.filter((user) => user.user_email.includes(query))) as never;
|
||||
});
|
||||
});
|
||||
|
||||
const searchedFor = (): string[] =>
|
||||
vi.mocked(userFilterUICall).mock.calls.map((call) => {
|
||||
const email = call[1].get("user_email");
|
||||
return email === null ? `user_id=${call[1].get("user_id")}` : `user_email=${email}`;
|
||||
});
|
||||
|
||||
const settleDebounce = () =>
|
||||
act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_WAIT_MS + 100));
|
||||
});
|
||||
|
||||
it("leaves the search unfiltered after a pick, so reopening searches the newly typed text", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<UserSearchModal isVisible onCancel={vi.fn()} onSubmit={vi.fn()} accessToken="sk-test" />);
|
||||
|
||||
const input = getEmailSearchInput();
|
||||
await user.click(input);
|
||||
await user.type(input, "ali");
|
||||
await user.click(await screen.findByRole("option", { name: "alice.jones@example.com" }));
|
||||
|
||||
await settleDebounce();
|
||||
expect(searchedFor()).toEqual(["user_email=ali"]);
|
||||
|
||||
await user.click(input);
|
||||
await user.type(input, "bob");
|
||||
|
||||
expect(await screen.findByRole("option", { name: "bob@example.com" })).toBeInTheDocument();
|
||||
expect(searchedFor()).toEqual(["user_email=ali", "user_email=bob"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UserSearchModal out-of-order search results", () => {
|
||||
const answers = new Map<string, (users: { user_id: string; user_email: string }[]) => void>();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,12 @@
|
|||
import { useRef, useState } from "react";
|
||||
import { Info, UserPlus } from "lucide-react";
|
||||
import { Alert, AlertTitle } from "@/components/shared/Alert";
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { userFilterUICall } from "@/components/networking";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import { FieldGroup } from "@/components/ui/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
|
@ -119,14 +110,9 @@ const UserSearchModal: React.FC<UserSearchModalProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const debouncedSearch = useDebouncedCallback(
|
||||
(text: string, fieldName: "user_email" | "user_id") => fetchUsers(text, fieldName),
|
||||
{ wait: DEBOUNCE_WAIT_MS },
|
||||
);
|
||||
|
||||
const handleSearch = (value: string, fieldName: "user_email" | "user_id"): void => {
|
||||
setSelectedField(fieldName);
|
||||
debouncedSearch(value, fieldName);
|
||||
void fetchUsers(value, fieldName);
|
||||
};
|
||||
|
||||
const handleSelect = (option: UserOption | null): void => {
|
||||
|
|
@ -154,54 +140,30 @@ const UserSearchModal: React.FC<UserSearchModalProps> = ({
|
|||
if (event.key === "Enter") event.preventDefault();
|
||||
};
|
||||
|
||||
const optionsFor = (fieldName: "user_email" | "user_id", value: string | undefined): UserOption[] => {
|
||||
const visible = selectedField === fieldName ? userOptions : [];
|
||||
if (value == null || value === "" || visible.some((option) => option.value === value)) return visible;
|
||||
return [{ label: value, value, user: null }, ...visible];
|
||||
};
|
||||
|
||||
const renderUserSearch = (
|
||||
fieldName: "user_email" | "user_id",
|
||||
placeholder: string,
|
||||
controlProps: { id: string; value: string | undefined; onChange: (value: string | undefined) => void },
|
||||
testId?: string,
|
||||
) => {
|
||||
const items = optionsFor(fieldName, controlProps.value);
|
||||
const selected = items.find((option) => option.value === controlProps.value) ?? null;
|
||||
const items = selectedField === fieldName ? userOptions : [];
|
||||
return (
|
||||
<div data-testid={testId}>
|
||||
<Combobox
|
||||
items={items}
|
||||
value={selected}
|
||||
// @ts-expect-error TS2322 -- Combobox.Root narrows autoHighlight to boolean; the AriaCombobox it wraps
|
||||
// accepts "always", the only value that highlights a list this component filters server-side
|
||||
autoHighlight="always"
|
||||
filter={null}
|
||||
onValueChange={(option: UserOption | null) => {
|
||||
controlProps.onChange(option?.value);
|
||||
handleSelect(option);
|
||||
<div data-testid={testId} onKeyDown={swallowEnter}>
|
||||
<PaginatedSearchSelect
|
||||
options={items}
|
||||
value={controlProps.value}
|
||||
onValueChange={(value: string) => {
|
||||
controlProps.onChange(value === "" ? undefined : value);
|
||||
handleSelect(items.find((option) => option.value === value) ?? null);
|
||||
}}
|
||||
onInputValueChange={(text: string) => handleSearch(text, fieldName)}
|
||||
isItemEqualToValue={(a: UserOption, b: UserOption) => a.value === b.value}
|
||||
itemToStringLabel={(option: UserOption) => option.label}
|
||||
>
|
||||
<ComboboxInput
|
||||
id={controlProps.id}
|
||||
placeholder={placeholder}
|
||||
showClear={selected !== null}
|
||||
onKeyDown={swallowEnter}
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>{loading ? "Loading..." : "No results"}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(option: UserOption) => (
|
||||
<ComboboxItem key={option.value} value={option}>
|
||||
{option.label}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
onSearchChange={(query: string) => handleSearch(query, fieldName)}
|
||||
autoHighlight="always"
|
||||
isLoading={loading}
|
||||
placeholder={placeholder}
|
||||
emptyText="No results"
|
||||
loadingText="Loading..."
|
||||
inputId={controlProps.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -829,14 +829,14 @@ describe("CreateKey", () => {
|
|||
await act(async () => {
|
||||
answers.get("alice.smith@example.com")?.([{ user_id: "u-smith", user_email: "alice.smith@example.com" }]);
|
||||
});
|
||||
await screen.findByTitle("alice.smith@example.com (u-smith)");
|
||||
await screen.findByRole("option", { name: "alice.smith@example.com (u-smith)" });
|
||||
|
||||
await act(async () => {
|
||||
answers.get("ali")?.([{ user_id: "u-jones", user_email: "alice.jones@example.com" }]);
|
||||
});
|
||||
|
||||
expect(screen.queryByTitle("alice.jones@example.com (u-jones)")).not.toBeInTheDocument();
|
||||
expect(screen.getByTitle("alice.smith@example.com (u-smith)")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("option", { name: "alice.jones@example.com (u-jones)" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "alice.smith@example.com (u-smith)" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stops searching once the box is cleared and the abandoned search answers", async () => {
|
||||
|
|
@ -863,7 +863,7 @@ describe("CreateKey", () => {
|
|||
answers.get("ali")?.([{ user_id: "u-jones", user_email: "alice.jones@example.com" }]);
|
||||
});
|
||||
|
||||
expect(screen.queryByTitle("alice.jones@example.com (u-jones)")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("option", { name: "alice.jones@example.com (u-jones)" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("No users found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -896,7 +896,7 @@ describe("CreateKey", () => {
|
|||
await act(async () => {
|
||||
answers.get("alice.smith@example.com")?.([{ user_id: "u-smith", user_email: "alice.smith@example.com" }]);
|
||||
});
|
||||
await screen.findByTitle("alice.smith@example.com (u-smith)");
|
||||
await screen.findByRole("option", { name: "alice.smith@example.com (u-smith)" });
|
||||
});
|
||||
|
||||
it("only warns about a failed search when it is the one the box is waiting on", async () => {
|
||||
|
|
@ -926,14 +926,14 @@ describe("CreateKey", () => {
|
|||
.get("alice.smith@example.com")
|
||||
?.resolve([{ user_id: "u-smith", user_email: "alice.smith@example.com" }]);
|
||||
});
|
||||
await screen.findByTitle("alice.smith@example.com (u-smith)");
|
||||
await screen.findByRole("option", { name: "alice.smith@example.com (u-smith)" });
|
||||
|
||||
await act(async () => {
|
||||
answers.get("ali")?.reject(new Error("search failed"));
|
||||
});
|
||||
|
||||
expect(toast.fromError).not.toHaveBeenCalled();
|
||||
expect(screen.getByTitle("alice.smith@example.com (u-smith)")).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "alice.smith@example.com (u-smith)" })).toBeInTheDocument();
|
||||
|
||||
await user.type(search, "x");
|
||||
await waitFor(() => expect(answers.has("alice.smith@example.comx")).toBe(true), { timeout: 3000 });
|
||||
|
|
@ -946,6 +946,45 @@ describe("CreateKey", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("user picker selection", () => {
|
||||
it("keeps the picked user in the box instead of searching for its own label", async () => {
|
||||
const directory = [
|
||||
{ user_id: "u-77", user_email: "alice@example.com" },
|
||||
{ user_id: "u-88", user_email: "bob@example.com" },
|
||||
];
|
||||
vi.mocked(userFilterUICall).mockImplementation(
|
||||
(_accessToken, params) =>
|
||||
Promise.resolve(
|
||||
directory.filter((entry) => entry.user_email.includes(params.get("user_email") ?? "")),
|
||||
) as never,
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
renderCreateKey({
|
||||
autoOpenCreate: true,
|
||||
prefillData: { owned_by: "another_user", key_alias: "contract-key" },
|
||||
});
|
||||
const search = await userSearchInput();
|
||||
|
||||
await user.type(search, "alice");
|
||||
await user.click(await screen.findByRole("option", { name: "alice@example.com (u-77)" }));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
});
|
||||
|
||||
expect(search).toHaveValue("alice@example.com (u-77)");
|
||||
expect(vi.mocked(userFilterUICall)).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
await submit();
|
||||
expect((await createdPayload()).user_id).toBe("u-77");
|
||||
});
|
||||
});
|
||||
|
||||
describe("created key display", () => {
|
||||
it("surfaces the generated key after a successful create", async () => {
|
||||
await openModal();
|
||||
|
|
|
|||
|
|
@ -13,25 +13,16 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Field, FieldLabel } from "@/components/ui/field";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
import { MultiSelect, type MultiSelectOption } from "@/components/shared/MultiSelect";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect";
|
||||
import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect";
|
||||
import { TagsInput } from "@/app/(dashboard)/guardrails/_components/content_filter/TagsInput";
|
||||
import { ChevronDown, Info } from "lucide-react";
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { type Control, useForm, useWatch, type UseFormSetValue } from "react-hook-form";
|
||||
import { rolesWithWriteAccess } from "../../utils/roles";
|
||||
|
|
@ -169,12 +160,6 @@ interface User {
|
|||
role?: string;
|
||||
}
|
||||
|
||||
interface UserOption {
|
||||
label: string;
|
||||
value: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export const fetchTeamModels = async (
|
||||
userID: string,
|
||||
userRole: string,
|
||||
|
|
@ -270,7 +255,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
|
||||
const [isCreateUserModalVisible, setIsCreateUserModalVisible] = useState(false);
|
||||
const [possibleUIRoles, setPossibleUIRoles] = useState<Record<string, Record<string, string>>>({});
|
||||
const [userOptions, setUserOptions] = useState<UserOption[]>([]);
|
||||
const [userOptions, setUserOptions] = useState<SearchSelectOption[]>([]);
|
||||
const [userSearchLoading, setUserSearchLoading] = useState<boolean>(false);
|
||||
const latestUserSearchRef = useRef(0);
|
||||
const [disabledCallbacks, setDisabledCallbacks] = useState<string[]>([]);
|
||||
|
|
@ -588,10 +573,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
if (!isLatestSearch()) return;
|
||||
|
||||
const data: User[] = response;
|
||||
const options: UserOption[] = data.map((user) => ({
|
||||
const options: SearchSelectOption[] = data.map((user) => ({
|
||||
label: `${user.user_email} (${user.user_id})`,
|
||||
value: user.user_id,
|
||||
user,
|
||||
}));
|
||||
|
||||
setUserOptions(options);
|
||||
|
|
@ -603,8 +587,6 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
}
|
||||
};
|
||||
|
||||
const handleUserSearch = useDebouncedCallback((text: string) => fetchUsers(text), { wait: DEBOUNCE_WAIT_MS });
|
||||
|
||||
const changeOrganization = (write: FieldWrite) => (orgId: string) => {
|
||||
write(orgId);
|
||||
setSelectedOrganizationId(orgId || null);
|
||||
|
|
@ -736,36 +718,20 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
{(control) => (
|
||||
<div>
|
||||
<div className="mb-2 flex">
|
||||
<Combobox
|
||||
items={userOptions}
|
||||
value={userOptions.find((option) => option.value === control.value) ?? null}
|
||||
filter={null}
|
||||
onValueChange={(option: UserOption | null) => control.onChange(option?.value)}
|
||||
onInputValueChange={handleUserSearch}
|
||||
isItemEqualToValue={(a: UserOption, b: UserOption) => a.value === b.value}
|
||||
itemToStringLabel={(option: UserOption) => option.label}
|
||||
>
|
||||
<ComboboxInput
|
||||
id={control.id}
|
||||
className="w-full"
|
||||
placeholder="Type email to search for users"
|
||||
aria-required={control["aria-required"]}
|
||||
aria-invalid={control["aria-invalid"]}
|
||||
aria-describedby={control["aria-describedby"]}
|
||||
showClear={control.value != null && control.value !== ""}
|
||||
onBlur={control.onBlur}
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>{userSearchLoading ? "Searching..." : "No users found"}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(option: UserOption) => (
|
||||
<ComboboxItem key={option.value} value={option} title={option.label}>
|
||||
{option.label}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
<PaginatedSearchSelect
|
||||
options={userOptions}
|
||||
value={typeof control.value === "string" ? control.value : undefined}
|
||||
onValueChange={control.onChange}
|
||||
onSearchChange={fetchUsers}
|
||||
isLoading={userSearchLoading}
|
||||
placeholder="Type email to search for users"
|
||||
emptyText="No users found"
|
||||
loadingText="Searching..."
|
||||
inputId={control.id}
|
||||
aria-required={control["aria-required"] === "true" ? true : undefined}
|
||||
aria-invalid={control["aria-invalid"] === "true" ? true : undefined}
|
||||
aria-describedby={control["aria-describedby"]}
|
||||
/>
|
||||
<Button variant="outline" className="ml-2" onClick={() => setIsCreateUserModalVisible(true)}>
|
||||
Create User
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -69,6 +69,38 @@ describe("PaginatedMultiSelect", () => {
|
|||
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith(""), { timeout: 2000 });
|
||||
});
|
||||
|
||||
it("puts the unfiltered page back when a typed query is abandoned by closing", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchChange = vi.fn();
|
||||
renderSelect({ onSearchChange });
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.click(input);
|
||||
await user.type(input, "gamma");
|
||||
await waitFor(() => expect(onSearchChange).toHaveBeenCalledWith("gamma"), { timeout: 2000 });
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith(""), { timeout: 2000 });
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("puts the unfiltered page back when the popup is dismissed by clicking away", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchChange = vi.fn();
|
||||
renderSelect({ onSearchChange });
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.click(input);
|
||||
await user.type(input, "gamma");
|
||||
await waitFor(() => expect(onSearchChange).toHaveBeenCalledWith("gamma"), { timeout: 2000 });
|
||||
|
||||
await user.click(document.body);
|
||||
|
||||
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith(""), { timeout: 2000 });
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("selects multiple values and reports them cumulatively", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onValueChange = vi.fn();
|
||||
|
|
@ -192,6 +224,33 @@ describe("PaginatedMultiSelect", () => {
|
|||
expect(within(chips).queryByText("hash-alpha")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears every selection through the clear-all control when a label is provided", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onValueChange = vi.fn();
|
||||
renderSelect({ value: ["alias-alpha", "alias-beta"], onValueChange, clearAllLabel: "Clear all" });
|
||||
|
||||
await user.click(screen.getByLabelText("Clear all"));
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it("shows no clear-all control without a label or without selections", () => {
|
||||
const { unmount } = render(
|
||||
<PaginatedMultiSelect
|
||||
options={OPTIONS}
|
||||
value={["alias-alpha"]}
|
||||
onValueChange={vi.fn()}
|
||||
onSearchChange={vi.fn()}
|
||||
onLoadMore={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(document.querySelector('[data-slot="combobox-clear"]')).not.toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
renderSelect({ value: [], clearAllLabel: "Clear all" });
|
||||
expect(document.querySelector('[data-slot="combobox-clear"]')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("anchors the dropdown to the chips container so it tracks the growing chip box", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSelect({});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
ComboboxChip,
|
||||
ComboboxChips,
|
||||
ComboboxChipsInput,
|
||||
ComboboxClear,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxItem,
|
||||
|
|
@ -32,6 +33,7 @@ interface PaginatedMultiSelectProps {
|
|||
emptyText?: string;
|
||||
errorText?: string;
|
||||
loadingText?: string;
|
||||
clearAllLabel?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
inputId?: string;
|
||||
|
|
@ -52,6 +54,7 @@ export function PaginatedMultiSelect({
|
|||
emptyText = "No results",
|
||||
errorText,
|
||||
loadingText = "Loading…",
|
||||
clearAllLabel,
|
||||
disabled = false,
|
||||
className,
|
||||
inputId,
|
||||
|
|
@ -119,6 +122,7 @@ export function PaginatedMultiSelect({
|
|||
className="h-5 min-w-24 flex-1 border-0 bg-transparent py-0 text-sm"
|
||||
aria-label={placeholder}
|
||||
/>
|
||||
{clearAllLabel != null && value.length > 0 && <ComboboxClear aria-label={clearAllLabel} disabled={disabled} />}
|
||||
</ComboboxChips>
|
||||
<ComboboxContent anchor={anchor}>
|
||||
<ComboboxEmpty className={errorText == null ? undefined : "text-destructive"}>
|
||||
|
|
|
|||
|
|
@ -276,6 +276,57 @@ describe("PaginatedSearchSelect", () => {
|
|||
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith("gamma"));
|
||||
});
|
||||
|
||||
it("commits the first server-filtered match on Enter when autoHighlight is always", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
function ServerBacked() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [value, setValue] = useState("");
|
||||
return (
|
||||
<PaginatedSearchSelect
|
||||
options={OPTIONS.filter((option) => option.label.includes(search))}
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
onSearchChange={setSearch}
|
||||
onLoadMore={vi.fn()}
|
||||
autoHighlight="always"
|
||||
/>
|
||||
);
|
||||
}
|
||||
render(<ServerBacked />);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.click(input);
|
||||
await user.type(input, "gamma");
|
||||
await waitFor(() => expect(screen.queryByText("alias-alpha")).not.toBeInTheDocument());
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
await waitFor(() => expect(input).toHaveValue("gamma-key"));
|
||||
});
|
||||
|
||||
it("highlights the picked label on focus so typing starts over", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSelect({ value: "alias-alpha" });
|
||||
|
||||
await user.tab();
|
||||
|
||||
const input = screen.getByRole("combobox") as HTMLInputElement;
|
||||
expect(input.selectionStart).toBe(0);
|
||||
expect(input.selectionEnd).toBe("alias-alpha".length);
|
||||
});
|
||||
|
||||
it("takes a paste over the highlighted label wholesale even when it shares a prefix", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchChange = vi.fn();
|
||||
renderSelect({ onSearchChange, value: "alias-alpha" });
|
||||
|
||||
await user.tab();
|
||||
await user.paste("alias-alphabet");
|
||||
|
||||
expect(screen.getByRole("combobox")).toHaveValue("alias-alphabet");
|
||||
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith("alias-alphabet"));
|
||||
});
|
||||
|
||||
it("starts a fresh query when typing lands inside the selected label", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchChange = vi.fn();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useRef, useState, type SyntheticEvent } from "react";
|
||||
|
||||
import {
|
||||
Combobox,
|
||||
|
|
@ -28,9 +28,11 @@ interface PaginatedSearchSelectProps {
|
|||
emptyText?: string;
|
||||
errorText?: string;
|
||||
loadingText?: string;
|
||||
autoHighlight?: boolean | "always";
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
inputId?: string;
|
||||
"aria-required"?: true | undefined;
|
||||
"aria-invalid"?: true | undefined;
|
||||
"aria-describedby"?: string;
|
||||
}
|
||||
|
|
@ -61,13 +63,22 @@ export function PaginatedSearchSelect({
|
|||
emptyText = "No results",
|
||||
errorText,
|
||||
loadingText = "Loading…",
|
||||
autoHighlight = false,
|
||||
disabled = false,
|
||||
className,
|
||||
inputId,
|
||||
"aria-required": ariaRequired,
|
||||
"aria-invalid": ariaInvalid,
|
||||
"aria-describedby": ariaDescribedBy,
|
||||
}: PaginatedSearchSelectProps) {
|
||||
const [pickedOption, setPickedOption] = useState<SearchSelectOption | null>(null);
|
||||
const wholeSelectionRef = useRef(false);
|
||||
|
||||
const snapshotWholeSelection = (event: SyntheticEvent<HTMLInputElement>) => {
|
||||
const input = event.currentTarget;
|
||||
wholeSelectionRef.current =
|
||||
input.value.length > 0 && input.selectionStart === 0 && input.selectionEnd === input.value.length;
|
||||
};
|
||||
|
||||
const selected = useMemo<SearchSelectOption | null>(() => {
|
||||
if (value === undefined || value === "") return null;
|
||||
|
|
@ -86,6 +97,15 @@ export function PaginatedSearchSelect({
|
|||
const pagination = { onSearchChange, onLoadMore, hasNextPage, isFetchingNextPage };
|
||||
const { typedQuery, handleInputValueChange, handleOpenChange, handleScroll } = usePaginatedCombobox(pagination);
|
||||
|
||||
const handleTypedInput = (next: string, reason: string) => {
|
||||
const replacedWholeInput = wholeSelectionRef.current;
|
||||
wholeSelectionRef.current = false;
|
||||
handleInputValueChange(
|
||||
typedQuery === null && !replacedWholeInput ? typedInsertion(selected?.label ?? "", next) : next,
|
||||
reason,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
items={items}
|
||||
|
|
@ -95,22 +115,24 @@ export function PaginatedSearchSelect({
|
|||
setPickedOption(item);
|
||||
onValueChange(item?.value ?? "");
|
||||
}}
|
||||
onInputValueChange={(next, eventDetails) =>
|
||||
handleInputValueChange(
|
||||
typedQuery === null ? typedInsertion(selected?.label ?? "", next) : next,
|
||||
eventDetails.reason,
|
||||
)
|
||||
}
|
||||
onInputValueChange={(next, eventDetails) => handleTypedInput(next, eventDetails.reason)}
|
||||
onOpenChange={(nextOpen, eventDetails) => handleOpenChange(nextOpen, eventDetails.reason)}
|
||||
isItemEqualToValue={(a: SearchSelectOption, b: SearchSelectOption) => a.value === b.value}
|
||||
itemToStringLabel={(item: SearchSelectOption) => item.label}
|
||||
// @ts-expect-error TS2322 -- Combobox.Root narrows autoHighlight to boolean; the AriaCombobox it wraps
|
||||
// accepts "always", the only value that highlights a list filtered server-side
|
||||
autoHighlight={autoHighlight}
|
||||
filter={null}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ComboboxInput
|
||||
id={inputId}
|
||||
aria-required={ariaRequired}
|
||||
aria-invalid={ariaInvalid}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onKeyDown={snapshotWholeSelection}
|
||||
onPaste={snapshotWholeSelection}
|
||||
placeholder={placeholder}
|
||||
showClear={value !== undefined && value !== ""}
|
||||
className={`w-full ${className ?? ""}`}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders, testQueryClient } from "../../../tests/test-utils";
|
||||
import { ERROR_CODE_OPTIONS } from "./constants";
|
||||
import { LOG_FILTER_IDS } from "./log_filter_logic";
|
||||
import { RequestLogsFilters } from "./RequestLogsFilters";
|
||||
|
||||
|
|
@ -45,6 +47,20 @@ function renderFilters(filters: Record<string, string> = {}) {
|
|||
return { set };
|
||||
}
|
||||
|
||||
function StatefulFilters() {
|
||||
const [filters, setFilters] = useState<Record<string, string | undefined>>({});
|
||||
return (
|
||||
<RequestLogsFilters
|
||||
get={(id: string) => filters[id]}
|
||||
set={(id: string, value: unknown) =>
|
||||
setFilters((previous) => ({ ...previous, [id]: typeof value === "string" ? value : undefined }))
|
||||
}
|
||||
teams={[]}
|
||||
logsWindow={LOGS_WINDOW}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("RequestLogsFilters", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -284,6 +300,42 @@ describe("RequestLogsFilters", () => {
|
|||
expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.CACHE_STATUS, expected);
|
||||
});
|
||||
|
||||
it("stores the raw status code when a labeled error code is picked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { set } = renderFilters();
|
||||
|
||||
await user.click(await screen.findByPlaceholderText("Select or type an error code"));
|
||||
await user.click(await screen.findByRole("option", { name: "429 - Rate Limited" }));
|
||||
|
||||
expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.ERROR_CODE, "429");
|
||||
});
|
||||
|
||||
it("offers every error code again after one was picked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<StatefulFilters />);
|
||||
|
||||
const input = await screen.findByPlaceholderText("Select or type an error code");
|
||||
await user.click(input);
|
||||
await user.click(await screen.findByRole("option", { name: "429 - Rate Limited" }));
|
||||
await user.click(input);
|
||||
|
||||
const list = await screen.findByTestId("error-code-filter-list");
|
||||
expect(within(list).getAllByRole("option")).toHaveLength(ERROR_CODE_OPTIONS.length);
|
||||
expect(within(list).queryByText(/^Use custom code:/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters by an error code the list does not offer", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { set } = renderFilters();
|
||||
|
||||
const input = await screen.findByPlaceholderText("Select or type an error code");
|
||||
await user.click(input);
|
||||
await user.type(input, "418");
|
||||
await user.click(await screen.findByRole("option", { name: "Use custom code: 418" }));
|
||||
|
||||
expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.ERROR_CODE, "418");
|
||||
});
|
||||
|
||||
it("selecting All Requests clears the cache filter", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { set } = renderFilters({ [LOG_FILTER_IDS.CACHE_STATUS]: "hit" });
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ const CACHE_FILTER_ITEMS = [
|
|||
] as const;
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const SEARCH_INPUT_REASONS: ReadonlySet<string> = new Set(["input-change", "input-clear", "clear-press"]);
|
||||
|
||||
const asString = (value: unknown): string => (typeof value === "string" ? value : "");
|
||||
const emptyToUndefined = (value: string): string | undefined => (value === "" ? undefined : value);
|
||||
|
||||
|
|
@ -254,7 +256,10 @@ function ErrorCodeFilterField({ value, onChange }: { value: string; onChange: (v
|
|||
const trimmed = query.trim();
|
||||
const lowered = trimmed.toLowerCase();
|
||||
const matches = ERROR_CODE_OPTIONS.filter((option) => option.label.toLowerCase().includes(lowered));
|
||||
if (trimmed === "" || ERROR_CODE_OPTIONS.some((option) => option.value === trimmed)) return matches;
|
||||
const isKnownCode = ERROR_CODE_OPTIONS.some(
|
||||
(option) => option.value === trimmed || option.label.toLowerCase() === lowered,
|
||||
);
|
||||
if (trimmed === "" || isKnownCode) return matches;
|
||||
return [...matches, { label: `Use custom code: ${trimmed}`, value: trimmed }];
|
||||
}, [query]);
|
||||
|
||||
|
|
@ -275,12 +280,20 @@ function ErrorCodeFilterField({ value, onChange }: { value: string; onChange: (v
|
|||
items={items}
|
||||
value={selected}
|
||||
onValueChange={(item: SearchSelectOption | null) => onChange(emptyToUndefined(item?.value ?? ""))}
|
||||
onInputValueChange={setQuery}
|
||||
onInputValueChange={(next, eventDetails) => setQuery(SEARCH_INPUT_REASONS.has(eventDetails.reason) ? next : "")}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) setQuery("");
|
||||
}}
|
||||
isItemEqualToValue={(a: SearchSelectOption, b: SearchSelectOption) => a.value === b.value}
|
||||
itemToStringLabel={(item: SearchSelectOption) => item.label}
|
||||
filter={null}
|
||||
>
|
||||
<ComboboxInput placeholder="Select or type an error code" showClear={value !== ""} className="w-full" />
|
||||
<ComboboxInput
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
placeholder="Select or type an error code"
|
||||
showClear={value !== ""}
|
||||
className="w-full"
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No error codes found</ComboboxEmpty>
|
||||
<ComboboxList data-testid="error-code-filter-list">
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Add table
Reference in a new issue