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.
This commit is contained in:
Jeremy Ruppel
2026-06-17 11:51:04 -04:00
committed by GitHub
parent d00958d464
commit 286bd836f4
5 changed files with 93 additions and 7 deletions
+6
View File
@@ -2446,6 +2446,12 @@ class ApiMethods {
return response.data;
};
getTemplateBuilderBases =
async (): Promise<TypesGen.TemplateBuilderBasesResponse> => {
const response = await this.axios.get("/api/v2/templatebuilder/bases");
return response.data;
};
uploadFile = async (file: File): Promise<TypesGen.UploadResponse> => {
const response = await this.axios.post("/api/v2/files", file, {
headers: { "Content-Type": file.type },
+6
View File
@@ -0,0 +1,6 @@
import { API } from "#/api/api";
export const templateBuilderBases = () => ({
queryKey: ["templateBuilder", "bases"],
queryFn: API.getTemplateBuilderBases,
});
@@ -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<BaseInfraSelectStepProps> = ({
selectedBaseId,
onSelectBase,
}) => {
const { data, error, isLoading } = useQuery(templateBuilderBases());
if (isLoading) {
return <Loader />;
}
if (error) {
return <ErrorAlert error={error} />;
}
const bases = data?.bases ?? [];
return (
<div role="radiogroup" aria-label="Base infrastructure templates">
<h2 className="text-lg font-semibold mb-1">Pick a base template</h2>
<p className="text-sm text-content-secondary mb-4">
Select your infrastructure foundation.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{bases.map((base) => (
<TemplateCard
key={base.id}
name={base.name}
description={base.description}
iconUrl={base.icon}
detailsUrl={detailsUrl(base.id)}
selected={base.id === selectedBaseId}
onSelect={() => onSelectBase(toSelectedBaseMeta(base))}
/>
))}
</div>
</div>
);
};
@@ -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<TemplateBuilderPageViewProps> = ({
};
return (
<Margins>
<Margins className="pb-12">
<PageHeader>
<PageHeaderTitle>Create new template</PageHeaderTitle>
<PageHeaderSubtitle>
@@ -72,11 +73,18 @@ export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
<div className="flex gap-8">
{/* Main content area */}
<div className="flex-1 min-w-0">
<div className="rounded-lg border border-solid border-border bg-surface-primary p-6 min-h-[400px]">
<p className="text-sm text-content-secondary">
Step: {currentStep.id}
</p>
</div>
{currentStep.id === "base-infra" ? (
<BaseInfraSelectStep
selectedBaseId={state.selectedBase?.id ?? null}
onSelectBase={(base) => dispatch({ type: "SET_BASE", base })}
/>
) : (
<div className="rounded-lg border border-solid border-border bg-surface-primary p-6 min-h-[400px]">
<p className="text-sm text-content-secondary">
Step: {currentStep.id}
</p>
</div>
)}
{/* Navigation controls */}
<div className="flex justify-end mt-6 gap-2">
@@ -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;