feat: add search to parameter dropdowns (#18729)

This commit is contained in:
ケイラ
2025-07-15 11:23:49 -06:00
committed by GitHub
parent dad033ee3d
commit 52c4b61391
9 changed files with 178 additions and 138 deletions
+1
View File
@@ -25,5 +25,6 @@ ignorePatterns:
- pattern: "docs.github.com"
- pattern: "claude.ai"
- pattern: "splunk.com"
- pattern: "stackoverflow.com/questions"
aliveStatusCodes:
- 200
+3
View File
@@ -66,6 +66,9 @@ All UI-related code is in the `site` folder. Key directories include:
- **util** - Helper functions that can be used across the application
- **static** - Static assets like images, fonts, icons, etc
Do not use barrel files. Imports should be directly from the file that defines
the value.
## Routing
We use [react-router](https://reactrouter.com/en/main) as our routing engine.
+1 -1
View File
@@ -17,7 +17,7 @@
"lint:check": " biome lint --error-on-warnings .",
"lint:circular-deps": "dpdm --no-tree --no-warning -T ./src/App.tsx",
"lint:knip": "knip",
"lint:fix": " biome lint --error-on-warnings --write . && knip --fix",
"lint:fix": "biome lint --error-on-warnings --write . && knip --fix",
"lint:types": "tsc -p .",
"playwright:install": "playwright install --with-deps chromium",
"playwright:test": "playwright test --config=e2e/playwright.config.ts",
@@ -3,9 +3,35 @@ import { expect, screen, userEvent, waitFor, within } from "@storybook/test";
import { useState } from "react";
import { Combobox } from "./Combobox";
const options = ["Option 1", "Option 2", "Option 3", "Another Option"];
const simpleOptions = ["Go", "Gleam", "Kotlin", "Rust"];
const ComboboxWithHooks = () => {
const advancedOptions = [
{
displayName: "Go",
value: "go",
icon: "/icon/go.svg",
},
{
displayName: "Gleam",
value: "gleam",
icon: "https://github.com/gleam-lang.png",
},
{
displayName: "Kotlin",
value: "kotlin",
description: "Kotlin 2.1, OpenJDK 24, gradle",
icon: "/icon/kotlin.svg",
},
{
displayName: "Rust",
value: "rust",
icon: "/icon/rust.svg",
},
] as const;
const ComboboxWithHooks = ({
options = advancedOptions,
}: { options?: React.ComponentProps<typeof Combobox>["options"] }) => {
const [value, setValue] = useState("");
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
@@ -34,17 +60,21 @@ const ComboboxWithHooks = () => {
const meta: Meta<typeof Combobox> = {
title: "components/Combobox",
component: Combobox,
args: { options: advancedOptions },
};
export default meta;
type Story = StoryObj<typeof Combobox>;
export const Default: Story = {
render: () => <ComboboxWithHooks />,
export const Default: Story = {};
export const SimpleOptions: Story = {
args: {
options: simpleOptions,
},
};
export const OpenCombobox: Story = {
render: () => <ComboboxWithHooks />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button"));
@@ -58,11 +88,7 @@ export const SelectOption: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button"));
await userEvent.click(screen.getByText("Option 1"));
await waitFor(() =>
expect(canvas.getByRole("button")).toHaveTextContent("Option 1"),
);
await userEvent.click(screen.getByText("Go"));
},
};
@@ -71,19 +97,13 @@ export const SearchAndFilter: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button"));
await userEvent.type(screen.getByRole("combobox"), "Another");
await userEvent.click(
screen.getByRole("option", { name: "Another Option" }),
);
await userEvent.type(screen.getByRole("combobox"), "r");
await waitFor(() => {
expect(
screen.getByRole("option", { name: "Another Option" }),
).toBeInTheDocument();
expect(
screen.queryByRole("option", { name: "Option 1" }),
screen.queryByRole("option", { name: "Kotlin" }),
).not.toBeInTheDocument();
});
await userEvent.click(screen.getByRole("option", { name: "Rust" }));
},
};
@@ -92,16 +112,11 @@ export const EnterCustomValue: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button"));
await userEvent.type(screen.getByRole("combobox"), "Custom Value{enter}");
await waitFor(() =>
expect(canvas.getByRole("button")).toHaveTextContent("Custom Value"),
);
await userEvent.type(screen.getByRole("combobox"), "Swift{enter}");
},
};
export const NoResults: Story = {
render: () => <ComboboxWithHooks />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button"));
@@ -120,10 +135,11 @@ export const ClearSelectedOption: Story = {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button"));
// const goOption = screen.getByText("Go");
// First select an option
await userEvent.click(screen.getByRole("option", { name: "Option 1" }));
await userEvent.click(await screen.findByRole("option", { name: "Go" }));
// Then clear it by selecting it again
await userEvent.click(screen.getByRole("option", { name: "Option 1" }));
await userEvent.click(await screen.findByRole("option", { name: "Go" }));
await waitFor(() =>
expect(canvas.getByRole("button")).toHaveTextContent("Select option"),
+78 -17
View File
@@ -1,3 +1,4 @@
import { Avatar } from "components/Avatar/Avatar";
import { Button } from "components/Button/Button";
import {
Command,
@@ -12,22 +13,36 @@ import {
PopoverContent,
PopoverTrigger,
} from "components/Popover/Popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { Check, ChevronDown, CornerDownLeft } from "lucide-react";
import type { FC, KeyboardEventHandler } from "react";
import { Info } from "lucide-react";
import { type FC, type KeyboardEventHandler, useState } from "react";
import { cn } from "utils/cn";
interface ComboboxProps {
value: string;
options?: readonly string[];
options?: Readonly<Array<string | ComboboxOption>>;
placeholder?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
inputValue: string;
onInputChange: (value: string) => void;
open?: boolean;
onOpenChange?: (open: boolean) => void;
inputValue?: string;
onInputChange?: (value: string) => void;
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
onSelect: (value: string) => void;
}
type ComboboxOption = {
icon?: string;
displayName: string;
value: string;
description?: string;
};
export const Combobox: FC<ComboboxProps> = ({
value,
options = [],
@@ -39,16 +54,37 @@ export const Combobox: FC<ComboboxProps> = ({
onKeyDown,
onSelect,
}) => {
const [managedOpen, setManagedOpen] = useState(false);
const [managedInputValue, setManagedInputValue] = useState("");
const optionsMap = new Map<string, ComboboxOption>(
options.map((option) =>
typeof option === "string"
? [option, { displayName: option, value: option }]
: [option.value, option],
),
);
const optionObjects = [...optionsMap.values()];
const showIcons = optionObjects.some((it) => it.icon);
const isOpen = open ?? managedOpen;
return (
<Popover open={open} onOpenChange={onOpenChange}>
<Popover
open={isOpen}
onOpenChange={(newOpen) => {
setManagedOpen(newOpen);
onOpenChange?.(newOpen);
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
aria-expanded={open}
aria-expanded={isOpen}
className="w-72 justify-between group"
>
<span className={cn(!value && "text-content-secondary")}>
{value || placeholder}
{optionsMap.get(value)?.displayName || value || placeholder}
</span>
<ChevronDown className="size-icon-sm text-content-secondary group-hover:text-content-primary" />
</Button>
@@ -57,8 +93,11 @@ export const Combobox: FC<ComboboxProps> = ({
<Command>
<CommandInput
placeholder="Search or enter custom value"
value={inputValue}
onValueChange={onInputChange}
value={inputValue ?? managedInputValue}
onValueChange={(newValue) => {
setManagedInputValue(newValue);
onInputChange?.(newValue);
}}
onKeyDown={onKeyDown}
/>
<CommandList>
@@ -70,18 +109,40 @@ export const Combobox: FC<ComboboxProps> = ({
</span>
</CommandEmpty>
<CommandGroup>
{options.map((option) => (
{optionObjects.map((option) => (
<CommandItem
key={option}
value={option}
key={option.value}
value={option.value}
keywords={[option.displayName]}
onSelect={(currentValue) => {
onSelect(currentValue === value ? "" : currentValue);
}}
>
{option}
{value === option && (
<Check className="size-icon-sm ml-auto" />
{showIcons && (
<Avatar
size="sm"
src={option.icon}
fallback={option.value}
/>
)}
{option.displayName}
<div className="flex flex-row items-center ml-auto gap-1">
{value === option.value && (
<Check className="size-icon-sm" />
)}
{option.description && (
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-3.5 h-3.5 text-content-secondary" />
</TooltipTrigger>
<TooltipContent side="right" sideOffset={10}>
{option.description}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</CommandItem>
))}
</CommandGroup>
@@ -617,7 +617,7 @@ export const MultiSelectCombobox = forwardRef<
}}
>
{isLoading ? (
<>{loadingIndicator}</>
loadingIndicator
) : (
<>
{EmptyItem()}
@@ -56,24 +56,48 @@ export const Dropdown: Story = {
type: "string",
options: [
{
name: "Option 1",
value: { valid: true, value: "option1" },
description: "this is option 1",
icon: "",
name: "Nissa, Worldsoul Speaker",
value: { valid: true, value: "nissa" },
description:
"Zendikar still seems so far off, but Chandra is my home.",
icon: "/emojis/1f7e2.png",
},
{
name: "Option 2",
value: { valid: true, value: "option2" },
description: "this is option 2",
icon: "",
name: "Canopy Spider",
value: { valid: true, value: "spider" },
description:
"It keeps the upper reaches of the forest free of every menace . . . except for the spider itself.",
icon: "/emojis/1f7e2.png",
},
{
name: "Option 3",
value: { valid: true, value: "option3" },
description: "this is option 3",
icon: "",
name: "Ajani, Nacatl Pariah",
value: { valid: true, value: "ajani" },
description: "His pride denied him; his brother did not.",
icon: "/emojis/26aa.png",
},
{
name: "Glowing Anemone",
value: { valid: true, value: "anemone" },
description: "Beautiful to behold, terrible to be held.",
icon: "/emojis/1f535.png",
},
{
name: "Springmantle Cleric",
value: { valid: true, value: "cleric" },
description: "Hope and courage bloom in her wake.",
icon: "/emojis/1f7e2.png",
},
{
name: "Aegar, the Freezing Flame",
value: { valid: true, value: "aegar" },
description:
"Though Phyrexian machines could adapt to extremes of heat or cold, they never figured out how to adapt to both at once.",
icon: "/emojis/1f308.png",
},
],
styling: {
placeholder: "Select a creature",
},
},
},
};
@@ -191,7 +191,7 @@ describe("DynamicParameter", () => {
});
});
describe("Select Parameter", () => {
describe("dropdown parameter", () => {
const mockSelectParameter = createMockParameter({
name: "select_param",
display_name: "Select Parameter",
@@ -221,19 +221,6 @@ describe("DynamicParameter", () => {
],
});
it("renders select parameter with options", () => {
render(
<DynamicParameter
parameter={mockSelectParameter}
value="option1"
onChange={mockOnChange}
/>,
);
expect(screen.getByText("Select Parameter")).toBeInTheDocument();
expect(screen.getByRole("combobox")).toBeInTheDocument();
});
it("displays all options when opened", async () => {
render(
<DynamicParameter
@@ -243,7 +230,7 @@ describe("DynamicParameter", () => {
/>,
);
const select = screen.getByRole("combobox");
const select = screen.getByRole("button");
await waitFor(async () => {
await userEvent.click(select);
});
@@ -263,7 +250,7 @@ describe("DynamicParameter", () => {
/>,
);
const select = screen.getByRole("combobox");
const select = screen.getByRole("button");
await waitFor(async () => {
await userEvent.click(select);
});
@@ -275,26 +262,6 @@ describe("DynamicParameter", () => {
expect(mockOnChange).toHaveBeenCalledWith("option2");
});
it("displays option icons when provided", async () => {
render(
<DynamicParameter
parameter={mockSelectParameter}
value="option1"
onChange={mockOnChange}
/>,
);
const select = screen.getByRole("combobox");
await waitFor(async () => {
await userEvent.click(select);
});
const icons = screen.getAllByRole("img");
expect(
icons.some((icon) => icon.getAttribute("src") === "/icon2.png"),
).toBe(true);
});
});
describe("Radio Parameter", () => {
@@ -829,7 +796,7 @@ describe("DynamicParameter", () => {
/>,
);
expect(screen.getByRole("combobox")).toBeInTheDocument();
expect(screen.getByRole("button")).toBeInTheDocument();
});
it("handles null/undefined values", () => {
@@ -7,6 +7,7 @@ import type {
import { Badge } from "components/Badge/Badge";
import { Button } from "components/Button/Button";
import { Checkbox } from "components/Checkbox/Checkbox";
import { Combobox } from "components/Combobox/Combobox";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { Input } from "components/Input/Input";
import { Label } from "components/Label/Label";
@@ -16,13 +17,6 @@ import {
type Option,
} from "components/MultiSelectCombobox/MultiSelectCombobox";
import { RadioGroup, RadioGroupItem } from "components/RadioGroup/RadioGroup";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "components/Select/Select";
import { Slider } from "components/Slider/Slider";
import { Stack } from "components/Stack/Stack";
import { Switch } from "components/Switch/Switch";
@@ -434,43 +428,17 @@ const ParameterField: FC<ParameterFieldProps> = ({
}) => {
switch (parameter.form_type) {
case "dropdown": {
const EMPTY_VALUE_PLACEHOLDER = "__EMPTY_STRING__";
const selectValue = value === "" ? EMPTY_VALUE_PLACEHOLDER : value;
const handleSelectChange = (newValue: string) => {
onChange(newValue === EMPTY_VALUE_PLACEHOLDER ? "" : newValue);
};
return (
<Select
onValueChange={handleSelectChange}
value={selectValue}
disabled={disabled}
required={parameter.required}
>
<SelectTrigger id={id}>
<SelectValue
placeholder={parameter.styling?.placeholder || "Select option"}
/>
</SelectTrigger>
<SelectContent>
{parameter.options.map((option, index) => {
const optionValue =
option.value.value === ""
? EMPTY_VALUE_PLACEHOLDER
: option.value.value;
return (
<SelectItem
key={
option.value.value || `${EMPTY_VALUE_PLACEHOLDER}:${index}`
}
value={optionValue}
>
<OptionDisplay option={option} />
</SelectItem>
);
})}
</SelectContent>
</Select>
<Combobox
value={value ?? ""}
onSelect={(value) => onChange(value)}
options={parameter.options.map((option) => ({
icon: option.icon,
displayName: option.name,
value: option.value.value,
description: option.description,
}))}
/>
);
}