Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/components/Learn/tours/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type TourStep = StepType &
resetLibrarySearch?: boolean;
ensureWindowRestored?: string;
requiresTaskSelected?: string;
anchorCreatedSubgraph?: boolean;
};

export interface TourDefinition {
Expand Down
3 changes: 2 additions & 1 deletion src/components/Learn/tours/subgraphs.tour.json
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,8 @@
{
"selector": "[data-tour-anchor=\"no-spotlight\"]",
"content": "You've built a new subgraph from scratch.\n\nReach for subgraphs whenever a section of your pipeline reads as a single logical step, or you find you are often repeating combinations of tasks. They keep the top level clean and turn complex workflows into reusable building blocks.",
"position": [500, 60]
"position": "center",
"anchorCreatedSubgraph": true
}
]
}
11 changes: 10 additions & 1 deletion src/components/Onboarding/OnboardingNavPill.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

Expand All @@ -14,6 +14,7 @@ let onboarding = {
total: 4,
shouldShowOnboarding: true,
markDocsRead: vi.fn(),
dismiss: vi.fn(),
};
vi.mock("@/providers/OnboardingProvider/OnboardingProvider", () => ({
useOnboarding: () => onboarding,
Expand All @@ -26,6 +27,7 @@ function resetState() {
total: 4,
shouldShowOnboarding: true,
markDocsRead: vi.fn(),
dismiss: vi.fn(),
};
}

Expand All @@ -45,4 +47,11 @@ describe("OnboardingNavPill", () => {
render(<OnboardingNavPill />);
expect(pill()).toBeNull();
});

it("dismisses onboarding from the popover footer", () => {
render(<OnboardingNavPill />);
fireEvent.click(screen.getByText("Onboarding · 1/4"));
fireEvent.click(screen.getByText("Dismiss onboarding"));
expect(onboarding.dismiss).toHaveBeenCalledOnce();
});
});
18 changes: 16 additions & 2 deletions src/components/Onboarding/OnboardingNavPill.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import { OnboardingChecklist } from "@/components/Onboarding/OnboardingChecklist";
import { Button } from "@/components/ui/button";
import { Icon } from "@/components/ui/icon";
import { BlockStack } from "@/components/ui/layout";
import { BlockStack, InlineStack } from "@/components/ui/layout";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { Heading } from "@/components/ui/typography";
import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider";
import { tracking } from "@/utils/tracking";

