feat(centralui): NodeBrowserDialog.SelectableNodeClasses — opt-in Object node selection (default unchanged)

Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
This commit is contained in:
Joseph Doherty
2026-08-21 06:07:12 -04:00
parent 8d17af37ac
commit ab6e3432bd
3 changed files with 90 additions and 11 deletions
@@ -56,12 +56,24 @@
@* A button, not an href="javascript:void(0)" anchor:
this selects a node rather than navigating, and the
pseudo-href was a CSP/a11y smell. Matches the tree
label in TreeRow.razor. *@
<button type="button"
class="btn btn-link p-0 text-start align-baseline @(node.NodeId == _selectedNodeId ? "fw-bold text-primary" : "")"
@onclick="() => SelectBrowseNode(node)">
@node.DisplayName
</button>
label in TreeRow.razor. A node class the caller does
not accept renders as plain text — nothing to click. *@
@if (IsSelectable(node.NodeClass))
{
<button type="button"
class="btn btn-link p-0 text-start align-baseline @(node.NodeId == _selectedNodeId ? "fw-bold text-primary" : "")"
@onclick="() => SelectBrowseNode(node)">
@node.DisplayName
</button>
@if (node.NodeClass == BrowseNodeClass.Object)
{
<span class="badge bg-body-secondary text-body-secondary border ms-1">object</span>
}
}
else
{
<span class="text-muted">@node.DisplayName</span>
}
@if (!string.IsNullOrEmpty(node.DataType))
{
<span class="badge bg-body-secondary text-body-secondary border ms-1">@node.DataType</span>
@@ -90,7 +102,7 @@
<ul class="list-unstyled mb-0">
@foreach (var node in _rootNodes)
{
<TreeRow Node="node" OnToggle="ToggleAsync" OnSelect="Select" OnLoadMore="LoadMoreAsync" SelectedNodeId="@_selectedNodeId" />
<TreeRow Node="node" OnToggle="ToggleAsync" OnSelect="Select" OnLoadMore="LoadMoreAsync" SelectedNodeId="@_selectedNodeId" Selectable="IsSelectable" />
}
</ul>
}
@@ -113,6 +125,10 @@
}
</div>
<div class="modal-footer">
@if (SelectableNodeClasses.Contains(BrowseNodeClass.Object))
{
<span class="text-muted small me-3">Select an object or tag</span>
}
<span class="me-auto text-muted">Selected: <code>@(_selectedNodeId ?? "(none)")</code></span>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary" @onclick="Cancel">Cancel</button>
@@ -147,6 +163,15 @@
/// always fail. Default true preserves OPC UA behaviour.</summary>
[Parameter] public bool ShowSearch { get; set; } = true;
/// <summary>
/// Additive: node classes the picker accepts. Default Variable-only (every
/// existing caller). The create page passes Object+Variable so a module's
/// container can be chosen (design 2026-08-21).
/// </summary>
[Parameter] public IReadOnlyCollection<BrowseNodeClass> SelectableNodeClasses { get; set; } = new[] { BrowseNodeClass.Variable };
private bool IsSelectable(BrowseNodeClass nodeClass) => SelectableNodeClasses.Contains(nodeClass);
private bool _isVisible;
private string? _selectedNodeId;
private BrowseNode? _selectedNode;
@@ -355,7 +380,7 @@
// Select button confirms and OnSelected fires with this node id.
private void SelectBrowseNode(BrowseNode node)
{
if (node.NodeClass != BrowseNodeClass.Variable) return;
if (!IsSelectable(node.NodeClass)) return;
_selectedNodeId = node.NodeId;
_manualNodeId = node.NodeId;
_selectedNode = node;
@@ -363,7 +388,7 @@
private void Select(TreeNode node)
{
if (node.NodeClass != BrowseNodeClass.Variable) return;
if (!IsSelectable(node.NodeClass)) return;
_selectedNodeId = node.NodeId;
_manualNodeId = node.NodeId;
_selectedNode = node.Source;
@@ -11,7 +11,7 @@
}
</span>
@if (Node.NodeClass == BrowseNodeClass.Variable)
@if (IsSelectable)
{
@* Display name (+ type badge) on the first line, the node id clipped
beneath it: an OPC UA node id runs 60–120 characters and inline it
@@ -49,7 +49,7 @@
<ul class="list-unstyled ms-4">
@foreach (var child in Node.Children)
{
<TreeRow Node="child" OnToggle="OnToggle" OnSelect="OnSelect" OnLoadMore="OnLoadMore" SelectedNodeId="@SelectedNodeId" />
<TreeRow Node="child" OnToggle="OnToggle" OnSelect="OnSelect" OnLoadMore="OnLoadMore" SelectedNodeId="@SelectedNodeId" Selectable="Selectable" />
}
@if (!string.IsNullOrEmpty(Node.ContinuationToken))
{
@@ -74,4 +74,14 @@
[Parameter] public EventCallback<NodeBrowserDialog.TreeNode> OnSelect { get; set; }
[Parameter] public EventCallback<NodeBrowserDialog.TreeNode> OnLoadMore { get; set; }
[Parameter] public string? SelectedNodeId { get; set; }
/// <summary>
/// Additive: which node classes the picker accepts, supplied by
/// <see cref="NodeBrowserDialog.SelectableNodeClasses"/>. Null keeps the historical
/// Variable-only behaviour. The expand toggle renders regardless, so a selectable
/// Object node can still be drilled into.
/// </summary>
[Parameter] public Func<BrowseNodeClass, bool>? Selectable { get; set; }
private bool IsSelectable => Selectable?.Invoke(Node.NodeClass) ?? Node.NodeClass == BrowseNodeClass.Variable;
}
@@ -29,6 +29,9 @@ public class NodeBrowserDialogSelectionTests : BunitContext
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", "Pump1", BrowseNodeClass.Object, HasChildren: true),
Path: "Devices/Pump1"),
},
CapReached: false, Failure: null));
}
@@ -100,4 +103,45 @@ public class NodeBrowserDialogSelectionTests : BunitContext
Assert.Equal("Area1.Pump.Speed", captured!.NodeId);
Assert.Equal("Float", captured.DataType);
}
// SelectableNodeClasses (design 2026-08-21): the create page needs to pick a
// module's CONTAINER object, not just a leaf tag. Default stays Variable-only
// so every existing caller is unaffected.
[Fact]
public void Object_node_is_not_selectable_by_default()
{
var cut = Render<NodeBrowserDialog>(p => p
.Add(c => c.SiteId, "plant-a")
.Add(c => c.ConnectionName, "PLC-OPC"));
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();
// Only the Variable renders a select button; the Object is plain text.
Assert.Single(cut.FindAll("[data-test=node-search-result] button.btn-link"));
}
[Fact]
public void Object_node_is_selectable_when_listed()
{
BrowseNode? captured = null;
var cut = Render<NodeBrowserDialog>(p => p
.Add(c => c.SiteId, "plant-a")
.Add(c => c.ConnectionName, "PLC-OPC")
.Add(c => c.SelectableNodeClasses, (IReadOnlyCollection<BrowseNodeClass>)new[] { BrowseNodeClass.Object, BrowseNodeClass.Variable })
.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();
var buttons = cut.FindAll("[data-test=node-search-result] button.btn-link");
Assert.Equal(2, buttons.Count);
buttons.First(b => b.TextContent.Trim() == "Pump1").Click();
cut.Find(".modal-footer .btn-primary").Click();
Assert.Equal("ns=2;s=Pump1", captured!.NodeId);
Assert.Equal(BrowseNodeClass.Object, captured.NodeClass);
}
}