diff --git a/site/src/pages/TemplateBuilder/ModuleSettingsStep.tsx b/site/src/pages/TemplateBuilder/ModuleSettingsStep.tsx index a156884adbf..61549e044b7 100644 --- a/site/src/pages/TemplateBuilder/ModuleSettingsStep.tsx +++ b/site/src/pages/TemplateBuilder/ModuleSettingsStep.tsx @@ -27,6 +27,7 @@ interface ModuleSettingsStepProps { variables: Record, ) => void; onRemoveModule: (moduleId: string) => void; + registerModuleRef: (moduleId: string, node: HTMLDivElement | null) => void; } function variableToField( @@ -109,6 +110,7 @@ export const ModuleSettingsStep: FC = ({ moduleVariables, onChangeModuleVariables, onRemoveModule, + registerModuleRef, }) => { const { data } = useQuery(templateBuilderModules(baseId)); const modules = data?.modules ?? []; @@ -150,7 +152,11 @@ export const ModuleSettingsStep: FC = ({ const optionalFields = optionalVars.map(toField); return ( -
+
registerModuleRef(mod.id, node)} + className="scroll-mt-24" + > = { title: "pages/TemplateBuilder/SelectionSummary", component: SelectionSummary, + args: { + onNavigateModule: fn(), + }, }; export default meta; @@ -111,14 +114,28 @@ export const WithLongNameModule: Story = { }, ], }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); +}; - const deselectModuleButton = await canvas.findByRole("button", { - name: "Deselect module", +export const NavigateModuleClick: Story = { + args: { + currentStep: 2, + 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" }, + ], + onNavigateModule: fn(), + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + const moduleButton = await canvas.findByRole("button", { + name: "Configure Claude Code", }); - deselectModuleButton.focus(); - await expect(deselectModuleButton).toBeVisible(); + await userEvent.click(moduleButton); + await expect(args.onNavigateModule).toHaveBeenCalledWith("claude-code"); }, }; diff --git a/site/src/pages/TemplateBuilder/SelectionSummary.tsx b/site/src/pages/TemplateBuilder/SelectionSummary.tsx index f6691430486..b29e4b395d1 100644 --- a/site/src/pages/TemplateBuilder/SelectionSummary.tsx +++ b/site/src/pages/TemplateBuilder/SelectionSummary.tsx @@ -22,12 +22,18 @@ type SelectionSummaryProps = { currentStep: number; selectedTemplate?: SelectedTemplate; selectedModules?: SelectedModule[]; + /** + * Jump to a specific module's configuration section. The consumer + * switches to the module settings step and scrolls the module into view. + */ + onNavigateModule: (moduleId: string) => void; }; export const SelectionSummary: React.FC = ({ currentStep, selectedTemplate, selectedModules, + onNavigateModule, }) => { const variant = (step: number) => { if (currentStep === step) return "current"; @@ -49,7 +55,10 @@ export const SelectionSummary: React.FC = ({ Modules {selectedModules ? ( - + ) : ( )} @@ -154,23 +163,32 @@ const BaseTemplateSelection: React.FC = ({ type ModuleSelectionProps = { modules: SelectedModule[]; + onSelectModule: (moduleId: string) => void; }; -const ModuleSelection: React.FC = ({ modules }) => { +const ModuleSelection: React.FC = ({ + modules, + onSelectModule, +}) => { return ( {modules.map((module) => ( -
onSelectModule(module.id)} + aria-label={`Configure ${module.name}`} + className={cn( + "flex items-start w-full text-left p-1 mb-1 rounded-sm bg-transparent border-0 cursor-pointer", + "text-sm text-content-secondary hover:text-content-primary hover:bg-surface-secondary", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-primary", + )} >
- - {module.name} - -
+ {module.name} + ))}
); diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx index 12e776ed201..e5736b6329f 100644 --- a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx +++ b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useReducer, + useRef, } from "react"; import { useQuery } from "react-query"; @@ -177,6 +178,69 @@ export const TemplateBuilderPageView: FC = ({ }); }; + // Maps module id -> its config section node, populated by + // ModuleSettingsStep via callback refs. Used to scroll a module into + // view without relying on DOM ids. + const moduleRefs = useRef(new Map()); + + const registerModuleRef = useCallback( + (moduleId: string, node: HTMLDivElement | null) => { + if (node) { + moduleRefs.current.set(moduleId, node); + } else { + moduleRefs.current.delete(moduleId); + } + }, + [], + ); + + // Holds the module a sidebar click wants to scroll to, so the scroll can + // happen after the module-settings step has rendered. + const pendingModuleScrollRef = useRef(null); + + const scrollModuleIntoView = (moduleId: string) => { + moduleRefs.current.get(moduleId)?.scrollIntoView({ behavior: "smooth" }); + }; + + // Sidebar module rows call this to jump to a module's configuration. + const navigateToModule = (moduleId: string) => { + const settingsIndex = WIZARD_STEPS.findIndex( + (s) => s.id === "module-settings", + ); + const settingsVisible = + settingsIndex >= 0 && !WIZARD_STEPS[settingsIndex].shouldSkip(state); + + // If module-settings is skipped (no configurable vars) there is no + // card to scroll to, so the click is a no-op. + if (!settingsVisible) { + return; + } + + if (currentStep.id === "module-settings") { + scrollModuleIntoView(moduleId); + return; + } + // Remember the target and scroll once the step has rendered. + pendingModuleScrollRef.current = moduleId; + navigateToStep(settingsIndex); + }; + + // Runs after the scroll-reset effect above (declared earlier, so it fires + // first). Scrolls the requested module into view once module-settings + // has rendered. + // biome-ignore lint/correctness/useExhaustiveDependencies: run on step change + useEffect(() => { + if (currentStep.id !== "module-settings") { + return; + } + const moduleId = pendingModuleScrollRef.current; + if (!moduleId) { + return; + } + pendingModuleScrollRef.current = null; + requestAnimationFrame(() => scrollModuleIntoView(moduleId)); + }, [currentStep.id]); + if (isCreating) { return ; } @@ -211,6 +275,7 @@ export const TemplateBuilderPageView: FC = ({ createError, handleProvisionerStatusChange, handleDeselectModule, + registerModuleRef, )}
@@ -235,6 +300,7 @@ export const TemplateBuilderPageView: FC = ({
void, onRemoveModule: (moduleId: string) => void, + registerModuleRef: (moduleId: string, node: HTMLDivElement | null) => void, ): ReactNode { switch (stepId) { case "base-infra": @@ -309,6 +376,7 @@ function renderStepContent( }) } onRemoveModule={onRemoveModule} + registerModuleRef={registerModuleRef} /> ); case "customizations":