Skip to content
Draft
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
27 changes: 26 additions & 1 deletion src/components/Learn/tours/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,34 @@ import type { StepType } from "@reactour/tour";
import { publicAsset } from "@/utils/publicAsset";

export type TourStep = StepType & {
interaction?: "undock-window" | "redock-window" | "select-task";
interaction?:
| "undock-window"
| "redock-window"
| "select-task"
| "add-task"
| "add-input"
| "add-output"
| "connect-edge"
| "expand-folder"
| "library-search"
| "set-argument";
targetWindowId?: string;
targetWindowName?: string;
targetFolderName?: string;
targetArgumentName?: string;
targetSearchTerm?: string;
targetTaskName?: string;
targetComponentName?: string;
targetEdge?: {
sourceTaskName: string;
sourcePortName: string;
targetTaskName?: string;
targetPortName?: string;
};
ringSelectors?: string[];
resetLibrarySearch?: boolean;
ensureWindowRestored?: string;
requiresTaskSelected?: string;
fallbackContent?: string;
};

Expand Down
26 changes: 20 additions & 6 deletions src/providers/TourProvider/TourPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ export const POPOVER_STYLES = {
...base,
rx: 6,
}),
clickArea: (base: object) => ({
...base,
pointerEvents: "none" as const,
}),
highlightedArea: (
base: object,
state?: { width?: number; height?: number },
Expand Down Expand Up @@ -79,20 +83,30 @@ export function computeDefaultPopoverPosition(
props: PositionProps,
): ResolvedPosition {
const targetHeight = props.bottom - props.top;
const isTallStrip = targetHeight > props.windowHeight * 0.5;
const margin = 16;

const isFullHeightRightStrip =
props.right >= props.windowWidth - 4 &&
targetHeight > props.windowHeight * 0.5;

if (isFullHeightRightStrip) {
// Right-anchored full-height strip (e.g. right sidebar): place popover to
// its LEFT. Reactour's "left" fallback can swap to "top"/"bottom" for tall
// targets, so we return explicit coords.
if (isTallStrip && props.right >= props.windowWidth - 4) {
const popoverWidth = props.width || 380;
const margin = 16;
return [
Math.max(margin, props.left - popoverWidth - margin),
Math.max(props.top + margin, 64),
];
}

// Left-anchored full-height strip (e.g. left dock): place popover to its
// RIGHT. Same reason — reactour's "right" fallback drops to "top" for tall
// targets even when there's plenty of room horizontally. We test the
// target's right edge against the viewport midline rather than its left
// edge against zero, so a dock that isn't flush to the window edge still
// qualifies.
if (isTallStrip && props.right < props.windowWidth * 0.5) {
return [props.right + margin, Math.max(props.top + margin, 64)];
}

return "bottom";
}

Expand Down
22 changes: 22 additions & 0 deletions src/providers/TourProvider/tourActionLabels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,28 @@ export function tourActionLabel(step: TourActionLabelInput): string {
return step.targetWindowName
? `Dock the **${step.targetWindowName}** panel back into place`
: "Dock the panel back into place";
case "expand-folder":
return step.targetFolderName
? `Open the **${step.targetFolderName}** folder`
: "Open the highlighted folder";
case "library-search":
return step.targetSearchTerm
? `Search the library for **${step.targetSearchTerm}**`
: "Search the component library";
case "add-task":
return step.targetTaskName
? `Add **${step.targetTaskName}** to the canvas`
: "Add the highlighted component to the canvas";
case "add-input":
return "Add an input node to the canvas";
case "add-output":
return "Add an output node to the canvas";
case "connect-edge":
return "Connect the highlighted ports";
case "set-argument":
return step.targetArgumentName
? `Set the **${step.targetArgumentName}** value`
: "Set the highlighted value";
default:
return GENERIC_LABEL;
}
Expand Down
135 changes: 135 additions & 0 deletions src/routes/v2/pages/Editor/components/EditorTourBridge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";

import { EditorTourBridge } from "./EditorTourBridge";

type Task = { $id: string; name: string; arguments: unknown[] };

const setCurrentStep = vi.fn();
const selectNode = vi.fn();
const markStepComplete = vi.fn();
const isStepComplete = vi.fn<(step: number) => boolean>(() => false);

let editorState: {
selectedNodeType: string | null;
selectedNodeId: string | null;
multiSelection: unknown[];
selectNode: typeof selectNode;
};
let navigationState: { activeSpec: { tasks: Task[] } | null };
let tourState: {
isOpen: boolean;
currentStep: number;
steps: Record<string, unknown>[];
setCurrentStep: typeof setCurrentStep;
setSteps: ReturnType<typeof vi.fn>;
};

vi.mock("@reactour/tour", () => ({
useTour: () => tourState,
}));

vi.mock("@xyflow/react", () => ({
useViewport: () => ({ x: 0, y: 0, zoom: 1 }),
}));

vi.mock("@/routes/v2/shared/store/SharedStoreContext", () => ({
useSharedStores: () => ({
windows: { getWindowById: () => undefined, getAllWindows: () => [] },
navigation: navigationState,
editor: editorState,
}),
}));

vi.mock("@/providers/TourProvider/TourProgressContext", () => ({
useTourProgress: () => ({ markStepComplete, isStepComplete }),
}));

const trainTask: Task = {
$id: "task-train",
name: "Train XGBoost model on CSV",
arguments: [],
};

function renderBridge(opts: {
selectedNodeId?: string | null;
selectedNodeType?: string | null;
tasks?: Task[];
stepComplete?: boolean;
}) {
isStepComplete.mockReturnValue(opts.stepComplete ?? false);
editorState = {
selectedNodeType: opts.selectedNodeType ?? null,
selectedNodeId: opts.selectedNodeId ?? null,
multiSelection: [],
selectNode,
};
navigationState = {
activeSpec: { tasks: opts.tasks ?? [trainTask] },
};
tourState = {
isOpen: true,
currentStep: 1,
steps: [
{
selector: "body",
content: "select",
interaction: "select-task",
targetTaskName: trainTask.name,
},
{
selector: "body",
content: "requires",
requiresTaskSelected: trainTask.name,
},
],
setCurrentStep,
setSteps: vi.fn(),
};
return render(<EditorTourBridge />);
}

describe("EditorTourBridge requiresTaskSelected guard", () => {
afterEach(() => vi.clearAllMocks());

it("re-asserts the required selection instead of navigating backward", () => {
renderBridge({ selectedNodeType: null, selectedNodeId: null });

expect(selectNode).toHaveBeenCalledWith(trainTask.$id, "task");
expect(setCurrentStep).not.toHaveBeenCalled();
});

it("does nothing when the required task is already selected", () => {
renderBridge({
selectedNodeType: "task",
selectedNodeId: trainTask.$id,
});

expect(selectNode).not.toHaveBeenCalled();
expect(setCurrentStep).not.toHaveBeenCalled();
});

it("never bounces back onto an already-completed select-task step", () => {
renderBridge({
selectedNodeType: null,
selectedNodeId: null,
tasks: [],
stepComplete: true,
});

expect(selectNode).not.toHaveBeenCalled();
expect(setCurrentStep).not.toHaveBeenCalled();
});

it("falls back to the select-task step only when the task is gone and that step is not complete", () => {
renderBridge({
selectedNodeType: null,
selectedNodeId: null,
tasks: [],
stepComplete: false,
});

expect(selectNode).not.toHaveBeenCalled();
expect(setCurrentStep).toHaveBeenCalledWith(0);
});
});
Loading
Loading