From aeba38f865597d0f374fe92cca109fbfdac54088 Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Mon, 15 Jun 2026 10:57:36 -0700 Subject: [PATCH] feat: Guided Tours Cleanup --- .../Learn/tours/navigatingEditor.tour.json | 6 +- src/components/Learn/tours/registry.ts | 1 - .../shared/SubgraphBreadcrumbsView.tsx | 1 - src/providers/TourProvider/TourNavigation.tsx | 21 +- src/providers/TourProvider/TourPopover.tsx | 35 +- .../TourProvider/TourProgressContext.tsx | 26 +- src/routes/Dashboard/Learn/Tour.tsx | 2 - .../EditorMenuBar/EditorMenuBar.tsx | 1 - .../Editor/components/EditorTourBridge.tsx | 496 ++++-------------- .../FlowCanvas/components/CanvasUndoRedo.tsx | 5 +- .../components/editorTourBridge.utils.ts | 135 +++++ .../shared/nodes/IONode/inputManifestBase.ts | 16 +- .../shared/nodes/IONode/outputManifestBase.ts | 16 +- 13 files changed, 309 insertions(+), 452 deletions(-) create mode 100644 src/routes/v2/pages/Editor/components/editorTourBridge.utils.ts 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 f77b02992..afe5a4555 100644 --- a/src/components/Learn/tours/registry.ts +++ b/src/components/Learn/tours/registry.ts @@ -43,7 +43,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/providers/TourProvider/TourNavigation.tsx b/src/providers/TourProvider/TourNavigation.tsx index 70bd0fc53..6489736df 100644 --- a/src/providers/TourProvider/TourNavigation.tsx +++ b/src/providers/TourProvider/TourNavigation.tsx @@ -13,17 +13,6 @@ import { tourActionLabel, type TourActionLabelInput } from "./tourActionLabels"; import { renderInline } from "./TourContent"; import { useTourProgress } from "./TourProgressContext"; -const tourVisitedMax = { value: 0 }; -function recordTourVisited(step: number): void { - if (step > tourVisitedMax.value) tourVisitedMax.value = step; -} -function getTourVisitedMax(): number { - return tourVisitedMax.value; -} -export function resetTourVisited(): void { - tourVisitedMax.value = 0; -} - type NextButtonProps = Parameters>[0]; type NavButtonProps = { @@ -86,7 +75,7 @@ type GatedNextButtonProps = Omit & { function GatedNextButton(props: GatedNextButtonProps) { const { Button, currentStep, stepsLength, setCurrentStep, steps } = props; - const { isStepComplete } = useTourProgress(); + const { isStepComplete, maxVisitedStep } = useTourProgress(); const hiddenPlaceholder = ( @@ -104,7 +93,7 @@ function GatedNextButton(props: GatedNextButtonProps) { return hiddenPlaceholder; } - const isRevisit = currentStep < getTourVisitedMax(); + const isRevisit = currentStep < maxVisitedStep; const advance = () => setCurrentStep((s: number) => Math.min(s + 1, stepsLength - 1)); @@ -189,8 +178,10 @@ export function TourNavigation(props: NavigationProps) { const stepsLength = steps.length; - recordTourVisited(currentStep); - const visited = getTourVisitedMax(); + const { recordVisited, maxVisitedStep: visited } = useTourProgress(); + useEffect(() => { + recordVisited(currentStep); + }, [recordVisited, currentStep]); const BoundNavButton = useCallback>>( ({ children, ...rest }) => ( diff --git a/src/providers/TourProvider/TourPopover.tsx b/src/providers/TourProvider/TourPopover.tsx index ca19a87cf..bd6f77b4d 100644 --- a/src/providers/TourProvider/TourPopover.tsx +++ b/src/providers/TourProvider/TourPopover.tsx @@ -1,6 +1,6 @@ import { useTour } from "@reactour/tour"; import { useNavigate } from "@tanstack/react-router"; -import { useEffect } from "react"; +import { useEffect, useSyncExternalStore } from "react"; import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; @@ -10,7 +10,6 @@ import { APP_ROUTES } from "@/routes/router"; import { setTourActive } from "@/utils/tourActive"; import { tracking } from "@/utils/tracking"; -import { resetTourVisited } from "./TourNavigation"; import { useTourProgress } from "./TourProgressContext"; // Matches the step-number badge's ≈13px outside offset plus a small margin. @@ -87,9 +86,6 @@ export function computeDefaultPopoverPosition( const isTallStrip = targetHeight > props.windowHeight * 0.5; const margin = 16; - // 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; return [ @@ -98,12 +94,6 @@ export function computeDefaultPopoverPosition( ]; } - // 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)]; } @@ -127,21 +117,39 @@ export function computeDefaultPopoverPosition( } let saveExploreHandler: (() => void) | null = null; +const saveExploreListeners = new Set<() => void>(); export function registerSaveExploreHandler( handler: (() => void) | null, ): () => void { saveExploreHandler = handler; + saveExploreListeners.forEach((listener) => listener()); return () => { if (saveExploreHandler === handler) { saveExploreHandler = null; + saveExploreListeners.forEach((listener) => listener()); } }; } +function subscribeSaveExplore(listener: () => void): () => void { + saveExploreListeners.add(listener); + return () => { + saveExploreListeners.delete(listener); + }; +} + +function getSaveExploreHandler(): (() => void) | null { + return saveExploreHandler; +} + export function TourCompletionActions() { const navigate = useNavigate(); const { setIsOpen } = useTour(); + const saveHandler = useSyncExternalStore( + subscribeSaveExplore, + getSaveExploreHandler, + ); const onDone = () => { setIsOpen(false); @@ -150,7 +158,7 @@ export function TourCompletionActions() { const onSavePipeline = () => { setIsOpen(false); - saveExploreHandler?.(); + saveHandler?.(); }; return ( @@ -164,7 +172,7 @@ export function TourCompletionActions() { Finish Tour - {saveExploreHandler && ( + {saveHandler && ( Continue exploring: @@ -216,7 +224,6 @@ export function PopoverClampBridge() { useEffect(() => { if (isOpen) { - resetTourVisited(); resetTourProgress(); } }, [isOpen, resetTourProgress]); diff --git a/src/providers/TourProvider/TourProgressContext.tsx b/src/providers/TourProvider/TourProgressContext.tsx index 02230eb60..c7e8daf2c 100644 --- a/src/providers/TourProvider/TourProgressContext.tsx +++ b/src/providers/TourProvider/TourProgressContext.tsx @@ -9,6 +9,8 @@ export interface TourProgressValue { completedSteps: ReadonlySet; isStepComplete(step: number): boolean; markStepComplete(step: number): void; + maxVisitedStep: number; + recordVisited(step: number): void; reset(): void; } @@ -20,6 +22,7 @@ export function TourProgressProvider({ children }: { children: ReactNode }) { const [completedSteps, setCompletedSteps] = useState>( () => new Set(), ); + const [maxVisitedStep, setMaxVisitedStep] = useState(0); const isStepComplete = useCallback( (step: number) => completedSteps.has(step), @@ -35,13 +38,32 @@ export function TourProgressProvider({ children }: { children: ReactNode }) { }); }, []); + const recordVisited = useCallback((step: number) => { + setMaxVisitedStep((prev) => (step > prev ? step : prev)); + }, []); + const reset = useCallback(() => { setCompletedSteps((prev) => (prev.size === 0 ? prev : new Set())); + setMaxVisitedStep(0); }, []); const value = useMemo( - () => ({ completedSteps, isStepComplete, markStepComplete, reset }), - [completedSteps, isStepComplete, markStepComplete, reset], + () => ({ + completedSteps, + isStepComplete, + markStepComplete, + maxVisitedStep, + recordVisited, + reset, + }), + [ + completedSteps, + isStepComplete, + markStepComplete, + maxVisitedStep, + recordVisited, + reset, + ], ); return ( diff --git a/src/routes/Dashboard/Learn/Tour.tsx b/src/routes/Dashboard/Learn/Tour.tsx index 57c146957..3ed46ecdf 100644 --- a/src/routes/Dashboard/Learn/Tour.tsx +++ b/src/routes/Dashboard/Learn/Tour.tsx @@ -291,5 +291,3 @@ function TourMockBackendController() { return null; } - -// placeholder for empty pr 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() {
{ - 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; - }; + return pollForSelectorThenRefreshSteps( + wantSelector, + `[data-dock-window="${ensureWindowRestoredId}"]`, + setSteps, + ); }, [ isOpen, ensureWindowRestoredId, @@ -243,7 +203,7 @@ export function EditorTourBridge() { if (!libraryDragAllow) return undefined; const allow = libraryDragAllow.toLowerCase(); const handleDragStart = (event: DragEvent) => { - const target = event.target as Element | null; + const target = elementFromEvent(event); if (!target?.closest('[data-dock-window-content="component-library"]')) { return; } @@ -283,26 +243,12 @@ export function EditorTourBridge() { } } - 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; - }; + return pollForSelectorThenRefreshSteps( + wantSelector, + "[data-folder-name]", + setSteps, + ); }, [isOpen, currentStep, resetLibrarySearchFlag, setSteps, stepSelector]); useEffect(() => { @@ -328,12 +274,24 @@ export function EditorTourBridge() { } }; + // 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(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]); @@ -341,8 +299,8 @@ export function EditorTourBridge() { useEffect(() => { if (!isOpen) return undefined; - // Run outside the interaction branch so informational/fallback steps that - // target a floating window still track its position. + // Run outside the interaction branch so informational steps that target a + // floating window still track its position. const stopFollow = followWindowPosition(windows, targetWindowId); if (!interaction) return stopFollow; @@ -350,9 +308,7 @@ export function EditorTourBridge() { // 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); + const complete = () => markStepComplete(currentStep); if (interaction === "undock-window" || interaction === "redock-window") { const isDocked = (w: { dockState: string }) => w.dockState !== "none"; @@ -365,7 +321,7 @@ export function EditorTourBridge() { if (targetWindowId) { const target = windows.getWindowById(targetWindowId); if (!target || matchTransition(target)) { - skipWithFallback(step); + complete(); return stopFollow; } } else { @@ -373,8 +329,7 @@ export function EditorTourBridge() { .getAllWindows() .some((w) => w.state !== "hidden" && matchInitial(w)); if (!hasSourceWindow) { - if (step) skipWithFallback(step); - else skip(); + complete(); return stopFollow; } } @@ -391,7 +346,7 @@ export function EditorTourBridge() { if (pendingCheck !== null) clearTimeout(pendingCheck); pendingCheck = setTimeout(() => { pendingCheck = null; - if (tracker.didTransition()) advance(); + if (tracker.didTransition()) complete(); }, 0); }; document.addEventListener("mouseup", handleMouseUp); @@ -407,8 +362,9 @@ export function EditorTourBridge() { 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"]'); + const node = elementFromEvent(event)?.closest( + '[data-tour-node="task"]', + ); if (!node) return; if (targetName) { const name = ( @@ -416,7 +372,7 @@ export function EditorTourBridge() { ).toLowerCase(); if (!name.includes(targetName)) return; } - advance(); + complete(); }; document.addEventListener("click", handleClick); return () => { @@ -428,28 +384,10 @@ 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(); - }; + return watchSelector(expandedSelector, complete, stopFollow, [ + "aria-expanded", + ]); } if (interaction === "library-search") { @@ -464,21 +402,21 @@ export function EditorTourBridge() { }; let advanceTimer: ReturnType | null = null; - const scheduleStep = (move: () => void) => { + const scheduleComplete = () => { if (advanceTimer !== null) return; advanceTimer = setTimeout(() => { advanceTimer = null; - move(); + complete(); }, 600); }; if (matches()) { - scheduleStep(skip); + scheduleComplete(); } const handleInput = (event: Event) => { - const target = event.target as Element | null; - if (target?.matches(sel) && matches()) scheduleStep(advance); + const target = elementFromEvent(event); + if (target?.matches(sel) && matches()) scheduleComplete(); }; document.addEventListener("input", handleInput, true); @@ -493,38 +431,27 @@ export function EditorTourBridge() { 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) => - task.arguments.some( + 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() !== "", - ), - ); + ); + }); }; - if (hasArgumentValue()) { - skip(); - return stopFollow; - } - - const dispose = reaction( - () => hasArgumentValue(), - (matches) => { - if (matches) { - dispose(); - advance(); - } - }, - ); - - return () => { - stopFollow(); - dispose(); - }; + return watchValue(hasArgumentValue, complete, stopFollow); } if (interaction === "assign-secret-argument") { @@ -542,118 +469,39 @@ export function EditorTourBridge() { ); }; - if (hasSecretArgument()) { - skip(); - return stopFollow; - } - - const dispose = reaction( - () => hasSecretArgument(), - (matches) => { - if (matches) { - dispose(); - advance(); - } - }, - ); - - return () => { - stopFollow(); - dispose(); - }; + return watchValue(hasSecretArgument, complete, stopFollow); } 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(); - }; + return watchSelector( + '[data-testid="select-secret-dialog"]', + complete, + stopFollow, + ); } 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(); - }; + return watchSelector( + '[data-tour="tour-settings-dialog"]', + complete, + stopFollow, + ); } 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(); - }; + return watchSelector( + '[data-tour="submit-arguments-dialog"]', + complete, + stopFollow, + ); } 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(); - }; + return watchSelector( + '[data-tour="submit-arguments-dialog"] [data-testid="dynamic-data-argument-input"]', + complete, + stopFollow, + ); } if (interaction === "connect-edge" && step?.targetEdge) { @@ -687,30 +535,11 @@ export function EditorTourBridge() { ); }; - if (hasTargetEdge()) { - skip(); - return stopFollow; - } - - const dispose = reaction( - () => hasTargetEdge(), - (matches) => { - if (matches) { - dispose(); - advance(); - } - }, - ); - - return () => { - stopFollow(); - dispose(); - }; + return watchValue(hasTargetEdge, complete, stopFollow); } if (interaction === "add-task" && step?.targetTaskName) { const targetName = step.targetTaskName.toLowerCase(); - const countMatches = () => { const spec = navigation.activeSpec; if (!spec) return 0; @@ -719,27 +548,12 @@ export function EditorTourBridge() { ).length; }; const baseline = countMatches(); - - const dispose = reaction( - () => countMatches(), - (current) => { - if (current > baseline) { - dispose(); - advance(); - } - }, - ); - - return () => { - stopFollow(); - dispose(); - }; + return watchValue(() => countMatches() > baseline, complete, stopFollow); } 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; @@ -747,144 +561,54 @@ export function EditorTourBridge() { 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(); - }; + return watchValue(matches, complete, stopFollow); } 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 watchValue( + () => navigation.navigationDepth === 0, + complete, + stopFollow, ); - - 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(); - } - }, + const baseline = countSubgraphTasks(navigation.activeSpec); + return watchValue( + () => countSubgraphTasks(navigation.activeSpec) < baseline, + complete, + stopFollow, ); - - 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 watchValue( + () => taskSelectionCount() >= minCount, + complete, + stopFollow, ); - - 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(); - } - }, + const baseline = countSubgraphTasks(navigation.activeSpec); + return watchValue( + () => countSubgraphTasks(navigation.activeSpec) > baseline, + complete, + stopFollow, ); - - 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 watchValue( + () => + countForInteraction(navigation.activeSpec, interaction) > baseline, + complete, + stopFollow, ); - - return () => { - stopFollow(); - dispose(); - }; } return stopFollow; 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/pages/Editor/components/editorTourBridge.utils.ts b/src/routes/v2/pages/Editor/components/editorTourBridge.utils.ts new file mode 100644 index 000000000..bbeac93d1 --- /dev/null +++ b/src/routes/v2/pages/Editor/components/editorTourBridge.utils.ts @@ -0,0 +1,135 @@ +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"; + +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; +} + +/** + * Gate a step on a MobX-observable condition: marks the step complete + * immediately if already satisfied, otherwise once the predicate becomes true. + * Returns a cleanup that stops the window-follow and disposes the reaction. + */ +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(); + }; +} + +/** + * Gate a step on a DOM element appearing: marks the step complete immediately if + * the selector already matches, otherwise once a matching element is added. + * Pass `attributeFilter` to watch attribute changes instead of childList. + */ +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; + }; +} 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), ); },