diff --git a/src/components/Learn/tours/navigatingEditor.tour.json b/src/components/Learn/tours/navigatingEditor.tour.json index d697e759c..6e3bb121d 100644 --- a/src/components/Learn/tours/navigatingEditor.tour.json +++ b/src/components/Learn/tours/navigatingEditor.tour.json @@ -81,8 +81,7 @@ "stepInteraction": true, "interaction": "undock-window", "targetWindowId": "context-panel", - "targetWindowName": "Task Properties", - "fallbackContent": "Windows are flexible. Try grabbing the Task Properties header and dragging it around the canvas." + "targetWindowName": "Task Properties" }, { "selector": "[data-window-id=\"context-panel\"]", @@ -91,8 +90,7 @@ "stepInteraction": true, "interaction": "redock-window", "targetWindowId": "context-panel", - "targetWindowName": "Task Properties", - "fallbackContent": "Windows can be docked in either sidebar. Create the perfect layout that suits you!" + "targetWindowName": "Task Properties" }, { "selector": "[data-tracking-id=\"v2.pipeline_editor.windows_menu\"]", diff --git a/src/components/Learn/tours/registry.ts b/src/components/Learn/tours/registry.ts index d1d61b474..1c5296d34 100644 --- a/src/components/Learn/tours/registry.ts +++ b/src/components/Learn/tours/registry.ts @@ -10,7 +10,6 @@ export type TourStep = StepType & resetLibrarySearch?: boolean; ensureWindowRestored?: string; requiresTaskSelected?: string; - fallbackContent?: string; }; export interface TourDefinition { diff --git a/src/components/shared/SubgraphBreadcrumbsView.tsx b/src/components/shared/SubgraphBreadcrumbsView.tsx index 375b1ce20..9f0634329 100644 --- a/src/components/shared/SubgraphBreadcrumbsView.tsx +++ b/src/components/shared/SubgraphBreadcrumbsView.tsx @@ -55,7 +55,6 @@ export const SubgraphBreadcrumbsView = ({ onClick={() => onNavigate(index)} className="h-6 px-2" data-tour-crumb={isRoot ? "root" : "ancestor"} - data-tour-crumb-index={index} {...(getCrumbTracking?.(index) ?? {})} > {isRoot ? ( diff --git a/src/routes/v2/pages/Editor/EditorV2.tsx b/src/routes/v2/pages/Editor/EditorV2.tsx index 5d032fdef..850b97631 100644 --- a/src/routes/v2/pages/Editor/EditorV2.tsx +++ b/src/routes/v2/pages/Editor/EditorV2.tsx @@ -36,7 +36,7 @@ import { createEditorAgentWorker } from "./components/AiChat/editorAgentWorker"; import { useDebugPanelWindow } from "./components/DebugPanel"; import { DriverPermissionGate } from "./components/DriverPermissionGate"; import { EditorMenuBar } from "./components/EditorMenuBar/EditorMenuBar"; -import { EditorTourBridge } from "./components/EditorTourBridge"; +import { EditorTourBridge } from "./components/EditorTourBridge/EditorTourBridge"; import { EmptyEditorState } from "./components/EmptyEditorState"; import { FlowCanvas } from "./components/FlowCanvas/FlowCanvas"; import { useAiChatWindow } from "./hooks/useAiChatWindow"; diff --git a/src/routes/v2/pages/Editor/components/EditorMenuBar/EditorMenuBar.tsx b/src/routes/v2/pages/Editor/components/EditorMenuBar/EditorMenuBar.tsx index 299e2b2f6..b95f1a0f3 100644 --- a/src/routes/v2/pages/Editor/components/EditorMenuBar/EditorMenuBar.tsx +++ b/src/routes/v2/pages/Editor/components/EditorMenuBar/EditorMenuBar.tsx @@ -54,7 +54,6 @@ export const EditorMenuBar = observer(function EditorMenuBar() {
void { - if (!targetWindowId) return () => undefined; - - let rafId: number | null = null; - const dispose = reaction( - () => { - const w = windows.getWindowById(targetWindowId); - return w ? `${w.position.x},${w.position.y}` : ""; - }, - () => { - if (rafId !== null) return; - rafId = requestAnimationFrame(() => { - rafId = null; - window.dispatchEvent(new Event("resize")); - }); - }, - ); - - return () => { - dispose(); - if (rafId !== null) cancelAnimationFrame(rafId); - }; -} - -function trackDockStateTransition( - windows: WindowStoreImpl, - matchInitial: (w: { dockState: string }) => boolean, - matchTransition: (w: { dockState: string }) => boolean, - targetWindowId?: string, -): { didTransition: () => boolean; dispose: () => void } { - const baseline = new Set(); - for (const w of windows.getAllWindows()) { - if (targetWindowId ? w.id === targetWindowId : matchInitial(w)) { - baseline.add(w.id); - } - } - let fired = false; - - const stateReaction = reaction( - () => - windows - .getAllWindows() - .map((w) => `${w.id}:${w.dockState}`) - .join("|"), - () => { - for (const w of windows.getAllWindows()) { - if (targetWindowId) { - if (w.id === targetWindowId && matchTransition(w)) { - fired = true; - } - continue; - } - if (matchInitial(w)) { - baseline.add(w.id); - } else if (baseline.has(w.id) && matchTransition(w)) { - fired = true; - } - } - }, - ); - - return { - didTransition: () => fired, - dispose: stateReaction, - }; -} - -export function EditorTourBridge() { - const { steps, currentStep, setCurrentStep, setSteps, isOpen } = useTour(); - const { windows, navigation, editor } = useSharedStores(); - const { markStepComplete, isStepComplete } = useTourProgress(); - const { x: viewportX, y: viewportY, zoom: viewportZoom } = useViewport(); - - useEffect(() => { - if (!isOpen) return; - const rafId = requestAnimationFrame(() => { - window.dispatchEvent(new Event("resize")); - }); - return () => cancelAnimationFrame(rafId); - }, [isOpen, viewportX, viewportY, viewportZoom]); - - 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; - const stepSelector = step?.selector; - - useEffect(() => { - if (!isOpen) return; - if (!ensureWindowRestoredId) return; - const w = windows.getWindowById(ensureWindowRestoredId); - const wasHidden = !!w && (w.state === "hidden" || w.isMinimized); - if (wasHidden) { - w.restore(); - } - if (!w) return; - - let cancelled = false; - const start = Date.now(); - const wantSelector = typeof stepSelector === "string" ? stepSelector : null; - const waitForDom = () => { - if (cancelled) return; - const found = wantSelector - ? document.querySelector(wantSelector) - : document.querySelector( - `[data-dock-window="${ensureWindowRestoredId}"]`, - ); - if (found || Date.now() - start > 1500) { - setSteps?.((prev) => [...prev]); - return; - } - window.setTimeout(waitForDom, 50); - }; - window.setTimeout(waitForDom, 50); - - return () => { - cancelled = true; - }; - }, [ - isOpen, - ensureWindowRestoredId, - currentStep, - windows, - stepSelector, - setSteps, - ]); - - 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; - 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]); - - 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; - - // Run outside the interaction branch so informational/fallback steps that - // target a floating window still track its position. - const stopFollow = followWindowPosition(windows, targetWindowId); - - if (!interaction) return stopFollow; - - // Gated progression: completing the interaction (or finding it already - // satisfied on entry) marks the step done so "Next" enables. Advancing is - // the user's click, handled by the popover. - const advance = () => markStepComplete(currentStep); - const skip = () => markStepComplete(currentStep); - const skipWithFallback = (_step: TourStep) => markStepComplete(currentStep); - - if (interaction === "undock-window" || interaction === "redock-window") { - const isDocked = (w: { dockState: string }) => w.dockState !== "none"; - const isUndocked = (w: { dockState: string }) => w.dockState === "none"; - const matchInitial = - interaction === "undock-window" ? isDocked : isUndocked; - const matchTransition = - interaction === "undock-window" ? isUndocked : isDocked; - - if (targetWindowId) { - const target = windows.getWindowById(targetWindowId); - if (!target || matchTransition(target)) { - skipWithFallback(step); - return stopFollow; - } - } else { - const hasSourceWindow = windows - .getAllWindows() - .some((w) => w.state !== "hidden" && matchInitial(w)); - if (!hasSourceWindow) { - if (step) skipWithFallback(step); - else skip(); - return stopFollow; - } - } - - const tracker = trackDockStateTransition( - windows, - matchInitial, - matchTransition, - targetWindowId, - ); - - let pendingCheck: ReturnType | null = null; - const handleMouseUp = () => { - if (pendingCheck !== null) clearTimeout(pendingCheck); - pendingCheck = setTimeout(() => { - pendingCheck = null; - if (tracker.didTransition()) advance(); - }, 0); - }; - document.addEventListener("mouseup", handleMouseUp); - - return () => { - stopFollow(); - tracker.dispose(); - if (pendingCheck !== null) clearTimeout(pendingCheck); - document.removeEventListener("mouseup", handleMouseUp); - }; - } - - if (interaction === "select-task") { - const targetName = step?.targetTaskName?.toLowerCase(); - const handleClick = (event: MouseEvent) => { - const target = event.target as Element | null; - 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 () => { - stopFollow(); - document.removeEventListener("click", handleClick); - }; - } - - 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 === "assign-secret-argument") { - const targetArgumentName = step?.targetArgumentName; - - const hasSecretArgument = () => { - const spec = navigation.activeSpec; - if (!spec) return false; - return spec.tasks.some((task) => - task.arguments.some( - (arg) => - (!targetArgumentName || arg.name === targetArgumentName) && - isSecretArgument(arg.value), - ), - ); - }; - - if (hasSecretArgument()) { - skip(); - return stopFollow; - } - - const dispose = reaction( - () => hasSecretArgument(), - (matches) => { - if (matches) { - dispose(); - advance(); - } - }, - ); - - return () => { - stopFollow(); - dispose(); - }; - } - - if (interaction === "open-secret-dialog") { - const dialogSelector = '[data-testid="select-secret-dialog"]'; - const isDialogOpen = () => !!document.querySelector(dialogSelector); - - if (isDialogOpen()) { - skip(); - return stopFollow; - } - - const observer = new MutationObserver(() => { - if (isDialogOpen()) { - observer.disconnect(); - advance(); - } - }); - observer.observe(document.body, { childList: true, subtree: true }); - - return () => { - stopFollow(); - observer.disconnect(); - }; - } - - if (interaction === "open-settings-panel") { - const panelSelector = '[data-tour="tour-settings-dialog"]'; - const isPanelOpen = () => !!document.querySelector(panelSelector); - - if (isPanelOpen()) { - skip(); - return stopFollow; - } - - const observer = new MutationObserver(() => { - if (isPanelOpen()) { - observer.disconnect(); - advance(); - } - }); - observer.observe(document.body, { childList: true, subtree: true }); - - return () => { - stopFollow(); - observer.disconnect(); - }; - } - - if (interaction === "open-submit-dialog") { - const dialogSelector = '[data-tour="submit-arguments-dialog"]'; - const isDialogOpen = () => !!document.querySelector(dialogSelector); - - if (isDialogOpen()) { - skip(); - return stopFollow; - } - - const observer = new MutationObserver(() => { - if (isDialogOpen()) { - observer.disconnect(); - advance(); - } - }); - observer.observe(document.body, { childList: true, subtree: true }); - - return () => { - stopFollow(); - observer.disconnect(); - }; - } - - if (interaction === "assign-secret-submit") { - const secretSelector = - '[data-tour="submit-arguments-dialog"] [data-testid="dynamic-data-argument-input"]'; - const hasSecret = () => !!document.querySelector(secretSelector); - - if (hasSecret()) { - skip(); - return stopFollow; - } - - const observer = new MutationObserver(() => { - if (hasSecret()) { - observer.disconnect(); - advance(); - } - }); - observer.observe(document.body, { childList: true, subtree: true }); - - return () => { - stopFollow(); - observer.disconnect(); - }; - } - - 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 (interaction === "navigate-into-subgraph") { - const targetName = step?.targetTaskName?.toLowerCase(); - const baselineDepth = navigation.navigationDepth; - - const matches = () => { - if (navigation.navigationDepth <= baselineDepth) return false; - if (!targetName) return true; - const last = - navigation.navigationPath[navigation.navigationPath.length - 1]; - return last?.displayName.toLowerCase() === targetName; - }; - - if (matches()) { - skip(); - return stopFollow; - } - - const dispose = reaction( - () => matches(), - (m) => { - if (m) { - dispose(); - advance(); - } - }, - ); - - return () => { - stopFollow(); - dispose(); - }; - } - - if (interaction === "navigate-to-root") { - const isAtRoot = () => navigation.navigationDepth === 0; - - if (isAtRoot()) { - skip(); - return stopFollow; - } - - const dispose = reaction( - () => isAtRoot(), - (m) => { - if (m) { - dispose(); - advance(); - } - }, - ); - - return () => { - stopFollow(); - dispose(); - }; - } - - if (interaction === "unpack-subgraph") { - const countSubgraphTasks = () => { - const spec = navigation.activeSpec; - if (!spec) return 0; - return spec.tasks.filter((t) => t.subgraphSpec !== undefined).length; - }; - const baseline = countSubgraphTasks(); - - const dispose = reaction( - () => countSubgraphTasks(), - (current) => { - if (current < baseline) { - dispose(); - advance(); - } - }, - ); - - return () => { - stopFollow(); - dispose(); - }; - } - - if (interaction === "multi-select-tasks") { - const minCount = step?.targetMinCount ?? 2; - - const taskSelectionCount = () => - editor.multiSelection.filter((n) => n.type === "task").length; - - if (taskSelectionCount() >= minCount) { - skip(); - return stopFollow; - } - - const dispose = reaction( - () => taskSelectionCount(), - (current) => { - if (current >= minCount) { - dispose(); - advance(); - } - }, - ); - - return () => { - stopFollow(); - dispose(); - }; - } - - if (interaction === "create-subgraph") { - const countSubgraphTasks = () => { - const spec = navigation.activeSpec; - if (!spec) return 0; - return spec.tasks.filter((t) => t.subgraphSpec !== undefined).length; - }; - const baseline = countSubgraphTasks(); - - const dispose = reaction( - () => countSubgraphTasks(), - (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, - ]); - - return null; -} diff --git a/src/routes/v2/pages/Editor/components/EditorTourBridge.test.tsx b/src/routes/v2/pages/Editor/components/EditorTourBridge/EditorTourBridge.test.tsx similarity index 100% rename from src/routes/v2/pages/Editor/components/EditorTourBridge.test.tsx rename to src/routes/v2/pages/Editor/components/EditorTourBridge/EditorTourBridge.test.tsx diff --git a/src/routes/v2/pages/Editor/components/EditorTourBridge/EditorTourBridge.tsx b/src/routes/v2/pages/Editor/components/EditorTourBridge/EditorTourBridge.tsx new file mode 100644 index 000000000..34eedfc17 --- /dev/null +++ b/src/routes/v2/pages/Editor/components/EditorTourBridge/EditorTourBridge.tsx @@ -0,0 +1,82 @@ +import { useTour } from "@reactour/tour"; +import { useViewport } from "@xyflow/react"; + +import { useTourProgress } from "@/providers/TourProvider/TourProgressContext"; +import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext"; + +import { + useEnsureWindowRestored, + useInteractionGate, + useLibraryDragGate, + useRequiresTaskSelected, + useResetLibrarySearch, + useRingSelectors, + useViewportResizeDispatch, +} from "./editorTourBridge.hooks"; +import { asTourStep } from "./editorTourBridge.utils"; + +/** + * Render-null bridge between the active guided tour and editor state. It watches + * DOM/store changes and marks the current step complete when its interaction is + * satisfied. Each concern lives in its own hook; the per-interaction completion + * logic lives in the INTERACTION_HANDLERS registry. + */ +export function EditorTourBridge() { + const { steps, currentStep, setCurrentStep, setSteps, isOpen } = useTour(); + const { windows, navigation, editor } = useSharedStores(); + const { markStepComplete, isStepComplete } = useTourProgress(); + const { x: viewportX, y: viewportY, zoom: viewportZoom } = useViewport(); + + const step = asTourStep(steps[currentStep]); + + useViewportResizeDispatch({ isOpen, viewportX, viewportY, viewportZoom }); + + useEnsureWindowRestored({ + isOpen, + ensureWindowRestoredId: step?.ensureWindowRestored, + currentStep, + windows, + stepSelector: step?.selector, + setSteps, + }); + + useRequiresTaskSelected({ + isOpen, + requiresTaskSelected: step?.requiresTaskSelected, + currentStep, + steps, + editor, + navigation, + isStepComplete, + setCurrentStep, + }); + + useLibraryDragGate({ + isOpen, + libraryDragAllow: step?.targetComponentName ?? step?.targetTaskName, + }); + + useResetLibrarySearch({ + isOpen, + currentStep, + resetLibrarySearchFlag: step?.resetLibrarySearch ?? false, + setSteps, + stepSelector: step?.selector, + }); + + useRingSelectors({ isOpen, ringSelectors: step?.ringSelectors }); + + useInteractionGate({ + isOpen, + interaction: step?.interaction, + step, + targetWindowId: step?.targetWindowId, + windows, + navigation, + editor, + markStepComplete, + currentStep, + }); + + return null; +} diff --git a/src/routes/v2/pages/Editor/components/EditorTourBridge/editorTourBridge.hooks.ts b/src/routes/v2/pages/Editor/components/EditorTourBridge/editorTourBridge.hooks.ts new file mode 100644 index 000000000..a635dd880 --- /dev/null +++ b/src/routes/v2/pages/Editor/components/EditorTourBridge/editorTourBridge.hooks.ts @@ -0,0 +1,372 @@ +import type { StepType } from "@reactour/tour"; +import { reaction } from "mobx"; +import { type Dispatch, type SetStateAction, useEffect } from "react"; + +import type { TourStep } from "@/components/Learn/tours/registry"; +import type { TourInteraction } from "@/providers/TourProvider/tourActions"; +import type { EditorStore } from "@/routes/v2/shared/store/editorStore"; +import type { NavigationStore } from "@/routes/v2/shared/store/navigationStore"; +import type { WindowStoreImpl } from "@/routes/v2/shared/windows/windowStore"; + +import { INTERACTION_HANDLERS } from "./editorTourBridge.interactions"; +import { + asTourStep, + elementFromEvent, + followWindowPosition, + pollForSelectorThenRefreshSteps, +} from "./editorTourBridge.utils"; + +type SetSteps = Dispatch> | undefined; + +interface ViewportResizeArgs { + isOpen: boolean; + viewportX: number; + viewportY: number; + viewportZoom: number; +} + +/** Nudge floating windows/popovers to re-measure as the canvas pans or zooms. */ +export function useViewportResizeDispatch({ + isOpen, + viewportX, + viewportY, + viewportZoom, +}: ViewportResizeArgs): void { + useEffect(() => { + if (!isOpen) return; + const rafId = requestAnimationFrame(() => { + window.dispatchEvent(new Event("resize")); + }); + return () => cancelAnimationFrame(rafId); + }, [isOpen, viewportX, viewportY, viewportZoom]); +} + +interface EnsureWindowRestoredArgs { + isOpen: boolean; + ensureWindowRestoredId: string | undefined; + currentStep: number; + windows: WindowStoreImpl; + stepSelector: TourStep["selector"] | undefined; + setSteps: SetSteps; +} + +/** Restore a hidden/minimized window a step targets, then refresh once it mounts. */ +export function useEnsureWindowRestored({ + isOpen, + ensureWindowRestoredId, + currentStep, + windows, + stepSelector, + setSteps, +}: EnsureWindowRestoredArgs): void { + useEffect(() => { + if (!isOpen) return; + if (!ensureWindowRestoredId) return; + const w = windows.getWindowById(ensureWindowRestoredId); + const wasHidden = !!w && (w.state === "hidden" || w.isMinimized); + if (wasHidden) { + w.restore(); + } + if (!w) return; + + const wantSelector = typeof stepSelector === "string" ? stepSelector : null; + return pollForSelectorThenRefreshSteps( + wantSelector, + `[data-dock-window="${ensureWindowRestoredId}"]`, + setSteps, + ); + }, [ + isOpen, + ensureWindowRestoredId, + currentStep, + windows, + stepSelector, + setSteps, + ]); +} + +interface RequiresTaskSelectedArgs { + isOpen: boolean; + requiresTaskSelected: string | undefined; + currentStep: number; + steps: StepType[]; + editor: EditorStore; + navigation: NavigationStore; + isStepComplete: (step: number) => boolean; + setCurrentStep: Dispatch>; +} + +/** + * Keep the task a step depends on selected: re-select it if the user clicks + * away, or rewind to the earlier select-task step if it no longer exists. + */ +export function useRequiresTaskSelected({ + isOpen, + requiresTaskSelected, + currentStep, + steps, + editor, + navigation, + isStepComplete, + setCurrentStep, +}: RequiresTaskSelectedArgs): void { + 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 = asTourStep(steps[i]); + 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; + 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, + ]); +} + +interface LibraryDragGateArgs { + isOpen: boolean; + libraryDragAllow: string | undefined; +} + +/** Block dragging library components other than the one the step calls for. */ +export function useLibraryDragGate({ + isOpen, + libraryDragAllow, +}: LibraryDragGateArgs): void { + useEffect(() => { + if (!isOpen) return undefined; + if (!libraryDragAllow) return undefined; + const allow = libraryDragAllow.toLowerCase(); + const handleDragStart = (event: DragEvent) => { + const target = elementFromEvent(event); + 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]); +} + +interface ResetLibrarySearchArgs { + isOpen: boolean; + currentStep: number; + resetLibrarySearchFlag: boolean; + setSteps: SetSteps; + stepSelector: TourStep["selector"] | undefined; +} + +/** Clear the component-library search box when a step asks for a clean slate. */ +export function useResetLibrarySearch({ + isOpen, + currentStep, + resetLibrarySearchFlag, + setSteps, + stepSelector, +}: ResetLibrarySearchArgs): void { + 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 })); + } + } + + const wantSelector = typeof stepSelector === "string" ? stepSelector : null; + return pollForSelectorThenRefreshSteps( + wantSelector, + "[data-folder-name]", + setSteps, + ); + }, [isOpen, currentStep, resetLibrarySearchFlag, setSteps, stepSelector]); +} + +interface RingSelectorsArgs { + isOpen: boolean; + ringSelectors: string[] | undefined; +} + +/** Maintain the `.tour-ring` highlight on the elements a step points at. */ +export function useRingSelectors({ + isOpen, + ringSelectors, +}: RingSelectorsArgs): void { + 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); + } + }; + + // Coalesce mutation bursts (ReactFlow churns node DOM on pan/zoom/edit) into + // at most one re-query per frame. + let rafId: number | null = null; + const scheduleUpdate = () => { + if (rafId !== null) return; + rafId = requestAnimationFrame(() => { + rafId = null; + update(); + }); + }; + + update(); + const observer = new MutationObserver(scheduleUpdate); + observer.observe(document.body, { childList: true, subtree: true }); + + return () => { + observer.disconnect(); + if (rafId !== null) cancelAnimationFrame(rafId); + for (const el of ringed) el.classList.remove("tour-ring"); + }; + }, [isOpen, ringSelectors]); +} + +interface InteractionGateArgs { + isOpen: boolean; + interaction: TourInteraction | undefined; + step: TourStep | undefined; + targetWindowId: string | undefined; + windows: WindowStoreImpl; + navigation: NavigationStore; + editor: EditorStore; + markStepComplete: (step: number) => void; + currentStep: number; +} + +/** + * Dispatch the active step's interaction to its handler. Window-position + * following runs even for informational (non-interaction) steps so a popover + * anchored to a floating window keeps tracking it. + */ +export function useInteractionGate({ + isOpen, + interaction, + step, + targetWindowId, + windows, + navigation, + editor, + markStepComplete, + currentStep, +}: InteractionGateArgs): void { + useEffect(() => { + if (!isOpen) return undefined; + + const stopFollow = followWindowPosition(windows, targetWindowId); + if (!interaction || !step) return stopFollow; + + // Gated progression: completing the interaction (or finding it already + // satisfied on entry) marks the step done so "Next" enables. Advancing is + // the user's click, handled by the popover. + const complete = () => markStepComplete(currentStep); + const handler = INTERACTION_HANDLERS[interaction]; + if (!handler) return stopFollow; + + return handler({ + step, + windows, + navigation, + editor, + targetWindowId, + complete, + stopFollow, + }); + }, [ + isOpen, + interaction, + step, + targetWindowId, + windows, + navigation, + editor, + markStepComplete, + currentStep, + ]); +} diff --git a/src/routes/v2/pages/Editor/components/EditorTourBridge/editorTourBridge.interactions.test.ts b/src/routes/v2/pages/Editor/components/EditorTourBridge/editorTourBridge.interactions.test.ts new file mode 100644 index 000000000..b75e33608 --- /dev/null +++ b/src/routes/v2/pages/Editor/components/EditorTourBridge/editorTourBridge.interactions.test.ts @@ -0,0 +1,181 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { TourStep } from "@/components/Learn/tours/registry"; + +import { + INTERACTION_HANDLERS, + type TourInteractionContext, +} from "./editorTourBridge.interactions"; + +function makeCtx( + step: Partial, + stores: { navigation?: unknown; editor?: unknown } = {}, +) { + const complete = vi.fn(); + const stopFollow = vi.fn(); + const ctx = { + step, + navigation: stores.navigation ?? { activeSpec: null }, + editor: stores.editor ?? { multiSelection: [] }, + windows: { getWindowById: () => undefined, getAllWindows: () => [] }, + targetWindowId: undefined, + complete, + stopFollow, + } as unknown as TourInteractionContext; + return { ctx, complete, stopFollow }; +} + +afterEach(() => { + document.body.innerHTML = ""; + vi.clearAllMocks(); +}); + +describe("handleSelectTask", () => { + function addTaskNode(name: string) { + const node = document.createElement("div"); + node.setAttribute("data-tour-node", "task"); + node.setAttribute("data-task-name", name); + document.body.appendChild(node); + return node; + } + + it("completes when a matching task node is clicked", () => { + const { ctx, complete } = makeCtx({ + interaction: "select-task", + targetTaskName: "train", + }); + const cleanup = INTERACTION_HANDLERS["select-task"](ctx); + addTaskNode("Train XGBoost model").dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + expect(complete).toHaveBeenCalledTimes(1); + cleanup(); + }); + + it("ignores clicks on a task node that does not match the target name", () => { + const { ctx, complete } = makeCtx({ + interaction: "select-task", + targetTaskName: "train", + }); + const cleanup = INTERACTION_HANDLERS["select-task"](ctx); + addTaskNode("Evaluate model").dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + expect(complete).not.toHaveBeenCalled(); + cleanup(); + }); +}); + +describe("handleSetArgument", () => { + it("completes only when the argument is set on the targeted task", () => { + const spec = { + tasks: [ + { name: "train model", arguments: [{ name: "epochs", value: "10" }] }, + { name: "eval model", arguments: [{ name: "epochs", value: "" }] }, + ], + }; + const { ctx, complete } = makeCtx( + { + interaction: "set-argument", + targetArgumentName: "epochs", + targetTaskName: "train", + }, + { navigation: { activeSpec: spec } }, + ); + INTERACTION_HANDLERS["set-argument"](ctx); + expect(complete).toHaveBeenCalledTimes(1); + }); + + it("does not complete when only a different task has the argument set", () => { + const spec = { + tasks: [ + { name: "train model", arguments: [{ name: "epochs", value: "" }] }, + { name: "eval model", arguments: [{ name: "epochs", value: "10" }] }, + ], + }; + const { ctx, complete } = makeCtx( + { + interaction: "set-argument", + targetArgumentName: "epochs", + targetTaskName: "train", + }, + { navigation: { activeSpec: spec } }, + ); + INTERACTION_HANDLERS["set-argument"](ctx); + expect(complete).not.toHaveBeenCalled(); + }); +}); + +describe("handleConnectEdge", () => { + it("completes when the target edge already exists", () => { + const spec = { + tasks: [ + { $id: "s", name: "src" }, + { $id: "t", name: "dst" }, + ], + bindings: [ + { + sourceEntityId: "s", + targetEntityId: "t", + sourcePortName: "out", + targetPortName: "in", + }, + ], + }; + const { ctx, complete } = makeCtx( + { + interaction: "connect-edge", + targetEdge: { + sourceTaskName: "src", + targetTaskName: "dst", + sourcePortName: "out", + targetPortName: "in", + }, + }, + { navigation: { activeSpec: spec } }, + ); + INTERACTION_HANDLERS["connect-edge"](ctx); + expect(complete).toHaveBeenCalledTimes(1); + }); + + it("falls back to binding count and waits for a new edge when no target is given", () => { + const spec = { tasks: [], bindings: [{}, {}] }; + const { ctx, complete } = makeCtx( + { interaction: "connect-edge" }, + { navigation: { activeSpec: spec } }, + ); + INTERACTION_HANDLERS["connect-edge"](ctx); + expect(complete).not.toHaveBeenCalled(); + }); +}); + +describe("handleAddTask", () => { + it("waits for a new matching task rather than completing on entry", () => { + const spec = { tasks: [{ name: "train model" }] }; + const { ctx, complete } = makeCtx( + { interaction: "add-task", targetTaskName: "train" }, + { navigation: { activeSpec: spec } }, + ); + INTERACTION_HANDLERS["add-task"](ctx); + expect(complete).not.toHaveBeenCalled(); + }); +}); + +describe("selector-based handlers", () => { + it("completes immediately when the secret dialog is already present", () => { + const dialog = document.createElement("div"); + dialog.setAttribute("data-testid", "select-secret-dialog"); + document.body.appendChild(dialog); + + const { ctx, complete } = makeCtx({ interaction: "open-secret-dialog" }); + INTERACTION_HANDLERS["open-secret-dialog"](ctx); + expect(complete).toHaveBeenCalledTimes(1); + }); + + it("does not complete when the targeted dialog is absent", () => { + const { ctx, complete } = makeCtx({ interaction: "open-submit-dialog" }); + const cleanup = INTERACTION_HANDLERS["open-submit-dialog"](ctx); + expect(complete).not.toHaveBeenCalled(); + cleanup(); + }); +}); diff --git a/src/routes/v2/pages/Editor/components/EditorTourBridge/editorTourBridge.interactions.ts b/src/routes/v2/pages/Editor/components/EditorTourBridge/editorTourBridge.interactions.ts new file mode 100644 index 000000000..c3a08e6f5 --- /dev/null +++ b/src/routes/v2/pages/Editor/components/EditorTourBridge/editorTourBridge.interactions.ts @@ -0,0 +1,371 @@ +import type { TourStep } from "@/components/Learn/tours/registry"; +import type { TourInteraction } from "@/providers/TourProvider/tourActions"; +import type { EditorStore } from "@/routes/v2/shared/store/editorStore"; +import type { NavigationStore } from "@/routes/v2/shared/store/navigationStore"; +import type { WindowStoreImpl } from "@/routes/v2/shared/windows/windowStore"; +import { isSecretArgument } from "@/utils/componentSpec"; + +import { + countForInteraction, + countSubgraphTasks, + elementFromEvent, + isCountInteraction, + trackDockStateTransition, + watchSelector, + watchValue, +} from "./editorTourBridge.utils"; + +/** + * Everything a tour interaction handler needs to detect completion. The handler + * sets up its listeners/observers/reactions and returns a cleanup function. + * `complete` marks the current step done; `stopFollow` tears down the + * window-position follower the gate set up before dispatching. + */ +export interface TourInteractionContext { + step: TourStep; + windows: WindowStoreImpl; + navigation: NavigationStore; + editor: EditorStore; + targetWindowId: string | undefined; + complete: () => void; + stopFollow: () => void; +} + +export type InteractionHandler = (ctx: TourInteractionContext) => () => void; + +function handleDockTransition(ctx: TourInteractionContext): () => void { + const { step, windows, targetWindowId, complete, stopFollow } = ctx; + const isDocked = (w: { dockState: string }) => w.dockState !== "none"; + const isUndocked = (w: { dockState: string }) => w.dockState === "none"; + const matchInitial = + step.interaction === "undock-window" ? isDocked : isUndocked; + const matchTransition = + step.interaction === "undock-window" ? isUndocked : isDocked; + + if (targetWindowId) { + const target = windows.getWindowById(targetWindowId); + if (!target || matchTransition(target)) { + complete(); + return stopFollow; + } + } else { + const hasSourceWindow = windows + .getAllWindows() + .some((w) => w.state !== "hidden" && matchInitial(w)); + if (!hasSourceWindow) { + complete(); + return stopFollow; + } + } + + const tracker = trackDockStateTransition( + windows, + matchInitial, + matchTransition, + targetWindowId, + ); + + let pendingCheck: ReturnType | null = null; + const handleMouseUp = () => { + if (pendingCheck !== null) clearTimeout(pendingCheck); + pendingCheck = setTimeout(() => { + pendingCheck = null; + if (tracker.didTransition()) complete(); + }, 0); + }; + document.addEventListener("mouseup", handleMouseUp); + + return () => { + stopFollow(); + tracker.dispose(); + if (pendingCheck !== null) clearTimeout(pendingCheck); + document.removeEventListener("mouseup", handleMouseUp); + }; +} + +function handleSelectTask(ctx: TourInteractionContext): () => void { + const { step, complete, stopFollow } = ctx; + const targetName = step.targetTaskName?.toLowerCase(); + const handleClick = (event: MouseEvent) => { + const node = elementFromEvent(event)?.closest('[data-tour-node="task"]'); + if (!node) return; + if (targetName) { + const name = (node.getAttribute("data-task-name") ?? "").toLowerCase(); + if (!name.includes(targetName)) return; + } + complete(); + }; + document.addEventListener("click", handleClick); + return () => { + stopFollow(); + document.removeEventListener("click", handleClick); + }; +} + +function handleExpandFolder(ctx: TourInteractionContext): () => void { + const { step, complete, stopFollow } = ctx; + const targetFolderName = step.targetFolderName; + if (!targetFolderName) return stopFollow; + const expandedSelector = `[data-folder-name="${targetFolderName}"] [aria-expanded="true"]`; + return watchSelector(expandedSelector, complete, stopFollow, [ + "aria-expanded", + ]); +} + +function handleLibrarySearch(ctx: TourInteractionContext): () => void { + const { step, complete, stopFollow } = ctx; + 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 scheduleComplete = () => { + if (advanceTimer !== null) return; + advanceTimer = setTimeout(() => { + advanceTimer = null; + complete(); + }, 600); + }; + + if (matches()) { + scheduleComplete(); + } + + const handleInput = (event: Event) => { + const target = elementFromEvent(event); + if (target?.matches(sel) && matches()) scheduleComplete(); + }; + document.addEventListener("input", handleInput, true); + + return () => { + stopFollow(); + document.removeEventListener("input", handleInput, true); + if (advanceTimer !== null) clearTimeout(advanceTimer); + }; +} + +function handleSetArgument(ctx: TourInteractionContext): () => void { + const { step, navigation, complete, stopFollow } = ctx; + const targetArgumentName = step.targetArgumentName; + if (!targetArgumentName) return stopFollow; + + const targetTaskName = step.targetTaskName?.toLowerCase(); + const hasArgumentValue = () => { + const spec = navigation.activeSpec; + if (!spec) return false; + return spec.tasks.some((task) => { + if (targetTaskName && !task.name.toLowerCase().includes(targetTaskName)) { + return false; + } + return task.arguments.some( + (arg) => + arg.name === targetArgumentName && + typeof arg.value === "string" && + arg.value.trim() !== "", + ); + }); + }; + + return watchValue(hasArgumentValue, complete, stopFollow); +} + +function handleAssignSecretArgument(ctx: TourInteractionContext): () => void { + const { step, navigation, complete, stopFollow } = ctx; + const targetArgumentName = step.targetArgumentName; + const hasSecretArgument = () => { + const spec = navigation.activeSpec; + if (!spec) return false; + return spec.tasks.some((task) => + task.arguments.some( + (arg) => + (!targetArgumentName || arg.name === targetArgumentName) && + isSecretArgument(arg.value), + ), + ); + }; + return watchValue(hasSecretArgument, complete, stopFollow); +} + +function handleOpenSecretDialog(ctx: TourInteractionContext): () => void { + return watchSelector( + '[data-testid="select-secret-dialog"]', + ctx.complete, + ctx.stopFollow, + ); +} + +function handleOpenSettingsPanel(ctx: TourInteractionContext): () => void { + return watchSelector( + '[data-tour="tour-settings-dialog"]', + ctx.complete, + ctx.stopFollow, + ); +} + +function handleOpenSubmitDialog(ctx: TourInteractionContext): () => void { + return watchSelector( + '[data-tour="submit-arguments-dialog"]', + ctx.complete, + ctx.stopFollow, + ); +} + +function handleAssignSecretSubmit(ctx: TourInteractionContext): () => void { + return watchSelector( + '[data-tour="submit-arguments-dialog"] [data-testid="dynamic-data-argument-input"]', + ctx.complete, + ctx.stopFollow, + ); +} + +function handleConnectEdge(ctx: TourInteractionContext): () => void { + const { step, navigation, complete, stopFollow } = ctx; + const target = step.targetEdge; + // No explicit edge target: fall back to "a new binding appeared" (count). + if (!target) return handleCount(ctx); + + 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, + ); + }; + + return watchValue(hasTargetEdge, complete, stopFollow); +} + +function handleAddTask(ctx: TourInteractionContext): () => void { + const { step, navigation, complete, stopFollow } = ctx; + // No named target: fall back to the generic "task count went up". + if (!step.targetTaskName) return handleCount(ctx); + + 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(); + return watchValue(() => countMatches() > baseline, complete, stopFollow); +} + +function handleNavigateIntoSubgraph(ctx: TourInteractionContext): () => void { + const { step, navigation, complete, stopFollow } = ctx; + const targetName = step.targetTaskName?.toLowerCase(); + const baselineDepth = navigation.navigationDepth; + const matches = () => { + if (navigation.navigationDepth <= baselineDepth) return false; + if (!targetName) return true; + const last = + navigation.navigationPath[navigation.navigationPath.length - 1]; + return last?.displayName.toLowerCase() === targetName; + }; + return watchValue(matches, complete, stopFollow); +} + +function handleNavigateToRoot(ctx: TourInteractionContext): () => void { + const { navigation, complete, stopFollow } = ctx; + return watchValue( + () => navigation.navigationDepth === 0, + complete, + stopFollow, + ); +} + +function handleUnpackSubgraph(ctx: TourInteractionContext): () => void { + const { navigation, complete, stopFollow } = ctx; + const baseline = countSubgraphTasks(navigation.activeSpec); + return watchValue( + () => countSubgraphTasks(navigation.activeSpec) < baseline, + complete, + stopFollow, + ); +} + +function handleCreateSubgraph(ctx: TourInteractionContext): () => void { + const { navigation, complete, stopFollow } = ctx; + const baseline = countSubgraphTasks(navigation.activeSpec); + return watchValue( + () => countSubgraphTasks(navigation.activeSpec) > baseline, + complete, + stopFollow, + ); +} + +function handleMultiSelectTasks(ctx: TourInteractionContext): () => void { + const { step, editor, complete, stopFollow } = ctx; + const minCount = step.targetMinCount ?? 2; + const taskSelectionCount = () => + editor.multiSelection.filter((n) => n.type === "task").length; + return watchValue( + () => taskSelectionCount() >= minCount, + complete, + stopFollow, + ); +} + +function handleCount(ctx: TourInteractionContext): () => void { + const { step, navigation, complete, stopFollow } = ctx; + const interaction = step.interaction; + if (!isCountInteraction(interaction)) return stopFollow; + const baseline = countForInteraction(navigation.activeSpec, interaction); + return watchValue( + () => countForInteraction(navigation.activeSpec, interaction) > baseline, + complete, + stopFollow, + ); +} + +/** + * Maps each tour interaction to the handler that detects its completion. A full + * (non-Partial) Record gives compile-time exhaustiveness: adding a member to + * TourInteraction forces a handler entry here. + */ +export const INTERACTION_HANDLERS: Record = + { + "undock-window": handleDockTransition, + "redock-window": handleDockTransition, + "select-task": handleSelectTask, + "library-search": handleLibrarySearch, + "expand-folder": handleExpandFolder, + "set-argument": handleSetArgument, + "assign-secret-argument": handleAssignSecretArgument, + "open-secret-dialog": handleOpenSecretDialog, + "open-settings-panel": handleOpenSettingsPanel, + "open-submit-dialog": handleOpenSubmitDialog, + "assign-secret-submit": handleAssignSecretSubmit, + "connect-edge": handleConnectEdge, + "add-task": handleAddTask, + "add-input": handleCount, + "add-output": handleCount, + "navigate-into-subgraph": handleNavigateIntoSubgraph, + "navigate-to-root": handleNavigateToRoot, + "unpack-subgraph": handleUnpackSubgraph, + "create-subgraph": handleCreateSubgraph, + "multi-select-tasks": handleMultiSelectTasks, + }; diff --git a/src/routes/v2/pages/Editor/components/EditorTourBridge/editorTourBridge.utils.ts b/src/routes/v2/pages/Editor/components/EditorTourBridge/editorTourBridge.utils.ts new file mode 100644 index 000000000..53d2e89cc --- /dev/null +++ b/src/routes/v2/pages/Editor/components/EditorTourBridge/editorTourBridge.utils.ts @@ -0,0 +1,203 @@ +import type { StepType } from "@reactour/tour"; +import { reaction } from "mobx"; +import type { Dispatch, SetStateAction } from "react"; + +import type { TourStep } from "@/components/Learn/tours/registry"; +import type { ComponentSpec } from "@/models/componentSpec"; +import type { WindowStoreImpl } from "@/routes/v2/shared/windows/windowStore"; + +// reactour types `steps` as the base StepType, but our tours always carry the +// richer TourStep shape. Centralize the cast here, with its rationale, so call +// sites stay free of bare assertions. +export function asTourStep(step: StepType | undefined): TourStep | undefined { + return step as TourStep | undefined; +} + +export type CountInteraction = + | "add-task" + | "add-input" + | "add-output" + | "connect-edge"; + +export function isCountInteraction( + interaction: TourStep["interaction"], +): interaction is CountInteraction { + return ( + interaction === "add-task" || + interaction === "add-input" || + interaction === "add-output" || + interaction === "connect-edge" + ); +} + +export 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; + } +} + +export function countSubgraphTasks(spec: ComponentSpec | null): number { + if (!spec) return 0; + return spec.tasks.filter((t) => t.subgraphSpec !== undefined).length; +} + +export function elementFromEvent(event: Event): Element | null { + return event.target instanceof Element ? event.target : null; +} + +export function watchValue( + predicate: () => boolean, + complete: () => void, + stopFollow: () => void, +): () => void { + if (predicate()) { + complete(); + return stopFollow; + } + const dispose = reaction(predicate, (matches) => { + if (matches) { + dispose(); + complete(); + } + }); + return () => { + stopFollow(); + dispose(); + }; +} + +export function watchSelector( + selector: string, + complete: () => void, + stopFollow: () => void, + attributeFilter?: string[], +): () => void { + const present = () => !!document.querySelector(selector); + if (present()) { + complete(); + return stopFollow; + } + const observer = new MutationObserver(() => { + if (present()) { + observer.disconnect(); + complete(); + } + }); + observer.observe( + document.body, + attributeFilter + ? { attributes: true, attributeFilter, subtree: true } + : { childList: true, subtree: true }, + ); + return () => { + stopFollow(); + observer.disconnect(); + }; +} + +export function pollForSelectorThenRefreshSteps( + wantSelector: string | null, + fallbackSelector: string, + setSteps: Dispatch> | undefined, + budgetMs = 1500, + intervalMs = 50, +): () => void { + let cancelled = false; + const start = Date.now(); + const poll = () => { + if (cancelled) return; + if ( + document.querySelector(wantSelector ?? fallbackSelector) || + Date.now() - start > budgetMs + ) { + setSteps?.((prev) => [...prev]); + return; + } + window.setTimeout(poll, intervalMs); + }; + window.setTimeout(poll, intervalMs); + return () => { + cancelled = true; + }; +} + +export function followWindowPosition( + windows: WindowStoreImpl, + targetWindowId: string | undefined, +): () => void { + if (!targetWindowId) return () => undefined; + + let rafId: number | null = null; + const dispose = reaction( + () => { + const w = windows.getWindowById(targetWindowId); + return w ? `${w.position.x},${w.position.y}` : ""; + }, + () => { + if (rafId !== null) return; + rafId = requestAnimationFrame(() => { + rafId = null; + window.dispatchEvent(new Event("resize")); + }); + }, + ); + + return () => { + dispose(); + if (rafId !== null) cancelAnimationFrame(rafId); + }; +} + +export function trackDockStateTransition( + windows: WindowStoreImpl, + matchInitial: (w: { dockState: string }) => boolean, + matchTransition: (w: { dockState: string }) => boolean, + targetWindowId?: string, +): { didTransition: () => boolean; dispose: () => void } { + const baseline = new Set(); + for (const w of windows.getAllWindows()) { + if (targetWindowId ? w.id === targetWindowId : matchInitial(w)) { + baseline.add(w.id); + } + } + let fired = false; + + const stateReaction = reaction( + () => + windows + .getAllWindows() + .map((w) => `${w.id}:${w.dockState}`) + .join("|"), + () => { + for (const w of windows.getAllWindows()) { + if (targetWindowId) { + if (w.id === targetWindowId && matchTransition(w)) { + fired = true; + } + continue; + } + if (matchInitial(w)) { + baseline.add(w.id); + } else if (baseline.has(w.id) && matchTransition(w)) { + fired = true; + } + } + }, + ); + + return { + didTransition: () => fired, + dispose: stateReaction, + }; +} diff --git a/src/routes/v2/pages/Editor/components/FlowCanvas/components/CanvasUndoRedo.tsx b/src/routes/v2/pages/Editor/components/FlowCanvas/components/CanvasUndoRedo.tsx index d3864ddba..d62871fde 100644 --- a/src/routes/v2/pages/Editor/components/FlowCanvas/components/CanvasUndoRedo.tsx +++ b/src/routes/v2/pages/Editor/components/FlowCanvas/components/CanvasUndoRedo.tsx @@ -27,10 +27,7 @@ export const CanvasUndoRedo = observer(function CanvasUndoRedo() { }; return ( -
+
diff --git a/src/routes/v2/shared/nodes/IONode/inputManifestBase.ts b/src/routes/v2/shared/nodes/IONode/inputManifestBase.ts index 5ded8f9cb..fb69a9f9d 100644 --- a/src/routes/v2/shared/nodes/IONode/inputManifestBase.ts +++ b/src/routes/v2/shared/nodes/IONode/inputManifestBase.ts @@ -64,17 +64,11 @@ export const inputManifestBase: ManifestPartial = { buildNodes(spec) { return [...spec.inputs].map((input, index) => - createEntityNode( - input, - "input", - ioDefaultPosition(index, -200), - { - entityId: input.$id, - ioType: "input", - name: input.name, - } satisfies IONodeData, - { "data-task-name": input.name, "data-tour-node": "input" }, - ), + createEntityNode(input, "input", ioDefaultPosition(index, -200), { + entityId: input.$id, + ioType: "input", + name: input.name, + } satisfies IONodeData), ); }, diff --git a/src/routes/v2/shared/nodes/IONode/outputManifestBase.ts b/src/routes/v2/shared/nodes/IONode/outputManifestBase.ts index 6e2a5c53e..c3655dd9a 100644 --- a/src/routes/v2/shared/nodes/IONode/outputManifestBase.ts +++ b/src/routes/v2/shared/nodes/IONode/outputManifestBase.ts @@ -59,17 +59,11 @@ export const outputManifestBase: ManifestPartial = { buildNodes(spec) { return [...spec.outputs].map((output, index) => - createEntityNode( - output, - "output", - ioDefaultPosition(index, 800), - { - entityId: output.$id, - ioType: "output", - name: output.name, - } satisfies IONodeData, - { "data-task-name": output.name, "data-tour-node": "output" }, - ), + createEntityNode(output, "output", ioDefaultPosition(index, 800), { + entityId: output.$id, + ioType: "output", + name: output.name, + } satisfies IONodeData), ); },