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

39 KiB
Raw Permalink Blame History

TreeView Component Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.

Goal: Build a reusable generic TreeView<TItem> Blazor component and integrate it into the Instances, Data Connections, and Areas pages.

Architecture: Generic component in Components/Shared/ with @typeparam TItem, render fragments for content/context menu, internal expand/collapse state with sessionStorage persistence via JS interop, and ARIA accessibility. Three pages refactored to use it.

Tech Stack: Blazor Server, Bootstrap 5, bUnit 2.0.33-preview, xUnit, IJSRuntime for sessionStorage.


Task 1: Create TreeView.razor — Core Rendering (R1, R2, R3, R4, R14)

Files:

  • Create: src/ScadaLink.CentralUI/Components/Shared/TreeView.razor

Step 1: Write the failing tests

Create test file with core rendering tests.

Create: tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs

using Bunit;
using ScadaLink.CentralUI.Components.Shared;

namespace ScadaLink.CentralUI.Tests;

public class TreeViewTests : BunitContext
{
    // Test model
    record TestNode(string Key, string Label, List<TestNode> Children);

    private static List<TestNode> SimpleRoots() => new()
    {
        new("a", "Alpha", new()
        {
            new("a1", "Alpha-1", new()),
            new("a2", "Alpha-2", new()
            {
                new("a2x", "Alpha-2-X", new())
            })
        }),
        new("b", "Beta", new()),
    };

    private IRenderedComponent<TreeView<TestNode>> RenderTree(
        List<TestNode>? items = null,
        Action<ComponentParameterCollectionBuilder<TreeView<TestNode>>>? configure = null)
    {
        return Render<TreeView<TestNode>>(parameters =>
        {
            parameters
                .Add(p => p.Items, items ?? SimpleRoots())
                .Add(p => p.ChildrenSelector, n => n.Children)
                .Add(p => p.HasChildrenSelector, n => n.Children.Count > 0)
                .Add(p => p.KeySelector, n => n.Key)
                .Add(p => p.NodeContent, node => $"<span>{node.Label}</span>");
            configure?.Invoke(parameters);
        });
    }

    [Fact]
    public void Renders_RootLevelItems()
    {
        var cut = RenderTree();
        var rootItems = cut.FindAll("[role='tree'] > li[role='treeitem']");
        Assert.Equal(2, rootItems.Count);
        Assert.Contains("Alpha", rootItems[0].TextContent);
        Assert.Contains("Beta", rootItems[1].TextContent);
    }

    [Fact]
    public void Renders_EmptyContent_WhenNoItems()
    {
        var cut = RenderTree(items: new(), configure: p =>
            p.Add(x => x.EmptyContent, "<span class='empty'>Nothing here</span>"));
        Assert.Contains("Nothing here", cut.Markup);
    }

    [Fact]
    public void LeafNodes_HaveNoToggle()
    {
        var cut = RenderTree();
        // Beta is a leaf (no children)
        var betaItem = cut.FindAll("li[role='treeitem']")
            .First(li => li.TextContent.Contains("Beta"));
        Assert.Empty(betaItem.QuerySelectorAll(".tv-toggle"));
    }

    [Fact]
    public void BranchNodes_ShowCollapsedToggle()
    {
        var cut = RenderTree();
        var alphaItem = cut.FindAll("li[role='treeitem']")
            .First(li => li.TextContent.Contains("Alpha"));
        var toggle = alphaItem.QuerySelector(".tv-toggle");
        Assert.NotNull(toggle);
        Assert.Equal("false", alphaItem.GetAttribute("aria-expanded"));
    }

    [Fact]
    public void CollapsedBranch_ChildrenNotInDom()
    {
        var cut = RenderTree();
        // Children of collapsed Alpha should not be in the DOM
        Assert.DoesNotContain("Alpha-1", cut.Markup);
    }

    [Fact]
    public void ClickToggle_ExpandsNode_ShowsChildren()
    {
        var cut = RenderTree();
        var toggle = cut.Find(".tv-toggle");
        toggle.Click();
        Assert.Contains("Alpha-1", cut.Markup);
        Assert.Contains("Alpha-2", cut.Markup);
    }

