Files
Joseph Doherty 0c3837c778 docs(components): accuracy fixes from deep review (batch 4)
ManagementService (role table: queries any-auth, area mutations Designer;
audit contract exception), CLI (missing instance/api-key subcommands; server
JSON printed verbatim; bundle preview timeout), Transport (BundleFormatVersion
exact-match gate; dependency scan fields; three flushes), CentralUI
(/api/script-analysis endpoints; LoginLayout minimal; Health tile components),
TreeView (Topology no RevealNode; ContextMenu Site branch; InitiallyExpanded).
2026-06-03 16:39:29 -04:00

14 KiB

Tree View

TreeView<TItem> is a generic, reusable Blazor Server component that renders any tree-shaped data as an expandable/collapsible hierarchy with ARIA roles, optional guide lines, single or checkbox selection, and session-persistent expansion state.

Overview

The component lives at src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor alongside its scoped stylesheet TreeView.razor.css. It is data-agnostic: the caller supplies the data source, a children accessor, a key function, and a RenderFragment<TItem> for node content — the component owns only the structural chrome (indentation, toggle, guide lines, ARIA attributes, selection highlight, context menu positioning).

Active uses within the Central UI:

  • Data Connections page (DataConnections.razor) — two-level Site → Connection tree with a kebab action menu per node and search-based dimming.
  • Topology page (Topology.razor) — three-level Site → Area → Instance tree with inline rename, context menus, and StorageKey = "topology-tree" expansion persistence.
  • TemplateFolderTree (TemplateFolderTree.razor) — a domain-specific wrapper that projects TemplateFolder / Template entities into TemplateTreeNode items and delegates to TreeView<TemplateTreeNode>. Consumed by the Templates browser page (Single mode, click-to-navigate) and the Transport Export wizard (Checkbox mode, bulk template selection).

Key Concepts

Generic node typing

@typeparam TItem means the component imposes no node base class. The consumer brings its own model — a record, a class, an interface — and wires four [EditorRequired] delegates that teach the component how to navigate it. The TemplateTreeNode class in Components/Shared/TemplateTreeNode.cs is the shared adapter type for template/folder hierarchies; the Data Connections and Topology pages use local record types.

Expansion state

Expanded-node keys are stored in _expandedKeys (HashSet<string>). Keys are stringified via KeyStr(object) => key.ToString()! for consistency with sessionStorage, which stores them as a JSON array when StorageKey is set. On first render the component reads sessionStorage via treeviewStorage.load; on every toggle it writes back via treeviewStorage.save. InitiallyExpanded applies only when no persisted state exists (or StorageKey is null). The two sources are always unioned — a RevealNode call before the async storage read completes is not clobbered.

Selection modes

TreeViewSelectionMode (defined in TreeViewSelectionMode.cs) controls how nodes are selected:

public enum TreeViewSelectionMode
{
    Single,    // Default. Clicking node content fires SelectedKeyChanged with the node key.
    Checkbox,  // Renders a tri-state checkbox per node. Folder check state is aggregated
               // from descendant leaves; only leaf keys enter SelectedKeys.
}

In Single mode the component uses SelectedKey / SelectedKeyChanged (two-way binding on a single object?). In Checkbox mode it uses SelectedKeys / SelectedKeysChanged (HashSet<object>). Checking a folder selects or deselects all of its descendant leaf keys. The indeterminate checkbox property is set via JS interop (treeviewStorage.setIndeterminate) after every render because Blazor does not bind input.indeterminate natively.

Context menu

When ContextMenu is non-null, right-clicking any row suppresses the browser default and positions a Bootstrap dropdown-menu show div at the cursor coordinates using position: fixed. An invisible overlay behind the menu dismisses it on click-outside; Escape also dismisses it. The menu receives the TItem of the right-clicked node, so the consumer's fragment can branch on node type.

Architecture

The component is a single @typeparam .razor file with a private void RenderNode(TItem item, int depth) local function that recurses the tree at render time — no intermediate view model is built inside the component. Every <li> carries @key="key" so Blazor can diff the list efficiently.

