feat(central-ui): NodeBrowserDialog emits full node + optional ShowSearch
Fully suppress search UI (box + results panel) when ShowSearch=false, and reset search state on ShowAsync so a reused dialog never shows stale results.
This commit is contained in:
@@ -22,6 +22,8 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (ShowSearch)
|
||||||
|
{
|
||||||
<div class="input-group mb-3">
|
<div class="input-group mb-3">
|
||||||
<input class="form-control" data-test="node-search-input"
|
<input class="form-control" data-test="node-search-input"
|
||||||
@bind="_searchQuery" @bind:event="oninput"
|
@bind="_searchQuery" @bind:event="oninput"
|
||||||
@@ -29,8 +31,9 @@
|
|||||||
placeholder="Search the address space by name or path…" />
|
placeholder="Search the address space by name or path…" />
|
||||||
<button class="btn btn-outline-primary" data-test="node-search-button" @onclick="SearchAsync">Search</button>
|
<button class="btn btn-outline-primary" data-test="node-search-button" @onclick="SearchAsync">Search</button>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (_searchActive)
|
@if (ShowSearch && _searchActive)
|
||||||
{
|
{
|
||||||
<div class="node-browser-search mb-3">
|
<div class="node-browser-search mb-3">
|
||||||
@if (_searchResults.Count == 0)
|
@if (_searchResults.Count == 0)
|
||||||
@@ -113,8 +116,19 @@
|
|||||||
[Parameter] public EventCallback<string> OnSelected { get; set; }
|
[Parameter] public EventCallback<string> OnSelected { get; set; }
|
||||||
[Parameter] public EventCallback OnCancelled { get; set; }
|
[Parameter] public EventCallback OnCancelled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Additive: receives the full selected <see cref="BrowseNode"/> (incl.
|
||||||
|
/// DataType) on confirm, alongside the existing string-id <see cref="OnSelected"/>.
|
||||||
|
/// Optional — existing callers that only wire OnSelected are unaffected.</summary>
|
||||||
|
[Parameter] public EventCallback<BrowseNode> OnNodeSelected { get; set; }
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
[Parameter] public bool ShowSearch { get; set; } = true;
|
||||||
|
|
||||||
private bool _isVisible;
|
private bool _isVisible;
|
||||||
private string? _selectedNodeId;
|
private string? _selectedNodeId;
|
||||||
|
private BrowseNode? _selectedNode;
|
||||||
private string _manualNodeId = "";
|
private string _manualNodeId = "";
|
||||||
private BrowseFailure? _failure;
|
private BrowseFailure? _failure;
|
||||||
private string _failureMessage = "";
|
private string _failureMessage = "";
|
||||||
@@ -148,6 +162,10 @@
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? DataType { get; init; }
|
public string? DataType { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The originating BrowseNode, retained so selection can emit the
|
||||||
|
/// full node (incl. DataType) via OnNodeSelected.</summary>
|
||||||
|
public BrowseNode? Source { 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; }
|
||||||
@@ -174,6 +192,14 @@
|
|||||||
_isVisible = true;
|
_isVisible = true;
|
||||||
_manualNodeId = initialNodeId ?? "";
|
_manualNodeId = initialNodeId ?? "";
|
||||||
_selectedNodeId = 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();
|
await LoadRootAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +221,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static TreeNode ToTreeNode(BrowseNode c) =>
|
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)
|
private async Task ToggleAsync(TreeNode node)
|
||||||
{
|
{
|
||||||
@@ -311,6 +337,7 @@
|
|||||||
if (node.NodeClass != BrowseNodeClass.Variable) return;
|
if (node.NodeClass != BrowseNodeClass.Variable) return;
|
||||||
_selectedNodeId = node.NodeId;
|
_selectedNodeId = node.NodeId;
|
||||||
_manualNodeId = node.NodeId;
|
_manualNodeId = node.NodeId;
|
||||||
|
_selectedNode = node;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Select(TreeNode node)
|
private void Select(TreeNode node)
|
||||||
@@ -318,6 +345,7 @@
|
|||||||
if (node.NodeClass != BrowseNodeClass.Variable) return;
|
if (node.NodeClass != BrowseNodeClass.Variable) return;
|
||||||
_selectedNodeId = node.NodeId;
|
_selectedNodeId = node.NodeId;
|
||||||
_manualNodeId = node.NodeId;
|
_manualNodeId = node.NodeId;
|
||||||
|
_selectedNode = node.Source;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Task 17: map each BrowseFailureKind to a friendly UI message. Messages are
|
// Task 17: map each BrowseFailureKind to a friendly UI message. Messages are
|
||||||
@@ -349,13 +377,19 @@
|
|||||||
private void UseManual()
|
private void UseManual()
|
||||||
{
|
{
|
||||||
_selectedNodeId = _manualNodeId.Trim();
|
_selectedNodeId = _manualNodeId.Trim();
|
||||||
|
_selectedNode = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task Confirm()
|
private async Task Confirm()
|
||||||
{
|
{
|
||||||
_isVisible = false;
|
_isVisible = false;
|
||||||
if (!string.IsNullOrWhiteSpace(_selectedNodeId))
|
if (!string.IsNullOrWhiteSpace(_selectedNodeId))
|
||||||
|
{
|
||||||
await OnSelected.InvokeAsync(_selectedNodeId!);
|
await OnSelected.InvokeAsync(_selectedNodeId!);
|
||||||
|
if (OnNodeSelected.HasDelegate)
|
||||||
|
await OnNodeSelected.InvokeAsync(
|
||||||
|
_selectedNode ?? new BrowseNode(_selectedNodeId!, _selectedNodeId!, BrowseNodeClass.Variable, HasChildren: false));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task Cancel()
|
private async Task Cancel()
|
||||||
|
|||||||
+69
@@ -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<IBrowseService>();
|
||||||
|
|
||||||
|
public NodeBrowserDialogSelectionTests()
|
||||||
|
{
|
||||||
|
Services.AddSingleton(_browse);
|
||||||
|
_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));
|
||||||
|
_browse.SearchAsync(
|
||||||
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||||
|
Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
|
.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<NodeBrowserDialog>(p => p
|
||||||
|
.Add(c => c.SiteId, "plant-a")
|
||||||
|
.Add(c => c.ConnectionName, "PLC-OPC")
|
||||||
|
.Add(c => c.OnNodeSelected, EventCallback.Factory.Create<BrowseNode>(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<NodeBrowserDialog>(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]"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user