diff --git a/react-compiler.config.js b/react-compiler.config.js index 335b30c86..8cbb25164 100644 --- a/react-compiler.config.js +++ b/react-compiler.config.js @@ -64,6 +64,7 @@ export const REACT_COMPILER_ENABLED_DIRS = [ "src/components/shared/ReactFlow/FlowCanvas/Multiselect", "src/components/shared/CodeViewer/CodeEditor.tsx", "src/components/shared/Dialogs/MultilineTextInputDialog.tsx", + "src/components/shared/Dialogs/PipelineNameDialog.tsx", "src/components/shared/HighlightText.tsx", "src/components/shared/AnnouncementBanners.tsx", "src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection", diff --git a/src/components/shared/Dialogs/PipelineNameDialog.tsx b/src/components/shared/Dialogs/PipelineNameDialog.tsx index 006a53ac7..5d598f72c 100644 --- a/src/components/shared/Dialogs/PipelineNameDialog.tsx +++ b/src/components/shared/Dialogs/PipelineNameDialog.tsx @@ -1,11 +1,4 @@ -import { - Activity, - type ChangeEvent, - type ReactNode, - useCallback, - useMemo, - useState, -} from "react"; +import { Activity, type ChangeEvent, type ReactNode, useState } from "react"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; @@ -52,9 +45,6 @@ const PipelineNameDialog = ({ onOpenChange, }: PipelineNameDialogProps) => { const [name, setName] = useState(initialName); - // Gate the destructive Alert on interaction so dialogs opened with an empty - // initialName don't flash "Name cannot be empty" before the user types. The - // submit guard below still uses `error` directly, so empty submits stay blocked. const [touched, setTouched] = useState(false); const { @@ -63,7 +53,7 @@ const PipelineNameDialog = ({ refetch: refetchUserPipelines, } = useLoadUserPipelines(); - const error = useMemo(() => { + const computeError = () => { if (isLoadingUserPipelines) return null; const normalized = name.trim().toLowerCase(); if (normalized === "") return "Name cannot be empty"; @@ -77,28 +67,27 @@ const PipelineNameDialog = ({ ); if (existing.has(normalized)) return "Name already exists"; return null; - }, [name, userPipelines, isLoadingUserPipelines, excludeNames]); + }; + + const error = computeError(); - const handleOnChange = useCallback((e: ChangeEvent) => { + const handleOnChange = (e: ChangeEvent) => { setName(e.target.value); setTouched(true); - }, []); + }; - const handleDialogOpenChange = useCallback( - (open: boolean) => { - if (open) { - setName(initialName); - setTouched(false); - refetchUserPipelines(); - } - onOpenChange?.(open); - }, - [initialName, onOpenChange, refetchUserPipelines], - ); + const handleDialogOpenChange = (open: boolean) => { + if (open) { + setName(initialName); + setTouched(false); + refetchUserPipelines(); + } + onOpenChange?.(open); + }; - const handleSubmit = useCallback(() => { + const handleSubmit = () => { onSubmit(name.trim()); - }, [name, onSubmit]); + }; const isDisabled = isLoadingUserPipelines || 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 d3b2e502f..7350cfb46 100644 --- a/src/providers/TourProvider/TourPopover.tsx +++ b/src/providers/TourProvider/TourPopover.tsx @@ -5,11 +5,13 @@ import { useEffect } from "react"; 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 { APP_ROUTES } from "@/routes/router"; import { setTourActive } from "@/utils/tourActive"; import { tracking } from "@/utils/tracking"; import { useTourProgress } from "./TourProgressContext"; +import { useTourSaveExplore } from "./TourSaveExploreContext"; // Matches the step-number badge's ≈13px outside offset plus a small margin. const POPOVER_VIEWPORT_MARGIN = 16; @@ -98,12 +100,18 @@ export function computeDefaultPopoverPosition( export function TourCompletionActions() { const navigate = useNavigate(); const { setIsOpen } = useTour(); + const { available, setOpen } = useTourSaveExplore(); const onDone = () => { setIsOpen(false); void navigate({ to: APP_ROUTES.LEARN_TOURS }); }; + const onSavePipeline = () => { + setIsOpen(false); + setOpen(true); + }; + return ( + {available && ( + + + Continue exploring: + + + + )} ); } diff --git a/src/providers/TourProvider/TourProvider.tsx b/src/providers/TourProvider/TourProvider.tsx index b6d836883..be3574390 100644 --- a/src/providers/TourProvider/TourProvider.tsx +++ b/src/providers/TourProvider/TourProvider.tsx @@ -12,29 +12,32 @@ import { PopoverClampBridge, } from "./TourPopover"; import { TourProgressProvider } from "./TourProgressContext"; +import { TourSaveExploreProvider } from "./TourSaveExploreContext"; export function TourProvider({ children }: { children: ReactNode }) { return ( - undefined} - > - - - {children} - + + undefined} + > + + + {children} + + ); } diff --git a/src/providers/TourProvider/TourSaveExploreContext.tsx b/src/providers/TourProvider/TourSaveExploreContext.tsx new file mode 100644 index 000000000..3de79f2ad --- /dev/null +++ b/src/providers/TourProvider/TourSaveExploreContext.tsx @@ -0,0 +1,49 @@ +import { + type Dispatch, + type ReactNode, + type SetStateAction, + useState, +} from "react"; + +import { + createRequiredContext, + useRequiredContext, +} from "@/hooks/useRequiredContext"; + +export interface TourSaveExploreValue { + // The save-as-pipeline dialog is mounted inside the editor (a route-level + // descendant), while the completion popover renders under ReactourProvider at + // the app root. They share this root-level context instead of a module + // singleton so the popover's "Save demo pipeline" button reacts to the dialog + // mounting rather than depending on effect/mount ordering. + available: boolean; + setAvailable: Dispatch>; + open: boolean; + setOpen: Dispatch>; +} + +const TourSaveExploreContext = createRequiredContext( + "TourSaveExploreContext", +); + +export function TourSaveExploreProvider({ children }: { children: ReactNode }) { + const [available, setAvailable] = useState(false); + const [open, setOpen] = useState(false); + + const value: TourSaveExploreValue = { + available, + setAvailable, + open, + setOpen, + }; + + return ( + + {children} + + ); +} + +export function useTourSaveExplore(): TourSaveExploreValue { + return useRequiredContext(TourSaveExploreContext); +} diff --git a/src/providers/TourProvider/TourSaveExploreDialog.tsx b/src/providers/TourProvider/TourSaveExploreDialog.tsx new file mode 100644 index 000000000..b9397da56 --- /dev/null +++ b/src/providers/TourProvider/TourSaveExploreDialog.tsx @@ -0,0 +1,58 @@ +import { useEffect } from "react"; + +import { PipelineNameDialog } from "@/components/shared/Dialogs"; +import useToastNotification from "@/hooks/useToastNotification"; +import { serializeComponentSpecToText } from "@/models/componentSpec"; +import { useTourMode } from "@/providers/TourProvider/TourModeContext"; +import { useTourSaveExplore } from "@/providers/TourProvider/TourSaveExploreContext"; +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 { open, setOpen, setAvailable } = useTourSaveExplore(); + const { navigation } = useSharedStores(); + const { renamePipeline } = usePipelineActions(); + const notify = useToastNotification(); + + useEffect(() => { + if (!tourMode) return undefined; + setAvailable(true); + return () => setAvailable(false); + }, [tourMode, setAvailable]); + + if (!tourMode) return null; + + const onSubmit = async (name: string) => { + const rootSpec = navigation.rootSpec; + if (!rootSpec) { + notify( + "Pipeline isn't ready to save yet — try again in a moment.", + "error", + ); + return; + } + + try { + renamePipeline(rootSpec, name); + const yamlContent = serializeComponentSpecToText(rootSpec); + await tourMode.promoteToPipeline(name, yamlContent); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to save pipeline"; + notify(message, "error"); + } + }; + + return ( + + ); +} diff --git a/src/routes/Dashboard/Learn/Tour.tsx b/src/routes/Dashboard/Learn/Tour.tsx index 862103736..347aa8bff 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, @@ -148,6 +152,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 ; @@ -155,7 +177,11 @@ export function TourPage() { return ( - + ); } @@ -163,9 +189,11 @@ export function TourPage() { function TourPageBody({ tour, tourId, + promoteToPipeline, }: { tour: TourDefinition; tourId: string; + promoteToPipeline: TourModeValue["promoteToPipeline"]; }) { const search = useSearch({ strict: false }); const navigate = useNavigate(); @@ -219,6 +247,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 452771c2f..3989bd85d 100644 --- a/src/routes/v2/pages/Editor/EditorV2.tsx +++ b/src/routes/v2/pages/Editor/EditorV2.tsx @@ -15,6 +15,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 { AiChatStoreProvider } from "@/routes/v2/shared/components/AiChat/AiChatStoreContext"; import { useDockAreaAccordion } from "@/routes/v2/shared/hooks/useDockAreaAccordion"; import { useFocusMode } from "@/routes/v2/shared/hooks/useFocusMode"; @@ -164,6 +165,7 @@ function EditorV2Content({ pipelineRef }: { pipelineRef: PipelineRef | null }) { + {body}