diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/NodeBrowserDialog.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/NodeBrowserDialog.razor
index 87006acc..5d15697e 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/NodeBrowserDialog.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/NodeBrowserDialog.razor
@@ -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 @@
}
+
+
+
+
+
+ @if (_searchActive)
+ {
+
+ @if (_searchResults.Count == 0)
+ {
+
No matches.
+ }
+ else
+ {
+
+ }
+ @if (_searchCapReached)
+ {
+
Showing first 100 matches — refine your search.
+ }
+
+
+ }
+
@if (_rootNodes.Count == 0 && _failure is null)
{
@@ -31,7 +76,7 @@
@foreach (var node in _rootNodes)
{
-
+
}
}
@@ -75,6 +120,13 @@
private string _failureMessage = "";
private List
_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 _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; }
+ ///
+ /// Friendly DataType name for Variable nodes (T15 type column); null when
+ /// not a Variable or the type read failed. Rendered as a muted badge.
+ ///
+ public string? DataType { get; init; }
+
public List? Children { get; set; } // null = not loaded yet
public bool Expanded { get; set; }
public bool Loading { get; set; }
public bool Truncated { get; set; }
+
+ ///
+ /// 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
+ /// BrowseChildrenAsync to fetch the next page.
+ ///
+ 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;
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/TreeRow.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/TreeRow.razor
index a2dd3eff..4d862a6c 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/TreeRow.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/TreeRow.razor
@@ -19,6 +19,10 @@
@ondblclick="() => OnSelect.InvokeAsync(Node)">
@Node.DisplayName (@Node.NodeId)
+ @if (!string.IsNullOrEmpty(Node.DataType))
+ {
+ @Node.DataType
+ }
}
else
{
@@ -35,9 +39,18 @@
@foreach (var child in Node.Children)
{
-
+
}
- @if (Node.Truncated)
+ @if (!string.IsNullOrEmpty(Node.ContinuationToken))
+ {
+ -
+
+
+ }
+ else if (Node.Truncated)
{
- Results truncated — use manual entry if your tag isn't listed.
}
@@ -49,5 +62,6 @@
[Parameter] public NodeBrowserDialog.TreeNode Node { get; set; } = default!;
[Parameter] public EventCallback OnToggle { get; set; }
[Parameter] public EventCallback OnSelect { get; set; }
+ [Parameter] public EventCallback OnLoadMore { get; set; }
[Parameter] public string? SelectedNodeId { get; set; }
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/NodeBrowserDialogSearchTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/NodeBrowserDialogSearchTests.cs
new file mode 100644
index 00000000..8c7e727b
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/NodeBrowserDialogSearchTests.cs
@@ -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;
+
+///
+/// Covers the M7-B6 (T15/T16) additions to NodeBrowserDialog: the
+/// address-space search box that renders
+/// 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
+/// OnSelected callback fires the chosen node id on confirm).
+///
+public class NodeBrowserDialogSearchTests : BunitContext
+{
+ private readonly IBrowseService _browse = Substitute.For();
+
+ 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(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any())
+ .Returns(new BrowseNodeResult(Array.Empty(), 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 RenderShown(out string? selected)
+ {
+ string? captured = null;
+ var cut = Render(p => p
+ .Add(c => c.SiteId, "plant-a")
+ .Add(c => c.ConnectionName, "PLC-OPC")
+ .Add(c => c.OnSelected, EventCallback.Factory.Create(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 _capturedSelection = () => null;
+
+ [Fact]
+ public void Search_RendersMatchRows_WithDataTestHooks()
+ {
+ _browse.SearchAsync(
+ Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any(), Arg.Any())
+ .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(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any(), Arg.Any())
+ .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(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any(), Arg.Any())
+ .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]"));
+ }
+}