diff --git a/src/ScadaLink.CentralUI/Components/Shared/TreeView.razor b/src/ScadaLink.CentralUI/Components/Shared/TreeView.razor index de894d5..6149e8c 100644 --- a/src/ScadaLink.CentralUI/Components/Shared/TreeView.razor +++ b/src/ScadaLink.CentralUI/Components/Shared/TreeView.razor @@ -104,6 +104,12 @@ else ApplyInitialExpansion(_items); } } + + // Clear selection if the selected key no longer exists in the current items tree + if (Selectable && SelectedKey != null && _items is not null && !KeyExistsInTree(_items, SelectedKey)) + { + _ = SelectedKeyChanged.InvokeAsync(null); + } } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -133,6 +139,20 @@ else } } + private bool KeyExistsInTree(IReadOnlyList items, object key) + { + foreach (var item in items) + { + if (key.Equals(KeySelector(item))) + return true; + + var children = ChildrenSelector(item); + if (children is { Count: > 0 } && KeyExistsInTree(children, key)) + return true; + } + return false; + } + private void ApplyInitialExpansion(IReadOnlyList items) { foreach (var item in items) diff --git a/tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs b/tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs index 4096b93..0f55d49 100644 --- a/tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs @@ -464,6 +464,119 @@ public class TreeViewTests : BunitContext Assert.Equal(2, labels.Count); } + // ── External filtering tests (R8) ────────────────────────────────── + + [Fact] + public void Filtering_ReducedItems_HidesRemovedRoots() + { + var fullItems = SimpleRoots(); + var cut = RenderTreeView(items: fullItems); + + // Both roots visible + var labels = cut.FindAll(".node-label"); + Assert.Equal(2, labels.Count); + + // Re-render with only Alpha (Beta removed) + var alphaOnly = new List { fullItems[0] }; + cut.Render(parameters => + { + parameters + .Add(p => p.Items, alphaOnly) + .Add(p => p.ChildrenSelector, (Func>)(n => n.Children)) + .Add(p => p.HasChildrenSelector, (Func)(n => n.Children.Count > 0)) + .Add(p => p.KeySelector, (Func)(n => n.Key)) + .Add(p => p.NodeContent, (RenderFragment)(node => builder => + { + builder.AddMarkupContent(0, $"{node.Label}"); + })); + }); + + labels = cut.FindAll(".node-label"); + Assert.Single(labels); + Assert.Equal("Alpha", labels[0].TextContent); + } + + [Fact] + public void Filtering_ExpansionStatePreserved() + { + var fullItems = SimpleRoots(); + var cut = RenderTreeView(items: fullItems); + + // Expand Alpha + cut.Find(".tv-toggle").Click(); + Assert.Contains(cut.FindAll(".node-label"), l => l.TextContent == "Alpha-1"); + + // Re-render with only Alpha + var alphaOnly = new List { fullItems[0] }; + cut.Render(parameters => + { + parameters + .Add(p => p.Items, alphaOnly) + .Add(p => p.ChildrenSelector, (Func>)(n => n.Children)) + .Add(p => p.HasChildrenSelector, (Func)(n => n.Children.Count > 0)) + .Add(p => p.KeySelector, (Func)(n => n.Key)) + .Add(p => p.NodeContent, (RenderFragment)(node => builder => + { + builder.AddMarkupContent(0, $"{node.Label}"); + })); + }); + + // Alpha-1 still visible (expansion state preserved) + Assert.Contains(cut.FindAll(".node-label"), l => l.TextContent == "Alpha-1"); + + // Re-render with full list again + cut.Render(parameters => + { + parameters + .Add(p => p.Items, fullItems) + .Add(p => p.ChildrenSelector, (Func>)(n => n.Children)) + .Add(p => p.HasChildrenSelector, (Func)(n => n.Children.Count > 0)) + .Add(p => p.KeySelector, (Func)(n => n.Key)) + .Add(p => p.NodeContent, (RenderFragment)(node => builder => + { + builder.AddMarkupContent(0, $"{node.Label}"); + })); + }); + + // Alpha-1 still visible after restoration + Assert.Contains(cut.FindAll(".node-label"), l => l.TextContent == "Alpha-1"); + } + + [Fact] + public void Filtering_SelectionCleared_WhenNodeDisappears() + { + var fullItems = SimpleRoots(); + object? lastSelected = "b"; // track the last value passed to callback + var cut = RenderTreeView( + items: fullItems, + selectable: true, + selectedKey: "b", + onSelectedKeyChanged: k => lastSelected = k); + + // Re-render with only Alpha (Beta disappears) + var alphaOnly = new List { fullItems[0] }; + cut.Render(parameters => + { + parameters + .Add(p => p.Items, alphaOnly) + .Add(p => p.ChildrenSelector, (Func>)(n => n.Children)) + .Add(p => p.HasChildrenSelector, (Func)(n => n.Children.Count > 0)) + .Add(p => p.KeySelector, (Func)(n => n.Key)) + .Add(p => p.NodeContent, (RenderFragment)(node => builder => + { + builder.AddMarkupContent(0, $"{node.Label}"); + })) + .Add(p => p.Selectable, true) + .Add(p => p.SelectedKey, (object?)"b") + .Add(p => p.SelectedKeyChanged, (Action)(k => lastSelected = k)); + }); + + // SelectedKeyChanged should have been called with null + Assert.Null(lastSelected); + } + + // ── Context menu tests ────────────────────────────────────────────── + [Fact] public void ContextMenu_Null_NoMenuRendered() {