39 KiB
TreeView Component Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
Goal: Build a reusable generic TreeView<TItem> Blazor component and integrate it into the Instances, Data Connections, and Areas pages.
Architecture: Generic component in Components/Shared/ with @typeparam TItem, render fragments for content/context menu, internal expand/collapse state with sessionStorage persistence via JS interop, and ARIA accessibility. Three pages refactored to use it.
Tech Stack: Blazor Server, Bootstrap 5, bUnit 2.0.33-preview, xUnit, IJSRuntime for sessionStorage.
Task 1: Create TreeView.razor — Core Rendering (R1, R2, R3, R4, R14)
Files:
- Create:
src/ScadaLink.CentralUI/Components/Shared/TreeView.razor
Step 1: Write the failing tests
Create test file with core rendering tests.
Create: tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs
using Bunit;
using ScadaLink.CentralUI.Components.Shared;
namespace ScadaLink.CentralUI.Tests;
public class TreeViewTests : BunitContext
{
// Test model
record TestNode(string Key, string Label, List<TestNode> Children);
private static List<TestNode> SimpleRoots() => new()
{
new("a", "Alpha", new()
{
new("a1", "Alpha-1", new()),
new("a2", "Alpha-2", new()
{
new("a2x", "Alpha-2-X", new())
})
}),
new("b", "Beta", new()),
};
private IRenderedComponent<TreeView<TestNode>> RenderTree(
List<TestNode>? items = null,
Action<ComponentParameterCollectionBuilder<TreeView<TestNode>>>? configure = null)
{
return Render<TreeView<TestNode>>(parameters =>
{
parameters
.Add(p => p.Items, items ?? SimpleRoots())
.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 => $"<span>{node.Label}</span>");
configure?.Invoke(parameters);
});
}
[Fact]
public void Renders_RootLevelItems()
{
var cut = RenderTree();
var rootItems = cut.FindAll("[role='tree'] > li[role='treeitem']");
Assert.Equal(2, rootItems.Count);
Assert.Contains("Alpha", rootItems[0].TextContent);
Assert.Contains("Beta", rootItems[1].TextContent);
}
[Fact]
public void Renders_EmptyContent_WhenNoItems()
{
var cut = RenderTree(items: new(), configure: p =>
p.Add(x => x.EmptyContent, "<span class='empty'>Nothing here</span>"));
Assert.Contains("Nothing here", cut.Markup);
}
[Fact]
public void LeafNodes_HaveNoToggle()
{
var cut = RenderTree();
// Beta is a leaf (no children)
var betaItem = cut.FindAll("li[role='treeitem']")
.First(li => li.TextContent.Contains("Beta"));
Assert.Empty(betaItem.QuerySelectorAll(".tv-toggle"));
}
[Fact]
public void BranchNodes_ShowCollapsedToggle()
{
var cut = RenderTree();
var alphaItem = cut.FindAll("li[role='treeitem']")
.First(li => li.TextContent.Contains("Alpha"));
var toggle = alphaItem.QuerySelector(".tv-toggle");
Assert.NotNull(toggle);
Assert.Equal("false", alphaItem.GetAttribute("aria-expanded"));
}
[Fact]
public void CollapsedBranch_ChildrenNotInDom()
{
var cut = RenderTree();
// Children of collapsed Alpha should not be in the DOM
Assert.DoesNotContain("Alpha-1", cut.Markup);
}
[Fact]
public void ClickToggle_ExpandsNode_ShowsChildren()
{
var cut = RenderTree();
var toggle = cut.Find(".tv-toggle");
toggle.Click();
Assert.Contains("Alpha-1", cut.Markup);
Assert.Contains("Alpha-2", cut.Markup);
}
[Fact]
public void ClickToggle_CollapseExpandedNode()
{
var cut = RenderTree();
var toggle = cut.Find(".tv-toggle");
toggle.Click(); // expand
Assert.Contains("Alpha-1", cut.Markup);
toggle.Click(); // collapse
Assert.DoesNotContain("Alpha-1", cut.Markup);
}
[Fact]
public void DeepNesting_ExpandParentThenChild()
{
var cut = RenderTree();
// Expand Alpha
cut.Find(".tv-toggle").Click();
// Now find Alpha-2's toggle and expand it
var a2Item = cut.FindAll("li[role='treeitem']")
.First(li => li.TextContent.Contains("Alpha-2"));
a2Item.QuerySelector(".tv-toggle")!.Click();
Assert.Contains("Alpha-2-X", cut.Markup);
}
[Fact]
public void InitiallyExpanded_ExpandsMatchingNodes()
{
var cut = RenderTree(configure: p =>
p.Add(x => x.InitiallyExpanded, n => n.Key == "a"));
// Alpha should be expanded showing children
Assert.Contains("Alpha-1", cut.Markup);
Assert.Contains("Alpha-2", cut.Markup);
}
[Fact]
public void RootUl_HasTreeRole()
{
var cut = RenderTree();
Assert.NotNull(cut.Find("ul[role='tree']"));
}
[Fact]
public void NodeLi_HasTreeitemRole()
{
var cut = RenderTree();
var items = cut.FindAll("li[role='treeitem']");
Assert.True(items.Count >= 2);
}
[Fact]
public void ExpandedBranch_HasAriaExpandedTrue()
{
var cut = RenderTree(configure: p =>
p.Add(x => x.InitiallyExpanded, n => n.Key == "a"));
var alphaItem = cut.FindAll("li[role='treeitem']")
.First(li => li.TextContent.Contains("Alpha"));
Assert.Equal("true", alphaItem.GetAttribute("aria-expanded"));
}
[Fact]
public void ChildGroup_HasGroupRole()
{
var cut = RenderTree(configure: p =>
p.Add(x => x.InitiallyExpanded, n => n.Key == "a"));
Assert.NotNull(cut.Find("ul[role='group']"));
}
[Fact]
public void Indentation_ChildrenIndentedByIndentPx()
{
var cut = RenderTree(configure: p =>
{
p.Add(x => x.InitiallyExpanded, n => n.Key == "a");
p.Add(x => x.IndentPx, 30);
});
// Alpha-1 is depth 1 → padding-left should be 30px
var a1Row = cut.FindAll(".tv-row")
.First(r => r.TextContent.Contains("Alpha-1"));
Assert.Contains("padding-left: 30px", a1Row.GetAttribute("style") ?? "");
}
}
Step 2: Run tests to verify they fail
Run: dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests" -v minimal
Expected: Compilation error — TreeView component doesn't exist yet.
Step 3: Implement TreeView.razor
Create: src/ScadaLink.CentralUI/Components/Shared/TreeView.razor
The component should:
- Accept
@typeparam TItemwith all parameters from the API summary in the requirements doc. - Use a
HashSet<object> _expandedKeysfor expand/collapse state. - Render recursively: a private
RenderNode(TItem item, int depth)method outputting<li role="treeitem">with toggle span (.tv-toggle), content area (.tv-content), and recursive<ul role="group">for children. - Each row
<div class="tv-row">hasstyle="padding-left: {depth * IndentPx}px". - Branch toggle:
<span class="tv-toggle" @onclick="() => ToggleExpand(item)">showing+or−. - Leaf nodes: no toggle, but same left padding alignment (the content area starts at the same x as branch content — add a spacer element the same width as the toggle).
aria-expandedon branch<li>,role="group"on child<ul>.- When collapsed, do NOT render child
<ul>at all (not justdisplay:none). EmptyContentshown whenItemsis null or empty.- Apply
InitiallyExpandedinOnParametersSeton first render only: walk all items recursively, add matching keys to_expandedKeys.
Step 4: Run tests to verify they pass
Run: dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests" -v minimal
Expected: All tests PASS.
Step 5: Commit
git add src/ScadaLink.CentralUI/Components/Shared/TreeView.razor tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs
git commit -m "feat(ui): add TreeView<TItem> component with core rendering, expand/collapse, ARIA (R1-R4, R14)"
Task 2: Add Selection Support (R5)
Files:
- Modify:
src/ScadaLink.CentralUI/Components/Shared/TreeView.razor - Modify:
tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs
Step 1: Write the failing tests
Add to TreeViewTests.cs:
[Fact]
public void Selection_Disabled_ClickDoesNotFireCallback()
{
object? selected = null;
var cut = RenderTree(configure: p =>
{
p.Add(x => x.Selectable, false);
p.Add(x => x.SelectedKeyChanged, (object? key) => selected = key);
});
cut.Find(".tv-content").Click();
Assert.Null(selected);
}
[Fact]
public void Selection_Enabled_ClickContentFiresCallback()
{
object? selected = null;
var cut = RenderTree(configure: p =>
{
p.Add(x => x.Selectable, true);
p.Add(x => x.SelectedKeyChanged, (object? key) => selected = key);
});
// Click Alpha's content area
var alphaContent = cut.FindAll(".tv-content")
.First(c => c.TextContent.Contains("Alpha"));
alphaContent.Click();
Assert.Equal("a", selected);
}
[Fact]
public void Selection_ClickToggle_DoesNotChangeSelection()
{
object? selected = null;
var cut = RenderTree(configure: p =>
{
p.Add(x => x.Selectable, true);
p.Add(x => x.SelectedKeyChanged, (object? key) => selected = key);
});
cut.Find(".tv-toggle").Click();
Assert.Null(selected);
}
[Fact]
public void Selection_SelectedNode_HasCssClass()
{
var cut = RenderTree(configure: p =>
{
p.Add(x => x.Selectable, true);
p.Add(x => x.SelectedKey, (object)"a");
});
var alphaRow = cut.FindAll(".tv-row")
.First(r => r.TextContent.Contains("Alpha"));
Assert.Contains("bg-primary", alphaRow.ClassList);
}
[Fact]
public void Selection_CustomCssClass_Applied()
{
var cut = RenderTree(configure: p =>
{
p.Add(x => x.Selectable, true);
p.Add(x => x.SelectedKey, (object)"a");
p.Add(x => x.SelectedCssClass, "my-highlight");
});
var alphaRow = cut.FindAll(".tv-row")
.First(r => r.TextContent.Contains("Alpha"));
Assert.Contains("my-highlight", alphaRow.ClassList);
}
[Fact]
public void Selection_AriaSelected_SetOnSelectedNode()
{
var cut = RenderTree(configure: p =>
{
p.Add(x => x.Selectable, true);
p.Add(x => x.SelectedKey, (object)"a");
});
var alphaItem = cut.FindAll("li[role='treeitem']")
.First(li => li.TextContent.Contains("Alpha"));
Assert.Equal("true", alphaItem.GetAttribute("aria-selected"));
}
Step 2: Run tests to verify they fail
Run: dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests.Selection" -v minimal
Expected: FAIL
Step 3: Implement selection in TreeView.razor
Add to the component:
- When
Selectableis true and the.tv-contentarea is clicked, invokeSelectedKeyChangedwith the node's key. - When rendering, if a node's key equals
SelectedKey, addSelectedCssClassto the.tv-rowdiv. - Add
aria-selected="true"on the selected<li>. - The
.tv-toggleclick handler must NOT propagate to selection (use@onclick:stopPropagationor separate event handling).
Step 4: Run tests to verify they pass
Run: dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests" -v minimal
Expected: All PASS.
Step 5: Commit
git add src/ScadaLink.CentralUI/Components/Shared/TreeView.razor tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs
git commit -m "feat(ui): add selection support to TreeView (R5)"
Task 3: Add Session Storage Persistence (R11)
Files:
- Create:
src/ScadaLink.CentralUI/wwwroot/js/treeview-storage.js - Modify:
src/ScadaLink.CentralUI/Components/Shared/TreeView.razor - Modify:
tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs
Step 1: Write the failing tests
Add to TreeViewTests.cs:
[Fact]
public void SessionStorage_Null_NoJsInteropCalls()
{
var cut = RenderTree(configure: p =>
p.Add(x => x.StorageKey, (string?)null));
// Expand a node
cut.Find(".tv-toggle").Click();
// No JS interop should have been invoked
Assert.Empty(JSInterop.Invocations);
}
[Fact]
public void SessionStorage_Set_ExpandWritesToStorage()
{
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
var cut = RenderTree(configure: p =>
p.Add(x => x.StorageKey, "test-tree"));
// Expand Alpha
cut.Find(".tv-toggle").Click();
var saveInvocations = JSInterop.Invocations
.Where(i => i.Identifier == "treeviewStorage.save");
Assert.NotEmpty(saveInvocations);
}
[Fact]
public void SessionStorage_RestoresExpandedOnMount()
{
// Pre-populate storage with Alpha expanded
JSInterop.Setup<string?>("treeviewStorage.load", _ => true)
.SetResult("[\"a\"]");
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
var cut = RenderTree(configure: p =>
p.Add(x => x.StorageKey, "test-tree"));
// Alpha should be expanded from storage
Assert.Contains("Alpha-1", cut.Markup);
}
[Fact]
public void SessionStorage_TakesPrecedenceOverInitiallyExpanded()
{
// Storage says nothing expanded, but InitiallyExpanded says expand Alpha
JSInterop.Setup<string?>("treeviewStorage.load", _ => true)
.SetResult("[]");
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
var cut = RenderTree(configure: p =>
{
p.Add(x => x.StorageKey, "test-tree");
p.Add(x => x.InitiallyExpanded, n => n.Key == "a");
});
// Storage (empty) takes precedence — Alpha should NOT be expanded
Assert.DoesNotContain("Alpha-1", cut.Markup);
}
Step 2: Run tests to verify they fail
Run: dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests.SessionStorage" -v minimal
Expected: FAIL
Step 3: Create JS interop file
Create: src/ScadaLink.CentralUI/wwwroot/js/treeview-storage.js
window.treeviewStorage = {
save: function (storageKey, keysJson) {
sessionStorage.setItem("treeview:" + storageKey, keysJson);
},
load: function (storageKey) {
return sessionStorage.getItem("treeview:" + storageKey);
}
};
Add a <script> reference in the app's _Host.cshtml or App.razor layout (wherever other scripts are included):
<script src="js/treeview-storage.js"></script>
Step 4: Implement persistence in TreeView.razor
- Inject
[Inject] private IJSRuntime JSRuntime { get; set; } = default!; - In
OnAfterRenderAsync(bool firstRender): iffirstRender && StorageKey != null, callJSRuntime.InvokeAsync<string?>("treeviewStorage.load", StorageKey). If result is not null, deserialize JSON array of keys into_expandedKeysand callStateHasChanged(). Set a_storageLoaded = trueflag. - Only apply
InitiallyExpandedwhenStorageKeyis null OR storage returned null (no prior state). - On every
ToggleExpand, ifStorageKey != null, serialize_expandedKeysto JSON and callJSRuntime.InvokeVoidAsync("treeviewStorage.save", StorageKey, json).
Step 5: Run tests to verify they pass
Run: dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests" -v minimal
Expected: All PASS.
Step 6: Commit
git add src/ScadaLink.CentralUI/wwwroot/js/treeview-storage.js src/ScadaLink.CentralUI/Components/Shared/TreeView.razor tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs
git commit -m "feat(ui): add sessionStorage persistence for TreeView expansion state (R11)"
Task 4: Add ExpandAll, CollapseAll, RevealNode (R12, R13)
Files:
- Modify:
src/ScadaLink.CentralUI/Components/Shared/TreeView.razor - Modify:
tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs
Step 1: Write the failing tests
Add to TreeViewTests.cs:
[Fact]
public void ExpandAll_ExpandsAllBranches()
{
var cut = RenderTree();
cut.Instance.ExpandAll();
cut.Render();
// All nested content should be visible
Assert.Contains("Alpha-1", cut.Markup);
Assert.Contains("Alpha-2", cut.Markup);
Assert.Contains("Alpha-2-X", cut.Markup);
}
[Fact]
public void CollapseAll_CollapsesAllBranches()
{
var cut = RenderTree(configure: p =>
p.Add(x => x.InitiallyExpanded, _ => true));
Assert.Contains("Alpha-1", cut.Markup);
cut.Instance.CollapseAll();
cut.Render();
Assert.DoesNotContain("Alpha-1", cut.Markup);
}
[Fact]
public void RevealNode_ExpandsAncestors()
{
var cut = RenderTree();
// Alpha-2-X is at depth 2 — both Alpha and Alpha-2 need expanding
cut.Instance.RevealNode("a2x");
cut.Render();
Assert.Contains("Alpha-2-X", cut.Markup);
// Intermediate nodes should also be visible
Assert.Contains("Alpha-2", cut.Markup);
}
[Fact]
public void RevealNode_WithSelect_SelectsNode()
{
object? selected = null;
var cut = RenderTree(configure: p =>
{
p.Add(x => x.Selectable, true);
p.Add(x => x.SelectedKeyChanged, (object? key) => selected = key);
});
cut.Instance.RevealNode("a2x", select: true);
cut.Render();
Assert.Equal("a2x", selected);
}
[Fact]
public void RevealNode_UnknownKey_NoOp()
{
var cut = RenderTree();
// Should not throw
cut.Instance.RevealNode("nonexistent");
cut.Render();
// Alpha still collapsed
Assert.DoesNotContain("Alpha-1", cut.Markup);
}
Step 2: Run tests to verify they fail
Run: dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests.ExpandAll|CollapseAll|RevealNode" -v minimal
Expected: FAIL (methods don't exist)
Step 3: Implement the public methods
In TreeView.razor:
ExpandAll(): Walk the entire tree recursively starting fromItems. For each node whereHasChildrenSelectorreturns true, add its key to_expandedKeys. CallPersistExpandedState()andStateHasChanged().CollapseAll(): Clear_expandedKeys. CallPersistExpandedState()andStateHasChanged().RevealNode(object key, bool select = false): Build a parent lookup dictionary by walking the full tree (key → parent key). Starting from the target key, walk up through parents, adding each ancestor key to_expandedKeys. Ifselectis true, setSelectedKeyand invokeSelectedKeyChanged. CallPersistExpandedState()andStateHasChanged(). If key not found in the tree, return silently.- Extract
PersistExpandedState()private method that serializes and writes to sessionStorage ifStorageKeyis set.
Step 4: Run tests to verify they pass
Run: dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests" -v minimal
Expected: All PASS.
Step 5: Commit
git add src/ScadaLink.CentralUI/Components/Shared/TreeView.razor tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs
git commit -m "feat(ui): add ExpandAll, CollapseAll, RevealNode to TreeView (R12, R13)"
Task 5: Add Context Menu (R15)
Files:
- Modify:
src/ScadaLink.CentralUI/Components/Shared/TreeView.razor - Modify:
tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs
Step 1: Write the failing tests
Add to TreeViewTests.cs:
[Fact]
public void ContextMenu_Null_NoMenuRendered()
{
var cut = RenderTree();
// Right-click Alpha — no context menu should appear
var alphaRow = cut.FindAll(".tv-row")
.First(r => r.TextContent.Contains("Alpha"));
alphaRow.ContextMenu();
Assert.Throws<Bunit.ElementNotFoundException>(() => cut.Find(".dropdown-menu"));
}
[Fact]
public void ContextMenu_RightClickShowsMenu()
{
var cut = RenderTree(configure: p =>
p.Add(x => x.ContextMenu, (TestNode node) =>
$"<button class='dropdown-item'>Action for {node.Label}</button>"));
var alphaRow = cut.FindAll(".tv-row")
.First(r => r.TextContent.Contains("Alpha"));
alphaRow.ContextMenu();
var menu = cut.Find(".dropdown-menu");
Assert.Contains("Action for Alpha", menu.TextContent);
}
[Fact]
public void ContextMenu_EmptyFragment_NoMenu()
{
// ContextMenu renders nothing for leaves
var cut = RenderTree(configure: p =>
p.Add(x => x.ContextMenu, (TestNode node) =>
node.Children.Count > 0 ? $"<button class='dropdown-item'>Edit</button>" : ""));
// Right-click Beta (leaf) — fragment renders empty
var betaRow = cut.FindAll(".tv-row")
.First(r => r.TextContent.Contains("Beta"));
betaRow.ContextMenu();
Assert.Throws<Bunit.ElementNotFoundException>(() => cut.Find(".dropdown-menu"));
}
[Fact]
public void ContextMenu_RightClickDifferentNode_ReplacesMenu()
{
var cut = RenderTree(configure: p =>
{
p.Add(x => x.InitiallyExpanded, n => n.Key == "a");
p.Add(x => x.ContextMenu, (TestNode node) =>
$"<button class='dropdown-item'>{node.Label}</button>");
});
// Right-click Alpha
cut.FindAll(".tv-row").First(r => r.TextContent.Contains("Alpha")).ContextMenu();
Assert.Contains("Alpha", cut.Find(".dropdown-menu").TextContent);
// Right-click Alpha-1
cut.FindAll(".tv-row").First(r => r.TextContent.Contains("Alpha-1")).ContextMenu();
var menus = cut.FindAll(".dropdown-menu");
Assert.Single(menus);
Assert.Contains("Alpha-1", menus[0].TextContent);
}
Step 2: Run tests to verify they fail
Run: dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests.ContextMenu" -v minimal
Expected: FAIL
Step 3: Implement context menu
In TreeView.razor:
- Add
[Parameter] public RenderFragment<TItem>? ContextMenu { get; set; } - Add state:
private TItem? _contextMenuItem,private double _contextMenuX,private double _contextMenuY,private bool _showContextMenu - On the
.tv-rowdiv, add@oncontextmenu="(e) => OnContextMenu(e, item)" @oncontextmenu:preventDefault="_showContextMenu". OnContextMenu(MouseEventArgs e, TItem item): set_contextMenuItem = item,_contextMenuX = e.ClientX,_contextMenuY = e.ClientY,_showContextMenu = true.- Render the menu: if
_showContextMenu && _contextMenuItem != null && ContextMenu != null, render a<div class="dropdown-menu show" style="position:fixed; top:{_contextMenuY}px; left:{_contextMenuX}px; z-index:1050;">containing@ContextMenu(_contextMenuItem). - Handle empty fragment: after rendering, if the dropdown-menu has no child elements, hide it. Alternatively, always render and use CSS or check for content. The simplest approach: always render the container, and the test for "empty" checks whether any
.dropdown-itemchildren exist. If not, suppresspreventDefaultso browser default menu shows. - Dismiss: add a
@onclickhandler on a full-page overlay<div>(transparent, z-index below the menu) that sets_showContextMenu = false. Also handle Escape via@onkeydown. - Clicking a menu item: the consumer's
@onclickhandler runs, then the overlay catch dismisses the menu on the next render.
Step 4: Run tests to verify they pass
Run: dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests" -v minimal
Expected: All PASS.
Step 5: Commit
git add src/ScadaLink.CentralUI/Components/Shared/TreeView.razor tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs
git commit -m "feat(ui): add right-click context menu to TreeView (R15)"
Task 6: Add External Filtering Tests (R8)
Files:
- Modify:
tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs
This task adds tests for filtering behavior that should already work from the core implementation (the component just renders whatever Items it receives). These tests verify that expansion state is preserved across re-renders with different items.
Step 1: Write the tests
Add to TreeViewTests.cs:
[Fact]
public void Filtering_ReducedItems_HidesRemovedRoots()
{
var cut = RenderTree();
Assert.Contains("Alpha", cut.Markup);
Assert.Contains("Beta", cut.Markup);
// Re-render with only Alpha
cut.SetParametersAndRender(p =>
p.Add(x => x.Items, SimpleRoots().Where(n => n.Key == "a").ToList()));
Assert.Contains("Alpha", cut.Markup);
Assert.DoesNotContain("Beta", cut.Markup);
}
[Fact]
public void Filtering_ExpansionStatePreserved()
{
var cut = RenderTree();
// Expand Alpha
cut.Find(".tv-toggle").Click();
Assert.Contains("Alpha-1", cut.Markup);
// Filter to only Alpha
cut.SetParametersAndRender(p =>
p.Add(x => x.Items, SimpleRoots().Where(n => n.Key == "a").ToList()));
// Alpha should still be expanded
Assert.Contains("Alpha-1", cut.Markup);
// Restore full list — Alpha still expanded
cut.SetParametersAndRender(p =>
p.Add(x => x.Items, SimpleRoots()));
Assert.Contains("Alpha-1", cut.Markup);
}
[Fact]
public void Filtering_SelectionCleared_WhenNodeDisappears()
{
object? selected = "b"; // Beta selected
var cut = RenderTree(configure: p =>
{
p.Add(x => x.Selectable, true);
p.Add(x => x.SelectedKey, (object)"b");
p.Add(x => x.SelectedKeyChanged, (object? key) => selected = key);
});
// Filter to only Alpha — Beta disappears
cut.SetParametersAndRender(p =>
{
p.Add(x => x.Items, SimpleRoots().Where(n => n.Key == "a").ToList());
p.Add(x => x.Selectable, true);
p.Add(x => x.SelectedKey, (object)"b");
p.Add(x => x.SelectedKeyChanged, (object? key) => selected = key);
});
// Selection should be cleared
Assert.Null(selected);
}
Step 2: Run tests
Run: dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests.Filtering" -v minimal
Expected: PASS (behavior comes from core implementation). If any fail, fix in TreeView.razor.
Step 3: Commit
git add tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs
git commit -m "test(ui): add external filtering tests for TreeView (R8)"
Task 7: Integrate TreeView into Data Connections Page
Files:
- Modify:
src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor
This is the simplest integration (two-level tree, no recursion).
Step 1: Read the current file
Read: src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor
Step 2: Implement the changes
The page currently has a flat table. Replace it with a TreeView:
-
Add a
TreeNoderecord andNodeKindenum in the@codeblock:record TreeNode(string Key, string Label, NodeKind Kind, List<TreeNode> Children, DataConnection? Connection = null, int? SiteId = null); enum NodeKind { Site, DataConnection } -
Add
_treeRootsfield built inLoadDataAsync():private List<TreeNode> _treeRoots = new(); -
In
LoadDataAsync(), after loading sites and connections, build the tree:var connBySite = _connections.GroupBy(c => c.SiteId).ToDictionary(g => g.Key, g => g.ToList()); _treeRoots = _sites.Select(site => new TreeNode( Key: $"site-{site.Id}", Label: site.Name, Kind: NodeKind.Site, Children: (connBySite.GetValueOrDefault(site.Id) ?? new()) .Select(c => new TreeNode( Key: $"conn-{c.Id}", Label: c.Name, Kind: NodeKind.DataConnection, Children: new(), Connection: c)) .ToList(), SiteId: site.Id )).ToList(); -
Replace the
<table>with:<TreeView TItem="TreeNode" Items="_treeRoots" ChildrenSelector="n => n.Children" HasChildrenSelector="n => n.Children.Count > 0" KeySelector="n => n.Key" StorageKey="data-connections-tree"> <NodeContent Context="node"> @if (node.Kind == NodeKind.Site) { <span class="fw-semibold">@node.Label</span> <span class="badge bg-secondary ms-1">@node.Children.Count</span> } else { <span>@node.Label</span> <span class="badge bg-info ms-2">@node.Connection!.Protocol</span> } </NodeContent> <ContextMenu Context="node"> @if (node.Kind == NodeKind.DataConnection) { <button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/admin/data-connections/{node.Connection!.Id}/edit")'> Edit </button> <div class="dropdown-divider"></div> <button class="dropdown-item text-danger" @onclick="() => DeleteConnection(node.Connection!)"> Delete </button> } </ContextMenu> <EmptyContent> <span class="text-muted fst-italic">No data connections configured.</span> </EmptyContent> </TreeView> -
Remove the old
<table>/<thead>/<tbody>markup and inline Edit/Delete buttons. -
Keep
_toast,_confirmDialog, the "Create" button header, loading/error handling, and theDeleteConnectionmethod.
Step 3: Build and verify
Run: dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj
Expected: Build succeeds.
Step 4: Commit
git add src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor
git commit -m "refactor(ui): replace data connections table with TreeView grouped by site"
Task 8: Integrate TreeView into Areas Page
Files:
- Modify:
src/ScadaLink.CentralUI/Components/Pages/Admin/Areas.razor
Step 1: Read the current file
Read: src/ScadaLink.CentralUI/Components/Pages/Admin/Areas.razor
Step 2: Implement the changes
The Areas page has a two-panel layout. Keep the left site list panel. Replace the right panel's custom flat-tree rendering with a TreeView using the Area entity directly as TItem.
-
Replace the manual tree rendering block (the
@foreach (var node in _flatTree)loop with indentation) with:<TreeView TItem="Area" Items="_rootAreas" ChildrenSelector="a => a.Children.ToList()" HasChildrenSelector="a => a.Children.Any()" KeySelector="a => (object)a.Id" StorageKey="areas-tree"> <NodeContent Context="area"> <span>@area.Name</span> </NodeContent> <ContextMenu Context="area"> <button class="dropdown-item" @onclick="() => EditArea(area)"> Edit </button> <div class="dropdown-divider"></div> <button class="dropdown-item text-danger" @onclick="() => DeleteArea(area)"> Delete </button> </ContextMenu> <EmptyContent> <span class="text-muted fst-italic">No areas for this site. Add one above.</span> </EmptyContent> </TreeView> -
Add
_rootAreasfield computed from_areas:private List<Area> _rootAreas => _areas.Where(a => a.ParentAreaId == null).ToList(); -
Remove:
BuildFlatTree(),AddChildren(), theAreaTreeNoderecord,_flatTreefield, and all manual indentation CSS. -
Keep: site list panel, add/edit form,
LoadAreasAsync(),SaveArea(),DeleteArea(),GetAreaPath().
Step 3: Build and verify
Run: dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj
Expected: Build succeeds.
Step 4: Commit
git add src/ScadaLink.CentralUI/Components/Pages/Admin/Areas.razor
git commit -m "refactor(ui): replace manual area tree rendering with TreeView component"
Task 9: Integrate TreeView into Instances Page
Files:
- Modify:
src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor
This is the most complex integration — deep hierarchy with filtering and context menu.
Step 1: Read the current file
Read: src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor
Step 2: Add tree model and builder
Add to @code block:
record InstanceTreeNode(string Key, string Label, InstanceNodeKind Kind,
List<InstanceTreeNode> Children, Instance? Instance = null,
bool IsStale = false);
enum InstanceNodeKind { Site, Area, Instance }
private List<InstanceTreeNode> _treeRoots = new();
private void BuildTree()
{
// Build the hierarchy: Site → Area (recursive) → Instance
_treeRoots = _sites.Select(site =>
{
var siteAreas = _allAreas.Where(a => a.SiteId == site.Id).ToList();
var siteInstances = _filteredInstances.Where(i => i.SiteId == site.Id).ToList();
var areaNodes = BuildAreaNodes(siteAreas, siteInstances, parentId: null);
// Instances with no area attach directly under the site
var unassigned = siteInstances
.Where(i => i.AreaId == null)
.Select(MakeInstanceNode)
.ToList();
var children = areaNodes.Concat(unassigned).ToList();
return new InstanceTreeNode(
Key: $"site-{site.Id}",
Label: site.Name,
Kind: InstanceNodeKind.Site,
Children: children);
})
.Where(s => s.Children.Count > 0) // Prune empty sites
.ToList();
}
private List<InstanceTreeNode> BuildAreaNodes(List<Area> allAreas,
List<Instance> instances, int? parentId)
{
return allAreas
.Where(a => a.ParentAreaId == parentId)
.Select(area =>
{
var childAreas = BuildAreaNodes(allAreas, instances, area.Id);
var areaInstances = instances
.Where(i => i.AreaId == area.Id)
.Select(MakeInstanceNode)
.ToList();
var children = childAreas.Concat(areaInstances).ToList();
return new InstanceTreeNode(
Key: $"area-{area.Id}",
Label: area.Name,
Kind: InstanceNodeKind.Area,
Children: children);
})
.Where(a => a.Children.Count > 0) // Prune empty areas
.ToList();
}
private InstanceTreeNode MakeInstanceNode(Instance inst) => new(
Key: $"inst-{inst.Id}",
Label: inst.UniqueName,
Kind: InstanceNodeKind.Instance,
Children: new(),
Instance: inst,
IsStale: _stalenessMap.GetValueOrDefault(inst.Id));
Step 3: Update ApplyFilters to build tree
In ApplyFilters(), after the existing filtering logic, replace UpdatePage() with BuildTree(). Remove _totalPages, _currentPage, _pagedInstances, GoToPage(), UpdatePage(), and PageSize.
Step 4: Replace table with TreeView
Replace the <table> and pagination markup with:
<TreeView @ref="_instanceTree" TItem="InstanceTreeNode" Items="_treeRoots"
ChildrenSelector="n => n.Children"
HasChildrenSelector="n => n.Children.Count > 0"
KeySelector="n => n.Key"
StorageKey="instances-tree"
Selectable="true"
SelectedKey="_selectedKey"
SelectedKeyChanged="key => { _selectedKey = key; }">
<NodeContent Context="node">
@switch (node.Kind)
{
case InstanceNodeKind.Site:
<span class="fw-semibold">@node.Label</span>
break;
case InstanceNodeKind.Area:
<span class="text-secondary">@node.Label</span>
break;
case InstanceNodeKind.Instance:
<span>@node.Label</span>
<span class="badge @GetStateBadge(node.Instance!.State) ms-1">@node.Instance!.State</span>
@if (node.Instance!.State != InstanceState.NotDeployed)
{
<span class="badge @(node.IsStale ? "bg-warning text-dark" : "bg-light text-dark") ms-1">
@(node.IsStale ? "Stale" : "Current")
</span>
}
break;
}
</NodeContent>
<ContextMenu Context="node">
@if (node.Kind == InstanceNodeKind.Instance)
{
var inst = node.Instance!;
var isStale = node.IsStale;
<button class="dropdown-item" @onclick="() => DeployInstance(inst)"
disabled="@_actionInProgress">@(isStale ? "Redeploy" : "Deploy")</button>
@if (inst.State == InstanceState.Enabled)
{
<button class="dropdown-item" @onclick="() => DisableInstance(inst)"
disabled="@_actionInProgress">Disable</button>
}
else if (inst.State == InstanceState.Disabled)
{
<button class="dropdown-item" @onclick="() => EnableInstance(inst)"
disabled="@_actionInProgress">Enable</button>
}
<button class="dropdown-item"
@onclick='() => NavigationManager.NavigateTo($"/deployment/instances/{inst.Id}/configure")'>
Configure
</button>
<button class="dropdown-item" @onclick="() => ShowDiff(inst)"
disabled="@(_actionInProgress || inst.State == InstanceState.NotDeployed)">Diff</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger" @onclick="() => DeleteInstance(inst)"
disabled="@_actionInProgress">Delete</button>
}
</ContextMenu>
<EmptyContent>
<span class="text-muted fst-italic">No instances match the current filters.</span>
</EmptyContent>
</TreeView>
<div class="text-muted small mt-2">
@_filteredInstances.Count instance(s) total
</div>
Add fields:
private TreeView<InstanceTreeNode> _instanceTree = default!;
private object? _selectedKey;
Step 5: Remove old table/pagination code
Remove from @code:
_pagedInstances,_currentPage,_totalPages,PageSizeGoToPage(),UpdatePage()- Keep:
_allInstances,_filteredInstances,_allAreas,_sites,_templates,_stalenessMap,ApplyFilters()(modified), all action methods, diff modal
Step 6: Build and verify
Run: dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj
Expected: Build succeeds.
Step 7: Commit
git add src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor
git commit -m "refactor(ui): replace instances table with hierarchical TreeView (Site → Area → Instance)"
Task 10: Full Build Verification
Files:
- No changes — verification only.
Step 1: Build entire solution
Run: dotnet build ScadaLink.slnx
Expected: Build succeeds with 0 errors.
Step 2: Run all tests
Run: dotnet test ScadaLink.slnx -v minimal
Expected: All tests pass (including the new TreeView tests and existing tests).
Step 3: Run TreeView tests specifically
Run: dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests" -v minimal
Expected: All TreeView tests pass.
Step 4: Commit (if any fixes needed)
Only if build/test failures required fixes:
git add -A
git commit -m "fix(ui): resolve build/test issues from TreeView integration"