From 30b5b3049afaf00807a4c42f417d4753f02111cf Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Wed, 17 Jun 2026 13:23:49 -0700 Subject: [PATCH] feat: Onboarding Checklist --- react-compiler.config.js | 2 + src/components/Learn/DocsQuickLinks.tsx | 15 +- src/components/Learn/OnboardingHero.tsx | 169 +++++++-------- .../Onboarding/OnboardingChecklist.tsx | 117 +++++++++++ src/components/layout/RootLayout.tsx | 27 +-- .../OnboardingProvider.test.tsx | 192 ++++++++++++++++++ .../OnboardingProvider/OnboardingProvider.tsx | 159 +++++++++++++++ .../onboardingProgress.test.tsx | 143 +++++++++++++ .../OnboardingProvider/onboardingProgress.ts | 112 ++++++++++ .../OnboardingProvider/onboardingSteps.json | 30 +++ .../OnboardingProvider/steps.test.ts | 21 ++ src/providers/OnboardingProvider/steps.ts | 54 +++++ src/providers/TourProvider/tourCompletion.ts | 6 +- .../Dashboard/Learn/LearnHomeView.test.tsx | 8 +- src/routes/Dashboard/Learn/LearnHomeView.tsx | 10 +- src/routes/Settings/SettingsFlagsContext.tsx | 5 +- src/services/pipelineStorage/PipelineFile.ts | 3 + src/utils/componentStore.ts | 14 +- src/utils/constants.ts | 2 + src/utils/userPipelineWriteEvents.ts | 11 + 20 files changed, 970 insertions(+), 130 deletions(-) create mode 100644 src/components/Onboarding/OnboardingChecklist.tsx create mode 100644 src/providers/OnboardingProvider/OnboardingProvider.test.tsx create mode 100644 src/providers/OnboardingProvider/OnboardingProvider.tsx create mode 100644 src/providers/OnboardingProvider/onboardingProgress.test.tsx create mode 100644 src/providers/OnboardingProvider/onboardingProgress.ts create mode 100644 src/providers/OnboardingProvider/onboardingSteps.json create mode 100644 src/providers/OnboardingProvider/steps.test.ts create mode 100644 src/providers/OnboardingProvider/steps.ts create mode 100644 src/utils/userPipelineWriteEvents.ts diff --git a/react-compiler.config.js b/react-compiler.config.js index c0d19b382..6e5e17d39 100644 --- a/react-compiler.config.js +++ b/react-compiler.config.js @@ -6,6 +6,7 @@ export const REACT_COMPILER_ENABLED_DIRS = [ "src/components/Home", "src/components/Editor", "src/components/Learn", + "src/components/Onboarding", // 0 useCallback/useMemo - ready to enable "src/components/layout", @@ -70,6 +71,7 @@ export const REACT_COMPILER_ENABLED_DIRS = [ "src/components/ui/typography.tsx", "src/providers/DialogProvider", + "src/providers/OnboardingProvider", "src/providers/TourProvider/tourCompletion.ts", "src/routes/EditorV2", diff --git a/src/components/Learn/DocsQuickLinks.tsx b/src/components/Learn/DocsQuickLinks.tsx index bcf889b57..debd67974 100644 --- a/src/components/Learn/DocsQuickLinks.tsx +++ b/src/components/Learn/DocsQuickLinks.tsx @@ -2,6 +2,7 @@ import { Icon, type IconName } from "@/components/ui/icon"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Link } from "@/components/ui/link"; import { Text } from "@/components/ui/typography"; +import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider"; import { DOCUMENTATION_URL } from "@/utils/constants"; import { tracking } from "@/utils/tracking"; @@ -51,12 +52,19 @@ const QUICK_LINKS: QuickLink[] = [ }, ]; -function QuickLinkPill({ link }: { link: QuickLink }) { +function QuickLinkPill({ + link, + onClick, +}: { + link: QuickLink; + onClick: () => void; +}) { return ( @@ -83,7 +93,7 @@ export function DocsQuickLinks() { {QUICK_LINKS.map((link) => ( - + ))} Full docs diff --git a/src/components/Learn/OnboardingHero.tsx b/src/components/Learn/OnboardingHero.tsx index c9b5f12d2..b77034e70 100644 --- a/src/components/Learn/OnboardingHero.tsx +++ b/src/components/Learn/OnboardingHero.tsx @@ -1,116 +1,83 @@ -import { Link } from "@tanstack/react-router"; - +import { OnboardingChecklist } from "@/components/Onboarding/OnboardingChecklist"; import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; import { BlockStack, InlineStack } from "@/components/ui/layout"; -import { Heading, Paragraph, Text } from "@/components/ui/typography"; -import { cn } from "@/lib/utils"; -import { tracking } from "@/utils/tracking"; +import { Heading, Paragraph } from "@/components/ui/typography"; +import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider"; -interface OnboardingStep { - id: string; - label: string; - completed: boolean; +function scrollNearestScrollableToTop(el: HTMLElement | null) { + let node = el?.parentElement ?? null; + while (node) { + const { overflowY } = getComputedStyle(node); + if ( + (overflowY === "auto" || overflowY === "scroll") && + node.scrollHeight > node.clientHeight + ) { + node.scrollTo({ top: 0, behavior: "smooth" }); + return; + } + node = node.parentElement; + } + window.scrollTo({ top: 0, behavior: "smooth" }); } -const STUB_STEPS: OnboardingStep[] = [ - { id: "configure-backend", label: "Connect a backend", completed: true }, - { id: "import-sample", label: "Import a sample pipeline", completed: true }, - { id: "run-pipeline", label: "Run your first pipeline", completed: false }, - { id: "edit-component", label: "Edit a component", completed: false }, - { - id: "create-pipeline", - label: "Build a pipeline from scratch", - completed: false, - }, -]; - export function OnboardingHero() { - const completed = STUB_STEPS.filter((s) => s.completed).length; - const total = STUB_STEPS.length; - const isComplete = completed === total; - const nextStep = STUB_STEPS.find((s) => !s.completed); + const { isComplete, dismissed, dismiss, reopen } = useOnboarding(); - return ( -
- - - - - - - {isComplete - ? "Onboarding complete — explore tours and tips below to keep going." - : "Follow a few quick steps to get from zero to your first pipeline run."} - - - {!isComplete && nextStep && ( - + if (dismissed) { + return ( + + + + ); + } - - - - {completed} of {total} steps - -
-
-
- + return ( +
+ -
    - {STUB_STEPS.map((step) => ( -
  • -
  • - ))} -
+ + + + + + {isComplete + ? "Onboarding complete - explore tours and tips below to keep going." + : "Follow a few quick steps to get from zero to your first pipeline run."} + + +
); diff --git a/src/components/Onboarding/OnboardingChecklist.tsx b/src/components/Onboarding/OnboardingChecklist.tsx new file mode 100644 index 000000000..c7fe04bf9 --- /dev/null +++ b/src/components/Onboarding/OnboardingChecklist.tsx @@ -0,0 +1,117 @@ +import { Link } from "@tanstack/react-router"; + +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Link as ExternalLink } from "@/components/ui/link"; +import { Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; +import { + type OnboardingStep, + useOnboarding, +} from "@/providers/OnboardingProvider/OnboardingProvider"; +import { DOCUMENTATION_URL } from "@/utils/constants"; +import { tracking } from "@/utils/tracking"; + +function StepCta({ + step, + onReadDocs, +}: { + step: OnboardingStep; + onReadDocs: () => void; +}) { + if (step.id === "read_docs") { + return ( + + {step.cta.label} + + ); + } + + return ( + + ); +} + +function StepRow({ + step, + onReadDocs, +}: { + step: OnboardingStep; + onReadDocs: () => void; +}) { + return ( + + + ); +} + +export function OnboardingChecklist() { + const { steps, completedCount, total, markDocsRead } = useOnboarding(); + + return ( + + + + {completedCount} of {total} steps + +
+
+
+ + + + {steps.map((step) => ( + + ))} + + + ); +} diff --git a/src/components/layout/RootLayout.tsx b/src/components/layout/RootLayout.tsx index 823b3ff05..d4f3a52fc 100644 --- a/src/components/layout/RootLayout.tsx +++ b/src/components/layout/RootLayout.tsx @@ -10,6 +10,7 @@ import { useSessionPipelineStats } from "@/hooks/useSessionPipelineStats"; import { AnalyticsProvider } from "@/providers/AnalyticsProvider"; import { BackendProvider } from "@/providers/BackendProvider"; import { ComponentSpecProvider } from "@/providers/ComponentSpecProvider"; +import { OnboardingProvider } from "@/providers/OnboardingProvider/OnboardingProvider"; import { TourProvider } from "@/providers/TourProvider/TourProvider"; import { PipelineStorageProvider } from "@/services/pipelineStorage/PipelineStorageProvider"; @@ -29,21 +30,23 @@ function RootLayoutContent() { - - - + + + + -
- +
+ -
- -
+
+ +
- {import.meta.env.VITE_ENABLE_ROUTER_DEVTOOLS === "true" && ( - - )} -
+ {import.meta.env.VITE_ENABLE_ROUTER_DEVTOOLS === "true" && ( + + )} +
+
diff --git a/src/providers/OnboardingProvider/OnboardingProvider.test.tsx b/src/providers/OnboardingProvider/OnboardingProvider.test.tsx new file mode 100644 index 000000000..8287700d6 --- /dev/null +++ b/src/providers/OnboardingProvider/OnboardingProvider.test.tsx @@ -0,0 +1,192 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { emitUserPipelineWritten } from "@/utils/userPipelineWriteEvents"; + +import { OnboardingProvider, useOnboarding } from "./OnboardingProvider"; + +const fetchWithErrorHandling = vi.hoisted(() => + vi.fn<(url: string, options?: RequestInit) => Promise>(), +); +const track = vi.hoisted(() => vi.fn()); + +vi.mock("@/utils/fetchWithErrorHandling", () => ({ + fetchWithErrorHandling: (url: string, options?: RequestInit) => + fetchWithErrorHandling(url, options), +})); + +let backend = { available: true, backendUrl: "https://backend.example" }; +vi.mock("@/providers/BackendProvider", () => ({ + useBackend: () => backend, +})); + +vi.mock("@/providers/AnalyticsProvider", () => ({ + useAnalytics: () => ({ track }), +})); + +let tourCompletions: Record | undefined = {}; +vi.mock("@/providers/TourProvider/tourCompletion", () => ({ + useTourCompletions: () => ({ data: tourCompletions }), +})); + +let tourOpen = false; +vi.mock("@reactour/tour", () => ({ + useTour: () => ({ isOpen: tourOpen }), +})); + +let settingsPayload: unknown = {}; +let runsPayload: unknown = { pipeline_runs: [] }; + +function wrapper({ children }: { children: ReactNode }) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ( + + {children} + + ); +} + +function render() { + return renderHook(() => useOnboarding(), { wrapper }); +} + +function completedSteps(result: { current: ReturnType }) { + return result.current.steps + .filter((step) => step.completed) + .map((step) => step.id); +} + +function lastPatchBody() { + const patch = fetchWithErrorHandling.mock.calls.find( + ([, options]) => options?.method === "PATCH", + ); + return JSON.parse(patch?.[1]?.body as string); +} + +function patched() { + return fetchWithErrorHandling.mock.calls.some( + ([, options]) => options?.method === "PATCH", + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + backend = { available: true, backendUrl: "https://backend.example" }; + tourCompletions = {}; + settingsPayload = {}; + runsPayload = { pipeline_runs: [] }; + tourOpen = false; + + fetchWithErrorHandling.mockImplementation((url, options) => { + if (options?.method === "PATCH") return Promise.resolve({}); + if (url.includes("/api/users/me/settings")) + return Promise.resolve(settingsPayload); + if (url.includes("/api/pipeline_runs/")) + return Promise.resolve(runsPayload); + return Promise.resolve({}); + }); +}); + +describe("OnboardingProvider", () => { + it("reports no progress for a brand-new user and does not persist", async () => { + const { result } = render(); + + await waitFor(() => expect(result.current.total).toBe(4)); + expect(result.current.completedCount).toBe(0); + expect(result.current.isComplete).toBe(false); + expect(patched()).toBe(false); + }); + + it("derives tour and run completion live without persisting them", async () => { + tourCompletions = { "first-pipeline": { completedAt: "x" } }; + runsPayload = { pipeline_runs: [{ id: "run-1" }] }; + + const { result } = render(); + + await waitFor(() => + expect(completedSteps(result).sort()).toEqual([ + "complete_tour", + "execute_run", + ]), + ); + expect(patched()).toBe(false); + }); + + it("persists create_pipeline when the user writes a pipeline", async () => { + const { result } = render(); + + act(() => emitUserPipelineWritten()); + + await waitFor(() => + expect(completedSteps(result)).toContain("create_pipeline"), + ); + expect(lastPatchBody().settings.onboarding.steps.create_pipeline).toBe( + true, + ); + expect(track).toHaveBeenCalledWith("onboarding.step.completed", { + step_id: "create_pipeline", + }); + }); + + it("stays inert during a guided tour: practice writes don't persist", async () => { + tourOpen = true; + const { result } = render(); + + await waitFor(() => expect(result.current.total).toBe(4)); + act(() => emitUserPipelineWritten()); + + await waitFor(() => expect(patched()).toBe(false)); + expect(completedSteps(result)).not.toContain("create_pipeline"); + }); + + it("marks the docs step read on demand", async () => { + settingsPayload = { onboarding: { steps: { create_pipeline: true } } }; + const { result } = render(); + await waitFor(() => + expect(completedSteps(result)).toContain("create_pipeline"), + ); + + act(() => result.current.markDocsRead()); + + await waitFor(() => expect(completedSteps(result)).toContain("read_docs")); + expect(lastPatchBody().settings.onboarding.steps.read_docs).toBe(true); + expect(track).toHaveBeenCalledWith("onboarding.step.completed", { + step_id: "read_docs", + }); + }); + + it("dismisses and restores onboarding", async () => { + settingsPayload = { onboarding: { steps: { create_pipeline: true } } }; + const { result } = render(); + await waitFor(() => + expect(completedSteps(result)).toContain("create_pipeline"), + ); + expect(result.current.dismissed).toBe(false); + + act(() => result.current.dismiss()); + await waitFor(() => expect(result.current.dismissed).toBe(true)); + expect(lastPatchBody().settings.onboarding.dismissed).toBe(true); + expect(track).toHaveBeenCalledWith("onboarding.dismissed"); + + act(() => result.current.reopen()); + await waitFor(() => expect(result.current.dismissed).toBe(false)); + expect(track).toHaveBeenCalledWith("onboarding.reopened"); + }); + + it("is complete once persisted and derived steps are all satisfied", async () => { + settingsPayload = { + onboarding: { steps: { read_docs: true, create_pipeline: true } }, + }; + tourCompletions = { "first-pipeline": { completedAt: "x" } }; + runsPayload = { pipeline_runs: [{ id: "run-1" }] }; + + const { result } = render(); + + await waitFor(() => expect(result.current.isComplete).toBe(true)); + expect(result.current.completedCount).toBe(4); + }); +}); diff --git a/src/providers/OnboardingProvider/OnboardingProvider.tsx b/src/providers/OnboardingProvider/OnboardingProvider.tsx new file mode 100644 index 000000000..630f9aca9 --- /dev/null +++ b/src/providers/OnboardingProvider/OnboardingProvider.tsx @@ -0,0 +1,159 @@ +import { useTour } from "@reactour/tour"; +import { useQuery } from "@tanstack/react-query"; +import { type ReactNode, useEffect, useState } from "react"; + +import type { ListPipelineJobsResponse } from "@/api/types.gen"; +import { + createRequiredContext, + useRequiredContext, +} from "@/hooks/useRequiredContext"; +import { useAnalytics } from "@/providers/AnalyticsProvider"; +import { useBackend } from "@/providers/BackendProvider"; +import { useTourCompletions } from "@/providers/TourProvider/tourCompletion"; +import { fetchWithErrorHandling } from "@/utils/fetchWithErrorHandling"; +import { + filtersToFilterQuery, + parseFilterParam, +} from "@/utils/pipelineRunFilterUtils"; +import { subscribeUserPipelineWritten } from "@/utils/userPipelineWriteEvents"; + +import { + type OnboardingSteps, + useOnboardingProgress, + usePersistOnboardingProgress, +} from "./onboardingProgress"; +import { + ONBOARDING_STEP_IDS, + ONBOARDING_STEPS, + type OnboardingStepMeta, +} from "./steps"; + +const PIPELINE_RUNS_QUERY_URL = "/api/pipeline_runs/"; +const STALE_MS = 1000 * 60 * 5; + +export interface OnboardingStep extends OnboardingStepMeta { + completed: boolean; +} + +interface OnboardingContextValue { + steps: OnboardingStep[]; + completedCount: number; + total: number; + isComplete: boolean; + dismissed: boolean; + markDocsRead: () => void; + dismiss: () => void; + reopen: () => void; +} + +const OnboardingContext = + createRequiredContext("OnboardingProvider"); + +function useHasMyRun(enabled: boolean): boolean { + const { available, backendUrl } = useBackend(); + const filterQuery = filtersToFilterQuery(parseFilterParam("created_by:me")); + + const { data } = useQuery({ + queryKey: ["onboarding", "myRunCount", backendUrl], + enabled: enabled && available && Boolean(backendUrl), + staleTime: STALE_MS, + refetchOnWindowFocus: false, + queryFn: async () => { + const url = new URL(PIPELINE_RUNS_QUERY_URL, backendUrl); + if (filterQuery) url.searchParams.set("filter_query", filterQuery); + const payload = (await fetchWithErrorHandling( + url.toString(), + )) as ListPipelineJobsResponse; + return payload.pipeline_runs?.length ?? 0; + }, + }); + return (data ?? 0) > 0; +} + +export function OnboardingProvider({ children }: { children: ReactNode }) { + const { track } = useAnalytics(); + const { isOpen: tourActive } = useTour(); + const { data: progress } = useOnboardingProgress({ enabled: !tourActive }); + const persist = usePersistOnboardingProgress(); + + const { data: tourCompletions } = useTourCompletions(); + const hasCompletedTour = Boolean( + tourCompletions && Object.keys(tourCompletions).length > 0, + ); + const hasMyRun = useHasMyRun(!tourActive); + + const stored = progress?.steps; + const desiredSteps: OnboardingSteps = { + read_docs: stored?.read_docs ?? false, + create_pipeline: stored?.create_pipeline ?? false, + complete_tour: hasCompletedTour, + execute_run: hasMyRun, + }; + + const isComplete = ONBOARDING_STEP_IDS.every((id) => desiredSteps[id]); + + const [pipelineWriteCount, setPipelineWriteCount] = useState(0); + + useEffect(() => { + if (tourActive) return undefined; + return subscribeUserPipelineWritten(() => + setPipelineWriteCount((count) => count + 1), + ); + }, [tourActive]); + + useEffect(() => { + if ( + tourActive || + pipelineWriteCount === 0 || + !progress || + progress.steps.create_pipeline + ) { + return; + } + persist({ + ...progress, + steps: { ...progress.steps, create_pipeline: true }, + }); + track("onboarding.step.completed", { step_id: "create_pipeline" }); + }, [tourActive, pipelineWriteCount, progress, persist, track]); + + const markDocsRead = () => { + if (!progress || progress.steps.read_docs) return; + persist({ ...progress, steps: { ...progress.steps, read_docs: true } }); + track("onboarding.step.completed", { step_id: "read_docs" }); + }; + + const dismiss = () => { + if (!progress || progress.dismissed) return; + persist({ ...progress, dismissed: true }); + track("onboarding.dismissed"); + }; + + const reopen = () => { + if (!progress || !progress.dismissed) return; + persist({ ...progress, dismissed: false }); + track("onboarding.reopened"); + }; + + const steps: OnboardingStep[] = ONBOARDING_STEPS.map((meta) => ({ + ...meta, + completed: desiredSteps[meta.id], + })); + + const value: OnboardingContextValue = { + steps, + completedCount: steps.filter((step) => step.completed).length, + total: steps.length, + isComplete, + dismissed: progress?.dismissed ?? false, + markDocsRead, + dismiss, + reopen, + }; + + return {children}; +} + +export function useOnboarding() { + return useRequiredContext(OnboardingContext); +} diff --git a/src/providers/OnboardingProvider/onboardingProgress.test.tsx b/src/providers/OnboardingProvider/onboardingProgress.test.tsx new file mode 100644 index 000000000..e6fdc4106 --- /dev/null +++ b/src/providers/OnboardingProvider/onboardingProgress.test.tsx @@ -0,0 +1,143 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + emptyProgress, + type OnboardingProgress, + parseProgress, + useOnboardingProgress, + usePersistOnboardingProgress, +} from "./onboardingProgress"; + +const fetchWithErrorHandling = vi.hoisted(() => + vi.fn<(url: string, options?: RequestInit) => Promise>(() => + Promise.resolve({}), + ), +); + +vi.mock("@/utils/fetchWithErrorHandling", () => ({ + fetchWithErrorHandling: (url: string, options?: RequestInit) => + fetchWithErrorHandling(url, options), +})); + +let backend = { available: true, backendUrl: "https://backend.example" }; + +vi.mock("@/providers/BackendProvider", () => ({ + useBackend: () => backend, +})); + +function makeWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return function Wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); + }; +} + +function render() { + return renderHook( + () => ({ + progress: useOnboardingProgress(), + persist: usePersistOnboardingProgress(), + }), + { wrapper: makeWrapper() }, + ); +} + +const progressWithDocs: OnboardingProgress = { + steps: { + read_docs: true, + complete_tour: false, + create_pipeline: false, + execute_run: false, + }, + dismissed: false, +}; + +beforeEach(() => { + vi.clearAllMocks(); + fetchWithErrorHandling.mockResolvedValue({}); + backend = { available: true, backendUrl: "https://backend.example" }; +}); + +describe("parseProgress", () => { + it("defaults missing or unknown fields", () => { + expect(parseProgress(undefined)).toEqual(emptyProgress()); + expect(parseProgress("not json")).toEqual(emptyProgress()); + expect(parseProgress(42)).toEqual(emptyProgress()); + }); + + it("coerces step flags to booleans and ignores unknown keys", () => { + const parsed = parseProgress({ + steps: { read_docs: true, create_pipeline: "yes", bogus: true }, + dismissed: true, + }); + expect(parsed.steps).toEqual({ + read_docs: true, + complete_tour: false, + create_pipeline: false, + execute_run: false, + }); + expect(parsed.dismissed).toBe(true); + }); + + it("parses a JSON string payload", () => { + const parsed = parseProgress( + JSON.stringify({ steps: { complete_tour: true }, dismissed: false }), + ); + expect(parsed.steps.complete_tour).toBe(true); + }); +}); + +describe("onboardingProgress hooks (backend)", () => { + it("reads progress from the settings endpoint", async () => { + fetchWithErrorHandling.mockResolvedValueOnce({ + onboarding: { steps: { read_docs: true }, dismissed: false }, + }); + + const { result } = render(); + + await waitFor(() => + expect(result.current.progress.data?.steps.read_docs).toBe(true), + ); + }); + + it("persists by PATCHing the settings endpoint and flips the cache", async () => { + const { result } = render(); + await waitFor(() => expect(result.current.progress.isSuccess).toBe(true)); + + act(() => result.current.persist(progressWithDocs)); + + await waitFor(() => + expect(result.current.progress.data?.steps.read_docs).toBe(true), + ); + + const patchCall = fetchWithErrorHandling.mock.calls.find( + ([, options]) => options?.method === "PATCH", + ); + expect(patchCall?.[0]).toBe( + "https://backend.example/api/users/me/settings", + ); + const body = JSON.parse(patchCall?.[1]?.body as string); + expect(body.settings.onboarding.steps.read_docs).toBe(true); + expect(patchCall?.[1]?.keepalive).toBe(true); + }); +}); + +describe("onboardingProgress hooks (offline)", () => { + it("neither fetches nor persists when no backend is available", async () => { + backend = { available: false, backendUrl: "" }; + const { result } = render(); + + expect(result.current.progress.fetchStatus).toBe("idle"); + + act(() => result.current.persist(progressWithDocs)); + + expect(fetchWithErrorHandling).not.toHaveBeenCalled(); + }); +}); diff --git a/src/providers/OnboardingProvider/onboardingProgress.ts b/src/providers/OnboardingProvider/onboardingProgress.ts new file mode 100644 index 000000000..a6e29d0b9 --- /dev/null +++ b/src/providers/OnboardingProvider/onboardingProgress.ts @@ -0,0 +1,112 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { useBackend } from "@/providers/BackendProvider"; +import { USER_SETTINGS_PATH } from "@/utils/constants"; +import { fetchWithErrorHandling } from "@/utils/fetchWithErrorHandling"; + +import { ONBOARDING_STEP_IDS, type OnboardingStepId } from "./steps"; + +export type OnboardingSteps = Record; + +export interface OnboardingProgress { + steps: OnboardingSteps; + dismissed: boolean; +} + +const ONBOARDING_KEY = "onboarding"; +const QUERY_KEY = "onboardingProgress"; +const STALE_MS = 1000 * 60 * 5; + +function emptySteps(): OnboardingSteps { + return Object.fromEntries( + ONBOARDING_STEP_IDS.map((id) => [id, false]), + ) as OnboardingSteps; +} + +export function emptyProgress(): OnboardingProgress { + return { steps: emptySteps(), dismissed: false }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function parseProgress(value: unknown): OnboardingProgress { + let raw = value; + if (typeof raw === "string") { + try { + raw = JSON.parse(raw); + } catch { + return emptyProgress(); + } + } + if (!isRecord(raw)) return emptyProgress(); + + const rawSteps = isRecord(raw.steps) ? raw.steps : {}; + const steps = emptySteps(); + for (const id of ONBOARDING_STEP_IDS) { + steps[id] = rawSteps[id] === true; + } + + return { + steps, + dismissed: raw.dismissed === true, + }; +} + +function extractProgress(payload: unknown): OnboardingProgress { + if (!isRecord(payload)) return emptyProgress(); + const wrapped = isRecord(payload.settings) + ? payload.settings[ONBOARDING_KEY] + : undefined; + return parseProgress(payload[ONBOARDING_KEY] ?? wrapped); +} + +async function fetchProgress(backendUrl: string): Promise { + const url = new URL(USER_SETTINGS_PATH, backendUrl); + url.searchParams.set("setting_names", ONBOARDING_KEY); + const payload = await fetchWithErrorHandling(url.toString()); + return extractProgress(payload); +} + +function queryKey(backendUrl: string) { + return [QUERY_KEY, backendUrl] as const; +} + +export function useOnboardingProgress({ enabled = true } = {}) { + const { available, backendUrl } = useBackend(); + const hasBackend = available && Boolean(backendUrl); + + return useQuery({ + queryKey: queryKey(backendUrl), + queryFn: () => fetchProgress(backendUrl), + enabled: hasBackend && enabled, + staleTime: STALE_MS, + refetchOnWindowFocus: false, + }); +} + +export function usePersistOnboardingProgress() { + const queryClient = useQueryClient(); + const { available, backendUrl } = useBackend(); + const hasBackend = available && Boolean(backendUrl); + + const { mutate } = useMutation({ + mutationFn: async (next: OnboardingProgress) => { + const url = new URL(USER_SETTINGS_PATH, backendUrl); + await fetchWithErrorHandling(url.toString(), { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ settings: { [ONBOARDING_KEY]: next } }), + // Often fired just before a navigation/reload; survive page unload. + keepalive: true, + }); + }, + }); + + return (next: OnboardingProgress) => { + if (!hasBackend) return; + queryClient.setQueryData(queryKey(backendUrl), next); + mutate(next); + }; +} diff --git a/src/providers/OnboardingProvider/onboardingSteps.json b/src/providers/OnboardingProvider/onboardingSteps.json new file mode 100644 index 000000000..58822e347 --- /dev/null +++ b/src/providers/OnboardingProvider/onboardingSteps.json @@ -0,0 +1,30 @@ +[ + { + "id": "read_docs", + "label": "Read the documentation", + "description": "Skim the docs to get familiar with Tangle's core concepts.", + "icon": "BookOpen", + "cta": { "label": "Browse docs", "to": "/learn" } + }, + { + "id": "complete_tour", + "label": "Take a guided tour", + "description": "Follow an interactive walkthrough right inside the editor.", + "icon": "Compass", + "cta": { "label": "Start a tour", "to": "/learn/tours" } + }, + { + "id": "create_pipeline", + "label": "Create a pipeline", + "description": "Edit an existing pipeline or create a new one from scratch.", + "icon": "Workflow", + "cta": { "label": "Browse examples", "to": "/learn/examples" } + }, + { + "id": "execute_run", + "label": "Run a pipeline", + "description": "Execute a pipeline and watch it run end to end.", + "icon": "Play", + "cta": { "label": "View runs", "to": "/runs" } + } +] diff --git a/src/providers/OnboardingProvider/steps.test.ts b/src/providers/OnboardingProvider/steps.test.ts new file mode 100644 index 000000000..319ca92e3 --- /dev/null +++ b/src/providers/OnboardingProvider/steps.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { ONBOARDING_STEP_IDS, ONBOARDING_STEPS } from "./steps"; + +describe("onboarding steps", () => { + it("defines exactly one step per id, in canonical order", () => { + expect(ONBOARDING_STEPS.map((step) => step.id)).toEqual([ + ...ONBOARDING_STEP_IDS, + ]); + }); + + it("gives every step the metadata the UI needs", () => { + for (const step of ONBOARDING_STEPS) { + expect(step.label).toBeTruthy(); + expect(step.description).toBeTruthy(); + expect(step.icon).toBeTruthy(); + expect(step.cta.label).toBeTruthy(); + expect(step.cta.to).toBeTruthy(); + } + }); +}); diff --git a/src/providers/OnboardingProvider/steps.ts b/src/providers/OnboardingProvider/steps.ts new file mode 100644 index 000000000..0096c38c2 --- /dev/null +++ b/src/providers/OnboardingProvider/steps.ts @@ -0,0 +1,54 @@ +import type { IconName } from "@/components/ui/icon"; + +import rawSteps from "./onboardingSteps.json"; + +export const ONBOARDING_STEP_IDS = [ + "read_docs", + "complete_tour", + "create_pipeline", + "execute_run", +] as const; + +export type OnboardingStepId = (typeof ONBOARDING_STEP_IDS)[number]; + +interface OnboardingStepCta { + label: string; + to: string; +} + +export interface OnboardingStepMeta { + id: OnboardingStepId; + label: string; + description: string; + icon: IconName; + cta: OnboardingStepCta; +} + +function isStepId(value: string): value is OnboardingStepId { + return ONBOARDING_STEP_IDS.some((id) => id === value); +} + +function parseSteps(raw: typeof rawSteps): OnboardingStepMeta[] { + const byId = new Map(); + for (const step of raw) { + if (!isStepId(step.id)) { + throw new Error( + `Unknown onboarding step id "${step.id}" in onboardingSteps.json. ` + + `Expected one of: ${ONBOARDING_STEP_IDS.join(", ")}.`, + ); + } + byId.set(step.id, { ...step, id: step.id, icon: step.icon as IconName }); + } + + return ONBOARDING_STEP_IDS.map((id) => { + const step = byId.get(id); + if (!step) { + throw new Error( + `Missing onboarding step "${id}" in onboardingSteps.json.`, + ); + } + return step; + }); +} + +export const ONBOARDING_STEPS: OnboardingStepMeta[] = parseSteps(rawSteps); diff --git a/src/providers/TourProvider/tourCompletion.ts b/src/providers/TourProvider/tourCompletion.ts index e17ef6272..f00906f47 100644 --- a/src/providers/TourProvider/tourCompletion.ts +++ b/src/providers/TourProvider/tourCompletion.ts @@ -1,6 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useBackend } from "@/providers/BackendProvider"; +import { USER_SETTINGS_PATH } from "@/utils/constants"; import { fetchWithErrorHandling } from "@/utils/fetchWithErrorHandling"; interface TourCompletionRecord { @@ -10,7 +11,6 @@ interface TourCompletionRecord { type TourCompletionMap = Record; -const SETTINGS_PATH = "/api/users/me/settings"; const COMPLETED_TOURS_KEY = "completed_tours"; const QUERY_KEY = "tourCompletions"; const STALE_MS = 1000 * 60 * 5; @@ -57,7 +57,7 @@ function extractCompletedTours(payload: unknown): TourCompletionMap { async function fetchCompletions( backendUrl: string, ): Promise { - const url = new URL(SETTINGS_PATH, backendUrl); + const url = new URL(USER_SETTINGS_PATH, backendUrl); url.searchParams.set("setting_names", COMPLETED_TOURS_KEY); const payload = await fetchWithErrorHandling(url.toString()); return extractCompletedTours(payload); @@ -94,7 +94,7 @@ export function useRecordTourCompletion() { const { mutate } = useMutation({ mutationFn: async (next: TourCompletionMap) => { - const url = new URL(SETTINGS_PATH, backendUrl); + const url = new URL(USER_SETTINGS_PATH, backendUrl); await fetchWithErrorHandling(url.toString(), { method: "PATCH", headers: { "Content-Type": "application/json" }, diff --git a/src/routes/Dashboard/Learn/LearnHomeView.test.tsx b/src/routes/Dashboard/Learn/LearnHomeView.test.tsx index 4563b3367..ebf94b5f6 100644 --- a/src/routes/Dashboard/Learn/LearnHomeView.test.tsx +++ b/src/routes/Dashboard/Learn/LearnHomeView.test.tsx @@ -4,6 +4,8 @@ import { cleanup, render } from "@testing-library/react"; import type { ReactElement, ReactNode } from "react"; import { afterEach, describe, expect, test, vi } from "vitest"; +import { OnboardingProvider } from "@/providers/OnboardingProvider/OnboardingProvider"; + import { LearnHomeView } from "./LearnHomeView"; vi.mock("@tanstack/react-router", async (importOriginal) => ({ @@ -41,7 +43,9 @@ const queryClient = new QueryClient({ const renderWithClient = (component: ReactElement) => render( - {component}, + + {component} + , ); describe("", () => { @@ -64,7 +68,7 @@ describe("", () => { ).toBeInTheDocument(); }); - test.skip("renders the onboarding hero with progress", () => { + test("renders the onboarding hero with progress", () => { renderWithClient(); expect( screen.getByRole("heading", { level: 2, name: /welcome to tangle/i }), diff --git a/src/routes/Dashboard/Learn/LearnHomeView.tsx b/src/routes/Dashboard/Learn/LearnHomeView.tsx index 771922be8..8d71fe93d 100644 --- a/src/routes/Dashboard/Learn/LearnHomeView.tsx +++ b/src/routes/Dashboard/Learn/LearnHomeView.tsx @@ -8,12 +8,10 @@ import { LearnSearchBar } from "@/components/Learn/LearnSearchBar"; import { OnboardingHero } from "@/components/Learn/OnboardingHero"; import { TipOfTheDay } from "@/components/Learn/TipOfTheDay"; import { BlockStack } from "@/components/ui/layout"; - -// Learning Hub Milestone 1: Documentation, FAQ, Example Pipelines & Tips -// Not included: Guided Tours & Onboarding -const SHOW_WIP_FEATURES = false; +import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider"; export function LearnHomeView() { + const { dismissed } = useOnboarding(); return ( @@ -28,7 +26,7 @@ export function LearnHomeView() { - {SHOW_WIP_FEATURES && } + {!dismissed && }
@@ -45,6 +43,8 @@ export function LearnHomeView() {
+ + {dismissed && }
); } diff --git a/src/routes/Settings/SettingsFlagsContext.tsx b/src/routes/Settings/SettingsFlagsContext.tsx index 7e4d5035e..0f75bd3de 100644 --- a/src/routes/Settings/SettingsFlagsContext.tsx +++ b/src/routes/Settings/SettingsFlagsContext.tsx @@ -8,10 +8,9 @@ import { } from "@/hooks/useRequiredContext"; import { useBackend } from "@/providers/BackendProvider"; import type { Flag } from "@/types/configuration"; +import { USER_SETTINGS_PATH } from "@/utils/constants"; import { fetchWithErrorHandling } from "@/utils/fetchWithErrorHandling"; -const USER_SETTINGS_URL = "/api/users/me/settings"; - interface SettingsFlagsContextValue { betaFlags: Flag[]; settings: Flag[]; @@ -30,7 +29,7 @@ export function SettingsFlagsProvider({ children }: { children: ReactNode }) { dispatch({ type: "setFlag", payload: { key: flag, enabled } }); if (available) { - const url = new URL(USER_SETTINGS_URL, backendUrl); + const url = new URL(USER_SETTINGS_PATH, backendUrl); fetchWithErrorHandling(url.toString(), { method: "PATCH", headers: { "Content-Type": "application/json" }, diff --git a/src/services/pipelineStorage/PipelineFile.ts b/src/services/pipelineStorage/PipelineFile.ts index bec305024..ca3c6adb3 100644 --- a/src/services/pipelineStorage/PipelineFile.ts +++ b/src/services/pipelineStorage/PipelineFile.ts @@ -1,5 +1,7 @@ import { action, makeObservable, observable, runInAction } from "mobx"; +import { emitUserPipelineWritten } from "@/utils/userPipelineWriteEvents"; + import { emitPipelineFileChanged } from "./pipelineFileEvents"; import type { PipelineFolder } from "./PipelineFolder"; import { deleteEntry, updateEntry } from "./pipelineRegistry"; @@ -37,6 +39,7 @@ export class PipelineFile { async write(content: string): Promise { await this.folder.driver.write(this.storageKey, content); emitPipelineFileChanged({ storageKey: this.storageKey, source: "v2" }); + emitUserPipelineWritten(); } @action diff --git a/src/utils/componentStore.ts b/src/utils/componentStore.ts index 5f01b5d85..a5eb17bbd 100644 --- a/src/utils/componentStore.ts +++ b/src/utils/componentStore.ts @@ -5,8 +5,12 @@ import { fetchComponentTextFromUrl } from "@/services/componentService"; import type { DownloadDataType } from "./cache"; import { downloadDataWithCache } from "./cache"; import type { ComponentReference, ComponentSpec } from "./componentSpec"; -import { USER_COMPONENTS_LIST_NAME } from "./constants"; +import { + USER_COMPONENTS_LIST_NAME, + USER_PIPELINES_LIST_NAME, +} from "./constants"; import { getIdOrTitleFromPath } from "./URL"; +import { emitUserPipelineWritten } from "./userPipelineWriteEvents"; import { componentSpecFromYaml, componentSpecToYaml } from "./yaml"; // IndexedDB: DB and table names @@ -500,7 +504,13 @@ export const writeComponentToFileListFromText = async ( componentText: string | ArrayBuffer, ) => { const componentRef = await storeComponentText(componentText); - return writeComponentRefToFile(listName, fileName, componentRef); + const result = await writeComponentRefToFile( + listName, + fileName, + componentRef, + ); + if (listName === USER_PIPELINES_LIST_NAME) emitUserPipelineWritten(); + return result; }; export const renameComponentFileInList = async ( diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 1ed15666f..d2cbe36da 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -20,6 +20,8 @@ export const DOCUMENTATION_URL = export const API_URL = import.meta.env.VITE_BACKEND_API_URL || ""; export const BASE_URL = import.meta.env.VITE_BASE_URL || "/"; + +export const USER_SETTINGS_PATH = "/api/users/me/settings"; export const IS_GITHUB_PAGES = import.meta.env.VITE_GITHUB_PAGES === "true"; export const GIT_REPO_URL = diff --git a/src/utils/userPipelineWriteEvents.ts b/src/utils/userPipelineWriteEvents.ts new file mode 100644 index 000000000..1d39ae237 --- /dev/null +++ b/src/utils/userPipelineWriteEvents.ts @@ -0,0 +1,11 @@ +const target = new EventTarget(); +const EVENT_NAME = "written"; + +export function emitUserPipelineWritten(): void { + target.dispatchEvent(new Event(EVENT_NAME)); +} + +export function subscribeUserPipelineWritten(listener: () => void): () => void { + target.addEventListener(EVENT_NAME, listener); + return () => target.removeEventListener(EVENT_NAME, listener); +}