fix(flow-debugger): method pane takes structured parameters via ParameterValueForm + raw-JSON escape hatch (#37)
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
This commit is contained in:
+39
-10
@@ -214,24 +214,53 @@
|
||||
<div class="col-lg-3">
|
||||
<div class="card fd-inputs" data-test="flow-inputs">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title text-muted">Inputs</h6>
|
||||
@if (_entry == EntryKind.Method)
|
||||
{
|
||||
@foreach (var f in _inputFields)
|
||||
@* Method entry (#37): the same typed ParameterValueForm the script pane uses —
|
||||
per-item list editors, nested object groups, inline validation — keyed on
|
||||
the method so a change of method remounts it; plus a whole-payload raw-JSON
|
||||
escape hatch to paste the exact body an external caller sends. Both edit
|
||||
one value tree, and Run stays disabled while it is invalid. *@
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="card-title text-muted mb-0">Inputs</h6>
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Input mode">
|
||||
<button type="button" class="btn @(!_methodRawMode ? "btn-primary" : "btn-outline-secondary")"
|
||||
data-test="flow-input-mode-form" disabled="@_running" @onclick="() => SetMethodRawMode(false)">Form</button>
|
||||
<button type="button" class="btn @(_methodRawMode ? "btn-primary" : "btn-outline-secondary")"
|
||||
data-test="flow-input-mode-raw" disabled="@_running" @onclick="() => SetMethodRawMode(true)">Raw JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (_methodRawMode)
|
||||
{
|
||||
var field = f;
|
||||
<div class="mb-2">
|
||||
<label class="form-label form-label-sm">
|
||||
@field.Name
|
||||
<span class="text-muted">(@field.Schema.Type@(field.Required ? ", required" : ""))</span>
|
||||
</label>
|
||||
<input class="form-control form-control-sm" data-test="@("flow-input-" + field.Name)"
|
||||
value="@InputValue(field.Name)" @oninput="e => SetInput(field.Name, e.Value?.ToString())" disabled="@_running" />
|
||||
<div data-test="flow-method-raw">
|
||||
<MonacoEditor Language="json" Height="220px" ShowToolbar="false"
|
||||
JsonSchema="@MethodRawJsonSchema"
|
||||
Value="@_methodRawText"
|
||||
ValueChanged="@(v => SetMethodRawJson(v))" />
|
||||
@if (_methodRawError is { } rawError)
|
||||
{
|
||||
<div class="text-danger small mt-1" data-test="flow-method-raw-error">@rawError</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="form-text">The whole request body, validated against the method's parameter definitions.</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div data-test="flow-method-params">
|
||||
<ParameterValueForm @key="@_selectedMethodId"
|
||||
ParameterDefinitions="@_methods.FirstOrDefault(m => m.Id == _selectedMethodId)?.ParameterDefinitions"
|
||||
Values="_methodParams"
|
||||
ValuesChanged="@(v => OnMethodParamsChanged(v))"
|
||||
ValidChanged="@(ok => _methodParamsValid = ok)" />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else if (SelectedScript is { } script)
|
||||
{
|
||||
<h6 class="card-title text-muted">Inputs</h6>
|
||||
@* A write kind is fired by writing its own trigger attribute — the operator
|
||||
supplies only the value. Every other kind is called directly with the
|
||||
parameters its definitions declare. *@
|
||||
|
||||
+164
-64
@@ -1,6 +1,5 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.JSInterop;
|
||||
@@ -78,6 +77,11 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
[Inject] private IInboundApiRepository InboundApi { get; set; } = default!;
|
||||
[Inject] private ITemplateEngineRepository TemplateEngineRepository { get; set; } = default!;
|
||||
[Inject] private ISiteRepository SiteRepository { get; set; } = default!;
|
||||
/// <summary>
|
||||
/// Resolves <c>{"$ref":"lib:Name"}</c> nodes in a method's parameter schema so the raw-JSON
|
||||
/// escape hatch validates against the same resolved shape the form renders (#37).
|
||||
/// </summary>
|
||||
[Inject] private ISchemaLibraryQueryService SchemaLibrary { get; set; } = default!;
|
||||
[Inject] private SiteScopeService SiteScope { get; set; } = default!;
|
||||
[Inject] private IJSRuntime JS { get; set; } = default!;
|
||||
[Inject] private IDialogService Dialog { get; set; } = default!;
|
||||
@@ -149,8 +153,27 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
private Dictionary<string, object?> _scriptParams = new();
|
||||
private bool _scriptParamsValid;
|
||||
|
||||
/// <summary>
|
||||
/// Method entry (#37): the chosen method's resolved root schema (null when it declares no
|
||||
/// parameters or the definitions do not parse) and its top-level fields — the card renders
|
||||
/// only when there is at least one. The values live in <see cref="_methodParams"/>, the same
|
||||
/// coerced value tree <c>ParameterValueForm</c> emits for the script pane, so the request
|
||||
/// body is that tree serialized as-is: no per-field text coercion on the page any more.
|
||||
/// </summary>
|
||||
private InboundApiSchema? _methodSchema;
|
||||
private List<InboundApiSchemaField> _inputFields = [];
|
||||
private readonly Dictionary<string, string> _inputs = new(StringComparer.Ordinal);
|
||||
private Dictionary<string, object?> _methodParams = new();
|
||||
private bool _methodParamsValid;
|
||||
|
||||
/// <summary>
|
||||
/// Raw-JSON escape hatch for the whole method payload (#37): paste the exact body an
|
||||
/// external caller would send. The text is parsed and schema-validated on every edit;
|
||||
/// only a valid document reaches <see cref="_methodParams"/>, which stays the single source
|
||||
/// of truth in both modes.
|
||||
/// </summary>
|
||||
private bool _methodRawMode;
|
||||
private string _methodRawText = string.Empty;
|
||||
private string? _methodRawError;
|
||||
|
||||
private PredictedFlow? _predicted;
|
||||
private FlowGraph? _graph;
|
||||
@@ -231,7 +254,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
// Script entry: a write kind needs the value to write; a call kind needs a parameter form
|
||||
// that parses (a script declaring no parameters is runnable as soon as it is chosen).
|
||||
private bool CanRun => _canLive && !_running && _mode == FlowMode.Live && (
|
||||
_entry == EntryKind.Method ? _selectedMethodId > 0
|
||||
_entry == EntryKind.Method ? _selectedMethodId > 0 && (_inputFields.Count == 0 || _methodParamsValid)
|
||||
: SelectedScript is { } s && (s.IsWriteKind
|
||||
? !string.IsNullOrWhiteSpace(_valueChangeValue)
|
||||
: string.IsNullOrWhiteSpace(s.ParameterDefinitions) || _scriptParamsValid));
|
||||
@@ -393,6 +416,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
_scriptParams = new();
|
||||
_scriptParamsValid = false;
|
||||
_valueChangeValue = string.Empty;
|
||||
ResetMethodInputs();
|
||||
await PersistAsync(StorageEntry, entry.ToString());
|
||||
if (_entry == EntryKind.Method && _selectedMethodId > 0)
|
||||
await OnMethodChangedAsync();
|
||||
@@ -403,8 +427,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
private async Task OnMethodChangedAsync()
|
||||
{
|
||||
ResetResult();
|
||||
_inputFields = [];
|
||||
_inputs.Clear();
|
||||
ResetMethodInputs();
|
||||
_predicted = null;
|
||||
_state = FlowState.Idle;
|
||||
|
||||
@@ -412,16 +435,9 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
if (method == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var schema = InboundApiSchema.Parse(method.ParameterDefinitions);
|
||||
if (schema is { Type: "object" })
|
||||
_inputFields = schema.Fields.ToList();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
_inputFields = [];
|
||||
}
|
||||
_methodSchema = await ResolveMethodSchemaAsync(method.ParameterDefinitions);
|
||||
if (_methodSchema is { Type: "object" })
|
||||
_inputFields = _methodSchema.Fields.ToList();
|
||||
|
||||
await PersistAsync(StorageEntry, EntryKind.Method.ToString());
|
||||
await PersistAsync(StorageMethod, method.Id.ToString(CultureInfo.InvariantCulture));
|
||||
@@ -908,36 +924,139 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
|
||||
private void OnSelectStep(FlowStep step) => _selected = step;
|
||||
|
||||
private string InputValue(string name) => _inputs.TryGetValue(name, out var v) ? v : string.Empty;
|
||||
|
||||
private void SetInput(string name, string? value)
|
||||
/// <summary>Drops every per-method input: the schema, the value tree, its validity and the raw editor.</summary>
|
||||
private void ResetMethodInputs()
|
||||
{
|
||||
if (value == null) _inputs.Remove(name);
|
||||
else _inputs[name] = value;
|
||||
_methodSchema = null;
|
||||
_inputFields = [];
|
||||
_methodParams = new();
|
||||
_methodParamsValid = false;
|
||||
_methodRawMode = false;
|
||||
_methodRawText = string.Empty;
|
||||
_methodRawError = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the request body from the typed inputs, coercing by the schema's declared field
|
||||
/// type (integers/numbers/booleans become JSON scalars; objects/lists are parsed as JSON;
|
||||
/// everything else is a string). Empty inputs are omitted so a non-required field is not
|
||||
/// sent as an empty string.
|
||||
/// Parses a method's parameter definitions with <c>$ref:lib:</c> resolution — the same
|
||||
/// resolution <c>ParameterValueForm</c> applies — so the page's raw-JSON validation and the
|
||||
/// form agree on the shape. Null when the method declares nothing or the JSON is malformed.
|
||||
/// </summary>
|
||||
internal JsonElement? BuildParameters()
|
||||
private async Task<InboundApiSchema?> ResolveMethodSchemaAsync(string? definitions)
|
||||
{
|
||||
if (_inputFields.Count == 0)
|
||||
if (string.IsNullOrWhiteSpace(definitions))
|
||||
return null;
|
||||
|
||||
var obj = new JsonObject();
|
||||
foreach (var f in _inputFields)
|
||||
Func<string, string?>? resolver = null;
|
||||
if (InboundApiSchema.MightContainRef(definitions))
|
||||
{
|
||||
if (!_inputs.TryGetValue(f.Name, out var raw) || string.IsNullOrEmpty(raw))
|
||||
continue;
|
||||
obj[f.Name] = Coerce(f.Schema.Type, raw);
|
||||
var map = await SchemaLibrary.GetSchemaMapAsync();
|
||||
resolver = name => map.TryGetValue(name, out var json) ? json : null;
|
||||
}
|
||||
|
||||
return JsonSerializer.SerializeToElement(obj);
|
||||
try
|
||||
{
|
||||
return InboundApiSchema.ParseWithRefs(definitions, resolver).Schema;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>JSON Schema text for the raw editor's completion/hover; permissive when there is no schema.</summary>
|
||||
private string MethodRawJsonSchema => _methodSchema is null ? "{}" : ParameterValueForm.ToJsonSchema(_methodSchema);
|
||||
|
||||
private void OnMethodParamsChanged(Dictionary<string, object?> values)
|
||||
{
|
||||
_methodParams = values;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Form ⇄ Raw JSON (#37). Entering raw mode seeds the editor from the current value tree so
|
||||
/// the operator edits what the form would have sent; leaving it re-mounts the form over the
|
||||
/// last VALID tree (the form is keyed on the method, and it is not rendered while raw mode is
|
||||
/// on, so it mounts fresh and re-reports validity from the tree).
|
||||
/// </summary>
|
||||
private void SetMethodRawMode(bool raw)
|
||||
{
|
||||
if (_methodRawMode == raw) return;
|
||||
_methodRawMode = raw;
|
||||
_methodRawError = null;
|
||||
if (raw)
|
||||
{
|
||||
_methodRawText = _methodParams.Count == 0
|
||||
? "{}"
|
||||
: JsonSerializer.Serialize(_methodParams, RawJsonWriteOptions);
|
||||
_methodParamsValid = ValidateMethodTree(JsonSerializer.SerializeToElement(_methodParams)).Count == 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions RawJsonWriteOptions = new() { WriteIndented = true };
|
||||
|
||||
/// <summary>
|
||||
/// Raw editor edit: parse, require a JSON object, validate against the resolved schema.
|
||||
/// A document that fails at any stage leaves the previous tree untouched and disables Run —
|
||||
/// a failed parse is never sent, as a string or otherwise.
|
||||
/// </summary>
|
||||
private void SetMethodRawJson(string? text)
|
||||
{
|
||||
_methodRawText = text ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(_methodRawText))
|
||||
{
|
||||
_methodRawError = "Enter the request body as a JSON object.";
|
||||
_methodParamsValid = false;
|
||||
return;
|
||||
}
|
||||
|
||||
JsonElement root;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(_methodRawText);
|
||||
root = doc.RootElement.Clone();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_methodRawError = "JSON parse error: " + ex.Message;
|
||||
_methodParamsValid = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
_methodRawError = "The request body must be a JSON object.";
|
||||
_methodParamsValid = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var errors = ValidateMethodTree(root);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
_methodRawError = string.Join(" ", errors);
|
||||
_methodParamsValid = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_methodRawError = null;
|
||||
_methodParams = ParameterValueForm.JsonElementToObject(root) as Dictionary<string, object?> ?? new();
|
||||
_methodParamsValid = true;
|
||||
}
|
||||
|
||||
private List<string> ValidateMethodTree(JsonElement tree)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
_methodSchema?.Validate(tree, string.Empty, errors);
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The request body for a method-entry run: the value tree as-is (scalars as
|
||||
/// bool/long/double/string, objects as dictionaries, lists as lists — exactly what
|
||||
/// <c>ParameterValueForm</c> emits and what the raw editor parsed), or null when the method
|
||||
/// declares no parameters. Run is gated on validity, so nothing here re-checks the tree.
|
||||
/// </summary>
|
||||
internal JsonElement? BuildParameters()
|
||||
=> _inputFields.Count == 0 ? null : JsonSerializer.SerializeToElement(_methodParams);
|
||||
|
||||
/// <summary>
|
||||
/// The call-kind script's parameters as a flat JSON object, or null when the form produced
|
||||
/// nothing. <c>ParameterValueForm</c> already emits the coerced value tree (scalars as
|
||||
@@ -946,32 +1065,6 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
internal JsonElement? BuildScriptParameters()
|
||||
=> _scriptParams.Count == 0 ? null : JsonSerializer.SerializeToElement(_scriptParams);
|
||||
|
||||
private static JsonNode? Coerce(string? type, string raw)
|
||||
{
|
||||
switch (InboundApiSchema.NormalizeType(type))
|
||||
{
|
||||
case "integer":
|
||||
return long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var l) ? JsonValue.Create(l) : JsonValue.Create(raw);
|
||||
case "number":
|
||||
return double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) ? JsonValue.Create(d) : JsonValue.Create(raw);
|
||||
case "boolean":
|
||||
return bool.TryParse(raw, out var b) ? JsonValue.Create(b) : JsonValue.Create(raw);
|
||||
case "object":
|
||||
case "array":
|
||||
case "list":
|
||||
try
|
||||
{
|
||||
return JsonNode.Parse(raw);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return JsonValue.Create(raw);
|
||||
}
|
||||
default:
|
||||
return JsonValue.Create(raw);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PersistAsync(string key, string value)
|
||||
{
|
||||
try
|
||||
@@ -984,29 +1077,36 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Persists the method's value tree (not per-field strings) as a per-method convenience.</summary>
|
||||
private async Task PersistInputsAsync(int methodId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("localStorage.setItem", StorageInputsPrefix + methodId.ToString(CultureInfo.InvariantCulture),
|
||||
JsonSerializer.Serialize(_inputs));
|
||||
JsonSerializer.Serialize(_methodParams));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores a persisted value tree only when it still validates against the method's current
|
||||
/// schema — a stale tree (the schema changed, or the pre-#37 flat string-per-field entry) is
|
||||
/// dropped rather than painted into the form as errors.
|
||||
/// </summary>
|
||||
private async Task RestoreInputsAsync(int methodId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await JS.InvokeAsync<string?>("localStorage.getItem", StorageInputsPrefix + methodId.ToString(CultureInfo.InvariantCulture));
|
||||
if (string.IsNullOrEmpty(json)) return;
|
||||
var saved = JsonSerializer.Deserialize<Dictionary<string, string>>(json);
|
||||
if (saved == null) return;
|
||||
foreach (var (k, v) in saved)
|
||||
if (_inputFields.Any(f => f.Name == k))
|
||||
_inputs[k] = v;
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return;
|
||||
var root = doc.RootElement.Clone();
|
||||
if (ValidateMethodTree(root).Count > 0) return;
|
||||
if (ParameterValueForm.JsonElementToObject(root) is Dictionary<string, object?> tree)
|
||||
_methodParams = tree;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
|
||||
@@ -139,7 +139,7 @@ else
|
||||
/// 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>
|
||||
private static string ToJsonSchema(InboundApiSchema schema)
|
||||
internal static string ToJsonSchema(InboundApiSchema schema)
|
||||
{
|
||||
using var stream = new System.IO.MemoryStream();
|
||||
using (var writer = new System.Text.Json.Utf8JsonWriter(stream))
|
||||
@@ -736,7 +736,7 @@ else
|
||||
private static string JoinField(string path, string field) =>
|
||||
string.IsNullOrEmpty(path) ? field : $"{path}.{field}";
|
||||
|
||||
private static object? JsonElementToObject(JsonElement element)
|
||||
internal static object? JsonElementToObject(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user