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).
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, andStorageKey = "topology-tree"expansion persistence. TemplateFolderTree(TemplateFolderTree.razor) — a domain-specific wrapper that projectsTemplateFolder/Templateentities intoTemplateTreeNodeitems and delegates toTreeView<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 inMainLayout.razor.NodeContentfragments use<i class="bi bi-…">inside thetv-glyphslot. IJSRuntime— injected fortreeviewStorage.load/treeviewStorage.save(expansion persistence) andtreeviewStorage.setIndeterminate(checkbox tri-state). The JS helpers live in the CentralUI's shared JS bundle.TemplateFolderTree(Components/Shared/TemplateFolderTree.razor) — a domain-specific wrapper aroundTreeView<TemplateTreeNode>that handles folder/template tree construction, text filtering, andExtraTemplateChildreninjection. Consumers that need the template hierarchy useTemplateFolderTree; they do not wireTreeView<TemplateTreeNode>directly.TemplateTreeNode/TemplateTreeNodeKind(Components/Shared/TemplateTreeNode.cs) — the shared node model used byTemplateFolderTreeand its callers. Folder keys are prefixedf:, template keyst:, composition keysc:.- Data Connections page (
Components/Pages/Design/DataConnections.razor) — bindsTreeView<DcTreeNode>directly with a local two-level record type. - Topology page (
Components/Pages/Deployment/Topology.razor) — bindsTreeViewfor the Site → Area → Instance hierarchy; callsExpandAllandCollapseAllvia@ref. - Central UI component — see ./CentralUI.md for the broader Blazor Server application context.