feat(ui/triggers): expression trigger panel in the script & alarm editors
This commit is contained in:
@@ -12,6 +12,10 @@
|
|||||||
<div class="border rounded bg-white p-3">
|
<div class="border rounded bg-white p-3">
|
||||||
|
|
||||||
@* ── Monitored attribute ───────────────────────────────────────────── *@
|
@* ── 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">
|
<div class="mb-3">
|
||||||
<label for="alarm-attr-select" class="form-label small text-uppercase text-muted fw-semibold mb-1">
|
<label for="alarm-attr-select" class="form-label small text-uppercase text-muted fw-semibold mb-1">
|
||||||
Monitored attribute
|
Monitored attribute
|
||||||
@@ -67,6 +71,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@* ── Type-specific block ───────────────────────────────────────────── *@
|
@* ── Type-specific block ───────────────────────────────────────────── *@
|
||||||
@switch (TriggerType)
|
@switch (TriggerType)
|
||||||
@@ -83,6 +88,9 @@
|
|||||||
case AlarmTriggerType.HiLo:
|
case AlarmTriggerType.HiLo:
|
||||||
@RenderHiLo();
|
@RenderHiLo();
|
||||||
break;
|
break;
|
||||||
|
case AlarmTriggerType.Expression:
|
||||||
|
@RenderExpression();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@* ── Hint ──────────────────────────────────────────────────────────── *@
|
@* ── Hint ──────────────────────────────────────────────────────────── *@
|
||||||
@@ -559,6 +567,30 @@
|
|||||||
await Emit();
|
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"] > 80</code>.
|
||||||
|
</div>
|
||||||
|
};
|
||||||
|
|
||||||
|
private async Task OnExpressionChanged(string value)
|
||||||
|
{
|
||||||
|
_model.Expression = value;
|
||||||
|
await Emit();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Hint text ──────────────────────────────────────────────────────────
|
// ── Hint text ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private string BuildHint()
|
private string BuildHint()
|
||||||
@@ -582,6 +614,9 @@
|
|||||||
|
|
||||||
AlarmTriggerType.HiLo => BuildHiLoHint(attr),
|
AlarmTriggerType.HiLo => BuildHiLoHint(attr),
|
||||||
|
|
||||||
|
AlarmTriggerType.Expression =>
|
||||||
|
"Alarm is active while this expression is true.",
|
||||||
|
|
||||||
_ => string.Empty
|
_ => string.Empty
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
<option value="Interval">Interval — run on a fixed timer</option>
|
<option value="Interval">Interval — run on a fixed timer</option>
|
||||||
<option value="ValueChange">Value change — run when an attribute changes</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="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>
|
<option value="Call">Call — run only when invoked by another script</option>
|
||||||
@if (_kind == ScriptTriggerKind.Unknown)
|
@if (_kind == ScriptTriggerKind.Unknown)
|
||||||
{
|
{
|
||||||
@@ -45,6 +46,9 @@
|
|||||||
case ScriptTriggerKind.Conditional:
|
case ScriptTriggerKind.Conditional:
|
||||||
@RenderConditional();
|
@RenderConditional();
|
||||||
break;
|
break;
|
||||||
|
case ScriptTriggerKind.Expression:
|
||||||
|
@RenderExpression();
|
||||||
|
break;
|
||||||
case ScriptTriggerKind.Call:
|
case ScriptTriggerKind.Call:
|
||||||
<div class="small text-muted">
|
<div class="small text-muted">
|
||||||
No automatic trigger — this script runs only when another script
|
No automatic trigger — this script runs only when another script
|
||||||
@@ -62,7 +66,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@* ── Hint ──────────────────────────────────────────────────────────── *@
|
@* ── 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>
|
<div class="mt-3 pt-2 border-top small text-muted">@BuildHint()</div>
|
||||||
}
|
}
|
||||||
@@ -244,6 +249,30 @@
|
|||||||
await Emit();
|
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"] > 80</code>.
|
||||||
|
</div>
|
||||||
|
};
|
||||||
|
|
||||||
|
private async Task OnExpressionChanged(string value)
|
||||||
|
{
|
||||||
|
_model.Expression = value;
|
||||||
|
await Emit();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Attribute picker (ValueChange + Conditional) ───────────────────────
|
// ── Attribute picker (ValueChange + Conditional) ───────────────────────
|
||||||
|
|
||||||
private RenderFragment RenderAttributePicker(string label) => __builder =>
|
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, if {attr} {_model.Operator} {t.ToString("0.###", CultureInfo.InvariantCulture)}."
|
||||||
: $"Runs when {attr} changes and meets the configured condition — set a threshold above.",
|
: $"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
|
_ => string.Empty
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user