feat(site): add ModuleSelectStep (#26427)

Implement the module selection wizard step with multi-select toggle and
conflict warnings.

- Add `getTemplateBuilderModules` API client method with optional `base`
parameter for OS filtering
- Add react-query wrapper with `staleTime: Infinity` to prevent
re-fetches on step navigation
- Render a flat grid of `ModuleCard` components with checkbox-style
multi-select
- Show non-blocking conflict warnings when selected modules declare
`conflicts_with` each other
- Map selections to `TemplateBuilderComposeModule[]` and
`SelectedModuleMeta[]` for wizard state

Relates to [DEVEX-285](https://linear.app/codercom/issue/DEVEX-285).

> [!NOTE]
> This PR was authored with Coder Agents.

---------

Co-authored-by: Andrew Aquino <dawneraq@gmail.com>
This commit is contained in:
Jeremy Ruppel
2026-06-18 09:42:03 -04:00
committed by GitHub
co-authored by Andrew Aquino
parent a5a4c49a6f
commit dca8178f8d
5 changed files with 197 additions and 1 deletions
+10
View File
@@ -2452,6 +2452,16 @@ class ApiMethods {
return response.data;
};
getTemplateBuilderModules = async (
base?: string,
): Promise<TypesGen.TemplateBuilderModulesResponse> => {
const params = base ? `?base=${encodeURIComponent(base)}` : "";
const response = await this.axios.get(
`/api/v2/templatebuilder/modules${params}`,
);
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
@@ -4,3 +4,9 @@ export const templateBuilderBases = () => ({
queryKey: ["templateBuilder", "bases"],
queryFn: API.getTemplateBuilderBases,
});
export const templateBuilderModules = (base?: string) => ({
queryKey: ["templateBuilder", "modules", base ?? ""],
queryFn: () => API.getTemplateBuilderModules(base),
staleTime: Number.POSITIVE_INFINITY,
});
@@ -0,0 +1,171 @@
import { type FC, type PropsWithChildren, useMemo } from "react";
import { useQuery } from "react-query";
import { templateBuilderModules } from "#/api/queries/templateBuilder";
import type {
TemplateBuilderComposeModule,
TemplateBuilderModule,
} from "#/api/typesGenerated";
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Loader } from "#/components/Loader/Loader";
import { ModuleCard } from "./ModuleCard";
import {
moduleHasConfigurableVars,
type SelectedModuleMeta,
} from "./wizardState";
interface ModuleSelectStepProps {
baseId: string;
selectedModuleIds: string[];
onChangeModules: (
modules: TemplateBuilderComposeModule[],
meta: SelectedModuleMeta[],
) => void;
}
function toMeta(m: TemplateBuilderModule): SelectedModuleMeta {
return {
id: m.id,
name: m.display_name,
iconUrl: m.icon,
hasConfigurableVars: moduleHasConfigurableVars(m),
};
}
// TODO add this to the API response so we don't have to construct it manually here.
function moduleDetailsUrl(moduleId: string): string {
return `https://registry.coder.com/modules/${moduleId}`;
}
interface ModuleConflict {
moduleA: TemplateBuilderModule;
moduleB: TemplateBuilderModule;
}
const ModuleName: FC<PropsWithChildren> = ({ children }) => {
return (
<code className="text-content-secondary bg-surface-tertiary mx-1 first:ml-0 px-1.5 py-1 rounded-sm">
{children}
</code>
);
};
const ConflictWarning: FC<ModuleConflict> = ({ moduleA, moduleB }) => {
return (
<div>
<ModuleName>{moduleA.display_name}</ModuleName> and{" "}
<ModuleName>{moduleB.display_name}</ModuleName> are conflicting modules.
You can still continue, but you need to remove one of the conflicting
modules before publishing the template.
</div>
);
};
export const ModuleSelectStep: FC<ModuleSelectStepProps> = ({
baseId,
selectedModuleIds,
onChangeModules,
}) => {
const { data, error, isLoading } = useQuery(templateBuilderModules(baseId));
const selectedSet = useMemo(
() => new Set(selectedModuleIds),
[selectedModuleIds],
);
const modules = data?.modules ?? [];
const conflicts = useMemo<ModuleConflict[]>(() => {
const warnings: string[] = [];
// Loop through the selected modules and check for conflicts. We sort the
// pair of conflicting module IDs alphabetically and join them with a "+"
// to create a unique identifier for the pair.
for (const id of selectedSet) {
const m = modules.find((mod) => mod.id === id);
if (!m) continue;
for (const conflictId of m.conflicts_with) {
if (selectedSet.has(conflictId)) {
const pair = [id, conflictId].sort().join("+");
if (!warnings.includes(pair)) {
warnings.push(pair);
}
}
}
}
// take the computed warnings and return the actual modules that have
// conflicts so we can display their names in the UI.
return warnings.map((pair) => {
const [a, b] = pair.split("+");
const moduleA = modules.find((m) => m.id === a)!;
const moduleB = modules.find((m) => m.id === b)!;
return { moduleA, moduleB };
});
}, [selectedSet, modules]);
if (isLoading) {
return <Loader />;
}
if (error) {
return <ErrorAlert error={error} />;
}
const handleToggle = (target: TemplateBuilderModule) => {
const isSelected = selectedSet.has(target.id);
let nextIds: string[];
if (isSelected) {
nextIds = selectedModuleIds.filter((id) => id !== target.id);
} else {
nextIds = [...selectedModuleIds, target.id];
}
const modulesById = new Map(modules.map((m) => [m.id, m]));
const nextModules: TemplateBuilderComposeModule[] = nextIds.map((id) => ({
id,
}));
const nextMeta: SelectedModuleMeta[] = nextIds
.map((id) => modulesById.get(id))
.filter((m): m is TemplateBuilderModule => m != null)
.map(toMeta);
onChangeModules(nextModules, nextMeta);
};
return (
<div>
<h2 className="text-lg font-semibold mb-1">Select modules</h2>
<p className="text-sm text-content-secondary mb-4">
Add functionality to your template.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{modules.map((m) => (
<ModuleCard
key={m.id}
name={m.display_name}
description={m.description}
iconUrl={m.icon}
detailsUrl={moduleDetailsUrl(m.id)}
selected={selectedSet.has(m.id)}
onSelect={() => handleToggle(m)}
/>
))}
</div>
{conflicts.length > 0 && (
<Alert severity="warning" prominent className="mt-6">
<AlertTitle>Conflicting modules selected</AlertTitle>
<AlertDescription>
{conflicts.map(({ moduleA, moduleB }) => (
<ConflictWarning
key={`${moduleA.id}+${moduleB.id}`}
moduleA={moduleA}
moduleB={moduleB}
/>
))}
</AlertDescription>
</Alert>
)}
</div>
);
};
@@ -16,6 +16,7 @@ import {
BaseTemplateParametersStep,
baseParametersComplete,
} from "./BaseTemplateParametersStep";
import { ModuleSelectStep } from "./ModuleSelectStep";
import { SelectionSummary } from "./SelectionSummary";
import {
findNextVisibleIndex,
@@ -101,6 +102,14 @@ export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
dispatch({ type: "SET_BASE_VARIABLES", values })
}
/>
) : currentStep.id === "module-select" && state.selectedBase ? (
<ModuleSelectStep
baseId={state.selectedBase.id}
selectedModuleIds={state.modules.map((m) => m.id)}
onChangeModules={(modules, meta) =>
dispatch({ type: "SET_MODULES", modules, meta })
}
/>
) : (
<div className="rounded-lg border border-solid border-border bg-surface-primary p-6 min-h-[400px]">
<p className="text-sm text-content-secondary">
@@ -20,7 +20,7 @@ export type SelectedBaseMeta = {
* UI-only metadata for a selected module.
* Kept separate from the API request payload.
*/
type SelectedModuleMeta = {
export type SelectedModuleMeta = {
id: string;
name: string;
iconUrl: string;