Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions src/components/Onboarding/OnboardingNavPill.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { cleanup, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { OnboardingNavPill } from "./OnboardingNavPill";

vi.mock("@tanstack/react-router", () => ({
Link: ({ children }: { children: ReactNode }) => <a>{children}</a>,
}));

let onboarding = {
steps: [],
completedCount: 1,
total: 4,
isComplete: false,
dismissed: false,
isResolved: true,
markDocsRead: vi.fn(),
};
vi.mock("@/providers/OnboardingProvider/OnboardingProvider", () => ({
useOnboarding: () => onboarding,
}));

function resetState() {
onboarding = {
steps: [],
completedCount: 1,
total: 4,
isComplete: false,
dismissed: false,
isResolved: true,
markDocsRead: vi.fn(),
};
}

beforeEach(resetState);
afterEach(cleanup);

const pill = () => screen.queryByText(/Onboarding/);

describe("OnboardingNavPill", () => {
it("shows progress while onboarding is in progress", () => {
render(<OnboardingNavPill />);
expect(pill()).toHaveTextContent("Onboarding · 1/4");
});

it("is hidden once onboarding is complete", () => {
onboarding.isComplete = true;
render(<OnboardingNavPill />);
expect(pill()).toBeNull();
});

it("is hidden once onboarding is dismissed", () => {
onboarding.dismissed = true;
render(<OnboardingNavPill />);
expect(pill()).toBeNull();
});
});
42 changes: 42 additions & 0 deletions src/components/Onboarding/OnboardingNavPill.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { OnboardingChecklist } from "@/components/Onboarding/OnboardingChecklist";
import { Button } from "@/components/ui/button";
import { Icon } from "@/components/ui/icon";
import { BlockStack } from "@/components/ui/layout";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Heading } from "@/components/ui/typography";
import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider";
import { tracking } from "@/utils/tracking";

export function OnboardingNavPill() {
const { completedCount, total, isComplete, dismissed, isResolved } =
useOnboarding();

if (!isResolved || isComplete || dismissed) {
return null;
}

return (
<Popover>
<PopoverTrigger asChild {...tracking("navigation.onboarding_pill")}>
<Button
variant="ghost"
size="sm"
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"
>
<Icon name="Rocket" size="sm" aria-hidden="true" />
Onboarding · {completedCount}/{total}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-96">
<BlockStack gap="3">
<Heading level={3}>Get started with Tangle</Heading>
<OnboardingChecklist />
</BlockStack>
</PopoverContent>
</Popover>
);
}
3 changes: 3 additions & 0 deletions src/components/layout/AppMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
import { useState } from "react";

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

<OnboardingNavPill />

<EditorVersionToggle />

{/* Settings & status */}
Expand Down
56 changes: 49 additions & 7 deletions src/providers/OnboardingProvider/OnboardingProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { useTour } from "@reactour/tour";
import { useQuery } from "@tanstack/react-query";
import { type ReactNode, useEffect, useState } from "react";
import { type ReactNode, useEffect, useRef, useState } from "react";

