feat(site/src/pages/TemplateBuilder): make sidebar steps navigable (#28153)

## What

Makes the remaining `SelectionSummary` sidebar elements clickable jump
targets on `/templates/new/builder`, continuing the work from #27351
(which made module rows navigable).

Clickable now:

| Sidebar element | Jumps to |
|---|---|
| `Base Template` label | `base-infra` |
| Selected base-template row | `base-parameters` (falls back to
`base-infra` when that step is skipped) |
| `Modules` label | `module-select` |
| Each module row | `module-settings` + scroll (already shipped in
#27351) |
| `Customizations` label | `customizations` |

## Back-stack behavior

The sidebar previously colored groups purely from the current step, so
jumping backward would grey out and disable steps you had already
reached. This adds a `maxReachedGroup` that never shrinks on backward
navigation:

- Groups at or below the furthest-reached group stay `complete` (green)
and clickable, like a browser back-stack.
- Groups strictly above render as `upcoming` and inert (no button, no
hover, not focusable).
- The connecting divider color keys off `maxReachedGroup`, not the
current step, so it stays green after navigating backward.

Clickability is gated on `maxReachedGroup` (you can only jump to steps
you have already reached).

## Changes

- `SelectionSummary.tsx`: new required `maxReachedStep` and
`onNavigateStep` props. Split the single `variant()` into
`indicatorVariant` (label circle), `dividerVariant` (connecting line),
and a `reachable()` gate. `StepIndicator` and `BaseTemplateSelection`
render as `<button>` (hover + focus ring, `aria-label`) when a reachable
handler is supplied, else stay inert.
- `TemplateBuilderPageView.tsx`: track `maxReachedGroup`; add
`navigateToStepId(stepId)` that resolves skipped steps via
`nearestVisible` (so `base-parameters` falls back to `base-infra`) and
mirrors the existing customizations reset when leaving that step. Wire
both new props into `SelectionSummary`.
- `SelectionSummary.stories.tsx`: add `onNavigateStep` to meta and
`maxReachedStep` to existing stories; add `NavigationClicks` (asserts
each label/base/module callback), `BackwardNavigation` (dividers stay
green), and `UpcomingStepsInert` (steps above max-reached are not
buttons).

## Out of scope

Everything else from #27077 stays out: gallery height, sensitive-var
banner relocation, trash-icon wiring, and the scroll-past required-field
subsystem.

## Testing

- `pnpm check` (biome) clean
- `pnpm lint:types` (tsc) clean
- `pnpm vitest run --project=storybook src/pages/TemplateBuilder` — 37
pass
- `pnpm vitest run --project=unit src/pages/TemplateBuilder` — 55 pass

<details>
<summary>Implementation plan / decision log</summary>

### Origin

This is the remainder of PR #27077's item #2 (navigable selection
summary), rebased onto current `main` after #27351 shipped the
module-row navigation.

### Why a `maxReachedGroup` back-stack

`furthestAllowedIndex(state)` on current `main` is all-or-nothing (0
without a base selected, otherwise the last step), so it cannot express
"how far the user has progressed" for the sidebar coloring. A monotonic
`maxReachedGroup` (bumped when the current group advances, never shrunk)
is needed to keep completed steps green and clickable after backward
navigation, matching #27077.

### Decisions

- Reachability gating: gate both coloring and clickability on
`maxReachedGroup` (only jump to steps already reached), rather than the
looser `furthestAllowedIndex` (which would let users skip required steps
once a base is chosen).
- Base-template row target: jump to `base-parameters` and let
`nearestVisible` fall back to `base-infra` when the base has no
parameters/prerequisites.
- Module rows: keep `onNavigateModule` passed directly (not re-gated on
reachability), since a module can only be selected after reaching group
2, so `reachable(2)` is always true when module rows render. This
preserves the earlier decision to keep the module row's handler required
with no inert branch.

</details>

---

Coder Agents generated, on behalf of @aqandrew.
This commit is contained in:
Andrew Aquino
2026-08-17 12:15:29 -07:00
committed by GitHub
parent b4971bc49f
commit 039c0da5ae
3 changed files with 235 additions and 14 deletions
@@ -6,6 +6,7 @@ const meta: Meta<typeof SelectionSummary> = {
title: "pages/TemplateBuilder/SelectionSummary",
component: SelectionSummary,
args: {
onNavigateStep: fn(),
onNavigateModule: fn(),
},
};
@@ -16,6 +17,7 @@ type Story = StoryObj<typeof SelectionSummary>;
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();
},
};
@@ -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<SelectionSummaryProps> = ({
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 (
<div>
<h2 className="text-xl font-semibold">Selection</h2>
<div className="text-sm">
<VariantContext.Provider value={variant(1)}>
<StepIndicator step={1}>Base Template</StepIndicator>
<VariantContext.Provider value={indicatorVariant(1)}>
<StepIndicator
step={1}
onClick={
reachable(1) ? () => onNavigateStep("base-infra") : undefined
}
>
Base Template
</StepIndicator>
</VariantContext.Provider>
<VariantContext.Provider value={dividerVariant(1)}>
{selectedTemplate ? (
<BaseTemplateSelection template={selectedTemplate} />
<BaseTemplateSelection
template={selectedTemplate}
onClick={
reachable(1)
? () => onNavigateStep("base-parameters")
: undefined
}
/>
) : (
<StepDivider />
)}
</VariantContext.Provider>
<VariantContext.Provider value={variant(2)}>
<StepIndicator step={2}>Modules</StepIndicator>
<VariantContext.Provider value={indicatorVariant(2)}>
<StepIndicator
step={2}
onClick={
reachable(2) ? () => onNavigateStep("module-select") : undefined
}
>
Modules
</StepIndicator>
</VariantContext.Provider>
<VariantContext.Provider value={dividerVariant(2)}>
{selectedModules ? (
<ModuleSelection
modules={selectedModules}
@@ -63,8 +109,15 @@ export const SelectionSummary: React.FC<SelectionSummaryProps> = ({
<StepDivider />
)}
</VariantContext.Provider>
<VariantContext.Provider value={variant(3)}>
<StepIndicator step={3}>Customizations</StepIndicator>
<VariantContext.Provider value={indicatorVariant(3)}>
<StepIndicator
step={3}
onClick={
reachable(3) ? () => onNavigateStep("customizations") : undefined
}
>
Customizations
</StepIndicator>
</VariantContext.Provider>
</div>
</div>
@@ -96,10 +149,33 @@ const stepLabelVariants = cva("font-normal mr-2", {
type StepIndicatorProps = PropsWithChildren<{
step: number;
onClick?: () => void;
}>;
const StepIndicator: React.FC<StepIndicatorProps> = ({ step, children }) => {
const StepIndicator: React.FC<StepIndicatorProps> = ({
step,
onClick,
children,
}) => {
const variant = useContext(VariantContext);
const label = typeof children === "string" ? children : `step ${step}`;
if (onClick) {
return (
<button
type="button"
onClick={onClick}
aria-label={`Go to ${label}`}
className={cn(
"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",
)}
>
<div className={stepCircleVariants({ variant })}>{step}</div>
<span className={stepLabelVariants({ variant })}>{children}</span>
</button>
);
}
return (
<div className="flex items-center gap-2">
@@ -144,19 +220,37 @@ const StepDivider: React.FC<StepDividerProps> = ({ className, children }) => {
type BaseTemplateSelectionProps = {
template: SelectedTemplate;
onClick?: () => void;
};
const BaseTemplateSelection: React.FC<BaseTemplateSelectionProps> = ({
template,
onClick,
}) => {
return (
<StepDivider>
<div className="flex items-start p-1">
<div className="h-[1lh] content-center">
{onClick ? (
<button
type="button"
onClick={onClick}
aria-label={`Configure ${template.name}`}
className={cn(
"flex items-center gap-2 w-full text-left p-1 rounded-sm bg-transparent border-0 cursor-pointer",
"text-content-secondary hover:text-content-primary hover:bg-surface-secondary",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-primary",
)}
>
<Avatar src={template.iconUrl} size="sm" variant="icon" />
<span>{template.name}</span>
</button>
) : (
<div className="flex items-start p-1">
<div className="h-[1lh] content-center">
<Avatar src={template.iconUrl} size="sm" variant="icon" />
</div>
<span className="ml-2 text-content-secondary">{template.name}</span>
</div>
<span className="ml-2 text-content-secondary">{template.name}</span>
</div>
)}
</StepDivider>
);
};
@@ -5,6 +5,7 @@ import {
useEffect,
useReducer,
useRef,
useState,
} from "react";
import { useQuery } from "react-query";
@@ -105,6 +106,16 @@ export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
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<TemplateBuilderPageViewProps> = ({
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<TemplateBuilderPageViewProps> = ({
<div className="w-64 shrink-0 hidden md:block sticky top-[72px] self-start">
<SelectionSummary
currentStep={currentStep.group}
maxReachedStep={maxReachedGroup}
onNavigateStep={navigateToStepId}
onNavigateModule={navigateToModule}
selectedTemplate={
state.selectedBase