export function OnboardingNavPill() {
const { completedCount, total, shouldShowOnboarding } = useOnboarding();
const { completedCount, total, shouldShowOnboarding, dismiss } =
useOnboarding();

if (!shouldShowOnboarding) {
return null;
Expand All @@ -30,6 +32,18 @@ export function OnboardingNavPill() {
<BlockStack gap="3">
<Heading level={3}>Get started with Tangle</Heading>
<OnboardingChecklist />
<Separator />
<InlineStack align="end">
<Button
variant="ghost"
size="sm"
onClick={dismiss}
className="text-muted-foreground"
{...tracking("navigation.onboarding_pill.dismiss")}
>
Dismiss onboarding
</Button>
</InlineStack>
</BlockStack>
</PopoverContent>
</Popover>
Expand Down
25 changes: 25 additions & 0 deletions src/providers/OnboardingProvider/OnboardingProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,31 @@ describe("OnboardingProvider", () => {
});
});

it("tracks create_pipeline only once when progress reverts after completion", async () => {
const { result, queryClient } = renderWithClient();

await waitFor(() => expect(result.current.total).toBe(4));

act(() => emitUserPipelineWritten());
await waitFor(() =>
expect(completedSteps(result)).toContain("create_pipeline"),
);

await act(async () => {
await queryClient.invalidateQueries();
});
await waitFor(() =>
expect(completedSteps(result)).not.toContain("create_pipeline"),
);

const createPipelineTracks = track.mock.calls.filter(
([event, payload]) =>
event === "onboarding.step.completed" &&
(payload as { step_id?: string })?.step_id === "create_pipeline",
);
expect(createPipelineTracks).toHaveLength(1);
});

it("marks the docs step read on demand", async () => {
settingsPayload = { onboarding: { steps: { create_pipeline: true } } };
const { result } = render();
Expand Down
9 changes: 6 additions & 3 deletions src/providers/OnboardingProvider/OnboardingProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { type ReactNode, useEffect, useState } from "react";
import { type ReactNode, useEffect, useRef, useState } from "react";

import { useDocsVisitTracking } from "@/hooks/useDocsVisitTracking";
import {
Expand Down Expand Up @@ -121,6 +121,7 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
isOnboardingAvailable && !isComplete && !dismissed;

const [pipelineWriteCount, setPipelineWriteCount] = useState(0);
const pipelineCompletionFiredRef = useRef(false);

useEffect(
() =>
Expand All @@ -134,18 +135,20 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
if (
pipelineWriteCount === 0 ||
!progress ||
progress.steps.create_pipeline
progress.steps.create_pipeline ||
pipelineCompletionFiredRef.current
) {
return;
}
pipelineCompletionFiredRef.current = true;
persist({
...progress,
steps: { ...progress.steps, create_pipeline: true },
});
track("onboarding.step.completed", { step_id: "create_pipeline" });
}, [pipelineWriteCount, progress, persist, track]);

useStepCompletionToasts({ isResolved, desiredSteps, isComplete });
useStepCompletionToasts({ isOnboardingAvailable, desiredSteps, isComplete });

const markDocsRead = () => {
if (!progress || progress.steps.read_docs) return;
Expand Down
112 changes: 112 additions & 0 deletions src/providers/OnboardingProvider/useStepCompletionToasts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { stepLabel } from "./onboardingCompletion";
import type { OnboardingSteps } from "./onboardingProgress";
import { useStepCompletionToasts } from "./useStepCompletionToasts";

const notify = vi.fn();

vi.mock("@/hooks/useToastNotification", () => ({
default: () => notify,
}));

const NONE: OnboardingSteps = {
read_docs: false,
complete_tour: false,
create_pipeline: false,
execute_run: false,
};

const CREATED: OnboardingSteps = { ...NONE, create_pipeline: true };

const createdLabel = `Completed: ${stepLabel("create_pipeline")}`;

function setup(initial: {
isOnboardingAvailable: boolean;
desiredSteps: OnboardingSteps;
isComplete?: boolean;
}) {
return renderHook((props) => useStepCompletionToasts(props), {
initialProps: {
isOnboardingAvailable: initial.isOnboardingAvailable,
desiredSteps: initial.desiredSteps,
isComplete: initial.isComplete ?? false,
},
});
}

beforeEach(() => {
notify.mockClear();
});

afterEach(() => {
vi.clearAllMocks();
});

describe("useStepCompletionToasts", () => {
it("does not toast for steps already complete when the baseline seeds", () => {
setup({ isOnboardingAvailable: true, desiredSteps: CREATED });
expect(notify).not.toHaveBeenCalled();
});

it("toasts once when a step flips to complete", () => {
const { rerender } = setup({
isOnboardingAvailable: true,
desiredSteps: NONE,
});
rerender({
isOnboardingAvailable: true,
desiredSteps: { ...CREATED },
isComplete: false,
});
expect(notify).toHaveBeenCalledTimes(1);
expect(notify).toHaveBeenCalledWith(createdLabel, "success");
});

it("does not re-toast when a completed step reverts and completes again", () => {
const { rerender } = setup({
isOnboardingAvailable: true,
desiredSteps: NONE,
});
rerender({
isOnboardingAvailable: true,
desiredSteps: { ...CREATED },
isComplete: false,
});
rerender({
isOnboardingAvailable: true,
desiredSteps: { ...NONE },
isComplete: false,
});
rerender({
isOnboardingAvailable: true,
desiredSteps: { ...CREATED },
isComplete: false,
});
expect(notify).toHaveBeenCalledTimes(1);
});

it("does not re-toast after availability flaps and the step recompletes", () => {
const { rerender } = setup({
isOnboardingAvailable: true,
desiredSteps: NONE,
});
rerender({
isOnboardingAvailable: true,
desiredSteps: { ...CREATED },
isComplete: false,
});
rerender({
isOnboardingAvailable: false,
desiredSteps: { ...NONE },
isComplete: false,
});
rerender({
isOnboardingAvailable: true,
desiredSteps: { ...CREATED },
isComplete: false,
});
expect(notify).toHaveBeenCalledTimes(1);
});
});
31 changes: 24 additions & 7 deletions src/providers/OnboardingProvider/useStepCompletionToasts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,33 +11,50 @@ import type { OnboardingSteps } from "./onboardingProgress";
import type { OnboardingStepId } from "./steps";

interface UseStepCompletionToastsArgs {
isResolved: boolean;
isOnboardingAvailable: boolean;
desiredSteps: OnboardingSteps;
isComplete: boolean;
}

/**
* Fires a toast the first time each step flips to complete (and a single
* "all set up" toast once everything is done). The first resolved render
* "all set up" toast once everything is done). The first available render
* only seeds the baseline, so already-complete steps don't toast on mount.
*
* Gating on `isOnboardingAvailable` (not `isResolved`) matters: the backing
* queries are disabled until the backend is present, and a disabled query
* reports `isLoading: false`. Seeding earlier would capture an all-false
* baseline and then toast every already-complete step once the queries settle.
*/
export function useStepCompletionToasts({
isResolved,
isOnboardingAvailable,
desiredSteps,
isComplete,
}: UseStepCompletionToastsArgs) {
const notify = useToastNotification();
const previousRef = useRef<Set<OnboardingStepId> | null>(null);
const toastedRef = useRef<Set<OnboardingStepId>>(new Set());

useEffect(() => {
if (!isResolved) return;
if (!isOnboardingAvailable) {
// Not settled yet (or the backend went away) — drop the baseline so the
// next available render re-seeds from the true completed set.
previousRef.current = null;
return;
}
const current = new Set(completedStepIds(desiredSteps));
const previous = previousRef.current;
previousRef.current = current;
if (previous === null) return;
if (previous === null) {
for (const id of current) toastedRef.current.add(id);
return;
}

const newly = newlyCompletedIds(previous, current);
const newly = newlyCompletedIds(previous, current).filter(
(id) => !toastedRef.current.has(id),
);
if (newly.length === 0) return;
for (const id of newly) toastedRef.current.add(id);

if (isComplete) {
notify("You're all set up - onboarding complete!", "success");
Expand All @@ -47,5 +64,5 @@ export function useStepCompletionToasts({
const label = stepLabel(id);
if (label) notify(`Completed: ${label}`, "success");
}
}, [isResolved, desiredSteps, isComplete, notify]);
}, [isOnboardingAvailable, desiredSteps, isComplete, notify]);
}
4 changes: 0 additions & 4 deletions src/providers/TourProvider/TourPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,6 @@ export const POPOVER_STYLES = {
...base,
rx: 6,
}),
clickArea: (base: object) => ({
...base,
pointerEvents: "none" as const,
}),
highlightedArea: (
base: object,
state?: { width?: number; height?: number },
Expand Down
1 change: 1 addition & 0 deletions src/providers/TourProvider/TourProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export function TourProvider({ children }: { children: ReactNode }) {
padding={{ mask: 0, popover: 10 }}
position={computeDefaultPopoverPosition}
nextButton={renderNextButton}
maskClassName="reactour-tour-mask"
onClickMask={() => undefined}
>
<PopoverClampBridge />
Expand Down
Loading
Loading