diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 68805682..13c0483d 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -33,6 +33,7 @@ import { LazyMotionProvider } from "@/components/motion/lazy-motion-provider"; import { UpdateBanner } from "@/components/update-banner"; import { CommandPalette, triggerCommandPalette } from "@/components/command-palette"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; +import { ProductTour } from "@/components/onboarding/product-tour"; import { useEnvironmentStore } from "@/stores/environment-store"; import { DemoBanner } from "@/components/dashboard/demo-banner"; import { isDemoMode } from "@/lib/is-demo-mode"; @@ -311,6 +312,7 @@ export default function DashboardLayout({ +
diff --git a/src/components/onboarding/__tests__/product-tour.test.tsx b/src/components/onboarding/__tests__/product-tour.test.tsx new file mode 100644 index 00000000..5c4780c6 --- /dev/null +++ b/src/components/onboarding/__tests__/product-tour.test.tsx @@ -0,0 +1,113 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; +import { ProductTour, startProductTour, type TourStep } from "../product-tour"; + +const DISMISS_KEY = "vf:tour-dismissed"; + +const STEPS: TourStep[] = [ + { title: "Pipelines step", body: "Build pipelines here.", targetSelector: '[data-tour="pipelines"]' }, + { title: "Fleet step", body: "Manage your fleet.", targetSelector: '[data-tour="fleet"]' }, + { title: "Ghost step", body: "This target is absent.", targetSelector: '[data-tour="does-not-exist"]' }, +]; + +/** Mount two of the three step targets so the tour can anchor (and auto-start). */ +function mountTargets() { + for (const key of ["pipelines", "fleet"]) { + const el = document.createElement("a"); + el.setAttribute("data-tour", key); + el.setAttribute("href", `/${key}`); + document.body.appendChild(el); + } +} + +describe("ProductTour", () => { + beforeEach(() => { + window.localStorage.clear(); + mountTargets(); + }); + + afterEach(() => { + cleanup(); + document.body.innerHTML = ""; + }); + + it("auto-starts on first run and renders step one's title and body", () => { + render(); + expect(screen.getByText("Pipelines step")).toBeDefined(); + expect(screen.getByText("Build pipelines here.")).toBeDefined(); + // SR-only progress counter reflects the current step. + expect(screen.getByText("Step 1 of 3")).toBeDefined(); + expect(screen.getByRole("dialog")).toBeDefined(); + }); + + it("Next advances to the following step and Back returns", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Next" })); + expect(screen.getByText("Fleet step")).toBeDefined(); + expect(screen.queryByText("Pipelines step")).toBeNull(); + expect(screen.getByText("Step 2 of 3")).toBeDefined(); + + fireEvent.click(screen.getByRole("button", { name: "Back" })); + expect(screen.getByText("Pipelines step")).toBeDefined(); + expect(screen.getByText("Step 1 of 3")).toBeDefined(); + }); + + it("Skip persists the dismissal flag and unmounts the tour", () => { + const { container } = render(); + fireEvent.click(screen.getByRole("button", { name: "Skip tour" })); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + expect(window.localStorage.getItem(DISMISS_KEY)).toBe("1"); + }); + + it("Finish on the last step persists the flag and unmounts", () => { + const { container } = render(); + fireEvent.click(screen.getByRole("button", { name: "Next" })); // → step 2 + fireEvent.click(screen.getByRole("button", { name: "Next" })); // → step 3 (last) + expect(screen.getByText("Ghost step")).toBeDefined(); + + fireEvent.click(screen.getByRole("button", { name: "Finish" })); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + expect(window.localStorage.getItem(DISMISS_KEY)).toBe("1"); + }); + + it("Escape dismisses the tour and persists the flag", () => { + const { container } = render(); + act(() => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + }); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + expect(window.localStorage.getItem(DISMISS_KEY)).toBe("1"); + }); + + it("does not auto-start once dismissed, but startProductTour reopens it", () => { + window.localStorage.setItem(DISMISS_KEY, "1"); + const { container } = render(); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + + // Explicit restart clears the one-time flag and reopens at step one. + act(() => startProductTour()); + expect(screen.getByText("Pipelines step")).toBeDefined(); + expect(window.localStorage.getItem(DISMISS_KEY)).toBeNull(); + }); + + it("renders safely when a step's target selector matches nothing", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + // The ghost step's target is absent — the card still renders (anchored + // safely) rather than crashing or vanishing. + expect(screen.getByText("Ghost step")).toBeDefined(); + expect(screen.getByText("This target is absent.")).toBeDefined(); + expect(screen.getByRole("dialog")).toBeDefined(); + }); + + it("never auto-starts when none of the step targets exist on the page", () => { + document.body.innerHTML = ""; // remove the mounted targets + const { container } = render(); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + }); +}); diff --git a/src/components/onboarding/create-demo-pipeline-button.tsx b/src/components/onboarding/create-demo-pipeline-button.tsx index 877f9d16..4beb65b4 100644 --- a/src/components/onboarding/create-demo-pipeline-button.tsx +++ b/src/components/onboarding/create-demo-pipeline-button.tsx @@ -6,6 +6,7 @@ import { toast } from "sonner"; import { Loader2, Sparkles } from "lucide-react"; import { useTRPC } from "@/trpc/client"; import { Button } from "@/components/ui/button"; +import { TOUR_DEMO_PIPELINE_ATTR } from "@/components/onboarding/product-tour"; interface CreateDemoPipelineButtonProps { /** Environment the demo pipeline is created in. */ @@ -60,6 +61,7 @@ export function CreateDemoPipelineButton({ onClick={handleClick} disabled={createDemo.isPending || !environmentId} className={className} + data-tour={TOUR_DEMO_PIPELINE_ATTR} > {createDemo.isPending ? ( <> diff --git a/src/components/onboarding/onboarding-checklist.tsx b/src/components/onboarding/onboarding-checklist.tsx index 6a2f1145..f9cc5d75 100644 --- a/src/components/onboarding/onboarding-checklist.tsx +++ b/src/components/onboarding/onboarding-checklist.tsx @@ -21,6 +21,7 @@ import { useSyncExternalStore } from "react"; import Link from "next/link"; import { Button } from "@/components/ui/button"; +import { startProductTour } from "@/components/onboarding/product-tour"; import { cn } from "@/lib/utils"; const DISMISS_KEY = "vf:onboarding-dismissed"; @@ -147,16 +148,26 @@ export function OnboardingChecklist({ {completedCount} of {steps.length} steps complete

- {dismissible ? ( +
- ) : null} + {dismissible ? ( + + ) : null} +
    diff --git a/src/components/onboarding/product-tour.tsx b/src/components/onboarding/product-tour.tsx new file mode 100644 index 00000000..b41c5748 --- /dev/null +++ b/src/components/onboarding/product-tour.tsx @@ -0,0 +1,299 @@ +"use client"; + +/** + * Lightweight, dependency-free first-run product tour. + * + * Walks a brand-new user through the core surfaces (pipelines, the demo-pipeline + * action, the agent fleet, alerts) with a small coachmark card anchored next to + * real on-screen elements via `getBoundingClientRect`. Deliberately self-contained + * — no tour library, no Radix portal/observer machinery — so it stays trivially + * testable in jsdom and adds zero dependencies. + * + * Behaviour: + * - Auto-starts once per browser; completion/skip is persisted in localStorage + * (`vf:tour-dismissed`, matching the onboarding-checklist convention) so it + * never nags twice. + * - Re-startable from anywhere via `startProductTour()` (the onboarding + * checklist's "Take a tour" action), which clears the dismissal first. + * - Each step targets a CSS selector; a missing target is handled gracefully + * (the card anchors to a safe centered position rather than crashing), and + * auto-start is suppressed entirely when none of the targets exist. + * - Accessible: a focusable role="dialog" card, Esc to dismiss, labelled + * controls, and a screen-reader step counter. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export interface TourStep { + /** Short heading shown at the top of the coachmark. */ + title: string; + /** One- or two-sentence explanation of the surface. */ + body: string; + /** CSS selector for the on-screen element this step points at. */ + targetSelector: string; +} + +const TOUR_DISMISSED_KEY = "vf:tour-dismissed"; +const TOUR_START_EVENT = "vf:tour-start"; + +/** Selector marking the dashboard's "Create a demo pipeline" action. */ +export const TOUR_DEMO_PIPELINE_ATTR = "create-demo-pipeline"; + +/** + * The default ~4-step tour of VectorFlow's core surfaces. Steps anchor to the + * persistent sidebar nav (always present on dashboard routes) plus the + * empty-dashboard demo action (gracefully skipped when absent). + */ +export const DEFAULT_TOUR_STEPS: TourStep[] = [ + { + title: "Build your pipelines", + body: "Wire sources → transforms → sinks on the canvas. Every pipeline you create lives under Pipelines.", + targetSelector: 'a[href="/pipelines"]', + }, + { + title: "Start from a demo", + body: "No agent yet? Spin up a complete sample pipeline in one click and explore the editor right away.", + targetSelector: `[data-tour="${TOUR_DEMO_PIPELINE_ATTR}"]`, + }, + { + title: "Manage your fleet", + body: "Enroll vf-agents and watch their health, throughput, and assigned pipelines from Fleet.", + targetSelector: 'a[href="/fleet"]', + }, + { + title: "Stay ahead with alerts", + body: "Define alert rules on throughput and errors so you hear about problems before your users do.", + targetSelector: 'a[href="/alerts"]', + }, +]; + +function readDismissed(): boolean { + try { + return typeof window !== "undefined" && window.localStorage?.getItem(TOUR_DISMISSED_KEY) === "1"; + } catch { + // Storage unavailable (private mode / disabled) — treat as not dismissed. + return false; + } +} + +function hasAnyTarget(steps: TourStep[]): boolean { + if (typeof document === "undefined") return false; + return steps.some((step) => document.querySelector(step.targetSelector) !== null); +} + +/** + * (Re)start the product tour from anywhere on the client (e.g. the onboarding + * checklist's "Take a tour" action). Clears the one-time dismissal so an + * explicit restart always reopens the tour at step one. + */ +export function startProductTour(): void { + if (typeof window === "undefined") return; + try { + window.localStorage?.removeItem(TOUR_DISMISSED_KEY); + } catch { + // Ignore — the start event below still fires. + } + window.dispatchEvent(new Event(TOUR_START_EVENT)); +} + +interface TargetRect { + top: number; + left: number; + width: number; + height: number; +} + +interface Coords { + top: number; + left: number; + /** The anchored target's rect when found — drives the highlight ring. */ + rect: TargetRect | null; +} + +const CARD_WIDTH = 320; +const CARD_OFFSET = 12; +const CARD_EST_HEIGHT = 180; + +/** Position the card beside the target, clamped to the viewport. */ +function computeCoords(target: Element | null): Coords { + const vw = typeof window !== "undefined" ? window.innerWidth : 1024; + const vh = typeof window !== "undefined" ? window.innerHeight : 768; + + // Missing target (absent selector or not-yet-mounted): anchor safely so the + // step content is still reachable instead of crashing or vanishing. + if (!target) { + return { + top: Math.max(CARD_OFFSET, Math.round(vh * 0.18)), + left: Math.max(CARD_OFFSET, Math.round((vw - CARD_WIDTH) / 2)), + rect: null, + }; + } + + const r = target.getBoundingClientRect(); + // Prefer the target's right edge (sidebar nav); flip to the left if it would + // overflow, then fall back to centered. + let left = r.right + CARD_OFFSET; + if (left + CARD_WIDTH > vw - CARD_OFFSET) left = r.left - CARD_WIDTH - CARD_OFFSET; + if (left < CARD_OFFSET) left = Math.max(CARD_OFFSET, Math.round((vw - CARD_WIDTH) / 2)); + + let top = r.top; + if (top + CARD_EST_HEIGHT > vh - CARD_OFFSET) top = vh - CARD_EST_HEIGHT - CARD_OFFSET; + if (top < CARD_OFFSET) top = CARD_OFFSET; + + return { + top, + left, + rect: { top: r.top, left: r.left, width: r.width, height: r.height }, + }; +} + +export interface ProductTourProps { + /** Steps to walk through. Defaults to the core-surfaces tour. */ + steps?: TourStep[]; + /** Auto-open once per browser when not yet dismissed. Defaults to true. */ + autoStart?: boolean; +} + +export function ProductTour({ steps = DEFAULT_TOUR_STEPS, autoStart = true }: ProductTourProps) { + const [active, setActive] = useState(false); + const [index, setIndex] = useState(0); + const [coords, setCoords] = useState({ top: 0, left: 0, rect: null }); + const cardRef = useRef(null); + + const step = steps[index]; + const isFirst = index === 0; + const isLast = index === steps.length - 1; + + const close = useCallback((persist: boolean) => { + if (persist) { + try { + window.localStorage?.setItem(TOUR_DISMISSED_KEY, "1"); + } catch { + // Storage write blocked — dismissal is best-effort for this session. + } + } + setActive(false); + setIndex(0); + }, []); + + // Auto-start once per browser, and always respond to an explicit restart. + useEffect(() => { + const start = () => { + setIndex(0); + setActive(true); + }; + if (autoStart && !readDismissed() && hasAnyTarget(steps)) start(); + window.addEventListener(TOUR_START_EVENT, start); + return () => window.removeEventListener(TOUR_START_EVENT, start); + }, [autoStart, steps]); + + // Position the card against the current step's target; track resize/scroll. + useEffect(() => { + if (!active || !step) return; + const reposition = () => setCoords(computeCoords(document.querySelector(step.targetSelector))); + reposition(); + window.addEventListener("resize", reposition); + window.addEventListener("scroll", reposition, true); + return () => { + window.removeEventListener("resize", reposition); + window.removeEventListener("scroll", reposition, true); + }; + }, [active, step]); + + // Move focus to the card on open and on each step change (announces the new + // step to assistive tech, since the card is the labelled dialog). + useEffect(() => { + if (active) cardRef.current?.focus(); + }, [active, index]); + + // Esc dismisses the tour (and persists the one-time flag). + useEffect(() => { + if (!active) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") close(true); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [active, close]); + + if (!active || !step) return null; + + const titleId = "vf-product-tour-title"; + const bodyId = "vf-product-tour-body"; + return ( + <> + {coords.rect ? ( +