feat(reports): collapse/expand sub-categories in comparable reports (#254)
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>
This commit is contained in:
parent
36385320a6
commit
616cccec37
9 changed files with 437 additions and 20 deletions
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## [Non publié]
|
||||
|
||||
### Ajouté
|
||||
|
||||
- Rapports → Comparaison : les deux rapports comparables hiérarchiques (réel vs réel et réel vs budget) permettent désormais de **replier ou déplier les sous-catégories de chaque catégorie parente**. Un chevron sur chaque catégorie de premier niveau masque son détail tout en gardant sa ligne de sous-total visible, et un bouton « Tout déplier / Tout replier » les bascule ensemble. Les groupes s'ouvrent **repliés** pour un aperçu compact au niveau des sous-totaux ; ce que vous dépliez est mémorisé par rapport (#254).
|
||||
|
||||
## [0.12.0] - 2026-07-05
|
||||
|
||||
### Modifié
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Reports → Compare: the two hierarchical comparable reports (real-vs-real and real-vs-budget) now let you **collapse or expand each parent category's sub-categories**. A chevron on every top-level category folds its breakdown away while keeping the category's subtotal row in view, and an "Expand all / Collapse all" button toggles them together. Groups start **collapsed** so the report opens on a compact, subtotal-level overview; whatever you expand is remembered per report (#254).
|
||||
|
||||
## [0.12.0] - 2026-07-05
|
||||
|
||||
### Changed
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { Fragment, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUpDown } from "lucide-react";
|
||||
import { ArrowUpDown, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
|
||||
import type { BudgetVsActualRow } from "../../shared/types";
|
||||
import { reorderRows } from "../../utils/reorderRows";
|
||||
import type { CollapseAccessors } from "../../utils/collapsibleRows";
|
||||
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
|
||||
|
||||
const cadFormatter = (value: number) =>
|
||||
new Intl.NumberFormat("en-CA", {
|
||||
|
|
@ -26,6 +28,17 @@ interface BudgetVsActualTableProps {
|
|||
|
||||
const STORAGE_KEY = "subtotals-position";
|
||||
|
||||
// Collapse groups keyed by category id; depth mirrors the render logic below
|
||||
// (a missing depth is derived from parent_id) so hidden rows are exactly a
|
||||
// group's indented descendants.
|
||||
const BVA_COLLAPSE_ACCESSORS: CollapseAccessors<BudgetVsActualRow> = {
|
||||
keyOf: (row) => String(row.category_id),
|
||||
depthOf: (row) => row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0),
|
||||
isParent: (row) => row.is_parent,
|
||||
};
|
||||
|
||||
const BVA_EXPANDED_STORAGE_KEY = "reports-bva-expanded";
|
||||
|
||||
export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
|
||||
|
|
@ -41,6 +54,12 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
|||
});
|
||||
};
|
||||
|
||||
// Collapse/expand of sub-category groups — collapsed by default (issue #254).
|
||||
const groups = useCollapsibleGroups<BudgetVsActualRow>(
|
||||
BVA_EXPANDED_STORAGE_KEY,
|
||||
BVA_COLLAPSE_ACCESSORS,
|
||||
);
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
|
||||
|
|
@ -88,9 +107,22 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
|||
const totalMonthPct = totals.monthBudget !== 0 ? totals.monthVariation / Math.abs(totals.monthBudget) : null;
|
||||
const totalYtdPct = totals.ytdBudget !== 0 ? totals.ytdVariation / Math.abs(totals.ytdBudget) : null;
|
||||
|
||||
const hasGroups = groups.groupCount(data) > 0;
|
||||
const allExpanded = groups.allExpanded(data);
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
|
||||
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
|
||||
{hasGroups && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (allExpanded ? groups.collapseAll() : groups.expandAll(data))}
|
||||
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"
|
||||
>
|
||||
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
|
||||
{allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={toggleSubtotals}
|
||||
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"
|
||||
|
|
@ -163,11 +195,12 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
|||
{section.label}
|
||||
</td>
|
||||
</tr>
|
||||
{reorderRows(section.rows, subtotalsOnTop).map((row) => {
|
||||
{reorderRows(groups.visible(section.rows), subtotalsOnTop).map((row) => {
|
||||
const isParent = row.is_parent;
|
||||
const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0);
|
||||
const isTopParent = isParent && depth === 0;
|
||||
const isIntermediateParent = isParent && depth >= 1;
|
||||
const collapsed = isTopParent && groups.isCollapsed(row);
|
||||
const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
|
||||
return (
|
||||
<tr
|
||||
|
|
@ -184,6 +217,25 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
|||
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
|
||||
: `${paddingClass} bg-[var(--card)]`
|
||||
}`}>
|
||||
{isTopParent ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => groups.toggle(row)}
|
||||
aria-expanded={!collapsed}
|
||||
className="flex items-center gap-2 w-full text-left hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight size={14} className="shrink-0 text-[var(--muted-foreground)]" />
|
||||
) : (
|
||||
<ChevronDown size={14} className="shrink-0 text-[var(--muted-foreground)]" />
|
||||
)}
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: row.category_color }}
|
||||
/>
|
||||
{row.category_name}
|
||||
</button>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
|
|
@ -191,6 +243,7 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
|||
/>
|
||||
{row.category_name}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className={`text-right px-3 py-1.5 border-l border-[var(--border)]/50`}>
|
||||
{cadFormatter(row.monthActual)}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { Fragment, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUpDown } from "lucide-react";
|
||||
import { ArrowUpDown, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
|
||||
import type { CategoryDelta } from "../../shared/types";
|
||||
import { reorderRows } from "../../utils/reorderRows";
|
||||
import type { CollapseAccessors } from "../../utils/collapsibleRows";
|
||||
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
|
||||
import { type SectionType, type Totals, sumLeaves, pct, computeResults } from "./compareResults";
|
||||
|
||||
export interface ComparePeriodTableProps {
|
||||
|
|
@ -57,6 +59,16 @@ function deltaColor(value: number, higherIsBetter: boolean): string {
|
|||
|
||||
const STORAGE_KEY = "compare-subtotals-position";
|
||||
|
||||
// Collapse groups keyed by category id; depth/parent mirror the render logic
|
||||
// below so the hidden rows are exactly a group's indented descendants.
|
||||
const COMPARE_COLLAPSE_ACCESSORS: CollapseAccessors<CategoryDelta> = {
|
||||
keyOf: (row) => String(row.categoryId),
|
||||
depthOf: (row) => row.depth ?? 0,
|
||||
isParent: (row) => row.is_parent ?? false,
|
||||
};
|
||||
|
||||
const COMPARE_EXPANDED_STORAGE_KEY = "reports-compare-expanded";
|
||||
|
||||
const COL_COUNT = 9;
|
||||
|
||||
export default function ComparePeriodTable({
|
||||
|
|
@ -81,6 +93,12 @@ export default function ComparePeriodTable({
|
|||
});
|
||||
};
|
||||
|
||||
// Collapse/expand of sub-category groups — collapsed by default (issue #254).
|
||||
const groups = useCollapsibleGroups<CategoryDelta>(
|
||||
COMPARE_EXPANDED_STORAGE_KEY,
|
||||
COMPARE_COLLAPSE_ACCESSORS,
|
||||
);
|
||||
|
||||
const monthPrevLabel = previousLabel;
|
||||
const monthCurrLabel = currentLabel;
|
||||
const ytdPrevLabel = cumulativePreviousLabel ?? previousLabel;
|
||||
|
|
@ -131,11 +149,12 @@ export default function ComparePeriodTable({
|
|||
{sectionLabels[section.type]}
|
||||
</td>
|
||||
</tr>
|
||||
{reorderRows(section.rows, subtotalsOnTop).map((row) => {
|
||||
{reorderRows(groups.visible(section.rows), subtotalsOnTop).map((row) => {
|
||||
const isParent = row.is_parent ?? false;
|
||||
const depth = row.depth ?? 0;
|
||||
const isTopParent = isParent && depth === 0;
|
||||
const isIntermediateParent = isParent && depth >= 1;
|
||||
const collapsed = isTopParent && groups.isCollapsed(row);
|
||||
const paddingClass =
|
||||
depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
|
||||
return (
|
||||
|
|
@ -158,6 +177,25 @@ export default function ComparePeriodTable({
|
|||
: `${paddingClass} bg-[var(--card)]`
|
||||
}`}
|
||||
>
|
||||
{isTopParent ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => groups.toggle(row)}
|
||||
aria-expanded={!collapsed}
|
||||
className="flex items-center gap-2 w-full text-left hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight size={14} className="shrink-0 text-[var(--muted-foreground)]" />
|
||||
) : (
|
||||
<ChevronDown size={14} className="shrink-0 text-[var(--muted-foreground)]" />
|
||||
)}
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: row.categoryColor }}
|
||||
/>
|
||||
{row.categoryName}
|
||||
</button>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
|
|
@ -165,6 +203,7 @@ export default function ComparePeriodTable({
|
|||
/>
|
||||
{row.categoryName}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
{/* Monthly block */}
|
||||
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
|
||||
|
|
@ -301,9 +340,22 @@ export default function ComparePeriodTable({
|
|||
);
|
||||
};
|
||||
|
||||
const hasGroups = groups.groupCount(rows) > 0;
|
||||
const allExpanded = groups.allExpanded(rows);
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
|
||||
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
|
||||
{hasGroups && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (allExpanded ? groups.collapseAll() : 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"
|
||||
>
|
||||
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
|
||||
{allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={toggleSubtotals}
|
||||
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"
|
||||
|
|
|
|||
94
src/hooks/useCollapsibleGroups.ts
Normal file
94
src/hooks/useCollapsibleGroups.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
type CollapseAccessors,
|
||||
collapsibleKeys,
|
||||
parseStoredExpanded,
|
||||
serializeExpanded,
|
||||
visibleRows,
|
||||
} from "../utils/collapsibleRows";
|
||||
|
||||
export interface CollapsibleGroups<T> {
|
||||
/** Filters a section's rows down to the ones visible under the current state. */
|
||||
visible: (rows: T[]) => T[];
|
||||
/** True when this top-level parent row is collapsed (its children are hidden). */
|
||||
isCollapsed: (row: T) => boolean;
|
||||
/** Flip one group between collapsed and expanded, then persist. */
|
||||
toggle: (row: T) => void;
|
||||
/** Expand every collapsible group found in `rows`, then persist. */
|
||||
expandAll: (rows: T[]) => void;
|
||||
/** Collapse every group (back to the default), then persist. */
|
||||
collapseAll: () => void;
|
||||
/** True when `rows` has at least one group and all of them are expanded. */
|
||||
allExpanded: (rows: T[]) => boolean;
|
||||
/** Number of collapsible top-level groups in `rows`. */
|
||||
groupCount: (rows: T[]) => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-report collapse/expand state for the hierarchical comparable tables
|
||||
* (issue #254). Groups are collapsed by default (issue #260): the persisted
|
||||
* value is the set of *expanded* group keys, so a first-ever visit (empty set)
|
||||
* shows every group collapsed with only its subtotal, while any group the user
|
||||
* expands is remembered.
|
||||
*
|
||||
* Persistence uses localStorage, the same synchronous store the sibling
|
||||
* "subtotals on top/bottom" toggle already uses in these tables. State is kept
|
||||
* per report via a distinct `storageKey`.
|
||||
*/
|
||||
export function useCollapsibleGroups<T>(
|
||||
storageKey: string,
|
||||
acc: CollapseAccessors<T>,
|
||||
): CollapsibleGroups<T> {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() =>
|
||||
parseStoredExpanded(
|
||||
typeof localStorage !== "undefined" ? localStorage.getItem(storageKey) : null,
|
||||
),
|
||||
);
|
||||
|
||||
const persist = useCallback(
|
||||
(next: Set<string>) => {
|
||||
try {
|
||||
localStorage.setItem(storageKey, serializeExpanded(next));
|
||||
} catch {
|
||||
// Best-effort: a write failure just means the next session falls back
|
||||
// to the collapsed default.
|
||||
}
|
||||
setExpanded(next);
|
||||
},
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const isCollapsed = useCallback((row: T) => !expanded.has(acc.keyOf(row)), [expanded, acc]);
|
||||
|
||||
const visible = useCallback((rows: T[]) => visibleRows(rows, acc, isCollapsed), [acc, isCollapsed]);
|
||||
|
||||
const toggle = useCallback(
|
||||
(row: T) => {
|
||||
const key = acc.keyOf(row);
|
||||
const next = new Set(expanded);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
persist(next);
|
||||
},
|
||||
[expanded, acc, persist],
|
||||
);
|
||||
|
||||
const expandAll = useCallback(
|
||||
(rows: T[]) => persist(new Set(collapsibleKeys(rows, acc))),
|
||||
[acc, persist],
|
||||
);
|
||||
|
||||
const collapseAll = useCallback(() => persist(new Set()), [persist]);
|
||||
|
||||
const groupCount = useCallback((rows: T[]) => collapsibleKeys(rows, acc).length, [acc]);
|
||||
|
||||
const allExpanded = useCallback(
|
||||
(rows: T[]) => {
|
||||
const keys = collapsibleKeys(rows, acc);
|
||||
return keys.length > 0 && keys.every((k) => expanded.has(k));
|
||||
},
|
||||
[expanded, acc],
|
||||
);
|
||||
|
||||
return { visible, isCollapsed, toggle, expandAll, collapseAll, allExpanded, groupCount };
|
||||
}
|
||||
|
|
@ -379,6 +379,10 @@
|
|||
"budgetVsActual": "Budget vs Actual",
|
||||
"subtotalsOnTop": "Subtotals on top",
|
||||
"subtotalsOnBottom": "Subtotals on bottom",
|
||||
"collapse": {
|
||||
"expandAll": "Expand all",
|
||||
"collapseAll": "Collapse all"
|
||||
},
|
||||
"detail": {
|
||||
"showAmounts": "Show amounts",
|
||||
"hideAmounts": "Hide amounts"
|
||||
|
|
|
|||
|
|
@ -379,6 +379,10 @@
|
|||
"budgetVsActual": "Budget vs Réel",
|
||||
"subtotalsOnTop": "Sous-totaux en haut",
|
||||
"subtotalsOnBottom": "Sous-totaux en bas",
|
||||
"collapse": {
|
||||
"expandAll": "Tout déplier",
|
||||
"collapseAll": "Tout replier"
|
||||
},
|
||||
"detail": {
|
||||
"showAmounts": "Afficher les montants",
|
||||
"hideAmounts": "Masquer les montants"
|
||||
|
|
|
|||
116
src/utils/collapsibleRows.test.ts
Normal file
116
src/utils/collapsibleRows.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
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"]);
|
||||
});
|
||||
});
|
||||
86
src/utils/collapsibleRows.ts
Normal file
86
src/utils/collapsibleRows.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* Collapse/expand of top-level category groups in the hierarchical comparable
|
||||
* reports (real-vs-real Compare + Budget-vs-Actual) — issue #254.
|
||||
*
|
||||
* Pure helpers only; the React state + persistence glue lives in the
|
||||
* `useCollapsibleGroups` hook.
|
||||
*
|
||||
* A "group" is a top-level parent row (depth 0, is_parent). Collapsing it keeps
|
||||
* its own subtotal row visible but hides its entire subtree (every following
|
||||
* depth ≥ 1 row until the next depth-0 row). Rows must arrive in depth-first
|
||||
* order — the order the compare/budget services already emit them in, and the
|
||||
* same order `reorderRows` relies on.
|
||||
*
|
||||
* The accessors are passed in rather than read off fixed field names so each
|
||||
* table can supply the *exact* depth expression it renders with (CategoryDelta
|
||||
* uses `depth ?? 0`; BudgetVsActualRow derives a missing depth from
|
||||
* `parent_id`). Keeping the collapse depth and the indentation depth identical
|
||||
* guarantees the hidden rows are exactly the indented descendants.
|
||||
*/
|
||||
|
||||
/** How the collapse logic reads hierarchy position + identity off a row. */
|
||||
export interface CollapseAccessors<T> {
|
||||
/** Stable per-group key (a category id, stringified). */
|
||||
keyOf: (row: T) => string;
|
||||
/** Indentation depth; 0 = top-level. Must match the rendered indentation. */
|
||||
depthOf: (row: T) => number;
|
||||
/** True when the row is a group header / subtotal (has children). */
|
||||
isParent: (row: T) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps every row that is currently visible given which top-level groups are
|
||||
* collapsed. `isCollapsed` is consulted only for top-level parent rows; a
|
||||
* collapsed one keeps its own (subtotal) row and drops its whole subtree.
|
||||
*/
|
||||
export function visibleRows<T>(
|
||||
rows: T[],
|
||||
acc: CollapseAccessors<T>,
|
||||
isCollapsed: (row: T) => boolean,
|
||||
): T[] {
|
||||
const out: T[] = [];
|
||||
// While true we are inside a collapsed top-level parent's subtree and drop
|
||||
// every deeper row until the next depth-0 row re-opens the flow.
|
||||
let hidingSubtree = false;
|
||||
for (const row of rows) {
|
||||
if (acc.depthOf(row) === 0) {
|
||||
out.push(row);
|
||||
hidingSubtree = acc.isParent(row) && isCollapsed(row);
|
||||
} else if (!hidingSubtree) {
|
||||
out.push(row);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Keys of every collapsible group (top-level parent rows), in encounter order. */
|
||||
export function collapsibleKeys<T>(rows: T[], acc: CollapseAccessors<T>): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const row of rows) {
|
||||
if (acc.depthOf(row) === 0 && acc.isParent(row)) keys.push(acc.keyOf(row));
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the persisted value into the set of *expanded* group keys. The report
|
||||
* default is "everything collapsed" (issue #254/#260), so an absent or corrupt
|
||||
* value yields an empty set — i.e. all groups collapsed on a first-ever visit.
|
||||
*/
|
||||
export function parseStoredExpanded(raw: string | null): Set<string> {
|
||||
if (!raw) return new Set();
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
return new Set(parsed.filter((k): k is string => typeof k === "string"));
|
||||
}
|
||||
} catch {
|
||||
// Corrupt value: fall back to the collapsed default.
|
||||
}
|
||||
return new Set();
|
||||
}
|
||||
|
||||
/** Serialises the set of expanded group keys for persistence. */
|
||||
export function serializeExpanded(expanded: Set<string>): string {
|
||||
return JSON.stringify([...expanded]);
|
||||
}
|
||||
Loading…
Reference in a new issue