feat(ui/triggers): expression trigger panel in the script & alarm editors

This commit is contained in:
Joseph Doherty
2026-05-16 05:46:27 -04:00
parent 78b10d00d8
commit 3499d76f14
3 changed files with 117 additions and 1 deletions

View File

@@ -12,6 +12,10 @@
<div class="border rounded bg-white p-3">
@* ── Monitored attribute ───────────────────────────────────────────── *@
@* Expression triggers reference attributes inside the C# expression itself,
so they do not use the single-attribute picker. *@
@if (TriggerType != AlarmTriggerType.Expression)
{
<div class="mb-3">
<label for="alarm-attr-select" class="form-label small text-uppercase text-muted fw-semibold mb-1">
Monitored attribute
@@ -67,6 +71,7 @@
</div>
}
</div>
}
@* ── Type-specific block ───────────────────────────────────────────── *@
@switch (TriggerType)
@@ -83,6 +88,9 @@
case AlarmTriggerType.HiLo:
@RenderHiLo();
break;
case AlarmTriggerType.Expression:
@RenderExpression();
break;
}
@* ── Hint ──────────────────────────────────────────────────────────── *@
@@ -559,6 +567,30 @@
await Emit();
}
// ── Expression ─────────────────────────────────────────────────────────
private RenderFragment RenderExpression() => __builder =>
{
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">Trigger expression</label>
<MonacoEditor Height="120px"
Language="csharp"
ScriptKind="ScriptAnalysis.ScriptKind.Template"
ShowToolbar="false"
Value="@(_model.Expression ?? string.Empty)"
ValueChanged="OnExpressionChanged"
SelfAttributes="@TriggerAttributeMapper.SelfAttributes(AvailableAttributes)"
Children="@TriggerAttributeMapper.Children(AvailableAttributes)" />
<div class="form-text">
A boolean C# expression — e.g. <code>Attributes["Temperature"] &gt; 80</code>.
</div>
};
private async Task OnExpressionChanged(string value)
{
_model.Expression = value;
await Emit();
}
// ── Hint text ──────────────────────────────────────────────────────────
private string BuildHint()
@@ -582,6 +614,9 @@
AlarmTriggerType.HiLo => BuildHiLoHint(attr),
AlarmTriggerType.Expression =>
"Alarm is active while this expression is true.",
_ => string.Empty
};
}

View File

@@ -23,6 +23,7 @@
<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="Expression">Expression — run when a boolean expression becomes true</option>
<option value="Call">Call — run only when invoked by another script</option>
@if (_kind == ScriptTriggerKind.Unknown)
{
@@ -45,6 +46,9 @@
case ScriptTriggerKind.Conditional:
@RenderConditional();
break;
case ScriptTriggerKind.Expression:
@RenderExpression();
break;
case ScriptTriggerKind.Call:
<div class="small text-muted">
No automatic trigger — this script runs only when another script
@@ -62,7 +66,8 @@
}
@* ── Hint ──────────────────────────────────────────────────────────── *@
@if (_kind is ScriptTriggerKind.Interval or ScriptTriggerKind.ValueChange or ScriptTriggerKind.Conditional)
@if (_kind is ScriptTriggerKind.Interval or ScriptTriggerKind.ValueChange
or ScriptTriggerKind.Conditional or ScriptTriggerKind.Expression)
{
<div class="mt-3 pt-2 border-top small text-muted">@BuildHint()</div>
}
@@ -244,6 +249,30 @@
await Emit();
}
// ── Expression ─────────────────────────────────────────────────────────
private RenderFragment RenderExpression() => __builder =>
{
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">Trigger expression</label>
<MonacoEditor Height="120px"
Language="csharp"
ScriptKind="ScriptAnalysis.ScriptKind.Template"
ShowToolbar="false"
Value="@(_model.Expression ?? string.Empty)"
ValueChanged="OnExpressionChanged"
SelfAttributes="@TriggerAttributeMapper.SelfAttributes(AvailableAttributes)"
Children="@TriggerAttributeMapper.Children(AvailableAttributes)" />
<div class="form-text">
A boolean C# expression — e.g. <code>Attributes["Temperature"] &gt; 80</code>.
</div>
};
private async Task OnExpressionChanged(string value)
{
_model.Expression = value;
await Emit();
}
// ── Attribute picker (ValueChange + Conditional) ───────────────────────
private RenderFragment RenderAttributePicker(string label) => __builder =>
@@ -315,6 +344,9 @@
? $"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.",
ScriptTriggerKind.Expression =>
"Runs once each time this expression becomes true.",
_ => string.Empty
};
}

View File

@@ -0,0 +1,49 @@
using ScadaLink.CentralUI.ScriptAnalysis;
namespace ScadaLink.CentralUI.Components.Shared;
/// <summary>
/// Maps the trigger editors' flattened <see cref="AlarmAttributeChoice"/> list
/// into the metadata the <see cref="MonacoEditor"/> uses to drive C# completion
/// inside an expression trigger:
/// <list type="bullet">
/// <item>Direct + Inherited choices become <see cref="AttributeShape"/>s,
/// surfaced under <c>Attributes["..."]</c>.</item>
/// <item>Composed choices — whose canonical name is dotted, e.g.
/// <c>CoolingTank.Temp</c> — are grouped by their composition-instance prefix
/// into <see cref="CompositionContext"/>s, surfaced under
/// <c>Children["..."].Attributes["..."]</c>.</item>
/// </list>
/// </summary>
public static class TriggerAttributeMapper
{
/// <summary>Direct and inherited attributes, exposed as <c>Attributes["..."]</c>.</summary>
public static IReadOnlyList<AttributeShape> SelfAttributes(
IReadOnlyList<AlarmAttributeChoice> choices) =>
choices
.Where(c => c.Source is "Direct" or "Inherited")
.Select(c => new AttributeShape(c.CanonicalName, c.DataType))
.ToList();
/// <summary>
/// Composed attributes grouped by composition-instance name, exposed as
/// <c>Children["X"].Attributes["Y"]</c>. Entries without a dotted prefix
/// are skipped (no child scope to attach them to).
/// </summary>
public static IReadOnlyList<CompositionContext> Children(
IReadOnlyList<AlarmAttributeChoice> choices) =>
choices
.Where(c => c.Source == "Composed" && c.CanonicalName.Contains('.'))
.Select(c => new
{
Child = c.CanonicalName[..c.CanonicalName.IndexOf('.')],
Member = c.CanonicalName[(c.CanonicalName.IndexOf('.') + 1)..],
c.DataType
})
.GroupBy(x => x.Child, StringComparer.Ordinal)
.Select(g => new CompositionContext(
g.Key,
g.Select(x => new AttributeShape(x.Member, x.DataType)).ToList(),
Array.Empty<ScriptShape>()))
.ToList();
}