Files
scadalink-design/docs/requirements/Component-TreeView.md

29 KiB
Raw Permalink Blame History

TreeView Component

Purpose

A reusable, generic Blazor Server component that renders hierarchical data as an expandable/collapsible tree. The component is data-agnostic — it accepts any tree-shaped data via type parameters and render fragments, following the same pattern as the existing DataTable<TItem> shared component.

Location

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

Primary Use Case: Instance Hierarchy

The motivating use case is displaying instances organized by site and area:

- Site A
  + Area 1
    - Sub Area 1
        Instance 1
        Instance 2
  + Area 2
+ Site B
+ Site C

Hierarchy: Site → Area → Sub Area (recursive) → Instance (leaf)

Nodes at each level may be expandable (branches) or plain items (leaves). Leaf nodes have no expand/collapse toggle.

Requirements

R1 — Generic Type Parameter

The component accepts a single type parameter TItem representing any node in the tree. The consumer provides:

Parameter Type Required Description
Items IReadOnlyList<TItem> Yes Root-level items
ChildrenSelector Func<TItem, IReadOnlyList<TItem>> Yes Returns children for a given node
HasChildrenSelector Func<TItem, bool> Yes Whether the node can be expanded (branch vs. leaf)
KeySelector Func<TItem, object> Yes Unique key per node (for state tracking)

R2 — Render Fragments

Parameter Type Required Description
NodeContent RenderFragment<TItem> Yes Renders the label/content for each node
EmptyContent RenderFragment? No Shown when Items is empty

The NodeContent fragment receives the TItem and is responsible for rendering the node's display (text, icons, badges, action buttons, etc.). The tree component only renders the structural chrome (indentation, expand/collapse toggle, vertical guide lines).

R3 — Expand/Collapse Behavior

  • Each branch node displays a toggle indicator: + when collapsed, when expanded.
  • Clicking the toggle icon expands/collapses the node. Clicking the content area does not toggle expansion (it is reserved for selection — see R5).
  • Leaf nodes (where HasChildrenSelector returns false) display no toggle — they are indented inline with sibling branch nodes.
  • Expand/collapse state is tracked internally by the component using KeySelector for identity.
  • All nodes start collapsed by default unless InitiallyExpanded is set.
  • Session persistence: When the user navigates away and returns, previously expanded nodes are restored (see R11).
Parameter Type Required Description
InitiallyExpanded Func<TItem, bool>? No Predicate — nodes matching this start expanded (first load only, before any persisted state exists)

R4 — Indentation and Visual Structure

  • Each depth level is indented by a fixed amount (default 24px, configurable via IndentPx parameter).
  • Vertical guide lines connect parent to children at each depth level (thin left-border or CSS pseudo-element).
  • The toggle icon is inline with the node content, left-aligned at the current depth.
  • Leaf nodes align with sibling branch labels (the content starts at the same horizontal position, with empty space where the toggle would be).
Parameter Type Required Description
IndentPx int No Pixels per indent level. Default: 24
ShowGuideLines bool No Show vertical connector lines. Default: true

R5 — Selection

Parameter Type Required Description
Selectable bool No Enable click-to-select. Default: false
SelectedKey object? No Currently selected node key (two-way binding)
SelectedKeyChanged EventCallback<object?> No Fires when selection changes
SelectedCssClass string No CSS class for selected node. Default: "bg-primary bg-opacity-10"

When Selectable is true, clicking a node row selects it (highlighted). Clicking the expand/collapse toggle does not change selection — only clicking the content area does.

R6 — Lazy Loading (Deferred)

Future enhancement. For now, all children are provided synchronously via ChildrenSelector. A future version may support Func<TItem, Task<IReadOnlyList<TItem>>> for on-demand loading with a spinner placeholder.

R7 — Keyboard Navigation (Deferred)

Future enhancement. Arrow keys for navigation, Enter/Space for expand/collapse, Home/End for first/last.

R8 — External Filtering

The tree component itself does not implement filter UI. Filtering is driven externally by the consuming page — for example, a site dropdown that filters the tree to show only the selected site's hierarchy.

How it works:

  • The consumer filters Items (and/or adjusts ChildrenSelector results) and passes the filtered list to the component.
  • When Items changes (Blazor re-render), the component re-renders the tree with the new data.
  • Expansion state is preserved across filter changes. Nodes that were expanded before filtering remain expanded if they reappear after the filter changes. The component tracks expanded keys independently of the current Items — keys are never purged when items disappear, so re-adding a previously expanded node restores its expanded state.
  • Selection is cleared if the selected node is no longer present after filtering.

