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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user