feat(ui): add sessionStorage persistence for TreeView expansion state (R11)
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
@* Reusable hierarchical tree view with expand/collapse, ARIA roles, and guide lines *@
|
@* Reusable hierarchical tree view with expand/collapse, ARIA roles, and guide lines *@
|
||||||
@typeparam TItem
|
@typeparam TItem
|
||||||
|
@inject IJSRuntime JSRuntime
|
||||||
|
|
||||||
@if (_items is null || _items.Count == 0)
|
@if (_items is null || _items.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -58,6 +59,7 @@ else
|
|||||||
private IReadOnlyList<TItem>? _items;
|
private IReadOnlyList<TItem>? _items;
|
||||||
private HashSet<object> _expandedKeys = new();
|
private HashSet<object> _expandedKeys = new();
|
||||||
private bool _initialExpansionApplied;
|
private bool _initialExpansionApplied;
|
||||||
|
private bool _storageLoaded;
|
||||||
|
|
||||||
[Parameter, EditorRequired] public IReadOnlyList<TItem> Items { get; set; } = [];
|
[Parameter, EditorRequired] public IReadOnlyList<TItem> Items { get; set; } = [];
|
||||||
[Parameter, EditorRequired] public Func<TItem, IReadOnlyList<TItem>> ChildrenSelector { get; set; } = default!;
|
[Parameter, EditorRequired] public Func<TItem, IReadOnlyList<TItem>> ChildrenSelector { get; set; } = default!;
|
||||||
@@ -72,6 +74,7 @@ else
|
|||||||
[Parameter] public object? SelectedKey { get; set; }
|
[Parameter] public object? SelectedKey { get; set; }
|
||||||
[Parameter] public EventCallback<object?> SelectedKeyChanged { get; set; }
|
[Parameter] public EventCallback<object?> SelectedKeyChanged { get; set; }
|
||||||
[Parameter] public string SelectedCssClass { get; set; } = "bg-primary bg-opacity-10";
|
[Parameter] public string SelectedCssClass { get; set; } = "bg-primary bg-opacity-10";
|
||||||
|
[Parameter] public string? StorageKey { get; set; }
|
||||||
|
|
||||||
protected override void OnParametersSet()
|
protected override void OnParametersSet()
|
||||||
{
|
{
|
||||||
@@ -79,8 +82,40 @@ else
|
|||||||
|
|
||||||
if (!_initialExpansionApplied && InitiallyExpanded != null && _items is { Count: > 0 })
|
if (!_initialExpansionApplied && InitiallyExpanded != null && _items is { Count: > 0 })
|
||||||
{
|
{
|
||||||
_initialExpansionApplied = true;
|
// Only apply InitiallyExpanded when there is no StorageKey, or storage
|
||||||
ApplyInitialExpansion(_items);
|
// 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<string?>("treeviewStorage.load", StorageKey);
|
||||||
|
_storageLoaded = true;
|
||||||
|
|
||||||
|
if (json != null)
|
||||||
|
{
|
||||||
|
var keys = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
|
||||||
|
if (keys != null)
|
||||||
|
{
|
||||||
|
_expandedKeys = new HashSet<object>(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);
|
_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)
|
private async Task OnContentClick(object key)
|
||||||
|
|||||||
8
src/ScadaLink.CentralUI/wwwroot/js/treeview-storage.js
Normal file
8
src/ScadaLink.CentralUI/wwwroot/js/treeview-storage.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
window.treeviewStorage = {
|
||||||
|
save: function (storageKey, keysJson) {
|
||||||
|
sessionStorage.setItem("treeview:" + storageKey, keysJson);
|
||||||
|
},
|
||||||
|
load: function (storageKey) {
|
||||||
|
return sessionStorage.getItem("treeview:" + storageKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -92,6 +92,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/js/treeview-storage.js"></script>
|
||||||
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ public class TreeViewTests : BunitContext
|
|||||||
bool selectable = false,
|
bool selectable = false,
|
||||||
object? selectedKey = null,
|
object? selectedKey = null,
|
||||||
Action<object?>? onSelectedKeyChanged = null,
|
Action<object?>? onSelectedKeyChanged = null,
|
||||||
string? selectedCssClass = null)
|
string? selectedCssClass = null,
|
||||||
|
string? storageKey = null)
|
||||||
{
|
{
|
||||||
return Render<TreeView<TestNode>>(parameters =>
|
return Render<TreeView<TestNode>>(parameters =>
|
||||||
{
|
{
|
||||||
@@ -61,6 +62,11 @@ public class TreeViewTests : BunitContext
|
|||||||
{
|
{
|
||||||
parameters.Add(p => p.SelectedCssClass, selectedCssClass);
|
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];
|
var alphaLi = cut.FindAll("li[role='treeitem']")[0];
|
||||||
Assert.Equal("true", alphaLi.GetAttribute("aria-selected"));
|
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<string?>("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<string?>("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<string?>("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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user