feat(site): support icon and description in preset (#19063)

## Description

This PR updates the `CreateWorkspacePageView` to use the `Combobox`
React component instead of `SelectFilter` for the Preset selection.

## Changes

* Updated `CreateWorkspacePageView` to use the `Combobox` component in
place of `SelectFilter`.
* Modified the `Combobox` component to render preset icons using
`ExternalImage` instead of `Avatar`.

<img width="2172" height="1138" alt="Screenshot 2025-07-29 at 12 27 14"
src="https://github.com/user-attachments/assets/2ef8342f-7927-4430-bf87-bc93c47d2980"
/>

<img width="2176" height="1112" alt="Screenshot 2025-07-29 at 12 27 21"
src="https://github.com/user-attachments/assets/863089a6-dcfd-46ed-8b85-68838ee04f28"
/>

Follow-up from: https://github.com/coder/coder/pull/18977

---------

Co-authored-by: Jaayden Halko <jaayden.halko@gmail.com>
This commit is contained in:
Susana Ferreira
2025-07-29 17:45:32 +01:00
committed by GitHub
co-authored by Jaayden Halko
parent 219d1b4101
commit 71738f6db9
7 changed files with 75 additions and 72 deletions
@@ -103,7 +103,8 @@ export const SearchAndFilter: Story = {
screen.queryByRole("option", { name: "Kotlin" }),
).not.toBeInTheDocument();
});
await userEvent.click(screen.getByRole("option", { name: "Rust" }));
// Accessible name includes both image alt text and text content: "Rust Rust"
await userEvent.click(screen.getByRole("option", { name: "Rust Rust" }));
},
};
@@ -137,9 +138,11 @@ export const ClearSelectedOption: Story = {
await userEvent.click(canvas.getByRole("button"));
// const goOption = screen.getByText("Go");
// First select an option
await userEvent.click(await screen.findByRole("option", { name: "Go" }));
// Accessible name includes both image alt text and text content: "Go Go"
await userEvent.click(await screen.findByRole("option", { name: "Go Go" }));
// Then clear it by selecting it again
await userEvent.click(await screen.findByRole("option", { name: "Go" }));
// Accessible name includes both image alt text and text content: "Go Go"
await userEvent.click(await screen.findByRole("option", { name: "Go Go" }));
await waitFor(() =>
expect(canvas.getByRole("button")).toHaveTextContent("Select option"),
+22 -17
View File
@@ -1,4 +1,3 @@
import { Avatar } from "components/Avatar/Avatar";
import { Button } from "components/Button/Button";
import {
Command,
@@ -23,6 +22,7 @@ import { Check, ChevronDown, CornerDownLeft } from "lucide-react";
import { Info } from "lucide-react";
import { type FC, type KeyboardEventHandler, useState } from "react";
import { cn } from "utils/cn";
import { ExternalImage } from "../ExternalImage/ExternalImage";
interface ComboboxProps {
value: string;
@@ -69,19 +69,18 @@ export const Combobox: FC<ComboboxProps> = ({
const isOpen = open ?? managedOpen;
const handleOpenChange = (newOpen: boolean) => {
setManagedOpen(newOpen);
onOpenChange?.(newOpen);
};
return (
<Popover
open={isOpen}
onOpenChange={(newOpen) => {
setManagedOpen(newOpen);
onOpenChange?.(newOpen);
}}
>
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant="outline"
aria-expanded={isOpen}
className="w-72 justify-between group"
className="w-full justify-between group"
>
<span className={cn(!value && "text-content-secondary")}>
{optionsMap.get(value)?.displayName || value || placeholder}
@@ -89,7 +88,7 @@ export const Combobox: FC<ComboboxProps> = ({
<ChevronDown className="size-icon-sm text-content-secondary group-hover:text-content-primary" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-72">
<PopoverContent className="w-[var(--radix-popover-trigger-width)]">
<Command>
<CommandInput
placeholder="Search or enter custom value"
@@ -116,15 +115,21 @@ export const Combobox: FC<ComboboxProps> = ({
keywords={[option.displayName]}
onSelect={(currentValue) => {
onSelect(currentValue === value ? "" : currentValue);
// Close the popover after selection
handleOpenChange(false);
}}
>
{showIcons && (
<Avatar
size="sm"
src={option.icon}
fallback={option.value}
/>
)}
{showIcons &&
(option.icon ? (
<ExternalImage
className="w-4 h-4 object-contain"
src={option.icon}
alt={option.displayName}
/>
) : (
/* 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 && (
@@ -1,5 +1,6 @@
import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/react";
import { expect, screen, waitFor } from "@storybook/test";
import { within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { chromatic } from "testHelpers/chromatic";
@@ -129,8 +130,8 @@ export const PresetsButNoneSelected: Story = {
{
ID: "preset-1",
Name: "Preset 1",
Description: "",
Icon: "",
Description: "Preset 1 description",
Icon: "/emojis/0031-fe0f-20e3.png",
Default: false,
Parameters: [
{
@@ -143,9 +144,8 @@ export const PresetsButNoneSelected: Story = {
{
ID: "preset-2",
Name: "Preset 2",
Description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse imperdiet ultricies massa, eu dapibus ex fermentum ac.",
Icon: "/emojis/1f60e.png",
Description: "Preset 2 description",
Icon: "/emojis/0032-fe0f-20e3.png",
Default: false,
Parameters: [
{
@@ -165,21 +165,12 @@ export const PresetsButNoneSelected: Story = {
};
export const PresetSelected: Story = {
args: PresetsButNoneSelected.args,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByLabelText("Preset"));
await userEvent.click(canvas.getByText("Preset 1"));
},
};
export const PresetSelectedWithHiddenParameters: Story = {
args: PresetsButNoneSelected.args,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Select a preset
await userEvent.click(canvas.getByLabelText("Preset"));
await userEvent.click(canvas.getByText("Preset 1"));
await userEvent.click(canvas.getByRole("button", { name: "None" }));
await userEvent.click(screen.getByText("Preset 1"));
},
};
@@ -188,8 +179,8 @@ export const PresetSelectedWithVisibleParameters: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Select a preset
await userEvent.click(canvas.getByLabelText("Preset"));
await userEvent.click(canvas.getByText("Preset 1"));
await userEvent.click(canvas.getByRole("button", { name: "None" }));
await userEvent.click(screen.getByText("Preset 1"));
// Toggle off the show preset parameters switch
await userEvent.click(canvas.getByLabelText("Show preset parameters"));
},
@@ -201,16 +192,12 @@ export const PresetReselected: Story = {
const canvas = within(canvasElement);
// First selection of Preset 1
await userEvent.click(canvas.getByLabelText("Preset"));
await userEvent.click(
canvas.getByText("Preset 1", { selector: ".MuiMenuItem-root" }),
);
await userEvent.click(canvas.getByRole("button", { name: "None" }));
await userEvent.click(screen.getByText("Preset 1"));
// Reselect the same preset
await userEvent.click(canvas.getByLabelText("Preset"));
await userEvent.click(
canvas.getByText("Preset 1", { selector: ".MuiMenuItem-root" }),
);
await userEvent.click(canvas.getByRole("button", { name: "Preset 1" }));
await userEvent.click(canvas.getByText("Preset 1"));
},
};
@@ -230,12 +217,11 @@ export const PresetNoneSelected: Story = {
const canvas = within(canvasElement);
// First select a preset to set the field value
await userEvent.click(canvas.getByLabelText("Preset"));
await userEvent.click(canvas.getByText("Preset 1"));
await userEvent.click(canvas.getByRole("button", { name: "None" }));
await userEvent.click(screen.getByText("Preset 1"));
// Then select "None" to unset the field value
await userEvent.click(canvas.getByLabelText("Preset"));
await userEvent.click(canvas.getByText("None"));
await userEvent.click(screen.getByText("None"));
// Fill in required fields and submit to test the API call
await userEvent.type(
@@ -260,8 +246,8 @@ export const PresetsWithDefault: Story = {
{
ID: "preset-1",
Name: "Preset 1",
Icon: "",
Description: "",
Description: "Preset 1 description",
Icon: "/emojis/0031-fe0f-20e3.png",
Default: false,
Parameters: [
{
@@ -274,9 +260,8 @@ export const PresetsWithDefault: Story = {
{
ID: "preset-2",
Name: "Preset 2",
Icon: "/emojis/1f60e.png",
Description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse imperdiet ultricies massa, eu dapibus ex fermentum ac.",
Description: "Preset 2 description",
Icon: "/emojis/0032-fe0f-20e3.png",
Default: true,
Parameters: [
{
@@ -295,6 +280,10 @@ export const PresetsWithDefault: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Should have the default preset listed first
await waitFor(() =>
expect(canvas.getByRole("button", { name: "Preset 2 (Default)" })),
);
// Wait for the switch to be available since preset parameters are populated asynchronously
await canvas.findByLabelText("Show preset parameters");
// Toggle off the show preset parameters switch
@@ -6,7 +6,7 @@ import { Alert } from "components/Alert/Alert";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Avatar } from "components/Avatar/Avatar";
import { Button } from "components/Button/Button";
import { SelectFilter } from "components/Filter/SelectFilter";
import { Combobox } from "components/Combobox/Combobox";
import {
FormFields,
FormFooter,
@@ -158,16 +158,18 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
);
const [presetOptions, setPresetOptions] = useState([
{ label: "None", value: "" },
{ displayName: "None", value: "undefined", icon: "", description: "" },
]);
const [selectedPresetIndex, setSelectedPresetIndex] = useState(0);
// Build options and keep default label/value in sync
useEffect(() => {
const options = [
{ label: "None", value: "" },
...presets.map((p) => ({
label: p.Default ? `${p.Name} (Default)` : p.Name,
value: p.ID,
{ displayName: "None", value: "undefined", icon: "", description: "" },
...presets.map((preset) => ({
displayName: preset.Default ? `${preset.Name} (Default)` : preset.Name,
value: preset.ID,
icon: preset.Icon,
description: preset.Description,
})),
];
setPresetOptions(options);
@@ -392,12 +394,15 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
</Stack>
<Stack direction="column" spacing={2}>
<Stack direction="row" spacing={2}>
<SelectFilter
label="Preset"
<Combobox
value={
presetOptions[selectedPresetIndex]?.displayName || ""
}
options={presetOptions}
onSelect={(option) => {
placeholder="Select a preset"
onSelect={(value) => {
const index = presetOptions.findIndex(
(preset) => preset.value === option?.value,
(preset) => preset.value === value,
);
if (index === -1) {
return;
@@ -405,12 +410,13 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
setSelectedPresetIndex(index);
form.setFieldValue(
"template_version_preset_id",
// Empty string is equivalent to using None
option?.value === "" ? undefined : option?.value,
// "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,
);
}}
placeholder="Select a preset"
selectedOption={presetOptions[selectedPresetIndex]}
/>
</Stack>
{/* Only show the preset parameter visibility toggle if preset parameters are actually being modified, otherwise it has no effect. */}
@@ -215,7 +215,7 @@ export const IdpOrgSyncPageView: FC<IdpSyncPageViewProps> = ({
)}
<div className="flex flex-col gap-7">
<div className="flex flex-row pt-8 gap-2 justify-between items-start">
<div className="grid items-center gap-1">
<div className="grid items-center gap-1 w-72">
<Label className="text-sm" htmlFor={`${id}-idp-org-name`}>
IdP organization name
</Label>
@@ -219,7 +219,7 @@ export const IdpGroupSyncForm: FC<IdpGroupSyncFormProps> = ({
</span>
</div>
<div className="flex flex-row gap-2 justify-between items-start">
<div className="grid items-center gap-1">
<div className="grid items-center gap-1 w-72">
<Label className="text-sm" htmlFor={`${id}-idp-group-name`}>
IdP group name
</Label>
@@ -159,7 +159,7 @@ export const IdpRoleSyncForm: FC<IdpRoleSyncFormProps> = ({
<p className="text-content-danger text-sm m-0">{form.errors.field}</p>
)}
<div className="flex flex-row gap-2 justify-between items-start">
<div className="grid items-center gap-1">
<div className="grid items-center gap-1 w-72">
<Label className="text-sm" htmlFor={`${id}-idp-role-name`}>
IdP role name
</Label>