From fff58bdc1329d381523c021733bfb366ce848aa9 Mon Sep 17 00:00:00 2001 From: Jeremy Ruppel Date: Mon, 8 Jun 2026 08:59:49 -0400 Subject: [PATCH] feat(site): create Template Builder components (#25123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR introduces several components used in the Template Builder designs: Base template card Screenshot 2026-05-11 at 10 10 49 AM Base template customization Screenshot 2026-05-11 at 10 11 55 AM Module card Screenshot 2026-05-11 at 10 17 43 AM Module customization Screenshot 2026-05-11 at 10 11 20 AM Selection overview Screenshot 2026-05-11 at 10 11 02 AM > Parts of this PR were written by Claude Code 🤖 and other parts by yours truly 👨‍💻 --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Andrew Aquino --- .../TemplateBuilder/ConfigurationField.tsx | 325 ++++++++++++++++++ .../TemplateBuilder/ModuleCard.stories.tsx | 50 +++ site/src/pages/TemplateBuilder/ModuleCard.tsx | 87 +++++ .../ModuleConfiguration.stories.tsx | 113 ++++++ .../TemplateBuilder/ModuleConfiguration.tsx | 82 +++++ .../SelectionSummary.stories.tsx | 127 +++++++ .../TemplateBuilder/SelectionSummary.tsx | 181 ++++++++++ .../TemplateBuilder/TemplateCard.stories.tsx | 51 +++ .../pages/TemplateBuilder/TemplateCard.tsx | 85 +++++ .../TemplateConfiguration.stories.tsx | 70 ++++ .../TemplateBuilder/TemplateConfiguration.tsx | 64 ++++ 11 files changed, 1235 insertions(+) create mode 100644 site/src/pages/TemplateBuilder/ConfigurationField.tsx create mode 100644 site/src/pages/TemplateBuilder/ModuleCard.stories.tsx create mode 100644 site/src/pages/TemplateBuilder/ModuleCard.tsx 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/SelectionSummary.stories.tsx create mode 100644 site/src/pages/TemplateBuilder/SelectionSummary.tsx create mode 100644 site/src/pages/TemplateBuilder/TemplateCard.stories.tsx create mode 100644 site/src/pages/TemplateBuilder/TemplateCard.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/ConfigurationField.tsx b/site/src/pages/TemplateBuilder/ConfigurationField.tsx new file mode 100644 index 0000000000..efe2ed6ce3 --- /dev/null +++ b/site/src/pages/TemplateBuilder/ConfigurationField.tsx @@ -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 ; + case "select": + return ; + case "radio": + return ; + case "switch": + return ; + case "switch-group": + return ; + } +}; + +const TextField: FC = ({ + id, + field, + label, + description, + required, + placeholder, +}) => ( + +); + +const SelectField: FC = ({ + id, + label, + description, + required, + value, + onChange, + placeholder, + options, +}) => { + const descriptionId = `${id}-description`; + return ( +
+ + {description && ( +
+ {description} +
+ )} + +
+ ); +}; + +const RadioField: FC = ({ + id, + label, + description, + required, + value, + onChange, + options, +}) => { + const labelId = `${id}-label`; + const descriptionId = `${id}-description`; + return ( +
+ + {description && ( +
+ {description} +
+ )} + + {options.map((option) => { + const optionId = `${id}-${option.value}`; + return ( +
+ + +
+ ); + })} +
+
+ ); +}; + +const SwitchRow: FC<{ + id: string; + label: ReactNode; + checked?: boolean; + defaultChecked?: boolean; + onCheckedChange?: (checked: boolean) => void; + describedBy?: string; +}> = ({ id, label, checked, defaultChecked, onCheckedChange, describedBy }) => ( +
+ + +
+); + +const SwitchField: FC = ({ + id, + label, + description, + required, + checked, + defaultChecked, + onCheckedChange, +}) => { + const descriptionId = `${id}-description`; + return ( +
+ + {label} + {required && ( + <> + {" "} + + * + + + )} + + } + checked={checked} + defaultChecked={defaultChecked} + onCheckedChange={onCheckedChange} + describedBy={description ? descriptionId : undefined} + /> + {description && ( +
+ {description} +
+ )} +
+ ); +}; + +const SwitchGroupField: FC = ({ + id, + label, + description, + required, + switches, +}) => { + const labelId = `${id}-label`; + const descriptionId = `${id}-description`; + return ( +
+ + {description && ( +
+ {description} +
+ )} +
+ {switches.map((item) => ( + + ))} +
+
+ ); +}; diff --git a/site/src/pages/TemplateBuilder/ModuleCard.stories.tsx b/site/src/pages/TemplateBuilder/ModuleCard.stories.tsx new file mode 100644 index 0000000000..7012ed25e6 --- /dev/null +++ b/site/src/pages/TemplateBuilder/ModuleCard.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; +import { ModuleCard } from "./ModuleCard"; + +const meta: Meta = { + title: "pages/TemplateBuilder/ModuleCard", + component: ModuleCard, + args: { + onSelect: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/site/src/pages/TemplateBuilder/ModuleCard.tsx b/site/src/pages/TemplateBuilder/ModuleCard.tsx new file mode 100644 index 0000000000..e8af3cd033 --- /dev/null +++ b/site/src/pages/TemplateBuilder/ModuleCard.tsx @@ -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 = ({ + name, + description, + iconUrl, + detailsUrl, + selected = false, + onSelect, +}) => { + const nameId = useId(); + return ( +
onSelect?.()} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect?.(); + } + }} + > +
+
+ {iconUrl ? ( + + ) : ( +
+ )} +
+ +
+ +
+

