From 10c08dd30954be7570ef6e4499cdfbb79da4bbc5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 12:40:22 -0400 Subject: [PATCH] feat(m9/T30): schema-driven nested object/list value-entry forms (+ $ref resolution) --- .../Shared/ParameterValueForm.razor | 557 +++++++++++++++--- .../Design/ParameterValueFormTests.cs | 187 ++++++ 2 files changed, 668 insertions(+), 76 deletions(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ParameterValueFormTests.cs 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 74e898de..38dba798 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ParameterValueForm.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ParameterValueForm.razor @@ -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, lists as + List. The companion SchemaBuilder edits the schema; this edits + values. *@ -@if (Shapes.Count == 0) +@if (_shapes.Count == 0) {
No parameters declared.
} else {
- @foreach (var shape in Shapes) + @foreach (var field in _topLevelFields) { -
+ var shape = _shapes.FirstOrDefault(s => s.Name == field.Name); +
-
- @RenderInput(shape) - @if (_parseErrors.TryGetValue(shape.Name, out var err)) - { -
@err
- } + @RenderNode(field.Schema, field.Name, FieldId(field.Name))
} @@ -41,127 +48,525 @@ else [Parameter] public Dictionary Values { get; set; } = new(); [Parameter] public EventCallback> ValuesChanged { get; set; } - private IReadOnlyList Shapes => - ScriptParameterNames.ParseShapes(ParameterDefinitions); + private IReadOnlyList _shapes = Array.Empty(); + private IReadOnlyList _topLevelFields = Array.Empty(); + 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 _rawText = new(); private readonly Dictionary _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(); + } + + /// + /// 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). + /// + private async Task ParseRootAsync() + { + if (string.IsNullOrWhiteSpace(ParameterDefinitions)) + { + return null; + } + + Func? 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":
- +
break; - case "Integer": - + case "integer": + + @RenderError(path) break; - case "Float": - + case "number": + + @RenderError(path) break; - case "String": - + case "string": + + @RenderError(path) break; - default: // Object, List, List<...>, unknown - + case "object": + @RenderObject(schema, path) + break; + + case "array": + @RenderArray(schema, path) + break; + + default: // unresolved $ref / unknown — raw-JSON escape hatch. + + @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); +
+ @foreach (var field in schema.Fields) + { + var childPath = JoinField(path, field.Name); +
+
+ +
+
+ @RenderNode(field.Schema, childPath, FieldId(childPath)) +
+
+ } +
+ }; + + private RenderFragment RenderArray(InboundApiSchema schema, string path) => __builder => + { + var count = ListCount(path); + var itemSchema = schema.Items ?? new InboundApiSchema { Type = "string" }; +
+ @for (var i = 0; i < count; i++) + { + var index = i; + var itemPath = $"{path}[{index}]"; +
+
+ @RenderItem(itemSchema, itemPath) +
+ +
+ } +
+ +
+
+ }; + + // 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": +
+ +
+ break; + + case "integer": + + @RenderError(path) + break; + + case "number": + + @RenderError(path) + break; + + case "string": + + @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)) + { +
@err
+ } + }; + + // ---- 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, lists as List — 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 ParsePath(string path) + { + var segs = new List(); + 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 dict && dict.TryGetValue(seg.Field, out var v)) node = v; + else return null; + } + else + { + if (node is List 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)node!; + if (last) { dict[seg.Field] = value; return; } + if (dict.TryGetValue(seg.Field, out var child) && child is (Dictionary or List)) node = child; + else { var created = NextContainer(segs[s + 1]); dict[seg.Field] = created; node = created; } + } + else + { + var list = (List)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 or List)) 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 d && d.TryGetValue(seg.Field, out var v) ? v : null) + : (node is List 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 dict) dict.Remove(leaf.Field); + else if (node is List 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() : new List(); + + // ---- List add/remove --------------------------------------------------- + + private int ListCount(string path) => GetValue(path) is List list ? list.Count : 0; + + private async Task AddListItem(string path, InboundApiSchema itemSchema) + { + if (GetValue(path) is not List list) + { + list = new List(); + SetValue(path, list); + } + // Seed structural items with an empty container so their nested inputs render. + list.Add(itemSchema.Type switch + { + "object" => new Dictionary(), + "array" => new List(), + _ => null, + }); + await Emit(); + } + + private async Task RemoveListItem(string path, int index) + { + if (GetValue(path) is List 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(); + 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 diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ParameterValueFormTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ParameterValueFormTests.cs new file mode 100644 index 00000000..bb63acab --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ParameterValueFormTests.cs @@ -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; + +/// +/// M9-T30: ParameterValueForm renders typed nested inputs for object and +/// list parameters (replacing the raw-JSON textarea fallback), driven by the +/// declared schema (with {"$ref":"lib:Name"} resolved via +/// ), 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 +/// Dictionary<string,object?>, lists as List<object?>). +/// +public class ParameterValueFormTests : BunitContext +{ + private readonly ISchemaLibraryQueryService _library = Substitute.For(); + + public ParameterValueFormTests() + { + // Default: empty library (no $ref entries). Individual tests override. + _library.GetSchemaMapAsync(Arg.Any()) + .Returns(new Dictionary()); + 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(p => p + .Add(x => x.ParameterDefinitions, ObjectParamSchema) + .Add(x => x.Values, new Dictionary())); + + // 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? emitted = null; + var cut = Render(p => p + .Add(x => x.ParameterDefinitions, ObjectParamSchema) + .Add(x => x.Values, new Dictionary()) + .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>(emitted!["order"]); + Assert.Equal(42L, order["id"]); + Assert.Equal("widget", order["name"]); + } + + [Fact] + public void ListParameter_RendersAddRemoveTypedItemRows() + { + Dictionary? emitted = null; + var cut = Render(p => p + .Add(x => x.ParameterDefinitions, ListParamSchema) + .Add(x => x.Values, new Dictionary()) + .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>(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>(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()) + .Returns(new Dictionary { ["Address"] = libSchema }); + + const string paramSchema = """ + { + "type": "object", + "properties": { + "shipTo": { "$ref": "lib:Address" } + } + } + """; + + var cut = Render(p => p + .Add(x => x.ParameterDefinitions, paramSchema) + .Add(x => x.Values, new Dictionary())); + + // 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(p => p + .Add(x => x.ParameterDefinitions, ObjectParamSchema) + .Add(x => x.Values, new Dictionary())); + + 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")); + } +}