Example — site filter on the instances page:

<select class="form-select form-select-sm" @bind="_selectedSiteId">
    <option value="">All Sites</option>
    @foreach (var site in _sites)
    {
        <option value="@site.Id">@site.Name</option>
    }
</select>

<TreeView TItem="TreeNode" Items="GetFilteredRoots()" ...>
    ...
</TreeView>

@code {
    private int? _selectedSiteId;

    private List<TreeNode> GetFilteredRoots()
    {
        if (_selectedSiteId == null) return _allRoots;
        return _allRoots.Where(r => r.SiteId == _selectedSiteId).ToList();
    }
}

This keeps filter logic in the page (domain-specific) while the component handles rendering whatever it receives.

R9 — Styling

  • Uses Bootstrap 5 utility classes only (no third-party frameworks).
  • No hardcoded colors — uses standard Bootstrap text/background utilities.
  • Toggle icons: Unicode characters (+ / ) in a <span> with cursor: pointer, or a small SVG chevron. No icon library dependency.
  • Compact row height for dense data (matching table-sm density).
  • Hover effect on rows: subtle background highlight (bg-light or similar).
  • CSS scoped to the component via Blazor CSS isolation (TreeView.razor.css).

R10 — No Internal Scrolling

The tree renders inline in the page flow. The consuming page is responsible for placing it in a scrollable container if needed (e.g., overflow-auto with max-height).

R11 — Session-Persistent Expansion State

When a user expands nodes, navigates away (e.g., clicks an instance link to the configure page), and returns to the page, the tree must restore the same expansion state.

Mechanism:

  • The component requires a StorageKey parameter — a unique string identifying this tree instance (e.g., "instances-tree", "data-connections-tree").
  • Expanded node keys are stored in browser sessionStorage under the key treeview:{StorageKey}.
  • On mount (OnAfterRenderAsync first render), the component reads sessionStorage and expands any nodes whose keys are present. This takes precedence over InitiallyExpanded.
  • On every expand/collapse toggle, the component writes the updated set of expanded keys to sessionStorage.
  • sessionStorage is scoped to the browser tab — each tab has independent state. State is cleared when the tab is closed.

Implementation note: Blazor Server requires IJSRuntime to access sessionStorage. The component injects IJSRuntime and uses a small JS interop helper (inline or in a shared .js file) for getItem/setItem.

Parameter Type Required Description
StorageKey string? No Key for sessionStorage persistence. If null, expansion state is not persisted (in-memory only).

R12 — Expand All / Collapse All

The component exposes methods that the consumer can call via @ref:

/// Expands all branch nodes in the tree (recursive).
public void ExpandAll();

/// Collapses all branch nodes in the tree.
public void CollapseAll();

Usage:

<button class="btn btn-outline-secondary btn-sm" @onclick="() => _tree.ExpandAll()">Expand All</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _tree.CollapseAll()">Collapse All</button>

<TreeView @ref="_tree" TItem="TreeNode" ... />

@code {
    private TreeView<TreeNode> _tree = default!;
}

Both methods update sessionStorage if StorageKey is set. ExpandAll requires walking the full tree via ChildrenSelector to collect all branch node keys.

R13 — Programmatic Expand-to-Node

The component exposes a method to reveal a specific node by expanding all of its ancestors:

/// Expands all ancestor nodes so that the node with the given key becomes visible.
/// Optionally selects the node and scrolls it into view.
public void RevealNode(object key, bool select = false);

This requires the component to build a parent lookup (key → parent key) from the tree data. When called:

  1. Walk from the target node's key up to the root, collecting ancestor keys.
  2. Expand all ancestors.
  3. If select is true, set the node as selected and fire SelectedKeyChanged.
  4. After rendering, scroll the node element into view via JS interop (element.scrollIntoView({ block: 'nearest' })).

Use case: Search box on the instances page — user types "Motor-1", results list shows matching instances. Clicking a result calls _tree.RevealNode(instanceKey, select: true) to expand the Site → Area path and highlight the instance.

R14 — Accessibility (ARIA)

The component renders semantic ARIA attributes for screen reader support:

  • The root <ul> has role="tree".
  • Each node <li> has role="treeitem".
  • Branch nodes have aria-expanded="true" or aria-expanded="false".
  • Child <ul> containers have role="group".
  • When Selectable is true, the selected node has aria-selected="true".
  • Each node row has a unique id derived from KeySelector for anchor targeting.

