fix(ui): keep MultiSelect description search opt-out and disable it for tags

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
gabriele 2026-10-05 10:53:56 +00:00
parent c5c7061c3e
commit f60bee0da3
5 changed files with 68 additions and 12 deletions

View file

@ -193,6 +193,7 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
label: tag.name,
description: tag.description || undefined,
}))}
searchDescriptions={false}
allowCustomValues
/>
)}

View file

@ -84,8 +84,16 @@ describe("MultiSelect", () => {
expect(screen.getByText("Showing first 100 of 5000 matches. Type to narrow results.")).toBeInTheDocument();
});
it("does not match the option description", async () => {
const { input } = renderMultiSelect({ options: LARGE_OPTIONS });
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);

View file

@ -34,6 +34,7 @@ interface MultiSelectProps {
disabled?: boolean;
loading?: boolean;
allowCustomValues?: boolean;
searchDescriptions?: boolean;
className?: string;
}
@ -43,13 +44,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)
);
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,6 +63,7 @@ export function MultiSelect({
disabled = false,
loading = false,
allowCustomValues = false,
searchDescriptions = true,
className,
}: MultiSelectProps) {
const anchor = useComboboxAnchor();
@ -75,8 +77,8 @@ export function MultiSelect({
[options],
);
const matchingOptions = useMemo(
() => safeOptions.filter((option) => matchesQuery(option, query)),
[safeOptions, query],
() => safeOptions.filter((option) => matchesQuery(option, query, searchDescriptions)),
[safeOptions, query, searchDescriptions],
);
const visibleOptions = matchingOptions.slice(0, MAX_VISIBLE_OPTIONS);
const selectedOptions = value

View file

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

View file

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