    [Fact]
    public void ClickToggle_CollapseExpandedNode()
    {
        var cut = RenderTree();
        var toggle = cut.Find(".tv-toggle");
        toggle.Click(); // expand
        Assert.Contains("Alpha-1", cut.Markup);
        toggle.Click(); // collapse
        Assert.DoesNotContain("Alpha-1", cut.Markup);
    }

    [Fact]
    public void DeepNesting_ExpandParentThenChild()
    {
        var cut = RenderTree();
        // Expand Alpha
        cut.Find(".tv-toggle").Click();
        // Now find Alpha-2's toggle and expand it
        var a2Item = cut.FindAll("li[role='treeitem']")
            .First(li => li.TextContent.Contains("Alpha-2"));
        a2Item.QuerySelector(".tv-toggle")!.Click();
        Assert.Contains("Alpha-2-X", cut.Markup);
    }

    [Fact]
    public void InitiallyExpanded_ExpandsMatchingNodes()
    {
        var cut = RenderTree(configure: p =>
            p.Add(x => x.InitiallyExpanded, n => n.Key == "a"));
        // Alpha should be expanded showing children
        Assert.Contains("Alpha-1", cut.Markup);
        Assert.Contains("Alpha-2", cut.Markup);
    }

    [Fact]
    public void RootUl_HasTreeRole()
    {
        var cut = RenderTree();
        Assert.NotNull(cut.Find("ul[role='tree']"));
    }

    [Fact]
    public void NodeLi_HasTreeitemRole()
    {
        var cut = RenderTree();
        var items = cut.FindAll("li[role='treeitem']");
        Assert.True(items.Count >= 2);
    }

    [Fact]
    public void ExpandedBranch_HasAriaExpandedTrue()
    {
        var cut = RenderTree(configure: p =>
            p.Add(x => x.InitiallyExpanded, n => n.Key == "a"));
        var alphaItem = cut.FindAll("li[role='treeitem']")
            .First(li => li.TextContent.Contains("Alpha"));
        Assert.Equal("true", alphaItem.GetAttribute("aria-expanded"));
    }

    [Fact]
    public void ChildGroup_HasGroupRole()
    {
        var cut = RenderTree(configure: p =>
            p.Add(x => x.InitiallyExpanded, n => n.Key == "a"));
        Assert.NotNull(cut.Find("ul[role='group']"));
    }

    [Fact]
    public void Indentation_ChildrenIndentedByIndentPx()
    {
        var cut = RenderTree(configure: p =>
        {
            p.Add(x => x.InitiallyExpanded, n => n.Key == "a");
            p.Add(x => x.IndentPx, 30);
        });
        // Alpha-1 is depth 1 → padding-left should be 30px
        var a1Row = cut.FindAll(".tv-row")
            .First(r => r.TextContent.Contains("Alpha-1"));
        Assert.Contains("padding-left: 30px", a1Row.GetAttribute("style") ?? "");
    }
}

Step 2: Run tests to verify they fail

Run: dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests" -v minimal Expected: Compilation error — TreeView component doesn't exist yet.

Step 3: Implement TreeView.razor

Create: src/ScadaLink.CentralUI/Components/Shared/TreeView.razor

The component should:

  • Accept @typeparam 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

git add src/ScadaLink.CentralUI/Components/Shared/TreeView.razor tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs
git commit -m "feat(ui): add TreeView<TItem> component with core rendering, expand/collapse, ARIA (R1-R4, R14)"

Task 2: Add Selection Support (R5)

Files:

  • Modify: src/ScadaLink.CentralUI/Components/Shared/TreeView.razor
  • Modify: tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs

Step 1: Write the failing tests

Add to TreeViewTests.cs:

[Fact]
public void Selection_Disabled_ClickDoesNotFireCallback()
{
    object? selected = null;
    var cut = RenderTree(configure: p =>
    {
        p.Add(x => x.Selectable, false);
        p.Add(x => x.SelectedKeyChanged, (object? key) => selected = key);
    });
    cut.Find(".tv-content").Click();
    Assert.Null(selected);
}

[Fact]
public void Selection_Enabled_ClickContentFiresCallback()
{
    object? selected = null;
    var cut = RenderTree(configure: p =>
    {
        p.Add(x => x.Selectable, true);
        p.Add(x => x.SelectedKeyChanged, (object? key) => selected = key);
    });
    // Click Alpha's content area
    var alphaContent = cut.FindAll(".tv-content")
        .First(c => c.TextContent.Contains("Alpha"));
    alphaContent.Click();
    Assert.Equal("a", selected);
}

