mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(ui): let the paginated search select keep what the user types (#38475)
* fix(ui): let the paginated search select keep what the user types The combobox handed Base UI a freshly built option object for the current selection every time a page of results came back. Base UI answers a changed value by rewriting the input with that option's label, so every search response wiped the query mid-typing and the list never narrowed. Once a user had been picked in the Usage page filter box, no other user could be reached. The component now owns the input text. It holds the query while the list is open, falls back to the selected option's label once the list closes, and remembers the picked option so its label survives later pages that no longer carry it, the way the multi-select sibling already does. * refactor(ui): name the paginated select's search state instead of commenting it * fix(ui): start a fresh query when typing lands on the selected label Focusing the filter box without clicking it leaves the caret at the end of the selected option's label, so the next keystroke extended that label into a query no server could match. Only a click cleared the box first. A keystroke that arrives while the box is showing a label is now read as the start of a new query, wherever in the label it landed.
This commit is contained in:
parent
2d5e49d65a
commit
3746ba58d7
3 changed files with 200 additions and 9 deletions
|
|
@ -142,6 +142,154 @@ describe("PaginatedSearchSelect", () => {
|
|||
expect(onValueChange).toHaveBeenCalledWith("alias-beta");
|
||||
});
|
||||
|
||||
it("keeps the typed query when a refreshed page of options arrives while a value is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchChange = vi.fn();
|
||||
|
||||
function ServerBacked() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [value, setValue] = useState("alias-alpha");
|
||||
const freshlyBuiltOptions = OPTIONS.filter((option) => option.label.includes(search)).map((option) => ({
|
||||
...option,
|
||||
}));
|
||||
return (
|
||||
<PaginatedSearchSelect
|
||||
options={freshlyBuiltOptions}
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
onSearchChange={(query) => {
|
||||
onSearchChange(query);
|
||||
setSearch(query);
|
||||
}}
|
||||
onLoadMore={vi.fn()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
render(<ServerBacked />);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.click(input);
|
||||
await user.type(input, "gamma");
|
||||
|
||||
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith("gamma"));
|
||||
await waitFor(() => expect(input).toHaveValue("gamma"));
|
||||
expect(await screen.findByText("gamma-key")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the selection again after the popup closes with the query abandoned", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSelect({ value: "alias-alpha" });
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.click(input);
|
||||
expect(input).toHaveValue("");
|
||||
|
||||
await user.type(input, "gamma");
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
await waitFor(() => expect(input).toHaveValue("alias-alpha"));
|
||||
});
|
||||
|
||||
it("puts the unfiltered page back when a typed query is abandoned", 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"));
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith(""));
|
||||
});
|
||||
|
||||
it("puts the unfiltered page back once an option found by typing is picked", 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"));
|
||||
|
||||
await user.click(await screen.findByText("gamma-key"));
|
||||
|
||||
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith(""));
|
||||
});
|
||||
|
||||
it("keeps the first character when typing is what opened the list", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchChange = vi.fn();
|
||||
renderSelect({ onSearchChange });
|
||||
|
||||
await user.tab();
|
||||
await user.keyboard("gamma");
|
||||
|
||||
expect(screen.getByRole("combobox")).toHaveValue("gamma");
|
||||
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith("gamma"));
|
||||
});
|
||||
|
||||
it("keeps showing a picked option's label after it drops out of the loaded page", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
function Refetching() {
|
||||
const [options, setOptions] = useState<SearchSelectOption[]>([{ label: "Beta Team", value: "team-2" }]);
|
||||
const [value, setValue] = useState("");
|
||||
return (
|
||||
<>
|
||||
<PaginatedSearchSelect
|
||||
options={options}
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
onSearchChange={vi.fn()}
|
||||
onLoadMore={vi.fn()}
|
||||
/>
|
||||
<button type="button" onClick={() => setOptions([])}>
|
||||
refetch
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Refetching />);
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(await screen.findByText("Beta Team"));
|
||||
await user.click(screen.getByRole("button", { name: "refetch" }));
|
||||
|
||||
expect(screen.getByRole("combobox")).toHaveValue("Beta Team");
|
||||
});
|
||||
|
||||
it("starts a fresh query when typing lands after the selected label", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchChange = vi.fn();
|
||||
renderSelect({ onSearchChange, value: "alias-alpha" });
|
||||
|
||||
const input = screen.getByRole("combobox") as HTMLInputElement;
|
||||
input.focus();
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
await user.keyboard("gamma");
|
||||
|
||||
expect(input).toHaveValue("gamma");
|
||||
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith("gamma"));
|
||||
});
|
||||
|
||||
it("starts a fresh query when typing lands inside the selected label", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchChange = vi.fn();
|
||||
renderSelect({ onSearchChange, value: "alias-alpha" });
|
||||
|
||||
const input = screen.getByRole("combobox") as HTMLInputElement;
|
||||
input.focus();
|
||||
input.setSelectionRange(3, 3);
|
||||
await user.keyboard("g");
|
||||
|
||||
expect(input).toHaveValue("g");
|
||||
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith("g"));
|
||||
});
|
||||
|
||||
it("surfaces loading and fetching-more affordances", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = render(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
Combobox,
|
||||
|
|
@ -35,6 +35,19 @@ interface PaginatedSearchSelectProps {
|
|||
"aria-describedby"?: string;
|
||||
}
|
||||
|
||||
const typedInsertion = (previous: string, next: string): string => {
|
||||
let start = 0;
|
||||
while (start < previous.length && start < next.length && previous[start] === next[start]) start++;
|
||||
let end = 0;
|
||||
while (
|
||||
end < previous.length - start &&
|
||||
end < next.length - start &&
|
||||
previous[previous.length - 1 - end] === next[next.length - 1 - end]
|
||||
)
|
||||
end++;
|
||||
return next.slice(start, next.length - end);
|
||||
};
|
||||
|
||||
export function PaginatedSearchSelect({
|
||||
options,
|
||||
value,
|
||||
|
|
@ -54,10 +67,15 @@ export function PaginatedSearchSelect({
|
|||
"aria-invalid": ariaInvalid,
|
||||
"aria-describedby": ariaDescribedBy,
|
||||
}: PaginatedSearchSelectProps) {
|
||||
const [pickedOption, setPickedOption] = useState<SearchSelectOption | null>(null);
|
||||
|
||||
const selected = useMemo<SearchSelectOption | null>(() => {
|
||||
if (value === undefined || value === "") return null;
|
||||
return options.find((option) => option.value === value) ?? { label: value, value };
|
||||
}, [options, value]);
|
||||
return (
|
||||
options.find((option) => option.value === value) ??
|
||||
(pickedOption?.value === value ? pickedOption : { label: value, value })
|
||||
);
|
||||
}, [options, value, pickedOption]);
|
||||
|
||||
const items = useMemo<SearchSelectOption[]>(() => {
|
||||
if (selected === null) return options;
|
||||
|
|
@ -66,14 +84,24 @@ export function PaginatedSearchSelect({
|
|||
}, [options, selected]);
|
||||
|
||||
const pagination = { onSearchChange, onLoadMore, hasNextPage, isFetchingNextPage };
|
||||
const { handleInputValueChange, handleScroll } = usePaginatedCombobox(pagination);
|
||||
const { typedQuery, handleInputValueChange, handleOpenChange, handleScroll } = usePaginatedCombobox(pagination);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
items={items}
|
||||
value={selected}
|
||||
onValueChange={(item: SearchSelectOption | null) => onValueChange(item?.value ?? "")}
|
||||
onInputValueChange={(next, eventDetails) => handleInputValueChange(next, eventDetails.reason)}
|
||||
inputValue={typedQuery ?? selected?.label ?? ""}
|
||||
onValueChange={(item: SearchSelectOption | null) => {
|
||||
setPickedOption(item);
|
||||
onValueChange(item?.value ?? "");
|
||||
}}
|
||||
onInputValueChange={(next, eventDetails) =>
|
||||
handleInputValueChange(
|
||||
typedQuery === null ? typedInsertion(selected?.label ?? "", next) : next,
|
||||
eventDetails.reason,
|
||||
)
|
||||
}
|
||||
onOpenChange={(nextOpen, eventDetails) => handleOpenChange(nextOpen, eventDetails.reason)}
|
||||
isItemEqualToValue={(a: SearchSelectOption, b: SearchSelectOption) => a.value === b.value}
|
||||
itemToStringLabel={(item: SearchSelectOption) => item.label}
|
||||
filter={null}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
import type { UIEvent } from "react";
|
||||
import { useState, type UIEvent } from "react";
|
||||
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
|
||||
|
|
@ -23,12 +23,27 @@ export function usePaginatedCombobox({
|
|||
isFetchingNextPage,
|
||||
}: PaginatedComboboxCallbacks) {
|
||||
const debouncedSearch = useDebouncedCallback(onSearchChange, { wait: DEBOUNCE_WAIT_MS });
|
||||
const [typedQuery, setTypedQuery] = useState<string | null>(null);
|
||||
|
||||
const handleInputValueChange = (next: string, reason: string) => {
|
||||
if (!SEARCH_REASONS.has(reason)) return;
|
||||
if (!SEARCH_REASONS.has(reason)) {
|
||||
setTypedQuery(null);
|
||||
return;
|
||||
}
|
||||
setTypedQuery(next);
|
||||
debouncedSearch(next);
|
||||
};
|
||||
|
||||
const handleOpenChange = (open: boolean, reason: string) => {
|
||||
if (!open) {
|
||||
if (typedQuery) debouncedSearch("");
|
||||
setTypedQuery(null);
|
||||
return;
|
||||
}
|
||||
const openedByTyping = SEARCH_REASONS.has(reason);
|
||||
if (!openedByTyping) setTypedQuery("");
|
||||
};
|
||||
|
||||
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
|
||||
const target = event.currentTarget;
|
||||
if (target.scrollHeight === 0) return;
|
||||
|
|
@ -38,5 +53,5 @@ export function usePaginatedCombobox({
|
|||
}
|
||||
};
|
||||
|
||||
return { handleInputValueChange, handleScroll };
|
||||
return { typedQuery, handleInputValueChange, handleOpenChange, handleScroll };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue