diff --git a/src/components/Onboarding/IndexRedirect.test.tsx b/src/components/Onboarding/IndexRedirect.test.tsx new file mode 100644 index 000000000..17ce65d0e --- /dev/null +++ b/src/components/Onboarding/IndexRedirect.test.tsx @@ -0,0 +1,37 @@ +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 = { isResolved: true, shouldShowOnboarding: true }; +vi.mock("@/providers/OnboardingProvider/OnboardingProvider", () => ({ + useOnboarding: () => onboarding, +})); + +afterEach(cleanup); + +const target = () => screen.queryByTestId("navigate"); + +describe("IndexRedirect", () => { + it("waits (no redirect) until onboarding state is resolved", () => { + onboarding = { isResolved: false, shouldShowOnboarding: false }; + render(); + expect(target()).toBeNull(); + }); + + it("redirects to /welcome while onboarding should show", () => { + onboarding = { isResolved: true, shouldShowOnboarding: true }; + render(); + expect(target()).toHaveTextContent("/welcome"); + }); + + 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 new file mode 100644 index 000000000..39332ac75 --- /dev/null +++ b/src/components/Onboarding/IndexRedirect.tsx @@ -0,0 +1,25 @@ +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 { isResolved, shouldShowOnboarding } = useOnboarding(); + + if (!isResolved) { + return ( + + + + ); + } + + 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 new file mode 100644 index 000000000..a3c96a81f --- /dev/null +++ b/src/components/Onboarding/OnboardingWelcome.tsx @@ -0,0 +1,44 @@ +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/appRoutes"; +import { tracking } from "@/utils/tracking"; + +export function OnboardingWelcome() { + const { isResolved, shouldShowOnboarding } = useOnboarding(); + + if (!isResolved) { + return ( + + + + ); + } + + if (!shouldShowOnboarding) { + return ; + } + + return ( + +
+ +
+ + Explore the Learning Hub โ†’ + +
+ ); +} 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 298b0d682..b96c2db15 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,9 @@ export function DashboardLayout() { const requiresAuthorization = isAuthorizationRequired(); const isComponentSearchEnabled = useFlagValue("component-search-v2"); - const sidebarItems = isComponentSearchEnabled + const { shouldShowOnboarding } = useOnboarding(); + + const baseItems = isComponentSearchEnabled ? BASE_SIDEBAR_ITEMS.map((item) => item.to === APP_ROUTES.DASHBOARD_COMPONENTS ? COMPONENT_SEARCH_ITEM @@ -61,6 +69,18 @@ export function DashboardLayout() { ) : BASE_SIDEBAR_ITEMS; + const sidebarItems: SidebarItem[] = shouldShowOnboarding + ? [ + { + to: APP_ROUTES.WELCOME, + label: "Get Started", + icon: "Rocket", + exact: true, + }, + ...baseItems, + ] + : baseItems; + return (
", () => { ).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 && ( + + )}
diff --git a/src/routes/appRoutes.ts b/src/routes/appRoutes.ts index 231cb67fc..f278ca6a1 100644 --- a/src/routes/appRoutes.ts +++ b/src/routes/appRoutes.ts @@ -9,7 +9,8 @@ const TOUR_BASE_PATH = "/tour"; export const APP_ROUTES = { HOME: "/", - DASHBOARD: "/", + DASHBOARD: "/dashboard", + WELCOME: "/welcome", DASHBOARD_RUNS: "/runs", DASHBOARD_PIPELINES: "/pipelines", DASHBOARD_COMPONENTS: "/components", diff --git a/src/routes/router.ts b/src/routes/router.ts index f5b7d3ff6..94efa7673 100644 --- a/src/routes/router.ts +++ b/src/routes/router.ts @@ -7,6 +7,8 @@ import { redirect, } from "@tanstack/react-router"; +import { IndexRedirect } from "@/components/Onboarding/IndexRedirect"; +import { OnboardingWelcome } from "@/components/Onboarding/OnboardingWelcome"; import { ErrorPage } from "@/components/shared/ErrorPage"; import { AuthorizationResultScreen as GitHubAuthorizationResultScreen } from "@/components/shared/GitHubAuth/AuthorizationResultScreen"; import { AuthorizationResultScreen as HuggingFaceAuthorizationResultScreen } from "@/components/shared/HuggingFaceAuth/AuthorizationResultScreen"; @@ -79,9 +81,21 @@ const dashboardRoute = createRoute({ const dashboardIndexRoute = createRoute({ getParentRoute: () => 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);