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

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions site/src/pages/TemplateBuilder/SelectionSummary.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const meta: Meta<typeof SelectionSummary> = {
title: "pages/TemplateBuilder/SelectionSummary",
component: SelectionSummary,
args: {
onNavigateStep: fn(),
onNavigateModule: fn(),
},
};
Expand All @@ -16,6 +17,7 @@ type Story = StoryObj<typeof SelectionSummary>;
export const NoSelection: Story = {
args: {
currentStep: 0,
maxReachedStep: 0,
selectedTemplate: undefined,
selectedModules: undefined,
},
Expand All @@ -24,6 +26,7 @@ export const NoSelection: Story = {
export const BaseTemplateStep: Story = {
args: {
currentStep: 1,
maxReachedStep: 1,
selectedTemplate: undefined,
selectedModules: undefined,
},
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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();
},
};
122 changes: 108 additions & 14 deletions site/src/pages/TemplateBuilder/SelectionSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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.
Expand All @@ -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}
Expand All @@ -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>
Expand Down Expand Up @@ -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">
Expand Down Expand Up @@ -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>
);
};
Expand Down
Loading
Loading