mirror of
https://github.com/coder/coder.git
synced 2026-09-21 12:44:32 +08:00
feat: add search to parameter dropdowns (#18729)
This commit is contained in:
@@ -25,5 +25,6 @@ ignorePatterns:
|
||||
- pattern: "docs.github.com"
|
||||
- pattern: "claude.ai"
|
||||
- pattern: "splunk.com"
|
||||
- pattern: "stackoverflow.com/questions"
|
||||
aliveStatusCodes:
|
||||
- 200
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user