This is baseline accessibility — no keyboard navigation yet (deferred in R7), but screen readers can understand the tree structure.

R15 — Context Menu

The component supports an optional right-click context menu on nodes, defined by the consumer via a render fragment.

Parameter Type Required Description
ContextMenu RenderFragment<TItem>? No Menu content rendered when a node is right-clicked. Receives the right-clicked TItem.

Behavior:

  • Right-clicking a node renders the ContextMenu fragment for that node. The component checks whether the fragment produces any content — if the fragment renders nothing (empty markup), no menu is shown and the browser default context menu is used. This is how per-node-type menus work: the consumer uses @if blocks in the fragment, and nodes that don't match any condition simply produce no output.
  • When content is produced, the browser's default context menu is suppressed (@oncontextmenu:preventDefault) and a floating menu is shown at the cursor.
  • The menu is rendered as a Bootstrap dropdown: <div class="dropdown-menu show"> containing <button class="dropdown-item"> elements.
  • Clicking a menu item or clicking anywhere outside the menu dismisses it.
  • Pressing Escape dismisses the menu.
  • Only one context menu is visible at a time — right-clicking another node replaces the current menu.
  • If the ContextMenu parameter itself is null (not provided), right-click always uses the browser default for all nodes.

The consumer controls which items appear and what they do:

<TreeView TItem="TreeNode" Items="_roots" ... >
    <NodeContent Context="node">
        <span>@node.Label</span>
    </NodeContent>
    <ContextMenu Context="node">
        @if (node.Kind == NodeKind.Instance)
        {
            <button class="dropdown-item" @onclick="() => DeployInstance(node)">
                Deploy
            </button>
            @if (node.State == InstanceState.Enabled)
            {
                <button class="dropdown-item" @onclick="() => DisableInstance(node)">
                    Disable
                </button>
            }
            else if (node.State == InstanceState.Disabled)
            {
                <button class="dropdown-item" @onclick="() => EnableInstance(node)">
                    Enable
                </button>
            }
            <button class="dropdown-item" @onclick="() => NavigateToConfigure(node)">
                Configure
            </button>
            <button class="dropdown-item" @onclick="() => ShowDiff(node)">
                Diff
            </button>
            <div class="dropdown-divider"></div>
            <button class="dropdown-item text-danger" @onclick="() => DeleteInstance(node)">
                Delete
            </button>
        }
        else if (node.Kind == NodeKind.Site)
        {
            <button class="dropdown-item" @onclick="() => DeployAllInSite(node)">
                Deploy All
            </button>
        }
    </ContextMenu>
</TreeView>

This keeps the tree clean — no inline action buttons cluttering leaf nodes. Different node types can show different menu items (instances get full CRUD actions, sites might get bulk operations, areas might have no menu at all).

Positioning:

  • The menu is absolutely positioned relative to the viewport using the mouse event's clientX/clientY.
  • If the menu would overflow the viewport bottom or right edge, it flips direction (opens upward or leftward).
  • The component handles positioning internally — no JS interop needed (CSS position: fixed with top/left from the mouse event).

R16 — Multi-Selection (Deferred)

Future enhancement. Single selection (R5) covers current needs. A future version may add:

  • MultiSelect bool parameter
  • SelectedKeys / SelectedKeysChanged for set-based selection
  • Shift+click for range select, Ctrl+click for toggle
  • Use case: bulk operations (select multiple instances → deploy/disable all)

Component API Summary

@typeparam TItem

// Data
[Parameter] public IReadOnlyList<TItem> Items { get; set; }
[Parameter] public Func<TItem, IReadOnlyList<TItem>> ChildrenSelector { get; set; }
[Parameter] public Func<TItem, bool> HasChildrenSelector { get; set; }
[Parameter] public Func<TItem, object> KeySelector { get; set; }

// Rendering
[Parameter] public RenderFragment<TItem> NodeContent { get; set; }
[Parameter] public RenderFragment? EmptyContent { get; set; }
[Parameter] public RenderFragment<TItem>? ContextMenu { get; set; }

// Layout
[Parameter] public int IndentPx { get; set; } = 24;
[Parameter] public bool ShowGuideLines { get; set; } = true;

