Files
ScadaBridge/src/ScadaLink.CentralUI/Components/Shared/SchemaBuilder.razor
T
Joseph Doherty 783da8e21a 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.
2026-05-13 00:33:00 -04:00

208 lines
7.9 KiB
Plaintext

@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)
}
};
}