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 a1e813cc..9d4242c4 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 @@ -38,22 +38,107 @@ else {
- +
} + + + + +@if (_confirmNode is not null) +{ + + +} + @code { private IReadOnlyList _roots = Array.Empty(); private string? _filter; private bool _loading = true; + // --- Area modal state --- + private bool _areaModalVisible; + private bool _areaModalIsNew; + private string? _areaModalClusterId; + private AreaEditDto? _areaModalExisting; + + // --- Line modal state --- + private bool _lineModalVisible; + private bool _lineModalIsNew; + private string? _lineModalAreaId; + private LineEditDto? _lineModalExisting; + private IReadOnlyList<(string Id, string Display)> _lineModalAreaOptions = Array.Empty<(string, string)>(); + + // --- Delete-confirm state --- + private UnsNode? _confirmNode; + private bool _confirmBusy; + private string? _confirmError; + + /// The served-by cluster options for the AreaModal, derived from the loaded tree. + private IReadOnlyList<(string Id, string Display)> ClusterOptions => + _roots + .SelectMany(ent => ent.Children) + .Where(n => n.Kind == UnsNodeKind.Cluster && n.EntityId is not null) + .Select(n => (n.EntityId!, n.DisplayName)) + .ToList(); + protected override async Task OnInitializedAsync() { _roots = await Svc.LoadStructureAsync(); _loading = false; } + /// Returns the (Id, Display) area options inside a single cluster, for the line picker. + private IReadOnlyList<(string Id, string Display)> AreaOptionsForCluster(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 && a.EntityId is not null) + .Select(a => (a.EntityId!, a.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. @@ -88,6 +173,146 @@ } } + /// + /// 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. + /// + private void HandleAddChild(UnsNode node) + { + CloseModals(); + switch (node.Kind) + { + case UnsNodeKind.Cluster: + _areaModalIsNew = true; + _areaModalExisting = null; + _areaModalClusterId = node.ClusterId ?? node.EntityId; + _areaModalVisible = true; + break; + + case UnsNodeKind.Area: + _lineModalIsNew = true; + _lineModalExisting = null; + _lineModalAreaId = node.EntityId; + _lineModalAreaOptions = AreaOptionsForCluster(node.ClusterId); + _lineModalVisible = true; + break; + } + } + + /// + /// 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. + /// + private async Task HandleEdit(UnsNode node) + { + CloseModals(); + switch (node.Kind) + { + case UnsNodeKind.Area: + var area = await Svc.LoadAreaAsync(node.EntityId!); + if (area is null) { return; } + _areaModalIsNew = false; + _areaModalExisting = area; + _areaModalClusterId = area.ClusterId; + _areaModalVisible = true; + break; + + case UnsNodeKind.Line: + var line = await Svc.LoadLineAsync(node.EntityId!); + if (line is null) { return; } + _lineModalIsNew = false; + _lineModalExisting = line; + _lineModalAreaId = line.UnsAreaId; + _lineModalAreaOptions = AreaOptionsForCluster(node.ClusterId); + _lineModalVisible = true; + break; + } + } + + /// Opens the delete-confirm modal for a node, stashing it as the pending target. + private void HandleDelete(UnsNode node) + { + CloseModals(); + _confirmNode = node; + } + + /// + /// 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. + /// + private async Task ConfirmDeleteAsync() + { + if (_confirmNode is null) { return; } + + _confirmBusy = true; + _confirmError = null; + try + { + var node = _confirmNode; + UnsMutationResult result; + switch (node.Kind) + { + case UnsNodeKind.Area: + var area = await Svc.LoadAreaAsync(node.EntityId!); + if (area is null) { await ReloadAndCloseAsync(); return; } + result = await Svc.DeleteAreaAsync(node.EntityId!, area.RowVersion); + break; + + case UnsNodeKind.Line: + var line = await Svc.LoadLineAsync(node.EntityId!); + if (line is null) { await ReloadAndCloseAsync(); return; } + result = await Svc.DeleteLineAsync(node.EntityId!, line.RowVersion); + break; + + default: + // Equipment/Tag/VirtualTag deletes are wired in later tasks. + result = new UnsMutationResult(true, null); + break; + } + + if (result.Ok) + { + await ReloadAndCloseAsync(); + } + else + { + _confirmError = result.Error; + } + } + finally + { + _confirmBusy = false; + } + } + + /// Reloads the tree after a successful modal save and closes any open modal. + private async Task OnModalSavedAsync() + { + _roots = await Svc.LoadStructureAsync(); + CloseModals(); + StateHasChanged(); + } + + /// Reloads the tree after a successful delete and closes the confirm modal. + private async Task ReloadAndCloseAsync() + { + _roots = await Svc.LoadStructureAsync(); + CloseModals(); + StateHasChanged(); + } + + /// Closes every modal and clears its transient state. + private void CloseModals() + { + _areaModalVisible = false; + _areaModalExisting = null; + _lineModalVisible = false; + _lineModalExisting = null; + _lineModalAreaOptions = Array.Empty<(string, string)>(); + _confirmNode = null; + _confirmError = null; + } + /// /// Expands every structural node (Enterprise/Cluster/Area/Line). Equipment nodes /// are intentionally left collapsed because expanding them would trigger lazy loads. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AreaModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AreaModal.razor new file mode 100644 index 00000000..1f24a6de --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AreaModal.razor @@ -0,0 +1,145 @@ +@* Create/edit modal for a UNS area, wired straight into IUnsTreeService. The host page owns + visibility and supplies the parent cluster (create) or the loaded AreaEditDto (edit) plus the + served-by cluster list. 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 area; false to edit . + [Parameter] public bool IsNew { get; set; } + + /// The parent cluster id used to default the served-by select on create. + [Parameter] public string? ClusterId { get; set; } + + /// The area being edited, when is false. + [Parameter] public AreaEditDto? Existing { get; set; } + + /// The selectable served-by clusters as (Id, Display) pairs. + [Parameter] public IReadOnlyList<(string Id, string Display)> Clusters { 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 { ClusterId = ClusterId ?? "" }; + } + else if (Existing is not null) + { + _form = new FormModel + { + UnsAreaId = Existing.UnsAreaId, + Name = Existing.Name, + Notes = Existing.Notes, + ClusterId = Existing.ClusterId, + }; + } + _error = null; + } + + private async Task SaveAsync() + { + _busy = true; + _error = null; + try + { + var result = IsNew + ? await Svc.CreateAreaAsync(_form.ClusterId, _form.UnsAreaId, _form.Name, _form.Notes) + : await Svc.UpdateAreaAsync(_form.UnsAreaId, _form.Name, _form.Notes, _form.ClusterId, 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-Za-z0-9_-]+$")] public string UnsAreaId { get; set; } = ""; + [Required] public string Name { get; set; } = ""; + [Required] public string ClusterId { get; set; } = ""; + public string? Notes { get; set; } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/LineModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/LineModal.razor new file mode 100644 index 00000000..4fbf2265 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/LineModal.razor @@ -0,0 +1,146 @@ +@* Create/edit modal for a UNS line, wired straight into IUnsTreeService. The host page owns + visibility and supplies the parent area (create) or the loaded LineEditDto (edit). The parent-area + list is SCOPED TO THE LINE'S CLUSTER by the host so an edit cannot move a line across clusters. + 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 line; false to edit . + [Parameter] public bool IsNew { get; set; } + + /// The parent area id used to default the parent-area select on create. + [Parameter] public string? UnsAreaId { get; set; } + + /// The line being edited, when is false. + [Parameter] public LineEditDto? Existing { get; set; } + + /// The selectable parent areas — scoped to the line's cluster by the host — as (Id, Display) pairs. + [Parameter] public IReadOnlyList<(string Id, string Display)> Areas { 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 { UnsAreaId = UnsAreaId ?? "" }; + } + else if (Existing is not null) + { + _form = new FormModel + { + UnsLineId = Existing.UnsLineId, + UnsAreaId = Existing.UnsAreaId, + Name = Existing.Name, + Notes = Existing.Notes, + }; + } + _error = null; + } + + private async Task SaveAsync() + { + _busy = true; + _error = null; + try + { + var result = IsNew + ? await Svc.CreateLineAsync(_form.UnsAreaId, _form.UnsLineId, _form.Name, _form.Notes) + : await Svc.UpdateLineAsync(_form.UnsLineId, _form.Name, _form.Notes, _form.UnsAreaId, 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-Za-z0-9_-]+$")] public string UnsLineId { get; set; } = ""; + [Required] public string UnsAreaId { get; set; } = ""; + [Required] public string Name { get; set; } = ""; + public string? Notes { get; set; } + } +} 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 2916da49..531d2b72 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs @@ -1,5 +1,27 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; +/// +/// A UNS area projected for editing: its operator-editable fields plus the owning cluster and +/// the concurrency token the edit modal must echo back on save. +/// +/// The area's stable id (read-only on edit). +/// The area name. +/// Optional notes; null when unset. +/// The owning cluster id (the served-by selection). +/// The optimistic-concurrency token last read. +public sealed record AreaEditDto(string UnsAreaId, string Name, string? Notes, string ClusterId, byte[] RowVersion); + +/// +/// A UNS line projected for editing: its operator-editable fields plus the parent area and the +/// concurrency token the edit modal must echo back on save. +/// +/// The line's stable id (read-only on edit). +/// The owning area id (the parent-area selection). +/// The line name. +/// Optional notes; null when unset. +/// The optimistic-concurrency token last read. +public sealed record LineEditDto(string UnsLineId, string UnsAreaId, string Name, string? Notes, byte[] RowVersion); + /// /// Loads the structural portion of the unified-namespace (UNS) browse tree — /// Enterprise → Cluster → Area → Line → Equipment — from the config database. @@ -27,6 +49,24 @@ public interface IUnsTreeService /// Tag nodes followed by VirtualTag nodes; empty if the equipment has none. Task> LoadEquipmentChildrenAsync(string equipmentId, CancellationToken ct = default); + /// + /// Loads a single UNS area projected for editing, or null if it no longer exists. + /// Reads untracked and captures the current concurrency token for last-write-wins saves. + /// + /// The area to load. + /// A token to cancel the load. + /// The area's edit projection, or null when missing. + Task LoadAreaAsync(string unsAreaId, CancellationToken ct = default); + + /// + /// Loads a single UNS line projected for editing, or null if it no longer exists. + /// Reads untracked and captures the current concurrency token for last-write-wins saves. + /// + /// The line to load. + /// A token to cancel the load. + /// The line's edit projection, or null when missing. + Task LoadLineAsync(string unsLineId, 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 1bef2435..31125379 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs @@ -124,6 +124,30 @@ public sealed class UnsTreeService(IDbContextFactory dbF return result; } + /// + public async Task LoadAreaAsync(string unsAreaId, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + return await db.UnsAreas + .AsNoTracking() + .Where(a => a.UnsAreaId == unsAreaId) + .Select(a => new AreaEditDto(a.UnsAreaId, a.Name, a.Notes, a.ClusterId, a.RowVersion)) + .FirstOrDefaultAsync(ct); + } + + /// + public async Task LoadLineAsync(string unsLineId, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + return await db.UnsLines + .AsNoTracking() + .Where(l => l.UnsLineId == unsLineId) + .Select(l => new LineEditDto(l.UnsLineId, l.UnsAreaId, l.Name, l.Notes, l.RowVersion)) + .FirstOrDefaultAsync(ct); + } + /// 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 new file mode 100644 index 00000000..89153968 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceLoadEditTests.cs @@ -0,0 +1,68 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +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. +/// +[Trait("Category", "Unit")] +public sealed class UnsTreeServiceLoadEditTests +{ + private static (UnsTreeService Service, string DbName) Seeded() + { + var dbName = $"uns-loadedit-{Guid.NewGuid():N}"; + UnsTreeTestDb.SeedNamed(dbName); + return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName); + } + + /// Loading a seeded area maps its fields, owning cluster, and a non-empty RowVersion. + [Fact] + public async Task LoadArea_returns_dto() + { + var (service, _) = Seeded(); + + var dto = await service.LoadAreaAsync("AREA-1"); + + dto.ShouldNotBeNull(); + dto.UnsAreaId.ShouldBe("AREA-1"); + dto.Name.ShouldBe("assembly"); + dto.ClusterId.ShouldBe(UnsTreeTestDb.PopulatedClusterId); + dto.RowVersion.ShouldNotBeNull(); + } + + /// Loading a missing area returns null. + [Fact] + public async Task LoadArea_missing_returns_null() + { + var (service, _) = Seeded(); + + (await service.LoadAreaAsync("NOPE")).ShouldBeNull(); + } + + /// Loading a seeded line maps its fields, parent area, and a non-empty RowVersion. + [Fact] + public async Task LoadLine_returns_dto() + { + var (service, _) = Seeded(); + + var dto = await service.LoadLineAsync("LINE-1"); + + dto.ShouldNotBeNull(); + dto.UnsLineId.ShouldBe("LINE-1"); + dto.UnsAreaId.ShouldBe("AREA-1"); + dto.Name.ShouldBe("line-a"); + dto.RowVersion.ShouldNotBeNull(); + } + + /// Loading a missing line returns null. + [Fact] + public async Task LoadLine_missing_returns_null() + { + var (service, _) = Seeded(); + + (await service.LoadLineAsync("NOPE")).ShouldBeNull(); + } +}