diff --git a/site/src/pages/TemplateBuilder/BaseInfraSelectStep.tsx b/site/src/pages/TemplateBuilder/BaseInfraSelectStep.tsx index 93ddbd6de5e8d..c7744c6ff392d 100644 --- a/site/src/pages/TemplateBuilder/BaseInfraSelectStep.tsx +++ b/site/src/pages/TemplateBuilder/BaseInfraSelectStep.tsx @@ -1,7 +1,6 @@ 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 { @@ -9,25 +8,13 @@ import { TemplateBuilderTitle, } from "#/pages/TemplateBuilder/TemplateBuilderHeader"; import { TemplateCard } from "./TemplateCard"; -import type { SelectedBaseMeta } from "./wizardState"; +import { type SelectedBaseMeta, toSelectedBaseMeta } 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: - base.variables?.length > 0 && base.variables?.some((v) => !v.sensitive), - hasPrerequisites: Boolean(base.prerequisites?.length), - }; -} - function detailsUrl(baseId: string): string { return `https://registry.coder.com/templates/${baseId}`; } diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx index 592d69050a745..561653e0599ec 100644 --- a/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx +++ b/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx @@ -1,6 +1,6 @@ -import type { FC } from "react"; +import { type FC, useEffect, useState } from "react"; import { useMutation, useQuery } from "react-query"; -import { Navigate, useNavigate } from "react-router"; +import { Navigate, useNavigate, useSearchParams } from "react-router"; import { deploymentConfig } from "#/api/queries/deployment"; import { createTemplateFromBuilder, @@ -11,13 +11,17 @@ import { useAuthenticated } from "#/hooks/useAuthenticated"; import { linkToTemplate, useLinks } from "#/modules/navigation"; import { pageTitle } from "#/utils/page"; import { TemplateBuilderPageView } from "./TemplateBuilderPageView"; -import type { TemplateBuilderWizardState } from "./wizardState"; -import { toCreateTemplateRequest } from "./wizardState"; +import type { + SelectedBaseMeta, + TemplateBuilderWizardState, +} from "./wizardState"; +import { toCreateTemplateRequest, toSelectedBaseMeta } from "./wizardState"; const TemplateBuilderPage: FC = () => { const navigate = useNavigate(); const getLink = useLinks(); const { permissions } = useAuthenticated(); + const [searchParams, setSearchParams] = useSearchParams(); const { data, error, isLoading } = useQuery(deploymentConfig()); const createMutation = useMutation(createTemplateFromBuilder()); @@ -28,7 +32,27 @@ const TemplateBuilderPage: FC = () => { enabled: !builderDisabled && !isLoading && permissions.createTemplates, }); - if (isLoading) { + // ?base= is the only search param accepted on entry. It is consumed + // here: resolved against the available bases, stored in local state, + // and removed from the URL before the wizard mounts. + const baseParam = searchParams.get("base"); + const [preselectedBase, setPreselectedBase] = useState(); + useEffect(() => { + if (!baseParam || !basesQuery.data) { + return; + } + const match = basesQuery.data.bases?.find((b) => b.id === baseParam); + if (match) { + setPreselectedBase(toSelectedBaseMeta(match)); + } + const next = new URLSearchParams(searchParams); + next.delete("base"); + setSearchParams(next, { replace: true }); + }, [baseParam, basesQuery.data, searchParams, setSearchParams]); + + // Hold the wizard until ?base= has been fully consumed so it mounts + // exactly once with its initial state settled. + if (isLoading || baseParam) { return ; } @@ -59,6 +83,7 @@ const TemplateBuilderPage: FC = () => { void; createError: Error | null; isCreating: boolean; @@ -63,22 +67,58 @@ interface TemplateBuilderPageViewProps { export const TemplateBuilderPageView: FC = ({ error, basesData, + preselectedBase, onCreateTemplate, createError, isCreating, onClearCreateError, }) => { - const [state, dispatch] = useReducer(wizardReducer, initialWizardState); - const [stepIndex, setStepIndex] = useState(0); + const [state, dispatch] = useReducer( + wizardReducer, + preselectedBase, + initWizardState, + ); + const [searchParams, setSearchParams] = useSearchParams(); const modulesQuery = useQuery(templateBuilderModules(state.selectedBase?.id)); const moduleVarMap = Object.fromEntries( state.modules.map((m) => [m.id, m.variables ?? {}]), ); - const currentIndex = nearestVisible(stepIndex, state); + // The ?step= search param drives the current step so that browser + // back/forward moves between steps. The requested step is clamped to + // what the wizard state allows and snapped to the nearest visible + // step, keeping the URL and state in sync even when the URL points at + // a step the user cannot be on. + const stepParam = searchParams.get("step"); + const requestedIndex = WIZARD_STEPS.findIndex((s) => s.id === stepParam); + const defaultIndex = preselectedBase + ? Math.max(findNextVisibleIndex(0, state), 0) + : 0; + const clampedIndex = Math.min( + requestedIndex >= 0 ? requestedIndex : defaultIndex, + furthestAllowedIndex(state), + ); + const currentIndex = nearestVisible(clampedIndex, state); const currentStep = WIZARD_STEPS[currentIndex]; + // Rewrite the URL whenever it disagrees with the resolved step. + useEffect(() => { + if (searchParams.get("step") === currentStep.id) { + return; + } + const next = new URLSearchParams(searchParams); + next.set("step", currentStep.id); + setSearchParams(next, { replace: true }); + }, [currentStep.id, searchParams, setSearchParams]); + + // Reset scroll whenever the active step changes, including on browser + // back/forward (popstate) where button click handlers would not fire. + // biome-ignore lint/correctness/useExhaustiveDependencies: scroll must reset when step changes + useEffect(() => { + window.scrollTo(0, 0); + }, [currentStep.id]); + const nextIndex = findNextVisibleIndex(currentIndex, state); const prevIndex = findPrevVisibleIndex(currentIndex, state); const isFirstStep = prevIndex === -1; @@ -92,13 +132,22 @@ export const TemplateBuilderPageView: FC = ({ moduleVarMap, ); + // Pushes a history entry so browser back/forward walks the steps. + const navigateToStep = useCallback( + (index: number) => { + const next = new URLSearchParams(searchParams); + next.set("step", WIZARD_STEPS[index].id); + setSearchParams(next, { replace: false }); + }, + [searchParams, setSearchParams], + ); + const handleBack = () => { if (currentStep.id === "customizations") { dispatch({ type: "RESET_CUSTOMIZATIONS" }); onClearCreateError?.(); } - window.scrollTo(0, 0); - setStepIndex(prevIndex); + navigateToStep(prevIndex); }; const handleNext = () => { @@ -106,8 +155,7 @@ export const TemplateBuilderPageView: FC = ({ onCreateTemplate(state); return; } - window.scrollTo(0, 0); - setStepIndex(nextIndex); + navigateToStep(nextIndex); }; const handleProvisionerStatusChange = useCallback( @@ -120,7 +168,7 @@ export const TemplateBuilderPageView: FC = ({ const handleDeselectModule = (moduleId: string) => { // If the only module gets deselected, go back to module selection if (state.modules.length === 1) { - setStepIndex(WIZARD_STEPS.findIndex((s) => s.id === "module-select")); + navigateToStep(WIZARD_STEPS.findIndex((s) => s.id === "module-select")); } dispatch({ type: "SET_MODULES", diff --git a/site/src/pages/TemplateBuilder/steps.test.ts b/site/src/pages/TemplateBuilder/steps.test.ts index f17645c1e20d7..66d1128d7056f 100644 --- a/site/src/pages/TemplateBuilder/steps.test.ts +++ b/site/src/pages/TemplateBuilder/steps.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { findNextVisibleIndex, findPrevVisibleIndex, + furthestAllowedIndex, nearestVisible, stepModuleSettingsRequired, WIZARD_STEPS, @@ -232,3 +233,21 @@ describe("nearestVisible", () => { expect(nearestVisible(1, allSkippable)).toBe(0); }); }); + +describe("furthestAllowedIndex", () => { + it("returns 0 when no base is selected", () => { + expect(furthestAllowedIndex(initialWizardState)).toBe(0); + }); + + it("returns the last step index when a base is selected", () => { + const withBase = stateWith({ + selectedBase: { + id: "docker", + name: "Docker", + hasParameters: false, + hasPrerequisites: false, + }, + }); + expect(furthestAllowedIndex(withBase)).toBe(WIZARD_STEPS.length - 1); + }); +}); diff --git a/site/src/pages/TemplateBuilder/steps.ts b/site/src/pages/TemplateBuilder/steps.ts index 85d26a211d764..767eb6e36abc5 100644 --- a/site/src/pages/TemplateBuilder/steps.ts +++ b/site/src/pages/TemplateBuilder/steps.ts @@ -121,3 +121,17 @@ export function nearestVisible( } return 0; } + +/** + * Returns the highest step index the user can reach given the current + * wizard state. Steps past base-infra require a selected base template; + * without one the wizard cannot advance beyond the first step. + */ +export function furthestAllowedIndex( + state: TemplateBuilderWizardState, +): number { + if (!state.selectedBase) { + return 0; + } + return WIZARD_STEPS.length - 1; +} diff --git a/site/src/pages/TemplateBuilder/wizardState.ts b/site/src/pages/TemplateBuilder/wizardState.ts index 45cd9a337b15b..23b565465a6bf 100644 --- a/site/src/pages/TemplateBuilder/wizardState.ts +++ b/site/src/pages/TemplateBuilder/wizardState.ts @@ -1,4 +1,5 @@ import type { + TemplateBuilderBase, TemplateBuilderComposeModule, TemplateBuilderComposeRequest, TemplateBuilderCreateTemplateRequest, @@ -18,6 +19,23 @@ export type SelectedBaseMeta = { hasPrerequisites: boolean; }; +/** + * Maps an API TemplateBuilderBase to the UI-only SelectedBaseMeta. + */ +export function toSelectedBaseMeta( + base: TemplateBuilderBase, +): SelectedBaseMeta { + return { + id: base.id, + name: base.name, + iconUrl: base.icon, + os: base.os, + hasParameters: + base.variables?.length > 0 && base.variables?.some((v) => !v.sensitive), + hasPrerequisites: Boolean(base.prerequisites?.length), + }; +} + /** * UI-only metadata for a selected module. * Kept separate from the API request payload. @@ -56,6 +74,23 @@ export const initialWizardState: TemplateBuilderWizardState = { selectedModules: [], }; +/** + * Builds the initial wizard state, optionally preselecting a base + * template. + */ +export function initWizardState( + preselectedBase?: SelectedBaseMeta, +): TemplateBuilderWizardState { + if (!preselectedBase) { + return initialWizardState; + } + return { + ...initialWizardState, + baseTemplateId: preselectedBase.id, + selectedBase: preselectedBase, + }; +} + export type WizardAction = | { type: "SET_BASE"; base: SelectedBaseMeta } | { type: "SET_BASE_VARIABLES"; values: Record }