diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index c8f8c3a..7a79449 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -7,6 +7,7 @@ - Un bandeau de démarrage en haut de chaque page tant que le parcours de démarrage est affiché : « Démarrage 3/6 », l'étape suivante avec un lien vers la page où la faire, « Voir la liste », qui déplie les six étapes en place, et « Masquer », qui masque le bandeau et la liste pour le profil courant. Il n'apparaît pas sur le tableau de bord d'un profil sans transaction, dont la carte d'accueil contient déjà la liste. Une fois toutes les étapes faites, il l'annonce et propose « Ouvrir le guide », qui mène à la section « Premiers pas » du guide d'utilisation, et « Fermer », qui le retire jusqu'à un réaffichage depuis Paramètres → Utilisateurs (#397). - Une carte de démarrage sur la page Import, à la place du bandeau, tant que l'étape suivante du parcours s'y fait (choisir un dossier d'import, configurer une source d'import, importer un premier fichier). Elle indique « Étape n sur N », dit quoi faire à chaque écran de l'assistant d'import et désigne le contrôle à utiliser par un contour qui pulse trois fois, et pas du tout quand le système demande de réduire les animations. Elle suit l'assistant pas à pas, annonce « Fait. » une fois le fichier importé et propose « Étape suivante », qui mène à la page de l'étape d'après ; elle n'annonce pas « Fait. » pour un import qui n'a ajouté aucune transaction. Arriver sur la page Import par un lien « Étape suivante » place le focus du clavier sur le titre de la carte. « Passer cette étape » marque l'étape comme passée dans la liste de démarrage, où « Reprendre » l'annule (#398). - Une carte de démarrage sur la page Transactions, à la place du bandeau, tant que l'étape suivante du parcours est de classer une transaction à la main. Le lien de cette étape ouvre la page sur les transactions non catégorisées, toutes dates confondues, ou sur toutes les transactions s'il n'en reste aucune à classer. La carte dit quoi faire et désigne la catégorie de la première transaction affichée. Choisir une catégorie coche l'étape : la carte annonce « Fait. » et propose « Saisir un premier budget », ou la fin du parcours en édition Gratuite (#399). +- Une carte de démarrage sur la page Budget, à la place du bandeau, tant que l'étape suivante du parcours est de saisir un premier budget. La grille du budget s'ouvre catégories repliées : la carte désigne d'abord « Tout déplier », puis la cellule du mois courant de la première catégorie à remplir, et ne déplie jamais la grille à votre place. Elle rappelle qu'Entrée enregistre, et que Tab enregistre et passe au mois suivant. Enregistrer un montant coche l'étape : la carte annonce « Fait. » et termine le parcours, avec « Ouvrir le guide » et « Fermer » (#400). ### Modifié diff --git a/CHANGELOG.md b/CHANGELOG.md index ec24ec3..dfda285 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - A getting-started banner at the top of every page while the getting-started path is shown: "Getting started 3/6", the next step with a link to the page where it is done, "Show the list", which unfolds the six steps in place, and "Hide", which hides the banner and the list for the current profile. It does not show on the dashboard of a profile without transactions, whose welcome card already holds the list. Once every step is done, it says so and offers "Open the guide", which leads to the "Getting Started" section of the user guide, and "Close", which removes it until it is shown again from Settings → Users (#397). - A getting-started card on the Import page, in place of the banner, while the next step of the path is done there (choose an import folder, set up an import source, import a first file). It shows "Step n of N", says what to do on each screen of the import wizard and points at the control to use with an outline that pulses three times, not at all when the system asks for reduced motion. It follows the wizard as you go, says "Done." once the file is imported and offers "Next step", which leads to the page of the following step; it does not say "Done." for an import that added no transaction. Arriving on the Import page by a "Next step" link puts the keyboard focus on the card's title. "Skip this step" marks the step as skipped in the getting-started list, where "Resume" takes it back (#398). - A getting-started card on the Transactions page, in place of the banner, while the next step of the path is to categorize a transaction by hand. The link of that step opens the page on the uncategorized transactions whatever their date, or on every transaction when none is left uncategorized. The card says what to do and points at the category of the first transaction shown. Choosing a category ticks the step: the card says "Done." and offers "Enter a first budget", or the end of the path in the Free edition (#399). +- A getting-started card on the Budget page, in place of the banner, while the next step of the path is to enter a first budget. The budget grid opens with its categories folded: the card first points at "Expand all", then at the current month's cell of the first category you can fill in, and never unfolds the grid for you. It reminds you that Enter saves, and that Tab saves and moves to the next month. Saving an amount ticks the step: the card says "Done." and ends the path, with "Open the guide" and "Close" (#400). ### Changed 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;