mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
Merge f60bee0da3 into 527d882508
This commit is contained in:
commit
6322256578
5 changed files with 146 additions and 17 deletions
|
|
@ -194,6 +194,7 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
|
|||
label: tag.name,
|
||||
description: tag.description || undefined,
|
||||
}))}
|
||||
searchDescriptions={false}
|
||||
allowCustomValues
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { MultiSelect, type MultiSelectOption } from "./MultiSelect";
|
||||
import { MAX_VISIBLE_OPTIONS, MultiSelect, type MultiSelectOption } from "./MultiSelect";
|
||||
|
||||
const OPTIONS: MultiSelectOption[] = [
|
||||
{ value: "vs-alpha", label: "alpha-kb (vs-alpha)" },
|
||||
{ value: "vs-beta", label: "beta-kb (vs-beta)", description: "second store" },
|
||||
];
|
||||
|
||||
const LARGE_OPTIONS: MultiSelectOption[] = Array.from({ length: 5000 }, (_, index) => ({
|
||||
value: `tag-${index}`,
|
||||
label: `tag-${index}`,
|
||||
description: "shared spend tag description",
|
||||
}));
|
||||
|
||||
const renderMultiSelect = (props: Partial<React.ComponentProps<typeof MultiSelect>> = {}) => {
|
||||
const onValueChange = vi.fn();
|
||||
render(<MultiSelect options={OPTIONS} onValueChange={onValueChange} placeholder="Select stores" {...props} />);
|
||||
|
|
@ -69,6 +75,66 @@ describe("MultiSelect", () => {
|
|||
expect(screen.getByLabelText("beta-kb (vs-beta)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("caps rendered options and reports the number of matching options", async () => {
|
||||
const { input } = renderMultiSelect({ options: LARGE_OPTIONS });
|
||||
|
||||
await openPopup(input);
|
||||
|
||||
expect(screen.getAllByRole("option")).toHaveLength(MAX_VISIBLE_OPTIONS);
|
||||
expect(screen.getByText("Showing first 100 of 5000 matches. Type to narrow results.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("matches option descriptions by default", async () => {
|
||||
const { input } = renderMultiSelect();
|
||||
await userEvent.type(input, "second store");
|
||||
|
||||
expect(screen.getAllByRole("option")).toHaveLength(1);
|
||||
expect(screen.getByRole("option", { name: /beta-kb/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("skips descriptions when searchDescriptions is false", async () => {
|
||||
const { input } = renderMultiSelect({ options: LARGE_OPTIONS, searchDescriptions: false });
|
||||
await userEvent.type(input, "shared spend");
|
||||
|
||||
expect(screen.queryAllByRole("option")).toHaveLength(0);
|
||||
expect(await screen.findByText("No options found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters by label and updates the match hint", async () => {
|
||||
const { input } = renderMultiSelect({ options: LARGE_OPTIONS });
|
||||
await userEvent.type(input, "tag-49");
|
||||
|
||||
expect(screen.getAllByRole("option")).toHaveLength(MAX_VISIBLE_OPTIONS);
|
||||
expect(screen.getByText("Showing first 100 of 111 matches. Type to narrow results.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an under-cap label match without the match hint", async () => {
|
||||
const { input } = renderMultiSelect({ options: LARGE_OPTIONS });
|
||||
await userEvent.type(input, "tag-4999");
|
||||
|
||||
expect(screen.getAllByRole("option")).toHaveLength(1);
|
||||
expect(screen.getByRole("option", { name: /tag-4999/ })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Showing first/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the custom option available when matching results exceed the cap", async () => {
|
||||
const { onValueChange, input } = renderMultiSelect({ options: LARGE_OPTIONS, allowCustomValues: true });
|
||||
await userEvent.type(input, "tag");
|
||||
|
||||
await userEvent.click(screen.getByRole("option", { name: 'Create "tag"' }));
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith(["tag"]);
|
||||
});
|
||||
|
||||
it("renders a selected chip outside the visible option slice", async () => {
|
||||
const { input } = renderMultiSelect({ options: LARGE_OPTIONS, value: ["tag-4999"] });
|
||||
|
||||
await openPopup(input);
|
||||
|
||||
expect(screen.getByLabelText("tag-4999")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("option")).toHaveLength(MAX_VISIBLE_OPTIONS);
|
||||
});
|
||||
|
||||
it("labels an unknown selected value with its raw id", () => {
|
||||
renderMultiSelect({ value: ["vs-deleted"] });
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
|
|
@ -15,6 +15,8 @@ import {
|
|||
useComboboxAnchor,
|
||||
} from "@/components/ui/combobox";
|
||||
|
||||
export const MAX_VISIBLE_OPTIONS = 100;
|
||||
|
||||
export interface MultiSelectOption {
|
||||
label: string;
|
||||
value: string;
|
||||
|
|
@ -32,6 +34,7 @@ interface MultiSelectProps {
|
|||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
allowCustomValues?: boolean;
|
||||
searchDescriptions?: boolean;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
|
@ -42,14 +45,13 @@ const splitOnCommas = (raw: string): string[] =>
|
|||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0);
|
||||
|
||||
const matchesQuery = (option: MultiSelectOption, query: string): boolean => {
|
||||
const matchesQuery = (option: MultiSelectOption, query: string, searchDescriptions: boolean): boolean => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
return (
|
||||
!normalizedQuery ||
|
||||
option.label.toLowerCase().includes(normalizedQuery) ||
|
||||
option.value.toLowerCase().includes(normalizedQuery) ||
|
||||
(option.description?.toLowerCase().includes(normalizedQuery) ?? false)
|
||||
);
|
||||
const matchesLabelOrValue =
|
||||
option.label.toLowerCase().includes(normalizedQuery) || option.value.toLowerCase().includes(normalizedQuery);
|
||||
const matchesDescription =
|
||||
searchDescriptions && (option.description?.toLowerCase().includes(normalizedQuery) ?? false);
|
||||
return !normalizedQuery || matchesLabelOrValue || matchesDescription;
|
||||
};
|
||||
|
||||
export function MultiSelect({
|
||||
|
|
@ -62,15 +64,25 @@ export function MultiSelect({
|
|||
disabled = false,
|
||||
loading = false,
|
||||
allowCustomValues = false,
|
||||
searchDescriptions = true,
|
||||
className,
|
||||
contentClassName,
|
||||
}: MultiSelectProps) {
|
||||
const anchor = useComboboxAnchor();
|
||||
const [query, setQuery] = useState("");
|
||||
const safeOptions = options.filter(
|
||||
(option): option is MultiSelectOption =>
|
||||
option != null && typeof option.value === "string" && option.value.length > 0,
|
||||
const safeOptions = useMemo(
|
||||
() =>
|
||||
options.filter(
|
||||
(option): option is MultiSelectOption =>
|
||||
option != null && typeof option.value === "string" && option.value.length > 0,
|
||||
),
|
||||
[options],
|
||||
);
|
||||
const matchingOptions = useMemo(
|
||||
() => safeOptions.filter((option) => matchesQuery(option, query, searchDescriptions)),
|
||||
[safeOptions, query, searchDescriptions],
|
||||
);
|
||||
const visibleOptions = matchingOptions.slice(0, MAX_VISIBLE_OPTIONS);
|
||||
const selectedOptions = value
|
||||
.filter((selectedValue): selectedValue is string => typeof selectedValue === "string" && selectedValue.length > 0)
|
||||
.map(
|
||||
|
|
@ -84,8 +96,8 @@ export function MultiSelect({
|
|||
const customOptionExists = safeOptions.some((option) => option.value.toLowerCase() === customOption.toLowerCase());
|
||||
const items =
|
||||
allowCustomValues && customOption && !customOptionExists
|
||||
? [...safeOptions, { label: `Create "${customOption}"`, value: customOption }]
|
||||
: safeOptions;
|
||||
? [{ label: `Create "${customOption}"`, value: customOption }, ...visibleOptions]
|
||||
: visibleOptions;
|
||||
|
||||
const canClear = (selected: MultiSelectOption[]) => selected.length > 0 && !disabled && !loading;
|
||||
|
||||
|
|
@ -101,13 +113,13 @@ export function MultiSelect({
|
|||
<Combobox
|
||||
multiple
|
||||
items={items}
|
||||
filter={null}
|
||||
value={selectedOptions}
|
||||
onValueChange={handleValueChange}
|
||||
inputValue={query}
|
||||
onInputValueChange={setQuery}
|
||||
isItemEqualToValue={(option: MultiSelectOption, selected: MultiSelectOption) => option.value === selected.value}
|
||||
itemToStringLabel={(option: MultiSelectOption) => option.label}
|
||||
filter={matchesQuery}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
<ComboboxChips render={<div ref={anchor} />} className={`min-h-8 py-1 text-sm ${className ?? ""}`}>
|
||||
|
|
@ -144,6 +156,11 @@ export function MultiSelect({
|
|||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
{matchingOptions.length > MAX_VISIBLE_OPTIONS && (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
Showing first {MAX_VISIBLE_OPTIONS} of {matchingOptions.length} matches. Type to narrow results.
|
||||
</div>
|
||||
)}
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,21 @@
|
|||
import { fireEvent, render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { tagListCall } from "../networking";
|
||||
import TagSelector from "./TagSelector";
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
tagListCall: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockTagListCall = vi.mocked(tagListCall);
|
||||
|
||||
describe("TagSelector", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockTagListCall.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("should render the tag selector", () => {
|
||||
render(<TagSelector onChange={() => {}} accessToken="test-token" />);
|
||||
});
|
||||
|
|
@ -18,4 +31,35 @@ describe("TagSelector", () => {
|
|||
expect(tagSelector).toHaveValue("new-tag");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not match shared dynamic tag descriptions", async () => {
|
||||
mockTagListCall.mockResolvedValue({
|
||||
first: {
|
||||
name: "dynamic-tag-one",
|
||||
description: "This is just a spend tag that was passed dynamically",
|
||||
models: [],
|
||||
created_at: "2025-01-01",
|
||||
updated_at: "2025-01-01",
|
||||
},
|
||||
second: {
|
||||
name: "dynamic-tag-two",
|
||||
description: "This is just a spend tag that was passed dynamically",
|
||||
models: [],
|
||||
created_at: "2025-01-01",
|
||||
updated_at: "2025-01-01",
|
||||
},
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<TagSelector onChange={() => {}} accessToken="test-token" />);
|
||||
|
||||
await waitFor(() => expect(mockTagListCall).toHaveBeenCalledWith("test-token"));
|
||||
const input = screen.getByRole("combobox");
|
||||
await waitFor(() => expect(input).toBeEnabled());
|
||||
await user.click(input);
|
||||
expect(await screen.findByRole("option", { name: /dynamic-tag-one/ })).toBeInTheDocument();
|
||||
await user.type(input, "spend tag");
|
||||
|
||||
expect(await screen.findByRole("option", { name: 'Create "spend tag"' })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("option")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ const TagSelector: React.FC<TagSelectorProps> = ({ onChange, value, className, a
|
|||
loading={loading}
|
||||
className={className}
|
||||
allowCustomValues
|
||||
searchDescriptions={false}
|
||||
options={tags.map((tag) => ({
|
||||
label: tag.name,
|
||||
value: tag.name,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue