diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor index 6842e5be..4ce81ea9 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor @@ -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 { - +
- + @foreach (var attr in _overrideAttrs) { - + - + } @@ -507,6 +545,17 @@ // Overrides private List _overrideAttrs = new(); private Dictionary _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 _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> _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 _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 ────────── + + /// Working rows for a List attribute's override (whole-list replacement). + private List GetListRows(string attrName) + => _listRows.TryGetValue(attrName, out var rows) ? rows : (_listRows[attrName] = new()); + + private void OnListRowsChanged(string attrName, List rows) + { + _listRows[attrName] = rows; + // A fresh edit clears any stale validation error for this attribute. + _overrideErrors.Remove(attrName); + } + + /// True if a persisted override row exists for the attribute (so Clear is offered). + private bool HasOverrideRow(string attrName) => _existingOverrides.ContainsKey(attrName); + + /// + /// 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. + /// + private static List 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() + .Select(x => AttributeValueCodec.Encode(x) ?? string.Empty) + .ToList(); + } + catch (FormatException) + { + // Malformed stored value — start from empty so the editor still opens. + } + return new(); + } + + /// + /// 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. + /// + 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(_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) { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureListOverrideTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureListOverrideTests.cs new file mode 100644 index 00000000..12f97735 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureListOverrideTests.cs @@ -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; + +/// +/// MV-14: the Instance Configure attribute-override panel uses the shared +/// AttributeListEditor for a List attribute (whole-list replacement; the +/// element type is fixed by the base attribute, so the type select is hidden via +/// ShowElementType="false"). 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. InstanceConfigure +/// is a heavyweight page (multiple injected services incl. InstanceService +/// 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. +/// +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(" 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 { "10", "20", "30" }); + var decoded = AttributeValueCodec.Decode(json, DataType.List, DataType.Int32); + var list = Assert.IsType>(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 { "1", "not-a-number" }); + Assert.Throws( + () => AttributeValueCodec.Decode(json, DataType.List, DataType.Int32)); + } +}
Attribute Type Template ValueOverride ValueOverride Value
@attr.Name@attr.DataType + @attr.DataType + @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"). *@ + + of @(attr.ElementDataType ?? DataType.String) + + } + @(attr.Value ?? "—") - + @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. *@ + + @if (_overrideErrors.TryGetValue(attr.Name, out var listErr)) + { +
@listErr
+ } + @if (HasOverrideRow(attr.Name)) + { + + } + } + else + { + + }