mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user