Skip to content

Commit c296a9f

Browse files
committed
feat: Onboarding Pill
1 parent cd2d469 commit c296a9f

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";
@@ -42,6 +43,8 @@ interface OnboardingContextValue {
4243
total: number;
4344
isComplete: boolean;
4445
dismissed: boolean;
46+
isReady: boolean;
47+
isResolved: boolean;
4548
markDocsRead: () => void;
4649
dismiss: () => void;
4750
reopen: () => void;
@@ -50,11 +53,14 @@ interface OnboardingContextValue {
5053
const OnboardingContext =
5154
createRequiredContext<OnboardingContextValue>("OnboardingProvider");
5255

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

57-
const { data } = useQuery({
63+
const { data, isLoading } = useQuery({
5864
queryKey: [...ONBOARDING_MY_RUN_COUNT_KEY, backendUrl],
5965
enabled: available && Boolean(backendUrl),
6066
staleTime: STALE_MS,
@@ -68,19 +74,23 @@ function useHasMyRun(): boolean {
6874
return payload.pipeline_runs?.length ?? 0;
6975
},
7076
});
71-
return (data ?? 0) > 0;
77+
return { hasRun: (data ?? 0) > 0, isLoading };
7278
}
7379

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

79-
const { data: tourCompletions } = useTourCompletions();
88+
const { data: tourCompletions, isLoading: toursLoading } =
89+
useTourCompletions();
8090
const hasCompletedTour = Boolean(
8191
tourCompletions && Object.keys(tourCompletions).length > 0,
8292
);
83-
const hasMyRun = useHasMyRun();
93+
const { hasRun: hasMyRun, isLoading: runsLoading } = useHasMyRun();
8494

8595
const stored = progress?.steps;
8696
const desiredSteps: OnboardingSteps = {
@@ -91,6 +101,8 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
91101
};
92102

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

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

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

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

134173
const reopen = () => {
@@ -148,6 +187,8 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
148187
total: steps.length,
149188
isComplete,
150189
dismissed: progress?.dismissed ?? false,
190+
isReady,
191+
isResolved,
151192
markDocsRead,
152193
dismiss,
153194
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)