From 26c68e4157a6bd494fc8a79ca50311a7cd047d7e Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Fri, 19 Jun 2026 08:49:45 -0700 Subject: [PATCH 1/2] feat: Onboarding Welcome Page --- .../Onboarding/IndexRedirect.test.tsx | 43 +++++++++++++++++++ src/components/Onboarding/IndexRedirect.tsx | 26 +++++++++++ .../Onboarding/OnboardingWelcome.tsx | 35 +++++++++++++++ src/routes/Dashboard/DashboardLayout.tsx | 25 ++++++++++- src/routes/appRoutes.ts | 3 +- src/routes/router.ts | 16 +++++++ tests/e2e/navigation-tracking.spec.ts | 37 +++++++--------- 7 files changed, 161 insertions(+), 24 deletions(-) create mode 100644 src/components/Onboarding/IndexRedirect.test.tsx create mode 100644 src/components/Onboarding/IndexRedirect.tsx create mode 100644 src/components/Onboarding/OnboardingWelcome.tsx diff --git a/src/components/Onboarding/IndexRedirect.test.tsx b/src/components/Onboarding/IndexRedirect.test.tsx new file mode 100644 index 000000000..b39a726b9 --- /dev/null +++ b/src/components/Onboarding/IndexRedirect.test.tsx @@ -0,0 +1,43 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { IndexRedirect } from "./IndexRedirect"; + +vi.mock("@tanstack/react-router", () => ({ + Navigate: ({ to }: { to: string }) =>
{to}
, +})); + +let onboarding = { isReady: true, isComplete: false, dismissed: false }; +vi.mock("@/providers/OnboardingProvider/OnboardingProvider", () => ({ + useOnboarding: () => onboarding, +})); + +afterEach(cleanup); + +const target = () => screen.queryByTestId("navigate"); + +describe("IndexRedirect", () => { + it("waits (no redirect) until onboarding state is ready", () => { + onboarding = { isReady: false, isComplete: false, dismissed: false }; + render(); + expect(target()).toBeNull(); + }); + + it("redirects to /welcome while onboarding is active", () => { + onboarding = { isReady: true, isComplete: false, dismissed: false }; + render(); + expect(target()).toHaveTextContent("/welcome"); + }); + + it("redirects to /dashboard once complete", () => { + onboarding = { isReady: true, isComplete: true, dismissed: false }; + render(); + expect(target()).toHaveTextContent("/dashboard"); + }); + + it("redirects to /dashboard once dismissed", () => { + onboarding = { isReady: true, isComplete: false, dismissed: true }; + render(); + expect(target()).toHaveTextContent("/dashboard"); + }); +}); diff --git a/src/components/Onboarding/IndexRedirect.tsx b/src/components/Onboarding/IndexRedirect.tsx new file mode 100644 index 000000000..1923b0df5 --- /dev/null +++ b/src/components/Onboarding/IndexRedirect.tsx @@ -0,0 +1,26 @@ +import { Navigate } from "@tanstack/react-router"; + +import { BlockStack } from "@/components/ui/layout"; +import { Spinner } from "@/components/ui/spinner"; +import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider"; +import { APP_ROUTES } from "@/routes/appRoutes"; + +export function IndexRedirect() { + const { isReady, isComplete, dismissed } = useOnboarding(); + + if (!isReady) { + return ( + + + + ); + } + + const showOnboarding = !isComplete && !dismissed; + return ( + + ); +} diff --git a/src/components/Onboarding/OnboardingWelcome.tsx b/src/components/Onboarding/OnboardingWelcome.tsx new file mode 100644 index 000000000..3eb0cc031 --- /dev/null +++ b/src/components/Onboarding/OnboardingWelcome.tsx @@ -0,0 +1,35 @@ +import { Link, Navigate } from "@tanstack/react-router"; + +import { OnboardingHero } from "@/components/Learn/OnboardingHero"; +import { BlockStack } from "@/components/ui/layout"; +import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider"; +import { APP_ROUTES } from "@/routes/router"; +import { tracking } from "@/utils/tracking"; + +export function OnboardingWelcome() { + const { isReady, isComplete, dismissed } = useOnboarding(); + + if (isReady && (isComplete || dismissed)) { + return ; + } + + return ( + +
+ +
+ + Explore the Learning Hub โ†’ + +
+ ); +} diff --git a/src/routes/Dashboard/DashboardLayout.tsx b/src/routes/Dashboard/DashboardLayout.tsx index 298b0d682..1a2ba75c6 100644 --- a/src/routes/Dashboard/DashboardLayout.tsx +++ b/src/routes/Dashboard/DashboardLayout.tsx @@ -9,6 +9,7 @@ import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Link as UILink } from "@/components/ui/link"; import { Text } from "@/components/ui/typography"; import { cn } from "@/lib/utils"; +import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider"; import { APP_ROUTES } from "@/routes/appRoutes"; import { ABOUT_URL, @@ -28,7 +29,12 @@ interface SidebarItem { } const BASE_SIDEBAR_ITEMS: SidebarItem[] = [ - { to: "/", label: "My Dashboard", icon: "LayoutDashboard", exact: true }, + { + to: APP_ROUTES.DASHBOARD, + label: "My Dashboard", + icon: "LayoutDashboard", + exact: true, + }, { to: "/pipelines", label: "My Pipelines", icon: "GitBranch" }, { to: "/runs", label: "All Runs", icon: "Play" }, { to: "/components", label: "Components", icon: "Package" }, @@ -53,7 +59,10 @@ export function DashboardLayout() { const requiresAuthorization = isAuthorizationRequired(); const isComponentSearchEnabled = useFlagValue("component-search-v2"); - const sidebarItems = isComponentSearchEnabled + const { isComplete, dismissed, isResolved } = useOnboarding(); + const showOnboarding = isResolved && !isComplete && !dismissed; + + const baseItems = isComponentSearchEnabled ? BASE_SIDEBAR_ITEMS.map((item) => item.to === APP_ROUTES.DASHBOARD_COMPONENTS ? COMPONENT_SEARCH_ITEM @@ -61,6 +70,18 @@ export function DashboardLayout() { ) : BASE_SIDEBAR_ITEMS; + const sidebarItems: SidebarItem[] = showOnboarding + ? [ + { + to: APP_ROUTES.WELCOME, + label: "Get started", + icon: "Rocket", + exact: true, + }, + ...baseItems, + ] + : baseItems; + return (
dashboardRoute, path: "/", + component: IndexRedirect, +}); + +const dashboardHomeRoute = createRoute({ + getParentRoute: () => dashboardRoute, + path: APP_ROUTES.DASHBOARD, component: DashboardHomeView, }); +const welcomeRoute = createRoute({ + getParentRoute: () => dashboardRoute, + path: APP_ROUTES.WELCOME, + component: OnboardingWelcome, +}); + const dashboardRunsRoute = createRoute({ getParentRoute: () => dashboardRoute, path: "/runs", @@ -329,6 +343,8 @@ const artifactPreviewRoute = createRoute({ const dashboardRouteTree = dashboardRoute.addChildren([ dashboardIndexRoute, + dashboardHomeRoute, + welcomeRoute, dashboardRunsRoute, dashboardPipelinesRoute, dashboardComponentsRoute, diff --git a/tests/e2e/navigation-tracking.spec.ts b/tests/e2e/navigation-tracking.spec.ts index 90dbb376e..adc2aa0a0 100644 --- a/tests/e2e/navigation-tracking.spec.ts +++ b/tests/e2e/navigation-tracking.spec.ts @@ -5,12 +5,10 @@ test.describe("Navigation tracking", () => { page, }) => { await page.addInitScript(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any (window as any).__analyticsEvents = []; window.addEventListener("tangle.analytics.track", (e) => { const detail = (e as CustomEvent).detail; if (detail.actionType === "page_view") { - // eslint-disable-next-line @typescript-eslint/no-explicit-any (window as any).__analyticsEvents.push(detail); } }); @@ -24,26 +22,28 @@ test.describe("Navigation tracking", () => { await page.getByRole("link", { name: "Settings" }).click(); await expect(page).toHaveURL(/\/settings/); - const events = await page.evaluate( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - () => (window as any).__analyticsEvents, - ); + const events = await page.evaluate(() => (window as any).__analyticsEvents); expect(events.length).toBeGreaterThanOrEqual(2); - const [initial, navigation] = events; - - // First page_view from landing on / - expect(initial.actionType).toBe("page_view"); - expect(initial.metadata).toMatchObject({ - to: "/", + const landing = events.find( + (e: { metadata?: { to?: string } }) => + e.metadata?.to === "/welcome" || e.metadata?.to === "/dashboard", + ); + expect(landing).toBeTruthy(); + expect(landing.metadata).toMatchObject({ + to: expect.stringMatching(/^\/(welcome|dashboard)$/), route_pattern: expect.any(String), }); - // Second page_view from navigating to settings - expect(navigation.actionType).toBe("page_view"); + // Navigating to settings emits a page_view. + const navigation = events.find( + (e: { metadata?: { to?: string } }) => + typeof e.metadata?.to === "string" && + e.metadata.to.includes("/settings"), + ); + expect(navigation).toBeTruthy(); expect(navigation.metadata).toMatchObject({ - from: "/", to: expect.stringContaining("/settings"), route_pattern: expect.any(String), }); @@ -51,12 +51,10 @@ test.describe("Navigation tracking", () => { test("captures search params in page_view metadata", async ({ page }) => { await page.addInitScript(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any (window as any).__analyticsEvents = []; window.addEventListener("tangle.analytics.track", (e) => { const detail = (e as CustomEvent).detail; if (detail.actionType === "page_view") { - // eslint-disable-next-line @typescript-eslint/no-explicit-any (window as any).__analyticsEvents.push(detail); } }); @@ -67,10 +65,7 @@ test.describe("Navigation tracking", () => { page.locator("[data-testid='app-menu-actions']"), ).toBeVisible(); - const events = await page.evaluate( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - () => (window as any).__analyticsEvents, - ); + const events = await page.evaluate(() => (window as any).__analyticsEvents); expect(events.length).toBeGreaterThanOrEqual(1); From eb185a25f17e21fac207c4fcff7278c8c75263d7 Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Fri, 19 Jun 2026 16:26:29 -0700 Subject: [PATCH 2/2] address pr feedback - Gate OnboardingWelcome on !isReady with a spinner (match IndexRedirect) - Import APP_ROUTES from appRoutes instead of the router shim Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Onboarding/IndexRedirect.test.tsx | 20 ++++----- src/components/Onboarding/IndexRedirect.tsx | 7 ++-- .../Onboarding/OnboardingNavPill.test.tsx | 20 +++------ .../Onboarding/OnboardingNavPill.tsx | 5 +-- .../Onboarding/OnboardingWelcome.test.tsx | 41 +++++++++++++++++++ .../Onboarding/OnboardingWelcome.tsx | 15 +++++-- src/providers/BackendProvider.tsx | 2 + .../OnboardingProvider.test.tsx | 11 +++++ .../OnboardingProvider/OnboardingProvider.tsx | 18 +++++++- src/providers/TourProvider/tourCompletion.ts | 6 --- src/routes/Dashboard/DashboardLayout.tsx | 7 ++-- .../Dashboard/Learn/LearnHomeView.test.tsx | 10 ++--- src/routes/Dashboard/Learn/LearnHomeView.tsx | 6 ++- 13 files changed, 111 insertions(+), 57 deletions(-) create mode 100644 src/components/Onboarding/OnboardingWelcome.test.tsx diff --git a/src/components/Onboarding/IndexRedirect.test.tsx b/src/components/Onboarding/IndexRedirect.test.tsx index b39a726b9..17ce65d0e 100644 --- a/src/components/Onboarding/IndexRedirect.test.tsx +++ b/src/components/Onboarding/IndexRedirect.test.tsx @@ -7,7 +7,7 @@ vi.mock("@tanstack/react-router", () => ({ Navigate: ({ to }: { to: string }) =>
{to}
, })); -let onboarding = { isReady: true, isComplete: false, dismissed: false }; +let onboarding = { isResolved: true, shouldShowOnboarding: true }; vi.mock("@/providers/OnboardingProvider/OnboardingProvider", () => ({ useOnboarding: () => onboarding, })); @@ -17,26 +17,20 @@ afterEach(cleanup); const target = () => screen.queryByTestId("navigate"); describe("IndexRedirect", () => { - it("waits (no redirect) until onboarding state is ready", () => { - onboarding = { isReady: false, isComplete: false, dismissed: false }; + it("waits (no redirect) until onboarding state is resolved", () => { + onboarding = { isResolved: false, shouldShowOnboarding: false }; render(); expect(target()).toBeNull(); }); - it("redirects to /welcome while onboarding is active", () => { - onboarding = { isReady: true, isComplete: false, dismissed: false }; + it("redirects to /welcome while onboarding should show", () => { + onboarding = { isResolved: true, shouldShowOnboarding: true }; render(); expect(target()).toHaveTextContent("/welcome"); }); - it("redirects to /dashboard once complete", () => { - onboarding = { isReady: true, isComplete: true, dismissed: false }; - render(); - expect(target()).toHaveTextContent("/dashboard"); - }); - - it("redirects to /dashboard once dismissed", () => { - onboarding = { isReady: true, isComplete: false, dismissed: true }; + it("redirects to /dashboard when onboarding should not show", () => { + onboarding = { isResolved: true, shouldShowOnboarding: false }; render(); expect(target()).toHaveTextContent("/dashboard"); }); diff --git a/src/components/Onboarding/IndexRedirect.tsx b/src/components/Onboarding/IndexRedirect.tsx index 1923b0df5..39332ac75 100644 --- a/src/components/Onboarding/IndexRedirect.tsx +++ b/src/components/Onboarding/IndexRedirect.tsx @@ -6,9 +6,9 @@ import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider import { APP_ROUTES } from "@/routes/appRoutes"; export function IndexRedirect() { - const { isReady, isComplete, dismissed } = useOnboarding(); + const { isResolved, shouldShowOnboarding } = useOnboarding(); - if (!isReady) { + if (!isResolved) { return ( @@ -16,11 +16,10 @@ export function IndexRedirect() { ); } - const showOnboarding = !isComplete && !dismissed; return ( ); } diff --git a/src/components/Onboarding/OnboardingNavPill.test.tsx b/src/components/Onboarding/OnboardingNavPill.test.tsx index 48fbb8965..9e75e00c7 100644 --- a/src/components/Onboarding/OnboardingNavPill.test.tsx +++ b/src/components/Onboarding/OnboardingNavPill.test.tsx @@ -12,9 +12,7 @@ let onboarding = { steps: [], completedCount: 1, total: 4, - isComplete: false, - dismissed: false, - isResolved: true, + shouldShowOnboarding: true, markDocsRead: vi.fn(), }; vi.mock("@/providers/OnboardingProvider/OnboardingProvider", () => ({ @@ -26,9 +24,7 @@ function resetState() { steps: [], completedCount: 1, total: 4, - isComplete: false, - dismissed: false, - isResolved: true, + shouldShowOnboarding: true, markDocsRead: vi.fn(), }; } @@ -39,19 +35,13 @@ afterEach(cleanup); const pill = () => screen.queryByText(/Onboarding/); describe("OnboardingNavPill", () => { - it("shows progress while onboarding is in progress", () => { + it("shows progress while onboarding should show", () => { 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; + it("is hidden when onboarding should not show", () => { + onboarding.shouldShowOnboarding = false; render(); expect(pill()).toBeNull(); }); diff --git a/src/components/Onboarding/OnboardingNavPill.tsx b/src/components/Onboarding/OnboardingNavPill.tsx index 3e8d49f21..8a9601d38 100644 --- a/src/components/Onboarding/OnboardingNavPill.tsx +++ b/src/components/Onboarding/OnboardingNavPill.tsx @@ -12,10 +12,9 @@ import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider import { tracking } from "@/utils/tracking"; export function OnboardingNavPill() { - const { completedCount, total, isComplete, dismissed, isResolved } = - useOnboarding(); + const { completedCount, total, shouldShowOnboarding } = useOnboarding(); - if (!isResolved || isComplete || dismissed) { + if (!shouldShowOnboarding) { return null; } diff --git a/src/components/Onboarding/OnboardingWelcome.test.tsx b/src/components/Onboarding/OnboardingWelcome.test.tsx new file mode 100644 index 000000000..93d3c9631 --- /dev/null +++ b/src/components/Onboarding/OnboardingWelcome.test.tsx @@ -0,0 +1,41 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { OnboardingWelcome } from "./OnboardingWelcome"; + +vi.mock("@tanstack/react-router", () => ({ + Navigate: ({ to }: { to: string }) =>
{to}
, + Link: ({ children }: { children: React.ReactNode }) => {children}, +})); + +vi.mock("@/components/Learn/OnboardingHero", () => ({ + OnboardingHero: () =>
, +})); + +let onboarding = { isResolved: true, shouldShowOnboarding: true }; +vi.mock("@/providers/OnboardingProvider/OnboardingProvider", () => ({ + useOnboarding: () => onboarding, +})); + +afterEach(cleanup); + +describe("OnboardingWelcome", () => { + it("shows a spinner until onboarding state is resolved", () => { + onboarding = { isResolved: false, shouldShowOnboarding: false }; + render(); + expect(screen.queryByTestId("hero")).toBeNull(); + expect(screen.queryByTestId("navigate")).toBeNull(); + }); + + it("renders the welcome hero when onboarding should show", () => { + onboarding = { isResolved: true, shouldShowOnboarding: true }; + render(); + expect(screen.getByTestId("hero")).toBeInTheDocument(); + }); + + it("redirects to /dashboard when onboarding should not show", () => { + onboarding = { isResolved: true, shouldShowOnboarding: false }; + render(); + expect(screen.getByTestId("navigate")).toHaveTextContent("/dashboard"); + }); +}); diff --git a/src/components/Onboarding/OnboardingWelcome.tsx b/src/components/Onboarding/OnboardingWelcome.tsx index 3eb0cc031..a3c96a81f 100644 --- a/src/components/Onboarding/OnboardingWelcome.tsx +++ b/src/components/Onboarding/OnboardingWelcome.tsx @@ -2,14 +2,23 @@ import { Link, Navigate } from "@tanstack/react-router"; import { OnboardingHero } from "@/components/Learn/OnboardingHero"; import { BlockStack } from "@/components/ui/layout"; +import { Spinner } from "@/components/ui/spinner"; import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider"; -import { APP_ROUTES } from "@/routes/router"; +import { APP_ROUTES } from "@/routes/appRoutes"; import { tracking } from "@/utils/tracking"; export function OnboardingWelcome() { - const { isReady, isComplete, dismissed } = useOnboarding(); + const { isResolved, shouldShowOnboarding } = useOnboarding(); - if (isReady && (isComplete || dismissed)) { + if (!isResolved) { + return ( + + + + ); + } + + if (!shouldShowOnboarding) { return ; } diff --git a/src/providers/BackendProvider.tsx b/src/providers/BackendProvider.tsx index e9e405daa..0f98b1fc2 100644 --- a/src/providers/BackendProvider.tsx +++ b/src/providers/BackendProvider.tsx @@ -105,6 +105,7 @@ export const BackendProvider = ({ children }: { children: ReactNode }) => { if (!normalizedUrl) { if (notifyResult) notify("Backend is not configured", "error"); if (saveAvailability) setAvailable(false); + setReady(true); return Promise.resolve(false); } return fetch(`${normalizedUrl}/services/ping`) @@ -126,6 +127,7 @@ export const BackendProvider = ({ children }: { children: ReactNode }) => { .catch(() => { if (notifyResult) notify("Backend unavailable", "error"); if (saveAvailability) setAvailable(false); + setReady(true); return false; }); }, diff --git a/src/providers/OnboardingProvider/OnboardingProvider.test.tsx b/src/providers/OnboardingProvider/OnboardingProvider.test.tsx index de11ed013..2b4791166 100644 --- a/src/providers/OnboardingProvider/OnboardingProvider.test.tsx +++ b/src/providers/OnboardingProvider/OnboardingProvider.test.tsx @@ -111,6 +111,17 @@ describe("OnboardingProvider", () => { expect(patched()).toBe(false); }); + it("does not show onboarding when the backend is unavailable", async () => { + backend = { available: false, backendUrl: "https://backend.example" }; + + const { result } = render(); + + await waitFor(() => expect(result.current.isReady).toBe(true)); + expect(result.current.isOnboardingAvailable).toBe(false); + expect(result.current.shouldShowOnboarding).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" }] }; diff --git a/src/providers/OnboardingProvider/OnboardingProvider.tsx b/src/providers/OnboardingProvider/OnboardingProvider.tsx index 15f8c99dd..502753746 100644 --- a/src/providers/OnboardingProvider/OnboardingProvider.tsx +++ b/src/providers/OnboardingProvider/OnboardingProvider.tsx @@ -51,6 +51,8 @@ interface OnboardingContextValue { dismissed: boolean; isReady: boolean; isResolved: boolean; + isOnboardingAvailable: boolean; + shouldShowOnboarding: boolean; markDocsRead: () => void; dismiss: () => void; reopen: () => void; @@ -84,7 +86,13 @@ function useHasMyRun(): { export function OnboardingProvider({ children }: { children: ReactNode }) { const { track } = useAnalytics(); const notify = useToastNotification(); - const { ready: backendReady, configured } = useBackend(); + const { + ready: backendReady, + configured, + available, + backendUrl, + } = useBackend(); + const hasBackend = available && Boolean(backendUrl); const { data: progress, isLoading: progressLoading } = useOnboardingProgress(); const persist = usePersistOnboardingProgress(); @@ -105,8 +113,12 @@ export function OnboardingProvider({ children }: { children: ReactNode }) { }; const isComplete = ONBOARDING_STEP_IDS.every((id) => desiredSteps[id]); + const dismissed = progress?.dismissed ?? false; const isReady = !progressLoading && !toursLoading && !runsLoading; const isResolved = (backendReady || !configured) && isReady; + const isOnboardingAvailable = isResolved && hasBackend; + const shouldShowOnboarding = + isOnboardingAvailable && !isComplete && !dismissed; const [pipelineWriteCount, setPipelineWriteCount] = useState(0); @@ -166,9 +178,11 @@ export function OnboardingProvider({ children }: { children: ReactNode }) { completedCount: steps.filter((step) => step.completed).length, total: steps.length, isComplete, - dismissed: progress?.dismissed ?? false, + dismissed, isReady, isResolved, + isOnboardingAvailable, + shouldShowOnboarding, markDocsRead, dismiss, reopen, diff --git a/src/providers/TourProvider/tourCompletion.ts b/src/providers/TourProvider/tourCompletion.ts index 327743355..d1a0ab4e9 100644 --- a/src/providers/TourProvider/tourCompletion.ts +++ b/src/providers/TourProvider/tourCompletion.ts @@ -95,9 +95,6 @@ export function useRecordTourCompletion() { const { mutate } = useMutation({ mutationFn: async (tourId: string) => { const key = queryKey(backendUrl); - // Merge against the authoritative server map โ€” fetching it when the query - // hasn't loaded yet or previously failed โ€” so the PATCH can never replace - // saved completions with only the current tour. const current = await queryClient.ensureQueryData({ queryKey: key, queryFn: () => fetchCompletions(backendUrl), @@ -122,7 +119,6 @@ export function useRecordTourCompletion() { queryClient.setQueryData(queryKey(backendUrl), next); }, onError: () => { - // Our optimistic value may be wrong; re-sync from the server. void queryClient.invalidateQueries({ queryKey: queryKey(backendUrl) }); }, }); @@ -136,8 +132,6 @@ export function useRecordTourCompletion() { if (available && backendUrl) { mutate(tourId); } else { - // No backend: nothing to clobber, so keep the optimistic local cache - // update so the completion is reflected for the rest of the session. queryClient.setQueryData(key, { ...current, [tourId]: { completedAt: new Date().toISOString(), completionCount }, diff --git a/src/routes/Dashboard/DashboardLayout.tsx b/src/routes/Dashboard/DashboardLayout.tsx index 1a2ba75c6..b96c2db15 100644 --- a/src/routes/Dashboard/DashboardLayout.tsx +++ b/src/routes/Dashboard/DashboardLayout.tsx @@ -59,8 +59,7 @@ export function DashboardLayout() { const requiresAuthorization = isAuthorizationRequired(); const isComponentSearchEnabled = useFlagValue("component-search-v2"); - const { isComplete, dismissed, isResolved } = useOnboarding(); - const showOnboarding = isResolved && !isComplete && !dismissed; + const { shouldShowOnboarding } = useOnboarding(); const baseItems = isComponentSearchEnabled ? BASE_SIDEBAR_ITEMS.map((item) => @@ -70,11 +69,11 @@ export function DashboardLayout() { ) : BASE_SIDEBAR_ITEMS; - const sidebarItems: SidebarItem[] = showOnboarding + const sidebarItems: SidebarItem[] = shouldShowOnboarding ? [ { to: APP_ROUTES.WELCOME, - label: "Get started", + label: "Get Started", icon: "Rocket", exact: true, }, diff --git a/src/routes/Dashboard/Learn/LearnHomeView.test.tsx b/src/routes/Dashboard/Learn/LearnHomeView.test.tsx index ebf94b5f6..fe4d0f06a 100644 --- a/src/routes/Dashboard/Learn/LearnHomeView.test.tsx +++ b/src/routes/Dashboard/Learn/LearnHomeView.test.tsx @@ -68,14 +68,14 @@ describe("", () => { ).toBeInTheDocument(); }); - test("renders the onboarding hero with progress", () => { + test("hides the onboarding hero when no backend is available", () => { renderWithClient(); expect( - screen.getByRole("heading", { level: 2, name: /welcome to tangle/i }), - ).toBeInTheDocument(); + screen.queryByRole("heading", { level: 2, name: /welcome to tangle/i }), + ).not.toBeInTheDocument(); expect( - screen.getByRole("progressbar", { name: /onboarding progress/i }), - ).toBeInTheDocument(); + screen.queryByRole("progressbar", { name: /onboarding progress/i }), + ).not.toBeInTheDocument(); }); test("renders the docs quicklinks and full-docs link at the top", () => { diff --git a/src/routes/Dashboard/Learn/LearnHomeView.tsx b/src/routes/Dashboard/Learn/LearnHomeView.tsx index f74fff801..752a5b672 100644 --- a/src/routes/Dashboard/Learn/LearnHomeView.tsx +++ b/src/routes/Dashboard/Learn/LearnHomeView.tsx @@ -11,7 +11,7 @@ import { BlockStack } from "@/components/ui/layout"; import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider"; export function LearnHomeView() { - const { dismissed } = useOnboarding(); + const { dismissed, isOnboardingAvailable } = useOnboarding(); return ( @@ -26,7 +26,9 @@ export function LearnHomeView() { - + {isOnboardingAvailable && ( + + )}