diff --git a/ui/litellm-dashboard/src/components/shared/PaginatedMultiSelect.test.tsx b/ui/litellm-dashboard/src/components/shared/PaginatedMultiSelect.test.tsx index 140a55b1e62..abb0a96fc05 100644 --- a/ui/litellm-dashboard/src/components/shared/PaginatedMultiSelect.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/PaginatedMultiSelect.test.tsx @@ -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(); diff --git a/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.test.tsx b/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.test.tsx index 310b5363b0b..44eb55bfe88 100644 --- a/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.test.tsx @@ -276,6 +276,29 @@ describe("PaginatedSearchSelect", () => { await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith("gamma")); }); + 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(); diff --git a/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.tsx b/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.tsx index bb1730941a6..7b991133d12 100644 --- a/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.tsx +++ b/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.tsx @@ -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, @@ -68,6 +68,13 @@ export function PaginatedSearchSelect({ "aria-describedby": ariaDescribedBy, }: PaginatedSearchSelectProps) { const [pickedOption, setPickedOption] = useState(null); + const wholeSelectionRef = useRef(false); + + const snapshotWholeSelection = (event: SyntheticEvent) => { + const input = event.currentTarget; + wholeSelectionRef.current = + input.value.length > 0 && input.selectionStart === 0 && input.selectionEnd === input.value.length; + }; const selected = useMemo(() => { if (value === undefined || value === "") return null; @@ -95,12 +102,14 @@ export function PaginatedSearchSelect({ setPickedOption(item); onValueChange(item?.value ?? ""); }} - onInputValueChange={(next, eventDetails) => + onInputValueChange={(next, eventDetails) => { + const replacedWholeInput = wholeSelectionRef.current; + wholeSelectionRef.current = false; handleInputValueChange( - typedQuery === null ? typedInsertion(selected?.label ?? "", next) : next, + typedQuery === null && !replacedWholeInput ? 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} @@ -111,6 +120,9 @@ export function PaginatedSearchSelect({ id={inputId} 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 ?? ""}`}