feat(centralui): NodeBrowserDialog search + load-more + type column (T15/T16)
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol
|
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol
|
||||||
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
|
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
|
||||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||||
@@ -21,6 +22,50 @@
|
|||||||
</div>
|
</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">
|
<div class="node-browser-tree">
|
||||||
@if (_rootNodes.Count == 0 && _failure is null)
|
@if (_rootNodes.Count == 0 && _failure is null)
|
||||||
{
|
{
|
||||||
@@ -31,7 +76,7 @@
|
|||||||
<ul class="list-unstyled mb-0">
|
<ul class="list-unstyled mb-0">
|
||||||
@foreach (var node in _rootNodes)
|
@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>
|
</ul>
|
||||||
}
|
}
|
||||||
@@ -75,6 +120,13 @@
|
|||||||
private string _failureMessage = "";
|
private string _failureMessage = "";
|
||||||
private List<TreeNode> _rootNodes = new();
|
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 sealed class TreeNode
|
||||||
{
|
{
|
||||||
public TreeNode(string nodeId, string displayName, BrowseNodeClass nodeClass, bool hasChildren)
|
public TreeNode(string nodeId, string displayName, BrowseNodeClass nodeClass, bool hasChildren)
|
||||||
@@ -90,10 +142,24 @@
|
|||||||
public BrowseNodeClass NodeClass { get; }
|
public BrowseNodeClass NodeClass { get; }
|
||||||
public bool HasChildren { 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 List<TreeNode>? Children { get; set; } // null = not loaded yet
|
||||||
public bool Expanded { get; set; }
|
public bool Expanded { get; set; }
|
||||||
public bool Loading { get; set; }
|
public bool Loading { get; set; }
|
||||||
public bool Truncated { 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 = "";
|
private string _runtimeSiteId = "";
|
||||||
@@ -124,10 +190,13 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_rootNodes = result.Children.Select(c => new TreeNode(c.NodeId, c.DisplayName, c.NodeClass, c.HasChildren)).ToList();
|
_rootNodes = result.Children.Select(ToTreeNode).ToList();
|
||||||
StateHasChanged();
|
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)
|
private async Task ToggleAsync(TreeNode node)
|
||||||
{
|
{
|
||||||
if (!node.HasChildren) return;
|
if (!node.HasChildren) return;
|
||||||
@@ -151,15 +220,83 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
node.Children = result.Children
|
node.Children = result.Children.Select(ToTreeNode).ToList();
|
||||||
.Select(c => new TreeNode(c.NodeId, c.DisplayName, c.NodeClass, c.HasChildren))
|
|
||||||
.ToList();
|
|
||||||
node.Truncated = result.Truncated;
|
node.Truncated = result.Truncated;
|
||||||
|
node.ContinuationToken = result.ContinuationToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
node.Expanded = true;
|
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)
|
private void Select(TreeNode node)
|
||||||
{
|
{
|
||||||
if (node.NodeClass != BrowseNodeClass.Variable) return;
|
if (node.NodeClass != BrowseNodeClass.Variable) return;
|
||||||
|
|||||||
@@ -19,6 +19,10 @@
|
|||||||
@ondblclick="() => OnSelect.InvokeAsync(Node)">
|
@ondblclick="() => OnSelect.InvokeAsync(Node)">
|
||||||
@Node.DisplayName <small class="text-muted">(@Node.NodeId)</small>
|
@Node.DisplayName <small class="text-muted">(@Node.NodeId)</small>
|
||||||
</a>
|
</a>
|
||||||
|
@if (!string.IsNullOrEmpty(Node.DataType))
|
||||||
|
{
|
||||||
|
<span class="badge bg-light text-muted border ms-1" data-test="node-type">@Node.DataType</span>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -35,9 +39,18 @@
|
|||||||
<ul class="list-unstyled ms-4">
|
<ul class="list-unstyled ms-4">
|
||||||
@foreach (var child in Node.Children)
|
@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>
|
<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 NodeBrowserDialog.TreeNode Node { get; set; } = default!;
|
||||||
[Parameter] public EventCallback<NodeBrowserDialog.TreeNode> OnToggle { get; set; }
|
[Parameter] public EventCallback<NodeBrowserDialog.TreeNode> OnToggle { get; set; }
|
||||||
[Parameter] public EventCallback<NodeBrowserDialog.TreeNode> OnSelect { get; set; }
|
[Parameter] public EventCallback<NodeBrowserDialog.TreeNode> OnSelect { get; set; }
|
||||||
|
[Parameter] public EventCallback<NodeBrowserDialog.TreeNode> OnLoadMore { get; set; }
|
||||||
[Parameter] public string? SelectedNodeId { get; set; }
|
[Parameter] public string? SelectedNodeId { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
using Bunit;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NSubstitute;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Dialogs;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers the M7-B6 (T15/T16) additions to <c>NodeBrowserDialog</c>: the
|
||||||
|
/// address-space search box that renders <see cref="IBrowseService.SearchAsync"/>
|
||||||
|
/// matches as a flat selectable list, and that selecting a search result feeds
|
||||||
|
/// the SAME selection mechanism the tree uses (so the dialog's existing
|
||||||
|
/// <c>OnSelected</c> callback fires the chosen node id on confirm).
|
||||||
|
/// </summary>
|
||||||
|
public class NodeBrowserDialogSearchTests : BunitContext
|
||||||
|
{
|
||||||
|
private readonly IBrowseService _browse = Substitute.For<IBrowseService>();
|
||||||
|
|
||||||
|
public NodeBrowserDialogSearchTests()
|
||||||
|
{
|
||||||
|
Services.AddSingleton(_browse);
|
||||||
|
// The root load fires on ShowAsync; give it an empty (successful) result
|
||||||
|
// so the dialog renders without a failure banner and the tree is empty.
|
||||||
|
_browse.BrowseChildrenAsync(
|
||||||
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
||||||
|
Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new BrowseNodeResult(Array.Empty<BrowseNode>(), Truncated: false, Failure: null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SearchAddressSpaceResult TwoMatches() => new(
|
||||||
|
Matches: new[]
|
||||||
|
{
|
||||||
|
new AddressSpaceMatch(
|
||||||
|
new BrowseNode("ns=2;s=Pump1.Speed", "Speed", BrowseNodeClass.Variable, HasChildren: false, DataType: "Double"),
|
||||||
|
Path: "Devices/Pump1/Speed"),
|
||||||
|
new AddressSpaceMatch(
|
||||||
|
new BrowseNode("ns=2;s=Pump1.Flow", "Flow", BrowseNodeClass.Variable, HasChildren: false, DataType: "Float"),
|
||||||
|
Path: "Devices/Pump1/Flow"),
|
||||||
|
},
|
||||||
|
CapReached: false,
|
||||||
|
Failure: null);
|
||||||
|
|
||||||
|
private IRenderedComponent<NodeBrowserDialog> RenderShown(out string? selected)
|
||||||
|
{
|
||||||
|
string? captured = null;
|
||||||
|
var cut = Render<NodeBrowserDialog>(p => p
|
||||||
|
.Add(c => c.SiteId, "plant-a")
|
||||||
|
.Add(c => c.ConnectionName, "PLC-OPC")
|
||||||
|
.Add(c => c.OnSelected, EventCallback.Factory.Create<string>(this, id => captured = id)));
|
||||||
|
|
||||||
|
cut.InvokeAsync(() => cut.Instance.ShowAsync("plant-a", "PLC-OPC", null));
|
||||||
|
cut.Render();
|
||||||
|
|
||||||
|
// capture box is read back by the caller after assertions
|
||||||
|
_capturedSelection = () => captured;
|
||||||
|
selected = captured;
|
||||||
|
return cut;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Func<string?> _capturedSelection = () => null;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Search_RendersMatchRows_WithDataTestHooks()
|
||||||
|
{
|
||||||
|
_browse.SearchAsync(
|
||||||
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||||
|
Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(TwoMatches());
|
||||||
|
|
||||||
|
var cut = RenderShown(out _);
|
||||||
|
|
||||||
|
var input = cut.Find("[data-test=node-search-input]");
|
||||||
|
input.Input("Pump1");
|
||||||
|
cut.Find("[data-test=node-search-button]").Click();
|
||||||
|
|
||||||
|
var rows = cut.FindAll("[data-test=node-search-result]");
|
||||||
|
Assert.Equal(2, rows.Count);
|
||||||
|
Assert.Contains("Speed", cut.Markup);
|
||||||
|
Assert.Contains("Devices/Pump1/Speed", cut.Markup);
|
||||||
|
Assert.Contains("Double", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClickingSearchResult_RaisesSelectionCallback_WithNodeId()
|
||||||
|
{
|
||||||
|
_browse.SearchAsync(
|
||||||
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||||
|
Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(TwoMatches());
|
||||||
|
|
||||||
|
var cut = RenderShown(out _);
|
||||||
|
|
||||||
|
cut.Find("[data-test=node-search-input]").Input("Pump1");
|
||||||
|
cut.Find("[data-test=node-search-button]").Click();
|
||||||
|
|
||||||
|
// Click the first result's link — this drives the SAME selection
|
||||||
|
// mechanism the tree uses, so the footer Select button confirms and
|
||||||
|
// OnSelected fires.
|
||||||
|
cut.FindAll("[data-test=node-search-result] a")[0].Click();
|
||||||
|
cut.Find(".modal-footer .btn-primary").Click();
|
||||||
|
|
||||||
|
Assert.Equal("ns=2;s=Pump1.Speed", _capturedSelection());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BlankQuery_ClearsResults()
|
||||||
|
{
|
||||||
|
_browse.SearchAsync(
|
||||||
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||||
|
Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(TwoMatches());
|
||||||
|
|
||||||
|
var cut = RenderShown(out _);
|
||||||
|
|
||||||
|
cut.Find("[data-test=node-search-input]").Input("Pump1");
|
||||||
|
cut.Find("[data-test=node-search-button]").Click();
|
||||||
|
Assert.Equal(2, cut.FindAll("[data-test=node-search-result]").Count);
|
||||||
|
|
||||||
|
cut.Find("[data-test=node-search-input]").Input("");
|
||||||
|
cut.Find("[data-test=node-search-button]").Click();
|
||||||
|
Assert.Empty(cut.FindAll("[data-test=node-search-result]"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user