1129 lines
39 KiB
Markdown
1129 lines
39 KiB
Markdown
# 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"
|
||
```
|