feat(uns): tag + virtual-tag modals wired into the tree
This commit is contained in:
@@ -41,6 +41,7 @@
|
|||||||
<UnsTree Roots="_roots" Filter="_filter"
|
<UnsTree Roots="_roots" Filter="_filter"
|
||||||
OnToggleExpand="ToggleAsync"
|
OnToggleExpand="ToggleAsync"
|
||||||
OnAddChild="HandleAddChild"
|
OnAddChild="HandleAddChild"
|
||||||
|
OnAddVirtualTag="HandleAddVirtualTag"
|
||||||
OnEdit="HandleEdit"
|
OnEdit="HandleEdit"
|
||||||
OnDelete="HandleDelete" />
|
OnDelete="HandleDelete" />
|
||||||
</div>
|
</div>
|
||||||
@@ -72,6 +73,22 @@
|
|||||||
OnSaved="OnModalSavedAsync"
|
OnSaved="OnModalSavedAsync"
|
||||||
OnCancel="CloseModals" />
|
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)
|
@if (_confirmNode is not null)
|
||||||
{
|
{
|
||||||
<div class="modal-backdrop fade show" style="display:block"></div>
|
<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)> _equipmentModalLineOptions = Array.Empty<(string, string)>();
|
||||||
private IReadOnlyList<(string Id, string Display)> _equipmentModalDriverOptions = 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 ---
|
// --- Delete-confirm state ---
|
||||||
private UnsNode? _confirmNode;
|
private UnsNode? _confirmNode;
|
||||||
private bool _confirmBusy;
|
private bool _confirmBusy;
|
||||||
@@ -204,8 +238,8 @@
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens the create modal for a node's primary child: a cluster gets a new area; an area gets a
|
/// 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
|
/// new line scoped to its cluster; a line gets a new equipment scoped to its cluster; an equipment
|
||||||
/// "+ Tag" is handled in a later task.
|
/// gets a new tag scoped to its candidate drivers.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task HandleAddChild(UnsNode node)
|
private async Task HandleAddChild(UnsNode node)
|
||||||
{
|
{
|
||||||
@@ -235,12 +269,34 @@
|
|||||||
_equipmentModalDriverOptions = await Svc.LoadDriversForClusterAsync(node.ClusterId!);
|
_equipmentModalDriverOptions = await Svc.LoadDriversForClusterAsync(node.ClusterId!);
|
||||||
_equipmentModalVisible = true;
|
_equipmentModalVisible = true;
|
||||||
break;
|
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>
|
/// <summary>
|
||||||
/// Opens the edit modal for an Area or Line, loading the entity first to prefill the form and
|
/// Opens the edit modal for the clicked node, loading the entity first to prefill the form and
|
||||||
/// capture its RowVersion. Other kinds are handled in later tasks.
|
/// 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>
|
/// </summary>
|
||||||
private async Task HandleEdit(UnsNode node)
|
private async Task HandleEdit(UnsNode node)
|
||||||
{
|
{
|
||||||
@@ -276,6 +332,28 @@
|
|||||||
_equipmentModalDriverOptions = await Svc.LoadDriversForClusterAsync(node.ClusterId!);
|
_equipmentModalDriverOptions = await Svc.LoadDriversForClusterAsync(node.ClusterId!);
|
||||||
_equipmentModalVisible = true;
|
_equipmentModalVisible = true;
|
||||||
break;
|
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>
|
/// <summary>
|
||||||
/// Performs the pending delete. Loads the entity's RowVersion first, then dispatches on Kind.
|
/// 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>
|
/// </summary>
|
||||||
private async Task ConfirmDeleteAsync()
|
private async Task ConfirmDeleteAsync()
|
||||||
{
|
{
|
||||||
@@ -300,8 +379,43 @@
|
|||||||
{
|
{
|
||||||
var node = _confirmNode;
|
var node = _confirmNode;
|
||||||
UnsMutationResult result;
|
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)
|
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:
|
case UnsNodeKind.Area:
|
||||||
var area = await Svc.LoadAreaAsync(node.EntityId!);
|
var area = await Svc.LoadAreaAsync(node.EntityId!);
|
||||||
if (area is null) { await ReloadAndCloseAsync(); return; }
|
if (area is null) { await ReloadAndCloseAsync(); return; }
|
||||||
@@ -321,7 +435,7 @@
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
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.");
|
result = new UnsMutationResult(false, "Delete for this node kind is not yet available.");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -349,6 +463,64 @@
|
|||||||
StateHasChanged();
|
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>
|
/// <summary>Reloads the tree after a successful delete and closes the confirm modal.</summary>
|
||||||
private async Task ReloadAndCloseAsync()
|
private async Task ReloadAndCloseAsync()
|
||||||
{
|
{
|
||||||
@@ -369,6 +541,15 @@
|
|||||||
_equipmentModalExisting = null;
|
_equipmentModalExisting = null;
|
||||||
_equipmentModalLineOptions = Array.Empty<(string, string)>();
|
_equipmentModalLineOptions = Array.Empty<(string, string)>();
|
||||||
_equipmentModalDriverOptions = 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;
|
_confirmNode = null;
|
||||||
_confirmError = 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 #44–45)</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;
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -49,6 +51,44 @@ public sealed record EquipmentEditDto(string EquipmentId, string Name, string Ma
|
|||||||
string? HardwareRevision, string? SoftwareRevision, short? YearOfConstruction, string? AssetLocation,
|
string? HardwareRevision, string? SoftwareRevision, short? YearOfConstruction, string? AssetLocation,
|
||||||
string? ManufacturerUri, string? DeviceManualUri, bool Enabled, byte[] RowVersion);
|
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>
|
/// <summary>
|
||||||
/// Loads the structural portion of the unified-namespace (UNS) browse tree —
|
/// Loads the structural portion of the unified-namespace (UNS) browse tree —
|
||||||
/// Enterprise → Cluster → Area → Line → Equipment — from the config database.
|
/// 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>
|
/// <returns>The equipment's edit projection, or <c>null</c> when missing.</returns>
|
||||||
Task<EquipmentEditDto?> LoadEquipmentAsync(string equipmentId, CancellationToken ct = default);
|
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>
|
/// <summary>
|
||||||
/// Loads every driver instance in a cluster (regardless of namespace kind) so the equipment modal
|
/// 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
|
/// 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);
|
.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 />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<(string DriverInstanceId, string Display)>> LoadDriversForClusterAsync(
|
public async Task<IReadOnlyList<(string DriverInstanceId, string Display)>> LoadDriversForClusterAsync(
|
||||||
string clusterId,
|
string clusterId,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Shouldly;
|
|||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||||
|
|
||||||
@@ -142,4 +143,58 @@ public sealed class UnsTreeServiceLoadEditTests
|
|||||||
drivers[1].DriverInstanceId.ShouldBe("DRV-B");
|
drivers[1].DriverInstanceId.ShouldBe("DRV-B");
|
||||||
drivers[1].Display.ShouldBe("DRV-B — modbus-b (ModbusTcp)");
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user