useOnboardingChecklist.wiring gains three pins the old stubs could not hold: one loader for the provider's life (refresh and the shared value keep their identity, no re-read), 'Skip' moving the next step on the next render, and a change of mode alone reaching the consumers. UsersSettingsPage now commits its effects, so an effect that read the preference goes red on the assertion rather than on 'Invalid hook call'. BudgetTable.wiring, BudgetPage.wiring, AppShell.slot and OnboardingStepCard drop their local slot copies; OnboardingStepCard plays StrictMode through replayEffects and runs its effect only on a changed dependency, which now pins location.key. Refs #409 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
129 lines
5.2 KiB
TypeScript
129 lines
5.2 KiB
TypeScript
// OnboardingBannerSlot — the list's fold state and the path's wiring (#397, #409).
|
|
//
|
|
// renderToStaticMarkup runs no click, so React's hooks come from the shared
|
|
// harness (src/test/hookHarness.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";
|
|
|
|
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
|
|
const ctx = vi.hoisted(() => ({
|
|
pathname: "/reports",
|
|
onboarding: null as unknown,
|
|
}));
|
|
vi.mock("react-router-dom", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("react-router-dom")>()),
|
|
useLocation: () => ({ pathname: ctx.pathname }),
|
|
}));
|
|
vi.mock("../../contexts/OnboardingContext", () => ({
|
|
OnboardingProvider: ({ children }: { children: ReactNode }) => children,
|
|
useOnboarding: () => ctx.onboarding,
|
|
}));
|
|
vi.mock("./Sidebar", () => ({ default: () => null }));
|
|
|
|
import { hookHarness } from "../../test/hookHarness";
|
|
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";
|
|
|
|
const harness = hookHarness();
|
|
|
|
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 {
|
|
const element = harness.render(() => OnboardingBannerSlot() as ReactElement<OnboardingBannerProps>);
|
|
expect(element.type).toBe(OnboardingBanner);
|
|
return element.props;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
harness.clear();
|
|
ctx.pathname = "/reports";
|
|
ctx.onboarding = onboardingValue();
|
|
});
|
|
|
|
describe("OnboardingBannerSlot (#397)", () => {
|
|
it("hands the shared path to the banner, the list folded", () => {
|
|
const value = ctx.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", () => {
|
|
ctx.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", () => {
|
|
ctx.pathname = "/import";
|
|
const shell = harness.render(() => AppShell() as ReactNode);
|
|
const [slot] = find(shell, (e) => e.type === OnboardingBannerSlot);
|
|
expect(slot).toBeDefined();
|
|
expect(slot.key).toBe("/import");
|
|
|
|
ctx.pathname = "/transactions";
|
|
const [next] = find(harness.render(() => 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 = harness.render(() => 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);
|
|
});
|
|
});
|