The two hierarchical comparable reports — real-vs-real Compare and real-vs-budget — now let the user collapse or expand each top-level category's sub-categories. Groups start collapsed by default (#260): only the parent's subtotal row shows until the user expands it, and each expanded group is remembered per report. - New pure module utils/collapsibleRows.ts (visibility filter, group-key extraction, expanded-set (de)serialization) + useCollapsibleGroups hook wrapping the localStorage-persisted expanded set. Persisting the *expanded* set (not the collapsed one) makes "all collapsed" the zero/default state. - A chevron toggles each top-level parent; an "Expand all / Collapse all" button toggles them together. Section subtotals and result lines keep summing every leaf, so collapsing never changes any total. - Accessors mirror each table's own depth/parent logic so hidden rows are exactly a group's indented descendants. - i18n keys reports.collapse.{expandAll,collapseAll} (FR/EN); CHANGELOG. CategoryOverTimeTable (Trends -> by category) is intentionally left out: its rows are a flat top-N category list with no parent/child hierarchy to fold. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
116 lines
3.8 KiB
TypeScript
116 lines
3.8 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
type CollapseAccessors,
|
|
collapsibleKeys,
|
|
parseStoredExpanded,
|
|
serializeExpanded,
|
|
visibleRows,
|
|
} from "./collapsibleRows";
|
|
|
|
/** Tiny hierarchy row for the tests: key, depth, is-parent. */
|
|
interface Row {
|
|
k: string;
|
|
d: number;
|
|
p: boolean;
|
|
}
|
|
|
|
const acc: CollapseAccessors<Row> = {
|
|
keyOf: (r) => r.k,
|
|
depthOf: (r) => r.d,
|
|
isParent: (r) => r.p,
|
|
};
|
|
|
|
const row = (k: string, d: number, p: boolean): Row => ({ k, d, p });
|
|
|
|
// A two-group tree with one nested sub-group:
|
|
// Housing (parent, d0)
|
|
// Rent (leaf, d1)
|
|
// Utilities (parent, d1) <- intermediate parent
|
|
// Power (leaf, d2)
|
|
// Salary (parent, d0)
|
|
// Paycheck (leaf, d1)
|
|
// Misc (leaf, d0) <- top-level leaf, not a group
|
|
const tree: Row[] = [
|
|
row("housing", 0, true),
|
|
row("rent", 1, false),
|
|
row("utilities", 1, true),
|
|
row("power", 2, false),
|
|
row("salary", 0, true),
|
|
row("paycheck", 1, false),
|
|
row("misc", 0, false),
|
|
];
|
|
|
|
// Default = collapsed: a group is collapsed unless its key is in the expanded set.
|
|
const isCollapsed = (expanded: Set<string>) => (r: Row) => !expanded.has(r.k);
|
|
|
|
describe("collapsibleRows.visibleRows", () => {
|
|
it("collapses every group by default (empty expanded set)", () => {
|
|
const visible = visibleRows(tree, acc, isCollapsed(new Set()));
|
|
// Only depth-0 rows survive: the two group headers + the top-level leaf.
|
|
expect(visible.map((r) => r.k)).toEqual(["housing", "salary", "misc"]);
|
|
});
|
|
|
|
it("reveals a group's full subtree (incl. nested sub-groups) when expanded", () => {
|
|
const visible = visibleRows(tree, acc, isCollapsed(new Set(["housing"])));
|
|
expect(visible.map((r) => r.k)).toEqual([
|
|
"housing",
|
|
"rent",
|
|
"utilities",
|
|
"power",
|
|
"salary",
|
|
"misc",
|
|
]);
|
|
});
|
|
|
|
it("keeps groups independent — expanding one leaves the others collapsed", () => {
|
|
const visible = visibleRows(tree, acc, isCollapsed(new Set(["salary"])));
|
|
expect(visible.map((r) => r.k)).toEqual(["housing", "salary", "paycheck", "misc"]);
|
|
});
|
|
|
|
it("shows everything when all groups are expanded", () => {
|
|
const visible = visibleRows(tree, acc, isCollapsed(new Set(["housing", "salary"])));
|
|
expect(visible).toHaveLength(tree.length);
|
|
});
|
|
|
|
it("always keeps a top-level leaf that has no group", () => {
|
|
const visible = visibleRows(tree, acc, isCollapsed(new Set()));
|
|
expect(visible.some((r) => r.k === "misc")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("collapsibleRows.collapsibleKeys", () => {
|
|
it("lists only top-level parent rows, in order", () => {
|
|
expect(collapsibleKeys(tree, acc)).toEqual(["housing", "salary"]);
|
|
});
|
|
|
|
it("returns [] when there is no hierarchy", () => {
|
|
const flat = [row("a", 0, false), row("b", 0, false)];
|
|
expect(collapsibleKeys(flat, acc)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("collapsibleRows.parseStoredExpanded / serializeExpanded", () => {
|
|
it("treats absent storage as the collapsed default (empty set)", () => {
|
|
expect(parseStoredExpanded(null).size).toBe(0);
|
|
expect(parseStoredExpanded("").size).toBe(0);
|
|
});
|
|
|
|
it("parses a JSON string array back into a set", () => {
|
|
expect([...parseStoredExpanded('["housing","salary"]')].sort()).toEqual(["housing", "salary"]);
|
|
});
|
|
|
|
it("falls back to empty on corrupt or non-array JSON", () => {
|
|
expect(parseStoredExpanded("{not json").size).toBe(0);
|
|
expect(parseStoredExpanded('{"a":1}').size).toBe(0);
|
|
expect(parseStoredExpanded("42").size).toBe(0);
|
|
});
|
|
|
|
it("drops non-string members defensively", () => {
|
|
expect([...parseStoredExpanded('["ok", 3, null, "yes"]')].sort()).toEqual(["ok", "yes"]);
|
|
});
|
|
|
|
it("round-trips through serialize", () => {
|
|
const set = new Set(["a", "b"]);
|
|
expect([...parseStoredExpanded(serializeExpanded(set))].sort()).toEqual(["a", "b"]);
|
|
});
|
|
});
|