feat(ui): List attribute editor in TemplateEdit
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
|
||||
@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.TemplateEngine
|
||||
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services
|
||||
@@ -80,6 +81,10 @@
|
||||
private string _attrName = string.Empty;
|
||||
private string? _attrValue;
|
||||
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 string? _attrDataSourceRef;
|
||||
private string? _attrFormError;
|
||||
@@ -553,17 +558,32 @@
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<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>())
|
||||
{
|
||||
<option value="@dt">@dt</option>
|
||||
}
|
||||
</select>
|
||||
@if (editing)
|
||||
{
|
||||
<div class="form-text">Data type is fixed once the attribute is created.</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Value</label>
|
||||
<input type="text" class="form-control" @bind="_attrValue" />
|
||||
</div>
|
||||
@if (_attrDataType == DataType.List)
|
||||
{
|
||||
<div class="col-12">
|
||||
<AttributeListEditor @bind-ElementDataType="_attrElementDataType"
|
||||
@bind-Rows="_attrListRows"
|
||||
ShowElementType="@(!editing)" />
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="col-12">
|
||||
<label class="form-label">Value</label>
|
||||
<input type="text" class="form-control" @bind="_attrValue" />
|
||||
</div>
|
||||
}
|
||||
<div class="col-12">
|
||||
<label class="form-label">Data Source Ref</label>
|
||||
<input type="text" class="form-control" @bind="_attrDataSourceRef" placeholder="Tag path" />
|
||||
@@ -1535,6 +1555,8 @@
|
||||
_attrName = string.Empty;
|
||||
_attrValue = null;
|
||||
_attrDataType = default;
|
||||
_attrElementDataType = DataType.String;
|
||||
_attrListRows = new();
|
||||
_attrIsLocked = false;
|
||||
_attrDataSourceRef = null;
|
||||
}
|
||||
@@ -1547,6 +1569,8 @@
|
||||
_attrName = attr.Name;
|
||||
_attrValue = attr.Value;
|
||||
_attrDataType = attr.DataType;
|
||||
_attrElementDataType = attr.ElementDataType ?? DataType.String;
|
||||
_attrListRows = DecodeListRows(attr.Value, attr.ElementDataType);
|
||||
_attrIsLocked = attr.IsLocked;
|
||||
_attrDataSourceRef = attr.DataSourceReference;
|
||||
}
|
||||
@@ -1558,12 +1582,72 @@
|
||||
_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()
|
||||
{
|
||||
if (_selectedTemplate == null) return;
|
||||
_attrFormError = null;
|
||||
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();
|
||||
|
||||
if (_editAttrId is int id)
|
||||
@@ -1573,7 +1657,8 @@
|
||||
var proposed = new TemplateAttribute(existing.Name)
|
||||
{
|
||||
DataType = _attrDataType,
|
||||
Value = _attrValue?.Trim(),
|
||||
ElementDataType = elementType,
|
||||
Value = attrValue,
|
||||
IsLocked = _attrIsLocked,
|
||||
DataSourceReference = _attrDataSourceRef?.Trim(),
|
||||
Description = existing.Description,
|
||||
@@ -1598,7 +1683,8 @@
|
||||
var attr = new TemplateAttribute(_attrName.Trim())
|
||||
{
|
||||
DataType = _attrDataType,
|
||||
Value = _attrValue?.Trim(),
|
||||
ElementDataType = elementType,
|
||||
Value = attrValue,
|
||||
IsLocked = _attrIsLocked,
|
||||
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"
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user