diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ParameterValueForm.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ParameterValueForm.razor
index 38dba798..bbb7e6b4 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ParameterValueForm.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ParameterValueForm.razor
@@ -18,7 +18,7 @@
values.
*@
-@if (_shapes.Count == 0)
+@if (_topLevelFields.Count == 0)
{
No parameters declared.
}
@@ -399,18 +399,47 @@ else
await Emit();
}
- // After a removal, item paths above the removed index shift down by one; clear
- // their stale raw-text/error entries so the re-rendered rows read fresh.
+ // After a removal at removedIndex, paths for items above that index shift down
+ // by one. Mirror the value-list RemoveAt by: (1) for each index > removedIndex
+ // found in a dict, re-key it as index-1; (2) clear the now-vacated top slot
+ // (the original highest index). Items below the removed index are unaffected.
private void ShiftListState(string path, int removedIndex)
+ {
+ ShiftDict(_rawText, path, removedIndex);
+ ShiftDict(_parseErrors, path, removedIndex);
+ }
+
+ private static void ShiftDict(Dictionary dict, string path, int removedIndex)
{
var prefix = $"{path}[";
- foreach (var key in _rawText.Keys.Where(k => k.StartsWith(prefix, StringComparison.Ordinal)).ToList())
+ // Collect keys under this list prefix, parse the item index, and build the
+ // shift map in one pass so in-place mutation does not interfere with iteration.
+ var toShift = new List<(string Key, int ItemIndex, string Suffix)>();
+ foreach (var key in dict.Keys)
{
- _rawText.Remove(key);
+ if (!key.StartsWith(prefix, StringComparison.Ordinal)) continue;
+ // Extract the integer after the '['.
+ var rest = key.AsSpan(prefix.Length); // e.g. "2]" or "2].field"
+ var closePos = rest.IndexOf(']');
+ if (closePos < 0) continue;
+ if (!int.TryParse(rest[..closePos], System.Globalization.NumberStyles.Integer,
+ System.Globalization.CultureInfo.InvariantCulture, out var idx)) continue;
+ // Only touch items ABOVE the removed index; items at lower indices are stable.
+ if (idx <= removedIndex) continue;
+ var suffix = rest[(closePos + 1)..].ToString(); // everything after ']'
+ toShift.Add((key, idx, suffix));
}
- foreach (var key in _parseErrors.Keys.Where(k => k.StartsWith(prefix, StringComparison.Ordinal)).ToList())
+
+ // Apply: remove old key, write shifted key. Higher indices first so we never
+ // overwrite a key that is itself still pending a shift (would happen if we
+ // iterated lowest-to-highest and the same path base appeared at consecutive
+ // indices). Processing highest → lowest avoids the collision.
+ foreach (var (key, idx, suffix) in toShift.OrderByDescending(t => t.ItemIndex))
{
- _parseErrors.Remove(key);
+ var value = dict[key];
+ dict.Remove(key);
+ var shiftedKey = $"{path}[{idx - 1}]{suffix}";
+ dict[shiftedKey] = value;
}
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ParameterValueFormTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ParameterValueFormTests.cs
index bb63acab..f968b3c8 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ParameterValueFormTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ParameterValueFormTests.cs
@@ -184,4 +184,102 @@ public class ParameterValueFormTests : BunitContext
// A field-level error class is shown.
Assert.NotEmpty(cut.FindAll(".text-danger"));
}
+
+ // M1 — top-level $ref: empty-state guard must key off resolved fields, not _shapes
+ [Fact]
+ public void TopLevelRef_ResolvesToObject_RendersFields_NotEmptyState()
+ {
+ // Library entry "SomeObject" resolves to an object with two fields.
+ const string libSchema = """
+ {
+ "type": "object",
+ "properties": {
+ "code": { "type": "integer" },
+ "label": { "type": "string" }
+ },
+ "required": ["code"]
+ }
+ """;
+ _library.GetSchemaMapAsync(Arg.Any())
+ .Returns(new Dictionary { ["SomeObject"] = libSchema });
+
+ // The parameter definitions IS a top-level $ref — no "properties" key at the
+ // root level, so _shapes (JsonSchemaShapeParser) stays empty.
+ const string paramSchema = """{"$ref":"lib:SomeObject"}""";
+
+ var cut = Render(p => p
+ .Add(x => x.ParameterDefinitions, paramSchema)
+ .Add(x => x.Values, new Dictionary()));
+
+ // Must NOT show the empty-state message.
+ Assert.DoesNotContain("No parameters declared", cut.Markup);
+
+ // Must render the resolved object's two fields.
+ Assert.Contains("code", cut.Markup);
+ Assert.Contains("label", cut.Markup);
+ // Integer and String inputs are present.
+ Assert.NotEmpty(cut.FindAll("input[type=number]"));
+ Assert.NotEmpty(cut.FindAll("input[type=text]"));
+ }
+
+ // I1 — list item removal: in-progress raw text of later items must SHIFT, not wipe.
+ // Uses a list-of-objects schema so sub-field raw-text entries (e.g. "rows[1].qty")
+ // persist in the rendered list (the LIST SLOT stays; only the nested field value
+ // is cleared on invalid input, not the containing dict). This is the scenario the
+ // ShiftDict fix is designed for: "rows[1].qty" → "rows[0].qty" when rows[0] is
+ // removed, so the error/raw-text follows the item rather than being wiped.
+ [Fact]
+ public void ListItemRemoval_ShiftsNestedRawTextOfLaterItems_NotWiped()
+ {
+ // List of objects, each with an integer "qty" field.
+ const string schema = """
+ {
+ "type": "object",
+ "properties": {
+ "rows": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "qty": { "type": "integer" }
+ }
+ }
+ }
+ }
+ }
+ """;
+
+ var cut = Render(p => p
+ .Add(x => x.ParameterDefinitions, schema)
+ .Add(x => x.Values, new Dictionary())
+ .Add(x => x.ValuesChanged, _ => { }));
+
+ // Add two object items.
+ var addButton = cut.Find("button.param-list-add");
+ addButton.Click(); // rows[0]
+ addButton.Click(); // rows[1]
+
+ // Commit a valid qty in rows[0].
+ var numberInputs = cut.FindAll("input[type=number]");
+ numberInputs[0].Input("5");
+
+ // Enter an INVALID qty in rows[1] — parks raw text "abc" in _rawText["rows[1].qty"]
+ // and _parseErrors["rows[1].qty"]. The LIST SLOT rows[1] is preserved (only the
+ // nested field value is cleared, not the containing dict).
+ numberInputs = cut.FindAll("input[type=number]");
+ numberInputs[1].Input("abc");
+
+ // The error for rows[1].qty is visible before removal.
+ Assert.NotEmpty(cut.FindAll(".text-danger"));
+
+ // Remove rows[0]. With the fix, "rows[1].qty" raw-text/error entries shift to
+ // "rows[0].qty", following the item. Without the fix they are wiped and the
+ // item renders as if freshly added (no error).
+ cut.Find("button.param-list-remove").Click();
+
+ // After removal, the remaining item (formerly rows[1]) must still show the
+ // invalid-field error. Two remove buttons collapsed to one; one item renders.
+ Assert.Single(cut.FindAll("button.param-list-remove"));
+ Assert.NotEmpty(cut.FindAll(".text-danger"));
+ }
}