Lines
30.45 %
Functions
21.37 %
Branches
100 %
//! Report table controls: collapsible tree rows + column sort.
//!
//! The DOM wiring (click handlers, attribute reads, row reordering via
//! `Node::insert_before`) is in the `wasm` module. The pure logic —
//! given a flat list of rows, which ids should be hidden on collapse,
//! or in what order should the top-level rows appear when sorted — lives
//! at module level and is exercised by unit tests with constructed
//! `RowRef` fixtures. No browser needed to cover the tricky bits.
use std::collections::HashSet;
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use web_sys::{Element, HtmlElement, HtmlTableRowElement};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RowRef {
pub id: String,
pub parent_id: Option<String>,
pub depth: usize,
}
/// Walk the flat row list and return the ids of every descendant of
/// `parent`. The row list must be DFS-ordered (roots before their
/// descendants, siblings in display order) — this is the order the
/// Askama `_tree_table` macro emits.
#[must_use]
pub fn descendants_of(rows: &[RowRef], parent: &str) -> HashSet<String> {
let mut descendants: HashSet<String> = HashSet::new();
descendants.insert(parent.to_string());
for row in rows {
if let Some(pid) = &row.parent_id
&& descendants.contains(pid)
{
descendants.insert(row.id.clone());
descendants.remove(parent);
descendants
/// Compute the initial hide-set when the user requests "collapse to
/// depth N". Rows at depth > N are hidden; rows at depth ≤ N stay open.
pub fn rows_to_hide_by_depth(rows: &[RowRef], max_depth: usize) -> HashSet<String> {
rows.iter()
.filter(|r| r.depth > max_depth)
.map(|r| r.id.clone())
.collect()
#[derive(Debug, Clone, Copy)]
pub enum SortDir {
Asc,
Desc,
/// Return the ids of the depth-0 rows in sorted order. Callers apply the
/// reorder to the DOM; subtree rows ride with their root.
///
/// Sorting uses a caller-supplied key function so the same logic handles
/// both string (account-name) and numeric (commodity-amount) orderings.
pub fn sort_order_for<K, F>(rows: &[RowRef], dir: SortDir, mut key_fn: F) -> Vec<String>
where
K: Ord,
F: FnMut(&RowRef) -> K,
let mut roots: Vec<&RowRef> = rows.iter().filter(|r| r.depth == 0).collect();
roots.sort_by(|a, b| {
let ka = key_fn(a);
let kb = key_fn(b);
match dir {
SortDir::Asc => ka.cmp(&kb),
SortDir::Desc => kb.cmp(&ka),
});
roots.into_iter().map(|r| r.id.clone()).collect()
// --- DOM wiring ---
/// Already-initialised containers mark themselves with this attribute so
/// that re-init after HTMX swaps doesn't double-bind event listeners.
const INITIALISED_ATTR: &str = "data-controls-initialised";
fn collect_rows(table: &Element) -> Vec<(HtmlTableRowElement, RowRef)> {
let node_list = match table.query_selector_all("tbody tr[data-account-id]") {
Ok(list) => list,
Err(_) => return Vec::new(),
};
let mut out = Vec::new();
for i in 0..node_list.length() {
let Some(node) = node_list.item(i) else {
continue;
let Ok(tr) = node.dyn_into::<HtmlTableRowElement>() else {
let id = tr.get_attribute("data-account-id").unwrap_or_default();
let parent_id = tr.get_attribute("data-parent-id");
let depth = tr
.get_attribute("data-depth")
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(0);
out.push((
tr,
RowRef {
id,
parent_id,
depth,
},
));
out
fn toggle_subtree(table: &Element, parent_id: &str, expand: bool) {
let entries = collect_rows(table);
let refs: Vec<RowRef> = entries.iter().map(|(_, r)| r.clone()).collect();
let ids = descendants_of(&refs, parent_id);
for (tr, row) in entries {
if ids.contains(&row.id) {
let display = if expand { "" } else { "none" };
let _ = tr.style().set_property("display", display);
fn button_set_expanded(btn: &HtmlElement, expanded: bool) {
let _ = btn.set_attribute("aria-expanded", if expanded { "true" } else { "false" });
btn.set_inner_text(if expanded { "▾" } else { "▸" });
fn enclosing_table(from: &Element) -> Option<Element> {
let mut cur: Option<Element> = Some(from.clone());
while let Some(el) = cur {
if el.tag_name().eq_ignore_ascii_case("table") {
return Some(el);
cur = el.parent_element();
None
fn enclosing_row(from: &Element) -> Option<HtmlTableRowElement> {
if el.tag_name().eq_ignore_ascii_case("tr") {
return el.dyn_into::<HtmlTableRowElement>().ok();
fn apply_initial_depth_fold(table: &Element, max_depth: usize) {
let ids = rows_to_hide_by_depth(&refs, max_depth);
let _ = tr.style().set_property("display", "none");
// Reflect aria-expanded=false on parents whose direct children are hidden.
if let Ok(buttons) = table.query_selector_all(".tree-toggle") {
for i in 0..buttons.length() {
let Some(node) = buttons.item(i) else {
let Ok(btn) = node.dyn_into::<HtmlElement>() else {
let Some(tr) = btn
.parent_element()
.and_then(|p| p.parent_element())
.and_then(|p| p.dyn_into::<HtmlTableRowElement>().ok())
else {
if depth >= max_depth {
button_set_expanded(&btn, false);
/// Initialise tree-collapse handlers on every sortable / collapsible
/// tree table inside `container`. Idempotent via `data-controls-initialised`.
#[wasm_bindgen]
pub fn init_tree_collapse(container: &Element) {
let Ok(tables) =
container.query_selector_all(".report-table[data-sortable='true'], .report-table")
return;
let initial_depth = container
.query_selector("[name='collapsed_depth']")
.ok()
.flatten()
.and_then(|el| el.dyn_into::<web_sys::HtmlInputElement>().ok())
.and_then(|input| input.value().parse::<usize>().ok());
for i in 0..tables.length() {
let Some(node) = tables.item(i) else { continue };
let Ok(table) = node.dyn_into::<Element>() else {
if table.has_attribute(INITIALISED_ATTR) {
let _ = table.set_attribute(INITIALISED_ATTR, "collapse");
if let Some(depth) = initial_depth {
apply_initial_depth_fold(&table, depth);
let Ok(buttons) = table.query_selector_all(".tree-toggle") else {
for j in 0..buttons.length() {
let Some(btn_node) = buttons.item(j) else {
let Ok(btn) = btn_node.dyn_into::<HtmlElement>() else {
let btn_clone = btn.clone();
let closure = Closure::<dyn FnMut(_)>::new(move |_: web_sys::Event| {
let Some(tr) = enclosing_row(&btn_clone) else {
let Some(table) = enclosing_table(&tr) else {
let Some(id) = tr.get_attribute("data-account-id") else {
let expanded = btn_clone
.get_attribute("aria-expanded")
.is_none_or(|v| v == "true");
toggle_subtree(&table, &id, !expanded);
button_set_expanded(&btn_clone, !expanded);
let _ = btn.add_event_listener_with_callback("click", closure.as_ref().unchecked_ref());
closure.forget();
fn row_sort_key(row: &HtmlTableRowElement, column: usize) -> String {
let cells = row.get_elements_by_tag_name("td");
let Some(cell) = cells.item(u32::try_from(column).unwrap_or(0)) else {
return String::new();
if column == 0 {
cell.text_content().unwrap_or_default().trim().to_string()
} else {
cell.query_selector(".rational")
.and_then(|r| r.get_attribute("data-value"))
.unwrap_or_default()
.to_ascii_lowercase()
fn sort_table(table: &Element, column_header: &HtmlElement) {
let sort_key = column_header
.get_attribute("data-sort-key")
.unwrap_or_default();
if sort_key.is_empty() {
let column_index = {
let Ok(headers) = table.query_selector_all("thead th") else {
let mut idx = 0usize;
for i in 0..headers.length() {
if let Some(th) = headers.item(i).and_then(|n| n.dyn_into::<Element>().ok())
&& th.is_same_node(Some(column_header))
idx = i as usize;
break;
idx
let current = column_header.get_attribute("data-sort-dir");
let dir = match current.as_deref() {
Some("asc") => SortDir::Desc,
_ => SortDir::Asc,
let keys: std::collections::HashMap<String, String> = entries
.iter()
.filter(|(_, r)| r.depth == 0)
.map(|(tr, r)| (r.id.clone(), row_sort_key(tr, column_index)))
.collect();
let ordered = sort_order_for(&refs, dir, |row| {
keys.get(&row.id).cloned().unwrap_or_default()
let Some(tbody) = table.query_selector("tbody").ok().flatten() else {
for id in ordered {
let mut visit = vec![id.clone()];
while let Some(cur) = visit.pop() {
if let Some((tr, _)) = entries.iter().find(|(_, r)| r.id == cur) {
let _ = tbody.append_child(tr);
for (_, r) in &entries {
if r.parent_id.as_deref() == Some(cur.as_str()) {
visit.push(r.id.clone());
// Clear prior dir markers then tag the active header.
if let Ok(headers) = table.query_selector_all("thead th") {
if let Some(th) = headers.item(i).and_then(|n| n.dyn_into::<Element>().ok()) {
let _ = th.remove_attribute("data-sort-dir");
th.set_class_name(
&th.class_name()
.replace(" sort-asc", "")
.replace(" sort-desc", ""),
);
let (dir_str, cls) = match dir {
SortDir::Asc => ("asc", "sort-asc"),
SortDir::Desc => ("desc", "sort-desc"),
let _ = column_header.set_attribute("data-sort-dir", dir_str);
column_header.set_class_name(&format!("{} {}", column_header.class_name(), cls));
/// Initialise column-sort click handlers on every sortable table inside
/// `container`. Idempotent via `data-controls-initialised`.
pub fn init_column_sorting(container: &Element) {
let Ok(tables) = container.query_selector_all(".report-table[data-sortable='true']") else {
let marker = table.get_attribute(INITIALISED_ATTR).unwrap_or_default();
if marker.contains("sort") {
let _ = table.set_attribute(INITIALISED_ATTR, format!("{marker} sort").trim());
let Ok(headers) = table.query_selector_all("thead th[data-sort-key]") else {
for j in 0..headers.length() {
let Some(th_node) = headers.item(j) else {
let Ok(th) = th_node.dyn_into::<HtmlElement>() else {
let th_clone = th.clone();
let Some(table) = enclosing_table(&th_clone) else {
sort_table(&table, &th_clone);
let _ = th.add_event_listener_with_callback("click", closure.as_ref().unchecked_ref());
#[cfg(test)]
mod tests {
use super::*;
fn row(id: &str, parent: Option<&str>, depth: usize) -> RowRef {
id: id.to_string(),
parent_id: parent.map(str::to_string),
fn fixture() -> Vec<RowRef> {
// Tree:
// assets (0)
// bank (1)
// checking (2)
// savings (2)
// cash (1)
// liabilities (0)
// card (1)
vec![
row("assets", None, 0),
row("bank", Some("assets"), 1),
row("checking", Some("bank"), 2),
row("savings", Some("bank"), 2),
row("cash", Some("assets"), 1),
row("liabilities", None, 0),
row("card", Some("liabilities"), 1),
]
#[test]
fn descendants_of_collects_whole_subtree() {
let rows = fixture();
let d = descendants_of(&rows, "assets");
assert!(d.contains("bank"));
assert!(d.contains("checking"));
assert!(d.contains("savings"));
assert!(d.contains("cash"));
assert!(!d.contains("assets"), "parent itself is excluded");
assert!(!d.contains("liabilities"));
assert!(!d.contains("card"));
fn descendants_of_intermediate_node_stops_at_subtree() {
let d = descendants_of(&rows, "bank");
assert_eq!(d.len(), 2);
fn descendants_of_leaf_is_empty() {
let d = descendants_of(&rows, "checking");
assert!(d.is_empty());
fn rows_to_hide_by_depth_respects_threshold() {
let hidden = rows_to_hide_by_depth(&rows, 1);
// Only depth-2 rows hide.
assert_eq!(hidden.len(), 2);
assert!(hidden.contains("checking"));
assert!(hidden.contains("savings"));
let hidden0 = rows_to_hide_by_depth(&rows, 0);
// Depth 1 and 2 hide, depth 0 stays.
assert_eq!(hidden0.len(), 5);
assert!(!hidden0.contains("assets"));
assert!(!hidden0.contains("liabilities"));
fn sort_order_for_sorts_only_top_level_rows() {
let asc = sort_order_for(&rows, SortDir::Asc, |r| r.id.clone());
assert_eq!(asc, vec!["assets".to_string(), "liabilities".to_string()]);
let desc = sort_order_for(&rows, SortDir::Desc, |r| r.id.clone());
assert_eq!(desc, vec!["liabilities".to_string(), "assets".to_string()]);
fn sort_order_for_handles_numeric_key_via_closure() {
let rows = vec![row("a", None, 0), row("b", None, 0), row("c", None, 0)];
let amounts = [("a", 30), ("b", 10), ("c", 20)];
let order = sort_order_for(&rows, SortDir::Desc, |r| {
amounts
.find(|(id, _)| *id == r.id)
.map_or(0, |(_, v)| *v)
assert_eq!(
order,
vec!["a".to_string(), "c".to_string(), "b".to_string()]