diff --git a/src/components/Home/PipelineSection/PipelineRow.tsx b/src/components/Home/PipelineSection/PipelineRow.tsx index 0c1af9cca..380902a82 100644 --- a/src/components/Home/PipelineSection/PipelineRow.tsx +++ b/src/components/Home/PipelineSection/PipelineRow.tsx @@ -30,7 +30,7 @@ import { import { Paragraph } from "@/components/ui/typography"; import { cn } from "@/lib/utils"; import { useAnalytics } from "@/providers/AnalyticsProvider"; -import { EDITOR_PATH } from "@/routes/router"; +import { getDefaultEditorPath } from "@/routes/editorRoutes"; import { deletePipeline } from "@/services/pipelineService"; import { getPipelineTagsFromSpec } from "@/utils/annotations"; import type { ComponentReferenceWithSpec } from "@/utils/componentStore"; @@ -107,17 +107,15 @@ const PipelineRow = withSuspenseWrapper( return; } + if (!name) return; + if (e.ctrlKey || e.metaKey) { - if (name) { - rowTrack("pipeline_opened", { open_mode: "editor_new_tab" }); - } - window.open(`${EDITOR_PATH}/${name}`, "_blank"); + rowTrack("pipeline_opened", { open_mode: "editor_new_tab" }); + window.open(getDefaultEditorPath(name), "_blank"); return; } - if (name) { - rowTrack("pipeline_opened", { open_mode: "editor_same_tab" }); - } - navigate({ to: `${EDITOR_PATH}/${name}` }); + rowTrack("pipeline_opened", { open_mode: "editor_same_tab" }); + navigate({ to: getDefaultEditorPath(name) }); }; const handleCheckboxChange = (checked: boolean | "indeterminate") => { diff --git a/src/components/Learn/useImportPipeline.ts b/src/components/Learn/useImportPipeline.ts index 6d9ee4288..a436a591a 100644 --- a/src/components/Learn/useImportPipeline.ts +++ b/src/components/Learn/useImportPipeline.ts @@ -2,7 +2,7 @@ import { useMutation } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import useToastNotification from "@/hooks/useToastNotification"; -import { EDITOR_PATH } from "@/routes/router"; +import { getDefaultEditorPath } from "@/routes/editorRoutes"; import { importPipelineFromUrl } from "./importPipelineFromUrl"; @@ -15,7 +15,7 @@ export function useImportPipeline() { onSuccess: (result) => { notify(`Pipeline "${result.name}" created successfully`, "success"); navigate({ - to: `${EDITOR_PATH}/${encodeURIComponent(result.name)}`, + to: getDefaultEditorPath(result.name), }); }, }); diff --git a/src/components/PipelineRun/RunToolbar.tsx b/src/components/PipelineRun/RunToolbar.tsx index 38b637db9..ef22656b0 100644 --- a/src/components/PipelineRun/RunToolbar.tsx +++ b/src/components/PipelineRun/RunToolbar.tsx @@ -5,6 +5,7 @@ import { useUserDetails } from "@/hooks/useUserDetails"; import { cn } from "@/lib/utils"; import { useComponentSpec } from "@/providers/ComponentSpecProvider"; import { useExecutionData } from "@/providers/ExecutionDataProvider"; +import { getDefaultEditorPath } from "@/routes/editorRoutes"; import { extractCanonicalName } from "@/utils/canonicalPipelineName"; import { countInProgressFromStats, @@ -30,7 +31,7 @@ export const RunToolbar = () => { const { data: currentUserDetails } = useUserDetails(); const editorRoute = componentSpec.name - ? `/editor/${encodeURIComponent(componentSpec.name)}` + ? getDefaultEditorPath(componentSpec.name) : ""; const canAccessEditorSpec = useCheckComponentSpecFromPath( diff --git a/src/components/PipelineRun/components/InspectPipelineButton.test.tsx b/src/components/PipelineRun/components/InspectPipelineButton.test.tsx index 5200e67e3..35a7717f9 100644 --- a/src/components/PipelineRun/components/InspectPipelineButton.test.tsx +++ b/src/components/PipelineRun/components/InspectPipelineButton.test.tsx @@ -16,6 +16,6 @@ describe("", () => { render(); const inspectButton = screen.getByTestId("inspect-pipeline-button"); act(() => fireEvent.click(inspectButton)); - expect(mockNavigate).toHaveBeenCalledWith({ to: "/editor/foo" }); + expect(mockNavigate).toHaveBeenCalledWith({ to: "/editor-v2/foo" }); }); }); diff --git a/src/components/PipelineRun/components/InspectPipelineButton.tsx b/src/components/PipelineRun/components/InspectPipelineButton.tsx index 3b12ebd16..db7c9ea42 100644 --- a/src/components/PipelineRun/components/InspectPipelineButton.tsx +++ b/src/components/PipelineRun/components/InspectPipelineButton.tsx @@ -7,6 +7,7 @@ import { import TooltipButton from "@/components/shared/Buttons/TooltipButton"; import { Icon } from "@/components/ui/icon"; +import { getDefaultEditorPath } from "@/routes/editorRoutes"; type InspectPipelineButtonProps = { pipelineName: string; @@ -25,7 +26,7 @@ export const InspectPipelineButton = ({ const handleInspect = useCallback( (e: MouseEvent) => { - const clickThroughUrl = `/editor/${encodeURIComponent(pipelineName)}`; + const clickThroughUrl = getDefaultEditorPath(pipelineName); if (e.ctrlKey || e.metaKey) { window.open(clickThroughUrl, "_blank"); diff --git a/src/components/layout/AiModelQuickSelect.test.tsx b/src/components/layout/AiModelQuickSelect.test.tsx index bde57d31a..0e984559b 100644 --- a/src/components/layout/AiModelQuickSelect.test.tsx +++ b/src/components/layout/AiModelQuickSelect.test.tsx @@ -30,6 +30,7 @@ describe("AiModelQuickSelect", () => { }); it("does not render when both AI features are disabled", () => { + enableFlags({ "ai-assistant": false, "component-search-v2": false }); window.localStorage.setItem( STORAGE_KEY, JSON.stringify({ diff --git a/src/components/shared/EditorV2WelcomeSpotlight.tsx b/src/components/shared/EditorV2WelcomeSpotlight.tsx new file mode 100644 index 000000000..3289eb9c1 --- /dev/null +++ b/src/components/shared/EditorV2WelcomeSpotlight.tsx @@ -0,0 +1,210 @@ +import { + type RefObject, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; + +import { Button } from "@/components/ui/button"; +import { BlockStack } from "@/components/ui/layout"; +import { Paragraph, Text } from "@/components/ui/typography"; +import { getStorage } from "@/utils/typedStorage"; + +interface EditorV2WelcomeStorage { + "seen-editor-v2-welcome": boolean; +} + +const storage = getStorage< + keyof EditorV2WelcomeStorage, + EditorV2WelcomeStorage +>(); +const STORAGE_KEY = "seen-editor-v2-welcome"; +const SPOTLIGHT_PADDING = 16; +const CARD_WIDTH = 320; +const SPOTLIGHT_COLOR = "#5B35F5"; +const SPOTLIGHT_HALO_COLOR = "rgba(91, 53, 245, 0.35)"; + +interface SpotlightRect { + centerX: number; + centerY: number; + radius: number; +} + +export function hasSeenEditorV2Welcome(): boolean { + return storage.getItem(STORAGE_KEY) === true; +} + +export function markEditorV2WelcomeSeen() { + storage.setItem(STORAGE_KEY, true); +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +function getSpotlightRect(target: HTMLElement): SpotlightRect { + const rect = target.getBoundingClientRect(); + return { + centerX: rect.left + rect.width / 2, + centerY: rect.top + rect.height / 2, + radius: Math.max(rect.width, rect.height) / 2 + SPOTLIGHT_PADDING, + }; +} + +interface ClickBlockersProps { + spotlight: SpotlightRect; + onDismiss: () => void; +} + +function ClickBlockers({ spotlight, onDismiss }: ClickBlockersProps) { + const { centerX, centerY, radius } = spotlight; + const top = Math.max(centerY - radius, 0); + const bottom = Math.min(centerY + radius, window.innerHeight); + const left = Math.max(centerX - radius, 0); + const right = Math.min(centerX + radius, window.innerWidth); + + return ( + <> + + + + , + document.body, + ); +} diff --git a/src/components/shared/EditorVersionToggle.tsx b/src/components/shared/EditorVersionToggle.tsx index 35ced979e..7a85e5f30 100644 --- a/src/components/shared/EditorVersionToggle.tsx +++ b/src/components/shared/EditorVersionToggle.tsx @@ -1,7 +1,14 @@ import { useLocation, useNavigate } from "@tanstack/react-router"; +import { useCallback, useRef, useState } from "react"; import TooltipButton from "@/components/shared/Buttons/TooltipButton"; +import { + EditorV2WelcomeSpotlight, + hasSeenEditorV2Welcome, + markEditorV2WelcomeSeen, +} from "@/components/shared/EditorV2WelcomeSpotlight"; import { Icon } from "@/components/ui/icon"; +import { cn } from "@/lib/utils"; import { APP_ROUTES, EDITOR_PATH } from "@/routes/router"; import { useFlagValue } from "./Settings/useFlags"; @@ -18,6 +25,13 @@ export const EditorVersionToggle = () => { const location = useLocation(); const navigate = useNavigate(); const isEnabled = useFlagValue("v2_editor"); + const toggleRef = useRef(null); + const [welcomeSeen, setWelcomeSeen] = useState(hasSeenEditorV2Welcome); + + const dismissWelcome = useCallback(() => { + markEditorV2WelcomeSeen(); + setWelcomeSeen(true); + }, []); if (!isEnabled) return null; @@ -35,14 +49,28 @@ export const EditorVersionToggle = () => { : `${EDITOR_PATH}/${encodeURIComponent(pipelineName)}`; const tooltip = targetVersion === "v2" ? "Switch to new editor" : "Switch to legacy editor"; + const showWelcome = version === "v2" && !welcomeSeen; return ( - navigate({ to: targetPath })} - aria-label={tooltip} - > - - + <> + { + if (showWelcome) dismissWelcome(); + navigate({ to: targetPath }); + }} + aria-label={tooltip} + > + + + {showWelcome && ( + + )} + ); }; diff --git a/src/components/shared/ImportPipeline.tsx b/src/components/shared/ImportPipeline.tsx index eb97ed5a1..9438d8455 100644 --- a/src/components/shared/ImportPipeline.tsx +++ b/src/components/shared/ImportPipeline.tsx @@ -19,7 +19,7 @@ import { Label } from "@/components/ui/label"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { useAnalytics } from "@/providers/AnalyticsProvider"; -import { EDITOR_PATH } from "@/routes/router"; +import { getDefaultEditorPath } from "@/routes/editorRoutes"; import { importPipelineFromFile, importPipelineFromYaml, @@ -63,7 +63,7 @@ const ImportPipeline = ({ onImportComplete(importedPipeline); } else { navigate({ - to: `${EDITOR_PATH}/${encodeURIComponent(importedPipeline.name)}`, + to: getDefaultEditorPath(importedPipeline.name), }); } }; diff --git a/src/components/shared/NewPipelineButton.tsx b/src/components/shared/NewPipelineButton.tsx index e8c4e26af..3721e60b2 100644 --- a/src/components/shared/NewPipelineButton.tsx +++ b/src/components/shared/NewPipelineButton.tsx @@ -3,7 +3,7 @@ import { generate } from "random-words"; import type { MouseEvent, ReactNode } from "react"; import { Button, type ButtonProps } from "@/components/ui/button"; -import { EDITOR_PATH } from "@/routes/router"; +import { getDefaultEditorPath } from "@/routes/editorRoutes"; import { writeComponentToFileListFromText } from "@/utils/componentStore"; import { defaultPipelineYamlWithName, @@ -32,7 +32,7 @@ const NewPipelineButton = ({ componentText, ); - const clickThroughUrl = `${EDITOR_PATH}/${encodeURIComponent(name)}`; + const clickThroughUrl = getDefaultEditorPath(name); if (e.ctrlKey || e.metaKey) { window.open(clickThroughUrl, "_blank"); diff --git a/src/flags.ts b/src/flags.ts index 88e05ac27..65099b862 100644 --- a/src/flags.ts +++ b/src/flags.ts @@ -67,7 +67,7 @@ export const ExistingFlags: ConfigFlags = { name: "Component Search", description: "Show the experimental component search that searches across standard, published, registered, and user component sources, with optional AI rerank.", - default: false, + default: true, category: "beta", }, diff --git a/src/routes/Dashboard/TypePill.tsx b/src/routes/Dashboard/TypePill.tsx index 24aefb9e9..c6b35c5d4 100644 --- a/src/routes/Dashboard/TypePill.tsx +++ b/src/routes/Dashboard/TypePill.tsx @@ -2,7 +2,8 @@ import { Icon, type IconName } from "@/components/ui/icon"; import type { FavoriteItem } from "@/hooks/useFavorites"; import type { RecentlyViewedItem } from "@/hooks/useRecentlyViewed"; import { cn } from "@/lib/utils"; -import { APP_ROUTES, EDITOR_PATH, RUNS_BASE_PATH } from "@/routes/router"; +import { getDefaultEditorPath } from "@/routes/editorRoutes"; +import { APP_ROUTES, RUNS_BASE_PATH } from "@/routes/router"; type ItemType = "pipeline" | "run" | "component"; @@ -50,12 +51,12 @@ export const TypePill = ({ }; export function getFavoriteUrl(item: FavoriteItem): string { - if (item.type === "pipeline") return `${EDITOR_PATH}/${item.id}`; + if (item.type === "pipeline") return getDefaultEditorPath(item.id); return `${RUNS_BASE_PATH}/${item.id}`; } export function getRecentlyViewedUrl(item: RecentlyViewedItem): string { - if (item.type === "pipeline") return `${EDITOR_PATH}/${item.id}`; + if (item.type === "pipeline") return getDefaultEditorPath(item.id); if (item.type === "run") return `${RUNS_BASE_PATH}/${item.id}`; return APP_ROUTES.DASHBOARD_COMPONENTS; } diff --git a/src/routes/Import/Import.test.tsx b/src/routes/Import/Import.test.tsx index 819b44f68..a2be0a0b2 100644 --- a/src/routes/Import/Import.test.tsx +++ b/src/routes/Import/Import.test.tsx @@ -142,7 +142,7 @@ describe("ImportPage", () => { await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith({ - to: "/editor/Test%20Pipeline", + to: "/editor-v2/Test%20Pipeline", }); }); }); diff --git a/src/routes/Import/index.tsx b/src/routes/Import/index.tsx index fd84ffd6b..69c504480 100644 --- a/src/routes/Import/index.tsx +++ b/src/routes/Import/index.tsx @@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Spinner } from "@/components/ui/spinner"; import { Paragraph, Text } from "@/components/ui/typography"; -import { EDITOR_PATH } from "@/routes/router"; +import { getDefaultEditorPath } from "@/routes/editorRoutes"; import { importPipelineFromYaml } from "@/services/pipelineService"; /** @@ -202,7 +202,7 @@ export const ImportPage = () => { setPipelineName(result.name); setStep(Step.Done); navigate({ - to: `${EDITOR_PATH}/${encodeURIComponent(result.name)}`, + to: getDefaultEditorPath(result.name), }); } else { setError(result.errorMessage || "Failed to import pipeline from URL."); diff --git a/src/routes/editorRoutes.ts b/src/routes/editorRoutes.ts new file mode 100644 index 000000000..347a7c1c2 --- /dev/null +++ b/src/routes/editorRoutes.ts @@ -0,0 +1,17 @@ +import { isFlagEnabled } from "@/components/shared/Settings/useFlags"; + +import { APP_ROUTES, EDITOR_PATH } from "./appRoutes"; + +function getLegacyEditorPath(pipelineName: string): string { + return `${EDITOR_PATH}/${encodeURIComponent(pipelineName)}`; +} + +function getEditorV2Path(pipelineName: string): string { + return `${APP_ROUTES.EDITOR_V2}/${encodeURIComponent(pipelineName)}`; +} + +export function getDefaultEditorPath(pipelineName: string): string { + return isFlagEnabled("v2_editor") + ? getEditorV2Path(pipelineName) + : getLegacyEditorPath(pipelineName); +} diff --git a/src/routes/router.ts b/src/routes/router.ts index fd9aaf68e..0cde964f2 100644 --- a/src/routes/router.ts +++ b/src/routes/router.ts @@ -288,12 +288,25 @@ const editorV2Route = createRoute({ getParentRoute: () => mainLayout, path: APP_ROUTES.EDITOR_V2, component: EditorV2, + beforeLoad: () => { + if (!isFlagEnabled("v2_editor")) { + throw redirect({ to: APP_ROUTES.DASHBOARD_PIPELINES }); + } + }, }); const editorV2PipelineRoute = createRoute({ getParentRoute: () => mainLayout, path: APP_ROUTES.EDITOR_V2_PIPELINE, component: EditorV2, + beforeLoad: ({ params }) => { + if (!isFlagEnabled("v2_editor")) { + throw redirect({ + to: APP_ROUTES.PIPELINE_EDITOR, + params: { name: params.pipelineName }, + }); + } + }, }); const runV2Route = createRoute({ diff --git a/src/routes/v2/pages/RunView/components/RunViewMenuBar/components/RunMenu.tsx b/src/routes/v2/pages/RunView/components/RunViewMenuBar/components/RunMenu.tsx index ae5b68699..f0d0b6995 100644 --- a/src/routes/v2/pages/RunView/components/RunViewMenuBar/components/RunMenu.tsx +++ b/src/routes/v2/pages/RunView/components/RunViewMenuBar/components/RunMenu.tsx @@ -8,6 +8,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Icon } from "@/components/ui/icon"; +import { getDefaultEditorPath } from "@/routes/editorRoutes"; import { useRunViewActions } from "@/routes/v2/pages/RunView/hooks/useRunViewActions"; import { MenuTriggerButton } from "@/routes/v2/shared/components/MenuTriggerButton"; import { tracking } from "@/utils/tracking"; @@ -41,7 +42,7 @@ export function RunMenu() { const handleInspect = () => { if (pipelineName) { - navigate({ to: `/editor/${encodeURIComponent(pipelineName)}` }); + navigate({ to: getDefaultEditorPath(pipelineName) }); } }; diff --git a/src/routes/v2/pages/RunView/hooks/useRunViewActions.ts b/src/routes/v2/pages/RunView/hooks/useRunViewActions.ts index 5b5e8c725..7ac2be45d 100644 --- a/src/routes/v2/pages/RunView/hooks/useRunViewActions.ts +++ b/src/routes/v2/pages/RunView/hooks/useRunViewActions.ts @@ -8,6 +8,7 @@ import { useCheckComponentSpecFromPath } from "@/hooks/useCheckComponentSpecFrom import { useUserDetails } from "@/hooks/useUserDetails"; import { useComponentSpec } from "@/providers/ComponentSpecProvider"; import { useExecutionData } from "@/providers/ExecutionDataProvider"; +import { getDefaultEditorPath } from "@/routes/editorRoutes"; import { extractCanonicalName } from "@/utils/canonicalPipelineName"; import type { ComponentSpec } from "@/utils/componentSpec"; import { @@ -80,7 +81,7 @@ export function useRunViewActions(): RunViewActions { const { data: currentUserDetails } = useUserDetails(); const editorRoute = componentSpec?.name - ? `/editor/${encodeURIComponent(componentSpec.name)}` + ? getDefaultEditorPath(componentSpec.name) : ""; const canAccessEditorSpec = useCheckComponentSpecFromPath( diff --git a/src/services/pipelineRunService.ts b/src/services/pipelineRunService.ts index 026d1b75d..e148dbc6e 100644 --- a/src/services/pipelineRunService.ts +++ b/src/services/pipelineRunService.ts @@ -4,7 +4,7 @@ import type { BodyCreateApiPipelineRunsPost, ListAnnotationsApiPipelineRunsIdAnnotationsGetResponse, } from "@/api/types.gen"; -import { APP_ROUTES } from "@/routes/router"; +import { getDefaultEditorPath } from "@/routes/editorRoutes"; import type { PipelineRun } from "@/types/pipelineRun"; import { EDITOR_FLOW_DIRECTION_ANNOTATION } from "@/utils/annotations"; import { removeCachingStrategyFromSpec } from "@/utils/cache"; @@ -161,10 +161,8 @@ export const copyRunToPipeline = async ( componentText, ); - const urlName = encodeURIComponent(newName); - return { - url: APP_ROUTES.PIPELINE_EDITOR.replace("$name", urlName), + url: getDefaultEditorPath(newName), name: newName, }; } catch (error) { diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index ab96423fd..66a25fc75 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -5,6 +5,19 @@ import { expect, type Locator, type Page } from "@playwright/test"; * Waits for the React Flow canvas to be fully loaded (past Suspense loading state). */ export async function createNewPipeline(page: Page): Promise { + // These shared E2E helpers cover the legacy editor until v2 has matching coverage. + await page.addInitScript(() => { + const flags = JSON.parse( + window.localStorage.getItem("betaFlags") ?? "{}", + ) as Record; + + window.localStorage.setItem( + "betaFlags", + JSON.stringify({ ...flags, v2_editor: false }), + ); + window.localStorage.setItem("seen-editor-v2-welcome", JSON.stringify(true)); + }); + await page.goto("/"); await page.getByTestId("new-pipeline-button").click(); await waitForFlowCanvas(page);