feat(reports): shared FilterPanel component (temporal slot + account multi-select) #282
6 changed files with 304 additions and 2 deletions
|
|
@ -8,6 +8,7 @@
|
|||
- Rapports → Tendances → par catégorie (vue tableau) : le tableau d'analyse de résultat est désormais **hiérarchique et repliable**, à l'image du rapport de comparaison. Les catégories parentes apparaissent en groupes indentés au-dessus de leurs sous-catégories, chacune avec son propre sous-total, et un chevron (plus un bouton « Tout déplier / Tout replier ») replie un groupe jusqu'à ce seul sous-total. Les groupes s'ouvrent **repliés**, et vos choix sont mémorisés séparément des tableaux comparables. La ligne **Résultat avant transferts** se place maintenant entre les sections de dépenses et la section des transferts au lieu du bas, et replier un groupe ne déplace aucun sous-total ni résultat — les chiffres sont toujours calculés sur l'ensemble des données (#265).
|
||||
- Rapports : pose les fondations d'un futur filtre de comptes pour les rapports — le hook de période partagé suit désormais aussi une sélection multi-comptes via l'URL (mémorisable comme la plage de dates, et validée contre les URL malformées ou modifiées à la main). Purement interne pour l'instant : aucun contrôle de filtre visible encore, le sélecteur de comptes et le branchement par rapport arrivent dans des issues de suivi (#272).
|
||||
- Rapports : les sept services de rapports (tendances, catégories dans le temps, comparaison réel-vs-réel, budget-vs-réel, dépenses par catégorie, et le tableau de bord Cartes) acceptent désormais un filtre multi-comptes optionnel, comparé à une liste paramétrée de sources d'import. Plomberie interne seulement — omettre le filtre partout donne des résultats identiques au byte près, et aucune page de rapport n'expose encore de contrôle de filtre ; cela arrive dans des issues de suivi (#273).
|
||||
- Rapports : pose les fondations de l'interface du filtre de comptes — un panneau de filtre partagé affiche le contrôle de période propre à chaque rapport à côté d'une sélection multiple (cases à cocher) de vos sources d'import. Purement interne pour l'instant : aucune page de rapport n'affiche encore ce panneau, cela arrive dans des issues de suivi (#274).
|
||||
|
||||
### Corrigé
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
- Reports → Trends → by category (table view): the income-statement table is now **hierarchical and collapsible**, matching the compare report. Parent categories appear as indented groups above their sub-categories, each with its own subtotal, and a chevron (plus an "Expand all / Collapse all" button) folds a group down to just that subtotal. Groups start **collapsed**, and your choices are remembered separately from the comparable tables. The **Result before transfers** line now sits between the expense sections and the transfers section instead of at the bottom, and folding a group never moves any subtotal or result — the figures are always computed from the full data (#265).
|
||||
- Reports: laid the groundwork for an upcoming account filter across the report pages — the shared period hook now also tracks a URL-backed, multi-account selection (bookmarkable like the date range, and validated against malformed/hand-edited URLs). Internal only for now: no filter control is visible yet, the account picker and per-report wiring land in follow-up issues (#272).
|
||||
- Reports: all seven report services (trends, by-category-over-time, real-vs-real compare, budget-vs-actual, expenses-by-category, and the Cartes dashboard) now accept an optional multi-account filter, matched against a parameterized list of import sources. Backend plumbing only — leaving every filter out still returns byte-identical results, and no report page exposes a filter control yet; that lands in follow-up issues (#273).
|
||||
- Reports: laid the groundwork for the account filter's UI — a shared filter panel renders each report page's own period control alongside a multi-select (checkbox list) of your import sources. Internal only for now: no report page renders this panel yet, that lands in follow-up issues (#274).
|
||||
|
||||
### Fixed
|
||||
|
||||
|
|
|
|||
199
src/components/reports/FilterPanel.test.tsx
Normal file
199
src/components/reports/FilterPanel.test.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
// FilterPanel — unit tests (Issue #274).
|
||||
//
|
||||
// NOTE: This project has no @testing-library/react / jsdom configured (see
|
||||
// PriceFetchControl.test.tsx). FilterPanel, unlike most components with
|
||||
// their own test file, has NO internal state or hooks besides `useTranslation`
|
||||
// — mocked below into a plain function — so it can be invoked directly as a
|
||||
// plain function (bypassing JSX/React.createElement's usual `<FilterPanel />`
|
||||
// call site) and its return value (a plain React-element object tree, since
|
||||
// JSX creation never touches the DOM) walked directly. This gives genuine
|
||||
// coverage of the shipped render + selection-wiring code, not a
|
||||
// re-implementation of its logic in the test.
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: vi.fn(() => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: "fr" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
Inbox: () => null,
|
||||
}));
|
||||
|
||||
import FilterPanel, { toggleAccountId } from "./FilterPanel";
|
||||
import type { FilterPanelProps } from "./FilterPanel";
|
||||
import type { ImportSource } from "../../shared/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal React-element tree walker (no renderer/DOM involved)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ElementNode {
|
||||
type: unknown;
|
||||
props: Record<string, unknown> & { children?: unknown };
|
||||
}
|
||||
|
||||
type FlatNode = ElementNode | string | number;
|
||||
|
||||
function isElementNode(x: unknown): x is ElementNode {
|
||||
return typeof x === "object" && x !== null && "props" in x;
|
||||
}
|
||||
|
||||
function flatten(node: unknown): FlatNode[] {
|
||||
if (Array.isArray(node)) return node.flatMap(flatten);
|
||||
if (node === null || node === undefined || typeof node === "boolean") return [];
|
||||
if (typeof node === "string" || typeof node === "number") return [node];
|
||||
if (isElementNode(node)) return [node, ...flatten(node.props.children)];
|
||||
return [];
|
||||
}
|
||||
|
||||
function findCheckboxes(root: unknown): ElementNode[] {
|
||||
return flatten(root).filter(
|
||||
(n): n is ElementNode => isElementNode(n) && n.props.type === "checkbox",
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SOURCE_CHEQUING: ImportSource = {
|
||||
id: 1,
|
||||
name: "Compte chèque Desjardins",
|
||||
date_format: "YYYY-MM-DD",
|
||||
delimiter: ",",
|
||||
encoding: "utf-8",
|
||||
column_mapping: "{}",
|
||||
skip_lines: 0,
|
||||
has_header: true,
|
||||
created_at: "2026-01-01",
|
||||
updated_at: "2026-01-01",
|
||||
};
|
||||
|
||||
const SOURCE_VISA: ImportSource = {
|
||||
...SOURCE_CHEQUING,
|
||||
id: 2,
|
||||
name: "Visa Desjardins",
|
||||
};
|
||||
|
||||
function renderPanel(overrides: Partial<FilterPanelProps> = {}) {
|
||||
const onAccountIdsChange = vi.fn();
|
||||
const props: FilterPanelProps = {
|
||||
temporalControl: "TEMPORAL_CONTROL_MARKER",
|
||||
accountIds: [],
|
||||
onAccountIdsChange,
|
||||
accounts: [SOURCE_CHEQUING, SOURCE_VISA],
|
||||
...overrides,
|
||||
};
|
||||
const element = FilterPanel(props);
|
||||
return { element, onAccountIdsChange };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// toggleAccountId — pure selection logic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("toggleAccountId", () => {
|
||||
it("adds an absent id to an empty list", () => {
|
||||
expect(toggleAccountId([], 1)).toEqual([1]);
|
||||
});
|
||||
|
||||
it("appends an absent id after the existing ones", () => {
|
||||
expect(toggleAccountId([1, 2], 3)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("removes a present id, preserving the order of the remaining ids", () => {
|
||||
expect(toggleAccountId([1, 2, 3], 2)).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it("removing the only selected id returns an empty array (= all accounts)", () => {
|
||||
expect(toggleAccountId([1], 1)).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not mutate the input array", () => {
|
||||
const input = [1, 2];
|
||||
toggleAccountId(input, 3);
|
||||
expect(input).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FilterPanel — render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("FilterPanel — render", () => {
|
||||
it("renders the injected temporalControl as-is", () => {
|
||||
const { element } = renderPanel();
|
||||
expect(flatten(element)).toContain("TEMPORAL_CONTROL_MARKER");
|
||||
});
|
||||
|
||||
it("renders one checkbox per account", () => {
|
||||
const { element } = renderPanel();
|
||||
expect(findCheckboxes(element)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("renders each account's name as visible text", () => {
|
||||
const { element } = renderPanel();
|
||||
const text = flatten(element);
|
||||
expect(text).toContain("Compte chèque Desjardins");
|
||||
expect(text).toContain("Visa Desjardins");
|
||||
});
|
||||
|
||||
it("checks exactly the accounts present in accountIds", () => {
|
||||
const { element } = renderPanel({ accountIds: [2] });
|
||||
const checkboxes = findCheckboxes(element);
|
||||
expect(checkboxes.map((c) => c.props.checked)).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it("checks no checkbox when accountIds is empty (= all accounts, no explicit filter)", () => {
|
||||
const { element } = renderPanel({ accountIds: [] });
|
||||
const checkboxes = findCheckboxes(element);
|
||||
expect(checkboxes.every((c) => c.props.checked === false)).toBe(true);
|
||||
});
|
||||
|
||||
it("renders zero checkboxes when accounts is empty, without crashing", () => {
|
||||
const { element } = renderPanel({ accounts: [] });
|
||||
expect(findCheckboxes(element)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("uses i18n keys for the accounts filter label and hint (never a hardcoded string)", () => {
|
||||
const { element } = renderPanel();
|
||||
const text = flatten(element);
|
||||
expect(text).toContain("reports.filters.accounts.label");
|
||||
expect(text).toContain("reports.filters.accounts.hint");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FilterPanel — selection wiring
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("FilterPanel — selection wiring", () => {
|
||||
it("checking an unselected account calls onAccountIdsChange with it added", () => {
|
||||
const { element, onAccountIdsChange } = renderPanel({ accountIds: [1] });
|
||||
const checkboxes = findCheckboxes(element);
|
||||
// accounts[1] = SOURCE_VISA (id 2), currently unselected
|
||||
(checkboxes[1].props.onChange as () => void)();
|
||||
expect(onAccountIdsChange).toHaveBeenCalledWith([1, 2]);
|
||||
});
|
||||
|
||||
it("unchecking the only selected account calls onAccountIdsChange with an empty array", () => {
|
||||
const { element, onAccountIdsChange } = renderPanel({ accountIds: [1] });
|
||||
const checkboxes = findCheckboxes(element);
|
||||
// accounts[0] = SOURCE_CHEQUING (id 1), currently selected
|
||||
(checkboxes[0].props.onChange as () => void)();
|
||||
expect(onAccountIdsChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it("checking a second account preserves the first (multi-select)", () => {
|
||||
const { element, onAccountIdsChange } = renderPanel({ accountIds: [] });
|
||||
const checkboxes = findCheckboxes(element);
|
||||
(checkboxes[0].props.onChange as () => void)();
|
||||
(checkboxes[1].props.onChange as () => void)();
|
||||
expect(onAccountIdsChange).toHaveBeenNthCalledWith(1, [1]);
|
||||
expect(onAccountIdsChange).toHaveBeenNthCalledWith(2, [2]);
|
||||
});
|
||||
});
|
||||
93
src/components/reports/FilterPanel.tsx
Normal file
93
src/components/reports/FilterPanel.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
// FilterPanel — shared filter bar for the report pages (Issue #274, epic #260).
|
||||
//
|
||||
// Design: a "slot" component. Each report page owns its own temporal state
|
||||
// (Compare = a local reducer, Trends/ByCategory = the `sources` query string
|
||||
// via useReportsPeriod, Budget = a plain year) and renders its own control
|
||||
// (PeriodSelector / CompareReferenceMonthPicker / YearNavigator / …), passed
|
||||
// in as-is via `temporalControl`. FilterPanel does NOT own a `temporalMode`
|
||||
// enum and does not know which control it is rendering — an earlier design
|
||||
// unifying temporal state behind such an enum was rejected specifically to
|
||||
// avoid mixing state sources (see spec-plan-rapports-uniformes-suite.md).
|
||||
// The only state this component actually owns/shares across report pages is
|
||||
// the account (import source) filter.
|
||||
//
|
||||
// `accounts`/`accountIds` refer to `ImportSource` rows (bank statement import
|
||||
// sources, e.g. "Compte chèque Desjardins") — a disjoint id space from the
|
||||
// Bilan module's `balance_accounts` ("Compte" in that module's own UI). The
|
||||
// copy in this component deliberately says "sources" everywhere, never
|
||||
// "comptes"/"accounts", so the two concepts never read as the same filter.
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Inbox } from "lucide-react";
|
||||
import type { ImportSource } from "../../shared/types";
|
||||
|
||||
export interface FilterPanelProps {
|
||||
/** The page's own temporal control, rendered as-is. */
|
||||
temporalControl: ReactNode;
|
||||
/** Selected import source ids. Empty array = no filter (all sources). */
|
||||
accountIds: number[];
|
||||
onAccountIdsChange: (accountIds: number[]) => void;
|
||||
/** Import sources available to filter on. */
|
||||
accounts: ImportSource[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles `id` in/out of `accountIds` (add if absent, remove if present),
|
||||
* preserving the relative order of the remaining ids. Pure and exported so
|
||||
* the selection logic is unit-testable without rendering the component.
|
||||
*/
|
||||
export function toggleAccountId(accountIds: number[], id: number): number[] {
|
||||
return accountIds.includes(id)
|
||||
? accountIds.filter((existing) => existing !== id)
|
||||
: [...accountIds, id];
|
||||
}
|
||||
|
||||
export default function FilterPanel({
|
||||
temporalControl,
|
||||
accountIds,
|
||||
onAccountIdsChange,
|
||||
accounts,
|
||||
}: FilterPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const accountsLabel = t("reports.filters.accounts.label");
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] rounded-xl p-4 border border-[var(--border)] mb-4 flex flex-wrap items-start gap-4">
|
||||
<div>{temporalControl}</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-[var(--muted-foreground)]">
|
||||
<Inbox size={14} />
|
||||
{accountsLabel}
|
||||
</span>
|
||||
<div
|
||||
className="flex flex-wrap gap-x-4 gap-y-1.5"
|
||||
role="group"
|
||||
aria-label={accountsLabel}
|
||||
>
|
||||
{accounts.map((account) => (
|
||||
<label
|
||||
key={account.id}
|
||||
className="flex items-center gap-1.5 text-sm cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={accountIds.includes(account.id)}
|
||||
onChange={() =>
|
||||
onAccountIdsChange(toggleAccountId(accountIds, account.id))
|
||||
}
|
||||
className="accent-[var(--primary)]"
|
||||
/>
|
||||
{account.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{t("reports.filters.accounts.hint")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -392,7 +392,11 @@
|
|||
"search": "Search...",
|
||||
"all": "All",
|
||||
"none": "None",
|
||||
"allTypes": "All types"
|
||||
"allTypes": "All types",
|
||||
"accounts": {
|
||||
"label": "Import sources",
|
||||
"hint": "No selection = all sources"
|
||||
}
|
||||
},
|
||||
"bva": {
|
||||
"monthly": "Monthly",
|
||||
|
|
|
|||
|
|
@ -392,7 +392,11 @@
|
|||
"search": "Rechercher...",
|
||||
"all": "Toutes",
|
||||
"none": "Aucune",
|
||||
"allTypes": "Tous les types"
|
||||
"allTypes": "Tous les types",
|
||||
"accounts": {
|
||||
"label": "Sources d'import",
|
||||
"hint": "Aucune sélection = toutes les sources"
|
||||
}
|
||||
},
|
||||
"bva": {
|
||||
"monthly": "Mensuel",
|
||||
|
|
|
|||
Loading…
Reference in a new issue