feat(onboarding): Budget page step card and its highlight #406
14 changed files with 1212 additions and 79 deletions
205
src/components/budget/BudgetTable.render.test.tsx
Normal file
205
src/components/budget/BudgetTable.render.test.tsx
Normal file
|
|
@ -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:<category id>`): the grid opens collapsed. */
|
||||
expanded: new Set<string>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useCollapsibleGroups", async () => {
|
||||
const { collapsibleKeys, isCollapsedFor, visibleRows } = await import("../../utils/collapsibleRows");
|
||||
return {
|
||||
useCollapsibleGroups: <T,>(_key: string | null, acc: CollapseAccessors<T>) => {
|
||||
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(
|
||||
<BudgetTable
|
||||
rows={rows}
|
||||
onUpdatePlanned={() => 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(/<button [^>]*data-onboarding-target="([^"]+)" data-onboarding-highlight="true"[^>]*>(.*?)<\/button>/);
|
||||
expect(match).not.toBeNull();
|
||||
return { target: match![1], inner: match![2] };
|
||||
}
|
||||
|
||||
/** The markup of the row named `name`, from its `<tr` to its `</tr>`. */
|
||||
function rowOf(html: string, name: string): string {
|
||||
const at = html.indexOf(`>${name}<`);
|
||||
expect(at).toBeGreaterThan(-1);
|
||||
return html.slice(html.lastIndexOf("<tr", at), html.indexOf("</tr>", at));
|
||||
}
|
||||
|
||||
/** The month (0-11) of the highlighted cell in a row, -1 for none: name, last year and annual come first. */
|
||||
function ringedMonth(row: string): number {
|
||||
const cell = row
|
||||
.split("<td")
|
||||
.slice(1)
|
||||
.findIndex((td) => td.includes(HIGHLIGHT));
|
||||
return cell === -1 ? -1 : cell - 3;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
harness.expanded = new Set();
|
||||
vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => undefined });
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date(2026, MARCH, 15));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("BudgetTable — the getting-started card's target (#400)", () => {
|
||||
it("groups collapsed: 'Expand all' is the one target, no cell", () => {
|
||||
const html = render(GROUPED, true);
|
||||
const target = highlighted(html);
|
||||
expect(target.target).toBe(BUDGET_EXPAND_ALL_TARGET);
|
||||
expect(target.inner).toContain("t(reports.collapse.expandAll)");
|
||||
expect(html).not.toContain(BUDGET_CELL_TARGET);
|
||||
});
|
||||
|
||||
it("a group open: this month's cell of its first editable row is the one target", () => {
|
||||
harness.expanded = new Set(["p:10"]);
|
||||
const html = render(GROUPED, true);
|
||||
expect(highlighted(html).target).toBe(BUDGET_CELL_TARGET);
|
||||
expect(ringedMonth(rowOf(html, "Loyer"))).toBe(MARCH);
|
||||
expect(ringedMonth(rowOf(html, "Électricité"))).toBe(-1);
|
||||
// The group's subtotal row has no button to point at.
|
||||
expect(ringedMonth(rowOf(html, "Logement"))).toBe(-1);
|
||||
expect(html).not.toContain(BUDGET_EXPAND_ALL_TARGET);
|
||||
});
|
||||
|
||||
it("every group open: the first editable row of the grid, income first", () => {
|
||||
harness.expanded = new Set(["p:1", "p:10"]);
|
||||
const html = render(GROUPED, true);
|
||||
expect(ringedMonth(rowOf(html, "Salaire"))).toBe(MARCH);
|
||||
expect(count(html, HIGHLIGHT)).toBe(1);
|
||||
});
|
||||
|
||||
it("a category without sub-category shown beside collapsed groups: its cell, 'Expand all' stays plain", () => {
|
||||
const html = render(WITH_STANDALONE, true);
|
||||
expect(highlighted(html).target).toBe(BUDGET_CELL_TARGET);
|
||||
expect(ringedMonth(rowOf(html, "Divers"))).toBe(MARCH);
|
||||
expect(html).toContain("t(reports.collapse.expandAll)");
|
||||
expect(html).not.toContain(BUDGET_EXPAND_ALL_TARGET);
|
||||
});
|
||||
|
||||
it("no groups: no 'Expand all' at all, the first row's cell", () => {
|
||||
const html = render(FLAT, true);
|
||||
expect(html).not.toContain("t(reports.collapse.expandAll)");
|
||||
expect(ringedMonth(rowOf(html, "Divers"))).toBe(MARCH);
|
||||
expect(count(html, HIGHLIGHT)).toBe(1);
|
||||
});
|
||||
|
||||
it("the cell follows the clock's month", () => {
|
||||
vi.setSystemTime(new Date(2026, DECEMBER, 31, 23, 0));
|
||||
expect(ringedMonth(rowOf(render(FLAT, true), "Divers"))).toBe(DECEMBER);
|
||||
});
|
||||
|
||||
it("subtotals at the bottom: still the first editable row shown", () => {
|
||||
vi.stubGlobal("localStorage", { getItem: () => "bottom", setItem: () => undefined });
|
||||
harness.expanded = new Set(["p:10"]);
|
||||
const html = render(GROUPED, true);
|
||||
expect(html).toContain("t(reports.subtotalsOnBottom)");
|
||||
expect(ringedMonth(rowOf(html, "Loyer"))).toBe(MARCH);
|
||||
expect(count(html, HIGHLIGHT)).toBe(1);
|
||||
});
|
||||
|
||||
it("without `onboardingTarget`, nothing is highlighted", () => {
|
||||
for (const expanded of [[], ["p:10"]]) {
|
||||
harness.expanded = new Set(expanded);
|
||||
for (const onboardingTarget of [false, undefined]) {
|
||||
const html = render(GROUPED, onboardingTarget);
|
||||
expect(count(html, HIGHLIGHT)).toBe(0);
|
||||
expect(html).not.toContain("data-onboarding-target");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("no row: the grid's 'no category' box, nothing highlighted", () => {
|
||||
const html = render([], true);
|
||||
expect(html).toContain("t(budget.noCategories)");
|
||||
expect(count(html, HIGHLIGHT)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
import { useState, useRef, useEffect, Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, ArrowUpDown, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
|
||||
import type { BudgetYearRow } from "../../shared/types";
|
||||
import type { BudgetOnboardingTargetKind, BudgetYearRow } from "../../shared/types";
|
||||
import { reorderRows } from "../../utils/reorderRows";
|
||||
import type { CollapseAccessors } from "../../utils/collapsibleRows";
|
||||
import { onboardingTargetAttributes } from "../../utils/onboardingPath";
|
||||
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
|
||||
import { computeBudgetResults, sumLeavesForType, type BudgetTotals } from "./budgetTableResults";
|
||||
import { budgetOnboardingTarget } from "./budgetOnboardingTarget";
|
||||
|
||||
const fmt = new Intl.NumberFormat("en-CA", {
|
||||
style: "currency",
|
||||
|
|
@ -37,13 +39,35 @@ const BUDGET_COLLAPSE_ACCESSORS: CollapseAccessors<BudgetYearRow> = {
|
|||
|
||||
const BUDGET_EXPANDED_KEY = "budget-grid-expanded";
|
||||
|
||||
/** `data-onboarding-target` of the highlighted month cell. */
|
||||
export const BUDGET_CELL_TARGET = "budget-cell";
|
||||
/** `data-onboarding-target` of the highlighted "Expand all" button. */
|
||||
export const BUDGET_EXPAND_ALL_TARGET = "budget-expand-all";
|
||||
|
||||
interface BudgetTableProps {
|
||||
rows: BudgetYearRow[];
|
||||
onUpdatePlanned: (categoryId: number, month: number, amount: number) => void;
|
||||
onSplitEvenly: (categoryId: number, annualAmount: number) => void;
|
||||
/**
|
||||
* Highlight the control the getting-started card points at (#400): this
|
||||
* month's cell of the first editable row shown, or "Expand all" while none
|
||||
* is shown (`budgetOnboardingTarget`). One target at a time.
|
||||
*/
|
||||
onboardingTarget?: boolean;
|
||||
/**
|
||||
* Told which control that is, at mount and whenever it changes: the
|
||||
* collapse state lives here, and the card's sentence depends on it.
|
||||
*/
|
||||
onOnboardingTargetChange?: (kind: BudgetOnboardingTargetKind | null) => void;
|
||||
}
|
||||
|
||||
export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: BudgetTableProps) {
|
||||
export default function BudgetTable({
|
||||
rows,
|
||||
onUpdatePlanned,
|
||||
onSplitEvenly,
|
||||
onboardingTarget,
|
||||
onOnboardingTargetChange,
|
||||
}: BudgetTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const [editingCell, setEditingCell] = useState<{ categoryId: number; monthIdx: number } | null>(null);
|
||||
const [editingAnnual, setEditingAnnual] = useState<{ categoryId: number } | null>(null);
|
||||
|
|
@ -177,6 +201,30 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
|||
|
||||
const totalCols = 15; // category + prev year + annual + 12 months
|
||||
|
||||
// The rows each section shows, in the order it shows them: collapse-filtered
|
||||
// (issue #289), then subtotals moved to the chosen side.
|
||||
const shownByType: Record<string, BudgetYearRow[]> = {};
|
||||
for (const type of typeOrder) {
|
||||
shownByType[type] = reorderRows(groups.visible(grouped[type] ?? []), subtotalsOnTop);
|
||||
}
|
||||
const hasGroups = groups.groupCount(rows) > 0;
|
||||
const allExpanded = groups.allExpanded(rows);
|
||||
|
||||
// The control the getting-started card points at (#400, D12): this month's
|
||||
// cell of the first editable row shown, else "Expand all". Its kind goes up
|
||||
// to the page, which picks the card's sentence; the grid stays as the user
|
||||
// left it (the card never unfolds it).
|
||||
const target = budgetOnboardingTarget(
|
||||
typeOrder.flatMap((type) => shownByType[type]),
|
||||
hasGroups,
|
||||
new Date().getMonth(),
|
||||
);
|
||||
const targetKind = target?.kind ?? null;
|
||||
useEffect(() => {
|
||||
onOnboardingTargetChange?.(targetKind);
|
||||
}, [targetKind, onOnboardingTargetChange]);
|
||||
const targetCell = onboardingTarget && target?.kind === "cell" ? target : null;
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="bg-[var(--card)] rounded-xl p-8 border border-[var(--border)] text-center text-[var(--muted-foreground)]">
|
||||
|
|
@ -321,6 +369,12 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
|||
onClick={() => handleStartEdit(row.category_id, mIdx, val)}
|
||||
title={t("budget.clickToEdit")}
|
||||
className="w-full text-right hover:text-[var(--primary)] hover:bg-[var(--muted)]/40 transition-colors cursor-pointer text-xs rounded px-1 py-0.5"
|
||||
{...onboardingTargetAttributes(
|
||||
BUDGET_CELL_TARGET,
|
||||
targetCell !== null &&
|
||||
targetCell.categoryId === row.category_id &&
|
||||
targetCell.monthIdx === mIdx,
|
||||
)}
|
||||
>
|
||||
{formatSigned(val * sign)}
|
||||
</button>
|
||||
|
|
@ -352,7 +406,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
|||
{t(typeLabelKeys[type])}
|
||||
</td>
|
||||
</tr>
|
||||
{reorderRows(groups.visible(group), subtotalsOnTop).map((row) => renderRow(row))}
|
||||
{shownByType[type].map((row) => renderRow(row))}
|
||||
<tr className="bg-[var(--muted)]/40 border-b border-[var(--border)]">
|
||||
<td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)]/40 z-10 text-sm font-semibold">
|
||||
{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 (
|
||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
|
||||
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
|
||||
|
|
@ -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 ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
|
||||
{allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")}
|
||||
|
|
|
|||
174
src/components/budget/BudgetTable.wiring.test.ts
Normal file
174
src/components/budget/BudgetTable.wiring.test.ts
Normal file
|
|
@ -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<number, readonly unknown[] | undefined>(),
|
||||
/** Keys of the open groups (`p:<category id>`): the grid opens collapsed. */
|
||||
expanded: new Set<string>(),
|
||||
}));
|
||||
|
||||
vi.mock("react", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react")>();
|
||||
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: <T,>(_key: string | null, acc: CollapseAccessors<T>) => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
100
src/components/budget/budgetOnboardingTarget.test.ts
Normal file
100
src/components/budget/budgetOnboardingTarget.test.ts
Normal file
|
|
@ -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> = {}): 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<BudgetYearRow> = {}) =>
|
||||
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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
33
src/components/budget/budgetOnboardingTarget.ts
Normal file
33
src/components/budget/budgetOnboardingTarget.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
194
src/pages/BudgetPage.render.test.tsx
Normal file
194
src/pages/BudgetPage.render.test.tsx
Normal file
|
|
@ -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<string, unknown>) =>
|
||||
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<typeof import("../hooks/useBudget")>()),
|
||||
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<BudgetState> = {}) {
|
||||
const state: BudgetState = { ...initialBudgetState(), rows: ROWS, totalInDb: 41, ...overrides };
|
||||
const noop = vi.fn();
|
||||
return new Proxy({ state } as Record<string, unknown>, {
|
||||
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> = {}): 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(
|
||||
<MemoryRouter initialEntries={["/budget"]}>
|
||||
<BudgetPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
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("</h2>", html.indexOf(CARD));
|
||||
return html.slice(title).match(/^<\/h2><p [^>]*>(.*?)<\/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("<h1"));
|
||||
expect(html).toContain("t(onboarding.card.stepOf[n=6,total=6])");
|
||||
expect(html).toContain("t(onboarding.checklist.items.budget)");
|
||||
expect(html).toContain("t(onboarding.card.skip)");
|
||||
// The grid is there, below the card.
|
||||
expect(html.indexOf("Logement")).toBeGreaterThan(html.indexOf(CARD));
|
||||
});
|
||||
|
||||
it("before the grid reports its target: the title and 'Skip this step', no sentence, no ring", () => {
|
||||
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("<h1"));
|
||||
});
|
||||
|
||||
it("a first amount saved: the card stays, says 'Done.' and ends the path", () => {
|
||||
harness.onboarding = path(BUDGETED);
|
||||
const html = render();
|
||||
expect(count(html, CARD)).toBe(1);
|
||||
expect(html).toMatch(/<p role="status"[^>]*>.*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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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<BudgetOnboardingTargetKind | null>(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 (
|
||||
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
|
||||
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold">{t("budget.title")}</h1>
|
||||
<PageHelp helpKey="budget" />
|
||||
<div>
|
||||
{/* Outside the loading dim: the grid reloads after every save, right
|
||||
when the card says "Done.". */}
|
||||
<OnboardingStepCard model={cardModel} />
|
||||
|
||||
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
|
||||
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold">{t("budget.title")}</h1>
|
||||
<PageHelp helpKey="budget" />
|
||||
</div>
|
||||
<TemplateActions
|
||||
templates={templates}
|
||||
onApply={applyTemplate}
|
||||
onApplyAllMonths={applyTemplateAllMonths}
|
||||
onSave={saveTemplate}
|
||||
onDelete={deleteTemplate}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
<TemplateActions
|
||||
templates={templates}
|
||||
onApply={applyTemplate}
|
||||
onApplyAllMonths={applyTemplateAllMonths}
|
||||
onSave={saveTemplate}
|
||||
onDelete={deleteTemplate}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmptyProfile ? (
|
||||
<EmptyState
|
||||
icon={<Calculator size={40} />}
|
||||
title={t("budget.empty.title")}
|
||||
description={t("budget.empty.description")}
|
||||
primaryAction={{ label: t("budget.empty.cta"), to: "/import" }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<FilterPanel
|
||||
temporalControl={<YearNavigator year={year} onNavigate={navigateYear} />}
|
||||
accountIds={accountIds}
|
||||
onAccountIdsChange={setAccountIds}
|
||||
accounts={accounts}
|
||||
/>
|
||||
|
||||
<BudgetTable
|
||||
rows={rows}
|
||||
onUpdatePlanned={updatePlanned}
|
||||
onSplitEvenly={splitEvenly}
|
||||
onboardingTarget={highlight}
|
||||
onOnboardingTargetChange={setGridTarget}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmptyProfile ? (
|
||||
<EmptyState
|
||||
icon={<Calculator size={40} />}
|
||||
title={t("budget.empty.title")}
|
||||
description={t("budget.empty.description")}
|
||||
primaryAction={{ label: t("budget.empty.cta"), to: "/import" }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<FilterPanel
|
||||
temporalControl={<YearNavigator year={year} onNavigate={navigateYear} />}
|
||||
accountIds={accountIds}
|
||||
onAccountIdsChange={setAccountIds}
|
||||
accounts={accounts}
|
||||
/>
|
||||
|
||||
<BudgetTable
|
||||
rows={rows}
|
||||
onUpdatePlanned={updatePlanned}
|
||||
onSplitEvenly={splitEvenly}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
186
src/pages/BudgetPage.wiring.test.ts
Normal file
186
src/pages/BudgetPage.wiring.test.ts
Normal file
|
|
@ -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<typeof import("react")>();
|
||||
return {
|
||||
...actual,
|
||||
useState: (init: unknown) => {
|
||||
const index = harness.cursor++;
|
||||
if (!(index in harness.slots)) {
|
||||
harness.slots[index] = typeof init === "function" ? (init as () => unknown)() : init;
|
||||
}
|
||||
harness.setters[index] ??= (next: unknown) => {
|
||||
harness.slots[index] =
|
||||
typeof next === "function" ? (next as (previous: unknown) => unknown)(harness.slots[index]) : next;
|
||||
};
|
||||
return [harness.slots[index], harness.setters[index]];
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("react-router-dom", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-router-dom")>()),
|
||||
useLocation: () => ({ pathname: harness.pathname }),
|
||||
}));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }),
|
||||
}));
|
||||
// The page's modules pull the database: stub the connection.
|
||||
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
|
||||
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
|
||||
vi.mock("../hooks/useReportsPeriod", () => ({
|
||||
useReportsPeriod: () => ({ accountIds: [], setAccountIds: () => undefined }),
|
||||
}));
|
||||
vi.mock("../hooks/useBudget", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../hooks/useBudget")>()),
|
||||
useBudget: () => harness.budget,
|
||||
}));
|
||||
|
||||
import BudgetPage from "./BudgetPage";
|
||||
import BudgetTable from "../components/budget/BudgetTable";
|
||||
import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
|
||||
import { initialBudgetState, type BudgetState } from "../hooks/useBudget";
|
||||
import type { OnboardingValue } from "../hooks/useOnboardingChecklist";
|
||||
import { computeChecklist } from "../utils/onboardingChecklist";
|
||||
import { budgetCardModel, nextStep, type BudgetCardModel } from "../utils/onboardingPath";
|
||||
import type { BudgetOnboardingTargetKind, OnboardingCounters } from "../shared/types";
|
||||
|
||||
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
|
||||
|
||||
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
|
||||
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
|
||||
if (!isValidElement(node)) return [];
|
||||
const el = node as AnyElement;
|
||||
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
|
||||
}
|
||||
|
||||
/** Steps 2 to 5 done: the next step is the budget. */
|
||||
const BUDGET_LEFT: OnboardingCounters = {
|
||||
sources: 1,
|
||||
imports: 1,
|
||||
manualCategorized: 1,
|
||||
budgetEntries: 0,
|
||||
transactions: 41,
|
||||
};
|
||||
|
||||
/** The path latched on /budget (card mode), as the provider shares it. */
|
||||
function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue> = {}): OnboardingValue {
|
||||
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
|
||||
return {
|
||||
status: "ready",
|
||||
checklist,
|
||||
counters,
|
||||
hidden: false,
|
||||
budgetReady: true,
|
||||
nextStep: nextStep(checklist),
|
||||
mode: "card",
|
||||
cardPage: "/budget",
|
||||
hide: vi.fn(async () => undefined),
|
||||
show: vi.fn(async () => undefined),
|
||||
skip: vi.fn(async () => undefined),
|
||||
resume: vi.fn(async () => undefined),
|
||||
refresh: vi.fn(async () => undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** One render of the mounted page: its hooks are read in the same order each time. */
|
||||
function render() {
|
||||
harness.cursor = 0;
|
||||
const page = BudgetPage() as AnyElement;
|
||||
const [card] = find(page, (el) => el.type === OnboardingStepCard);
|
||||
const [grid] = find(page, (el) => el.type === BudgetTable);
|
||||
return {
|
||||
page,
|
||||
model: card.props.model as BudgetCardModel,
|
||||
highlight: grid.props.onboardingTarget as boolean,
|
||||
report: grid.props.onOnboardingTargetChange as (kind: BudgetOnboardingTargetKind | null) => void,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
harness.slots = [];
|
||||
harness.setters = [];
|
||||
harness.pathname = "/budget";
|
||||
harness.onboarding = path(BUDGET_LEFT);
|
||||
const state: BudgetState = { ...initialBudgetState(), totalInDb: 41 };
|
||||
harness.budget = new Proxy({ state } as Record<string, unknown>, {
|
||||
get: (target, key) => (key in target ? target[key as string] : vi.fn()),
|
||||
});
|
||||
});
|
||||
|
||||
describe("BudgetPage — the grid's target and the card (#400)", () => {
|
||||
it("renders the card as the page's first child, ahead of its heading", () => {
|
||||
const { page } = render();
|
||||
const children = page.props.children as ReactNode[];
|
||||
expect((children[0] as AnyElement).type).toBe(OnboardingStepCard);
|
||||
expect(find(children[0], (el) => el.type === "h1")).toHaveLength(0);
|
||||
expect(find(children.slice(1), (el) => el.type === "h1")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("follows what the grid reports: its sentence, and the ring once a target is known", () => {
|
||||
const checklist = (harness.onboarding as OnboardingValue).checklist;
|
||||
let view = render();
|
||||
expect(view.model).toEqual(budgetCardModel({ target: null }, checklist));
|
||||
expect(view.highlight).toBe(false);
|
||||
|
||||
view.report("expandAll");
|
||||
view = render();
|
||||
expect(view.model.text?.key).toBe("onboarding.card.budget.collapsed");
|
||||
expect(view.highlight).toBe(true);
|
||||
|
||||
view.report("cell");
|
||||
view = render();
|
||||
expect(view.model.text?.key).toBe("onboarding.card.budget.flat");
|
||||
expect(view.highlight).toBe(true);
|
||||
|
||||
view.report(null);
|
||||
view = render();
|
||||
expect(view.model.text).toBeNull();
|
||||
expect(view.highlight).toBe(false);
|
||||
});
|
||||
|
||||
it("asks for no ring while the card is not on screen", () => {
|
||||
for (const overrides of [
|
||||
{ mode: "banner" as const, cardPage: null },
|
||||
{ mode: "none" as const, cardPage: null },
|
||||
{ mode: "complete" as const, cardPage: null },
|
||||
{ mode: "card" as const, cardPage: "/transactions" },
|
||||
]) {
|
||||
harness.slots = [];
|
||||
harness.setters = [];
|
||||
harness.onboarding = path(BUDGET_LEFT, overrides);
|
||||
render().report("cell");
|
||||
expect(render().highlight, JSON.stringify(overrides)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("asks for no ring once the step is checked: the card says 'Done.'", () => {
|
||||
harness.onboarding = path({ ...BUDGET_LEFT, budgetEntries: 1 });
|
||||
render().report("cell");
|
||||
const view = render();
|
||||
expect(view.model).toEqual({ text: null, done: true, target: null });
|
||||
expect(view.highlight).toBe(false);
|
||||
});
|
||||
|
||||
it("hands the grid the same callback at every render, so the grid does not report at every render", () => {
|
||||
expect(render().report).toBe(render().report);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string> = 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({
|
||||
|
|
|
|||
|
|
@ -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<string> = new Set<string>(["/import", "/transactions"]);
|
||||
export const CARD_PAGES: ReadonlySet<string> = new Set<string>([
|
||||
"/import",
|
||||
"/transactions",
|
||||
"/budget",
|
||||
]);
|
||||
|
||||
const PAGE_STEPS: Readonly<Record<string, readonly ChecklistItemKey[]>> = {
|
||||
"/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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue