Skip to content
Merged
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
2 changes: 2 additions & 0 deletions src/app/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import { AppSidebar } from "@/components/app-sidebar";
import { TeamSelector } from "@/components/team-selector";
import { EnvironmentSelector } from "@/components/environment-selector";
import { cn } from "@/lib/utils";

Check warning on line 17 in src/app/(dashboard)/layout.tsx

View workflow job for this annotation

GitHub Actions / Lint & Type Check

'cn' is defined but never used
import { ChangePasswordDialog } from "@/components/change-password-dialog";
import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button";
Expand All @@ -33,6 +33,7 @@
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";
Expand Down Expand Up @@ -311,6 +312,7 @@
<UpdateBanner />
<CommandPalette />
<KeyboardShortcutsModal />
<ProductTour />
<LazyMotionProvider>
<main id="main-content" className="flex-1 min-h-0 overflow-auto" tabIndex={-1}>
<ErrorBoundary>
Expand Down
113 changes: 113 additions & 0 deletions src/components/onboarding/__tests__/product-tour.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ProductTour steps={STEPS} />);
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(<ProductTour steps={STEPS} />);

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(<ProductTour steps={STEPS} />);
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(<ProductTour steps={STEPS} />);
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(<ProductTour steps={STEPS} />);
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(<ProductTour steps={STEPS} />);
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(<ProductTour steps={STEPS} />);
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(<ProductTour steps={STEPS} />);
expect(container.querySelector('[role="dialog"]')).toBeNull();
});
});
2 changes: 2 additions & 0 deletions src/components/onboarding/create-demo-pipeline-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -60,6 +61,7 @@ export function CreateDemoPipelineButton({
onClick={handleClick}
disabled={createDemo.isPending || !environmentId}
className={className}
data-tour={TOUR_DEMO_PIPELINE_ATTR}
>
{createDemo.isPending ? (
<>
Expand Down
21 changes: 16 additions & 5 deletions src/components/onboarding/onboarding-checklist.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -147,16 +148,26 @@ export function OnboardingChecklist({
{completedCount} of {steps.length} steps complete
</p>
</div>
{dismissible ? (
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={dismiss}
aria-label="Dismiss getting-started checklist"
onClick={startProductTour}
aria-label="Take a guided product tour"
>
Dismiss
Take a tour
</Button>
) : null}
{dismissible ? (
<Button
variant="ghost"
size="sm"
onClick={dismiss}
aria-label="Dismiss getting-started checklist"
>
Dismiss
</Button>
) : null}
</div>
</header>

<ol className="mt-4 space-y-3">
Expand Down
Loading
Loading