# 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` 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 Children); private static List 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> RenderTree( List? items = null, Action>>? configure = null) { return Render>(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 => $"{node.Label}"); 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, "Nothing here")); 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 _expandedKeys` for expand/collapse state. - Render recursively: a private `RenderNode(TItem item, int depth)` method outputting `
  • ` with toggle span (`.tv-toggle`), content area (`.tv-content`), and recursive `
      ` for children. - Each row `
      ` has `style="padding-left: {depth * IndentPx}px"`. - Branch toggle: `` 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 `
    • `, `role="group"` on child `
        `. - When collapsed, do NOT render child `
          ` 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 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 `
        • `. - 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("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("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("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 ` ``` **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("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(() => cut.Find(".dropdown-menu")); } [Fact] public void ContextMenu_RightClickShowsMenu() { var cut = RenderTree(configure: p => p.Add(x => x.ContextMenu, (TestNode node) => $"")); 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 ? $"" : "")); // Right-click Beta (leaf) — fragment renders empty var betaRow = cut.FindAll(".tv-row") .First(r => r.TextContent.Contains("Beta")); betaRow.ContextMenu(); Assert.Throws(() => 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) => $""); }); // 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? 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 `