From 8be18b16bb5a6d4e08c381d58793e6f377c19dd6 Mon Sep 17 00:00:00 2001 From: Jeremy Ruppel Date: Wed, 6 May 2026 15:57:10 +0000 Subject: [PATCH] feat(site): add ModuleConfiguration and TemplateConfiguration components Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ModuleConfiguration.stories.tsx | 230 ++++++++++++++++++ .../TemplateBuilder/ModuleConfiguration.tsx | 78 ++++++ .../TemplateConfiguration.stories.tsx | 105 ++++++++ .../TemplateBuilder/TemplateConfiguration.tsx | 58 +++++ 4 files changed, 471 insertions(+) create mode 100644 site/src/pages/TemplateBuilder/ModuleConfiguration.stories.tsx create mode 100644 site/src/pages/TemplateBuilder/ModuleConfiguration.tsx create mode 100644 site/src/pages/TemplateBuilder/TemplateConfiguration.stories.tsx create mode 100644 site/src/pages/TemplateBuilder/TemplateConfiguration.tsx diff --git a/site/src/pages/TemplateBuilder/ModuleConfiguration.stories.tsx b/site/src/pages/TemplateBuilder/ModuleConfiguration.stories.tsx new file mode 100644 index 0000000000..abbf3fb96f --- /dev/null +++ b/site/src/pages/TemplateBuilder/ModuleConfiguration.stories.tsx @@ -0,0 +1,230 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; +import { Input } from "#/components/Input/Input"; +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 { ModuleConfiguration } from "./ModuleConfiguration"; + +const meta: Meta = { + title: "pages/TemplateBuilder/ModuleConfiguration", + component: ModuleConfiguration, + args: { + onRemove: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +const TextInputField: React.FC<{ + id: string; + label: string; + required?: boolean; + placeholder?: string; +}> = ({ id, label, required, placeholder }) => ( +
+ + +
+); + +const RadioField: React.FC<{ + label: string; + required?: boolean; + options: { value: string; label: string; iconUrl?: string }[]; +}> = ({ label, required, options }) => ( +
+ + + {options.map((opt) => ( +
+
+ +
+ +
+ ))} +
+
+); + +const SelectField: React.FC<{ + id: string; + label: string; + optional?: boolean; + placeholder?: string; + options: { value: string; label: string }[]; +}> = ({ id, label, optional, placeholder, options }) => ( +
+ + +
+); + +const SwitchField: React.FC<{ + id: string; + label: string; + defaultChecked?: boolean; +}> = ({ id, label, defaultChecked }) => ( +
+
+ +
+ +
+); + +const SwitchGroupField: React.FC<{ + label: string; + required?: boolean; + switches: { id: string; label: string; defaultChecked?: boolean }[]; +}> = ({ label, required, switches }) => ( +
+ +
+ {switches.map((s) => ( + + ))} +
+
+); + +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", + }, + render: (args) => ( + +
+ + +
+
+ + +
+
+ ), +}; + +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", + }, + render: (args) => ( + + + + ), +}; + +export const WithoutIcon: Story = { + args: { + name: "Unnamed Module", + description: "A module without an icon.", + detailsUrl: "https://registry.coder.com", + }, + render: (args) => ( + + + + ), +}; diff --git a/site/src/pages/TemplateBuilder/ModuleConfiguration.tsx b/site/src/pages/TemplateBuilder/ModuleConfiguration.tsx new file mode 100644 index 0000000000..8db9f409a0 --- /dev/null +++ b/site/src/pages/TemplateBuilder/ModuleConfiguration.tsx @@ -0,0 +1,78 @@ +import { TrashIcon } from "lucide-react"; +import { Button } from "#/components/Button/Button"; +import { Link } from "#/components/Link/Link"; + +type ModuleConfigurationProps = { + name: string; + description: string; + iconUrl?: string; + detailsUrl?: string; + onRemove?: () => void; + children?: React.ReactNode; +}; + +export const ModuleConfiguration: React.FC = ({ + name, + description, + iconUrl, + detailsUrl, + onRemove, + children, +}) => { + return ( +
+
+
+
+ {iconUrl ? ( + {`${name} + ) : ( +
+ )} +
+
+
+ {name} +
+
+ + {description} + + {detailsUrl && ( + + View details + + )} +
+
+
+ {onRemove && ( + + )} +
+ + {children && ( +
+ {children} +
+ )} +
+ ); +}; diff --git a/site/src/pages/TemplateBuilder/TemplateConfiguration.stories.tsx b/site/src/pages/TemplateBuilder/TemplateConfiguration.stories.tsx new file mode 100644 index 0000000000..2994955885 --- /dev/null +++ b/site/src/pages/TemplateBuilder/TemplateConfiguration.stories.tsx @@ -0,0 +1,105 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Label } from "#/components/Label/Label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "#/components/Select/Select"; +import { TemplateConfiguration } from "./TemplateConfiguration"; + +const meta: Meta = { + title: "pages/TemplateBuilder/TemplateConfiguration", + component: TemplateConfiguration, +}; + +export default meta; +type Story = StoryObj; + +const SelectField: React.FC<{ + id: string; + label: string; + placeholder: string; + options: { value: string; label: string }[]; +}> = ({ id, label, placeholder, options }) => ( +
+ + +
+); + +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", + }, + render: (args) => ( + + + + ), +}; + +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", + }, + render: (args) => ( + + + + ), +}; + +export const WithoutIcon: Story = { + args: { + name: "Unnamed Template", + description: "A template without an icon.", + detailsUrl: "https://registry.coder.com", + }, +}; diff --git a/site/src/pages/TemplateBuilder/TemplateConfiguration.tsx b/site/src/pages/TemplateBuilder/TemplateConfiguration.tsx new file mode 100644 index 0000000000..3e1ffba0e6 --- /dev/null +++ b/site/src/pages/TemplateBuilder/TemplateConfiguration.tsx @@ -0,0 +1,58 @@ +import { Link } from "#/components/Link/Link"; + +type TemplateConfigurationProps = { + name: string; + description: string; + iconUrl?: string; + detailsUrl?: string; + children?: React.ReactNode; +}; + +export const TemplateConfiguration: React.FC = ({ + name, + description, + iconUrl, + detailsUrl, + children, +}) => { + return ( +
+
+
+ {iconUrl ? ( + {`${name} + ) : ( +
+ )} +
+
+
+ {name} +
+
+ + {description} + + {detailsUrl && ( + + View details + + )} +
+
+
+ + {children &&
{children}
} +
+ ); +};