diff --git a/package.json b/package.json index 7eaccb93f..503499256 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", + "@reactour/tour": "3.8.0", "@tailwindcss/vite": "^4.3.0", "@tanstack/history": "1.162.0", "@tanstack/react-query": "^5.101.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3405ab8ac..87fde52bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.2.8 version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(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.0 version: 4.3.0(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -2259,6 +2262,26 @@ packages: resolution: {integrity: sha512-gMDYY2rw6OWajCcDlXSIgs2LC432YJXSb3Lm5yM187uhRgBYddoEVULi36h+IolX3r7jSb3ew7vn9FfI8NSo0A==} 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} @@ -2488,6 +2511,11 @@ packages: 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-internal/server-utils@10.56.0': resolution: {integrity: sha512-6kuZI/vAjyVKMm1cTzc2pdUmVR4Px4etMG6wnCPyFnwEaGbUKQnTynUBFpTuo/q6Js6QBQvhLNoAnO4YsOfW4w==} engines: {node: '>=18'} @@ -5444,6 +5472,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'} @@ -8128,6 +8159,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 @@ -8264,6 +8318,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.1': optional: true + '@rooks/use-mutation-observer@4.11.2(react@19.2.7)': + dependencies: + react: 19.2.7 + '@sentry-internal/server-utils@10.56.0': dependencies: '@sentry/core': 10.56.0 @@ -11886,6 +11944,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 b989f6c64..dc036480d 100644 --- a/src/components/layout/RootLayout.tsx +++ b/src/components/layout/RootLayout.tsx @@ -9,6 +9,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"; @@ -26,20 +27,22 @@ function RootLayoutContent() { - - - -
- - -
- -
- - {import.meta.env.VITE_ENABLE_ROUTER_DEVTOOLS === "true" && ( - - )} -
+ + + + +
+ + +
+ +
+ + {import.meta.env.VITE_ENABLE_ROUTER_DEVTOOLS === "true" && ( + + )} +
+
diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index 33108c928..2e82fe203 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, @@ -7,9 +8,21 @@ import { } from "react"; import { Icon } from "@/components/ui/icon"; +import { dispatchResizeOnToggle } from "@/lib/dispatchResizeOnToggle"; import { cn } from "@/lib/utils"; -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..ad5db1b8d 100644 --- a/src/components/ui/popover.tsx +++ b/src/components/ui/popover.tsx @@ -1,12 +1,23 @@ import * as PopoverPrimitive from "@radix-ui/react-popover"; import * as React from "react"; +import { dispatchResizeOnToggle } from "@/lib/dispatchResizeOnToggle"; import { cn } from "@/lib/utils"; function Popover({ + onOpenChange, ...props }: React.ComponentProps) { - return ; + return ( + { + dispatchResizeOnToggle(open); + onOpenChange?.(open); + }} + {...props} + /> + ); } function PopoverTrigger({ diff --git a/src/lib/dispatchResizeOnToggle.ts b/src/lib/dispatchResizeOnToggle.ts new file mode 100644 index 000000000..70ed7727d --- /dev/null +++ b/src/lib/dispatchResizeOnToggle.ts @@ -0,0 +1,13 @@ +// Nudges layout-sensitive listeners (reactour spotlights, floating UI) to +// re-measure after a portaled element opens or closes. The trailing dispatch +// covers exit animations. +export function dispatchResizeOnToggle(open: boolean): void { + requestAnimationFrame(() => { + window.dispatchEvent(new Event("resize")); + }); + if (!open) { + setTimeout(() => { + window.dispatchEvent(new Event("resize")); + }, 250); + } +} 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 {