feat(site): create Template Builder components (#25123)

This PR introduces several components used in the Template Builder
designs:

Base template card
<img width="364" height="248" alt="Screenshot 2026-05-11 at 10 10 49 AM"
src="https://github.com/user-attachments/assets/a948acef-46ba-4b91-adb9-acb3c905f0ab"
/>

Base template customization
<img width="858" height="401" alt="Screenshot 2026-05-11 at 10 11 55 AM"
src="https://github.com/user-attachments/assets/bd688291-00b5-4d9d-b98e-a9887cd97067"
/>

Module card
<img width="406" height="226" alt="Screenshot 2026-05-11 at 10 17 43 AM"
src="https://github.com/user-attachments/assets/7fa0b515-921b-47aa-a451-07a8be7356f7"
/>

Module customization
<img width="859" height="374" alt="Screenshot 2026-05-11 at 10 11 20 AM"
src="https://github.com/user-attachments/assets/c9327da4-7539-4456-b2fc-331de40b4779"
/>

Selection overview
<img width="258" height="528" alt="Screenshot 2026-05-11 at 10 11 02 AM"
src="https://github.com/user-attachments/assets/ddab9fe3-2b45-4204-98c2-d39fd454e969"
/>



> Parts of this PR were written by Claude Code 🤖 and other parts by
yours truly 👨‍💻

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Andrew Aquino <dawneraq@gmail.com>
This commit is contained in:
Jeremy Ruppel
2026-06-08 08:59:49 -04:00
committed by GitHub
co-authored by Claude Sonnet 4.6 Andrew Aquino
parent 70c0ffcfb5
commit fff58bdc13
11 changed files with 1235 additions and 0 deletions
@@ -0,0 +1,325 @@
import type { FC, ReactNode } from "react";
import { FormField } from "#/components/FormField/FormField";
import { Label } from "#/components/Label/Label";
import { RadioGroup, RadioGroupItem } from "#/components/RadioGroup/RadioGroup";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "#/components/Select/Select";
import { Switch } from "#/components/Switch/Switch";
import type { FormHelpers } from "#/utils/formUtils";
type FieldOption = {
value: string;
label: string;
iconUrl?: string;
};
type SwitchItem = {
id: string;
label: string;
defaultChecked?: boolean;
};
type BaseField = {
id: string;
label: ReactNode;
description?: ReactNode;
required?: boolean;
};
type TextFieldDefinition = BaseField & {
type: "text";
field: FormHelpers;
placeholder?: string;
};
type SelectFieldDefinition = BaseField & {
type: "select";
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
options: FieldOption[];
};
type RadioFieldDefinition = BaseField & {
type: "radio";
value?: string;
onChange?: (value: string) => void;
options: FieldOption[];
};
type SwitchFieldDefinition = BaseField & {
type: "switch";
checked?: boolean;
defaultChecked?: boolean;
onCheckedChange?: (checked: boolean) => void;
};
type SwitchGroupFieldDefinition = BaseField & {
type: "switch-group";
switches: SwitchItem[];
};
export type ConfigurationFieldDefinition =
| TextFieldDefinition
| SelectFieldDefinition
| RadioFieldDefinition
| SwitchFieldDefinition
| SwitchGroupFieldDefinition;
export const ConfigurationField: FC<{
field: ConfigurationFieldDefinition;
}> = ({ field }) => {
switch (field.type) {
case "text":
return <TextField {...field} />;
case "select":
return <SelectField {...field} />;
case "radio":
return <RadioField {...field} />;
case "switch":
return <SwitchField {...field} />;
case "switch-group":
return <SwitchGroupField {...field} />;
}
};
const TextField: FC<TextFieldDefinition> = ({
id,
field,
label,
description,
required,
placeholder,
}) => (
<FormField
id={id}
field={field}
label={label}
description={description}
required={required}
placeholder={placeholder}
/>
);
const SelectField: FC<SelectFieldDefinition> = ({
id,
label,
description,
required,
value,
onChange,
placeholder,
options,
}) => {
const descriptionId = `${id}-description`;
return (
<div className="flex flex-col gap-2">
<Label htmlFor={id}>
{label}
{required && (
<>
{" "}
<span className="text-xs font-bold text-content-destructive">
*
</span>
</>
)}
</Label>
{description && (
<div id={descriptionId} className="text-xs text-content-secondary">
{description}
</div>
)}
<Select value={value} onValueChange={onChange}>
<SelectTrigger
id={id}
aria-describedby={description ? descriptionId : undefined}
>
<SelectValue placeholder={placeholder ?? "Select..."} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};
const RadioField: FC<RadioFieldDefinition> = ({
id,
label,
description,
required,
value,
onChange,
options,
}) => {
const labelId = `${id}-label`;
const descriptionId = `${id}-description`;
return (
<div className="flex flex-col gap-2">
<Label id={labelId}>
{label}
{required && (
<>
{" "}
<span className="text-xs font-bold text-content-destructive">
*
</span>
</>
)}
</Label>
{description && (
<div id={descriptionId} className="text-xs text-content-secondary">
{description}
</div>
)}
<RadioGroup
value={value}
onValueChange={onChange}
aria-labelledby={labelId}
aria-describedby={description ? descriptionId : undefined}
>
{options.map((option) => {
const optionId = `${id}-${option.value}`;
return (
<div key={option.value} className="flex items-center gap-2">
<RadioGroupItem id={optionId} value={option.value} />
<Label
htmlFor={optionId}
className="flex items-center gap-1 font-normal"
>
{option.iconUrl && (
<img
src={option.iconUrl}
alt=""
className="size-6 object-contain"
/>
)}
{option.label}
</Label>
</div>
);
})}
</RadioGroup>
</div>
);
};
const SwitchRow: FC<{
id: string;
label: ReactNode;
checked?: boolean;
defaultChecked?: boolean;
onCheckedChange?: (checked: boolean) => void;
describedBy?: string;
}> = ({ id, label, checked, defaultChecked, onCheckedChange, describedBy }) => (
<div className="flex items-center gap-2">
<Switch
id={id}
checked={checked}
defaultChecked={defaultChecked}
onCheckedChange={onCheckedChange}
aria-describedby={describedBy}
/>
<Label htmlFor={id} className="font-normal">
{label}
</Label>
</div>
);
const SwitchField: FC<SwitchFieldDefinition> = ({
id,
label,
description,
required,
checked,
defaultChecked,
onCheckedChange,
}) => {
const descriptionId = `${id}-description`;
return (
<div className="flex flex-col gap-1">
<SwitchRow
id={id}
label={
<>
{label}
{required && (
<>
{" "}
<span className="text-xs font-bold text-content-destructive">
*
</span>
</>
)}
</>
}
checked={checked}
defaultChecked={defaultChecked}
onCheckedChange={onCheckedChange}
describedBy={description ? descriptionId : undefined}
/>
{description && (
<div id={descriptionId} className="text-xs text-content-secondary">
{description}
</div>
)}
</div>
);
};
const SwitchGroupField: FC<SwitchGroupFieldDefinition> = ({
id,
label,
description,
required,
switches,
}) => {
const labelId = `${id}-label`;
const descriptionId = `${id}-description`;
return (
<div className="flex flex-col gap-2">
<Label id={labelId}>
{label}
{required && (
<>
{" "}
<span className="text-xs font-bold text-content-destructive">
*
</span>
</>
)}
</Label>
{description && (
<div id={descriptionId} className="text-xs text-content-secondary">
{description}
</div>
)}
<div
className="flex flex-col gap-2"
role="group"
aria-labelledby={labelId}
aria-describedby={description ? descriptionId : undefined}
>
{switches.map((item) => (
<SwitchRow
key={item.id}
id={item.id}
label={item.label}
defaultChecked={item.defaultChecked}
/>
))}
</div>
</div>
);
};
@@ -0,0 +1,50 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { ModuleCard } from "./ModuleCard";
const meta: Meta<typeof ModuleCard> = {
title: "pages/TemplateBuilder/ModuleCard",
component: ModuleCard,
args: {
onSelect: fn(),
},
};
export default meta;
type Story = StoryObj<typeof ModuleCard>;
export const Default: Story = {
args: {
name: "Docker Containers",
description: "Provision Docker containers as Coder workspaces.",
iconUrl: "/icon/docker.svg",
selected: false,
},
};
export const Selected: Story = {
args: {
name: "Docker Containers",
description: "Provision Docker containers as Coder workspaces.",
iconUrl: "/icon/docker.svg",
selected: true,
},
};
export const NoIcon: Story = {
args: {
name: "Custom Template",
description: "A template without an icon.",
selected: false,
},
};
export const LongDescription: Story = {
args: {
name: "Kubernetes Pods",
description:
"Provision Kubernetes pods as Coder workspaces with full cluster access and custom resource limits.",
iconUrl: "/icon/k8s.svg",
selected: false,
},
};
@@ -0,0 +1,87 @@
import { CheckIcon } from "lucide-react";
import { useId } from "react";
import { Link } from "#/components/Link/Link";
import { cn } from "#/utils/cn";
type ModuleCardProps = {
name: string;
description: string;
iconUrl?: string;
detailsUrl?: string;
selected?: boolean;
onSelect?: () => void;
};
export const ModuleCard: React.FC<ModuleCardProps> = ({
name,
description,
iconUrl,
detailsUrl,
selected = false,
onSelect,
}) => {
const nameId = useId();
return (
<div
role="checkbox"
aria-checked={selected}
aria-labelledby={nameId}
tabIndex={0}
className={cn(
"flex flex-col pt-4 px-4 pb-6 rounded",
"bg-surface-secondary border border-solid",
"cursor-pointer",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-primary",
selected ? "border-border-pending" : "border-border",
)}
onClick={() => onSelect?.()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect?.();
}
}}
>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center justify-center p-1 rounded-md size-10 shrink-0 bg-surface-secondary border border-solid border-border">
{iconUrl ? (
<img src={iconUrl} alt="" className="size-7 object-contain" />
) : (
<div className="size-7 rounded bg-surface-primary" />
)}
</div>
<div
aria-hidden="true"
className={cn(
"flex items-center justify-center size-4 rounded-xs mt-0.5 shrink-0",
"border border-solid border-border-secondary",
selected ? "bg-content-primary" : "bg-surface-secondary",
)}
>
{selected && (
<CheckIcon className="size-3 absolute text-content-invert" />
)}
</div>
</div>
<div>
<h3 id={nameId} className="text-md font-semibold text-content-primary">
{name}
</h3>
<p className="text-sm font-normal text-content-secondary">
{description}
</p>
<div>
<Link
href={detailsUrl}
target="_blank"
className="text-sm font-normal"
>
View Details
</Link>
</div>
</div>
</div>
);
};
@@ -0,0 +1,113 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import type { FormHelpers } from "#/utils/formUtils";
import { ModuleConfiguration } from "./ModuleConfiguration";
const meta: Meta<typeof ModuleConfiguration> = {
title: "pages/TemplateBuilder/ModuleConfiguration",
component: ModuleConfiguration,
args: {
onRemove: fn(),
},
};
export default meta;
type Story = StoryObj<typeof ModuleConfiguration>;
const stubField = (name: string, value = ""): FormHelpers => ({
name,
id: name,
value,
onChange: fn(),
onBlur: fn(),
error: false,
});
export const Default: Story = {
args: {
name: "Claude Code",
description: "Run the Claude Code agent in your workspace.",
iconUrl: "/icon/claude.svg",
detailsUrl: "https://registry.coder.com/modules/claude-code",
fields: [
{
type: "text",
id: "anthropic-api-key",
label: "Anthropic API key",
required: true,
placeholder: "Enter API key",
field: stubField("anthropic-api-key"),
},
{
type: "select",
id: "one-more-example",
label: "One more example",
options: [
{ value: "a", label: "Option A" },
{ value: "b", label: "Option B" },
{ value: "c", label: "Option C" },
],
},
{
type: "radio",
id: "other-example",
label: "Other example",
required: true,
options: [
{ value: "opt-1", label: "Radio text", iconUrl: "/icon/aws.svg" },
{ value: "opt-2", label: "Radio text", iconUrl: "/icon/aws.svg" },
{ value: "opt-3", label: "Radio text", iconUrl: "/icon/aws.svg" },
{ value: "opt-4", label: "Radio text", iconUrl: "/icon/aws.svg" },
],
},
{
type: "switch-group",
id: "switch-example",
label: "Other example",
required: true,
switches: [
{ id: "switch-1", label: "Explaining text", defaultChecked: true },
{ id: "switch-2", label: "Explaining text", defaultChecked: false },
],
},
],
},
};
export const NoConfiguration: Story = {
args: {
name: "Git Clone",
description: "Clone a Git repository into your workspace on start.",
iconUrl: "/icon/git.svg",
detailsUrl: "https://registry.coder.com/modules/git-clone",
},
};
export const WithoutDetailsLink: Story = {
args: {
name: "Custom Module",
description: "A module without an external details link.",
iconUrl: "/icon/code.svg",
fields: [
{
type: "text",
id: "custom-input",
label: "Configuration value",
required: true,
placeholder: "Enter value",
field: stubField("custom-input"),
},
],
},
};
export const WithoutIcon: Story = {
args: {
name: "Unnamed Module",
description: "A module without an icon.",
detailsUrl: "https://registry.coder.com",
fields: [
{ type: "switch", id: "enabled", label: "Enabled", defaultChecked: true },
],
},
};
@@ -0,0 +1,82 @@
import { TrashIcon } from "lucide-react";
import { Button } from "#/components/Button/Button";
import { Link } from "#/components/Link/Link";
import {
ConfigurationField,
type ConfigurationFieldDefinition,
} from "./ConfigurationField";
type ModuleConfigurationProps = {
name: string;
description: string;
iconUrl?: string;
detailsUrl?: string;
onRemove?: () => void;
fields?: ConfigurationFieldDefinition[];
};
export const ModuleConfiguration: React.FC<ModuleConfigurationProps> = ({
name,
description,
iconUrl,
detailsUrl,
onRemove,
fields,
}) => {
return (
<section className="pt-4 px-4 pb-6 rounded bg-surface-secondary">
<header className="flex items-start gap-6 mb-6">
<div className="flex flex-1 items-center gap-3 min-w-0">
<figure className="flex items-center justify-center p-1 rounded-md size-10 shrink-0 bg-surface-secondary border border-solid border-border">
{iconUrl ? (
<img
src={iconUrl}
alt={`${name} icon`}
className="size-7 object-contain"
/>
) : (
<div className="size-7 rounded bg-surface-primary" />
)}
</figure>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-content-primary">
{name}
</h3>
<p className="text-xs font-normal text-content-secondary inline">
{description}
</p>
{detailsUrl && (
<Link
href={detailsUrl}
target="_blank"
rel="noreferrer"
size="sm"
className="text-xs font-normal ml-1"
>
View details
</Link>
)}
</div>
</div>
{onRemove && (
<Button
variant="outline"
size="icon"
onClick={onRemove}
aria-label={`Remove ${name}`}
>
<TrashIcon />
</Button>
)}
</header>
{fields && fields.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 items-start">
{fields.map((field) => (
<ConfigurationField key={field.id} field={field} />
))}
</div>
)}
</section>
);
};
@@ -0,0 +1,127 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { SelectionSummary } from "./SelectionSummary";
const meta: Meta<typeof SelectionSummary> = {
title: "pages/TemplateBuilder/SelectionSummary",
component: SelectionSummary,
args: {
onDeselectModule: fn(),
},
};
export default meta;
type Story = StoryObj<typeof SelectionSummary>;
export const NoSelection: Story = {
args: {
currentStep: 0,
selectedTemplate: undefined,
selectedModules: undefined,
},
};
export const BaseTemplateStep: Story = {
args: {
currentStep: 1,
selectedTemplate: undefined,
selectedModules: undefined,
},
};
export const WithBaseTemplate: Story = {
args: {
currentStep: 1,
selectedTemplate: {
name: "Docker Containers",
iconUrl: "/icon/docker.svg",
},
},
};
export const ModulesStep: Story = {
args: {
currentStep: 2,
selectedTemplate: {
name: "Docker Containers",
iconUrl: "/icon/docker.svg",
},
selectedModules: undefined,
},
};
export const WithModules: Story = {
args: {
currentStep: 2,
selectedTemplate: {
name: "Docker Containers",
iconUrl: "/icon/docker.svg",
},
selectedModules: [
{
id: "jetbrains",
name: "JetBrains",
iconUrl: "/icon/jetbrains.svg",
},
{
id: "jetbrains-toolbox",
name: "JetBrains Toolbox",
iconUrl: "/icon/jetbrains-toolbox.svg",
},
{
id: "cursor",
name: "Cursor IDE",
iconUrl: "/icon/cursor.svg",
},
{
id: "claude-code",
name: "Claude Code",
iconUrl: "/icon/claude.svg",
},
{
id: "filebrowser",
name: "File browser",
iconUrl: "/icon/filebrowser.svg",
},
{
id: "git-clone",
name: "Git clone",
iconUrl: "/icon/git.svg",
},
{
id: "devcontainers",
name: "Devcontainers",
iconUrl: "/icon/devcontainers.svg",
},
],
},
};
export const ManyModules: Story = {
args: {
currentStep: 2,
selectedTemplate: {
name: "Docker Containers",
iconUrl: "/icon/docker.svg",
},
selectedModules: Array.from({ length: 12 }, (_, i) => ({
id: `module-${i}`,
name: `Module ${i + 1}`,
iconUrl: "/icon/docker.svg",
})),
},
};
export const Customizations: Story = {
args: {
currentStep: 3,
selectedTemplate: {
name: "Docker Containers",
iconUrl: "/icon/docker.svg",
},
selectedModules: [
{ id: "claude-code", name: "Claude Code", iconUrl: "/icon/claude.svg" },
{ id: "cursor", name: "Cursor IDE", iconUrl: "/icon/cursor.svg" },
],
},
};
@@ -0,0 +1,181 @@
import { cva, type VariantProps } from "class-variance-authority";
import { XIcon } from "lucide-react";
import { Button } from "#/components/Button/Button";
import { cn } from "#/utils/cn";
type SelectedTemplate = {
name: string;
iconUrl?: string;
};
type SelectedModule = {
id: string;
name: string;
iconUrl: string;
};
type SelectionSummaryProps = {
currentStep: number;
selectedTemplate?: SelectedTemplate;
selectedModules?: SelectedModule[];
onDeselectModule: (moduleId: string) => void;
};
export const SelectionSummary: React.FC<SelectionSummaryProps> = ({
currentStep,
selectedTemplate,
selectedModules,
onDeselectModule,
}) => {
const variant = (step: number) => {
if (currentStep === step) return "current";
if (currentStep > step) return "complete";
return "upcoming";
};
return (
<div>
<h2 className="font-semibold">Selection</h2>
<div>
<StepIndicator step={1} variant={variant(1)}>
Base Template
</StepIndicator>
{selectedTemplate ? (
<BaseTemplateSelection template={selectedTemplate} />
) : (
<StepDivider />
)}
<StepIndicator step={2} variant={variant(2)}>
Modules
</StepIndicator>
{selectedModules ? (
<ModuleSelection
modules={selectedModules}
onDeselectModule={onDeselectModule}
/>
) : (
<StepDivider />
)}
<StepIndicator step={3} variant={variant(3)}>
Customizations
</StepIndicator>
</div>
</div>
);
};
const stepCircleVariants = cva(
"rounded-full w-8 h-8 border border-solid flex items-center justify-center",
{
variants: {
variant: {
complete: "border-border-success",
current: "border-content-primary",
upcoming: "border-border text-content-secondary",
},
},
},
);
const stepLabelVariants = cva("font-normal mr-2", {
variants: {
variant: {
complete: "text-content-primary",
current: "text-content-primary",
upcoming: "text-content-secondary",
},
},
});
type StepIndicatorProps = VariantProps<typeof stepCircleVariants> & {
step: number;
children: React.ReactNode;
};
const StepIndicator: React.FC<StepIndicatorProps> = ({
step,
variant,
children,
}) => {
return (
<div className="flex items-center gap-2">
<div className={stepCircleVariants({ variant })}>{step}</div>
<span className={stepLabelVariants({ variant })}>{children}</span>
</div>
);
};
const StepDivider: React.FC<{
children?: React.ReactNode;
className?: string;
}> = ({ children, className }) => {
return (
<div
className={cn(
"border-0 border-l border-border border-solid mx-4 -translate-x-px",
children ? "px-3 py-2" : "h-4",
className,
)}
>
{children}
</div>
);
};
type BaseTemplateSelectionProps = {
template: SelectedTemplate;
};
const BaseTemplateSelection: React.FC<BaseTemplateSelectionProps> = ({
template,
}) => {
return (
<StepDivider>
<div className="flex items-center p-1">
<img
src={template.iconUrl}
alt={`${template.name} icon`}
className="w-6 h-6 p-1 rounded-sm border border-border border-solid bg-surface-secondary"
/>
<span className="ml-2">{template.name}</span>
</div>
</StepDivider>
);
};
type ModuleSelectionProps = {
modules: SelectedModule[];
onDeselectModule: (moduleId: string) => void;
};
const ModuleSelection: React.FC<ModuleSelectionProps> = ({
modules,
onDeselectModule,
}) => {
return (
<StepDivider className="max-h-72 overflow-y-auto">
{modules.map((module) => (
<div
key={module.id}
className="group flex items-center justify-between p-1 mb-1 hover:bg-surface-secondary"
>
<div className="flex items-center">
<img
src={module.iconUrl}
alt={`${module.name} icon`}
className="w-6 h-6 p-1 rounded-sm border border-border border-solid bg-surface-secondary"
/>
<span className="ml-2">{module.name}</span>
</div>
<Button
size="xs"
variant="subtle"
className="opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
onClick={() => onDeselectModule(module.id)}
>
<XIcon className="w-4 h-4" />
</Button>
</div>
))}
</StepDivider>
);
};
@@ -0,0 +1,51 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { TemplateCard } from "./TemplateCard";
const meta: Meta<typeof TemplateCard> = {
title: "pages/TemplateBuilder/TemplateCard",
component: TemplateCard,
args: {
onSelect: fn(),
detailsUrl: "https://registry.coder.com/templates/docker",
},
};
export default meta;
type Story = StoryObj<typeof TemplateCard>;
export const Default: Story = {
args: {
name: "Docker Containers",
description: "Provision Docker containers as Coder workspaces.",
iconUrl: "/icon/docker.svg",
selected: false,
},
};
export const Selected: Story = {
args: {
name: "Docker Containers",
description: "Provision Docker containers as Coder workspaces.",
iconUrl: "/icon/docker.svg",
selected: true,
},
};
export const NoIcon: Story = {
args: {
name: "Custom Template",
description: "A template without an icon.",
selected: false,
},
};
export const LongDescription: Story = {
args: {
name: "Kubernetes Pods",
description:
"Provision Kubernetes pods as Coder workspaces with full cluster access and custom resource limits.",
iconUrl: "/icon/k8s.svg",
selected: false,
},
};
@@ -0,0 +1,85 @@
import { useId } from "react";
import { Link } from "#/components/Link/Link";
import { cn } from "#/utils/cn";
type TemplateCardProps = {
name: string;
description: string;
iconUrl?: string;
detailsUrl: string;
selected?: boolean;
onSelect?: () => void;
};
export const TemplateCard: React.FC<TemplateCardProps> = ({
name,
description,
iconUrl,
detailsUrl,
selected = false,
onSelect,
}) => {
const nameId = useId();
return (
<div
role="radio"
aria-checked={selected}
aria-labelledby={nameId}
tabIndex={0}
className={cn(
"flex flex-col pt-4 px-4 pb-6 rounded",
"bg-surface-secondary border border-solid",
"cursor-pointer",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-primary",
selected ? "border-border-pending" : "border-border",
)}
onClick={() => onSelect?.()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect?.();
}
}}
>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center justify-center p-1 rounded-md size-10 shrink-0 bg-surface-secondary border border-solid border-border">
{iconUrl ? (
<img src={iconUrl} alt="" className="size-7 object-contain" />
) : (
<div className="size-7 rounded bg-surface-primary" />
)}
</div>
<div
aria-hidden="true"
className={cn(
"flex items-center justify-center size-4 rounded-full border border-solid mt-0.5 shrink-0",
selected ? "border-content-primary" : "border-content-secondary",
)}
>
{selected && (
<div className="size-2 rounded-full bg-content-primary" />
)}
</div>
</div>
<div>
<h3 id={nameId} className="text-md font-semibold text-content-primary">
{name}
</h3>
<div>
<p className="text-sm font-normal text-content-secondary">
{description}
</p>
<Link
href={detailsUrl}
target="_blank"
className="text-sm font-normal"
>
View Details
</Link>
</div>
</div>
</div>
);
};
@@ -0,0 +1,70 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { TemplateConfiguration } from "./TemplateConfiguration";
const meta: Meta<typeof TemplateConfiguration> = {
title: "pages/TemplateBuilder/TemplateConfiguration",
component: TemplateConfiguration,
};
export default meta;
type Story = StoryObj<typeof TemplateConfiguration>;
export const Default: Story = {
args: {
name: "Docker Containers",
description: "Provision Docker containers as Coder workspaces.",
iconUrl: "/icon/docker.svg",
detailsUrl: "https://registry.coder.com/templates/docker",
fields: [
{
type: "select",
id: "docker-image",
label: "Select image",
placeholder: "Ubuntu (default)",
options: [
{ value: "ubuntu", label: "Ubuntu" },
{ value: "debian", label: "Debian" },
{ value: "alpine", label: "Alpine" },
{ value: "fedora", label: "Fedora" },
],
},
],
},
};
export const NoConfiguration: Story = {
args: {
name: "Kubernetes Pods",
description: "Provision Kubernetes pods as Coder workspaces.",
iconUrl: "/icon/k8s.svg",
detailsUrl: "https://registry.coder.com/templates/kubernetes",
},
};
export const WithoutDetailsLink: Story = {
args: {
name: "Custom Template",
description: "A template without an external details link.",
iconUrl: "/icon/code.svg",
fields: [
{
type: "select",
id: "custom-config",
label: "Configuration",
placeholder: "Select option",
options: [
{ value: "a", label: "Option A" },
{ value: "b", label: "Option B" },
],
},
],
},
};
export const WithoutIcon: Story = {
args: {
name: "Unnamed Template",
description: "A template without an icon.",
detailsUrl: "https://registry.coder.com",
},
};
@@ -0,0 +1,64 @@
import { Link } from "#/components/Link/Link";
import {
ConfigurationField,
type ConfigurationFieldDefinition,
} from "./ConfigurationField";
type TemplateConfigurationProps = {
name: string;
description: string;
iconUrl?: string;
detailsUrl?: string;
fields?: ConfigurationFieldDefinition[];
};
export const TemplateConfiguration: React.FC<TemplateConfigurationProps> = ({
name,
description,
iconUrl,
detailsUrl,
fields,
}) => {
return (
<section className="pt-4 px-4 pb-6 rounded bg-surface-secondary">
<header className="mb-6">
<figure className="flex items-center justify-center p-1 rounded-md size-10 shrink-0 bg-surface-secondary border border-solid border-border mb-3">
{iconUrl ? (
<img
src={iconUrl}
alt={`${name} icon`}
className="size-7 object-contain"
/>
) : (
<div className="size-7 rounded bg-surface-primary" />
)}
</figure>
<div>
<h3 className="text-sm font-semibold text-content-primary">{name}</h3>
<p className="text-xs font-normal text-content-secondary inline">
{description}
</p>
{detailsUrl && (
<Link
href={detailsUrl}
target="_blank"
rel="noreferrer"
size="sm"
className="text-xs font-normal ml-1"
>
View details
</Link>
)}
</div>
</header>
{fields && fields.length > 0 && (
<div className="space-y-6">
{fields.map((field) => (
<ConfigurationField key={field.id} field={field} />
))}
</div>
)}
</section>
);
};