diff --git a/src/components/Onboarding/IndexRedirect.test.tsx b/src/components/Onboarding/IndexRedirect.test.tsx new file mode 100644 index 000000000..b39a726b9 --- /dev/null +++ b/src/components/Onboarding/IndexRedirect.test.tsx @@ -0,0 +1,43 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { IndexRedirect } from "./IndexRedirect"; + +vi.mock("@tanstack/react-router", () => ({ + Navigate: ({ to }: { to: string }) =>
{to}
, +})); + +let onboarding = { isReady: true, isComplete: false, dismissed: false }; +vi.mock("@/providers/OnboardingProvider/OnboardingProvider", () => ({ + useOnboarding: () => onboarding, +})); + +afterEach(cleanup); + +const target = () => screen.queryByTestId("navigate"); + +describe("IndexRedirect", () => { + it("waits (no redirect) until onboarding state is ready", () => { + onboarding = { isReady: false, isComplete: false, dismissed: false }; + render(); + expect(target()).toBeNull(); + }); + + it("redirects to /welcome while onboarding is active", () => { + onboarding = { isReady: true, isComplete: false, dismissed: false }; + render(); + expect(target()).toHaveTextContent("/welcome"); + }); + + it("redirects to /dashboard once complete", () => { + onboarding = { isReady: true, isComplete: true, dismissed: false }; + render(); + expect(target()).toHaveTextContent("/dashboard"); + }); + + it("redirects to /dashboard once dismissed", () => { + onboarding = { isReady: true, isComplete: false, dismissed: true }; + render(); + expect(target()).toHaveTextContent("/dashboard"); + }); +}); diff --git a/src/components/Onboarding/IndexRedirect.tsx b/src/components/Onboarding/IndexRedirect.tsx new file mode 100644 index 000000000..1923b0df5 --- /dev/null +++ b/src/components/Onboarding/IndexRedirect.tsx @@ -0,0 +1,26 @@ +import { Navigate } from "@tanstack/react-router"; + +import { BlockStack } from "@/components/ui/layout"; +import { Spinner } from "@/components/ui/spinner"; +import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider"; +import { APP_ROUTES } from "@/routes/appRoutes"; + +export function IndexRedirect() { + const { isReady, isComplete, dismissed } = useOnboarding(); + + if (!isReady) { + return ( + + + + ); + } + + const showOnboarding = !isComplete && !dismissed; + return ( + + ); +} diff --git a/src/components/Onboarding/OnboardingWelcome.tsx b/src/components/Onboarding/OnboardingWelcome.tsx new file mode 100644 index 000000000..3eb0cc031 --- /dev/null +++ b/src/components/Onboarding/OnboardingWelcome.tsx @@ -0,0 +1,35 @@ +import { Link, Navigate } from "@tanstack/react-router"; + +import { OnboardingHero } from "@/components/Learn/OnboardingHero"; +import { BlockStack } from "@/components/ui/layout"; +import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider"; +import { APP_ROUTES } from "@/routes/router"; +import { tracking } from "@/utils/tracking"; + +export function OnboardingWelcome() { + const { isReady, isComplete, dismissed } = useOnboarding(); + + if (isReady && (isComplete || dismissed)) { + return ; + } + + return ( + +
+ +
+ + Explore the Learning Hub → + +
+ ); +} diff --git a/src/routes/Dashboard/DashboardLayout.tsx b/src/routes/Dashboard/DashboardLayout.tsx index 298b0d682..1a2ba75c6 100644 --- a/src/routes/Dashboard/DashboardLayout.tsx +++ b/src/routes/Dashboard/DashboardLayout.tsx @@ -9,6 +9,7 @@ import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Link as UILink } from "@/components/ui/link"; import { Text } from "@/components/ui/typography"; import { cn } from "@/lib/utils"; +import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider"; import { APP_ROUTES } from "@/routes/appRoutes"; import { ABOUT_URL, @@ -28,7 +29,12 @@ interface SidebarItem { } const BASE_SIDEBAR_ITEMS: SidebarItem[] = [ - { to: "/", label: "My Dashboard", icon: "LayoutDashboard", exact: true }, + { + to: APP_ROUTES.DASHBOARD, + label: "My Dashboard", + icon: "LayoutDashboard", + exact: true, + }, { to: "/pipelines", label: "My Pipelines", icon: "GitBranch" }, { to: "/runs", label: "All Runs", icon: "Play" }, { to: "/components", label: "Components", icon: "Package" }, @@ -53,7 +59,10 @@ export function DashboardLayout() { const requiresAuthorization = isAuthorizationRequired(); const isComponentSearchEnabled = useFlagValue("component-search-v2"); - const sidebarItems = isComponentSearchEnabled + const { isComplete, dismissed, isResolved } = useOnboarding(); + const showOnboarding = isResolved && !isComplete && !dismissed; + + const baseItems = isComponentSearchEnabled ? BASE_SIDEBAR_ITEMS.map((item) => item.to === APP_ROUTES.DASHBOARD_COMPONENTS ? COMPONENT_SEARCH_ITEM @@ -61,6 +70,18 @@ export function DashboardLayout() { ) : BASE_SIDEBAR_ITEMS; + const sidebarItems: SidebarItem[] = showOnboarding + ? [ + { + to: APP_ROUTES.WELCOME, + label: "Get started", + icon: "Rocket", + exact: true, + }, + ...baseItems, + ] + : baseItems; + return (
dashboardRoute, path: "/", + component: IndexRedirect, +}); + +const dashboardHomeRoute = createRoute({ + getParentRoute: () => dashboardRoute, + path: APP_ROUTES.DASHBOARD, component: DashboardHomeView, }); +const welcomeRoute = createRoute({ + getParentRoute: () => dashboardRoute, + path: APP_ROUTES.WELCOME, + component: OnboardingWelcome, +}); + const dashboardRunsRoute = createRoute({ getParentRoute: () => dashboardRoute, path: "/runs", @@ -329,6 +343,8 @@ const artifactPreviewRoute = createRoute({ const dashboardRouteTree = dashboardRoute.addChildren([ dashboardIndexRoute, + dashboardHomeRoute, + welcomeRoute, dashboardRunsRoute, dashboardPipelinesRoute, dashboardComponentsRoute, diff --git a/tests/e2e/navigation-tracking.spec.ts b/tests/e2e/navigation-tracking.spec.ts index 90dbb376e..adc2aa0a0 100644 --- a/tests/e2e/navigation-tracking.spec.ts +++ b/tests/e2e/navigation-tracking.spec.ts @@ -5,12 +5,10 @@ test.describe("Navigation tracking", () => { page, }) => { await page.addInitScript(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any (window as any).__analyticsEvents = []; window.addEventListener("tangle.analytics.track", (e) => { const detail = (e as CustomEvent).detail; if (detail.actionType === "page_view") { - // eslint-disable-next-line @typescript-eslint/no-explicit-any (window as any).__analyticsEvents.push(detail); } }); @@ -24,26 +22,28 @@ test.describe("Navigation tracking", () => { await page.getByRole("link", { name: "Settings" }).click(); await expect(page).toHaveURL(/\/settings/); - const events = await page.evaluate( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - () => (window as any).__analyticsEvents, - ); + const events = await page.evaluate(() => (window as any).__analyticsEvents); expect(events.length).toBeGreaterThanOrEqual(2); - const [initial, navigation] = events; - - // First page_view from landing on / - expect(initial.actionType).toBe("page_view"); - expect(initial.metadata).toMatchObject({ - to: "/", + const landing = events.find( + (e: { metadata?: { to?: string } }) => + e.metadata?.to === "/welcome" || e.metadata?.to === "/dashboard", + ); + expect(landing).toBeTruthy(); + expect(landing.metadata).toMatchObject({ + to: expect.stringMatching(/^\/(welcome|dashboard)$/), route_pattern: expect.any(String), }); - // Second page_view from navigating to settings - expect(navigation.actionType).toBe("page_view"); + // Navigating to settings emits a page_view. + const navigation = events.find( + (e: { metadata?: { to?: string } }) => + typeof e.metadata?.to === "string" && + e.metadata.to.includes("/settings"), + ); + expect(navigation).toBeTruthy(); expect(navigation.metadata).toMatchObject({ - from: "/", to: expect.stringContaining("/settings"), route_pattern: expect.any(String), }); @@ -51,12 +51,10 @@ test.describe("Navigation tracking", () => { test("captures search params in page_view metadata", async ({ page }) => { await page.addInitScript(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any (window as any).__analyticsEvents = []; window.addEventListener("tangle.analytics.track", (e) => { const detail = (e as CustomEvent).detail; if (detail.actionType === "page_view") { - // eslint-disable-next-line @typescript-eslint/no-explicit-any (window as any).__analyticsEvents.push(detail); } }); @@ -67,10 +65,7 @@ test.describe("Navigation tracking", () => { page.locator("[data-testid='app-menu-actions']"), ).toBeVisible(); - const events = await page.evaluate( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - () => (window as any).__analyticsEvents, - ); + const events = await page.evaluate(() => (window as any).__analyticsEvents); expect(events.length).toBeGreaterThanOrEqual(1);