// Expand/Collapse
[Parameter] public Func<TItem, bool>? InitiallyExpanded { get; set; }
[Parameter] public string? StorageKey { get; set; }  // sessionStorage persistence key

// Selection
[Parameter] public bool Selectable { get; set; }
[Parameter] public object? SelectedKey { get; set; }
[Parameter] public EventCallback<object?> SelectedKeyChanged { get; set; }
[Parameter] public string SelectedCssClass { get; set; } = "bg-primary bg-opacity-10";

// Public methods (called via @ref)
public void ExpandAll();
public void CollapseAll();
public void RevealNode(object key, bool select = false);

Usage Example: Instance Hierarchy

@* Build a unified tree model from sites, areas, and instances *@

<TreeView TItem="TreeNode" Items="_roots"
          ChildrenSelector="n => n.Children"
          HasChildrenSelector="n => n.Children.Count > 0"
          KeySelector="n => n.Key"
          Selectable="true"
          SelectedKey="_selectedKey"
          SelectedKeyChanged="key => { _selectedKey = key; StateHasChanged(); }">
    <NodeContent Context="node">
        @switch (node.Kind)
        {
            case NodeKind.Site:
                <span class="fw-semibold">@node.Label</span>
                break;
            case NodeKind.Area:
                <span class="text-secondary">@node.Label</span>
                break;
            case NodeKind.Instance:
                <span>@node.Label</span>
                <span class="badge bg-success ms-2">Enabled</span>
                break;
        }
    </NodeContent>
    <EmptyContent>
        <span class="text-muted fst-italic">No items to display.</span>
    </EmptyContent>
</TreeView>

@code {
    private object? _selectedKey;
    private List<TreeNode> _roots = new();

    record TreeNode(string Key, string Label, NodeKind Kind, List<TreeNode> Children);
    enum NodeKind { Site, Area, Instance }
}

Usage Example: Data Connections by Site

A simpler two-level tree — Site → Data Connections (leaves):

- Site A
    Data Connection 1
    Data Connection 2
+ Site B
+ Site C
<TreeView TItem="TreeNode" Items="_roots"
          ChildrenSelector="n => n.Children"
          HasChildrenSelector="n => n.Children.Count > 0"
          KeySelector="n => n.Key">
    <NodeContent Context="node">
        @if (node.Kind == NodeKind.Site)
        {
            <span class="fw-semibold">@node.Label</span>
        }
        else
        {
            <span>@node.Label</span>
            <span class="badge bg-info ms-2">@node.Protocol</span>
        }
    </NodeContent>
</TreeView>

@code {
    private List<TreeNode> _roots = new();

    record TreeNode(string Key, string Label, NodeKind Kind, List<TreeNode> Children, string? Protocol = null);
    enum NodeKind { Site, DataConnection }

    // Build: group data connections by SiteId, wrap each site as a branch
    // with its connections as leaf children
}

This demonstrates the component working with a flat two-level grouping — no recursive hierarchy needed. The consumer simply groups data connections by site and builds one level of children per site node.

Tree Model Construction Pattern

The consuming page is responsible for building the tree model. The component only knows about TItem.

Instance hierarchy (deep, recursive):

  1. Load sites, areas (with ParentAreaId hierarchy), and instances.
  2. Build Area subtree per site using recursive ParentAreaId traversal.
  3. Attach instances as leaf children of their assigned area (or directly under the site if AreaId is null).
  4. Wrap each entity in a uniform TreeNode.

Data connections by site (flat, two-level):

  1. Load sites and data connections.
  2. Group connections by SiteId.
  3. Each site becomes a branch node with its connections as leaf children.

Other Potential Uses

The component is generic enough for:

  • Template inheritance tree: Template → child templates (via ParentTemplateId)
  • Area management: Site → Area hierarchy (replace current flat indentation in Areas.razor)
  • Data connections: Site → connections (flat grouping, as shown above)
  • Navigation sidebar: Hierarchical menu structure
  • File/folder browser: Any nested structure

Testing

Unit tests use the existing bUnit + xUnit + NSubstitute setup in tests/ScadaLink.CentralUI.Tests/. Tests live in a dedicated file: TreeViewTests.cs.

All tests use a simple test model:

record TestNode(string Key, string Label, List<TestNode> Children);

Test Categories

Rendering:

  • Renders root-level items with correct labels
  • Renders EmptyContent when Items is empty
  • Does not render EmptyContent when items exist
  • Leaf nodes have no expand/collapse toggle
  • Branch nodes show + toggle when collapsed

