Simpl-Resultat/src/components/layout/AppShell.slot.test.tsx
le king fu 75c1708bbe feat(onboarding): show the getting-started banner on top of every page
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>
2026-10-08 08:02:39 -04:00

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);
});
});