Skip to content

Commit 4df288c

Browse files
committed
feat: Onboarding Welcome Page
1 parent 50a2381 commit 4df288c

7 files changed

Lines changed: 161 additions & 24 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { cleanup, render, screen } from "@testing-library/react";
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
4+
import { IndexRedirect } from "./IndexRedirect";
5+
6+
vi.mock("@tanstack/react-router", () => ({
7+
Navigate: ({ to }: { to: string }) => <div data-testid="navigate">{to}</div>,
8+
}));
9+
10+
let onboarding = { isReady: true, isComplete: false, dismissed: false };
11+
vi.mock("@/providers/OnboardingProvider/OnboardingProvider", () => ({
12+
useOnboarding: () => onboarding,
13+
}));
14+
15+
afterEach(cleanup);
16+
17+
const target = () => screen.queryByTestId("navigate");
18+
19+
describe("IndexRedirect", () => {
20+
it("waits (no redirect) until onboarding state is ready", () => {
21+
onboarding = { isReady: false, isComplete: false, dismissed: false };
22+
render(<IndexRedirect />);
23+
expect(target()).toBeNull();
24+
});
25+
26+
it("redirects to /welcome while onboarding is active", () => {
27+
onboarding = { isReady: true, isComplete: false, dismissed: false };
28+
render(<IndexRedirect />);
29+
expect(target()).toHaveTextContent("/welcome");
30+
});
31+
32+
it("redirects to /dashboard once complete", () => {
33+
onboarding = { isReady: true, isComplete: true, dismissed: false };
34+
render(<IndexRedirect />);
35+
expect(target()).toHaveTextContent("/dashboard");
36+
});
37+
38+
it("redirects to /dashboard once dismissed", () => {
39+
onboarding = { isReady: true, isComplete: false, dismissed: true };
40+
render(<IndexRedirect />);
41+
expect(target()).toHaveTextContent("/dashboard");
42+
});
43+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { Navigate } from "@tanstack/react-router";
2+
3+
import { BlockStack } from "@/components/ui/layout";
4+
import { Spinner } from "@/components/ui/spinner";
5+
import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider";
6+
import { APP_ROUTES } from "@/routes/appRoutes";
7+
8+
export function IndexRedirect() {
9+
const { isReady, isComplete, dismissed } = useOnboarding();
10+
11+
if (!isReady) {
12+
return (
13+
<BlockStack align="center" inlineAlign="center" className="h-full">
14+
<Spinner />
15+
</BlockStack>
16+
);
17+
}
18+
19+
const showOnboarding = !isComplete && !dismissed;
20+
return (
21+
<Navigate
22+
replace
23+
to={showOnboarding ? APP_ROUTES.WELCOME : APP_ROUTES.DASHBOARD}
24+
/>
25+
);
26+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Link, Navigate } from "@tanstack/react-router";
2+
3+
import { OnboardingHero } from "@/components/Learn/OnboardingHero";
4+
import { BlockStack } from "@/components/ui/layout";
5+
import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider";
6+
import { APP_ROUTES } from "@/routes/router";
7+
import { tracking } from "@/utils/tracking";
8+
9+
export function OnboardingWelcome() {
10+
const { isReady, isComplete, dismissed } = useOnboarding();
11+
12+
if (isReady && (isComplete || dismissed)) {
13+
return <Navigate to={APP_ROUTES.DASHBOARD} replace />;
14+
}
15+
16+
return (
17+
<BlockStack
18+
gap="4"
19+
align="center"
20+
inlineAlign="center"
21+
className="h-full w-full"
22+
>
23+
<div className="w-full max-w-2xl">
24+
<OnboardingHero />
25+
</div>
26+
<Link
27+
to={APP_ROUTES.LEARN}
28+
className="text-sm text-muted-foreground hover:text-foreground"
29+
{...tracking("homepage.onboarding.learning_hub")}
30+
>
31+
Explore the Learning Hub →
32+
</Link>
33+
</BlockStack>
34+
);
35+
}

src/routes/Dashboard/DashboardLayout.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { BlockStack, InlineStack } from "@/components/ui/layout";
99
import { Link as UILink } from "@/components/ui/link";
1010
import { Text } from "@/components/ui/typography";
1111
import { cn } from "@/lib/utils";
12+
import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider";
1213
import { APP_ROUTES } from "@/routes/appRoutes";
1314
import {
1415
ABOUT_URL,
@@ -28,7 +29,12 @@ interface SidebarItem {
2829
}
2930

3031
const BASE_SIDEBAR_ITEMS: SidebarItem[] = [
31-
{ to: "/", label: "My Dashboard", icon: "LayoutDashboard", exact: true },
32+
{
33+
to: APP_ROUTES.DASHBOARD,
34+
label: "My Dashboard",
35+
icon: "LayoutDashboard",
36+
exact: true,
37+
},
3238
{ to: "/pipelines", label: "My Pipelines", icon: "GitBranch" },
3339
{ to: "/runs", label: "All Runs", icon: "Play" },
3440
{ to: "/components", label: "Components", icon: "Package" },
@@ -53,14 +59,29 @@ export function DashboardLayout() {
5359
const requiresAuthorization = isAuthorizationRequired();
5460
const isComponentSearchEnabled = useFlagValue("component-search-v2");
5561

56-
const sidebarItems = isComponentSearchEnabled
62+
const { isComplete, dismissed, isResolved } = useOnboarding();
63+
const showOnboarding = isResolved && !isComplete && !dismissed;
64+
65+
const baseItems = isComponentSearchEnabled
5766
? BASE_SIDEBAR_ITEMS.map((item) =>
5867
item.to === APP_ROUTES.DASHBOARD_COMPONENTS
5968
? COMPONENT_SEARCH_ITEM
6069
: item,
6170
)
6271
: BASE_SIDEBAR_ITEMS;
6372

73+
const sidebarItems: SidebarItem[] = showOnboarding
74+
? [
75+
{
76+
to: APP_ROUTES.WELCOME,
77+
label: "Get started",
78+
icon: "Rocket",
79+
exact: true,
80+
},
81+
...baseItems,
82+
]
83+
: baseItems;
84+
6485
return (
6586
<div
6687
className="flex w-full overflow-hidden"

src/routes/appRoutes.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ const TOUR_BASE_PATH = "/tour";
99

1010
export const APP_ROUTES = {
1111
HOME: "/",
12-
DASHBOARD: "/",
12+
DASHBOARD: "/dashboard",
13+
WELCOME: "/welcome",
1314
DASHBOARD_RUNS: "/runs",
1415
DASHBOARD_PIPELINES: "/pipelines",
1516
DASHBOARD_COMPONENTS: "/components",

src/routes/router.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
redirect,
88
} from "@tanstack/react-router";
99

10+
import { IndexRedirect } from "@/components/Onboarding/IndexRedirect";
11+
import { OnboardingWelcome } from "@/components/Onboarding/OnboardingWelcome";
1012
import { ErrorPage } from "@/components/shared/ErrorPage";
1113
import { AuthorizationResultScreen as GitHubAuthorizationResultScreen } from "@/components/shared/GitHubAuth/AuthorizationResultScreen";
1214
import { AuthorizationResultScreen as HuggingFaceAuthorizationResultScreen } from "@/components/shared/HuggingFaceAuth/AuthorizationResultScreen";
@@ -79,9 +81,21 @@ const dashboardRoute = createRoute({
7981
const dashboardIndexRoute = createRoute({
8082
getParentRoute: () => dashboardRoute,
8183
path: "/",
84+
component: IndexRedirect,
85+
});
86+
87+
const dashboardHomeRoute = createRoute({
88+
getParentRoute: () => dashboardRoute,
89+
path: APP_ROUTES.DASHBOARD,
8290
component: DashboardHomeView,
8391
});
8492

93+
const welcomeRoute = createRoute({
94+
getParentRoute: () => dashboardRoute,
95+
path: APP_ROUTES.WELCOME,
96+
component: OnboardingWelcome,
97+
});
98+
8599
const dashboardRunsRoute = createRoute({
86100
getParentRoute: () => dashboardRoute,
87101
path: "/runs",
@@ -329,6 +343,8 @@ const artifactPreviewRoute = createRoute({
329343

330344
const dashboardRouteTree = dashboardRoute.addChildren([
331345
dashboardIndexRoute,
346+
dashboardHomeRoute,
347+
welcomeRoute,
332348
dashboardRunsRoute,
333349
dashboardPipelinesRoute,
334350
dashboardComponentsRoute,

tests/e2e/navigation-tracking.spec.ts

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,10 @@ test.describe("Navigation tracking", () => {
55
page,
66
}) => {
77
await page.addInitScript(() => {
8-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
98
(window as any).__analyticsEvents = [];
109
window.addEventListener("tangle.analytics.track", (e) => {
1110
const detail = (e as CustomEvent).detail;
1211
if (detail.actionType === "page_view") {
13-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1412
(window as any).__analyticsEvents.push(detail);
1513
}
1614
});
@@ -24,39 +22,39 @@ test.describe("Navigation tracking", () => {
2422
await page.getByRole("link", { name: "Settings" }).click();
2523
await expect(page).toHaveURL(/\/settings/);
2624

27-
const events = await page.evaluate(
28-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
29-
() => (window as any).__analyticsEvents,
30-
);
25+
const events = await page.evaluate(() => (window as any).__analyticsEvents);
3126

3227
expect(events.length).toBeGreaterThanOrEqual(2);
3328

34-
const [initial, navigation] = events;
35-
36-
// First page_view from landing on /
37-
expect(initial.actionType).toBe("page_view");
38-
expect(initial.metadata).toMatchObject({
39-
to: "/",
29+
const landing = events.find(
30+
(e: { metadata?: { to?: string } }) =>
31+
e.metadata?.to === "/welcome" || e.metadata?.to === "/dashboard",
32+
);
33+
expect(landing).toBeTruthy();
34+
expect(landing.metadata).toMatchObject({
35+
to: expect.stringMatching(/^\/(welcome|dashboard)$/),
4036
route_pattern: expect.any(String),
4137
});
4238

43-
// Second page_view from navigating to settings
44-
expect(navigation.actionType).toBe("page_view");
39+
// Navigating to settings emits a page_view.
40+
const navigation = events.find(
41+
(e: { metadata?: { to?: string } }) =>
42+
typeof e.metadata?.to === "string" &&
43+
e.metadata.to.includes("/settings"),
44+
);
45+
expect(navigation).toBeTruthy();
4546
expect(navigation.metadata).toMatchObject({
46-
from: "/",
4747
to: expect.stringContaining("/settings"),
4848
route_pattern: expect.any(String),
4949
});
5050
});
5151

5252
test("captures search params in page_view metadata", async ({ page }) => {
5353
await page.addInitScript(() => {
54-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
5554
(window as any).__analyticsEvents = [];
5655
window.addEventListener("tangle.analytics.track", (e) => {
5756
const detail = (e as CustomEvent).detail;
5857
if (detail.actionType === "page_view") {
59-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
6058
(window as any).__analyticsEvents.push(detail);
6159
}
6260
});
@@ -67,10 +65,7 @@ test.describe("Navigation tracking", () => {
6765
page.locator("[data-testid='app-menu-actions']"),
6866
).toBeVisible();
6967

70-
const events = await page.evaluate(
71-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
72-
() => (window as any).__analyticsEvents,
73-
);
68+
const events = await page.evaluate(() => (window as any).__analyticsEvents);
7469

7570
expect(events.length).toBeGreaterThanOrEqual(1);
7671

0 commit comments

Comments
 (0)