@* Recursive, read-only UNS browse-tree renderer. Modelled on DriverBrowseTree:
per-node indent, expand chevron and Bootstrap/theme styling. This component
owns no state and calls no service — it reads node.Expanded/Loading/Error/
Children and raises callbacks; the host page owns expansion + lazy loading. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@foreach (var root in Roots.Where(r => VisibleUnder(r, false)))
{
@RenderNode(root, 0, false)
}
@code {
/// The top-level UNS nodes to render (typically the enterprise roots).
[Parameter, EditorRequired] public IReadOnlyList Roots { get; set; } = default!;
/// Raised for the primary "add child" action of a structural node (e.g. "+ Area" on a cluster, "+ Equipment" on a line).
[Parameter] public EventCallback OnAddChild { get; set; }
/// Raised when the user edits a node (Area/Line).
[Parameter] public EventCallback OnEdit { get; set; }
/// Raised when the user deletes a node (Area/Line/Equipment/Tag/VirtualTag).
[Parameter] public EventCallback OnDelete { get; set; }
/// Raised when the user toggles a node's expansion; the host owns the resulting state + lazy load.
[Parameter] public EventCallback OnToggleExpand { get; set; }
///
/// Optional case-insensitive substring filter on DisplayName. A node is shown when it matches,
/// sits under a matching ancestor, or has a matching descendant, and the path to matches
/// auto-expands. Lazy tag children are only considered once their equipment has been loaded.
///
[Parameter] public string? Filter { get; set; }
private RenderFragment RenderNode(UnsNode node, int depth, bool ancestorMatched) => __builder =>
{
var indent = $"padding-left:{depth * 18}px";
var hasExpander = node.Children.Count > 0 || node.HasLazyChildren;
// Under an active filter the matched path auto-expands, so the chevron and the child block
// follow `childrenShown` (derived from the filter) rather than the user's node.Expanded flag.
var matched = ancestorMatched || SelfMatches(node);
var visibleChildren = HasFilter ? VisibleChildren(node, matched).ToList() : node.Children;
var childrenShown = HasFilter ? visibleChildren.Count > 0 : node.Expanded;
}
else if (node.Expanded && node.Error is not null)
{
@node.Error
}
else if (childrenShown)
{
@foreach (var child in visibleChildren)
{
@RenderNode(child, depth + 1, matched)
}
}
};
private RenderFragment RenderActions(UnsNode node) => __builder =>
{
switch (node.Kind)
{
case UnsNodeKind.Enterprise:
// No actions on the enterprise root.
break;
case UnsNodeKind.Cluster:
⚙ settings
break;
case UnsNodeKind.Area:
break;
case UnsNodeKind.Line:
break;
case UnsNodeKind.Equipment:
Open
break;
}
};
/// Whether a non-empty filter is currently active.
private bool HasFilter => !string.IsNullOrWhiteSpace(Filter);
/// Whether the node's own DisplayName matches the active filter (case-insensitive substring).
private bool SelfMatches(UnsNode node)
{
var f = Filter?.Trim();
return !string.IsNullOrEmpty(f)
&& node.DisplayName.Contains(f, StringComparison.OrdinalIgnoreCase);
}
///
/// Whether the node should appear under the active filter: it self-matches, sits under a matched
/// ancestor, or has a (loaded) descendant that matches. Unloaded lazy tag children can't be
/// inspected, so an unexpanded equipment matches on its own name only. The structural tree is
/// bounded, so this recursive walk is cheap.
///
private bool VisibleUnder(UnsNode node, bool ancestorMatched)
{
if (!HasFilter) { return true; }
if (ancestorMatched || SelfMatches(node)) { return true; }
return node.Children.Any(c => VisibleUnder(c, false));
}
///
/// The children to render under the active filter: the whole child list when the node (or an
/// ancestor) matched, otherwise only the branches that still contain a match.
///
private IEnumerable VisibleChildren(UnsNode node, bool matched) =>
matched ? node.Children : node.Children.Where(c => VisibleUnder(c, false));
}