feat(ui): structured editors for script schemas and alarm triggers
Replace raw-JSON text inputs with rich UI: script parameter/return types use a JSON Schema builder (SchemaBuilder + JsonSchemaShapeParser, with a migration to convert existing definitions); alarm trigger config uses a type-aware editor with a flattened attribute picker (AlarmTriggerEditor). AlarmActor gains optional direction (rising/falling/either) on RateOfChange triggers.
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
@namespace ScadaLink.CentralUI.Components.Shared
|
||||
|
||||
@* Bootstrap-only JSON Schema editor. Two modes:
|
||||
- "object" parameters: edits a top-level object schema (named properties).
|
||||
- "value" return type: edits a single value schema; object/array fall back
|
||||
to the same property editor as Mode=object.
|
||||
Recurses through methods (not nested components) so we stay in one file. *@
|
||||
|
||||
@if (_root.Type == "object" && Mode == "object")
|
||||
{
|
||||
@PropertyList(_root, isRoot: true)
|
||||
}
|
||||
else
|
||||
{
|
||||
@ValueRoot(_root)
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary><c>"object"</c> for parameters, <c>"value"</c> for return type.</summary>
|
||||
[Parameter] public string Mode { get; set; } = "object";
|
||||
|
||||
/// <summary>JSON Schema text. Empty/null seeds the mode's default.</summary>
|
||||
[Parameter] public string? Value { get; set; }
|
||||
|
||||
[Parameter] public EventCallback<string?> ValueChanged { get; set; }
|
||||
|
||||
private SchemaNode _root = new();
|
||||
private string? _lastSeenJson;
|
||||
private bool _initialized;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// OnInitialized fires before this on first mount; OnParametersSet runs
|
||||
// on every parameter change. Guard against the initial null==null case
|
||||
// where the early-exit would skip applying the mode-appropriate default.
|
||||
if (_initialized && Value == _lastSeenJson) return;
|
||||
_initialized = true;
|
||||
_lastSeenJson = Value;
|
||||
_root = SchemaBuilderModel.Parse(
|
||||
Value,
|
||||
Mode == "object" ? SchemaBuilderModel.NewObject() : SchemaBuilderModel.NewValue());
|
||||
}
|
||||
|
||||
private async Task Emit()
|
||||
{
|
||||
var json = SchemaBuilderModel.Serialize(_root);
|
||||
_lastSeenJson = json;
|
||||
await ValueChanged.InvokeAsync(json);
|
||||
}
|
||||
|
||||
private async Task OnTypeChange(SchemaNode node)
|
||||
{
|
||||
if (node.Type == "array" && node.Items == null)
|
||||
node.Items = new SchemaNode { Type = "string" };
|
||||
await Emit();
|
||||
}
|
||||
|
||||
private async Task AddProperty(SchemaNode parent)
|
||||
{
|
||||
parent.Properties.Add(new SchemaProperty { Schema = new SchemaNode { Type = "string" } });
|
||||
await Emit();
|
||||
}
|
||||
|
||||
private async Task RemoveProperty(SchemaNode parent, SchemaProperty prop)
|
||||
{
|
||||
parent.Properties.Remove(prop);
|
||||
await Emit();
|
||||
}
|
||||
|
||||
// ── Render helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Renders the property list for an object schema node. <paramref name="isRoot"/>
|
||||
/// just tweaks the wording on the Add button ("parameter" at root vs "field"
|
||||
/// inside a nested object).
|
||||
/// </summary>
|
||||
private RenderFragment PropertyList(SchemaNode node, bool isRoot = false) => __builder =>
|
||||
{
|
||||
<div class="border rounded bg-white p-2">
|
||||
@if (node.Properties.Count == 0)
|
||||
{
|
||||
<div class="text-muted small fst-italic px-1 py-2">
|
||||
@(isRoot ? "No parameters defined." : "No fields defined.")
|
||||
</div>
|
||||
}
|
||||
@foreach (var prop in node.Properties)
|
||||
{
|
||||
<div @key="prop.Id" class="border rounded p-2 mb-2 bg-light-subtle">
|
||||
@PropertyRow(node, prop)
|
||||
@NestedEditor(prop.Schema)
|
||||
</div>
|
||||
}
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="() => AddProperty(node)">
|
||||
+ Add @(isRoot ? "parameter" : "field")
|
||||
</button>
|
||||
</div>
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// One property's compact horizontal row: name, type, (items type if array),
|
||||
/// required toggle, remove button. Nested object / array-of-object editors
|
||||
/// render below the row via <see cref="NestedEditor"/>.
|
||||
/// </summary>
|
||||
private RenderFragment PropertyRow(SchemaNode parent, SchemaProperty prop) => __builder =>
|
||||
{
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
style="max-width: 14rem;" placeholder="name"
|
||||
@bind="prop.Name" @bind:event="oninput" @bind:after="Emit" />
|
||||
|
||||
<select class="form-select form-select-sm" style="max-width: 9rem;"
|
||||
@bind="prop.Schema.Type" @bind:after="() => OnTypeChange(prop.Schema)">
|
||||
@foreach (var t in SchemaBuilderModel.PrimitiveTypes)
|
||||
{
|
||||
<option value="@t">@t</option>
|
||||
}
|
||||
</select>
|
||||
|
||||
@if (prop.Schema.Type == "array")
|
||||
{
|
||||
<span class="small text-muted">items:</span>
|
||||
<select class="form-select form-select-sm" style="max-width: 9rem;"
|
||||
@bind="prop.Schema.Items!.Type" @bind:after="() => OnTypeChange(prop.Schema.Items!)">
|
||||
@foreach (var t in SchemaBuilderModel.PrimitiveTypes)
|
||||
{
|
||||
<option value="@t">@t</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
|
||||
<div class="form-check form-check-inline mb-0">
|
||||
<input class="form-check-input" type="checkbox" id="req-@prop.Id"
|
||||
@bind="prop.Required" @bind:after="Emit" />
|
||||
<label class="form-check-label small" for="req-@prop.Id">required</label>
|
||||
</div>
|
||||
|
||||
<button type="button"
|
||||
class="btn btn-link btn-sm text-danger p-0 ms-auto"
|
||||
title="Remove" aria-label="Remove field"
|
||||
@onclick="() => RemoveProperty(parent, prop)">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Renders the indented sub-editor for object / array-of-object properties.
|
||||
/// No-op for scalar properties.
|
||||
/// </summary>
|
||||
private RenderFragment NestedEditor(SchemaNode schema) => __builder =>
|
||||
{
|
||||
if (schema.Type == "object")
|
||||
{
|
||||
<div class="ms-3 mt-2">
|
||||
@PropertyList(schema)
|
||||
</div>
|
||||
}
|
||||
else if (schema.Type == "array" && schema.Items?.Type == "object")
|
||||
{
|
||||
<div class="ms-3 mt-2">
|
||||
<div class="small text-muted mb-1">item properties:</div>
|
||||
@PropertyList(schema.Items)
|
||||
</div>
|
||||
}
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Mode=value root: a single type picker. When the user picks <c>object</c>
|
||||
/// or <c>array</c> we expose the same nested editors used by Mode=object.
|
||||
/// </summary>
|
||||
private RenderFragment ValueRoot(SchemaNode node) => __builder =>
|
||||
{
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
|
||||
<label class="form-label mb-0">Return type:</label>
|
||||
<select class="form-select form-select-sm" style="max-width: 10rem;"
|
||||
@bind="node.Type" @bind:after="() => OnTypeChange(node)">
|
||||
@foreach (var t in SchemaBuilderModel.PrimitiveTypes)
|
||||
{
|
||||
<option value="@t">@t</option>
|
||||
}
|
||||
</select>
|
||||
|
||||
@if (node.Type == "array")
|
||||
{
|
||||
<label class="form-label mb-0 ms-2">Item type:</label>
|
||||
<select class="form-select form-select-sm" style="max-width: 10rem;"
|
||||
@bind="node.Items!.Type" @bind:after="() => OnTypeChange(node.Items!)">
|
||||
@foreach (var t in SchemaBuilderModel.PrimitiveTypes)
|
||||
{
|
||||
<option value="@t">@t</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (node.Type == "object")
|
||||
{
|
||||
<div class="text-muted small mb-1">Properties of return value:</div>
|
||||
@PropertyList(node)
|
||||
}
|
||||
else if (node.Type == "array" && node.Items?.Type == "object")
|
||||
{
|
||||
<div class="text-muted small mb-1">Item properties:</div>
|
||||
@PropertyList(node.Items)
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user