refactor(ui/design): replace JSON inputs with structured editors
Two new shared components in Components/Shared:
- ParameterListEditor: table of rows (name + type + item type + required + remove)
- ReturnTypeEditor: single type (+ item type when List)
Both round-trip the same JSON shape already stored on the entity:
parameters: [{"name":"x","type":"String","required":true},...]
return: {"type":"List","itemType":"Integer"} | null
Type set follows the Inbound API validator (Boolean, Integer, Float,
String, Object, List). Legacy values normalize on read — Int32 / int64
/ Double / Decimal / lowercase string / etc all coalesce to the new
set so existing rows render correctly. Re-saving persists the
normalized form.
Applied to:
- SharedScriptForm
- TemplateEdit Add Script form (also surfaces ParameterDefinitions
+ ReturnDefinition which the entity supported but the form was
never wiring through)
- ApiMethodForm
Graceful degradation: invalid JSON is shown with a "Start fresh"
escape hatch instead of crashing the form.
This commit is contained in:
@@ -29,14 +29,12 @@
|
||||
<input type="number" class="form-control" @bind="_timeoutSeconds" min="1" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Params (JSON)</label>
|
||||
<input type="text" class="form-control" @bind="_params"
|
||||
placeholder='[{"name":"id","type":"Int32"}]' />
|
||||
<label class="form-label">Parameters</label>
|
||||
<ParameterListEditor Json="@_params" JsonChanged="@(v => _params = v)" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Returns (JSON)</label>
|
||||
<input type="text" class="form-control" @bind="_returns"
|
||||
placeholder='{"type":"Boolean"}' />
|
||||
<label class="form-label">Return value</label>
|
||||
<ReturnTypeEditor Json="@_returns" JsonChanged="@(v => _returns = v)" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Script</label>
|
||||
|
||||
@@ -29,25 +29,13 @@
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formName"
|
||||
disabled="@(Id.HasValue)" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">
|
||||
Parameters (JSON)
|
||||
<span class="text-muted" title='JSON array of {name, type} objects. Example: [{"name":"id","type":"Int32"},{"name":"label","type":"String"}]'>
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</span>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formParameters"
|
||||
placeholder='e.g. [{"name":"x","type":"Int32"}]' />
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Parameters</label>
|
||||
<ParameterListEditor Json="@_formParameters" JsonChanged="@(v => _formParameters = v)" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">
|
||||
Return Definition (JSON)
|
||||
<span class="text-muted" title='JSON object with a type field. Example: {"type":"Boolean"} or {"type":"List","itemType":"Int32"}'>
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</span>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formReturn"
|
||||
placeholder='e.g. {"type":"Boolean"}' />
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Return value</label>
|
||||
<ReturnTypeEditor Json="@_formReturn" JsonChanged="@(v => _formReturn = v)" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Code</label>
|
||||
|
||||
@@ -85,6 +85,8 @@
|
||||
private string _scriptCode = string.Empty;
|
||||
private string? _scriptTriggerType;
|
||||
private string? _scriptTriggerConfig;
|
||||
private string? _scriptParameters;
|
||||
private string? _scriptReturn;
|
||||
private bool _scriptIsLocked;
|
||||
private string? _scriptFormError;
|
||||
|
||||
@@ -615,7 +617,7 @@
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h5 class="mb-0">Scripts</h5>
|
||||
<button class="btn btn-primary btn-sm" @onclick="() => { _showScriptForm = true; _scriptFormError = null; _scriptName = string.Empty; _scriptCode = string.Empty; _scriptTriggerType = null; _scriptTriggerConfig = null; _scriptIsLocked = false; }">Add Script</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="() => { _showScriptForm = true; _scriptFormError = null; _scriptName = string.Empty; _scriptCode = string.Empty; _scriptTriggerType = null; _scriptTriggerConfig = null; _scriptParameters = null; _scriptReturn = null; _scriptIsLocked = false; }">Add Script</button>
|
||||
</div>
|
||||
|
||||
@if (_showScriptForm)
|
||||
@@ -636,6 +638,14 @@
|
||||
<label class="form-label">Trigger Config (JSON)</label>
|
||||
<input type="text" class="form-control" @bind="_scriptTriggerConfig" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Parameters</label>
|
||||
<ParameterListEditor Json="@_scriptParameters" JsonChanged="@(v => _scriptParameters = v)" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Return value</label>
|
||||
<ReturnTypeEditor Json="@_scriptReturn" JsonChanged="@(v => _scriptReturn = v)" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" @bind="_scriptIsLocked" id="scriptLocked" />
|
||||
@@ -884,6 +894,8 @@
|
||||
{
|
||||
TriggerType = _scriptTriggerType?.Trim(),
|
||||
TriggerConfiguration = _scriptTriggerConfig?.Trim(),
|
||||
ParameterDefinitions = _scriptParameters,
|
||||
ReturnDefinition = _scriptReturn,
|
||||
IsLocked = _scriptIsLocked
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
@namespace ScadaLink.CentralUI.Components.Shared
|
||||
@using System.Text.Json
|
||||
|
||||
@if (_parseError != null)
|
||||
{
|
||||
<div class="alert alert-warning py-2 small mb-2">
|
||||
Could not parse existing parameter JSON: <code>@_parseError</code>
|
||||
<button class="btn btn-link btn-sm p-0 ms-2" type="button" @onclick="StartFresh">Start fresh</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_rows.Count > 0)
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-2">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th style="width: 160px;">Type</th>
|
||||
<th style="width: 160px;">Item type</th>
|
||||
<th class="text-center" style="width: 100px;">Required</th>
|
||||
<th style="width: 50px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var row in _rows)
|
||||
{
|
||||
var r = row;
|
||||
<tr @key="r">
|
||||
<td>
|
||||
<input class="form-control form-control-sm" @bind="r.Name" @bind:event="oninput" @bind:after="Emit"
|
||||
placeholder="e.g. id" aria-label="Parameter name" />
|
||||
</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm" @bind="r.Type" @bind:after="Emit"
|
||||
aria-label="Parameter type">
|
||||
@foreach (var t in Types)
|
||||
{
|
||||
<option value="@t">@t</option>
|
||||
}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
@if (r.Type == "List")
|
||||
{
|
||||
<select class="form-select form-select-sm" @bind="r.ItemType" @bind:after="Emit"
|
||||
aria-label="List item type">
|
||||
@foreach (var t in ItemTypes)
|
||||
{
|
||||
<option value="@t">@t</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<input type="checkbox" class="form-check-input" @bind="r.Required" @bind:after="Emit"
|
||||
aria-label="Required" />
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-danger"
|
||||
@onclick="() => Remove(r)"
|
||||
aria-label="@($"Remove parameter {r.Name}")">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
else if (_parseError == null)
|
||||
{
|
||||
<p class="text-muted small fst-italic mb-2">No parameters defined.</p>
|
||||
}
|
||||
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="Add">+ Add parameter</button>
|
||||
|
||||
@code {
|
||||
[Parameter] public string? Json { get; set; }
|
||||
[Parameter] public EventCallback<string?> JsonChanged { get; set; }
|
||||
|
||||
private static readonly string[] Types = { "Boolean", "Integer", "Float", "String", "Object", "List" };
|
||||
private static readonly string[] ItemTypes = { "Boolean", "Integer", "Float", "String", "Object" };
|
||||
|
||||
private List<ParamRow> _rows = new();
|
||||
private string? _parseError;
|
||||
private string? _lastSeenJson;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (Json != _lastSeenJson)
|
||||
{
|
||||
_lastSeenJson = Json;
|
||||
ParseFromJson();
|
||||
}
|
||||
}
|
||||
|
||||
private void ParseFromJson()
|
||||
{
|
||||
_parseError = null;
|
||||
_rows = new();
|
||||
if (string.IsNullOrWhiteSpace(Json)) return;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(Json);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
_parseError = "Expected a JSON array of parameter objects.";
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var el in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
var name = el.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
||||
var rawType = el.TryGetProperty("type", out var t) ? t.GetString() ?? "String" : "String";
|
||||
var rawItem = el.TryGetProperty("itemType", out var it) ? it.GetString() ?? "String" : "String";
|
||||
var required = !el.TryGetProperty("required", out var rq) || rq.ValueKind != JsonValueKind.False;
|
||||
_rows.Add(new ParamRow
|
||||
{
|
||||
Name = name,
|
||||
Type = NormalizeType(rawType),
|
||||
ItemType = NormalizeType(rawItem),
|
||||
Required = required
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_parseError = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeType(string raw)
|
||||
{
|
||||
if (string.IsNullOrEmpty(raw)) return "String";
|
||||
return raw.ToLowerInvariant() switch
|
||||
{
|
||||
"boolean" or "bool" => "Boolean",
|
||||
"integer" or "int" or "int32" or "int64" or "int16" or "byte" or "sbyte" or "uint32" or "uint64" or "uint16" => "Integer",
|
||||
"float" or "double" or "single" or "decimal" => "Float",
|
||||
"string" or "datetime" => "String",
|
||||
"object" => "Object",
|
||||
"list" => "List",
|
||||
_ => raw
|
||||
};
|
||||
}
|
||||
|
||||
private async Task StartFresh()
|
||||
{
|
||||
_parseError = null;
|
||||
_rows = new();
|
||||
await Emit();
|
||||
}
|
||||
|
||||
private async Task Add()
|
||||
{
|
||||
_rows.Add(new ParamRow { Type = "String", ItemType = "String", Required = true });
|
||||
await Emit();
|
||||
}
|
||||
|
||||
private async Task Remove(ParamRow row)
|
||||
{
|
||||
_rows.Remove(row);
|
||||
await Emit();
|
||||
}
|
||||
|
||||
private async Task Emit()
|
||||
{
|
||||
var json = SerializeToJson();
|
||||
_lastSeenJson = json;
|
||||
await JsonChanged.InvokeAsync(json);
|
||||
}
|
||||
|
||||
private string? SerializeToJson()
|
||||
{
|
||||
if (_rows.Count == 0) return null;
|
||||
var list = new List<Dictionary<string, object>>();
|
||||
foreach (var r in _rows)
|
||||
{
|
||||
var obj = new Dictionary<string, object>
|
||||
{
|
||||
["name"] = r.Name,
|
||||
["type"] = r.Type,
|
||||
};
|
||||
if (r.Type == "List") obj["itemType"] = r.ItemType;
|
||||
if (!r.Required) obj["required"] = false;
|
||||
list.Add(obj);
|
||||
}
|
||||
return JsonSerializer.Serialize(list);
|
||||
}
|
||||
|
||||
private class ParamRow
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string Type { get; set; } = "String";
|
||||
public string ItemType { get; set; } = "String";
|
||||
public bool Required { get; set; } = true;
|
||||
}
|
||||
}
|
||||
117
src/ScadaLink.CentralUI/Components/Shared/ReturnTypeEditor.razor
Normal file
117
src/ScadaLink.CentralUI/Components/Shared/ReturnTypeEditor.razor
Normal file
@@ -0,0 +1,117 @@
|
||||
@namespace ScadaLink.CentralUI.Components.Shared
|
||||
@using System.Text.Json
|
||||
|
||||
@if (_parseError != null)
|
||||
{
|
||||
<div class="alert alert-warning py-2 small mb-2">
|
||||
Could not parse existing return JSON: <code>@_parseError</code>
|
||||
<button class="btn btn-link btn-sm p-0 ms-2" type="button" @onclick="StartFresh">Start fresh</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Type</label>
|
||||
<select class="form-select form-select-sm" @bind="_type" @bind:after="Emit" aria-label="Return type">
|
||||
<option value="">(no return value)</option>
|
||||
@foreach (var t in Types)
|
||||
{
|
||||
<option value="@t">@t</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
@if (_type == "List")
|
||||
{
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Item type</label>
|
||||
<select class="form-select form-select-sm" @bind="_itemType" @bind:after="Emit" aria-label="List item type">
|
||||
@foreach (var t in ItemTypes)
|
||||
{
|
||||
<option value="@t">@t</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string? Json { get; set; }
|
||||
[Parameter] public EventCallback<string?> JsonChanged { get; set; }
|
||||
|
||||
private static readonly string[] Types = { "Boolean", "Integer", "Float", "String", "Object", "List" };
|
||||
private static readonly string[] ItemTypes = { "Boolean", "Integer", "Float", "String", "Object" };
|
||||
|
||||
private string _type = "";
|
||||
private string _itemType = "String";
|
||||
private string? _parseError;
|
||||
private string? _lastSeenJson;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (Json != _lastSeenJson)
|
||||
{
|
||||
_lastSeenJson = Json;
|
||||
ParseFromJson();
|
||||
}
|
||||
}
|
||||
|
||||
private void ParseFromJson()
|
||||
{
|
||||
_parseError = null;
|
||||
_type = "";
|
||||
_itemType = "String";
|
||||
if (string.IsNullOrWhiteSpace(Json)) return;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(Json);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
_parseError = "Expected a JSON object with a type field.";
|
||||
return;
|
||||
}
|
||||
_type = doc.RootElement.TryGetProperty("type", out var t) ? NormalizeType(t.GetString() ?? "") : "";
|
||||
_itemType = doc.RootElement.TryGetProperty("itemType", out var it) ? NormalizeType(it.GetString() ?? "String") : "String";
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_parseError = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeType(string raw)
|
||||
{
|
||||
if (string.IsNullOrEmpty(raw)) return "";
|
||||
return raw.ToLowerInvariant() switch
|
||||
{
|
||||
"boolean" or "bool" => "Boolean",
|
||||
"integer" or "int" or "int32" or "int64" or "int16" or "byte" or "sbyte" or "uint32" or "uint64" or "uint16" => "Integer",
|
||||
"float" or "double" or "single" or "decimal" => "Float",
|
||||
"string" or "datetime" => "String",
|
||||
"object" => "Object",
|
||||
"list" => "List",
|
||||
_ => raw
|
||||
};
|
||||
}
|
||||
|
||||
private async Task StartFresh()
|
||||
{
|
||||
_parseError = null;
|
||||
_type = "";
|
||||
_itemType = "String";
|
||||
await Emit();
|
||||
}
|
||||
|
||||
private async Task Emit()
|
||||
{
|
||||
string? json = null;
|
||||
if (!string.IsNullOrEmpty(_type))
|
||||
{
|
||||
var obj = new Dictionary<string, object> { ["type"] = _type };
|
||||
if (_type == "List") obj["itemType"] = _itemType;
|
||||
json = JsonSerializer.Serialize(obj);
|
||||
}
|
||||
_lastSeenJson = json;
|
||||
await JsonChanged.InvokeAsync(json);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user