Skip to content

Commit 6a73580

Browse files
committed
feat: Onboarding Pill
1 parent dd64845 commit 6a73580

5 files changed

Lines changed: 153 additions & 7 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { cleanup, render, screen } from "@testing-library/react";
2+
import type { ReactNode } from "react";
3+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4+
5+
import { OnboardingNavPill } from "./OnboardingNavPill";
6+
7+
vi.mock("@tanstack/react-router", () => ({
8+
Link: ({ children }: { children: ReactNode }) => <a>{children}</a>,
9+
}));
10+
11+
let onboarding = {
12+
steps: [],
13+
completedCount: 1,
14+
total: 4,
15+
isComplete: false,
16+
dismissed: false,
17+
isResolved: true,
18+
markDocsRead: vi.fn(),
19+
};
20+
vi.mock("@/providers/OnboardingProvider/OnboardingProvider", () => ({
21+
useOnboarding: () => onboarding,
22+
}));
23+
24+
function resetState() {
25+
onboarding = {
26+
steps: [],
27+
completedCount: 1,
28+
total: 4,
29+
isComplete: false,
30+
dismissed: false,
31+
isResolved: true,
32+
markDocsRead: vi.fn(),
33+
};
34+
}
35+
36+
beforeEach(resetState);
37+
afterEach(cleanup);
38+
39+
const pill = () => screen.queryByText(/Onboarding/);
40+
41+
describe("OnboardingNavPill", () => {
42+
it("shows progress while onboarding is in progress", () => {
43+
render(<OnboardingNavPill />);
44+
expect(pill()).toHaveTextContent("Onboarding · 1/4");
45+
});
46+
47+
it("is hidden once onboarding is complete", () => {
48+
onboarding.isComplete = true;
49+
render(<OnboardingNavPill />);
50+
expect(pill()).toBeNull();
51+
});
52+
53+
it("is hidden once onboarding is dismissed", () => {
54+
onboarding.dismissed = true;
55+
render(<OnboardingNavPill />);
56+
expect(pill()).toBeNull();
57+
});
58+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { OnboardingChecklist } from "@/components/Onboarding/OnboardingChecklist";
2+
import { Button } from "@/components/ui/button";
3+
import { Icon } from "@/components/ui/icon";
4+
import { BlockStack } from "@/components/ui/layout";
5+
import {
6+
Popover,
7+
PopoverContent,
8+
PopoverTrigger,
9+
} from "@/components/ui/popover";
10+
import { Heading } from "@/components/ui/typography";
11+
import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider";
12+
import { tracking } from "@/utils/tracking";
13+
14+
export function OnboardingNavPill() {
15+
const { completedCount, total, isComplete, dismissed, isResolved } =
16+
useOnboarding();
17+
18+
if (!isResolved || isComplete || dismissed) {
19+
return null;
20+
}
21+
22+
return (
23+
<Popover>
24+
<PopoverTrigger asChild {...tracking("navigation.onboarding_pill")}>
25+
<Button
26+
variant="ghost"
27+
size="sm"
28+
className="h-8 gap-1.5 rounded-full bg-stone-700 px-3 text-xs font-semibold text-white hover:bg-stone-600 hover:text-white"
29+
>
30+
<Icon name="Rocket" size="sm" aria-hidden="true" />
31+
Onboarding · {completedCount}/{total}
32+
</Button>
33+
</PopoverTrigger>
34+
<PopoverContent align="end" className="w-96">
35+
<BlockStack gap="3">
36+
<Heading level={3}>Get started with Tangle</Heading>
37+
<OnboardingChecklist />
38+
</BlockStack>
39+
</PopoverContent>
40+
</Popover>
41+
);
42+
}

src/components/layout/AppMenu.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
import { useState } from "react";
77

88
import logo from "/Tangle_white.png";
9+
import { OnboardingNavPill } from "@/components/Onboarding/OnboardingNavPill";
910
import { isAuthorizationRequired } from "@/components/shared/Authentication/helpers";
1011
import { TopBarAuthentication } from "@/components/shared/Authentication/TopBarAuthentication";
1112
import { CopyText } from "@/components/shared/CopyText/CopyText";
@@ -110,6 +111,8 @@ const DefaultAppMenu = () => {
110111
<div className="w-px h-5 bg-stone-700" />
111112
</div>
112113

114+
<OnboardingNavPill />
115+
113116
<EditorVersionToggle />
114117

115118
{/* Settings & status */}

src/providers/OnboardingProvider/OnboardingProvider.tsx

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { useQuery } from "@tanstack/react-query";
2-
import { type ReactNode, useEffect, useState } from "react";
2+
import { type ReactNode, useEffect, useRef, useState } from "react";
33

44
import type { ListPipelineJobsResponse } from "@/api/types.gen";
55
import { useDocsVisitTracking } from "@/hooks/useDocsVisitTracking";
66
import {
77
createRequiredContext,
88
useRequiredContext,
99
} from "@/hooks/useRequiredContext";
10+
import useToastNotification from "@/hooks/useToastNotification";
1011
import { useAnalytics } from "@/providers/AnalyticsProvider";
1112
import { useBackend } from "@/providers/BackendProvider";
1213
import { useTourCompletions } from "@/providers/TourProvider/tourCompletion";
@@ -41,6 +42,8 @@ interface OnboardingContextValue {
4142
total: number;
4243
isComplete: boolean;
4344
dismissed: boolean;
45+
isReady: boolean;
46+
isResolved: boolean;
4447
markDocsRead: () => void;
4548
dismiss: () => void;
4649
reopen: () => void;
@@ -49,11 +52,14 @@ interface OnboardingContextValue {
4952
const OnboardingContext =
5053
createRequiredContext<OnboardingContextValue>("OnboardingProvider");
5154

52-
function useHasMyRun(): boolean {
55+
function useHasMyRun(): {
56+
hasRun: boolean;
57+
isLoading: boolean;
58+
} {
5359
const { available, backendUrl } = useBackend();
5460
const filterQuery = filtersToFilterQuery(parseFilterParam("created_by:me"));
5561

56-
const { data } = useQuery({
62+
const { data, isLoading } = useQuery({
5763
queryKey: ["onboarding", "myRunCount", backendUrl],
5864
enabled: available && Boolean(backendUrl),
5965
staleTime: STALE_MS,
@@ -67,19 +73,23 @@ function useHasMyRun(): boolean {
6773
return payload.pipeline_runs?.length ?? 0;
6874
},
6975
});
70-
return (data ?? 0) > 0;
76+
return { hasRun: (data ?? 0) > 0, isLoading };
7177
}
7278

7379
export function OnboardingProvider({ children }: { children: ReactNode }) {
7480
const { track } = useAnalytics();
75-
const { data: progress } = useOnboardingProgress();
81+
const notify = useToastNotification();
82+
const { ready: backendReady, configured } = useBackend();
83+
const { data: progress, isLoading: progressLoading } =
84+
useOnboardingProgress();
7685
const persist = usePersistOnboardingProgress();
7786

78-
const { data: tourCompletions } = useTourCompletions();
87+
const { data: tourCompletions, isLoading: toursLoading } =
88+
useTourCompletions();
7989
const hasCompletedTour = Boolean(
8090
tourCompletions && Object.keys(tourCompletions).length > 0,
8191
);
82-
const hasMyRun = useHasMyRun();
92+
const { hasRun: hasMyRun, isLoading: runsLoading } = useHasMyRun();
8393

8494
const stored = progress?.steps;
8595
const desiredSteps: OnboardingSteps = {
@@ -90,6 +100,8 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
90100
};
91101

92102
const isComplete = ONBOARDING_STEP_IDS.every((id) => desiredSteps[id]);
103+
const isReady = !progressLoading && !toursLoading && !runsLoading;
104+
const isResolved = (backendReady || !configured) && isReady;
93105

94106
const [pipelineWriteCount, setPipelineWriteCount] = useState(0);
95107

@@ -116,6 +128,32 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
116128
track("onboarding.step.completed", { step_id: "create_pipeline" });
117129
}, [pipelineWriteCount, progress, persist, track]);
118130

131+
const completedRef = useRef<Set<string> | null>(null);
132+
133+
useEffect(() => {
134+
if (!isResolved) return;
135+
const current = new Set(
136+
ONBOARDING_STEP_IDS.filter((id) => desiredSteps[id]),
137+
);
138+
const previous = completedRef.current;
139+
completedRef.current = current;
140+
if (previous === null) return;
141+
142+
const newlyCompleted = ONBOARDING_STEP_IDS.filter(
143+
(id) => current.has(id) && !previous.has(id),
144+
);
145+
if (newlyCompleted.length === 0) return;
146+
147+
if (isComplete) {
148+
notify("You're all set up - onboarding complete!", "success");
149+
return;
150+
}
151+
for (const id of newlyCompleted) {
152+
const label = ONBOARDING_STEPS.find((step) => step.id === id)?.label;
153+
if (label) notify(`Completed: ${label}`, "success");
154+
}
155+
}, [isResolved, desiredSteps, isComplete, notify]);
156+
119157
const markDocsRead = () => {
120158
if (!progress || progress.steps.read_docs) return;
121159
persist({ ...progress, steps: { ...progress.steps, read_docs: true } });
@@ -128,6 +166,7 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
128166
if (!progress || progress.dismissed) return;
129167
persist({ ...progress, dismissed: true });
130168
track("onboarding.dismissed");
169+
notify("You can resume onboarding from the Learning Hub", "info");
131170
};
132171

133172
const reopen = () => {
@@ -147,6 +186,8 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
147186
total: steps.length,
148187
isComplete,
149188
dismissed: progress?.dismissed ?? false,
189+
isReady,
190+
isResolved,
150191
markDocsRead,
151192
dismiss,
152193
reopen,

src/routes/v2/shared/components/AppMenuActions.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Link as RouterLink } from "@tanstack/react-router";
22

3+
import { OnboardingNavPill } from "@/components/Onboarding/OnboardingNavPill";
34
import { isAuthorizationRequired } from "@/components/shared/Authentication/helpers";
45
import { TopBarAuthentication } from "@/components/shared/Authentication/TopBarAuthentication";
56
import TooltipButton from "@/components/shared/Buttons/TooltipButton";
@@ -23,6 +24,7 @@ export function AppMenuActions() {
2324
className="shrink-0"
2425
data-testid="app-menu-actions"
2526
>
27+
{!tourMode && <OnboardingNavPill />}
2628
<EditorVersionToggle />
2729
{tourMode ? (
2830
<TooltipButton

0 commit comments

Comments
 (0)