[Fact]
public void Selection_ClickToggle_DoesNotChangeSelection()
{
    object? selected = null;
    var cut = RenderTree(configure: p =>
    {
        p.Add(x => x.Selectable, true);
        p.Add(x => x.SelectedKeyChanged, (object? key) => selected = key);
    });
    cut.Find(".tv-toggle").Click();
    Assert.Null(selected);
}

[Fact]
public void Selection_SelectedNode_HasCssClass()
{
    var cut = RenderTree(configure: p =>
    {
        p.Add(x => x.Selectable, true);
        p.Add(x => x.SelectedKey, (object)"a");
    });
    var alphaRow = cut.FindAll(".tv-row")
        .First(r => r.TextContent.Contains("Alpha"));
    Assert.Contains("bg-primary", alphaRow.ClassList);
}

[Fact]
public void Selection_CustomCssClass_Applied()
{
    var cut = RenderTree(configure: p =>
    {
        p.Add(x => x.Selectable, true);
        p.Add(x => x.SelectedKey, (object)"a");
        p.Add(x => x.SelectedCssClass, "my-highlight");
    });
    var alphaRow = cut.FindAll(".tv-row")
        .First(r => r.TextContent.Contains("Alpha"));
    Assert.Contains("my-highlight", alphaRow.ClassList);
}

[Fact]
public void Selection_AriaSelected_SetOnSelectedNode()
{
    var cut = RenderTree(configure: p =>
    {
        p.Add(x => x.Selectable, true);
        p.Add(x => x.SelectedKey, (object)"a");
    });
    var alphaItem = cut.FindAll("li[role='treeitem']")
        .First(li => li.TextContent.Contains("Alpha"));
    Assert.Equal("true", alphaItem.GetAttribute("aria-selected"));
}

Step 2: Run tests to verify they fail

Run: dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests.Selection" -v minimal Expected: FAIL

Step 3: Implement selection in TreeView.razor

Add to the component:

  • When 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

git add src/ScadaLink.CentralUI/Components/Shared/TreeView.razor tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs
git commit -m "feat(ui): add selection support to TreeView (R5)"

Task 3: Add Session Storage Persistence (R11)

Files:

  • Create: src/ScadaLink.CentralUI/wwwroot/js/treeview-storage.js
  • Modify: src/ScadaLink.CentralUI/Components/Shared/TreeView.razor
  • Modify: tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs

Step 1: Write the failing tests

Add to TreeViewTests.cs:

[Fact]
public void SessionStorage_Null_NoJsInteropCalls()
{
    var cut = RenderTree(configure: p =>
        p.Add(x => x.StorageKey, (string?)null));
    // Expand a node
    cut.Find(".tv-toggle").Click();
    // No JS interop should have been invoked
    Assert.Empty(JSInterop.Invocations);
}

[Fact]
public void SessionStorage_Set_ExpandWritesToStorage()
{
    JSInterop.SetupVoid("treeviewStorage.save", _ => true);
    JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
    var cut = RenderTree(configure: p =>
        p.Add(x => x.StorageKey, "test-tree"));
    // Expand Alpha
    cut.Find(".tv-toggle").Click();
    var saveInvocations = JSInterop.Invocations
        .Where(i => i.Identifier == "treeviewStorage.save");
    Assert.NotEmpty(saveInvocations);
}

[Fact]
public void SessionStorage_RestoresExpandedOnMount()
{
    // Pre-populate storage with Alpha expanded
    JSInterop.Setup<string?>("treeviewStorage.load", _ => true)
        .SetResult("[\"a\"]");
    JSInterop.SetupVoid("treeviewStorage.save", _ => true);
    var cut = RenderTree(configure: p =>
        p.Add(x => x.StorageKey, "test-tree"));
    // Alpha should be expanded from storage
    Assert.Contains("Alpha-1", cut.Markup);
}

