From fa0fbc4da16adc9e100cd285bbf3404dcfff2b85 Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Fri, 19 Jun 2026 08:49:45 -0700 Subject: [PATCH] feat: Onboarding Welcome Page --- .../Onboarding/IndexRedirect.test.tsx | 43 +++++++++++++++++++ src/components/Onboarding/IndexRedirect.tsx | 26 +++++++++++ .../Onboarding/OnboardingWelcome.tsx | 35 +++++++++++++++ src/routes/Dashboard/DashboardLayout.tsx | 25 ++++++++++- src/routes/appRoutes.ts | 3 +- src/routes/router.ts | 16 +++++++ tests/e2e/navigation-tracking.spec.ts | 22 ++++++---- 7 files changed, 159 insertions(+), 11 deletions(-) create mode 100644 src/components/Onboarding/IndexRedirect.test.tsx create mode 100644 src/components/Onboarding/IndexRedirect.tsx create mode 100644 src/components/Onboarding/OnboardingWelcome.tsx 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..1f7d672ce 100644 --- a/tests/e2e/navigation-tracking.spec.ts +++ b/tests/e2e/navigation-tracking.spec.ts @@ -31,19 +31,25 @@ test.describe("Navigation tracking", () => { 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({ + // Landing on / emits a page_view (/ then redirects to the welcome/dashboard + // route, which emits its own page_view — so we match by route, not position). + const landing = events.find( + (e: { metadata?: { to?: string } }) => e.metadata?.to === "/", + ); + expect(landing).toBeTruthy(); + expect(landing.metadata).toMatchObject({ to: "/", 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), });