diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverBrowseTree.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverBrowseTree.razor new file mode 100644 index 00000000..214fda1c --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverBrowseTree.razor @@ -0,0 +1,154 @@ +@* 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 + +
+ @if (_loading) + { +
Loading…
+ } + else if (_error is not null) + { +
@_error
+ } + else if (_roots is null || _roots.Count == 0) + { +
No nodes.
+ } + else + { + @foreach (var n in _roots) { @RenderNode(n, 0) } + } +
+ +@code { + /// The browse-session token returned by IBrowserSessionService.OpenAsync. + [Parameter, EditorRequired] public Guid SessionToken { get; set; } + + /// The currently-selected node's NodeId, for visual selection highlighting. + [Parameter] public string SelectedNodeId { get; set; } = ""; + + /// Fired when the user clicks a leaf (or any node — caller decides what to do with it). + [Parameter] public EventCallback OnNodeSelected { get; set; } + + private bool _loading = true; + private string? _error; + private List? _roots; + + 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) + { + SelectedNodeId = item.Node.NodeId; + await OnNodeSelected.InvokeAsync(item.Node); + } + + private RenderFragment RenderNode(TreeItem item, int depth) => __builder => + { + var indent = $"padding-left:{depth * 18}px"; + var selectedCls = SelectedNodeId == item.Node.NodeId ? "bg-primary-subtle" : ""; +
+ @if (item.Node.Kind == BrowseNodeKind.Folder && item.Node.HasChildrenHint) + { + + } + else + { + + } + @item.Node.DisplayName + @if (item.Node.Kind == BrowseNodeKind.Leaf) + { + leaf + } +
+ @if (item.Expanded && item.Loading) + { +
+ Loading… +
+ } + else if (item.Expanded && item.Error is not null) + { +
+ @item.Error +
+ } + else if (item.Expanded && item.Loaded && item.Children is { Count: > 0 }) + { + + @foreach (var c in FilterChildren(item)) + { + @RenderNode(c, depth + 1) + } + } + }; + + private static IEnumerable 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? Children { get; set; } + public string? Filter { get; set; } + } +}