feat(adminui): shared lazy DriverBrowseTree component with per-node filter
This commit is contained in:
+154
@@ -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
|
||||
|
||||
<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…</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) }
|
||||
}
|
||||
</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; }
|
||||
|
||||
private bool _loading = true;
|
||||
private string? _error;
|
||||
private List<TreeItem>? _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" : "";
|
||||
<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"
|
||||
@onclick="@(() => ToggleAsync(item))" style="width:18px">
|
||||
@(item.Expanded ? "▼" : "▶")
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span style="width:18px"></span>
|
||||
}
|
||||
<a href="#" @onclick="@(() => SelectAsync(item))" @onclick:preventDefault
|
||||
class="text-decoration-none mono small">@item.Node.DisplayName</a>
|
||||
@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…
|
||||
</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)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user