feat(ui/templates): structured trigger editor for template scripts
The script add/edit modal exposed a script's trigger as two raw free-text inputs — a type string and hand-written config JSON — with no validation and no parity with the alarm trigger UI. Replace them with a ScriptTriggerEditor component (mirroring AlarmTriggerEditor): a trigger-type dropdown plus type-specific panels for Interval, ValueChange, Conditional, and Call, a grouped attribute picker, and an auto-generated hint. A ScriptTriggerConfigCodec round-trips the TriggerConfiguration JSON the site runtime's ScriptActor consumes, tolerant of legacy keys; an unrecognized stored type is preserved untouched in a read-only panel.
This commit is contained in:
@@ -874,13 +874,12 @@
|
|||||||
<label class="form-label">Name</label>
|
<label class="form-label">Name</label>
|
||||||
<input type="text" class="form-control" @bind="_scriptName" readonly="@editingScript" />
|
<input type="text" class="form-control" @bind="_scriptName" readonly="@editingScript" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-12">
|
||||||
<label class="form-label">Trigger Type</label>
|
<label class="form-label">Trigger</label>
|
||||||
<input type="text" class="form-control" @bind="_scriptTriggerType" placeholder="e.g. ValueChange" />
|
<ScriptTriggerEditor TriggerType="@_scriptTriggerType"
|
||||||
</div>
|
TriggerConfig="@_scriptTriggerConfig"
|
||||||
<div class="col-md-6">
|
Changed="@OnScriptTriggerChanged"
|
||||||
<label class="form-label">Trigger Config (JSON)</label>
|
AvailableAttributes="@BuildAlarmAttributeChoices()" />
|
||||||
<input type="text" class="form-control" @bind="_scriptTriggerConfig" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
@@ -1456,6 +1455,13 @@
|
|||||||
else { _toast.ShowError(result.Error); }
|
else { _toast.ShowError(result.Error); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Applies the structured trigger editor's type + config atomically.</summary>
|
||||||
|
private void OnScriptTriggerChanged(ScriptTriggerValue v)
|
||||||
|
{
|
||||||
|
_scriptTriggerType = v.TriggerType;
|
||||||
|
_scriptTriggerConfig = v.Config;
|
||||||
|
}
|
||||||
|
|
||||||
private void BeginAddScript()
|
private void BeginAddScript()
|
||||||
{
|
{
|
||||||
_showScriptForm = true;
|
_showScriptForm = true;
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.Components.Shared;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which kind of trigger a template script has. <see cref="None"/> is no
|
||||||
|
/// trigger; <see cref="Unknown"/> is a stored trigger-type string the runtime
|
||||||
|
/// does not recognize (preserved as-is by the editor).
|
||||||
|
/// </summary>
|
||||||
|
internal enum ScriptTriggerKind { None, Interval, ValueChange, Conditional, Call, Unknown }
|
||||||
|
|
||||||
|
/// <summary>A script's trigger as the editor emits it: a type string + config JSON.</summary>
|
||||||
|
public sealed record ScriptTriggerValue(string? TriggerType, string? Config);
|
||||||
|
|
||||||
|
/// <summary>Parsed/editable view of a script trigger's configuration.</summary>
|
||||||
|
internal sealed class ScriptTriggerModel
|
||||||
|
{
|
||||||
|
/// <summary>Interval period in milliseconds.</summary>
|
||||||
|
public long? IntervalMs { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Monitored attribute (ValueChange + Conditional).</summary>
|
||||||
|
public string? AttributeName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Comparison operator (Conditional) — one of <see cref="ScriptTriggerConfigCodec.Operators"/>.</summary>
|
||||||
|
public string Operator { get; set; } = ">";
|
||||||
|
|
||||||
|
/// <summary>Comparison threshold (Conditional).</summary>
|
||||||
|
public double? Threshold { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Round-trip codec for a template script's <c>TriggerType</c> +
|
||||||
|
/// <c>TriggerConfiguration</c>, shared by <see cref="ScriptTriggerEditor"/> (UI
|
||||||
|
/// editing) and consumed at the site by <c>ScriptActor.ParseTriggerConfig</c>.
|
||||||
|
/// Serialized config shapes:
|
||||||
|
/// Interval { intervalMs }
|
||||||
|
/// ValueChange { attributeName }
|
||||||
|
/// Conditional { attributeName, operator, threshold }
|
||||||
|
/// Call { }
|
||||||
|
///
|
||||||
|
/// Parsing also accepts the legacy aliases <c>attribute</c> and <c>value</c> so
|
||||||
|
/// older configs survive a round-trip through the editor.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ScriptTriggerConfigCodec
|
||||||
|
{
|
||||||
|
/// <summary>The six comparison operators <c>ScriptActor.EvaluateCondition</c> accepts.</summary>
|
||||||
|
internal static readonly string[] Operators = { ">", ">=", "<", "<=", "==", "!=" };
|
||||||
|
|
||||||
|
/// <summary>Classifies a raw <c>TriggerType</c> string (case-insensitive).</summary>
|
||||||
|
internal static ScriptTriggerKind ParseKind(string? triggerType)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(triggerType)) return ScriptTriggerKind.None;
|
||||||
|
return triggerType.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"interval" => ScriptTriggerKind.Interval,
|
||||||
|
"valuechange" => ScriptTriggerKind.ValueChange,
|
||||||
|
"conditional" => ScriptTriggerKind.Conditional,
|
||||||
|
"call" => ScriptTriggerKind.Call,
|
||||||
|
_ => ScriptTriggerKind.Unknown
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Canonical <c>TriggerType</c> string for a kind; null for None/Unknown.</summary>
|
||||||
|
internal static string? KindToString(ScriptTriggerKind kind) => kind switch
|
||||||
|
{
|
||||||
|
ScriptTriggerKind.Interval => "Interval",
|
||||||
|
ScriptTriggerKind.ValueChange => "ValueChange",
|
||||||
|
ScriptTriggerKind.Conditional => "Conditional",
|
||||||
|
ScriptTriggerKind.Call => "Call",
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a trigger configuration JSON in the context of the given kind.
|
||||||
|
/// Returns a model with default values on null/empty/malformed input or for
|
||||||
|
/// missing keys — never throws.
|
||||||
|
/// </summary>
|
||||||
|
internal static ScriptTriggerModel Parse(string? json, ScriptTriggerKind kind)
|
||||||
|
{
|
||||||
|
var model = new ScriptTriggerModel();
|
||||||
|
if (string.IsNullOrWhiteSpace(json)) return model;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case ScriptTriggerKind.Interval:
|
||||||
|
model.IntervalMs = TryReadLong(root, "intervalMs");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ScriptTriggerKind.ValueChange:
|
||||||
|
model.AttributeName = TryReadAttributeName(root);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ScriptTriggerKind.Conditional:
|
||||||
|
model.AttributeName = TryReadAttributeName(root);
|
||||||
|
var op = root.TryGetProperty("operator", out var o) ? o.GetString() : null;
|
||||||
|
model.Operator = NormalizeOperator(op);
|
||||||
|
model.Threshold = TryReadDouble(root, "threshold") ?? TryReadDouble(root, "value");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// Malformed JSON — fall through with default model.
|
||||||
|
}
|
||||||
|
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serializes the model to the JSON shape <c>ScriptActor.ParseTriggerConfig</c>
|
||||||
|
/// expects. Returns null for None/Unknown (no structured config to emit).
|
||||||
|
/// </summary>
|
||||||
|
internal static string? Serialize(ScriptTriggerModel model, ScriptTriggerKind kind)
|
||||||
|
{
|
||||||
|
if (kind is ScriptTriggerKind.None or ScriptTriggerKind.Unknown) return null;
|
||||||
|
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
using (var w = new Utf8JsonWriter(stream))
|
||||||
|
{
|
||||||
|
w.WriteStartObject();
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case ScriptTriggerKind.Interval:
|
||||||
|
if (model.IntervalMs.HasValue)
|
||||||
|
w.WriteNumber("intervalMs", model.IntervalMs.Value);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ScriptTriggerKind.ValueChange:
|
||||||
|
w.WriteString("attributeName", model.AttributeName ?? "");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ScriptTriggerKind.Conditional:
|
||||||
|
w.WriteString("attributeName", model.AttributeName ?? "");
|
||||||
|
w.WriteString("operator", model.Operator);
|
||||||
|
if (model.Threshold.HasValue)
|
||||||
|
w.WriteNumber("threshold", model.Threshold.Value);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Call → empty object.
|
||||||
|
}
|
||||||
|
w.WriteEndObject();
|
||||||
|
}
|
||||||
|
return Encoding.UTF8.GetString(stream.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns <paramref name="raw"/> if it is a recognized operator, else ">".</summary>
|
||||||
|
internal static string NormalizeOperator(string? raw)
|
||||||
|
{
|
||||||
|
var op = raw?.Trim();
|
||||||
|
return op != null && Array.IndexOf(Operators, op) >= 0 ? op : ">";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? TryReadAttributeName(JsonElement root) =>
|
||||||
|
root.TryGetProperty("attributeName", out var a) ? a.GetString()
|
||||||
|
: root.TryGetProperty("attribute", out var a2) ? a2.GetString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private static long? TryReadLong(JsonElement el, string name)
|
||||||
|
{
|
||||||
|
if (!el.TryGetProperty(name, out var p)) return null;
|
||||||
|
return p.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.Number when p.TryGetInt64(out var i) => i,
|
||||||
|
JsonValueKind.Number => (long)p.GetDouble(),
|
||||||
|
JsonValueKind.String when long.TryParse(
|
||||||
|
p.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) => v,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
@namespace ScadaLink.CentralUI.Components.Shared
|
||||||
|
@using System.Globalization
|
||||||
|
|
||||||
|
@* Structured editor for a template script's trigger. Owns both the trigger-type
|
||||||
|
selector and the type-specific configuration, emitting (via Changed) the
|
||||||
|
canonical TriggerType string + the TriggerConfiguration JSON that
|
||||||
|
ScriptActor.ParseTriggerConfig consumes:
|
||||||
|
Interval { intervalMs }
|
||||||
|
ValueChange { attributeName }
|
||||||
|
Conditional { attributeName, operator, threshold }
|
||||||
|
Call { } *@
|
||||||
|
|
||||||
|
<div class="border rounded bg-white p-3">
|
||||||
|
|
||||||
|
@* ── Trigger type ──────────────────────────────────────────────────── *@
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="script-trigger-type" class="form-label small text-uppercase text-muted fw-semibold mb-1">
|
||||||
|
Trigger type
|
||||||
|
</label>
|
||||||
|
<select id="script-trigger-type" class="form-select form-select-sm"
|
||||||
|
@bind="_kindValue" @bind:after="OnKindChanged">
|
||||||
|
<option value="None">— none (never runs automatically) —</option>
|
||||||
|
<option value="Interval">Interval — run on a fixed timer</option>
|
||||||
|
<option value="ValueChange">Value change — run when an attribute changes</option>
|
||||||
|
<option value="Conditional">Conditional — run when a condition is met</option>
|
||||||
|
<option value="Call">Call — run only when invoked by another script</option>
|
||||||
|
@if (_kind == ScriptTriggerKind.Unknown)
|
||||||
|
{
|
||||||
|
<optgroup label="Unrecognized">
|
||||||
|
<option value="Unknown">@_rawType (unknown)</option>
|
||||||
|
</optgroup>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Type-specific configuration ───────────────────────────────────── *@
|
||||||
|
@switch (_kind)
|
||||||
|
{
|
||||||
|
case ScriptTriggerKind.Interval:
|
||||||
|
@RenderInterval();
|
||||||
|
break;
|
||||||
|
case ScriptTriggerKind.ValueChange:
|
||||||
|
<div class="mb-1">@RenderAttributePicker("Monitored attribute")</div>
|
||||||
|
break;
|
||||||
|
case ScriptTriggerKind.Conditional:
|
||||||
|
@RenderConditional();
|
||||||
|
break;
|
||||||
|
case ScriptTriggerKind.Call:
|
||||||
|
<div class="small text-muted">
|
||||||
|
No automatic trigger — this script runs only when another script
|
||||||
|
invokes it via <code>Instance.CallScript("...")</code>.
|
||||||
|
</div>
|
||||||
|
break;
|
||||||
|
case ScriptTriggerKind.Unknown:
|
||||||
|
<div class="alert alert-warning py-2 small mb-0">
|
||||||
|
Unrecognized trigger type <code>@_rawType</code>. Its stored
|
||||||
|
configuration is shown below and left untouched — pick a known
|
||||||
|
trigger type above to reconfigure it.
|
||||||
|
<pre class="bg-light border rounded p-2 mt-2 mb-0">@(string.IsNullOrWhiteSpace(TriggerConfig) ? "(empty)" : TriggerConfig)</pre>
|
||||||
|
</div>
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ── Hint ──────────────────────────────────────────────────────────── *@
|
||||||
|
@if (_kind is ScriptTriggerKind.Interval or ScriptTriggerKind.ValueChange or ScriptTriggerKind.Conditional)
|
||||||
|
{
|
||||||
|
<div class="mt-3 pt-2 border-top small text-muted">@BuildHint()</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
// ── Parameters ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Parameter] public string? TriggerType { get; set; }
|
||||||
|
[Parameter] public string? TriggerConfig { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Raised whenever the type or config changes — emits both atomically.</summary>
|
||||||
|
[Parameter] public EventCallback<ScriptTriggerValue> Changed { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Flattened attribute list (direct + inherited + composed) for the picker.</summary>
|
||||||
|
[Parameter] public IReadOnlyList<AlarmAttributeChoice> AvailableAttributes { get; set; } =
|
||||||
|
Array.Empty<AlarmAttributeChoice>();
|
||||||
|
|
||||||
|
// ── Internal state ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private ScriptTriggerKind _kind;
|
||||||
|
private string _kindValue = "None";
|
||||||
|
private string? _rawType;
|
||||||
|
private ScriptTriggerModel _model = new();
|
||||||
|
|
||||||
|
// Last type/config seen on Parameters — distinguishes an external change
|
||||||
|
// (re-parse) from this component's own echo (skip).
|
||||||
|
private bool _seen;
|
||||||
|
private string? _lastType;
|
||||||
|
private string? _lastConfig;
|
||||||
|
|
||||||
|
// Text mirrors — @bind needs settable backing fields; kept in sync with the
|
||||||
|
// model so blank inputs round-trip blank rather than as 0.
|
||||||
|
private string _attributeName = string.Empty;
|
||||||
|
private string _operator = ">";
|
||||||
|
private string? _thresholdText;
|
||||||
|
private string? _intervalText;
|
||||||
|
private string _intervalUnit = "ms";
|
||||||
|
|
||||||
|
// ── Parse / serialize lifecycle ────────────────────────────────────────
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
if (_seen && _lastType == TriggerType && _lastConfig == TriggerConfig) return;
|
||||||
|
_seen = true;
|
||||||
|
_lastType = TriggerType;
|
||||||
|
_lastConfig = TriggerConfig;
|
||||||
|
|
||||||
|
_rawType = TriggerType;
|
||||||
|
_kind = ScriptTriggerConfigCodec.ParseKind(TriggerType);
|
||||||
|
_kindValue = _kind.ToString();
|
||||||
|
_model = ScriptTriggerConfigCodec.Parse(TriggerConfig, _kind);
|
||||||
|
SyncMirrors();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SyncMirrors()
|
||||||
|
{
|
||||||
|
_attributeName = _model.AttributeName ?? string.Empty;
|
||||||
|
_operator = _model.Operator;
|
||||||
|
_thresholdText = _model.Threshold?.ToString("R", CultureInfo.InvariantCulture);
|
||||||
|
(_intervalText, _intervalUnit) = SplitInterval(_model.IntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Chooses the largest whole unit (min/sec/ms) that represents the period exactly.</summary>
|
||||||
|
private static (string?, string) SplitInterval(long? ms)
|
||||||
|
{
|
||||||
|
if (ms is not { } v) return (null, "ms");
|
||||||
|
if (v >= 60000 && v % 60000 == 0) return ((v / 60000).ToString(CultureInfo.InvariantCulture), "min");
|
||||||
|
if (v >= 1000 && v % 1000 == 0) return ((v / 1000).ToString(CultureInfo.InvariantCulture), "sec");
|
||||||
|
return (v.ToString(CultureInfo.InvariantCulture), "ms");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long UnitFactor(string unit) => unit switch
|
||||||
|
{
|
||||||
|
"min" => 60000,
|
||||||
|
"sec" => 1000,
|
||||||
|
_ => 1
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Serializes the current model and raises <see cref="Changed"/> once.</summary>
|
||||||
|
private async Task Emit()
|
||||||
|
{
|
||||||
|
var type = ScriptTriggerConfigCodec.KindToString(_kind);
|
||||||
|
var config = ScriptTriggerConfigCodec.Serialize(_model, _kind);
|
||||||
|
_lastType = type;
|
||||||
|
_lastConfig = config;
|
||||||
|
await Changed.InvokeAsync(new ScriptTriggerValue(type, config));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Trigger type ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task OnKindChanged()
|
||||||
|
{
|
||||||
|
if (!Enum.TryParse<ScriptTriggerKind>(_kindValue, out var newKind)
|
||||||
|
|| newKind == ScriptTriggerKind.Unknown)
|
||||||
|
{
|
||||||
|
_kindValue = _kind.ToString();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carry the attribute name across a ValueChange <-> Conditional switch.
|
||||||
|
var preservedAttr = _model.AttributeName;
|
||||||
|
_kind = newKind;
|
||||||
|
_model = new ScriptTriggerModel();
|
||||||
|
if (newKind is ScriptTriggerKind.ValueChange or ScriptTriggerKind.Conditional)
|
||||||
|
_model.AttributeName = preservedAttr;
|
||||||
|
SyncMirrors();
|
||||||
|
await Emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Interval ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private RenderFragment RenderInterval() => __builder =>
|
||||||
|
{
|
||||||
|
<div class="row g-2 align-items-end" style="max-width: 420px;">
|
||||||
|
<div class="col-7">
|
||||||
|
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">Run every</label>
|
||||||
|
<input type="number" min="1" step="1" class="form-control form-control-sm"
|
||||||
|
placeholder="period"
|
||||||
|
@bind="_intervalText" @bind:event="oninput" @bind:after="OnIntervalChanged" />
|
||||||
|
</div>
|
||||||
|
<div class="col-5">
|
||||||
|
<select class="form-select form-select-sm"
|
||||||
|
@bind="_intervalUnit" @bind:after="OnIntervalChanged">
|
||||||
|
<option value="ms">milliseconds</option>
|
||||||
|
<option value="sec">seconds</option>
|
||||||
|
<option value="min">minutes</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
};
|
||||||
|
|
||||||
|
private async Task OnIntervalChanged()
|
||||||
|
{
|
||||||
|
_model.IntervalMs =
|
||||||
|
long.TryParse(_intervalText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) && n > 0
|
||||||
|
? n * UnitFactor(_intervalUnit)
|
||||||
|
: null;
|
||||||
|
await Emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Conditional ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private RenderFragment RenderConditional() => __builder =>
|
||||||
|
{
|
||||||
|
<div class="mb-2">@RenderAttributePicker("Monitored attribute")</div>
|
||||||
|
<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">Operator</label>
|
||||||
|
<select class="form-select form-select-sm"
|
||||||
|
@bind="_operator" @bind:after="OnOperatorChanged">
|
||||||
|
@foreach (var op in ScriptTriggerConfigCodec.Operators)
|
||||||
|
{
|
||||||
|
<option value="@op">@op — @OperatorLabel(op)</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-7">
|
||||||
|
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">Threshold</label>
|
||||||
|
<input type="number" step="any" class="form-control form-control-sm"
|
||||||
|
placeholder="numeric value"
|
||||||
|
@bind="_thresholdText" @bind:event="oninput" @bind:after="OnThresholdChanged" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
};
|
||||||
|
|
||||||
|
private async Task OnOperatorChanged()
|
||||||
|
{
|
||||||
|
_model.Operator = ScriptTriggerConfigCodec.NormalizeOperator(_operator);
|
||||||
|
await Emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnThresholdChanged()
|
||||||
|
{
|
||||||
|
_model.Threshold =
|
||||||
|
double.TryParse(_thresholdText, NumberStyles.Float, CultureInfo.InvariantCulture, out var v)
|
||||||
|
? v
|
||||||
|
: null;
|
||||||
|
await Emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Attribute picker (ValueChange + Conditional) ───────────────────────
|
||||||
|
|
||||||
|
private RenderFragment RenderAttributePicker(string label) => __builder =>
|
||||||
|
{
|
||||||
|
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">@label</label>
|
||||||
|
<select class="form-select form-select-sm"
|
||||||
|
@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))
|
||||||
|
{
|
||||||
|
<option value="@choice.CanonicalName">@choice.CanonicalName (@choice.DataType)</option>
|
||||||
|
}
|
||||||
|
</optgroup>
|
||||||
|
}
|
||||||
|
@* Keep a saved-but-missing attribute selectable so it stays visible. *@
|
||||||
|
@if (!string.IsNullOrEmpty(_attributeName)
|
||||||
|
&& !AvailableAttributes.Any(c => string.Equals(c.CanonicalName, _attributeName, StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
<optgroup label="Unknown">
|
||||||
|
<option value="@_attributeName">@_attributeName (not on this template)</option>
|
||||||
|
</optgroup>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
};
|
||||||
|
|
||||||
|
private async Task OnAttributeChanged()
|
||||||
|
{
|
||||||
|
_model.AttributeName = _attributeName;
|
||||||
|
await Emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int SourceOrder(string source) => source switch
|
||||||
|
{
|
||||||
|
"Direct" => 0,
|
||||||
|
"Inherited" => 1,
|
||||||
|
"Composed" => 2,
|
||||||
|
_ => 3
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Hint ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private string BuildHint()
|
||||||
|
{
|
||||||
|
var attr = string.IsNullOrWhiteSpace(_model.AttributeName)
|
||||||
|
? "the selected attribute"
|
||||||
|
: $"\"{_model.AttributeName}\"";
|
||||||
|
|
||||||
|
return _kind switch
|
||||||
|
{
|
||||||
|
ScriptTriggerKind.Interval =>
|
||||||
|
_model.IntervalMs is { } ms
|
||||||
|
? $"Runs every {_intervalText} {UnitLabel(_intervalUnit)} ({ms} ms)."
|
||||||
|
: "Runs on a fixed timer — set the period above.",
|
||||||
|
|
||||||
|
ScriptTriggerKind.ValueChange =>
|
||||||
|
$"Runs whenever {attr} changes value.",
|
||||||
|
|
||||||
|
ScriptTriggerKind.Conditional =>
|
||||||
|
_model.Threshold is { } t
|
||||||
|
? $"Runs when {attr} changes, if {attr} {_model.Operator} {t.ToString("0.###", CultureInfo.InvariantCulture)}."
|
||||||
|
: $"Runs when {attr} changes and meets the configured condition — set a threshold above.",
|
||||||
|
|
||||||
|
_ => string.Empty
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string UnitLabel(string unit) => unit switch
|
||||||
|
{
|
||||||
|
"min" => "minute(s)",
|
||||||
|
"sec" => "second(s)",
|
||||||
|
_ => "millisecond(s)"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string OperatorLabel(string op) => op switch
|
||||||
|
{
|
||||||
|
">" => "greater than",
|
||||||
|
">=" => "at least",
|
||||||
|
"<" => "less than",
|
||||||
|
"<=" => "at most",
|
||||||
|
"==" => "equals",
|
||||||
|
"!=" => "not equal",
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user