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);