From a9fcb2971e28808ee7ab3ff5e34b0812c492d6ba Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 13 Aug 2026 20:10:29 +0000 Subject: [PATCH 1/2] feat(site/src/pages/TemplateBuilder): make sidebar steps navigable Make the SelectionSummary step labels and the selected base-template row clickable jump targets, matching the already-navigable module rows. Add a maxReachedGroup back-stack so completed steps stay green and clickable after navigating backward, and gate clickability on it. The base-template row jumps to base-parameters, falling back to base-infra when that step is skipped. --- .../SelectionSummary.stories.tsx | 98 ++++++++++++++ .../TemplateBuilder/SelectionSummary.tsx | 122 ++++++++++++++++-- .../TemplateBuilderPageView.tsx | 29 +++++ 3 files changed, 235 insertions(+), 14 deletions(-) diff --git a/site/src/pages/TemplateBuilder/SelectionSummary.stories.tsx b/site/src/pages/TemplateBuilder/SelectionSummary.stories.tsx index caa63a697360f..ef1e4c90f2692 100644 --- a/site/src/pages/TemplateBuilder/SelectionSummary.stories.tsx +++ b/site/src/pages/TemplateBuilder/SelectionSummary.stories.tsx @@ -6,6 +6,7 @@ const meta: Meta = { title: "pages/TemplateBuilder/SelectionSummary", component: SelectionSummary, args: { + onNavigateStep: fn(), onNavigateModule: fn(), }, }; @@ -16,6 +17,7 @@ type Story = StoryObj; export const NoSelection: Story = { args: { currentStep: 0, + maxReachedStep: 0, selectedTemplate: undefined, selectedModules: undefined, }, @@ -24,6 +26,7 @@ export const NoSelection: Story = { export const BaseTemplateStep: Story = { args: { currentStep: 1, + maxReachedStep: 1, selectedTemplate: undefined, selectedModules: undefined, }, @@ -32,6 +35,7 @@ export const BaseTemplateStep: Story = { export const WithBaseTemplate: Story = { args: { currentStep: 1, + maxReachedStep: 1, selectedTemplate: { name: "Docker Containers", iconUrl: "/icon/docker.svg", @@ -42,6 +46,7 @@ export const WithBaseTemplate: Story = { export const ModulesStep: Story = { args: { currentStep: 2, + maxReachedStep: 2, selectedTemplate: { name: "Docker Containers", iconUrl: "/icon/docker.svg", @@ -53,6 +58,7 @@ export const ModulesStep: Story = { export const WithModules: Story = { args: { currentStep: 2, + maxReachedStep: 2, selectedTemplate: { name: "Docker Containers", iconUrl: "/icon/docker.svg", @@ -102,6 +108,7 @@ export const WithLongNameModule: Story = { parameters: { pixel: { exclude: true } }, args: { currentStep: 2, + maxReachedStep: 2, selectedTemplate: { name: "Docker Containers", iconUrl: "/icon/docker.svg", @@ -119,6 +126,7 @@ export const WithLongNameModule: Story = { export const NavigateModuleClick: Story = { args: { currentStep: 2, + maxReachedStep: 2, selectedTemplate: { name: "Docker Containers", iconUrl: "/icon/docker.svg", @@ -142,6 +150,7 @@ export const NavigateModuleClick: Story = { export const ManyModules: Story = { args: { currentStep: 2, + maxReachedStep: 2, selectedTemplate: { name: "Docker Containers", iconUrl: "/icon/docker.svg", @@ -157,6 +166,7 @@ export const ManyModules: Story = { export const Customizations: Story = { args: { currentStep: 3, + maxReachedStep: 3, selectedTemplate: { name: "Docker Containers", iconUrl: "/icon/docker.svg", @@ -167,3 +177,91 @@ export const Customizations: Story = { ], }, }; + +export const NavigationClicks: Story = { + args: { + currentStep: 3, + maxReachedStep: 3, + selectedTemplate: { + name: "Docker Containers", + iconUrl: "/icon/docker.svg", + }, + selectedModules: [ + { id: "claude-code", name: "Claude Code", iconUrl: "/icon/claude.svg" }, + { id: "cursor", name: "Cursor IDE", iconUrl: "/icon/cursor.svg" }, + ], + onNavigateStep: fn(), + onNavigateModule: fn(), + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + + await userEvent.click( + await canvas.findByRole("button", { name: "Go to Base Template" }), + ); + await expect(args.onNavigateStep).toHaveBeenCalledWith("base-infra"); + + await userEvent.click( + await canvas.findByRole("button", { + name: "Configure Docker Containers", + }), + ); + await expect(args.onNavigateStep).toHaveBeenCalledWith("base-parameters"); + + await userEvent.click( + await canvas.findByRole("button", { name: "Go to Modules" }), + ); + await expect(args.onNavigateStep).toHaveBeenCalledWith("module-select"); + + await userEvent.click( + await canvas.findByRole("button", { name: "Go to Customizations" }), + ); + await expect(args.onNavigateStep).toHaveBeenCalledWith("customizations"); + + await userEvent.click( + await canvas.findByRole("button", { name: "Configure Claude Code" }), + ); + await expect(args.onNavigateModule).toHaveBeenCalledWith("claude-code"); + }, +}; + +export const BackwardNavigation: Story = { + // The user reached Customizations (step 3) then jumped back to step 1. + // Steps 2 and 3 must stay clickable, and both dividers must remain in the + // completed (green) variant. + args: { + currentStep: 1, + maxReachedStep: 3, + selectedTemplate: { + name: "Docker Containers", + iconUrl: "/icon/docker.svg", + }, + selectedModules: [ + { id: "claude-code", name: "Claude Code", iconUrl: "/icon/claude.svg" }, + ], + }, + play: async ({ canvasElement }) => { + const dividers = canvasElement.querySelectorAll( + "[class*='border-border-success']", + ); + await expect(dividers.length).toBeGreaterThanOrEqual(2); + }, +}; + +export const UpcomingStepsInert: Story = { + // On step 1 with nothing selected, steps 2 and 3 must render without a + // button so they are neither clickable nor focusable. + args: { + currentStep: 1, + maxReachedStep: 1, + selectedTemplate: undefined, + selectedModules: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Modules").closest("button")).toBeNull(); + await expect( + canvas.getByText("Customizations").closest("button"), + ).toBeNull(); + }, +}; diff --git a/site/src/pages/TemplateBuilder/SelectionSummary.tsx b/site/src/pages/TemplateBuilder/SelectionSummary.tsx index b29e4b395d1a7..8d47acfe666de 100644 --- a/site/src/pages/TemplateBuilder/SelectionSummary.tsx +++ b/site/src/pages/TemplateBuilder/SelectionSummary.tsx @@ -2,6 +2,7 @@ import { cva } from "class-variance-authority"; import { createContext, type PropsWithChildren, useContext } from "react"; import { Avatar } from "#/components/Avatar/Avatar"; import { cn } from "#/utils/cn"; +import type { StepId } from "./steps"; type Variant = "complete" | "current" | "upcoming" | null | undefined; @@ -20,8 +21,20 @@ type SelectedModule = { type SelectionSummaryProps = { currentStep: number; + /** + * The highest sidebar group the user has reached. Groups at or below this + * value stay `complete` and clickable even when the current step is lower, + * so the sidebar behaves like a browser back-stack. Groups strictly above + * are `upcoming` and inert. + */ + maxReachedStep: number; selectedTemplate?: SelectedTemplate; selectedModules?: SelectedModule[]; + /** + * Jump to a wizard step. Called from the numbered step labels and from the + * selected base-template row. + */ + onNavigateStep: (stepId: StepId) => void; /** * Jump to a specific module's configuration section. The consumer * switches to the module settings step and scrolls the module into view. @@ -31,29 +44,62 @@ type SelectionSummaryProps = { export const SelectionSummary: React.FC = ({ currentStep, + maxReachedStep, selectedTemplate, selectedModules, + onNavigateStep, onNavigateModule, }) => { - const variant = (step: number) => { + const indicatorVariant = (step: number): Variant => { if (currentStep === step) return "current"; - if (currentStep > step) return "complete"; + if (step <= maxReachedStep) return "complete"; return "upcoming"; }; + // The vertical line below step N represents the path from N to N+1. It + // stays green once the user has advanced past N, even if the current step + // later drops back below N (backward navigation). + const dividerVariant = (step: number): Variant => + maxReachedStep > step ? "complete" : "current"; + const reachable = (step: number) => step <= maxReachedStep; return (

Selection

- - Base Template + + onNavigateStep("base-infra") : undefined + } + > + Base Template + + + {selectedTemplate ? ( - + onNavigateStep("base-parameters") + : undefined + } + /> ) : ( )} - - Modules + + onNavigateStep("module-select") : undefined + } + > + Modules + + + {selectedModules ? ( = ({ )} - - Customizations + + onNavigateStep("customizations") : undefined + } + > + Customizations +
@@ -96,10 +149,33 @@ const stepLabelVariants = cva("font-normal mr-2", { type StepIndicatorProps = PropsWithChildren<{ step: number; + onClick?: () => void; }>; -const StepIndicator: React.FC = ({ step, children }) => { +const StepIndicator: React.FC = ({ + step, + onClick, + children, +}) => { const variant = useContext(VariantContext); + const label = typeof children === "string" ? children : `step ${step}`; + + if (onClick) { + return ( + + ); + } return (
@@ -144,19 +220,37 @@ const StepDivider: React.FC = ({ className, children }) => { type BaseTemplateSelectionProps = { template: SelectedTemplate; + onClick?: () => void; }; const BaseTemplateSelection: React.FC = ({ template, + onClick, }) => { return ( -
-
+ {onClick ? ( + + ) : ( +
+
+ +
+ {template.name}
- {template.name} -
+ )} ); }; diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx index 53fbacfb4716f..e73d1a195d92c 100644 --- a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx +++ b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx @@ -5,6 +5,7 @@ import { useEffect, useReducer, useRef, + useState, } from "react"; import { useQuery } from "react-query"; @@ -105,6 +106,16 @@ export const TemplateBuilderPageView: FC = ({ const currentIndex = nearestVisible(clampedIndex, state); const currentStep = WIZARD_STEPS[currentIndex]; + // The highest sidebar group the user has reached. It never shrinks on + // backward navigation, so completed steps stay green and clickable in the + // SelectionSummary sidebar like a browser back-stack. + const [maxReachedGroup, setMaxReachedGroup] = useState<1 | 2 | 3>( + currentStep.group, + ); + if (currentStep.group > maxReachedGroup) { + setMaxReachedGroup(currentStep.group); + } + // Rewrite the URL whenever it disagrees with the resolved step. useEffect(() => { if (searchParams.get("step") === currentStep.id) { @@ -161,6 +172,22 @@ export const TemplateBuilderPageView: FC = ({ navigateToStep(nextIndex); }; + // Sidebar step labels and the base-template row call this to jump to a + // specific wizard step. Skipped steps resolve to the nearest visible one + // (so jumping to base-parameters lands on base-infra when the base has no + // parameters). + const navigateToStepId = (stepId: StepId) => { + const target = WIZARD_STEPS.findIndex((s) => s.id === stepId); + if (target < 0) { + return; + } + if (currentStep.id === "customizations" && stepId !== "customizations") { + dispatch({ type: "RESET_CUSTOMIZATIONS" }); + onClearCreateError?.(); + } + navigateToStep(nearestVisible(target, state)); + }; + const handleProvisionerStatusChange = useCallback( (value: boolean | undefined) => { dispatch({ type: "SET_HAS_PROVISIONERS", value }); @@ -302,6 +329,8 @@ export const TemplateBuilderPageView: FC = ({
Date: Thu, 13 Aug 2026 20:28:36 +0000 Subject: [PATCH 2/2] fix: make StepIndicator button text color theme-reactive --- site/src/pages/TemplateBuilder/SelectionSummary.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/TemplateBuilder/SelectionSummary.tsx b/site/src/pages/TemplateBuilder/SelectionSummary.tsx index 8d47acfe666de..bf68406e3f413 100644 --- a/site/src/pages/TemplateBuilder/SelectionSummary.tsx +++ b/site/src/pages/TemplateBuilder/SelectionSummary.tsx @@ -167,7 +167,7 @@ const StepIndicator: React.FC = ({ onClick={onClick} aria-label={`Go to ${label}`} className={cn( - "flex items-center gap-2 w-full text-left p-0 bg-transparent border-0 cursor-pointer rounded-sm", + "flex items-center gap-2 w-full text-left text-content-primary p-0 bg-transparent border-0 cursor-pointer rounded-sm", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-primary", )} >