From 7a16a28b6d35c0034b159ed997b6ada13cc8a01c Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Fri, 22 May 2026 16:35:56 -0700 Subject: [PATCH] feat: Guided Tours Rework --- src/components/Learn/FeaturedTours.tsx | 116 ++++--- src/components/Learn/ToursLibrary.tsx | 37 +- src/components/Learn/tours.json | 2 +- .../Learn/tours/navigatingEditor.tour.ts | 2 +- src/components/layout/AppMenu.tsx | 4 + .../TourProvider/TourModeContext.tsx | 45 +++ .../TourProvider/TourOrphanCleanup.tsx | 29 ++ src/providers/TourProvider/TourProvider.tsx | 319 +----------------- src/providers/TourProvider/finishingSignal.ts | 21 -- src/providers/TourProvider/index.ts | 3 +- .../TourProvider/pausedTourStorage.ts | 37 -- src/providers/TourProvider/tourPopover.tsx | 7 +- src/routes/Tour.tsx | 262 ++++++++++++++ src/routes/router.ts | 11 + src/routes/v2/pages/Editor/EditorV2.tsx | 22 +- .../EditorMenuBar/EditorMenuBar.tsx | 137 ++++++-- .../EditorMenuBar/components/FileMenu.tsx | 48 +-- .../Editor/components/ResumeTourButton.tsx | 42 --- 18 files changed, 628 insertions(+), 516 deletions(-) create mode 100644 src/providers/TourProvider/TourModeContext.tsx create mode 100644 src/providers/TourProvider/TourOrphanCleanup.tsx delete mode 100644 src/providers/TourProvider/finishingSignal.ts delete mode 100644 src/providers/TourProvider/pausedTourStorage.ts create mode 100644 src/routes/Tour.tsx delete mode 100644 src/routes/v2/pages/Editor/components/ResumeTourButton.tsx diff --git a/src/components/Learn/FeaturedTours.tsx b/src/components/Learn/FeaturedTours.tsx index 9679d75ae..a4a88114e 100644 --- a/src/components/Learn/FeaturedTours.tsx +++ b/src/components/Learn/FeaturedTours.tsx @@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Heading, Paragraph, Text } from "@/components/ui/typography"; -import { useTours } from "@/providers/TourProvider"; +import { APP_ROUTES } from "@/routes/router"; import { tracking } from "@/utils/tracking"; import { tours as tourCards } from "./tours"; @@ -20,7 +20,7 @@ interface FeaturedTour { } const FEATURED_TOUR_IDS: Array> = [ - { id: "navigating-editor", tag: "new" }, + { id: "navigating-the-editor", tag: "new" }, { id: "first-pipeline", tag: "popular" }, { id: "using-secrets" }, { id: "multinode-tasks" }, @@ -43,7 +43,6 @@ function buildFeaturedTours(): FeaturedTour[] { } export function FeaturedTours() { - const { startTour } = useTours(); const featured = buildFeaturedTours(); return ( @@ -73,47 +72,47 @@ export function FeaturedTours() { @@ -121,3 +120,32 @@ export function FeaturedTours() { ); } + +function FeaturedTourLabel({ tour }: { tour: FeaturedTour }) { + return ( + + + + {tour.title} + + {tour.tag && ( + + {tour.tag} + + )} + {!tour.available && ( + + Coming soon + + )} + + + {tour.duration} + + + ); +} diff --git a/src/components/Learn/ToursLibrary.tsx b/src/components/Learn/ToursLibrary.tsx index 1cf9356c1..b6c1683d3 100644 --- a/src/components/Learn/ToursLibrary.tsx +++ b/src/components/Learn/ToursLibrary.tsx @@ -1,3 +1,5 @@ +import { Link } from "@tanstack/react-router"; + import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -10,7 +12,7 @@ import { import { Icon } from "@/components/ui/icon"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Heading, Paragraph, Text } from "@/components/ui/typography"; -import { useTours } from "@/providers/TourProvider"; +import { APP_ROUTES } from "@/routes/router"; import { tracking } from "@/utils/tracking"; import { @@ -25,7 +27,6 @@ import { import { getTour } from "./tours/registry"; function TourCard({ tour }: { tour: Tour }) { - const { startTour } = useTours(); const isAvailable = getTour(tour.id) !== undefined; return ( @@ -46,16 +47,28 @@ function TourCard({ tour }: { tour: Tour }) { {tour.duration} - + {isAvailable ? ( + + ) : ( + + )} diff --git a/src/components/Learn/tours.json b/src/components/Learn/tours.json index e6ae145d9..8131b5185 100644 --- a/src/components/Learn/tours.json +++ b/src/components/Learn/tours.json @@ -8,7 +8,7 @@ "area": "Editor" }, { - "id": "navigating-editor", + "id": "navigating-the-editor", "title": "Find your way around the editor", "description": "Get oriented with the canvas, dockable panels, properties view, canvas tools, and the menu bar so you know where every feature lives.", "difficulty": "Beginner", diff --git a/src/components/Learn/tours/navigatingEditor.tour.ts b/src/components/Learn/tours/navigatingEditor.tour.ts index 1beac0b2c..b9fe979ca 100644 --- a/src/components/Learn/tours/navigatingEditor.tour.ts +++ b/src/components/Learn/tours/navigatingEditor.tour.ts @@ -140,7 +140,7 @@ const steps: TourStep[] = [ ]; export const navigatingEditorTour: TourDefinition = { - id: "navigating-editor", + id: "navigating-the-editor", displayName: "Guided Tour: Navigating the Editor", requiresEditor: true, starterPipelineUrl: diff --git a/src/components/layout/AppMenu.tsx b/src/components/layout/AppMenu.tsx index 76acd180d..cea3f0515 100644 --- a/src/components/layout/AppMenu.tsx +++ b/src/components/layout/AppMenu.tsx @@ -196,6 +196,10 @@ const AppMenu = () => { return null; } + if (pathname.startsWith(APP_ROUTES.TOUR)) { + return null; + } + return ; }; diff --git a/src/providers/TourProvider/TourModeContext.tsx b/src/providers/TourProvider/TourModeContext.tsx new file mode 100644 index 000000000..de1fec670 --- /dev/null +++ b/src/providers/TourProvider/TourModeContext.tsx @@ -0,0 +1,45 @@ +import { createContext, type ReactNode, useContext } from "react"; + +import type { TourDefinition } from "@/components/Learn/tours/registry"; + +/** + * Provided by the `/tour/$tourId` route. Editor components inside use + * `useTourMode()` to detect tour mode and adapt their UI — e.g. show the + * tour title instead of the pipeline storage key, hide actions that don't + * make sense for a transient tour pipeline. + */ +export interface TourModeValue { + tour: TourDefinition; + /** + * Storage key of the current temp pipeline backing this tour. The "Save + * as new pipeline" action uses this to know what file to promote. + */ + tempPipelineName: string; + /** + * Called after the temp pipeline has been promoted to a real pipeline + * (renamed / saved-as). The route uses this to skip its on-unmount + * delete and avoid clobbering the just-saved file. + */ + markPipelinePromoted: () => void; +} + +const TourModeContext = createContext(null); + +export function TourModeProvider({ + value, + children, +}: { + value: TourModeValue; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +/** Returns the current tour-mode value, or null when not inside a tour. */ +export function useTourMode(): TourModeValue | null { + return useContext(TourModeContext); +} diff --git a/src/providers/TourProvider/TourOrphanCleanup.tsx b/src/providers/TourProvider/TourOrphanCleanup.tsx new file mode 100644 index 000000000..34f26ab01 --- /dev/null +++ b/src/providers/TourProvider/TourOrphanCleanup.tsx @@ -0,0 +1,29 @@ +import { useRouterState } from "@tanstack/react-router"; +import { useEffect } from "react"; + +import { APP_ROUTES } from "@/routes/router"; +import { usePipelineStorage } from "@/services/pipelineStorage/PipelineStorageProvider"; + +import { cleanupOrphanTourPipelines } from "./tourPipelineLifecycle"; + +/** + * Removes any `__tour__*` pipelines lingering in storage. Tour pipelines + * are deleted by the `/tour/$tourId` route on unmount, but a tab close or + * crash skips that path — this sweep handles those orphans on app load. + * + * Skipped while the user is actually on a tour route so we don't race + * with that route's own create-pipeline flow. + */ +export function TourOrphanCleanup() { + const storage = usePipelineStorage(); + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }); + + useEffect(() => { + if (pathname.startsWith(APP_ROUTES.TOUR)) return; + void cleanupOrphanTourPipelines(storage); + }, [storage, pathname]); + + return null; +} diff --git a/src/providers/TourProvider/TourProvider.tsx b/src/providers/TourProvider/TourProvider.tsx index 712aae51e..8713c2e37 100644 --- a/src/providers/TourProvider/TourProvider.tsx +++ b/src/providers/TourProvider/TourProvider.tsx @@ -1,309 +1,20 @@ -import { - TourProvider as ReactourProvider, - useTour, -} from "@reactour/tour"; -import { useNavigate, useRouterState } from "@tanstack/react-router"; -import { generate } from "random-words"; -import { - createContext, - type ReactNode, - useContext, - useEffect, - useRef, - useState, -} from "react"; - -import { getTour } from "@/components/Learn/tours/registry"; -import { APP_ROUTES } from "@/routes/router"; -import { - restoreLayout, - snapshotLayout, -} from "@/routes/v2/shared/windows/windowPersistence"; -import { usePipelineStorage } from "@/services/pipelineStorage/PipelineStorageProvider"; +import { TourProvider as ReactourProvider } from "@reactour/tour"; +import type { ReactNode } from "react"; -import { finishingSignal } from "./finishingSignal"; -import { - type PausedTourState, - readPausedTour, - writePausedTour, -} from "./pausedTourStorage"; -import { - buildTourPipelineYaml, - cleanupOrphanTourPipelines, - deleteTourPipelineByName, - promoteTourPipelineName, - TOUR_PIPELINE_PREFIX, -} from "./tourPipelineLifecycle"; +import { TourOrphanCleanup } from "./TourOrphanCleanup"; import { computeDefaultPopoverPosition, POPOVER_STYLES, PopoverClampBridge, renderNextButton, } from "./tourPopover"; -import { waitForSelector } from "./waitForSelector"; - -const EDITOR_LAYOUT_ID = "editor"; - -interface TourContextValue { - startTour: (tourId: string) => Promise; - resumeTour: () => Promise; - dismissPausedTour: () => Promise; - pausedTour: PausedTourState | null; -} - -const TourContext = createContext(null); - -function TourOrchestrator({ children }: { children: ReactNode }) { - const { setSteps, setCurrentStep, setIsOpen, currentStep, isOpen } = - useTour(); - const navigate = useNavigate(); - const routerState = useRouterState(); - const pathname = routerState.location.pathname; - const storage = usePipelineStorage(); - - const activeTourIdRef = useRef(null); - const tempPipelineNameRef = useRef(null); - const tempPipelineFileIdRef = useRef(null); - const lastStepRef = useRef(0); - - const [pausedTour, setPausedTour] = useState(() => - readPausedTour(), - ); - - // Track current step so we can save it if the tour is interrupted. - useEffect(() => { - lastStepRef.current = currentStep; - }, [currentStep]); - - // If the user navigates away from the page the tour expects to be on - // (e.g. hits the browser back button while the editor tour is running), - // close the tour so the popover doesn't strand on the new route. - // `TourCloseListener` then runs `handleBeforeClose`, which saves the paused - // state so the user can resume from the floating button later. - useEffect(() => { - if (!isOpen) return; - const tourId = activeTourIdRef.current; - if (!tourId) return; - const tour = getTour(tourId); - if (!tour) return; - if (tour.requiresEditor && !pathname.startsWith(APP_ROUTES.EDITOR_V2)) { - setIsOpen(false); - } - }, [pathname, isOpen, setIsOpen]); - - useEffect(() => { - const paused = readPausedTour(); - setPausedTour(paused); - cleanupOrphanTourPipelines(storage, paused?.pipelineName ?? null); - restoreLayout(EDITOR_LAYOUT_ID); - }, [storage]); - - const startTour = async (tourId: string) => { - const tour = getTour(tourId); - if (!tour) { - console.warn(`Unknown tour: ${tourId}`); - return; - } - - // Starting a new tour discards any previous paused state and its - // associated pipeline. - const previousPaused = readPausedTour(); - if (previousPaused?.pipelineName) { - await deleteTourPipelineByName(storage, previousPaused.pipelineName); - } - writePausedTour(null); - setPausedTour(null); - - activeTourIdRef.current = tourId; - finishingSignal.reset(); - - if (tour.requiresEditor && !pathname.startsWith(APP_ROUTES.EDITOR_V2)) { - if (tempPipelineNameRef.current) { - await deleteTourPipelineByName(storage, tempPipelineNameRef.current); - tempPipelineNameRef.current = null; - tempPipelineFileIdRef.current = null; - } - - const words = (generate(3) as string[]).join("-"); - const name = `${TOUR_PIPELINE_PREFIX}${words}`; - const yamlContent = await buildTourPipelineYaml(tour, name); - const file = await storage.rootFolder.addFile(name, yamlContent); - tempPipelineNameRef.current = name; - tempPipelineFileIdRef.current = file.id; - - snapshotLayout(EDITOR_LAYOUT_ID); - - await navigate({ - to: APP_ROUTES.EDITOR_V2_PIPELINE, - params: { pipelineName: name }, - search: { fileId: file.id }, - }); - } - - if (tour.requiresEditor) { - await waitForSelector('[data-testid="editor-v2"]'); - } else { - const firstSelector = tour.steps[0]?.selector; - if (typeof firstSelector === "string") { - await waitForSelector(firstSelector); - } - } - - setSteps?.(tour.steps); - setCurrentStep(0); - setIsOpen(true); - }; - - const resumeTour = async () => { - const paused = readPausedTour(); - if (!paused) return; - const tour = getTour(paused.tourId); - if (!tour) { - writePausedTour(null); - setPausedTour(null); - return; - } - - activeTourIdRef.current = paused.tourId; - finishingSignal.reset(); - tempPipelineNameRef.current = paused.pipelineName ?? null; - tempPipelineFileIdRef.current = paused.fileId ?? null; - - if ( - tour.requiresEditor && - paused.pipelineName && - (!pathname.startsWith(APP_ROUTES.EDITOR_V2) || - !pathname.includes(paused.pipelineName)) - ) { - snapshotLayout(EDITOR_LAYOUT_ID); - await navigate({ - to: APP_ROUTES.EDITOR_V2_PIPELINE, - params: { pipelineName: paused.pipelineName }, - search: paused.fileId ? { fileId: paused.fileId } : {}, - }); - } - - if (tour.requiresEditor) { - await waitForSelector('[data-testid="editor-v2"]'); - } - - setSteps?.(tour.steps); - const safeStep = Math.min( - Math.max(0, paused.step), - Math.max(0, tour.steps.length - 1), - ); - setCurrentStep(safeStep); - setIsOpen(true); - }; - - const dismissPausedTour = async () => { - const paused = readPausedTour(); - writePausedTour(null); - setPausedTour(null); - if (paused?.pipelineName) { - await deleteTourPipelineByName(storage, paused.pipelineName); - } - tempPipelineNameRef.current = null; - tempPipelineFileIdRef.current = null; - }; - - const handleBeforeClose = () => { - restoreLayout(EDITOR_LAYOUT_ID); - - const wasFinishing = finishingSignal.consume(); - - const tourId = activeTourIdRef.current; - const pipelineName = tempPipelineNameRef.current; - const fileId = tempPipelineFileIdRef.current; - const step = lastStepRef.current; - - if (wasFinishing) { - tempPipelineNameRef.current = null; - tempPipelineFileIdRef.current = null; - activeTourIdRef.current = null; - if (pipelineName) deleteTourPipelineByName(storage, pipelineName); - writePausedTour(null); - setPausedTour(null); - return; - } - - // X / ESC: preserve the pipeline and remember where we left off so the - // user can resume from the floating button. Promote the `__tour__*` slug - // to the tour's displayName so the pipeline shows up in lists with a - // human-readable name. Keep `__tour__` if the user has renamed it - // themselves during the tour. - if (tourId) { - const tour = getTour(tourId); - const desired = tour?.displayName ?? pipelineName ?? ""; - - void (async () => { - let finalName = pipelineName; - - if (pipelineName && desired) { - const { newName, renamed } = await promoteTourPipelineName( - storage, - pipelineName, - desired, - ); - finalName = newName; - tempPipelineNameRef.current = newName; - - // Update the URL only if the user is still sitting on the tour - // pipeline's route at this moment — they may have navigated away - // while the rename was running. - const livePath = window.location.pathname; - const stillOnTourPipeline = - renamed && - livePath.startsWith(APP_ROUTES.EDITOR_V2) && - livePath.includes(pipelineName); - - if (stillOnTourPipeline) { - await navigate({ - to: APP_ROUTES.EDITOR_V2_PIPELINE, - params: { pipelineName: newName }, - search: fileId ? { fileId } : {}, - replace: true, - }); - } - } - - const next: PausedTourState = { - tourId, - step, - pipelineName: finalName ?? undefined, - fileId: fileId ?? undefined, - }; - writePausedTour(next); - setPausedTour(next); - })(); - } - }; - - return ( - - - - {children} - - ); -} - -function TourCloseListener({ onClose }: { onClose: () => void }) { - const { isOpen } = useTour(); - const wasOpen = useRef(false); - - useEffect(() => { - if (wasOpen.current && !isOpen) { - onClose(); - } - wasOpen.current = isOpen; - }, [isOpen, onClose]); - - return null; -} +/** + * Top-level reactour provider. Mounted in `RootLayout` so any descendant can + * read the tour state via `@reactour/tour`'s `useTour()` hook. All tour + * orchestration (mounting steps, syncing URL ↔ current step, lifecycle of + * the temp pipeline) lives in the `/tour/$tourId` route — not here. + */ export function TourProvider({ children }: { children: ReactNode }) { return ( undefined} > - {children} + + + {children} ); } - -export function useTours(): TourContextValue { - const ctx = useContext(TourContext); - if (!ctx) { - throw new Error("useTours must be used within TourProvider"); - } - return ctx; -} diff --git a/src/providers/TourProvider/finishingSignal.ts b/src/providers/TourProvider/finishingSignal.ts deleted file mode 100644 index 2cc93bb7c..000000000 --- a/src/providers/TourProvider/finishingSignal.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Reactour renders the Popover (and our FinishButton inside it) as a SIBLING -// of the provider's `children`, so a React Context placed inside the -// orchestrator isn't visible to FinishButton. We use module-local closure -// state with explicit setter/getter functions so call sites in components -// and hooks invoke utility functions instead of writing to outer-scope -// variables (which react-compiler flags). -let flag = false; - -export const finishingSignal = { - mark(): void { - flag = true; - }, - consume(): boolean { - const previous = flag; - flag = false; - return previous; - }, - reset(): void { - flag = false; - }, -}; diff --git a/src/providers/TourProvider/index.ts b/src/providers/TourProvider/index.ts index bba60cffd..8fc8352dd 100644 --- a/src/providers/TourProvider/index.ts +++ b/src/providers/TourProvider/index.ts @@ -1 +1,2 @@ -export { TourProvider, useTours } from "./TourProvider"; +export { useTourMode } from "./TourModeContext"; +export { TourProvider } from "./TourProvider"; diff --git a/src/providers/TourProvider/pausedTourStorage.ts b/src/providers/TourProvider/pausedTourStorage.ts deleted file mode 100644 index e34dad8ac..000000000 --- a/src/providers/TourProvider/pausedTourStorage.ts +++ /dev/null @@ -1,37 +0,0 @@ -const PAUSED_TOUR_KEY = "tour-paused-state"; - -export interface PausedTourState { - tourId: string; - step: number; - pipelineName?: string; - fileId?: string; -} - -export function readPausedTour(): PausedTourState | null { - try { - const raw = localStorage.getItem(PAUSED_TOUR_KEY); - if (!raw) return null; - const parsed = JSON.parse(raw) as PausedTourState; - if ( - typeof parsed?.tourId === "string" && - typeof parsed?.step === "number" - ) { - return parsed; - } - } catch { - // fall through - } - return null; -} - -export function writePausedTour(state: PausedTourState | null): void { - try { - if (state === null) { - localStorage.removeItem(PAUSED_TOUR_KEY); - } else { - localStorage.setItem(PAUSED_TOUR_KEY, JSON.stringify(state)); - } - } catch { - // ignore - } -} diff --git a/src/providers/TourProvider/tourPopover.tsx b/src/providers/TourProvider/tourPopover.tsx index 4403f4173..46e2ba08b 100644 --- a/src/providers/TourProvider/tourPopover.tsx +++ b/src/providers/TourProvider/tourPopover.tsx @@ -5,8 +5,6 @@ import { useEffect } from "react"; import type { TourStep } from "@/components/Learn/tours/registry"; import { APP_ROUTES } from "@/routes/router"; -import { finishingSignal } from "./finishingSignal"; - // Keep the popover at least this many pixels away from every viewport edge. // Matches the badge's outside offset (≈13px) plus a small safety margin so // the step-number chip in the top-left of the popover never gets clipped. @@ -112,7 +110,10 @@ function FinishButton({ setIsOpen }: Pick) { color: "#1f2937", }} onClick={() => { - finishingSignal.mark(); + // Finish exits the tour entirely. The route's unmount handles + // cleanup (delete temp pipeline, clear session, restore layout). + // X / ESC, by contrast, just close the popover and leave the + // user on the tour route so they can save or resume. setIsOpen(false); navigate({ to: APP_ROUTES.LEARN_TOURS }); }} diff --git a/src/routes/Tour.tsx b/src/routes/Tour.tsx new file mode 100644 index 000000000..a9b107c6c --- /dev/null +++ b/src/routes/Tour.tsx @@ -0,0 +1,262 @@ +import { useTour } from "@reactour/tour"; +import { + Navigate, + useNavigate, + useParams, + useSearch, +} from "@tanstack/react-router"; +import { generate } from "random-words"; +import { useEffect, useRef, useState } from "react"; + +import { + getTour, + type TourDefinition, +} from "@/components/Learn/tours/registry"; +import { InlineStack } from "@/components/ui/layout"; +import { Spinner } from "@/components/ui/spinner"; +import { Text } from "@/components/ui/typography"; +import { TourModeProvider } from "@/providers/TourProvider/TourModeContext"; +import { + buildTourPipelineYaml, + cleanupOrphanTourPipelines, + deleteTourPipelineByName, + TOUR_PIPELINE_PREFIX, +} from "@/providers/TourProvider/tourPipelineLifecycle"; +import { waitForSelector } from "@/providers/TourProvider/waitForSelector"; +import { APP_ROUTES } from "@/routes/router"; +import { EditorV2 } from "@/routes/v2/pages/Editor/EditorV2"; +import { + restoreLayout, + snapshotLayout, +} from "@/routes/v2/shared/windows/windowPersistence"; +import { usePipelineStorage } from "@/services/pipelineStorage/PipelineStorageProvider"; +import type { PipelineStorageService } from "@/services/pipelineStorage/PipelineStorageService"; + +const EDITOR_LAYOUT_ID = "editor"; + +interface ResolvedPipeline { + name: string; + fileId: string; +} + +/** + * Always creates a fresh temp pipeline for the tour. Any pre-existing + * `__tour__*` pipelines are deleted first — tours have no save state, so + * leftover ones (from closed tabs, crashes, or strict-mode double-mounts) + * are always orphans. + */ +async function createTourPipeline( + tour: TourDefinition, + storage: PipelineStorageService, +): Promise { + await cleanupOrphanTourPipelines(storage); + + const slug = (generate(3) as string[]).join("-"); + const name = `${TOUR_PIPELINE_PREFIX}${slug}`; + const yamlContent = await buildTourPipelineYaml(tour, name); + const file = await storage.rootFolder.addFile(name, yamlContent); + + return { name, fileId: file.id }; +} + +function LoadingState() { + return ( +
+ + + Preparing tour… + +
+ ); +} + +/** + * Bridges the URL's `?step=N` and reactour's internal `currentStep` so + * either side can drive the other. Mounted only after the temp pipeline + * is ready so reactour doesn't try to highlight elements before the + * editor has mounted. + */ +function TourReactourBridge({ + tour, + urlStep, + onUrlStepChange, +}: { + tour: TourDefinition; + urlStep: number; + onUrlStepChange: (step: number) => void; +}) { + const { setSteps, setCurrentStep, setIsOpen, currentStep } = useTour(); + + const lastSyncRef = useRef(null); + const initializedRef = useRef(false); + + // Defer reactour activation until the editor has actually mounted — + // step selectors target editor DOM, and reactour silently no-ops when + // they're missing at open time. + useEffect(() => { + if (initializedRef.current) return; + let cancelled = false; + void waitForSelector('[data-testid="editor-v2"]').then(() => { + if (cancelled || initializedRef.current) return; + initializedRef.current = true; + + setSteps?.(tour.steps); + const clamped = Math.min(Math.max(0, urlStep), tour.steps.length - 1); + setCurrentStep(clamped); + lastSyncRef.current = clamped; + setIsOpen(true); + }); + return () => { + cancelled = true; + }; + }, [tour, urlStep, setSteps, setCurrentStep, setIsOpen]); + + // Close reactour when the route unmounts so the popover doesn't strand + // over whatever page the user navigated to. + useEffect(() => { + return () => { + setIsOpen(false); + }; + }, [setIsOpen]); + + // URL → reactour: browser back/forward, deep-link tweaks. Guarded so it + // doesn't fight the reactour-driven update below. + useEffect(() => { + if (!initializedRef.current) return; + if (urlStep === lastSyncRef.current) return; + lastSyncRef.current = urlStep; + const clamped = Math.min(Math.max(0, urlStep), tour.steps.length - 1); + setCurrentStep(clamped); + }, [urlStep, tour.steps.length, setCurrentStep]); + + // Reactour → URL: Next / Previous clicks. `replace: true` keeps history + // tidy (no entry per step click). + useEffect(() => { + if (!initializedRef.current) return; + if (currentStep === lastSyncRef.current) return; + lastSyncRef.current = currentStep; + onUrlStepChange(currentStep); + }, [currentStep, onUrlStepChange]); + + return null; +} + +export function TourPage() { + const params = useParams({ strict: false }); + const search = useSearch({ strict: false }); + const navigate = useNavigate(); + const storage = usePipelineStorage(); + + const tourId = + "tourId" in params && typeof params.tourId === "string" + ? params.tourId + : ""; + + const tour = getTour(tourId); + + const [resolved, setResolved] = useState(null); + + // Tracked via ref (not state) so the unmount cleanup closure can always + // read the latest pipeline name — even when state updates are batched or + // when strict-mode mounts the component twice. + const tempPipelineRef = useRef(null); + const promotedRef = useRef(false); + + // Snapshot the editor's saved layout so the tour boots with defaults. + // Restored on unmount regardless of how the user leaves. + useEffect(() => { + if (!tour) return; + snapshotLayout(EDITOR_LAYOUT_ID); + return () => { + restoreLayout(EDITOR_LAYOUT_ID); + }; + }, [tour]); + + // Create the temp pipeline. Always fresh — tours have no save state. + // The async flow is structured so that if the effect is cancelled + // mid-flight (strict-mode unmount, navigation away) we delete the + // pipeline we just created instead of orphaning it. + useEffect(() => { + if (!tour) return undefined; + let cancelled = false; + void (async () => { + let created: ResolvedPipeline | null = null; + try { + created = await createTourPipeline(tour, storage); + } catch (error) { + console.warn("Failed to create tour pipeline:", error); + return; + } + if (cancelled) { + await deleteTourPipelineByName(storage, created.name); + return; + } + tempPipelineRef.current = created.name; + setResolved(created); + })(); + return () => { + cancelled = true; + }; + }, [tour, storage]); + + // Delete the temp pipeline on unmount unless it was promoted to a real + // pipeline via "Save as new pipeline". + useEffect(() => { + return () => { + if (promotedRef.current) return; + const name = tempPipelineRef.current; + if (!name) return; + tempPipelineRef.current = null; + void deleteTourPipelineByName(storage, name); + }; + }, [storage]); + + const handleUrlStepChange = (step: number) => { + void navigate({ + to: APP_ROUTES.TOUR_DETAIL, + params: { tourId }, + search: { step }, + replace: true, + }); + }; + + const markPipelinePromoted = () => { + promotedRef.current = true; + }; + + if (!tour) { + return ; + } + + if (!resolved) { + return ; + } + + const rawStep = (search as { step?: unknown }).step; + const parsedStep = + typeof rawStep === "number" + ? rawStep + : typeof rawStep === "string" + ? Number.parseInt(rawStep, 10) + : 0; + const urlStep = Number.isFinite(parsedStep) ? parsedStep : 0; + + return ( + + + + + ); +} diff --git a/src/routes/router.ts b/src/routes/router.ts index 5692898b6..c3b5d78f3 100644 --- a/src/routes/router.ts +++ b/src/routes/router.ts @@ -37,6 +37,7 @@ import { BetaFeaturesSettings } from "./Settings/sections/BetaFeaturesSettings"; import { PreferencesSettings } from "./Settings/sections/PreferencesSettings"; import { SecretsSettings } from "./Settings/sections/SecretsSettings"; import { SettingsLayout } from "./Settings/SettingsLayout"; +import { TourPage } from "./Tour"; import { EditorV2 } from "./v2/pages/Editor/EditorV2"; import { PipelineFoldersPage } from "./v2/pages/PipelineFolders/PipelineFoldersPage"; import { RunViewV2 } from "./v2/pages/RunView/RunViewV2"; @@ -54,6 +55,7 @@ const EDITOR_V2_BASE_PATH = "/editor-v2"; const RUNS_V2_BASE_PATH = "/runs-v2"; const SETTINGS_PATH = "/settings"; const IMPORT_PATH = "/app/editor/import-pipeline"; +const TOUR_BASE_PATH = "/tour"; export const APP_ROUTES = { HOME: "/", DASHBOARD: "/", @@ -88,6 +90,8 @@ export const APP_ROUTES = { PIPELINE_FOLDERS: "/pipeline-folders", PLAYGROUND: "/playground", ARTIFACT_PREVIEW: "/artifact/$artifactId", + TOUR: TOUR_BASE_PATH, + TOUR_DETAIL: `${TOUR_BASE_PATH}/$tourId`, } as const; const rootRoute = createRootRoute({ @@ -324,6 +328,12 @@ const artifactPreviewRoute = createRoute({ component: ArtifactPreviewPage, }); +const tourRoute = createRoute({ + getParentRoute: () => mainLayout, + path: APP_ROUTES.TOUR_DETAIL, + component: TourPage, +}); + const dashboardRouteTree = dashboardRoute.addChildren([ dashboardIndexRoute, dashboardRunsRoute, @@ -351,6 +361,7 @@ const appRouteTree = mainLayout.addChildren([ runV2WithSubgraphRoute, pipelineFoldersRoute, artifactPreviewRoute, + tourRoute, ]); const rootRouteTree = rootRoute.addChildren([ diff --git a/src/routes/v2/pages/Editor/EditorV2.tsx b/src/routes/v2/pages/Editor/EditorV2.tsx index 51004d2e6..ba2516fb8 100644 --- a/src/routes/v2/pages/Editor/EditorV2.tsx +++ b/src/routes/v2/pages/Editor/EditorV2.tsx @@ -33,7 +33,6 @@ import { EditorMenuBar } from "./components/EditorMenuBar/EditorMenuBar"; import { EditorTourBridge } from "./components/EditorTourBridge"; import { EmptyEditorState } from "./components/EmptyEditorState"; import { FlowCanvas } from "./components/FlowCanvas/FlowCanvas"; -import { ResumeTourButton } from "./components/ResumeTourButton"; import { useComponentLibraryWindow } from "./hooks/useComponentLibraryWindow"; import { useEditorEscapeShortcut } from "./hooks/useEditorEscapeShortcut"; import { useHistoryWindow } from "./hooks/useHistoryWindow"; @@ -135,7 +134,6 @@ function EditorV2Content({ pipelineRef }: { pipelineRef: PipelineRef | null }) { - {pipelineRef ? ( @@ -151,9 +149,16 @@ function EditorV2Content({ pipelineRef }: { pipelineRef: PipelineRef | null }) { } /** - * Shell component for the Editor V2 route. + * Shell component for the Editor V2 route. Accepts a `pipelineRef` prop so + * non-editor-v2 routes (notably `/tour/$tourId`) can mount the same editor + * against a pipeline they resolved themselves; when no prop is passed, the + * shell reads `pipelineName` / `fileId` from the current route. */ -export function EditorV2() { +export function EditorV2({ + pipelineRef: pipelineRefProp, +}: { + pipelineRef?: PipelineRef | null; +} = {}) { const params = useParams({ strict: false }); const search = useSearch({ strict: false }); const fileId = @@ -166,9 +171,12 @@ export function EditorV2() { ? params.pipelineName : null; - const pipelineRef: PipelineRef | null = pipelineName - ? { name: pipelineName, fileId } - : null; + const pipelineRef: PipelineRef | null = + pipelineRefProp !== undefined + ? pipelineRefProp + : pipelineName + ? { name: pipelineName, fileId } + : null; return (
diff --git a/src/routes/v2/pages/Editor/components/EditorMenuBar/EditorMenuBar.tsx b/src/routes/v2/pages/Editor/components/EditorMenuBar/EditorMenuBar.tsx index 73a29c796..4b5b7f90f 100644 --- a/src/routes/v2/pages/Editor/components/EditorMenuBar/EditorMenuBar.tsx +++ b/src/routes/v2/pages/Editor/components/EditorMenuBar/EditorMenuBar.tsx @@ -1,17 +1,26 @@ +import { useTour } from "@reactour/tour"; +import { useNavigate } from "@tanstack/react-router"; import { observer } from "mobx-react-lite"; import { useState } from "react"; import logo from "/Tangle_Icon_White.png"; import { PipelineNameDialog } from "@/components/shared/Dialogs"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Link } from "@/components/ui/link"; import { Text } from "@/components/ui/typography"; +import { useTourMode } from "@/providers/TourProvider"; +import { promoteTourPipelineName } from "@/providers/TourProvider/tourPipelineLifecycle"; +import { APP_ROUTES } from "@/routes/router"; + +const TOUR_POPUP_OPEN_FALLBACK_LABEL = "Resume tour"; import { usePipelineRename } from "@/routes/v2/pages/Editor/hooks/usePipelineRename"; import { useEditorSession } from "@/routes/v2/pages/Editor/store/EditorSessionContext"; import { AppMenuActions } from "@/routes/v2/shared/components/AppMenuActions"; import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext"; +import { usePipelineStorage } from "@/services/pipelineStorage/PipelineStorageProvider"; import { TOP_NAV_HEIGHT } from "@/utils/constants"; import { tracking } from "@/utils/tracking"; @@ -28,11 +37,48 @@ export const EditorMenuBar = observer(function EditorMenuBar() { const { navigation } = useSharedStores(); const { pipelineFile } = useEditorSession(); const handlePipelineRename = usePipelineRename(); + const tourMode = useTourMode(); + const storage = usePipelineStorage(); + const navigate = useNavigate(); + const { isOpen: tourPopupOpen, setIsOpen: setTourPopupOpen } = useTour(); + const spec = navigation.activeSpec; - const pipelineName = spec?.name ?? "Untitled pipeline"; + const pipelineNameFromSpec = spec?.name ?? "Untitled pipeline"; + const displayName = + tourMode?.tour.displayName ?? tourMode?.tour.id ?? pipelineNameFromSpec; const displayMenu = Boolean(pipelineFile.activePipelineFile); const [renameOpen, setRenameOpen] = useState(false); + const [saveAsOpen, setSaveAsOpen] = useState(false); + + // While the popover is showing, the tour itself is driving the user. + // Off-popover actions (save, exit, resume) only make sense once the + // popover has been dismissed. + const showTourActions = Boolean(tourMode) && !tourPopupOpen; + + const handleTourSaveAs = async (name: string) => { + if (!tourMode) return; + const { newName } = await promoteTourPipelineName( + storage, + tourMode.tempPipelineName, + name, + ); + tourMode.markPipelinePromoted(); + const file = await storage.resolvePipelineByName(newName); + void navigate({ + to: APP_ROUTES.EDITOR_V2_PIPELINE, + params: { pipelineName: newName }, + search: file?.id ? { fileId: file.id } : {}, + }); + }; + + const handleResumeTour = () => { + setTourPopupOpen(true); + }; + + const handleExitTour = () => { + void navigate({ to: APP_ROUTES.LEARN_TOURS }); + }; return (
- {pipelineName} + {displayName} - + {tourMode && ( + + Tour + + )} + {!tourMode && ( + + )} - name === pipelineName} - /> + {!tourMode && ( + name === pipelineNameFromSpec} + /> + )} {displayMenu && ( <> + {showTourActions && tourMode && ( + <> + + + + +
+ + )}
diff --git a/src/routes/v2/pages/Editor/components/EditorMenuBar/components/FileMenu.tsx b/src/routes/v2/pages/Editor/components/EditorMenuBar/components/FileMenu.tsx index 0e27479b4..a4f7200e8 100644 --- a/src/routes/v2/pages/Editor/components/EditorMenuBar/components/FileMenu.tsx +++ b/src/routes/v2/pages/Editor/components/EditorMenuBar/components/FileMenu.tsx @@ -16,6 +16,7 @@ import { } from "@/components/ui/dropdown-menu"; import { Icon } from "@/components/ui/icon"; import { useAnalytics } from "@/providers/AnalyticsProvider"; +import { useTourMode } from "@/providers/TourProvider"; import { APP_ROUTES } from "@/routes/router"; import { useEditorSession } from "@/routes/v2/pages/Editor/store/EditorSessionContext"; import { MenuTriggerButton } from "@/routes/v2/shared/components/MenuTriggerButton"; @@ -55,6 +56,7 @@ export function FileMenu() { const activePipeline = pipelineFileStore.activePipelineFile; const [moveDialogOpen, setMoveDialogOpen] = useState(false); const canMove = activePipeline?.folder.canMoveFilesOut ?? false; + const tourMode = useTourMode(); return ( <> @@ -96,15 +98,17 @@ export function FileMenu() { Save as - { - track("v2.pipeline_editor.file_menu.rename.click"); - setRenameDialogOpen(true); - }} - > - - Rename - + {!tourMode && ( + { + track("v2.pipeline_editor.file_menu.rename.click"); + setRenameDialogOpen(true); + }} + > + + Rename + + )} { @@ -147,17 +151,21 @@ export function FileMenu() { )} - - { - track("v2.pipeline_editor.file_menu.delete_pipeline.click"); - setDeleteDialogOpen(true); - }} - className="text-destructive focus:text-destructive" - > - - Delete pipeline - + {!tourMode && ( + <> + + { + track("v2.pipeline_editor.file_menu.delete_pipeline.click"); + setDeleteDialogOpen(true); + }} + className="text-destructive focus:text-destructive" + > + + Delete pipeline + + + )} diff --git a/src/routes/v2/pages/Editor/components/ResumeTourButton.tsx b/src/routes/v2/pages/Editor/components/ResumeTourButton.tsx deleted file mode 100644 index 64ec3270f..000000000 --- a/src/routes/v2/pages/Editor/components/ResumeTourButton.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { Icon } from "@/components/ui/icon"; -import { useTours } from "@/providers/TourProvider"; -import { tracking } from "@/utils/tracking"; - -export function ResumeTourButton() { - const { pausedTour, resumeTour, dismissPausedTour } = useTours(); - - if (!pausedTour) return null; - - return ( -
-
- ); -}