mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
co-authored by
Jaayden Halko
parent
219d1b4101
commit
71738f6db9
@@ -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"),
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user