From 99bcaf9066e2972dd96ad10f1972871549509eed Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Thu, 28 May 2026 14:33:20 -0700 Subject: [PATCH] feat: Save and Resume Paused Tours --- .../TourProvider/TourModeContext.tsx | 1 + src/providers/TourProvider/TourPopover.tsx | 35 +++++++++++++++ .../TourProvider/TourSaveExploreDialog.tsx | 43 +++++++++++++++++++ src/routes/Dashboard/Learn/Tour.tsx | 33 +++++++++++++- src/routes/v2/pages/Editor/EditorV2.tsx | 2 + 5 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 src/providers/TourProvider/TourSaveExploreDialog.tsx diff --git a/src/providers/TourProvider/TourModeContext.tsx b/src/providers/TourProvider/TourModeContext.tsx index f1b20f8d5..9d28d6f9a 100644 --- a/src/providers/TourProvider/TourModeContext.tsx +++ b/src/providers/TourProvider/TourModeContext.tsx @@ -5,6 +5,7 @@ import type { TourDefinition } from "@/components/Learn/tours/registry"; export interface TourModeValue { tour: TourDefinition; tempPipelineName: string; + promoteToPipeline: (newName: string, yamlContent: string) => Promise; } const TourModeContext = createContext(null); diff --git a/src/providers/TourProvider/TourPopover.tsx b/src/providers/TourProvider/TourPopover.tsx index 7a7f3a85a..cca0e321c 100644 --- a/src/providers/TourProvider/TourPopover.tsx +++ b/src/providers/TourProvider/TourPopover.tsx @@ -7,6 +7,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; import { BlockStack } from "@/components/ui/layout"; +import { Text } from "@/components/ui/typography"; import { cn } from "@/lib/utils"; import { APP_ROUTES } from "@/routes/router"; import { tracking } from "@/utils/tracking"; @@ -93,6 +94,19 @@ export function computeDefaultPopoverPosition( type NextButtonProps = Parameters>[0]; +let saveExploreHandler: (() => void) | null = null; + +export function registerSaveExploreHandler( + handler: (() => void) | null, +): () => void { + saveExploreHandler = handler; + return () => { + if (saveExploreHandler === handler) { + saveExploreHandler = null; + } + }; +} + export function TourCompletionActions() { const navigate = useNavigate(); const { setIsOpen } = useTour(); @@ -102,6 +116,11 @@ export function TourCompletionActions() { void navigate({ to: APP_ROUTES.LEARN_TOURS }); }; + const onSavePipeline = () => { + setIsOpen(false); + saveExploreHandler?.(); + }; + return ( + {saveExploreHandler && ( + + + Continue exploring: + + + + )} ); } diff --git a/src/providers/TourProvider/TourSaveExploreDialog.tsx b/src/providers/TourProvider/TourSaveExploreDialog.tsx new file mode 100644 index 000000000..2501f4d2f --- /dev/null +++ b/src/providers/TourProvider/TourSaveExploreDialog.tsx @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; + +import { PipelineNameDialog } from "@/components/shared/Dialogs"; +import { serializeComponentSpecToText } from "@/models/componentSpec"; +import { useTourMode } from "@/providers/TourProvider/TourModeContext"; +import { registerSaveExploreHandler } from "@/providers/TourProvider/TourPopover"; +import { usePipelineActions } from "@/routes/v2/pages/Editor/store/actions/usePipelineActions"; +import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext"; + +export function TourSaveExploreDialog() { + const tourMode = useTourMode(); + const { navigation } = useSharedStores(); + const { renamePipeline } = usePipelineActions(); + const [open, setOpen] = useState(false); + + useEffect(() => { + if (!tourMode) return; + return registerSaveExploreHandler(() => setOpen(true)); + }, [tourMode]); + + if (!tourMode) return null; + + const onSubmit = async (name: string) => { + const rootSpec = navigation.rootSpec; + if (!rootSpec) return; + + renamePipeline(rootSpec, name); + const yamlContent = serializeComponentSpecToText(rootSpec); + await tourMode.promoteToPipeline(name, yamlContent); + }; + + return ( + + ); +} diff --git a/src/routes/Dashboard/Learn/Tour.tsx b/src/routes/Dashboard/Learn/Tour.tsx index a2318b42f..28687ed1e 100644 --- a/src/routes/Dashboard/Learn/Tour.tsx +++ b/src/routes/Dashboard/Learn/Tour.tsx @@ -11,8 +11,12 @@ import { getTour, type TourDefinition, } from "@/components/Learn/tours/registry"; +import useToastNotification from "@/hooks/useToastNotification"; import { TourContent } from "@/providers/TourProvider/TourContent"; -import { TourModeProvider } from "@/providers/TourProvider/TourModeContext"; +import { + TourModeProvider, + type TourModeValue, +} from "@/providers/TourProvider/TourModeContext"; import { buildTourPipelineYaml, TOUR_PIPELINE_PREFIX, @@ -142,6 +146,24 @@ export function TourPage() { ? params.tourId : ""; const tour = getTour(tourId); + const navigate = useNavigate(); + const storage = usePipelineStorage(); + const notify = useToastNotification(); + + const promoteToPipeline = async (newName: string, yamlContent: string) => { + try { + const file = await storage.rootFolder.addFile(newName, yamlContent); + await navigate({ + to: APP_ROUTES.EDITOR_V2_PIPELINE, + params: { pipelineName: newName }, + search: { fileId: file.id }, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to save pipeline"; + notify(message, "error"); + } + }; if (!tour) { return ; @@ -149,7 +171,11 @@ export function TourPage() { return ( - + ); } @@ -157,9 +183,11 @@ export function TourPage() { function TourPageBody({ tour, tourId, + promoteToPipeline, }: { tour: TourDefinition; tourId: string; + promoteToPipeline: TourModeValue["promoteToPipeline"]; }) { const search = useSearch({ strict: false }); const navigate = useNavigate(); @@ -212,6 +240,7 @@ function TourPageBody({ value={{ tour, tempPipelineName: resolved?.name ?? tourPipelineName(tour), + promoteToPipeline, }} > {resolved && ( diff --git a/src/routes/v2/pages/Editor/EditorV2.tsx b/src/routes/v2/pages/Editor/EditorV2.tsx index e841df42d..c681cd5c9 100644 --- a/src/routes/v2/pages/Editor/EditorV2.tsx +++ b/src/routes/v2/pages/Editor/EditorV2.tsx @@ -14,6 +14,7 @@ import { ComponentLibraryProvider } from "@/providers/ComponentLibraryProvider"; import { ForcedSearchProvider } from "@/providers/ComponentLibraryProvider/ForcedSearchProvider"; import { DialogProvider } from "@/providers/DialogProvider/DialogProvider"; import { useTourMode } from "@/providers/TourProvider/TourModeContext"; +import { TourSaveExploreDialog } from "@/providers/TourProvider/TourSaveExploreDialog"; import { useDockAreaAccordion } from "@/routes/v2/shared/hooks/useDockAreaAccordion"; import { useFocusMode } from "@/routes/v2/shared/hooks/useFocusMode"; import { NodeRegistryProvider } from "@/routes/v2/shared/nodes/NodeRegistryContext"; @@ -151,6 +152,7 @@ function EditorV2Content({ pipelineRef }: { pipelineRef: PipelineRef | null }) { + {body}