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..3e8d49f21 --- /dev/null +++ b/src/components/Onboarding/OnboardingNavPill.tsx @@ -0,0 +1,38 @@ +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/components/ui/button.tsx b/src/components/ui/button.tsx index 4a775bc94..02b165f76 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -25,6 +25,7 @@ const buttonVariants = cva( "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "text-primary underline-offset-4 hover:underline", "link-info": "text-info underline decoration-dotted", + nav: "bg-stone-700 text-white text-xs font-semibold hover:bg-stone-600 hover:text-white", }, size: { default: "h-9 px-4 py-2 has-[>svg]:px-3", @@ -35,10 +36,15 @@ const buttonVariants = cva( icon: "size-9", min: "h-fit w-fit p-1 rounded-sm", }, + shape: { + default: "", + pill: "rounded-full", + }, }, defaultVariants: { variant: "default", size: "default", + shape: "default", }, }, ); @@ -52,6 +58,7 @@ function Button({ className, variant, size, + shape, asChild = false, ...props }: ButtonProps) { @@ -60,7 +67,7 @@ function Button({ return ( ); diff --git a/src/providers/OnboardingProvider/OnboardingProvider.tsx b/src/providers/OnboardingProvider/OnboardingProvider.tsx index 469be6607..15f8c99dd 100644 --- a/src/providers/OnboardingProvider/OnboardingProvider.tsx +++ b/src/providers/OnboardingProvider/OnboardingProvider.tsx @@ -6,6 +6,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"; @@ -28,6 +29,7 @@ import { ONBOARDING_STEPS, type OnboardingStepMeta, } from "./steps"; +import { useStepCompletionToasts } from "./useStepCompletionToasts"; const PIPELINE_RUNS_QUERY_URL = "/api/pipeline_runs/"; const STALE_MS = 1000 * 60 * 5; @@ -47,6 +49,8 @@ interface OnboardingContextValue { total: number; isComplete: boolean; dismissed: boolean; + isReady: boolean; + isResolved: boolean; markDocsRead: () => void; dismiss: () => void; reopen: () => void; @@ -55,11 +59,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_MY_RUN_COUNT_KEY, backendUrl], enabled: available && Boolean(backendUrl), staleTime: STALE_MS, @@ -71,19 +78,23 @@ function useHasMyRun(): boolean { return countPipelineRuns(payload); }, }); - 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 = { @@ -94,6 +105,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); @@ -120,6 +133,8 @@ export function OnboardingProvider({ children }: { children: ReactNode }) { track("onboarding.step.completed", { step_id: "create_pipeline" }); }, [pipelineWriteCount, progress, persist, track]); + useStepCompletionToasts({ isResolved, desiredSteps, isComplete }); + const markDocsRead = () => { if (!progress || progress.steps.read_docs) return; persist({ ...progress, steps: { ...progress.steps, read_docs: true } }); @@ -132,6 +147,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 = () => { @@ -151,6 +167,8 @@ export function OnboardingProvider({ children }: { children: ReactNode }) { total: steps.length, isComplete, dismissed: progress?.dismissed ?? false, + isReady, + isResolved, markDocsRead, dismiss, reopen, diff --git a/src/providers/OnboardingProvider/onboardingCompletion.test.ts b/src/providers/OnboardingProvider/onboardingCompletion.test.ts new file mode 100644 index 000000000..33b04ec3e --- /dev/null +++ b/src/providers/OnboardingProvider/onboardingCompletion.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { + completedStepIds, + newlyCompletedIds, + stepLabel, +} from "./onboardingCompletion"; +import { ONBOARDING_STEPS } from "./steps"; + +describe("completedStepIds", () => { + it("returns completed ids in canonical order", () => { + expect( + completedStepIds({ + read_docs: true, + complete_tour: false, + create_pipeline: true, + execute_run: false, + }), + ).toEqual(["read_docs", "create_pipeline"]); + }); + + it("returns an empty array when nothing is complete", () => { + expect( + completedStepIds({ + read_docs: false, + complete_tour: false, + create_pipeline: false, + execute_run: false, + }), + ).toEqual([]); + }); +}); + +describe("newlyCompletedIds", () => { + it("returns ids present in current but not previous", () => { + expect( + newlyCompletedIds( + new Set(["read_docs"]), + new Set(["read_docs", "execute_run"]), + ), + ).toEqual(["execute_run"]); + }); + + it("returns empty when nothing newly completed", () => { + expect( + newlyCompletedIds(new Set(["read_docs"]), new Set(["read_docs"])), + ).toEqual([]); + }); +}); + +describe("stepLabel", () => { + it("resolves the label for a known step", () => { + expect(stepLabel("read_docs")).toBe( + ONBOARDING_STEPS.find((step) => step.id === "read_docs")?.label, + ); + }); +}); diff --git a/src/providers/OnboardingProvider/onboardingCompletion.ts b/src/providers/OnboardingProvider/onboardingCompletion.ts new file mode 100644 index 000000000..6fca90731 --- /dev/null +++ b/src/providers/OnboardingProvider/onboardingCompletion.ts @@ -0,0 +1,21 @@ +import type { OnboardingSteps } from "./onboardingProgress"; +import { + ONBOARDING_STEP_IDS, + ONBOARDING_STEPS, + type OnboardingStepId, +} from "./steps"; + +export function completedStepIds(steps: OnboardingSteps): OnboardingStepId[] { + return ONBOARDING_STEP_IDS.filter((id) => steps[id]); +} + +export function newlyCompletedIds( + previous: ReadonlySet, + current: ReadonlySet, +): OnboardingStepId[] { + return [...current].filter((id) => !previous.has(id)); +} + +export function stepLabel(id: OnboardingStepId): string | undefined { + return ONBOARDING_STEPS.find((step) => step.id === id)?.label; +} diff --git a/src/providers/OnboardingProvider/useStepCompletionToasts.ts b/src/providers/OnboardingProvider/useStepCompletionToasts.ts new file mode 100644 index 000000000..9130715cc --- /dev/null +++ b/src/providers/OnboardingProvider/useStepCompletionToasts.ts @@ -0,0 +1,51 @@ +import { useEffect, useRef } from "react"; + +import useToastNotification from "@/hooks/useToastNotification"; + +import { + completedStepIds, + newlyCompletedIds, + stepLabel, +} from "./onboardingCompletion"; +import type { OnboardingSteps } from "./onboardingProgress"; +import type { OnboardingStepId } from "./steps"; + +interface UseStepCompletionToastsArgs { + isResolved: boolean; + desiredSteps: OnboardingSteps; + isComplete: boolean; +} + +/** + * Fires a toast the first time each step flips to complete (and a single + * "all set up" toast once everything is done). The first resolved render + * only seeds the baseline, so already-complete steps don't toast on mount. + */ +export function useStepCompletionToasts({ + isResolved, + desiredSteps, + isComplete, +}: UseStepCompletionToastsArgs) { + const notify = useToastNotification(); + const previousRef = useRef | null>(null); + + useEffect(() => { + if (!isResolved) return; + const current = new Set(completedStepIds(desiredSteps)); + const previous = previousRef.current; + previousRef.current = current; + if (previous === null) return; + + const newly = newlyCompletedIds(previous, current); + if (newly.length === 0) return; + + if (isComplete) { + notify("You're all set up - onboarding complete!", "success"); + return; + } + for (const id of newly) { + const label = stepLabel(id); + if (label) notify(`Completed: ${label}`, "success"); + } + }, [isResolved, desiredSteps, isComplete, notify]); +} 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 ? (