import type { ListPipelineJobsResponse } from "@/api/types.gen";
import {
createRequiredContext,
useRequiredContext,
} from "@/hooks/useRequiredContext";
import useToastNotification from "@/hooks/useToastNotification";
import { useAnalytics } from "@/providers/AnalyticsProvider";
import { useBackend } from "@/providers/BackendProvider";
import { useTourCompletions } from "@/providers/TourProvider/tourCompletion";
Expand Down Expand Up @@ -41,6 +42,8 @@ interface OnboardingContextValue {
total: number;
isComplete: boolean;
dismissed: boolean;
isReady: boolean;
isResolved: boolean;
markDocsRead: () => void;
dismiss: () => void;
reopen: () => void;
Expand All @@ -49,11 +52,14 @@ interface OnboardingContextValue {
const OnboardingContext =
createRequiredContext<OnboardingContextValue>("OnboardingProvider");

function useHasMyRun(enabled: boolean): boolean {
function useHasMyRun(enabled: boolean): {
hasRun: boolean;
isLoading: boolean;
} {
const { available, backendUrl } = useBackend();
const filterQuery = filtersToFilterQuery(parseFilterParam("created_by:me"));

const { data } = useQuery({
const { data, isLoading } = useQuery({
queryKey: ["onboarding", "myRunCount", backendUrl],
enabled: enabled && available && Boolean(backendUrl),
staleTime: STALE_MS,
Expand All @@ -67,20 +73,25 @@ function useHasMyRun(enabled: boolean): boolean {
return payload.pipeline_runs?.length ?? 0;
},
});
return (data ?? 0) > 0;
return { hasRun: (data ?? 0) > 0, isLoading };
}

export function OnboardingProvider({ children }: { children: ReactNode }) {
const { track } = useAnalytics();
const { isOpen: tourActive } = useTour();
const { data: progress } = useOnboardingProgress({ enabled: !tourActive });
const notify = useToastNotification();
const { ready: backendReady, configured } = useBackend();
const { data: progress, isLoading: progressLoading } = useOnboardingProgress({
enabled: !tourActive,
});
const persist = usePersistOnboardingProgress();

const { data: tourCompletions } = useTourCompletions();
const { data: tourCompletions, isLoading: toursLoading } =
useTourCompletions();
const hasCompletedTour = Boolean(
tourCompletions && Object.keys(tourCompletions).length > 0,
);
const hasMyRun = useHasMyRun(!tourActive);
const { hasRun: hasMyRun, isLoading: runsLoading } = useHasMyRun(!tourActive);

const stored = progress?.steps;
const desiredSteps: OnboardingSteps = {
Expand All @@ -91,6 +102,8 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
};

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

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

Expand All @@ -117,6 +130,32 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
track("onboarding.step.completed", { step_id: "create_pipeline" });
}, [tourActive, pipelineWriteCount, progress, persist, track]);

const completedRef = useRef<Set<string> | null>(null);

useEffect(() => {
if (tourActive || !isResolved) return;
const current = new Set(
ONBOARDING_STEP_IDS.filter((id) => desiredSteps[id]),
);
const previous = completedRef.current;
completedRef.current = current;
if (previous === null) return;

const newlyCompleted = ONBOARDING_STEP_IDS.filter(
(id) => current.has(id) && !previous.has(id),
);
if (newlyCompleted.length === 0) return;

if (isComplete) {
notify("You're all set up - onboarding complete!", "success");
return;
}
for (const id of newlyCompleted) {
const label = ONBOARDING_STEPS.find((step) => step.id === id)?.label;
if (label) notify(`Completed: ${label}`, "success");
}
}, [tourActive, isResolved, desiredSteps, isComplete, notify]);

const markDocsRead = () => {
if (!progress || progress.steps.read_docs) return;
persist({ ...progress, steps: { ...progress.steps, read_docs: true } });
Expand All @@ -127,6 +166,7 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
if (!progress || progress.dismissed) return;
persist({ ...progress, dismissed: true });
track("onboarding.dismissed");
notify("You can resume onboarding from the Learning Hub", "info");
};

const reopen = () => {
Expand All @@ -146,6 +186,8 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
total: steps.length,
isComplete,
dismissed: progress?.dismissed ?? false,
isReady,
isResolved,
markDocsRead,
dismiss,
reopen,
Expand Down
2 changes: 2 additions & 0 deletions src/routes/v2/shared/components/AppMenuActions.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Link as RouterLink } from "@tanstack/react-router";

import { OnboardingNavPill } from "@/components/Onboarding/OnboardingNavPill";
import { isAuthorizationRequired } from "@/components/shared/Authentication/helpers";
import { TopBarAuthentication } from "@/components/shared/Authentication/TopBarAuthentication";
import TooltipButton from "@/components/shared/Buttons/TooltipButton";
Expand All @@ -23,6 +24,7 @@ export function AppMenuActions() {
className="shrink-0"
data-testid="app-menu-actions"
>
{!tourMode && <OnboardingNavPill />}
<EditorVersionToggle />
{tourMode ? (
<TooltipButton
Expand Down
Loading