feat(adminui): B2 Wave-B integration — wire real /raw modals + dialogs + refresh

Replace RawTree's 22 stub handlers with the real Wave-B modals and 3 new
small dialogs, plus post-mutation subtree refresh:

- New dialogs: RawNameDialog (create/rename, RawPaths.ValidateSegment inline),
  RawConfirmDialog (deletes), RawDriverTypeDialog (New driver type+name picker,
  incl. Calculation).
- Configure driver/device -> DriverConfigModal/DeviceModal (edit); New device ->
  DeviceModal (create); Edit tag -> RawTagModal; Manual entry / CSV import/export
  -> the Raw*Modal surfaces; New folder/tag-group/group + New driver + Rename +
  Delete + Toggle -> IRawTreeService directly via the dialogs.
- Refresh: reload the container after create-under, the parent after item-level
  ops (parent found by searching the loaded tree; robust, non-throwing).
- Rename warnings + blocked-delete errors surfaced via the repurposed RawStubModal
  message surface. Browse device stays a placeholder (Wave C). Driver-level
  'Test connect' menu item removed (test-connect now lives on the device modal).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 03:38:32 -04:00
parent f986519317
commit aef9ef8452
4 changed files with 805 additions and 62 deletions
@@ -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)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@Title</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="Cancel"></button>
</div>
<div class="modal-body">
<p class="mb-0">@Message</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel" disabled="@_busy">Cancel</button>
<button type="button" class="btn btn-danger" @onclick="Confirm" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@ConfirmLabel
</button>
</div>
</div>
</div>
</div>
}
@code {
/// <summary>Whether the dialog is shown (supports <c>@bind-Visible</c>).</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>Two-way visibility callback.</summary>
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
/// <summary>The dialog title (e.g. "Delete driver").</summary>
[Parameter] public string Title { get; set; } = "Confirm";
/// <summary>The confirmation body message.</summary>
[Parameter] public string Message { get; set; } = "";
/// <summary>The confirm-button label.</summary>
[Parameter] public string ConfirmLabel { get; set; } = "Delete";
/// <summary>Raised when the operator confirms. The dialog awaits it, then self-closes.</summary>
[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);
}
}
@@ -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)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">New driver</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="Cancel"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label" for="raw-drv-type">Driver type</label>
<select id="raw-drv-type" class="form-select form-select-sm" @bind="_type">
@foreach (var t in Types)
{
<option value="@t.Value">@t.Label</option>
}
</select>
</div>
<div class="mb-1">
<label class="form-label" for="raw-drv-name">Name</label>
<input id="raw-drv-name" class="form-control form-control-sm mono"
@bind="_name" @bind:event="oninput" placeholder="line3-modbus" />
<div class="form-text">A RawPath segment — no '/', no leading or trailing spaces.</div>
</div>
@if (_error is not null)
{
<div class="text-danger small mt-1">@_error</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel">Cancel</button>
<button type="button" class="btn btn-primary" @onclick="Submit">Create</button>
</div>
</div>
</div>
</div>
}
@code {
/// <summary>Whether the dialog is shown (supports <c>@bind-Visible</c>).</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>Two-way visibility callback.</summary>
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
/// <summary>Raised with the chosen driver type + name when the operator confirms; the dialog self-closes after.</summary>
[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);
}
}
@@ -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)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@Title</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="Cancel"></button>
</div>
<div class="modal-body">
<label class="form-label" for="raw-name-input">Name</label>
<input id="raw-name-input" class="form-control form-control-sm mono"
@bind="_value" @bind:event="oninput" @onkeydown="OnKeyDown"
placeholder="segment-name" />
<div class="form-text">A RawPath segment — no '/', no leading or trailing spaces.</div>
@if (_error is not null)
{
<div class="text-danger small mt-1">@_error</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel">Cancel</button>
<button type="button" class="btn btn-primary" @onclick="Submit">OK</button>
</div>
</div>
</div>
</div>
}
@code {
/// <summary>Whether the dialog is shown (supports <c>@bind-Visible</c>).</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>Two-way visibility callback.</summary>
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
/// <summary>The dialog title (e.g. "New folder", "Rename driver").</summary>
[Parameter] public string Title { get; set; } = "Name";
/// <summary>The initial value seeded into the input on open (blank for create, current name for rename).</summary>
[Parameter] public string Value { get; set; } = "";
/// <summary>Raised with the validated name when the operator confirms. The dialog self-closes after.</summary>
[Parameter] public EventCallback<string> 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);
}
}
@@ -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)
}
<RawStubModal Visible="_stubVisible" Title="@_stubTitle" Message="@_stubMessage"
NodeName="@_stubNodeName" OnClose="CloseStub" />
@* --- Real modals + dialogs (one instance each for the whole recursive tree) --- *@
<DriverConfigModal @bind-Visible="_driverCfgVisible" DriverInstanceId="@_driverCfgId" OnSaved="DriverCfgSaved" />
<DeviceModal @bind-Visible="_deviceVisible" DeviceId="@_deviceEditId"
DriverInstanceId="@_deviceCreateDriverId" DriverType="@_deviceDriverType" OnSaved="DeviceSaved" />
<RawTagModal @bind-Visible="_tagVisible" TagId="@_tagId" DriverType="@_tagDriverType" OnSaved="TagSaved" />
<RawManualTagEntryModal @bind-Visible="_manualVisible" DeviceId="@_manualDeviceId"
TagGroupId="@_manualTagGroupId" DriverType="@_manualDriverType" OnSaved="ManualSaved" />
<RawCsvImportModal @bind-Visible="_csvImportVisible" DeviceId="@_csvImportDeviceId"
TagGroupId="@_csvImportTagGroupId" DriverType="@_csvImportDriverType" OnSaved="CsvImportSaved" />
<RawCsvExportModal @bind-Visible="_csvExportVisible" DeviceId="@_csvExportDeviceId"
TagGroupId="@_csvExportTagGroupId" DriverType="@_csvExportDriverType" />
<RawNameDialog @bind-Visible="_nameVisible" Title="@_nameTitle" Value="@_nameInitial" OnSubmit="NameSubmitted" />
<RawConfirmDialog @bind-Visible="_confirmVisible" Title="@_confirmTitle" Message="@_confirmMessage"
OnConfirm="Confirmed" />
<RawDriverTypeDialog @bind-Visible="_driverTypeVisible" OnSubmit="DriverTypeSubmitted" />
@* Shared message surface (repurposed RawStubModal) — delete blockers, rename warnings, Browse note. *@
<RawStubModal Visible="_msgVisible" Title="@_msgTitle" Message="@_msgMessage"
NodeName="@_msgNodeName" OnClose="CloseMessage" />
@code {
/// <summary>The top-level Raw nodes to render (the enterprise roots from <c>LoadRootsAsync</c>).</summary>
[Parameter, EditorRequired] public IReadOnlyList<RawNode> 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;
/// <summary>
/// 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.
/// </summary>
[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<string, Task>? _nameSubmit;
// --- Confirm dialog (deletes) ---
private bool _confirmVisible;
private string _confirmTitle = "";
private string _confirmMessage = "";
private Func<Task>? _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<ContextMenuItem> 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<string, Task> 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<Task> 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.
// ---------------------------------------------------------------------------
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>
/// 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.
/// </summary>
private async Task ReloadParentAsync(RawNode node)
{
var parent = FindParent(node);
if (parent is not null) { await ReloadNodeAsync(parent); }
else { await OnRootsChanged.InvokeAsync(); }
}
/// <summary>Finds a node's parent by searching the currently-loaded tree; null for a root.</summary>
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;
}
/// <summary>Walks up from <paramref name="node"/> (inclusive) to the nearest Device node, or null.</summary>
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;
}
}