feat(uns): area + line modals wired into the tree
This commit is contained in:
@@ -38,22 +38,107 @@
|
||||
else
|
||||
{
|
||||
<div style="padding:.5rem 1rem">
|
||||
<UnsTree Roots="_roots" Filter="_filter" OnToggleExpand="ToggleAsync" />
|
||||
<UnsTree Roots="_roots" Filter="_filter"
|
||||
OnToggleExpand="ToggleAsync"
|
||||
OnAddChild="HandleAddChild"
|
||||
OnEdit="HandleEdit"
|
||||
OnDelete="HandleDelete" />
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<AreaModal Visible="_areaModalVisible"
|
||||
IsNew="_areaModalIsNew"
|
||||
ClusterId="_areaModalClusterId"
|
||||
Existing="_areaModalExisting"
|
||||
Clusters="ClusterOptions"
|
||||
OnSaved="OnModalSavedAsync"
|
||||
OnCancel="CloseModals" />
|
||||
|
||||
<LineModal Visible="_lineModalVisible"
|
||||
IsNew="_lineModalIsNew"
|
||||
UnsAreaId="_lineModalAreaId"
|
||||
Existing="_lineModalExisting"
|
||||
Areas="_lineModalAreaOptions"
|
||||
OnSaved="OnModalSavedAsync"
|
||||
OnCancel="CloseModals" />
|
||||
|
||||
@if (_confirmNode is not null)
|
||||
{
|
||||
<div class="modal-backdrop fade show" style="display:block"></div>
|
||||
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Delete @_confirmNode.Kind</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseModals"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Delete <span class="mono">@_confirmNode.DisplayName</span>? This cannot be undone.</p>
|
||||
@if (!string.IsNullOrWhiteSpace(_confirmError))
|
||||
{
|
||||
<div class="text-danger small mt-2">@_confirmError</div>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="CloseModals" disabled="@_confirmBusy">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" @onclick="ConfirmDeleteAsync" disabled="@_confirmBusy">
|
||||
@if (_confirmBusy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private IReadOnlyList<UnsNode> _roots = Array.Empty<UnsNode>();
|
||||
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;
|
||||
|
||||
/// <summary>The served-by cluster options for the AreaModal, derived from the loaded tree.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Returns the <c>(Id, Display)</c> area options inside a single cluster, for the line picker.</summary>
|
||||
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();
|
||||
|
||||
/// <summary>
|
||||
/// 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 @@
|
||||
}
|
||||
}
|
||||
|
||||
/// <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. Equipment "+ Tag" is handled in a later task.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Opens the delete-confirm modal for a node, stashing it as the pending target.</summary>
|
||||
private void HandleDelete(UnsNode node)
|
||||
{
|
||||
CloseModals();
|
||||
_confirmNode = node;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reloads the tree after a successful modal save and closes any open modal.</summary>
|
||||
private async Task OnModalSavedAsync()
|
||||
{
|
||||
_roots = await Svc.LoadStructureAsync();
|
||||
CloseModals();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>Reloads the tree after a successful delete and closes the confirm modal.</summary>
|
||||
private async Task ReloadAndCloseAsync()
|
||||
{
|
||||
_roots = await Svc.LoadStructureAsync();
|
||||
CloseModals();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>Closes every modal and clears its transient state.</summary>
|
||||
private void CloseModals()
|
||||
{
|
||||
_areaModalVisible = false;
|
||||
_areaModalExisting = null;
|
||||
_lineModalVisible = false;
|
||||
_lineModalExisting = null;
|
||||
_lineModalAreaOptions = Array.Empty<(string, string)>();
|
||||
_confirmNode = null;
|
||||
_confirmError = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expands every structural node (Enterprise/Cluster/Area/Line). Equipment nodes
|
||||
/// are intentionally left collapsed because expanding them would trigger lazy loads.
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
<div class="modal-backdrop fade show" style="display:block"></div>
|
||||
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<EditForm Model="_form" OnValidSubmit="SaveAsync" FormName="areaModal">
|
||||
<DataAnnotationsValidator />
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">@(IsNew ? "New UNS area" : "Edit UNS area")</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAsync"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="area-id">UnsAreaId</label>
|
||||
<InputText id="area-id" @bind-Value="_form.UnsAreaId" disabled="@(!IsNew)"
|
||||
class="form-control form-control-sm mono" />
|
||||
<ValidationMessage For="@(() => _form.UnsAreaId)" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="area-name">Name</label>
|
||||
<InputText id="area-name" @bind-Value="_form.Name" class="form-control form-control-sm" />
|
||||
<ValidationMessage For="@(() => _form.Name)" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="area-cluster">Served by cluster</label>
|
||||
<InputSelect id="area-cluster" @bind-Value="_form.ClusterId" class="form-select form-select-sm">
|
||||
@foreach (var (id, display) in Clusters)
|
||||
{
|
||||
<option value="@id">@display</option>
|
||||
}
|
||||
</InputSelect>
|
||||
<ValidationMessage For="@(() => _form.ClusterId)" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="area-notes">Notes</label>
|
||||
<InputTextArea id="area-notes" @bind-Value="_form.Notes" class="form-control form-control-sm" rows="3" />
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(_error))
|
||||
{
|
||||
<div class="text-danger small mt-2">@_error</div>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="CancelAsync" disabled="@_busy">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" disabled="@_busy">
|
||||
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
@(IsNew ? "Create" : "Save changes")
|
||||
</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
|
||||
/// <summary><c>true</c> to create a new area; <c>false</c> to edit <see cref="Existing"/>.</summary>
|
||||
[Parameter] public bool IsNew { get; set; }
|
||||
|
||||
/// <summary>The parent cluster id used to default the served-by select on create.</summary>
|
||||
[Parameter] public string? ClusterId { get; set; }
|
||||
|
||||
/// <summary>The area being edited, when <see cref="IsNew"/> is <c>false</c>.</summary>
|
||||
[Parameter] public AreaEditDto? Existing { get; set; }
|
||||
|
||||
/// <summary>The selectable served-by clusters as <c>(Id, Display)</c> pairs.</summary>
|
||||
[Parameter] public IReadOnlyList<(string Id, string Display)> Clusters { get; set; } = Array.Empty<(string, string)>();
|
||||
|
||||
/// <summary>Raised after a successful create/save so the host can reload and close.</summary>
|
||||
[Parameter] public EventCallback OnSaved { get; set; }
|
||||
|
||||
/// <summary>Raised when the user cancels so the host can close.</summary>
|
||||
[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; }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
<div class="modal-backdrop fade show" style="display:block"></div>
|
||||
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<EditForm Model="_form" OnValidSubmit="SaveAsync" FormName="lineModal">
|
||||
<DataAnnotationsValidator />
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">@(IsNew ? "New UNS line" : "Edit UNS line")</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAsync"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="line-id">UnsLineId</label>
|
||||
<InputText id="line-id" @bind-Value="_form.UnsLineId" disabled="@(!IsNew)"
|
||||
class="form-control form-control-sm mono" />
|
||||
<ValidationMessage For="@(() => _form.UnsLineId)" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="line-area">Parent area</label>
|
||||
<InputSelect id="line-area" @bind-Value="_form.UnsAreaId" class="form-select form-select-sm">
|
||||
@foreach (var (id, display) in Areas)
|
||||
{
|
||||
<option value="@id">@display</option>
|
||||
}
|
||||
</InputSelect>
|
||||
<ValidationMessage For="@(() => _form.UnsAreaId)" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="line-name">Name</label>
|
||||
<InputText id="line-name" @bind-Value="_form.Name" class="form-control form-control-sm" />
|
||||
<ValidationMessage For="@(() => _form.Name)" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="line-notes">Notes</label>
|
||||
<InputTextArea id="line-notes" @bind-Value="_form.Notes" class="form-control form-control-sm" rows="3" />
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(_error))
|
||||
{
|
||||
<div class="text-danger small mt-2">@_error</div>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="CancelAsync" disabled="@_busy">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" disabled="@_busy">
|
||||
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
@(IsNew ? "Create" : "Save changes")
|
||||
</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
|
||||
/// <summary><c>true</c> to create a new line; <c>false</c> to edit <see cref="Existing"/>.</summary>
|
||||
[Parameter] public bool IsNew { get; set; }
|
||||
|
||||
/// <summary>The parent area id used to default the parent-area select on create.</summary>
|
||||
[Parameter] public string? UnsAreaId { get; set; }
|
||||
|
||||
/// <summary>The line being edited, when <see cref="IsNew"/> is <c>false</c>.</summary>
|
||||
[Parameter] public LineEditDto? Existing { get; set; }
|
||||
|
||||
/// <summary>The selectable parent areas — scoped to the line's cluster by the host — as <c>(Id, Display)</c> pairs.</summary>
|
||||
[Parameter] public IReadOnlyList<(string Id, string Display)> Areas { get; set; } = Array.Empty<(string, string)>();
|
||||
|
||||
/// <summary>Raised after a successful create/save so the host can reload and close.</summary>
|
||||
[Parameter] public EventCallback OnSaved { get; set; }
|
||||
|
||||
/// <summary>Raised when the user cancels so the host can close.</summary>
|
||||
[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; }
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,27 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="UnsAreaId">The area's stable id (read-only on edit).</param>
|
||||
/// <param name="Name">The area name.</param>
|
||||
/// <param name="Notes">Optional notes; <c>null</c> when unset.</param>
|
||||
/// <param name="ClusterId">The owning cluster id (the served-by selection).</param>
|
||||
/// <param name="RowVersion">The optimistic-concurrency token last read.</param>
|
||||
public sealed record AreaEditDto(string UnsAreaId, string Name, string? Notes, string ClusterId, byte[] RowVersion);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="UnsLineId">The line's stable id (read-only on edit).</param>
|
||||
/// <param name="UnsAreaId">The owning area id (the parent-area selection).</param>
|
||||
/// <param name="Name">The line name.</param>
|
||||
/// <param name="Notes">Optional notes; <c>null</c> when unset.</param>
|
||||
/// <param name="RowVersion">The optimistic-concurrency token last read.</param>
|
||||
public sealed record LineEditDto(string UnsLineId, string UnsAreaId, string Name, string? Notes, byte[] RowVersion);
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <returns>Tag nodes followed by VirtualTag nodes; empty if the equipment has none.</returns>
|
||||
Task<IReadOnlyList<UnsNode>> LoadEquipmentChildrenAsync(string equipmentId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Loads a single UNS area projected for editing, or <c>null</c> if it no longer exists.
|
||||
/// Reads untracked and captures the current concurrency token for last-write-wins saves.
|
||||
/// </summary>
|
||||
/// <param name="unsAreaId">The area to load.</param>
|
||||
/// <param name="ct">A token to cancel the load.</param>
|
||||
/// <returns>The area's edit projection, or <c>null</c> when missing.</returns>
|
||||
Task<AreaEditDto?> LoadAreaAsync(string unsAreaId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Loads a single UNS line projected for editing, or <c>null</c> if it no longer exists.
|
||||
/// Reads untracked and captures the current concurrency token for last-write-wins saves.
|
||||
/// </summary>
|
||||
/// <param name="unsLineId">The line to load.</param>
|
||||
/// <param name="ct">A token to cancel the load.</param>
|
||||
/// <returns>The line's edit projection, or <c>null</c> when missing.</returns>
|
||||
Task<LineEditDto?> LoadLineAsync(string unsLineId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new UNS area under a cluster. Fails if an area with the same id already exists.
|
||||
/// Whitespace-only notes are stored as <c>null</c>.
|
||||
|
||||
@@ -124,6 +124,30 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AreaEditDto?> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LineEditDto?> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> CreateAreaAsync(
|
||||
string clusterId,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the load-for-edit projections on <see cref="UnsTreeService"/> that prefill the
|
||||
/// Area/Line edit modals and carry the concurrency token back for last-write-wins saves.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Loading a seeded area maps its fields, owning cluster, and a non-empty RowVersion.</summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>Loading a missing area returns null.</summary>
|
||||
[Fact]
|
||||
public async Task LoadArea_missing_returns_null()
|
||||
{
|
||||
var (service, _) = Seeded();
|
||||
|
||||
(await service.LoadAreaAsync("NOPE")).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Loading a seeded line maps its fields, parent area, and a non-empty RowVersion.</summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>Loading a missing line returns null.</summary>
|
||||
[Fact]
|
||||
public async Task LoadLine_missing_returns_null()
|
||||
{
|
||||
var (service, _) = Seeded();
|
||||
|
||||
(await service.LoadLineAsync("NOPE")).ShouldBeNull();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user