@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, 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 (_topLevelFields.Count == 0) {
No parameters declared.
} else {
@foreach (var field in _topLevelFields) { var shape = _shapes.FirstOrDefault(s => s.Name == field.Name);
@RenderNode(field.Schema, field.Name, FieldId(field.Name))
}
} @code { [Parameter] public string? ParameterDefinitions { get; set; } [Parameter] public Dictionary Values { get; set; } = new(); [Parameter] public EventCallback> ValuesChanged { get; set; } /// /// Raised after every edit with true when the value tree has no parse or validation errors /// (Flow Debugger script entry, design 2026-09-08 §5: the page gates Run on it). Additive; existing /// callers ignore it. /// [Parameter] public EventCallback ValidChanged { get; set; } 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(); protected override async Task OnParametersSetAsync() { // Re-parse only when the declared schema text changes. if (_parsedFor == ParameterDefinitions) { return; } _parsedFor = ParameterDefinitions; _shapes = ScriptParameterNames.ParseShapes(ParameterDefinitions); _rootSchema = await ParseRootAsync(); _topLevelFields = _rootSchema?.Type == "object" ? _rootSchema.Fields : Array.Empty(); // Report validity for the freshly-(re)parsed schema so a caller gating a Run // button on ValidChanged never starts (or carries over from a prior script) // a stale "valid" reading when required fields are unset. This deliberately // does NOT call Validate()/populate _parseErrors: the component has no // "untouched field" concept -- RenderError paints whatever is in // _parseErrors unconditionally -- so doing that here would immediately // paint every required field red before the user has touched anything. The // silent check below computes the same answer without that side effect; // _parseErrors (and the visible red text) still only appears once editing // drives it through Emit()/Validate() as before. await ValidChanged.InvokeAsync(ComputeIsValid()); } // Mirrors Validate()'s schema pass (plus any raw-text parse markers already // parked in _parseErrors) WITHOUT writing to _parseErrors, so it can run on // every schema (re)parse without painting untouched required fields red. private bool ComputeIsValid() { if (_parseErrors.Count > 0) { return false; } if (_rootSchema is null) { return true; } try { var element = JsonSerializer.SerializeToElement(Values); var errors = new List(); _rootSchema.Validate(element, string.Empty, errors); return errors.Count == 0; } catch (JsonException) { return false; } } /// /// Serializes the escape-hatch FIELD's own (resolved) sub-schema to a JSON /// Schema string for that field's Monaco editor — NOT the whole root object /// schema. So a field's raw-JSON editor validates/completes against just that /// field's portion of the type, not the entire object. Any /// {"$ref":"lib:Name"} the parse already inlined is emitted as its /// resolved shape; a node whose ref could NOT be resolved (Type /// "ref") — or any unknown type — has no derivable sub-schema and /// collapses to a permissive {}, so the editor still validates /// well-formed JSON without false errors. Cheap and on-demand: only the rare /// escape-hatch nodes pay for it, and only at render time. /// private static string FieldSchemaJson(InboundApiSchema fieldSchema) => ToJsonSchema(fieldSchema); /// /// Serializes a (resolved) back to a JSON Schema /// string for the escape-hatch Monaco editor. Any {"$ref":"lib:Name"} the /// parse already inlined is emitted as its resolved shape (no $ref remains), /// so Monaco's JSON language sees the full type. The shape-only / unresolved-ref /// placeholder (Type not in the JSON Schema set) collapses to a permissive /// {} so the editor still validates well-formed JSON without false errors. /// internal static string ToJsonSchema(InboundApiSchema schema) { using var stream = new System.IO.MemoryStream(); using (var writer = new System.Text.Json.Utf8JsonWriter(stream)) { WriteSchema(writer, schema); } return System.Text.Encoding.UTF8.GetString(stream.ToArray()); } private static void WriteSchema(System.Text.Json.Utf8JsonWriter writer, InboundApiSchema schema) { switch (schema.Type) { case "boolean": case "integer": case "number": case "string": writer.WriteStartObject(); writer.WriteString("type", schema.Type); writer.WriteEndObject(); break; case "object": writer.WriteStartObject(); writer.WriteString("type", "object"); writer.WritePropertyName("properties"); writer.WriteStartObject(); foreach (var field in schema.Fields) { writer.WritePropertyName(field.Name); WriteSchema(writer, field.Schema); } writer.WriteEndObject(); var required = schema.Fields.Where(f => f.Required).Select(f => f.Name).ToList(); if (required.Count > 0) { writer.WritePropertyName("required"); writer.WriteStartArray(); foreach (var name in required) { writer.WriteStringValue(name); } writer.WriteEndArray(); } writer.WriteEndObject(); break; case "array": writer.WriteStartObject(); writer.WriteString("type", "array"); if (schema.Items is not null) { writer.WritePropertyName("items"); WriteSchema(writer, schema.Items); } writer.WriteEndObject(); break; default: // unresolved $ref / unknown — permissive (accept any JSON). writer.WriteStartObject(); writer.WriteEndObject(); break; } } /// /// 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": @RenderError(path) break; case "number": @RenderError(path) break; case "string": @RenderError(path) break; case "object": @RenderObject(schema, path) break; case "array": @RenderArray(schema, path) break; default: // unresolved $ref / unknown — raw-JSON escape hatch (Monaco).
@RenderError(path) break; } }; private RenderFragment RenderObject(InboundApiSchema schema, string path) => __builder => {
@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 object? GetValue(string path) { 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 void SetValue(string path, object? value) { 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 at removedIndex, paths for items above that index shift down // by one. Mirror the value-list RemoveAt by: (1) for each index > removedIndex // found in a dict, re-key it as index-1; (2) clear the now-vacated top slot // (the original highest index). Items below the removed index are unaffected. private void ShiftListState(string path, int removedIndex) { ShiftDict(_rawText, path, removedIndex); ShiftDict(_parseErrors, path, removedIndex); } private static void ShiftDict(Dictionary dict, string path, int removedIndex) { var prefix = $"{path}["; // 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) { 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)); } // 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)) { var value = dict[key]; dict.Remove(key); var shiftedKey = $"{path}[{idx - 1}]{suffix}"; dict[shiftedKey] = value; } } // ---- 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)) { RemoveValue(path); await Emit(); return; } if (integerOnly && long.TryParse(raw, out var 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(path); SetValue(path, d); } else { // 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 Emit(); } private async Task SetJson(string path, string? raw) { _rawText[path] = raw ?? ""; if (string.IsNullOrWhiteSpace(raw)) { RemoveValue(path); await Emit(); return; } try { using var doc = JsonDocument.Parse(raw); _parseErrors.Remove(path); SetValue(path, JsonElementToObject(doc.RootElement.Clone())); } catch (JsonException ex) { _parseErrors[path] = $"JSON parse error: {ex.Message}"; RemoveValue(path); } await Emit(); } // ---- Emit + validate ---------------------------------------------------- private async Task Emit() { Validate(); await ValuesChanged.InvokeAsync(Values); await ValidChanged.InvokeAsync(IsValid); } private bool IsValid => _parseErrors.Count == 0; // 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}"; internal static object? JsonElementToObject(JsonElement element) { return element.ValueKind switch { JsonValueKind.String => element.GetString(), JsonValueKind.Number => element.TryGetInt64(out var i) ? (object)i : element.GetDouble(), JsonValueKind.True => true, JsonValueKind.False => false, JsonValueKind.Null => null, JsonValueKind.Array => element.EnumerateArray().Select(JsonElementToObject).ToList(), JsonValueKind.Object => element.EnumerateObject() .ToDictionary(p => p.Name, p => JsonElementToObject(p.Value)), _ => null }; } }