From 286bd836f4dbfe5178d9eb80dae29234c24c6102 Mon Sep 17 00:00:00 2001 From: Jeremy Ruppel Date: Wed, 17 Jun 2026 11:51:04 -0400 Subject: [PATCH] feat(site): add BaseInfraSelectStep (#26424) Implement the first wizard step for selecting a base infrastructure template. - Add `getTemplateBuilderBases` API client method and react-query wrapper - Render a responsive grid of `TemplateCard` components fetched from `GET /api/v2/templatebuilder/bases` - Map `TemplateBuilderBase` to `SelectedBaseMeta` with `hasParameters` derived from the base variables - Single-choice selection that dispatches `SET_BASE` to wizard state, preserving selection on back navigation Relates to [DEVEX-283](https://linear.app/codercom/issue/DEVEX-283). > [!NOTE] > This PR was authored with Coder Agents. --- site/src/api/api.ts | 6 ++ site/src/api/queries/templateBuilder.ts | 6 ++ .../TemplateBuilder/BaseInfraSelectStep.tsx | 66 +++++++++++++++++++ .../TemplateBuilderPageView.tsx | 20 ++++-- site/src/pages/TemplateBuilder/wizardState.ts | 2 +- 5 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 site/src/api/queries/templateBuilder.ts create mode 100644 site/src/pages/TemplateBuilder/BaseInfraSelectStep.tsx diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 6bc523d342..34eafbf592 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2446,6 +2446,12 @@ class ApiMethods { return response.data; }; + getTemplateBuilderBases = + async (): Promise => { + const response = await this.axios.get("/api/v2/templatebuilder/bases"); + return response.data; + }; + uploadFile = async (file: File): Promise => { const response = await this.axios.post("/api/v2/files", file, { headers: { "Content-Type": file.type }, diff --git a/site/src/api/queries/templateBuilder.ts b/site/src/api/queries/templateBuilder.ts new file mode 100644 index 0000000000..8b0639398a --- /dev/null +++ b/site/src/api/queries/templateBuilder.ts @@ -0,0 +1,6 @@ +import { API } from "#/api/api"; + +export const templateBuilderBases = () => ({ + queryKey: ["templateBuilder", "bases"], + queryFn: API.getTemplateBuilderBases, +}); diff --git a/site/src/pages/TemplateBuilder/BaseInfraSelectStep.tsx b/site/src/pages/TemplateBuilder/BaseInfraSelectStep.tsx new file mode 100644 index 0000000000..cf3e3d416b --- /dev/null +++ b/site/src/pages/TemplateBuilder/BaseInfraSelectStep.tsx @@ -0,0 +1,66 @@ +import type { FC } from "react"; +import { useQuery } from "react-query"; +import { templateBuilderBases } from "#/api/queries/templateBuilder"; +import type { TemplateBuilderBase } from "#/api/typesGenerated"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { Loader } from "#/components/Loader/Loader"; +import { TemplateCard } from "./TemplateCard"; +import type { SelectedBaseMeta } from "./wizardState"; + +interface BaseInfraSelectStepProps { + selectedBaseId: string | null; + onSelectBase: (base: SelectedBaseMeta) => void; +} + +function toSelectedBaseMeta(base: TemplateBuilderBase): SelectedBaseMeta { + return { + id: base.id, + name: base.name, + iconUrl: base.icon, + os: base.os, + hasParameters: false, + }; +} + +function detailsUrl(baseId: string): string { + return `https://registry.coder.com/templates/${baseId}`; +} + +export const BaseInfraSelectStep: FC = ({ + selectedBaseId, + onSelectBase, +}) => { + const { data, error, isLoading } = useQuery(templateBuilderBases()); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + const bases = data?.bases ?? []; + + return ( +
+

Pick a base template

+

+ Select your infrastructure foundation. +

+
+ {bases.map((base) => ( + onSelectBase(toSelectedBaseMeta(base))} + /> + ))} +
+
+ ); +}; diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx index df5eb67754..6500859f8a 100644 --- a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx +++ b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx @@ -9,6 +9,7 @@ import { PageHeaderTitle, } from "#/components/PageHeader/PageHeader"; import { docs } from "#/utils/docs"; +import { BaseInfraSelectStep } from "./BaseInfraSelectStep"; import { SelectionSummary } from "./SelectionSummary"; import { findNextVisibleIndex, @@ -56,7 +57,7 @@ export const TemplateBuilderPageView: FC = ({ }; return ( - + Create new template @@ -72,11 +73,18 @@ export const TemplateBuilderPageView: FC = ({
{/* Main content area */}
-
-

- Step: {currentStep.id} -

-
+ {currentStep.id === "base-infra" ? ( + dispatch({ type: "SET_BASE", base })} + /> + ) : ( +
+

+ Step: {currentStep.id} +

+
+ )} {/* Navigation controls */}
diff --git a/site/src/pages/TemplateBuilder/wizardState.ts b/site/src/pages/TemplateBuilder/wizardState.ts index 89eccc1280..83f7efec83 100644 --- a/site/src/pages/TemplateBuilder/wizardState.ts +++ b/site/src/pages/TemplateBuilder/wizardState.ts @@ -8,7 +8,7 @@ import type { * UI-only metadata for the selected base template. * Kept separate from the API request payload. */ -type SelectedBaseMeta = { +export type SelectedBaseMeta = { id: string; name: string; iconUrl?: string;