mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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"),
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user