Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverBrowseTree.razor
T
Joseph Doherty ec652f6bde ui(adminui): group driver/acl shared button pairs, adopt notice-alert + tree-twist
Sweep task 5 (Shared/Drivers + Acls):
- btn-group the Save/Cancel pair in DriverFormShell (all 13 driver forms),
  CollectionEditor foot + per-row Edit/Delete, DriverConfigModal /
  DeviceModal / DriverTagPicker / AclScopePickerModal feet,
  DriverStatusPanel Reconnect+Restart and its inline restart confirm,
  and the Refresh+Close browse toolbars on the FOCAS/AbCip/TwinCAT pickers.
- Replace the no-op inline border-color:var(--alert) tint with .notice-alert
  in DriverFormShell, DriverConfigModal, DeviceModal, MqttDriverForm,
  MTConnectDriverForm, MqttDeviceForm.
- Replace inline style="width:18px" expander sizing with .tree-twist in
  DriverBrowseTree and AclScopeTree.

Markup only — every @onclick, binding, disabled and arm->confirm flow is
unchanged.
2026-08-10 16:32:47 -04:00

220 lines
9.8 KiB
Plaintext

@* Lazy tree component with per-node text filter. Driver-agnostic — consumes
IBrowserSessionService for root/expand. Selected node is bound back to parent
via OnNodeSelected EventCallback. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing
@using ZB.MOM.WW.OtOpcUa.Commons.Browsing
@inject IBrowserSessionService BrowserService
<div class="border rounded p-2" style="max-height:420px; overflow:auto; min-height:240px">
@if (_loading)
{
<div class="text-muted small"><span class="spinner-border spinner-border-sm me-1"></span>Loading&hellip;</div>
}
else if (_error is not null)
{
<div class="text-danger small">@_error</div>
}
else if (_roots is null || _roots.Count == 0)
{
<div class="text-muted small">No nodes.</div>
}
else
{
@foreach (var n in _roots) { @RenderNode(n, 0, System.Array.Empty<string>()) }
}
</div>
@code {
/// <summary>The browse-session token returned by IBrowserSessionService.OpenAsync.</summary>
[Parameter, EditorRequired] public Guid SessionToken { get; set; }
/// <summary>The currently-selected node's NodeId, for visual selection highlighting.</summary>
[Parameter] public string SelectedNodeId { get; set; } = "";
/// <summary>Fired when the user clicks a leaf (or any node — caller decides what to do with it).</summary>
[Parameter] public EventCallback<BrowseNode> OnNodeSelected { get; set; }
/// <summary>The same click as <see cref="OnNodeSelected"/>, plus the node's captured ancestor-folder
/// path (root→parent) — which is how a caller learns a node's DEPTH, and therefore what kind of thing
/// it is in a driver whose tree levels mean different things (MQTT/Sparkplug: group / edge node /
/// device). Both callbacks fire; wiring only the first is the existing behaviour.</summary>
[Parameter] public EventCallback<ZB.MOM.WW.OtOpcUa.AdminUI.Browsing.BrowseLeafSelection> OnNodeSelectedWithPath { get; set; }
/// <summary>When true, leaves render a selection checkbox and clicking a leaf toggles it (WP6 "Browse
/// device…" multi-select) instead of single-selecting. Folders still expand/navigate as usual. Default
/// false preserves the single-select address-picker behavior.</summary>
[Parameter] public bool MultiSelect { get; set; }
/// <summary>In multi-select mode, the set of currently-selected leaf NodeIds (drives checkbox state +
/// row highlight). Owned by the parent; the tree only reads it.</summary>
[Parameter] public IReadOnlyCollection<string>? SelectedLeafIds { get; set; }
/// <summary>In multi-select mode, fired when a leaf's checkbox is toggled — carries the leaf plus its
/// captured ancestor-folder display-name path (root→parent) for optional TagGroup mirroring.</summary>
[Parameter] public EventCallback<ZB.MOM.WW.OtOpcUa.AdminUI.Browsing.BrowseLeafSelection> OnLeafToggled { get; set; }
private bool _loading = true;
private string? _error;
private List<TreeItem>? _roots;
private string _selectedNodeIdLocal = "";
protected override void OnParametersSet()
{
_selectedNodeIdLocal = SelectedNodeId ?? "";
}
protected override async Task OnInitializedAsync()
{
await LoadRootAsync();
}
private async Task LoadRootAsync()
{
try
{
var roots = await BrowserService.RootAsync(SessionToken, default);
_roots = roots.Select(n => new TreeItem(n)).ToList();
}
catch (Exception ex) { _error = ex.Message; }
finally { _loading = false; }
}
private async Task ToggleAsync(TreeItem item)
{
item.Expanded = !item.Expanded;
if (item.Expanded && !item.Loaded) await ExpandAsync(item);
}
private async Task ExpandAsync(TreeItem item)
{
if (item.Loaded || item.Loading) return;
item.Loading = true; StateHasChanged();
try
{
var kids = await BrowserService.ExpandAsync(SessionToken, item.Node.NodeId, default);
item.Children = kids.Select(k => new TreeItem(k)).ToList();
item.Loaded = true;
}
catch (Exception ex) { item.Error = ex.Message; }
finally { item.Loading = false; StateHasChanged(); }
}
private async Task SelectAsync(TreeItem item, IReadOnlyList<string> ancestorPath)
{
_selectedNodeIdLocal = item.Node.NodeId;
await OnNodeSelected.InvokeAsync(item.Node);
await OnNodeSelectedWithPath.InvokeAsync(
new ZB.MOM.WW.OtOpcUa.AdminUI.Browsing.BrowseLeafSelection(item.Node, ancestorPath));
}
private async Task ToggleLeafAsync(TreeItem item, IReadOnlyList<string> ancestorPath)
{
await OnLeafToggled.InvokeAsync(
new ZB.MOM.WW.OtOpcUa.AdminUI.Browsing.BrowseLeafSelection(item.Node, ancestorPath));
}
private bool IsLeafSelected(string nodeId) => SelectedLeafIds?.Contains(nodeId) == true;
private static IReadOnlyList<string> Append(IReadOnlyList<string> path, string segment)
{
var list = new List<string>(path.Count + 1);
list.AddRange(path);
list.Add(segment);
return list;
}
private RenderFragment RenderNode(TreeItem item, int depth, IReadOnlyList<string> ancestorPath) => __builder =>
{
var indent = $"padding-left:{depth * 18}px";
var isMultiLeaf = MultiSelect && item.Node.Kind == BrowseNodeKind.Leaf;
var selectedCls = (isMultiLeaf ? IsLeafSelected(item.Node.NodeId) : _selectedNodeIdLocal == item.Node.NodeId)
? "bg-primary-subtle" : "";
<div class="d-flex align-items-center gap-1 py-1 @selectedCls" style="@indent">
@if (item.Node.Kind == BrowseNodeKind.Folder && item.Node.HasChildrenHint)
{
<button type="button" class="btn btn-sm btn-link p-0 tree-twist"
@onclick="@(() => ToggleAsync(item))">
@(item.Expanded ? "▼" : "▶")
</button>
}
else
{
<span class="tree-twist"></span>
}
@* BUTTONS, NOT <a href="#">. This is a Blazor Web App: blazor.web.js installs a
document-level click interceptor for enhanced navigation, and it resolves a bare "#"
against <base href="/"> rather than the current URL — so clicking a node label
navigated the whole AdminUI to "/", tore down the circuit, and destroyed whatever modal
the tree was hosted in (losing the browse session AND the tag selection with it).
@onclick:preventDefault does not help: it suppresses the BROWSER's default action, not
Blazor's own interceptor. A <button> is never a navigation candidate, so the handler is
the only thing that runs. Found by the MQTT/Sparkplug P2 live gate — the Request-rebirth
scope is chosen by clicking a FOLDER label, which is the first feature that ever
required clicking a label rather than the ▶ toggle (a button, which always worked). *@
@if (isMultiLeaf)
{
<input type="checkbox" class="form-check-input" checked="@IsLeafSelected(item.Node.NodeId)"
@onchange="@(() => ToggleLeafAsync(item, ancestorPath))" />
<button type="button" @onclick="@(() => ToggleLeafAsync(item, ancestorPath))"
class="btn btn-link btn-sm p-0 text-decoration-none mono small text-start">@item.Node.DisplayName</button>
}
else
{
<button type="button" @onclick="@(() => SelectAsync(item, ancestorPath))"
class="btn btn-link btn-sm p-0 text-decoration-none mono small text-start">@item.Node.DisplayName</button>
}
@if (item.Node.Kind == BrowseNodeKind.Leaf)
{
<span class="chip chip-idle ms-1" style="font-size:0.7rem">leaf</span>
}
</div>
@if (item.Expanded && item.Loading)
{
<div class="small text-muted" style="@($"padding-left:{(depth + 1) * 18}px")">
<span class="spinner-border spinner-border-sm me-1"></span>Loading&hellip;
</div>
}
else if (item.Expanded && item.Error is not null)
{
<div class="small text-danger" style="@($"padding-left:{(depth + 1) * 18}px")">
@item.Error
</div>
}
else if (item.Expanded && item.Loaded && item.Children is { Count: > 0 })
{
<input type="text" class="form-control form-control-sm mt-1"
placeholder="filter children..."
style="@($"width:calc(100% - {(depth + 2) * 18}px); margin-left:{(depth + 2) * 18}px")"
@bind="item.Filter" @bind:event="oninput" />
@foreach (var c in FilterChildren(item))
{
@RenderNode(c, depth + 1, Append(ancestorPath, item.Node.DisplayName))
}
}
};
private static IEnumerable<TreeItem> FilterChildren(TreeItem item)
{
if (item.Children is null) yield break;
var f = item.Filter?.Trim();
foreach (var c in item.Children)
{
if (string.IsNullOrEmpty(f)) { yield return c; continue; }
if (c.Node.DisplayName.Contains(f, StringComparison.OrdinalIgnoreCase) ||
c.Node.NodeId.Contains(f, StringComparison.OrdinalIgnoreCase))
yield return c;
}
}
private sealed class TreeItem(BrowseNode node)
{
public BrowseNode Node { get; } = node;
public bool Expanded { get; set; }
public bool Loaded { get; set; }
public bool Loading { get; set; }
public string? Error { get; set; }
public List<TreeItem>? Children { get; set; }
public string? Filter { get; set; }
}
}