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