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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy