diff --git a/src/components/Onboarding/OnboardingNavPill.test.tsx b/src/components/Onboarding/OnboardingNavPill.test.tsx new file mode 100644 index 000000000..48fbb8965 --- /dev/null +++ b/src/components/Onboarding/OnboardingNavPill.test.tsx @@ -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 }) => {children}, +})); + +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(); + expect(pill()).toHaveTextContent("Onboarding · 1/4"); + }); + + it("is hidden once onboarding is complete", () => { + onboarding.isComplete = true; + render(); + expect(pill()).toBeNull(); + }); + + it("is hidden once onboarding is dismissed", () => { + onboarding.dismissed = true; + render(); + expect(pill()).toBeNull(); + }); +}); diff --git a/src/components/Onboarding/OnboardingNavPill.tsx b/src/components/Onboarding/OnboardingNavPill.tsx new file mode 100644 index 000000000..f49a555ad --- /dev/null +++ b/src/components/Onboarding/OnboardingNavPill.tsx @@ -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 ( + + + + + + + Get started with Tangle + + + + + ); +} diff --git a/src/components/layout/AppMenu.tsx b/src/components/layout/AppMenu.tsx index cea3f0515..2cc453527 100644 --- a/src/components/layout/AppMenu.tsx +++ b/src/components/layout/AppMenu.tsx @@ -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"; @@ -110,6 +111,8 @@ const DefaultAppMenu = () => {
+ + {/* Settings & status */} diff --git a/src/providers/OnboardingProvider/OnboardingProvider.tsx b/src/providers/OnboardingProvider/OnboardingProvider.tsx index 596ff9110..cf824b4f1 100644 --- a/src/providers/OnboardingProvider/OnboardingProvider.tsx +++ b/src/providers/OnboardingProvider/OnboardingProvider.tsx @@ -1,5 +1,5 @@ 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 { useDocsVisitTracking } from "@/hooks/useDocsVisitTracking"; @@ -7,6 +7,7 @@ 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"; @@ -41,6 +42,8 @@ interface OnboardingContextValue { total: number; isComplete: boolean; dismissed: boolean; + isReady: boolean; + isResolved: boolean; markDocsRead: () => void; dismiss: () => void; reopen: () => void; @@ -49,11 +52,14 @@ interface OnboardingContextValue { const OnboardingContext = createRequiredContext("OnboardingProvider"); -function useHasMyRun(): boolean { +function useHasMyRun(): { + 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: available && Boolean(backendUrl), staleTime: STALE_MS, @@ -67,19 +73,23 @@ function useHasMyRun(): 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 { data: progress } = useOnboardingProgress(); + const notify = useToastNotification(); + const { ready: backendReady, configured } = useBackend(); + const { data: progress, isLoading: progressLoading } = + useOnboardingProgress(); 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(); + const { hasRun: hasMyRun, isLoading: runsLoading } = useHasMyRun(); const stored = progress?.steps; const desiredSteps: OnboardingSteps = { @@ -90,6 +100,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); @@ -116,6 +128,32 @@ export function OnboardingProvider({ children }: { children: ReactNode }) { track("onboarding.step.completed", { step_id: "create_pipeline" }); }, [pipelineWriteCount, progress, persist, track]); + const completedRef = useRef | null>(null); + + useEffect(() => { + if (!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"); + } + }, [isResolved, desiredSteps, isComplete, notify]); + const markDocsRead = () => { if (!progress || progress.steps.read_docs) return; persist({ ...progress, steps: { ...progress.steps, read_docs: true } }); @@ -128,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 = () => { @@ -147,6 +186,8 @@ export function OnboardingProvider({ children }: { children: ReactNode }) { total: steps.length, isComplete, dismissed: progress?.dismissed ?? false, + isReady, + isResolved, markDocsRead, dismiss, reopen, diff --git a/src/routes/v2/shared/components/AppMenuActions.tsx b/src/routes/v2/shared/components/AppMenuActions.tsx index 3fe3fc8ee..0fe78a982 100644 --- a/src/routes/v2/shared/components/AppMenuActions.tsx +++ b/src/routes/v2/shared/components/AppMenuActions.tsx @@ -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"; @@ -23,6 +24,7 @@ export function AppMenuActions() { className="shrink-0" data-testid="app-menu-actions" > + {!tourMode && } {tourMode ? (