1
//! Report table controls: collapsible tree rows + column sort.
2
//!
3
//! The DOM wiring (click handlers, attribute reads, row reordering via
4
//! `Node::insert_before`) is in the `wasm` module. The pure logic —
5
//! given a flat list of rows, which ids should be hidden on collapse,
6
//! or in what order should the top-level rows appear when sorted — lives
7
//! at module level and is exercised by unit tests with constructed
8
//! `RowRef` fixtures. No browser needed to cover the tricky bits.
9

            
10
use std::collections::HashSet;
11

            
12
use wasm_bindgen::JsCast;
13
use wasm_bindgen::prelude::*;
14
use web_sys::{Element, HtmlElement, HtmlTableRowElement};
15

            
16
#[derive(Debug, Clone, PartialEq, Eq)]
17
pub struct RowRef {
18
    pub id: String,
19
    pub parent_id: Option<String>,
20
    pub depth: usize,
21
}
22

            
23
/// Walk the flat row list and return the ids of every descendant of
24
/// `parent`. The row list must be DFS-ordered (roots before their
25
/// descendants, siblings in display order) — this is the order the
26
/// Askama `_tree_table` macro emits.
27
#[must_use]
28
3
pub fn descendants_of(rows: &[RowRef], parent: &str) -> HashSet<String> {
29
3
    let mut descendants: HashSet<String> = HashSet::new();
30
3
    descendants.insert(parent.to_string());
31
21
    for row in rows {
32
21
        if let Some(pid) = &row.parent_id
33
15
            && descendants.contains(pid)
34
6
        {
35
6
            descendants.insert(row.id.clone());
36
15
        }
37
    }
38
3
    descendants.remove(parent);
39
3
    descendants
40
3
}
41

            
42
/// Compute the initial hide-set when the user requests "collapse to
43
/// depth N". Rows at depth > N are hidden; rows at depth ≤ N stay open.
44
#[must_use]
45
2
pub fn rows_to_hide_by_depth(rows: &[RowRef], max_depth: usize) -> HashSet<String> {
46
2
    rows.iter()
47
14
        .filter(|r| r.depth > max_depth)
48
7
        .map(|r| r.id.clone())
49
2
        .collect()
50
2
}
51

            
52
#[derive(Debug, Clone, Copy)]
53
pub enum SortDir {
54
    Asc,
55
    Desc,
56
}
57

            
58
/// Return the ids of the depth-0 rows in sorted order. Callers apply the
59
/// reorder to the DOM; subtree rows ride with their root.
60
///
61
/// Sorting uses a caller-supplied key function so the same logic handles
62
/// both string (account-name) and numeric (commodity-amount) orderings.
63
#[must_use]
64
3
pub fn sort_order_for<K, F>(rows: &[RowRef], dir: SortDir, mut key_fn: F) -> Vec<String>
65
3
where
66
3
    K: Ord,
67
3
    F: FnMut(&RowRef) -> K,
