feat(ui): multi-key shadow eval picker and per-key breakdown (#37389)

Stacked on the multi-key shadow eval backend. The key picker becomes a
paginated multi-select with chips, built on the base-ui combobox chips
primitives, with the pagination and debounced-search logic extracted into a
shared usePaginatedCombobox hook that PaginatedSearchSelect now also uses.
The detail view gains a per key table showing each key's own status, judged
turns against its budget, and win rates from the by_key slice, and the job
headline pluralises to "N keys" for multi-key jobs
This commit is contained in:
tin-berri 2026-08-20 10:43:27 -07:00 committed by GitHub
parent d7e4b1bdd0
commit d2d158f271
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 657 additions and 60 deletions

View file

@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
@ -135,6 +135,7 @@ const keyEntry = (
api_key_id,
max_turns: 200,
stopped_at: null,
attempt_count: null,
key_alias: null,
key_name: null,
...overrides,
@ -359,15 +360,19 @@ describe("ShadowEvalSection", () => {
expect(container).toBeEmptyDOMElement();
});
it("keeps the start button disabled until key, router, and judge model are picked, then submits the key as a list", async () => {
it("keeps the start button disabled until key, router, and judge model are picked, then submits every picked key", async () => {
const user = userEvent.setup();
const { start } = mockHooks({});
render(<ShadowEvalSection />);
expect(screen.getByText("Start shadow eval")).toBeDisabled();
await user.click(screen.getByPlaceholderText("Search keys by alias"));
await user.click(await screen.findByText("prod-alpha"));
const keyInput = screen.getByPlaceholderText("Search keys by alias");
await user.click(keyInput);
const keyList = await screen.findByTestId("paginated-multi-select-list");
await user.click(within(keyList).getByText("prod-alpha"));
await user.click(keyInput);
await user.click(within(keyList).getByText("staging-beta"));
await user.click(screen.getByPlaceholderText("Select an auto-router"));
await user.click(await screen.findByText("gpt-auto"));
@ -378,7 +383,7 @@ describe("ShadowEvalSection", () => {
await user.click(screen.getByText("Start shadow eval"));
const expectedBody = {
api_key_ids: ["hash-alpha"],
api_key_ids: ["hash-alpha", "hash-beta"],
router_name: "gpt-auto",
direction: "forward",
shadow_percentage: 10,
@ -399,7 +404,8 @@ describe("ShadowEvalSection", () => {
await user.click(screen.getByText("Adoption check: key's traffic vs the router"));
await user.click(await screen.findByText("Regression check: router's picks vs a baseline"));
await user.click(screen.getByPlaceholderText("Search keys by alias"));
await user.click(await screen.findByText("prod-alpha"));
const keyList = await screen.findByTestId("paginated-multi-select-list");
await user.click(within(keyList).getByText("prod-alpha"));
await user.click(screen.getByPlaceholderText("Select an auto-router"));
await user.click(await screen.findByText("gpt-auto"));
await user.click(screen.getByPlaceholderText("Select a judge model"));
@ -451,6 +457,119 @@ describe("ShadowEvalSection", () => {
expect(shadowedKeyLabel(keyEntry("hashed-key-abc"))).toBe("hashed-key…");
});
it("breaks results down per key, so one key exhausting its own budget is visible while a sibling runs on", () => {
mockHooks({
jobs: [
job({
judged_count: 205,
keys: [
keyEntry("hash-spent", { max_turns: 200, stopped_at: "2026-08-08T00:00:00Z" }),
keyEntry("hash-hungry", { max_turns: 500 }),
],
results: {
by_tier: [],
by_current_model: [],
by_key: [
{
group: "hash-spent",
turn_count: 200,
real_win_rate_pct: 20.0,
shadow_win_rate_pct: 60.0,
tie_rate_pct: 20.0,
avg_judge_confidence: 0.9,
},
],
overall_shadow_win_rate_pct: 60.0,
overall_tie_rate_pct: 20.0,
},
}),
],
});
render(<ShadowEvalSection />);
const spent = screen.getByText("hash-spent…").closest("tr");
const hungry = screen.getByText("hash-hungr…").closest("tr");
if (!spent || !hungry) throw new Error("expected a table row per scoped key");
expect(within(spent).getByText("stopped")).toBeInTheDocument();
expect(within(spent).getByText("200 / 200")).toBeInTheDocument();
expect(within(spent).getByText("60.0%")).toBeInTheDocument();
expect(within(hungry).getByText("running")).toBeInTheDocument();
expect(within(hungry).getByText("0 / 500")).toBeInTheDocument();
expect(within(hungry).getByText("No verdicts yet")).toBeInTheDocument();
expect(screen.getByText(/205 of 700 turns judged/)).toBeInTheDocument();
expect(screen.getByText(/Shadowing 10% of/)).toBeInTheDocument();
expect(screen.getByText("2 keys")).toBeInTheDocument();
});
it("reads a key that spent its budget as completed even before the sweep stamps it", () => {
mockHooks({
jobs: [
job({
keys: [
keyEntry("hash-spent", { max_turns: 200, attempt_count: 200 }),
keyEntry("hash-hungry", { max_turns: 500, attempt_count: 3 }),
],
}),
],
});
render(<ShadowEvalSection />);
const spent = screen.getByText("hash-spent…").closest("tr");
const hungry = screen.getByText("hash-hungr…").closest("tr");
if (!spent || !hungry) throw new Error("expected a table row per scoped key");
expect(within(spent).getByText("completed")).toBeInTheDocument();
expect(within(spent).getByText("200 / 200")).toBeInTheDocument();
expect(within(hungry).getByText("running")).toBeInTheDocument();
expect(within(hungry).getByText("3 / 500")).toBeInTheDocument();
});
it("shows the per-key table while a multi-key job is still collecting, before any verdicts exist", () => {
mockHooks({
jobs: [
job({
judged_count: 0,
results: null,
keys: [
keyEntry("hash-spent", { max_turns: 2, attempt_count: 2 }),
keyEntry("hash-hungry", { max_turns: 500, attempt_count: 1 }),
],
}),
],
});
render(<ShadowEvalSection />);
const spent = screen.getByText("hash-spent…").closest("tr");
if (!spent) throw new Error("expected a per-key row before verdicts exist");
expect(within(spent).getByText("completed")).toBeInTheDocument();
expect(within(spent).getByText("2 / 2")).toBeInTheDocument();
expect(screen.getByText("Budget used")).toBeInTheDocument();
expect(screen.queryByText("Judged turns")).not.toBeInTheDocument();
expect(screen.getByText(/Collecting verdicts/)).toBeInTheDocument();
});
it("reads every key as completed once the job's window closes, whatever its own stop state", () => {
mockHooks({
jobs: [
job({
status: "completed",
keys: [
keyEntry("hash-spent", { max_turns: 200, stopped_at: "2026-08-08T00:00:00Z" }),
keyEntry("hash-hungry", { max_turns: 500 }),
],
}),
],
});
render(<ShadowEvalSection />);
const hungry = screen.getByText("hash-hungr…").closest("tr");
if (!hungry) throw new Error("expected a table row per scoped key");
expect(within(hungry).getByText("completed")).toBeInTheDocument();
expect(within(hungry).queryByText("running")).not.toBeInTheDocument();
});
it("keeps an older job's verdicts reachable through the previous evaluations list", async () => {
const user = userEvent.setup();
const emptyOverrides: Partial<ShadowEvalJob> = {

View file

@ -6,7 +6,7 @@ import { useInfiniteKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap";
import { useAutoRouters, usePlainModelGroups } from "@/app/(dashboard)/hooks/models/useModels";
import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect";
import { PaginatedMultiSelect } from "@/components/shared/PaginatedMultiSelect";
import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -59,6 +59,13 @@ const shadowedKeysLabel = (job: ShadowEvalJob): string =>
const totalBudget = (job: ShadowEvalJob): number => job.keys.reduce((sum, key) => sum + key.max_turns, 0);
const keySpent = (key: ShadowEvalJobKey): boolean => key.attempt_count != null && key.attempt_count >= key.max_turns;
const keyStatus = (job: ShadowEvalJob, key: ShadowEvalJobKey): string => {
if (job.status === "completed" || (key.stopped_at == null && keySpent(key))) return "completed";
return key.stopped_at != null ? "stopped" : "running";
};
const jobHeadline = (job: ShadowEvalJob): React.ReactNode =>
job.direction === "reverse" ? (
<>
@ -175,6 +182,55 @@ const VerdictBar: React.FC<{ direction: ShadowEvalDirection; results: NonNullabl
);
};
const KeyTable: React.FC<{ job: ShadowEvalJob }> = ({ job }) => {
const slices = new Map((job.results?.by_key ?? []).map((slice) => [slice.group, slice]));
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Key</TableHead>
<TableHead>Status</TableHead>
{["Budget used", "Router wins", `${otherArmLabel(job.direction)} wins`].map((label) => (
<TableHead key={label} className="text-right">
{label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{job.keys.map((key) => {
const slice = slices.get(key.api_key_id);
return (
<TableRow key={key.api_key_id}>
<TableCell className="font-medium text-foreground">{shadowedKeyLabel(key)}</TableCell>
<TableCell>
<StatusBadge status={keyStatus(job, key)} />
</TableCell>
<TableCell className="text-right tabular-nums">
{(key.attempt_count ?? slice?.turn_count ?? 0).toLocaleString()} / {key.max_turns.toLocaleString()}
</TableCell>
{slice ? (
<>
<TableCell className="text-right font-medium tabular-nums text-foreground">
{pct(routerWinRate(job.direction, slice))}
</TableCell>
<TableCell className="text-right tabular-nums">
{pct(otherArmWinRate(job.direction, slice))}
</TableCell>
</>
) : (
<TableCell colSpan={2} className="text-right text-muted-foreground">
No verdicts yet
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
);
};
const emptyResultsText = (job: ShadowEvalJob, resultsError: boolean): string => {
if (resultsError) return "Results could not be loaded. Retrying.";
if (isActive(job)) return "Collecting verdicts. Results appear as sampled requests are judged.";
@ -184,32 +240,45 @@ const emptyResultsText = (job: ShadowEvalJob, resultsError: boolean): string =>
const ResultsBody: React.FC<{ job: ShadowEvalJob; resultsError?: boolean }> = ({ job, resultsError = false }) => {
const results = job.results;
const stratifications = results ? [results.by_tier, results.by_current_model, results.by_key] : [];
if (!results || stratifications.every((slices) => slices.length === 0)) {
return <p className="px-6 py-8 text-center text-sm text-muted-foreground">{emptyResultsText(job, resultsError)}</p>;
}
const hasVerdicts = results != null && (results.by_tier.length > 0 || results.by_current_model.length > 0);
return (
<>
<div className="flex flex-col gap-1 border-b px-6 py-4">
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">
Router matched or beat {job.direction === "reverse" ? "the baseline" : "your current model"}
</p>
<p className="text-3xl font-semibold text-foreground">{pct(routerMatchedOrBeatPct(job.direction, results))}</p>
<p className="text-xs text-muted-foreground">of {(job.judged_count ?? 0).toLocaleString()} judged responses</p>
</div>
<VerdictBar direction={job.direction} results={results} />
{results.by_current_model.length > 0 && (
<SliceTable
groupHeader={job.direction === "reverse" ? "Router pick" : "Compared against"}
direction={job.direction}
slices={results.by_current_model}
/>
)}
{results.by_tier.length > 0 && (
<div className={results.by_current_model.length > 0 ? "border-t" : ""}>
<SliceTable groupHeader="Prompt difficulty" direction={job.direction} slices={results.by_tier} />
{job.keys.length > 1 && (
<div className="border-b">
<KeyTable job={job} />
</div>
)}
{/* results == null re-stated for TS narrowing; hasVerdicts alone cannot narrow it */}
{!hasVerdicts || results == null ? (
<p className="px-6 py-8 text-center text-sm text-muted-foreground">{emptyResultsText(job, resultsError)}</p>
) : (
<>
<div className="flex flex-col gap-1 border-b px-6 py-4">
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">
Router matched or beat {job.direction === "reverse" ? "the baseline" : "your current model"}
</p>
<p className="text-3xl font-semibold text-foreground">
{pct(routerMatchedOrBeatPct(job.direction, results))}
</p>
<p className="text-xs text-muted-foreground">
of {(job.judged_count ?? 0).toLocaleString()} judged responses
</p>
</div>
<VerdictBar direction={job.direction} results={results} />
{results.by_current_model.length > 0 && (
<SliceTable
groupHeader={job.direction === "reverse" ? "Router pick" : "Compared against"}
direction={job.direction}
slices={results.by_current_model}
/>
)}
{results.by_tier.length > 0 && (
<div className={results.by_current_model.length > 0 ? "border-t" : ""}>
<SliceTable groupHeader="Prompt difficulty" direction={job.direction} slices={results.by_tier} />
</div>
)}
</>
)}
</>
);
};
@ -306,9 +375,9 @@ const DIRECTION_OPTIONS: readonly { value: ShadowEvalDirection; label: string }[
const START_FORM_DESCRIPTION: Record<ShadowEvalDirection, string> = {
forward:
"Duplicates a sampled slice of the key's traffic through the auto-router and has an LLM judge compare both answers blind. The router's answers are never served to users; judge calls bill to the shadowed key.",
"Duplicates a sampled slice of the selected keys' traffic through the auto-router and has an LLM judge compare both answers blind. Each key gets its own turn budget. The router's answers are never served to users; judge calls bill to the shadowed key.",
reverse:
"Duplicates a sampled slice of the traffic the auto-router already serves against a fixed baseline model and has an LLM judge compare both answers blind. The baseline's answers are never served to users; judge calls bill to the shadowed key.",
"Duplicates a sampled slice of the traffic the auto-router already serves against a fixed baseline model and has an LLM judge compare both answers blind. Each key gets its own turn budget. The baseline's answers are never served to users; judge calls bill to the shadowed key.",
};
const DURATION_OPTIONS = [
@ -333,7 +402,7 @@ const Field: React.FC<{ label: string; htmlFor?: string; className?: string; chi
</div>
);
const KeySelect: React.FC<{ value: string; onChange: (token: string) => void }> = ({ value, onChange }) => {
const KeySelect: React.FC<{ value: string[]; onChange: (tokens: string[]) => void }> = ({ value, onChange }) => {
const [search, setSearch] = useState("");
const { data, isPending, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteKeys(50, {
selectedKeyAlias: search || null,
@ -350,7 +419,7 @@ const KeySelect: React.FC<{ value: string; onChange: (token: string) => void }>
[data],
);
return (
<PaginatedSearchSelect
<PaginatedMultiSelect
inputId="shadow-eval-key"
options={options}
value={value}
@ -369,7 +438,7 @@ const KeySelect: React.FC<{ value: string; onChange: (token: string) => void }>
const StartForm: React.FC = () => {
const { accessToken } = useAuthorized();
const [apiKeyId, setApiKeyId] = useState("");
const [apiKeyIds, setApiKeyIds] = useState<string[]>([]);
const [routerName, setRouterName] = useState("");
const [direction, setDirection] = useState<ShadowEvalDirection>("forward");
const [baselineModel, setBaselineModel] = useState("");
@ -394,12 +463,12 @@ const StartForm: React.FC = () => {
const parsedMaxTurns = Number.parseInt(maxTurns, 10);
const maxTurnsValid = parsedMaxTurns >= 1 && parsedMaxTurns <= 2000;
const baselinePicked = direction === "forward" || baselineModel !== "";
const filled = [apiKeyId, routerName, judgeModel].every((field) => field !== "") && baselinePicked;
const filled = apiKeyIds.length > 0 && [routerName, judgeModel].every((field) => field !== "") && baselinePicked;
const boundsValid = percentageValid && maxTurnsValid;
const valid = Boolean(accessToken) && filled && boundsValid;
const handleStart = () => {
const startBody = {
api_key_ids: [apiKeyId],
api_key_ids: apiKeyIds,
router_name: routerName,
direction,
...(direction === "reverse" ? { baseline_model: baselineModel } : {}),
@ -436,8 +505,8 @@ const StartForm: React.FC = () => {
</SelectContent>
</Select>
</Field>
<Field label="Key to shadow" htmlFor="shadow-eval-key">
<KeySelect value={apiKeyId} onChange={setApiKeyId} />
<Field label="Keys to shadow" htmlFor="shadow-eval-key">
<KeySelect value={apiKeyIds} onChange={setApiKeyIds} />
</Field>
<Field label="Auto-router">
<SearchSelect

View file

@ -0,0 +1,238 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import { PaginatedMultiSelect } from "./PaginatedMultiSelect";
import type { SearchSelectOption } from "./SearchSelect";
const OPTIONS: SearchSelectOption[] = [
{ label: "alias-alpha", value: "alias-alpha" },
{ label: "alias-beta", value: "alias-beta" },
{ label: "gamma-key", value: "gamma-key" },
];
function renderSelect(overrides: Partial<React.ComponentProps<typeof PaginatedMultiSelect>> = {}) {
const props: React.ComponentProps<typeof PaginatedMultiSelect> = {
options: OPTIONS,
onValueChange: vi.fn(),
onSearchChange: vi.fn(),
onLoadMore: vi.fn(),
...overrides,
};
render(<PaginatedMultiSelect {...props} />);
return props;
}
function setListMetrics(list: HTMLElement, metrics: { scrollTop: number; clientHeight: number; scrollHeight: number }) {
Object.defineProperty(list, "scrollTop", { value: metrics.scrollTop, configurable: true });
Object.defineProperty(list, "clientHeight", { value: metrics.clientHeight, configurable: true });
Object.defineProperty(list, "scrollHeight", { value: metrics.scrollHeight, configurable: true });
}
function chipRemoveButton(label: string): HTMLElement {
const chip = screen.getByText(label).closest('[data-slot="combobox-chip"]');
if (chip === null) throw new Error(`no chip found for ${label}`);
const button = chip.querySelector('[data-slot="combobox-chip-remove"]');
if (button === null) throw new Error(`no remove control found on chip for ${label}`);
return button as HTMLElement;
}
describe("PaginatedMultiSelect", () => {
it("reports the cleared query upstream after a selection, so the next open is not still filtered", async () => {
const user = userEvent.setup();
const onSearchChange = vi.fn();
function Controlled() {
const [value, setValue] = useState<string[]>([]);
return (
<PaginatedMultiSelect
options={OPTIONS}
value={value}
onValueChange={setValue}
onSearchChange={onSearchChange}
onLoadMore={vi.fn()}
/>
);
}
render(<Controlled />);
const input = screen.getByRole("combobox");
await user.click(input);
await user.type(input, "alias-a");
await waitFor(() => expect(onSearchChange).toHaveBeenCalledWith("alias-a"), { timeout: 2000 });
const list = await screen.findByTestId("paginated-multi-select-list");
await user.click(within(list).getByText("alias-alpha"));
expect(input).toHaveValue("");
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith(""), { timeout: 2000 });
});
it("selects multiple values and reports them cumulatively", async () => {
const user = userEvent.setup();
const onValueChange = vi.fn();
function Controlled() {
const [value, setValue] = useState<string[]>([]);
return (
<PaginatedMultiSelect
options={OPTIONS}
value={value}
onValueChange={(next) => {
setValue(next);
onValueChange(next);
}}
onSearchChange={vi.fn()}
onLoadMore={vi.fn()}
/>
);
}
render(<Controlled />);
const input = screen.getByRole("combobox");
await user.click(input);
const list = await screen.findByTestId("paginated-multi-select-list");
await user.click(within(list).getByText("alias-alpha"));
await user.click(input);
await user.click(within(list).getByText("gamma-key"));
expect(onValueChange).toHaveBeenLastCalledWith(["alias-alpha", "gamma-key"]);
const chips = document.querySelector('[data-slot="combobox-chips"]') as HTMLElement;
expect(within(chips).getByText("alias-alpha")).toBeInTheDocument();
expect(within(chips).getByText("gamma-key")).toBeInTheDocument();
});
it("deselects one value via the chip remove control and keeps the rest", async () => {
const user = userEvent.setup();
const onValueChange = vi.fn();
function Controlled() {
const [value, setValue] = useState<string[]>(["alias-alpha", "alias-beta"]);
return (
<PaginatedMultiSelect
options={OPTIONS}
value={value}
onValueChange={(next) => {
setValue(next);
onValueChange(next);
}}
onSearchChange={vi.fn()}
onLoadMore={vi.fn()}
/>
);
}
render(<Controlled />);
await user.click(chipRemoveButton("alias-alpha"));
expect(onValueChange).toHaveBeenCalledWith(["alias-beta"]);
expect(screen.queryByText("alias-alpha")).not.toBeInTheDocument();
expect(screen.getByText("alias-beta")).toBeInTheDocument();
});
it("keeps a selected chip visible after the options page no longer contains it", () => {
const { rerender } = render(
<PaginatedMultiSelect
options={OPTIONS}
value={["ghost-key"]}
onValueChange={vi.fn()}
onSearchChange={vi.fn()}
onLoadMore={vi.fn()}
/>,
);
expect(screen.getByText("ghost-key")).toBeInTheDocument();
rerender(
<PaginatedMultiSelect
options={[{ label: "alias-beta", value: "alias-beta" }]}
value={["ghost-key"]}
onValueChange={vi.fn()}
onSearchChange={vi.fn()}
onLoadMore={vi.fn()}
/>,
);
expect(screen.getByText("ghost-key")).toBeInTheDocument();
});
it("keeps a picked chip's label after the search filters it off the options page", async () => {
const user = userEvent.setup();
const aliased: SearchSelectOption[] = [
{ label: "Prod Alpha", value: "hash-alpha" },
{ label: "Staging Beta", value: "hash-beta" },
];
function Controlled({ options }: { options: SearchSelectOption[] }) {
const [value, setValue] = useState<string[]>([]);
return (
<PaginatedMultiSelect
options={options}
value={value}
onValueChange={setValue}
onSearchChange={vi.fn()}
onLoadMore={vi.fn()}
/>
);
}
const { rerender } = render(<Controlled options={aliased} />);
const input = screen.getByRole("combobox");
await user.click(input);
const list = await screen.findByTestId("paginated-multi-select-list");
await user.click(within(list).getByText("Prod Alpha"));
rerender(<Controlled options={[{ label: "Staging Beta", value: "hash-beta" }]} />);
const chips = document.querySelector('[data-slot="combobox-chips"]') as HTMLElement;
expect(within(chips).getByText("Prod Alpha")).toBeInTheDocument();
expect(within(chips).queryByText("hash-alpha")).not.toBeInTheDocument();
});
it("anchors the dropdown to the chips container so it tracks the growing chip box", async () => {
const user = userEvent.setup();
renderSelect({});
await user.click(screen.getByRole("combobox"));
await screen.findByTestId("paginated-multi-select-list");
const content = document.querySelector('[data-slot="combobox-content"]');
expect(content).toHaveAttribute("data-chips", "true");
});
it("does not request the next page on scroll when there is no next page", async () => {
const user = userEvent.setup();
const onLoadMore = vi.fn();
renderSelect({ onLoadMore, hasNextPage: false });
const input = screen.getByRole("combobox");
await user.click(input);
const list = await screen.findByTestId("paginated-multi-select-list");
setListMetrics(list, { scrollTop: 900, clientHeight: 100, scrollHeight: 1000 });
fireEvent.scroll(list);
expect(onLoadMore).not.toHaveBeenCalled();
});
it("requests the next page once scrolled past the threshold when a next page exists", async () => {
const user = userEvent.setup();
const onLoadMore = vi.fn();
renderSelect({ onLoadMore, hasNextPage: true });
const input = screen.getByRole("combobox");
await user.click(input);
const list = await screen.findByTestId("paginated-multi-select-list");
setListMetrics(list, { scrollTop: 0, clientHeight: 100, scrollHeight: 1000 });
fireEvent.scroll(list);
expect(onLoadMore).not.toHaveBeenCalled();
setListMetrics(list, { scrollTop: 850, clientHeight: 100, scrollHeight: 1000 });
fireEvent.scroll(list);
expect(onLoadMore).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,147 @@
"use client";
import { Loader2 } from "lucide-react";
import { useMemo, useState } from "react";
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from "@/components/ui/combobox";
import type { SearchSelectOption } from "./SearchSelect";
import { usePaginatedCombobox } from "./usePaginatedCombobox";
interface PaginatedMultiSelectProps {
options: SearchSelectOption[];
value?: string[];
onValueChange: (value: string[]) => void;
onSearchChange: (query: string) => void;
onLoadMore: () => void;
hasNextPage?: boolean;
isLoading?: boolean;
isFetchingNextPage?: boolean;
placeholder?: string;
emptyText?: string;
errorText?: string;
loadingText?: string;
disabled?: boolean;
className?: string;
inputId?: string;
"aria-invalid"?: true | undefined;
"aria-describedby"?: string;
}
export function PaginatedMultiSelect({
options,
value = [],
onValueChange,
onSearchChange,
onLoadMore,
hasNextPage = false,
isLoading = false,
isFetchingNextPage = false,
placeholder = "Search…",
emptyText = "No results",
errorText,
loadingText = "Loading…",
disabled = false,
className,
inputId,
"aria-invalid": ariaInvalid,
"aria-describedby": ariaDescribedBy,
}: PaginatedMultiSelectProps) {
const anchor = useComboboxAnchor();
const [query, setQuery] = useState("");
const [pickedOptions, setPickedOptions] = useState<ReadonlyMap<string, SearchSelectOption>>(new Map());
const selected = useMemo<SearchSelectOption[]>(
() =>
value.map(
(selectedValue) =>
options.find((option) => option.value === selectedValue) ??
pickedOptions.get(selectedValue) ?? { label: selectedValue, value: selectedValue },
),
[options, value, pickedOptions],
);
const items = useMemo<SearchSelectOption[]>(() => {
const missing = selected.filter((option) => !options.some((o) => o.value === option.value));
return missing.length === 0 ? options : [...missing, ...options];
}, [options, selected]);
const pagination = { onSearchChange, onLoadMore, hasNextPage, isFetchingNextPage };
const { handleInputValueChange, handleScroll } = usePaginatedCombobox(pagination);
const handleChipsInputChange = (next: string, reason: string) => {
setQuery(next);
handleInputValueChange(next, reason);
};
return (
<Combobox
multiple
items={items}
value={selected}
onValueChange={(next: SearchSelectOption[]) => {
setPickedOptions(new Map(next.map((option) => [option.value, option])));
onValueChange(next.map((option) => option.value));
}}
inputValue={query}
onInputValueChange={(next, eventDetails) => handleChipsInputChange(next, eventDetails.reason)}
isItemEqualToValue={(a: SearchSelectOption, b: SearchSelectOption) => a.value === b.value}
itemToStringLabel={(item: SearchSelectOption) => item.label}
filter={null}
disabled={disabled}
>
<ComboboxChips render={<div ref={anchor} />} className={`min-h-8 py-1 text-sm ${className ?? ""}`}>
<ComboboxValue>
{(selectedItems: SearchSelectOption[]) =>
selectedItems.map((option) => (
<ComboboxChip key={option.value} aria-label={option.label}>
{option.label}
</ComboboxChip>
))
}
</ComboboxValue>
<ComboboxChipsInput
id={inputId}
aria-invalid={ariaInvalid}
aria-describedby={ariaDescribedBy}
placeholder={placeholder}
className="h-5 min-w-24 flex-1 border-0 bg-transparent py-0 text-sm"
aria-label={placeholder}
/>
</ComboboxChips>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty className={errorText == null ? undefined : "text-destructive"}>
{errorText ?? (isLoading ? loadingText : emptyText)}
</ComboboxEmpty>
<ComboboxList onScroll={handleScroll} data-testid="paginated-multi-select-list">
{(item: SearchSelectOption) => (
<ComboboxItem key={item.value} value={item}>
<span className="flex min-w-0 flex-col">
<span className="truncate">{item.label}</span>
{item.sublabel != null && item.sublabel !== "" && (
<span className="truncate text-xs text-muted-foreground">{item.sublabel}</span>
)}
</span>
</ComboboxItem>
)}
</ComboboxList>
{isFetchingNextPage && (
<div className="flex justify-center py-2" data-testid="paginated-multi-select-loading-more">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
)}
</ComboboxContent>
</Combobox>
);
}

View file

@ -1,8 +1,7 @@
"use client";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
import { Loader2 } from "lucide-react";
import { useMemo, type UIEvent } from "react";
import { useMemo } from "react";
import {
Combobox,
@ -12,13 +11,9 @@ import {
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import type { SearchSelectOption } from "./SearchSelect";
const SCROLL_THRESHOLD = 0.8;
const SEARCH_REASONS: ReadonlySet<string> = new Set(["input-change", "input-clear", "clear-press"]);
import { usePaginatedCombobox } from "./usePaginatedCombobox";
interface PaginatedSearchSelectProps {
options: SearchSelectOption[];
@ -70,21 +65,8 @@ export function PaginatedSearchSelect({
return [selected, ...options];
}, [options, selected]);
const debouncedSearch = useDebouncedCallback(onSearchChange, { wait: DEBOUNCE_WAIT_MS });
const handleInputValueChange = (next: string, reason: string) => {
if (!SEARCH_REASONS.has(reason)) return;
debouncedSearch(next);
};
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
const target = event.currentTarget;
if (target.scrollHeight === 0) return;
const ratio = (target.scrollTop + target.clientHeight) / target.scrollHeight;
if (ratio >= SCROLL_THRESHOLD && hasNextPage && !isFetchingNextPage) {
onLoadMore?.();
}
};
const pagination = { onSearchChange, onLoadMore, hasNextPage, isFetchingNextPage };
const { handleInputValueChange, handleScroll } = usePaginatedCombobox(pagination);
return (
<Combobox

View file

@ -0,0 +1,42 @@
"use client";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
import type { UIEvent } from "react";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
const SCROLL_THRESHOLD = 0.8;
const SEARCH_REASONS: ReadonlySet<string> = new Set(["input-change", "input-clear", "clear-press"]);
export interface PaginatedComboboxCallbacks {
onSearchChange: (query: string) => void;
onLoadMore?: () => void;
hasNextPage: boolean;
isFetchingNextPage: boolean;
}
export function usePaginatedCombobox({
onSearchChange,
onLoadMore,
hasNextPage,
isFetchingNextPage,
}: PaginatedComboboxCallbacks) {
const debouncedSearch = useDebouncedCallback(onSearchChange, { wait: DEBOUNCE_WAIT_MS });
const handleInputValueChange = (next: string, reason: string) => {
if (!SEARCH_REASONS.has(reason)) return;
debouncedSearch(next);
};
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
const target = event.currentTarget;
if (target.scrollHeight === 0) return;
const ratio = (target.scrollTop + target.clientHeight) / target.scrollHeight;
if (ratio >= SCROLL_THRESHOLD && hasNextPage && !isFetchingNextPage) {
onLoadMore?.();
}
};
return { handleInputValueChange, handleScroll };
}