From e099ed20386cb2c90e3c2c4f219657a354453235 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 24 May 2026 05:13:04 -0400 Subject: [PATCH] feat(centralui): TreeView checkbox-selection mode with tri-state --- .../Components/Shared/TreeView.razor | 156 ++++++++++++++++++ .../Shared/TreeViewSelectionMode.cs | 12 ++ .../wwwroot/js/treeview-storage.js | 8 + .../TreeViewMultiSelectTests.cs | 147 +++++++++++++++++ 4 files changed, 323 insertions(+) create mode 100644 src/ScadaLink.CentralUI/Components/Shared/TreeViewSelectionMode.cs create mode 100644 tests/ScadaLink.CentralUI.Tests/TreeViewMultiSelectTests.cs diff --git a/src/ScadaLink.CentralUI/Components/Shared/TreeView.razor b/src/ScadaLink.CentralUI/Components/Shared/TreeView.razor index 1c4c2b3..40954c1 100644 --- a/src/ScadaLink.CentralUI/Components/Shared/TreeView.razor +++ b/src/ScadaLink.CentralUI/Components/Shared/TreeView.razor @@ -38,6 +38,12 @@ else var isSelected = Selectable && SelectedKey != null && SelectedKey.Equals(key); var rowClasses = "tv-row" + (isSelected ? " tv-selected " + SelectedCssClass : ""); + // Checkbox-mode tri-state computed for this node (folder = aggregate of + // descendant leaves; leaf = present-in-SelectedKeys). + var checkState = SelectionMode == TreeViewSelectionMode.Checkbox + ? ComputeCheckState(item) + : CheckState.Unchecked; +
  • @@ -51,6 +57,15 @@ else { } + @if (SelectionMode == TreeViewSelectionMode.Checkbox) + { + + } @NodeContent(item) @@ -99,6 +114,15 @@ else [Parameter] public string SelectedCssClass { get; set; } = "bg-primary bg-opacity-10"; [Parameter] public string? StorageKey { get; set; } + // ── Checkbox-selection mode (additive; SelectionMode=Single keeps prior behaviour) ── + [Parameter] public TreeViewSelectionMode SelectionMode { get; set; } = TreeViewSelectionMode.Single; + [Parameter] public HashSet? SelectedKeys { get; set; } + [Parameter] public EventCallback> SelectedKeysChanged { get; set; } + + private readonly Dictionary _checkboxRefs = new(); + + private enum CheckState { Unchecked, Checked, Indeterminate } + protected override void OnParametersSet() { _items = Items; @@ -182,6 +206,32 @@ else StateHasChanged(); } + + // Apply checkbox tri-state (`indeterminate`) after every render in + // Checkbox mode. Blazor doesn't bind input.indeterminate natively. + if (SelectionMode == TreeViewSelectionMode.Checkbox && _items is { Count: > 0 }) + { + await ApplyIndeterminateStateAsync(); + } + } + + private async Task ApplyIndeterminateStateAsync() + { + foreach (var (keyStr, elemRef) in _checkboxRefs) + { + var item = FindItemByKey(_items!, keyStr); + if (item is null) continue; + var state = ComputeCheckState(item); + try + { + await JSRuntime.InvokeVoidAsync( + "treeviewStorage.setIndeterminate", + elemRef, + state == CheckState.Indeterminate); + } + catch (Microsoft.JSInterop.JSDisconnectedException) { /* circuit gone */ } + catch (Microsoft.JSInterop.JSException) { /* element gone */ } + } } private bool KeyExistsInTree(IReadOnlyList items, object key) @@ -362,4 +412,110 @@ else } } } + + // ── Checkbox-selection helpers ────────────────────────────────────────── + // Folder = aggregate of its descendant LEAVES (we don't track folder keys + // in SelectedKeys — only leaf keys are persisted). A folder is Checked when + // every descendant leaf is in SelectedKeys, Unchecked when none are, and + // Indeterminate otherwise. + private CheckState ComputeCheckState(TItem item) + { + var children = ChildrenSelector(item); + var isBranch = HasChildrenSelector(item); + if (!isBranch || children is null || children.Count == 0) + { + var leafKey = KeySelector(item); + return SelectedKeys != null && SelectedKeys.Contains(leafKey) + ? CheckState.Checked + : CheckState.Unchecked; + } + + var total = 0; + var selected = 0; + CountDescendantLeaves(item, ref total, ref selected); + if (total == 0) return CheckState.Unchecked; + if (selected == 0) return CheckState.Unchecked; + if (selected == total) return CheckState.Checked; + return CheckState.Indeterminate; + } + + private void CountDescendantLeaves(TItem item, ref int total, ref int selected) + { + var children = ChildrenSelector(item); + var hasChildren = HasChildrenSelector(item) && children is { Count: > 0 }; + if (!hasChildren) + { + total++; + if (SelectedKeys != null && SelectedKeys.Contains(KeySelector(item))) + { + selected++; + } + return; + } + + foreach (var child in children!) + { + CountDescendantLeaves(child, ref total, ref selected); + } + } + + private void CollectDescendantLeafKeys(TItem item, List sink) + { + var children = ChildrenSelector(item); + var hasChildren = HasChildrenSelector(item) && children is { Count: > 0 }; + if (!hasChildren) + { + sink.Add(KeySelector(item)); + return; + } + + foreach (var child in children!) + { + CollectDescendantLeafKeys(child, sink); + } + } + + private async Task OnCheckboxToggle(TItem item) + { + // SelectedKeys is the source of truth — copy-on-write so consumers + // observe a fresh reference and Blazor reliably re-renders. + var current = SelectedKeys is null + ? new HashSet() + : new HashSet(SelectedKeys); + + var leaves = new List(); + CollectDescendantLeafKeys(item, leaves); + if (leaves.Count == 0) return; + + // Folder-toggle semantics: if every descendant leaf is currently selected, + // uncheck them all; otherwise select all. Leaf nodes have leaves = { self } + // so this simplifies to a plain toggle. + var allSelected = leaves.All(current.Contains); + if (allSelected) + { + foreach (var k in leaves) current.Remove(k); + } + else + { + foreach (var k in leaves) current.Add(k); + } + + SelectedKeys = current; + await SelectedKeysChanged.InvokeAsync(current); + } + + private TItem? FindItemByKey(IReadOnlyList items, string keyStr) + { + foreach (var item in items) + { + if (KeyStr(KeySelector(item)) == keyStr) return item; + var children = ChildrenSelector(item); + if (children is { Count: > 0 }) + { + var found = FindItemByKey(children, keyStr); + if (found is not null) return found; + } + } + return default; + } } diff --git a/src/ScadaLink.CentralUI/Components/Shared/TreeViewSelectionMode.cs b/src/ScadaLink.CentralUI/Components/Shared/TreeViewSelectionMode.cs new file mode 100644 index 0000000..ff9d65a --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Shared/TreeViewSelectionMode.cs @@ -0,0 +1,12 @@ +namespace ScadaLink.CentralUI.Components.Shared; + +/// +/// Selection mode for . is the +/// default click-to-select behaviour (preserves legacy callers). +/// renders an input checkbox per node with tri-state propagation on folders. +/// +public enum TreeViewSelectionMode +{ + Single, + Checkbox, +} diff --git a/src/ScadaLink.Host/wwwroot/js/treeview-storage.js b/src/ScadaLink.Host/wwwroot/js/treeview-storage.js index d28ff17..e0b3cad 100644 --- a/src/ScadaLink.Host/wwwroot/js/treeview-storage.js +++ b/src/ScadaLink.Host/wwwroot/js/treeview-storage.js @@ -4,5 +4,13 @@ window.treeviewStorage = { }, load: function (storageKey) { return sessionStorage.getItem("treeview:" + storageKey); + }, + // Blazor cannot bind input.indeterminate natively (only `checked`). The + // TreeView's Checkbox-selection mode calls this from OnAfterRenderAsync to + // toggle the tri-state visual on each render. + setIndeterminate: function (el, value) { + if (el) { + el.indeterminate = !!value; + } } }; diff --git a/tests/ScadaLink.CentralUI.Tests/TreeViewMultiSelectTests.cs b/tests/ScadaLink.CentralUI.Tests/TreeViewMultiSelectTests.cs new file mode 100644 index 0000000..9991116 --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/TreeViewMultiSelectTests.cs @@ -0,0 +1,147 @@ +using Bunit; +using Microsoft.AspNetCore.Components; +using ScadaLink.CentralUI.Components.Shared; + +namespace ScadaLink.CentralUI.Tests; + +/// +/// bUnit tests for TreeView's Checkbox-selection mode (T19 of the Transport +/// feature). Verifies that: +/// - a checkbox renders next to every node in Checkbox mode, +/// - clicking a folder checkbox cascades selection to every descendant leaf, +/// - clicking a leaf produces a partial parent state (Indeterminate), +/// - Single-mode behaviour is preserved (regression). +/// +public class TreeViewMultiSelectTests : BunitContext +{ + private record TestNode(string Key, string Label, List Children); + + private static List TwoFoldersThreeLeaves() => new() + { + // Folder1 → Leaf1a, Leaf1b + new("f1", "Folder1", new() + { + new("l1a", "Leaf1a", new()), + new("l1b", "Leaf1b", new()), + }), + // Folder2 → Leaf2a + new("f2", "Folder2", new() + { + new("l2a", "Leaf2a", new()), + }), + }; + + private IRenderedComponent> RenderCheckboxTree( + List? items = null, + HashSet? selectedKeys = null, + Action>? onSelectedKeysChanged = null) + { + // Indeterminate is set via JS interop after each render. We stub it so + // bUnit's strict mode doesn't blow up on the unmocked call. + JSInterop.SetupVoid("treeviewStorage.setIndeterminate", _ => true); + + return Render>(parameters => + { + parameters + .Add(p => p.Items, items ?? TwoFoldersThreeLeaves()) + .Add(p => p.ChildrenSelector, n => n.Children) + .Add(p => p.HasChildrenSelector, n => n.Children.Count > 0) + .Add(p => p.KeySelector, n => n.Key) + .Add(p => p.NodeContent, node => builder => + { + builder.AddMarkupContent(0, $"{node.Label}"); + }) + .Add(p => p.SelectionMode, TreeViewSelectionMode.Checkbox) + .Add(p => p.InitiallyExpanded, _ => true) + .Add(p => p.SelectedKeys, selectedKeys); + + if (onSelectedKeysChanged != null) + { + parameters.Add(p => p.SelectedKeysChanged, onSelectedKeysChanged); + } + }); + } + + [Fact] + public void Checkbox_mode_renders_input_checkbox_per_node() + { + var cut = RenderCheckboxTree(); + + // 2 folders + 3 leaves = 5 nodes, expanded → 5 checkboxes. + var checkboxes = cut.FindAll("input.tv-checkbox"); + Assert.Equal(5, checkboxes.Count); + } + + [Fact] + public void Clicking_folder_checkbox_selects_all_descendant_leaves() + { + HashSet? captured = null; + var cut = RenderCheckboxTree( + selectedKeys: new HashSet(), + onSelectedKeysChanged: keys => captured = keys); + + // First checkbox is Folder1 (root #1). It has leaves l1a + l1b. + var folderCheckbox = cut.FindAll("input.tv-checkbox")[0]; + folderCheckbox.Change(true); + + Assert.NotNull(captured); + Assert.Equal(2, captured!.Count); + Assert.Contains((object)"l1a", captured); + Assert.Contains((object)"l1b", captured); + } + + [Fact] + public void Clicking_leaf_makes_parent_indeterminate_when_sibling_unchecked() + { + // Pre-select one of Folder1's two leaves → Folder1 should compute as + // Indeterminate on the next render (carries the `tv-checkbox-indeterminate` + // CSS marker class — the JS-set `indeterminate` DOM property is set via + // interop and isn't observable through bUnit's rendered HTML). + var cut = RenderCheckboxTree( + selectedKeys: new HashSet { "l1a" }); + + // Folder1 checkbox is the first .tv-checkbox; should carry the partial + // marker class. + var folderCheckbox = cut.FindAll("input.tv-checkbox")[0]; + var classAttr = folderCheckbox.GetAttribute("class") ?? string.Empty; + Assert.Contains("tv-checkbox-indeterminate", classAttr); + + // …and not the fully-checked attribute. + Assert.NotEqual("true", folderCheckbox.GetAttribute("checked")); + } + + [Fact] + public void Single_mode_unchanged() + { + // Default SelectionMode is Single. No SelectedKeysChanged should fire, + // and SelectedKeyChanged (singular) should fire on content click. + object? selected = null; + HashSet? bulk = null; + + var cut = Render>(parameters => + { + parameters + .Add(p => p.Items, TwoFoldersThreeLeaves()) + .Add(p => p.ChildrenSelector, n => n.Children) + .Add(p => p.HasChildrenSelector, n => n.Children.Count > 0) + .Add(p => p.KeySelector, n => n.Key) + .Add(p => p.NodeContent, node => builder => + { + builder.AddMarkupContent(0, $"{node.Label}"); + }) + .Add(p => p.InitiallyExpanded, _ => true) + .Add(p => p.Selectable, true) + .Add(p => p.SelectedKeyChanged, (Action)(k => selected = k)) + .Add(p => p.SelectedKeysChanged, (Action>)(s => bulk = s)); + }); + + // No checkboxes rendered in Single mode. + Assert.Empty(cut.FindAll("input.tv-checkbox")); + + // Clicking the content fires SelectedKeyChanged (singular) and NOT + // SelectedKeysChanged (plural). + cut.Find(".tv-content").Click(); + Assert.Equal("f1", selected); + Assert.Null(bulk); + } +}