The budget grid opens collapsed and only its leaf rows take an amount, so the card first points at "Expand all", then at the current month's cell of the first editable row shown (D12). `budgetOnboardingTarget` (pure, beside budgetTableResults.ts) decides it from the rows each section shows, in the order it shows them; the collapse state stays BudgetTable's own and the card never unfolds the grid. BudgetTable rings that one control when the page asks (`onboardingTarget`) and reports its kind through `onOnboardingTargetChange`, in an effect, at mount and on change only: the page needs it for the card's sentence before it asks for the ring. `budgetCardModel(input, checklist)` gives the "collapsed" sentence for "Expand all", the "flat" one for a cell, no sentence and no ring before the grid says, and "Done." once the budget step is checked, which the card needs to end the path. CARD_PAGES gets /budget. BudgetPage renders the card first, outside the loading dim the grid gets after each save, and asks for the ring only while the card shows. The Free edition never mounts the page and never latches it: its path ends at 5/5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
186 lines
7.2 KiB
TypeScript
186 lines
7.2 KiB
TypeScript
// BudgetPage — what the page does with the grid's target (#400).
|
|
//
|
|
// A static render runs no effect, so the grid never reports there. React's
|
|
// `useState` is stubbed (technique of AppShell.slot.test.tsx): the page runs
|
|
// as a plain function, its state survives from one call to the next as
|
|
// between two renders of a mounted component, and its setters keep their
|
|
// identity as React's do. The test plays the grid by calling the callback the
|
|
// page hands it, then reads what the page hands the card and the grid.
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
|
|
|
const harness = vi.hoisted(() => ({
|
|
slots: [] as unknown[],
|
|
setters: [] as Array<(next: unknown) => void>,
|
|
cursor: 0,
|
|
pathname: "/budget",
|
|
onboarding: null as unknown,
|
|
budget: 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;
|
|
}
|
|
harness.setters[index] ??= (next: unknown) => {
|
|
harness.slots[index] =
|
|
typeof next === "function" ? (next as (previous: unknown) => unknown)(harness.slots[index]) : next;
|
|
};
|
|
return [harness.slots[index], harness.setters[index]];
|
|
},
|
|
};
|
|
});
|
|
vi.mock("react-router-dom", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("react-router-dom")>()),
|
|
useLocation: () => ({ pathname: harness.pathname }),
|
|
}));
|
|
vi.mock("react-i18next", () => ({
|
|
useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }),
|
|
}));
|
|
// The page's modules pull the database: stub the connection.
|
|
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
|
|
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
|
|
vi.mock("../hooks/useReportsPeriod", () => ({
|
|
useReportsPeriod: () => ({ accountIds: [], setAccountIds: () => undefined }),
|
|
}));
|
|
vi.mock("../hooks/useBudget", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("../hooks/useBudget")>()),
|
|
useBudget: () => harness.budget,
|
|
}));
|
|
|
|
import BudgetPage from "./BudgetPage";
|
|
import BudgetTable from "../components/budget/BudgetTable";
|
|
import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
|
|
import { initialBudgetState, type BudgetState } from "../hooks/useBudget";
|
|
import type { OnboardingValue } from "../hooks/useOnboardingChecklist";
|
|
import { computeChecklist } from "../utils/onboardingChecklist";
|
|
import { budgetCardModel, nextStep, type BudgetCardModel } from "../utils/onboardingPath";
|
|
import type { BudgetOnboardingTargetKind, OnboardingCounters } from "../shared/types";
|
|
|
|
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)];
|
|
}
|
|
|
|
/** Steps 2 to 5 done: the next step is the budget. */
|
|
const BUDGET_LEFT: OnboardingCounters = {
|
|
sources: 1,
|
|
imports: 1,
|
|
manualCategorized: 1,
|
|
budgetEntries: 0,
|
|
transactions: 41,
|
|
};
|
|
|
|
/** The path latched on /budget (card mode), as the provider shares it. */
|
|
function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue> = {}): OnboardingValue {
|
|
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
|
|
return {
|
|
status: "ready",
|
|
checklist,
|
|
counters,
|
|
hidden: false,
|
|
budgetReady: true,
|
|
nextStep: nextStep(checklist),
|
|
mode: "card",
|
|
cardPage: "/budget",
|
|
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),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/** One render of the mounted page: its hooks are read in the same order each time. */
|
|
function render() {
|
|
harness.cursor = 0;
|
|
const page = BudgetPage() as AnyElement;
|
|
const [card] = find(page, (el) => el.type === OnboardingStepCard);
|
|
const [grid] = find(page, (el) => el.type === BudgetTable);
|
|
return {
|
|
page,
|
|
model: card.props.model as BudgetCardModel,
|
|
highlight: grid.props.onboardingTarget as boolean,
|
|
report: grid.props.onOnboardingTargetChange as (kind: BudgetOnboardingTargetKind | null) => void,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
harness.slots = [];
|
|
harness.setters = [];
|
|
harness.pathname = "/budget";
|
|
harness.onboarding = path(BUDGET_LEFT);
|
|
const state: BudgetState = { ...initialBudgetState(), totalInDb: 41 };
|
|
harness.budget = new Proxy({ state } as Record<string, unknown>, {
|
|
get: (target, key) => (key in target ? target[key as string] : vi.fn()),
|
|
});
|
|
});
|
|
|
|
describe("BudgetPage — the grid's target and the card (#400)", () => {
|
|
it("renders the card as the page's first child, ahead of its heading", () => {
|
|
const { page } = render();
|
|
const children = page.props.children as ReactNode[];
|
|
expect((children[0] as AnyElement).type).toBe(OnboardingStepCard);
|
|
expect(find(children[0], (el) => el.type === "h1")).toHaveLength(0);
|
|
expect(find(children.slice(1), (el) => el.type === "h1")).toHaveLength(1);
|
|
});
|
|
|
|
it("follows what the grid reports: its sentence, and the ring once a target is known", () => {
|
|
const checklist = (harness.onboarding as OnboardingValue).checklist;
|
|
let view = render();
|
|
expect(view.model).toEqual(budgetCardModel({ target: null }, checklist));
|
|
expect(view.highlight).toBe(false);
|
|
|
|
view.report("expandAll");
|
|
view = render();
|
|
expect(view.model.text?.key).toBe("onboarding.card.budget.collapsed");
|
|
expect(view.highlight).toBe(true);
|
|
|
|
view.report("cell");
|
|
view = render();
|
|
expect(view.model.text?.key).toBe("onboarding.card.budget.flat");
|
|
expect(view.highlight).toBe(true);
|
|
|
|
view.report(null);
|
|
view = render();
|
|
expect(view.model.text).toBeNull();
|
|
expect(view.highlight).toBe(false);
|
|
});
|
|
|
|
it("asks for no ring while the card is not on screen", () => {
|
|
for (const overrides of [
|
|
{ mode: "banner" as const, cardPage: null },
|
|
{ mode: "none" as const, cardPage: null },
|
|
{ mode: "complete" as const, cardPage: null },
|
|
{ mode: "card" as const, cardPage: "/transactions" },
|
|
]) {
|
|
harness.slots = [];
|
|
harness.setters = [];
|
|
harness.onboarding = path(BUDGET_LEFT, overrides);
|
|
render().report("cell");
|
|
expect(render().highlight, JSON.stringify(overrides)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("asks for no ring once the step is checked: the card says 'Done.'", () => {
|
|
harness.onboarding = path({ ...BUDGET_LEFT, budgetEntries: 1 });
|
|
render().report("cell");
|
|
const view = render();
|
|
expect(view.model).toEqual({ text: null, done: true, target: null });
|
|
expect(view.highlight).toBe(false);
|
|
});
|
|
|
|
it("hands the grid the same callback at every render, so the grid does not report at every render", () => {
|
|
expect(render().report).toBe(render().report);
|
|
});
|
|
});
|