Skip to content

Commit 52c4b61

Browse files
authored
feat: add search to parameter dropdowns (#18729)
1 parent dad033e commit 52c4b61

File tree

9 files changed

+178
-138
lines changed

9 files changed

+178
-138
lines changed

.github/.linkspector.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,6 @@ ignorePatterns:
2525
- pattern: "docs.github.com"
2626
- pattern: "claude.ai"
2727
- pattern: "splunk.com"
28+
- pattern: "stackoverflow.com/questions"
2829
aliveStatusCodes:
2930
- 200

docs/about/contributing/frontend.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ All UI-related code is in the `site` folder. Key directories include:
6666
- **util** - Helper functions that can be used across the application
6767
- **static** - Static assets like images, fonts, icons, etc
6868

69+
Do not use barrel files. Imports should be directly from the file that defines
70+
the value.
71+
6972
## Routing
7073

7174
We use [react-router](https://reactrouter.com/en/main) as our routing engine.

site/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"lint:check": " biome lint --error-on-warnings .",
1818
"lint:circular-deps": "dpdm --no-tree --no-warning -T ./src/App.tsx",
1919
"lint:knip": "knip",
20-
"lint:fix": " biome lint --error-on-warnings --write . && knip --fix",
20+
"lint:fix": "biome lint --error-on-warnings --write . && knip --fix",
2121
"lint:types": "tsc -p .",
2222
"playwright:install": "playwright install --with-deps chromium",
2323
"playwright:test": "playwright test --config=e2e/playwright.config.ts",

site/src/components/Combobox/Combobox.stories.tsx

Lines changed: 43 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,35 @@ import { expect, screen, userEvent, waitFor, within } from "@storybook/test";
33
import { useState } from "react";
44
import { Combobox } from "./Combobox";
55

6-
const options = ["Option 1", "Option 2", "Option 3", "Another Option"];
6+
const simpleOptions = ["Go", "Gleam", "Kotlin", "Rust"];
77

8-
const ComboboxWithHooks = () => {
8+
const advancedOptions = [
9+
{
10+
displayName: "Go",
11+
value: "go",
12+
icon: "/icon/go.svg",
13+
},
14+
{
15+
displayName: "Gleam",
16+
value: "gleam",
17+
icon: "https://github.com/gleam-lang.png",
18+
},
19+
{
20+
displayName: "Kotlin",
21+
value: "kotlin",
22+
description: "Kotlin 2.1, OpenJDK 24, gradle",
23+
icon: "/icon/kotlin.svg",
24+
},
25+
{
26+
displayName: "Rust",
27+
value: "rust",
28+
icon: "/icon/rust.svg",
29+
},
30+
] as const;
31+
32+
const ComboboxWithHooks = ({
33+
options = advancedOptions,
34+
}: { options?: React.ComponentProps<typeof Combobox>["options"] }) => {
935
const [value, setValue] = useState("");
1036
const [open, setOpen] = useState(false);
1137
const [inputValue, setInputValue] = useState("");
@@ -34,17 +60,21 @@ const ComboboxWithHooks = () => {
3460
const meta: Meta<typeof Combobox> = {
3561
title: "components/Combobox",
3662
component: Combobox,
63+
args: { options: advancedOptions },
3764
};
3865

3966
export default meta;
4067
type Story = StoryObj<typeof Combobox>;
4168

42-
export const Default: Story = {
43-
render: () => <ComboboxWithHooks />,
69+
export const Default: Story = {};
70+
71+
export const SimpleOptions: Story = {
72+
args: {
73+
options: simpleOptions,
74+
},
4475
};
4576

4677
export const OpenCombobox: Story = {
47-
render: () => <ComboboxWithHooks />,
4878
play: async ({ canvasElement }) => {
4979
const canvas = within(canvasElement);
5080
await userEvent.click(canvas.getByRole("button"));
@@ -58,11 +88,7 @@ export const SelectOption: Story = {
5888
play: async ({ canvasElement }) => {
5989
const canvas = within(canvasElement);
6090
await userEvent.click(canvas.getByRole("button"));
61-
await userEvent.click(screen.getByText("Option 1"));
62-
63-
await waitFor(() =>
64-
expect(canvas.getByRole("button")).toHaveTextContent("Option 1"),
65-
);
91+
await userEvent.click(screen.getByText("Go"));
6692
},
6793
};
6894

@@ -71,19 +97,13 @@ export const SearchAndFilter: Story = {
7197
play: async ({ canvasElement }) => {
7298
const canvas = within(canvasElement);
7399
await userEvent.click(canvas.getByRole("button"));
74-
await userEvent.type(screen.getByRole("combobox"), "Another");
75-
await userEvent.click(
76-
screen.getByRole("option", { name: "Another Option" }),
77-
);
78-
100+
await userEvent.type(screen.getByRole("combobox"), "r");
79101
await waitFor(() => {
80102
expect(
81-
screen.getByRole("option", { name: "Another Option" }),
82-
).toBeInTheDocument();
83-
expect(
84-
screen.queryByRole("option", { name: "Option 1" }),
103+
screen.queryByRole("option", { name: "Kotlin" }),
85104
).not.toBeInTheDocument();
86105
});
106+
await userEvent.click(screen.getByRole("option", { name: "Rust" }));
87107
},
88108
};
89109

@@ -92,16 +112,11 @@ export const EnterCustomValue: Story = {
92112
play: async ({ canvasElement }) => {
93113
const canvas = within(canvasElement);
94114
await userEvent.click(canvas.getByRole("button"));
95-
await userEvent.type(screen.getByRole("combobox"), "Custom Value{enter}");
96-
97-
await waitFor(() =>
98-
expect(canvas.getByRole("button")).toHaveTextContent("Custom Value"),
99-
);
115+
await userEvent.type(screen.getByRole("combobox"), "Swift{enter}");
100116
},
101117
};
102118

103119
export const NoResults: Story = {
104-
render: () => <ComboboxWithHooks />,
105120
play: async ({ canvasElement }) => {
106121
const canvas = within(canvasElement);
107122
await userEvent.click(canvas.getByRole("button"));
@@ -120,10 +135,11 @@ export const ClearSelectedOption: Story = {
120135
const canvas = within(canvasElement);
121136

122137
await userEvent.click(canvas.getByRole("button"));
138+
// const goOption = screen.getByText("Go");
123139
// First select an option
124-
await userEvent.click(screen.getByRole("option", { name: "Option 1" }));
140+
await userEvent.click(await screen.findByRole("option", { name: "Go" }));
125141
// Then clear it by selecting it again
126-
await userEvent.click(screen.getByRole("option", { name: "Option 1" }));
142+
await userEvent.click(await screen.findByRole("option", { name: "Go" }));
127143

128144
await waitFor(() =>
129145
expect(canvas.getByRole("button")).toHaveTextContent("Select option"),

site/src/components/Combobox/Combobox.tsx

Lines changed: 78 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Avatar } from "components/Avatar/Avatar";
12
import { Button } from "components/Button/Button";
23
import {
34
Command,
@@ -12,22 +13,36 @@ import {
1213
PopoverContent,
1314
PopoverTrigger,
1415
} from "components/Popover/Popover";
16+
import {
17+
Tooltip,
18+
TooltipContent,
19+
TooltipProvider,
20+
TooltipTrigger,
21+
} from "components/Tooltip/Tooltip";
1522
import { Check, ChevronDown, CornerDownLeft } from "lucide-react";
16-
import type { FC, KeyboardEventHandler } from "react";
23+
import { Info } from "lucide-react";
24+
import { type FC, type KeyboardEventHandler, useState } from "react";
1725
import { cn } from "utils/cn";
1826

1927
interface ComboboxProps {
2028
value: string;
21-
options?: readonly string[];
29+
options?: Readonly<Array<string | ComboboxOption>>;
2230
placeholder?: string;
23-
open: boolean;
24-
onOpenChange: (open: boolean) => void;
25-
inputValue: string;
26-
onInputChange: (value: string) => void;
31+
open?: boolean;
32+
onOpenChange?: (open: boolean) => void;
33+
inputValue?: string;
34+
onInputChange?: (value: string) => void;
2735
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
2836
onSelect: (value: string) => void;
2937
}
3038

39+
type ComboboxOption = {
40+
icon?: string;
41+
displayName: string;
42+
value: string;
43+
description?: string;
44+
};
45+
3146
export const Combobox: FC<ComboboxProps> = ({
3247
value,
3348
options = [],
@@ -39,16 +54,37 @@ export const Combobox: FC<ComboboxProps> = ({
3954
onKeyDown,
4055
onSelect,
4156
}) => {
57+
const [managedOpen, setManagedOpen] = useState(false);
58+
const [managedInputValue, setManagedInputValue] = useState("");
59+
60+
const optionsMap = new Map<string, ComboboxOption>(
61+
options.map((option) =>
62+
typeof option === "string"
63+
? [option, { displayName: option, value: option }]
64+
: [option.value, option],
65+
),
66+
);
67+
const optionObjects = [...optionsMap.values()];
68+
const showIcons = optionObjects.some((it) => it.icon);
69+
70+
const isOpen = open ?? managedOpen;
71+
4272
return (
43-
<Popover open={open} onOpenChange={onOpenChange}>
73+
<Popover
74+
open={isOpen}
75+
onOpenChange={(newOpen) => {
76+
setManagedOpen(newOpen);
77+
onOpenChange?.(newOpen);
78+
}}
79+
>
4480
<PopoverTrigger asChild>
4581
<Button
4682
variant="outline"
47-
aria-expanded={open}
83+
aria-expanded={isOpen}
4884
className="w-72 justify-between group"
4985
>
5086
<span className={cn(!value && "text-content-secondary")}>
51-
{value || placeholder}
87+
{optionsMap.get(value)?.displayName || value || placeholder}
5288
</span>
5389
<ChevronDown className="size-icon-sm text-content-secondary group-hover:text-content-primary" />
5490
</Button>
@@ -57,8 +93,11 @@ export const Combobox: FC<ComboboxProps> = ({
5793
<Command>
5894
<CommandInput
5995
placeholder="Search or enter custom value"
60-
value={inputValue}
61-
onValueChange={onInputChange}
96+
value={inputValue ?? managedInputValue}
97+
onValueChange={(newValue) => {
98+
setManagedInputValue(newValue);
99+
onInputChange?.(newValue);
100+
}}
62101
onKeyDown={onKeyDown}
63102
/>
64103
<CommandList>
@@ -70,18 +109,40 @@ export const Combobox: FC<ComboboxProps> = ({
70109
</span>
71110
</CommandEmpty>
72111
<CommandGroup>
73-
{options.map((option) => (
112+
{optionObjects.map((option) => (
74113
<CommandItem
75-
key={option}
76-
value={option}
114+
key={option.value}
115+
value={option.value}
116+
keywords={[option.displayName]}
77117
onSelect={(currentValue) => {
78118
onSelect(currentValue === value ? "" : currentValue);
79119
}}
80120
>
81-
{option}
82-
{value === option && (
83-
<Check className="size-icon-sm ml-auto" />
121+
{showIcons && (
122+
<Avatar
123+
size="sm"
124+
src={option.icon}
125+
fallback={option.value}
126+
/>
84127
)}
128+
{option.displayName}
129+
<div className="flex flex-row items-center ml-auto gap-1">
130+
{value === option.value && (
131+
<Check className="size-icon-sm" />
132+
)}
133+
{option.description && (
134+
<TooltipProvider delayDuration={100}>
135+
<Tooltip>
136+
<TooltipTrigger asChild>
137+
<Info className="w-3.5 h-3.5 text-content-secondary" />
138+
</TooltipTrigger>
139+
<TooltipContent side="right" sideOffset={10}>
140+
{option.description}
141+
</TooltipContent>
142+
</Tooltip>
143+
</TooltipProvider>
144+
)}
145+
</div>
85146
</CommandItem>
86147
))}
87148
</CommandGroup>

site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -617,7 +617,7 @@ export const MultiSelectCombobox = forwardRef<
617617
}}
618618
>
619619
{isLoading ? (
620-
<>{loadingIndicator}</>
620+
loadingIndicator
621621
) : (
622622
<>
623623
{EmptyItem()}

site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -56,24 +56,48 @@ export const Dropdown: Story = {
5656
type: "string",
5757
options: [
5858
{
59-
name: "Option 1",
60-
value: { valid: true, value: "option1" },
61-
description: "this is option 1",
62-
icon: "",
59+
name: "Nissa, Worldsoul Speaker",
60+
value: { valid: true, value: "nissa" },
61+
description:
62+
"Zendikar still seems so far off, but Chandra is my home.",
63+
icon: "/emojis/1f7e2.png",
6364
},
6465
{
65-
name: "Option 2",
66-
value: { valid: true, value: "option2" },
67-
description: "this is option 2",
68-
icon: "",
66+
name: "Canopy Spider",
67+
value: { valid: true, value: "spider" },
68+
description:
69+
"It keeps the upper reaches of the forest free of every menace . . . except for the spider itself.",
70+
icon: "/emojis/1f7e2.png",
6971
},
7072
{
71-
name: "Option 3",
72-
value: { valid: true, value: "option3" },
73-
description: "this is option 3",
74-
icon: "",
73+
name: "Ajani, Nacatl Pariah",
74+
value: { valid: true, value: "ajani" },
75+
description: "His pride denied him; his brother did not.",
76+
icon: "/emojis/26aa.png",
77+
},
78+
{
79+
name: "Glowing Anemone",
80+
value: { valid: true, value: "anemone" },
81+
description: "Beautiful to behold, terrible to be held.",
82+
icon: "/emojis/1f535.png",
83+
},
84+
{
85+
name: "Springmantle Cleric",
86+
value: { valid: true, value: "cleric" },
87+
description: "Hope and courage bloom in her wake.",
88+
icon: "/emojis/1f7e2.png",
89+
},
90+
{
91+
name: "Aegar, the Freezing Flame",
92+
value: { valid: true, value: "aegar" },
93+
description:
94+
"Though Phyrexian machines could adapt to extremes of heat or cold, they never figured out how to adapt to both at once.",
95+
icon: "/emojis/1f308.png",
7596
},
7697
],
98+
styling: {
99+
placeholder: "Select a creature",
100+
},
77101
},
78102
},
79103
};

0 commit comments

Comments
 (0)