From df136ffc4db378e627f6f0fa9144e45463b19837 Mon Sep 17 00:00:00 2001 From: Jeremy Ruppel Date: Mon, 13 Jul 2026 12:52:09 +0000 Subject: [PATCH] fix(site/src/pages/TemplateBuilder): support browser back/forward in wizard Drive the wizard's current step from a ?step= search param so browser back/forward moves between steps instead of leaving the wizard. Step transitions push history entries; invalid, skipped, or unreachable steps are clamped to what the wizard state allows and the URL is rewritten with a replace. The ?base= param is the only search param accepted on entry. It is consumed by the page container: resolved against the available bases, held in local state, and removed from the URL before the wizard view mounts. When a base is preselected this way, the wizard starts on the first step after base selection. Also moves toSelectedBaseMeta into wizardState.ts so both the page container and BaseInfraSelectStep can use it, and adds initWizardState and furthestAllowedIndex helpers. --- .../TemplateBuilder/BaseInfraSelectStep.tsx | 15 +--- .../TemplateBuilder/TemplateBuilderPage.tsx | 35 ++++++++-- .../TemplateBuilderPageView.tsx | 68 ++++++++++++++++--- site/src/pages/TemplateBuilder/steps.test.ts | 19 ++++++ site/src/pages/TemplateBuilder/steps.ts | 14 ++++ site/src/pages/TemplateBuilder/wizardState.ts | 35 ++++++++++ 6 files changed, 157 insertions(+), 29 deletions(-) diff --git a/site/src/pages/TemplateBuilder/BaseInfraSelectStep.tsx b/site/src/pages/TemplateBuilder/BaseInfraSelectStep.tsx index 93ddbd6de5e..c7744c6ff39 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 592d69050a7..561653e0599 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 f17645c1e20..66d1128d705 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 85d26a211d7..767eb6e36ab 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 45cd9a337b1..23b565465a6 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 }