@* Recursive, lazily-expanding renderer for the v3 Raw project tree (/raw). Unlike UnsTree (which eager-loads the whole structural tree and never touches the RawNode lazy fields), THIS component actually exercises the lazy plumbing: expanding an unloaded container calls IRawTreeService.LoadChildrenAsync, appends the returned level into node.Children and flips Loaded/Loading, showing a spinner in flight and an error row on failure. Per-node context menus (right-click surface + "⋯" fallback) are built per RawNodeKind. Wave-B integration (this pass): every named handler now opens the REAL modal/dialog (Configure driver/device, Edit/Manual/CSV tag surfaces) or calls IRawTreeService directly (New folder/driver/group, Rename, Delete, Toggle) via the small dialogs built alongside — RawNameDialog / RawConfirmDialog / RawDriverTypeDialog — and refreshes the affected subtree afterwards (reload the container for create-under ops, the parent for item-level ops). Only Browse device stays a placeholder (Wave C owns it). The former RawStubModal is repurposed here as the shared message surface for delete-blockers, rename warnings, and the Browse "coming soon" note. This component is a single instance that recurses via the RenderNode RenderFragment, so it owns one set of modals + one injected service for the whole tree. *@ @using ZB.MOM.WW.OtOpcUa.AdminUI.Uns @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers @inject IRawTreeService Svc @foreach (var root in Roots) { @RenderNode(root, 0) } @* --- Real modals + dialogs (one instance each for the whole recursive tree) --- *@ @* Shared message surface (repurposed RawStubModal) — delete blockers, rename warnings, Browse note. *@ @code { /// The top-level Raw nodes to render (the enterprise roots from LoadRootsAsync). [Parameter, EditorRequired] public IReadOnlyList Roots { get; set; } = default!; /// /// Optional safety valve: raised when a mutation's affected node has no in-tree parent to reload /// (only reachable for a cluster-less top-level node). Every menu-bearing node's parent is in the /// tree today, so this is normally a no-op; the page may wire it to reload roots if that ever changes. /// [Parameter] public EventCallback OnRootsChanged { get; set; } // --- Configure-driver modal (edit) --- private bool _driverCfgVisible; private string? _driverCfgId; private RawNode? _driverCfgNode; // --- Device modal (create + edit) --- private bool _deviceVisible; private string? _deviceEditId; private string? _deviceCreateDriverId; private string? _deviceDriverType; private RawNode? _deviceNode; private bool _deviceIsCreate; // --- Edit-tag modal --- private bool _tagVisible; private string? _tagId; private string? _tagDriverType; private RawNode? _tagNode; // --- Manual tag-entry modal --- private bool _manualVisible; private string _manualDeviceId = ""; private string? _manualTagGroupId; private string _manualDriverType = ""; private RawNode? _manualNode; // --- CSV import modal --- private bool _csvImportVisible; private string _csvImportDeviceId = ""; private string? _csvImportTagGroupId; private string _csvImportDriverType = ""; private RawNode? _csvImportNode; // --- CSV export modal (read-only, no refresh) --- private bool _csvExportVisible; private string _csvExportDeviceId = ""; private string? _csvExportTagGroupId; private string _csvExportDriverType = ""; // --- Name dialog (New folder/group, Rename) --- private bool _nameVisible; private string _nameTitle = ""; private string _nameInitial = ""; private Func? _nameSubmit; // --- Confirm dialog (deletes) --- private bool _confirmVisible; private string _confirmTitle = ""; private string _confirmMessage = ""; private Func? _confirmAction; // --- Driver-type dialog (New driver) --- private bool _driverTypeVisible; private Func<(string DriverType, string Name), Task>? _driverTypeSubmit; // --- Shared message surface (repurposed RawStubModal): delete blockers, rename warnings, Browse note --- private bool _msgVisible; private string _msgTitle = ""; private string _msgMessage = ""; private string? _msgNodeName; // --------------------------------------------------------------------------- // Row rendering // --------------------------------------------------------------------------- private RenderFragment RenderNode(RawNode node, int depth) => __builder => { var indent = $"padding-left:{depth * 18}px"; var hasExpander = node.HasLazyChildren || node.Children.Count > 0; var muted = node.Enabled == false && node.Kind is RawNodeKind.Driver or RawNodeKind.Device; var items = MenuFor(node);
@if (hasExpander) { } else { } @if (items.Count > 0) { @RenderRowBody(node, muted) } else { @RenderRowBody(node, muted) }
@if (node.Expanded && node.Loading) {
Loading…
} else if (node.Expanded && node.Error is not null) {
@node.Error
} else if (node.Expanded) { @foreach (var child in node.Children) { @RenderNode(child, depth + 1) } } }; // The icon + label + badge shown for a node — the ContextMenu right-click surface. private RenderFragment RenderRowBody(RawNode node, bool muted) => __builder => { @node.DisplayName @if (muted) { disabled } @if (node.ChildCount > 0) { @node.ChildCount } }; private static string KindIcon(RawNodeKind kind) => kind switch { RawNodeKind.Enterprise => "🏢", RawNodeKind.Cluster => "🖥️", RawNodeKind.Folder => "📁", RawNodeKind.Driver => "🔌", RawNodeKind.Device => "📟", RawNodeKind.TagGroup => "🗂️", RawNodeKind.Tag => "🏷️", _ => "•", }; // --------------------------------------------------------------------------- // Lazy expansion — the whole point of WP2 (UNS never did this). // --------------------------------------------------------------------------- /// /// Toggles a node's expansion. On first expand of an unloaded container this sets /// Loading, awaits , appends the /// returned level into Children, flips Loaded, and re-renders — showing a /// spinner in flight and an error row on failure. A leaf (Tag) has no expander so never /// reaches here. /// private async Task ToggleAsync(RawNode node) { if (node.Loading) { return; } node.Expanded = !node.Expanded; if (node.Expanded && node.HasLazyChildren && !node.Loaded) { node.Loading = true; node.Error = null; StateHasChanged(); try { var children = await Svc.LoadChildrenAsync(node); node.Children.Clear(); node.Children.AddRange(children); node.ChildCount = node.Children.Count; node.Loaded = true; } catch (Exception ex) { node.Error = ex.Message; } finally { node.Loading = false; } StateHasChanged(); } } // --------------------------------------------------------------------------- // Per-kind context menus. Every OnClick opens a placeholder in this wave; the // named handlers below are the seam Wave B/C replaces with the real modals. // --------------------------------------------------------------------------- private IReadOnlyList MenuFor(RawNode node) => node.Kind switch { RawNodeKind.Cluster => ClusterMenu(node), RawNodeKind.Folder => FolderMenu(node), RawNodeKind.Driver => DriverMenu(node), RawNodeKind.Device => DeviceMenu(node), RawNodeKind.TagGroup => TagGroupMenu(node), RawNodeKind.Tag => TagMenu(node), _ => Array.Empty(), // Enterprise: label only, no menu. }; private List ClusterMenu(RawNode node) => new() { new() { Label = "New folder", Icon = "📁", OnClick = () => OnNewFolder(node) }, new() { Label = "New driver", Icon = "🔌", OnClick = () => OnNewDriver(node) }, }; private List FolderMenu(RawNode node) => new() { new() { Label = "New folder", Icon = "📁", OnClick = () => OnNewFolder(node) }, new() { Label = "New driver", Icon = "🔌", OnClick = () => OnNewDriver(node) }, ContextMenuItem.Separator(), new() { Label = "Rename", Icon = "✏️", OnClick = () => OnRenameFolder(node) }, new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteFolder(node) }, }; private List DriverMenu(RawNode node) => new() { new() { Label = "Configure", Icon = "⚙", OnClick = () => OnConfigureDriver(node) }, new() { Label = "New device", Icon = "📟", OnClick = () => OnNewDevice(node) }, ContextMenuItem.Separator(), new() { Label = node.Enabled ? "Disable" : "Enable", Icon = node.Enabled ? "⏸" : "▶", OnClick = () => OnToggleDriverEnabled(node) }, new() { Label = "Rename", Icon = "✏️", OnClick = () => OnRenameDriver(node) }, new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteDriver(node) }, }; private List DeviceMenu(RawNode node) => new() { new() { Label = "Configure", Icon = "⚙", OnClick = () => OnConfigureDevice(node) }, new() { Label = "New tag-group", Icon = "🗂️", OnClick = () => OnNewTagGroup(node) }, ContextMenuItem.Separator(), // "Add tags ▸" flattened — ContextMenu is single-level, so the submenu is a labelled group. new() { Label = "Add tags", Disabled = true }, new() { Label = "Manual entry", Icon = "✍️", OnClick = () => OnAddManualTags(node) }, new() { Label = "Import CSV", Icon = "📥", OnClick = () => OnImportCsv(node) }, new() { Label = "Export CSV", Icon = "📤", OnClick = () => OnExportCsv(node) }, new() { Label = "Browse device…", Icon = "🔭", OnClick = () => OnBrowseDevice(node) }, ContextMenuItem.Separator(), new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteDevice(node) }, }; private List TagGroupMenu(RawNode node) => new() { new() { Label = "New group", Icon = "🗂️", OnClick = () => OnNewGroup(node) }, ContextMenuItem.Separator(), new() { Label = "Add tags", Disabled = true }, new() { Label = "Manual entry", Icon = "✍️", OnClick = () => OnAddManualTags(node) }, new() { Label = "Import CSV", Icon = "📥", OnClick = () => OnImportCsv(node) }, new() { Label = "Export CSV", Icon = "📤", OnClick = () => OnExportCsv(node) }, new() { Label = "Browse device…", Icon = "🔭", OnClick = () => OnBrowseDevice(node) }, ContextMenuItem.Separator(), new() { Label = "Rename", Icon = "✏️", OnClick = () => OnRenameTagGroup(node) }, new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteTagGroup(node) }, }; private List TagMenu(RawNode node) => new() { new() { Label = "Edit", Icon = "✏️", OnClick = () => OnEditTag(node) }, new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteTag(node) }, }; // --------------------------------------------------------------------------- // Named action handlers — each opens the real modal/dialog or calls the service // directly, then refreshes the affected subtree. Only OnBrowseDevice is a // placeholder (Wave C owns device browse). // --------------------------------------------------------------------------- // --- Folder / Cluster --- // New folder under a Cluster (parentFolderId=null) or a Folder (parentFolderId=this folder). private Task OnNewFolder(RawNode node) { var clusterId = node.Kind == RawNodeKind.Cluster ? (node.ClusterId ?? node.EntityId) : node.ClusterId; var parentFolderId = node.Kind == RawNodeKind.Folder ? node.EntityId : null; ShowNameDialog("New folder", "", async name => { var res = await Svc.CreateFolderAsync(clusterId ?? "", parentFolderId, name); if (!res.Ok) { ShowMessage("New folder failed", res.Error ?? "Create failed.", node.DisplayName); return; } await ReloadNodeAsync(node); }); return Task.CompletedTask; } // New driver: pick type + name, then create a minimal driver ("{}") — configure it afterward. private Task OnNewDriver(RawNode node) { var clusterId = node.Kind == RawNodeKind.Cluster ? (node.ClusterId ?? node.EntityId) : node.ClusterId; var folderId = node.Kind == RawNodeKind.Folder ? node.EntityId : null; ShowDriverTypeDialog(async choice => { var res = await Svc.CreateDriverAsync(clusterId ?? "", folderId, choice.Name, choice.DriverType, "{}"); if (!res.Ok) { ShowMessage("New driver failed", res.Error ?? "Create failed.", node.DisplayName); return; } await ReloadNodeAsync(node); }); return Task.CompletedTask; } private Task OnRenameFolder(RawNode node) { ShowNameDialog("Rename folder", node.DisplayName, async name => await AfterRename(await Svc.RenameFolderAsync(node.EntityId!, name, node.RowVersion), node)); return Task.CompletedTask; } private Task OnDeleteFolder(RawNode node) { ShowConfirm("Delete folder", $"Delete folder “{node.DisplayName}”?", async () => await AfterDelete(await Svc.DeleteFolderAsync(node.EntityId!, node.RowVersion), node)); return Task.CompletedTask; } // --- Driver --- private Task OnConfigureDriver(RawNode node) { _driverCfgId = node.EntityId; _driverCfgNode = node; _driverCfgVisible = true; StateHasChanged(); return Task.CompletedTask; } private Task OnNewDevice(RawNode node) { _deviceIsCreate = true; _deviceEditId = null; _deviceCreateDriverId = node.EntityId; _deviceDriverType = node.DriverType; _deviceNode = node; _deviceVisible = true; StateHasChanged(); return Task.CompletedTask; } private async Task OnToggleDriverEnabled(RawNode node) { var res = await Svc.SetDriverEnabledAsync(node.EntityId!, !node.Enabled, node.RowVersion); if (!res.Ok) { ShowMessage("Update failed", res.Error ?? "Could not change enabled state.", node.DisplayName); return; } await ReloadParentAsync(node); } private Task OnRenameDriver(RawNode node) { ShowNameDialog("Rename driver", node.DisplayName, async name => await AfterRename(await Svc.RenameDriverAsync(node.EntityId!, name, node.RowVersion), node)); return Task.CompletedTask; } private Task OnDeleteDriver(RawNode node) { ShowConfirm("Delete driver", $"Delete driver “{node.DisplayName}” and its empty devices?", async () => await AfterDelete(await Svc.DeleteDriverAsync(node.EntityId!, node.RowVersion), node)); return Task.CompletedTask; } // --- Device --- private Task OnConfigureDevice(RawNode node) { _deviceIsCreate = false; _deviceEditId = node.EntityId; _deviceCreateDriverId = null; _deviceDriverType = node.DriverType; _deviceNode = node; _deviceVisible = true; StateHasChanged(); return Task.CompletedTask; } private Task OnNewTagGroup(RawNode node) { // node is a Device — its root tag-groups. ShowNameDialog("New tag-group", "", async name => { var res = await Svc.CreateTagGroupAsync(node.EntityId!, null, name); if (!res.Ok) { ShowMessage("New tag-group failed", res.Error ?? "Create failed.", node.DisplayName); return; } await ReloadNodeAsync(node); }); return Task.CompletedTask; } private Task OnDeleteDevice(RawNode node) { ShowConfirm("Delete device", $"Delete device “{node.DisplayName}”?", async () => await AfterDelete(await Svc.DeleteDeviceAsync(node.EntityId!, node.RowVersion), node)); return Task.CompletedTask; } // --- Add-tags surfaces (shared by Device + TagGroup) --- private Task OnAddManualTags(RawNode node) { // Manual entry needs the device id: for a Device node it's node itself; for a TagGroup walk up. _manualDeviceId = FindAncestorDevice(node)?.EntityId ?? ""; _manualTagGroupId = node.Kind == RawNodeKind.TagGroup ? node.EntityId : null; _manualDriverType = node.DriverType ?? ""; _manualNode = node; _manualVisible = true; StateHasChanged(); return Task.CompletedTask; } private Task OnImportCsv(RawNode node) { // Device node → DeviceId; TagGroup node → TagGroupId (the modal self-resolves its device). if (node.Kind == RawNodeKind.Device) { _csvImportDeviceId = node.EntityId ?? ""; _csvImportTagGroupId = null; } else { _csvImportDeviceId = ""; _csvImportTagGroupId = node.EntityId; } _csvImportDriverType = node.DriverType ?? ""; _csvImportNode = node; _csvImportVisible = true; StateHasChanged(); return Task.CompletedTask; } private Task OnExportCsv(RawNode node) { if (node.Kind == RawNodeKind.Device) { _csvExportDeviceId = node.EntityId ?? ""; _csvExportTagGroupId = null; } else { _csvExportDeviceId = ""; _csvExportTagGroupId = node.EntityId; } _csvExportDriverType = node.DriverType ?? ""; _csvExportVisible = true; StateHasChanged(); return Task.CompletedTask; } // Browse device stays a placeholder — Wave C (WP6) wires the discovery-browser modal. private Task OnBrowseDevice(RawNode node) { ShowMessage("Browse device", "Device browse arrives in the browse wave (Wave C).", node.DisplayName); return Task.CompletedTask; } // --- TagGroup --- private Task OnNewGroup(RawNode node) { // node is a TagGroup — create a sub-group under it (needs its owning device id). var deviceId = FindAncestorDevice(node)?.EntityId ?? ""; ShowNameDialog("New group", "", async name => { var res = await Svc.CreateTagGroupAsync(deviceId, node.EntityId, name); if (!res.Ok) { ShowMessage("New group failed", res.Error ?? "Create failed.", node.DisplayName); return; } await ReloadNodeAsync(node); }); return Task.CompletedTask; } private Task OnRenameTagGroup(RawNode node) { ShowNameDialog("Rename tag-group", node.DisplayName, async name => await AfterRename(await Svc.RenameTagGroupAsync(node.EntityId!, name, node.RowVersion), node)); return Task.CompletedTask; } private Task OnDeleteTagGroup(RawNode node) { ShowConfirm("Delete tag-group", $"Delete tag-group “{node.DisplayName}”?", async () => await AfterDelete(await Svc.DeleteTagGroupAsync(node.EntityId!, node.RowVersion), node)); return Task.CompletedTask; } // --- Tag --- private Task OnEditTag(RawNode node) { _tagId = node.EntityId; _tagDriverType = node.DriverType; _tagNode = node; _tagVisible = true; StateHasChanged(); return Task.CompletedTask; } private Task OnDeleteTag(RawNode node) { ShowConfirm("Delete tag", $"Delete tag “{node.DisplayName}”?", async () => await AfterDelete(await Svc.DeleteTagAsync(node.EntityId!, node.RowVersion), node)); return Task.CompletedTask; } // --------------------------------------------------------------------------- // Modal OnSaved callbacks — refresh the affected subtree. // --------------------------------------------------------------------------- private async Task DriverCfgSaved() { if (_driverCfgNode is not null) { await ReloadParentAsync(_driverCfgNode); } } private async Task DeviceSaved() { if (_deviceNode is null) { return; } // Create → reload the driver container; edit → reload the device's parent driver. if (_deviceIsCreate) { await ReloadNodeAsync(_deviceNode); } else { await ReloadParentAsync(_deviceNode); } } private async Task TagSaved() { if (_tagNode is not null) { await ReloadParentAsync(_tagNode); } } private async Task ManualSaved() { if (_manualNode is not null) { await ReloadNodeAsync(_manualNode); } } private async Task CsvImportSaved() { if (_csvImportNode is not null) { await ReloadNodeAsync(_csvImportNode); } } // --------------------------------------------------------------------------- // Rename / delete outcome surfacing. // --------------------------------------------------------------------------- private async Task AfterRename(RawRenameResult res, RawNode node) { if (res.Ok) { await ReloadParentAsync(node); } if (!res.Ok) { ShowMessage("Rename failed", res.Error ?? "Rename failed.", node.DisplayName); } else if (res.Warnings.Count > 0) { ShowMessage("Renamed — with warnings", string.Join(" • ", res.Warnings), node.DisplayName); } } private async Task AfterDelete(UnsMutationResult res, RawNode node) { if (!res.Ok) { ShowMessage("Delete blocked", res.Error ?? "Delete failed.", node.DisplayName); return; } await ReloadParentAsync(node); } // --------------------------------------------------------------------------- // Dialog show-helpers (one instance each; a pending delegate carries the op). // --------------------------------------------------------------------------- private void ShowNameDialog(string title, string initial, Func submit) { _nameTitle = title; _nameInitial = initial; _nameSubmit = submit; _nameVisible = true; StateHasChanged(); } private async Task NameSubmitted(string name) { var submit = _nameSubmit; if (submit is not null) { await submit(name); } } private void ShowConfirm(string title, string message, Func action) { _confirmTitle = title; _confirmMessage = message; _confirmAction = action; _confirmVisible = true; StateHasChanged(); } private async Task Confirmed() { var action = _confirmAction; if (action is not null) { await action(); } } private void ShowDriverTypeDialog(Func<(string DriverType, string Name), Task> submit) { _driverTypeSubmit = submit; _driverTypeVisible = true; StateHasChanged(); } private async Task DriverTypeSubmitted((string DriverType, string Name) choice) { var submit = _driverTypeSubmit; if (submit is not null) { await submit(choice); } } // --------------------------------------------------------------------------- // Shared message surface (repurposed RawStubModal). // --------------------------------------------------------------------------- private void ShowMessage(string title, string message, string? nodeName) { _msgTitle = title; _msgMessage = message; _msgNodeName = nodeName; _msgVisible = true; StateHasChanged(); } private void CloseMessage() { _msgVisible = false; _msgTitle = ""; _msgMessage = ""; _msgNodeName = null; } // --------------------------------------------------------------------------- // Tree refresh + parent lookup. // --------------------------------------------------------------------------- /// /// Reloads a container node's children in place (create-under refresh): clears its loaded level and /// re-runs the lazy load, keeping it expanded. A refresh failure is captured on the node, never thrown. /// private async Task ReloadNodeAsync(RawNode container) { try { container.Expanded = true; container.Loaded = false; container.Children.Clear(); container.Error = null; if (container.HasLazyChildren) { container.Loading = true; StateHasChanged(); var children = await Svc.LoadChildrenAsync(container); container.Children.Clear(); container.Children.AddRange(children); container.ChildCount = container.Children.Count; container.Loaded = true; } } catch (Exception ex) { container.Error = ex.Message; } finally { container.Loading = false; } StateHasChanged(); } /// /// Reloads the parent container of an item-level node (rename/delete/toggle/edit refresh) so a changed /// name / removed row / muted styling is reflected while preserving surrounding expansion state. Every /// menu-bearing node's parent is in the loaded tree; the OnRootsChanged fallback is a belt-and-braces /// path for a would-be cluster-less top-level node. /// private async Task ReloadParentAsync(RawNode node) { var parent = FindParent(node); if (parent is not null) { await ReloadNodeAsync(parent); } else { await OnRootsChanged.InvokeAsync(); } } /// Finds a node's parent by searching the currently-loaded tree; null for a root. private RawNode? FindParent(RawNode target) { foreach (var root in Roots) { var found = FindParentIn(root, target); if (found is not null) { return found; } } return null; } private static RawNode? FindParentIn(RawNode node, RawNode target) { foreach (var child in node.Children) { if (ReferenceEquals(child, target)) { return node; } var deeper = FindParentIn(child, target); if (deeper is not null) { return deeper; } } return null; } /// Walks up from (inclusive) to the nearest Device node, or null. private RawNode? FindAncestorDevice(RawNode node) { var current = node; while (current is not null) { if (current.Kind == RawNodeKind.Device) { return current; } current = FindParent(current); } return null; } }