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.Services
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi
|
||||
@using System.Text.Json
|
||||
@inject ISchemaLibraryQueryService SchemaLibrary
|
||||
|
||||
@*
|
||||
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 /
|
||||
checkbox); Object and List fall back to a JSON textarea with inline parse
|
||||
errors. The companion SchemaBuilder edits the schema; this edits values.
|
||||
for a script test run, driven by the declared schema. Primitive types get
|
||||
typed inputs (text / number / checkbox); OBJECT parameters render a labeled
|
||||
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>
|
||||
}
|
||||
else
|
||||
{
|
||||
<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">
|
||||
<label class="form-label small mb-0" for="@FieldId(shape)">
|
||||
<code>@shape.Name</code>
|
||||
<span class="text-muted ms-1">@shape.Type@(shape.Required ? "" : "?")</span>
|
||||
<label class="form-label small mb-0" for="@FieldId(field.Name)">
|
||||
<code>@field.Name</code>
|
||||
<span class="text-muted ms-1">@(shape?.Type ?? DisplayType(field.Schema))@(field.Required ? "" : "?")</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-sm-8">
|
||||
@RenderInput(shape)
|
||||
@if (_parseErrors.TryGetValue(shape.Name, out var err))
|
||||
{
|
||||
<div class="text-danger small mt-1">@err</div>
|
||||
}
|
||||
@RenderNode(field.Schema, field.Name, FieldId(field.Name))
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -41,127 +48,525 @@ else
|
||||
[Parameter] public Dictionary<string, object?> Values { get; set; } = new();
|
||||
[Parameter] public EventCallback<Dictionary<string, object?>> ValuesChanged { get; set; }
|
||||
|
||||
private IReadOnlyList<ParameterShape> Shapes =>
|
||||
ScriptParameterNames.ParseShapes(ParameterDefinitions);
|
||||
private IReadOnlyList<ParameterShape> _shapes = Array.Empty<ParameterShape>();
|
||||
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> _parseErrors = new();
|
||||
|
||||
private static string FieldId(ParameterShape shape) => $"param-{shape.Name}";
|
||||
|
||||
private RenderFragment RenderInput(ParameterShape shape) => __builder =>
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
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">
|
||||
<input class="form-check-input" type="checkbox" id="@FieldId(shape)"
|
||||
checked="@AsBool(shape.Name)"
|
||||
@onchange="e => SetBool(shape.Name, (bool)(e.Value ?? false))" />
|
||||
<input class="form-check-input" type="checkbox" id="@id"
|
||||
checked="@AsBool(path)"
|
||||
@onchange="e => SetBool(path, (bool)(e.Value ?? false))" />
|
||||
</div>
|
||||
break;
|
||||
|
||||
case "Integer":
|
||||
<input class="form-control form-control-sm" type="number" step="1" id="@FieldId(shape)"
|
||||
value="@AsRaw(shape.Name)"
|
||||
@oninput="e => SetNumeric(shape.Name, (string?)e.Value, integerOnly: true)" />
|
||||
case "integer":
|
||||
<input class="form-control form-control-sm" type="number" step="1" id="@id"
|
||||
value="@AsRaw(path)"
|
||||
@oninput="e => SetNumeric(path, (string?)e.Value, integerOnly: true)" />
|
||||
@RenderError(path)
|
||||
break;
|
||||
|
||||
case "Float":
|
||||
<input class="form-control form-control-sm" type="number" step="any" id="@FieldId(shape)"
|
||||
value="@AsRaw(shape.Name)"
|
||||
@oninput="e => SetNumeric(shape.Name, (string?)e.Value, integerOnly: false)" />
|
||||
case "number":
|
||||
<input class="form-control form-control-sm" type="number" step="any" id="@id"
|
||||
value="@AsRaw(path)"
|
||||
@oninput="e => SetNumeric(path, (string?)e.Value, integerOnly: false)" />
|
||||
@RenderError(path)
|
||||
break;
|
||||
|
||||
case "String":
|
||||
<input class="form-control form-control-sm" type="text" id="@FieldId(shape)"
|
||||
value="@AsRaw(shape.Name)"
|
||||
@oninput="e => SetString(shape.Name, (string?)e.Value)" />
|
||||
case "string":
|
||||
<input class="form-control form-control-sm" type="text" id="@id"
|
||||
value="@AsRaw(path)"
|
||||
@oninput="e => SetString(path, (string?)e.Value)" />
|
||||
@RenderError(path)
|
||||
break;
|
||||
|
||||
default: // Object, List, List<...>, unknown
|
||||
<textarea class="form-control form-control-sm font-monospace" rows="3" id="@FieldId(shape)"
|
||||
placeholder='@($"JSON {shape.Type.ToLowerInvariant()}")'
|
||||
@oninput="e => SetJson(shape.Name, (string?)e.Value)">@AsRaw(shape.Name)</textarea>
|
||||
case "object":
|
||||
@RenderObject(schema, path)
|
||||
break;
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
private string AsRaw(string name) =>
|
||||
_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)
|
||||
private RenderFragment RenderObject(InboundApiSchema schema, string path) => __builder =>
|
||||
{
|
||||
_rawText[name] = raw ?? "";
|
||||
_parseErrors.Remove(name);
|
||||
Values[name] = raw ?? "";
|
||||
await ValuesChanged.InvokeAsync(Values);
|
||||
<div class="border-start ps-3 d-flex flex-column gap-2 param-object">
|
||||
@foreach (var field in schema.Fields)
|
||||
{
|
||||
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);
|
||||
Values[name] = value;
|
||||
await ValuesChanged.InvokeAsync(Values);
|
||||
object? node = Values;
|
||||
foreach (var seg in ParsePath(path))
|
||||
{
|
||||
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))
|
||||
{
|
||||
_parseErrors.Remove(name);
|
||||
Values.Remove(name);
|
||||
await ValuesChanged.InvokeAsync(Values);
|
||||
RemoveValue(path);
|
||||
await Emit();
|
||||
return;
|
||||
}
|
||||
if (integerOnly && long.TryParse(raw, out var i))
|
||||
{
|
||||
_parseErrors.Remove(name);
|
||||
Values[name] = i;
|
||||
_parseErrors.Remove(path);
|
||||
SetValue(path, i);
|
||||
}
|
||||
else if (!integerOnly && double.TryParse(raw,
|
||||
System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var d))
|
||||
{
|
||||
_parseErrors.Remove(name);
|
||||
Values[name] = d;
|
||||
_parseErrors.Remove(path);
|
||||
SetValue(path, d);
|
||||
}
|
||||
else
|
||||
{
|
||||
_parseErrors[name] = integerOnly ? "Not a valid integer." : "Not a valid number.";
|
||||
Values.Remove(name);
|
||||
// Park the unparseable raw text, drop the value, and mark the field. The
|
||||
// "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))
|
||||
{
|
||||
_parseErrors.Remove(name);
|
||||
Values.Remove(name);
|
||||
await ValuesChanged.InvokeAsync(Values);
|
||||
RemoveValue(path);
|
||||
await Emit();
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
Values[name] = JsonElementToObject(doc.RootElement.Clone());
|
||||
_parseErrors.Remove(name);
|
||||
_parseErrors.Remove(path);
|
||||
SetValue(path, JsonElementToObject(doc.RootElement.Clone()));
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_parseErrors[name] = $"JSON parse error: {ex.Message}";
|
||||
Values.Remove(name);
|
||||
_parseErrors[path] = $"JSON parse error: {ex.Message}";
|
||||
RemoveValue(path);
|
||||
}
|
||||
await Emit();
|
||||
}
|
||||
|
||||
// ---- Emit + validate ----------------------------------------------------
|
||||
|
||||
private async Task Emit()
|
||||
{
|
||||
Validate();
|
||||
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)
|
||||
{
|
||||
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