diff --git a/src/components/Learn/tours/firstPipeline.tour.json b/src/components/Learn/tours/firstPipeline.tour.json new file mode 100644 index 000000000..1f6a30fd7 --- /dev/null +++ b/src/components/Learn/tours/firstPipeline.tour.json @@ -0,0 +1,278 @@ +{ + "id": "first-pipeline", + "displayName": "Guided Tour: Build Your First Pipeline", + "requiresEditor": true, + "steps": [ + { + "selector": "[data-tour-anchor=\"no-spotlight\"]", + "content": "Let's build your first pipeline!\n\nA pipeline is a visual graph of three kinds of nodes: **task nodes** that do the work, **input nodes** that pass run-time parameters, and **output nodes** that capture results.\n\nIn this tour we'll connect some components into a working pipeline that loads data, trains a model, and makes predictions.", + "position": "center" + }, + { + "selector": "[data-folder-name=\"Standard library\"]", + "mutationObservables": [ + "[data-dock-window-content=\"component-library\"]" + ], + "resizeObservables": ["[data-dock-window-content=\"component-library\"]"], + "content": "Let's start by opening the **Standard library** folder in the Component Library.", + "position": "right", + "stepInteraction": true, + "interaction": "expand-folder", + "targetFolderName": "Standard library" + }, + { + "selector": "[data-folder-name=\"Quick start\"]", + "mutationObservables": [ + "[data-dock-window-content=\"component-library\"]" + ], + "resizeObservables": ["[data-dock-window-content=\"component-library\"]"], + "content": "Now open **Quick start** to see three premade components.", + "position": "right", + "stepInteraction": true, + "interaction": "expand-folder", + "targetFolderName": "Quick start" + }, + { + "selector": "[data-component-name=\"Chicago Taxi Trips dataset\"]", + "mutationObservables": [ + "[data-dock-window-content=\"component-library\"]" + ], + "resizeObservables": ["[data-dock-window-content=\"component-library\"]"], + "content": "Drag the **Chicago Taxi Trips dataset** onto the canvas. It becomes a **task**, one step in your pipeline.\n\nA task is a unit of execution: it takes inputs, runs some code, and produces outputs.", + "position": "right", + "stepInteraction": true, + "interaction": "add-task", + "targetTaskName": "Chicago Taxi Trips dataset" + }, + { + "selector": "[data-tour=\"library-search\"]", + "mutationObservables": [ + "[data-dock-window-content=\"component-library\"]" + ], + "resizeObservables": ["[data-dock-window-content=\"component-library\"]"], + "content": "When you know what you're looking for, the **search box** is faster than browsing folders.\n\nType **predict** to filter the library down to matching components.", + "position": "right", + "stepInteraction": true, + "interaction": "library-search", + "targetSearchTerm": "predict" + }, + { + "selector": "[data-component-name*=\"xgboost predict on csv\" i]", + "mutationObservables": [ + "[data-dock-window-content=\"component-library\"]" + ], + "resizeObservables": ["[data-dock-window-content=\"component-library\"]"], + "content": "Drag **Xgboost predict on CSV** from the search results onto the canvas.", + "position": "right", + "stepInteraction": true, + "interaction": "add-task", + "targetTaskName": "Xgboost predict on CSV" + }, + { + "selector": "[data-dock-window-content=\"component-library\"]", + "highlightedSelectors": [ + "[data-dock-window=\"component-library\"]", + "[data-dock-window-content=\"component-library\"]" + ], + "ringSelectors": [ + "[data-component-name*=\"train xgboost model on csv\" i]" + ], + "mutationObservables": [ + "[data-dock-window-content=\"component-library\"]" + ], + "resizeObservables": ["[data-dock-window-content=\"component-library\"]"], + "content": "Now add **Train XGBoost model on CSV** onto the canvas.\n\nFind it however you like. Search for **train**, or clear the search box and browse Quick start.", + "stepInteraction": true, + "interaction": "add-task", + "targetTaskName": "Train XGBoost model on CSV" + }, + { + "selector": "[data-tour=\"editor-canvas\"]", + "highlightedSelectors": ["[data-tour=\"editor-canvas\"]"], + "resizeObservables": ["[data-tour=\"editor-canvas\"]"], + "content": "You now have three tasks on the canvas, but they're not connected yet.\n\nTasks pass data through **edges** that link one task's output to another task's input. Whatever a task produces flows along the edge to the next step; Tangle handles the actual storage and transfer behind the scenes.", + "position": [16, 80] + }, + { + "selector": "[data-tour=\"editor-canvas\"]", + "highlightedSelectors": ["[data-tour=\"editor-canvas\"]"], + "resizeObservables": ["[data-tour=\"editor-canvas\"]"], + "content": "Before we start connecting things, take a moment to **lay out your tasks**. Click and drag each task to reposition it so nothing overlaps. The clearer the layout, the easier the next few steps will be.", + "position": [16, 80] + }, + { + "selector": "[data-tour=\"editor-canvas\"]", + "highlightedSelectors": ["[data-tour=\"editor-canvas\"]"], + "ringSelectors": [ + "[data-task-name=\"Chicago Taxi Trips dataset\"] [data-handleid=\"output_Table\"]", + "[data-task-name=\"Train XGBoost model on CSV\"] [data-handleid=\"input_training_data\"]" + ], + "resizeObservables": ["[data-tour=\"editor-canvas\"]"], + "content": "Let's make the first connection.\n\nDrag from the **dataset's** `Table` **output** (right side) to the **train task's** `training_data` **input** (left side). This feeds your data into the training step.", + "position": [16, 80], + "stepInteraction": true, + "interaction": "connect-edge", + "targetEdge": { + "sourceTaskName": "Chicago Taxi Trips dataset", + "sourcePortName": "Table", + "targetTaskName": "Train XGBoost model on CSV", + "targetPortName": "training_data" + } + }, + { + "selector": "[data-tour=\"editor-canvas\"]", + "highlightedSelectors": ["[data-tour=\"editor-canvas\"]"], + "ringSelectors": [ + "[data-task-name=\"Chicago Taxi Trips dataset\"] [data-handleid=\"output_Table\"]", + "[data-task-name=\"Xgboost predict on CSV\"] [data-handleid=\"input_data\"]" + ], + "resizeObservables": ["[data-tour=\"editor-canvas\"]"], + "content": "The prediction step needs the same data.\n\nDrag from the **dataset's** `Table` **output** to the **predict task's** `data` **input**.", + "position": [16, 80], + "stepInteraction": true, + "interaction": "connect-edge", + "targetEdge": { + "sourceTaskName": "Chicago Taxi Trips dataset", + "sourcePortName": "Table", + "targetTaskName": "Xgboost predict on CSV", + "targetPortName": "data" + } + }, + { + "selector": "[data-tour=\"editor-canvas\"]", + "highlightedSelectors": ["[data-tour=\"editor-canvas\"]"], + "ringSelectors": [ + "[data-task-name=\"Train XGBoost model on CSV\"] [data-handleid=\"output_model\"]", + "[data-task-name=\"Xgboost predict on CSV\"] [data-handleid=\"input_model\"]" + ], + "resizeObservables": ["[data-tour=\"editor-canvas\"]"], + "content": "One more edge.\n\nDrag from the **train task's** `model` **output** to the **predict task's** `model` **input**. This hands what the trainer learned to the predictor.", + "position": [16, 80], + "stepInteraction": true, + "interaction": "connect-edge", + "targetEdge": { + "sourceTaskName": "Train XGBoost model on CSV", + "sourcePortName": "model", + "targetTaskName": "Xgboost predict on CSV", + "targetPortName": "model" + } + }, + { + "selector": "[data-folder-name=\"Inputs & Outputs\"]", + "ringSelectors": ["[data-component-name=\"Output Node\"]"], + "mutationObservables": [ + "[data-dock-window-content=\"component-library\"]" + ], + "resizeObservables": ["[data-dock-window-content=\"component-library\"]"], + "content": "Let's expose the model's predictions at the pipeline boundary.\n\nOpen the **Inputs & Outputs** folder in the Component Library and drag an **Output Node** onto the canvas.\n\nOutput nodes capture task results so they're easy to find after the pipeline runs.", + "position": "right", + "stepInteraction": true, + "interaction": "add-output", + "targetComponentName": "Output Node", + "resetLibrarySearch": true + }, + { + "selector": "[data-tour=\"editor-canvas\"]", + "highlightedSelectors": ["[data-tour=\"editor-canvas\"]"], + "ringSelectors": [ + "[data-task-name=\"Xgboost predict on CSV\"] [data-handleid=\"output_predictions\"]", + "[data-tour-node=\"output\"]" + ], + "resizeObservables": ["[data-tour=\"editor-canvas\"]"], + "content": "Now connect the predict task to your new Output node.\n\nDrag from the **predict task's** `predictions` **output** (right side) to the **Output node's** input handle (left side of the new node).", + "position": [16, 80], + "stepInteraction": true, + "interaction": "connect-edge", + "targetEdge": { + "sourceTaskName": "Xgboost predict on CSV", + "sourcePortName": "predictions" + } + }, + { + "selector": "[data-tour=\"editor-canvas\"]", + "highlightedSelectors": ["[data-tour=\"editor-canvas\"]"], + "ringSelectors": [ + "[data-task-name=\"Chicago Taxi Trips dataset\"] [data-handleid=\"input_Limit\"]" + ], + "resizeObservables": ["[data-tour=\"editor-canvas\"]"], + "content": "Now for the other end: make the dataset's row count configurable.\n\nHold **Cmd** (or **Alt**) and drag from the dataset's `Limit` **input handle** (highlighted) into empty canvas. This shortcut creates an **Input node** already connected to that handle.\n\nInput nodes are pipeline-level parameters set at submission time, so the same pipeline can be re-run with different settings.", + "position": [16, 80], + "stepInteraction": true, + "interaction": "add-input" + }, + { + "selector": "[data-tour=\"editor-canvas\"]", + "highlightedSelectors": [ + "[data-tour=\"editor-canvas\"]", + "[data-window-id=\"context-panel\"]" + ], + "ringSelectors": [ + "[data-tour-node=\"task\"][data-task-name=\"Train XGBoost model on CSV\"]" + ], + "resizeObservables": [ + "[data-tour=\"editor-canvas\"]", + "[data-window-id=\"context-panel\"]" + ], + "content": "Last thing: there's one required argument on the training step.\n\n**Click the Train XGBoost task** to select it. Its details will appear in the **Task Properties** panel on the right.", + "position": [16, 80], + "stepInteraction": true, + "interaction": "select-task", + "targetTaskName": "Train XGBoost model on CSV" + }, + { + "selector": "[data-window-id=\"context-panel\"]", + "highlightedSelectors": [ + "[data-window-id=\"context-panel\"]", + "[data-dock-window-content=\"context-panel\"]" + ], + "mutationObservables": [ + "[data-window-id=\"context-panel\"]", + "[data-dock-window-content=\"context-panel\"]" + ], + "resizeObservables": [ + "[data-window-id=\"context-panel\"]", + "[data-dock-window-content=\"context-panel\"]" + ], + "targetWindowId": "context-panel", + "content": "Task Properties lists every input the task accepts. Each one is an **argument** you can set directly, leave at its default, or feed from another source.\n\nArguments marked with a `*` are required.", + "position": "left" + }, + { + "selector": "[data-window-id=\"context-panel\"]", + "highlightedSelectors": [ + "[data-window-id=\"context-panel\"]", + "[data-dock-window-content=\"context-panel\"]" + ], + "ringSelectors": ["[data-argument-name=\"label_column_name\"]"], + "mutationObservables": [ + "[data-window-id=\"context-panel\"]", + "[data-dock-window-content=\"context-panel\"]" + ], + "resizeObservables": [ + "[data-window-id=\"context-panel\"]", + "[data-dock-window-content=\"context-panel\"]" + ], + "targetWindowId": "context-panel", + "content": "Find the `label_column_name` argument (highlighted) and type **tips**. That tells XGBoost which column of the dataset to predict.", + "position": "left", + "stepInteraction": true, + "interaction": "set-argument", + "targetArgumentName": "label_column_name" + }, + { + "selector": "[data-dock-window=\"runs-and-submission\"]", + "highlightedSelectors": [ + "[data-dock-window=\"runs-and-submission\"]", + "[data-dock-window-content=\"runs-and-submission\"]" + ], + "mutationObservables": [ + "[data-dock-window-content=\"runs-and-submission\"]" + ], + "resizeObservables": [ + "[data-dock-window-content=\"runs-and-submission\"]" + ], + "content": "Your pipeline is done. Three tasks, a configurable input, an output for the results, and the one required argument set.\n\nOpen **Runs and submission** in the left sidebar to run it. Use **Save as new pipeline** in the menu bar to keep this one.", + "position": "right" + } + ] +} diff --git a/src/components/Learn/tours/registry.ts b/src/components/Learn/tours/registry.ts index c306b4864..a944c6aba 100644 --- a/src/components/Learn/tours/registry.ts +++ b/src/components/Learn/tours/registry.ts @@ -3,8 +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; + targetFolderName?: string; + targetArgumentName?: string; + targetSearchTerm?: string; + targetTaskName?: string; + targetComponentName?: string; + // targetTaskName / targetPortName are optional. When omitted, any new + // binding from the source side counts (useful when the target is an IO + // node with an auto-generated entity id we can't predict in JSON). + targetEdge?: { + sourceTaskName: string; + sourcePortName: string; + targetTaskName?: string; + targetPortName?: string; + }; + ringSelectors?: string[]; + resetLibrarySearch?: boolean; fallbackContent?: string; }; diff --git a/src/components/shared/ReactFlow/FlowSidebar/components/ComponentItem.tsx b/src/components/shared/ReactFlow/FlowSidebar/components/ComponentItem.tsx index 4264feda5..ecb2b3563 100644 --- a/src/components/shared/ReactFlow/FlowSidebar/components/ComponentItem.tsx +++ b/src/components/shared/ReactFlow/FlowSidebar/components/ComponentItem.tsx @@ -273,6 +273,7 @@ interface IONodeSidebarItemProps { } export const IONodeSidebarItem = ({ nodeType }: IONodeSidebarItemProps) => { + const displayName = nodeType === "input" ? "Input Node" : "Output Node"; const onDragStart = useCallback( (event: DragEvent) => { event.dataTransfer.setData( @@ -298,12 +299,11 @@ export const IONodeSidebarItem = ({ nodeType }: IONodeSidebarItemProps) => { )} draggable onDragStart={onDragStart} + data-component-name={displayName} >
- - {nodeType === "input" ? "Input Node" : "Output Node"} - + {displayName}
); diff --git a/src/components/shared/ReactFlow/FlowSidebar/components/PublishedComponentsSearch.tsx b/src/components/shared/ReactFlow/FlowSidebar/components/PublishedComponentsSearch.tsx index 708a01703..d3291521e 100644 --- a/src/components/shared/ReactFlow/FlowSidebar/components/PublishedComponentsSearch.tsx +++ b/src/components/shared/ReactFlow/FlowSidebar/components/PublishedComponentsSearch.tsx @@ -184,7 +184,7 @@ const SearchRequestInput = ({ value, onChange }: SearchRequestProps) => { return ( -
+
{ return (
-
+
diff --git a/src/providers/TourProvider/tourPipelineLifecycle.ts b/src/providers/TourProvider/tourPipelineLifecycle.ts index 2bb8f889b..f0fbe665a 100644 --- a/src/providers/TourProvider/tourPipelineLifecycle.ts +++ b/src/providers/TourProvider/tourPipelineLifecycle.ts @@ -1,6 +1,10 @@ import yaml from "js-yaml"; import type { TourDefinition } from "@/components/Learn/tours/registry"; +import { + deleteEntry, + findByStorageKey, +} from "@/services/pipelineStorage/pipelineRegistry"; import type { PipelineStorageService } from "@/services/pipelineStorage/PipelineStorageService"; import { defaultPipelineYamlWithName } from "@/utils/constants"; @@ -18,6 +22,20 @@ export async function deleteTourPipelineByName( } } +// PipelineFile.deleteFile() does driver.delete then deleteEntry as separate +// awaits. If a fast remount lands in that gap (or a prior delete was +// interrupted), the driver has no file but the registry still holds the +// entry — so resolvePipelineByName returns undefined while +// assertStorageKeyUnique still trips. This sweeps that orphan entry. +export async function clearStaleTourRegistryEntry(name: string): Promise { + try { + const entry = await findByStorageKey(name); + if (entry) await deleteEntry(entry.id); + } catch (error) { + console.warn(`Failed to clear stale registry entry "${name}":`, error); + } +} + export async function cleanupOrphanTourPipelines( storage: PipelineStorageService, keep?: string | null, diff --git a/src/providers/TourProvider/tourPopover.tsx b/src/providers/TourProvider/tourPopover.tsx index f99ace054..3d9ef07ad 100644 --- a/src/providers/TourProvider/tourPopover.tsx +++ b/src/providers/TourProvider/tourPopover.tsx @@ -25,6 +25,15 @@ export const POPOVER_STYLES = { ...base, rx: 6, }), + // Reactour's default clickArea intercepts pointer events on the dimmed + // region, which blocks drag-and-drop (drop events never reach the canvas + // from a library component drag). Disabling it lets events pass through + // to underlying UI everywhere — the highlight still draws focus, but the + // user isn't locked out of interacting with anything. + clickArea: (base: object) => ({ + ...base, + pointerEvents: "none" as const, + }), highlightedArea: ( base: object, state?: { width?: number; height?: number }, @@ -69,20 +78,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/routes/Dashboard/Learn/Tour.tsx b/src/routes/Dashboard/Learn/Tour.tsx index 62c16a222..dc7fc4e5d 100644 --- a/src/routes/Dashboard/Learn/Tour.tsx +++ b/src/routes/Dashboard/Learn/Tour.tsx @@ -19,6 +19,7 @@ import { TourModeProvider } from "@/providers/TourProvider/TourModeContext"; import { buildTourPipelineYaml, cleanupOrphanTourPipelines, + clearStaleTourRegistryEntry, deleteTourPipelineByName, TOUR_PIPELINE_PREFIX, } from "@/providers/TourProvider/tourPipelineLifecycle"; @@ -48,7 +49,7 @@ function tourPipelineName(tour: TourDefinition): string { // survives in IndexedDB — we find it here and reuse it, preserving progress. // On any other exit, the unmount cleanup deletes it, and this mount-time // orphan sweep removes anything stale from prior tours or crashes. -async function createOrReuseTourPipeline( +async function doCreateOrReuseTourPipeline( tour: TourDefinition, storage: PipelineStorageService, ): Promise { @@ -56,17 +57,48 @@ async function createOrReuseTourPipeline( await cleanupOrphanTourPipelines(storage, name); + // FolderIndexDbStorageDriver.hasKey checks the registry, not IndexedDB, + // so resolvePipelineByName can return a PipelineFile for an entry whose + // backing IndexedDB content is already gone (mid-deleteFile race, prior + // crash). Probe with read() to confirm the file is actually present. const existing = await storage.resolvePipelineByName(name); if (existing) { - return { name, fileId: existing.id }; + try { + await existing.read(); + return { name, fileId: existing.id }; + } catch { + // Stale entry — fall through to clear + recreate. + } } + await clearStaleTourRegistryEntry(name); + const yamlContent = await buildTourPipelineYaml(tour, name); const file = await storage.rootFolder.addFile(name, yamlContent); - return { name, fileId: file.id }; } +// Strict-mode double-mounts run this effect twice concurrently. With +// deterministic naming both invocations target the same storage key and race +// `addFile`'s unique constraint. Share one in-flight Promise per tour id so +// the second caller awaits the first's result instead of racing it. +const inflightCreates = new Map>(); + +function createOrReuseTourPipeline( + tour: TourDefinition, + storage: PipelineStorageService, +): Promise { + const existing = inflightCreates.get(tour.id); + if (existing) return existing; + const promise = doCreateOrReuseTourPipeline(tour, storage).finally(() => { + if (inflightCreates.get(tour.id) === promise) { + inflightCreates.delete(tour.id); + } + }); + inflightCreates.set(tour.id, promise); + return promise; +} + function LoadingState() { return (
diff --git a/src/routes/v2/pages/Editor/components/ArgumentRow/ArgumentRow.tsx b/src/routes/v2/pages/Editor/components/ArgumentRow/ArgumentRow.tsx index c51524aaf..52b9de2f5 100644 --- a/src/routes/v2/pages/Editor/components/ArgumentRow/ArgumentRow.tsx +++ b/src/routes/v2/pages/Editor/components/ArgumentRow/ArgumentRow.tsx @@ -165,7 +165,12 @@ export const ArgumentRow = observer(function ArgumentRow({ const typeLabel = typeSpecToString(inputSpec.type); return ( -
+
{ + 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]); + + // Steps that bring the user back to the component library after a search + // can flip a flag to clear the search input so folder navigation works + // again. We update the input via the native setter so React's onChange + // handler picks up the change and the parent's filter state resets. + // + // The search component debounces its state by ~200ms, and reactour's + // mutationObservables only check direct mutation.addedNodes — when the + // folder tree remounts as a unit, the wrapper is added but its descendant + // [data-folder-name] items aren't seen as added nodes. So after the reset + // we poll for the folder tree to come back, then bump reactour via + // setSteps to force a re-render and a fresh querySelector on step.selector. + 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]); + + // Per-element ring overlay. Reactour merges highlightedSelectors into a + // single union rect, so an inner narrow ring is impossible through that + // mechanism. We apply a CSS class to matching elements and refresh it on + // any DOM mutation so late-arriving elements (e.g. search results) get + // ringed too. + 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; @@ -168,11 +330,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 () => { @@ -181,6 +350,220 @@ 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()) { + advance(); + 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; + }; + + // Search is debounced and results render asynchronously, so give the + // result list a beat to settle. Otherwise the next step flashes in its + // fallback position before its selector lands in the DOM. + let advanceTimer: ReturnType | null = null; + const scheduleAdvance = () => { + if (advanceTimer !== null) return; + advanceTimer = setTimeout(() => { + advanceTimer = null; + advance(); + }, 600); + }; + + if (matches()) { + scheduleAdvance(); + } + + const handleInput = (event: Event) => { + const target = event.target as Element | null; + if (target?.matches(sel) && matches()) scheduleAdvance(); + }; + 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()) { + advance(); + return stopFollow; + } + + const dispose = reaction( + () => hasArgumentValue(), + (matches) => { + if (matches) { + dispose(); + advance(); + } + }, + ); + + return () => { + stopFollow(); + dispose(); + }; + } + + if (interaction === "connect-edge" && step?.targetEdge) { + // Gate advancement on the SPECIFIC edge described by targetEdge. Any + // other connection the user makes is allowed but won't advance the + // step; only the prescribed source→target binding does. When only the + // source is given, any new binding from that source counts — used when + // wiring into IO nodes whose entity ids aren't predictable from JSON. + 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()) { + advance(); + return stopFollow; + } + + const dispose = reaction( + () => hasTargetEdge(), + (matches) => { + if (matches) { + dispose(); + advance(); + } + }, + ); + + return () => { + stopFollow(); + dispose(); + }; + } + + if (interaction === "add-task" && step?.targetTaskName) { + // Specific-task variant: advance only when a task whose name matches + // (case-insensitive substring) is added. The drag-block is handled by + // the standalone effect above keyed on targetTaskName / targetComponentName. + 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)) { + // Snapshot baseline so each step gates on "do one more of X", not on + // absolute count — works for the first edge and the third edge alike. + 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, @@ -191,6 +574,7 @@ export function EditorTourBridge() { step, steps, windows, + navigation, ]); return null; diff --git a/src/routes/v2/pages/Editor/store/autoSaveStore.ts b/src/routes/v2/pages/Editor/store/autoSaveStore.ts index 871a43a34..885f5f15a 100644 --- a/src/routes/v2/pages/Editor/store/autoSaveStore.ts +++ b/src/routes/v2/pages/Editor/store/autoSaveStore.ts @@ -14,6 +14,12 @@ import type { UndoStore } from "./undoStore"; const AUTOSAVE_MIN_SAVING_INDICATOR_MS = 600; +// Pipelines with this prefix are ephemeral (guided tours). Their owning route +// deletes them on unmount; a flush-on-dispose write here runs in the same +// unmount chain and would rewrite the IndexedDB row right after that delete, +// leaving an orphan file in the user's pipelines list. +const EPHEMERAL_PIPELINE_PREFIX = "__tour__"; + export class AutoSaveStore { @observable accessor isSaving = false; @observable accessor lastSavedAt: Date | null = null; @@ -48,9 +54,20 @@ export class AutoSaveStore { } @action dispose() { + // Flush any pending change before tearing down. cancel() alone drops it, + // so an edit within the debounce window (e.g. a drag immediately followed + // by a refresh or navigation) wouldn't get persisted. Skip for ephemeral + // tour pipelines — their owning route deletes the file in the same + // unmount chain and a flush here would resurrect it. + const yaml = this.serializeSpec(); + const file = this.pipelineFileStore.activePipelineFile; + const isEphemeral = file?.storageKey.startsWith(EPHEMERAL_PIPELINE_PREFIX); + if (yaml && file && !isEphemeral) { + void file.write(yaml); + } + this.debouncedSave.cancel(); this.disposeReaction?.(); this.disposeReaction = null; - this.debouncedSave.cancel(); this.spec = null; this.pipelineName = null; } diff --git a/src/routes/v2/shared/nodes/IONode/inputManifestBase.ts b/src/routes/v2/shared/nodes/IONode/inputManifestBase.ts index af5fb77df..5ded8f9cb 100644 --- a/src/routes/v2/shared/nodes/IONode/inputManifestBase.ts +++ b/src/routes/v2/shared/nodes/IONode/inputManifestBase.ts @@ -73,7 +73,7 @@ export const inputManifestBase: ManifestPartial = { ioType: "input", name: input.name, } satisfies IONodeData, - { "data-task-name": input.name }, + { "data-task-name": input.name, "data-tour-node": "input" }, ), ); }, diff --git a/src/routes/v2/shared/nodes/IONode/outputManifestBase.ts b/src/routes/v2/shared/nodes/IONode/outputManifestBase.ts index a544d8740..6e2a5c53e 100644 --- a/src/routes/v2/shared/nodes/IONode/outputManifestBase.ts +++ b/src/routes/v2/shared/nodes/IONode/outputManifestBase.ts @@ -68,7 +68,7 @@ export const outputManifestBase: ManifestPartial = { ioType: "output", name: output.name, } satisfies IONodeData, - { "data-task-name": output.name }, + { "data-task-name": output.name, "data-tour-node": "output" }, ), ); }, diff --git a/src/styles/editor.css b/src/styles/editor.css index d0b04745a..4464b2fd2 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -1,3 +1,14 @@ +/* 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; +} + /* 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/