Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/components/Onboarding/IndexRedirect.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => <div data-testid="navigate">{to}</div>,
}));

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(<IndexRedirect />);
expect(target()).toBeNull();
});

it("redirects to /welcome while onboarding should show", () => {
onboarding = { isResolved: true, shouldShowOnboarding: true };
render(<IndexRedirect />);
expect(target()).toHaveTextContent("/welcome");
});

it("redirects to /dashboard when onboarding should not show", () => {
onboarding = { isResolved: true, shouldShowOnboarding: false };
render(<IndexRedirect />);
expect(target()).toHaveTextContent("/dashboard");
});
});
25 changes: 25 additions & 0 deletions src/components/Onboarding/IndexRedirect.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<BlockStack align="center" inlineAlign="center" className="h-full">
Comment thread
camielvs marked this conversation as resolved.
<Spinner />
</BlockStack>
);
}

return (
<Navigate
replace
to={shouldShowOnboarding ? APP_ROUTES.WELCOME : APP_ROUTES.DASHBOARD}
/>
);
}
20 changes: 5 additions & 15 deletions src/components/Onboarding/OnboardingNavPill.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand All @@ -26,9 +24,7 @@ function resetState() {
steps: [],
completedCount: 1,
total: 4,
isComplete: false,
dismissed: false,
isResolved: true,
shouldShowOnboarding: true,
markDocsRead: vi.fn(),
};
}
Expand All @@ -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(<OnboardingNavPill />);
expect(pill()).toHaveTextContent("Onboarding · 1/4");
});

it("is hidden once onboarding is complete", () => {
onboarding.isComplete = true;
render(<OnboardingNavPill />);
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(<OnboardingNavPill />);
expect(pill()).toBeNull();
});
Expand Down
5 changes: 2 additions & 3 deletions src/components/Onboarding/OnboardingNavPill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
41 changes: 41 additions & 0 deletions src/components/Onboarding/OnboardingWelcome.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => <div data-testid="navigate">{to}</div>,
Link: ({ children }: { children: React.ReactNode }) => <a>{children}</a>,
}));

vi.mock("@/components/Learn/OnboardingHero", () => ({
OnboardingHero: () => <div data-testid="hero" />,
}));

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(<OnboardingWelcome />);
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(<OnboardingWelcome />);
expect(screen.getByTestId("hero")).toBeInTheDocument();
});

it("redirects to /dashboard when onboarding should not show", () => {
onboarding = { isResolved: true, shouldShowOnboarding: false };
render(<OnboardingWelcome />);
expect(screen.getByTestId("navigate")).toHaveTextContent("/dashboard");
});
});
44 changes: 44 additions & 0 deletions src/components/Onboarding/OnboardingWelcome.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<BlockStack align="center" inlineAlign="center" className="h-full">
<Spinner />
</BlockStack>
);
}

if (!shouldShowOnboarding) {
return <Navigate to={APP_ROUTES.DASHBOARD} replace />;
}

return (
<BlockStack
gap="4"
align="center"
inlineAlign="center"
className="h-full w-full"
>
<div className="w-full max-w-2xl">
<OnboardingHero />
</div>
<Link
to={APP_ROUTES.LEARN}
className="text-sm text-muted-foreground hover:text-foreground"
{...tracking("homepage.onboarding.learning_hub")}
>
Explore the Learning Hub →
</Link>
</BlockStack>
);
}
2 changes: 2 additions & 0 deletions src/providers/BackendProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand All @@ -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;
});
},
Expand Down
11 changes: 11 additions & 0 deletions src/providers/OnboardingProvider/OnboardingProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" }] };
Expand Down
18 changes: 16 additions & 2 deletions src/providers/OnboardingProvider/OnboardingProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ interface OnboardingContextValue {
dismissed: boolean;
isReady: boolean;
isResolved: boolean;
isOnboardingAvailable: boolean;
shouldShowOnboarding: boolean;
markDocsRead: () => void;
dismiss: () => void;
reopen: () => void;
Expand Down Expand Up @@ -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();
Expand All @@ -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);

Expand Down Expand Up @@ -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,
Expand Down
6 changes: 0 additions & 6 deletions src/providers/TourProvider/tourCompletion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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) });
},
});
Expand All @@ -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<TourCompletionMap>(key, {
...current,
[tourId]: { completedAt: new Date().toISOString(), completionCount },
Expand Down
24 changes: 22 additions & 2 deletions src/routes/Dashboard/DashboardLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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" },
Expand All @@ -53,14 +59,28 @@ 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
: item,
)
: BASE_SIDEBAR_ITEMS;

const sidebarItems: SidebarItem[] = shouldShowOnboarding
? [
{
to: APP_ROUTES.WELCOME,
label: "Get Started",
icon: "Rocket",
exact: true,
},
...baseItems,
]
: baseItems;

return (
<div
className="flex w-full overflow-hidden"
Expand Down
10 changes: 5 additions & 5 deletions src/routes/Dashboard/Learn/LearnHomeView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,14 @@ describe("<LearnHomeView/>", () => {
).toBeInTheDocument();
});

test("renders the onboarding hero with progress", () => {
test("hides the onboarding hero when no backend is available", () => {
renderWithClient(<LearnHomeView />);
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", () => {
Expand Down
Loading
Loading