feat(uns): tag + virtual-tag modals wired into the tree

This commit is contained in:
Joseph Doherty
2026-06-08 13:47:34 -04:00
parent d637b834b9
commit c0346f14ce
6 changed files with 757 additions and 6 deletions
@@ -41,6 +41,7 @@
<UnsTree Roots="_roots" Filter="_filter"
OnToggleExpand="ToggleAsync"
OnAddChild="HandleAddChild"
OnAddVirtualTag="HandleAddVirtualTag"
OnEdit="HandleEdit"
OnDelete="HandleDelete" />
</div>
@@ -72,6 +73,22 @@
OnSaved="OnModalSavedAsync"
OnCancel="CloseModals" />
<TagModal Visible="_tagModalVisible"
IsNew="_tagModalIsNew"
EquipmentId="_tagModalEquipmentId"
Existing="_tagModalExisting"
Drivers="_tagModalDriverOptions"
OnSaved="OnEquipmentChildModalSavedAsync"
OnCancel="CloseModals" />
<VirtualTagModal Visible="_vtagModalVisible"
IsNew="_vtagModalIsNew"
EquipmentId="_vtagModalEquipmentId"
Existing="_vtagModalExisting"
Scripts="_vtagModalScriptOptions"
OnSaved="OnEquipmentChildModalSavedAsync"
OnCancel="CloseModals" />
@if (_confirmNode is not null)
{
<div class="modal-backdrop fade show" style="display:block"></div>
@@ -127,6 +144,23 @@
private IReadOnlyList<(string Id, string Display)> _equipmentModalLineOptions = Array.Empty<(string, string)>();
private IReadOnlyList<(string Id, string Display)> _equipmentModalDriverOptions = Array.Empty<(string, string)>();
// --- Tag modal state ---
private bool _tagModalVisible;
private bool _tagModalIsNew;
private string? _tagModalEquipmentId;
private TagEditDto? _tagModalExisting;
private IReadOnlyList<(string Id, string Display)> _tagModalDriverOptions = Array.Empty<(string, string)>();
// --- Virtual-tag modal state ---
private bool _vtagModalVisible;
private bool _vtagModalIsNew;
private string? _vtagModalEquipmentId;
private VirtualTagEditDto? _vtagModalExisting;
private IReadOnlyList<(string Id, string Display)> _vtagModalScriptOptions = Array.Empty<(string, string)>();
// --- Owning equipment to refresh in place after a tag/virtual-tag mutation ---
private string? _childRefreshEquipmentId;
// --- Delete-confirm state ---
private UnsNode? _confirmNode;
private bool _confirmBusy;
@@ -204,8 +238,8 @@
/// <summary>
/// Opens the create modal for a node's primary child: a cluster gets a new area; an area gets a
/// new line scoped to its cluster; a line gets a new equipment scoped to its cluster. Equipment
/// "+ Tag" is handled in a later task.
/// new line scoped to its cluster; a line gets a new equipment scoped to its cluster; an equipment
/// gets a new tag scoped to its candidate drivers.
/// </summary>
private async Task HandleAddChild(UnsNode node)
{
@@ -235,12 +269,34 @@
_equipmentModalDriverOptions = await Svc.LoadDriversForClusterAsync(node.ClusterId!);
_equipmentModalVisible = true;
break;
case UnsNodeKind.Equipment:
_tagModalIsNew = true;
_tagModalExisting = null;
_tagModalEquipmentId = node.EntityId;
_childRefreshEquipmentId = node.EntityId;
_tagModalDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(node.EntityId!);
_tagModalVisible = true;
break;
}
}
/// <summary>Opens the create modal for a new virtual tag scoped to the clicked equipment.</summary>
private async Task HandleAddVirtualTag(UnsNode node)
{
CloseModals();
_vtagModalIsNew = true;
_vtagModalExisting = null;
_vtagModalEquipmentId = node.EntityId;
_childRefreshEquipmentId = node.EntityId;
_vtagModalScriptOptions = await Svc.LoadScriptsAsync();
_vtagModalVisible = true;
}
/// <summary>
/// Opens the edit modal for an Area or Line, loading the entity first to prefill the form and
/// capture its RowVersion. Other kinds are handled in later tasks.
/// Opens the edit modal for the clicked node, loading the entity first to prefill the form and
/// capture its RowVersion. Tag/VirtualTag edits also stash the owning equipment id so a successful
/// save can refresh just that equipment's children in place.
/// </summary>
private async Task HandleEdit(UnsNode node)
{
@@ -276,6 +332,28 @@
_equipmentModalDriverOptions = await Svc.LoadDriversForClusterAsync(node.ClusterId!);
_equipmentModalVisible = true;
break;
case UnsNodeKind.Tag:
var tag = await Svc.LoadTagAsync(node.EntityId!);
if (tag is null) { return; }
_tagModalIsNew = false;
_tagModalExisting = tag;
_tagModalEquipmentId = tag.EquipmentId;
_childRefreshEquipmentId = tag.EquipmentId;
_tagModalDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(tag.EquipmentId);
_tagModalVisible = true;
break;
case UnsNodeKind.VirtualTag:
var vtag = await Svc.LoadVirtualTagAsync(node.EntityId!);
if (vtag is null) { return; }
_vtagModalIsNew = false;
_vtagModalExisting = vtag;
_vtagModalEquipmentId = vtag.EquipmentId;
_childRefreshEquipmentId = vtag.EquipmentId;
_vtagModalScriptOptions = await Svc.LoadScriptsAsync();
_vtagModalVisible = true;
break;
}
}
@@ -288,7 +366,8 @@
/// <summary>
/// Performs the pending delete. Loads the entity's RowVersion first, then dispatches on Kind.
/// Area/Line/Equipment are handled here; Tag/VirtualTag are wired in later tasks.
/// Area/Line/Equipment trigger a full structural reload on success; Tag/VirtualTag refresh only the
/// owning equipment's children in place so the rest of the user's expansion is preserved.
/// </summary>
private async Task ConfirmDeleteAsync()
{
@@ -300,8 +379,43 @@
{
var node = _confirmNode;
UnsMutationResult result;
// Tag/VirtualTag deletes refresh just the owning equipment's children rather than the
// whole tree, so they're handled separately from the structural Area/Line/Equipment path.
switch (node.Kind)
{
case UnsNodeKind.Tag:
var tag = await Svc.LoadTagAsync(node.EntityId!);
if (tag is null) { await ReloadAndCloseAsync(); return; }
result = await Svc.DeleteTagAsync(node.EntityId!, tag.RowVersion);
if (result.Ok)
{
await RefreshEquipmentChildrenAsync(tag.EquipmentId);
CloseModals();
StateHasChanged();
}
else
{
_confirmError = result.Error;
}
return;
case UnsNodeKind.VirtualTag:
var vtag = await Svc.LoadVirtualTagAsync(node.EntityId!);
if (vtag is null) { await ReloadAndCloseAsync(); return; }
result = await Svc.DeleteVirtualTagAsync(node.EntityId!, vtag.RowVersion);
if (result.Ok)
{
await RefreshEquipmentChildrenAsync(vtag.EquipmentId);
CloseModals();
StateHasChanged();
}
else
{
_confirmError = result.Error;
}
return;
case UnsNodeKind.Area:
var area = await Svc.LoadAreaAsync(node.EntityId!);
if (area is null) { await ReloadAndCloseAsync(); return; }
@@ -321,7 +435,7 @@
break;
default:
// Tag/VirtualTag deletes are wired in later tasks.
// Enterprise/Cluster have no delete button, so this branch is unreachable in practice.
result = new UnsMutationResult(false, "Delete for this node kind is not yet available.");
break;
}
@@ -349,6 +463,64 @@
StateHasChanged();
}
/// <summary>
/// Handles a successful Tag/VirtualTag modal save by refreshing only the owning equipment's children
/// in place — never a full structural reload, which would collapse the user's expansion.
/// </summary>
private async Task OnEquipmentChildModalSavedAsync()
{
if (_childRefreshEquipmentId is not null)
{
await RefreshEquipmentChildrenAsync(_childRefreshEquipmentId);
}
CloseModals();
StateHasChanged();
}
/// <summary>
/// Reloads a single equipment node's tag/virtual-tag children in place, leaving the rest of the tree
/// (and the user's expansion) untouched. Falls back to a full structural reload only if the node
/// can no longer be found in the current tree.
/// </summary>
private async Task RefreshEquipmentChildrenAsync(string equipmentId)
{
var node = FindEquipmentNode(equipmentId);
if (node is null) { _roots = await Svc.LoadStructureAsync(); return; }
var kids = await Svc.LoadEquipmentChildrenAsync(equipmentId);
node.Children.Clear();
node.Children.AddRange(kids);
node.ChildCount = node.Children.Count;
node.Loaded = true;
node.Expanded = true;
}
/// <summary>Recursively walks the current tree for the Equipment node with the given id, or null.</summary>
private UnsNode? FindEquipmentNode(string equipmentId)
{
foreach (var root in _roots)
{
var found = FindEquipmentNode(root, equipmentId);
if (found is not null) { return found; }
}
return null;
}
private static UnsNode? FindEquipmentNode(UnsNode node, string equipmentId)
{
if (node.Kind == UnsNodeKind.Equipment && node.EntityId == equipmentId)
{
return node;
}
foreach (var child in node.Children)
{
var found = FindEquipmentNode(child, equipmentId);
if (found is not null) { return found; }
}
return null;
}
/// <summary>Reloads the tree after a successful delete and closes the confirm modal.</summary>
private async Task ReloadAndCloseAsync()
{
@@ -369,6 +541,15 @@
_equipmentModalExisting = null;
_equipmentModalLineOptions = Array.Empty<(string, string)>();
_equipmentModalDriverOptions = Array.Empty<(string, string)>();
_tagModalVisible = false;
_tagModalExisting = null;
_tagModalEquipmentId = null;
_tagModalDriverOptions = Array.Empty<(string, string)>();
_vtagModalVisible = false;
_vtagModalExisting = null;
_vtagModalEquipmentId = null;
_vtagModalScriptOptions = Array.Empty<(string, string)>();
_childRefreshEquipmentId = null;
_confirmNode = null;
_confirmError = null;
}