fix(m9/T30): empty-state guard keys off resolved fields (top-level \$ref); shift list raw-text on item removal
This commit is contained in:
@@ -18,7 +18,7 @@
|
|||||||
values.
|
values.
|
||||||
*@
|
*@
|
||||||
|
|
||||||
@if (_shapes.Count == 0)
|
@if (_topLevelFields.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="text-muted small fst-italic">No parameters declared.</div>
|
<div class="text-muted small fst-italic">No parameters declared.</div>
|
||||||
}
|
}
|
||||||
@@ -399,18 +399,47 @@ else
|
|||||||
await Emit();
|
await Emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// After a removal, item paths above the removed index shift down by one; clear
|
// After a removal at removedIndex, paths for items above that index shift down
|
||||||
// their stale raw-text/error entries so the re-rendered rows read fresh.
|
// 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)
|
private void ShiftListState(string path, int removedIndex)
|
||||||
|
{
|
||||||
|
ShiftDict(_rawText, path, removedIndex);
|
||||||
|
ShiftDict(_parseErrors, path, removedIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ShiftDict(Dictionary<string, string> dict, string path, int removedIndex)
|
||||||
{
|
{
|
||||||
var prefix = $"{path}[";
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -184,4 +184,102 @@ public class ParameterValueFormTests : BunitContext
|
|||||||
// A field-level error class is shown.
|
// A field-level error class is shown.
|
||||||
Assert.NotEmpty(cut.FindAll(".text-danger"));
|
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<CancellationToken>())
|
||||||
|
.Returns(new Dictionary<string, string> { ["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<ParameterValueForm>(p => p
|
||||||
|
.Add(x => x.ParameterDefinitions, paramSchema)
|
||||||
|
.Add(x => x.Values, new Dictionary<string, object?>()));
|
||||||
|
|
||||||
|
// 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<ParameterValueForm>(p => p
|
||||||
|
.Add(x => x.ParameterDefinitions, schema)
|
||||||
|
.Add(x => x.Values, new Dictionary<string, object?>())
|
||||||
|
.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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user