diff --git a/src/components/budget/BudgetTable.render.test.tsx b/src/components/budget/BudgetTable.render.test.tsx new file mode 100644 index 0000000..d943c06 --- /dev/null +++ b/src/components/budget/BudgetTable.render.test.tsx @@ -0,0 +1,205 @@ +// BudgetTable and the getting-started card's target (#400). No jsdom: +// react-dom/server renders in vitest's node environment. The collapse hook is +// replaced by the real pure helpers over a state the test sets (the real hook +// hydrates in an effect, which a static render never runs), and the +// subtotal-position preference is read from a stubbed localStorage. What is +// pinned is where the one highlight lands: "Expand all" or this month's cell +// of the first editable row shown. +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { BudgetYearRow } from "../../shared/types"; +import type { CollapseAccessors } from "../../utils/collapsibleRows"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }), +})); + +const harness = vi.hoisted(() => ({ + /** Keys of the open groups (`p:`): the grid opens collapsed. */ + expanded: new Set(), +})); + +vi.mock("../../hooks/useCollapsibleGroups", async () => { + const { collapsibleKeys, isCollapsedFor, visibleRows } = await import("../../utils/collapsibleRows"); + return { + useCollapsibleGroups: (_key: string | null, acc: CollapseAccessors) => { + const isCollapsed = (row: T) => isCollapsedFor(harness.expanded, acc.keyOf(row), false); + return { + visible: (rows: T[]) => visibleRows(rows, acc, isCollapsed), + isCollapsed, + toggle: () => undefined, + expandAll: () => undefined, + collapseAll: () => undefined, + allExpanded: (rows: T[]) => { + const keys = collapsibleKeys(rows, acc); + return keys.length > 0 && keys.every((key) => harness.expanded.has(key)); + }, + groupCount: (rows: T[]) => collapsibleKeys(rows, acc).length, + }; + }, + }; +}); + +import BudgetTable, { BUDGET_CELL_TARGET, BUDGET_EXPAND_ALL_TARGET } from "./BudgetTable"; + +const leaf = ( + category_id: number, + category_name: string, + category_type: BudgetYearRow["category_type"], + parent_id: number | null = null, +): BudgetYearRow => ({ + category_id, + category_name, + category_color: "#9ca3af", + category_type, + parent_id, + is_parent: false, + depth: parent_id === null ? 0 : 1, + months: Array(12).fill(0) as number[], + annual: 0, + previousYearTotal: 0, +}); +const group = ( + category_id: number, + category_name: string, + category_type: BudgetYearRow["category_type"], +): BudgetYearRow => ({ ...leaf(category_id, category_name, category_type), is_parent: true }); + +/** Two groups, in the order `buildBudgetYearRows` emits them: income first. */ +const GROUPED: BudgetYearRow[] = [ + group(1, "Revenus", "income"), + leaf(2, "Salaire", "income", 1), + group(10, "Logement", "expense"), + leaf(11, "Loyer", "expense", 10), + leaf(12, "Électricité", "expense", 10), +]; +/** A category without sub-category stays on screen while the groups are collapsed. */ +const WITH_STANDALONE: BudgetYearRow[] = [...GROUPED, leaf(30, "Divers", "expense")]; +/** No group at all. */ +const FLAT: BudgetYearRow[] = [leaf(30, "Divers", "expense"), leaf(31, "Cadeaux", "expense")]; + +const MARCH = 2; +const DECEMBER = 11; + +const render = (rows: BudgetYearRow[], onboardingTarget?: boolean) => + renderToStaticMarkup( + undefined} + onSplitEvenly={() => undefined} + onboardingTarget={onboardingTarget} + />, + ); + +const HIGHLIGHT = 'data-onboarding-highlight="true"'; +const count = (html: string, needle: string) => html.split(needle).length - 1; + +/** The one highlighted control: its target key and its inner markup. */ +function highlighted(html: string): { target: string; inner: string } { + expect(count(html, HIGHLIGHT)).toBe(1); + const match = html.match(/ @@ -352,7 +406,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu {t(typeLabelKeys[type])} - {reorderRows(groups.visible(group), subtotalsOnTop).map((row) => renderRow(row))} + {shownByType[type].map((row) => renderRow(row))} {t(typeTotalKeys[type])} @@ -395,9 +449,6 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu ); }; - const hasGroups = groups.groupCount(rows) > 0; - const allExpanded = groups.allExpanded(rows); - return (
@@ -406,6 +457,10 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu type="button" onClick={() => (allExpanded ? groups.collapseAll(rows) : groups.expandAll(rows))} className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors" + {...onboardingTargetAttributes( + BUDGET_EXPAND_ALL_TARGET, + onboardingTarget && target?.kind === "expandAll", + )} > {allExpanded ? : } {allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")} diff --git a/src/components/budget/BudgetTable.wiring.test.ts b/src/components/budget/BudgetTable.wiring.test.ts new file mode 100644 index 0000000..567ac0c --- /dev/null +++ b/src/components/budget/BudgetTable.wiring.test.ts @@ -0,0 +1,174 @@ +// BudgetTable — how the grid tells the page its target (#400). +// +// renderToStaticMarkup runs no effect, so the report would stay unguarded. +// React's `useState`, `useRef` and `useEffect` are stubbed (technique of +// OnboardingStepCard.test.tsx and useOnboardingChecklist.wiring.test.ts): the +// grid runs as a plain function, its state and refs survive from one call to +// the next as between two renders of a mounted component, and `commit()` runs +// the effects as React does, an effect only when one of its dependencies +// changed since its last run. The collapse hook is replaced by the real pure +// helpers over a state the test sets. +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { BudgetYearRow } from "../../shared/types"; +import type { CollapseAccessors } from "../../utils/collapsibleRows"; + +type Effect = { fn: () => void | (() => void); deps: readonly unknown[] | undefined }; + +const harness = vi.hoisted(() => ({ + slots: [] as unknown[], + setters: [] as Array<(next: unknown) => void>, + cursor: 0, + effects: [] as Effect[], + /** Dependencies of each effect at its last run. */ + ran: new Map(), + /** Keys of the open groups (`p:`): the grid opens collapsed. */ + expanded: new Set(), +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + const slot = (init: () => unknown) => { + const index = harness.cursor++; + if (!(index in harness.slots)) harness.slots[index] = init(); + return index; + }; + return { + ...actual, + useState: (init: unknown) => { + const index = slot(() => (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]]; + }, + useRef: (init: unknown) => harness.slots[slot(() => ({ current: init }))], + useEffect: (fn: Effect["fn"], deps?: readonly unknown[]) => { + harness.effects.push({ fn, deps }); + }, + }; +}); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }), +})); +vi.mock("../../hooks/useCollapsibleGroups", async () => { + const { collapsibleKeys, isCollapsedFor, visibleRows } = await import("../../utils/collapsibleRows"); + return { + useCollapsibleGroups: (_key: string | null, acc: CollapseAccessors) => { + const isCollapsed = (row: T) => isCollapsedFor(harness.expanded, acc.keyOf(row), false); + return { + visible: (rows: T[]) => visibleRows(rows, acc, isCollapsed), + isCollapsed, + toggle: () => undefined, + expandAll: () => undefined, + collapseAll: () => undefined, + allExpanded: (rows: T[]) => { + const keys = collapsibleKeys(rows, acc); + return keys.length > 0 && keys.every((key) => harness.expanded.has(key)); + }, + groupCount: (rows: T[]) => collapsibleKeys(rows, acc).length, + }; + }, + }; +}); + +import BudgetTable from "./BudgetTable"; +import type { BudgetOnboardingTargetKind } from "../../shared/types"; + +const leaf = (category_id: number, parent_id: number | null = null): BudgetYearRow => ({ + category_id, + category_name: `Category ${category_id}`, + category_color: "#9ca3af", + category_type: "expense", + parent_id, + is_parent: false, + depth: parent_id === null ? 0 : 1, + months: Array(12).fill(0) as number[], + annual: 0, + previousYearTotal: 0, +}); +const GROUPED: BudgetYearRow[] = [{ ...leaf(10), is_parent: true }, leaf(11, 10), leaf(12, 10)]; + +/** One render of the mounted grid: its hooks are called in the same order each time. */ +function render( + rows: BudgetYearRow[], + onChange: (kind: BudgetOnboardingTargetKind | null) => void, + onboardingTarget?: boolean, +) { + harness.cursor = 0; + harness.effects = []; + BudgetTable({ + rows, + onUpdatePlanned: () => undefined, + onSplitEvenly: () => undefined, + onboardingTarget, + onOnboardingTargetChange: onChange, + }); +} + +/** After a render: run each effect whose dependencies changed since its last run, as React does. */ +function commit() { + harness.effects.forEach((effect, index) => { + const previous = harness.ran.get(index); + const changed = + !harness.ran.has(index) || + effect.deps === undefined || + previous === undefined || + effect.deps.some((dep, k) => !Object.is(dep, previous[k])); + harness.ran.set(index, effect.deps); + if (changed) effect.fn(); + }); +} + +beforeEach(() => { + harness.slots = []; + harness.setters = []; + harness.effects = []; + harness.ran = new Map(); + harness.expanded = new Set(); + vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => undefined }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("BudgetTable — the target told to the page (#400)", () => { + it("tells it once mounted, then only when it changes", () => { + const onChange = vi.fn(); + render(GROUPED, onChange, true); + commit(); + expect(onChange.mock.calls).toEqual([["expandAll"]]); + + // Another render with nothing changed: not told again. + render(GROUPED, onChange, true); + commit(); + expect(onChange).toHaveBeenCalledTimes(1); + + // The user opens the group: an editable row shows. + harness.expanded = new Set(["p:10"]); + render(GROUPED, onChange, true); + commit(); + render(GROUPED, onChange, true); + commit(); + expect(onChange.mock.calls).toEqual([["expandAll"], ["cell"]]); + + // The rows are gone: nothing to point at. + render([], onChange, true); + commit(); + expect(onChange.mock.calls).toEqual([["expandAll"], ["cell"], [null]]); + }); + + it("tells it before any highlight is asked: the page needs it for the card's sentence", () => { + const onChange = vi.fn(); + render([leaf(30)], onChange); + commit(); + expect(onChange.mock.calls).toEqual([["cell"]]); + }); + + it("tells nothing during a render", () => { + const onChange = vi.fn(); + render(GROUPED, onChange, true); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/budget/budgetOnboardingTarget.test.ts b/src/components/budget/budgetOnboardingTarget.test.ts new file mode 100644 index 0000000..c3d4696 --- /dev/null +++ b/src/components/budget/budgetOnboardingTarget.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from "vitest"; +import { budgetOnboardingTarget } from "./budgetOnboardingTarget"; +import type { BudgetYearRow } from "../../shared/types"; + +/** A grid row; a leaf at the top level unless `overrides` says otherwise. */ +const row = (category_id: number, overrides: Partial = {}): BudgetYearRow => ({ + category_id, + category_name: `Category ${category_id}`, + category_color: "#9ca3af", + category_type: "expense", + parent_id: null, + is_parent: false, + depth: 0, + months: Array(12).fill(0) as number[], + annual: 0, + previousYearTotal: 0, + ...overrides, +}); +/** A group's subtotal row: read-only. */ +const group = (category_id: number, overrides: Partial = {}) => + row(category_id, { is_parent: true, ...overrides }); +/** A leaf under `parent_id`. */ +const child = (category_id: number, parent_id: number, depth = 1) => + row(category_id, { parent_id, depth }); + +const OCTOBER = 9; + +describe("budgetOnboardingTarget (#400, D12)", () => { + it("groups collapsed: 'Expand all', the editable rows being folded away", () => { + // Collapsed, the grid shows its top-level subtotals only. + expect(budgetOnboardingTarget([group(10), group(20)], true, OCTOBER)).toEqual({ + kind: "expandAll", + }); + // A group open on a sub-group still folded: still no editable row shown. + expect( + budgetOnboardingTarget([group(10), group(11, { parent_id: 10, depth: 1 })], true, OCTOBER), + ).toEqual({ kind: "expandAll" }); + }); + + it("the first leaf shown: its cell in the given month, past the subtotal rows above it", () => { + expect(budgetOnboardingTarget([group(10), child(13, 10), child(14, 10)], true, OCTOBER)).toEqual({ + kind: "cell", + categoryId: 13, + monthIdx: OCTOBER, + }); + // A sub-group's subtotal before the leaves is skipped too. + expect( + budgetOnboardingTarget( + [group(10), group(11, { parent_id: 10, depth: 1 }), child(15, 11, 2)], + true, + OCTOBER, + ), + ).toEqual({ kind: "cell", categoryId: 15, monthIdx: OCTOBER }); + }); + + it("subtotals at the bottom: the first leaf, not the subtotal under it", () => { + expect(budgetOnboardingTarget([child(13, 10), child(14, 10), group(10)], true, OCTOBER)).toEqual({ + kind: "cell", + categoryId: 13, + monthIdx: OCTOBER, + }); + }); + + it("a '(direct)' leaf is a leaf: its cell, though it shares its group's category id", () => { + expect( + budgetOnboardingTarget([group(10), row(10, { parent_id: 10, depth: 1 }), child(13, 10)], true, 0), + ).toEqual({ kind: "cell", categoryId: 10, monthIdx: 0 }); + }); + + it("a leaf shown while the groups are collapsed (a category without sub-category): already a cell", () => { + expect(budgetOnboardingTarget([group(10), row(30), group(20)], true, OCTOBER)).toEqual({ + kind: "cell", + categoryId: 30, + monthIdx: OCTOBER, + }); + }); + + it("without groups: the first row's cell", () => { + expect(budgetOnboardingTarget([row(30), row(31)], false, OCTOBER)).toEqual({ + kind: "cell", + categoryId: 30, + monthIdx: OCTOBER, + }); + }); + + it("no row: nothing to point at", () => { + expect(budgetOnboardingTarget([], false, OCTOBER)).toBeNull(); + expect(budgetOnboardingTarget([], false, 0)).toBeNull(); + }); + + it("keeps the month it is given, January to December", () => { + for (const monthIdx of [0, 5, 11]) { + expect(budgetOnboardingTarget([row(30)], false, monthIdx)).toEqual({ + kind: "cell", + categoryId: 30, + monthIdx, + }); + } + }); +}); diff --git a/src/components/budget/budgetOnboardingTarget.ts b/src/components/budget/budgetOnboardingTarget.ts new file mode 100644 index 0000000..1d925fc --- /dev/null +++ b/src/components/budget/budgetOnboardingTarget.ts @@ -0,0 +1,33 @@ +import type { BudgetYearRow } from "../../shared/types"; + +/** + * The control of the budget grid that the getting-started card points at + * (#400): the month cell of an editable row, or "Expand all". + */ +export type BudgetOnboardingTarget = + | { kind: "cell"; categoryId: number; monthIdx: number } + | { kind: "expandAll" }; + +/** + * Which control of the budget grid the Budget step card points at (#400, D12). + * + * `visibleRows` are the rows the grid shows, in the order it shows them. A + * subtotal row is read-only and a row folded under a collapsed group is not on + * screen, so the target is: + * - the `monthIdx` cell of the first leaf row shown; + * - otherwise "Expand all" when the grid has groups: its editable rows are + * folded away, and the card never unfolds them for the user; + * - otherwise null: no row at all, nothing to point at. + * + * Pure (no React, no storage), like `budgetTableResults.ts`, so the rule is + * tested without rendering the grid and its collapse hook. + */ +export function budgetOnboardingTarget( + visibleRows: readonly BudgetYearRow[], + hasGroups: boolean, + monthIdx: number, +): BudgetOnboardingTarget | null { + const leaf = visibleRows.find((row) => !row.is_parent); + if (leaf) return { kind: "cell", categoryId: leaf.category_id, monthIdx }; + return hasGroups ? { kind: "expandAll" } : null; +} diff --git a/src/components/layout/AppShell.render.test.tsx b/src/components/layout/AppShell.render.test.tsx index c458582..a3c830f 100644 --- a/src/components/layout/AppShell.render.test.tsx +++ b/src/components/layout/AppShell.render.test.tsx @@ -70,11 +70,16 @@ const loaded = (snapshot: OnboardingSnapshot): OnboardingChecklistState => ].reduce(onboardingChecklistReducer, initialOnboardingChecklistState); /** What `useOnboarding()` gives at `pathname` for `state`, derived as the provider does. */ -function pathAt(pathname: string, state: OnboardingChecklistState, budgetReady = true): OnboardingValue { +function pathAt( + pathname: string, + state: OnboardingChecklistState, + budgetReady = true, + budgetAllowed = true, +): OnboardingValue { const checklist = state.counters ? computeChecklist(state.counters, { importFolderSet: state.importFolderSet, - budgetAllowed: true, + budgetAllowed, skipped: state.skipped, }) : null; @@ -150,18 +155,6 @@ describe("AppShell — the getting-started banner (#397)", () => { } }); - it.each([["/budget", BUDGET_LEFT]] as const)( - "keeps the banner on %s, the page of the next step, while no step card is delivered", - (pathname, snapshot) => { - expect(CARD_PAGES.has(pathname)).toBe(false); - const value = pathAt(pathname, loaded(snapshot)); - expect(value.nextStep?.to).toBe(pathname); - const html = renderWith(pathname, value); - expect(html).toContain(BANNER); - expect(html).toContain(`href="${pathname}"`); - }, - ); - it("gives /import, the page of the next step, its step card instead of the banner (#398)", () => { expect(CARD_PAGES.has("/import")).toBe(true); const value = pathAt("/import", loaded(NEW_PROFILE)); @@ -184,6 +177,28 @@ describe("AppShell — the getting-started banner (#397)", () => { expect(renderAt("/reports", loaded(IMPORTED))).toContain('href="/transactions"'); }); + it("gives /budget, the page of the next step, its step card instead of the banner (#400)", () => { + expect(CARD_PAGES.has("/budget")).toBe(true); + const value = pathAt("/budget", loaded(BUDGET_LEFT)); + expect(value.nextStep?.to).toBe("/budget"); + expect(value.mode).toBe("card"); + expect(value.cardPage).toBe("/budget"); + expect(renderWith("/budget", value)).not.toContain(BANNER); + // Anywhere else, the same profile keeps the banner and its link to /budget. + expect(renderAt("/reports", loaded(BUDGET_LEFT))).toContain('href="/budget"'); + }); + + it("in the Free edition, /budget never gets a card and the path ends at 5/5 (#400)", () => { + const value = pathAt("/budget", loaded(BUDGET_LEFT), true, false); + expect(value.checklist?.total).toBe(5); + expect(value.nextStep).toBeNull(); + expect(value.cardPage).toBeNull(); + expect(value.mode).toBe("complete"); + const html = renderWith("/budget", value); + expect(html).toContain(BANNER); + expect(html).toContain("t(onboarding.banner.complete)"); + }); + it("shows nothing on a page that carries a step card", () => { const value = { ...pathAt("/import", loaded(NEW_PROFILE)), mode: "card" as const, cardPage: "/import" }; expect(renderWith("/import", value)).not.toContain(BANNER); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b961253..12333e2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -93,6 +93,10 @@ "transactions": { "uncategorized": "Only {{filter, lowercase}} transactions are shown. Click the category of one of them and pick one from the list. Simpl'Résultat keeps your choice, and automatic categorization will not replace it.", "all": "Click a transaction's category and pick one from the list. Simpl'Résultat keeps your choice, and automatic categorization will not replace it." + }, + "budget": { + "collapsed": "Click {{expand}} to show your categories, then click a month and enter an amount. Enter saves; Tab saves and moves to the next month.", + "flat": "Click a month and enter an amount. Enter saves; Tab saves and moves to the next month." } }, "state": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index ec1481c..43a2030 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -93,6 +93,10 @@ "transactions": { "uncategorized": "Seules les transactions {{filter, lowercase}} sont affichées. Cliquez sur la catégorie de l'une d'elles et choisissez-en une dans la liste. Simpl'Résultat retient ce choix, et la catégorisation automatique ne le remplacera pas.", "all": "Cliquez sur la catégorie d'une transaction et choisissez-en une dans la liste. Simpl'Résultat retient ce choix, et la catégorisation automatique ne le remplacera pas." + }, + "budget": { + "collapsed": "Cliquez {{expand}} pour afficher vos catégories, puis cliquez sur un mois et saisissez un montant. Entrée enregistre ; Tab enregistre et passe au mois suivant.", + "flat": "Cliquez sur un mois et saisissez un montant. Entrée enregistre ; Tab enregistre et passe au mois suivant." } }, "state": { diff --git a/src/pages/BudgetPage.render.test.tsx b/src/pages/BudgetPage.render.test.tsx new file mode 100644 index 0000000..0d6d7df --- /dev/null +++ b/src/pages/BudgetPage.render.test.tsx @@ -0,0 +1,194 @@ +// BudgetPage and its getting-started card (#400). No jsdom: react-dom/server +// renders in vitest's node environment. The budget is a fake `useBudget()` +// over its real initial state, the path a fake `useOnboarding()` built from the +// real checklist, and the grid is the real BudgetTable. A static render runs +// no effect, so the grid never reports its target here (the report and what +// the page does with it are in BudgetPage.wiring.test.ts). What is pinned is +// where the card sits and what it says before and after the step is checked. +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter } from "react-router-dom"; + +// The budget, collapse and preference services pull the database: stub the connection. +vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() })); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (k: string, o?: Record) => + o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`, + i18n: { language: "fr" }, + }), +})); +vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null })); + +const harness = vi.hoisted(() => ({ budget: null as unknown, onboarding: null as unknown })); + +vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding })); +vi.mock("../hooks/useBudget", async (importOriginal) => ({ + ...(await importOriginal()), + useBudget: () => harness.budget, +})); + +import BudgetPage from "./BudgetPage"; +import { initialBudgetState, type BudgetState } from "../hooks/useBudget"; +import type { OnboardingValue } from "../hooks/useOnboardingChecklist"; +import { computeChecklist } from "../utils/onboardingChecklist"; +import { nextStep } from "../utils/onboardingPath"; +import type { BudgetYearRow, OnboardingCounters } from "../shared/types"; + +/** 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, +}; +/** A first amount saved: every step done. */ +const BUDGETED: OnboardingCounters = { ...BUDGET_LEFT, budgetEntries: 1 }; + +const ROWS: BudgetYearRow[] = [ + { + category_id: 10, + category_name: "Logement", + category_color: "#9ca3af", + category_type: "expense", + parent_id: null, + is_parent: true, + depth: 0, + months: Array(12).fill(0) as number[], + annual: 0, + previousYearTotal: 0, + }, + { + category_id: 11, + category_name: "Loyer", + category_color: "#9ca3af", + category_type: "expense", + parent_id: 10, + is_parent: false, + depth: 1, + months: Array(12).fill(0) as number[], + annual: 0, + previousYearTotal: 0, + }, +]; + +/** The budget after its first read, with `overrides`. */ +function budget(overrides: Partial = {}) { + const state: BudgetState = { ...initialBudgetState(), rows: ROWS, totalInDb: 41, ...overrides }; + const noop = vi.fn(); + return new Proxy({ state } as Record, { + get: (target, key) => (key in target ? target[key as string] : noop), + }); +} + +/** The path latched on /budget (card mode), as the provider shares it. */ +function path(counters: OnboardingCounters, overrides: Partial = {}): 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, + }; +} + +const render = () => + renderToStaticMarkup( + + + , + ); + +const CARD = 'aria-label="t(onboarding.banner.label)"'; +const HIGHLIGHT = 'data-onboarding-highlight="true"'; +const DIM = 'class="opacity-50 pointer-events-none"'; +const count = (html: string, needle: string) => html.split(needle).length - 1; +/** + * The card's sentence: the paragraph right after the card's own title, null + * without one (the empty state below has a title and a paragraph too). + */ +function sentence(html: string): string | null { + const title = html.indexOf("", html.indexOf(CARD)); + return html.slice(title).match(/^<\/h2>

]*>(.*?)<\/p>/)?.[1] ?? null; +} + +beforeEach(() => { + harness.budget = budget(); + harness.onboarding = path(BUDGET_LEFT); + vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => undefined }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("BudgetPage — the step card (#400)", () => { + it("renders the card first, before the page's heading, at its place in the path", () => { + const html = render(); + expect(count(html, CARD)).toBe(1); + expect(html.indexOf(CARD)).toBeLessThan(html.indexOf(" { + const html = render(); + expect(sentence(html)).toBeNull(); + expect(count(html, HIGHLIGHT)).toBe(0); + }); + + it("keeps the card out of the loading dim, which still covers the page", () => { + harness.budget = budget({ isLoading: true }); + const html = render(); + expect(count(html, DIM)).toBe(1); + expect(html.indexOf("t(onboarding.card.skip)")).toBeLessThan(html.indexOf(DIM)); + expect(html.indexOf(DIM)).toBeLessThan(html.indexOf(" { + harness.onboarding = path(BUDGETED); + const html = render(); + expect(count(html, CARD)).toBe(1); + expect(html).toMatch(/

]*>.*t\(onboarding\.card\.done\)/); + expect(html).toContain("t(onboarding.banner.complete)"); + expect(html).toContain("t(onboarding.banner.openGuide)"); + expect(html).not.toContain("t(onboarding.card.skip)"); + expect(count(html, HIGHLIGHT)).toBe(0); + }); + + it("a profile without transactions: the card above the 'import first' state", () => { + harness.budget = budget({ rows: [], totalInDb: 0 }); + const html = render(); + expect(html).toContain("t(budget.empty.title)"); + expect(html.indexOf(CARD)).toBeLessThan(html.indexOf("t(budget.empty.title)")); + expect(html).toContain("t(onboarding.card.skip)"); + expect(sentence(html)).toBeNull(); + }); + + it("no card when the path is not in card mode on this page", () => { + for (const overrides of [ + { mode: "banner" as const, cardPage: null }, + { mode: "none" as const, cardPage: null }, + { mode: "card" as const, cardPage: "/transactions" }, + ]) { + harness.onboarding = path(BUDGET_LEFT, overrides); + const html = render(); + expect(html).not.toContain(CARD); + expect(html).toContain("Logement"); + } + }); +}); diff --git a/src/pages/BudgetPage.tsx b/src/pages/BudgetPage.tsx index 2b4ee03..764017d 100644 --- a/src/pages/BudgetPage.tsx +++ b/src/pages/BudgetPage.tsx @@ -1,16 +1,24 @@ +import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { useLocation } from "react-router-dom"; import { Calculator } from "lucide-react"; import { PageHelp } from "../components/shared/PageHelp"; import EmptyState from "../components/shared/EmptyState"; import FilterPanel from "../components/reports/FilterPanel"; +import OnboardingStepCard from "../components/onboarding/OnboardingStepCard"; +import { useOnboarding } from "../contexts/OnboardingContext"; +import { budgetCardModel, showsStepCard } from "../utils/onboardingPath"; import { useBudget, showsEmptyState } from "../hooks/useBudget"; import { useReportsPeriod } from "../hooks/useReportsPeriod"; import YearNavigator from "../components/budget/YearNavigator"; import BudgetTable from "../components/budget/BudgetTable"; import TemplateActions from "../components/budget/TemplateActions"; +import type { BudgetOnboardingTargetKind } from "../shared/types"; export default function BudgetPage() { const { t } = useTranslation(); + const { pathname } = useLocation(); + const onboarding = useOnboarding(); const { accountIds, setAccountIds } = useReportsPeriod(); const { state, @@ -22,6 +30,9 @@ export default function BudgetPage() { applyTemplateAllMonths, deleteTemplate, } = useBudget(); + // What the grid points the getting-started card at (#400). The collapse + // state lives in BudgetTable, which reports its target here. + const [gridTarget, setGridTarget] = useState(null); const { year, rows, templates, accounts, isLoading, isSaving, error } = state; @@ -30,52 +41,67 @@ export default function BudgetPage() { // actions stay. const isEmptyProfile = showsEmptyState(state); + // The getting-started card follows the grid (#400). Its target is + // highlighted only while the card is on screen: a ring needs the card that + // explains it. + const cardModel = budgetCardModel({ target: gridTarget }, onboarding.checklist); + const highlight = + cardModel.target !== null && showsStepCard(onboarding.mode, onboarding.cardPage, pathname); + return ( -

-
-
-

{t("budget.title")}

- +
+ {/* Outside the loading dim: the grid reloads after every save, right + when the card says "Done.". */} + + +
+
+
+

{t("budget.title")}

+ +
+
- + + {error && ( +
+ {error} +
+ )} + + {isEmptyProfile ? ( + } + title={t("budget.empty.title")} + description={t("budget.empty.description")} + primaryAction={{ label: t("budget.empty.cta"), to: "/import" }} + /> + ) : ( + <> + } + accountIds={accountIds} + onAccountIdsChange={setAccountIds} + accounts={accounts} + /> + + + + )}
- - {error && ( -
- {error} -
- )} - - {isEmptyProfile ? ( - } - title={t("budget.empty.title")} - description={t("budget.empty.description")} - primaryAction={{ label: t("budget.empty.cta"), to: "/import" }} - /> - ) : ( - <> - } - accountIds={accountIds} - onAccountIdsChange={setAccountIds} - accounts={accounts} - /> - - - - )}
); } diff --git a/src/pages/BudgetPage.wiring.test.ts b/src/pages/BudgetPage.wiring.test.ts new file mode 100644 index 0000000..6023293 --- /dev/null +++ b/src/pages/BudgetPage.wiring.test.ts @@ -0,0 +1,186 @@ +// 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(); + 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()), + 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()), + 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 & { 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 { + 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, { + 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); + }); +}); diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 38a950a..d1873b1 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -1021,3 +1021,19 @@ export interface ImportCardInput { /** Transactions the import added (`ImportReport.importedCount`), null outside the `report` step. */ importedCount: number | null; } + +/** + * The control of the budget grid that the Budget step card points at (#400): + * a month cell of an editable row, or "Expand all" while none is shown. + */ +export type BudgetOnboardingTargetKind = "cell" | "expandAll"; + +/** + * What the Budget page's step card reads of the grid (#400). The collapse + * state lives in `BudgetTable`, which reports its target to the page; + * `budgetCardModel` turns it into the card. + */ +export interface BudgetCardInput { + /** The grid's target; null without one (no grid on the page, no row) or before the grid says. */ + target: BudgetOnboardingTargetKind | null; +} diff --git a/src/utils/onboardingPath.test.ts b/src/utils/onboardingPath.test.ts index 075617d..6ef80be 100644 --- a/src/utils/onboardingPath.test.ts +++ b/src/utils/onboardingPath.test.ts @@ -4,6 +4,7 @@ import { computeChecklist, type Checklist } from "./onboardingChecklist"; import { CARD_PAGES, bannerMode, + budgetCardModel, cardNext, cardStep, importCardModel, @@ -16,6 +17,7 @@ import { stepsOfPage, transactionsCardModel, type BannerModeInput, + type BudgetCardModel, type ImportCardModel, type StepCardText, type TransactionsCardModel, @@ -61,6 +63,17 @@ const step = (key: ChecklistItemKey): ChecklistItem => const ALL_CARD_PAGES: ReadonlySet = new Set(["/import", "/transactions", "/budget"]); +/** A sentence of a card as StepCard renders it, through the app's real i18n. */ +const render = (text: StepCardText | null, lng: "fr" | "en") => + text && + i18n.t(text.key, { + lng, + ...text.values, + ...Object.fromEntries( + Object.entries(text.labels ?? {}).map(([name, key]) => [name, i18n.t(key, { lng })]), + ), + }); + describe("nextStep (#396)", () => { it("is the folder step on a new profile", () => { expect(nextStep(checklistOf(zero))?.key).toBe("importFolder"); @@ -346,7 +359,7 @@ describe("stepNavState (#397)", () => { }); }); -describe("CARD_PAGES (#398, #399)", () => { +describe("CARD_PAGES (#398, #399, #400)", () => { it("carries the Import page", () => { expect(CARD_PAGES.has("/import")).toBe(true); }); @@ -354,6 +367,10 @@ describe("CARD_PAGES (#398, #399)", () => { it("carries the Transactions page", () => { expect(CARD_PAGES.has("/transactions")).toBe(true); }); + + it("carries the Budget page", () => { + expect(CARD_PAGES.has("/budget")).toBe(true); + }); }); describe("showsStepCard (#398)", () => { @@ -594,17 +611,6 @@ describe("transactionsCardModel (#399)", () => { target: "firstRowCategory", }; - /** A sentence of the card as StepCard renders it, through the app's real i18n. */ - const render = (text: StepCardText | null, lng: "fr" | "en") => - text && - i18n.t(text.key, { - lng, - ...text.values, - ...Object.fromEntries( - Object.entries(text.labels ?? {}).map(([name, key]) => [name, i18n.t(key, { lng })]), - ), - }); - it("only the uncategorized transactions listed: says so, the filter named by its own label", () => { expect(transactionsCardModel({ uncategorizedOnly: true }, imported)).toEqual(UNCATEGORIZED); }); @@ -655,6 +661,76 @@ describe("transactionsCardModel (#399)", () => { }); }); +describe("budgetCardModel (#400)", () => { + /** Steps 2 to 5 done: the next step is the budget. */ + const budgetLeft = checklistOf(upToManual, { importFolderSet: true }); + const COLLAPSED: BudgetCardModel = { + text: { + key: "onboarding.card.budget.collapsed", + labels: { expand: "reports.collapse.expandAll" }, + }, + done: false, + target: "expandAll", + }; + const FLAT: BudgetCardModel = { + text: { key: "onboarding.card.budget.flat" }, + done: false, + target: "cell", + }; + const DONE: BudgetCardModel = { text: null, done: true, target: null }; + + it("'Expand all' pointed at: unfold the grid first, the button named by its own label", () => { + expect(budgetCardModel({ target: "expandAll" }, budgetLeft)).toEqual(COLLAPSED); + }); + + it("a month cell pointed at: the sentence for a grid whose editable rows show", () => { + expect(budgetCardModel({ target: "cell" }, budgetLeft)).toEqual(FLAT); + }); + + it("no target (no grid on the page, no row, or not said yet): no sentence and no ring", () => { + expect(budgetCardModel({ target: null }, budgetLeft)).toEqual({ + text: null, + done: false, + target: null, + }); + }); + + it("follows the grid before the checklist is read", () => { + expect(budgetCardModel({ target: "expandAll" }, null)).toEqual(COLLAPSED); + expect(budgetCardModel({ target: "cell" }, null)).toEqual(FLAT); + }); + + it("the budget step checked: 'Done.', nothing to point at, whatever the grid shows", () => { + const budgeted = checklistOf({ ...upToManual, budgetEntries: 1 }, { importFolderSet: true }); + for (const target of ["expandAll", "cell", null] as const) { + expect(budgetCardModel({ target }, budgeted), String(target)).toEqual(DONE); + } + }); + + it("a skipped step is not a checked one", () => { + const skipped = checklistOf(upToManual, { importFolderSet: true, skipped: ["budget"] }); + expect(budgetCardModel({ target: "cell" }, skipped)).toEqual(FLAT); + }); + + it("reads the two sentences in French, the button named by its label", () => { + expect(render(COLLAPSED.text, "fr")).toBe( + "Cliquez Tout déplier pour afficher vos catégories, puis cliquez sur un mois et saisissez un montant. Entrée enregistre ; Tab enregistre et passe au mois suivant.", + ); + expect(render(FLAT.text, "fr")).toBe( + "Cliquez sur un mois et saisissez un montant. Entrée enregistre ; Tab enregistre et passe au mois suivant.", + ); + }); + + it("reads the two sentences in English, the button named by its label", () => { + expect(render(COLLAPSED.text, "en")).toBe( + "Click Expand all to show your categories, then click a month and enter an amount. Enter saves; Tab saves and moves to the next month.", + ); + expect(render(FLAT.text, "en")).toBe( + "Click a month and enter an amount. Enter saves; Tab saves and moves to the next month.", + ); + }); +}); + describe("onboardingTargetAttributes (#398)", () => { it("marks the target and its highlight when on, nothing otherwise", () => { expect(onboardingTargetAttributes("wizard-next", true)).toEqual({ diff --git a/src/utils/onboardingPath.ts b/src/utils/onboardingPath.ts index 2c5a0ac..eb6f0d4 100644 --- a/src/utils/onboardingPath.ts +++ b/src/utils/onboardingPath.ts @@ -1,5 +1,7 @@ import type { Checklist } from "./onboardingChecklist"; import type { + BudgetCardInput, + BudgetOnboardingTargetKind, ChecklistItem, ChecklistItemKey, ImportCardInput, @@ -8,7 +10,7 @@ import type { } from "../shared/types"; /** - * Pure rules of the guided getting-started path (#396, #398, #399). + * Pure rules of the guided getting-started path (#396, #398, #399, #400). * * The provider (`useOnboardingChecklist`) feeds them its state; the banner, * the step cards and the pages that carry a card only read what they decide. @@ -25,10 +27,14 @@ export function nextStep(checklist: Checklist): ChecklistItem | null { /** * Routes whose page carries a step card. Each card issue adds its page (#398: - * Import, #399: Transactions), and a page without a delivered card keeps the - * banner, so a partly merged stack never removes any guidance. + * Import, #399: Transactions, #400: Budget), and a page without a delivered + * card keeps the banner, so a partly merged stack never removes any guidance. */ -export const CARD_PAGES: ReadonlySet = new Set(["/import", "/transactions"]); +export const CARD_PAGES: ReadonlySet = new Set([ + "/import", + "/transactions", + "/budget", +]); const PAGE_STEPS: Readonly> = { "/import": ["importFolder", "source", "import"], @@ -368,6 +374,45 @@ export function transactionsCardModel( }; } +export interface BudgetCardModel extends StepCardModel { + /** The control to highlight, null for none. */ + target: BudgetOnboardingTargetKind | null; +} + +/** + * The Budget page's card (#400), read from the grid's target and the + * checklist: + * - the budget step checked: "Done.", nothing to point at; an entry of 0 + * counts, as it always has (D13); + * - "Expand all" pointed at: the sentence that starts by unfolding the + * grid, the button named by its own label; + * - a month cell pointed at: the sentence for a grid whose editable rows + * show; + * - no target (the grid is not on the page, has no row, or has not said yet): + * no sentence and no ring; the title and "Skip this step" stay. + */ +export function budgetCardModel( + input: BudgetCardInput, + checklist: Checklist | null, +): BudgetCardModel { + if (checked(checklist, "budget")) return { text: null, done: true, target: null }; + switch (input.target) { + case "expandAll": + return { + text: { + key: "onboarding.card.budget.collapsed", + labels: { expand: "reports.collapse.expandAll" }, + }, + done: false, + target: "expandAll", + }; + case "cell": + return { text: { key: "onboarding.card.budget.flat" }, done: false, target: "cell" }; + default: + return { text: null, done: false, target: null }; + } +} + /** The attributes of a highlighted control; `styles.css` draws the ring on the second one. */ export interface OnboardingTargetAttributes { "data-onboarding-target"?: string;