feat(adminui): driver-agnostic isArray/arrayLength Tag-modal control

This commit is contained in:
Joseph Doherty
2026-06-16 21:50:27 -04:00
parent eb8a8dc19d
commit c2006dfb57
3 changed files with 321 additions and 0 deletions
@@ -170,6 +170,37 @@
</div>
}
@* Driver-agnostic array-shape intent. Merges the root `isArray` / `arrayLength`
keys onto the canonical TagConfig via the pure TagArrayConfig seam so it composes
with the typed editor's driver-specific fields (both preserve unknown keys). When
checked, the server materialises a 1-D array node (ValueRank=1). Shown for EVERY
driver once one is picked — same place/pattern as the historize control above. *@
@if (!string.IsNullOrEmpty(_form.DriverInstanceId))
{
<div class="mb-3">
<label class="form-label">Array</label>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="tag-is-array"
checked="@_arrayState.IsArray"
@onchange="OnIsArrayChanged" />
<label class="form-check-label" for="tag-is-array">This tag is an array (1-D)</label>
</div>
<div class="form-text">
When checked, the server materialises this tag as a fixed-length 1-D array node.
</div>
@if (_arrayState.IsArray)
{
<div class="mt-2">
<label class="form-label" for="tag-array-length">Array length</label>
<input type="number" min="1" step="1" class="form-control form-control-sm mono" id="tag-array-length"
value="@_arrayState.ArrayLength"
@onchange="OnArrayLengthChanged" />
<div class="form-text">Number of elements (must be a positive whole number).</div>
</div>
}
</div>
}
@* Native-alarm options: shown only when the TagConfig carries an `alarm` object (the tag
is a Part 9 condition). The "Historize to AVEVA" toggle edits the alarm.historizeToAveva
opt-out (bool?, unchecked-via-clear ⇒ absent ⇒ historize default-on at the server gate;
@@ -255,6 +286,11 @@
// the driver changes; the change handlers merge it back onto _form.TagConfig via TagHistorizeConfig.
private TagHistorizeConfig.HistorizeState _historizeState;
// Driver-agnostic array-shape intent (root `isArray` / `arrayLength`), reflected for the array controls.
// Re-read from _form.TagConfig whenever the modal (re)opens or the driver changes; the change handlers
// merge it back onto _form.TagConfig via TagArrayConfig (same pattern as _historizeState above).
private TagArrayConfig.ArrayState _arrayState;
// The DriverType of the currently-selected driver (drives editor dispatch). Null when no driver chosen.
private string? SelectedDriverType =>
Drivers.FirstOrDefault(d => d.Id == _form.DriverInstanceId).DriverType;
@@ -285,6 +321,8 @@
_galaxyPickedIsAlarm = false;
// The reset TagConfig carries no history intent — reflect that in the historize controls.
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
// Likewise the reset TagConfig carries no array intent.
_arrayState = TagArrayConfig.Read(_form.TagConfig);
}
// The operator picked a Galaxy reference (tag_name.AttributeName); store it as the canonical
@@ -304,10 +342,13 @@
JsonSerializer.Serialize(new { FullName = address }),
_historizeState.IsHistorized,
_historizeState.HistorianTagname);
// Re-merge any array intent for the same reason — a fresh {FullName} blob would otherwise drop it.
config = TagArrayConfig.Set(config, _arrayState.IsArray, _arrayState.ArrayLength);
_form.TagConfig = _galaxyPickedIsAlarm
? NativeAlarmModel.SeedDefaultAlarm(config)
: config;
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
_arrayState = TagArrayConfig.Read(_form.TagConfig);
}
private IDictionary<string, object> BuildEditorParameters() => new Dictionary<string, object>
@@ -372,6 +413,30 @@
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
}
// Toggle the root `isArray` array-shape intent in the raw TagConfig, merged via the pure TagArrayConfig
// seam so the typed editor's driver-specific keys are preserved. Clearing it drops `arrayLength` too
// (no orphan length), so the carried _arrayState.ArrayLength is irrelevant when unchecking.
private void OnIsArrayChanged(ChangeEventArgs e)
{
var isArray = e.Value is true;
_form.TagConfig = TagArrayConfig.Set(_form.TagConfig, isArray, _arrayState.ArrayLength);
_arrayState = TagArrayConfig.Read(_form.TagConfig);
}
// Merge the array length (root `arrayLength`) into the raw TagConfig. A blank/zero/negative/non-numeric
// entry parses to null, so the key is dropped until a positive length is typed (and SaveAsync rejects
// an array with no positive length).
private void OnArrayLengthChanged(ChangeEventArgs e)
{
var length = ParsePositiveLength(e.Value?.ToString());
_form.TagConfig = TagArrayConfig.Set(_form.TagConfig, _arrayState.IsArray, length);
_arrayState = TagArrayConfig.Read(_form.TagConfig);
}
// Parse the numeric-input string to a positive uint, or null for blank/zero/negative/overflow/non-numeric.
private static uint? ParsePositiveLength(string? raw)
=> uint.TryParse(raw, out var u) && u > 0 ? u : null;
protected override void OnParametersSet()
{
// Rebuild the working form whenever the host (re)opens the modal for a fresh target.
@@ -400,6 +465,8 @@
_galaxyAddress = ReadFullName(_form.TagConfig);
// Seed the historize controls from any existing root isHistorized/historianTagname keys.
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
// Seed the array controls from any existing root isArray/arrayLength keys.
_arrayState = TagArrayConfig.Read(_form.TagConfig);
}
// Best-effort extraction of FullName from a Galaxy TagConfig; returns "" when absent or unparseable.
@@ -436,6 +503,15 @@
return;
}
// Driver-agnostic array-shape validation: an array tag needs a positive length. Mirrors the
// per-driver config validation above so a missing length is caught here rather than at deploy.
var arrayError = TagArrayConfig.Validate(_arrayState.IsArray, _arrayState.ArrayLength);
if (arrayError is not null)
{
_error = arrayError;
return;
}
var input = new TagInput(
_form.TagId,
_form.Name,
@@ -0,0 +1,68 @@
using System.Text.Json.Nodes;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>
/// Pure, driver-agnostic merge helper for the two server-side array-shape intent keys at the ROOT of a
/// tag's <c>TagConfig</c> JSON: <c>isArray</c> (camelCase bool — omit/false default) and <c>arrayLength</c>
/// (camelCase JSON number — the 1-D array length; omit when not an array). These map to what the
/// EquipmentTagPlan reads to materialise a 1-D array OPC UA node (ValueRank=1, ArrayDimensions=[length]).
///
/// <para>
/// This is a TagModal-merge seam mirroring <see cref="TagHistorizeConfig"/>: the TagModal owns the
/// canonical TagConfig JSON; the driver's typed editor reads/writes its driver-specific fields on that same
/// JSON, and the modal's "This tag is an array" checkbox + "Array length" numeric input read/write these two
/// keys through this helper. They COMPOSE because both sides preserve every unrecognised key (delegated to
/// <see cref="TagConfigJson.ParseOrNew"/> / <see cref="TagConfigJson.Set"/>), so neither clobbers the
/// other's fields. These keys are intentionally NOT carried inside any per-driver typed model.
/// </para>
/// </summary>
public static class TagArrayConfig
{
/// <summary>The two array-shape values parsed from a TagConfig JSON's root.</summary>
/// <param name="IsArray">Whether the server materialises this tag as a 1-D array node.</param>
/// <param name="ArrayLength">The 1-D array length; <c>null</c> when absent (incl. when not an array).</param>
public readonly record struct ArrayState(bool IsArray, uint? ArrayLength);
/// <summary>Reads the array-shape keys from a TagConfig JSON, defaulting any absent key
/// (null/blank/malformed input ⇒ <c>(false, null)</c>). A negative or non-numeric <c>arrayLength</c>
/// reads as <c>null</c>.</summary>
public static ArrayState Read(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
return new ArrayState(
TagConfigJson.GetBool(o, "isArray"),
ReadLength(o));
}
/// <summary>Merges the two array-shape keys into <paramref name="json"/>, preserving every other
/// (driver-specific or unknown) key. <c>isArray</c> is dropped when false (absent ⇒ false at the
/// materialiser); <c>arrayLength</c> is dropped when <paramref name="isArray"/> is false or
/// <paramref name="arrayLength"/> is null (never leaves an orphan length behind a cleared isArray).</summary>
public static string Set(string? json, bool isArray, uint? arrayLength)
{
var o = TagConfigJson.ParseOrNew(json);
TagConfigJson.Set(o, "isArray", isArray ? true : null);
// Drop arrayLength whenever isArray is off OR no length is set, so a cleared array never
// leaves an orphan length key behind.
TagConfigJson.Set(o, "arrayLength", isArray && arrayLength is { } len ? len : null);
return TagConfigJson.Serialize(o);
}
/// <summary>Validates the array-shape intent: when <paramref name="isArray"/> is set, a positive
/// <paramref name="arrayLength"/> is required. Returns an error string when invalid, or <c>null</c>
/// when valid (a non-array is always valid regardless of length).</summary>
public static string? Validate(bool isArray, uint? arrayLength)
=> isArray && (arrayLength is null or 0)
? "Array length must be a positive number when 'This tag is an array' is checked."
: null;
// Reads arrayLength as a non-negative uint, or null when absent/null/non-numeric/negative.
private static uint? ReadLength(JsonObject o)
=> o.TryGetPropertyValue("arrayLength", out var n)
&& n is JsonValue v
&& v.TryGetValue<long>(out var l)
&& l is >= 0 and <= uint.MaxValue
? (uint)l
: null;
}