From ef25baf581774926f403c1372b505c7c4638c4ec Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Fri, 13 Feb 2026 15:44:26 +1100 Subject: [PATCH] feat: refactor `` with compound componentisation (#21778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This pull-request refactors the `` component from a monolithic design to a composable compound component pattern, providing more flexibility and reusability across the codebase - Migrates `` to use the new `` instead of the legacy `` components - Updates all existing consumers of `` and `` to use the new API The `` 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: ケイラ --- .../components/Combobox/Combobox.stories.tsx | 200 +++++++++---- site/src/components/Combobox/Combobox.tsx | 271 +++++++++--------- .../Filter/SelectFilter.stories.tsx | 63 ++-- site/src/components/Filter/SelectFilter.tsx | 138 ++++----- site/src/components/Filter/UserFilter.tsx | 8 +- site/src/modules/tableFiltering/options.tsx | 6 +- .../DynamicParameter/DynamicParameter.tsx | 52 +++- .../CreateWorkspacePage.jest.tsx | 9 +- .../CreateWorkspacePageView.stories.tsx | 271 ++++++++++++++++++ .../CreateWorkspacePageView.tsx | 58 +++- .../IdpOrgSyncPage/IdpOrgSyncPageView.tsx | 61 +++- .../IdpSyncPage/IdpGroupSyncForm.tsx | 61 +++- .../IdpSyncPage/IdpRoleSyncForm.tsx | 61 +++- .../src/pages/WorkspacesPage/filter/menus.tsx | 8 +- 14 files changed, 893 insertions(+), 374 deletions(-) diff --git a/site/src/components/Combobox/Combobox.stories.tsx b/site/src/components/Combobox/Combobox.stories.tsx index 49fafd9ab3..e1e12a49eb 100644 --- a/site/src/components/Combobox/Combobox.stories.tsx +++ b/site/src/components/Combobox/Combobox.stories.tsx @@ -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["options"]; + optionsList?: SelectFilterOption[]; }) => { - const [value, setValue] = useState(""); - const [open, setOpen] = useState(false); + const [value, setValue] = useState(undefined); + const selectedOption = optionsList.find((opt) => opt.value === value); + + return ( + + + + + + + + {optionsList.map((option) => ( + + {option.label} + + ))} + + No results found + + + ); +}; + +const ComboboxWithCustomValue = ({ + optionsList = options, +}: { + optionsList?: SelectFilterOption[]; +}) => { + const [value, setValue] = useState(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 ( { - if (e.key === "Enter" && inputValue && !options.includes(inputValue)) { - setValue(inputValue); - setInputValue(""); - setOpen(false); - } - }} - /> + > + + + + + + + {optionsList.map((option) => ( + + {option.label} + + ))} + + + No results found + {inputValue && ( + + Press Enter to use "{inputValue}" + + )} + + + ); }; const meta: Meta = { title: "components/Combobox", component: Combobox, - args: { options: advancedOptions }, }; export default meta; type Story = StoryObj; -export const Default: Story = {}; +export const Default: Story = { + render: () => , +}; -export const SimpleOptions: Story = { - args: { - options: simpleOptions, - }, +export const WithAdvancedOptions: Story = { + render: () => , }; export const OpenCombobox: Story = { + render: () => , 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: () => , +}; + export const EnterCustomValue: Story = { - render: () => , + render: () => , 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: () => , 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"), diff --git a/site/src/components/Combobox/Combobox.tsx b/site/src/components/Combobox/Combobox.tsx index 3db02836f5..1a15212c68 100644 --- a/site/src/components/Combobox/Combobox.tsx +++ b/site/src/components/Combobox/Combobox.tsx @@ -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>; - placeholder?: string; - open?: boolean; - onOpenChange?: (open: boolean) => void; - inputValue?: string; - onInputChange?: (value: string) => void; - onKeyDown?: KeyboardEventHandler; - 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(null); + +function useCombobox() { + const context = useContext(ComboboxContext); + if (!context) { + throw new Error("useCombobox must be used within a "); + } + return context; } -type ComboboxOption = { - icon?: string; - displayName: string; - value: string; - description?: string; -}; +interface ComboboxProps extends React.ComponentProps { + value?: string; + onValueChange?: (value: string | undefined) => void; +} -export const Combobox: FC = ({ +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( - 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 ( - - - - - - - { - setManagedInputValue(newValue); - onInputChange?.(newValue); - }} - onKeyDown={onKeyDown} - /> - - -

No results found

- - Enter custom value - - -
- - {optionObjects.map((option) => ( - { - onSelect(currentValue === value ? "" : currentValue); - // Close the popover after selection - handleOpenChange(false); - }} - > - {showIcons && - (option.icon ? ( - - ) : ( - /* Placeholder for missing icon to maintain layout consistency */ -
- ))} - {option.displayName} -
- {value === option.value && ( - - )} - {option.description && ( - - - e.stopPropagation()} - > - - - - - {option.description} - - - )} -
-
- ))} -
-
-
-
-
+ + + {children} + + ); }; + +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 ( + + ); +}; + +export const ComboboxContent = ({ + children, + className, + ref, + ...props +}: React.ComponentPropsWithRef) => { + return ( + + {children} + + ); +}; + +export const ComboboxInput = CommandInput; +export const ComboboxList = CommandList; + +export const ComboboxItem = ({ + children, + className, + onSelect, + value, + ...props +}: React.ComponentPropsWithRef) => { + const { setOpen, value: selectedValue, onValueChange } = useCombobox(); + const isSelected = value === selectedValue; + + return ( + { + setOpen(false); + // Toggle behavior: selecting the same value deselects it. + const newValue = itemValue === selectedValue ? undefined : itemValue; + onValueChange?.(newValue); + onSelect?.(itemValue); + }} + {...props} + > + {children} + + + ); +}; + +export const ComboboxEmpty = CommandEmpty; diff --git a/site/src/components/Filter/SelectFilter.stories.tsx b/site/src/components/Filter/SelectFilter.stories.tsx index e0c70be3b1..efd41c055a 100644 --- a/site/src/components/Filter/SelectFilter.stories.tsx +++ b/site/src/components/Filter/SelectFilter.stories.tsx @@ -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: , @@ -47,10 +43,16 @@ export default meta; type Story = StoryObj; 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: ( - { + const [selectedOption, setSelectedOption] = useState< + SelectFilterOption | undefined + >(args.selectedOption); + const [search, setSearch] = useState(""); + + return ( + + } /> - ), + ); + }, + 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={ - } /> diff --git a/site/src/components/Filter/SelectFilter.tsx b/site/src/components/Filter/SelectFilter.tsx index 09453cacdd..d93c196203 100644 --- a/site/src/components/Filter/SelectFilter.tsx +++ b/site/src/components/Filter/SelectFilter.tsx @@ -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 = ({ @@ -42,93 +40,65 @@ export const SelectFilter: FC = ({ selectedOption, onSelect, placeholder, - emptyText, - selectFilterSearch, + emptyText = "No options found", width = BASE_WIDTH, + selectFilterSearch, }) => { - const [open, setOpen] = useState(false); - return ( - - - + onSelect(options?.find((opt) => opt.value === value)) + } + > + + - {selectedOption?.label ?? placeholder} - - - + + {selectFilterSearch} - {options ? ( - options.length > 0 ? ( - - {options.map((o) => { - const isSelected = o.value === selectedOption?.value; - return ( - { - setOpen(false); - onSelect(isSelected ? undefined : o); - }} - > - {o.startIcon && ( - {o.startIcon} - )} - {o.label} - - ); - })} - + + {options !== undefined ? ( + options.map((option) => ( + + {option.startIcon} + {option.label} + + )) ) : ( -
({ - display: "flex", - alignItems: "center", - justifyContent: "center", - padding: 32, - color: theme.palette.text.secondary, - lineHeight: 1, - })} - > - {emptyText || "No options found"} +
+
- ) - ) : ( - - )} - - - ); -}; - -export const SelectFilterSearch = ({ - className, - ...props -}: SearchFieldProps) => { - return ( - + )} + + {options !== undefined && {emptyText}} + + ); }; diff --git a/site/src/components/Filter/UserFilter.tsx b/site/src/components/Filter/UserFilter.tsx index 2f08ba0ae0..2b555094c0 100644 --- a/site/src/components/Filter/UserFilter.tsx +++ b/site/src/components/Filter/UserFilter.tsx @@ -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 = ({ menu, width, placeholder }) => { options={menu.searchOptions} onSelect={menu.selectOption} selectedOption={menu.selectedOption ?? undefined} + width={width} selectFilterSearch={ - } - width={width} /> ); }; diff --git a/site/src/modules/tableFiltering/options.tsx b/site/src/modules/tableFiltering/options.tsx index a14c0cb8c0..4bbc228ddf 100644 --- a/site/src/modules/tableFiltering/options.tsx +++ b/site/src/modules/tableFiltering/options.tsx @@ -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 = ({ onSelect={menu.selectOption} selectedOption={menu.selectedOption ?? undefined} selectFilterSearch={ - } diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index 51cc19736b..ed3f3829ce 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -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 = ({ } case "dropdown": { + const selectedOption = parameter.options.find( + (opt) => opt.value.value === value, + ); return ( 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 ?? "")} + > + + + + + + {parameter.options.map((option) => ( + + {option.name} + + ))} + + + ); } diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.jest.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.jest.tsx index dfa85fdaed..bcc4086b67 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.jest.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.jest.tsx @@ -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(); diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx index 92d0522828..63acc69604 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx @@ -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], + }, +}; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index cda5f14959..6474c4c1b4 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -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 = ({ }, [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 = ({
{ + value={presetOptions[selectedPresetIndex]?.value} + onValueChange={(value) => { const index = presetOptions.findIndex( (preset) => preset.value === value, ); @@ -587,14 +589,44 @@ export const CreateWorkspacePageView: FC = ({ 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, ); }} - /> + > + + + + + {presetOptions.map((preset) => ( + + {preset.icon && ( + {preset.label} + )} + {preset.label} + + ))} + +
{/* Only show the preset parameter visibility toggle if preset parameters are actually being modified, otherwise it is ineffectual */} {presetParameterNames.length > 0 && ( diff --git a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.tsx b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.tsx index 4c66b62add..f570ef0a4f 100644 --- a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.tsx @@ -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 = ({ {claimFieldValues ? ( { - setIdpOrgName(value); - setOpen(false); - }} - /> + value={idpOrgName} + onValueChange={(value) => setIdpOrgName(value ?? "")} + > + + + + + + + {claimFieldValues + .filter((value) => + value + .toLowerCase() + .includes(inputValue.toLowerCase()), + ) + .map((value) => ( + setInputValue("")} + > + {value} + + ))} + + + ) : ( = ({ {claimFieldValues ? ( { - setIdpGroupName(value); - setOpen(false); - }} - /> + value={idpGroupName} + onValueChange={(value) => setIdpGroupName(value ?? "")} + > + + + + + + + {claimFieldValues + .filter((value) => + value + .toLowerCase() + .includes(comboInputValue.toLowerCase()), + ) + .map((value) => ( + setComboInputValue("")} + > + {value} + + ))} + + + ) : ( = ({ {claimFieldValues ? ( { - setIdpRoleName(value); - setOpen(false); - }} - /> + value={idpRoleName} + onValueChange={(value) => setIdpRoleName(value ?? "")} + > + + + + + + + {claimFieldValues + .filter((value) => + value + .toLowerCase() + .includes(comboInputValue.toLowerCase()), + ) + .map((value) => ( + setComboInputValue("")} + > + {value} + + ))} + + + ) : ( = ({ width, menu }) => { onSelect={menu.selectOption} selectedOption={menu.selectedOption ?? undefined} selectFilterSearch={ - } />