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;
}
@@ -0,0 +1,212 @@
@* Create/edit modal for an equipment-bound tag, wired straight into IUnsTreeService. The host page
owns visibility and supplies the owning equipment id (create) or the loaded TagEditDto (edit), plus
the equipment-scoped candidate-driver list. Tree tags are always equipment-bound (decision #110), so
the legacy SystemPlatform/FolderPath branch is dropped entirely — there is no equipment selector
either, the owning equipment is fixed. On a successful save it raises OnSaved so the host can
refresh the equipment's children in place. *@
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Components.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@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 modal-lg" role="document">
<div class="modal-content">
<EditForm Model="_form" OnValidSubmit="SaveAsync" FormName="tagModal">
<DataAnnotationsValidator />
<div class="modal-header">
<h5 class="modal-title">@(IsNew ? "New tag" : "Edit tag")</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAsync"></button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="tag-id">TagId</label>
<InputText id="tag-id" @bind-Value="_form.TagId" disabled="@(!IsNew)"
class="form-control form-control-sm mono"
placeholder="tag-line3-temp-01" />
<ValidationMessage For="@(() => _form.TagId)" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="tag-name">Name</label>
<InputText id="tag-name" @bind-Value="_form.Name" class="form-control form-control-sm"
placeholder="Temperature setpoint" />
<ValidationMessage For="@(() => _form.Name)" />
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="tag-driver">Driver instance</label>
<InputSelect id="tag-driver" @bind-Value="_form.DriverInstanceId" class="form-select form-select-sm">
<option value="">— pick a driver —</option>
@foreach (var (id, display) in Drivers)
{
<option value="@id">@display</option>
}
</InputSelect>
<ValidationMessage For="@(() => _form.DriverInstanceId)" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="tag-dtype">Data type</label>
<InputSelect id="tag-dtype" @bind-Value="_form.DataType" class="form-select form-select-sm">
@foreach (var dt in DataTypes)
{
<option value="@dt">@dt</option>
}
</InputSelect>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="tag-access">Access level</label>
<InputSelect id="tag-access" @bind-Value="_form.AccessLevel" class="form-select form-select-sm">
<option value="@TagAccessLevel.Read">Read</option>
<option value="@TagAccessLevel.ReadWrite">ReadWrite</option>
</InputSelect>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">WriteIdempotent</label>
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.WriteIdempotent" class="form-check-input" />
<label class="form-check-label">Safe to retry writes (decision #4445)</label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label" for="tag-pgroup">PollGroupId (optional)</label>
<InputText id="tag-pgroup" @bind-Value="_form.PollGroupId" class="form-control form-control-sm mono" />
</div>
<div class="mb-3">
<label class="form-label" for="tag-config">Tag config (JSON)</label>
<InputTextArea id="tag-config" @bind-Value="_form.TagConfig" rows="6"
class="form-control form-control-sm mono"
placeholder='{ "register": 40001, "scale": 0.1 }' />
<div class="form-text">Schemaless per driver type — register / address / scaling / byte-order. Validated server-side at deploy.</div>
<ValidationMessage For="@(() => _form.TagConfig)" />
</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 {
private static readonly string[] DataTypes =
["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32",
"Int64", "UInt64", "Float", "Double", "String", "DateTime", "Guid", "ByteString"];
/// <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 tag; <c>false</c> to edit <see cref="Existing"/>.</summary>
[Parameter] public bool IsNew { get; set; }
/// <summary>The owning equipment id the created tag binds to (used only on create).</summary>
[Parameter] public string? EquipmentId { get; set; }
/// <summary>The tag being edited, when <see cref="IsNew"/> is <c>false</c>.</summary>
[Parameter] public TagEditDto? Existing { get; set; }
/// <summary>The candidate drivers — scoped to the equipment's cluster by the host — as <c>(Id, Display)</c> pairs.</summary>
[Parameter] public IReadOnlyList<(string Id, string Display)> Drivers { get; set; } = Array.Empty<(string, string)>();
/// <summary>Raised after a successful create/save so the host can refresh the equipment's children 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();
}
else if (Existing is not null)
{
_form = new FormModel
{
TagId = Existing.TagId,
Name = Existing.Name,
DriverInstanceId = Existing.DriverInstanceId,
DataType = Existing.DataType,
AccessLevel = Existing.AccessLevel,
WriteIdempotent = Existing.WriteIdempotent,
PollGroupId = Existing.PollGroupId,
TagConfig = Existing.TagConfig,
};
}
_error = null;
}
private async Task SaveAsync()
{
_busy = true;
_error = null;
try
{
var input = new TagInput(
_form.TagId,
_form.Name,
_form.DriverInstanceId,
_form.DataType,
_form.AccessLevel,
_form.WriteIdempotent,
_form.PollGroupId,
_form.TagConfig);
var result = IsNew
? await Svc.CreateTagAsync(EquipmentId!, input)
: await Svc.UpdateTagAsync(Existing!.TagId, 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-Za-z0-9_-]+$")] public string TagId { get; set; } = "";
[Required] public string Name { get; set; } = "";
[Required] public string DriverInstanceId { get; set; } = "";
public string DataType { get; set; } = "Float";
public TagAccessLevel AccessLevel { get; set; } = TagAccessLevel.Read;
public bool WriteIdempotent { get; set; }
public string? PollGroupId { get; set; }
[Required] public string TagConfig { get; set; } = "{}";
}
}
@@ -0,0 +1,201 @@
@* Create/edit modal for an equipment-bound virtual tag, wired straight into IUnsTreeService. The host
page owns visibility and supplies the owning equipment id (create) or the loaded VirtualTagEditDto
(edit), plus the script list. Virtual tags are always scoped to an equipment (plan decision #2) and
that binding never moves, so this modal deliberately offers NO equipment selector — the owning
equipment is fixed. On a successful save it raises OnSaved so the host can refresh the equipment's
children in place. *@
@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 modal-lg" role="document">
<div class="modal-content">
<EditForm Model="_form" OnValidSubmit="SaveAsync" FormName="virtualTagModal">
<DataAnnotationsValidator />
<div class="modal-header">
<h5 class="modal-title">@(IsNew ? "New virtual tag" : "Edit virtual tag")</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAsync"></button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="vtag-id">VirtualTagId</label>
<InputText id="vtag-id" @bind-Value="_form.VirtualTagId" disabled="@(!IsNew)"
class="form-control form-control-sm mono" />
<ValidationMessage For="@(() => _form.VirtualTagId)" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="vtag-name">Name</label>
<InputText id="vtag-name" @bind-Value="_form.Name" class="form-control form-control-sm" />
<ValidationMessage For="@(() => _form.Name)" />
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="vtag-dtype">DataType</label>
<InputText id="vtag-dtype" @bind-Value="_form.DataType" class="form-control form-control-sm mono"
placeholder="Double" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="vtag-script">Script</label>
<InputSelect id="vtag-script" @bind-Value="_form.ScriptId" class="form-select form-select-sm">
<option value="">— pick script —</option>
@foreach (var (id, display) in Scripts)
{
<option value="@id">@display</option>
}
</InputSelect>
<ValidationMessage For="@(() => _form.ScriptId)" />
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">Change-triggered</label>
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.ChangeTriggered" class="form-check-input" />
<label class="form-check-label">Re-evaluate on dependency change</label>
</div>
</div>
<div class="col-md-4 mb-3">
<label class="form-label" for="vtag-timer">TimerIntervalMs (optional)</label>
<InputNumber id="vtag-timer" @bind-Value="_form.TimerIntervalMs" class="form-control form-control-sm" />
<div class="form-text">Periodic re-evaluation. Null = change-trigger only.</div>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Historize</label>
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.Historize" class="form-check-input" />
<label class="form-check-label">Send to Wonderware historian</label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Enabled</label>
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.Enabled" class="form-check-input" />
<label class="form-check-label">Spawn this virtual tag in deployments</label>
</div>
</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 virtual tag; <c>false</c> to edit <see cref="Existing"/>.</summary>
[Parameter] public bool IsNew { get; set; }
/// <summary>The owning equipment id the created virtual tag binds to (used only on create).</summary>
[Parameter] public string? EquipmentId { get; set; }
/// <summary>The virtual tag being edited, when <see cref="IsNew"/> is <c>false</c>.</summary>
[Parameter] public VirtualTagEditDto? Existing { get; set; }
/// <summary>The selectable scripts as <c>(Id, Display)</c> pairs.</summary>
[Parameter] public IReadOnlyList<(string Id, string Display)> Scripts { get; set; } = Array.Empty<(string, string)>();
/// <summary>Raised after a successful create/save so the host can refresh the equipment's children 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();
}
else if (Existing is not null)
{
_form = new FormModel
{
VirtualTagId = Existing.VirtualTagId,
Name = Existing.Name,
DataType = Existing.DataType,
ScriptId = Existing.ScriptId,
ChangeTriggered = Existing.ChangeTriggered,
TimerIntervalMs = Existing.TimerIntervalMs,
Historize = Existing.Historize,
Enabled = Existing.Enabled,
};
}
_error = null;
}
private async Task SaveAsync()
{
_busy = true;
_error = null;
try
{
var input = new VirtualTagInput(
_form.VirtualTagId,
_form.Name,
_form.DataType,
_form.ScriptId,
_form.ChangeTriggered,
_form.TimerIntervalMs,
_form.Historize,
_form.Enabled);
var result = IsNew
? await Svc.CreateVirtualTagAsync(EquipmentId!, input)
: await Svc.UpdateVirtualTagAsync(Existing!.VirtualTagId, 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-Za-z0-9_-]+$")] public string VirtualTagId { get; set; } = "";
[Required] public string Name { get; set; } = "";
public string DataType { get; set; } = "Double";
[Required] public string ScriptId { get; set; } = "";
public bool ChangeTriggered { get; set; } = true;
public int? TimerIntervalMs { get; set; }
public bool Historize { get; set; }
public bool Enabled { get; set; } = true;
}
}
@@ -1,3 +1,5 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
@@ -49,6 +51,44 @@ public sealed record EquipmentEditDto(string EquipmentId, string Name, string Ma
string? HardwareRevision, string? SoftwareRevision, short? YearOfConstruction, string? AssetLocation,
string? ManufacturerUri, string? DeviceManualUri, bool Enabled, byte[] RowVersion);
/// <summary>
/// An equipment-bound tag projected for editing: its operator-editable fields, the owning equipment
/// (so the host can scope the candidate-driver list and refresh the right node), plus the concurrency
/// token the edit modal must echo back on save. Tree tags are always equipment-bound (decision #110),
/// so <c>FolderPath</c> never surfaces here.
/// </summary>
/// <param name="TagId">The tag's stable id (read-only on edit).</param>
/// <param name="EquipmentId">The owning equipment id.</param>
/// <param name="Name">The tag name.</param>
/// <param name="DriverInstanceId">The bound driver id.</param>
/// <param name="DataType">The OPC UA built-in type name.</param>
/// <param name="AccessLevel">The tag-level access baseline.</param>
/// <param name="WriteIdempotent">Whether writes are safe to retry.</param>
/// <param name="PollGroupId">Optional poll-group key; <c>null</c> when unset.</param>
/// <param name="TagConfig">The schemaless per-driver-type JSON config.</param>
/// <param name="RowVersion">The optimistic-concurrency token last read.</param>
public sealed record TagEditDto(string TagId, string EquipmentId, string Name, string DriverInstanceId, string DataType,
TagAccessLevel AccessLevel, bool WriteIdempotent, string? PollGroupId, string TagConfig, byte[] RowVersion);
/// <summary>
/// An equipment-bound virtual tag projected for editing: its operator-editable fields, the owning
/// equipment (so the host can refresh the right node), plus the concurrency token the edit modal must
/// echo back on save. Virtual tags are always scoped to an equipment (plan decision #2), so the modal
/// never offers an equipment-change control.
/// </summary>
/// <param name="VirtualTagId">The virtual tag's stable id (read-only on edit).</param>
/// <param name="EquipmentId">The owning equipment id.</param>
/// <param name="Name">The virtual-tag name.</param>
/// <param name="DataType">The OPC UA built-in type name.</param>
/// <param name="ScriptId">The bound script id.</param>
/// <param name="ChangeTriggered">Whether the tag re-evaluates on dependency change.</param>
/// <param name="TimerIntervalMs">Optional periodic re-evaluation cadence in ms; <c>null</c> when unset.</param>
/// <param name="Historize">Whether the tag's values are historized.</param>
/// <param name="Enabled">Whether the tag is spawned in deployments.</param>
/// <param name="RowVersion">The optimistic-concurrency token last read.</param>
public sealed record VirtualTagEditDto(string VirtualTagId, string EquipmentId, string Name, string DataType, string ScriptId,
bool ChangeTriggered, int? TimerIntervalMs, bool Historize, bool Enabled, byte[] RowVersion);
/// <summary>
/// Loads the structural portion of the unified-namespace (UNS) browse tree —
/// Enterprise → Cluster → Area → Line → Equipment — from the config database.
@@ -103,6 +143,24 @@ public interface IUnsTreeService
/// <returns>The equipment's edit projection, or <c>null</c> when missing.</returns>
Task<EquipmentEditDto?> LoadEquipmentAsync(string equipmentId, CancellationToken ct = default);
/// <summary>
/// Loads a single equipment-bound tag 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="tagId">The tag to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The tag's edit projection, or <c>null</c> when missing.</returns>
Task<TagEditDto?> LoadTagAsync(string tagId, CancellationToken ct = default);
/// <summary>
/// Loads a single equipment-bound virtual tag 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="virtualTagId">The virtual tag to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The virtual tag's edit projection, or <c>null</c> when missing.</returns>
Task<VirtualTagEditDto?> LoadVirtualTagAsync(string virtualTagId, CancellationToken ct = default);
/// <summary>
/// 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 <c>DriverInstanceId</c>. Each is
@@ -178,6 +178,50 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
.FirstOrDefaultAsync(ct);
}
/// <inheritdoc />
public async Task<TagEditDto?> LoadTagAsync(string tagId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
return await db.Tags
.AsNoTracking()
.Where(t => t.TagId == tagId)
.Select(t => new TagEditDto(
t.TagId,
t.EquipmentId!,
t.Name,
t.DriverInstanceId,
t.DataType,
t.AccessLevel,
t.WriteIdempotent,
t.PollGroupId,
t.TagConfig,
t.RowVersion))
.FirstOrDefaultAsync(ct);
}
/// <inheritdoc />
public async Task<VirtualTagEditDto?> LoadVirtualTagAsync(string virtualTagId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
return await db.VirtualTags
.AsNoTracking()
.Where(v => v.VirtualTagId == virtualTagId)
.Select(v => new VirtualTagEditDto(
v.VirtualTagId,
v.EquipmentId,
v.Name,
v.DataType,
v.ScriptId,
v.ChangeTriggered,
v.TimerIntervalMs,
v.Historize,
v.Enabled,
v.RowVersion))
.FirstOrDefaultAsync(ct);
}
/// <inheritdoc />
public async Task<IReadOnlyList<(string DriverInstanceId, string Display)>> LoadDriversForClusterAsync(
string clusterId,
@@ -2,6 +2,7 @@ using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
@@ -142,4 +143,58 @@ public sealed class UnsTreeServiceLoadEditTests
drivers[1].DriverInstanceId.ShouldBe("DRV-B");
drivers[1].Display.ShouldBe("DRV-B — modbus-b (ModbusTcp)");
}
/// <summary>Loading a seeded tag maps its fields, owning equipment, and a non-empty RowVersion.</summary>
[Fact]
public async Task LoadTag_returns_dto()
{
var (service, _) = Seeded();
var dto = await service.LoadTagAsync("TAG-1");
dto.ShouldNotBeNull();
dto.TagId.ShouldBe("TAG-1");
dto.EquipmentId.ShouldBe(UnsTreeTestDb.SeededEquipmentId);
dto.Name.ShouldBe("speed");
dto.DriverInstanceId.ShouldBe("DRV-1");
dto.DataType.ShouldBe("Float");
dto.AccessLevel.ShouldBe(TagAccessLevel.Read);
dto.TagConfig.ShouldBe("{}");
dto.RowVersion.ShouldNotBeNull();
}
/// <summary>Loading a missing tag returns null.</summary>
[Fact]
public async Task LoadTag_missing_returns_null()
{
var (service, _) = Seeded();
(await service.LoadTagAsync("NOPE")).ShouldBeNull();
}
/// <summary>Loading a seeded virtual tag maps its fields, owning equipment, and a non-empty RowVersion.</summary>
[Fact]
public async Task LoadVirtualTag_returns_dto()
{
var (service, _) = Seeded();
var dto = await service.LoadVirtualTagAsync("VTAG-1");
dto.ShouldNotBeNull();
dto.VirtualTagId.ShouldBe("VTAG-1");
dto.EquipmentId.ShouldBe(UnsTreeTestDb.SeededEquipmentId);
dto.Name.ShouldBe("computed");
dto.DataType.ShouldBe("Double");
dto.ScriptId.ShouldBe("SCRIPT-1");
dto.RowVersion.ShouldNotBeNull();
}
/// <summary>Loading a missing virtual tag returns null.</summary>
[Fact]
public async Task LoadVirtualTag_missing_returns_null()
{
var (service, _) = Seeded();
(await service.LoadVirtualTagAsync("NOPE")).ShouldBeNull();
}
}