68
{
69
17
    let mut roots: Vec<&RowRef> = rows.iter().filter(|r| r.depth == 0).collect();
70
5
    roots.sort_by(|a, b| {
71
5
        let ka = key_fn(a);
72
5
        let kb = key_fn(b);
73
5
        match dir {
74
1
            SortDir::Asc => ka.cmp(&kb),
75
4
            SortDir::Desc => kb.cmp(&ka),
76
        }
77
5
    });
78
7
    roots.into_iter().map(|r| r.id.clone()).collect()
79
3
}
80

            
81
// --- DOM wiring ---
82

            
83
/// Already-initialised containers mark themselves with this attribute so
84
/// that re-init after HTMX swaps doesn't double-bind event listeners.
85
const INITIALISED_ATTR: &str = "data-controls-initialised";
86

            
87
fn collect_rows(table: &Element) -> Vec<(HtmlTableRowElement, RowRef)> {
88
    let node_list = match table.query_selector_all("tbody tr[data-account-id]") {
89
        Ok(list) => list,
90
        Err(_) => return Vec::new(),
91
    };
92
    let mut out = Vec::new();
93
    for i in 0..node_list.length() {
94
        let Some(node) = node_list.item(i) else {
95
            continue;
96
        };
97
        let Ok(tr) = node.dyn_into::<HtmlTableRowElement>() else {
98
            continue;
99
        };
100
        let id = tr.get_attribute("data-account-id").unwrap_or_default();
101
        let parent_id = tr.get_attribute("data-parent-id");
102
        let depth = tr
103
            .get_attribute("data-depth")
104
            .and_then(|s| s.parse::<usize>().ok())
105
            .unwrap_or(0);
106
        out.push((
107
            tr,
108
            RowRef {
109
                id,
110
                parent_id,
111
                depth,
112
            },
113
        ));
114
    }
115
    out
116
}
117

            
118
fn toggle_subtree(table: &Element, parent_id: &str, expand: bool) {
119
    let entries = collect_rows(table);
120
    let refs: Vec<RowRef> = entries.iter().map(|(_, r)| r.clone()).collect();
121
    let ids = descendants_of(&refs, parent_id);
122
    for (tr, row) in entries {
123
        if ids.contains(&row.id) {
124
            let display = if expand { "" } else { "none" };
125
            let _ = tr.style().set_property("display", display);
126
        }
127
    }
128
}
129

            
130
fn button_set_expanded(btn: &HtmlElement, expanded: bool) {
131
    let _ = btn.set_attribute("aria-expanded", if expanded { "true" } else { "false" });
132
    btn.set_inner_text(if expanded { "▾" } else { "▸" });
133
}
134

            
135
fn enclosing_table(from: &Element) -> Option<Element> {
136
    let mut cur: Option<Element> = Some(from.clone());
137
    while let Some(el) = cur {
138
        if el.tag_name().eq_ignore_ascii_case("table") {
139
            return Some(el);
140
        }
141
        cur = el.parent_element();
142
    }
143
    None
144
}
145

            
146
fn enclosing_row(from: &Element) -> Option<HtmlTableRowElement> {
147
    let mut cur: Option<Element> = Some(from.clone());
148
    while let Some(el) = cur {
149
        if el.tag_name().eq_ignore_ascii_case("tr") {
150
            return el.dyn_into::<HtmlTableRowElement>().ok();
151
        }
152
        cur = el.parent_element();
153
    }
154
    None
155
}
156

            
157
fn apply_initial_depth_fold(table: &Element, max_depth: usize) {
158
    let entries = collect_rows(table);
159
    let refs: Vec<RowRef> = entries.iter().map(|(_, r)| r.clone()).collect();
160
    let ids = rows_to_hide_by_depth(&refs, max_depth);
161
    for (tr, row) in entries {
162
        if ids.contains(&row.id) {
163
            let _ = tr.style().set_property("display", "none");
164
        }
165
    }
166
    // Reflect aria-expanded=false on parents whose direct children are hidden.
167
    if let Ok(buttons) = table.query_selector_all(".tree-toggle") {
168
        for i in 0..buttons.length() {
169
            let Some(node) = buttons.item(i) else {
170
                continue;
171
            };
172
            let Ok(btn) = node.dyn_into::<HtmlElement>() else {
173
                continue;
174
            };
175
            let Some(tr) = btn
176
                .parent_element()
177
                .and_then(|p| p.parent_element())
178
                .and_then(|p| p.dyn_into::<HtmlTableRowElement>().ok())
179
            else {
180
                continue;
181
            };
182
            let depth = tr
183
                .get_attribute("data-depth")
184
                .and_then(|s| s.parse::<usize>().ok())
185
                .unwrap_or(0);
186
            if depth >= max_depth {
187
                button_set_expanded(&btn, false);
188
            }
189
        }
190
    }
191
}
192

            
193
/// Initialise tree-collapse handlers on every sortable / collapsible
194
/// tree table inside `container`. Idempotent via `data-controls-initialised`.
195
#[wasm_bindgen]
196
pub fn init_tree_collapse(container: &Element) {
197
    let Ok(tables) =
198
        container.query_selector_all(".report-table[data-sortable='true'], .report-table")
199
    else {
200
        return;
201
    };
202
    let initial_depth = container
203
        .query_selector("[name='collapsed_depth']")
204
        .ok()
205
        .flatten()
206
        .and_then(|el| el.dyn_into::<web_sys::HtmlInputElement>().ok())
207
        .and_then(|input| input.value().parse::<usize>().ok());
208

            
209
    for i in 0..tables.length() {
210
        let Some(node) = tables.item(i) else { continue };
211
        let Ok(table) = node.dyn_into::<Element>() else {
212
            continue;
213
        };
214
        if table.has_attribute(INITIALISED_ATTR) {
215
            continue;
216
        }
217
        let _ = table.set_attribute(INITIALISED_ATTR, "collapse");
218

            
219
        if let Some(depth) = initial_depth {
220
            apply_initial_depth_fold(&table, depth);
221
        }
222

            
223
        let Ok(buttons) = table.query_selector_all(".tree-toggle") else {
224
            continue;
225
        };
226
        for j in 0..buttons.length() {
227
            let Some(btn_node) = buttons.item(j) else {
228
                continue;
229
            };
230
            let Ok(btn) = btn_node.dyn_into::<HtmlElement>() else {
231
                continue;
232
            };
233
            let btn_clone = btn.clone();
234
            let closure = Closure::<dyn FnMut(_)>::new(move |_: web_sys::Event| {
235
                let Some(tr) = enclosing_row(&btn_clone) else {
236
                    return;
237
                };
238
                let Some(table) = enclosing_table(&tr) else {
239
                    return;
240
                };
241
                let Some(id) = tr.get_attribute("data-account-id") else {
242
                    return;
243
                };
244
                let expanded = btn_clone
245
                    .get_attribute("aria-expanded")
246
                    .is_none_or(|v| v == "true");
247
                toggle_subtree(&table, &id, !expanded);
248
                button_set_expanded(&btn_clone, !expanded);
249
            });
250
            let _ = btn.add_event_listener_with_callback("click", closure.as_ref().unchecked_ref());
251
            closure.forget();
252
        }
253
    }
254
}
255

            
256
fn row_sort_key(row: &HtmlTableRowElement, column: usize) -> String {
257
    let cells = row.get_elements_by_tag_name("td");
258
    let Some(cell) = cells.item(u32::try_from(column).unwrap_or(0)) else {
259
        return String::new();
260
    };
261
    if column == 0 {
262
        cell.text_content().unwrap_or_default().trim().to_string()
263
    } else {
264
        cell.query_selector(".rational")
265
            .ok()
266
            .flatten()
267
            .and_then(|r| r.get_attribute("data-value"))
268
            .unwrap_or_default()
269
    }
270
    .to_ascii_lowercase()
271
}
272

            
273
fn sort_table(table: &Element, column_header: &HtmlElement) {
274
    let sort_key = column_header
275
        .get_attribute("data-sort-key")
276
        .unwrap_or_default();
277
    if sort_key.is_empty() {
278
        return;
279
    }
280

            
281
    let column_index = {
282
        let Ok(headers) = table.query_selector_all("thead th") else {
283
            return;
284
        };
285
        let mut idx = 0usize;
286
        for i in 0..headers.length() {
287
            if let Some(th) = headers.item(i).and_then(|n| n.dyn_into::<Element>().ok())
288
                && th.is_same_node(Some(column_header))
289
            {
290
                idx = i as usize;
291
                break;
292
            }
293
        }
294
        idx
295
    };
296

            
297
    let current = column_header.get_attribute("data-sort-dir");
298
    let dir = match current.as_deref() {
299
        Some("asc") => SortDir::Desc,
300
        _ => SortDir::Asc,
301
    };
302

            
303
    let entries = collect_rows(table);
304
    let refs: Vec<RowRef> = entries.iter().map(|(_, r)| r.clone()).collect();
305
    let keys: std::collections::HashMap<String, String> = entries
306
        .iter()
307
        .filter(|(_, r)| r.depth == 0)
308
        .map(|(tr, r)| (r.id.clone(), row_sort_key(tr, column_index)))
309
        .collect();
310

            
311
    let ordered = sort_order_for(&refs, dir, |row| {
312
        keys.get(&row.id).cloned().unwrap_or_default()
313
    });
314

            
315
    let Some(tbody) = table.query_selector("tbody").ok().flatten() else {
316
        return;
317
    };
318

            
319
    for id in ordered {
320
        let mut visit = vec![id.clone()];
321
        while let Some(cur) = visit.pop() {
322
            if let Some((tr, _)) = entries.iter().find(|(_, r)| r.id == cur) {
323
                let _ = tbody.append_child(tr);
324
            }
325
            for (_, r) in &entries {
326
                if r.parent_id.as_deref() == Some(cur.as_str()) {
327
                    visit.push(r.id.clone());
328
                }
329
            }
330
        }
331
    }
332

            
333
    // Clear prior dir markers then tag the active header.
334
    if let Ok(headers) = table.query_selector_all("thead th") {
335
        for i in 0..headers.length() {
336
            if let Some(th) = headers.item(i).and_then(|n| n.dyn_into::<Element>().ok()) {
337
                let _ = th.remove_attribute("data-sort-dir");
338
                th.set_class_name(
339
                    &th.class_name()
340
                        .replace(" sort-asc", "")
341
                        .replace(" sort-desc", ""),
342
                );
343
            }
344
        }
345
    }
346
    let (dir_str, cls) = match dir {
347
        SortDir::Asc => ("asc", "sort-asc"),
348
        SortDir::Desc => ("desc", "sort-desc"),
349
    };
350
    let _ = column_header.set_attribute("data-sort-dir", dir_str);
351
    column_header.set_class_name(&format!("{} {}", column_header.class_name(), cls));
352
}
353

            
354
/// Initialise column-sort click handlers on every sortable table inside
355
/// `container`. Idempotent via `data-controls-initialised`.
356
#[wasm_bindgen]
357
pub fn init_column_sorting(container: &Element) {
358
    let Ok(tables) = container.query_selector_all(".report-table[data-sortable='true']") else {
359
        return;
360
    };
361
    for i in 0..tables.length() {
362
        let Some(node) = tables.item(i) else { continue };
363
        let Ok(table) = node.dyn_into::<Element>() else {
364
            continue;
365
        };
366
        let marker = table.get_attribute(INITIALISED_ATTR).unwrap_or_default();
367
        if marker.contains("sort") {
368
            continue;
369
        }
370
        let _ = table.set_attribute(INITIALISED_ATTR, format!("{marker} sort").trim());
371

            
372
        let Ok(headers) = table.query_selector_all("thead th[data-sort-key]") else {
373
            continue;
374
        };
375
        for j in 0..headers.length() {
376
            let Some(th_node) = headers.item(j) else {
377
                continue;
378
            };
379
            let Ok(th) = th_node.dyn_into::<HtmlElement>() else {
380
                continue;
381
            };
382
            let th_clone = th.clone();
383
            let closure = Closure::<dyn FnMut(_)>::new(move |_: web_sys::Event| {
384
                let Some(table) = enclosing_table(&th_clone) else {
385
                    return;
386
                };
387
                sort_table(&table, &th_clone);
388
            });
389
            let _ = th.add_event_listener_with_callback("click", closure.as_ref().unchecked_ref());
390
            closure.forget();
391
        }
392
    }
393
}
394

            
395
#[cfg(test)]
396
mod tests {
397
    use super::*;
398

            
399
38
    fn row(id: &str, parent: Option<&str>, depth: usize) -> RowRef {
400
38
        RowRef {
401
38
            id: id.to_string(),
402
38
            parent_id: parent.map(str::to_string),
403
38
            depth,
404
38
        }
405
38
    }
406

            
407
5
    fn fixture() -> Vec<RowRef> {
408
        // Tree:
409
        //   assets (0)
410
        //     bank (1)
411
        //       checking (2)
412
        //       savings (2)
413
        //     cash (1)
414
        //   liabilities (0)
415
        //     card (1)
416
5
        vec![
417
5
            row("assets", None, 0),
418
5
            row("bank", Some("assets"), 1),
419
5
            row("checking", Some("bank"), 2),
420
5
            row("savings", Some("bank"), 2),
421
5
            row("cash", Some("assets"), 1),
422
5
            row("liabilities", None, 0),
423
5
            row("card", Some("liabilities"), 1),
424
        ]
425
5
    }
426

            
427
    #[test]
428
1
    fn descendants_of_collects_whole_subtree() {
429
1
        let rows = fixture();
430
1
        let d = descendants_of(&rows, "assets");
431
1
        assert!(d.contains("bank"));
432
1
        assert!(d.contains("checking"));
433
1
        assert!(d.contains("savings"));
434
1
        assert!(d.contains("cash"));
435
1
        assert!(!d.contains("assets"), "parent itself is excluded");
436
1
        assert!(!d.contains("liabilities"));
437
1
        assert!(!d.contains("card"));
438
1
    }
439

            
440
    #[test]
441
1
    fn descendants_of_intermediate_node_stops_at_subtree() {
442
1
        let rows = fixture();
443
1
        let d = descendants_of(&rows, "bank");
444
1
        assert_eq!(d.len(), 2);
445
1
        assert!(d.contains("checking"));
446
1
        assert!(d.contains("savings"));
447
1
    }
448

            
449
    #[test]
450
1
    fn descendants_of_leaf_is_empty() {
451
1
        let rows = fixture();
452
1
        let d = descendants_of(&rows, "checking");
453
1
        assert!(d.is_empty());
454
1
    }
455

            
456
    #[test]
457
1
    fn rows_to_hide_by_depth_respects_threshold() {
458
1
        let rows = fixture();
459
1
        let hidden = rows_to_hide_by_depth(&rows, 1);
460
        // Only depth-2 rows hide.
461
1
        assert_eq!(hidden.len(), 2);
462
1
        assert!(hidden.contains("checking"));
463
1
        assert!(hidden.contains("savings"));
464

            
465
1
        let hidden0 = rows_to_hide_by_depth(&rows, 0);
466
        // Depth 1 and 2 hide, depth 0 stays.
467
1
        assert_eq!(hidden0.len(), 5);
468
1
        assert!(!hidden0.contains("assets"));
469
1
        assert!(!hidden0.contains("liabilities"));
470
1
    }
471

            
472
    #[test]
473
1
    fn sort_order_for_sorts_only_top_level_rows() {
474
1
        let rows = fixture();
475
2
        let asc = sort_order_for(&rows, SortDir::Asc, |r| r.id.clone());
476
1
        assert_eq!(asc, vec!["assets".to_string(), "liabilities".to_string()]);
477
2
        let desc = sort_order_for(&rows, SortDir::Desc, |r| r.id.clone());
478
1
        assert_eq!(desc, vec!["liabilities".to_string(), "assets".to_string()]);
479
1
    }
480

            
481
    #[test]
482
1
    fn sort_order_for_handles_numeric_key_via_closure() {
483
1
        let rows = vec![row("a", None, 0), row("b", None, 0), row("c", None, 0)];
484
1
        let amounts = [("a", 30), ("b", 10), ("c", 20)];
485
6
        let order = sort_order_for(&rows, SortDir::Desc, |r| {
486
6
            amounts
487
6
                .iter()
488
12
                .find(|(id, _)| *id == r.id)
489
6
                .map_or(0, |(_, v)| *v)
490
6
        });
491
1
        assert_eq!(
492
            order,
493
1
            vec!["a".to_string(), "c".to_string(), "b".to_string()]
494
        );
495
1
    }
496
}