Expand/Collapse:

  • Clicking toggle expands node and shows children
  • Clicking expanded toggle collapses node and hides children
  • Children of collapsed nodes are not in the DOM
  • Deep nesting: expand parent, then expand child — grandchildren visible
  • InitiallyExpanded predicate expands matching nodes on first render

Indentation:

  • Root nodes have zero indentation
  • Child nodes are indented by IndentPx pixels per depth level
  • Custom IndentPx value is applied correctly

Selection:

  • When Selectable is false (default), clicking a node does not fire SelectedKeyChanged
  • When Selectable is true, clicking node content fires SelectedKeyChanged with correct key
  • Clicking expand toggle does not change selection
  • Selected node has SelectedCssClass applied
  • Custom SelectedCssClass is used when provided

External Filtering:

  • Re-rendering with a filtered Items list removes hidden root nodes
  • Expansion state is preserved after filter changes — expanding Site A, filtering to Site A only, then removing filter still shows Site A expanded
  • Selection is cleared when the selected node disappears from filtered results

Session Persistence (R11):

  • When StorageKey is null, no JS interop calls are made
  • When StorageKey is set, expanding a node writes to sessionStorage via JS interop
  • On mount with a StorageKey, reads sessionStorage and restores expanded nodes
  • Persisted state takes precedence over InitiallyExpanded

