feat(centralui): NodeBrowserDialog.SelectableNodeClasses — opt-in Object node selection (default unchanged)
Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
+44
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user