diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/UnsTree.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/UnsTree.razor index 9deb928f..4b872e2e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/UnsTree.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/UnsTree.razor @@ -4,9 +4,9 @@ Children and raises callbacks; the host page owns expansion + lazy loading. *@ @using ZB.MOM.WW.OtOpcUa.AdminUI.Uns -@foreach (var root in Roots) +@foreach (var root in Roots.Where(r => VisibleUnder(r, false))) { - @RenderNode(root, 0) + @RenderNode(root, 0, false) } @code { @@ -28,19 +28,28 @@ /// 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 applied to visible children by DisplayName. + /// + /// 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) => __builder => + 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;
@if (hasExpander) { } else @@ -66,11 +75,11 @@ @node.Error
} - else if (node.Expanded) + else if (childrenShown) { - @foreach (var child in FilterChildren(node)) + @foreach (var child in visibleChildren) { - @RenderNode(child, depth + 1) + @RenderNode(child, depth + 1, matched) } } }; @@ -128,15 +137,34 @@ } }; - private IEnumerable FilterChildren(UnsNode node) + /// 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(); - if (string.IsNullOrEmpty(f)) - { - return node.Children; - } - - return node.Children.Where(c => - c.DisplayName.Contains(f, StringComparison.OrdinalIgnoreCase)); + 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)); }