From 2517e97a7d22ffb3d2144f368c89f14b0848b072 Mon Sep 17 00:00:00 2001 From: Jeremy Ruppel Date: Wed, 1 Jul 2026 20:44:15 +0000 Subject: [PATCH 1/3] feat(site/TemplateBuilder): disable create button when no provisioners Disable the Create Template button on the customizations step when the selected organization has no provisioner daemons. Adds hasProvisioners to the wizard state, updated via a callback from TemplateCustomizationsStep, and checked in computeCanContinue. --- .../TemplateBuilder/TemplateBuilderPageView.tsx | 8 +++++++- .../TemplateBuilder/TemplateCustomizationsStep.tsx | 12 ++++++++++-- site/src/pages/TemplateBuilder/wizardState.ts | 8 ++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx index 099cb94a89684..7b5306eb4a421 100644 --- a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx +++ b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx @@ -253,6 +253,12 @@ function renderStepContent( value, }) } + onProvisionerStatusChange={(value) => + dispatch({ + type: "SET_HAS_PROVISIONERS", + value, + }) + } /> ); @@ -284,7 +290,7 @@ function computeCanContinue( moduleVarMap, ); case "customizations": - return state.name.trim() !== ""; + return state.name.trim() !== "" && state.hasProvisioners !== false; default: return true; } diff --git a/site/src/pages/TemplateBuilder/TemplateCustomizationsStep.tsx b/site/src/pages/TemplateBuilder/TemplateCustomizationsStep.tsx index 16b433cfe4d1e..7f193fb214258 100644 --- a/site/src/pages/TemplateBuilder/TemplateCustomizationsStep.tsx +++ b/site/src/pages/TemplateBuilder/TemplateCustomizationsStep.tsx @@ -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(() => { diff --git a/site/src/pages/TemplateBuilder/wizardState.ts b/site/src/pages/TemplateBuilder/wizardState.ts index a2bf362b31d7c..023f1a7651c90 100644 --- a/site/src/pages/TemplateBuilder/wizardState.ts +++ b/site/src/pages/TemplateBuilder/wizardState.ts @@ -34,6 +34,7 @@ export type TemplateBuilderWizardState = { baseVariableValues: Record; 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,7 @@ export type WizardAction = field: "organizationId" | "name" | "displayName" | "description" | "icon"; value: string; } + | { type: "SET_HAS_PROVISIONERS"; value: boolean | undefined } | { type: "RESET" }; export function wizardReducer( @@ -123,6 +126,11 @@ export function wizardReducer( ...state, [action.field]: action.value, }; + case "SET_HAS_PROVISIONERS": + return { + ...state, + hasProvisioners: action.value, + }; case "RESET": return initialWizardState; default: From ac30dd2822d51471282dc973b8b15ab8aaad6b7f Mon Sep 17 00:00:00 2001 From: Jeremy Ruppel Date: Wed, 1 Jul 2026 20:50:00 +0000 Subject: [PATCH 2/3] feat(site/TemplateBuilder): reset customizations when navigating back Reset the template customization fields (name, displayName, description, icon, organizationId, hasProvisioners) and clear any create error when navigating back from the customizations step. The step component remounts fresh with clean local state on re-entry. --- .../src/pages/TemplateBuilder/TemplateBuilderPage.tsx | 1 + .../pages/TemplateBuilder/TemplateBuilderPageView.tsx | 6 ++++++ site/src/pages/TemplateBuilder/wizardState.ts | 11 +++++++++++ 3 files changed, 18 insertions(+) diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx index ffda44a0bf49d..592d69050a745 100644 --- a/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx +++ b/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx @@ -62,6 +62,7 @@ const TemplateBuilderPage: FC = () => { onCreateTemplate={handleCreate} createError={createMutation.error} isCreating={createMutation.isPending} + onClearCreateError={() => createMutation.reset()} /> ); diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx index 7b5306eb4a421..5ba2c1e24ef75 100644 --- a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx +++ b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx @@ -51,6 +51,7 @@ interface TemplateBuilderPageViewProps { onCreateTemplate: (state: TemplateBuilderWizardState) => void; createError: Error | null; isCreating: boolean; + onClearCreateError?: () => void; } export const TemplateBuilderPageView: FC = ({ @@ -59,6 +60,7 @@ export const TemplateBuilderPageView: FC = ({ onCreateTemplate, createError, isCreating, + onClearCreateError, }) => { const [state, dispatch] = useReducer(wizardReducer, initialWizardState); const [stepIndex, setStepIndex] = useState(0); @@ -85,6 +87,10 @@ export const TemplateBuilderPageView: FC = ({ ); const handleBack = () => { + if (currentStep.id === "customizations") { + dispatch({ type: "RESET_CUSTOMIZATIONS" }); + onClearCreateError?.(); + } window.scrollTo(0, 0); setStepIndex(prevIndex); }; diff --git a/site/src/pages/TemplateBuilder/wizardState.ts b/site/src/pages/TemplateBuilder/wizardState.ts index 023f1a7651c90..45cd9a337b15b 100644 --- a/site/src/pages/TemplateBuilder/wizardState.ts +++ b/site/src/pages/TemplateBuilder/wizardState.ts @@ -75,6 +75,7 @@ export type WizardAction = value: string; } | { type: "SET_HAS_PROVISIONERS"; value: boolean | undefined } + | { type: "RESET_CUSTOMIZATIONS" } | { type: "RESET" }; export function wizardReducer( @@ -131,6 +132,16 @@ export function wizardReducer( ...state, hasProvisioners: action.value, }; + case "RESET_CUSTOMIZATIONS": + return { + ...state, + organizationId: undefined, + hasProvisioners: undefined, + name: "", + displayName: "", + description: "", + icon: "", + }; case "RESET": return initialWizardState; default: From c8f4d33e3645503df8f98439d37149aaf122d2da Mon Sep 17 00:00:00 2001 From: Jeremy Ruppel Date: Wed, 1 Jul 2026 20:58:04 +0000 Subject: [PATCH 3/3] fix(site/TemplateBuilder): stabilize provisioner status callback Wrap onProvisionerStatusChange in useCallback to prevent an infinite render loop. The inline arrow function created a new reference each render, re-triggering the useEffect in TemplateCustomizationsStep. --- .../TemplateBuilderPageView.tsx | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx index 5ba2c1e24ef75..94e54359638b1 100644 --- a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx +++ b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx @@ -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"; @@ -104,6 +110,13 @@ export const TemplateBuilderPageView: FC = ({ 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) { @@ -148,6 +161,7 @@ export const TemplateBuilderPageView: FC = ({ dispatch, moduleVarMap, createError, + handleProvisionerStatusChange, )} @@ -199,6 +213,7 @@ function renderStepContent( dispatch: (action: WizardAction) => void, moduleVarMap: Record>, createError: Error | null, + onProvisionerStatusChange: (value: boolean | undefined) => void, ): ReactNode { switch (stepId) { case "base-infra": @@ -259,12 +274,7 @@ function renderStepContent( value, }) } - onProvisionerStatusChange={(value) => - dispatch({ - type: "SET_HAS_PROVISIONERS", - value, - }) - } + onProvisionerStatusChange={onProvisionerStatusChange} /> );