Thanks to visit codestin.com
Credit goes to github.com

Skip to content
8 changes: 7 additions & 1 deletion site/src/pages/TemplateBuilder/ModuleSettingsStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ interface ModuleSettingsStepProps {
variables: Record<string, string>,
) => void;
onRemoveModule: (moduleId: string) => void;
registerModuleRef: (moduleId: string, node: HTMLDivElement | null) => void;
}

function variableToField(
Expand Down Expand Up @@ -109,6 +110,7 @@ export const ModuleSettingsStep: FC<ModuleSettingsStepProps> = ({
moduleVariables,
onChangeModuleVariables,
onRemoveModule,
registerModuleRef,
}) => {
const { data } = useQuery(templateBuilderModules(baseId));
const modules = data?.modules ?? [];
Expand Down Expand Up @@ -150,7 +152,11 @@ export const ModuleSettingsStep: FC<ModuleSettingsStepProps> = ({
const optionalFields = optionalVars.map(toField);

return (
<div key={mod.id}>
<div
key={mod.id}
ref={(node) => registerModuleRef(mod.id, node)}
className="scroll-mt-24"
>
<ModuleConfiguration
name={mod.display_name}
description={mod.description}
Expand Down
31 changes: 24 additions & 7 deletions site/src/pages/TemplateBuilder/SelectionSummary.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import { expect, fn, userEvent, within } from "storybook/test";
import { SelectionSummary } from "./SelectionSummary";

const meta: Meta<typeof SelectionSummary> = {
title: "pages/TemplateBuilder/SelectionSummary",
component: SelectionSummary,
args: {
onNavigateModule: fn(),
},
};

export default meta;
Expand Down Expand Up @@ -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");
},
};

Expand Down
34 changes: 26 additions & 8 deletions site/src/pages/TemplateBuilder/SelectionSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SelectionSummaryProps> = ({
currentStep,
selectedTemplate,
selectedModules,
onNavigateModule,
}) => {
const variant = (step: number) => {
if (currentStep === step) return "current";
Expand All @@ -49,7 +55,10 @@ export const SelectionSummary: React.FC<SelectionSummaryProps> = ({
<VariantContext.Provider value={variant(2)}>
<StepIndicator step={2}>Modules</StepIndicator>
{selectedModules ? (
<ModuleSelection modules={selectedModules} />
<ModuleSelection
modules={selectedModules}
onSelectModule={onNavigateModule}
/>
) : (
<StepDivider />
)}
Expand Down Expand Up @@ -154,23 +163,32 @@ const BaseTemplateSelection: React.FC<BaseTemplateSelectionProps> = ({

type ModuleSelectionProps = {
modules: SelectedModule[];
onSelectModule: (moduleId: string) => void;
};

const ModuleSelection: React.FC<ModuleSelectionProps> = ({ modules }) => {
const ModuleSelection: React.FC<ModuleSelectionProps> = ({
modules,
onSelectModule,
}) => {
return (
<StepDivider className="max-h-72 overflow-y-auto">
{modules.map((module) => (
<div
<button
key={module.id}
className="group flex items-start justify-between p-1 mb-1 rounded-sm"
type="button"
onClick={() => 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",
)}
>
<div className="h-[1lh] content-center">
<Avatar src={module.iconUrl} size="sm" variant="icon" />
</div>
<span className="flex-1 ml-2 text-content-secondary">
{module.name}
</span>
</div>
<span className="flex-1 ml-2">{module.name}</span>
</button>
))}
</StepDivider>
);
Expand Down
68 changes: 68 additions & 0 deletions site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
useCallback,
useEffect,
useReducer,
useRef,
} from "react";

import { useQuery } from "react-query";
Expand Down Expand Up @@ -177,6 +178,69 @@ export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
});
};

// 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<string, HTMLDivElement>());

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<string | null>(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 <BuildingTemplateLoader />;
}
Expand Down Expand Up @@ -211,6 +275,7 @@ export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
createError,
handleProvisionerStatusChange,
handleDeselectModule,
registerModuleRef,
)}
</div>

Expand All @@ -235,6 +300,7 @@ export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
<div className="w-64 shrink-0 hidden md:block sticky top-[72px] self-start">
<SelectionSummary
currentStep={currentStep.group}
onNavigateModule={navigateToModule}
selectedTemplate={
state.selectedBase
? {
Expand Down Expand Up @@ -263,6 +329,7 @@ function renderStepContent(
createError: Error | null,
onProvisionerStatusChange: (value: boolean | undefined) => void,
onRemoveModule: (moduleId: string) => void,
registerModuleRef: (moduleId: string, node: HTMLDivElement | null) => void,
): ReactNode {
switch (stepId) {
case "base-infra":
Expand Down Expand Up @@ -309,6 +376,7 @@ function renderStepContent(
})
}
onRemoveModule={onRemoveModule}
registerModuleRef={registerModuleRef}
/>
);
case "customizations":
Expand Down
Loading