From 13caf5e5ad113761c1525544ed84ebef974907bd Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Tue, 2 Jun 2026 17:04:10 -0700 Subject: [PATCH 1/2] feat: Guided Tours Foundation --- package.json | 1 + pnpm-lock.yaml | 60 +++++ src/components/Learn/FeaturedTours.tsx | 11 +- src/components/Learn/ToursLibrary.tsx | 42 +++- src/components/Learn/tours/registry.ts | 29 +++ src/components/layout/AppMenu.tsx | 4 + src/components/layout/RootLayout.tsx | 33 +-- src/providers/TourProvider/TourContent.tsx | 31 +++ .../TourProvider/TourModeContext.tsx | 28 +++ src/providers/TourProvider/TourPopover.tsx | 216 +++++++++++++++++ src/providers/TourProvider/TourProvider.tsx | 31 +++ .../TourProvider/tourPipelineLifecycle.ts | 35 +++ src/providers/TourProvider/waitForSelector.ts | 19 ++ src/routes/Dashboard/Learn/Tour.tsx | 222 +++++++++++++++++- src/routes/v2/pages/Editor/EditorV2.tsx | 49 ++-- .../EditorMenuBar/EditorMenuBar.tsx | 81 +++++-- .../EditorMenuBar/components/FileMenu.tsx | 48 ++-- .../Editor/components/EditorTourBridge.tsx | 3 + .../v2/shared/windows/windowPersistence.ts | 55 ++++- 19 files changed, 909 insertions(+), 89 deletions(-) create mode 100644 src/components/Learn/tours/registry.ts create mode 100644 src/providers/TourProvider/TourContent.tsx create mode 100644 src/providers/TourProvider/TourModeContext.tsx create mode 100644 src/providers/TourProvider/TourPopover.tsx create mode 100644 src/providers/TourProvider/TourProvider.tsx create mode 100644 src/providers/TourProvider/tourPipelineLifecycle.ts create mode 100644 src/providers/TourProvider/waitForSelector.ts create mode 100644 src/routes/v2/pages/Editor/components/EditorTourBridge.tsx diff --git a/package.json b/package.json index f3951b121..f7020dc80 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "@radix-ui/react-switch": "^1.3.1", "@radix-ui/react-tabs": "^1.1.15", "@radix-ui/react-tooltip": "^1.2.10", + "@reactour/tour": "3.8.0", "@tailwindcss/vite": "^4.3.1", "@tanstack/history": "1.162.0", "@tanstack/react-query": "^5.101.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e2ba0f9b..b8c5f92ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.2.10 version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@reactour/tour': + specifier: 3.8.0 + version: 3.8.0(react@19.2.7) '@tailwindcss/vite': specifier: ^4.3.1 version: 4.3.1(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -2263,6 +2266,26 @@ packages: resolution: {integrity: sha512-Cc7d8mSwvoV8gpeTQbE8dMPdeXIyO6w+yIhzgi3jY06i03WLNhb/6jIxNBNF1cVRI7ujnFQXZA66BbnBNTpBSw==} hasBin: true + '@reactour/mask@1.2.0': + resolution: {integrity: sha512-XLgBLWfKJybtZjNTSO5lt/SIvRlCZBadB6JfE/hO1ErqURRjYhnv+edC0Ki1haUCqMGFppWk3lwcPCjmK0xNog==} + peerDependencies: + react: 16.x || 17.x || 18.x || 19.x + + '@reactour/popover@1.3.0': + resolution: {integrity: sha512-YdyjSmHPvEeQEcJM4gcGFa5pI/Yf4nZGqwG4JnT+rK1SyUJBIPnm4Gkl/h7/+1g0KCFMkwNwagS3ZiXvZB7ThA==} + peerDependencies: + react: 16.x || 17.x || 18.x || 19.x + + '@reactour/tour@3.8.0': + resolution: {integrity: sha512-KZTFi1pAvoTVKKRdBN5+XCYxXBp4k4Ql/acZcXyPvec8VU24fkMSEeV+v8krfYQpoVcewxIu3gM6xWZZLjxi7w==} + peerDependencies: + react: 16.x || 17.x || 18.x || 19.x + + '@reactour/utils@0.6.0': + resolution: {integrity: sha512-GqaLjQi7MJsgtAKjdiw2Eak1toFkADoLRnm1+HZpaD+yl+DkaHpC1N7JAl+kVOO5I17bWInPA+OFbXjO9Co8Qg==} + peerDependencies: + react: 16.x || 17.x || 18.x || 19.x + '@rolldown/binding-android-arm64@1.0.3': resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2491,6 +2514,11 @@ packages: resolution: {integrity: sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==} cpu: [x64] os: [win32] + + '@rooks/use-mutation-observer@4.11.2': + resolution: {integrity: sha512-vpsdrZdr6TkB1zZJcHx+fR1YC/pHs2BaqcuYiEGjBVbwY5xcC49+h0hAUtQKHth3oJqXfIX/Ng8S7s5HFHdM/A==} + peerDependencies: + react: '>=16.8.0' '@sentry/conventions@0.12.0': resolution: {integrity: sha512-z1JQrl/1SLY+8wpzvork6vl+fpsg/oCCxM7HWWhUnI/R+OGNyoIzieQuggX3uUMY7NBtp8UWCQx6FeFazzOF9g==} @@ -5394,6 +5422,9 @@ packages: resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -8113,6 +8144,29 @@ snapshots: prompts: 2.4.2 tinyexec: 1.2.4 + '@reactour/mask@1.2.0(react@19.2.7)': + dependencies: + '@reactour/utils': 0.6.0(react@19.2.7) + react: 19.2.7 + + '@reactour/popover@1.3.0(react@19.2.7)': + dependencies: + '@reactour/utils': 0.6.0(react@19.2.7) + react: 19.2.7 + + '@reactour/tour@3.8.0(react@19.2.7)': + dependencies: + '@reactour/mask': 1.2.0(react@19.2.7) + '@reactour/popover': 1.3.0(react@19.2.7) + '@reactour/utils': 0.6.0(react@19.2.7) + react: 19.2.7 + + '@reactour/utils@0.6.0(react@19.2.7)': + dependencies: + '@rooks/use-mutation-observer': 4.11.2(react@19.2.7) + react: 19.2.7 + resize-observer-polyfill: 1.5.1 + '@rolldown/binding-android-arm64@1.0.3': optional: true @@ -8249,6 +8303,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.61.1': optional: true + '@rooks/use-mutation-observer@4.11.2(react@19.2.7)': + dependencies: + react: 19.2.7 + '@sentry/conventions@0.12.0': {} '@sentry/core@10.59.0': {} @@ -11797,6 +11855,8 @@ snapshots: transitivePeerDependencies: - supports-color + resize-observer-polyfill@1.5.1: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} diff --git a/src/components/Learn/FeaturedTours.tsx b/src/components/Learn/FeaturedTours.tsx index b20f484b5..6195df749 100644 --- a/src/components/Learn/FeaturedTours.tsx +++ b/src/components/Learn/FeaturedTours.tsx @@ -9,6 +9,7 @@ import { APP_ROUTES } from "@/routes/router"; import { tracking } from "@/utils/tracking"; import { tours as tourCards } from "./tours"; +import { getTour } from "./tours/registry"; interface FeaturedTour { id: string; @@ -30,7 +31,13 @@ function buildFeaturedTours(): FeaturedTour[] { const card = tourCards.find((c) => c.id === id); if (!card) return []; return [ - { id, title: card.title, duration: card.duration, tag, available: false }, + { + id, + title: card.title, + duration: card.duration, + tag, + available: getTour(id) !== undefined, + }, ]; }); } @@ -73,7 +80,7 @@ export function FeaturedTours() { key={tour.id} variant="ghost" size="lg" - disabled + disabled={!tour.available} onClick={() => startTour(tour.id)} className="h-auto min-h-10 w-full justify-start whitespace-normal py-2 text-left" {...tracking("learning_hub.tours.start", { diff --git a/src/components/Learn/ToursLibrary.tsx b/src/components/Learn/ToursLibrary.tsx index 62c1349b8..8b8c8cb8d 100644 --- a/src/components/Learn/ToursLibrary.tsx +++ b/src/components/Learn/ToursLibrary.tsx @@ -1,3 +1,5 @@ +import { useNavigate } from "@tanstack/react-router"; + import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -10,6 +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 { APP_ROUTES } from "@/routes/router"; import { tracking } from "@/utils/tracking"; import { @@ -21,8 +24,19 @@ import { type TourDifficulty, tours, } from "./tours"; +import { getTour } from "./tours/registry"; function TourCard({ tour }: { tour: Tour }) { + const isAvailable = getTour(tour.id) !== undefined; + const navigate = useNavigate(); + + const startTour = () => { + void navigate({ + to: APP_ROUTES.TOUR_DETAIL, + params: { tourId: tour.id }, + }); + }; + return ( @@ -41,14 +55,26 @@ function TourCard({ tour }: { tour: Tour }) { {tour.duration} - + {isAvailable ? ( + + ) : ( + + )} diff --git a/src/components/Learn/tours/registry.ts b/src/components/Learn/tours/registry.ts new file mode 100644 index 000000000..ac4779674 --- /dev/null +++ b/src/components/Learn/tours/registry.ts @@ -0,0 +1,29 @@ +import type { StepType } from "@reactour/tour"; + +import { publicAsset } from "@/utils/publicAsset"; + +type TourStep = StepType; + +export interface TourDefinition { + id: string; + displayName?: string; + requiresEditor?: boolean; + starterPipelineUrl?: string; + steps: TourStep[]; +} + +const tourModules = import.meta.glob("./*.tour.json", { + eager: true, + import: "default", +}); + +const tours: TourDefinition[] = Object.values(tourModules).map((tour) => ({ + ...tour, + starterPipelineUrl: tour.starterPipelineUrl + ? publicAsset(tour.starterPipelineUrl) + : undefined, +})); + +export function getTour(id: string): TourDefinition | undefined { + return tours.find((tour) => tour.id === id); +} 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/components/layout/RootLayout.tsx b/src/components/layout/RootLayout.tsx index a4d1da81a..823b3ff05 100644 --- a/src/components/layout/RootLayout.tsx +++ b/src/components/layout/RootLayout.tsx @@ -10,6 +10,7 @@ import { useSessionPipelineStats } from "@/hooks/useSessionPipelineStats"; import { AnalyticsProvider } from "@/providers/AnalyticsProvider"; import { BackendProvider } from "@/providers/BackendProvider"; import { ComponentSpecProvider } from "@/providers/ComponentSpecProvider"; +import { TourProvider } from "@/providers/TourProvider/TourProvider"; import { PipelineStorageProvider } from "@/services/pipelineStorage/PipelineStorageProvider"; import AppMenu from "./AppMenu"; @@ -27,21 +28,23 @@ function RootLayoutContent() { - - - - -
- - -
- -
- - {import.meta.env.VITE_ENABLE_ROUTER_DEVTOOLS === "true" && ( - - )} -
+ + + + + +
+ + +
+ +
+ + {import.meta.env.VITE_ENABLE_ROUTER_DEVTOOLS === "true" && ( + + )} +
+
diff --git a/src/providers/TourProvider/TourContent.tsx b/src/providers/TourProvider/TourContent.tsx new file mode 100644 index 000000000..bcf8f23c6 --- /dev/null +++ b/src/providers/TourProvider/TourContent.tsx @@ -0,0 +1,31 @@ +import { Fragment, type ReactNode } from "react"; + +const INLINE_TOKEN = /(\*\*[^*]+\*\*|_[^_]+_|`[^`]+`)/g; + +function renderInline(text: string): ReactNode[] { + return text.split(INLINE_TOKEN).map((part, index) => { + if (part.startsWith("**") && part.endsWith("**")) { + return {part.slice(2, -2)}; + } + if (part.startsWith("_") && part.endsWith("_")) { + return {part.slice(1, -1)}; + } + if (part.startsWith("`") && part.endsWith("`")) { + return {part.slice(1, -1)}; + } + return {part}; + }); +} + +export function TourContent({ text }: { text: string }) { + const paragraphs = text.split(/\n{2,}/); + return ( + <> + {paragraphs.map((paragraph, index) => ( +

0 ? "mt-2" : undefined}> + {renderInline(paragraph)} +

+ ))} + + ); +} diff --git a/src/providers/TourProvider/TourModeContext.tsx b/src/providers/TourProvider/TourModeContext.tsx new file mode 100644 index 000000000..f1b20f8d5 --- /dev/null +++ b/src/providers/TourProvider/TourModeContext.tsx @@ -0,0 +1,28 @@ +import { createContext, type ReactNode, useContext } from "react"; + +import type { TourDefinition } from "@/components/Learn/tours/registry"; + +export interface TourModeValue { + tour: TourDefinition; + tempPipelineName: string; +} + +const TourModeContext = createContext(null); + +export function TourModeProvider({ + value, + children, +}: { + value: TourModeValue; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useTourMode(): TourModeValue | null { + return useContext(TourModeContext); +} diff --git a/src/providers/TourProvider/TourPopover.tsx b/src/providers/TourProvider/TourPopover.tsx new file mode 100644 index 000000000..c20c0f598 --- /dev/null +++ b/src/providers/TourProvider/TourPopover.tsx @@ -0,0 +1,216 @@ +import { type ProviderProps, useTour } from "@reactour/tour"; +import { useNavigate } from "@tanstack/react-router"; +import { useEffect } from "react"; + +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack } from "@/components/ui/layout"; +import { APP_ROUTES } from "@/routes/router"; +import { tracking } from "@/utils/tracking"; + +// Matches the step-number badge's ≈13px outside offset plus a small margin. +const POPOVER_VIEWPORT_MARGIN = 16; + +export const POPOVER_STYLES = { + popover: (base: object) => ({ + ...base, + borderRadius: "0.75rem", + padding: "1.25rem", + boxShadow: "0 10px 30px rgba(0,0,0,0.12)", + maxWidth: "420px", + }), + maskWrapper: (base: object) => ({ + ...base, + color: "rgba(15, 23, 42, 0.5)", + }), + maskArea: (base: object) => ({ + ...base, + rx: 6, + }), + highlightedArea: ( + base: object, + state?: { width?: number; height?: number }, + ) => ({ + ...base, + display: + state?.width && state?.height ? ("block" as const) : ("none" as const), + fill: "transparent", + stroke: "#60a5fa", + strokeWidth: 2, + rx: 6, + pointerEvents: "none" as const, + }), + badge: (base: object) => ({ + ...base, + background: "#0f172a", + color: "white", + fontSize: "0.75rem", + }), +}; + +interface PositionProps { + top: number; + left: number; + right: number; + bottom: number; + width: number; + height: number; + windowWidth: number; + windowHeight: number; +} + +type ResolvedPosition = + | "top" + | "right" + | "bottom" + | "left" + | "center" + | [number, number]; + +export function computeDefaultPopoverPosition( + props: PositionProps, +): ResolvedPosition { + const targetHeight = props.bottom - props.top; + + const isFullHeightRightStrip = + props.right >= props.windowWidth - 4 && + targetHeight > props.windowHeight * 0.5; + + if (isFullHeightRightStrip) { + const popoverWidth = props.width || 380; + const margin = 16; + return [ + Math.max(margin, props.left - popoverWidth - margin), + Math.max(props.top + margin, 64), + ]; + } + + return "bottom"; +} + +type NextButtonProps = Parameters>[0]; + +export function TourCompletionActions() { + const navigate = useNavigate(); + const { setIsOpen } = useTour(); + + const onDone = () => { + setIsOpen(false); + void navigate({ to: APP_ROUTES.LEARN_TOURS }); + }; + + return ( + + + + ); +} + +export function renderNextButton(props: NextButtonProps) { + const { Button, currentStep, stepsLength, setCurrentStep, steps } = props; + + const hiddenPlaceholder = ( + + + {tourMode && ( + + Tour + + )} + {!tourMode && ( + + )} - name === pipelineName} - /> + {!tourMode && ( + name === pipelineNameFromSpec} + /> + )} @@ -114,6 +139,20 @@ export const EditorMenuBar = observer(function EditorMenuBar() { )} + {tourMode && ( + + )} + @@ -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/EditorTourBridge.tsx b/src/routes/v2/pages/Editor/components/EditorTourBridge.tsx new file mode 100644 index 000000000..ecc49762e --- /dev/null +++ b/src/routes/v2/pages/Editor/components/EditorTourBridge.tsx @@ -0,0 +1,3 @@ +export function EditorTourBridge() { + return null; +} diff --git a/src/routes/v2/shared/windows/windowPersistence.ts b/src/routes/v2/shared/windows/windowPersistence.ts index 6e40d20d6..82be0a5bd 100644 --- a/src/routes/v2/shared/windows/windowPersistence.ts +++ b/src/routes/v2/shared/windows/windowPersistence.ts @@ -22,9 +22,60 @@ import type { WindowStoreImpl } from "./windowStore"; */ let activeLayoutId: string | null = null; +function getLayoutStorageKey(layoutId: string | null): string { + if (!layoutId) return "editorV2-window-layout"; + return `window-layout-${layoutId}`; +} + function getStorageKey(): string { - if (!activeLayoutId) return "editorV2-window-layout"; - return `window-layout-${activeLayoutId}`; + return getLayoutStorageKey(activeLayoutId); +} + +function snapshotStorageKey(layoutId: string): string { + return `${getLayoutStorageKey(layoutId)}-snapshot`; +} + +function snapshotActiveKey(layoutId: string): string { + return `${snapshotStorageKey(layoutId)}-active`; +} + +// Stashes the layout aside so the next mount starts from defaults. Pair with +// restoreLayout to roll back. +export function snapshotLayout(layoutId: string): void { + try { + const key = getLayoutStorageKey(layoutId); + const current = localStorage.getItem(key); + if (current !== null) { + localStorage.setItem(snapshotStorageKey(layoutId), current); + } else { + localStorage.removeItem(snapshotStorageKey(layoutId)); + } + localStorage.setItem(snapshotActiveKey(layoutId), "1"); + localStorage.removeItem(key); + } catch (error) { + console.warn(`Failed to snapshot layout "${layoutId}":`, error); + } +} + +export function restoreLayout(layoutId: string): boolean { + try { + if (localStorage.getItem(snapshotActiveKey(layoutId)) === null) { + return false; + } + const key = getLayoutStorageKey(layoutId); + const saved = localStorage.getItem(snapshotStorageKey(layoutId)); + if (saved !== null) { + localStorage.setItem(key, saved); + } else { + localStorage.removeItem(key); + } + localStorage.removeItem(snapshotStorageKey(layoutId)); + localStorage.removeItem(snapshotActiveKey(layoutId)); + return true; + } catch (error) { + console.warn(`Failed to restore layout "${layoutId}":`, error); + return false; + } } interface PersistedWindowState { From 0daa776855c0a542cbe89574b3fc282e375df307 Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Thu, 4 Jun 2026 10:24:46 -0700 Subject: [PATCH 2/2] feat: Generalize dropdown/popover resize-on-open --- src/components/ui/dropdown-menu.tsx | 28 +++++++++++++- src/components/ui/popover.tsx | 13 ++++++- src/providers/TourProvider/TourContent.tsx | 6 ++- src/providers/TourProvider/TourPopover.tsx | 8 ++++ .../TourProvider/tourPipelineLifecycle.ts | 2 +- src/providers/TourProvider/waitForSelector.ts | 19 ---------- src/routes/Dashboard/Learn/Tour.tsx | 25 ++++++------ src/routes/v2/pages/Editor/EditorV2.tsx | 1 + src/utils/dispatchResizeOnToggle.ts | 13 +++++++ src/utils/tourActive.ts | 9 +++++ src/utils/waitForSelector.ts | 38 +++++++++++++++++++ 11 files changed, 126 insertions(+), 36 deletions(-) delete mode 100644 src/providers/TourProvider/waitForSelector.ts create mode 100644 src/utils/dispatchResizeOnToggle.ts create mode 100644 src/utils/tourActive.ts create mode 100644 src/utils/waitForSelector.ts diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index 33108c928..62229b7a2 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -1,5 +1,6 @@ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import { + type ComponentProps, type ComponentPropsWithoutRef, type ComponentRef, forwardRef, @@ -8,8 +9,20 @@ import { import { Icon } from "@/components/ui/icon"; import { cn } from "@/lib/utils"; +import { dispatchResizeOnToggle } from "@/utils/dispatchResizeOnToggle"; -const DropdownMenu = DropdownMenuPrimitive.Root; +const DropdownMenu = ({ + onOpenChange, + ...props +}: ComponentProps) => ( + { + dispatchResizeOnToggle(open); + onOpenChange?.(open); + }} + {...props} + /> +); const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; @@ -17,7 +30,18 @@ const DropdownMenuGroup = DropdownMenuPrimitive.Group; const DropdownMenuPortal = DropdownMenuPrimitive.Portal; -const DropdownMenuSub = DropdownMenuPrimitive.Sub; +const DropdownMenuSub = ({ + onOpenChange, + ...props +}: ComponentProps) => ( + { + dispatchResizeOnToggle(open); + onOpenChange?.(open); + }} + {...props} + /> +); const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx index 2af4471db..611b1d333 100644 --- a/src/components/ui/popover.tsx +++ b/src/components/ui/popover.tsx @@ -2,11 +2,22 @@ import * as PopoverPrimitive from "@radix-ui/react-popover"; import * as React from "react"; import { cn } from "@/lib/utils"; +import { dispatchResizeOnToggle } from "@/utils/dispatchResizeOnToggle"; function Popover({ + onOpenChange, ...props }: React.ComponentProps) { - return ; + return ( + { + dispatchResizeOnToggle(open); + onOpenChange?.(open); + }} + {...props} + /> + ); } function PopoverTrigger({ diff --git a/src/providers/TourProvider/TourContent.tsx b/src/providers/TourProvider/TourContent.tsx index bcf8f23c6..a9bc008a4 100644 --- a/src/providers/TourProvider/TourContent.tsx +++ b/src/providers/TourProvider/TourContent.tsx @@ -1,5 +1,7 @@ import { Fragment, type ReactNode } from "react"; +import { Paragraph } from "@/components/ui/typography"; + const INLINE_TOKEN = /(\*\*[^*]+\*\*|_[^_]+_|`[^`]+`)/g; function renderInline(text: string): ReactNode[] { @@ -22,9 +24,9 @@ export function TourContent({ text }: { text: string }) { return ( <> {paragraphs.map((paragraph, index) => ( -

