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,8 @@
|
||||
namespace ScadaLink.CentralUI.Components.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// One option in the alarm trigger editor's attribute picker.
|
||||
/// <see cref="Source"/> is one of "Direct", "Inherited", or "Composed" —
|
||||
/// used to group entries in the dropdown.
|
||||
/// </summary>
|
||||
public record AlarmAttributeChoice(string CanonicalName, string DataType, string Source);
|
||||
@@ -0,0 +1,572 @@
|
||||
@namespace ScadaLink.CentralUI.Components.Shared
|
||||
@using System.Globalization
|
||||
@using System.IO
|
||||
@using System.Text
|
||||
@using System.Text.Json
|
||||
@using ScadaLink.Commons.Types.Enums
|
||||
|
||||
@* Rich alarm trigger configuration editor. Replaces the raw JSON text field
|
||||
used for TemplateAlarm.TriggerConfiguration. The editor emits the same JSON
|
||||
shape that AlarmActor.ParseEvalConfig consumes:
|
||||
ValueMatch { attributeName, matchValue } ("!=X" prefix = not equals)
|
||||
RangeViolation { attributeName, min, max }
|
||||
RateOfChange { attributeName, thresholdPerSecond, windowSeconds, direction } *@
|
||||
|
||||
<div class="border rounded bg-white p-3">
|
||||
|
||||
@* ── Monitored attribute ───────────────────────────────────────────── *@
|
||||
<div class="mb-3">
|
||||
<label for="alarm-attr-select" class="form-label small text-uppercase text-muted fw-semibold mb-1">
|
||||
Monitored attribute
|
||||
</label>
|
||||
|
||||
<div class="input-group input-group-sm">
|
||||
<select id="alarm-attr-select"
|
||||
class="form-select"
|
||||
@bind="_attributeName"
|
||||
@bind:after="OnAttributeChanged">
|
||||
<option value="">— select attribute —</option>
|
||||
@{
|
||||
var groups = AvailableAttributes
|
||||
.GroupBy(c => c.Source)
|
||||
.OrderBy(g => SourceOrder(g.Key))
|
||||
.ToList();
|
||||
}
|
||||
@foreach (var grp in groups)
|
||||
{
|
||||
<optgroup label="@grp.Key">
|
||||
@foreach (var choice in grp.OrderBy(c => c.CanonicalName, StringComparer.Ordinal))
|
||||
{
|
||||
var label = $"{choice.CanonicalName} ({choice.DataType})";
|
||||
var disabled = !IsAttributeCompatible(choice);
|
||||
<option value="@choice.CanonicalName" disabled="@disabled">@label</option>
|
||||
}
|
||||
</optgroup>
|
||||
}
|
||||
@* If the saved attribute name isn't in the current list, keep it selectable so it's visible. *@
|
||||
@if (!string.IsNullOrEmpty(_model.AttributeName) && _selectedChoice == null)
|
||||
{
|
||||
<optgroup label="Unknown">
|
||||
<option value="@_model.AttributeName">@_model.AttributeName (not found)</option>
|
||||
</optgroup>
|
||||
}
|
||||
</select>
|
||||
@if (_selectedDataType is { } dt)
|
||||
{
|
||||
<span class="input-group-text bg-light text-muted small">@dt</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_selectedChoice != null && !IsAttributeCompatible(_selectedChoice))
|
||||
{
|
||||
<div class="form-text text-danger">
|
||||
Selected attribute is @_selectedChoice.DataType — this trigger type requires a numeric attribute.
|
||||
</div>
|
||||
}
|
||||
else if (_selectedChoice == null && !string.IsNullOrWhiteSpace(_model.AttributeName))
|
||||
{
|
||||
<div class="form-text text-warning-emphasis">
|
||||
"@_model.AttributeName" is not in the current template. Save will still write it as-is.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* ── Type-specific block ───────────────────────────────────────────── *@
|
||||
@switch (TriggerType)
|
||||
{
|
||||
case AlarmTriggerType.ValueMatch:
|
||||
@RenderValueMatch();
|
||||
break;
|
||||
case AlarmTriggerType.RangeViolation:
|
||||
@RenderRangeViolation();
|
||||
break;
|
||||
case AlarmTriggerType.RateOfChange:
|
||||
@RenderRateOfChange();
|
||||
break;
|
||||
}
|
||||
|
||||
@* ── Hint ──────────────────────────────────────────────────────────── *@
|
||||
<div class="mt-3 pt-2 border-top small text-muted">
|
||||
@BuildHint()
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
// ── Parameters ─────────────────────────────────────────────────────────
|
||||
|
||||
[Parameter] public AlarmTriggerType TriggerType { get; set; }
|
||||
[Parameter] public string? Value { get; set; }
|
||||
[Parameter] public EventCallback<string?> ValueChanged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Flattened attribute list (direct + inherited + composed). Used to drive
|
||||
/// the picker and to determine the selected attribute's data type for
|
||||
/// type-aware inputs.
|
||||
/// </summary>
|
||||
[Parameter] public IReadOnlyList<AlarmAttributeChoice> AvailableAttributes { get; set; } =
|
||||
Array.Empty<AlarmAttributeChoice>();
|
||||
|
||||
// ── Internal state ─────────────────────────────────────────────────────
|
||||
|
||||
private TriggerModel _model = new();
|
||||
private AlarmTriggerType _lastSeenType;
|
||||
private string? _lastSeenJson;
|
||||
|
||||
/// <summary>The choice currently selected from <see cref="AvailableAttributes"/>, if any.</summary>
|
||||
private AlarmAttributeChoice? _selectedChoice;
|
||||
|
||||
private string? _selectedDataType => _selectedChoice?.DataType;
|
||||
|
||||
// ── Parse / serialize lifecycle ────────────────────────────────────────
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
var typeChanged = _lastSeenType != TriggerType;
|
||||
var jsonChanged = Value != _lastSeenJson;
|
||||
|
||||
if (!typeChanged && !jsonChanged) return;
|
||||
|
||||
_lastSeenType = TriggerType;
|
||||
_lastSeenJson = Value;
|
||||
|
||||
// Preserve attribute name across type changes — re-parse the JSON in
|
||||
// the context of the new type. Missing/unparseable keys fall back to
|
||||
// empty defaults.
|
||||
var preservedAttr = _model.AttributeName;
|
||||
_model = Parse(Value, TriggerType);
|
||||
if (jsonChanged == false && typeChanged && !string.IsNullOrEmpty(preservedAttr))
|
||||
_model.AttributeName = preservedAttr;
|
||||
|
||||
RefreshSelectedChoice();
|
||||
SyncTextMirrors();
|
||||
}
|
||||
|
||||
private void RefreshSelectedChoice()
|
||||
{
|
||||
_selectedChoice = AvailableAttributes.FirstOrDefault(
|
||||
c => string.Equals(c.CanonicalName, _model.AttributeName, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private async Task Emit()
|
||||
{
|
||||
var json = Serialize(_model, TriggerType);
|
||||
_lastSeenJson = json;
|
||||
await ValueChanged.InvokeAsync(json);
|
||||
}
|
||||
|
||||
// ── Attribute picker ───────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// String mirror for the attribute picker — required because @bind needs a
|
||||
/// settable backing field, not a computed expression.
|
||||
/// </summary>
|
||||
private string _attributeName = string.Empty;
|
||||
|
||||
private async Task OnAttributeChanged()
|
||||
{
|
||||
_model.AttributeName = _attributeName;
|
||||
RefreshSelectedChoice();
|
||||
await Emit();
|
||||
}
|
||||
|
||||
private static int SourceOrder(string source) => source switch
|
||||
{
|
||||
"Direct" => 0,
|
||||
"Inherited" => 1,
|
||||
"Composed" => 2,
|
||||
_ => 3
|
||||
};
|
||||
|
||||
private bool IsAttributeCompatible(AlarmAttributeChoice choice) =>
|
||||
TriggerType == AlarmTriggerType.ValueMatch
|
||||
|| IsNumericType(choice.DataType);
|
||||
|
||||
private static bool IsNumericType(string dataType) => dataType switch
|
||||
{
|
||||
"Integer" or "Int32" or "Int64" or "Float" or "Double" or "Number" => true,
|
||||
_ => false
|
||||
};
|
||||
|
||||
// ── ValueMatch ─────────────────────────────────────────────────────────
|
||||
|
||||
private RenderFragment RenderValueMatch() => __builder =>
|
||||
{
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
|
||||
Operator
|
||||
</label>
|
||||
<select class="form-select form-select-sm"
|
||||
@bind="_operatorText"
|
||||
@bind:after="OnOperatorChanged">
|
||||
<option value="eq">equals</option>
|
||||
<option value="ne">not equals</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
|
||||
Match value
|
||||
</label>
|
||||
@{
|
||||
var t = _selectedChoice?.DataType;
|
||||
if (t == "Boolean")
|
||||
{
|
||||
<select class="form-select form-select-sm"
|
||||
@bind="_matchValueText"
|
||||
@bind:after="OnMatchValueChanged">
|
||||
<option value="True">True</option>
|
||||
<option value="False">False</option>
|
||||
</select>
|
||||
}
|
||||
else if (IsNumericType(t ?? ""))
|
||||
{
|
||||
<input type="number" step="any" class="form-control form-control-sm"
|
||||
@bind="_matchValueText"
|
||||
@bind:event="oninput"
|
||||
@bind:after="OnMatchValueChanged" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
placeholder="value"
|
||||
@bind="_matchValueText"
|
||||
@bind:event="oninput"
|
||||
@bind:after="OnMatchValueChanged" />
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
// ── RangeViolation ─────────────────────────────────────────────────────
|
||||
|
||||
private RenderFragment RenderRangeViolation() => __builder =>
|
||||
{
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
|
||||
Minimum
|
||||
</label>
|
||||
<input type="number" step="any" class="form-control form-control-sm"
|
||||
@bind="_minText"
|
||||
@bind:event="oninput"
|
||||
@bind:after="OnMinChanged" />
|
||||
</div>
|
||||
<div class="col-md-2 text-center pb-1 text-muted small">to</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
|
||||
Maximum
|
||||
</label>
|
||||
<input type="number" step="any" class="form-control form-control-sm"
|
||||
@bind="_maxText"
|
||||
@bind:event="oninput"
|
||||
@bind:after="OnMaxChanged" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3" aria-hidden="true">
|
||||
<svg viewBox="0 0 200 12" preserveAspectRatio="none"
|
||||
style="width:100%; height:10px; border-radius:5px; overflow:hidden;">
|
||||
<rect x="0" y="0" width="20" height="12" fill="#f8d7da" />
|
||||
<rect x="20" y="0" width="160" height="12" fill="#d1e7dd" />
|
||||
<rect x="180" y="0" width="20" height="12" fill="#f8d7da" />
|
||||
</svg>
|
||||
<div class="d-flex justify-content-between small text-muted mt-1">
|
||||
<span>alarm</span>
|
||||
<span>normal</span>
|
||||
<span>alarm</span>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
private async Task OnMinChanged()
|
||||
{
|
||||
_model.Min = ParseDouble(_minText);
|
||||
await Emit();
|
||||
}
|
||||
|
||||
private async Task OnMaxChanged()
|
||||
{
|
||||
_model.Max = ParseDouble(_maxText);
|
||||
await Emit();
|
||||
}
|
||||
|
||||
// ── RateOfChange ───────────────────────────────────────────────────────
|
||||
|
||||
private RenderFragment RenderRateOfChange() => __builder =>
|
||||
{
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
|
||||
Rate threshold
|
||||
</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="number" step="any" class="form-control"
|
||||
@bind="_thresholdText"
|
||||
@bind:event="oninput"
|
||||
@bind:after="OnThresholdChanged" />
|
||||
<span class="input-group-text">units / sec</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
|
||||
Sampling window
|
||||
</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="number" step="any" min="0" class="form-control"
|
||||
@bind="_windowText"
|
||||
@bind:event="oninput"
|
||||
@bind:after="OnWindowChanged" />
|
||||
<span class="input-group-text">sec</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 row g-2">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
|
||||
Direction
|
||||
</label>
|
||||
<select class="form-select form-select-sm"
|
||||
@bind="_directionText"
|
||||
@bind:after="OnDirectionChanged">
|
||||
<option value="rising">Rising only</option>
|
||||
<option value="falling">Falling only</option>
|
||||
<option value="either">Either direction</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
private async Task OnThresholdChanged()
|
||||
{
|
||||
_model.ThresholdPerSecond = ParseDouble(_thresholdText);
|
||||
await Emit();
|
||||
}
|
||||
|
||||
private async Task OnWindowChanged()
|
||||
{
|
||||
_model.WindowSeconds = ParseDouble(_windowText);
|
||||
await Emit();
|
||||
}
|
||||
|
||||
private async Task OnDirectionChanged()
|
||||
{
|
||||
_model.Direction = _directionText;
|
||||
await Emit();
|
||||
}
|
||||
|
||||
private string _directionText = "either";
|
||||
|
||||
// ── Text mirrors for typed inputs ──────────────────────────────────────
|
||||
// @bind requires a settable backing field that round-trips text. We keep
|
||||
// these in sync with the model and re-parse on @bind:after.
|
||||
|
||||
private string? _minText;
|
||||
private string? _maxText;
|
||||
private string? _thresholdText;
|
||||
private string? _windowText;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
SyncTextMirrors();
|
||||
}
|
||||
|
||||
private void SyncTextMirrors()
|
||||
{
|
||||
_attributeName = _model.AttributeName ?? string.Empty;
|
||||
_matchValueText = _model.MatchValue ?? string.Empty;
|
||||
_operatorText = _model.NotEquals ? "ne" : "eq";
|
||||
_minText = FormatNullable(_model.Min);
|
||||
_maxText = FormatNullable(_model.Max);
|
||||
_thresholdText = FormatNullable(_model.ThresholdPerSecond);
|
||||
_windowText = FormatNullable(_model.WindowSeconds);
|
||||
_directionText = _model.Direction;
|
||||
}
|
||||
|
||||
private string _operatorText = "eq";
|
||||
private string _matchValueText = string.Empty;
|
||||
|
||||
private async Task OnOperatorChanged()
|
||||
{
|
||||
_model.NotEquals = (_operatorText == "ne");
|
||||
await Emit();
|
||||
}
|
||||
|
||||
private async Task OnMatchValueChanged()
|
||||
{
|
||||
_model.MatchValue = _matchValueText;
|
||||
await Emit();
|
||||
}
|
||||
|
||||
// ── Hint text ──────────────────────────────────────────────────────────
|
||||
|
||||
private string BuildHint()
|
||||
{
|
||||
var attr = string.IsNullOrWhiteSpace(_model.AttributeName)
|
||||
? "the selected attribute"
|
||||
: $"\"{_model.AttributeName}\"";
|
||||
|
||||
return TriggerType switch
|
||||
{
|
||||
AlarmTriggerType.ValueMatch =>
|
||||
$"Triggers when {attr} {(_model.NotEquals ? "is not equal to" : "equals")} \"{_model.MatchValue ?? ""}\".",
|
||||
|
||||
AlarmTriggerType.RangeViolation =>
|
||||
_model.Min.HasValue && _model.Max.HasValue
|
||||
? $"Triggers when {attr} < {Fmt(_model.Min)} or > {Fmt(_model.Max)}."
|
||||
: $"Triggers when {attr} goes outside the configured range.",
|
||||
|
||||
AlarmTriggerType.RateOfChange =>
|
||||
$"Triggers when {attr} changes faster than {Fmt(_model.ThresholdPerSecond) ?? "?"} units/sec ({_model.Direction}) over a {Fmt(_model.WindowSeconds) ?? "?"} sec window.",
|
||||
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static string Fmt(double? v) =>
|
||||
v.HasValue ? v.Value.ToString("0.###", CultureInfo.InvariantCulture) : "";
|
||||
|
||||
private static string FormatNullable(double? v) =>
|
||||
v.HasValue ? v.Value.ToString("R", CultureInfo.InvariantCulture) : "";
|
||||
|
||||
private static double? ParseDouble(string? s) =>
|
||||
double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : null;
|
||||
|
||||
// ── Model + parse/serialize ────────────────────────────────────────────
|
||||
|
||||
private sealed class TriggerModel
|
||||
{
|
||||
public string? AttributeName { get; set; }
|
||||
|
||||
// ValueMatch
|
||||
public string? MatchValue { get; set; }
|
||||
public bool NotEquals { get; set; }
|
||||
|
||||
// RangeViolation
|
||||
public double? Min { get; set; }
|
||||
public double? Max { get; set; }
|
||||
|
||||
// RateOfChange
|
||||
public double? ThresholdPerSecond { get; set; }
|
||||
public double? WindowSeconds { get; set; }
|
||||
public string Direction { get; set; } = "either";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an existing trigger configuration JSON in the context of the
|
||||
/// given trigger type. Returns sensible defaults on parse failure or for
|
||||
/// missing keys.
|
||||
/// </summary>
|
||||
private static TriggerModel Parse(string? json, AlarmTriggerType type)
|
||||
{
|
||||
var model = new TriggerModel();
|
||||
if (string.IsNullOrWhiteSpace(json)) return model;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
model.AttributeName =
|
||||
root.TryGetProperty("attributeName", out var a) ? a.GetString()
|
||||
: root.TryGetProperty("attribute", out var a2) ? a2.GetString()
|
||||
: null;
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case AlarmTriggerType.ValueMatch:
|
||||
{
|
||||
var raw = root.TryGetProperty("matchValue", out var mv) ? mv.GetString()
|
||||
: root.TryGetProperty("value", out var mv2) ? mv2.GetString()
|
||||
: null;
|
||||
if (raw != null && raw.StartsWith("!=", StringComparison.Ordinal))
|
||||
{
|
||||
model.NotEquals = true;
|
||||
model.MatchValue = raw[2..];
|
||||
}
|
||||
else
|
||||
{
|
||||
model.MatchValue = raw;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case AlarmTriggerType.RangeViolation:
|
||||
model.Min = TryReadDouble(root, "min") ?? TryReadDouble(root, "low");
|
||||
model.Max = TryReadDouble(root, "max") ?? TryReadDouble(root, "high");
|
||||
break;
|
||||
|
||||
case AlarmTriggerType.RateOfChange:
|
||||
model.ThresholdPerSecond = TryReadDouble(root, "thresholdPerSecond");
|
||||
model.WindowSeconds = TryReadDouble(root, "windowSeconds");
|
||||
var dir = root.TryGetProperty("direction", out var d) ? d.GetString() : null;
|
||||
model.Direction = NormalizeDirection(dir);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Malformed JSON — fall through with default model.
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
private static string NormalizeDirection(string? raw) => raw?.ToLowerInvariant() switch
|
||||
{
|
||||
"rising" or "up" or "positive" => "rising",
|
||||
"falling" or "down" or "negative" => "falling",
|
||||
_ => "either"
|
||||
};
|
||||
|
||||
private static double? TryReadDouble(JsonElement el, string name)
|
||||
{
|
||||
if (!el.TryGetProperty(name, out var p)) return null;
|
||||
return p.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number => p.GetDouble(),
|
||||
JsonValueKind.String when double.TryParse(p.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var v) => v,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the model to the JSON shape AlarmActor.ParseEvalConfig
|
||||
/// expects. Always writes <c>attributeName</c> (canonical key) and only
|
||||
/// the keys relevant to the current trigger type.
|
||||
/// </summary>
|
||||
private static string Serialize(TriggerModel model, AlarmTriggerType type)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using (var w = new Utf8JsonWriter(stream))
|
||||
{
|
||||
w.WriteStartObject();
|
||||
w.WriteString("attributeName", model.AttributeName ?? "");
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case AlarmTriggerType.ValueMatch:
|
||||
var mv = model.MatchValue ?? "";
|
||||
if (model.NotEquals) mv = "!=" + mv;
|
||||
w.WriteString("matchValue", mv);
|
||||
break;
|
||||
|
||||
case AlarmTriggerType.RangeViolation:
|
||||
if (model.Min.HasValue) w.WriteNumber("min", model.Min.Value);
|
||||
if (model.Max.HasValue) w.WriteNumber("max", model.Max.Value);
|
||||
break;
|
||||
|
||||
case AlarmTriggerType.RateOfChange:
|
||||
if (model.ThresholdPerSecond.HasValue)
|
||||
w.WriteNumber("thresholdPerSecond", model.ThresholdPerSecond.Value);
|
||||
if (model.WindowSeconds.HasValue)
|
||||
w.WriteNumber("windowSeconds", model.WindowSeconds.Value);
|
||||
w.WriteString("direction", model.Direction);
|
||||
break;
|
||||
}
|
||||
|
||||
w.WriteEndObject();
|
||||
}
|
||||
return System.Text.Encoding.UTF8.GetString(stream.ToArray());
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,8 @@
|
||||
else
|
||||
{
|
||||
<label class="form-label">@state.Body</label>
|
||||
<input class="form-control form-control-sm"
|
||||
<input @ref="_promptInputRef"
|
||||
class="form-control form-control-sm"
|
||||
placeholder="@state.Placeholder"
|
||||
value="@_promptValue"
|
||||
@oninput="OnPromptInput" />
|
||||
@@ -50,8 +51,10 @@
|
||||
|
||||
@code {
|
||||
private ElementReference _modalRef;
|
||||
private ElementReference _promptInputRef;
|
||||
private string _promptValue = string.Empty;
|
||||
private DialogState? _lastSeenState;
|
||||
private DialogState? _focusedForState;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
@@ -85,11 +88,26 @@
|
||||
{
|
||||
try { await JS.InvokeVoidAsync("document.body.classList.add", "modal-open"); }
|
||||
catch { /* prerender: no JS — ignore */ }
|
||||
try { await _modalRef.FocusAsync(); }
|
||||
catch { /* element not yet attached: ignore */ }
|
||||
|
||||
// Focus once per opened dialog. Without this guard, every input
|
||||
// keystroke triggers a re-render which would re-focus the modal
|
||||
// element and yank the caret off the prompt input.
|
||||
if (!ReferenceEquals(current, _focusedForState))
|
||||
{
|
||||
_focusedForState = current;
|
||||
try
|
||||
{
|
||||
if (current.Kind == DialogKind.Prompt)
|
||||
await _promptInputRef.FocusAsync();
|
||||
else
|
||||
await _modalRef.FocusAsync();
|
||||
}
|
||||
catch { /* element not yet attached: ignore */ }
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_focusedForState = null;
|
||||
try { await JS.InvokeVoidAsync("document.body.classList.remove", "modal-open"); }
|
||||
catch { /* prerender: no JS — ignore */ }
|
||||
}
|
||||
|
||||
@@ -28,9 +28,9 @@
|
||||
[Parameter] public bool ShowToolbar { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Parameter names declared on the form (from the ParameterListEditor),
|
||||
/// surfaced as completions inside Parameters["..."] literals and used by
|
||||
/// the unknown-key diagnostic.
|
||||
/// Parameter names declared on the form (derived from the SchemaBuilder's
|
||||
/// JSON Schema), surfaced as completions inside Parameters["..."] literals
|
||||
/// and used by the unknown-key diagnostic.
|
||||
/// </summary>
|
||||
[Parameter] public IReadOnlyList<string>? DeclaredParameters { get; set; }
|
||||
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
@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 (_normalized)
|
||||
{
|
||||
<div class="alert alert-info py-2 small mb-2">
|
||||
Some parameter types were normalized to the current type set. Save to persist the canonical form.
|
||||
</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 bool _normalized;
|
||||
private string? _lastSeenJson;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (Json != _lastSeenJson)
|
||||
{
|
||||
_lastSeenJson = Json;
|
||||
ParseFromJson();
|
||||
}
|
||||
}
|
||||
|
||||
private void ParseFromJson()
|
||||
{
|
||||
_parseError = null;
|
||||
_normalized = false;
|
||||
_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;
|
||||
var normType = NormalizeType(rawType);
|
||||
var normItem = NormalizeType(rawItem);
|
||||
if (normType != rawType || (rawType == "List" && normItem != rawItem))
|
||||
{
|
||||
_normalized = true;
|
||||
}
|
||||
_rows.Add(new ParamRow
|
||||
{
|
||||
Name = name,
|
||||
Type = normType,
|
||||
ItemType = normItem,
|
||||
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;
|
||||
_normalized = false;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
@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>
|
||||
}
|
||||
@if (_normalized)
|
||||
{
|
||||
<div class="alert alert-info py-2 small mb-2">
|
||||
Return type was normalized to the current type set. Save to persist the canonical form.
|
||||
</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 bool _normalized;
|
||||
private string? _lastSeenJson;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (Json != _lastSeenJson)
|
||||
{
|
||||
_lastSeenJson = Json;
|
||||
ParseFromJson();
|
||||
}
|
||||
}
|
||||
|
||||
private void ParseFromJson()
|
||||
{
|
||||
_parseError = null;
|
||||
_normalized = false;
|
||||
_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;
|
||||
}
|
||||
var rawType = doc.RootElement.TryGetProperty("type", out var t) ? t.GetString() ?? "" : "";
|
||||
var rawItem = doc.RootElement.TryGetProperty("itemType", out var it) ? it.GetString() ?? "String" : "String";
|
||||
_type = NormalizeType(rawType);
|
||||
_itemType = NormalizeType(rawItem);
|
||||
if (_type != rawType || (rawType == "List" && _itemType != rawItem))
|
||||
{
|
||||
_normalized = true;
|
||||
}
|
||||
}
|
||||
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;
|
||||
_normalized = false;
|
||||
await JsonChanged.InvokeAsync(json);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ScadaLink.CentralUI.Components.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory JSON Schema tree used by <see cref="SchemaBuilder"/>. The editor
|
||||
/// mutates this graph directly; <see cref="SchemaBuilderModel"/> handles
|
||||
/// parse / serialize round-tripping to the canonical JSON Schema text stored
|
||||
/// in TemplateScript / SharedScript / ApiMethod columns.
|
||||
/// </summary>
|
||||
internal sealed class SchemaNode
|
||||
{
|
||||
/// <summary>One of: <c>string · integer · number · boolean · object · array</c>.</summary>
|
||||
public string Type { get; set; } = "string";
|
||||
|
||||
/// <summary>For <c>type=array</c>: the schema of the array's items.</summary>
|
||||
public SchemaNode? Items { get; set; }
|
||||
|
||||
/// <summary>For <c>type=object</c>: ordered list of named properties.</summary>
|
||||
public List<SchemaProperty> Properties { get; } = new();
|
||||
}
|
||||
|
||||
internal sealed class SchemaProperty
|
||||
{
|
||||
/// <summary>Stable identity for Blazor <c>@key</c> across renames.</summary>
|
||||
public Guid Id { get; } = Guid.NewGuid();
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public bool Required { get; set; } = true;
|
||||
public SchemaNode Schema { get; set; } = new();
|
||||
}
|
||||
|
||||
internal static class SchemaBuilderModel
|
||||
{
|
||||
public static readonly string[] PrimitiveTypes =
|
||||
{ "string", "integer", "number", "boolean", "object", "array" };
|
||||
|
||||
/// <summary>
|
||||
/// Parse a JSON Schema string into a <see cref="SchemaNode"/> tree.
|
||||
/// Returns the supplied <paramref name="fallback"/> when the input is
|
||||
/// empty or malformed. Also accepts the legacy flat-array parameter
|
||||
/// shape (<c>[{name,type,required,itemType?}]</c>) for safety during the
|
||||
/// transition window — translates it into an equivalent object schema.
|
||||
/// </summary>
|
||||
public static SchemaNode Parse(string? json, SchemaNode fallback)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return fallback;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return doc.RootElement.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object => ParseSchema(doc.RootElement),
|
||||
JsonValueKind.Array => ParseLegacyArray(doc.RootElement),
|
||||
_ => fallback,
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Default empty object schema (parameters mode default).</summary>
|
||||
public static SchemaNode NewObject() => new() { Type = "object" };
|
||||
|
||||
/// <summary>Default scalar schema (return mode default).</summary>
|
||||
public static SchemaNode NewValue() => new() { Type = "string" };
|
||||
|
||||
public static string Serialize(SchemaNode node)
|
||||
{
|
||||
using var stream = new System.IO.MemoryStream();
|
||||
using (var writer = new Utf8JsonWriter(stream))
|
||||
{
|
||||
WriteNode(writer, node);
|
||||
}
|
||||
return System.Text.Encoding.UTF8.GetString(stream.ToArray());
|
||||
}
|
||||
|
||||
// ── Parse helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
private static SchemaNode ParseSchema(JsonElement el)
|
||||
{
|
||||
var node = new SchemaNode { Type = "string" };
|
||||
if (el.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
node.Type = NormalizeType(t.GetString());
|
||||
}
|
||||
|
||||
if (node.Type == "array")
|
||||
{
|
||||
node.Items = el.TryGetProperty("items", out var items) && items.ValueKind == JsonValueKind.Object
|
||||
? ParseSchema(items)
|
||||
: new SchemaNode { Type = "string" };
|
||||
}
|
||||
else if (node.Type == "object")
|
||||
{
|
||||
var requiredSet = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (el.TryGetProperty("required", out var req) && req.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var r in req.EnumerateArray())
|
||||
{
|
||||
if (r.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var s = r.GetString();
|
||||
if (!string.IsNullOrEmpty(s)) requiredSet.Add(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (el.TryGetProperty("properties", out var props) && props.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in props.EnumerateObject())
|
||||
{
|
||||
node.Properties.Add(new SchemaProperty
|
||||
{
|
||||
Name = prop.Name,
|
||||
Required = requiredSet.Contains(prop.Name),
|
||||
Schema = prop.Value.ValueKind == JsonValueKind.Object
|
||||
? ParseSchema(prop.Value)
|
||||
: new SchemaNode { Type = "string" },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
private static SchemaNode ParseLegacyArray(JsonElement arr)
|
||||
{
|
||||
var root = new SchemaNode { Type = "object" };
|
||||
foreach (var item in arr.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind != JsonValueKind.Object) continue;
|
||||
var name = item.TryGetProperty("name", out var n) ? n.GetString() : null;
|
||||
if (string.IsNullOrEmpty(name)) continue;
|
||||
|
||||
var rawType = item.TryGetProperty("type", out var t) ? t.GetString() : "string";
|
||||
var required = !item.TryGetProperty("required", out var rq) || rq.ValueKind != JsonValueKind.False;
|
||||
var schema = new SchemaNode { Type = NormalizeType(rawType) };
|
||||
if (schema.Type == "array")
|
||||
{
|
||||
var inner = item.TryGetProperty("itemType", out var it) ? it.GetString() : "string";
|
||||
schema.Items = new SchemaNode { Type = NormalizeType(inner) };
|
||||
}
|
||||
root.Properties.Add(new SchemaProperty
|
||||
{
|
||||
Name = name,
|
||||
Required = required,
|
||||
Schema = schema,
|
||||
});
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
private static string NormalizeType(string? raw) => raw?.ToLowerInvariant() switch
|
||||
{
|
||||
"boolean" or "bool" => "boolean",
|
||||
"integer" or "int" or "int32" or "int64" => "integer",
|
||||
"number" or "float" or "double" or "decimal" => "number",
|
||||
"string" or "datetime" => "string",
|
||||
"object" => "object",
|
||||
"array" or "list" => "array",
|
||||
_ => "string",
|
||||
};
|
||||
|
||||
// ── Serialize helpers ─────────────────────────────────────────────────────
|
||||
|
||||
private static void WriteNode(Utf8JsonWriter w, SchemaNode node)
|
||||
{
|
||||
w.WriteStartObject();
|
||||
w.WriteString("type", node.Type);
|
||||
|
||||
if (node.Type == "array")
|
||||
{
|
||||
w.WritePropertyName("items");
|
||||
WriteNode(w, node.Items ?? new SchemaNode { Type = "string" });
|
||||
}
|
||||
else if (node.Type == "object")
|
||||
{
|
||||
w.WritePropertyName("properties");
|
||||
w.WriteStartObject();
|
||||
foreach (var p in node.Properties.Where(p => !string.IsNullOrWhiteSpace(p.Name)))
|
||||
{
|
||||
w.WritePropertyName(p.Name);
|
||||
WriteNode(w, p.Schema);
|
||||
}
|
||||
w.WriteEndObject();
|
||||
|
||||
var required = node.Properties
|
||||
.Where(p => p.Required && !string.IsNullOrWhiteSpace(p.Name))
|
||||
.Select(p => p.Name)
|
||||
.ToArray();
|
||||
if (required.Length > 0)
|
||||
{
|
||||
w.WritePropertyName("required");
|
||||
w.WriteStartArray();
|
||||
foreach (var r in required) w.WriteStringValue(r);
|
||||
w.WriteEndArray();
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteEndObject();
|
||||
}
|
||||
}
|
||||
@@ -1,51 +1,20 @@
|
||||
using System.Text.Json;
|
||||
using ScadaLink.CentralUI.ScriptAnalysis;
|
||||
|
||||
namespace ScadaLink.CentralUI.Components.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Parses the parameter-definitions JSON written by ParameterListEditor and
|
||||
/// Parses the parameter-definitions JSON Schema written by SchemaBuilder and
|
||||
/// returns the declared parameter names (and shapes). Used by script-edit
|
||||
/// pages to feed the Monaco editor's Parameters["..."] context.
|
||||
/// </summary>
|
||||
public static class ScriptParameterNames
|
||||
{
|
||||
public static IReadOnlyList<string> Parse(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<string>();
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Array) return Array.Empty<string>();
|
||||
return doc.RootElement.EnumerateArray()
|
||||
.Select(e => e.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "")
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.ToList();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
public static IReadOnlyList<string> Parse(string? json) =>
|
||||
JsonSchemaShapeParser.ParseParameters(json)
|
||||
.Select(p => p.Name)
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.ToList();
|
||||
|
||||
public static IReadOnlyList<ParameterShape> ParseShapes(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<ParameterShape>();
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Array) return Array.Empty<ParameterShape>();
|
||||
return doc.RootElement.EnumerateArray()
|
||||
.Select(el => new ParameterShape(
|
||||
Name: el.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "",
|
||||
Type: el.TryGetProperty("type", out var t) ? t.GetString() ?? "String" : "String",
|
||||
Required: !el.TryGetProperty("required", out var rq) || rq.ValueKind != JsonValueKind.False))
|
||||
.Where(p => !string.IsNullOrEmpty(p.Name))
|
||||
.ToList();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<ParameterShape>();
|
||||
}
|
||||
}
|
||||
public static IReadOnlyList<ParameterShape> ParseShapes(string? json) =>
|
||||
JsonSchemaShapeParser.ParseParameters(json);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user