diff --git a/src/ScadaLink.CentralUI/Components/Shared/TreeView.razor b/src/ScadaLink.CentralUI/Components/Shared/TreeView.razor index 897fc84..cddb90f 100644 --- a/src/ScadaLink.CentralUI/Components/Shared/TreeView.razor +++ b/src/ScadaLink.CentralUI/Components/Shared/TreeView.razor @@ -163,4 +163,95 @@ else await SelectedKeyChanged.InvokeAsync(key); } } + + /// Expand every branch node in the tree. + public void ExpandAll() + { + if (_items is { Count: > 0 }) + { + ExpandAllRecursive(_items); + } + + PersistExpandedState(); + StateHasChanged(); + } + + private void ExpandAllRecursive(IReadOnlyList items) + { + foreach (var item in items) + { + if (HasChildrenSelector(item)) + { + _expandedKeys.Add(KeySelector(item)); + } + + var children = ChildrenSelector(item); + if (children is { Count: > 0 }) + { + ExpandAllRecursive(children); + } + } + } + + /// Collapse every node in the tree. + public void CollapseAll() + { + _expandedKeys.Clear(); + PersistExpandedState(); + StateHasChanged(); + } + + /// + /// Expand all ancestors of the given key so it becomes visible. + /// Optionally select the node. + /// + public async Task RevealNode(object key, bool select = false) + { + var parentLookup = BuildParentLookup(); + + // If key is not in the tree at all, no-op + if (!parentLookup.ContainsKey(key)) + return; + + // Walk up through ancestors + var current = key; + while (parentLookup.TryGetValue(current, out var parentKey) && parentKey != null) + { + _expandedKeys.Add(parentKey); + current = parentKey; + } + + if (select && Selectable) + { + await SelectedKeyChanged.InvokeAsync(key); + } + + PersistExpandedState(); + StateHasChanged(); + } + + private Dictionary BuildParentLookup() + { + var lookup = new Dictionary(); + if (_items is { Count: > 0 }) + { + BuildParentLookupRecursive(_items, null, lookup); + } + return lookup; + } + + private void BuildParentLookupRecursive(IReadOnlyList items, object? parentKey, Dictionary lookup) + { + foreach (var item in items) + { + var key = KeySelector(item); + lookup[key] = parentKey; + + var children = ChildrenSelector(item); + if (children is { Count: > 0 }) + { + BuildParentLookupRecursive(children, key, lookup); + } + } + } } diff --git a/tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs b/tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs index 408cecb..544a12e 100644 --- a/tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs @@ -383,4 +383,77 @@ public class TreeViewTests : BunitContext var labels = cut.FindAll(".node-label"); Assert.DoesNotContain(labels, l => l.TextContent == "Alpha-1"); } + + [Fact] + public void ExpandAll_ExpandsAllBranches() + { + var cut = RenderTreeView(); + + // Everything collapsed initially + Assert.Equal(2, cut.FindAll(".node-label").Count); + + cut.InvokeAsync(() => cut.Instance.ExpandAll()); + + var labels = cut.FindAll(".node-label"); + Assert.Contains(labels, l => l.TextContent == "Alpha-1"); + Assert.Contains(labels, l => l.TextContent == "Alpha-2"); + Assert.Contains(labels, l => l.TextContent == "Alpha-2-X"); + } + + [Fact] + public void CollapseAll_CollapsesAllBranches() + { + var cut = RenderTreeView(initiallyExpanded: _ => true); + + // Verify deep content is visible + var labels = cut.FindAll(".node-label"); + Assert.Contains(labels, l => l.TextContent == "Alpha-2-X"); + + cut.InvokeAsync(() => cut.Instance.CollapseAll()); + + // Only roots should be visible + labels = cut.FindAll(".node-label"); + Assert.Equal(2, labels.Count); + Assert.DoesNotContain(labels, l => l.TextContent == "Alpha-1"); + } + + [Fact] + public void RevealNode_ExpandsAncestors() + { + var cut = RenderTreeView(); + + // Everything collapsed initially + Assert.Equal(2, cut.FindAll(".node-label").Count); + + cut.InvokeAsync(() => cut.Instance.RevealNode("a2x")); + + // Alpha-2-X should now be visible (Alpha and Alpha-2 expanded) + var labels = cut.FindAll(".node-label"); + Assert.Contains(labels, l => l.TextContent == "Alpha-2-X"); + Assert.Contains(labels, l => l.TextContent == "Alpha-1"); // sibling also visible since Alpha is expanded + Assert.Contains(labels, l => l.TextContent == "Alpha-2"); + } + + [Fact] + public void RevealNode_WithSelect_SelectsNode() + { + object? selected = null; + var cut = RenderTreeView(selectable: true, onSelectedKeyChanged: k => selected = k); + + cut.InvokeAsync(() => cut.Instance.RevealNode("a2x", select: true)); + + Assert.Equal("a2x", selected); + } + + [Fact] + public void RevealNode_UnknownKey_NoOp() + { + var cut = RenderTreeView(); + + cut.InvokeAsync(() => cut.Instance.RevealNode("nonexistent")); + + // Alpha should still be collapsed + var labels = cut.FindAll(".node-label"); + Assert.Equal(2, labels.Count); + } }