feat(ui): List attribute override editor in InstanceConfigure

When overriding a List attribute, render the shared AttributeListEditor
(whole-list replacement; element type fixed by the base, shown read-only via
ShowElementType=false) instead of the single-line input. Loading an existing
override decodes its JSON into rows (malformed -> empty); saving encodes rows to
canonical JSON with a pre-submit Decode round-trip guard surfacing element
errors inline. Clearing removes the InstanceAttributeOverride row
(repository-direct, mirroring native-alarm-source overrides). Non-List override
UX unchanged.
This commit is contained in:
Joseph Doherty
2026-06-16 16:25:58 -04:00
parent ba7331e67c
commit ae2e1efb1c
2 changed files with 280 additions and 10 deletions
@@ -5,6 +5,7 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@using ZB.MOM.WW.ScadaBridge.Commons.Types
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services
@@ -184,26 +185,63 @@
}
else
{
<table class="table table-sm table-bordered mb-0">
<table class="table table-sm table-bordered mb-0 align-middle">
<thead class="table-light">
<tr>
<th>Attribute</th>
<th>Type</th>
<th>Template Value</th>
<th style="width: 280px;">Override Value</th>
<th style="width: 320px;">Override Value</th>
</tr>
</thead>
<tbody>
@foreach (var attr in _overrideAttrs)
{
<tr>
<tr data-test="override-row-@attr.Name">
<td class="small">@attr.Name</td>
<td><span class="badge bg-light text-dark">@attr.DataType</span></td>
<td>
<span class="badge bg-light text-dark">@attr.DataType</span>
@if (attr.DataType == DataType.List)
{
@* Element type is fixed by the base attribute — shown
read-only here (the List editor renders it hidden via
ShowElementType="false"). *@
<span class="badge bg-light text-dark border ms-1"
data-test="override-element-type">
of @(attr.ElementDataType ?? DataType.String)
</span>
}
</td>
<td class="small text-muted">@(attr.Value ?? "—")</td>
<td>
<input type="text" class="form-control form-control-sm"
value="@GetOverrideValue(attr.Name)"
@onchange="(e) => OnOverrideChanged(attr.Name, e)" />
@if (attr.DataType == DataType.List)
{
@* Whole-list replacement: the shared editor renders the
element-type select hidden (fixed by the base) plus the
repeatable rows. Clearing removes the override row. *@
<AttributeListEditor ElementDataType="@(attr.ElementDataType ?? DataType.String)"
Rows="@GetListRows(attr.Name)"
RowsChanged="@(r => OnListRowsChanged(attr.Name, r))"
ShowElementType="false" />
@if (_overrideErrors.TryGetValue(attr.Name, out var listErr))
{
<div class="alert alert-danger small mt-2 mb-0"
data-test="override-list-error">@listErr</div>
}
@if (HasOverrideRow(attr.Name))
{
<button class="btn btn-outline-danger btn-sm mt-2"
data-test="override-clear-btn"
@onclick="() => ClearListOverride(attr.Name)"
disabled="@_saving">Clear Override</button>
}
}
else
{
<input type="text" class="form-control form-control-sm"
value="@GetOverrideValue(attr.Name)"
@onchange="(e) => OnOverrideChanged(attr.Name, e)" />
}
</td>
</tr>
}
@@ -507,6 +545,17 @@
// Overrides
private List<TemplateAttribute> _overrideAttrs = new();
private Dictionary<string, string?> _overrideValues = new();
// MV-14: existing override rows keyed by attribute name — tracks which
// attributes already have a persisted override (so List rows know whether a
// Clear is available) and carries the row Id for repository-direct delete.
private Dictionary<string, InstanceAttributeOverride> _existingOverrides = new();
// MV-14: per-List working rows (whole-list replacement), keyed by attribute
// name. Seeded on load from the effective value; encoded to canonical JSON on
// save. Element type is fixed by the base attribute.
private Dictionary<string, List<string>> _listRows = new();
// MV-14: per-attribute validation errors surfaced inline (e.g. an
// un-parseable List element caught on the pre-submit round-trip).
private Dictionary<string, string> _overrideErrors = new();
// Alarm overrides — read-only state pulled from the repo. The edit modal
// is the only mutation path (one alarm at a time).
@@ -595,7 +644,23 @@
_overrideAttrs = attrs.Where(a => !a.IsLocked).ToList();
var existingOverrides = await TemplateEngineRepository.GetOverridesByInstanceIdAsync(Id);
foreach (var o in existingOverrides)
{
_overrideValues[o.AttributeName] = o.OverrideValue;
_existingOverrides[o.AttributeName] = o;
}
// MV-14: seed the per-List working rows. A List attribute's editor is
// initialized from the effective value — the existing override JSON if
// present, otherwise the template default — decoded into string rows
// using the element type fixed by the base attribute. A malformed
// stored value falls back to empty rows (the editor still opens).
foreach (var attr in _overrideAttrs.Where(a => a.DataType == DataType.List))
{
var effective = _existingOverrides.TryGetValue(attr.Name, out var ovr)
? ovr.OverrideValue
: attr.Value;
_listRows[attr.Name] = DecodeListRows(effective, attr.ElementDataType);
}
// Alarm overrides — load all non-locked template alarms and
// existing override rows. Pre-seed the dirty maps from existing
@@ -805,15 +870,116 @@
else _overrideValues[attrName] = val;
}
// ── MV-14: structured List (multi-value) overrides ──────────
/// <summary>Working rows for a List attribute's override (whole-list replacement).</summary>
private List<string> GetListRows(string attrName)
=> _listRows.TryGetValue(attrName, out var rows) ? rows : (_listRows[attrName] = new());
private void OnListRowsChanged(string attrName, List<string> rows)
{
_listRows[attrName] = rows;
// A fresh edit clears any stale validation error for this attribute.
_overrideErrors.Remove(attrName);
}
/// <summary>True if a persisted override row exists for the attribute (so Clear is offered).</summary>
private bool HasOverrideRow(string attrName) => _existingOverrides.ContainsKey(attrName);
/// <summary>
/// Decodes a stored List JSON value into editable string rows using the
/// element type fixed by the base attribute. A malformed stored value (e.g.
/// hand-edited or an element-type mismatch) falls back to empty rows rather
/// than crashing the editor — mirrors TemplateEdit.DecodeListRows.
/// </summary>
private static 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();
}
/// <summary>
/// Removes a List attribute's override row entirely (repository-direct, the
/// same pattern as native-alarm-source overrides) and resets the editor to
/// the inherited template value.
/// </summary>
private async Task ClearListOverride(string attrName)
{
_saving = true;
try
{
if (_existingOverrides.TryGetValue(attrName, out var ovr))
{
await TemplateEngineRepository.DeleteInstanceAttributeOverrideAsync(ovr.Id);
await TemplateEngineRepository.SaveChangesAsync();
_existingOverrides.Remove(attrName);
}
_overrideValues.Remove(attrName);
_overrideErrors.Remove(attrName);
// Reset the editor to the inherited template default.
var attr = _overrideAttrs.FirstOrDefault(a => a.Name == attrName);
_listRows[attrName] = DecodeListRows(attr?.Value, attr?.ElementDataType);
_toast.ShowSuccess($"Cleared override on '{attrName}'.");
}
catch (Exception ex)
{
_toast.ShowError($"Clear failed: {ex.Message}");
}
_saving = false;
}
private async Task SaveOverrides()
{
_saving = true;
try
{
_overrideErrors.Clear();
var user = await GetCurrentUserAsync();
foreach (var (attrName, value) in _overrideValues)
await InstanceService.SetAttributeOverrideAsync(Id, attrName, value, user);
_toast.ShowSuccess($"Saved {_overrideValues.Count} override(s).");
// Build the set of override values to persist. Scalars come straight
// from the single-input map (unchanged). List attributes encode their
// working rows to canonical JSON; each is round-tripped through Decode
// first to surface any un-parseable element (mirrors TemplateEdit) —
// an invalid element aborts the whole save and is shown inline.
var toSave = new Dictionary<string, string?>(_overrideValues);
var listAttrs = _overrideAttrs.Where(a => a.DataType == DataType.List).ToList();
var hasError = false;
foreach (var attr in listAttrs)
{
var elementType = attr.ElementDataType ?? DataType.String;
var json = AttributeValueCodec.Encode(GetListRows(attr.Name));
try { AttributeValueCodec.Decode(json, DataType.List, elementType); }
catch (FormatException ex) { _overrideErrors[attr.Name] = ex.Message; hasError = true; continue; }
toSave[attr.Name] = json;
}
if (hasError)
{
_toast.ShowError("Some List overrides have invalid elements — see the highlighted rows.");
_saving = false;
return;
}
foreach (var (attrName, value) in toSave)
{
var result = await InstanceService.SetAttributeOverrideAsync(Id, attrName, value, user);
if (result.IsSuccess)
_existingOverrides[attrName] = result.Value!;
}
_toast.ShowSuccess($"Saved {toSave.Count} override(s).");
}
catch (Exception ex)
{
@@ -0,0 +1,104 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// MV-14: the Instance Configure attribute-override panel uses the shared
/// <c>AttributeListEditor</c> for a List attribute (whole-list replacement; the
/// element type is fixed by the base attribute, so the type select is hidden via
/// <c>ShowElementType="false"</c>). Loading an existing override decodes its JSON
/// into rows; saving encodes the rows back to canonical JSON with a pre-submit
/// round-trip guard; clearing removes the override row. <c>InstanceConfigure</c>
/// is a heavyweight page (multiple injected services incl. <c>InstanceService</c>
/// and the flattening pipeline), so — consistent with the native-alarm and
/// template-editor coverage — these are structural assertions over the component
/// source that pin the wiring, plus a real codec round-trip mirroring what the
/// page does on load/save.
/// </summary>
public class InstanceConfigureListOverrideTests
{
private static string InstanceConfigureMarkup
{
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", "Deployment", "InstanceConfigure.razor"));
}
}
[Fact]
public void ListOverride_RevealsSharedEditor_WithElementTypeHidden()
{
var markup = InstanceConfigureMarkup;
// Conditional reveal on a List attribute.
Assert.Contains("attr.DataType == DataType.List", markup);
Assert.Contains("<AttributeListEditor", markup);
// Element type is fixed by the base attribute → type select hidden.
Assert.Contains("ShowElementType=\"false\"", markup);
Assert.Contains("ElementDataType=\"@(attr.ElementDataType ?? DataType.String)\"", markup);
// Bound to the per-attribute working rows.
Assert.Contains("Rows=\"@GetListRows(attr.Name)\"", markup);
Assert.Contains("RowsChanged=\"@(r => OnListRowsChanged(attr.Name, r))\"", markup);
}
[Fact]
public void ListOverride_DecodesOnLoad_AndEncodesOnSaveWithGuard()
{
var markup = InstanceConfigureMarkup;
// Load: effective value (existing override JSON or template default)
// decoded into rows via the shared codec, malformed → empty rows.
Assert.Contains("DecodeListRows(", markup);
Assert.Contains("catch (FormatException)", markup);
// Save: rows encoded to canonical JSON + round-trip Decode guard.
Assert.Contains("AttributeValueCodec.Encode(GetListRows(", markup);
Assert.Contains("AttributeValueCodec.Decode(json, DataType.List, elementType)", markup);
Assert.Contains("_overrideErrors", markup);
}
[Fact]
public void ListOverride_ClearRemovesTheOverrideRow()
{
var markup = InstanceConfigureMarkup;
Assert.Contains("ClearListOverride", markup);
// Repository-direct delete (the page only edits InstanceConfigure; no new
// server method) — same pattern as native-alarm-source overrides.
Assert.Contains("DeleteInstanceAttributeOverrideAsync", markup);
Assert.Contains("HasOverrideRow", markup);
}
[Fact]
public void NonListOverride_KeepsSingleInputUx()
{
var markup = InstanceConfigureMarkup;
// The scalar path still binds the single text input via the existing helpers.
Assert.Contains("GetOverrideValue(attr.Name)", markup);
Assert.Contains("OnOverrideChanged(attr.Name, e)", markup);
}
[Fact]
public void EncodedRows_RoundTripThroughCodec_AsThePageDoes()
{
// Mirrors the load (Decode → rows) / save (Encode → JSON) cycle the page runs.
var json = AttributeValueCodec.Encode(new List<string> { "10", "20", "30" });
var decoded = AttributeValueCodec.Decode(json, DataType.List, DataType.Int32);
var list = Assert.IsType<List<int>>(decoded);
Assert.Equal(new[] { 10, 20, 30 }, list);
// The re-encoded form is stable, so a clean override round-trips losslessly.
var roundTrip = AttributeValueCodec.Encode(decoded);
Assert.Equal(json, roundTrip);
}
[Fact]
public void MalformedListElement_SurfacesFormatException_ForInlineError()
{
// The pre-submit guard catches this and shows it inline rather than crashing.
var json = AttributeValueCodec.Encode(new List<string> { "1", "not-a-number" });
Assert.Throws<FormatException>(
() => AttributeValueCodec.Decode(json, DataType.List, DataType.Int32));
}
}