IJSRuntime is injected for two purposes: reading/writing sessionStorage for expansion persistence, and setting input.indeterminate for tri-state checkboxes. Both call sites guard JSDisconnectedException so a disconnected circuit never throws out of the lifecycle methods.

The public surface the caller can invoke via @ref:

public bool IsExpanded(object key);        // Whether the given key is currently expanded.
public void ExpandAll();                   // Expand every branch node; persists if StorageKey set.
public void CollapseAll();                 // Collapse every node; clears persisted state.
public async Task RevealNode(object key, bool select = false);
    // Expands all ancestors of the given key. Optionally selects the node.
    // No-op if the key does not exist in the current tree.

Usage

The Data Connections page binds a two-level Site → Connection tree with StorageKey, single selection, and a context menu for Edit and Delete actions on connection nodes:

<TreeView @ref="_tree" TItem="DcTreeNode" Items="_treeRoots"
          ChildrenSelector="n => n.Children"
          HasChildrenSelector="n => n.Children.Count > 0"
          KeySelector="n => (object)n.Key"
          StorageKey="data-connections-tree"
          Selectable="true"
          SelectedKey="_selectedKey"
          SelectedKeyChanged="OnTreeNodeSelected">
    <NodeContent Context="node">
        @if (node.Kind == DcNodeKind.Site)
        {
            <span class="tv-label fw-semibold">@node.Label</span>
            <span class="badge bg-secondary ms-1">@node.Children.Count</span>
        }
        else
        {
            <span class="tv-label">@node.Label</span>
            <span class="badge bg-info ms-2">@node.Connection!.Protocol</span>
        }
    </NodeContent>
    <ContextMenu Context="node">
        @if (node.Kind == DcNodeKind.Site)
        {
            <button class="dropdown-item"
                    @onclick="() => AddConnectionForSite(node.SiteId!.Value)">
                Add Connection here
            </button>
        }
        else
        {
            <button class="dropdown-item"
                    @onclick='() => NavigationManager.NavigateTo($"/design/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 sites configured. Add sites under Admin → Sites.</span>
    </EmptyContent>
</TreeView>

@code {
    record DcTreeNode(string Key, string Label, DcNodeKind Kind, List<DcTreeNode> Children,
        int? SiteId = null, DataConnection? Connection = null);
    enum DcNodeKind { Site, DataConnection }

    private TreeView<DcTreeNode>? _tree;
    private object? _selectedKey;

    private void OnTreeNodeSelected(object? key) => _selectedKey = key;
}

The TemplateFolderTree wrapper demonstrates Checkbox mode, where SelectedKeys / SelectedKeysChanged drive bulk template selection in the Transport Export wizard:

<TreeView @ref="_tree" TItem="TemplateTreeNode"
          Items="_visibleRoots"
          ChildrenSelector="n => n.Children"
          HasChildrenSelector="n => n.Children.Count > 0"
          KeySelector="n => (object)n.Key"
          Selectable="@(SelectionMode == TreeViewSelectionMode.Single)"
          SelectionMode="SelectionMode"
          SelectedKeys="SelectedKeys"
          SelectedKeysChanged="SelectedKeysChanged"
          InitiallyExpanded="@(_initiallyExpanded)"
          StorageKey="@StorageKey">
    <NodeContent Context="node">
        <span class="tv-glyph"><i class="bi @(NodeGlyph(node))"></i></span>
        <span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
              title="@node.Name">@node.Name</span>
    </NodeContent>
</TreeView>

Configuration

All [Parameter] properties on TreeView<TItem>. Parameters marked required carry [EditorRequired] and must be supplied; omitting them produces a build warning.

Parameter Type Default Description
Items IReadOnlyList<TItem> Required. Root-level nodes to render.
ChildrenSelector Func<TItem, IReadOnlyList<TItem>> Required. Returns the ordered children of a node.
HasChildrenSelector Func<TItem, bool> Required. Returns true for branch nodes. Determines whether the expand toggle is rendered.
KeySelector Func<TItem, object> Required. Unique stable key per node. Used for expansion tracking, selection, and @key diffing.
NodeContent RenderFragment<TItem> Required. Render fragment for node label content. Receives the TItem; responsible for all domain-specific markup (glyphs, labels, badges).
EmptyContent RenderFragment? null Shown when Items is empty or null.
ContextMenu RenderFragment<TItem>? null Right-click menu content. Receives the right-clicked node. If null, right-click is not intercepted and the browser default is preserved. If non-null, @oncontextmenu:preventDefault is always active — the browser default is suppressed for every node regardless of whether the fragment renders any items for that node type.
IndentPx int 24 Pixels of left padding added per depth level via inline style.
ShowGuideLines bool true Adds tv-guides CSS class to the root <ul>, enabling the depth guide lines drawn by a linear-gradient pseudo-element in TreeView.razor.css.
InitiallyExpanded Func<TItem, bool>? null Predicate used to expand matching nodes on first load. When StorageKey is null it is applied immediately (synchronously in OnParametersSet). When StorageKey is set, the predicate is applied only after the async storage read completes and returns empty — persisted state takes precedence and InitiallyExpanded is a fallback for first-ever loads.
StorageKey string? null Browser sessionStorage key for expansion persistence (treeview:{StorageKey}). When null, expansion is in-memory only.
Selectable bool false Enables click-to-select on node content. Clicking the expand toggle never changes selection.
SelectedKey object? null Currently selected node key for Single mode. Supports two-way binding (@bind-SelectedKey).
SelectedKeyChanged EventCallback<object?> Fires when selection changes in Single mode. Also fires on RevealNode(..., select: true). Fires with null when the selected key disappears from the tree.
SelectedCssClass string "bg-primary bg-opacity-10" CSS class(es) applied to the selected node's row div in addition to tv-selected.
SelectionMode TreeViewSelectionMode Single Switches between single-key selection and tri-state checkbox selection.
SelectedKeys HashSet<object>? null Set of currently selected leaf keys for Checkbox mode.
SelectedKeysChanged EventCallback<HashSet<object>> Fires with the updated set when any checkbox is toggled in Checkbox mode. Always fires with a fresh HashSet reference.

CSS utility classes in NodeContent

The scoped stylesheet defines layout slots that NodeContent fragments should use for consistent alignment:

Class Purpose
tv-glyph 20 px flex slot for a Bootstrap Icon (<i class="bi bi-…">).
tv-label flex: 1 1 auto; min-width: 0 — primary text with ellipsis overflow.
tv-meta margin-left: auto — right-aligned badges or trailing controls.
tv-kebab Opt-in hidden-by-default "more actions" slot; revealed on row hover.

Dependencies & Interactions

  • Bootstrap 5 — all state visuals use Bootstrap utility classes and CSS variables (--bs-tertiary-bg, --bs-border-color, --bs-primary-rgb). No third-party Blazor component frameworks.
  • Bootstrap Icons — static files served from wwwroot/lib/bootstrap-icons/; referenced once in MainLayout.razor. NodeContent fragments use <i class="bi bi-…"> inside the tv-glyph slot.
  • IJSRuntime — injected for treeviewStorage.load / treeviewStorage.save (expansion persistence) and treeviewStorage.setIndeterminate (checkbox tri-state). The JS helpers live in the CentralUI's shared JS bundle.
  • TemplateFolderTree (Components/Shared/TemplateFolderTree.razor) — a domain-specific wrapper around TreeView<TemplateTreeNode> that handles folder/template tree construction, text filtering, and ExtraTemplateChildren injection. Consumers that need the template hierarchy use TemplateFolderTree; they do not wire TreeView<TemplateTreeNode> directly.
  • TemplateTreeNode / TemplateTreeNodeKind (Components/Shared/TemplateTreeNode.cs) — the shared node model used by TemplateFolderTree and its callers. Folder keys are prefixed f:, template keys t:, composition keys c:.
  • Data Connections page (Components/Pages/Design/DataConnections.razor) — binds TreeView<DcTreeNode> directly with a local two-level record type.
  • Topology page (Components/Pages/Deployment/Topology.razor) — binds TreeView for the Site → Area → Instance hierarchy; calls ExpandAll and CollapseAll via @ref.
  • Central UI component — see ./CentralUI.md for the broader Blazor Server application context.