+ {name} +

+

+ {description} +

+ +
+ + View Details + +
+
+
+ ); +}; diff --git a/site/src/pages/TemplateBuilder/ModuleConfiguration.stories.tsx b/site/src/pages/TemplateBuilder/ModuleConfiguration.stories.tsx new file mode 100644 index 0000000000..a86b50d251 --- /dev/null +++ b/site/src/pages/TemplateBuilder/ModuleConfiguration.stories.tsx @@ -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 = { + title: "pages/TemplateBuilder/ModuleConfiguration", + component: ModuleConfiguration, + args: { + onRemove: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +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 }, + ], + }, +}; diff --git a/site/src/pages/TemplateBuilder/ModuleConfiguration.tsx b/site/src/pages/TemplateBuilder/ModuleConfiguration.tsx new file mode 100644 index 0000000000..70f01a564c --- /dev/null +++ b/site/src/pages/TemplateBuilder/ModuleConfiguration.tsx @@ -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 = ({ + name, + description, + iconUrl, + detailsUrl, + onRemove, + fields, +}) => { + return ( +
+
+
+
+ {iconUrl ? ( + {`${name} + ) : ( +
+ )} +
+
+

+ {name} +

+

+ {description} +

+ {detailsUrl && ( + + View details + + )} +
+
+ {onRemove && ( + + )} +
+ + {fields && fields.length > 0 && ( +
+ {fields.map((field) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/site/src/pages/TemplateBuilder/SelectionSummary.stories.tsx b/site/src/pages/TemplateBuilder/SelectionSummary.stories.tsx new file mode 100644 index 0000000000..c7df9cc2ad --- /dev/null +++ b/site/src/pages/TemplateBuilder/SelectionSummary.stories.tsx @@ -0,0 +1,127 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; +import { SelectionSummary } from "./SelectionSummary"; + +const meta: Meta = { + title: "pages/TemplateBuilder/SelectionSummary", + component: SelectionSummary, + args: { + onDeselectModule: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +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" }, + ], + }, +}; diff --git a/site/src/pages/TemplateBuilder/SelectionSummary.tsx b/site/src/pages/TemplateBuilder/SelectionSummary.tsx new file mode 100644 index 0000000000..20fea1f00b --- /dev/null +++ b/site/src/pages/TemplateBuilder/SelectionSummary.tsx @@ -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 = ({ + currentStep, + selectedTemplate, + selectedModules, + onDeselectModule, +}) => { + const variant = (step: number) => { + if (currentStep === step) return "current"; + if (currentStep > step) return "complete"; + return "upcoming"; + }; + return ( +
+

Selection

+
+ + Base Template + + {selectedTemplate ? ( + + ) : ( + + )} + + Modules + + {selectedModules ? ( + + ) : ( + + )} + + Customizations + +
+
+ ); +}; + +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 & { + step: number; + children: React.ReactNode; +}; + +const StepIndicator: React.FC = ({ + step, + variant, + children, +}) => { + return ( +
+
{step}
+ {children} +
+ ); +}; + +const StepDivider: React.FC<{ + children?: React.ReactNode; + className?: string; +}> = ({ children, className }) => { + return ( +
+ {children} +
+ ); +}; + +type BaseTemplateSelectionProps = { + template: SelectedTemplate; +}; + +const BaseTemplateSelection: React.FC = ({ + template, +}) => { + return ( + +
+ {`${template.name} + {template.name} +
+
+ ); +}; + +type ModuleSelectionProps = { + modules: SelectedModule[]; + onDeselectModule: (moduleId: string) => void; +}; + +const ModuleSelection: React.FC = ({ + modules, + onDeselectModule, +}) => { + return ( + + {modules.map((module) => ( +
+
+ {`${module.name} + {module.name} +
+ +
+ ))} +
+ ); +}; diff --git a/site/src/pages/TemplateBuilder/TemplateCard.stories.tsx b/site/src/pages/TemplateBuilder/TemplateCard.stories.tsx new file mode 100644 index 0000000000..77ecebec4d --- /dev/null +++ b/site/src/pages/TemplateBuilder/TemplateCard.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; +import { TemplateCard } from "./TemplateCard"; + +const meta: Meta = { + title: "pages/TemplateBuilder/TemplateCard", + component: TemplateCard, + args: { + onSelect: fn(), + detailsUrl: "https://registry.coder.com/templates/docker", + }, +}; + +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/site/src/pages/TemplateBuilder/TemplateCard.tsx b/site/src/pages/TemplateBuilder/TemplateCard.tsx new file mode 100644 index 0000000000..e0bcce2e8c --- /dev/null +++ b/site/src/pages/TemplateBuilder/TemplateCard.tsx @@ -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 = ({ + name, + description, + iconUrl, + detailsUrl, + selected = false, + onSelect, +}) => { + const nameId = useId(); + return ( +
onSelect?.()} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect?.(); + } + }} + > +
+
+ {iconUrl ? ( + + ) : ( +
+ )} +
+ + +
+

+ {name} +

+
+

+ {description} +

+ + + View Details + +
+
+
+ ); +}; diff --git a/site/src/pages/TemplateBuilder/TemplateConfiguration.stories.tsx b/site/src/pages/TemplateBuilder/TemplateConfiguration.stories.tsx new file mode 100644 index 0000000000..9902f0fb49 --- /dev/null +++ b/site/src/pages/TemplateBuilder/TemplateConfiguration.stories.tsx @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TemplateConfiguration } from "./TemplateConfiguration"; + +const meta: Meta = { + title: "pages/TemplateBuilder/TemplateConfiguration", + component: TemplateConfiguration, +}; + +export default meta; +type Story = StoryObj; + +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", + }, +}; diff --git a/site/src/pages/TemplateBuilder/TemplateConfiguration.tsx b/site/src/pages/TemplateBuilder/TemplateConfiguration.tsx new file mode 100644 index 0000000000..4409c3a5c1 --- /dev/null +++ b/site/src/pages/TemplateBuilder/TemplateConfiguration.tsx @@ -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 = ({ + name, + description, + iconUrl, + detailsUrl, + fields, +}) => { + return ( +
+
+
+ {iconUrl ? ( + {`${name} + ) : ( +
+ )} +
+
+

{name}

+

+ {description} +

+ {detailsUrl && ( + + View details + + )} +
+
+ + {fields && fields.length > 0 && ( +
+ {fields.map((field) => ( + + ))} +
+ )} +
+ ); +};