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 023a2a99..c4b727fe 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/NodeBrowserDialog.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/NodeBrowserDialog.razor
@@ -22,15 +22,18 @@
}
-
-
-
-
+ @if (ShowSearch)
+ {
+
+
+
+
+ }
- @if (_searchActive)
+ @if (ShowSearch && _searchActive)
{
@if (_searchResults.Count == 0)
@@ -113,8 +116,19 @@
[Parameter] public EventCallback OnSelected { get; set; }
[Parameter] public EventCallback OnCancelled { get; set; }
+ /// Additive: receives the full selected (incl.
+ /// DataType) on confirm, alongside the existing string-id .
+ /// Optional — existing callers that only wire OnSelected are unaffected.
+ [Parameter] public EventCallback OnNodeSelected { get; set; }
+
+ /// When false, hides the address-space search box. Set false for protocols
+ /// that don't implement IAddressSpaceSearchable (e.g. MxGateway), where search would
+ /// always fail. Default true preserves OPC UA behaviour.
+ [Parameter] public bool ShowSearch { get; set; } = true;
+
private bool _isVisible;
private string? _selectedNodeId;
+ private BrowseNode? _selectedNode;
private string _manualNodeId = "";
private BrowseFailure? _failure;
private string _failureMessage = "";
@@ -148,6 +162,10 @@
///
public string? DataType { get; init; }
+ /// The originating BrowseNode, retained so selection can emit the
+ /// full node (incl. DataType) via OnNodeSelected.
+ public BrowseNode? Source { get; init; }
+
public List? Children { get; set; } // null = not loaded yet
public bool Expanded { get; set; }
public bool Loading { get; set; }
@@ -174,6 +192,14 @@
_isVisible = true;
_manualNodeId = initialNodeId ?? "";
_selectedNodeId = initialNodeId;
+ _selectedNode = null;
+ // Reset any prior search state so a reused dialog instance never renders
+ // stale results on re-open (and the search panel stays fully suppressed
+ // when ShowSearch is false).
+ _searchActive = false;
+ _searchQuery = "";
+ _searchResults = new();
+ _searchCapReached = false;
await LoadRootAsync();
}
@@ -195,7 +221,7 @@
}
private static TreeNode ToTreeNode(BrowseNode c) =>
- new(c.NodeId, c.DisplayName, c.NodeClass, c.HasChildren) { DataType = c.DataType };
+ new(c.NodeId, c.DisplayName, c.NodeClass, c.HasChildren) { DataType = c.DataType, Source = c };
private async Task ToggleAsync(TreeNode node)
{
@@ -311,6 +337,7 @@
if (node.NodeClass != BrowseNodeClass.Variable) return;
_selectedNodeId = node.NodeId;
_manualNodeId = node.NodeId;
+ _selectedNode = node;
}
private void Select(TreeNode node)
@@ -318,6 +345,7 @@
if (node.NodeClass != BrowseNodeClass.Variable) return;
_selectedNodeId = node.NodeId;
_manualNodeId = node.NodeId;
+ _selectedNode = node.Source;
}
// Task 17: map each BrowseFailureKind to a friendly UI message. Messages are
@@ -349,13 +377,19 @@
private void UseManual()
{
_selectedNodeId = _manualNodeId.Trim();
+ _selectedNode = null;
}
private async Task Confirm()
{
_isVisible = false;
if (!string.IsNullOrWhiteSpace(_selectedNodeId))
+ {
await OnSelected.InvokeAsync(_selectedNodeId!);
+ if (OnNodeSelected.HasDelegate)
+ await OnNodeSelected.InvokeAsync(
+ _selectedNode ?? new BrowseNode(_selectedNodeId!, _selectedNodeId!, BrowseNodeClass.Variable, HasChildren: false));
+ }
}
private async Task Cancel()
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/NodeBrowserDialogSelectionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/NodeBrowserDialogSelectionTests.cs
new file mode 100644
index 00000000..3ecf52a3
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/NodeBrowserDialogSelectionTests.cs
@@ -0,0 +1,69 @@
+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;
+
+public class NodeBrowserDialogSelectionTests : BunitContext
+{
+ private readonly IBrowseService _browse = Substitute.For();
+
+ public NodeBrowserDialogSelectionTests()
+ {
+ Services.AddSingleton(_browse);
+ _browse.BrowseChildrenAsync(
+ Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any())
+ .Returns(new BrowseNodeResult(Array.Empty(), Truncated: false, Failure: null));
+ _browse.SearchAsync(
+ Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(new SearchAddressSpaceResult(
+ Matches: new[]
+ {
+ new AddressSpaceMatch(
+ new BrowseNode("ns=2;s=Pump1.Speed", "Speed", BrowseNodeClass.Variable, HasChildren: false, DataType: "Double"),
+ Path: "Devices/Pump1/Speed"),
+ },
+ CapReached: false, Failure: null));
+ }
+
+ [Fact]
+ public void Confirm_emits_full_node_with_datatype_on_OnNodeSelected()
+ {
+ BrowseNode? captured = null;
+ var cut = Render(p => p
+ .Add(c => c.SiteId, "plant-a")
+ .Add(c => c.ConnectionName, "PLC-OPC")
+ .Add(c => c.OnNodeSelected, EventCallback.Factory.Create(this, n => captured = n)));
+ cut.InvokeAsync(() => cut.Instance.ShowAsync("plant-a", "PLC-OPC", null));
+ cut.Render();
+
+ cut.Find("[data-test=node-search-input]").Input("Pump1");
+ cut.Find("[data-test=node-search-button]").Click();
+ cut.FindAll("[data-test=node-search-result] a")[0].Click();
+ cut.Find(".modal-footer .btn-primary").Click();
+
+ Assert.NotNull(captured);
+ Assert.Equal("ns=2;s=Pump1.Speed", captured!.NodeId);
+ Assert.Equal("Double", captured.DataType);
+ }
+
+ [Fact]
+ public void ShowSearch_false_hides_search_box()
+ {
+ var cut = Render(p => p
+ .Add(c => c.SiteId, "plant-a")
+ .Add(c => c.ConnectionName, "PLC-MX")
+ .Add(c => c.ShowSearch, false));
+ cut.InvokeAsync(() => cut.Instance.ShowAsync("plant-a", "PLC-MX", null));
+ cut.Render();
+
+ Assert.Empty(cut.FindAll("[data-test=node-search-input]"));
+ }
+}