mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
co-authored by
Claude Sonnet 4.6
Andrew Aquino
parent
70c0ffcfb5
commit
fff58bdc13
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user