fix(ui): reason-gate the remaining server-searched comboboxes

Migrate the create-key user picker, add-member user search, and usage team filter onto the shared paginated selects, gate the logs error-code filter on input reasons, and add clearAllLabel, autoHighlight, and aria-required passthroughs the migrations need.
This commit is contained in:
ryan-crabbe-berri 2026-08-27 14:22:10 -07:00
parent bf86eadd83
commit 141c91404f
13 changed files with 330 additions and 206 deletions

View file

@ -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" />);

View file

@ -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>
/>
);
};

View file

@ -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>();

View file

@ -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>
);
};

View file

@ -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();

View file

@ -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>

View file

@ -224,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({});

View file

@ -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"}>

View file

@ -276,6 +276,34 @@ 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" });

View file

@ -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,9 +63,11 @@ 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) {
@ -93,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}
@ -102,22 +115,19 @@ export function PaginatedSearchSelect({
setPickedOption(item);
onValueChange(item?.value ?? "");
}}
onInputValueChange={(next, eventDetails) => {
const replacedWholeInput = wholeSelectionRef.current;
wholeSelectionRef.current = false;
handleInputValueChange(
typedQuery === null && !replacedWholeInput ? 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()}

View file

@ -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" });

View file

@ -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