feat(ui): List attribute editor in TemplateEdit

This commit is contained in:
Joseph Doherty
2026-06-16 16:20:08 -04:00
parent 85db4571b2
commit ba7331e67c
3 changed files with 355 additions and 7 deletions
@@ -4,6 +4,7 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates @using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites @using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories @using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Types
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums @using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@using ZB.MOM.WW.ScadaBridge.TemplateEngine @using ZB.MOM.WW.ScadaBridge.TemplateEngine
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services @using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services
@@ -80,6 +81,10 @@
private string _attrName = string.Empty; private string _attrName = string.Empty;
private string? _attrValue; private string? _attrValue;
private DataType _attrDataType; private DataType _attrDataType;
// List-attribute authoring state (DataType.List only): the element scalar
// type + the per-element string rows. Encoded to canonical JSON on submit.
private DataType _attrElementDataType = DataType.String;
private List<string> _attrListRows = new();
private bool _attrIsLocked; private bool _attrIsLocked;
private string? _attrDataSourceRef; private string? _attrDataSourceRef;
private string? _attrFormError; private string? _attrFormError;
@@ -553,17 +558,32 @@
</div> </div>
<div class="col-12"> <div class="col-12">
<label class="form-label">Data Type</label> <label class="form-label">Data Type</label>
<select class="form-select" @bind="_attrDataType"> <select class="form-select" value="@_attrDataType" @onchange="OnAttrDataTypeChanged" disabled="@editing">
@foreach (var dt in Enum.GetValues<DataType>()) @foreach (var dt in Enum.GetValues<DataType>())
{ {
<option value="@dt">@dt</option> <option value="@dt">@dt</option>
} }
</select> </select>
@if (editing)
{
<div class="form-text">Data type is fixed once the attribute is created.</div>
}
</div> </div>
@if (_attrDataType == DataType.List)
{
<div class="col-12">
<AttributeListEditor @bind-ElementDataType="_attrElementDataType"
@bind-Rows="_attrListRows"
ShowElementType="@(!editing)" />
</div>
}
else
{
<div class="col-12"> <div class="col-12">
<label class="form-label">Value</label> <label class="form-label">Value</label>
<input type="text" class="form-control" @bind="_attrValue" /> <input type="text" class="form-control" @bind="_attrValue" />
</div> </div>
}
<div class="col-12"> <div class="col-12">
<label class="form-label">Data Source Ref</label> <label class="form-label">Data Source Ref</label>
<input type="text" class="form-control" @bind="_attrDataSourceRef" placeholder="Tag path" /> <input type="text" class="form-control" @bind="_attrDataSourceRef" placeholder="Tag path" />
@@ -1535,6 +1555,8 @@
_attrName = string.Empty; _attrName = string.Empty;
_attrValue = null; _attrValue = null;
_attrDataType = default; _attrDataType = default;
_attrElementDataType = DataType.String;
_attrListRows = new();
_attrIsLocked = false; _attrIsLocked = false;
_attrDataSourceRef = null; _attrDataSourceRef = null;
} }
@@ -1547,6 +1569,8 @@
_attrName = attr.Name; _attrName = attr.Name;
_attrValue = attr.Value; _attrValue = attr.Value;
_attrDataType = attr.DataType; _attrDataType = attr.DataType;
_attrElementDataType = attr.ElementDataType ?? DataType.String;
_attrListRows = DecodeListRows(attr.Value, attr.ElementDataType);
_attrIsLocked = attr.IsLocked; _attrIsLocked = attr.IsLocked;
_attrDataSourceRef = attr.DataSourceReference; _attrDataSourceRef = attr.DataSourceReference;
} }
@@ -1558,12 +1582,72 @@
_attrFormError = null; _attrFormError = null;
} }
// Switching the data type clears stale list state so a List ⇄ scalar
// toggle never carries the other mode's value into the submit.
private void OnAttrDataTypeChanged(ChangeEventArgs e)
{
if (!Enum.TryParse<DataType>((string?)e.Value, out var dt) || dt == _attrDataType) return;
_attrDataType = dt;
if (dt == DataType.List)
{
_attrValue = null;
if (_attrListRows.Count == 0) _attrListRows = new();
if (!AttributeValueCodec.IsValidElementType(_attrElementDataType))
_attrElementDataType = DataType.String;
}
else
{
_attrListRows = new();
}
}
// Decodes a stored List JSON value into editable string rows. A malformed
// stored value (e.g. hand-edited / element-type mismatch) is shown as empty
// rather than crashing the editor — the user can rebuild it.
private List<string> DecodeListRows(string? value, DataType? elementType)
{
if (string.IsNullOrEmpty(value)) return new();
try
{
var decoded = AttributeValueCodec.Decode(value, DataType.List, elementType ?? DataType.String);
if (decoded is System.Collections.IEnumerable items)
return items.Cast<object?>()
.Select(x => AttributeValueCodec.Encode(x) ?? string.Empty)
.ToList();
}
catch (FormatException)
{
// Malformed stored value — start from empty so the editor still opens.
}
return new();
}
private async Task SaveAttribute() private async Task SaveAttribute()
{ {
if (_selectedTemplate == null) return; if (_selectedTemplate == null) return;
_attrFormError = null; _attrFormError = null;
if (string.IsNullOrWhiteSpace(_attrName)) { _attrFormError = "Name is required."; return; } if (string.IsNullOrWhiteSpace(_attrName)) { _attrFormError = "Name is required."; return; }
// Resolve the value + element type per data type. List attributes encode
// their rows to canonical JSON and validate them locally before submit
// (TemplateService persists directly and does not list-validate).
string? attrValue;
DataType? elementType;
if (_attrDataType == DataType.List)
{
elementType = _attrElementDataType;
attrValue = AttributeValueCodec.Encode(_attrListRows);
// Round-trip through Decode to surface any un-parseable element
// (e.g. non-numeric in an Int32 list) before hitting the server.
try { AttributeValueCodec.Decode(attrValue, DataType.List, elementType); }
catch (FormatException ex) { _attrFormError = ex.Message; return; }
}
else
{
elementType = null;
attrValue = _attrValue?.Trim();
}
var user = await GetCurrentUserAsync(); var user = await GetCurrentUserAsync();
if (_editAttrId is int id) if (_editAttrId is int id)
@@ -1573,7 +1657,8 @@
var proposed = new TemplateAttribute(existing.Name) var proposed = new TemplateAttribute(existing.Name)
{ {
DataType = _attrDataType, DataType = _attrDataType,
Value = _attrValue?.Trim(), ElementDataType = elementType,
Value = attrValue,
IsLocked = _attrIsLocked, IsLocked = _attrIsLocked,
DataSourceReference = _attrDataSourceRef?.Trim(), DataSourceReference = _attrDataSourceRef?.Trim(),
Description = existing.Description, Description = existing.Description,
@@ -1598,7 +1683,8 @@
var attr = new TemplateAttribute(_attrName.Trim()) var attr = new TemplateAttribute(_attrName.Trim())
{ {
DataType = _attrDataType, DataType = _attrDataType,
Value = _attrValue?.Trim(), ElementDataType = elementType,
Value = attrValue,
IsLocked = _attrIsLocked, IsLocked = _attrIsLocked,
DataSourceReference = _attrDataSourceRef?.Trim() DataSourceReference = _attrDataSourceRef?.Trim()
}; };
@@ -0,0 +1,130 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Types
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@*
Repeatable list-value editor for a structured multi-value (List) attribute.
Reveals an element-type <select> (the six valid scalar types) plus one text
input per element with add/remove controls. Binds a List<string> working
model; the host encodes it to canonical JSON via AttributeValueCodec.Encode.
MV-14 reuses this component for instance overrides. Public API:
- ElementDataType (two-way, bound) : the chosen element scalar.
- Rows (two-way, bound) : the per-element string values.
- ShowElementType (default true) : hide the type <select> when the
element type is fixed (e.g. an
instance override inherits it).
- Disabled (default false) : render read-only.
Both ElementDataTypeChanged and RowsChanged fire on every edit.
*@
<div class="attribute-list-editor">
@if (ShowElementType)
{
<div class="mb-2">
<label class="form-label">Element Type</label>
<select class="form-select" value="@ElementDataType" @onchange="OnElementTypeChanged" disabled="@Disabled">
@foreach (var dt in ElementTypes)
{
<option value="@dt">@dt</option>
}
</select>
</div>
}
<label class="form-label">List Values</label>
@if (Rows.Count == 0)
{
<p class="text-muted small mb-2">No elements. Use “Add element” to add one.</p>
}
else
{
<div class="d-flex flex-column gap-2 mb-2">
@for (var i = 0; i < Rows.Count; i++)
{
var index = i;
<div class="input-group input-group-sm">
<span class="input-group-text" style="min-width: 2.5rem;">@index</span>
<input type="text" class="form-control font-monospace"
value="@Rows[index]"
placeholder="@Placeholder()"
@oninput="e => OnRowInput(index, (string?)e.Value)"
disabled="@Disabled" />
@if (!Disabled)
{
<button type="button" class="btn btn-outline-danger"
aria-label="@($"Remove element {index}")"
@onclick="() => RemoveRow(index)">Remove</button>
}
</div>
}
</div>
}
@if (!Disabled)
{
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="AddRow">Add element</button>
}
</div>
@code {
/// <summary>The chosen element scalar type. Two-way bound.</summary>
[Parameter] public DataType ElementDataType { get; set; } = DataType.String;
[Parameter] public EventCallback<DataType> ElementDataTypeChanged { get; set; }
/// <summary>The per-element string values. Two-way bound.</summary>
[Parameter] public List<string> Rows { get; set; } = new();
[Parameter] public EventCallback<List<string>> RowsChanged { get; set; }
/// <summary>
/// When false, the element-type <select> is hidden — used where the element
/// type is fixed by the base attribute (e.g. instance overrides in MV-14).
/// </summary>
[Parameter] public bool ShowElementType { get; set; } = true;
/// <summary>Render every control read-only.</summary>
[Parameter] public bool Disabled { get; set; }
private static readonly DataType[] ElementTypes =
Enum.GetValues<DataType>()
.Where(AttributeValueCodec.IsValidElementType)
.ToArray();
private async Task OnElementTypeChanged(ChangeEventArgs e)
{
if (Enum.TryParse<DataType>((string?)e.Value, out var dt) && dt != ElementDataType)
{
ElementDataType = dt;
await ElementDataTypeChanged.InvokeAsync(dt);
}
}
private async Task OnRowInput(int index, string? value)
{
if (index < 0 || index >= Rows.Count) return;
Rows[index] = value ?? string.Empty;
await RowsChanged.InvokeAsync(Rows);
}
private async Task AddRow()
{
Rows.Add(string.Empty);
await RowsChanged.InvokeAsync(Rows);
}
private async Task RemoveRow(int index)
{
if (index < 0 || index >= Rows.Count) return;
Rows.RemoveAt(index);
await RowsChanged.InvokeAsync(Rows);
}
private string Placeholder() => ElementDataType switch
{
DataType.Int32 => "e.g. 42",
DataType.Float => "e.g. 3.14",
DataType.Double => "e.g. 3.14159",
DataType.Boolean => "true / false",
DataType.DateTime => "e.g. 2026-06-16T00:00:00Z",
_ => "text value"
};
}
@@ -0,0 +1,132 @@
using Bunit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design;
/// <summary>
/// MV-13: the shared <c>AttributeListEditor</c> reveals an element-type select
/// plus a repeatable list-value editor for structured List attributes. These are
/// real bUnit rendering/interaction tests over the self-contained component, plus
/// structural assertions pinning the TemplateEdit attribute-form wiring (the page
/// itself is heavyweight to render — see <c>TemplateNativeAlarmSourceEditorTests</c>).
/// </summary>
public class AttributeListEditorTests : BunitContext
{
private static string TemplateEditMarkup
{
get
{
var dir = AppContext.BaseDirectory;
for (var i = 0; i < 6 && dir is not null; i++)
dir = Directory.GetParent(dir)?.FullName;
return File.ReadAllText(Path.Combine(dir!, "src", "ZB.MOM.WW.ScadaBridge.CentralUI",
"Components", "Pages", "Design", "TemplateEdit.razor"));
}
}
[Fact]
public void Editor_RendersElementTypeSelect_WithSixValidScalars()
{
var cut = Render<AttributeListEditor>(p => p
.Add(x => x.ElementDataType, DataType.String)
.Add(x => x.Rows, new List<string>()));
var select = cut.Find("select.form-select");
var options = select.QuerySelectorAll("option");
Assert.Equal(6, options.Length);
var texts = options.Select(o => o.TextContent).ToArray();
Assert.Contains("String", texts);
Assert.Contains("Int32", texts);
Assert.Contains("Float", texts);
Assert.Contains("Double", texts);
Assert.Contains("Boolean", texts);
Assert.Contains("DateTime", texts);
// List itself must never appear as an element type.
Assert.DoesNotContain("List", texts);
}
[Fact]
public void ShowElementType_False_HidesTheSelect()
{
var cut = Render<AttributeListEditor>(p => p
.Add(x => x.ShowElementType, false)
.Add(x => x.Rows, new List<string>()));
Assert.Throws<ElementNotFoundException>(() => cut.Find("select.form-select"));
}
[Fact]
public void Editor_RendersOneInputPerRow()
{
var cut = Render<AttributeListEditor>(p => p
.Add(x => x.ElementDataType, DataType.Int32)
.Add(x => x.Rows, new List<string> { "1", "2", "3" }));
var inputs = cut.FindAll("input.form-control");
Assert.Equal(3, inputs.Count);
Assert.Equal("1", inputs[0].GetAttribute("value"));
Assert.Equal("3", inputs[2].GetAttribute("value"));
}
[Fact]
public void AddElement_AppendsRow_AndRaisesRowsChanged()
{
var rows = new List<string> { "a" };
List<string>? changed = null;
var cut = Render<AttributeListEditor>(p => p
.Add(x => x.Rows, rows)
.Add(x => x.RowsChanged, r => changed = r));
cut.Find("button.btn-outline-secondary").Click();
Assert.NotNull(changed);
Assert.Equal(2, changed!.Count);
Assert.Equal("", changed[1]);
}
[Fact]
public void RemoveElement_DropsRow_AndRaisesRowsChanged()
{
var rows = new List<string> { "a", "b" };
List<string>? changed = null;
var cut = Render<AttributeListEditor>(p => p
.Add(x => x.Rows, rows)
.Add(x => x.RowsChanged, r => changed = r));
// First per-row Remove button.
cut.FindAll("button.btn-outline-danger")[0].Click();
Assert.NotNull(changed);
Assert.Single(changed!);
Assert.Equal("b", changed![0]);
}
[Fact]
public void EncodedRows_RoundTripThroughCodec()
{
// Mirrors what TemplateEdit.SaveAttribute does on submit.
var rows = new List<string> { "10", "20" };
var json = AttributeValueCodec.Encode(rows);
var decoded = AttributeValueCodec.Decode(json, DataType.List, DataType.Int32);
var list = Assert.IsType<List<int>>(decoded);
Assert.Equal(new[] { 10, 20 }, list);
}
[Fact]
public void TemplateEdit_RevealsListEditor_AndSendsElementType()
{
var markup = TemplateEditMarkup;
// Conditional reveal on DataType.List.
Assert.Contains("_attrDataType == DataType.List", markup);
Assert.Contains("<AttributeListEditor", markup);
Assert.Contains("@bind-ElementDataType=\"_attrElementDataType\"", markup);
Assert.Contains("@bind-Rows=\"_attrListRows\"", markup);
// Submit encodes rows to canonical JSON and passes the element type.
Assert.Contains("AttributeValueCodec.Encode(_attrListRows)", markup);
Assert.Contains("ElementDataType = elementType", markup);
// Edit decodes the stored JSON value into rows.
Assert.Contains("DecodeListRows(", markup);
}
}