[Fact]
public void SessionStorage_TakesPrecedenceOverInitiallyExpanded()
{
    // Storage says nothing expanded, but InitiallyExpanded says expand Alpha
    JSInterop.Setup<string?>("treeviewStorage.load", _ => true)
        .SetResult("[]");
    JSInterop.SetupVoid("treeviewStorage.save", _ => true);
    var cut = RenderTree(configure: p =>
    {
        p.Add(x => x.StorageKey, "test-tree");
        p.Add(x => x.InitiallyExpanded, n => n.Key == "a");
    });
    // Storage (empty) takes precedence — Alpha should NOT be expanded
    Assert.DoesNotContain("Alpha-1", cut.Markup);
}

Step 2: Run tests to verify they fail

Run: dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests.SessionStorage" -v minimal Expected: FAIL

Step 3: Create JS interop file

Create: src/ScadaLink.CentralUI/wwwroot/js/treeview-storage.js

window.treeviewStorage = {
    save: function (storageKey, keysJson) {
        sessionStorage.setItem("treeview:" + storageKey, keysJson);
    },
    load: function (storageKey) {
        return sessionStorage.getItem("treeview:" + storageKey);
    }
};

Add a <script> reference in the app's _Host.cshtml or App.razor layout (wherever other scripts are included):

<script src="js/treeview-storage.js"></script>

Step 4: Implement persistence in TreeView.razor

  • Inject [Inject] private IJSRuntime JSRuntime { get; set; } = default!;
  • In OnAfterRenderAsync(bool firstRender): 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

git add src/ScadaLink.CentralUI/wwwroot/js/treeview-storage.js src/ScadaLink.CentralUI/Components/Shared/TreeView.razor tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs
git commit -m "feat(ui): add sessionStorage persistence for TreeView expansion state (R11)"

Task 4: Add ExpandAll, CollapseAll, RevealNode (R12, R13)

Files:

  • Modify: src/ScadaLink.CentralUI/Components/Shared/TreeView.razor
  • Modify: tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs

Step 1: Write the failing tests

Add to TreeViewTests.cs:

[Fact]
public void ExpandAll_ExpandsAllBranches()
{
    var cut = RenderTree();
    cut.Instance.ExpandAll();
    cut.Render();
    // All nested content should be visible
    Assert.Contains("Alpha-1", cut.Markup);
    Assert.Contains("Alpha-2", cut.Markup);
    Assert.Contains("Alpha-2-X", cut.Markup);
}

[Fact]
public void CollapseAll_CollapsesAllBranches()
{
    var cut = RenderTree(configure: p =>
        p.Add(x => x.InitiallyExpanded, _ => true));
    Assert.Contains("Alpha-1", cut.Markup);
    cut.Instance.CollapseAll();
    cut.Render();
    Assert.DoesNotContain("Alpha-1", cut.Markup);
}

[Fact]
public void RevealNode_ExpandsAncestors()
{
    var cut = RenderTree();
    // Alpha-2-X is at depth 2 — both Alpha and Alpha-2 need expanding
    cut.Instance.RevealNode("a2x");
    cut.Render();
    Assert.Contains("Alpha-2-X", cut.Markup);
    // Intermediate nodes should also be visible
    Assert.Contains("Alpha-2", cut.Markup);
}

[Fact]
public void RevealNode_WithSelect_SelectsNode()
{
    object? selected = null;
    var cut = RenderTree(configure: p =>
    {
        p.Add(x => x.Selectable, true);
        p.Add(x => x.SelectedKeyChanged, (object? key) => selected = key);
    });
    cut.Instance.RevealNode("a2x", select: true);
    cut.Render();
    Assert.Equal("a2x", selected);
}

[Fact]
public void RevealNode_UnknownKey_NoOp()
{
    var cut = RenderTree();
    // Should not throw
    cut.Instance.RevealNode("nonexistent");
    cut.Render();
    // Alpha still collapsed
    Assert.DoesNotContain("Alpha-1", cut.Markup);
}

Step 2: Run tests to verify they fail

Run: dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests.ExpandAll|CollapseAll|RevealNode" -v minimal Expected: FAIL (methods don't exist)

Step 3: Implement the public methods

In TreeView.razor:

  • ExpandAll(): Walk the entire tree recursively starting 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

git add src/ScadaLink.CentralUI/Components/Shared/TreeView.razor tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs
git commit -m "feat(ui): add ExpandAll, CollapseAll, RevealNode to TreeView (R12, R13)"

