diff --git a/src/components/Learn/tours/registry.ts b/src/components/Learn/tours/registry.ts index 1c5296d34..3a5aa5bb3 100644 --- a/src/components/Learn/tours/registry.ts +++ b/src/components/Learn/tours/registry.ts @@ -10,6 +10,7 @@ export type TourStep = StepType & resetLibrarySearch?: boolean; ensureWindowRestored?: string; requiresTaskSelected?: string; + anchorCreatedSubgraph?: boolean; }; export interface TourDefinition { diff --git a/src/components/Learn/tours/subgraphs.tour.json b/src/components/Learn/tours/subgraphs.tour.json index 6b56114c5..6478dfbac 100644 --- a/src/components/Learn/tours/subgraphs.tour.json +++ b/src/components/Learn/tours/subgraphs.tour.json @@ -164,7 +164,8 @@ { "selector": "[data-tour-anchor=\"no-spotlight\"]", "content": "You've built a new subgraph from scratch.\n\nReach for subgraphs whenever a section of your pipeline reads as a single logical step, or you find you are often repeating combinations of tasks. They keep the top level clean and turn complex workflows into reusable building blocks.", - "position": [500, 60] + "position": "center", + "anchorCreatedSubgraph": true } ] } diff --git a/src/components/Onboarding/OnboardingNavPill.test.tsx b/src/components/Onboarding/OnboardingNavPill.test.tsx index 9e75e00c7..b880b1ca3 100644 --- a/src/components/Onboarding/OnboardingNavPill.test.tsx +++ b/src/components/Onboarding/OnboardingNavPill.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -14,6 +14,7 @@ let onboarding = { total: 4, shouldShowOnboarding: true, markDocsRead: vi.fn(), + dismiss: vi.fn(), }; vi.mock("@/providers/OnboardingProvider/OnboardingProvider", () => ({ useOnboarding: () => onboarding, @@ -26,6 +27,7 @@ function resetState() { total: 4, shouldShowOnboarding: true, markDocsRead: vi.fn(), + dismiss: vi.fn(), }; } @@ -45,4 +47,11 @@ describe("OnboardingNavPill", () => { render(); expect(pill()).toBeNull(); }); + + it("dismisses onboarding from the popover footer", () => { + render(); + fireEvent.click(screen.getByText("Onboarding · 1/4")); + fireEvent.click(screen.getByText("Dismiss onboarding")); + expect(onboarding.dismiss).toHaveBeenCalledOnce(); + }); }); diff --git a/src/components/Onboarding/OnboardingNavPill.tsx b/src/components/Onboarding/OnboardingNavPill.tsx index 8a9601d38..431fe0c56 100644 --- a/src/components/Onboarding/OnboardingNavPill.tsx +++ b/src/components/Onboarding/OnboardingNavPill.tsx @@ -1,18 +1,20 @@ import { OnboardingChecklist } from "@/components/Onboarding/OnboardingChecklist"; import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; -import { BlockStack } from "@/components/ui/layout"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; import { Heading } from "@/components/ui/typography"; import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider"; import { tracking } from "@/utils/tracking"; export function OnboardingNavPill() { - const { completedCount, total, shouldShowOnboarding } = useOnboarding(); + const { completedCount, total, shouldShowOnboarding, dismiss } = + useOnboarding(); if (!shouldShowOnboarding) { return null; @@ -30,6 +32,18 @@ export function OnboardingNavPill() { Get started with Tangle + + + + diff --git a/src/providers/OnboardingProvider/OnboardingProvider.test.tsx b/src/providers/OnboardingProvider/OnboardingProvider.test.tsx index 2b4791166..2862ff7ee 100644 --- a/src/providers/OnboardingProvider/OnboardingProvider.test.tsx +++ b/src/providers/OnboardingProvider/OnboardingProvider.test.tsx @@ -184,6 +184,31 @@ describe("OnboardingProvider", () => { }); }); + it("tracks create_pipeline only once when progress reverts after completion", async () => { + const { result, queryClient } = renderWithClient(); + + await waitFor(() => expect(result.current.total).toBe(4)); + + act(() => emitUserPipelineWritten()); + await waitFor(() => + expect(completedSteps(result)).toContain("create_pipeline"), + ); + + await act(async () => { + await queryClient.invalidateQueries(); + }); + await waitFor(() => + expect(completedSteps(result)).not.toContain("create_pipeline"), + ); + + const createPipelineTracks = track.mock.calls.filter( + ([event, payload]) => + event === "onboarding.step.completed" && + (payload as { step_id?: string })?.step_id === "create_pipeline", + ); + expect(createPipelineTracks).toHaveLength(1); + }); + it("marks the docs step read on demand", async () => { settingsPayload = { onboarding: { steps: { create_pipeline: true } } }; const { result } = render(); diff --git a/src/providers/OnboardingProvider/OnboardingProvider.tsx b/src/providers/OnboardingProvider/OnboardingProvider.tsx index 502753746..2bfd00994 100644 --- a/src/providers/OnboardingProvider/OnboardingProvider.tsx +++ b/src/providers/OnboardingProvider/OnboardingProvider.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { type ReactNode, useEffect, useState } from "react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; import { useDocsVisitTracking } from "@/hooks/useDocsVisitTracking"; import { @@ -121,6 +121,7 @@ export function OnboardingProvider({ children }: { children: ReactNode }) { isOnboardingAvailable && !isComplete && !dismissed; const [pipelineWriteCount, setPipelineWriteCount] = useState(0); + const pipelineCompletionFiredRef = useRef(false); useEffect( () => @@ -134,10 +135,12 @@ export function OnboardingProvider({ children }: { children: ReactNode }) { if ( pipelineWriteCount === 0 || !progress || - progress.steps.create_pipeline + progress.steps.create_pipeline || + pipelineCompletionFiredRef.current ) { return; } + pipelineCompletionFiredRef.current = true; persist({ ...progress, steps: { ...progress.steps, create_pipeline: true }, @@ -145,7 +148,7 @@ export function OnboardingProvider({ children }: { children: ReactNode }) { track("onboarding.step.completed", { step_id: "create_pipeline" }); }, [pipelineWriteCount, progress, persist, track]); - useStepCompletionToasts({ isResolved, desiredSteps, isComplete }); + useStepCompletionToasts({ isOnboardingAvailable, desiredSteps, isComplete }); const markDocsRead = () => { if (!progress || progress.steps.read_docs) return; diff --git a/src/providers/OnboardingProvider/useStepCompletionToasts.test.ts b/src/providers/OnboardingProvider/useStepCompletionToasts.test.ts new file mode 100644 index 000000000..a49cbcbd9 --- /dev/null +++ b/src/providers/OnboardingProvider/useStepCompletionToasts.test.ts @@ -0,0 +1,112 @@ +import { renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { stepLabel } from "./onboardingCompletion"; +import type { OnboardingSteps } from "./onboardingProgress"; +import { useStepCompletionToasts } from "./useStepCompletionToasts"; + +const notify = vi.fn(); + +vi.mock("@/hooks/useToastNotification", () => ({ + default: () => notify, +})); + +const NONE: OnboardingSteps = { + read_docs: false, + complete_tour: false, + create_pipeline: false, + execute_run: false, +}; + +const CREATED: OnboardingSteps = { ...NONE, create_pipeline: true }; + +const createdLabel = `Completed: ${stepLabel("create_pipeline")}`; + +function setup(initial: { + isOnboardingAvailable: boolean; + desiredSteps: OnboardingSteps; + isComplete?: boolean; +}) { + return renderHook((props) => useStepCompletionToasts(props), { + initialProps: { + isOnboardingAvailable: initial.isOnboardingAvailable, + desiredSteps: initial.desiredSteps, + isComplete: initial.isComplete ?? false, + }, + }); +} + +beforeEach(() => { + notify.mockClear(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("useStepCompletionToasts", () => { + it("does not toast for steps already complete when the baseline seeds", () => { + setup({ isOnboardingAvailable: true, desiredSteps: CREATED }); + expect(notify).not.toHaveBeenCalled(); + }); + + it("toasts once when a step flips to complete", () => { + const { rerender } = setup({ + isOnboardingAvailable: true, + desiredSteps: NONE, + }); + rerender({ + isOnboardingAvailable: true, + desiredSteps: { ...CREATED }, + isComplete: false, + }); + expect(notify).toHaveBeenCalledTimes(1); + expect(notify).toHaveBeenCalledWith(createdLabel, "success"); + }); + + it("does not re-toast when a completed step reverts and completes again", () => { + const { rerender } = setup({ + isOnboardingAvailable: true, + desiredSteps: NONE, + }); + rerender({ + isOnboardingAvailable: true, + desiredSteps: { ...CREATED }, + isComplete: false, + }); + rerender({ + isOnboardingAvailable: true, + desiredSteps: { ...NONE }, + isComplete: false, + }); + rerender({ + isOnboardingAvailable: true, + desiredSteps: { ...CREATED }, + isComplete: false, + }); + expect(notify).toHaveBeenCalledTimes(1); + }); + + it("does not re-toast after availability flaps and the step recompletes", () => { + const { rerender } = setup({ + isOnboardingAvailable: true, + desiredSteps: NONE, + }); + rerender({ + isOnboardingAvailable: true, + desiredSteps: { ...CREATED }, + isComplete: false, + }); + rerender({ + isOnboardingAvailable: false, + desiredSteps: { ...NONE }, + isComplete: false, + }); + rerender({ + isOnboardingAvailable: true, + desiredSteps: { ...CREATED }, + isComplete: false, + }); + expect(notify).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/providers/OnboardingProvider/useStepCompletionToasts.ts b/src/providers/OnboardingProvider/useStepCompletionToasts.ts index 9130715cc..49e2197d0 100644 --- a/src/providers/OnboardingProvider/useStepCompletionToasts.ts +++ b/src/providers/OnboardingProvider/useStepCompletionToasts.ts @@ -11,33 +11,50 @@ import type { OnboardingSteps } from "./onboardingProgress"; import type { OnboardingStepId } from "./steps"; interface UseStepCompletionToastsArgs { - isResolved: boolean; + isOnboardingAvailable: boolean; desiredSteps: OnboardingSteps; isComplete: boolean; } /** * Fires a toast the first time each step flips to complete (and a single - * "all set up" toast once everything is done). The first resolved render + * "all set up" toast once everything is done). The first available render * only seeds the baseline, so already-complete steps don't toast on mount. + * + * Gating on `isOnboardingAvailable` (not `isResolved`) matters: the backing + * queries are disabled until the backend is present, and a disabled query + * reports `isLoading: false`. Seeding earlier would capture an all-false + * baseline and then toast every already-complete step once the queries settle. */ export function useStepCompletionToasts({ - isResolved, + isOnboardingAvailable, desiredSteps, isComplete, }: UseStepCompletionToastsArgs) { const notify = useToastNotification(); const previousRef = useRef | null>(null); + const toastedRef = useRef>(new Set()); useEffect(() => { - if (!isResolved) return; + if (!isOnboardingAvailable) { + // Not settled yet (or the backend went away) — drop the baseline so the + // next available render re-seeds from the true completed set. + previousRef.current = null; + return; + } const current = new Set(completedStepIds(desiredSteps)); const previous = previousRef.current; previousRef.current = current; - if (previous === null) return; + if (previous === null) { + for (const id of current) toastedRef.current.add(id); + return; + } - const newly = newlyCompletedIds(previous, current); + const newly = newlyCompletedIds(previous, current).filter( + (id) => !toastedRef.current.has(id), + ); if (newly.length === 0) return; + for (const id of newly) toastedRef.current.add(id); if (isComplete) { notify("You're all set up - onboarding complete!", "success"); @@ -47,5 +64,5 @@ export function useStepCompletionToasts({ const label = stepLabel(id); if (label) notify(`Completed: ${label}`, "success"); } - }, [isResolved, desiredSteps, isComplete, notify]); + }, [isOnboardingAvailable, desiredSteps, isComplete, notify]); } diff --git a/src/providers/TourProvider/TourPopover.tsx b/src/providers/TourProvider/TourPopover.tsx index 6c09c8a2f..c19d7fdc6 100644 --- a/src/providers/TourProvider/TourPopover.tsx +++ b/src/providers/TourProvider/TourPopover.tsx @@ -36,10 +36,6 @@ export const POPOVER_STYLES = { ...base, rx: 6, }), - clickArea: (base: object) => ({ - ...base, - pointerEvents: "none" as const, - }), highlightedArea: ( base: object, state?: { width?: number; height?: number }, diff --git a/src/providers/TourProvider/TourProvider.tsx b/src/providers/TourProvider/TourProvider.tsx index be3574390..c3c887050 100644 --- a/src/providers/TourProvider/TourProvider.tsx +++ b/src/providers/TourProvider/TourProvider.tsx @@ -31,6 +31,7 @@ export function TourProvider({ children }: { children: ReactNode }) { padding={{ mask: 0, popover: 10 }} position={computeDefaultPopoverPosition} nextButton={renderNextButton} + maskClassName="reactour-tour-mask" onClickMask={() => undefined} > diff --git a/src/providers/TourProvider/TourSecretsDialog.tsx b/src/providers/TourProvider/TourSecretsDialog.tsx index d7f712212..5e599a869 100644 --- a/src/providers/TourProvider/TourSecretsDialog.tsx +++ b/src/providers/TourProvider/TourSecretsDialog.tsx @@ -17,6 +17,7 @@ import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Heading, Text } from "@/components/ui/typography"; import { cn } from "@/lib/utils"; import { useTourMode } from "@/providers/TourProvider/TourModeContext"; +import { useTourProgress } from "@/providers/TourProvider/TourProgressContext"; // The Settings gear (AppMenuActions) calls this in tour mode instead of routing // to /settings, which would unmount the tour page and tear the tour down. @@ -87,7 +88,8 @@ function TourSecretsManager() { // router links the real page relies on, so the tour never navigates away. export function TourSecretsDialog() { const tourMode = useTourMode(); - const { steps, currentStep, isOpen: tourIsOpen } = useTour(); + const { steps, currentStep, isOpen: tourIsOpen, setCurrentStep } = useTour(); + const { isStepComplete } = useTourProgress(); const [open, setOpen] = useState(false); const [openKey, setOpenKey] = useState(0); @@ -102,12 +104,57 @@ export function TourSecretsDialog() { const step = steps?.[currentStep] as TourStep | undefined; const onSettingsStep = !!tourIsOpen && step?.tourPanel === "secrets-manager"; const isExplainStep = onSettingsStep && !step?.interaction; + const isOpenSettingsStep = onSettingsStep && !!step?.interaction; + const isCurrentStepComplete = isStepComplete(currentStep); + const stepsLength = steps?.length ?? 0; useEffect(() => { if (isExplainStep) setOpen(true); else if (!onSettingsStep) setOpen(false); }, [isExplainStep, onSettingsStep]); + useEffect(() => { + if (!(open && isOpenSettingsStep && isCurrentStepComplete)) + return undefined; + let done = false; + let fallback = 0; + let dialog: Element | null = null; + const advance = () => { + if (done) return; + done = true; + setCurrentStep((s: number) => + Math.min(s + 1, Math.max(stepsLength - 1, 0)), + ); + }; + const waitForSettledDialog = () => { + if (done) return; + dialog = document.querySelector('[data-tour="tour-settings-dialog"]'); + if (!dialog) { + requestAnimationFrame(waitForSettledDialog); + return; + } + dialog.addEventListener("animationend", advance, { once: true }); + fallback = window.setTimeout(advance, 400); + }; + requestAnimationFrame(waitForSettledDialog); + return () => { + done = true; + clearTimeout(fallback); + dialog?.removeEventListener("animationend", advance); + }; + }, [ + open, + isOpenSettingsStep, + isCurrentStepComplete, + setCurrentStep, + stepsLength, + ]); + + const handleClose = () => { + setOpen(false); + if (isExplainStep) setCurrentStep(Math.max(0, currentStep - 1)); + }; + if (!tourMode) return null; return ( @@ -115,7 +162,7 @@ export function TourSecretsDialog() { event.preventDefault()} onEscapeKeyDown={(event) => event.preventDefault()} > @@ -124,6 +171,16 @@ export function TourSecretsDialog() { Manage your secrets. + +
Settings diff --git a/src/providers/TourProvider/resolveComponentWindowSteps.test.ts b/src/providers/TourProvider/resolveComponentWindowSteps.test.ts new file mode 100644 index 000000000..4111fd5ff --- /dev/null +++ b/src/providers/TourProvider/resolveComponentWindowSteps.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; + +import type { TourStep } from "@/components/Learn/tours/registry"; + +import { + COMPONENT_SEARCH_V2_WINDOW_ID, + componentWindowIdForFlag, + LEGACY_COMPONENT_WINDOW_ID, + resolveComponentWindowSteps, +} from "./resolveComponentWindowSteps"; + +const steps: TourStep[] = [ + { + selector: '[data-dock-window-content="component-library"]', + highlightedSelectors: [ + '[data-dock-window="component-library"]', + '[data-dock-window-content="component-library"]', + ], + mutationObservables: ['[data-dock-window-content="component-library"]'], + resizeObservables: ['[data-dock-window-content="component-library"]'], + ensureWindowRestored: "component-library", + content: "Add a component", + }, + { + selector: '[data-folder-name="Standard library"]', + targetWindowId: "context-panel", + content: "Unrelated step", + }, +]; + +describe("componentWindowIdForFlag", () => { + it("maps the flag to the active window id", () => { + expect(componentWindowIdForFlag(false)).toBe(LEGACY_COMPONENT_WINDOW_ID); + expect(componentWindowIdForFlag(true)).toBe(COMPONENT_SEARCH_V2_WINDOW_ID); + }); +}); + +describe("resolveComponentWindowSteps", () => { + it("returns the steps untouched for the legacy window id", () => { + expect(resolveComponentWindowSteps(steps, LEGACY_COMPONENT_WINDOW_ID)).toBe( + steps, + ); + }); + + it("rewrites every component-window reference to the v2 id", () => { + const [first] = resolveComponentWindowSteps( + steps, + COMPONENT_SEARCH_V2_WINDOW_ID, + ); + + expect(first.selector).toBe( + '[data-dock-window-content="component-search-v2"]', + ); + expect(first.highlightedSelectors).toEqual([ + '[data-dock-window="component-search-v2"]', + '[data-dock-window-content="component-search-v2"]', + ]); + expect(first.mutationObservables).toEqual([ + '[data-dock-window-content="component-search-v2"]', + ]); + expect(first.resizeObservables).toEqual([ + '[data-dock-window-content="component-search-v2"]', + ]); + expect(first.ensureWindowRestored).toBe("component-search-v2"); + }); + + it("leaves unrelated selectors and window ids alone", () => { + const [, second] = resolveComponentWindowSteps( + steps, + COMPONENT_SEARCH_V2_WINDOW_ID, + ); + + expect(second.selector).toBe('[data-folder-name="Standard library"]'); + expect(second.targetWindowId).toBe("context-panel"); + }); +}); diff --git a/src/providers/TourProvider/resolveComponentWindowSteps.ts b/src/providers/TourProvider/resolveComponentWindowSteps.ts new file mode 100644 index 000000000..34d1009aa --- /dev/null +++ b/src/providers/TourProvider/resolveComponentWindowSteps.ts @@ -0,0 +1,49 @@ +import type { TourStep } from "@/components/Learn/tours/registry"; + +export const LEGACY_COMPONENT_WINDOW_ID = "component-library"; +export const COMPONENT_SEARCH_V2_WINDOW_ID = "component-search-v2"; + +export function componentWindowIdForFlag( + componentSearchV2Enabled: boolean, +): string { + return componentSearchV2Enabled + ? COMPONENT_SEARCH_V2_WINDOW_ID + : LEGACY_COMPONENT_WINDOW_ID; +} + +export function resolveComponentWindowSteps( + steps: TourStep[], + activeWindowId: string, +): TourStep[] { + if (activeWindowId === LEGACY_COMPONENT_WINDOW_ID) return steps; + + const rewrite = (value: string) => + value.replaceAll(LEGACY_COMPONENT_WINDOW_ID, activeWindowId); + const rewriteList = (list: string[] | undefined) => list?.map(rewrite); + + return steps.map((step) => { + const next: TourStep = { ...step }; + if (typeof next.selector === "string") { + next.selector = rewrite(next.selector); + } + if (next.highlightedSelectors) { + next.highlightedSelectors = rewriteList(next.highlightedSelectors); + } + if (next.mutationObservables) { + next.mutationObservables = rewriteList(next.mutationObservables); + } + if (next.resizeObservables) { + next.resizeObservables = rewriteList(next.resizeObservables); + } + if (next.ringSelectors) { + next.ringSelectors = rewriteList(next.ringSelectors); + } + if (next.ensureWindowRestored) { + next.ensureWindowRestored = rewrite(next.ensureWindowRestored); + } + if (typeof next.targetWindowId === "string") { + next.targetWindowId = rewrite(next.targetWindowId); + } + return next; + }); +} diff --git a/src/routes/Dashboard/Learn/Tour.tsx b/src/routes/Dashboard/Learn/Tour.tsx index 219e0029d..37d925243 100644 --- a/src/routes/Dashboard/Learn/Tour.tsx +++ b/src/routes/Dashboard/Learn/Tour.tsx @@ -11,8 +11,13 @@ import { getTour, type TourDefinition, } from "@/components/Learn/tours/registry"; +import { useFlagValue } from "@/components/shared/Settings/useFlags"; import useToastNotification from "@/hooks/useToastNotification"; import { useBackend } from "@/providers/BackendProvider"; +import { + componentWindowIdForFlag, + resolveComponentWindowSteps, +} from "@/providers/TourProvider/resolveComponentWindowSteps"; import { TourContent } from "@/providers/TourProvider/TourContent"; import { setTourMockActive } from "@/providers/TourProvider/tourMockBackend"; import { @@ -30,16 +35,14 @@ import { TourTelemetryBridge } from "@/providers/TourProvider/TourTelemetryBridg import { APP_ROUTES } from "@/routes/router"; import { EditorV2 } from "@/routes/v2/pages/Editor/EditorV2"; import { - restoreLayout, - snapshotLayout, + clearLayout, + TOUR_WINDOW_LAYOUT_ID, } from "@/routes/v2/shared/windows/windowPersistence"; import { usePipelineStorage } from "@/services/pipelineStorage/PipelineStorageProvider"; import type { PipelineStorageService } from "@/services/pipelineStorage/PipelineStorageService"; import { isRecord } from "@/utils/typeGuards"; import { waitForSelector } from "@/utils/waitForSelector"; -const EDITOR_LAYOUT_ID = "editor"; - interface ResolvedPipeline { name: string; fileId: string; @@ -76,6 +79,9 @@ function TourReactourBridge({ onUrlStepChange: (step: number) => void; }) { const { setSteps, setCurrentStep, setIsOpen, currentStep } = useTour(); + const componentWindowId = componentWindowIdForFlag( + useFlagValue("component-search-v2"), + ); const lastSyncRef = useRef(null); const initializedRef = useRef(false); @@ -95,8 +101,12 @@ function TourReactourBridge({ } initializedRef.current = true; - const lastIdx = tour.steps.length - 1; - const stepsWithMarkdown = tour.steps.map((step, idx) => { + const resolvedSteps = resolveComponentWindowSteps( + tour.steps, + componentWindowId, + ); + const lastIdx = resolvedSteps.length - 1; + const stepsWithMarkdown = resolvedSteps.map((step, idx) => { const normalized = typeof step.content === "string" ? { ...step, content: } @@ -123,7 +133,7 @@ function TourReactourBridge({ setIsOpen(true); }); return () => controller.abort(); - }, [tour, urlStep, setSteps, setCurrentStep, setIsOpen]); + }, [tour, urlStep, componentWindowId, setSteps, setCurrentStep, setIsOpen]); useEffect(() => { return () => { @@ -205,9 +215,9 @@ function TourPageBody({ const [resolved, setResolved] = useState(null); useEffect(() => { - snapshotLayout(EDITOR_LAYOUT_ID); + clearLayout(TOUR_WINDOW_LAYOUT_ID); return () => { - restoreLayout(EDITOR_LAYOUT_ID); + clearLayout(TOUR_WINDOW_LAYOUT_ID); }; }, []); diff --git a/src/routes/v2/pages/Editor/EditorV2.tsx b/src/routes/v2/pages/Editor/EditorV2.tsx index 850b97631..48a7e8089 100644 --- a/src/routes/v2/pages/Editor/EditorV2.tsx +++ b/src/routes/v2/pages/Editor/EditorV2.tsx @@ -29,7 +29,10 @@ import { } from "@/routes/v2/shared/store/SharedStoreContext"; import { DockArea } from "@/routes/v2/shared/windows/DockArea"; import { WindowContainer } from "@/routes/v2/shared/windows/WindowContainer"; -import { useWindowPersistence } from "@/routes/v2/shared/windows/windowPersistence"; +import { + TOUR_WINDOW_LAYOUT_ID, + useWindowPersistence, +} from "@/routes/v2/shared/windows/windowPersistence"; import type { PipelineRef } from "@/services/pipelineStorage/types"; import { createEditorAgentWorker } from "./components/AiChat/editorAgentWorker"; @@ -80,8 +83,9 @@ const PipelineEditor = withSuspenseWrapper( data: { spec: rootSpec, restoredUndoStore }, } = useLoadSpec(pipelineRef); const { navigation } = useSharedStores(); + const tourMode = useTourMode(); - useWindowPersistence("editor"); + useWindowPersistence(tourMode ? TOUR_WINDOW_LAYOUT_ID : "editor"); useDockAreaAccordion(); useSpecLifecycle(rootSpec, pipelineRef, restoredUndoStore); useSelectionWindowSync(); diff --git a/src/routes/v2/pages/Editor/components/ComponentSearchV2Content.test.tsx b/src/routes/v2/pages/Editor/components/ComponentSearchV2Content.test.tsx index f73860026..178448eea 100644 --- a/src/routes/v2/pages/Editor/components/ComponentSearchV2Content.test.tsx +++ b/src/routes/v2/pages/Editor/components/ComponentSearchV2Content.test.tsx @@ -91,6 +91,14 @@ describe("ComponentSearchV2Content", () => { expect(screen.getByTestId("results-searching")).toHaveTextContent("false"); }); + it("anchors the guided-tour library-search step to the search box", () => { + const { container } = render(); + + const anchor = container.querySelector('[data-tour="library-search"]'); + expect(anchor).not.toBeNull(); + expect(anchor).toContainElement(screen.getByLabelText("Search components")); + }); + it("shows active AI rerank progress below the search box", async () => { mocks.useComponentSearchV2State.mockImplementation(() => ({ results: [], diff --git a/src/routes/v2/pages/Editor/components/ComponentSearchV2Content.tsx b/src/routes/v2/pages/Editor/components/ComponentSearchV2Content.tsx index a6e7725a5..3b967892d 100644 --- a/src/routes/v2/pages/Editor/components/ComponentSearchV2Content.tsx +++ b/src/routes/v2/pages/Editor/components/ComponentSearchV2Content.tsx @@ -156,7 +156,7 @@ export function ComponentSearchV2Content() { wrap="nowrap" className="w-full" > -
+
> | undefined; @@ -187,7 +188,11 @@ export function useLibraryDragGate({ const allow = libraryDragAllow.toLowerCase(); const handleDragStart = (event: DragEvent) => { const target = elementFromEvent(event); - if (!target?.closest('[data-dock-window-content="component-library"]')) { + if ( + !target?.closest( + '[data-dock-window-content="component-library"], [data-dock-window-content="component-search-v2"]', + ) + ) { return; } const item = @@ -209,6 +214,82 @@ export function useLibraryDragGate({ }, [isOpen, libraryDragAllow]); } +export function useTourDragPassthrough({ isOpen }: { isOpen: boolean }): void { + useEffect(() => { + if (!isOpen) return undefined; + const setDragging = () => { + document.body.dataset.tourDragging = "true"; + }; + const clearDragging = () => { + delete document.body.dataset.tourDragging; + }; + document.addEventListener("dragstart", setDragging, true); + document.addEventListener("dragend", clearDragging, true); + document.addEventListener("drop", clearDragging, true); + return () => { + document.removeEventListener("dragstart", setDragging, true); + document.removeEventListener("dragend", clearDragging, true); + document.removeEventListener("drop", clearDragging, true); + clearDragging(); + }; + }, [isOpen]); +} + +interface AnchorCreatedSubgraphArgs { + isOpen: boolean; + active: boolean; + currentStep: number; + navigation: NavigationStore; + setSteps: SetSteps; +} + +export function useAnchorCreatedSubgraph({ + isOpen, + active, + currentStep, + navigation, + setSteps, +}: AnchorCreatedSubgraphArgs): void { + const createdNameRef = useRef(null); + + useEffect(() => { + if (!isOpen) return undefined; + const subgraphIds = () => + navigation.activeSpec?.tasks + .filter((t) => t.subgraphSpec !== undefined) + .map((t) => t.$id) ?? []; + const seen = new Set(subgraphIds()); + const record = () => { + const name = recordNewSubgraphName( + seen, + navigation.activeSpec?.tasks ?? [], + ); + if (name) createdNameRef.current = name; + }; + return reaction(() => subgraphIds().join("|"), record); + }, [isOpen, navigation]); + + useEffect(() => { + if (!isOpen || !active) return undefined; + const name = createdNameRef.current; + if (!name) return undefined; + const selector = `[data-tour-card="task"][data-tour-card-name="${name}"]`; + setSteps?.((prev) => + prev.map((step, index) => { + if (index !== currentStep) return step; + const anchored: TourStep = { + ...step, + selector, + ringSelectors: [selector], + position: "top", + }; + return anchored; + }), + ); + return pollForSelectorThenRefreshSteps(selector, selector, setSteps); + }, [isOpen, active, currentStep, setSteps]); +} + interface ResetLibrarySearchArgs { isOpen: boolean; currentStep: number; diff --git a/src/routes/v2/pages/Editor/components/EditorTourBridge/editorTourBridge.utils.test.ts b/src/routes/v2/pages/Editor/components/EditorTourBridge/editorTourBridge.utils.test.ts new file mode 100644 index 000000000..9603925b3 --- /dev/null +++ b/src/routes/v2/pages/Editor/components/EditorTourBridge/editorTourBridge.utils.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { recordNewSubgraphName } from "./editorTourBridge.utils"; + +const subgraph = (id: string, name: string) => ({ + $id: id, + name, + subgraphSpec: {}, +}); +const leaf = (id: string, name: string) => ({ $id: id, name }); + +describe("recordNewSubgraphName", () => { + it("records every unseen subgraph and returns the last new name", () => { + const seen = new Set(); + const result = recordNewSubgraphName(seen, [ + subgraph("a", "Training"), + subgraph("b", "Evaluation"), + ]); + expect(result).toBe("Evaluation"); + expect(seen).toEqual(new Set(["a", "b"])); + }); + + it("reports the name of a newly created subgraph", () => { + const seen = new Set(["a", "b"]); + const result = recordNewSubgraphName(seen, [ + subgraph("a", "Training"), + subgraph("b", "Evaluation"), + subgraph("c", "My Subgraph"), + ]); + expect(result).toBe("My Subgraph"); + expect(seen.has("c")).toBe(true); + }); + + it("ignores non-subgraph tasks", () => { + const seen = new Set(); + const result = recordNewSubgraphName(seen, [ + leaf("g", "Generate"), + leaf("s", "Split"), + ]); + expect(result).toBeNull(); + expect(seen.size).toBe(0); + }); + + it("does not treat a re-surfaced subgraph as new after navigating away and back", () => { + const seen = new Set(); + const root = [subgraph("a", "Training"), subgraph("b", "Evaluation")]; + + recordNewSubgraphName(seen, root); + expect(recordNewSubgraphName(seen, [])).toBeNull(); + expect(recordNewSubgraphName(seen, root)).toBeNull(); + }); +}); diff --git a/src/routes/v2/pages/Editor/components/EditorTourBridge/editorTourBridge.utils.ts b/src/routes/v2/pages/Editor/components/EditorTourBridge/editorTourBridge.utils.ts index 95dff6b1a..7966a3cb4 100644 --- a/src/routes/v2/pages/Editor/components/EditorTourBridge/editorTourBridge.utils.ts +++ b/src/routes/v2/pages/Editor/components/EditorTourBridge/editorTourBridge.utils.ts @@ -49,6 +49,20 @@ export function countSubgraphTasks(spec: ComponentSpec | null): number { return spec.tasks.filter((t) => t.subgraphSpec !== undefined).length; } +export function recordNewSubgraphName( + seen: Set, + tasks: readonly { $id: string; name: string; subgraphSpec?: unknown }[], +): string | null { + let created: string | null = null; + for (const task of tasks) { + if (task.subgraphSpec !== undefined && !seen.has(task.$id)) { + seen.add(task.$id); + created = task.name; + } + } + return created; +} + export function elementFromEvent(event: Event): Element | null { return event.target instanceof Element ? event.target : null; } diff --git a/src/routes/v2/shared/windows/windowPersistence.test.ts b/src/routes/v2/shared/windows/windowPersistence.test.ts new file mode 100644 index 000000000..1826ebd87 --- /dev/null +++ b/src/routes/v2/shared/windows/windowPersistence.test.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { clearLayout, TOUR_WINDOW_LAYOUT_ID } from "./windowPersistence"; + +afterEach(() => { + localStorage.clear(); +}); + +describe("clearLayout", () => { + it("removes only the targeted layout key", () => { + localStorage.setItem("window-layout-editor", '{"editor":true}'); + localStorage.setItem("window-layout-tour", '{"tour":true}'); + + clearLayout(TOUR_WINDOW_LAYOUT_ID); + + expect(localStorage.getItem("window-layout-tour")).toBeNull(); + expect(localStorage.getItem("window-layout-editor")).toBe( + '{"editor":true}', + ); + }); + + it("is a no-op when the layout key is absent", () => { + expect(() => clearLayout(TOUR_WINDOW_LAYOUT_ID)).not.toThrow(); + expect(localStorage.getItem("window-layout-tour")).toBeNull(); + }); + + it("keeps the tour layout id distinct from the editor's", () => { + expect(TOUR_WINDOW_LAYOUT_ID).not.toBe("editor"); + }); +}); diff --git a/src/routes/v2/shared/windows/windowPersistence.ts b/src/routes/v2/shared/windows/windowPersistence.ts index 82be0a5bd..5fac10193 100644 --- a/src/routes/v2/shared/windows/windowPersistence.ts +++ b/src/routes/v2/shared/windows/windowPersistence.ts @@ -22,6 +22,8 @@ import type { WindowStoreImpl } from "./windowStore"; */ let activeLayoutId: string | null = null; +export const TOUR_WINDOW_LAYOUT_ID = "tour"; + function getLayoutStorageKey(layoutId: string | null): string { if (!layoutId) return "editorV2-window-layout"; return `window-layout-${layoutId}`; @@ -31,50 +33,11 @@ function getStorageKey(): string { return getLayoutStorageKey(activeLayoutId); } -function snapshotStorageKey(layoutId: string): string { - return `${getLayoutStorageKey(layoutId)}-snapshot`; -} - -function snapshotActiveKey(layoutId: string): string { - return `${snapshotStorageKey(layoutId)}-active`; -} - -// Stashes the layout aside so the next mount starts from defaults. Pair with -// restoreLayout to roll back. -export function snapshotLayout(layoutId: string): void { +export function clearLayout(layoutId: string): void { try { - const key = getLayoutStorageKey(layoutId); - const current = localStorage.getItem(key); - if (current !== null) { - localStorage.setItem(snapshotStorageKey(layoutId), current); - } else { - localStorage.removeItem(snapshotStorageKey(layoutId)); - } - localStorage.setItem(snapshotActiveKey(layoutId), "1"); - localStorage.removeItem(key); - } catch (error) { - console.warn(`Failed to snapshot layout "${layoutId}":`, error); - } -} - -export function restoreLayout(layoutId: string): boolean { - try { - if (localStorage.getItem(snapshotActiveKey(layoutId)) === null) { - return false; - } - const key = getLayoutStorageKey(layoutId); - const saved = localStorage.getItem(snapshotStorageKey(layoutId)); - if (saved !== null) { - localStorage.setItem(key, saved); - } else { - localStorage.removeItem(key); - } - localStorage.removeItem(snapshotStorageKey(layoutId)); - localStorage.removeItem(snapshotActiveKey(layoutId)); - return true; + localStorage.removeItem(getLayoutStorageKey(layoutId)); } catch (error) { - console.warn(`Failed to restore layout "${layoutId}":`, error); - return false; + console.warn(`Failed to clear layout "${layoutId}":`, error); } } diff --git a/src/styles/editor.css b/src/styles/editor.css index 838afa45c..a913d6b4c 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -15,6 +15,10 @@ position: absolute; } +body[data-tour-dragging="true"] .reactour-tour-mask svg rect { + pointer-events: none !important; +} + /* Use the proper box layout model by default, but allow elements to override */ html { box-sizing: border-box;