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/