Task 5: Add Context Menu (R15)

Files:

  • Modify: src/ScadaLink.CentralUI/Components/Shared/TreeView.razor
  • Modify: tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs

Step 1: Write the failing tests

Add to TreeViewTests.cs:

[Fact]
public void ContextMenu_Null_NoMenuRendered()
{
    var cut = RenderTree();
    // Right-click Alpha — no context menu should appear
    var alphaRow = cut.FindAll(".tv-row")
        .First(r => r.TextContent.Contains("Alpha"));
    alphaRow.ContextMenu();
    Assert.Throws<Bunit.ElementNotFoundException>(() => cut.Find(".dropdown-menu"));
}

[Fact]
public void ContextMenu_RightClickShowsMenu()
{
    var cut = RenderTree(configure: p =>
        p.Add(x => x.ContextMenu, (TestNode node) =>
            $"<button class='dropdown-item'>Action for {node.Label}</button>"));
    var alphaRow = cut.FindAll(".tv-row")
        .First(r => r.TextContent.Contains("Alpha"));
    alphaRow.ContextMenu();
    var menu = cut.Find(".dropdown-menu");
    Assert.Contains("Action for Alpha", menu.TextContent);
}

[Fact]
public void ContextMenu_EmptyFragment_NoMenu()
{
    // ContextMenu renders nothing for leaves
    var cut = RenderTree(configure: p =>
        p.Add(x => x.ContextMenu, (TestNode node) =>
            node.Children.Count > 0 ? $"<button class='dropdown-item'>Edit</button>" : ""));
    // Right-click Beta (leaf) — fragment renders empty
    var betaRow = cut.FindAll(".tv-row")
        .First(r => r.TextContent.Contains("Beta"));
    betaRow.ContextMenu();
    Assert.Throws<Bunit.ElementNotFoundException>(() => cut.Find(".dropdown-menu"));
}

[Fact]
public void ContextMenu_RightClickDifferentNode_ReplacesMenu()
{
    var cut = RenderTree(configure: p =>
    {
        p.Add(x => x.InitiallyExpanded, n => n.Key == "a");
        p.Add(x => x.ContextMenu, (TestNode node) =>
            $"<button class='dropdown-item'>{node.Label}</button>");
    });
    // Right-click Alpha
    cut.FindAll(".tv-row").First(r => r.TextContent.Contains("Alpha")).ContextMenu();
    Assert.Contains("Alpha", cut.Find(".dropdown-menu").TextContent);
    // Right-click Alpha-1
    cut.FindAll(".tv-row").First(r => r.TextContent.Contains("Alpha-1")).ContextMenu();
    var menus = cut.FindAll(".dropdown-menu");
    Assert.Single(menus);
    Assert.Contains("Alpha-1", menus[0].TextContent);
}

Step 2: Run tests to verify they fail

Run: dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests.ContextMenu" -v minimal Expected: FAIL

Step 3: Implement context menu

In TreeView.razor:

  • Add [Parameter] public RenderFragment<TItem>? ContextMenu { get; set; }
  • Add state: private TItem? _contextMenuItem, private double _contextMenuX, private double _contextMenuY, private bool _showContextMenu
  • On the .tv-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

git add src/ScadaLink.CentralUI/Components/Shared/TreeView.razor tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs
git commit -m "feat(ui): add right-click context menu to TreeView (R15)"

Task 6: Add External Filtering Tests (R8)

Files:

  • Modify: tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs

This task adds tests for filtering behavior that should already work from the core implementation (the component just renders whatever Items it receives). These tests verify that expansion state is preserved across re-renders with different items.

Step 1: Write the tests

Add to TreeViewTests.cs:

[Fact]
public void Filtering_ReducedItems_HidesRemovedRoots()
{
    var cut = RenderTree();
    Assert.Contains("Alpha", cut.Markup);
    Assert.Contains("Beta", cut.Markup);
    // Re-render with only Alpha
    cut.SetParametersAndRender(p =>
        p.Add(x => x.Items, SimpleRoots().Where(n => n.Key == "a").ToList()));
    Assert.Contains("Alpha", cut.Markup);
    Assert.DoesNotContain("Beta", cut.Markup);
}