Note: sessionStorage tests mock IJSRuntime (already available via bUnit's JSInterop).

Expand All / Collapse All (R12):

  • ExpandAll() expands all branch nodes — all descendants visible
  • CollapseAll() collapses all branch nodes — only roots visible
  • ExpandAll() updates sessionStorage when StorageKey is set
  • CollapseAll() clears sessionStorage expanded set when StorageKey is set

RevealNode (R13):

  • RevealNode(key) expands all ancestors of the target node
  • Target node's content is present in the DOM after reveal
  • RevealNode(key, select: true) selects the node and fires SelectedKeyChanged
  • RevealNode with unknown key is a no-op (does not throw)
  • Deeply nested node (3+ levels) — all intermediate ancestors expanded

Accessibility (R14):

  • Root <ul> has role="tree"
  • Node <li> elements have role="treeitem"
  • Expanded branch has aria-expanded="true"
  • Collapsed branch has aria-expanded="false"
  • Child container <ul> has role="group"
  • Selected node has aria-selected="true" when Selectable is true

Context Menu (R15):

  • Right-clicking a node shows the context menu with consumer-defined content
  • Context menu is positioned at cursor coordinates
  • When ContextMenu parameter is null, right-click does not render a menu
  • When ContextMenu fragment renders empty content for a node type, no menu appears and browser default is used
  • Right-clicking a node type with menu items shows the menu; right-clicking a node type without menu items does not
  • Clicking a menu item dismisses the menu
  • Clicking outside the menu dismisses it
  • Right-clicking a different node replaces the current menu

Test File Location

tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs

Dependencies

  • Bootstrap 5 (already included in CentralUI)
  • No additional packages
  • bUnit 2.0.33-preview (already in test project)

Page Integration Notes

1. Instances Page (/deployment/instances — Instances.razor)

Current state: Flat table with filters (Site, Template, Status, Search), pagination, and 6 inline action buttons per row (Deploy, Disable/Enable, Configure, Diff, Delete). ~490 lines.

Change to:

  • Replace the <table> with a <TreeView> showing Site → Area → Sub Area → Instance hierarchy.
  • Keep the existing filter bar (Site, Template, Status, Search). Filters control which tree roots and leaves are shown:
    • Site filter: pass only the matching site root to Items.
    • Template/Status/Search filters: filter at the instance (leaf) level. Branch nodes with no matching descendants should be pruned from the tree. Build a helper method (BuildFilteredTree()) that walks the hierarchy bottom-up, keeping only branches that contain at least one matching instance.
  • Remove the table, pagination, and Actions column. The tree replaces all of this.
  • Move all 6 action buttons into the ContextMenu fragment, shown only for instance nodes:
    • Deploy/Redeploy, Disable/Enable (conditional on state), Configure, Diff, Delete (with divider).
    • Site and Area nodes get no context menu (browser default).
  • Node content per type:
    • Site nodes: <span class="fw-semibold">SiteName</span>
    • Area nodes: <span class="text-secondary">AreaName</span>
    • Instance nodes: <span>UniqueName</span> + status badge + staleness badge
  • Tree model: Build in LoadDataAsync — load sites, areas (recursive via ParentAreaId), instances. Group instances by SiteId + AreaId. Instances with AreaId == null attach directly under their site. Wrap in a uniform TreeNode record.
  • StorageKey: "instances-tree"
  • Selection: Enable selection. Clicking an instance could show a detail panel or simply highlight it for context menu use.

Files to modify:

  • src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor — replace table with TreeView, add tree model building, move actions to context menu, keep filter bar.

Removed code:

  • Pagination logic (_currentPage, _totalPages, _pagedInstances, GoToPage)
  • Actions column markup
  • <table> / <thead> / <tbody> structure

2. Data Connections Page (/admin/data-connections — DataConnections.razor)

Current state: Flat table listing all data connections across all sites. Columns: ID, Name, Protocol, Site, Primary Config, Backup Config, Actions (Edit, Delete). No filters. ~119 lines.

Change to:

  • Replace the <table> with a <TreeView> showing Site → Data Connection hierarchy (two levels, no recursion).
  • No filter bar needed initially — the tree naturally groups by site. If the number of sites grows, a site filter dropdown can be added later using the external filtering pattern.
  • Move Edit and Delete into the ContextMenu fragment, shown only for data connection nodes:
    • Edit → navigates to /admin/data-connections/{id}/edit
    • Delete → shows confirm dialog, then deletes
    • Site nodes get no context menu.
  • Node content per type:
    • Site nodes: <span class="fw-semibold">SiteName</span> + child count badge (e.g., <span class="badge bg-secondary ms-1">3</span>)
    • Data Connection nodes: <span>Name</span> + protocol badge (e.g., <span class="badge bg-info ms-2">OPC UA</span>)
  • Tree model: Group data connections by SiteId. Each site becomes a branch, its connections become leaves. Sites with no connections still appear as empty branches (expandable but no children).
  • StorageKey: "data-connections-tree"

Files to modify:

  • src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor — replace table with TreeView, add tree model building, move actions to context menu.

Removed code:

  • <table> / <thead> / <tbody> structure
  • Inline Edit/Delete buttons

3. Areas Page (/admin/areas — Areas.razor)

Current state: Two-panel layout. Left panel: site list (list-group). Right panel: manually indented flat tree of areas for the selected site, with [+]/- indicators, inline Edit/Delete buttons, and an add/edit form. Custom BuildFlatTree() / AddChildren() methods, AreaTreeNode record, manual padding-left indentation. ~293 lines.

Change to:

  • Keep the two-panel layout (site list on left, area tree on right).
  • Replace the custom flat-tree rendering in the right panel with a <TreeView> component.
  • Site selection stays as-is (left panel list-group click sets _selectedSiteId). This acts as the external filter — the TreeView receives only the selected site's areas as Items.
  • Move Edit and Delete into the ContextMenu fragment for area nodes:
    • Edit → loads area into the add/edit form (same as current behavior)
    • Delete → shows confirm dialog (with child check, same as current)
  • Node content: <span>AreaName</span> — optionally show instance count if available.
  • Tree model: For the selected site, load root areas (ParentAreaId == null), use ChildrenSelector to return child areas. The Area entity already has Children collection, so it can be used directly as TItem without a wrapper record — ChildrenSelector = a => a.Children.ToList(), HasChildrenSelector = a => a.Children.Any(), KeySelector = a => a.Id.
  • Keep the add/edit form at the top of the right panel (above the tree). The "Parent Area" dropdown stays.
  • StorageKey: "areas-tree"

Files to modify:

  • src/ScadaLink.CentralUI/Components/Pages/Admin/Areas.razor — replace custom flat-tree rendering with TreeView, remove BuildFlatTree(), AddChildren(), AreaTreeNode record, manual indentation CSS.

Removed code:

  • BuildFlatTree() method
  • AddChildren() recursive helper
  • AreaTreeNode record
  • Manual padding-left indentation
  • Custom [+]/- toggle rendering
  • Inline Edit/Delete buttons in the tree rows

Interactions

  • DataTable: The tree replaces flat tables on the three pages listed above. Other pages that don't need hierarchy continue using DataTable.
  • InstanceConfigure.razor: Right-click → Configure on an instance node navigates to /deployment/instances/{Id}/configure.
  • Areas.razor: The simplest integration — Area entity used directly as TItem, no wrapper needed.