feat(centralui): NodeBrowserDialog search + load-more + type column (T15/T16)

This commit is contained in:
Joseph Doherty
2026-06-18 03:00:16 -04:00
parent 1f7bb7ace3
commit 90abb4b8e2
3 changed files with 285 additions and 7 deletions
@@ -1,3 +1,4 @@
@using Microsoft.AspNetCore.Components.Web
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@@ -21,6 +22,50 @@
</div>
}
<div class="input-group mb-3">
<input class="form-control" data-test="node-search-input"
@bind="_searchQuery" @bind:event="oninput"
@onkeydown="SearchOnEnter"
placeholder="Search the address space by name or path…" />
<button class="btn btn-outline-primary" data-test="node-search-button" @onclick="SearchAsync">Search</button>
</div>
@if (_searchActive)
{
<div class="node-browser-search mb-3">
@if (_searchResults.Count == 0)
{
<em class="text-muted">No matches.</em>
}
else
{
<ul class="list-unstyled mb-0">
@foreach (var match in _searchResults)
{
var node = match.Node;
<li data-test="node-search-result" class="py-1">
<a href="javascript:void(0)"
class="@(node.NodeId == _selectedNodeId ? "fw-bold text-primary" : "")"
@onclick="() => SelectBrowseNode(node)">
@node.DisplayName
</a>
<small class="text-muted ms-1">@match.Path</small>
@if (!string.IsNullOrEmpty(node.DataType))
{
<span class="badge bg-light text-muted border ms-1">@node.DataType</span>
}
</li>
}
</ul>
}
@if (_searchCapReached)
{
<small class="text-muted">Showing first 100 matches — refine your search.</small>
}
</div>
<hr />
}
<div class="node-browser-tree">
@if (_rootNodes.Count == 0 && _failure is null)
{
@@ -31,7 +76,7 @@
<ul class="list-unstyled mb-0">
@foreach (var node in _rootNodes)
{
<TreeRow Node="node" OnToggle="ToggleAsync" OnSelect="Select" SelectedNodeId="@_selectedNodeId" />
<TreeRow Node="node" OnToggle="ToggleAsync" OnSelect="Select" OnLoadMore="LoadMoreAsync" SelectedNodeId="@_selectedNodeId" />
}
</ul>
}
@@ -75,6 +120,13 @@
private string _failureMessage = "";
private List<TreeNode> _rootNodes = new();
// Search state (T16): _searchActive distinguishes "ran a search that found
// nothing" from "never searched"; a blank query clears the panel entirely.
private string _searchQuery = "";
private bool _searchActive;
private bool _searchCapReached;
private List<AddressSpaceMatch> _searchResults = new();
public sealed class TreeNode
{
public TreeNode(string nodeId, string displayName, BrowseNodeClass nodeClass, bool hasChildren)
@@ -90,10 +142,24 @@
public BrowseNodeClass NodeClass { get; }
public bool HasChildren { get; }
/// <summary>
/// Friendly DataType name for Variable nodes (T15 type column); null when
/// not a Variable or the type read failed. Rendered as a muted badge.
/// </summary>
public string? DataType { get; init; }
public List<TreeNode>? Children { get; set; } // null = not loaded yet
public bool Expanded { get; set; }
public bool Loading { get; set; }
public bool Truncated { get; set; }
/// <summary>
/// Opaque adapter cursor from the most recent browse of this node's
/// children. Non-null when more children remain (drives the "Load more"
/// button); null once the browse is exhausted. Echoed back verbatim to
/// <c>BrowseChildrenAsync</c> to fetch the next page.
/// </summary>
public string? ContinuationToken { get; set; }
}
private string _runtimeSiteId = "";
@@ -124,10 +190,13 @@
return;
}
_rootNodes = result.Children.Select(c => new TreeNode(c.NodeId, c.DisplayName, c.NodeClass, c.HasChildren)).ToList();
_rootNodes = result.Children.Select(ToTreeNode).ToList();
StateHasChanged();
}
private static TreeNode ToTreeNode(BrowseNode c) =>
new(c.NodeId, c.DisplayName, c.NodeClass, c.HasChildren) { DataType = c.DataType };
private async Task ToggleAsync(TreeNode node)
{
if (!node.HasChildren) return;
@@ -151,15 +220,83 @@
return;
}
node.Children = result.Children
.Select(c => new TreeNode(c.NodeId, c.DisplayName, c.NodeClass, c.HasChildren))
.ToList();
node.Children = result.Children.Select(ToTreeNode).ToList();
node.Truncated = result.Truncated;
node.ContinuationToken = result.ContinuationToken;
}
node.Expanded = true;
}
// Load-more / BrowseNext (T15): fetch the next page under a truncated node,
// APPEND the children (preserve what's already shown + expanded), and refresh
// the stored token (null → exhausted → the button disappears).
private async Task LoadMoreAsync(TreeNode node)
{
if (string.IsNullOrEmpty(node.ContinuationToken)) return;
node.Loading = true;
StateHasChanged();
var result = await BrowseService.BrowseChildrenAsync(
_runtimeSiteId, _runtimeConnectionName, node.NodeId, node.ContinuationToken);
node.Loading = false;
if (result.Failure is not null)
{
SetFailure(result.Failure);
return;
}
node.Children ??= new();
node.Children.AddRange(result.Children.Select(ToTreeNode));
node.Truncated = result.Truncated;
node.ContinuationToken = result.ContinuationToken;
StateHasChanged();
}
private Task SearchOnEnter(KeyboardEventArgs e) =>
e.Key == "Enter" ? SearchAsync() : Task.CompletedTask;
// Address-space search (T16): a blank query clears the panel; otherwise run
// the bounded recursive search and render matches as a flat selectable list.
private async Task SearchAsync()
{
if (string.IsNullOrWhiteSpace(_searchQuery))
{
_searchActive = false;
_searchResults = new();
_searchCapReached = false;
StateHasChanged();
return;
}
var result = await BrowseService.SearchAsync(
_runtimeSiteId, _runtimeConnectionName, _searchQuery.Trim(),
maxDepth: 6, maxResults: 100);
if (result.Failure is not null)
{
SetFailure(result.Failure);
return;
}
_failure = null;
_searchActive = true;
_searchResults = result.Matches.ToList();
_searchCapReached = result.CapReached;
StateHasChanged();
}
// Search result click → SAME selection mechanism the tree uses: set the
// selected node id (and mirror it into the manual field) so the footer
// Select button confirms and OnSelected fires with this node id.
private void SelectBrowseNode(BrowseNode node)
{
if (node.NodeClass != BrowseNodeClass.Variable) return;
_selectedNodeId = node.NodeId;
_manualNodeId = node.NodeId;
}
private void Select(TreeNode node)
{
if (node.NodeClass != BrowseNodeClass.Variable) return;
@@ -19,6 +19,10 @@
@ondblclick="() => OnSelect.InvokeAsync(Node)">
@Node.DisplayName <small class="text-muted">(@Node.NodeId)</small>
</a>
@if (!string.IsNullOrEmpty(Node.DataType))
{
<span class="badge bg-light text-muted border ms-1" data-test="node-type">@Node.DataType</span>
}
}
else
{
@@ -35,9 +39,18 @@
<ul class="list-unstyled ms-4">
@foreach (var child in Node.Children)
{
<TreeRow Node="child" OnToggle="OnToggle" OnSelect="OnSelect" SelectedNodeId="@SelectedNodeId" />
<TreeRow Node="child" OnToggle="OnToggle" OnSelect="OnSelect" OnLoadMore="OnLoadMore" SelectedNodeId="@SelectedNodeId" />
}
@if (Node.Truncated)
@if (!string.IsNullOrEmpty(Node.ContinuationToken))
{
<li>
<button class="btn btn-sm btn-link p-0" data-test="node-load-more"
@onclick="() => OnLoadMore.InvokeAsync(Node)" disabled="@Node.Loading">
@(Node.Loading ? "Loading…" : "Load more")
</button>
</li>
}
else if (Node.Truncated)
{
<li><small class="text-warning">Results truncated — use manual entry if your tag isn't listed.</small></li>
}
@@ -49,5 +62,6 @@
[Parameter] public NodeBrowserDialog.TreeNode Node { get; set; } = default!;
[Parameter] public EventCallback<NodeBrowserDialog.TreeNode> OnToggle { get; set; }
[Parameter] public EventCallback<NodeBrowserDialog.TreeNode> OnSelect { get; set; }
[Parameter] public EventCallback<NodeBrowserDialog.TreeNode> OnLoadMore { get; set; }
[Parameter] public string? SelectedNodeId { get; set; }
}