aef9ef8452
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
779 lines
30 KiB
Plaintext
779 lines
30 KiB
Plaintext
@* Recursive, lazily-expanding renderer for the v3 Raw project tree (/raw).
|
|
|
|
Unlike UnsTree (which eager-loads the whole structural tree and never touches
|
|
the RawNode lazy fields), THIS component actually exercises the lazy plumbing:
|
|
expanding an unloaded container calls IRawTreeService.LoadChildrenAsync, appends
|
|
the returned level into node.Children and flips Loaded/Loading, showing a spinner
|
|
in flight and an error row on failure. Per-node context menus (right-click surface
|
|
+ "⋯" fallback) are built per RawNodeKind.
|
|
|
|
Wave-B integration (this pass): every named handler now opens the REAL modal/dialog
|
|
(Configure driver/device, Edit/Manual/CSV tag surfaces) or calls IRawTreeService
|
|
directly (New folder/driver/group, Rename, Delete, Toggle) via the small dialogs
|
|
built alongside — RawNameDialog / RawConfirmDialog / RawDriverTypeDialog — and
|
|
refreshes the affected subtree afterwards (reload the container for create-under
|
|
ops, the parent for item-level ops). Only Browse device stays a placeholder (Wave C
|
|
owns it). The former RawStubModal is repurposed here as the shared message surface
|
|
for delete-blockers, rename warnings, and the Browse "coming soon" note. This
|
|
component is a single instance that recurses via the RenderNode RenderFragment, so it
|
|
owns one set of modals + one injected service for the whole tree. *@
|
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
|
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
|
@inject IRawTreeService Svc
|
|
|
|
@foreach (var root in Roots)
|
|
{
|
|
@RenderNode(root, 0)
|
|
}
|
|
|
|
@* --- Real modals + dialogs (one instance each for the whole recursive tree) --- *@
|
|
<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!;
|
|
|
|
/// <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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private RenderFragment RenderNode(RawNode node, int depth) => __builder =>
|
|
{
|
|
var indent = $"padding-left:{depth * 18}px";
|
|
var hasExpander = node.HasLazyChildren || node.Children.Count > 0;
|
|
var muted = node.Enabled == false && node.Kind is RawNodeKind.Driver or RawNodeKind.Device;
|
|
var items = MenuFor(node);
|
|
|
|
<div @key="node.Key" class="d-flex align-items-center gap-1 py-1" style="@indent">
|
|
@if (hasExpander)
|
|
{
|
|
<button type="button" class="btn btn-sm btn-link p-0" style="width:18px"
|
|
@onclick="@(() => ToggleAsync(node))">
|
|
@(node.Expanded ? "▼" : "▶")
|
|
</button>
|
|
}
|
|
else
|
|
{
|
|
<span style="width:18px"></span>
|
|
}
|
|
|
|
@if (items.Count > 0)
|
|
{
|
|
<ContextMenu Items="items" ShowEllipsis="true">
|
|
@RenderRowBody(node, muted)
|
|
</ContextMenu>
|
|
}
|
|
else
|
|
{
|
|
@RenderRowBody(node, muted)
|
|
}
|
|
</div>
|
|
|
|
@if (node.Expanded && node.Loading)
|
|
{
|
|
<div class="small text-muted" style="@($"padding-left:{(depth + 1) * 18}px")">
|
|
<span class="spinner-border spinner-border-sm me-1"></span>Loading…
|
|
</div>
|
|
}
|
|
else if (node.Expanded && node.Error is not null)
|
|
{
|
|
<div class="small text-danger" style="@($"padding-left:{(depth + 1) * 18}px")">
|
|
@node.Error
|
|
</div>
|
|
}
|
|
else if (node.Expanded)
|
|
{
|
|
@foreach (var child in node.Children)
|
|
{
|
|
@RenderNode(child, depth + 1)
|
|
}
|
|
}
|
|
};
|
|
|
|
// The icon + label + badge shown for a node — the ContextMenu right-click surface.
|
|
private RenderFragment RenderRowBody(RawNode node, bool muted) => __builder =>
|
|
{
|
|
<span class="d-inline-flex align-items-center gap-1">
|
|
<span aria-hidden="true">@KindIcon(node.Kind)</span>
|
|
<span class="mono small @(muted ? "text-muted" : "")">@node.DisplayName</span>
|
|
@if (muted)
|
|
{
|
|
<span class="chip chip-idle ms-1" style="font-size:0.65rem">disabled</span>
|
|
}
|
|
@if (node.ChildCount > 0)
|
|
{
|
|
<span class="chip chip-idle ms-1" style="font-size:0.7rem">@node.ChildCount</span>
|
|
}
|
|
</span>
|
|
};
|
|
|
|
private static string KindIcon(RawNodeKind kind) => kind switch
|
|
{
|
|
RawNodeKind.Enterprise => "🏢",
|
|
RawNodeKind.Cluster => "🖥️",
|
|
RawNodeKind.Folder => "📁",
|
|
RawNodeKind.Driver => "🔌",
|
|
RawNodeKind.Device => "📟",
|
|
RawNodeKind.TagGroup => "🗂️",
|
|
RawNodeKind.Tag => "🏷️",
|
|
_ => "•",
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lazy expansion — the whole point of WP2 (UNS never did this).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Toggles a node's expansion. On first expand of an unloaded container this sets
|
|
/// <c>Loading</c>, awaits <see cref="IRawTreeService.LoadChildrenAsync"/>, appends the
|
|
/// returned level into <c>Children</c>, flips <c>Loaded</c>, and re-renders — showing a
|
|
/// spinner in flight and an error row on failure. A leaf (Tag) has no expander so never
|
|
/// reaches here.
|
|
/// </summary>
|
|
private async Task ToggleAsync(RawNode node)
|
|
{
|
|
if (node.Loading) { return; }
|
|
|
|
node.Expanded = !node.Expanded;
|
|
|
|
if (node.Expanded && node.HasLazyChildren && !node.Loaded)
|
|
{
|
|
node.Loading = true;
|
|
node.Error = null;
|
|
StateHasChanged();
|
|
try
|
|
{
|
|
var children = await Svc.LoadChildrenAsync(node);
|
|
node.Children.Clear();
|
|
node.Children.AddRange(children);
|
|
node.ChildCount = node.Children.Count;
|
|
node.Loaded = true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
node.Error = ex.Message;
|
|
}
|
|
finally
|
|
{
|
|
node.Loading = false;
|
|
}
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Per-kind context menus. Every OnClick opens a placeholder in this wave; the
|
|
// named handlers below are the seam Wave B/C replaces with the real modals.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private IReadOnlyList<ContextMenuItem> MenuFor(RawNode node) => node.Kind switch
|
|
{
|
|
RawNodeKind.Cluster => ClusterMenu(node),
|
|
RawNodeKind.Folder => FolderMenu(node),
|
|
RawNodeKind.Driver => DriverMenu(node),
|
|
RawNodeKind.Device => DeviceMenu(node),
|
|
RawNodeKind.TagGroup => TagGroupMenu(node),
|
|
RawNodeKind.Tag => TagMenu(node),
|
|
_ => Array.Empty<ContextMenuItem>(), // Enterprise: label only, no menu.
|
|
};
|
|
|
|
private List<ContextMenuItem> ClusterMenu(RawNode node) => new()
|
|
{
|
|
new() { Label = "New folder", Icon = "📁", OnClick = () => OnNewFolder(node) },
|
|
new() { Label = "New driver", Icon = "🔌", OnClick = () => OnNewDriver(node) },
|
|
};
|
|
|
|
private List<ContextMenuItem> FolderMenu(RawNode node) => new()
|
|
{
|
|
new() { Label = "New folder", Icon = "📁", OnClick = () => OnNewFolder(node) },
|
|
new() { Label = "New driver", Icon = "🔌", OnClick = () => OnNewDriver(node) },
|
|
ContextMenuItem.Separator(),
|
|
new() { Label = "Rename", Icon = "✏️", OnClick = () => OnRenameFolder(node) },
|
|
new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteFolder(node) },
|
|
};
|
|
|
|
private List<ContextMenuItem> DriverMenu(RawNode node) => new()
|
|
{
|
|
new() { Label = "Configure", Icon = "⚙", OnClick = () => OnConfigureDriver(node) },
|
|
new() { Label = "New device", Icon = "📟", OnClick = () => OnNewDevice(node) },
|
|
ContextMenuItem.Separator(),
|
|
new() { Label = node.Enabled ? "Disable" : "Enable", Icon = node.Enabled ? "⏸" : "▶",
|
|
OnClick = () => OnToggleDriverEnabled(node) },
|
|
new() { Label = "Rename", Icon = "✏️", OnClick = () => OnRenameDriver(node) },
|
|
new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteDriver(node) },
|
|
};
|
|
|
|
private List<ContextMenuItem> DeviceMenu(RawNode node) => new()
|
|
{
|
|
new() { Label = "Configure", Icon = "⚙", OnClick = () => OnConfigureDevice(node) },
|
|
new() { Label = "New tag-group", Icon = "🗂️", OnClick = () => OnNewTagGroup(node) },
|
|
ContextMenuItem.Separator(),
|
|
// "Add tags ▸" flattened — ContextMenu is single-level, so the submenu is a labelled group.
|
|
new() { Label = "Add tags", Disabled = true },
|
|
new() { Label = "Manual entry", Icon = "✍️", OnClick = () => OnAddManualTags(node) },
|
|
new() { Label = "Import CSV", Icon = "📥", OnClick = () => OnImportCsv(node) },
|
|
new() { Label = "Export CSV", Icon = "📤", OnClick = () => OnExportCsv(node) },
|
|
new() { Label = "Browse device…", Icon = "🔭", OnClick = () => OnBrowseDevice(node) },
|
|
ContextMenuItem.Separator(),
|
|
new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteDevice(node) },
|
|
};
|
|
|
|
private List<ContextMenuItem> TagGroupMenu(RawNode node) => new()
|
|
{
|
|
new() { Label = "New group", Icon = "🗂️", OnClick = () => OnNewGroup(node) },
|
|
ContextMenuItem.Separator(),
|
|
new() { Label = "Add tags", Disabled = true },
|
|
new() { Label = "Manual entry", Icon = "✍️", OnClick = () => OnAddManualTags(node) },
|
|
new() { Label = "Import CSV", Icon = "📥", OnClick = () => OnImportCsv(node) },
|
|
new() { Label = "Export CSV", Icon = "📤", OnClick = () => OnExportCsv(node) },
|
|
new() { Label = "Browse device…", Icon = "🔭", OnClick = () => OnBrowseDevice(node) },
|
|
ContextMenuItem.Separator(),
|
|
new() { Label = "Rename", Icon = "✏️", OnClick = () => OnRenameTagGroup(node) },
|
|
new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteTagGroup(node) },
|
|
};
|
|
|
|
private List<ContextMenuItem> TagMenu(RawNode node) => new()
|
|
{
|
|
new() { Label = "Edit", Icon = "✏️", OnClick = () => OnEditTag(node) },
|
|
new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteTag(node) },
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Named action handlers — each opens the real modal/dialog or calls the service
|
|
// directly, then refreshes the affected subtree. Only OnBrowseDevice is a
|
|
// placeholder (Wave C owns device browse).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// --- Folder / Cluster ---
|
|
|
|
// New folder under a Cluster (parentFolderId=null) or a Folder (parentFolderId=this folder).
|
|
private Task OnNewFolder(RawNode node)
|
|
{
|
|
var clusterId = node.Kind == RawNodeKind.Cluster ? (node.ClusterId ?? node.EntityId) : node.ClusterId;
|
|
var parentFolderId = node.Kind == RawNodeKind.Folder ? node.EntityId : null;
|
|
ShowNameDialog("New folder", "", async name =>
|
|
{
|
|
var res = await Svc.CreateFolderAsync(clusterId ?? "", parentFolderId, name);
|
|
if (!res.Ok) { ShowMessage("New folder failed", res.Error ?? "Create failed.", node.DisplayName); return; }
|
|
await ReloadNodeAsync(node);
|
|
});
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
// New driver: pick type + name, then create a minimal driver ("{}") — configure it afterward.
|
|
private Task OnNewDriver(RawNode node)
|
|
{
|
|
var clusterId = node.Kind == RawNodeKind.Cluster ? (node.ClusterId ?? node.EntityId) : node.ClusterId;
|
|
var folderId = node.Kind == RawNodeKind.Folder ? node.EntityId : null;
|
|
ShowDriverTypeDialog(async choice =>
|
|
{
|
|
var res = await Svc.CreateDriverAsync(clusterId ?? "", folderId, choice.Name, choice.DriverType, "{}");
|
|
if (!res.Ok) { ShowMessage("New driver failed", res.Error ?? "Create failed.", node.DisplayName); return; }
|
|
await ReloadNodeAsync(node);
|
|
});
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private Task OnRenameFolder(RawNode node)
|
|
{
|
|
ShowNameDialog("Rename folder", node.DisplayName, async name =>
|
|
await AfterRename(await Svc.RenameFolderAsync(node.EntityId!, name, node.RowVersion), node));
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private Task OnDeleteFolder(RawNode node)
|
|
{
|
|
ShowConfirm("Delete folder", $"Delete folder “{node.DisplayName}”?", async () =>
|
|
await AfterDelete(await Svc.DeleteFolderAsync(node.EntityId!, node.RowVersion), node));
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
// --- Driver ---
|
|
|
|
private Task OnConfigureDriver(RawNode node)
|
|
{
|
|
_driverCfgId = node.EntityId;
|
|
_driverCfgNode = node;
|
|
_driverCfgVisible = true;
|
|
StateHasChanged();
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private Task OnNewDevice(RawNode node)
|
|
{
|
|
_deviceIsCreate = true;
|
|
_deviceEditId = null;
|
|
_deviceCreateDriverId = node.EntityId;
|
|
_deviceDriverType = node.DriverType;
|
|
_deviceNode = node;
|
|
_deviceVisible = true;
|
|
StateHasChanged();
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private async Task OnToggleDriverEnabled(RawNode node)
|
|
{
|
|
var res = await Svc.SetDriverEnabledAsync(node.EntityId!, !node.Enabled, node.RowVersion);
|
|
if (!res.Ok) { ShowMessage("Update failed", res.Error ?? "Could not change enabled state.", node.DisplayName); return; }
|
|
await ReloadParentAsync(node);
|
|
}
|
|
|
|
private Task OnRenameDriver(RawNode node)
|
|
{
|
|
ShowNameDialog("Rename driver", node.DisplayName, async name =>
|
|
await AfterRename(await Svc.RenameDriverAsync(node.EntityId!, name, node.RowVersion), node));
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private Task OnDeleteDriver(RawNode node)
|
|
{
|
|
ShowConfirm("Delete driver", $"Delete driver “{node.DisplayName}” and its empty devices?", async () =>
|
|
await AfterDelete(await Svc.DeleteDriverAsync(node.EntityId!, node.RowVersion), node));
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
// --- Device ---
|
|
|
|
private Task OnConfigureDevice(RawNode node)
|
|
{
|
|
_deviceIsCreate = false;
|
|
_deviceEditId = node.EntityId;
|
|
_deviceCreateDriverId = null;
|
|
_deviceDriverType = node.DriverType;
|
|
_deviceNode = node;
|
|
_deviceVisible = true;
|
|
StateHasChanged();
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private Task OnNewTagGroup(RawNode node)
|
|
{
|
|
// node is a Device — its root tag-groups.
|
|
ShowNameDialog("New tag-group", "", async name =>
|
|
{
|
|
var res = await Svc.CreateTagGroupAsync(node.EntityId!, null, name);
|
|
if (!res.Ok) { ShowMessage("New tag-group failed", res.Error ?? "Create failed.", node.DisplayName); return; }
|
|
await ReloadNodeAsync(node);
|
|
});
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private Task OnDeleteDevice(RawNode node)
|
|
{
|
|
ShowConfirm("Delete device", $"Delete device “{node.DisplayName}”?", async () =>
|
|
await AfterDelete(await Svc.DeleteDeviceAsync(node.EntityId!, node.RowVersion), node));
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
// --- Add-tags surfaces (shared by Device + TagGroup) ---
|
|
|
|
private Task OnAddManualTags(RawNode node)
|
|
{
|
|
// Manual entry needs the device id: for a Device node it's node itself; for a TagGroup walk up.
|
|
_manualDeviceId = FindAncestorDevice(node)?.EntityId ?? "";
|
|
_manualTagGroupId = node.Kind == RawNodeKind.TagGroup ? node.EntityId : null;
|
|
_manualDriverType = node.DriverType ?? "";
|
|
_manualNode = node;
|
|
_manualVisible = true;
|
|
StateHasChanged();
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private Task OnImportCsv(RawNode node)
|
|
{
|
|
// Device node → DeviceId; TagGroup node → TagGroupId (the modal self-resolves its device).
|
|
if (node.Kind == RawNodeKind.Device)
|
|
{
|
|
_csvImportDeviceId = node.EntityId ?? "";
|
|
_csvImportTagGroupId = null;
|
|
}
|
|
else
|
|
{
|
|
_csvImportDeviceId = "";
|
|
_csvImportTagGroupId = node.EntityId;
|
|
}
|
|
_csvImportDriverType = node.DriverType ?? "";
|
|
_csvImportNode = node;
|
|
_csvImportVisible = true;
|
|
StateHasChanged();
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private Task OnExportCsv(RawNode node)
|
|
{
|
|
if (node.Kind == RawNodeKind.Device)
|
|
{
|
|
_csvExportDeviceId = node.EntityId ?? "";
|
|
_csvExportTagGroupId = null;
|
|
}
|
|
else
|
|
{
|
|
_csvExportDeviceId = "";
|
|
_csvExportTagGroupId = node.EntityId;
|
|
}
|
|
_csvExportDriverType = node.DriverType ?? "";
|
|
_csvExportVisible = true;
|
|
StateHasChanged();
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
// Browse device stays a placeholder — Wave C (WP6) wires the discovery-browser modal.
|
|
private Task OnBrowseDevice(RawNode node)
|
|
{
|
|
ShowMessage("Browse device", "Device browse arrives in the browse wave (Wave C).", node.DisplayName);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
// --- TagGroup ---
|
|
|
|
private Task OnNewGroup(RawNode node)
|
|
{
|
|
// node is a TagGroup — create a sub-group under it (needs its owning device id).
|
|
var deviceId = FindAncestorDevice(node)?.EntityId ?? "";
|
|
ShowNameDialog("New group", "", async name =>
|
|
{
|
|
var res = await Svc.CreateTagGroupAsync(deviceId, node.EntityId, name);
|
|
if (!res.Ok) { ShowMessage("New group failed", res.Error ?? "Create failed.", node.DisplayName); return; }
|
|
await ReloadNodeAsync(node);
|
|
});
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private Task OnRenameTagGroup(RawNode node)
|
|
{
|
|
ShowNameDialog("Rename tag-group", node.DisplayName, async name =>
|
|
await AfterRename(await Svc.RenameTagGroupAsync(node.EntityId!, name, node.RowVersion), node));
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private Task OnDeleteTagGroup(RawNode node)
|
|
{
|
|
ShowConfirm("Delete tag-group", $"Delete tag-group “{node.DisplayName}”?", async () =>
|
|
await AfterDelete(await Svc.DeleteTagGroupAsync(node.EntityId!, node.RowVersion), node));
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
// --- Tag ---
|
|
|
|
private Task OnEditTag(RawNode node)
|
|
{
|
|
_tagId = node.EntityId;
|
|
_tagDriverType = node.DriverType;
|
|
_tagNode = node;
|
|
_tagVisible = true;
|
|
StateHasChanged();
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private Task OnDeleteTag(RawNode node)
|
|
{
|
|
ShowConfirm("Delete tag", $"Delete tag “{node.DisplayName}”?", async () =>
|
|
await AfterDelete(await Svc.DeleteTagAsync(node.EntityId!, node.RowVersion), node));
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Modal OnSaved callbacks — refresh the affected subtree.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private async Task DriverCfgSaved()
|
|
{
|
|
if (_driverCfgNode is not null) { await ReloadParentAsync(_driverCfgNode); }
|
|
}
|
|
|
|
private async Task DeviceSaved()
|
|
{
|
|
if (_deviceNode is null) { return; }
|
|
// Create → reload the driver container; edit → reload the device's parent driver.
|
|
if (_deviceIsCreate) { await ReloadNodeAsync(_deviceNode); }
|
|
else { await ReloadParentAsync(_deviceNode); }
|
|
}
|
|
|
|
private async Task TagSaved()
|
|
{
|
|
if (_tagNode is not null) { await ReloadParentAsync(_tagNode); }
|
|
}
|
|
|
|
private async Task ManualSaved()
|
|
{
|
|
if (_manualNode is not null) { await ReloadNodeAsync(_manualNode); }
|
|
}
|
|
|
|
private async Task CsvImportSaved()
|
|
{
|
|
if (_csvImportNode is not null) { await ReloadNodeAsync(_csvImportNode); }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rename / delete outcome surfacing.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private async Task AfterRename(RawRenameResult res, RawNode node)
|
|
{
|
|
if (res.Ok) { await ReloadParentAsync(node); }
|
|
|
|
if (!res.Ok)
|
|
{
|
|
ShowMessage("Rename failed", res.Error ?? "Rename failed.", node.DisplayName);
|
|
}
|
|
else if (res.Warnings.Count > 0)
|
|
{
|
|
ShowMessage("Renamed — with warnings", string.Join(" • ", res.Warnings), node.DisplayName);
|
|
}
|
|
}
|
|
|
|
private async Task AfterDelete(UnsMutationResult res, RawNode node)
|
|
{
|
|
if (!res.Ok) { ShowMessage("Delete blocked", res.Error ?? "Delete failed.", node.DisplayName); return; }
|
|
await ReloadParentAsync(node);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Dialog show-helpers (one instance each; a pending delegate carries the op).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private void ShowNameDialog(string title, string initial, Func<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;
|
|
}
|
|
}
|