The getting-started path now follows the user: AppShell mounts OnboardingBanner at the top of <main>, in the page flow, and the provider's mode keeps it off a hidden path, the dashboard of a profile without transactions, a page that carries a step card and the first load. The banner shows "Getting started n/N" once the Budget entitlement is known, the next step linked with its navigation state, "Show the list", which unfolds the detailed list in place, and "Hide"; at the end, the closing message, "Open the guide" and "Close". Each route mounts its own banner slot, so a page always starts folded. The dashboard's compact bar and the compact mode of OnboardingChecklist are gone (D7). The detailed list reads each state out to screen readers, shows a skipped step with its badge and "Resume", and gives every link to a step the same navigation state (stepNavState), with the guided entry for the manual category. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
146 lines
5.6 KiB
TypeScript
146 lines
5.6 KiB
TypeScript
// OnboardingBannerSlot — the list's fold state and the path's wiring (#397).
|
|
//
|
|
// renderToStaticMarkup runs no click, so React's `useState` is stubbed (same
|
|
// technique as useOnboardingChecklist.wiring.test.ts): the slot runs as a
|
|
// plain function, and its state survives from one call to the next as it
|
|
// would between two renders of a mounted component.
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
|
import { Outlet } from "react-router-dom";
|
|
|
|
const harness = vi.hoisted(() => ({
|
|
slots: [] as unknown[],
|
|
cursor: 0,
|
|
pathname: "/reports",
|
|
onboarding: null as unknown,
|
|
}));
|
|
|
|
vi.mock("react", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("react")>();
|
|
return {
|
|
...actual,
|
|
useState: (init: unknown) => {
|
|
const index = harness.cursor++;
|
|
if (!(index in harness.slots)) {
|
|
harness.slots[index] = typeof init === "function" ? (init as () => unknown)() : init;
|
|
}
|
|
const set = (next: unknown) => {
|
|
harness.slots[index] =
|
|
typeof next === "function" ? (next as (previous: unknown) => unknown)(harness.slots[index]) : next;
|
|
};
|
|
return [harness.slots[index], set];
|
|
},
|
|
};
|
|
});
|
|
vi.mock("react-router-dom", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("react-router-dom")>()),
|
|
useLocation: () => ({ pathname: harness.pathname }),
|
|
}));
|
|
vi.mock("../../contexts/OnboardingContext", () => ({
|
|
OnboardingProvider: ({ children }: { children: ReactNode }) => children,
|
|
useOnboarding: () => harness.onboarding,
|
|
}));
|
|
vi.mock("./Sidebar", () => ({ default: () => null }));
|
|
|
|
import AppShell, { OnboardingBannerSlot } from "./AppShell";
|
|
import OnboardingBanner, { type OnboardingBannerProps } from "../onboarding/OnboardingBanner";
|
|
import { computeChecklist } from "../../utils/onboardingChecklist";
|
|
import { nextStep } from "../../utils/onboardingPath";
|
|
import type { OnboardingValue } from "../../hooks/useOnboardingChecklist";
|
|
|
|
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
|
|
|
|
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
|
|
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
|
|
if (!isValidElement(node)) return [];
|
|
const el = node as AnyElement;
|
|
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
|
|
}
|
|
|
|
function onboardingValue(): OnboardingValue {
|
|
const counters = { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 40 };
|
|
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
|
|
return {
|
|
status: "ready",
|
|
checklist,
|
|
counters,
|
|
hidden: false,
|
|
budgetReady: true,
|
|
nextStep: nextStep(checklist),
|
|
mode: "banner",
|
|
cardPage: null,
|
|
hide: vi.fn(async () => undefined),
|
|
show: vi.fn(async () => undefined),
|
|
skip: vi.fn(async () => undefined),
|
|
resume: vi.fn(async () => undefined),
|
|
refresh: vi.fn(async () => undefined),
|
|
};
|
|
}
|
|
|
|
/** One render of the mounted slot: its hooks are read in the same order each time. */
|
|
function renderSlot(): OnboardingBannerProps {
|
|
harness.cursor = 0;
|
|
const element = OnboardingBannerSlot() as ReactElement<OnboardingBannerProps>;
|
|
expect(element.type).toBe(OnboardingBanner);
|
|
return element.props;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
harness.slots = [];
|
|
harness.pathname = "/reports";
|
|
harness.onboarding = onboardingValue();
|
|
});
|
|
|
|
describe("OnboardingBannerSlot (#397)", () => {
|
|
it("hands the shared path to the banner, the list folded", () => {
|
|
const value = harness.onboarding as OnboardingValue;
|
|
const props = renderSlot();
|
|
expect(props.mode).toBe("banner");
|
|
expect(props.checklist).toBe(value.checklist);
|
|
expect(props.nextStep).toBe(value.nextStep);
|
|
expect(props.budgetReady).toBe(true);
|
|
expect(props.listOpen).toBe(false);
|
|
expect(props.onHide).toBe(value.hide);
|
|
expect(props.onResume).toBe(value.resume);
|
|
});
|
|
|
|
it("passes the mode and the entitlement as they come: the banner decides what to show", () => {
|
|
harness.onboarding = { ...onboardingValue(), mode: "none", budgetReady: false };
|
|
const props = renderSlot();
|
|
expect(props.mode).toBe("none");
|
|
expect(props.budgetReady).toBe(false);
|
|
});
|
|
|
|
it("'Show the list' unfolds it, 'Close the list' folds it back", () => {
|
|
renderSlot().onToggleList();
|
|
expect(renderSlot().listOpen).toBe(true);
|
|
// Another render without a click keeps it unfolded.
|
|
expect(renderSlot().listOpen).toBe(true);
|
|
renderSlot().onToggleList();
|
|
expect(renderSlot().listOpen).toBe(false);
|
|
});
|
|
|
|
it("AppShell mounts one slot per route, so each page starts with the list folded", () => {
|
|
harness.pathname = "/import";
|
|
const shell = AppShell() as ReactNode;
|
|
const [slot] = find(shell, (e) => e.type === OnboardingBannerSlot);
|
|
expect(slot).toBeDefined();
|
|
expect(slot.key).toBe("/import");
|
|
|
|
harness.pathname = "/transactions";
|
|
const [next] = find(AppShell() as ReactNode, (e) => e.type === OnboardingBannerSlot);
|
|
expect(next.key).toBe("/transactions");
|
|
});
|
|
|
|
it("AppShell puts the slot before the routed page, inside the provider, inside main", () => {
|
|
const shell = AppShell() as ReactNode;
|
|
const [main] = find(shell, (e) => e.type === "main");
|
|
expect(main).toBeDefined();
|
|
const [provider] = find(main.props.children, (e) => typeof e.type === "function");
|
|
const children = provider.props.children as ReactNode[];
|
|
expect(Array.isArray(children)).toBe(true);
|
|
const [first, second] = children as AnyElement[];
|
|
expect(first.type).toBe(OnboardingBannerSlot);
|
|
expect(second.type).toBe(Outlet);
|
|
});
|
|
});
|