From 2beaa43d60994c08e8b87af87dbbb06943f5acbd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 8 Jun 2026 13:31:14 -0400 Subject: [PATCH] feat(uns): equipment modal wired into the tree --- .../Components/Pages/Uns/GlobalUns.razor | 68 ++++- .../Shared/Uns/EquipmentModal.razor | 252 ++++++++++++++++++ .../Uns/IUnsTreeService.cs | 47 ++++ .../Uns/UnsTreeService.cs | 49 ++++ .../Uns/UnsTreeServiceLoadEditTests.cs | 79 +++++- 5 files changed, 490 insertions(+), 5 deletions(-) create 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 d2e0a216..6ae88e08 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 @@ -63,6 +63,15 @@ OnSaved="OnModalSavedAsync" OnCancel="CloseModals" /> + + @if (_confirmNode is not null) { @@ -110,6 +119,14 @@ 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)>(); + // --- Delete-confirm state --- private UnsNode? _confirmNode; private bool _confirmBusy; @@ -139,6 +156,18 @@ .Select(a => (a.EntityId!, a.DisplayName)) .ToList(); + /// Returns the (Id, Display) line options inside a single cluster, for the equipment picker. + private IReadOnlyList<(string Id, string Display)> LinesForCluster(string? clusterId) => + _roots + .SelectMany(ent => ent.Children) + .Where(c => c.Kind == UnsNodeKind.Cluster && c.ClusterId == clusterId) + .SelectMany(c => c.Children) + .Where(a => a.Kind == UnsNodeKind.Area) + .SelectMany(a => a.Children) + .Where(l => l.Kind == UnsNodeKind.Line && l.EntityId is not null) + .Select(l => (l.EntityId!, l.DisplayName)) + .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. @@ -175,9 +204,10 @@ /// /// 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. Equipment "+ Tag" is handled in a later task. + /// new line scoped to its cluster; a line gets a new equipment scoped to its cluster. Equipment + /// "+ Tag" is handled in a later task. /// - private void HandleAddChild(UnsNode node) + private async Task HandleAddChild(UnsNode node) { CloseModals(); switch (node.Kind) @@ -196,6 +226,15 @@ _lineModalAreaOptions = AreaOptionsForCluster(node.ClusterId); _lineModalVisible = true; break; + + case UnsNodeKind.Line: + _equipmentModalIsNew = true; + _equipmentModalExisting = null; + _equipmentModalLineId = node.EntityId; + _equipmentModalLineOptions = LinesForCluster(node.ClusterId); + _equipmentModalDriverOptions = await Svc.LoadDriversForClusterAsync(node.ClusterId!); + _equipmentModalVisible = true; + break; } } @@ -226,6 +265,17 @@ _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; } } @@ -238,7 +288,7 @@ /// /// Performs the pending delete. Loads the entity's RowVersion first, then dispatches on Kind. - /// Area/Line are handled here; other kinds are wired in later tasks. + /// Area/Line/Equipment are handled here; Tag/VirtualTag are wired in later tasks. /// private async Task ConfirmDeleteAsync() { @@ -264,8 +314,14 @@ result = await Svc.DeleteLineAsync(node.EntityId!, line.RowVersion); break; + case UnsNodeKind.Equipment: + var equipment = await Svc.LoadEquipmentAsync(node.EntityId!); + if (equipment is null) { await ReloadAndCloseAsync(); return; } + result = await Svc.DeleteEquipmentAsync(node.EntityId!, equipment.RowVersion); + break; + default: - // Equipment/Tag/VirtualTag deletes are wired in later tasks. + // Tag/VirtualTag deletes are wired in later tasks. result = new UnsMutationResult(false, "Delete for this node kind is not yet available."); break; } @@ -309,6 +365,10 @@ _lineModalVisible = false; _lineModalExisting = null; _lineModalAreaOptions = Array.Empty<(string, string)>(); + _equipmentModalVisible = false; + _equipmentModalExisting = null; + _equipmentModalLineOptions = Array.Empty<(string, string)>(); + _equipmentModalDriverOptions = Array.Empty<(string, string)>(); _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 new file mode 100644 index 00000000..810b4e0f --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/EquipmentModal.razor @@ -0,0 +1,252 @@ +@* 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/Uns/IUnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs index 531d2b72..7aca7ae6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs @@ -22,6 +22,33 @@ public sealed record AreaEditDto(string UnsAreaId, string Name, string? Notes, s /// The optimistic-concurrency token last read. public sealed record LineEditDto(string UnsLineId, string UnsAreaId, string Name, string? Notes, byte[] RowVersion); +/// +/// An equipment projected for editing: its system-generated id, the operator-editable identity and +/// OPC 40010 identification fields, plus the concurrency token the edit modal must echo back on save. +/// +/// The system-generated stable id (read-only — never operator-edited, decision #125). +/// UNS level-5 segment name. +/// Operator colloquial id; unique fleet-wide. +/// The owning line id (the UNS-line selection). +/// Optional driver binding; null when driver-less. +/// Optional ERP equipment id. +/// Optional SAP PM equipment id. +/// Optional OPC 40010 manufacturer name. +/// Optional OPC 40010 model designation. +/// Optional OPC 40010 serial number. +/// Optional OPC 40010 hardware revision. +/// Optional OPC 40010 software revision. +/// Optional OPC 40010 year of construction. +/// Optional OPC 40010 asset location. +/// Optional OPC 40010 manufacturer URI. +/// Optional OPC 40010 device-manual URI. +/// Whether the equipment is surfaced in deployments. +/// The optimistic-concurrency token last read. +public sealed record EquipmentEditDto(string EquipmentId, string Name, string MachineCode, string UnsLineId, + string? DriverInstanceId, string? ZTag, string? SAPID, string? Manufacturer, string? Model, string? SerialNumber, + string? HardwareRevision, string? SoftwareRevision, short? YearOfConstruction, string? AssetLocation, + string? ManufacturerUri, string? DeviceManualUri, bool Enabled, byte[] RowVersion); + /// /// Loads the structural portion of the unified-namespace (UNS) browse tree — /// Enterprise → Cluster → Area → Line → Equipment — from the config database. @@ -67,6 +94,26 @@ public interface IUnsTreeService /// The line's edit projection, or null when missing. Task LoadLineAsync(string unsLineId, CancellationToken ct = default); + /// + /// Loads a single equipment projected for editing, or null if it no longer exists. + /// Reads untracked and captures the current concurrency token for last-write-wins saves. + /// + /// The equipment to load. + /// A token to cancel the load. + /// The equipment's edit projection, or null when missing. + Task LoadEquipmentAsync(string equipmentId, CancellationToken ct = default); + + /// + /// Loads every driver instance in a cluster (regardless of namespace kind) so the equipment modal + /// can offer the full cluster driver list for binding. Ordered by DriverInstanceId. Each is + /// projected to a (DriverInstanceId, Display) pair where Display is + /// "{DriverInstanceId} — {Name} ({DriverType})". + /// + /// The cluster whose drivers to load. + /// A token to cancel the load. + /// The cluster's drivers projected to (DriverInstanceId, Display) pairs. + Task> LoadDriversForClusterAsync(string clusterId, CancellationToken ct = default); + /// /// Creates a new UNS area under a cluster. Fails if an area with the same id already exists. /// Whitespace-only notes are stored as null. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs index 31125379..47278b61 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs @@ -148,6 +148,55 @@ public sealed class UnsTreeService(IDbContextFactory dbF .FirstOrDefaultAsync(ct); } + /// + public async Task LoadEquipmentAsync(string equipmentId, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + return await db.Equipment + .AsNoTracking() + .Where(e => e.EquipmentId == equipmentId) + .Select(e => new EquipmentEditDto( + e.EquipmentId, + e.Name, + e.MachineCode, + e.UnsLineId, + e.DriverInstanceId, + e.ZTag, + e.SAPID, + e.Manufacturer, + e.Model, + e.SerialNumber, + e.HardwareRevision, + e.SoftwareRevision, + e.YearOfConstruction, + e.AssetLocation, + e.ManufacturerUri, + e.DeviceManualUri, + e.Enabled, + e.RowVersion)) + .FirstOrDefaultAsync(ct); + } + + /// + public async Task> LoadDriversForClusterAsync( + string clusterId, + CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var drivers = await db.DriverInstances + .AsNoTracking() + .Where(d => d.ClusterId == clusterId) + .OrderBy(d => d.DriverInstanceId) + .Select(d => new { d.DriverInstanceId, d.Name, d.DriverType }) + .ToListAsync(ct); + + return drivers + .Select(d => (d.DriverInstanceId, Display: $"{d.DriverInstanceId} — {d.Name} ({d.DriverType})")) + .ToList(); + } + /// public async Task CreateAreaAsync( string clusterId, diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceLoadEditTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceLoadEditTests.cs index 89153968..1a509635 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceLoadEditTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceLoadEditTests.cs @@ -1,12 +1,13 @@ using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; /// /// Verifies the load-for-edit projections on that prefill the -/// Area/Line edit modals and carry the concurrency token back for last-write-wins saves. +/// Area/Line/Equipment edit modals and carry the concurrency token back for last-write-wins saves. /// [Trait("Category", "Unit")] public sealed class UnsTreeServiceLoadEditTests @@ -65,4 +66,80 @@ public sealed class UnsTreeServiceLoadEditTests (await service.LoadLineAsync("NOPE")).ShouldBeNull(); } + + /// Loading the seeded equipment maps its identity fields, line, and a non-empty RowVersion. + [Fact] + public async Task LoadEquipment_returns_dto() + { + var (service, _) = Seeded(); + + var dto = await service.LoadEquipmentAsync(UnsTreeTestDb.SeededEquipmentId); + + dto.ShouldNotBeNull(); + dto.EquipmentId.ShouldBe(UnsTreeTestDb.SeededEquipmentId); + dto.Name.ShouldBe("machine-1"); + dto.MachineCode.ShouldBe("machine_001"); + dto.UnsLineId.ShouldBe("LINE-1"); + dto.RowVersion.ShouldNotBeNull(); + } + + /// Loading a missing equipment returns null. + [Fact] + public async Task LoadEquipment_missing_returns_null() + { + var (service, _) = Seeded(); + + (await service.LoadEquipmentAsync("NOPE")).ShouldBeNull(); + } + + /// + /// LoadDriversForCluster returns every driver in the cluster (any namespace kind), ordered by id, + /// with the "{Id} — {Name} ({DriverType})" display; drivers in other clusters are excluded. + /// + [Fact] + public async Task LoadDriversForCluster_returns_cluster_drivers() + { + var (service, dbName) = Seeded(); + + await using (var db = UnsTreeTestDb.CreateNamed(dbName)) + { + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = "DRV-B", + ClusterId = UnsTreeTestDb.PopulatedClusterId, + NamespaceId = "NS-1", + Name = "modbus-b", + DriverType = "ModbusTcp", + DriverConfig = "{}", + }); + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = "DRV-A", + ClusterId = UnsTreeTestDb.PopulatedClusterId, + NamespaceId = "NS-1", + Name = "galaxy-a", + DriverType = "Galaxy", + DriverConfig = "{}", + }); + // A driver in a different cluster must be excluded. + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = "DRV-OTHER", + ClusterId = UnsTreeTestDb.EmptyClusterId, + NamespaceId = "NS-2", + Name = "other", + DriverType = "S7", + DriverConfig = "{}", + }); + await db.SaveChangesAsync(); + } + + var drivers = await service.LoadDriversForClusterAsync(UnsTreeTestDb.PopulatedClusterId); + + drivers.Count.ShouldBe(2); + drivers[0].DriverInstanceId.ShouldBe("DRV-A"); + drivers[0].Display.ShouldBe("DRV-A — galaxy-a (Galaxy)"); + drivers[1].DriverInstanceId.ShouldBe("DRV-B"); + drivers[1].Display.ShouldBe("DRV-B — modbus-b (ModbusTcp)"); + } }