feat(site/src/pages/TemplateBuilder): disable create button when no provisioners (#26938)

## Summary

Add provisioner awareness to the Template Builder wizard: disable the
Create Template button when the selected organization has no
provisioners, and reset the customizations step when navigating back.

## Changes

- Query provisioner daemons for the selected org in
`TemplateCustomizationsStep` and show a warning alert when none are
found
- Track `hasProvisioners` in wizard state via `SET_HAS_PROVISIONERS`
action; `computeCanContinue` disables the Create Template button when
`hasProvisioners === false`
- Add `RESET_CUSTOMIZATIONS` action to clear customization fields (name,
displayName, description, icon, organizationId, hasProvisioners) when
navigating back from the customizations step
- Clear the create mutation error on back navigation via
`onClearCreateError` callback

Follows up on #26935 which added the provisioner warning alert to the
Template Builder.

> 🤖 Generated by Coder Agents on behalf of @jeremyruppel
This commit is contained in:
Jeremy Ruppel
2026-07-01 17:30:09 -04:00
committed by GitHub
parent 04d523b9ab
commit 2318b0e60d
4 changed files with 54 additions and 4 deletions
@@ -62,6 +62,7 @@ const TemplateBuilderPage: FC = () => {
onCreateTemplate={handleCreate}
createError={createMutation.error}
isCreating={createMutation.isPending}
onClearCreateError={() => createMutation.reset()}
/>
</>
);
@@ -1,4 +1,10 @@
import { type FC, type ReactNode, useReducer, useState } from "react";
import {
type FC,
type ReactNode,
useCallback,
useReducer,
useState,
} from "react";
import { useQuery } from "react-query";
import { templateBuilderModules } from "#/api/queries/templateBuilder";
@@ -51,6 +57,7 @@ interface TemplateBuilderPageViewProps {
onCreateTemplate: (state: TemplateBuilderWizardState) => void;
createError: Error | null;
isCreating: boolean;
onClearCreateError?: () => void;
}
export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
@@ -59,6 +66,7 @@ export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
onCreateTemplate,
createError,
isCreating,
onClearCreateError,
}) => {
const [state, dispatch] = useReducer(wizardReducer, initialWizardState);
const [stepIndex, setStepIndex] = useState(0);
@@ -85,6 +93,10 @@ export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
);
const handleBack = () => {
if (currentStep.id === "customizations") {
dispatch({ type: "RESET_CUSTOMIZATIONS" });
onClearCreateError?.();
}
window.scrollTo(0, 0);
setStepIndex(prevIndex);
};
@@ -98,6 +110,13 @@ export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
setStepIndex(nextIndex);
};
const handleProvisionerStatusChange = useCallback(
(value: boolean | undefined) => {
dispatch({ type: "SET_HAS_PROVISIONERS", value });
},
[],
);
const handleDeselectModule = (moduleId: string) => {
// If the only module gets deselected, go back to module selection
if (state.modules.length === 1) {
@@ -142,6 +161,7 @@ export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
dispatch,
moduleVarMap,
createError,
handleProvisionerStatusChange,
)}
</div>
@@ -193,6 +213,7 @@ function renderStepContent(
dispatch: (action: WizardAction) => void,
moduleVarMap: Record<string, Record<string, string>>,
createError: Error | null,
onProvisionerStatusChange: (value: boolean | undefined) => void,
): ReactNode {
switch (stepId) {
case "base-infra":
@@ -253,6 +274,7 @@ function renderStepContent(
value,
})
}
onProvisionerStatusChange={onProvisionerStatusChange}
/>
</>
);
@@ -284,7 +306,7 @@ function computeCanContinue(
moduleVarMap,
);
case "customizations":
return state.name.trim() !== "";
return state.name.trim() !== "" && state.hasProvisioners !== false;
default:
return true;
}
@@ -28,11 +28,12 @@ interface TemplateCustomizationsStepProps {
field: "organizationId" | "name" | "displayName" | "description" | "icon",
value: string,
) => void;
onProvisionerStatusChange: (hasProvisioners: boolean | undefined) => void;
}
export const TemplateCustomizationsStep: FC<
TemplateCustomizationsStepProps
> = ({ state, onChangeField }) => {
> = ({ state, onChangeField, onProvisionerStatusChange }) => {
const permittedOrgsQuery = useQuery(
permittedOrganizations({
object: { resource_type: "template" },
@@ -47,7 +48,14 @@ export const TemplateCustomizationsStep: FC<
...provisionerDaemons(selectedOrg?.id ?? ""),
enabled: Boolean(selectedOrg),
});
const showProvisionerWarning = provisioners ? provisioners.length < 1 : false;
const hasProvisioners = provisioners ? provisioners.length > 0 : undefined;
const showProvisionerWarning = hasProvisioners === false;
// Notify parent when provisioner status changes so the wizard can
// disable the create button when no provisioners are available.
useEffect(() => {
onProvisionerStatusChange(hasProvisioners);
}, [hasProvisioners, onProvisionerStatusChange]);
// Auto-select when exactly one org is available.
useEffect(() => {
@@ -34,6 +34,7 @@ export type TemplateBuilderWizardState = {
baseVariableValues: Record<string, string>;
modules: TemplateBuilderComposeModule[];
organizationId?: string;
hasProvisioners: boolean | undefined;
name: string;
displayName: string;
description: string;
@@ -46,6 +47,7 @@ export const initialWizardState: TemplateBuilderWizardState = {
baseTemplateId: null,
baseVariableValues: {},
modules: [],
hasProvisioners: undefined,
name: "",
displayName: "",
description: "",
@@ -72,6 +74,8 @@ export type WizardAction =
field: "organizationId" | "name" | "displayName" | "description" | "icon";
value: string;
}
| { type: "SET_HAS_PROVISIONERS"; value: boolean | undefined }
| { type: "RESET_CUSTOMIZATIONS" }
| { type: "RESET" };
export function wizardReducer(
@@ -123,6 +127,21 @@ export function wizardReducer(
...state,
[action.field]: action.value,
};
case "SET_HAS_PROVISIONERS":
return {
...state,
hasProvisioners: action.value,
};
case "RESET_CUSTOMIZATIONS":
return {
...state,
organizationId: undefined,
hasProvisioners: undefined,
name: "",
displayName: "",
description: "",
icon: "",
};
case "RESET":
return initialWizardState;
default: