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:
|
@* A button, not an href="javascript:void(0)" anchor:
|
||||||
this selects a node rather than navigating, and the
|
this selects a node rather than navigating, and the
|
||||||
pseudo-href was a CSP/a11y smell. Matches the tree
|
pseudo-href was a CSP/a11y smell. Matches the tree
|
||||||
label in TreeRow.razor. *@
|
label in TreeRow.razor. A node class the caller does
|
||||||
<button type="button"
|
not accept renders as plain text — nothing to click. *@
|
||||||
class="btn btn-link p-0 text-start align-baseline @(node.NodeId == _selectedNodeId ? "fw-bold text-primary" : "")"
|
@if (IsSelectable(node.NodeClass))
|
||||||
@onclick="() => SelectBrowseNode(node)">
|
{
|
||||||
@node.DisplayName
|
<button type="button"
|
||||||
</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))
|
@if (!string.IsNullOrEmpty(node.DataType))
|
||||||
{
|
{
|
||||||
<span class="badge bg-body-secondary text-body-secondary border ms-1">@node.DataType</span>
|
<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">
|
<ul class="list-unstyled mb-0">
|
||||||
@foreach (var node in _rootNodes)
|
@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>
|
</ul>
|
||||||
}
|
}
|
||||||
@@ -113,6 +125,10 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<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>
|
<span class="me-auto text-muted">Selected: <code>@(_selectedNodeId ?? "(none)")</code></span>
|
||||||
<div class="btn-group btn-group-sm" role="group">
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
<button class="btn btn-outline-secondary" @onclick="Cancel">Cancel</button>
|
<button class="btn btn-outline-secondary" @onclick="Cancel">Cancel</button>
|
||||||
@@ -147,6 +163,15 @@
|
|||||||
/// always fail. Default true preserves OPC UA behaviour.</summary>
|
/// always fail. Default true preserves OPC UA behaviour.</summary>
|
||||||
[Parameter] public bool ShowSearch { get; set; } = true;
|
[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 bool _isVisible;
|
||||||
private string? _selectedNodeId;
|
private string? _selectedNodeId;
|
||||||
private BrowseNode? _selectedNode;
|
private BrowseNode? _selectedNode;
|
||||||
@@ -355,7 +380,7 @@
|
|||||||
// Select button confirms and OnSelected fires with this node id.
|
// Select button confirms and OnSelected fires with this node id.
|
||||||
private void SelectBrowseNode(BrowseNode node)
|
private void SelectBrowseNode(BrowseNode node)
|
||||||
{
|
{
|
||||||
if (node.NodeClass != BrowseNodeClass.Variable) return;
|
if (!IsSelectable(node.NodeClass)) return;
|
||||||
_selectedNodeId = node.NodeId;
|
_selectedNodeId = node.NodeId;
|
||||||
_manualNodeId = node.NodeId;
|
_manualNodeId = node.NodeId;
|
||||||
_selectedNode = node;
|
_selectedNode = node;
|
||||||
@@ -363,7 +388,7 @@
|
|||||||
|
|
||||||
private void Select(TreeNode node)
|
private void Select(TreeNode node)
|
||||||
{
|
{
|
||||||
if (node.NodeClass != BrowseNodeClass.Variable) return;
|
if (!IsSelectable(node.NodeClass)) return;
|
||||||
_selectedNodeId = node.NodeId;
|
_selectedNodeId = node.NodeId;
|
||||||
_manualNodeId = node.NodeId;
|
_manualNodeId = node.NodeId;
|
||||||
_selectedNode = node.Source;
|
_selectedNode = node.Source;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@if (Node.NodeClass == BrowseNodeClass.Variable)
|
@if (IsSelectable)
|
||||||
{
|
{
|
||||||
@* Display name (+ type badge) on the first line, the node id clipped
|
@* 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
|
beneath it: an OPC UA node id runs 60–120 characters and inline it
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
<ul class="list-unstyled ms-4">
|
<ul class="list-unstyled ms-4">
|
||||||
@foreach (var child in Node.Children)
|
@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))
|
@if (!string.IsNullOrEmpty(Node.ContinuationToken))
|
||||||
{
|
{
|
||||||
@@ -74,4 +74,14 @@
|
|||||||
[Parameter] public EventCallback<NodeBrowserDialog.TreeNode> OnSelect { get; set; }
|
[Parameter] public EventCallback<NodeBrowserDialog.TreeNode> OnSelect { get; set; }
|
||||||
[Parameter] public EventCallback<NodeBrowserDialog.TreeNode> OnLoadMore { get; set; }
|
[Parameter] public EventCallback<NodeBrowserDialog.TreeNode> OnLoadMore { get; set; }
|
||||||
[Parameter] public string? SelectedNodeId { 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 AddressSpaceMatch(
|
||||||
new BrowseNode("ns=2;s=Pump1.Speed", "Speed", BrowseNodeClass.Variable, HasChildren: false, DataType: "Double"),
|
new BrowseNode("ns=2;s=Pump1.Speed", "Speed", BrowseNodeClass.Variable, HasChildren: false, DataType: "Double"),
|
||||||
Path: "Devices/Pump1/Speed"),
|
Path: "Devices/Pump1/Speed"),
|
||||||
|
new AddressSpaceMatch(
|
||||||
|
new BrowseNode("ns=2;s=Pump1", "Pump1", BrowseNodeClass.Object, HasChildren: true),
|
||||||
|
Path: "Devices/Pump1"),
|
||||||
},
|
},
|
||||||
CapReached: false, Failure: null));
|
CapReached: false, Failure: null));
|
||||||
}
|
}
|
||||||
@@ -100,4 +103,45 @@ public class NodeBrowserDialogSelectionTests : BunitContext
|
|||||||
Assert.Equal("Area1.Pump.Speed", captured!.NodeId);
|
Assert.Equal("Area1.Pump.Speed", captured!.NodeId);
|
||||||
Assert.Equal("Float", captured.DataType);
|
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