From 826ffdc1a065e3f3e5d681965abd97a8fd33003f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 11 Jun 2026 15:01:10 -0400 Subject: [PATCH] feat(uns): equipment is a tree leaf linking to the detail page; drop EquipmentModal --- .../Components/Pages/Uns/GlobalUns.razor | 284 +----------------- .../Shared/Uns/EquipmentModal.razor | 252 ---------------- .../Components/Shared/Uns/UnsTree.razor | 22 +- .../ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsNode.cs | 2 +- .../Uns/UnsTreeAssemblyTests.cs | 7 +- .../Uns/UnsTreeServiceStructureTests.cs | 6 +- 6 files changed, 26 insertions(+), 547 deletions(-) delete mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/EquipmentModal.razor diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor index 2ca36cf3..a026185a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor @@ -4,6 +4,7 @@ @using ZB.MOM.WW.OtOpcUa.AdminUI.Uns @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Uns @inject IUnsTreeService Svc +@inject NavigationManager Nav UNS @@ -41,7 +42,6 @@ @@ -64,31 +64,6 @@ OnSaved="OnModalSavedAsync" OnCancel="CloseModals" /> - - - - - - @@ -127,7 +102,7 @@ private string? _filter; private bool _loading = true; - // Guards the async modal openers (HandleAddChild/HandleAddVirtualTag/HandleEdit) so a rapid + // Guards the async modal openers (HandleAddChild/HandleEdit) so a rapid // double-action can't race two service loads into the same modal state. private bool _modalBusy; @@ -144,34 +119,9 @@ private LineEditDto? _lineModalExisting; private IReadOnlyList<(string Id, string Display)> _lineModalAreaOptions = Array.Empty<(string, string)>(); - // --- Equipment modal state --- - private bool _equipmentModalVisible; - private bool _equipmentModalIsNew; - private string? _equipmentModalLineId; - private EquipmentEditDto? _equipmentModalExisting; - 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, string DriverType)> _tagModalDriverOptions = Array.Empty<(string, 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)>(); - // --- Import-equipment-CSV modal state --- private bool _importModalVisible; - // --- Owning equipment to refresh in place after a tag/virtual-tag mutation --- - private string? _childRefreshEquipmentId; - // --- Delete-confirm state --- private UnsNode? _confirmNode; private bool _confirmBusy; @@ -214,43 +164,21 @@ .ToList(); /// - /// Toggles a node's expansion. For equipment nodes whose children have not yet - /// been loaded, lazily fetches the tag/virtual-tag leaves on first expand. + /// Toggles a structural node's expansion. Equipment nodes are leaves (no expander), + /// so this path only ever fires for Enterprise/Cluster/Area/Line. /// - private async Task ToggleAsync(UnsNode node) + private Task ToggleAsync(UnsNode node) { - if (node.Loading) return; + if (node.Loading) { return Task.CompletedTask; } node.Expanded = !node.Expanded; - - if (node.Kind == UnsNodeKind.Equipment && node.Expanded && !node.Loaded) - { - node.Error = null; - node.Loading = true; - StateHasChanged(); - try - { - var kids = await Svc.LoadEquipmentChildrenAsync(node.EntityId!); - node.Children.Clear(); - node.Children.AddRange(kids); - node.Loaded = true; - } - catch (Exception ex) - { - node.Error = ex.Message; - } - finally - { - node.Loading = false; - StateHasChanged(); - } - } + return Task.CompletedTask; } /// - /// 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; an equipment - /// gets a new tag scoped to its candidate drivers. + /// Opens the create modal for a structural node's primary child: a cluster gets a new area; an area + /// gets a new line scoped to its cluster. A line's "+ Equipment" navigates to the equipment detail + /// page in create mode rather than opening a modal. /// private async Task HandleAddChild(UnsNode node) { @@ -277,21 +205,7 @@ break; case UnsNodeKind.Line: - _equipmentModalIsNew = true; - _equipmentModalExisting = null; - _equipmentModalLineId = node.EntityId; - _equipmentModalLineOptions = LinesForCluster(node.ClusterId); - _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; + Nav.NavigateTo($"/uns/equipment/new?lineId={node.EntityId}"); break; } } @@ -301,31 +215,10 @@ } } - /// Opens the create modal for a new virtual tag scoped to the clicked equipment. - private async Task HandleAddVirtualTag(UnsNode node) - { - if (_modalBusy) { return; } - _modalBusy = true; - try - { - CloseModals(); - _vtagModalIsNew = true; - _vtagModalExisting = null; - _vtagModalEquipmentId = node.EntityId; - _childRefreshEquipmentId = node.EntityId; - _vtagModalScriptOptions = await Svc.LoadScriptsAsync(); - _vtagModalVisible = true; - } - finally - { - _modalBusy = false; - } - } - /// /// 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. + /// capture its RowVersion. Only Area and Line are edited via a modal here; equipment is edited on + /// its own detail page (opened via the tree's "Open" link). /// private async Task HandleEdit(UnsNode node) { @@ -354,39 +247,6 @@ _lineModalAreaOptions = AreaOptionsForCluster(node.ClusterId); _lineModalVisible = true; break; - - case UnsNodeKind.Equipment: - var equipment = await Svc.LoadEquipmentAsync(node.EntityId!); - if (equipment is null) { return; } - _equipmentModalIsNew = false; - _equipmentModalExisting = equipment; - _equipmentModalLineId = equipment.UnsLineId; - _equipmentModalLineOptions = LinesForCluster(node.ClusterId); - _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; } } finally @@ -422,8 +282,7 @@ /// /// Performs the pending delete. Loads the entity's RowVersion first, then dispatches on Kind. - /// 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. + /// Area/Line/Equipment trigger a full structural reload on success. /// private async Task ConfirmDeleteAsync() { @@ -436,42 +295,8 @@ 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; } @@ -519,70 +344,6 @@ StateHasChanged(); } - /// - /// 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. - /// - private async Task OnEquipmentChildModalSavedAsync() - { - if (_childRefreshEquipmentId is not null) - { - await RefreshEquipmentChildrenAsync(_childRefreshEquipmentId); - } - CloseModals(); - StateHasChanged(); - } - - /// - /// 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. Either branch only mutates state — the caller is - /// responsible for calling StateHasChanged() afterwards (every current caller does). - /// - private async Task RefreshEquipmentChildrenAsync(string equipmentId) - { - var node = FindEquipmentNode(equipmentId); - if (node is null) - { - // Fallback: the equipment node is no longer in the current tree — reload the whole structure. - _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; - } - - /// Recursively walks the current tree for the Equipment node with the given id, or null. - 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; - } - /// Reloads the tree after a successful delete and closes the confirm modal. private async Task ReloadAndCloseAsync() { @@ -603,24 +364,7 @@ _lineModalAreaId = null; _lineModalExisting = null; _lineModalAreaOptions = Array.Empty<(string, string)>(); - _equipmentModalVisible = false; - _equipmentModalIsNew = false; - _equipmentModalLineId = null; - _equipmentModalExisting = null; - _equipmentModalLineOptions = Array.Empty<(string, string)>(); - _equipmentModalDriverOptions = Array.Empty<(string, string)>(); - _tagModalVisible = false; - _tagModalIsNew = false; - _tagModalEquipmentId = null; - _tagModalExisting = null; - _tagModalDriverOptions = Array.Empty<(string, string, string)>(); - _vtagModalVisible = false; - _vtagModalIsNew = false; - _vtagModalEquipmentId = null; - _vtagModalExisting = null; - _vtagModalScriptOptions = Array.Empty<(string, string)>(); _importModalVisible = false; - _childRefreshEquipmentId = null; _confirmNode = null; _confirmError = null; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/EquipmentModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/EquipmentModal.razor deleted file mode 100644 index 838a5e7a..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/EquipmentModal.razor +++ /dev/null @@ -1,252 +0,0 @@ -@* Create/edit modal for an equipment, wired straight into IUnsTreeService. The host page owns - visibility and supplies the parent line id (create) or the loaded EquipmentEditDto (edit), plus - the cluster-scoped UNS-line and driver lists. The EquipmentId is system-generated (decision #125) - so it is never an editable field — only shown read-only on edit. On a successful save it raises - OnSaved so the host can reload the tree. *@ -@using System.ComponentModel.DataAnnotations -@using Microsoft.AspNetCore.Components.Forms -@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns -@inject IUnsTreeService Svc - -@if (Visible) -{ - - -} - -@code { - /// Whether the modal is shown. The host owns this flag. - [Parameter] public bool Visible { get; set; } - - /// true to create a new equipment; false to edit . - [Parameter] public bool IsNew { get; set; } - - /// The parent line id used to default the UNS-line select on create. - [Parameter] public string? UnsLineId { get; set; } - - /// The equipment being edited, when is false. - [Parameter] public EquipmentEditDto? Existing { get; set; } - - /// The selectable UNS lines — scoped to the equipment's cluster by the host — as (Id, Display) pairs. - [Parameter] public IReadOnlyList<(string Id, string Display)> Lines { get; set; } = Array.Empty<(string, string)>(); - - /// The selectable drivers — scoped to the equipment's cluster by the host — as (Id, Display) pairs. - [Parameter] public IReadOnlyList<(string Id, string Display)> Drivers { get; set; } = Array.Empty<(string, string)>(); - - /// Raised after a successful create/save so the host can reload and close. - [Parameter] public EventCallback OnSaved { get; set; } - - /// Raised when the user cancels so the host can close. - [Parameter] public EventCallback OnCancel { get; set; } - - private FormModel _form = new(); - private bool _busy; - private string? _error; - - protected override void OnParametersSet() - { - // Rebuild the working form whenever the host (re)opens the modal for a fresh target. - if (IsNew) - { - _form = new FormModel { UnsLineId = UnsLineId ?? "" }; - } - else if (Existing is not null) - { - _form = new FormModel - { - Name = Existing.Name, - MachineCode = Existing.MachineCode, - UnsLineId = Existing.UnsLineId, - DriverInstanceId = Existing.DriverInstanceId, - ZTag = Existing.ZTag, - SAPID = Existing.SAPID, - Manufacturer = Existing.Manufacturer, - Model = Existing.Model, - SerialNumber = Existing.SerialNumber, - HardwareRevision = Existing.HardwareRevision, - SoftwareRevision = Existing.SoftwareRevision, - YearOfConstruction = Existing.YearOfConstruction, - AssetLocation = Existing.AssetLocation, - ManufacturerUri = Existing.ManufacturerUri, - DeviceManualUri = Existing.DeviceManualUri, - Enabled = Existing.Enabled, - }; - } - _error = null; - } - - private async Task SaveAsync() - { - _busy = true; - _error = null; - try - { - var input = new EquipmentInput( - _form.Name, - _form.MachineCode, - _form.UnsLineId, - _form.DriverInstanceId, - _form.ZTag, - _form.SAPID, - _form.Manufacturer, - _form.Model, - _form.SerialNumber, - _form.HardwareRevision, - _form.SoftwareRevision, - _form.YearOfConstruction, - _form.AssetLocation, - _form.ManufacturerUri, - _form.DeviceManualUri, - _form.Enabled); - - var result = IsNew - ? await Svc.CreateEquipmentAsync(input) - : await Svc.UpdateEquipmentAsync(Existing!.EquipmentId, input, Existing.RowVersion); - - if (result.Ok) - { - await OnSaved.InvokeAsync(); - } - else - { - _error = result.Error; - } - } - finally - { - _busy = false; - } - } - - private Task CancelAsync() => OnCancel.InvokeAsync(); - - private sealed class FormModel - { - [Required, RegularExpression("^[a-z0-9-]{1,32}$", ErrorMessage = "Lowercase letters, digits, dashes only; max 32 chars.")] - public string Name { get; set; } = ""; - [Required] public string MachineCode { get; set; } = ""; - [Required] public string UnsLineId { get; set; } = ""; - public string? DriverInstanceId { get; set; } - public string? ZTag { get; set; } - public string? SAPID { get; set; } - public string? Manufacturer { get; set; } - public string? Model { get; set; } - public string? SerialNumber { get; set; } - public string? HardwareRevision { get; set; } - public string? SoftwareRevision { get; set; } - public short? YearOfConstruction { get; set; } - public string? AssetLocation { get; set; } - public string? ManufacturerUri { get; set; } - public string? DeviceManualUri { get; set; } - public bool Enabled { get; set; } = true; - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/UnsTree.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/UnsTree.razor index 4b872e2e..c65b30cd 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/UnsTree.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/UnsTree.razor @@ -13,13 +13,10 @@ /// The top-level UNS nodes to render (typically the enterprise roots). [Parameter, EditorRequired] public IReadOnlyList Roots { get; set; } = default!; - /// Raised for the primary "add child" action of a node (e.g. "+ Area" on a cluster, "+ Tag" on equipment). + /// Raised for the primary "add child" action of a structural node (e.g. "+ Area" on a cluster, "+ Equipment" on a line). [Parameter] public EventCallback OnAddChild { get; set; } - /// Raised for the equipment-only "+ Virtual tag" action, kept distinct from OnAddChild ("+ Tag"). - [Parameter] public EventCallback OnAddVirtualTag { get; set; } - - /// Raised when the user edits a node (Area/Line/Equipment/Tag/VirtualTag). + /// Raised when the user edits a node (Area/Line). [Parameter] public EventCallback OnEdit { get; set; } /// Raised when the user deletes a node (Area/Line/Equipment/Tag/VirtualTag). @@ -117,20 +114,7 @@ break; case UnsNodeKind.Equipment: - - - - - break; - - case UnsNodeKind.Tag: - case UnsNodeKind.VirtualTag: - + Open break; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsNode.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsNode.cs index 80d87bbe..3265202d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsNode.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsNode.cs @@ -241,7 +241,7 @@ public static class UnsTreeAssembly ClusterId = clusterId, EntityId = equipment.EquipmentId, ChildCount = childCount, - HasLazyChildren = childCount > 0, + HasLazyChildren = false, }; } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeAssemblyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeAssemblyTests.cs index 05d70e02..cce549d2 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeAssemblyTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeAssemblyTests.cs @@ -68,7 +68,7 @@ public sealed class UnsTreeAssemblyTests } [Fact] - public void Build_sets_equipment_child_count_and_lazy_flag() + public void Build_sets_equipment_child_count_and_leaf_flag() { var clusters = new[] { new ClusterRow("c1", "zb", "SiteA", "Alpha") }; var areas = new[] { new AreaRow("a1", "c1", "AreaOne") }; @@ -82,9 +82,12 @@ public sealed class UnsTreeAssemblyTests var tree = UnsTreeAssembly.Build(clusters, areas, lines, equipment); var line = tree.Single().Children.Single().Children.Single().Children.Single(); + // Equipment is a tree leaf that links to its own detail page, so it never carries an + // expander — HasLazyChildren is always false regardless of its tag/virtual-tag count. + // The ChildCount badge still reflects tags + virtual tags. var withChildren = line.Children.Single(e => e.Key == "eq:e1"); withChildren.ChildCount.ShouldBe(3); - withChildren.HasLazyChildren.ShouldBeTrue(); + withChildren.HasLazyChildren.ShouldBeFalse(); var empty = line.Children.Single(e => e.Key == "eq:e2"); empty.ChildCount.ShouldBe(0); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceStructureTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceStructureTests.cs index e4c19c21..195b213f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceStructureTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceStructureTests.cs @@ -54,8 +54,8 @@ public sealed class UnsTreeServiceStructureTests equipment.ClusterId.ShouldBe(UnsTreeTestDb.PopulatedClusterId); } - /// The seeded equipment node's badge count equals tags + virtual tags and it is - /// flagged as lazily expandable. + /// The seeded equipment node's badge count equals tags + virtual tags. Equipment is a + /// tree leaf (no expander), so HasLazyChildren is always false. [Fact] public async Task LoadStructure_counts_tags_and_vtags_per_equipment() { @@ -72,7 +72,7 @@ public sealed class UnsTreeServiceStructureTests // Seed: 2 driver tags + 1 virtual tag (the orphan tag has no equipment and is excluded). equipment.ChildCount.ShouldBe(3); - equipment.HasLazyChildren.ShouldBeTrue(); + equipment.HasLazyChildren.ShouldBeFalse(); } /// An empty cluster (no areas) is still rendered as a Cluster node with no children.