diff --git a/src/components/Learn/tours/registry.ts b/src/components/Learn/tours/registry.ts index 2e650546e..6c66085e1 100644 --- a/src/components/Learn/tours/registry.ts +++ b/src/components/Learn/tours/registry.ts @@ -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; }; diff --git a/src/providers/TourProvider/TourPopover.tsx b/src/providers/TourProvider/TourPopover.tsx index cc4ee4b6c..0224a54f1 100644 --- a/src/providers/TourProvider/TourPopover.tsx +++ b/src/providers/TourProvider/TourPopover.tsx @@ -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 }, @@ -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"; } diff --git a/src/providers/TourProvider/tourActionLabels.ts b/src/providers/TourProvider/tourActionLabels.ts index 7aebeb4c8..d3e8be814 100644 --- a/src/providers/TourProvider/tourActionLabels.ts +++ b/src/providers/TourProvider/tourActionLabels.ts @@ -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; } diff --git a/src/routes/v2/pages/Editor/components/EditorTourBridge.test.tsx b/src/routes/v2/pages/Editor/components/EditorTourBridge.test.tsx new file mode 100644 index 000000000..2ecc54aed --- /dev/null +++ b/src/routes/v2/pages/Editor/components/EditorTourBridge.test.tsx @@ -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[]; + setCurrentStep: typeof setCurrentStep; + setSteps: ReturnType; +}; + +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(); +} + +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); + }); +}); diff --git a/src/routes/v2/pages/Editor/components/EditorTourBridge.tsx b/src/routes/v2/pages/Editor/components/EditorTourBridge.tsx index 4639e676e..b1749ec9d 100644 --- a/src/routes/v2/pages/Editor/components/EditorTourBridge.tsx +++ b/src/routes/v2/pages/Editor/components/EditorTourBridge.tsx @@ -3,10 +3,45 @@ import { reaction } from "mobx"; import { useEffect } from "react"; import type { TourStep } from "@/components/Learn/tours/registry"; +import type { ComponentSpec } from "@/models/componentSpec"; import { useTourProgress } from "@/providers/TourProvider/TourProgressContext"; import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext"; import type { WindowStoreImpl } from "@/routes/v2/shared/windows/windowStore"; +type CountInteraction = + | "add-task" + | "add-input" + | "add-output" + | "connect-edge"; + +function isCountInteraction( + interaction: TourStep["interaction"], +): interaction is CountInteraction { + return ( + interaction === "add-task" || + interaction === "add-input" || + interaction === "add-output" || + interaction === "connect-edge" + ); +} + +function countForInteraction( + spec: ComponentSpec | null, + interaction: CountInteraction, +): number { + if (!spec) return 0; + switch (interaction) { + case "add-task": + return spec.tasks.length; + case "add-input": + return spec.inputs.length; + case "add-output": + return spec.outputs.length; + case "connect-edge": + return spec.bindings.length; + } +} + function followWindowPosition( windows: WindowStoreImpl, targetWindowId: string | undefined, @@ -78,13 +113,192 @@ function trackDockStateTransition( } export function EditorTourBridge() { - const { steps, currentStep, isOpen } = useTour(); - const { windows } = useSharedStores(); - const { markStepComplete } = useTourProgress(); + const { steps, currentStep, setCurrentStep, setSteps, isOpen } = useTour(); + const { windows, navigation, editor } = useSharedStores(); + const { markStepComplete, isStepComplete } = useTourProgress(); const step = steps[currentStep] as TourStep | undefined; const interaction = step?.interaction; const targetWindowId = step?.targetWindowId; + const ringSelectors = step?.ringSelectors; + const resetLibrarySearchFlag = step?.resetLibrarySearch ?? false; + const ensureWindowRestoredId = step?.ensureWindowRestored; + const requiresTaskSelected = step?.requiresTaskSelected; + const libraryDragAllow = step?.targetComponentName ?? step?.targetTaskName; + + useEffect(() => { + if (!isOpen) return; + if (!ensureWindowRestoredId) return; + const w = windows.getWindowById(ensureWindowRestoredId); + if (w && (w.state === "hidden" || w.isMinimized)) { + w.restore(); + } + }, [isOpen, ensureWindowRestoredId, currentStep, windows]); + + useEffect(() => { + if (!isOpen) return undefined; + if (!requiresTaskSelected) return undefined; + + const requiredName = requiresTaskSelected.toLowerCase(); + const findSelectStep = (): number | null => { + for (let i = currentStep - 1; i >= 0; i--) { + const s = steps[i] as TourStep | undefined; + if ( + s?.interaction === "select-task" && + s.targetTaskName?.toLowerCase() === requiredName + ) { + return i; + } + } + return null; + }; + + const findRequiredTask = () => + navigation.activeSpec?.tasks.find( + (t) => t.name.toLowerCase() === requiredName, + ); + + const isRequiredTaskSelected = () => { + if (editor.selectedNodeType !== "task") return false; + const spec = navigation.activeSpec; + if (!spec) return false; + const task = spec.tasks.find((t) => t.$id === editor.selectedNodeId); + return task?.name.toLowerCase() === requiredName; + }; + + const dispose = reaction( + () => isRequiredTaskSelected(), + (matches) => { + if (matches) return; + // Re-assert the required selection in place instead of yanking the user + // backward. Sending them to an earlier step they've already completed + // turns "Next" into an inescapable bounce, so only navigate as a last + // resort when the task no longer exists, and never onto a done step. + const task = findRequiredTask(); + if (task) { + editor.selectNode(task.$id, "task"); + return; + } + const target = findSelectStep(); + if (target !== null && !isStepComplete(target)) { + setCurrentStep(target); + } + }, + { fireImmediately: true }, + ); + + return () => dispose(); + }, [ + isOpen, + requiresTaskSelected, + currentStep, + steps, + editor, + navigation, + isStepComplete, + setCurrentStep, + ]); + + useEffect(() => { + if (!isOpen) return undefined; + if (!libraryDragAllow) return undefined; + const allow = libraryDragAllow.toLowerCase(); + const handleDragStart = (event: DragEvent) => { + const target = event.target as Element | null; + if (!target?.closest('[data-dock-window-content="component-library"]')) { + return; + } + const item = + target.querySelector("[data-component-name]") ?? + target.closest("[data-component-name]"); + if (!item) return; + const name = ( + item.getAttribute("data-component-name") ?? "" + ).toLowerCase(); + if (!name.includes(allow)) { + event.preventDefault(); + event.stopPropagation(); + } + }; + document.addEventListener("dragstart", handleDragStart, true); + return () => { + document.removeEventListener("dragstart", handleDragStart, true); + }; + }, [isOpen, libraryDragAllow]); + + const stepSelector = step?.selector; + useEffect(() => { + if (!isOpen) return; + if (!resetLibrarySearchFlag) return; + + const input = document.querySelector( + '[data-testid="search-input"]', + ); + if (input?.value) { + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + if (setter) { + setter.call(input, ""); + input.dispatchEvent(new Event("input", { bubbles: true })); + } + } + + let cancelled = false; + const start = Date.now(); + const wantSelector = typeof stepSelector === "string" ? stepSelector : null; + const tryRefresh = () => { + if (cancelled) return; + const found = wantSelector + ? document.querySelector(wantSelector) + : document.querySelector("[data-folder-name]"); + if (found || Date.now() - start > 1500) { + // Force a re-render in reactour so step.selector is re-queried. + setSteps?.((prev) => [...prev]); + return; + } + window.setTimeout(tryRefresh, 50); + }; + window.setTimeout(tryRefresh, 50); + + return () => { + cancelled = true; + }; + }, [isOpen, currentStep, resetLibrarySearchFlag, setSteps, stepSelector]); + + useEffect(() => { + if (!isOpen) return undefined; + if (!ringSelectors?.length) return undefined; + + const ringed = new Set(); + + const update = () => { + const current = new Set(); + for (const sel of ringSelectors) { + document.querySelectorAll(sel).forEach((el) => current.add(el)); + } + for (const el of ringed) { + if (!current.has(el)) el.classList.remove("tour-ring"); + } + for (const el of current) { + el.classList.add("tour-ring"); + ringed.add(el); + } + for (const el of ringed) { + if (!current.has(el)) ringed.delete(el); + } + }; + + update(); + const observer = new MutationObserver(update); + observer.observe(document.body, { childList: true, subtree: true }); + + return () => { + observer.disconnect(); + for (const el of ringed) el.classList.remove("tour-ring"); + }; + }, [isOpen, ringSelectors]); useEffect(() => { if (!isOpen) return undefined; @@ -153,11 +367,18 @@ export function EditorTourBridge() { } if (interaction === "select-task") { + const targetName = step?.targetTaskName?.toLowerCase(); const handleClick = (event: MouseEvent) => { const target = event.target as Element | null; - if (target?.closest('[data-tour-node="task"]')) { - advance(); + const node = target?.closest('[data-tour-node="task"]'); + if (!node) return; + if (targetName) { + const name = ( + node.getAttribute("data-task-name") ?? "" + ).toLowerCase(); + if (!name.includes(targetName)) return; } + advance(); }; document.addEventListener("click", handleClick); return () => { @@ -166,13 +387,216 @@ export function EditorTourBridge() { }; } + if (interaction === "expand-folder") { + const targetFolderName = step?.targetFolderName; + if (!targetFolderName) return stopFollow; + + const expandedSelector = `[data-folder-name="${targetFolderName}"] [aria-expanded="true"]`; + const isExpanded = () => !!document.querySelector(expandedSelector); + + if (isExpanded()) { + skip(); + return stopFollow; + } + + const observer = new MutationObserver(() => { + if (isExpanded()) advance(); + }); + observer.observe(document.body, { + attributes: true, + attributeFilter: ["aria-expanded"], + subtree: true, + }); + + return () => { + stopFollow(); + observer.disconnect(); + }; + } + + if (interaction === "library-search") { + const sel = '[data-testid="search-input"]'; + const targetTerm = step?.targetSearchTerm?.toLowerCase(); + const matches = () => { + const el = document.querySelector(sel); + if (!el) return false; + const value = el.value.trim().toLowerCase(); + if (targetTerm) return value.includes(targetTerm); + return value.length > 0; + }; + + let advanceTimer: ReturnType | null = null; + const scheduleStep = (move: () => void) => { + if (advanceTimer !== null) return; + advanceTimer = setTimeout(() => { + advanceTimer = null; + move(); + }, 600); + }; + + if (matches()) { + scheduleStep(skip); + } + + const handleInput = (event: Event) => { + const target = event.target as Element | null; + if (target?.matches(sel) && matches()) scheduleStep(advance); + }; + document.addEventListener("input", handleInput, true); + + return () => { + stopFollow(); + document.removeEventListener("input", handleInput, true); + if (advanceTimer !== null) clearTimeout(advanceTimer); + }; + } + + if (interaction === "set-argument") { + const targetArgumentName = step?.targetArgumentName; + if (!targetArgumentName) return stopFollow; + + const hasArgumentValue = () => { + const spec = navigation.activeSpec; + if (!spec) return false; + return spec.tasks.some((task) => + task.arguments.some( + (arg) => + arg.name === targetArgumentName && + typeof arg.value === "string" && + arg.value.trim() !== "", + ), + ); + }; + + if (hasArgumentValue()) { + skip(); + return stopFollow; + } + + const dispose = reaction( + () => hasArgumentValue(), + (matches) => { + if (matches) { + dispose(); + advance(); + } + }, + ); + + return () => { + stopFollow(); + dispose(); + }; + } + + if (interaction === "connect-edge" && step?.targetEdge) { + const target = step.targetEdge; + const hasTargetEdge = () => { + const spec = navigation.activeSpec; + if (!spec) return false; + const sourceTask = spec.tasks.find( + (t) => t.name === target.sourceTaskName, + ); + if (!sourceTask) return false; + + if (!target.targetTaskName) { + return spec.bindings.some( + (b) => + b.sourceEntityId === sourceTask.$id && + b.sourcePortName === target.sourcePortName, + ); + } + + const targetTask = spec.tasks.find( + (t) => t.name === target.targetTaskName, + ); + if (!targetTask) return false; + return spec.bindings.some( + (b) => + b.sourceEntityId === sourceTask.$id && + b.targetEntityId === targetTask.$id && + b.sourcePortName === target.sourcePortName && + b.targetPortName === target.targetPortName, + ); + }; + + if (hasTargetEdge()) { + skip(); + return stopFollow; + } + + const dispose = reaction( + () => hasTargetEdge(), + (matches) => { + if (matches) { + dispose(); + advance(); + } + }, + ); + + return () => { + stopFollow(); + dispose(); + }; + } + + if (interaction === "add-task" && step?.targetTaskName) { + const targetName = step.targetTaskName.toLowerCase(); + + const countMatches = () => { + const spec = navigation.activeSpec; + if (!spec) return 0; + return spec.tasks.filter((t) => + t.name.toLowerCase().includes(targetName), + ).length; + }; + const baseline = countMatches(); + + const dispose = reaction( + () => countMatches(), + (current) => { + if (current > baseline) { + dispose(); + advance(); + } + }, + ); + + return () => { + stopFollow(); + dispose(); + }; + } + + if (isCountInteraction(interaction)) { + const baseline = countForInteraction(navigation.activeSpec, interaction); + + const dispose = reaction( + () => countForInteraction(navigation.activeSpec, interaction), + (current) => { + if (current > baseline) { + dispose(); + advance(); + } + }, + ); + + return () => { + stopFollow(); + dispose(); + }; + } + return stopFollow; }, [ isOpen, interaction, targetWindowId, step, + steps, windows, + navigation, markStepComplete, currentStep, ]); diff --git a/src/styles/editor.css b/src/styles/editor.css index d0b04745a..838afa45c 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -1,3 +1,20 @@ +/* Tour step ring: reactour merges highlightedSelectors into one bounding rect, + so per-element rings need their own mechanism. EditorTourBridge applies + this class to elements matching a step's ringSelectors. */ +.tour-ring { + outline: 2px solid #60a5fa; + outline-offset: 2px; + border-radius: 6px; + position: relative; + z-index: 1; +} + +/* Handles are absolutely positioned by React Flow — don't let .tour-ring + clobber that to relative, which would dump the handle into normal flow. */ +.tour-ring.react-flow__handle { + position: absolute; +} + /* Use the proper box layout model by default, but allow elements to override */ html { box-sizing: border-box; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index fadffff9c..1ed15666f 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -33,7 +33,7 @@ export const ENABLE_GOOGLE_CLOUD_SUBMITTER = export const USER_PIPELINES_LIST_NAME = "user_pipelines"; export const defaultPipelineYamlWithName = (name: string) => ` -name: ${name} +name: ${JSON.stringify(name)} metadata: annotations: sdk: https://cloud-pipelines.net/pipeline-editor/