feat(adminui): B2-WP4 manual tag entry + raw tag modal + Calculation editor

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 03:08:05 -04:00
parent 54ab413396
commit ada552fbec
7 changed files with 1018 additions and 0 deletions
@@ -0,0 +1,289 @@
@* Bulk manual tag-entry grid for the v3 /raw tree: add MANY new tags at once under a Device or a
TagGroup. Each row carries Name / DataType / AccessLevel / WriteIdempotent / PollGroup plus a
per-row driver-typed TagConfig editor (revealed in an expandable full-width sub-row — the grid stays
compact). The owning DriverType (fixed by the device) drives the typed-editor dispatch. On commit each
row is created via CreateTagAsync; committed rows drop off, failed rows stay with their inline error so
nothing is lost silently. Visibility is @bind-Visible; a successful commit raises OnSaved. *@
@using Microsoft.AspNetCore.Components.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.Commons.Types
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@inject IRawTreeService 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-xl" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add tags — manual entry <span class="text-muted small">(@DriverType)</span></h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseAsync"></button>
</div>
<div class="modal-body">
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th style="width:22%">Name</th>
<th style="width:14%">Data type</th>
<th style="width:14%">Access</th>
<th style="width:12%">WriteIdemp.</th>
<th style="width:18%">PollGroup</th>
<th style="width:12%">Config</th>
<th style="width:8%"></th>
</tr>
</thead>
<tbody>
@foreach (var row in _rows)
{
<tr @key="row.Key">
<td>
<input class="form-control form-control-sm" placeholder="TagName"
value="@row.Name" @onchange="@(e => row.Name = e.Value?.ToString() ?? "")" />
@if (row.Error is not null)
{
<div class="text-danger small">@row.Error</div>
}
</td>
<td>
<select class="form-select form-select-sm" value="@row.DataType"
@onchange="@(e => row.DataType = e.Value?.ToString() ?? "Float")">
@foreach (var dt in DataTypes)
{
<option value="@dt">@dt</option>
}
</select>
</td>
<td>
<select class="form-select form-select-sm" value="@row.AccessLevel"
@onchange="@(e => row.AccessLevel = ParseAccess(e.Value))">
<option value="@TagAccessLevel.Read">Read</option>
<option value="@TagAccessLevel.ReadWrite">ReadWrite</option>
</select>
</td>
<td class="text-center">
<input type="checkbox" class="form-check-input" checked="@row.WriteIdempotent"
@onchange="@(e => row.WriteIdempotent = e.Value is true)" />
</td>
<td>
<input class="form-control form-control-sm mono" placeholder="(optional)"
value="@row.PollGroupId" @onchange="@(e => row.PollGroupId = e.Value?.ToString())" />
</td>
<td>
<button type="button" class="btn btn-sm btn-outline-secondary"
@onclick="@(() => row.Expanded = !row.Expanded)">
@(row.Expanded ? "Hide" : "Configure")
</button>
</td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-danger"
@onclick="@(() => RemoveRow(row))" disabled="@(_rows.Count == 1)">✕</button>
</td>
</tr>
@if (row.Expanded)
{
<tr @key="@(row.Key + "-cfg")">
<td colspan="7">
<div class="border rounded p-2 bg-body-tertiary">
@{
var editorType = TagConfigEditorMap.Resolve(DriverType);
}
@if (editorType is not null)
{
<DynamicComponent Type="editorType" Parameters="BuildEditorParameters(row)" />
}
else
{
<textarea class="form-control form-control-sm mono" rows="4"
placeholder='{ "FullName": "tag_name.AttributeName" }'
value="@row.TagConfig"
@onchange="@(e => row.TagConfig = e.Value?.ToString() ?? "{}")"></textarea>
<div class="form-text">Schemaless per driver type. Validated server-side at deploy.</div>
}
</div>
</td>
</tr>
}
}
</tbody>
</table>
</div>
<button type="button" class="btn btn-sm btn-outline-primary" @onclick="AddRow">+ Add row</button>
@if (!string.IsNullOrWhiteSpace(_summary))
{
<div class="alert @(_summaryOk ? "alert-success" : "alert-warning") py-2 mt-3 mb-0">@_summary</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAsync" disabled="@_busy">Cancel</button>
<button type="button" class="btn btn-primary" @onclick="CommitAsync" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Create @_rows.Count tag@(_rows.Count == 1 ? "" : "s")
</button>
</div>
</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. Two-way (@bind-Visible) so the modal can self-close.</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>Raised when <see cref="Visible"/> changes (self-close). Completes the @bind-Visible contract.</summary>
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
/// <summary>The device the new tags are created under.</summary>
[Parameter] public string DeviceId { get; set; } = "";
/// <summary>The containing tag group, or null to create the tags directly under the device.</summary>
[Parameter] public string? TagGroupId { get; set; }
/// <summary>The owning device's driver type, used for typed TagConfig-editor dispatch.</summary>
[Parameter] public string DriverType { get; set; } = "";
/// <summary>Raised after at least one tag is committed so the host can refresh the tree.</summary>
[Parameter] public EventCallback OnSaved { get; set; }
private readonly List<RowModel> _rows = new();
private bool _busy;
private string? _summary;
private bool _summaryOk;
private bool _open; // guards re-seeding rows on unrelated re-renders
protected override void OnParametersSet()
{
if (!Visible)
{
_open = false;
return;
}
if (_open) { return; } // same open, re-render → preserve in-progress rows
_open = true;
_rows.Clear();
_rows.Add(new RowModel());
_summary = null;
_summaryOk = false;
}
private IDictionary<string, object> BuildEditorParameters(RowModel row) => new Dictionary<string, object>
{
["ConfigJson"] = row.TagConfig,
["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => row.TagConfig = v),
["DriverType"] = DriverType,
["GetDriverConfigJson"] = (Func<string>)(() => "{}"),
};
private void AddRow() => _rows.Add(new RowModel());
private void RemoveRow(RowModel row)
{
if (_rows.Count > 1) { _rows.Remove(row); }
}
private static TagAccessLevel ParseAccess(object? v)
=> Enum.TryParse<TagAccessLevel>(v?.ToString(), out var a) ? a : TagAccessLevel.Read;
private async Task CommitAsync()
{
_busy = true;
_summary = null;
try
{
// --- Client-side validation first: name segment + per-driver config + intra-grid dup names. ---
foreach (var row in _rows) { row.Error = null; }
var seen = new HashSet<string>(RawPaths.Comparer);
var anyClientError = false;
foreach (var row in _rows)
{
var nameError = RawPaths.ValidateSegment(row.Name);
if (nameError is not null) { row.Error = nameError; anyClientError = true; continue; }
if (!seen.Add(row.Name)) { row.Error = "Duplicate name in this batch."; anyClientError = true; continue; }
var configError = TagConfigValidator.Validate(DriverType, row.TagConfig);
if (configError is not null) { row.Error = configError; anyClientError = true; }
}
if (anyClientError)
{
_summaryOk = false;
_summary = "Fix the highlighted rows and try again.";
return;
}
// --- Commit each row; keep failures (with their error), drop committed rows. ---
var created = 0;
var failed = 0;
foreach (var row in _rows)
{
var input = new RawTagInput(
row.Name,
row.DataType,
row.AccessLevel,
row.WriteIdempotent,
string.IsNullOrWhiteSpace(row.PollGroupId) ? null : row.PollGroupId,
string.IsNullOrWhiteSpace(row.TagConfig) ? "{}" : row.TagConfig);
var result = await Svc.CreateTagAsync(DeviceId, TagGroupId, input);
if (result.Ok)
{
row.Committed = true;
created++;
}
else
{
row.Error = result.Error;
failed++;
}
}
_rows.RemoveAll(r => r.Committed);
if (failed == 0)
{
if (created > 0) { await OnSaved.InvokeAsync(); }
await CloseAsync();
}
else
{
// Some succeeded, some failed: refresh the tree for the good ones, keep the modal open on
// the failed rows so the operator can correct them without re-entering the whole batch.
if (created > 0) { await OnSaved.InvokeAsync(); }
_summaryOk = false;
_summary = $"{created} tag(s) created; {failed} failed. Fix the highlighted rows and retry.";
}
}
finally
{
_busy = false;
}
}
private async Task CloseAsync()
{
Visible = false;
_open = false;
await VisibleChanged.InvokeAsync(false);
}
private sealed class RowModel
{
public string Key { get; } = Guid.NewGuid().ToString("N");
public string Name { 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; }
public string TagConfig { get; set; } = "{}";
public bool Expanded { get; set; }
public bool Committed { get; set; }
public string? Error { get; set; }
}
}
@@ -0,0 +1,257 @@
@* Edit modal for a single raw Tag in the v3 /raw tree. Unlike the equipment TagModal there is NO
driver selector — the driver is fixed by the device the tag lives under, so the owning DriverType is
passed in and drives the typed TagConfig editor dispatch (TagConfigEditorMap; raw-JSON fallback for
unmapped drivers, e.g. Galaxy). Edit-only: the host opens it for an existing tree node (TagId), it
loads via LoadTagForEditAsync, and saves via UpdateTagAsync. Visibility is @bind-Visible; a successful
save raises OnSaved so the coordinator can refresh the tree. *@
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Components.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.Commons.Types
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@inject IRawTreeService 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="rawTagModal">
<DataAnnotationsValidator />
<div class="modal-header">
<h5 class="modal-title">Edit tag</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseAsync"></button>
</div>
<div class="modal-body">
@if (_loadError is not null)
{
<div class="alert alert-danger py-2">@_loadError</div>
}
else
{
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="raw-tag-name">Name</label>
<InputText id="raw-tag-name" @bind-Value="_form.Name" class="form-control form-control-sm"
placeholder="Temperature" />
<ValidationMessage For="@(() => _form.Name)" />
@if (_nameError is not null)
{
<div class="text-danger small">@_nameError</div>
}
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="raw-tag-dtype">Data type</label>
<InputSelect id="raw-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="raw-tag-access">Access level</label>
<InputSelect id="raw-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</label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label" for="raw-tag-pgroup">PollGroupId (optional)</label>
<InputText id="raw-tag-pgroup" @bind-Value="_form.PollGroupId" class="form-control form-control-sm mono" />
</div>
<div class="mb-3">
<label class="form-label">Tag config <span class="text-muted small">(@DriverType)</span></label>
@{
var editorType = TagConfigEditorMap.Resolve(DriverType);
}
@if (editorType is not null)
{
<DynamicComponent Type="editorType" Parameters="BuildEditorParameters()" />
}
else
{
<InputTextArea id="raw-tag-config" @bind-Value="_form.TagConfig" rows="6"
class="form-control form-control-sm mono"
placeholder='{ "FullName": "tag_name.AttributeName" }' />
<div class="form-text">Schemaless per driver type. Validated server-side at deploy.</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="CloseAsync" disabled="@_busy">Cancel</button>
<button type="submit" class="btn btn-primary" disabled="@(_busy || _loadError is not null)">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
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. Two-way (@bind-Visible) so the modal can self-close.</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>Raised when <see cref="Visible"/> changes (self-close). Completes the @bind-Visible contract.</summary>
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
/// <summary>The raw tag to edit. The modal loads it via <c>LoadTagForEditAsync</c> on open.</summary>
[Parameter] public string? TagId { get; set; }
/// <summary>The owning device's driver type, used for typed TagConfig-editor dispatch.</summary>
[Parameter] public string? DriverType { get; set; }
/// <summary>Raised after a successful save so the host can refresh the tree.</summary>
[Parameter] public EventCallback OnSaved { get; set; }
private FormModel _form = new();
private byte[] _rowVersion = Array.Empty<byte>();
private bool _busy;
private string? _error;
private string? _nameError;
private string? _loadError;
// Tracks which (open, tag) this modal last loaded for, so unrelated Blazor Server re-renders don't
// reload + clobber in-progress edits. Null while closed.
private string? _loadedKey;
protected override async Task OnParametersSetAsync()
{
if (!Visible)
{
_loadedKey = null; // closed → next open reloads fresh
return;
}
var key = TagId;
if (key == _loadedKey) { return; } // same open, re-render → preserve in-progress edits
_loadedKey = key;
_error = null;
_nameError = null;
_loadError = null;
if (string.IsNullOrEmpty(TagId))
{
_loadError = "No tag selected.";
return;
}
var dto = await Svc.LoadTagForEditAsync(TagId);
// RawTagEditDto is a record struct → a missing tag comes back as default (all-zero), not null.
if (dto is not { } d || string.IsNullOrEmpty(d.TagId))
{
_loadError = "This tag no longer exists.";
return;
}
_form = new FormModel
{
Name = d.Name,
DataType = string.IsNullOrEmpty(d.DataType) ? "Float" : d.DataType,
AccessLevel = d.AccessLevel,
WriteIdempotent = d.WriteIdempotent,
PollGroupId = d.PollGroupId,
TagConfig = string.IsNullOrWhiteSpace(d.TagConfig) ? "{}" : d.TagConfig,
};
_rowVersion = d.RowVersion;
}
private IDictionary<string, object> BuildEditorParameters() => new Dictionary<string, object>
{
["ConfigJson"] = _form.TagConfig,
["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _form.TagConfig = v),
["DriverType"] = DriverType ?? "",
["GetDriverConfigJson"] = (Func<string>)(() => "{}"),
};
private async Task SaveAsync()
{
_busy = true;
_error = null;
_nameError = null;
try
{
// Client-side name validation (RawPath segment) so a bad name is caught before the round-trip.
var nameError = RawPaths.ValidateSegment(_form.Name);
if (nameError is not null)
{
_nameError = nameError;
return;
}
// Client-side per-driver config validation (the typed editor's Validate()).
var configError = TagConfigValidator.Validate(DriverType, _form.TagConfig);
if (configError is not null)
{
_error = configError;
return;
}
var input = new RawTagInput(
_form.Name,
_form.DataType,
_form.AccessLevel,
_form.WriteIdempotent,
string.IsNullOrWhiteSpace(_form.PollGroupId) ? null : _form.PollGroupId,
string.IsNullOrWhiteSpace(_form.TagConfig) ? "{}" : _form.TagConfig);
var result = await Svc.UpdateTagAsync(TagId!, input, _rowVersion);
if (result.Ok)
{
await OnSaved.InvokeAsync();
await CloseAsync();
}
else
{
_error = result.Error;
}
}
finally
{
_busy = false;
}
}
private async Task CloseAsync()
{
Visible = false;
_loadedKey = null;
await VisibleChanged.InvokeAsync(false);
}
private sealed class FormModel
{
[Required] public string Name { 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,279 @@
@* Typed TagConfig editor for the Calculation pseudo-driver. Authors the calc binding —
scriptId + change/timer triggers — reusing the VirtualTagModal's script dropdown, "New
script" action, and inline Monaco source panel (all backed by the same IUnsTreeService
script seams). Dispatched from RawTagModal / RawManualTagEntryModal via TagConfigEditorMap,
so it takes the same (ConfigJson/ConfigJsonChanged/DriverType/GetDriverConfigJson) parameter
shape every typed editor takes; DriverType/GetDriverConfigJson are accepted for dispatch
uniformity but unused (a calc tag has no address to browse). *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@inject IUnsTreeService Svc
<div class="row g-2">
<div class="col-md-6">
<label class="form-label">Script</label>
<select class="form-select form-select-sm" value="@_m.ScriptId" @onchange="OnScriptChangedAsync">
<option value="">— pick script —</option>
@foreach (var (id, display) in _scripts)
{
<option value="@id">@display</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Change-triggered</label>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="calc-change-@_uid"
checked="@_m.ChangeTriggered"
@onchange="@(e => Update(() => _m.ChangeTriggered = e.Value is true))" />
<label class="form-check-label small" for="calc-change-@_uid">On dependency change</label>
</div>
</div>
<div class="col-md-3">
<label class="form-label">Timer (ms)</label>
<input type="number" min="50" step="1" class="form-control form-control-sm"
value="@_m.TimerIntervalMs"
@onchange="@(e => Update(() => _m.TimerIntervalMs = ParseTimer(e.Value)))" />
<div class="form-text">Blank = change-trigger only. Min 50 ms.</div>
</div>
</div>
@* No script bound yet → offer a one-click "New script" that creates a blank Script row, binds
it, and expands the inline editor so the operator can author the body without leaving. *@
@if (string.IsNullOrEmpty(_m.ScriptId))
{
<div class="mt-2">
<button type="button" class="btn btn-outline-primary btn-sm"
@onclick="CreateNewScriptAsync" disabled="@_scriptCreating">
@if (_scriptCreating) { <span class="spinner-border spinner-border-sm me-1"></span> }
New script
</button>
<span class="form-text ms-2">Creates a blank C# script bound to this calc tag.</span>
@if (!string.IsNullOrWhiteSpace(_scriptCreateError))
{
<div class="text-danger small mt-2">@_scriptCreateError</div>
}
</div>
}
@* Inline script-source editor — shown only when a script is bound. Saves the SHARED Script row on
its own concurrency-guarded button; independent of the enclosing tag save. *@
@if (!string.IsNullOrEmpty(_m.ScriptId) && _scriptLoaded)
{
<div class="card mt-2">
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<span class="fw-semibold">Script source</span>
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none"
@onclick="ToggleScriptPanel">
@(_scriptExpanded ? "Hide" : "Edit source")
</button>
</div>
@if (_scriptExpanded)
{
<div class="card-body">
<div class="alert alert-warning py-2 small mb-2">
Editing shared script "<span class="mono">@_scriptName</span>" — used by
@_scriptUsageCount virtual tag(s). Changes affect every tag using it.
</div>
<MonacoEditor @bind-Value="_scriptSource" Height="300px" />
@if (!string.IsNullOrWhiteSpace(_scriptError))
{
<div class="text-danger small mt-2">@_scriptError</div>
}
else if (_scriptSaved)
{
<div class="text-success small mt-2">Script saved.</div>
}
<div class="mt-2">
<button type="button" class="btn btn-outline-primary btn-sm"
@onclick="SaveScriptAsync" disabled="@_scriptSaving">
@if (_scriptSaving) { <span class="spinner-border spinner-border-sm me-1"></span> }
Save script
</button>
</div>
</div>
}
</div>
}
@code {
[Parameter] public string? ConfigJson { get; set; }
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
/// <summary>DriverType of the selected driver — accepted for dispatch uniformity; unused here.</summary>
[Parameter] public string DriverType { get; set; } = "";
/// <summary>Live accessor for the selected driver's DriverConfig — accepted for dispatch uniformity;
/// unused (a calc tag has no address to browse).</summary>
[Parameter] public Func<string> GetDriverConfigJson { get; set; } = () => "{}";
private CalculationTagConfigModel _m = new();
private string? _lastConfigJson;
// Unique suffix so multiple editor instances (e.g. manual-entry grid rows) get distinct control ids.
private readonly string _uid = Guid.NewGuid().ToString("N")[..8];
// Script dropdown options — loaded once, plus any inline-created script appended.
private List<(string Id, string Display)> _scripts = new();
// Inline script-source editor state (independent of the tag save).
private string _scriptSource = "";
private bool _scriptLoaded;
private byte[] _scriptRowVersion = Array.Empty<byte>();
private string? _scriptName;
private int _scriptUsageCount;
private bool _scriptExpanded;
private bool _scriptSaving;
private bool _scriptSaved;
private string? _scriptError;
// "New script" action state.
private bool _scriptCreating;
private string? _scriptCreateError;
protected override async Task OnInitializedAsync()
{
var scripts = await Svc.LoadScriptsAsync();
_scripts = scripts.Select(s => (s.ScriptId, s.Display)).ToList();
}
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render
// (Blazor Server live-status pushes) can't reset the operator's in-progress edits.
protected override async Task OnParametersSetAsync()
{
if (ConfigJson == _lastConfigJson) { return; }
_lastConfigJson = ConfigJson;
_m = CalculationTagConfigModel.FromJson(ConfigJson);
await LoadScriptSourceAsync();
}
private async Task Update(Action apply)
{
apply();
await WriteBackAsync();
}
// Push the model back to the host as JSON and pin _lastConfigJson so the echo doesn't re-parse.
private async Task WriteBackAsync()
{
var json = _m.ToJson();
_lastConfigJson = json;
await ConfigJsonChanged.InvokeAsync(json);
}
private static int? ParseTimer(object? v)
=> int.TryParse(v?.ToString(), out var i) && i > 0 ? i : null;
/// <summary>Refreshes the inline script panel + writes the new binding back when the operator picks
/// a different script.</summary>
private async Task OnScriptChangedAsync(ChangeEventArgs e)
{
_m.ScriptId = e.Value?.ToString() ?? "";
await WriteBackAsync();
await LoadScriptSourceAsync();
}
/// <summary>Loads the bound script's source for the inline panel; clears it when no script is bound.</summary>
private async Task LoadScriptSourceAsync()
{
_scriptSaved = false;
_scriptError = null;
if (string.IsNullOrEmpty(_m.ScriptId))
{
_scriptSource = "";
_scriptLoaded = false;
_scriptRowVersion = Array.Empty<byte>();
_scriptName = null;
_scriptUsageCount = 0;
return;
}
var loaded = await Svc.GetScriptSourceAsync(_m.ScriptId);
if (loaded is null)
{
_scriptSource = "";
_scriptLoaded = false;
_scriptRowVersion = Array.Empty<byte>();
_scriptName = null;
_scriptUsageCount = 0;
return;
}
_scriptSource = loaded.Value.SourceCode;
_scriptLoaded = true;
_scriptRowVersion = loaded.Value.RowVersion;
_scriptName = loaded.Value.Name;
_scriptUsageCount = await Svc.CountVirtualTagsUsingScriptAsync(_m.ScriptId);
}
/// <summary>Creates a blank script, binds this calc tag to it, and expands the inline editor. Persists
/// only the new Script row — the tag binding lives in the model until the host's save.</summary>
private async Task CreateNewScriptAsync()
{
_scriptCreating = true;
_scriptCreateError = null;
try
{
const string seedName = "Calc script";
var result = await Svc.CreateScriptAsync(seedName);
if (!result.Ok || string.IsNullOrEmpty(result.CreatedId))
{
_scriptCreateError = result.Error ?? "Could not create the script.";
return;
}
// Add the option BEFORE binding so the <select> can resolve the label on first render.
if (!_scripts.Any(s => s.Id == result.CreatedId))
{
_scripts.Add((result.CreatedId, $"{seedName} (CSharp)"));
}
_m.ScriptId = result.CreatedId;
await WriteBackAsync();
await LoadScriptSourceAsync();
_scriptExpanded = true;
}
finally
{
_scriptCreating = false;
}
}
/// <summary>Toggles the inline script-source panel, clearing a stale "saved" banner on (re)expand.</summary>
private void ToggleScriptPanel()
{
_scriptExpanded = !_scriptExpanded;
if (_scriptExpanded) { _scriptSaved = false; }
}
/// <summary>Saves the edited script body via its own RowVersion-guarded call. Wholly separate from the
/// tag save: never touches the model or closes the enclosing modal.</summary>
private async Task SaveScriptAsync()
{
if (string.IsNullOrEmpty(_m.ScriptId) || !_scriptLoaded) { return; }
_scriptSaving = true;
_scriptSaved = false;
_scriptError = null;
try
{
var result = await Svc.UpdateScriptSourceAsync(_m.ScriptId, _scriptSource, _scriptRowVersion);
if (result.Ok)
{
var reloaded = await Svc.GetScriptSourceAsync(_m.ScriptId);
if (reloaded is not null)
{
_scriptSource = reloaded.Value.SourceCode;
_scriptRowVersion = reloaded.Value.RowVersion;
_scriptName = reloaded.Value.Name;
}
_scriptSaved = true;
}
else
{
_scriptError = result.Error;
}
}
finally
{
_scriptSaving = false;
}
}
}
@@ -0,0 +1,76 @@
using System.Text.Json.Nodes;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>Typed working model for a <c>Calculation</c> tag's TagConfig JSON — the calc-driver binding
/// (<c>scriptId</c> + trigger fields). Mirrors the <c>&lt;Driver&gt;TagConfigModel</c> template: camelCase
/// JSON keys, enum-name serialisation, and unknown-key preservation across a load→save. Shape per the
/// Calculation mini-design: <c>{ "scriptId": "…", "changeTriggered": true, "timerIntervalMs": 5000 }</c>.</summary>
public sealed class CalculationTagConfigModel
{
/// <summary>Logical FK to the shared <c>Script</c> entity that computes this tag. Required.</summary>
public string ScriptId { get; set; } = "";
/// <summary>Re-evaluate whenever a declared dependency value changes. Defaults to <c>true</c>.</summary>
public bool ChangeTriggered { get; set; } = true;
/// <summary>Optional periodic re-evaluation interval in milliseconds; <c>null</c> ⇒ change-trigger only.
/// When set it must be ≥ 50 ms (enforced by <see cref="Validate"/>). An absent key stays absent across
/// a load→save (so it never leaks a phantom timer).</summary>
public int? TimerIntervalMs { get; set; }
private JsonObject _bag = new();
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining every
/// original key (so fields this editor doesn't expose survive a load→save).</summary>
/// <param name="json">The raw TagConfig JSON string, or <c>null</c> for a new/empty config.</param>
/// <returns>The populated <see cref="CalculationTagConfigModel"/>.</returns>
public static CalculationTagConfigModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
int? timer = null;
if (o.TryGetPropertyValue("timerIntervalMs", out var tn) && tn is JsonValue tv && tv.TryGetValue<int>(out var ti))
{
timer = ti;
}
return new CalculationTagConfigModel
{
ScriptId = TagConfigJson.GetString(o, "scriptId") ?? "",
ChangeTriggered = TagConfigJson.GetBool(o, "changeTriggered", true),
TimerIntervalMs = timer,
_bag = o,
};
}
/// <summary>Serialises this model back to a TagConfig JSON string over the preserved key bag. A blank
/// <see cref="ScriptId"/> and a null <see cref="TimerIntervalMs"/> remove their keys (never persist an
/// empty/phantom value).</summary>
/// <returns>The serialised TagConfig JSON string.</returns>
public string ToJson()
{
TagConfigJson.Set(_bag, "scriptId", string.IsNullOrWhiteSpace(ScriptId) ? null : ScriptId);
TagConfigJson.Set(_bag, "changeTriggered", ChangeTriggered);
TagConfigJson.Set(_bag, "timerIntervalMs", TimerIntervalMs);
return TagConfigJson.Serialize(_bag);
}
/// <summary>Validates the calc binding: a script is required, at least one trigger (change or timer)
/// must be set, and a set timer must be ≥ 50 ms.</summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
public string? Validate()
{
if (string.IsNullOrWhiteSpace(ScriptId))
{
return "A Calculation tag requires a script.";
}
if (!ChangeTriggered && TimerIntervalMs is null)
{
return "A Calculation tag needs at least one trigger: change-triggered and/or a timer interval.";
}
if (TimerIntervalMs is { } ms && ms < 50)
{
return "Timer interval must be at least 50 ms.";
}
return null;
}
}
@@ -17,6 +17,7 @@ public static class TagConfigEditorMap
["TwinCat"] = typeof(Components.Shared.Uns.TagEditors.TwinCATTagConfigEditor),
["Focas"] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor),
["OpcUaClient"] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor),
["Calculation"] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor),
};
/// <summary>Returns the editor component type for a driver type, or null if none is registered.</summary>
@@ -19,6 +19,7 @@ public static class TagConfigValidator
["TwinCat"] = j => TwinCATTagConfigModel.FromJson(j).Validate(),
["Focas"] = j => FocasTagConfigModel.FromJson(j).Validate(),
["OpcUaClient"] = j => OpcUaClientTagConfigModel.FromJson(j).Validate(),
["Calculation"] = j => CalculationTagConfigModel.FromJson(j).Validate(),
};
/// <summary>
@@ -0,0 +1,115 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
public sealed class CalculationTagConfigModelTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("{}")]
public void FromJson_returns_defaults_for_empty_input(string? json)
{
var m = CalculationTagConfigModel.FromJson(json);
m.ScriptId.ShouldBe("");
m.ChangeTriggered.ShouldBeTrue(); // default true
m.TimerIntervalMs.ShouldBeNull();
}
[Fact]
public void Round_trip_preserves_all_fields()
{
var m = new CalculationTagConfigModel
{
ScriptId = "SC-abc123",
ChangeTriggered = false,
TimerIntervalMs = 5000,
};
var m2 = CalculationTagConfigModel.FromJson(m.ToJson());
m2.ScriptId.ShouldBe("SC-abc123");
m2.ChangeTriggered.ShouldBeFalse();
m2.TimerIntervalMs.ShouldBe(5000);
}
[Fact]
public void ToJson_emits_camelCase_keys()
{
var json = new CalculationTagConfigModel
{
ScriptId = "SC-1",
ChangeTriggered = true,
TimerIntervalMs = 250,
}.ToJson();
json.ShouldContain("\"scriptId\":\"SC-1\"");
json.ShouldContain("\"changeTriggered\":true");
json.ShouldContain("\"timerIntervalMs\":250");
}
[Fact]
public void ToJson_omits_timer_when_null()
{
var json = new CalculationTagConfigModel
{
ScriptId = "SC-1",
ChangeTriggered = true,
TimerIntervalMs = null,
}.ToJson();
json.ShouldNotContain("timerIntervalMs");
}
[Fact]
public void FromJson_then_ToJson_preserves_unknown_keys()
{
var json = CalculationTagConfigModel
.FromJson("""{"scriptId":"SC-1","futureKnob":42,"note":"keep me"}""")
.ToJson();
json.ShouldContain("futureKnob");
json.ShouldContain("42");
json.ShouldContain("keep me");
json.ShouldContain("\"scriptId\":\"SC-1\"");
}
[Fact]
public void Validate_requires_a_script()
{
new CalculationTagConfigModel { ScriptId = "", ChangeTriggered = true }
.Validate().ShouldNotBeNull();
}
[Fact]
public void Validate_requires_at_least_one_trigger()
{
new CalculationTagConfigModel { ScriptId = "SC-1", ChangeTriggered = false, TimerIntervalMs = null }
.Validate().ShouldNotBeNull();
}
[Fact]
public void Validate_rejects_sub_50ms_timer()
{
new CalculationTagConfigModel { ScriptId = "SC-1", ChangeTriggered = false, TimerIntervalMs = 49 }
.Validate().ShouldNotBeNull();
}
[Fact]
public void Validate_accepts_change_trigger_only()
{
new CalculationTagConfigModel { ScriptId = "SC-1", ChangeTriggered = true, TimerIntervalMs = null }
.Validate().ShouldBeNull();
}
[Fact]
public void Validate_accepts_timer_only()
{
new CalculationTagConfigModel { ScriptId = "SC-1", ChangeTriggered = false, TimerIntervalMs = 50 }
.Validate().ShouldBeNull();
}
}