0 ? "mt-2" : undefined}> + 0 ? "mt-2" : undefined}> {renderInline(paragraph)} -

+ ))} ); diff --git a/src/providers/TourProvider/TourPopover.tsx b/src/providers/TourProvider/TourPopover.tsx index c20c0f598..0e408d507 100644 --- a/src/providers/TourProvider/TourPopover.tsx +++ b/src/providers/TourProvider/TourPopover.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; import { BlockStack } from "@/components/ui/layout"; import { APP_ROUTES } from "@/routes/router"; +import { setTourActive } from "@/utils/tourActive"; import { tracking } from "@/utils/tracking"; // Matches the step-number badge's ≈13px outside offset plus a small margin. @@ -173,6 +174,13 @@ function clampPopoverElement(el: HTMLElement): void { export function PopoverClampBridge() { const { isOpen } = useTour(); + // Expose tour-open state to non-React callers (e.g. dispatchResizeOnToggle) + // so app-wide popover/dropdown side effects can no-op outside tours. + useEffect(() => { + setTourActive(isOpen); + return () => setTourActive(false); + }, [isOpen]); + useEffect(() => { if (!isOpen) return undefined; diff --git a/src/providers/TourProvider/tourPipelineLifecycle.ts b/src/providers/TourProvider/tourPipelineLifecycle.ts index 8ce0281e9..62e1302d1 100644 --- a/src/providers/TourProvider/tourPipelineLifecycle.ts +++ b/src/providers/TourProvider/tourPipelineLifecycle.ts @@ -23,7 +23,7 @@ export async function buildTourPipelineYaml( ); } const text = await response.text(); - const parsed = yaml.load(text) as Record | null; + const parsed: unknown = yaml.load(text); if (!parsed || typeof parsed !== "object") { throw new Error("Starter pipeline YAML is not an object"); } diff --git a/src/providers/TourProvider/waitForSelector.ts b/src/providers/TourProvider/waitForSelector.ts deleted file mode 100644 index 550413e85..000000000 --- a/src/providers/TourProvider/waitForSelector.ts +++ /dev/null @@ -1,19 +0,0 @@ -export function waitForSelector( - selector: string, - timeoutMs = 5000, -): Promise { - if (document.querySelector(selector)) return Promise.resolve(true); - return new Promise((resolve) => { - const observer = new MutationObserver(() => { - if (document.querySelector(selector)) { - observer.disconnect(); - resolve(true); - } - }); - observer.observe(document.body, { childList: true, subtree: true }); - setTimeout(() => { - observer.disconnect(); - resolve(false); - }, timeoutMs); - }); -} diff --git a/src/routes/Dashboard/Learn/Tour.tsx b/src/routes/Dashboard/Learn/Tour.tsx index 33caaaf09..715647abd 100644 --- a/src/routes/Dashboard/Learn/Tour.tsx +++ b/src/routes/Dashboard/Learn/Tour.tsx @@ -18,7 +18,6 @@ import { TOUR_PIPELINE_PREFIX, } from "@/providers/TourProvider/tourPipelineLifecycle"; import { TourCompletionActions } from "@/providers/TourProvider/TourPopover"; -import { waitForSelector } from "@/providers/TourProvider/waitForSelector"; import { APP_ROUTES } from "@/routes/router"; import { EditorV2 } from "@/routes/v2/pages/Editor/EditorV2"; import { @@ -27,6 +26,7 @@ import { } from "@/routes/v2/shared/windows/windowPersistence"; import { usePipelineStorage } from "@/services/pipelineStorage/PipelineStorageProvider"; import type { PipelineStorageService } from "@/services/pipelineStorage/PipelineStorageService"; +import { waitForSelector } from "@/utils/waitForSelector"; const EDITOR_LAYOUT_ID = "editor"; @@ -70,12 +70,19 @@ function TourReactourBridge({ const lastSyncRef = useRef(null); const initializedRef = useRef(false); - // Reactour silently no-ops if step selectors aren't in the DOM at open time. + // Reactour silently no-ops if step selectors aren't in the DOM at open time, + // so wait for the editor to mount (data-editor-ready) before opening. useEffect(() => { - if (initializedRef.current) return; - let cancelled = false; - void waitForSelector('[data-testid="editor-v2"]').then(() => { - if (cancelled || initializedRef.current) return; + if (initializedRef.current) return undefined; + const controller = new AbortController(); + void waitForSelector("[data-editor-ready]", { + signal: controller.signal, + }).then((found) => { + if (controller.signal.aborted || initializedRef.current) return; + if (!found) { + console.warn("Editor did not become ready in time; tour not opened."); + return; + } initializedRef.current = true; const lastIdx = tour.steps.length - 1; @@ -105,9 +112,7 @@ function TourReactourBridge({ lastSyncRef.current = clamped; setIsOpen(true); }); - return () => { - cancelled = true; - }; + return () => controller.abort(); }, [tour, urlStep, setSteps, setCurrentStep, setIsOpen]); useEffect(() => { @@ -218,5 +223,3 @@ export function TourPage() { ); } - -// placeholder for empty pr diff --git a/src/routes/v2/pages/Editor/EditorV2.tsx b/src/routes/v2/pages/Editor/EditorV2.tsx index 3301992fa..452771c2f 100644 --- a/src/routes/v2/pages/Editor/EditorV2.tsx +++ b/src/routes/v2/pages/Editor/EditorV2.tsx @@ -118,6 +118,7 @@ const PipelineEditor = withSuspenseWrapper( blockAlign="stretch" wrap="nowrap" data-testid="editor-v2" + data-editor-ready="true" >
diff --git a/src/utils/dispatchResizeOnToggle.ts b/src/utils/dispatchResizeOnToggle.ts new file mode 100644 index 000000000..6d3236ba3 --- /dev/null +++ b/src/utils/dispatchResizeOnToggle.ts @@ -0,0 +1,13 @@ +import { isTourActive } from "@/utils/tourActive"; + +export function dispatchResizeOnToggle(open: boolean): void { + if (!isTourActive()) return; + requestAnimationFrame(() => { + window.dispatchEvent(new Event("resize")); + }); + if (!open) { + setTimeout(() => { + window.dispatchEvent(new Event("resize")); + }, 250); + } +} diff --git a/src/utils/tourActive.ts b/src/utils/tourActive.ts new file mode 100644 index 000000000..c7641d703 --- /dev/null +++ b/src/utils/tourActive.ts @@ -0,0 +1,9 @@ +let tourActive = false; + +export function setTourActive(active: boolean): void { + tourActive = active; +} + +export function isTourActive(): boolean { + return tourActive; +} diff --git a/src/utils/waitForSelector.ts b/src/utils/waitForSelector.ts new file mode 100644 index 000000000..81f70ac42 --- /dev/null +++ b/src/utils/waitForSelector.ts @@ -0,0 +1,38 @@ +interface WaitForSelectorOptions { + timeoutMs?: number; + signal?: AbortSignal; +} + +export function waitForSelector( + selector: string, + { timeoutMs = 5000, signal }: WaitForSelectorOptions = {}, +): Promise { + if (document.querySelector(selector)) return Promise.resolve(true); + if (signal?.aborted) return Promise.resolve(false); + + return new Promise((resolve) => { + const disposers: Array<() => void> = []; + let settled = false; + const finish = (found: boolean) => { + if (settled) return; + settled = true; + disposers.forEach((dispose) => dispose()); + resolve(found); + }; + + const observer = new MutationObserver(() => { + if (document.querySelector(selector)) finish(true); + }); + observer.observe(document.body, { childList: true, subtree: true }); + disposers.push(() => observer.disconnect()); + + const timer = setTimeout(() => finish(false), timeoutMs); + disposers.push(() => clearTimeout(timer)); + + if (signal) { + const onAbort = () => finish(false); + signal.addEventListener("abort", onAbort, { once: true }); + disposers.push(() => signal.removeEventListener("abort", onAbort)); + } + }); +}