diff --git a/src/ScadaLink.CentralUI/Components/Shared/TreeView.razor b/src/ScadaLink.CentralUI/Components/Shared/TreeView.razor index 92d286f..897fc84 100644 --- a/src/ScadaLink.CentralUI/Components/Shared/TreeView.razor +++ b/src/ScadaLink.CentralUI/Components/Shared/TreeView.razor @@ -1,5 +1,6 @@ @* Reusable hierarchical tree view with expand/collapse, ARIA roles, and guide lines *@ @typeparam TItem +@inject IJSRuntime JSRuntime @if (_items is null || _items.Count == 0) { @@ -58,6 +59,7 @@ else private IReadOnlyList? _items; private HashSet _expandedKeys = new(); private bool _initialExpansionApplied; + private bool _storageLoaded; [Parameter, EditorRequired] public IReadOnlyList Items { get; set; } = []; [Parameter, EditorRequired] public Func> ChildrenSelector { get; set; } = default!; @@ -72,6 +74,7 @@ else [Parameter] public object? SelectedKey { get; set; } [Parameter] public EventCallback SelectedKeyChanged { get; set; } [Parameter] public string SelectedCssClass { get; set; } = "bg-primary bg-opacity-10"; + [Parameter] public string? StorageKey { get; set; } protected override void OnParametersSet() { @@ -79,8 +82,40 @@ else if (!_initialExpansionApplied && InitiallyExpanded != null && _items is { Count: > 0 }) { - _initialExpansionApplied = true; - ApplyInitialExpansion(_items); + // Only apply InitiallyExpanded when there is no StorageKey, or storage + // has already been checked and returned nothing (no prior state). + if (StorageKey == null || (_storageLoaded && _expandedKeys.Count == 0)) + { + _initialExpansionApplied = true; + ApplyInitialExpansion(_items); + } + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender && StorageKey != null) + { + var json = await JSRuntime.InvokeAsync("treeviewStorage.load", StorageKey); + _storageLoaded = true; + + if (json != null) + { + var keys = System.Text.Json.JsonSerializer.Deserialize>(json); + if (keys != null) + { + _expandedKeys = new HashSet(keys); + _initialExpansionApplied = true; + } + } + else if (InitiallyExpanded != null && _items is { Count: > 0 } && !_initialExpansionApplied) + { + // Storage returned null (no prior state) — fall back to InitiallyExpanded + _initialExpansionApplied = true; + ApplyInitialExpansion(_items); + } + + StateHasChanged(); } } @@ -107,6 +142,18 @@ else { _expandedKeys.Add(key); } + + PersistExpandedState(); + } + + private void PersistExpandedState() + { + if (StorageKey != null) + { + var keys = _expandedKeys.Select(k => k.ToString()!).ToList(); + var json = System.Text.Json.JsonSerializer.Serialize(keys); + _ = JSRuntime.InvokeVoidAsync("treeviewStorage.save", StorageKey, json); + } } private async Task OnContentClick(object key) diff --git a/src/ScadaLink.CentralUI/wwwroot/js/treeview-storage.js b/src/ScadaLink.CentralUI/wwwroot/js/treeview-storage.js new file mode 100644 index 0000000..d28ff17 --- /dev/null +++ b/src/ScadaLink.CentralUI/wwwroot/js/treeview-storage.js @@ -0,0 +1,8 @@ +window.treeviewStorage = { + save: function (storageKey, keysJson) { + sessionStorage.setItem("treeview:" + storageKey, keysJson); + }, + load: function (storageKey) { + return sessionStorage.getItem("treeview:" + storageKey); + } +}; diff --git a/src/ScadaLink.Host/Components/App.razor b/src/ScadaLink.Host/Components/App.razor index a95f03a..ceb5f8e 100644 --- a/src/ScadaLink.Host/Components/App.razor +++ b/src/ScadaLink.Host/Components/App.razor @@ -92,6 +92,7 @@ } }); + diff --git a/tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs b/tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs index c618344..408cecb 100644 --- a/tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs @@ -33,7 +33,8 @@ public class TreeViewTests : BunitContext bool selectable = false, object? selectedKey = null, Action? onSelectedKeyChanged = null, - string? selectedCssClass = null) + string? selectedCssClass = null, + string? storageKey = null) { return Render>(parameters => { @@ -61,6 +62,11 @@ public class TreeViewTests : BunitContext { parameters.Add(p => p.SelectedCssClass, selectedCssClass); } + + if (storageKey != null) + { + parameters.Add(p => p.StorageKey, storageKey); + } }); } @@ -317,4 +323,64 @@ public class TreeViewTests : BunitContext var alphaLi = cut.FindAll("li[role='treeitem']")[0]; Assert.Equal("true", alphaLi.GetAttribute("aria-selected")); } + + [Fact] + public void SessionStorage_NullKey_NoJsInteropCalls() + { + var cut = RenderTreeView(); + + // Expand Alpha + cut.Find(".tv-toggle").Click(); + + // No JS interop calls should have been made + Assert.Empty(JSInterop.Invocations); + } + + [Fact] + public void SessionStorage_Set_ExpandWritesToStorage() + { + JSInterop.Setup("treeviewStorage.load", _ => true).SetResult(null); + JSInterop.SetupVoid("treeviewStorage.save", _ => true); + + var cut = RenderTreeView(storageKey: "test-tree"); + + // Expand Alpha + cut.Find(".tv-toggle").Click(); + + // Verify save was called + var saveInvocations = JSInterop.Invocations + .Where(i => i.Identifier == "treeviewStorage.save") + .ToList(); + Assert.Single(saveInvocations); + } + + [Fact] + public void SessionStorage_RestoresExpandedOnMount() + { + JSInterop.Setup("treeviewStorage.load", _ => true).SetResult("[\"a\"]"); + JSInterop.SetupVoid("treeviewStorage.save", _ => true); + + var cut = RenderTreeView(storageKey: "test-tree"); + + // Alpha's children should be visible because "a" was restored from storage + var labels = cut.FindAll(".node-label"); + Assert.Contains(labels, l => l.TextContent == "Alpha-1"); + Assert.Contains(labels, l => l.TextContent == "Alpha-2"); + } + + [Fact] + public void SessionStorage_TakesPrecedenceOverInitiallyExpanded() + { + // Storage returns empty array — meaning user explicitly collapsed everything + JSInterop.Setup("treeviewStorage.load", _ => true).SetResult("[]"); + JSInterop.SetupVoid("treeviewStorage.save", _ => true); + + var cut = RenderTreeView( + storageKey: "test-tree", + initiallyExpanded: n => n.Key == "a"); + + // Alpha should NOT be expanded — storage (empty) wins over InitiallyExpanded + var labels = cut.FindAll(".node-label"); + Assert.DoesNotContain(labels, l => l.TextContent == "Alpha-1"); + } }