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:
Joseph Doherty
2026-09-10 14:54:21 -04:00
parent 3b1211d79f
commit b5eb08f1a9
7 changed files with 422 additions and 94 deletions
@@ -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. *@
@@ -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
{