b5eb08f1a9
The Run-inbound-API-method pane rendered one text box per top-level schema field and coerced the text on the page; an array/object field went through JsonNode.Parse and, on failure, was SENT AS A STRING, while an empty box was silently dropped. A required array-of-object parameter (MesMoveOut WorkOrders) could not be entered at all — QA runs reached the site with "WorkOrders":[]. - The method pane now renders the same shared ParameterValueForm the script pane uses (typed inputs, nested groups, add/remove list item editors, $ref:lib: resolution, inline validation), keyed on the method id so a change of method remounts it. The page keeps ONE value tree and BuildParameters() serializes it as-is; the flat _inputs / Coerce() / SetInput path is deleted. - Form | Raw JSON toggle: raw mode is a Monaco json editor for the whole request body, given the method's root schema for completion, parsed and schema-validated (InboundApiSchema.Validate) on every edit with the error inline. Entering raw mode seeds the editor from the tree; leaving it re-mounts the form over the last valid tree. - Run is disabled while the form or the raw document is invalid (a required field left empty disables Run before the first keystroke). - localStorage per-method entry stores the value tree; on restore it is validated against the schema and dropped if invalid, which also retires the pre-#37 flat string-per-field entries. - ParameterValueForm.ToJsonSchema / JsonElementToObject made internal static for page reuse. - Tests: 7 new bUnit tests (WorkOrders array-of-object round-trip to the RunMethodAsync payload, required list-item gating, invalid raw JSON blocks Run + shows parse/schema/non-object errors, valid raw JSON sent verbatim and survives the switch back to the form, method switch resets, persisted tree restored, legacy flat entry dropped); existing method-pane tests moved to the form's #param-<field> ids and fill the required field before Run. Playwright smoke selector moved to #param-Text. - Docs: design doc, Component-CentralUI.md entry-points bullet, CLAUDE.md. CentralUI.Tests 1425/1425 green; Playwright project builds. Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
755 lines
30 KiB
Plaintext
755 lines
30 KiB
Plaintext
@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<string,object?>, lists as
|
|
List<object?>. The companion SchemaBuilder edits the schema; this edits
|
|
values.
|
|
*@
|
|
|
|
@if (_topLevelFields.Count == 0)
|
|
{
|
|
<div class="text-muted small fst-italic">No parameters declared.</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="d-flex flex-column gap-2">
|
|
@foreach (var field in _topLevelFields)
|
|
{
|
|
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(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">
|
|
@RenderNode(field.Schema, field.Name, FieldId(field.Name))
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
@code {
|
|
[Parameter] public string? ParameterDefinitions { get; set; }
|
|
[Parameter] public Dictionary<string, object?> Values { get; set; } = new();
|
|
[Parameter] public EventCallback<Dictionary<string, object?>> ValuesChanged { get; set; }
|
|
|
|
/// <summary>
|
|
/// Raised after every edit with <c>true</c> 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.
|
|
/// </summary>
|
|
[Parameter] public EventCallback<bool> ValidChanged { get; set; }
|
|
|
|
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();
|
|
|
|
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<InboundApiSchemaField>();
|
|
|
|
// 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<string>();
|
|
_rootSchema.Validate(element, string.Empty, errors);
|
|
return errors.Count == 0;
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <c>{"$ref":"lib:Name"}</c> the parse already inlined is emitted as its
|
|
/// resolved shape; a node whose ref could NOT be resolved (Type
|
|
/// <c>"ref"</c>) — or any unknown type — has no derivable sub-schema and
|
|
/// collapses to a permissive <c>{}</c>, 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.
|
|
/// </summary>
|
|
private static string FieldSchemaJson(InboundApiSchema fieldSchema) => ToJsonSchema(fieldSchema);
|
|
|
|
/// <summary>
|
|
/// Serializes a (resolved) <see cref="InboundApiSchema"/> back to a JSON Schema
|
|
/// string for the escape-hatch Monaco editor. Any <c>{"$ref":"lib:Name"}</c> the
|
|
/// parse already inlined is emitted as its resolved shape (no <c>$ref</c> 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
|
|
/// <c>{}</c> so the editor still validates well-formed JSON without false errors.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <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="@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="@id"
|
|
value="@AsRaw(path)"
|
|
@oninput="e => SetNumeric(path, (string?)e.Value, integerOnly: true)" />
|
|
@RenderError(path)
|
|
break;
|
|
|
|
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="@id"
|
|
value="@AsRaw(path)"
|
|
@oninput="e => SetString(path, (string?)e.Value)" />
|
|
@RenderError(path)
|
|
break;
|
|
|
|
case "object":
|
|
@RenderObject(schema, path)
|
|
break;
|
|
|
|
case "array":
|
|
@RenderArray(schema, path)
|
|
break;
|
|
|
|
default: // unresolved $ref / unknown — raw-JSON escape hatch (Monaco).
|
|
<div class="param-json-editor" data-path="@path">
|
|
<MonacoEditor Language="json" Height="120px" ShowToolbar="false"
|
|
JsonSchema="@FieldSchemaJson(schema)"
|
|
Value="@AsRaw(path)"
|
|
ValueChanged="@(v => SetJson(path, v))" />
|
|
</div>
|
|
@RenderError(path)
|
|
break;
|
|
}
|
|
};
|
|
|
|
private RenderFragment RenderObject(InboundApiSchema schema, string path) => __builder =>
|
|
{
|
|
<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 object? GetValue(string path)
|
|
{
|
|
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 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<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 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<string, string> 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<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}";
|
|
|
|
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
|
|
};
|
|
}
|
|
}
|