[Fact]
public void Filtering_ExpansionStatePreserved()
{
    var cut = RenderTree();
    // Expand Alpha
    cut.Find(".tv-toggle").Click();
    Assert.Contains("Alpha-1", cut.Markup);
    // Filter to only Alpha
    cut.SetParametersAndRender(p =>
        p.Add(x => x.Items, SimpleRoots().Where(n => n.Key == "a").ToList()));
    // Alpha should still be expanded
    Assert.Contains("Alpha-1", cut.Markup);
    // Restore full list — Alpha still expanded
    cut.SetParametersAndRender(p =>
        p.Add(x => x.Items, SimpleRoots()));
    Assert.Contains("Alpha-1", cut.Markup);
}

[Fact]
public void Filtering_SelectionCleared_WhenNodeDisappears()
{
    object? selected = "b"; // Beta selected
    var cut = RenderTree(configure: p =>
    {
        p.Add(x => x.Selectable, true);
        p.Add(x => x.SelectedKey, (object)"b");
        p.Add(x => x.SelectedKeyChanged, (object? key) => selected = key);
    });
    // Filter to only Alpha — Beta disappears
    cut.SetParametersAndRender(p =>
    {
        p.Add(x => x.Items, SimpleRoots().Where(n => n.Key == "a").ToList());
        p.Add(x => x.Selectable, true);
        p.Add(x => x.SelectedKey, (object)"b");
        p.Add(x => x.SelectedKeyChanged, (object? key) => selected = key);
    });
    // Selection should be cleared
    Assert.Null(selected);
}

Step 2: Run tests

Run: dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests.Filtering" -v minimal Expected: PASS (behavior comes from core implementation). If any fail, fix in TreeView.razor.

Step 3: Commit

git add tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs
git commit -m "test(ui): add external filtering tests for TreeView (R8)"

Task 7: Integrate TreeView into Data Connections Page

Files:

  • Modify: src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor

This is the simplest integration (two-level tree, no recursion).

Step 1: Read the current file

Read: src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor

Step 2: Implement the changes

The page currently has a flat table. Replace it with a TreeView:

  • Add a TreeNode record and NodeKind enum in the @code block:

    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():

    private List<TreeNode> _treeRoots = new();
    
  • In LoadDataAsync(), after loading sites and connections, build the tree:

    var connBySite = _connections.GroupBy(c => c.SiteId).ToDictionary(g => g.Key, g => g.ToList());
    _treeRoots = _sites.Select(site => new TreeNode(
        Key: $"site-{site.Id}",
        Label: site.Name,
        Kind: NodeKind.Site,
        Children: (connBySite.GetValueOrDefault(site.Id) ?? new())
            .Select(c => new TreeNode(
                Key: $"conn-{c.Id}",
                Label: c.Name,
                Kind: NodeKind.DataConnection,
                Children: new(),
                Connection: c))
            .ToList(),
        SiteId: site.Id
    )).ToList();
    
  • Replace the <table> with:

    <TreeView TItem="TreeNode" Items="_treeRoots"
              ChildrenSelector="n => n.Children"
              HasChildrenSelector="n => n.Children.Count > 0"
              KeySelector="n => n.Key"
              StorageKey="data-connections-tree">
        <NodeContent Context="node">
            @if (node.Kind == NodeKind.Site)
            {
                <span class="fw-semibold">@node.Label</span>
                <span class="badge bg-secondary ms-1">@node.Children.Count</span>
            }
            else
            {
                <span>@node.Label</span>
                <span class="badge bg-info ms-2">@node.Connection!.Protocol</span>
            }
        </NodeContent>
        <ContextMenu Context="node">
            @if (node.Kind == NodeKind.DataConnection)
            {
                <button class="dropdown-item"
                        @onclick='() => NavigationManager.NavigateTo($"/admin/data-connections/{node.Connection!.Id}/edit")'>
                    Edit
                </button>
                <div class="dropdown-divider"></div>
                <button class="dropdown-item text-danger"
                        @onclick="() => DeleteConnection(node.Connection!)">
                    Delete
                </button>
            }
        </ContextMenu>
        <EmptyContent>
            <span class="text-muted fst-italic">No data connections configured.</span>
        </EmptyContent>
    </TreeView>
    
  • Remove the old <table> / <thead> / <tbody> markup and inline Edit/Delete buttons.

  • Keep _toast, _confirmDialog, the "Create" button header, loading/error handling, and the DeleteConnection method.

