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 (_rootNodes.Count == 0 && _failure is null) { @@ -31,7 +76,7 @@ } @@ -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]")); + } +}