diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawConfirmDialog.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawConfirmDialog.razor new file mode 100644 index 00000000..2d8ccda4 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawConfirmDialog.razor @@ -0,0 +1,73 @@ +@* Reusable confirm dialog for the /raw destructive operations (delete folder / driver / device / + tag-group / tag). Raises OnConfirm when the operator confirms; the caller runs the delete service + call and surfaces any UnsMutationResult.Error (a blocked delete names its blocker) via a follow-up + message. Markup mirrors the other /raw modal shells. *@ + +@if (Visible) +{ + + +} + +@code { + /// Whether the dialog is shown (supports @bind-Visible). + [Parameter] public bool Visible { get; set; } + + /// Two-way visibility callback. + [Parameter] public EventCallback VisibleChanged { get; set; } + + /// The dialog title (e.g. "Delete driver"). + [Parameter] public string Title { get; set; } = "Confirm"; + + /// The confirmation body message. + [Parameter] public string Message { get; set; } = ""; + + /// The confirm-button label. + [Parameter] public string ConfirmLabel { get; set; } = "Delete"; + + /// Raised when the operator confirms. The dialog awaits it, then self-closes. + [Parameter] public EventCallback OnConfirm { get; set; } + + private bool _busy; + + private async Task Confirm() + { + _busy = true; + try + { + await OnConfirm.InvokeAsync(); + } + finally + { + _busy = false; + } + await Close(); + } + + private Task Cancel() => Close(); + + private async Task Close() + { + Visible = false; + await VisibleChanged.InvokeAsync(false); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor new file mode 100644 index 00000000..e43dc14a --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor @@ -0,0 +1,105 @@ +@* Driver-type picker for the /raw "New driver" action: pick a driver type + name, then the caller + creates a minimal driver (CreateDriverAsync with "{}") and the operator configures it afterwards via + the Configure-driver modal. Calculation is included so a Calculation driver row is authorable now + (its factory lands in Wave C). Name is inline-validated as a RawPath segment. Markup mirrors the other + /raw modal shells. *@ +@using ZB.MOM.WW.OtOpcUa.Commons.Types +@using ZB.MOM.WW.OtOpcUa.Core.Abstractions + +@if (Visible) +{ + + +} + +@code { + /// Whether the dialog is shown (supports @bind-Visible). + [Parameter] public bool Visible { get; set; } + + /// Two-way visibility callback. + [Parameter] public EventCallback VisibleChanged { get; set; } + + /// Raised with the chosen driver type + name when the operator confirms; the dialog self-closes after. + [Parameter] public EventCallback<(string DriverType, string Name)> OnSubmit { get; set; } + + // Label → DriverType value. Galaxy's factory registers as "GalaxyMxGateway"; Calculation is + // authorable now though its factory arrives in Wave C. + private static readonly (string Label, string Value)[] Types = + [ + ("Modbus", DriverTypeNames.Modbus), + ("S7", DriverTypeNames.S7), + ("AbCip", DriverTypeNames.AbCip), + ("AbLegacy", DriverTypeNames.AbLegacy), + ("TwinCAT", DriverTypeNames.TwinCAT), + ("FOCAS", DriverTypeNames.FOCAS), + ("OpcUaClient", DriverTypeNames.OpcUaClient), + ("Galaxy", DriverTypeNames.Galaxy), + ("Calculation", "Calculation"), + ]; + + private string _type = DriverTypeNames.Modbus; + private string _name = ""; + private string? _error; + private bool _open; // seed once per open; preserve edits across unrelated re-renders + + protected override void OnParametersSet() + { + if (!Visible) { _open = false; return; } + if (_open) { return; } + _open = true; + _type = DriverTypeNames.Modbus; + _name = ""; + _error = null; + } + + private async Task Submit() + { + var err = RawPaths.ValidateSegment(_name); + if (err is not null) { _error = err; return; } + await OnSubmit.InvokeAsync((_type, _name)); + await Close(); + } + + private Task Cancel() => Close(); + + private async Task Close() + { + Visible = false; + _open = false; + await VisibleChanged.InvokeAsync(false); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawNameDialog.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawNameDialog.razor new file mode 100644 index 00000000..c7743808 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawNameDialog.razor @@ -0,0 +1,88 @@ +@* Reusable name-input dialog for the /raw create + rename operations (New folder / New tag-group / + New group / Rename folder-driver-device-tag-group). Inline-validates the input as a RawPath segment + (RawPaths.ValidateSegment) before raising OnSubmit; a valid submit self-closes the dialog and the + caller runs the create/rename service call + surfaces any server-side error/warnings. Markup mirrors + the other /raw modal shells (RawStubModal). *@ +@using ZB.MOM.WW.OtOpcUa.Commons.Types + +@if (Visible) +{ + + +} + +@code { + /// Whether the dialog is shown (supports @bind-Visible). + [Parameter] public bool Visible { get; set; } + + /// Two-way visibility callback. + [Parameter] public EventCallback VisibleChanged { get; set; } + + /// The dialog title (e.g. "New folder", "Rename driver"). + [Parameter] public string Title { get; set; } = "Name"; + + /// The initial value seeded into the input on open (blank for create, current name for rename). + [Parameter] public string Value { get; set; } = ""; + + /// Raised with the validated name when the operator confirms. The dialog self-closes after. + [Parameter] public EventCallback OnSubmit { get; set; } + + private string _value = ""; + private string? _error; + private bool _open; // seed the input once per open; preserve typing across unrelated re-renders + + protected override void OnParametersSet() + { + if (!Visible) { _open = false; return; } + if (_open) { return; } + _open = true; + _value = Value ?? ""; + _error = null; + } + + private async Task Submit() + { + var err = RawPaths.ValidateSegment(_value); + if (err is not null) { _error = err; return; } + await OnSubmit.InvokeAsync(_value); + await Close(); + } + + private async Task OnKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") { await Submit(); } + } + + private Task Cancel() => Close(); + + private async Task Close() + { + Visible = false; + _open = false; + await VisibleChanged.InvokeAsync(false); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor index ed14b6d6..8508711b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor @@ -5,13 +5,21 @@ 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; every action opens a placeholder - (RawStubModal) in this wave — Wave B/C replaces each named handler's body with the - real Configure / Device / TagModal / CSV / Browse modal. This component is a single - instance that recurses via the RenderNode RenderFragment, so it owns one stub modal - and one injected service for the whole tree. *@ + + "⋯" 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) @@ -19,18 +27,105 @@ @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!; - // --- Stub-placeholder state (one instance for the whole recursive tree) --- - private bool _stubVisible; - private string _stubTitle = ""; - private string _stubMessage = ""; - private string? _stubNodeName; + /// + /// 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 @@ -194,7 +289,6 @@ private List DriverMenu(RawNode node) => new() { new() { Label = "Configure", Icon = "⚙", OnClick = () => OnConfigureDriver(node) }, - new() { Label = "Test connect", Icon = "🔎", OnClick = () => OnTestConnect(node) }, new() { Label = "New device", Icon = "📟", OnClick = () => OnNewDevice(node) }, ContextMenuItem.Separator(), new() { Label = node.Enabled ? "Disable" : "Enable", Icon = node.Enabled ? "⏸" : "▶", @@ -239,63 +333,446 @@ }; // --------------------------------------------------------------------------- - // Named action handlers — the Wave B/C replacement seam. Each opens a placeholder - // today; a later wave swaps the body for the real modal invocation. + // 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 - private Task OnNewFolder(RawNode node) => ShowStub("New folder", node); - private Task OnNewDriver(RawNode node) => ShowStub("New driver", node); - private Task OnRenameFolder(RawNode node) => ShowStub("Rename folder", node); - private Task OnDeleteFolder(RawNode node) => ShowStub("Delete folder", node); + // --- Folder / Cluster --- - // Driver - private Task OnConfigureDriver(RawNode node) => ShowStub("Configure driver", node); - private Task OnTestConnect(RawNode node) => ShowStub("Test connect", node); - private Task OnNewDevice(RawNode node) => ShowStub("New device", node); - private Task OnToggleDriverEnabled(RawNode node) => ShowStub(node.Enabled ? "Disable driver" : "Enable driver", node); - private Task OnRenameDriver(RawNode node) => ShowStub("Rename driver", node); - private Task OnDeleteDriver(RawNode node) => ShowStub("Delete driver", node); - - // Device - private Task OnConfigureDevice(RawNode node) => ShowStub("Configure device", node); - private Task OnNewTagGroup(RawNode node) => ShowStub("New tag-group", node); - private Task OnDeleteDevice(RawNode node) => ShowStub("Delete device", node); - - // Add-tags submenu (shared by Device + TagGroup) - private Task OnAddManualTags(RawNode node) => ShowStub("Add tags — manual entry", node); - private Task OnImportCsv(RawNode node) => ShowStub("Import tags CSV", node); - private Task OnExportCsv(RawNode node) => ShowStub("Export tags CSV", node); - private Task OnBrowseDevice(RawNode node) => ShowStub("Browse device", node); - - // TagGroup - private Task OnNewGroup(RawNode node) => ShowStub("New group", node); - private Task OnRenameTagGroup(RawNode node) => ShowStub("Rename tag-group", node); - private Task OnDeleteTagGroup(RawNode node) => ShowStub("Delete tag-group", node); - - // Tag - private Task OnEditTag(RawNode node) => ShowStub("Edit tag", node); - private Task OnDeleteTag(RawNode node) => ShowStub("Delete tag", node); - - // --------------------------------------------------------------------------- - // Placeholder plumbing (removed once every handler above has its real modal). - // --------------------------------------------------------------------------- - - private Task ShowStub(string action, RawNode node) + // New folder under a Cluster (parentFolderId=null) or a Folder (parentFolderId=this folder). + private Task OnNewFolder(RawNode node) { - _stubTitle = action; - _stubMessage = $"{action} — coming in a later wave."; - _stubNodeName = node.DisplayName; - _stubVisible = true; + 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 void CloseStub() + private Task OnNewDevice(RawNode node) { - _stubVisible = false; - _stubTitle = ""; - _stubMessage = ""; - _stubNodeName = null; + _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; } }