Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions react-compiler.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
45 changes: 17 additions & 28 deletions src/components/shared/Dialogs/PipelineNameDialog.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 {
Expand All @@ -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";
Expand All @@ -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<HTMLInputElement>) => {
const handleOnChange = (e: ChangeEvent<HTMLInputElement>) => {
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 ||
Expand Down
1 change: 1 addition & 0 deletions src/providers/TourProvider/TourModeContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
}

const TourModeContext = createContext<TourModeValue | null>(null);
Expand Down
24 changes: 24 additions & 0 deletions src/providers/TourProvider/TourPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
<BlockStack gap="3" align="center">
<Button
Expand All @@ -115,6 +123,22 @@ export function TourCompletionActions() {
<Icon name="Check" size="sm" />
Finish Tour
</Button>
{available && (
<BlockStack align="center">
<Text size="xs" tone="subdued">
Continue exploring:
</Text>
<Button
size="xs"
variant="link"
onClick={onSavePipeline}
{...tracking("v2.pipeline_editor.tour.save_as_pipeline")}
>
<Icon name="SaveAll" size="xs" />
Save demo pipeline
</Button>
</BlockStack>
)}
</BlockStack>
);
}
Expand Down
41 changes: 22 additions & 19 deletions src/providers/TourProvider/TourProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,29 +12,32 @@ import {
PopoverClampBridge,
} from "./TourPopover";
import { TourProgressProvider } from "./TourProgressContext";
import { TourSaveExploreProvider } from "./TourSaveExploreContext";

export function TourProvider({ children }: { children: ReactNode }) {
return (
<TourProgressProvider>
<ReactourProvider
steps={[]}
styles={POPOVER_STYLES}
components={{ Navigation: TourNavigation }}
scrollSmooth
showBadge
showCloseButton={false}
showNavigation
showPrevNextButtons
disableKeyboardNavigation={["esc"]}
padding={{ mask: 0, popover: 10 }}
position={computeDefaultPopoverPosition}
nextButton={renderNextButton}
onClickMask={() => undefined}
>
<PopoverClampBridge />
<TourAutoAdvance />
{children}
</ReactourProvider>
<TourSaveExploreProvider>
<ReactourProvider
steps={[]}
styles={POPOVER_STYLES}
components={{ Navigation: TourNavigation }}
scrollSmooth
showBadge
showCloseButton={false}
showNavigation
showPrevNextButtons
disableKeyboardNavigation={["esc"]}
padding={{ mask: 0, popover: 10 }}
position={computeDefaultPopoverPosition}
nextButton={renderNextButton}
onClickMask={() => undefined}
>
<PopoverClampBridge />
<TourAutoAdvance />
{children}
</ReactourProvider>
</TourSaveExploreProvider>
</TourProgressProvider>
);
}
49 changes: 49 additions & 0 deletions src/providers/TourProvider/TourSaveExploreContext.tsx
Original file line number Diff line number Diff line change
@@ -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<SetStateAction<boolean>>;
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
}

const TourSaveExploreContext = createRequiredContext<TourSaveExploreValue>(
"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 (
<TourSaveExploreContext.Provider value={value}>
{children}
</TourSaveExploreContext.Provider>
);
}

export function useTourSaveExplore(): TourSaveExploreValue {
return useRequiredContext(TourSaveExploreContext);
}
58 changes: 58 additions & 0 deletions src/providers/TourProvider/TourSaveExploreDialog.tsx
Original file line number Diff line number Diff line change
@@ -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() {
Comment thread
camielvs marked this conversation as resolved.
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 (
<PipelineNameDialog
open={open}
onOpenChange={setOpen}
title="Save pipeline"
description="Convert this demo pipeline into a regular pipeline you can keep editing."
initialName={tourMode.tour.displayName ?? tourMode.tour.id}
onSubmit={onSubmit}
submitButtonText="Save"
/>
);
}
33 changes: 31 additions & 2 deletions src/routes/Dashboard/Learn/Tour.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -148,24 +152,48 @@ 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 <Navigate to={APP_ROUTES.LEARN_TOURS} replace />;
}

return (
<TourPipelineStorageProvider>
<TourPageBody tour={tour} tourId={tourId} />
<TourPageBody
tour={tour}
tourId={tourId}
promoteToPipeline={promoteToPipeline}
/>
</TourPipelineStorageProvider>
);
}

function TourPageBody({
tour,
tourId,
promoteToPipeline,
}: {
tour: TourDefinition;
tourId: string;
promoteToPipeline: TourModeValue["promoteToPipeline"];
}) {
const search = useSearch({ strict: false });
const navigate = useNavigate();
Expand Down Expand Up @@ -219,6 +247,7 @@ function TourPageBody({
value={{
tour,
tempPipelineName: resolved?.name ?? tourPipelineName(tour),
promoteToPipeline,
}}
>
{resolved && (
Expand Down
2 changes: 2 additions & 0 deletions src/routes/v2/pages/Editor/EditorV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -164,6 +165,7 @@ function EditorV2Content({ pipelineRef }: { pipelineRef: PipelineRef | null }) {
<ReactFlowProvider>
<EditorMenuBar />
<EditorTourBridge />
<TourSaveExploreDialog />
<ForcedSearchProvider>{body}</ForcedSearchProvider>
</ReactFlowProvider>
</ComponentLibraryProvider>
Expand Down
Loading