Step 3: Build and verify

Run: dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj Expected: Build succeeds.

Step 4: Commit

git add src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor
git commit -m "refactor(ui): replace data connections table with TreeView grouped by site"

Task 8: Integrate TreeView into Areas Page

Files:

  • Modify: src/ScadaLink.CentralUI/Components/Pages/Admin/Areas.razor

Step 1: Read the current file

Read: src/ScadaLink.CentralUI/Components/Pages/Admin/Areas.razor

Step 2: Implement the changes

The Areas page has a two-panel layout. Keep the left site list panel. Replace the right panel's custom flat-tree rendering with a TreeView using the Area entity directly as TItem.

  • Replace the manual tree rendering block (the @foreach (var node in _flatTree) loop with indentation) with:

    <TreeView TItem="Area" Items="_rootAreas"
              ChildrenSelector="a => a.Children.ToList()"
              HasChildrenSelector="a => a.Children.Any()"
              KeySelector="a => (object)a.Id"
              StorageKey="areas-tree">
        <NodeContent Context="area">
            <span>@area.Name</span>
        </NodeContent>
        <ContextMenu Context="area">
            <button class="dropdown-item" @onclick="() => EditArea(area)">
                Edit
            </button>
            <div class="dropdown-divider"></div>
            <button class="dropdown-item text-danger" @onclick="() => DeleteArea(area)">
                Delete
            </button>
        </ContextMenu>
        <EmptyContent>
            <span class="text-muted fst-italic">No areas for this site. Add one above.</span>
        </EmptyContent>
    </TreeView>
    
  • Add _rootAreas field computed from _areas:

    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

git add src/ScadaLink.CentralUI/Components/Pages/Admin/Areas.razor
git commit -m "refactor(ui): replace manual area tree rendering with TreeView component"

Task 9: Integrate TreeView into Instances Page

Files:

  • Modify: src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor

This is the most complex integration — deep hierarchy with filtering and context menu.

Step 1: Read the current file

Read: src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor

Step 2: Add tree model and builder

Add to @code block:

record InstanceTreeNode(string Key, string Label, InstanceNodeKind Kind,
    List<InstanceTreeNode> Children, Instance? Instance = null,
    bool IsStale = false);
enum InstanceNodeKind { Site, Area, Instance }

private List<InstanceTreeNode> _treeRoots = new();

private void BuildTree()
{
    // Build the hierarchy: Site → Area (recursive) → Instance
    _treeRoots = _sites.Select(site =>
    {
        var siteAreas = _allAreas.Where(a => a.SiteId == site.Id).ToList();
        var siteInstances = _filteredInstances.Where(i => i.SiteId == site.Id).ToList();

        var areaNodes = BuildAreaNodes(siteAreas, siteInstances, parentId: null);

        // Instances with no area attach directly under the site
        var unassigned = siteInstances
            .Where(i => i.AreaId == null)
            .Select(MakeInstanceNode)
            .ToList();

        var children = areaNodes.Concat(unassigned).ToList();

        return new InstanceTreeNode(
            Key: $"site-{site.Id}",
            Label: site.Name,
            Kind: InstanceNodeKind.Site,
            Children: children);
    })
    .Where(s => s.Children.Count > 0) // Prune empty sites
    .ToList();
}

private List<InstanceTreeNode> BuildAreaNodes(List<Area> allAreas,
    List<Instance> instances, int? parentId)
{
    return allAreas
        .Where(a => a.ParentAreaId == parentId)
        .Select(area =>
        {
            var childAreas = BuildAreaNodes(allAreas, instances, area.Id);
            var areaInstances = instances
                .Where(i => i.AreaId == area.Id)
                .Select(MakeInstanceNode)
                .ToList();
            var children = childAreas.Concat(areaInstances).ToList();
            return new InstanceTreeNode(
                Key: $"area-{area.Id}",
                Label: area.Name,
                Kind: InstanceNodeKind.Area,
                Children: children);
        })
        .Where(a => a.Children.Count > 0) // Prune empty areas
        .ToList();
}

