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
35 changes: 35 additions & 0 deletions src/providers/TourProvider/TourPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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";
Expand Down Expand Up @@ -96,6 +97,19 @@ export function computeDefaultPopoverPosition(
return "bottom";
}

let saveExploreHandler: (() => void) | null = null;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 This is an AI-generated code review comment.

The save/explore action is registered by mutating a module-level variable and then read during TourCompletionActions render. Because registering the handler is not React state or context, changes do not cause a re-render; if the completion actions render before the effect in TourSaveExploreDialog registers, the Save demo pipeline button is omitted until some unrelated render happens. This also makes the feature depend on mount/effect ordering instead of explicit React data flow.

Suggestion: Move the save-dialog opener into React-owned state/context, e.g. include an openSavePipelineDialog callback or capability flag in TourModeValue/TourProvider, and render TourCompletionActions from that context instead of reading a mutable module singleton.

Rule: docs/react-best-practices.md / react-patterns — context/provider values should model shared state explicitly; avoid unstable/non-reactive values that bypass React data flow.


export function registerSaveExploreHandler(
handler: (() => void) | null,
): () => void {
saveExploreHandler = handler;
return () => {
if (saveExploreHandler === handler) {
saveExploreHandler = null;
}
};
}

export function TourCompletionActions() {
const navigate = useNavigate();
const { setIsOpen } = useTour();
Expand All @@ -105,6 +119,11 @@ export function TourCompletionActions() {
void navigate({ to: APP_ROUTES.LEARN_TOURS });
};

const onSavePipeline = () => {
setIsOpen(false);
saveExploreHandler?.();
};

return (
<BlockStack gap="3" align="center">
<Button
Expand All @@ -116,6 +135,22 @@ export function TourCompletionActions() {
<Icon name="Check" size="sm" />
Finish Tour
</Button>
{saveExploreHandler && (
Comment thread
camielvs marked this conversation as resolved.
<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
57 changes: 57 additions & 0 deletions src/providers/TourProvider/TourSaveExploreDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useEffect, useState } 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 { 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() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 This is an AI-generated code review comment.

This PR adds a new React component under src/providers, but react-compiler.config.js only enables src/routes and selected provider subdirectories; src/providers/TourProvider is still outside compiler coverage. The project review standard treats missing React Compiler registration for new components/hooks as a High-severity adoption gap.

Suggestion: Add this file explicitly to REACT_COMPILER_ENABLED_DIRS, or enable the appropriate TourProvider subdirectory after confirming it is compiler-compatible.

Rule: react-patterns / tangle-ui-review — new components and hooks must be covered by react-compiler.config.js; new files under non-enabled directories are not covered automatically.

const tourMode = useTourMode();
const { navigation } = useSharedStores();
const { renamePipeline } = usePipelineActions();
const notify = useToastNotification();
const [open, setOpen] = useState(false);

useEffect(() => {
if (!tourMode) return;
return registerSaveExploreHandler(() => setOpen(true));
}, [tourMode]);
Comment thread
camielvs marked this conversation as resolved.

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