feat: refactor <Combobox/> with compound componentisation (#21778)

This pull-request refactors the `<Combobox />` component from a
monolithic design to a composable compound component pattern, providing
more flexibility and reusability across the codebase

- Migrates `<SelectFilter />` to use the new `<Combobox />` instead of
the legacy `<SelectMenu />` components
- Updates all existing consumers of `<Combobox />` and `<SelectFilter
/>` to use the new API

<img
src="https://github.com/user-attachments/assets/a3336431-590c-48b5-adde-3fc5c16f459d"
/>

The `<Combobox />` component has been refactored to use a compound
component pattern, exposing:

- `Combobox` - Root component with context provider for open/value state
- `ComboboxTrigger` - Trigger wrapper (re-exports PopoverTrigger)
- `ComboboxButton` - Styled button with chevron and selected option
display
- `ComboboxContent` - Popover content with Command wrapper
- `ComboboxInput` - Search input (re-exports CommandInput)
- `ComboboxList` - List container (re-exports CommandList)
- `ComboboxItem` - Individual option with checkmark indicator
- `ComboboxEmpty` - Empty state (re-exports CommandEmpty)
- `useCombobox` - Hook to access combobox context

This pattern allows consumers to compose their own combobox layouts
while sharing consistent behavior and styling.

Furthermore, we had an issue with `CreateWorkspacePageView.stories.tsx`
lacking stories which would let us see the passed parameters and presets
in context. I've added stories to surround this.

### Updated Consumers

- `DynamicParameter.tsx` - Updated to use new Combobox API for parameter
options
- `CreateWorkspacePageView.tsx` - Updated preset combobox usage
- `IdpOrgSyncPageView.tsx` - Updated organization sync form
- `IdpGroupSyncForm.tsx` - Updated group sync form
- `IdpRoleSyncForm.tsx` - Updated role sync form
- `WorkspacesPage/filter/menus.tsx` - Updated workspace filter menus

---------

Co-authored-by: ケイラ <mckayla@hey.com>
This commit is contained in:
Jake Howell
2026-02-13 15:44:26 +11:00
committed by GitHub
co-authored by ケイラ
parent 2cc8cc59fa
commit ef25baf581
14 changed files with 893 additions and 374 deletions
+145 -55
View File
@@ -1,82 +1,153 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { SelectFilterOption } from "components/Filter/SelectFilter";
import { useState } from "react";
import { expect, screen, userEvent, waitFor, within } from "storybook/test";
import { Combobox } from "./Combobox";
import {
Combobox,
ComboboxButton,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
} from "./Combobox";
const simpleOptions = ["Go", "Gleam", "Kotlin", "Rust"];
const options: SelectFilterOption[] = [
{ value: "go", label: "Go" },
{ value: "gleam", label: "Gleam" },
{ value: "kotlin", label: "Kotlin" },
{ value: "rust", label: "Rust" },
];
const advancedOptions = [
const advancedOptions: SelectFilterOption[] = [
{ value: "go", label: "Go", startIcon: "/icon/go.svg" },
{ value: "gleam", label: "Gleam", startIcon: "/icon/gleam.svg" },
{
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",
label: "Kotlin",
startIcon: "/icon/kotlin.svg",
},
{
displayName: "Rust",
value: "rust",
icon: "/icon/rust.svg",
},
] as const;
{ value: "rust", label: "Rust", startIcon: "/icon/rust.svg" },
];
const ComboboxWithHooks = ({
options = advancedOptions,
optionsList = options,
}: {
options?: React.ComponentProps<typeof Combobox>["options"];
optionsList?: SelectFilterOption[];
}) => {
const [value, setValue] = useState("");
const [open, setOpen] = useState(false);
const [value, setValue] = useState<string | undefined>(undefined);
const selectedOption = optionsList.find((opt) => opt.value === value);
return (
<Combobox value={value} onValueChange={setValue}>
<ComboboxTrigger asChild>
<ComboboxButton
selectedOption={selectedOption}
placeholder="Select option"
/>
</ComboboxTrigger>
<ComboboxContent className="w-60">
<ComboboxInput placeholder="Search..." />
<ComboboxList>
{optionsList.map((option) => (
<ComboboxItem key={option.value} value={option.value}>
{option.label}
</ComboboxItem>
))}
</ComboboxList>
<ComboboxEmpty>No results found</ComboboxEmpty>
</ComboboxContent>
</Combobox>
);
};
const ComboboxWithCustomValue = ({
optionsList = options,
}: {
optionsList?: SelectFilterOption[];
}) => {
const [value, setValue] = useState<string | undefined>(undefined);
const [inputValue, setInputValue] = useState("");
const [open, setOpen] = useState(false);
const selectedOption = optionsList.find((opt) => opt.value === value);
const displayLabel = selectedOption?.label ?? value;
const handleKeyDown = (e: React.KeyboardEvent) => {
if (
e.key === "Enter" &&
inputValue &&
!optionsList.some((o) => o.value === inputValue)
) {
setValue(inputValue);
setInputValue("");
setOpen(false);
}
};
return (
<Combobox
value={value}
options={options}
placeholder="Select option"
onValueChange={setValue}
open={open}
onOpenChange={setOpen}
inputValue={inputValue}
onInputChange={setInputValue}
onSelect={setValue}
onKeyDown={(e) => {
if (e.key === "Enter" && inputValue && !options.includes(inputValue)) {
setValue(inputValue);
setInputValue("");
setOpen(false);
}
}}
/>
>
<ComboboxTrigger asChild>
<ComboboxButton
selectedOption={
displayLabel
? { label: displayLabel, value: value ?? "" }
: undefined
}
placeholder="Select option"
/>
</ComboboxTrigger>
<ComboboxContent className="w-60">
<ComboboxInput
placeholder="Search or enter custom..."
value={inputValue}
onValueChange={setInputValue}
onKeyDown={handleKeyDown}
/>
<ComboboxList>
{optionsList.map((option) => (
<ComboboxItem key={option.value} value={option.value}>
{option.label}
</ComboboxItem>
))}
</ComboboxList>
<ComboboxEmpty>
<span>No results found</span>
{inputValue && (
<span className="block text-content-secondary text-xs mt-1">
Press Enter to use "{inputValue}"
</span>
)}
</ComboboxEmpty>
</ComboboxContent>
</Combobox>
);
};
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 = {};
export const Default: Story = {
render: () => <ComboboxWithHooks />,
};
export const SimpleOptions: Story = {
args: {
options: simpleOptions,
},
export const WithAdvancedOptions: Story = {
render: () => <ComboboxWithHooks optionsList={advancedOptions} />,
};
export const OpenCombobox: Story = {
render: () => <ComboboxWithHooks />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button"));
@@ -91,6 +162,10 @@ export const SelectOption: Story = {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button"));
await userEvent.click(screen.getByText("Go"));
await waitFor(() =>
expect(canvas.getByRole("button")).toHaveTextContent("Go"),
);
},
};
@@ -100,25 +175,35 @@ export const SearchAndFilter: Story = {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button"));
await userEvent.type(screen.getByRole("combobox"), "r");
await waitFor(() => {
expect(screen.getByRole("option", { name: /Rust/ })).toBeInTheDocument();
expect(
screen.queryByRole("option", { name: "Kotlin" }),
screen.queryByRole("option", { name: /^Go$/ }),
).not.toBeInTheDocument();
});
await userEvent.click(screen.getByRole("option", { name: "Rust" }));
},
};
export const WithCustomValue: Story = {
render: () => <ComboboxWithCustomValue />,
};
export const EnterCustomValue: Story = {
render: () => <ComboboxWithHooks />,
render: () => <ComboboxWithCustomValue />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button"));
await userEvent.type(screen.getByRole("combobox"), "Swift{enter}");
await userEvent.type(screen.getByRole("combobox"), "Custom Value{enter}");
await waitFor(() =>
expect(canvas.getByRole("button")).toHaveTextContent("Custom Value"),
);
},
};
export const NoResults: Story = {
render: () => <ComboboxWithCustomValue />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button"));
@@ -126,7 +211,7 @@ export const NoResults: Story = {
await waitFor(() => {
expect(screen.getByText("No results found")).toBeInTheDocument();
expect(screen.getByText("Enter custom value")).toBeInTheDocument();
expect(screen.getByText(/Press Enter to use/)).toBeInTheDocument();
});
},
};
@@ -136,12 +221,17 @@ export const ClearSelectedOption: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button"));
// const goOption = screen.getByText("Go");
// First select an option
await userEvent.click(await screen.findByRole("option", { name: "Go" }));
// Then clear it by selecting it again
await userEvent.click(await screen.findByRole("option", { name: "Go" }));
await userEvent.click(canvas.getByRole("button"));
await userEvent.click(screen.getByRole("option", { name: /Go/ }));
await waitFor(() =>
expect(canvas.getByRole("button")).toHaveTextContent("Go"),
);
// Then clear it by selecting it again (toggle behavior)
await userEvent.click(canvas.getByRole("button"));
await userEvent.click(screen.getByRole("option", { name: /Go/ }));
await waitFor(() =>
expect(canvas.getByRole("button")).toHaveTextContent("Select option"),
+132 -139
View File
@@ -2,162 +2,155 @@ import { Button } from "components/Button/Button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "components/Command/Command";
import type { SelectFilterOption } from "components/Filter/SelectFilter";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/Popover/Popover";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { Check, ChevronDown, CornerDownLeft, Info } from "lucide-react";
import { type FC, type KeyboardEventHandler, useState } from "react";
import { CheckIcon, ChevronDownIcon } from "lucide-react";
import type React from "react";
import { createContext, useContext, useState } from "react";
import { cn } from "utils/cn";
import { ExternalImage } from "../ExternalImage/ExternalImage";
interface ComboboxProps {
value: string;
options?: Readonly<Array<string | ComboboxOption>>;
placeholder?: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
inputValue?: string;
onInputChange?: (value: string) => void;
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
onSelect: (value: string) => void;
id?: string;
type ComboboxContextProps = {
open: boolean;
setOpen: (open: boolean) => void;
value: string | undefined;
onValueChange: ((value: string | undefined) => void) | undefined;
};
const ComboboxContext = createContext<ComboboxContextProps | null>(null);
function useCombobox() {
const context = useContext(ComboboxContext);
if (!context) {
throw new Error("useCombobox must be used within a <Combobox />");
}
return context;
}
type ComboboxOption = {
icon?: string;
displayName: string;
value: string;
description?: string;
};
interface ComboboxProps extends React.ComponentProps<typeof Popover> {
value?: string;
onValueChange?: (value: string | undefined) => void;
}
export const Combobox: FC<ComboboxProps> = ({
export const Combobox = ({
children,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
value,
options = [],
placeholder = "Select option",
open,
onOpenChange,
inputValue,
onInputChange,
onKeyDown,
onSelect,
id,
}) => {
const [managedOpen, setManagedOpen] = useState(false);
const [managedInputValue, setManagedInputValue] = useState("");
onValueChange,
...props
}: ComboboxProps) => {
const [internalOpen, setInternalOpen] = useState(false);
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;
const handleOpenChange = (newOpen: boolean) => {
setManagedOpen(newOpen);
onOpenChange?.(newOpen);
};
// Use controlled state if provided, otherwise use internal state
const open = controlledOpen ?? internalOpen;
const setOpen = controlledOnOpenChange ?? setInternalOpen;
return (
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
id={id}
variant="outline"
aria-expanded={isOpen}
className="w-full justify-between group"
>
<span className={cn(!value && "text-content-secondary")}>
{optionsMap.get(value)?.displayName || value || placeholder}
</span>
<ChevronDown className="text-content-secondary group-hover:text-content-primary" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)]">
<Command>
<CommandInput
placeholder="Search or enter custom value"
value={inputValue ?? managedInputValue}
onValueChange={(newValue) => {
setManagedInputValue(newValue);
onInputChange?.(newValue);
}}
onKeyDown={onKeyDown}
/>
<CommandList>
<CommandEmpty>
<p>No results found</p>
<span className="flex flex-row items-center justify-center gap-1">
Enter custom value
<CornerDownLeft className="size-icon-sm bg-surface-tertiary rounded-sm p-1" />
</span>
</CommandEmpty>
<CommandGroup>
{optionObjects.map((option) => (
<CommandItem
key={option.value}
value={option.value}
keywords={[option.displayName]}
onSelect={(currentValue) => {
onSelect(currentValue === value ? "" : currentValue);
// Close the popover after selection
handleOpenChange(false);
}}
>
{showIcons &&
(option.icon ? (
<ExternalImage
className="w-4 h-4 object-contain"
src={option.icon}
alt=""
/>
) : (
/* Placeholder for missing icon to maintain layout consistency */
<div className="w-4 h-4"></div>
))}
{option.displayName}
<div className="flex flex-row items-center ml-auto gap-1">
{value === option.value && (
<Check className="size-icon-sm" />
)}
{option.description && (
<Tooltip>
<TooltipTrigger asChild>
<span
className="flex"
onMouseEnter={(e) => e.stopPropagation()}
>
<Info className="w-3.5 h-3.5 text-content-secondary" />
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={10}>
{option.description}
</TooltipContent>
</Tooltip>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<ComboboxContext.Provider value={{ open, setOpen, value, onValueChange }}>
<Popover open={open} onOpenChange={setOpen} {...props}>
{children}
</Popover>
</ComboboxContext.Provider>
);
};
export const ComboboxTrigger = PopoverTrigger;
interface ComboboxButtonProps extends React.ComponentPropsWithRef<"button"> {
width?: number;
selectedOption?: SelectFilterOption;
placeholder?: string;
}
export const ComboboxButton = ({
children,
className,
width,
selectedOption,
placeholder,
ref,
...props
}: ComboboxButtonProps) => {
return (
<Button
className="flex items-center justify-between shrink-0 grow gap-2 pr-1.5"
style={{ flexBasis: width }}
variant="outline"
ref={ref}
{...props}
>
{selectedOption?.startIcon}
<span className="text-left block overflow-hidden text-ellipsis flex-grow">
{selectedOption?.label ?? placeholder}
</span>
<ChevronDownIcon className="size-icon-sm" />
</Button>
);
};
export const ComboboxContent = ({
children,
className,
ref,
...props
}: React.ComponentPropsWithRef<typeof PopoverContent>) => {
return (
<PopoverContent
ref={ref}
className={cn(
"w-auto bg-surface-secondary border-surface-quaternary overflow-y-auto text-sm",
className,
)}
{...props}
>
<Command className="bg-surface-secondary">{children}</Command>
</PopoverContent>
);
};
export const ComboboxInput = CommandInput;
export const ComboboxList = CommandList;
export const ComboboxItem = ({
children,
className,
onSelect,
value,
...props
}: React.ComponentPropsWithRef<typeof CommandItem>) => {
const { setOpen, value: selectedValue, onValueChange } = useCombobox();
const isSelected = value === selectedValue;
return (
<CommandItem
value={value}
className={cn(className, "rounded-none")}
onSelect={(itemValue) => {
setOpen(false);
// Toggle behavior: selecting the same value deselects it.
const newValue = itemValue === selectedValue ? undefined : itemValue;
onValueChange?.(newValue);
onSelect?.(itemValue);
}}
{...props}
>
{children}
<CheckIcon
className={cn(
"ml-2 h-4 w-4 min-w-0 flex-shrink-0",
isSelected ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
);
};
export const ComboboxEmpty = CommandEmpty;
@@ -1,14 +1,10 @@
import { withDesktopViewport } from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Avatar } from "components/Avatar/Avatar";
import { ComboboxInput } from "components/Combobox/Combobox";
import { useState } from "react";
import { action } from "storybook/actions";
import { expect, screen, userEvent, within } from "storybook/test";
import {
SelectFilter,
type SelectFilterOption,
SelectFilterSearch,
} from "./SelectFilter";
import { SelectFilter, type SelectFilterOption } from "./SelectFilter";
const options: SelectFilterOption[] = Array.from({ length: 50 }, (_, i) => ({
startIcon: <Avatar fallback={`username ${i + 1}`} size="sm" />,
@@ -47,10 +43,16 @@ export default meta;
type Story = StoryObj<typeof SelectFilter>;
export const Closed: Story = {
play: () => {},
play: async () => {},
};
export const Open: Story = {};
export const Open: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole("button");
await userEvent.click(button);
},
};
export const Selected: Story = {
args: {
@@ -61,13 +63,32 @@ export const Selected: Story = {
export const WithSearch: Story = {
args: {
selectedOption: options[25],
selectFilterSearch: (
<SelectFilterSearch
value=""
onChange={action("onSearch")}
placeholder="Search options..."
},
render: (args) => {
const [selectedOption, setSelectedOption] = useState<
SelectFilterOption | undefined
>(args.selectedOption);
const [search, setSearch] = useState("");
return (
<SelectFilter
{...args}
selectedOption={selectedOption}
onSelect={setSelectedOption}
selectFilterSearch={
<ComboboxInput
placeholder="Search options..."
value={search}
onValueChange={setSearch}
/>
}
/>
),
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole("button");
await userEvent.click(button);
},
};
@@ -88,7 +109,7 @@ export const SelectingOption: Story = {
const canvas = within(canvasElement);
const button = canvas.getByRole("button");
await userEvent.click(button);
const option = screen.getByText("Option 25");
const option = screen.getByRole("option", { name: /Option 25/ });
await userEvent.click(option);
await expect(button).toHaveTextContent("Option 25");
},
@@ -102,8 +123,8 @@ export const UnselectingOption: Story = {
const canvas = within(canvasElement);
const button = canvas.getByRole("button");
await userEvent.click(button);
const menu = screen.getByRole("menu");
const option = within(menu).getByText("Option 26");
// Click the already-selected option to unselect it (toggle behavior)
const option = screen.getByRole("option", { name: /Option 26/ });
await userEvent.click(option);
await expect(button).toHaveTextContent("All options");
},
@@ -126,11 +147,11 @@ export const SearchingOption: Story = {
onSelect={setSelectedOption}
options={visibleOptions}
selectFilterSearch={
<SelectFilterSearch
value={search}
onChange={setSearch}
placeholder="Search options..."
<ComboboxInput
aria-label="Search options"
placeholder="Search options..."
value={search}
onValueChange={setSearch}
/>
}
/>
+54 -84
View File
@@ -1,16 +1,14 @@
import { Loader } from "components/Loader/Loader";
import type { SearchFieldProps } from "components/SearchField/SearchField";
import {
SelectMenu,
SelectMenuButton,
SelectMenuContent,
SelectMenuIcon,
SelectMenuItem,
SelectMenuList,
SelectMenuSearch,
SelectMenuTrigger,
} from "components/SelectMenu/SelectMenu";
import { type FC, type ReactNode, useState } from "react";
Combobox,
ComboboxButton,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
} from "components/Combobox/Combobox";
import { Spinner } from "components/Spinner/Spinner";
import type { FC, ReactNode } from "react";
import { cn } from "utils/cn";
const BASE_WIDTH = 200;
@@ -31,9 +29,9 @@ type SelectFilterProps = {
// Used to customize the empty state message
emptyText?: string;
onSelect: (option: SelectFilterOption | undefined) => void;
width?: number;
// SelectFilterSearch element
selectFilterSearch?: ReactNode;
width?: number;
};
export const SelectFilter: FC<SelectFilterProps> = ({
@@ -42,93 +40,65 @@ export const SelectFilter: FC<SelectFilterProps> = ({
selectedOption,
onSelect,
placeholder,
emptyText,
selectFilterSearch,
emptyText = "No options found",
width = BASE_WIDTH,
selectFilterSearch,
}) => {
const [open, setOpen] = useState(false);
return (
<SelectMenu open={open} onOpenChange={setOpen}>
<SelectMenuTrigger>
<SelectMenuButton
startIcon={selectedOption?.startIcon}
className="shrink-0 grow"
<Combobox
value={selectedOption?.value}
onValueChange={(value) =>
onSelect(options?.find((opt) => opt.value === value))
}
>
<ComboboxTrigger asChild>
<ComboboxButton
selectedOption={selectedOption}
placeholder={placeholder}
className="flex-shrink-0 grow"
style={{ flexBasis: width }}
aria-label={label}
>
{selectedOption?.label ?? placeholder}
</SelectMenuButton>
</SelectMenuTrigger>
<SelectMenuContent
align="end"
/>
</ComboboxTrigger>
<ComboboxContent
className={cn([
// When including selectFilterSearch, we aim for the width to be as
// wide as possible.
selectFilterSearch && "w-full",
"max-w-[320px]",
"max-w-[260px]",
])}
style={{
minWidth: width,
}}
align="end"
>
{selectFilterSearch}
{options ? (
options.length > 0 ? (
<SelectMenuList>
{options.map((o) => {
const isSelected = o.value === selectedOption?.value;
return (
<SelectMenuItem
key={o.value}
selected={isSelected}
onClick={() => {
setOpen(false);
onSelect(isSelected ? undefined : o);
}}
>
{o.startIcon && (
<SelectMenuIcon>{o.startIcon}</SelectMenuIcon>
)}
{o.label}
</SelectMenuItem>
);
})}
</SelectMenuList>
<ComboboxList
className={cn(
!selectFilterSearch && "border-t-0",
"border-surface-quaternary",
)}
>
{options !== undefined ? (
options.map((option) => (
<ComboboxItem
className="px-4 data-[selected=true]:bg-surface-tertiary font-normal gap-4"
key={option.value}
value={option.value}
keywords={[option.label]}
>
{option.startIcon}
<span className="flex-1 truncate">{option.label}</span>
</ComboboxItem>
))
) : (
<div
css={(theme) => ({
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 32,
color: theme.palette.text.secondary,
lineHeight: 1,
})}
>
{emptyText || "No options found"}
<div className="flex items-center justify-center py-4">
<Spinner size="sm" loading />
</div>
)
) : (
<Loader size="sm" />
)}
</SelectMenuContent>
</SelectMenu>
);
};
export const SelectFilterSearch = ({
className,
...props
}: SearchFieldProps) => {
return (
<SelectMenuSearch
className={cn(
className,
"rounded-none border-x-0 border-t-0",
"has-[input:focus-visible]:ring-0",
)}
{...props}
/>
)}
</ComboboxList>
{options !== undefined && <ComboboxEmpty>{emptyText}</ComboboxEmpty>}
</ComboboxContent>
</Combobox>
);
};
+4 -4
View File
@@ -1,9 +1,9 @@
import { API } from "api/api";
import { Avatar } from "components/Avatar/Avatar";
import { ComboboxInput } from "components/Combobox/Combobox";
import {
SelectFilter,
type SelectFilterOption,
SelectFilterSearch,
} from "components/Filter/SelectFilter";
import { useAuthenticated } from "hooks";
import type { FC } from "react";
@@ -97,15 +97,15 @@ export const UserMenu: FC<UserMenuProps> = ({ menu, width, placeholder }) => {
options={menu.searchOptions}
onSelect={menu.selectOption}
selectedOption={menu.selectedOption ?? undefined}
width={width}
selectFilterSearch={
<SelectFilterSearch
<ComboboxInput
placeholder="Search user..."
value={menu.query}
onChange={menu.setQuery}
onValueChange={menu.setQuery}
aria-label="Search user"
/>
}
width={width}
/>
);
};
+3 -3
View File
@@ -9,6 +9,7 @@
*/
import { API } from "api/api";
import { Avatar } from "components/Avatar/Avatar";
import { ComboboxInput } from "components/Combobox/Combobox";
import {
type UseFilterMenuOptions,
useFilterMenu,
@@ -16,7 +17,6 @@ import {
import {
SelectFilter,
type SelectFilterOption,
SelectFilterSearch,
} from "components/Filter/SelectFilter";
import type { FC } from "react";
// Organization helpers ////////////////////////////////////////////////////////
@@ -107,10 +107,10 @@ export const OrganizationsMenu: FC<OrganizationsMenuProps> = ({
onSelect={menu.selectOption}
selectedOption={menu.selectedOption ?? undefined}
selectFilterSearch={
<SelectFilterSearch
<ComboboxInput
placeholder="Search organization..."
value={menu.query}
onChange={menu.setQuery}
onValueChange={menu.setQuery}
aria-label="Search organization"
/>
}
@@ -7,7 +7,14 @@ 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 {
Combobox,
ComboboxButton,
ComboboxContent,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
} from "components/Combobox/Combobox";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { Input } from "components/Input/Input";
import { Label } from "components/Label/Label";
@@ -328,18 +335,41 @@ const ParameterField: FC<ParameterFieldProps> = ({
}
case "dropdown": {
const selectedOption = parameter.options.find(
(opt) => opt.value.value === value,
);
return (
<Combobox
id={id}
value={value ?? ""}
onSelect={(value) => onChange(value)}
options={parameter.options.map((option) => ({
icon: option.icon,
displayName: option.name,
value: option.value.value,
description: option.description,
}))}
/>
value={value}
onValueChange={(newValue) => onChange(newValue ?? "")}
>
<ComboboxTrigger asChild>
<ComboboxButton
selectedOption={
selectedOption
? {
label: selectedOption.name,
value: selectedOption.value.value,
}
: undefined
}
placeholder={parameter.styling?.placeholder || "Select option"}
disabled={disabled}
/>
</ComboboxTrigger>
<ComboboxContent>
<ComboboxList>
{parameter.options.map((option) => (
<ComboboxItem
key={option.value.value}
value={option.value.value}
>
{option.name}
</ComboboxItem>
))}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
}
@@ -16,7 +16,7 @@ import {
waitForLoaderToBeRemoved,
} from "testHelpers/renderHelpers";
import { createMockWebSocket } from "testHelpers/websockets";
import { screen, waitFor } from "@testing-library/react";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { API } from "api/api";
import type { DynamicParametersResponse } from "api/typesGenerated";
@@ -137,9 +137,10 @@ describe("CreateWorkspacePage", () => {
expect(screen.getByText(/instance type/i)).toBeInTheDocument();
const instanceTypeSelect = screen.getByRole("button", {
name: /instance type/i,
});
const instanceTypeField = screen.getByTestId(
"parameter-field-instance_type",
);
const instanceTypeSelect = within(instanceTypeField).getByRole("button");
expect(instanceTypeSelect).toBeInTheDocument();
jest.useFakeTimers();
@@ -2,6 +2,7 @@ import { chromatic } from "testHelpers/chromatic";
import { MockTemplate, MockUserOwner } from "testHelpers/entities";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DetailedError } from "api/errors";
import type { PreviewParameter } from "api/typesGenerated";
import { expect, screen, within } from "storybook/test";
import { CreateWorkspacePageView } from "./CreateWorkspacePageView";
@@ -73,3 +74,273 @@ export const ViewSourceButtonHiddenWithoutPermission: Story = {
).not.toBeInTheDocument();
},
};
const parameterInput: PreviewParameter = {
name: "workspace_name",
display_name: "Workspace Name",
description: "A friendly name for your workspace",
type: "string",
form_type: "input",
value: { valid: true, value: "" },
default_value: { valid: true, value: "my-workspace" },
diagnostics: [],
styling: { placeholder: "Enter workspace name" },
mutable: true,
icon: "",
options: [],
validations: [],
required: true,
order: 0,
ephemeral: false,
};
const parameterDropdown: PreviewParameter = {
name: "instance_type",
display_name: "Instance Type",
description: "The type of cloud instance to provision",
type: "string",
form_type: "dropdown",
value: { valid: true, value: "t3.medium" },
default_value: { valid: true, value: "t3.medium" },
diagnostics: [],
styling: { placeholder: "Select an instance type" },
mutable: true,
icon: "/emojis/1f4bb.png",
options: [
{
name: "t3.micro",
description: "1 vCPU, 1 GB RAM - Free tier eligible",
value: { valid: true, value: "t3.micro" },
icon: "/emojis/1f7e2.png",
},
{
name: "t3.small",
description: "2 vCPU, 2 GB RAM",
value: { valid: true, value: "t3.small" },
icon: "/emojis/1f7e1.png",
},
{
name: "t3.medium",
description: "2 vCPU, 4 GB RAM",
value: { valid: true, value: "t3.medium" },
icon: "/emojis/1f7e0.png",
},
{
name: "t3.large",
description: "2 vCPU, 8 GB RAM",
value: { valid: true, value: "t3.large" },
icon: "/emojis/1f534.png",
},
],
validations: [],
required: true,
order: 1,
ephemeral: false,
};
const parameterSlider: PreviewParameter = {
name: "cpu_count",
display_name: "CPU Count",
description: "Number of CPU cores to allocate",
type: "number",
form_type: "slider",
value: { valid: true, value: "4" },
default_value: { valid: true, value: "2" },
diagnostics: [],
styling: {},
mutable: true,
icon: "",
options: [],
validations: [],
required: true,
order: 2,
ephemeral: false,
};
const parameterSwitch: PreviewParameter = {
name: "enable_gpu",
display_name: "Enable GPU",
description: "Attach a GPU to the workspace for ML workloads",
type: "bool",
form_type: "switch",
value: { valid: true, value: "false" },
default_value: { valid: true, value: "false" },
diagnostics: [],
styling: {},
mutable: true,
icon: "",
options: [],
validations: [],
required: false,
order: 3,
ephemeral: false,
};
const parameterRadio: PreviewParameter = {
name: "region",
display_name: "Region",
description: "The geographic region for your workspace",
type: "string",
form_type: "radio",
value: { valid: true, value: "us-west-2" },
default_value: { valid: true, value: "us-west-2" },
diagnostics: [],
styling: {},
mutable: false,
icon: "",
options: [
{
name: "US West (Oregon)",
description: "us-west-2",
value: { valid: true, value: "us-west-2" },
icon: "/emojis/1f1fa-1f1f8.png",
},
{
name: "US East (N. Virginia)",
description: "us-east-1",
value: { valid: true, value: "us-east-1" },
icon: "/emojis/1f1fa-1f1f8.png",
},
{
name: "EU West (Ireland)",
description: "eu-west-1",
value: { valid: true, value: "eu-west-1" },
icon: "/emojis/1f1ea-1f1fa.png",
},
],
validations: [],
required: true,
order: 4,
ephemeral: false,
};
const parameterMultiSelect: PreviewParameter = {
name: "ides",
display_name: "IDEs",
description: "Select which IDEs to pre-install",
type: "list(string)",
form_type: "multi-select",
value: { valid: true, value: '["vscode", "cursor"]' },
default_value: { valid: true, value: "[]" },
diagnostics: [],
styling: {},
mutable: true,
icon: "",
options: [
{
name: "VS Code",
description: "Visual Studio Code",
value: { valid: true, value: "vscode" },
icon: "/icon/code.svg",
},
{
name: "Cursor",
description: "Cursor IDE",
value: { valid: true, value: "cursor" },
icon: "/icon/cursor.svg",
},
{
name: "JetBrains",
description: "JetBrains IDEs",
value: { valid: true, value: "jetbrains" },
icon: "/icon/jetbrains.svg",
},
{
name: "Neovim",
description: "Neovim editor",
value: { valid: true, value: "neovim" },
icon: "/icon/neovim.svg",
},
],
validations: [],
required: false,
order: 5,
ephemeral: false,
};
const parameterTextarea: PreviewParameter = {
name: "custom_env_vars",
display_name: "Environment Variables",
description:
"Additional environment variables to set in the workspace (one per line, KEY=value format)",
type: "string",
form_type: "textarea",
value: { valid: true, value: "" },
default_value: { valid: true, value: "" },
diagnostics: [],
styling: { placeholder: "NODE_ENV=development\nDEBUG=true" },
mutable: true,
icon: "/emojis/1f4dd.png",
options: [],
validations: [],
required: false,
order: 6,
ephemeral: false,
};
const parameterCheckbox: PreviewParameter = {
name: "auto_stop",
display_name: "Auto-stop",
description: "Automatically stop workspace after inactivity",
type: "bool",
form_type: "checkbox",
value: { valid: true, value: "true" },
default_value: { valid: true, value: "true" },
diagnostics: [],
styling: {},
mutable: true,
icon: "",
options: [],
validations: [],
required: false,
order: 7,
ephemeral: false,
};
export const WithParameters: Story = {
args: {
parameters: [
parameterInput,
parameterDropdown,
parameterSlider,
parameterSwitch,
parameterRadio,
parameterMultiSelect,
parameterTextarea,
parameterCheckbox,
],
},
parameters: {
docs: {
description: {
story:
"This story demonstrates a workspace creation form with presets and a variety of parameter types including text inputs, dropdowns, sliders, switches, radio buttons, multi-select, textarea, and checkboxes.",
},
},
},
};
export const WithPresets: Story = {
args: {
presets: [
{
ID: "preset-1",
Name: "Preset 1",
Description: "Preset 1 description",
Parameters: [{ Name: "workspace_name", Value: "my-workspace" }],
Default: false,
DesiredPrebuildInstances: null,
Icon: "/emojis/1f4bb.png",
},
{
ID: "preset-2",
Name: "Preset 2",
Description: "Preset 2 description",
Parameters: [{ Name: "workspace_name", Value: "my-workspace-2" }],
Default: false,
DesiredPrebuildInstances: null,
Icon: "/emojis/1f4bc.png",
},
],
parameters: [parameterInput, parameterDropdown],
},
};
@@ -5,7 +5,13 @@ import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Avatar } from "components/Avatar/Avatar";
import { Badge } from "components/Badge/Badge";
import { Button } from "components/Button/Button";
import { Combobox } from "components/Combobox/Combobox";
import {
Combobox,
ComboboxButton,
ComboboxContent,
ComboboxItem,
ComboboxTrigger,
} from "components/Combobox/Combobox";
import { Input } from "components/Input/Input";
import { Label } from "components/Label/Label";
import { Link } from "components/Link/Link";
@@ -172,15 +178,15 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
}, [form.submitCount, form.errors]);
const [presetOptions, setPresetOptions] = useState([
{ displayName: "None", value: "undefined", icon: "", description: "" },
{ label: "None", value: "undefined", icon: "", description: "" },
]);
const [selectedPresetIndex, setSelectedPresetIndex] = useState(0);
// Build options and keep default label/value in sync
useEffect(() => {
const options = [
{ displayName: "None", value: "undefined", icon: "", description: "" },
{ label: "None", value: "undefined", icon: "", description: "" },
...presets.map((preset) => ({
displayName: preset.Default ? `${preset.Name} (Default)` : preset.Name,
label: preset.Default ? `${preset.Name} (Default)` : preset.Name,
value: preset.ID,
icon: preset.Icon,
description: preset.Description,
@@ -572,12 +578,8 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
<div className="flex flex-col gap-4">
<div className="max-w-lg">
<Combobox
value={
presetOptions[selectedPresetIndex]?.displayName || ""
}
options={presetOptions}
placeholder="Select a preset"
onSelect={(value) => {
value={presetOptions[selectedPresetIndex]?.value}
onValueChange={(value) => {
const index = presetOptions.findIndex(
(preset) => preset.value === value,
);
@@ -587,14 +589,44 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
setSelectedPresetIndex(index);
form.setFieldValue(
"template_version_preset_id",
// "undefined" string is equivalent to using None option
// Combobox requires a value in order to correctly highlight the None option
// "undefined" string is equivalent to using None option.
// Combobox requires a value in order to correctly
// highlight the None option.
presetOptions[index].value === "undefined"
? undefined
: presetOptions[index].value,
);
}}
/>
>
<ComboboxTrigger asChild>
<ComboboxButton
selectedOption={{
label:
presetOptions[selectedPresetIndex]?.label || "",
value:
presetOptions[selectedPresetIndex]?.value || "",
}}
placeholder="Select a preset"
/>
</ComboboxTrigger>
<ComboboxContent align="start">
{presetOptions.map((preset) => (
<ComboboxItem
key={preset.value}
value={preset.value}
>
{preset.icon && (
<img
src={preset.icon}
alt={preset.label}
className="w-4 h-4"
/>
)}
{preset.label}
</ComboboxItem>
))}
</ComboboxContent>
</Combobox>
</div>
{/* Only show the preset parameter visibility toggle if preset parameters are actually being modified, otherwise it is ineffectual */}
{presetParameterNames.length > 0 && (
@@ -4,7 +4,15 @@ import type {
} from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Button } from "components/Button/Button";
import { Combobox } from "components/Combobox/Combobox";
import {
Combobox,
ComboboxButton,
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
} from "components/Combobox/Combobox";
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
import {
Dialog,
@@ -221,19 +229,48 @@ export const IdpOrgSyncPageView: FC<IdpSyncPageViewProps> = ({
{claimFieldValues ? (
<Combobox
value={idpOrgName}
options={claimFieldValues}
placeholder="Select IdP organization"
open={open}
onOpenChange={setOpen}
inputValue={inputValue}
onInputChange={setInputValue}
onKeyDown={handleKeyDown}
onSelect={(value: string) => {
setIdpOrgName(value);
setOpen(false);
}}
/>
value={idpOrgName}
onValueChange={(value) => setIdpOrgName(value ?? "")}
>
<ComboboxTrigger asChild>
<ComboboxButton
className="w-72"
selectedOption={
idpOrgName
? { label: idpOrgName, value: idpOrgName }
: undefined
}
placeholder="Select IdP organization"
/>
</ComboboxTrigger>
<ComboboxContent className="w-72">
<ComboboxInput
value={inputValue}
onValueChange={setInputValue}
placeholder="Search..."
onKeyDown={handleKeyDown}
/>
<ComboboxList>
{claimFieldValues
.filter((value) =>
value
.toLowerCase()
.includes(inputValue.toLowerCase()),
)
.map((value) => (
<ComboboxItem
key={value}
value={value}
onSelect={() => setInputValue("")}
>
{value}
</ComboboxItem>
))}
</ComboboxList>
</ComboboxContent>
</Combobox>
) : (
<Input
id={`${id}-idp-org-name`}
@@ -4,7 +4,15 @@ import type {
Organization,
} from "api/typesGenerated";
import { Button } from "components/Button/Button";
import { Combobox } from "components/Combobox/Combobox";
import {
Combobox,
ComboboxButton,
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
} from "components/Combobox/Combobox";
import {
HelpTooltip,
HelpTooltipContent,
@@ -224,19 +232,48 @@ export const IdpGroupSyncForm: FC<IdpGroupSyncFormProps> = ({
</Label>
{claimFieldValues ? (
<Combobox
value={idpGroupName}
options={claimFieldValues}
placeholder="Select IdP group"
open={open}
onOpenChange={setOpen}
inputValue={comboInputValue}
onInputChange={setComboInputValue}
onKeyDown={handleKeyDown}
onSelect={(value) => {
setIdpGroupName(value);
setOpen(false);
}}
/>
value={idpGroupName}
onValueChange={(value) => setIdpGroupName(value ?? "")}
>
<ComboboxTrigger asChild>
<ComboboxButton
className="w-72"
selectedOption={
idpGroupName
? { label: idpGroupName, value: idpGroupName }
: undefined
}
placeholder="Select IdP group"
/>
</ComboboxTrigger>
<ComboboxContent className="w-72">
<ComboboxInput
value={comboInputValue}
onValueChange={setComboInputValue}
placeholder="Search..."
onKeyDown={handleKeyDown}
/>
<ComboboxList>
{claimFieldValues
.filter((value) =>
value
.toLowerCase()
.includes(comboInputValue.toLowerCase()),
)
.map((value) => (
<ComboboxItem
key={value}
value={value}
onSelect={() => setComboInputValue("")}
>
{value}
</ComboboxItem>
))}
</ComboboxList>
</ComboboxContent>
</Combobox>
) : (
<Input
id={`${id}-idp-group-name`}
@@ -1,6 +1,14 @@
import type { Organization, Role, RoleSyncSettings } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import { Combobox } from "components/Combobox/Combobox";
import {
Combobox,
ComboboxButton,
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
} from "components/Combobox/Combobox";
import { Input } from "components/Input/Input";
import { Label } from "components/Label/Label";
import {
@@ -166,19 +174,48 @@ export const IdpRoleSyncForm: FC<IdpRoleSyncFormProps> = ({
</Label>
{claimFieldValues ? (
<Combobox
value={idpRoleName}
options={claimFieldValues}
placeholder="Select IdP role"
open={open}
onOpenChange={setOpen}
inputValue={comboInputValue}
onInputChange={setComboInputValue}
onKeyDown={handleKeyDown}
onSelect={(value) => {
setIdpRoleName(value);
setOpen(false);
}}
/>
value={idpRoleName}
onValueChange={(value) => setIdpRoleName(value ?? "")}
>
<ComboboxTrigger asChild>
<ComboboxButton
className="w-72"
selectedOption={
idpRoleName
? { label: idpRoleName, value: idpRoleName }
: undefined
}
placeholder="Select IdP role"
/>
</ComboboxTrigger>
<ComboboxContent className="w-72">
<ComboboxInput
value={comboInputValue}
onValueChange={setComboInputValue}
placeholder="Search..."
onKeyDown={handleKeyDown}
/>
<ComboboxList>
{claimFieldValues
.filter((value) =>
value
.toLowerCase()
.includes(comboInputValue.toLowerCase()),
)
.map((value) => (
<ComboboxItem
key={value}
value={value}
onSelect={() => setComboInputValue("")}
>
{value}
</ComboboxItem>
))}
</ComboboxList>
</ComboboxContent>
</Combobox>
) : (
<Input
id={`${id}-idp-role-name`}
@@ -1,6 +1,7 @@
import { API } from "api/api";
import type { Template, WorkspaceStatus } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { ComboboxInput } from "components/Combobox/Combobox";
import {
type UseFilterMenuOptions,
useFilterMenu,
@@ -8,7 +9,6 @@ import {
import {
SelectFilter,
type SelectFilterOption,
SelectFilterSearch,
} from "components/Filter/SelectFilter";
import {
StatusIndicatorDot,
@@ -84,11 +84,11 @@ export const TemplateMenu: FC<TemplateMenuProps> = ({ width, menu }) => {
onSelect={menu.selectOption}
selectedOption={menu.selectedOption ?? undefined}
selectFilterSearch={
<SelectFilterSearch
<ComboboxInput
aria-label="Search template"
placeholder="Search template..."
value={menu.query}
onChange={menu.setQuery}
aria-label="Search template"
onValueChange={menu.setQuery}
/>
}
/>