Files
scadalink-design/docs/plans/2026-03-23-treeview-component.md

1129 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`
```csharp
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 TItem` with all parameters from the API summary in the requirements doc.
- Use a `HashSet<object> _expandedKeys` for 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">` has `style="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-expanded` on branch `<li>`, `role="group"` on child `<ul>`.
- When collapsed, do NOT render child `<ul>` at all (not just `display:none`).
- `EmptyContent` shown when `Items` is null or empty.
- Apply `InitiallyExpanded` in `OnParametersSet` on 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**
```bash
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`:
```csharp
[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 `Selectable` is true and the `.tv-content` area is clicked, invoke `SelectedKeyChanged` with the node's key.
- When rendering, if a node's key equals `SelectedKey`, add `SelectedCssClass` to the `.tv-row` div.
- Add `aria-selected="true"` on the selected `<li>`.
- The `.tv-toggle` click handler must NOT propagate to selection (use `@onclick:stopPropagation` or 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**
```bash
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`:
```csharp
[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`
```javascript
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):
```html
<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)`: if `firstRender && StorageKey != null`, call `JSRuntime.InvokeAsync<string?>("treeviewStorage.load", StorageKey)`. If result is not null, deserialize JSON array of keys into `_expandedKeys` and call `StateHasChanged()`. Set a `_storageLoaded = true` flag.
- Only apply `InitiallyExpanded` when `StorageKey` is null OR storage returned null (no prior state).
- On every `ToggleExpand`, if `StorageKey != null`, serialize `_expandedKeys` to JSON and call `JSRuntime.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**
```bash
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`:
```csharp
[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 from `Items`. For each node where `HasChildrenSelector` returns true, add its key to `_expandedKeys`. Call `PersistExpandedState()` and `StateHasChanged()`.
- `CollapseAll()`: Clear `_expandedKeys`. Call `PersistExpandedState()` and `StateHasChanged()`.
- `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`. If `select` is true, set `SelectedKey` and invoke `SelectedKeyChanged`. Call `PersistExpandedState()` and `StateHasChanged()`. If key not found in the tree, return silently.
- Extract `PersistExpandedState()` private method that serializes and writes to sessionStorage if `StorageKey` is 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**
```bash
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`:
```csharp
[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-row` div, 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-item` children exist. If not, suppress `preventDefault` so browser default menu shows.
- Dismiss: add a `@onclick` handler 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 `@onclick` handler 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**
```bash
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`:
```csharp
[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**
```bash
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 `TreeNode` record and `NodeKind` enum in the `@code` block:
```csharp
record TreeNode(string Key, string Label, NodeKind Kind, List<TreeNode> Children,
DataConnection? Connection = null, int? SiteId = null);
enum NodeKind { Site, DataConnection }
```
- Add `_treeRoots` field built in `LoadDataAsync()`:
```csharp
private List<TreeNode> _treeRoots = new();
```
- In `LoadDataAsync()`, after loading sites and connections, build the tree:
```csharp
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:
```razor
<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 the `DeleteConnection` method.
**Step 3: Build and verify**
Run: `dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj`
Expected: Build succeeds.
**Step 4: Commit**
```bash
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:
```razor
<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 `_rootAreas` field computed from `_areas`:
```csharp
private List<Area> _rootAreas => _areas.Where(a => a.ParentAreaId == null).ToList();
```
- Remove: `BuildFlatTree()`, `AddChildren()`, the `AreaTreeNode` record, `_flatTree` field, 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**
```bash
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:
```csharp
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:
```razor
<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:
```csharp
private TreeView<InstanceTreeNode> _instanceTree = default!;
private object? _selectedKey;
```
**Step 5: Remove old table/pagination code**
Remove from `@code`:
- `_pagedInstances`, `_currentPage`, `_totalPages`, `PageSize`
- `GoToPage()`, `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**
```bash
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:
```bash
git add -A
git commit -m "fix(ui): resolve build/test issues from TreeView integration"
```