private InstanceTreeNode MakeInstanceNode(Instance inst) => new(
    Key: $"inst-{inst.Id}",
    Label: inst.UniqueName,
    Kind: InstanceNodeKind.Instance,
    Children: new(),
    Instance: inst,
    IsStale: _stalenessMap.GetValueOrDefault(inst.Id));

Step 3: Update ApplyFilters to build tree

In ApplyFilters(), after the existing filtering logic, replace UpdatePage() with BuildTree(). Remove _totalPages, _currentPage, _pagedInstances, GoToPage(), UpdatePage(), and PageSize.

Step 4: Replace table with TreeView

Replace the <table> and pagination markup with:

<TreeView @ref="_instanceTree" TItem="InstanceTreeNode" Items="_treeRoots"
          ChildrenSelector="n => n.Children"
          HasChildrenSelector="n => n.Children.Count > 0"
          KeySelector="n => n.Key"
          StorageKey="instances-tree"
          Selectable="true"
          SelectedKey="_selectedKey"
          SelectedKeyChanged="key => { _selectedKey = key; }">
    <NodeContent Context="node">
        @switch (node.Kind)
        {
            case InstanceNodeKind.Site:
                <span class="fw-semibold">@node.Label</span>
                break;
            case InstanceNodeKind.Area:
                <span class="text-secondary">@node.Label</span>
                break;
            case InstanceNodeKind.Instance:
                <span>@node.Label</span>
                <span class="badge @GetStateBadge(node.Instance!.State) ms-1">@node.Instance!.State</span>
                @if (node.Instance!.State != InstanceState.NotDeployed)
                {
                    <span class="badge @(node.IsStale ? "bg-warning text-dark" : "bg-light text-dark") ms-1">
                        @(node.IsStale ? "Stale" : "Current")
                    </span>
                }
                break;
        }
    </NodeContent>
    <ContextMenu Context="node">
        @if (node.Kind == InstanceNodeKind.Instance)
        {
            var inst = node.Instance!;
            var isStale = node.IsStale;
            <button class="dropdown-item" @onclick="() => DeployInstance(inst)"
                    disabled="@_actionInProgress">@(isStale ? "Redeploy" : "Deploy")</button>
            @if (inst.State == InstanceState.Enabled)
            {
                <button class="dropdown-item" @onclick="() => DisableInstance(inst)"
                        disabled="@_actionInProgress">Disable</button>
            }
            else if (inst.State == InstanceState.Disabled)
            {
                <button class="dropdown-item" @onclick="() => EnableInstance(inst)"
                        disabled="@_actionInProgress">Enable</button>
            }
            <button class="dropdown-item"
                    @onclick='() => NavigationManager.NavigateTo($"/deployment/instances/{inst.Id}/configure")'>
                Configure
            </button>
            <button class="dropdown-item" @onclick="() => ShowDiff(inst)"
                    disabled="@(_actionInProgress || inst.State == InstanceState.NotDeployed)">Diff</button>
            <div class="dropdown-divider"></div>
            <button class="dropdown-item text-danger" @onclick="() => DeleteInstance(inst)"
                    disabled="@_actionInProgress">Delete</button>
        }
    </ContextMenu>
    <EmptyContent>
        <span class="text-muted fst-italic">No instances match the current filters.</span>
    </EmptyContent>
</TreeView>

<div class="text-muted small mt-2">
    @_filteredInstances.Count instance(s) total
</div>

Add fields:

private TreeView<InstanceTreeNode> _instanceTree = default!;
private object? _selectedKey;

Step 5: Remove old table/pagination code

Remove from @code:

  • _pagedInstances, _currentPage, _totalPages, 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

git add src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor
git commit -m "refactor(ui): replace instances table with hierarchical TreeView (Site → Area → Instance)"

Task 10: Full Build Verification

Files:

  • No changes — verification only.

Step 1: Build entire solution

Run: dotnet build ScadaLink.slnx Expected: Build succeeds with 0 errors.

Step 2: Run all tests

Run: dotnet test ScadaLink.slnx -v minimal Expected: All tests pass (including the new TreeView tests and existing tests).

Step 3: Run TreeView tests specifically

Run: dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests" -v minimal Expected: All TreeView tests pass.

Step 4: Commit (if any fixes needed)

Only if build/test failures required fixes:

git add -A
git commit -m "fix(ui): resolve build/test issues from TreeView integration"