feat(m9/T30): schema-driven nested object/list value-entry forms (+ $ref resolution)
This commit is contained in:
@@ -1,35 +1,42 @@
|
|||||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis
|
||||||
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||||
|
@using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi
|
||||||
@using System.Text.Json
|
@using System.Text.Json
|
||||||
|
@inject ISchemaLibraryQueryService SchemaLibrary
|
||||||
|
|
||||||
@*
|
@*
|
||||||
Renders an input row per declared parameter so the user can supply values
|
Renders an input row per declared parameter so the user can supply values
|
||||||
for a script test run. Primitive types get typed inputs (text / number /
|
for a script test run, driven by the declared schema. Primitive types get
|
||||||
checkbox); Object and List fall back to a JSON textarea with inline parse
|
typed inputs (text / number / checkbox); OBJECT parameters render a labeled
|
||||||
errors. The companion SchemaBuilder edits the schema; this edits values.
|
group of per-field inputs (recursing for nested objects/lists) and ARRAY
|
||||||
|
parameters render an add/remove list of typed item editors. A
|
||||||
|
{"$ref":"lib:Name"} node resolves through ISchemaLibraryQueryService and
|
||||||
|
renders the referenced shape. Per-field validation surfaces inline via
|
||||||
|
InboundApiSchema.Validate. The emitted value shape is unchanged: scalars as
|
||||||
|
bool/long/double/string, objects as Dictionary<string,object?>, lists as
|
||||||
|
List<object?>. The companion SchemaBuilder edits the schema; this edits
|
||||||
|
values.
|
||||||
*@
|
*@
|
||||||
|
|
||||||
@if (Shapes.Count == 0)
|
@if (_shapes.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="text-muted small fst-italic">No parameters declared.</div>
|
<div class="text-muted small fst-italic">No parameters declared.</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="d-flex flex-column gap-2">
|
<div class="d-flex flex-column gap-2">
|
||||||
@foreach (var shape in Shapes)
|
@foreach (var field in _topLevelFields)
|
||||||
{
|
{
|
||||||
<div class="row g-2 align-items-center">
|
var shape = _shapes.FirstOrDefault(s => s.Name == field.Name);
|
||||||
|
<div class="row g-2 @(IsScalar(field.Schema) ? "align-items-center" : "align-items-start")">
|
||||||
<div class="col-sm-4">
|
<div class="col-sm-4">
|
||||||
<label class="form-label small mb-0" for="@FieldId(shape)">
|
<label class="form-label small mb-0" for="@FieldId(field.Name)">
|
||||||
<code>@shape.Name</code>
|
<code>@field.Name</code>
|
||||||
<span class="text-muted ms-1">@shape.Type@(shape.Required ? "" : "?")</span>
|
<span class="text-muted ms-1">@(shape?.Type ?? DisplayType(field.Schema))@(field.Required ? "" : "?")</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-8">
|
<div class="col-sm-8">
|
||||||
@RenderInput(shape)
|
@RenderNode(field.Schema, field.Name, FieldId(field.Name))
|
||||||
@if (_parseErrors.TryGetValue(shape.Name, out var err))
|
|
||||||
{
|
|
||||||
<div class="text-danger small mt-1">@err</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -41,127 +48,525 @@ else
|
|||||||
[Parameter] public Dictionary<string, object?> Values { get; set; } = new();
|
[Parameter] public Dictionary<string, object?> Values { get; set; } = new();
|
||||||
[Parameter] public EventCallback<Dictionary<string, object?>> ValuesChanged { get; set; }
|
[Parameter] public EventCallback<Dictionary<string, object?>> ValuesChanged { get; set; }
|
||||||
|
|
||||||
private IReadOnlyList<ParameterShape> Shapes =>
|
private IReadOnlyList<ParameterShape> _shapes = Array.Empty<ParameterShape>();
|
||||||
ScriptParameterNames.ParseShapes(ParameterDefinitions);
|
private IReadOnlyList<InboundApiSchemaField> _topLevelFields = Array.Empty<InboundApiSchemaField>();
|
||||||
|
private InboundApiSchema? _rootSchema;
|
||||||
|
private string? _parsedFor;
|
||||||
|
|
||||||
|
// Raw text and per-field parse/validation errors keyed by canonical PATH
|
||||||
|
// (e.g. "order.id", "tags[0]") so nested inputs each carry their own state.
|
||||||
private readonly Dictionary<string, string> _rawText = new();
|
private readonly Dictionary<string, string> _rawText = new();
|
||||||
private readonly Dictionary<string, string> _parseErrors = new();
|
private readonly Dictionary<string, string> _parseErrors = new();
|
||||||
|
|
||||||
private static string FieldId(ParameterShape shape) => $"param-{shape.Name}";
|
protected override async Task OnParametersSetAsync()
|
||||||
|
|
||||||
private RenderFragment RenderInput(ParameterShape shape) => __builder =>
|
|
||||||
{
|
{
|
||||||
switch (shape.Type)
|
// Re-parse only when the declared schema text changes.
|
||||||
|
if (_parsedFor == ParameterDefinitions)
|
||||||
{
|
{
|
||||||
case "Boolean":
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_parsedFor = ParameterDefinitions;
|
||||||
|
_shapes = ScriptParameterNames.ParseShapes(ParameterDefinitions);
|
||||||
|
_rootSchema = await ParseRootAsync();
|
||||||
|
_topLevelFields = _rootSchema?.Type == "object" ? _rootSchema.Fields : Array.Empty<InboundApiSchemaField>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses the declared parameter definitions into an object schema, resolving
|
||||||
|
/// any {"$ref":"lib:Name"} nodes through the schema library so a referenced
|
||||||
|
/// shape renders its resolved fields. Library load is skipped entirely when
|
||||||
|
/// the definition contains no $ref token. Malformed schemas yield null (the
|
||||||
|
/// form then shows nothing, consistent with the lenient shape parser).
|
||||||
|
/// </summary>
|
||||||
|
private async Task<InboundApiSchema?> ParseRootAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(ParameterDefinitions))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Func<string, string?>? resolver = null;
|
||||||
|
if (InboundApiSchema.MightContainRef(ParameterDefinitions))
|
||||||
|
{
|
||||||
|
var map = await SchemaLibrary.GetSchemaMapAsync();
|
||||||
|
resolver = name => map.TryGetValue(name, out var json) ? json : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Collect (not throw on) unresolved refs so a dangling reference still
|
||||||
|
// renders the rest of the form; the unresolved node falls through to
|
||||||
|
// the raw-JSON escape hatch.
|
||||||
|
return InboundApiSchema.ParseWithRefs(ParameterDefinitions, resolver).Schema;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FieldId(string path) => $"param-{path}";
|
||||||
|
|
||||||
|
private static bool IsScalar(InboundApiSchema schema) =>
|
||||||
|
schema.Type is "boolean" or "integer" or "number" or "string";
|
||||||
|
|
||||||
|
private static string DisplayType(InboundApiSchema schema) => schema.Type switch
|
||||||
|
{
|
||||||
|
"boolean" => "Boolean",
|
||||||
|
"integer" => "Integer",
|
||||||
|
"number" => "Float",
|
||||||
|
"string" => "String",
|
||||||
|
"object" => "Object",
|
||||||
|
"array" => schema.Items is null ? "List" : $"List<{DisplayType(schema.Items)}>",
|
||||||
|
_ => schema.Type,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Recursive node rendering ------------------------------------------
|
||||||
|
|
||||||
|
private RenderFragment RenderNode(InboundApiSchema schema, string path, string id) => __builder =>
|
||||||
|
{
|
||||||
|
switch (schema.Type)
|
||||||
|
{
|
||||||
|
case "boolean":
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="checkbox" id="@FieldId(shape)"
|
<input class="form-check-input" type="checkbox" id="@id"
|
||||||
checked="@AsBool(shape.Name)"
|
checked="@AsBool(path)"
|
||||||
@onchange="e => SetBool(shape.Name, (bool)(e.Value ?? false))" />
|
@onchange="e => SetBool(path, (bool)(e.Value ?? false))" />
|
||||||
</div>
|
</div>
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "Integer":
|
case "integer":
|
||||||
<input class="form-control form-control-sm" type="number" step="1" id="@FieldId(shape)"
|
<input class="form-control form-control-sm" type="number" step="1" id="@id"
|
||||||
value="@AsRaw(shape.Name)"
|
value="@AsRaw(path)"
|
||||||
@oninput="e => SetNumeric(shape.Name, (string?)e.Value, integerOnly: true)" />
|
@oninput="e => SetNumeric(path, (string?)e.Value, integerOnly: true)" />
|
||||||
|
@RenderError(path)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "Float":
|
case "number":
|
||||||
<input class="form-control form-control-sm" type="number" step="any" id="@FieldId(shape)"
|
<input class="form-control form-control-sm" type="number" step="any" id="@id"
|
||||||
value="@AsRaw(shape.Name)"
|
value="@AsRaw(path)"
|
||||||
@oninput="e => SetNumeric(shape.Name, (string?)e.Value, integerOnly: false)" />
|
@oninput="e => SetNumeric(path, (string?)e.Value, integerOnly: false)" />
|
||||||
|
@RenderError(path)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "String":
|
case "string":
|
||||||
<input class="form-control form-control-sm" type="text" id="@FieldId(shape)"
|
<input class="form-control form-control-sm" type="text" id="@id"
|
||||||
value="@AsRaw(shape.Name)"
|
value="@AsRaw(path)"
|
||||||
@oninput="e => SetString(shape.Name, (string?)e.Value)" />
|
@oninput="e => SetString(path, (string?)e.Value)" />
|
||||||
|
@RenderError(path)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default: // Object, List, List<...>, unknown
|
case "object":
|
||||||
<textarea class="form-control form-control-sm font-monospace" rows="3" id="@FieldId(shape)"
|
@RenderObject(schema, path)
|
||||||
placeholder='@($"JSON {shape.Type.ToLowerInvariant()}")'
|
break;
|
||||||
@oninput="e => SetJson(shape.Name, (string?)e.Value)">@AsRaw(shape.Name)</textarea>
|
|
||||||
|
case "array":
|
||||||
|
@RenderArray(schema, path)
|
||||||
|
break;
|
||||||
|
|
||||||
|
default: // unresolved $ref / unknown — raw-JSON escape hatch.
|
||||||
|
<textarea class="form-control form-control-sm font-monospace" rows="3" id="@id"
|
||||||
|
placeholder='@($"JSON {DisplayType(schema).ToLowerInvariant()}")'
|
||||||
|
@oninput="e => SetJson(path, (string?)e.Value)">@AsRaw(path)</textarea>
|
||||||
|
@RenderError(path)
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private string AsRaw(string name) =>
|
private RenderFragment RenderObject(InboundApiSchema schema, string path) => __builder =>
|
||||||
_rawText.TryGetValue(name, out var raw) ? raw : "";
|
|
||||||
|
|
||||||
private bool AsBool(string name) =>
|
|
||||||
Values.TryGetValue(name, out var v) && v is bool b && b;
|
|
||||||
|
|
||||||
private async Task SetString(string name, string? raw)
|
|
||||||
{
|
{
|
||||||
_rawText[name] = raw ?? "";
|
<div class="border-start ps-3 d-flex flex-column gap-2 param-object">
|
||||||
_parseErrors.Remove(name);
|
@foreach (var field in schema.Fields)
|
||||||
Values[name] = raw ?? "";
|
{
|
||||||
await ValuesChanged.InvokeAsync(Values);
|
var childPath = JoinField(path, field.Name);
|
||||||
|
<div class="row g-2 @(IsScalar(field.Schema) ? "align-items-center" : "align-items-start")">
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<label class="form-label small mb-0" for="@FieldId(childPath)">
|
||||||
|
<code>@field.Name</code>
|
||||||
|
<span class="text-muted ms-1">@DisplayType(field.Schema)@(field.Required ? "" : "?")</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-8">
|
||||||
|
@RenderNode(field.Schema, childPath, FieldId(childPath))
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
};
|
||||||
|
|
||||||
|
private RenderFragment RenderArray(InboundApiSchema schema, string path) => __builder =>
|
||||||
|
{
|
||||||
|
var count = ListCount(path);
|
||||||
|
var itemSchema = schema.Items ?? new InboundApiSchema { Type = "string" };
|
||||||
|
<div class="d-flex flex-column gap-2 param-list" data-path="@path">
|
||||||
|
@for (var i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
var index = i;
|
||||||
|
var itemPath = $"{path}[{index}]";
|
||||||
|
<div class="d-flex gap-2 align-items-start param-list-row">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
@RenderItem(itemSchema, itemPath)
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger param-list-remove"
|
||||||
|
@onclick="() => RemoveListItem(path, index)" title="Remove item">×</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary param-list-add"
|
||||||
|
@onclick="() => AddListItem(path, itemSchema)">+ Add item</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
};
|
||||||
|
|
||||||
|
// Item editors mark their scalar input with the param-list-item class so the
|
||||||
|
// add/remove list rows are addressable in tests and styling.
|
||||||
|
private RenderFragment RenderItem(InboundApiSchema schema, string path) => __builder =>
|
||||||
|
{
|
||||||
|
switch (schema.Type)
|
||||||
|
{
|
||||||
|
case "boolean":
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input param-list-item" type="checkbox" id="@FieldId(path)"
|
||||||
|
checked="@AsBool(path)"
|
||||||
|
@onchange="e => SetBool(path, (bool)(e.Value ?? false))" />
|
||||||
|
</div>
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "integer":
|
||||||
|
<input class="form-control form-control-sm param-list-item" type="number" step="1" id="@FieldId(path)"
|
||||||
|
value="@AsRaw(path)"
|
||||||
|
@oninput="e => SetNumeric(path, (string?)e.Value, integerOnly: true)" />
|
||||||
|
@RenderError(path)
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "number":
|
||||||
|
<input class="form-control form-control-sm param-list-item" type="number" step="any" id="@FieldId(path)"
|
||||||
|
value="@AsRaw(path)"
|
||||||
|
@oninput="e => SetNumeric(path, (string?)e.Value, integerOnly: false)" />
|
||||||
|
@RenderError(path)
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "string":
|
||||||
|
<input class="form-control form-control-sm param-list-item" type="text" id="@FieldId(path)"
|
||||||
|
value="@AsRaw(path)"
|
||||||
|
@oninput="e => SetString(path, (string?)e.Value)" />
|
||||||
|
@RenderError(path)
|
||||||
|
break;
|
||||||
|
|
||||||
|
default: // nested object/array/unknown item — recurse with the full renderer.
|
||||||
|
@RenderNode(schema, path, FieldId(path))
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private RenderFragment RenderError(string path) => __builder =>
|
||||||
|
{
|
||||||
|
if (_parseErrors.TryGetValue(path, out var err))
|
||||||
|
{
|
||||||
|
<div class="text-danger small mt-1">@err</div>
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Path-addressed value tree -----------------------------------------
|
||||||
|
//
|
||||||
|
// The single source of truth is the Values dictionary holding the nested
|
||||||
|
// value tree: scalars as bool/long/double/string, objects as
|
||||||
|
// Dictionary<string,object?>, lists as List<object?> — exactly the shape the
|
||||||
|
// consuming pages serialize to JSON. Reads/writes navigate by canonical path
|
||||||
|
// ("a.b", "a.b[2]"); structural containers are created lazily on first write.
|
||||||
|
|
||||||
|
private record PathSeg(string? Field, int? Index);
|
||||||
|
|
||||||
|
private static IReadOnlyList<PathSeg> ParsePath(string path)
|
||||||
|
{
|
||||||
|
var segs = new List<PathSeg>();
|
||||||
|
var i = 0;
|
||||||
|
while (i < path.Length)
|
||||||
|
{
|
||||||
|
if (path[i] == '[')
|
||||||
|
{
|
||||||
|
var close = path.IndexOf(']', i);
|
||||||
|
var num = path.Substring(i + 1, close - i - 1);
|
||||||
|
segs.Add(new PathSeg(null, int.Parse(num, System.Globalization.CultureInfo.InvariantCulture)));
|
||||||
|
i = close + 1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (path[i] == '.') i++;
|
||||||
|
var start = i;
|
||||||
|
while (i < path.Length && path[i] != '.' && path[i] != '[') i++;
|
||||||
|
segs.Add(new PathSeg(path.Substring(start, i - start), null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return segs;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SetBool(string name, bool value)
|
private object? GetValue(string path)
|
||||||
{
|
{
|
||||||
_parseErrors.Remove(name);
|
object? node = Values;
|
||||||
Values[name] = value;
|
foreach (var seg in ParsePath(path))
|
||||||
await ValuesChanged.InvokeAsync(Values);
|
{
|
||||||
|
if (seg.Field is not null)
|
||||||
|
{
|
||||||
|
if (node is Dictionary<string, object?> dict && dict.TryGetValue(seg.Field, out var v)) node = v;
|
||||||
|
else return null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (node is List<object?> list && seg.Index!.Value >= 0 && seg.Index.Value < list.Count) node = list[seg.Index.Value];
|
||||||
|
else return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SetNumeric(string name, string? raw, bool integerOnly)
|
private void SetValue(string path, object? value)
|
||||||
{
|
{
|
||||||
_rawText[name] = raw ?? "";
|
var segs = ParsePath(path);
|
||||||
|
object? node = Values;
|
||||||
|
for (var s = 0; s < segs.Count; s++)
|
||||||
|
{
|
||||||
|
var seg = segs[s];
|
||||||
|
var last = s == segs.Count - 1;
|
||||||
|
if (seg.Field is not null)
|
||||||
|
{
|
||||||
|
var dict = (Dictionary<string, object?>)node!;
|
||||||
|
if (last) { dict[seg.Field] = value; return; }
|
||||||
|
if (dict.TryGetValue(seg.Field, out var child) && child is (Dictionary<string, object?> or List<object?>)) node = child;
|
||||||
|
else { var created = NextContainer(segs[s + 1]); dict[seg.Field] = created; node = created; }
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var list = (List<object?>)node!;
|
||||||
|
var idx = seg.Index!.Value;
|
||||||
|
if (last) { while (list.Count <= idx) list.Add(null); list[idx] = value; return; }
|
||||||
|
if (idx < list.Count && list[idx] is (Dictionary<string, object?> or List<object?>)) node = list[idx];
|
||||||
|
else { while (list.Count <= idx) list.Add(null); var created = NextContainer(segs[s + 1]); list[idx] = created; node = created; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveValue(string path)
|
||||||
|
{
|
||||||
|
var segs = ParsePath(path);
|
||||||
|
object? node = Values;
|
||||||
|
for (var s = 0; s < segs.Count - 1; s++)
|
||||||
|
{
|
||||||
|
var seg = segs[s];
|
||||||
|
node = seg.Field is not null
|
||||||
|
? (node is Dictionary<string, object?> d && d.TryGetValue(seg.Field, out var v) ? v : null)
|
||||||
|
: (node is List<object?> l && seg.Index!.Value < l.Count ? l[seg.Index.Value] : null);
|
||||||
|
if (node is null) return;
|
||||||
|
}
|
||||||
|
var leaf = segs[^1];
|
||||||
|
if (leaf.Field is not null && node is Dictionary<string, object?> dict) dict.Remove(leaf.Field);
|
||||||
|
else if (node is List<object?> list && leaf.Index!.Value >= 0 && leaf.Index.Value < list.Count) list.RemoveAt(leaf.Index.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object NextContainer(PathSeg next) =>
|
||||||
|
next.Field is not null ? new Dictionary<string, object?>() : new List<object?>();
|
||||||
|
|
||||||
|
// ---- List add/remove ---------------------------------------------------
|
||||||
|
|
||||||
|
private int ListCount(string path) => GetValue(path) is List<object?> list ? list.Count : 0;
|
||||||
|
|
||||||
|
private async Task AddListItem(string path, InboundApiSchema itemSchema)
|
||||||
|
{
|
||||||
|
if (GetValue(path) is not List<object?> list)
|
||||||
|
{
|
||||||
|
list = new List<object?>();
|
||||||
|
SetValue(path, list);
|
||||||
|
}
|
||||||
|
// Seed structural items with an empty container so their nested inputs render.
|
||||||
|
list.Add(itemSchema.Type switch
|
||||||
|
{
|
||||||
|
"object" => new Dictionary<string, object?>(),
|
||||||
|
"array" => new List<object?>(),
|
||||||
|
_ => null,
|
||||||
|
});
|
||||||
|
await Emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RemoveListItem(string path, int index)
|
||||||
|
{
|
||||||
|
if (GetValue(path) is List<object?> list && index >= 0 && index < list.Count)
|
||||||
|
{
|
||||||
|
list.RemoveAt(index);
|
||||||
|
// Re-index raw-text / error state for shifted item paths.
|
||||||
|
ShiftListState(path, index);
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
private void ShiftListState(string path, int removedIndex)
|
||||||
|
{
|
||||||
|
var prefix = $"{path}[";
|
||||||
|
foreach (var key in _rawText.Keys.Where(k => k.StartsWith(prefix, StringComparison.Ordinal)).ToList())
|
||||||
|
{
|
||||||
|
_rawText.Remove(key);
|
||||||
|
}
|
||||||
|
foreach (var key in _parseErrors.Keys.Where(k => k.StartsWith(prefix, StringComparison.Ordinal)).ToList())
|
||||||
|
{
|
||||||
|
_parseErrors.Remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Scalar setters -----------------------------------------------------
|
||||||
|
|
||||||
|
private string AsRaw(string path)
|
||||||
|
{
|
||||||
|
if (_rawText.TryGetValue(path, out var raw)) return raw;
|
||||||
|
var v = GetValue(path);
|
||||||
|
return v switch { null => "", bool b => b.ToString(), _ => Convert.ToString(v, System.Globalization.CultureInfo.InvariantCulture) ?? "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool AsBool(string path) => GetValue(path) is bool b && b;
|
||||||
|
|
||||||
|
private async Task SetString(string path, string? raw)
|
||||||
|
{
|
||||||
|
_rawText[path] = raw ?? "";
|
||||||
|
SetValue(path, raw ?? "");
|
||||||
|
await Emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SetBool(string path, bool value)
|
||||||
|
{
|
||||||
|
SetValue(path, value);
|
||||||
|
await Emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SetNumeric(string path, string? raw, bool integerOnly)
|
||||||
|
{
|
||||||
|
_rawText[path] = raw ?? "";
|
||||||
if (string.IsNullOrWhiteSpace(raw))
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
{
|
{
|
||||||
_parseErrors.Remove(name);
|
RemoveValue(path);
|
||||||
Values.Remove(name);
|
await Emit();
|
||||||
await ValuesChanged.InvokeAsync(Values);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (integerOnly && long.TryParse(raw, out var i))
|
if (integerOnly && long.TryParse(raw, out var i))
|
||||||
{
|
{
|
||||||
_parseErrors.Remove(name);
|
_parseErrors.Remove(path);
|
||||||
Values[name] = i;
|
SetValue(path, i);
|
||||||
}
|
}
|
||||||
else if (!integerOnly && double.TryParse(raw,
|
else if (!integerOnly && double.TryParse(raw,
|
||||||
System.Globalization.NumberStyles.Float,
|
System.Globalization.NumberStyles.Float,
|
||||||
System.Globalization.CultureInfo.InvariantCulture, out var d))
|
System.Globalization.CultureInfo.InvariantCulture, out var d))
|
||||||
{
|
{
|
||||||
_parseErrors.Remove(name);
|
_parseErrors.Remove(path);
|
||||||
Values[name] = d;
|
SetValue(path, d);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_parseErrors[name] = integerOnly ? "Not a valid integer." : "Not a valid number.";
|
// Park the unparseable raw text, drop the value, and mark the field. The
|
||||||
Values.Remove(name);
|
// "Not a valid" prefix tags this as a raw-parse marker so the schema
|
||||||
|
// validation pass leaves it intact.
|
||||||
|
_parseErrors[path] = integerOnly ? "Not a valid integer." : "Not a valid number.";
|
||||||
|
RemoveValue(path);
|
||||||
}
|
}
|
||||||
await ValuesChanged.InvokeAsync(Values);
|
await Emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SetJson(string name, string? raw)
|
private async Task SetJson(string path, string? raw)
|
||||||
{
|
{
|
||||||
_rawText[name] = raw ?? "";
|
_rawText[path] = raw ?? "";
|
||||||
if (string.IsNullOrWhiteSpace(raw))
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
{
|
{
|
||||||
_parseErrors.Remove(name);
|
RemoveValue(path);
|
||||||
Values.Remove(name);
|
await Emit();
|
||||||
await ValuesChanged.InvokeAsync(Values);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var doc = JsonDocument.Parse(raw);
|
using var doc = JsonDocument.Parse(raw);
|
||||||
Values[name] = JsonElementToObject(doc.RootElement.Clone());
|
_parseErrors.Remove(path);
|
||||||
_parseErrors.Remove(name);
|
SetValue(path, JsonElementToObject(doc.RootElement.Clone()));
|
||||||
}
|
}
|
||||||
catch (JsonException ex)
|
catch (JsonException ex)
|
||||||
{
|
{
|
||||||
_parseErrors[name] = $"JSON parse error: {ex.Message}";
|
_parseErrors[path] = $"JSON parse error: {ex.Message}";
|
||||||
Values.Remove(name);
|
RemoveValue(path);
|
||||||
}
|
}
|
||||||
|
await Emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Emit + validate ----------------------------------------------------
|
||||||
|
|
||||||
|
private async Task Emit()
|
||||||
|
{
|
||||||
|
Validate();
|
||||||
await ValuesChanged.InvokeAsync(Values);
|
await ValuesChanged.InvokeAsync(Values);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validates the whole value tree against the parsed schema and projects each
|
||||||
|
// path-qualified error onto the input that owns it. Raw-text parse failures
|
||||||
|
// (numeric / JSON) are added directly by the setters; schema validation adds
|
||||||
|
// type/required errors for the structured fields.
|
||||||
|
private void Validate()
|
||||||
|
{
|
||||||
|
// Clear schema-derived errors but keep raw-parse markers set this cycle.
|
||||||
|
foreach (var field in _topLevelFields)
|
||||||
|
{
|
||||||
|
ClearSchemaErrors(field.Name);
|
||||||
|
}
|
||||||
|
if (_rootSchema is null) return;
|
||||||
|
|
||||||
|
var errors = new List<string>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var element = JsonSerializer.SerializeToElement(Values);
|
||||||
|
_rootSchema.Validate(element, string.Empty, errors);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var error in errors)
|
||||||
|
{
|
||||||
|
var path = ExtractPath(error);
|
||||||
|
if (path is not null && !_parseErrors.ContainsKey(path))
|
||||||
|
{
|
||||||
|
_parseErrors[path] = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The validator quotes the offending path as 'a.b[2].c'; pull it back so the
|
||||||
|
// message can be attached to the input at that path. Returns null for
|
||||||
|
// whole-value / object-level messages with no quoted path.
|
||||||
|
private static string? ExtractPath(string message)
|
||||||
|
{
|
||||||
|
var open = message.IndexOf('\'');
|
||||||
|
if (open < 0) return null;
|
||||||
|
var close = message.IndexOf('\'', open + 1);
|
||||||
|
if (close < 0) return null;
|
||||||
|
var path = message.Substring(open + 1, close - open - 1);
|
||||||
|
return path.Length == 0 ? null : path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearSchemaErrors(string topField)
|
||||||
|
{
|
||||||
|
foreach (var key in _parseErrors.Keys
|
||||||
|
.Where(k => k == topField || k.StartsWith(topField + ".", StringComparison.Ordinal) || k.StartsWith(topField + "[", StringComparison.Ordinal))
|
||||||
|
.ToList())
|
||||||
|
{
|
||||||
|
// Only drop schema-derived markers; raw-text parse markers carry the
|
||||||
|
// "Not a valid" / "JSON parse error" prefix and are owned by the setter.
|
||||||
|
var msg = _parseErrors[key];
|
||||||
|
if (!msg.StartsWith("Not a valid", StringComparison.Ordinal) && !msg.StartsWith("JSON parse error", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_parseErrors.Remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string JoinField(string path, string field) =>
|
||||||
|
string.IsNullOrEmpty(path) ? field : $"{path}.{field}";
|
||||||
|
|
||||||
private static object? JsonElementToObject(JsonElement element)
|
private static object? JsonElementToObject(JsonElement element)
|
||||||
{
|
{
|
||||||
return element.ValueKind switch
|
return element.ValueKind switch
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
using Bunit;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NSubstitute;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M9-T30: <c>ParameterValueForm</c> renders typed nested inputs for object and
|
||||||
|
/// list parameters (replacing the raw-JSON textarea fallback), driven by the
|
||||||
|
/// declared schema (with <c>{"$ref":"lib:Name"}</c> resolved via
|
||||||
|
/// <see cref="ISchemaLibraryQueryService"/>), with per-field validation, and
|
||||||
|
/// emits the SAME canonical JSON value shape the form has always produced
|
||||||
|
/// (scalars as bool/long/double/string, objects as
|
||||||
|
/// <c>Dictionary<string,object?></c>, lists as <c>List<object?></c>).
|
||||||
|
/// </summary>
|
||||||
|
public class ParameterValueFormTests : BunitContext
|
||||||
|
{
|
||||||
|
private readonly ISchemaLibraryQueryService _library = Substitute.For<ISchemaLibraryQueryService>();
|
||||||
|
|
||||||
|
public ParameterValueFormTests()
|
||||||
|
{
|
||||||
|
// Default: empty library (no $ref entries). Individual tests override.
|
||||||
|
_library.GetSchemaMapAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new Dictionary<string, string>());
|
||||||
|
Services.AddSingleton(_library);
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string ObjectParamSchema = """
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"order": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "integer" },
|
||||||
|
"name": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["id"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["order"]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string ListParamSchema = """
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"tags": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ObjectParameter_RendersPerFieldInputs_NotRawTextarea()
|
||||||
|
{
|
||||||
|
var cut = Render<ParameterValueForm>(p => p
|
||||||
|
.Add(x => x.ParameterDefinitions, ObjectParamSchema)
|
||||||
|
.Add(x => x.Values, new Dictionary<string, object?>()));
|
||||||
|
|
||||||
|
// No raw-JSON textarea fallback for the object parameter.
|
||||||
|
Assert.Empty(cut.FindAll("textarea"));
|
||||||
|
|
||||||
|
// Nested fields are rendered as their own typed inputs: an Integer (number)
|
||||||
|
// for order.id and a String (text) for order.name.
|
||||||
|
var numberInputs = cut.FindAll("input[type=number]");
|
||||||
|
var textInputs = cut.FindAll("input[type=text]");
|
||||||
|
Assert.NotEmpty(numberInputs);
|
||||||
|
Assert.NotEmpty(textInputs);
|
||||||
|
|
||||||
|
// The nested field labels surface.
|
||||||
|
Assert.Contains("id", cut.Markup);
|
||||||
|
Assert.Contains("name", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ObjectParameter_RoundTripsToCanonicalNestedJsonShape()
|
||||||
|
{
|
||||||
|
Dictionary<string, object?>? emitted = null;
|
||||||
|
var cut = Render<ParameterValueForm>(p => p
|
||||||
|
.Add(x => x.ParameterDefinitions, ObjectParamSchema)
|
||||||
|
.Add(x => x.Values, new Dictionary<string, object?>())
|
||||||
|
.Add(x => x.ValuesChanged, v => emitted = v));
|
||||||
|
|
||||||
|
// Enter the nested integer field (order.id).
|
||||||
|
var idInput = cut.FindAll("input[type=number]")[0];
|
||||||
|
idInput.Input("42");
|
||||||
|
|
||||||
|
// Enter the nested string field (order.name).
|
||||||
|
var nameInput = cut.FindAll("input[type=text]")[0];
|
||||||
|
nameInput.Input("widget");
|
||||||
|
|
||||||
|
Assert.NotNull(emitted);
|
||||||
|
var order = Assert.IsType<Dictionary<string, object?>>(emitted!["order"]);
|
||||||
|
Assert.Equal(42L, order["id"]);
|
||||||
|
Assert.Equal("widget", order["name"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ListParameter_RendersAddRemoveTypedItemRows()
|
||||||
|
{
|
||||||
|
Dictionary<string, object?>? emitted = null;
|
||||||
|
var cut = Render<ParameterValueForm>(p => p
|
||||||
|
.Add(x => x.ParameterDefinitions, ListParamSchema)
|
||||||
|
.Add(x => x.Values, new Dictionary<string, object?>())
|
||||||
|
.Add(x => x.ValuesChanged, v => emitted = v));
|
||||||
|
|
||||||
|
// No raw-JSON textarea fallback for the list parameter.
|
||||||
|
Assert.Empty(cut.FindAll("textarea"));
|
||||||
|
|
||||||
|
// An "add item" button exists; clicking it appends a typed item editor.
|
||||||
|
var addButton = cut.Find("button.param-list-add");
|
||||||
|
addButton.Click();
|
||||||
|
|
||||||
|
var itemInput = cut.Find("input.param-list-item");
|
||||||
|
itemInput.Input("alpha");
|
||||||
|
|
||||||
|
Assert.NotNull(emitted);
|
||||||
|
var tags = Assert.IsType<List<object?>>(emitted!["tags"]);
|
||||||
|
Assert.Single(tags);
|
||||||
|
Assert.Equal("alpha", tags[0]);
|
||||||
|
|
||||||
|
// Removing the row drops it back to an empty list.
|
||||||
|
cut.Find("button.param-list-remove").Click();
|
||||||
|
var tagsAfter = Assert.IsType<List<object?>>(emitted!["tags"]);
|
||||||
|
Assert.Empty(tagsAfter);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RefBearingParameter_RendersResolvedShape()
|
||||||
|
{
|
||||||
|
// The library entry "Address" resolves to an object schema with two string
|
||||||
|
// fields; the parameter references it via {"$ref":"lib:Address"}.
|
||||||
|
const string libSchema = """
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"street": { "type": "string" },
|
||||||
|
"city": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
_library.GetSchemaMapAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new Dictionary<string, string> { ["Address"] = libSchema });
|
||||||
|
|
||||||
|
const string paramSchema = """
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"shipTo": { "$ref": "lib:Address" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var cut = Render<ParameterValueForm>(p => p
|
||||||
|
.Add(x => x.ParameterDefinitions, paramSchema)
|
||||||
|
.Add(x => x.Values, new Dictionary<string, object?>()));
|
||||||
|
|
||||||
|
// The resolved shape renders its fields (no raw textarea, no unresolved-ref message).
|
||||||
|
Assert.Empty(cut.FindAll("textarea"));
|
||||||
|
Assert.Contains("street", cut.Markup);
|
||||||
|
Assert.Contains("city", cut.Markup);
|
||||||
|
// Two string inputs for the resolved object's two fields.
|
||||||
|
Assert.Equal(2, cut.FindAll("input[type=text]").Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PerFieldValidationError_SurfacesInline()
|
||||||
|
{
|
||||||
|
// A required nested field (order.id is an integer) left non-numeric must
|
||||||
|
// surface a per-field error inline.
|
||||||
|
var cut = Render<ParameterValueForm>(p => p
|
||||||
|
.Add(x => x.ParameterDefinitions, ObjectParamSchema)
|
||||||
|
.Add(x => x.Values, new Dictionary<string, object?>()));
|
||||||
|
|
||||||
|
var idInput = cut.FindAll("input[type=number]")[0];
|
||||||
|
idInput.Input("not-a-number");
|
||||||
|
|
||||||
|
// A field-level error class is shown.
|
||||||
|
Assert.NotEmpty(cut.FindAll(".text-danger"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user