refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.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,345 @@
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip codec for the alarm trigger configuration JSON used by both
|
||||
/// <see cref="AlarmTriggerEditor"/> (UI editing) and AlarmActor (runtime
|
||||
/// evaluation). The serialized shape per trigger type:
|
||||
/// ValueMatch { attributeName, matchValue } ("!=X" prefix = not equals)
|
||||
/// RangeViolation { attributeName, min, max }
|
||||
/// RateOfChange { attributeName, thresholdPerSecond, windowSeconds, direction }
|
||||
/// HiLo { attributeName, loLo, lo, hi, hiHi,
|
||||
/// loLoPriority, loPriority, hiPriority, hiHiPriority }
|
||||
/// Expression { expression }
|
||||
///
|
||||
/// All HiLo setpoints and per-setpoint priorities are optional — any subset
|
||||
/// is valid (e.g., only Hi/HiHi configured for over-temperature protection).
|
||||
///
|
||||
/// Parsing also accepts legacy aliases the runtime used to consume
|
||||
/// (<c>attribute</c>, <c>value</c>, <c>low</c>, <c>high</c>) so older configs
|
||||
/// survive a round-trip through the editor.
|
||||
/// </summary>
|
||||
internal static class AlarmTriggerConfigCodec
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses a trigger configuration JSON in the context of the given trigger
|
||||
/// type. Returns a model with default values on null/empty/malformed input
|
||||
/// or for missing keys — never throws.
|
||||
/// </summary>
|
||||
/// <param name="json">The trigger configuration JSON string, or null/empty for defaults.</param>
|
||||
/// <param name="type">The alarm trigger type that determines which properties to extract.</param>
|
||||
/// <returns>A populated AlarmTriggerModel with default values for missing fields.</returns>
|
||||
internal static AlarmTriggerModel Parse(string? json, AlarmTriggerType type)
|
||||
{
|
||||
var model = new AlarmTriggerModel();
|
||||
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;
|
||||
|
||||
case AlarmTriggerType.HiLo:
|
||||
model.LoLo = TryReadDouble(root, "loLo");
|
||||
model.Lo = TryReadDouble(root, "lo");
|
||||
model.Hi = TryReadDouble(root, "hi");
|
||||
model.HiHi = TryReadDouble(root, "hiHi");
|
||||
model.LoLoPriority = TryReadInt(root, "loLoPriority");
|
||||
model.LoPriority = TryReadInt(root, "loPriority");
|
||||
model.HiPriority = TryReadInt(root, "hiPriority");
|
||||
model.HiHiPriority = TryReadInt(root, "hiHiPriority");
|
||||
model.LoLoDeadband = TryReadDouble(root, "loLoDeadband");
|
||||
model.LoDeadband = TryReadDouble(root, "loDeadband");
|
||||
model.HiDeadband = TryReadDouble(root, "hiDeadband");
|
||||
model.HiHiDeadband = TryReadDouble(root, "hiHiDeadband");
|
||||
model.LoLoMessage = TryReadString(root, "loLoMessage");
|
||||
model.LoMessage = TryReadString(root, "loMessage");
|
||||
model.HiMessage = TryReadString(root, "hiMessage");
|
||||
model.HiHiMessage = TryReadString(root, "hiHiMessage");
|
||||
break;
|
||||
|
||||
case AlarmTriggerType.Expression:
|
||||
model.Expression = TryReadString(root, "expression");
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Malformed JSON — fall through with default model.
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the model to the JSON shape AlarmActor.ParseEvalConfig
|
||||
/// expects. Writes <c>attributeName</c> (canonical key) for the
|
||||
/// attribute-bound trigger types and only the keys relevant to the
|
||||
/// current trigger type. <c>Expression</c> is not bound to a single
|
||||
/// attribute, so <c>attributeName</c> is omitted for it.
|
||||
/// </summary>
|
||||
/// <param name="model">The AlarmTriggerModel to serialize.</param>
|
||||
/// <param name="type">The alarm trigger type determining which properties to serialize.</param>
|
||||
/// <returns>The serialized JSON representation of the model.</returns>
|
||||
internal static string Serialize(AlarmTriggerModel model, AlarmTriggerType type)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using (var w = new Utf8JsonWriter(stream))
|
||||
{
|
||||
w.WriteStartObject();
|
||||
if (type != AlarmTriggerType.Expression)
|
||||
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;
|
||||
|
||||
case AlarmTriggerType.HiLo:
|
||||
if (model.LoLo.HasValue) w.WriteNumber("loLo", model.LoLo.Value);
|
||||
if (model.Lo.HasValue) w.WriteNumber("lo", model.Lo.Value);
|
||||
if (model.Hi.HasValue) w.WriteNumber("hi", model.Hi.Value);
|
||||
if (model.HiHi.HasValue) w.WriteNumber("hiHi", model.HiHi.Value);
|
||||
if (model.LoLoPriority.HasValue) w.WriteNumber("loLoPriority", model.LoLoPriority.Value);
|
||||
if (model.LoPriority.HasValue) w.WriteNumber("loPriority", model.LoPriority.Value);
|
||||
if (model.HiPriority.HasValue) w.WriteNumber("hiPriority", model.HiPriority.Value);
|
||||
if (model.HiHiPriority.HasValue) w.WriteNumber("hiHiPriority", model.HiHiPriority.Value);
|
||||
if (model.LoLoDeadband.HasValue) w.WriteNumber("loLoDeadband", model.LoLoDeadband.Value);
|
||||
if (model.LoDeadband.HasValue) w.WriteNumber("loDeadband", model.LoDeadband.Value);
|
||||
if (model.HiDeadband.HasValue) w.WriteNumber("hiDeadband", model.HiDeadband.Value);
|
||||
if (model.HiHiDeadband.HasValue) w.WriteNumber("hiHiDeadband", model.HiHiDeadband.Value);
|
||||
if (!string.IsNullOrEmpty(model.LoLoMessage)) w.WriteString("loLoMessage", model.LoLoMessage);
|
||||
if (!string.IsNullOrEmpty(model.LoMessage)) w.WriteString("loMessage", model.LoMessage);
|
||||
if (!string.IsNullOrEmpty(model.HiMessage)) w.WriteString("hiMessage", model.HiMessage);
|
||||
if (!string.IsNullOrEmpty(model.HiHiMessage)) w.WriteString("hiHiMessage", model.HiHiMessage);
|
||||
break;
|
||||
|
||||
case AlarmTriggerType.Expression:
|
||||
w.WriteString("expression", model.Expression ?? "");
|
||||
break;
|
||||
}
|
||||
|
||||
w.WriteEndObject();
|
||||
}
|
||||
return Encoding.UTF8.GetString(stream.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a direction string to one of: rising, falling, or either.
|
||||
/// </summary>
|
||||
/// <param name="raw">The raw direction string to normalize.</param>
|
||||
/// <returns>Normalized direction: "rising", "falling", or "either".</returns>
|
||||
internal 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
|
||||
};
|
||||
}
|
||||
|
||||
private static int? TryReadInt(JsonElement el, string name)
|
||||
{
|
||||
if (!el.TryGetProperty(name, out var p)) return null;
|
||||
return p.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number when p.TryGetInt32(out var i) => i,
|
||||
JsonValueKind.Number => (int)p.GetDouble(),
|
||||
JsonValueKind.String when int.TryParse(p.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) => v,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string? TryReadString(JsonElement el, string name)
|
||||
{
|
||||
if (!el.TryGetProperty(name, out var p)) return null;
|
||||
return p.ValueKind == JsonValueKind.String ? p.GetString() : null;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AlarmTriggerModel
|
||||
{
|
||||
/// <summary>
|
||||
/// The attribute name bound to this trigger.
|
||||
/// </summary>
|
||||
public string? AttributeName { get; set; }
|
||||
|
||||
// ValueMatch
|
||||
/// <summary>
|
||||
/// The value to match against the attribute for ValueMatch triggers.
|
||||
/// </summary>
|
||||
public string? MatchValue { get; set; }
|
||||
/// <summary>
|
||||
/// Indicates whether the match should be inverted (not equal) for ValueMatch triggers.
|
||||
/// </summary>
|
||||
public bool NotEquals { get; set; }
|
||||
|
||||
// RangeViolation
|
||||
/// <summary>
|
||||
/// The minimum threshold for RangeViolation triggers.
|
||||
/// </summary>
|
||||
public double? Min { get; set; }
|
||||
/// <summary>
|
||||
/// The maximum threshold for RangeViolation triggers.
|
||||
/// </summary>
|
||||
public double? Max { get; set; }
|
||||
|
||||
// RateOfChange
|
||||
/// <summary>
|
||||
/// The threshold per second for RateOfChange triggers.
|
||||
/// </summary>
|
||||
public double? ThresholdPerSecond { get; set; }
|
||||
/// <summary>
|
||||
/// The time window in seconds for RateOfChange rate calculation.
|
||||
/// </summary>
|
||||
public double? WindowSeconds { get; set; }
|
||||
/// <summary>
|
||||
/// The direction of change: "rising", "falling", or "either" for RateOfChange triggers.
|
||||
/// </summary>
|
||||
public string Direction { get; set; } = "either";
|
||||
|
||||
// HiLo — any subset of setpoints may be set; per-setpoint priorities
|
||||
// override the alarm-level priority for that band.
|
||||
/// <summary>
|
||||
/// The low-low setpoint for HiLo triggers.
|
||||
/// </summary>
|
||||
public double? LoLo { get; set; }
|
||||
/// <summary>
|
||||
/// The low setpoint for HiLo triggers.
|
||||
/// </summary>
|
||||
public double? Lo { get; set; }
|
||||
/// <summary>
|
||||
/// The high setpoint for HiLo triggers.
|
||||
/// </summary>
|
||||
public double? Hi { get; set; }
|
||||
/// <summary>
|
||||
/// The high-high setpoint for HiLo triggers.
|
||||
/// </summary>
|
||||
public double? HiHi { get; set; }
|
||||
/// <summary>
|
||||
/// The priority for low-low alarm state.
|
||||
/// </summary>
|
||||
public int? LoLoPriority { get; set; }
|
||||
/// <summary>
|
||||
/// The priority for low alarm state.
|
||||
/// </summary>
|
||||
public int? LoPriority { get; set; }
|
||||
/// <summary>
|
||||
/// The priority for high alarm state.
|
||||
/// </summary>
|
||||
public int? HiPriority { get; set; }
|
||||
/// <summary>
|
||||
/// The priority for high-high alarm state.
|
||||
/// </summary>
|
||||
public int? HiHiPriority { get; set; }
|
||||
|
||||
// Hysteresis: optional deactivation deadband per setpoint. Once at the
|
||||
// band, the setpoint threshold is relaxed by this amount before the alarm
|
||||
// de-escalates. Prevents flapping when the value hovers at the boundary.
|
||||
/// <summary>
|
||||
/// The deadband for low-low alarm de-escalation.
|
||||
/// </summary>
|
||||
public double? LoLoDeadband { get; set; }
|
||||
/// <summary>
|
||||
/// The deadband for low alarm de-escalation.
|
||||
/// </summary>
|
||||
public double? LoDeadband { get; set; }
|
||||
/// <summary>
|
||||
/// The deadband for high alarm de-escalation.
|
||||
/// </summary>
|
||||
public double? HiDeadband { get; set; }
|
||||
/// <summary>
|
||||
/// The deadband for high-high alarm de-escalation.
|
||||
/// </summary>
|
||||
public double? HiHiDeadband { get; set; }
|
||||
|
||||
// Per-band operator message. Optional; surfaces on AlarmStateChanged.Message
|
||||
// and may be used by notification routing or operator displays.
|
||||
/// <summary>
|
||||
/// The operator message for low-low alarm state.
|
||||
/// </summary>
|
||||
public string? LoLoMessage { get; set; }
|
||||
/// <summary>
|
||||
/// The operator message for low alarm state.
|
||||
/// </summary>
|
||||
public string? LoMessage { get; set; }
|
||||
/// <summary>
|
||||
/// The operator message for high alarm state.
|
||||
/// </summary>
|
||||
public string? HiMessage { get; set; }
|
||||
/// <summary>
|
||||
/// The operator message for high-high alarm state.
|
||||
/// </summary>
|
||||
public string? HiHiMessage { get; set; }
|
||||
|
||||
// Expression — boolean C# expression evaluated on attribute updates.
|
||||
/// <summary>
|
||||
/// The boolean C# expression to evaluate for Expression triggers.
|
||||
/// </summary>
|
||||
public string? Expression { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
|
||||
@using System.Globalization
|
||||
@using ZB.MOM.WW.ScadaBridge.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 ───────────────────────────────────────────── *@
|
||||
@* 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
|
||||
</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;
|
||||
case AlarmTriggerType.HiLo:
|
||||
@RenderHiLo();
|
||||
break;
|
||||
case AlarmTriggerType.Expression:
|
||||
@RenderExpression();
|
||||
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 AlarmTriggerModel _model = new AlarmTriggerModel();
|
||||
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 = AlarmTriggerConfigCodec.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 = AlarmTriggerConfigCodec.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";
|
||||
|
||||
// ── HiLo ───────────────────────────────────────────────────────────────
|
||||
|
||||
private RenderFragment RenderHiLo() => __builder =>
|
||||
{
|
||||
<div class="small text-muted mb-2 fst-italic">
|
||||
Set any subset of the four setpoints. The most-severe matching band
|
||||
wins (LoLo/HiHi outrank Lo/Hi). Per-setpoint priority overrides the
|
||||
alarm-level priority for that band. Deadband (optional) relaxes the
|
||||
deactivation threshold by the configured amount to prevent flapping.
|
||||
</div>
|
||||
|
||||
@HiLoSetpointRow("HIGH-HIGH (critical)",
|
||||
_hiHiText, v => _hiHiText = v, OnHiHiChanged,
|
||||
_hiHiDeadbandText, v => _hiHiDeadbandText = v, OnHiHiDeadbandChanged,
|
||||
_hiHiPriorityText, v => _hiHiPriorityText = v, OnHiHiPriorityChanged,
|
||||
_hiHiMessageText, v => _hiHiMessageText = v, OnHiHiMessageChanged,
|
||||
"text-danger")
|
||||
|
||||
@HiLoSetpointRow("HIGH (warning)",
|
||||
_hiText, v => _hiText = v, OnHiChanged,
|
||||
_hiDeadbandText, v => _hiDeadbandText = v, OnHiDeadbandChanged,
|
||||
_hiPriorityText, v => _hiPriorityText = v, OnHiPriorityChanged,
|
||||
_hiMessageText, v => _hiMessageText = v, OnHiMessageChanged,
|
||||
"text-warning-emphasis")
|
||||
|
||||
@HiLoSetpointRow("LOW (warning)",
|
||||
_loText, v => _loText = v, OnLoChanged,
|
||||
_loDeadbandText, v => _loDeadbandText = v, OnLoDeadbandChanged,
|
||||
_loPriorityText, v => _loPriorityText = v, OnLoPriorityChanged,
|
||||
_loMessageText, v => _loMessageText = v, OnLoMessageChanged,
|
||||
"text-warning-emphasis")
|
||||
|
||||
@HiLoSetpointRow("LOW-LOW (critical)",
|
||||
_loLoText, v => _loLoText = v, OnLoLoChanged,
|
||||
_loLoDeadbandText, v => _loLoDeadbandText = v, OnLoLoDeadbandChanged,
|
||||
_loLoPriorityText, v => _loLoPriorityText = v, OnLoLoPriorityChanged,
|
||||
_loLoMessageText, v => _loLoMessageText = v, OnLoLoMessageChanged,
|
||||
"text-danger")
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Renders one setpoint row: value (number) + priority (int). Both are
|
||||
/// optional — leaving a value blank disables that band. The
|
||||
/// <paramref name="severityClass"/> tints the label to convey relative
|
||||
/// severity at a glance.
|
||||
/// </summary>
|
||||
private RenderFragment HiLoSetpointRow(
|
||||
string label,
|
||||
string? value, Action<string?> valueSetter, Func<Task> onValueChanged,
|
||||
string? deadband, Action<string?> deadbandSetter, Func<Task> onDeadbandChanged,
|
||||
string? priority, Action<string?> prioritySetter, Func<Task> onPriorityChanged,
|
||||
string? message, Action<string?> messageSetter, Func<Task> onMessageChanged,
|
||||
string severityClass) => __builder =>
|
||||
{
|
||||
<div class="row g-2 align-items-end mb-1">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small text-uppercase fw-semibold mb-1 @severityClass">
|
||||
@label
|
||||
</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text">setpoint</span>
|
||||
<input type="number" step="any" class="form-control"
|
||||
placeholder="—"
|
||||
value="@value"
|
||||
@oninput="@(e => { valueSetter(e.Value?.ToString()); _ = onValueChanged(); })" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
|
||||
Deadband
|
||||
</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text">±</span>
|
||||
<input type="number" step="any" min="0" class="form-control"
|
||||
placeholder="0"
|
||||
value="@deadband"
|
||||
@oninput="@(e => { deadbandSetter(e.Value?.ToString()); _ = onDeadbandChanged(); })" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
|
||||
Priority
|
||||
</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="number" min="0" max="1000" class="form-control"
|
||||
placeholder="@_priority"
|
||||
value="@priority"
|
||||
@oninput="@(e => { prioritySetter(e.Value?.ToString()); _ = onPriorityChanged(); })" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-12">
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
placeholder="Optional operator message for this band…"
|
||||
value="@message"
|
||||
@oninput="@(e => { messageSetter(e.Value?.ToString()); _ = onMessageChanged(); })" />
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
// Setpoint text mirrors — separate from the model so blank fields stay
|
||||
// blank (rather than appearing as 0) and we can detect "unset" cleanly.
|
||||
private string? _loLoText;
|
||||
private string? _loText;
|
||||
private string? _hiText;
|
||||
private string? _hiHiText;
|
||||
private string? _loLoPriorityText;
|
||||
private string? _loPriorityText;
|
||||
private string? _hiPriorityText;
|
||||
private string? _hiHiPriorityText;
|
||||
private string? _loLoDeadbandText;
|
||||
private string? _loDeadbandText;
|
||||
private string? _hiDeadbandText;
|
||||
private string? _hiHiDeadbandText;
|
||||
private string? _loLoMessageText;
|
||||
private string? _loMessageText;
|
||||
private string? _hiMessageText;
|
||||
private string? _hiHiMessageText;
|
||||
|
||||
// Mirrored on the parent so the placeholder shows the right fallback.
|
||||
[Parameter] public int FallbackPriority { get; set; } = 500;
|
||||
private int _priority => FallbackPriority;
|
||||
|
||||
private async Task OnLoLoChanged() { _model.LoLo = ParseDouble(_loLoText); await Emit(); }
|
||||
private async Task OnLoChanged() { _model.Lo = ParseDouble(_loText); await Emit(); }
|
||||
private async Task OnHiChanged() { _model.Hi = ParseDouble(_hiText); await Emit(); }
|
||||
private async Task OnHiHiChanged() { _model.HiHi = ParseDouble(_hiHiText); await Emit(); }
|
||||
private async Task OnLoLoPriorityChanged() { _model.LoLoPriority = ParseInt(_loLoPriorityText); await Emit(); }
|
||||
private async Task OnLoPriorityChanged() { _model.LoPriority = ParseInt(_loPriorityText); await Emit(); }
|
||||
private async Task OnHiPriorityChanged() { _model.HiPriority = ParseInt(_hiPriorityText); await Emit(); }
|
||||
private async Task OnHiHiPriorityChanged() { _model.HiHiPriority = ParseInt(_hiHiPriorityText); await Emit(); }
|
||||
private async Task OnLoLoDeadbandChanged() { _model.LoLoDeadband = ParseDouble(_loLoDeadbandText); await Emit(); }
|
||||
private async Task OnLoDeadbandChanged() { _model.LoDeadband = ParseDouble(_loDeadbandText); await Emit(); }
|
||||
private async Task OnHiDeadbandChanged() { _model.HiDeadband = ParseDouble(_hiDeadbandText); await Emit(); }
|
||||
private async Task OnHiHiDeadbandChanged() { _model.HiHiDeadband = ParseDouble(_hiHiDeadbandText); await Emit(); }
|
||||
private async Task OnLoLoMessageChanged() { _model.LoLoMessage = _loLoMessageText; await Emit(); }
|
||||
private async Task OnLoMessageChanged() { _model.LoMessage = _loMessageText; await Emit(); }
|
||||
private async Task OnHiMessageChanged() { _model.HiMessage = _hiMessageText; await Emit(); }
|
||||
private async Task OnHiHiMessageChanged() { _model.HiHiMessage = _hiHiMessageText; await Emit(); }
|
||||
|
||||
private static int? ParseInt(string? s) =>
|
||||
int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : null;
|
||||
|
||||
// ── 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;
|
||||
_loLoText = FormatNullable(_model.LoLo);
|
||||
_loText = FormatNullable(_model.Lo);
|
||||
_hiText = FormatNullable(_model.Hi);
|
||||
_hiHiText = FormatNullable(_model.HiHi);
|
||||
_loLoPriorityText = _model.LoLoPriority?.ToString(CultureInfo.InvariantCulture);
|
||||
_loPriorityText = _model.LoPriority?.ToString(CultureInfo.InvariantCulture);
|
||||
_hiPriorityText = _model.HiPriority?.ToString(CultureInfo.InvariantCulture);
|
||||
_hiHiPriorityText = _model.HiHiPriority?.ToString(CultureInfo.InvariantCulture);
|
||||
_loLoDeadbandText = FormatNullable(_model.LoLoDeadband);
|
||||
_loDeadbandText = FormatNullable(_model.LoDeadband);
|
||||
_hiDeadbandText = FormatNullable(_model.HiDeadband);
|
||||
_hiHiDeadbandText = FormatNullable(_model.HiHiDeadband);
|
||||
_loLoMessageText = _model.LoLoMessage ?? string.Empty;
|
||||
_loMessageText = _model.LoMessage ?? string.Empty;
|
||||
_hiMessageText = _model.HiMessage ?? string.Empty;
|
||||
_hiHiMessageText = _model.HiHiMessage ?? string.Empty;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// ── 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 ──────────────────────────────────────────────────────────
|
||||
|
||||
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.",
|
||||
|
||||
AlarmTriggerType.HiLo => BuildHiLoHint(attr),
|
||||
|
||||
AlarmTriggerType.Expression =>
|
||||
"Alarm is active while this expression is true.",
|
||||
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private string BuildHiLoHint(string attr)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (_model.LoLo.HasValue) parts.Add($"LoLo at {Fmt(_model.LoLo)}");
|
||||
if (_model.Lo.HasValue) parts.Add($"Lo at {Fmt(_model.Lo)}");
|
||||
if (_model.Hi.HasValue) parts.Add($"Hi at {Fmt(_model.Hi)}");
|
||||
if (_model.HiHi.HasValue) parts.Add($"HiHi at {Fmt(_model.HiHi)}");
|
||||
|
||||
if (parts.Count == 0)
|
||||
return $"Triggers when {attr} crosses any configured setpoint (none set yet).";
|
||||
return $"Triggers on {attr}: {string.Join(", ", parts)}.";
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
@if (IsVisible)
|
||||
{
|
||||
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title">Compose '@SourceName' into…</h6>
|
||||
<button type="button" class="btn-close" @onclick="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-muted mb-1">Parent template</label>
|
||||
<select class="form-select form-select-sm" @bind="_parentTemplateId">
|
||||
<option value="0" disabled selected>Select a parent template…</option>
|
||||
@foreach (var opt in ParentOptions)
|
||||
{
|
||||
<option value="@opt.Id">@opt.Label</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-1">
|
||||
<label class="form-label small text-muted mb-1">Slot name</label>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
placeholder="Slot name"
|
||||
@bind="_slotName" />
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-2">@ErrorMessage</div> }
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="Submit" disabled="@(_parentTemplateId == 0 || string.IsNullOrWhiteSpace(_slotName))">Compose</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public bool IsVisible { get; set; }
|
||||
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
|
||||
[Parameter] public int SourceTemplateId { get; set; }
|
||||
[Parameter] public string SourceName { get; set; } = string.Empty;
|
||||
[Parameter] public IEnumerable<(int Id, string Label)> ParentOptions { get; set; } = Array.Empty<(int, string)>();
|
||||
[Parameter] public string? ErrorMessage { get; set; }
|
||||
[Parameter] public EventCallback<(int SourceTemplateId, int ParentTemplateId, string SlotName)> OnSubmit { get; set; }
|
||||
|
||||
private bool _wasVisible;
|
||||
private int _parentTemplateId;
|
||||
private string _slotName = string.Empty;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (IsVisible && !_wasVisible)
|
||||
{
|
||||
_parentTemplateId = 0;
|
||||
_slotName = SourceName;
|
||||
}
|
||||
_wasVisible = IsVisible;
|
||||
}
|
||||
|
||||
private async Task Close() => await IsVisibleChanged.InvokeAsync(false);
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
if (_parentTemplateId == 0 || string.IsNullOrWhiteSpace(_slotName)) return;
|
||||
await OnSubmit.InvokeAsync((SourceTemplateId, _parentTemplateId, _slotName.Trim()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
@* Reusable data table with sorting, filtering, and pagination *@
|
||||
@typeparam TItem
|
||||
|
||||
<div class="mb-2">
|
||||
@if (ShowSearch)
|
||||
{
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" class="form-control form-control-sm" placeholder="Search..."
|
||||
@bind="_searchTerm" @bind:event="oninput" @bind:after="ApplyFilter" />
|
||||
@if (!string.IsNullOrEmpty(_searchTerm))
|
||||
{
|
||||
<button type="button" class="btn btn-outline-secondary"
|
||||
aria-label="Clear search" @onclick="ClearSearch">×</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (FilterContent != null)
|
||||
{
|
||||
<div class="col-md-8 d-flex gap-2 align-items-center">
|
||||
@FilterContent
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<table class="table table-sm table-striped table-hover">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
@HeaderContent
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (_pagedItems.Count == 0)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="100" class="text-muted text-center">@EmptyMessage</td>
|
||||
</tr>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var item in _pagedItems)
|
||||
{
|
||||
@RowContent(item)
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@if (_totalPages > 1)
|
||||
{
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm justify-content-end">
|
||||
<li class="page-item @(_currentPage <= 1 ? "disabled" : "")">
|
||||
<button class="page-link" type="button"
|
||||
disabled="@(_currentPage <= 1)"
|
||||
aria-disabled="@((_currentPage <= 1).ToString().ToLowerInvariant())"
|
||||
@onclick="() => GoToPage(_currentPage - 1)">Previous</button>
|
||||
</li>
|
||||
@foreach (var page in PagerWindow.Build(_currentPage, _totalPages))
|
||||
{
|
||||
if (page == 0)
|
||||
{
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">…</span>
|
||||
</li>
|
||||
}
|
||||
else
|
||||
{
|
||||
var p = page;
|
||||
<li class="page-item @(p == _currentPage ? "active" : "")">
|
||||
<button class="page-link" @onclick="() => GoToPage(p)">@(p)</button>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
<li class="page-item @(_currentPage >= _totalPages ? "disabled" : "")">
|
||||
<button class="page-link" type="button"
|
||||
disabled="@(_currentPage >= _totalPages)"
|
||||
aria-disabled="@((_currentPage >= _totalPages).ToString().ToLowerInvariant())"
|
||||
@onclick="() => GoToPage(_currentPage + 1)">Next</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
}
|
||||
|
||||
<div class="text-muted small">
|
||||
Showing @((_currentPage - 1) * PageSize + 1)–@Math.Min(_currentPage * PageSize, _filteredItems.Count) of @_filteredItems.Count items
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string _searchTerm = string.Empty;
|
||||
private int _currentPage = 1;
|
||||
private List<TItem> _filteredItems = new();
|
||||
private List<TItem> _pagedItems = new();
|
||||
private int _totalPages;
|
||||
|
||||
[Parameter, EditorRequired] public IReadOnlyList<TItem> Items { get; set; } = [];
|
||||
[Parameter, EditorRequired] public RenderFragment HeaderContent { get; set; } = default!;
|
||||
[Parameter, EditorRequired] public RenderFragment<TItem> RowContent { get; set; } = default!;
|
||||
[Parameter] public RenderFragment? FilterContent { get; set; }
|
||||
[Parameter] public int PageSize { get; set; } = 25;
|
||||
[Parameter] public bool ShowSearch { get; set; } = true;
|
||||
[Parameter] public string EmptyMessage { get; set; } = "No items found.";
|
||||
[Parameter] public Func<TItem, string, bool>? SearchFilter { get; set; }
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
ApplyFilter();
|
||||
}
|
||||
|
||||
private void ApplyFilter()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_searchTerm) && SearchFilter != null)
|
||||
{
|
||||
_filteredItems = Items.Where(i => SearchFilter(i, _searchTerm)).ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
_filteredItems = Items.ToList();
|
||||
}
|
||||
|
||||
_totalPages = Math.Max(1, (int)Math.Ceiling(_filteredItems.Count / (double)PageSize));
|
||||
if (_currentPage > _totalPages) _currentPage = 1;
|
||||
|
||||
UpdatePage();
|
||||
}
|
||||
|
||||
private void GoToPage(int page)
|
||||
{
|
||||
if (page < 1 || page > _totalPages) return;
|
||||
_currentPage = page;
|
||||
UpdatePage();
|
||||
}
|
||||
|
||||
private void ClearSearch()
|
||||
{
|
||||
_searchTerm = string.Empty;
|
||||
ApplyFilter();
|
||||
}
|
||||
|
||||
private void UpdatePage()
|
||||
{
|
||||
_pagedItems = _filteredItems
|
||||
.Skip((_currentPage - 1) * PageSize)
|
||||
.Take(PageSize)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public void Refresh()
|
||||
{
|
||||
ApplyFilter();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
@* Single global host component for IDialogService. Mounted once in MainLayout.
|
||||
Listens to DialogService.OnChange and renders the current dialog state.
|
||||
z-index ladder follows the same convention as ConfirmDialog/DiffDialog:
|
||||
Toast container 1090 > this modal 1055 > this backdrop 1040. *@
|
||||
@implements IDisposable
|
||||
@inject IDialogService Service
|
||||
@inject IJSRuntime JS
|
||||
|
||||
@if (Service is DialogService svc && svc.Current is { } state)
|
||||
{
|
||||
<div class="modal-backdrop fade show"></div>
|
||||
<div @ref="_modalRef"
|
||||
class="modal fade show d-block"
|
||||
tabindex="-1"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@onkeydown="OnKeyDown">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">@state.Title</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="Cancel"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (state.Kind == DialogKind.Confirm)
|
||||
{
|
||||
<p class="mb-0">@state.Body</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<label class="form-label">@state.Body</label>
|
||||
<input @ref="_promptInputRef"
|
||||
class="form-control form-control-sm"
|
||||
placeholder="@state.Placeholder"
|
||||
value="@_promptValue"
|
||||
@oninput="OnPromptInput" />
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="Cancel">Cancel</button>
|
||||
<button type="button"
|
||||
class="btn @(state.Danger ? "btn-danger" : "btn-primary") btn-sm"
|
||||
@onclick="Confirm">
|
||||
@ConfirmLabel(state)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private ElementReference _modalRef;
|
||||
private ElementReference _promptInputRef;
|
||||
private string _promptValue = string.Empty;
|
||||
private DialogState? _lastSeenState;
|
||||
private DialogState? _focusedForState;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// OnChange lives on the concrete DialogService — the interface stays
|
||||
// narrow (just ConfirmAsync / PromptAsync). DI hands us the concrete
|
||||
// instance, so a cast here is safe.
|
||||
if (Service is DialogService svc) svc.OnChange += OnServiceChanged;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Service is DialogService svc) svc.OnChange -= OnServiceChanged;
|
||||
}
|
||||
|
||||
private void OnServiceChanged()
|
||||
{
|
||||
// Seed prompt input value when a new prompt dialog opens.
|
||||
if (Service is DialogService s && s.Current is { Kind: DialogKind.Prompt } promptState
|
||||
&& !ReferenceEquals(promptState, _lastSeenState))
|
||||
{
|
||||
_promptValue = promptState.PromptInitial;
|
||||
}
|
||||
_lastSeenState = (Service as DialogService)?.Current;
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
var current = (Service as DialogService)?.Current;
|
||||
if (current is not null)
|
||||
{
|
||||
try { await JS.InvokeVoidAsync("document.body.classList.add", "modal-open"); }
|
||||
catch { /* prerender: no JS — 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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
private void OnKeyDown(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Escape")
|
||||
{
|
||||
Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPromptInput(ChangeEventArgs e)
|
||||
{
|
||||
_promptValue = e.Value?.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
private void Cancel()
|
||||
{
|
||||
if (Service is not DialogService svc || svc.Current is null) return;
|
||||
var resolveValue = svc.Current.Kind == DialogKind.Confirm
|
||||
? (object)false
|
||||
: (object?)null;
|
||||
_promptValue = string.Empty;
|
||||
svc.Resolve(resolveValue);
|
||||
}
|
||||
|
||||
private void Confirm()
|
||||
{
|
||||
if (Service is not DialogService svc || svc.Current is null) return;
|
||||
var resolveValue = svc.Current.Kind == DialogKind.Confirm
|
||||
? (object)true
|
||||
: (object?)_promptValue;
|
||||
_promptValue = string.Empty;
|
||||
svc.Resolve(resolveValue);
|
||||
}
|
||||
|
||||
private static string ConfirmLabel(DialogState state) => state.Kind switch
|
||||
{
|
||||
DialogKind.Prompt => "Save",
|
||||
_ => state.Danger ? "Delete" : "Confirm",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IDialogService"/> implementation. Holds the currently
|
||||
/// open dialog state in <see cref="Current"/> and notifies subscribers (the
|
||||
/// <c>DialogHost</c> component) via <see cref="OnChange"/>. Only a single
|
||||
/// dialog can be open at a time; attempting to open another while one is
|
||||
/// already active throws <see cref="InvalidOperationException"/> — there is
|
||||
/// no nested-dialog use case today and surfacing the bug is preferable to
|
||||
/// silently queuing.
|
||||
/// </summary>
|
||||
public class DialogService : IDialogService
|
||||
{
|
||||
/// <summary>
|
||||
/// Raised whenever <see cref="Current"/> changes (dialog opened or closed).
|
||||
/// The host component subscribes and calls <c>StateHasChanged</c>.
|
||||
/// </summary>
|
||||
public event Action? OnChange;
|
||||
|
||||
/// <summary>
|
||||
/// The dialog currently being displayed, or <c>null</c> when no dialog is
|
||||
/// open. The host reads this to decide what (if anything) to render.
|
||||
/// </summary>
|
||||
public DialogState? Current { get; private set; }
|
||||
|
||||
// CentralUI-015: the pending dialog result is held in a typed TCS that the
|
||||
// host completes directly via Resolve(). The previous implementation
|
||||
// projected the result through Task.ContinueWith(..., TaskScheduler.Default),
|
||||
// which ran the projection lambda on a thread-pool thread. Completing a
|
||||
// strongly-typed TCS directly removes that off-render-thread hop entirely —
|
||||
// the awaiting caller resumes on whatever SynchronizationContext it captured
|
||||
// (the Blazor renderer's, for an event-handler caller).
|
||||
private TaskCompletionSource<object?>? _tcs;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
|
||||
{
|
||||
EnsureNoActiveDialog();
|
||||
var tcs = new TaskCompletionSource<object?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_tcs = tcs;
|
||||
Current = new DialogState(title, DialogKind.Confirm, message, danger, PromptInitial: string.Empty, Placeholder: null);
|
||||
OnChange?.Invoke();
|
||||
return Project(tcs.Task, static r => r is bool b && b);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string?> PromptAsync(string title, string label, string initialValue = "", string? placeholder = null)
|
||||
{
|
||||
EnsureNoActiveDialog();
|
||||
var tcs = new TaskCompletionSource<object?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_tcs = tcs;
|
||||
Current = new DialogState(title, DialogKind.Prompt, label, Danger: false, PromptInitial: initialValue, Placeholder: placeholder);
|
||||
OnChange?.Invoke();
|
||||
return Project(tcs.Task, static r => r as string);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Awaits the host's result and projects it to the caller's type. The
|
||||
/// <c>await</c> here resumes on the caller's captured context (the renderer
|
||||
/// sync context for an event-handler caller), not a thread-pool thread.
|
||||
/// </summary>
|
||||
private static async Task<TResult> Project<TResult>(Task<object?> source, Func<object?, TResult> selector)
|
||||
{
|
||||
var result = await source.ConfigureAwait(false);
|
||||
return selector(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by the host component when the user dismisses or confirms the
|
||||
/// dialog. <paramref name="result"/> must be a <c>bool</c> for confirms
|
||||
/// and a <c>string?</c> for prompts (null = cancel).
|
||||
/// </summary>
|
||||
/// <param name="result">The user's response: a <c>bool</c> for confirms or a <c>string?</c> for prompts.</param>
|
||||
internal void Resolve(object? result)
|
||||
{
|
||||
var tcs = _tcs;
|
||||
_tcs = null;
|
||||
Current = null;
|
||||
OnChange?.Invoke();
|
||||
tcs?.TrySetResult(result);
|
||||
}
|
||||
|
||||
private void EnsureNoActiveDialog()
|
||||
{
|
||||
if (Current is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A dialog is already open. IDialogService does not support nested dialogs.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of a dialog's display state, exposed read-only on
|
||||
/// <see cref="DialogService.Current"/> for the host component to render.
|
||||
/// </summary>
|
||||
/// <param name="Title">Modal title text.</param>
|
||||
/// <param name="Kind">Discriminates between confirm and prompt rendering.</param>
|
||||
/// <param name="Body">For confirm: the message; for prompt: the input label.</param>
|
||||
/// <param name="Danger">When true, the confirm button uses danger styling.</param>
|
||||
/// <param name="PromptInitial">Initial value for prompt-kind dialogs.</param>
|
||||
/// <param name="Placeholder">Placeholder shown when the prompt input is empty.</param>
|
||||
public record DialogState(
|
||||
string Title,
|
||||
DialogKind Kind,
|
||||
string Body,
|
||||
bool Danger,
|
||||
string PromptInitial,
|
||||
string? Placeholder);
|
||||
|
||||
public enum DialogKind
|
||||
{
|
||||
Confirm,
|
||||
Prompt
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
@* Reusable diff/comparison dialog using Bootstrap modal.
|
||||
Mirrors the ConfirmDialog API: callers invoke ShowAsync(title, before, after)
|
||||
via @ref to display a side-by-side or simple before/after comparison.
|
||||
z-index ladder follows ConfirmDialog: modal 1055 > backdrop 1040 (toasts at 1090). *@
|
||||
@inject IJSRuntime JS
|
||||
@inject ILogger<DiffDialog> Logger
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@if (_visible)
|
||||
{
|
||||
<div class="modal-backdrop fade show"></div>
|
||||
<div @ref="_modalRef"
|
||||
class="modal fade show d-block"
|
||||
tabindex="-1"
|
||||
role="dialog"
|
||||
@onkeydown="OnKeyDownAsync">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">@Title</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close diff dialog" @onclick="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (BodyContent != null)
|
||||
{
|
||||
@BodyContent
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="small text-muted mb-1">Before</div>
|
||||
<pre class="border rounded p-2 small bg-light mb-0" style="max-height: 50vh; overflow: auto; white-space: pre-wrap;">@Before</pre>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="small text-muted mb-1">After</div>
|
||||
<pre class="border rounded p-2 small bg-light mb-0" style="max-height: 50vh; overflow: auto; white-space: pre-wrap;">@After</pre>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary btn-sm" @onclick="Close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private bool _visible;
|
||||
private bool _bodyLocked;
|
||||
private TaskCompletionSource<bool>? _tcs;
|
||||
private ElementReference _modalRef;
|
||||
|
||||
[Parameter] public string Title { get; set; } = "Diff";
|
||||
[Parameter] public string Before { get; set; } = string.Empty;
|
||||
[Parameter] public string After { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Optional custom body content. When supplied, it replaces the default
|
||||
/// before/after panes — useful when the caller wants to render a richer
|
||||
/// comparison (e.g. metadata badges, file lists, etc.).
|
||||
/// </summary>
|
||||
[Parameter] public RenderFragment? BodyContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Show the dialog with the supplied title and before/after text.
|
||||
/// Returns when the user dismisses the dialog.
|
||||
/// </summary>
|
||||
public Task<bool> ShowAsync(string title, string before, string after)
|
||||
{
|
||||
Title = title;
|
||||
Before = before;
|
||||
After = after;
|
||||
BodyContent = null;
|
||||
return OpenAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Show the dialog with a custom body. Useful when the diff is not a
|
||||
/// simple before/after string pair (e.g. a deployment comparison summary).
|
||||
/// </summary>
|
||||
public Task<bool> ShowAsync(string title, RenderFragment body)
|
||||
{
|
||||
Title = title;
|
||||
BodyContent = body;
|
||||
return OpenAsync();
|
||||
}
|
||||
|
||||
private Task<bool> OpenAsync()
|
||||
{
|
||||
_visible = true;
|
||||
_tcs = new TaskCompletionSource<bool>();
|
||||
StateHasChanged();
|
||||
return _tcs.Task;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (_visible && !_bodyLocked)
|
||||
{
|
||||
_bodyLocked = true;
|
||||
await TryLockBodyAsync();
|
||||
try { await _modalRef.FocusAsync(); }
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Prerender: the element reference is not attached yet — the
|
||||
// next interactive render focuses it. Expected, not logged.
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// Circuit gone before focus could run — nothing to do.
|
||||
}
|
||||
catch (JSException ex)
|
||||
{
|
||||
// A genuine focus interop failure (CentralUI-023) — log it.
|
||||
Logger.LogWarning(ex, "DiffDialog: failed to focus the modal.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnKeyDownAsync(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Escape")
|
||||
{
|
||||
Close();
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private void Close()
|
||||
{
|
||||
_visible = false;
|
||||
_ = TryUnlockBodyAsync();
|
||||
_tcs?.TrySetResult(true);
|
||||
}
|
||||
|
||||
private async Task TryLockBodyAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("document.body.classList.add", "modal-open");
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// Circuit gone — the body scroll lock is moot. Expected, silent.
|
||||
}
|
||||
catch (JSException ex)
|
||||
{
|
||||
// CentralUI-023: a genuine interop failure — log instead of doing
|
||||
// another (also-failing) JS call inside a bare catch.
|
||||
Logger.LogWarning(ex, "DiffDialog: failed to apply body scroll lock.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TryUnlockBodyAsync()
|
||||
{
|
||||
_bodyLocked = false;
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("document.body.classList.remove", "modal-open");
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// Circuit gone — the body scroll lock is moot. Expected, silent.
|
||||
}
|
||||
catch (JSException ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "DiffDialog: failed to remove body scroll lock.");
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
// CentralUI-011: if the dialog is disposed while still open (the user
|
||||
// navigated away), complete the pending task so the awaiting caller
|
||||
// resumes deterministically instead of hanging forever.
|
||||
_tcs?.TrySetResult(false);
|
||||
|
||||
if (_bodyLocked)
|
||||
{
|
||||
await TryUnlockBodyAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Converts a <see cref="TimeSpan"/> to and from the number+unit pair behind a
|
||||
/// duration input (milliseconds / seconds / minutes). A blank or non-positive
|
||||
/// number represents "unset" (a <c>null</c> duration).
|
||||
/// </summary>
|
||||
internal static class DurationInput
|
||||
{
|
||||
/// <summary>The unit tokens a duration input offers, smallest first.</summary>
|
||||
internal static readonly string[] Units = { "ms", "sec", "min" };
|
||||
|
||||
/// <summary>
|
||||
/// Splits a duration into the largest whole unit that represents it exactly.
|
||||
/// A null or non-positive duration yields a blank value and the default
|
||||
/// <c>sec</c> unit.
|
||||
/// </summary>
|
||||
/// <param name="duration">The duration to split, or null for unset.</param>
|
||||
internal static (string? Value, string Unit) Split(TimeSpan? duration)
|
||||
{
|
||||
if (duration is not { } d || d <= TimeSpan.Zero) return (null, "sec");
|
||||
|
||||
var ms = (long)d.TotalMilliseconds;
|
||||
if (ms % 60000 == 0) return ((ms / 60000).ToString(CultureInfo.InvariantCulture), "min");
|
||||
if (ms % 1000 == 0) return ((ms / 1000).ToString(CultureInfo.InvariantCulture), "sec");
|
||||
return (ms.ToString(CultureInfo.InvariantCulture), "ms");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composes a number+unit pair into a duration. A blank, unparseable, or
|
||||
/// non-positive value yields <c>null</c> (unset).
|
||||
/// </summary>
|
||||
/// <param name="value">The numeric string entered by the user.</param>
|
||||
/// <param name="unit">The selected unit token (ms, sec, or min).</param>
|
||||
internal static TimeSpan? Compose(string? value, string unit)
|
||||
{
|
||||
if (!long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)
|
||||
|| n <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var factorMs = unit switch
|
||||
{
|
||||
"min" => 60000L,
|
||||
"ms" => 1L,
|
||||
_ => 1000L,
|
||||
};
|
||||
return TimeSpan.FromMilliseconds(n * factorMs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Centralised dialog/modal service. Pages inject this service and call
|
||||
/// <see cref="ConfirmAsync"/> or <see cref="PromptAsync"/> programmatically
|
||||
/// instead of embedding per-page modal components. A single <c>DialogHost</c>
|
||||
/// rendered in <c>MainLayout</c> displays the resulting dialog state.
|
||||
/// </summary>
|
||||
public interface IDialogService
|
||||
{
|
||||
/// <summary>
|
||||
/// Shows a confirmation dialog and resolves to <c>true</c> when the user
|
||||
/// confirms, or <c>false</c> when the user cancels (button click, Escape,
|
||||
/// or backdrop dismiss).
|
||||
/// </summary>
|
||||
/// <param name="title">Modal title text.</param>
|
||||
/// <param name="message">Body text shown to the user.</param>
|
||||
/// <param name="danger">When <c>true</c>, the confirm button renders in
|
||||
/// <c>btn-danger</c> styling with the label "Delete"; otherwise a primary
|
||||
/// "Confirm" button is shown.</param>
|
||||
Task<bool> ConfirmAsync(string title, string message, bool danger = false);
|
||||
|
||||
/// <summary>
|
||||
/// Shows a single-line text prompt and resolves to the entered value, or
|
||||
/// <c>null</c> if the user cancels.
|
||||
/// </summary>
|
||||
/// <param name="title">Modal title text.</param>
|
||||
/// <param name="label">Label rendered above the input field.</param>
|
||||
/// <param name="initialValue">Pre-populated value for the input field.</param>
|
||||
/// <param name="placeholder">Optional placeholder shown when the input is empty.</param>
|
||||
Task<string?> PromptAsync(string title, string label, string initialValue = "", string? placeholder = null);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
@* Reusable loading spinner *@
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<div class="d-flex align-items-center text-secondary @CssClass">
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<span>@Message</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public bool IsLoading { get; set; }
|
||||
[Parameter] public string Message { get; set; } = "Loading...";
|
||||
[Parameter] public string CssClass { get; set; } = "";
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
|
||||
@implements IAsyncDisposable
|
||||
@inject IJSRuntime JS
|
||||
@inject Microsoft.Extensions.Logging.ILogger<MonacoEditor> Logger
|
||||
|
||||
@if (ShowToolbar)
|
||||
{
|
||||
<div class="d-flex justify-content-end align-items-center gap-3 mb-1 small text-muted">
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="FormatAsync"
|
||||
title="Format document (Ctrl/Cmd+Shift+F)">Format</button>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="ToggleWrap"
|
||||
title="Word wrap">@(_wrap ? "Wrap on" : "Wrap off")</button>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="ToggleMinimap"
|
||||
title="Toggle minimap">@(_minimap ? "Minimap on" : "Minimap off")</button>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="ToggleTheme"
|
||||
title="Toggle theme">@(_dark ? "Dark" : "Light")</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div @ref="_hostRef" class="monaco-editor-host"
|
||||
style="height: @Height; border: 1px solid var(--bs-border-color); border-radius: 0.25rem; overflow: hidden;"></div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Value { get; set; } = "";
|
||||
[Parameter] public EventCallback<string> ValueChanged { get; set; }
|
||||
[Parameter] public string Language { get; set; } = "csharp";
|
||||
[Parameter] public string Height { get; set; } = "320px";
|
||||
[Parameter] public bool ReadOnly { get; set; } = false;
|
||||
[Parameter] public bool ShowToolbar { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime globals surface the script is analyzed against. Defaults to
|
||||
/// template/shared-script globals; set to <c>InboundApi</c> on the API
|
||||
/// method editor so <c>Route</c> and <c>Parameters</c> type-check.
|
||||
/// </summary>
|
||||
[Parameter] public ScriptAnalysis.ScriptKind ScriptKind { get; set; } = ScriptAnalysis.ScriptKind.Template;
|
||||
|
||||
/// <summary>
|
||||
/// 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; }
|
||||
|
||||
/// <summary>
|
||||
/// Full shapes (name + type + required) for the declared parameters.
|
||||
/// Used by Parameters["name"] hover to show the declared type. If null,
|
||||
/// derived from <see cref="DeclaredParameters"/> with type "Object".
|
||||
/// </summary>
|
||||
[Parameter] public IReadOnlyList<ScriptAnalysis.ParameterShape>? DeclaredParameterShapes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Shapes (name + parameter list + return type) of other scripts on the
|
||||
/// same template. Surfaced inside CallScript("...") for completion,
|
||||
/// signature help, hover, and argument-count diagnostics.
|
||||
/// </summary>
|
||||
[Parameter] public IReadOnlyList<ScriptAnalysis.ScriptShape>? SiblingScripts { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Attributes declared on the current template. Surfaced inside
|
||||
/// <c>Attributes["..."]</c> for completion and SCADA006 diagnostics.
|
||||
/// </summary>
|
||||
[Parameter] public IReadOnlyList<ScriptAnalysis.AttributeShape>? SelfAttributes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Child compositions on the current template, each with its template's
|
||||
/// attributes and scripts. Surfaced for <c>Children["X"].Attributes</c>,
|
||||
/// <c>Children["X"].CallScript</c>, and SCADA007 diagnostics.
|
||||
/// </summary>
|
||||
[Parameter] public IReadOnlyList<ScriptAnalysis.CompositionContext>? Children { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Parent template when the current template is composed inside exactly
|
||||
/// one other template. <c>null</c> at the root or when multiple parents
|
||||
/// exist. Surfaced for <c>Parent.Attributes</c> / <c>Parent.CallScript</c>.
|
||||
/// </summary>
|
||||
[Parameter] public ScriptAnalysis.CompositionContext? Parent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Fires whenever Monaco's marker set updates (after the 500 ms diagnostic
|
||||
/// debounce). Hosts can render a <see cref="ProblemsPanel"/> with the same
|
||||
/// data.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback<IReadOnlyList<ScriptAnalysis.DiagnosticMarker>> MarkersChanged { get; set; }
|
||||
|
||||
private ElementReference _hostRef;
|
||||
private DotNetObjectReference<MonacoEditor>? _dotNetRef;
|
||||
private readonly string _id = Guid.NewGuid().ToString("N");
|
||||
private string _lastSentValue = "";
|
||||
private bool _initialized;
|
||||
private bool _wrap;
|
||||
private bool _minimap;
|
||||
private bool _dark;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
_dotNetRef = DotNetObjectReference.Create(this);
|
||||
_lastSentValue = Value ?? "";
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync(
|
||||
"MonacoBlazor.createEditor",
|
||||
_id,
|
||||
_hostRef,
|
||||
new
|
||||
{
|
||||
value = Value ?? "",
|
||||
language = Language,
|
||||
readOnly = ReadOnly
|
||||
},
|
||||
_dotNetRef);
|
||||
_initialized = true;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Prerendering: JS interop is not available yet — the next
|
||||
// (interactive) render retries. Expected, not logged.
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// Circuit disconnected before init completed — nothing to do.
|
||||
}
|
||||
catch (JSException ex)
|
||||
{
|
||||
// A genuine Monaco init failure — surface it instead of hiding it.
|
||||
Logger.LogError(ex, "Monaco editor {EditorId} failed to initialize.", _id);
|
||||
}
|
||||
}
|
||||
else if (_initialized && (Value ?? "") != _lastSentValue)
|
||||
{
|
||||
_lastSentValue = Value ?? "";
|
||||
await SafeInvokeAsync("MonacoBlazor.setValue", "set editor value", _id, _lastSentValue);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes a Monaco JS function, swallowing the expected disconnect case but
|
||||
/// logging any genuine JS error (CentralUI-018) so failures are not silent.
|
||||
/// </summary>
|
||||
private async ValueTask SafeInvokeAsync(string fn, string action, params object?[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync(fn, args);
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// Circuit gone — the editor no longer exists; nothing to log.
|
||||
}
|
||||
catch (JSException ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Monaco editor {EditorId}: failed to {Action}.", _id, action);
|
||||
}
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public Task OnValueChanged(string newValue)
|
||||
{
|
||||
_lastSentValue = newValue ?? "";
|
||||
return ValueChanged.InvokeAsync(_lastSentValue);
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public Task OnMarkersChanged(ScriptAnalysis.DiagnosticMarker[] markers) =>
|
||||
MarkersChanged.InvokeAsync(markers ?? Array.Empty<ScriptAnalysis.DiagnosticMarker>());
|
||||
|
||||
/// <summary>Programmatic scroll-to-line (called by the problems panel).</summary>
|
||||
public async Task RevealLineAsync(int line, int column = 1)
|
||||
{
|
||||
if (!_initialized) return;
|
||||
await SafeInvokeAsync("MonacoBlazor.revealLine", "reveal line", _id, line, column);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called from JS at completion-request time so the form's latest state is
|
||||
/// passed through, not whatever was captured when the editor was created.
|
||||
/// </summary>
|
||||
[JSInvokable]
|
||||
public ScadaContext GetContext() => new(
|
||||
DeclaredParameters?.ToArray() ?? Array.Empty<string>(),
|
||||
SiblingScripts?.ToArray() ?? Array.Empty<ScriptAnalysis.ScriptShape>(),
|
||||
DeclaredParameterShapes?.ToArray()
|
||||
?? DeclaredParameters?.Select(n => new ScriptAnalysis.ParameterShape(n, "Object", true)).ToArray()
|
||||
?? Array.Empty<ScriptAnalysis.ParameterShape>(),
|
||||
SelfAttributes?.ToArray() ?? Array.Empty<ScriptAnalysis.AttributeShape>(),
|
||||
Children?.ToArray() ?? Array.Empty<ScriptAnalysis.CompositionContext>(),
|
||||
Parent,
|
||||
ScriptKind);
|
||||
|
||||
private async Task FormatAsync()
|
||||
{
|
||||
if (!_initialized) return;
|
||||
await SafeInvokeAsync("MonacoBlazor.format", "format document", _id);
|
||||
}
|
||||
|
||||
private async Task ToggleWrap()
|
||||
{
|
||||
_wrap = !_wrap;
|
||||
await SafeInvokeAsync("MonacoBlazor.setEditorOption", "toggle word wrap", _id, "wordWrap", _wrap ? "on" : "off");
|
||||
}
|
||||
|
||||
private async Task ToggleMinimap()
|
||||
{
|
||||
_minimap = !_minimap;
|
||||
await SafeInvokeAsync("MonacoBlazor.setEditorOption", "toggle minimap", _id, "minimap", new { enabled = _minimap });
|
||||
}
|
||||
|
||||
private async Task ToggleTheme()
|
||||
{
|
||||
_dark = !_dark;
|
||||
await SafeInvokeAsync("MonacoBlazor.setEditorOption", "toggle theme", _id, "theme", _dark ? "vs-dark" : "vs");
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
// Disposal commonly races a circuit disconnect — JSDisconnectedException
|
||||
// here is expected and silent; a real JSException is still logged.
|
||||
await SafeInvokeAsync("MonacoBlazor.dispose", "dispose editor", _id);
|
||||
}
|
||||
_dotNetRef?.Dispose();
|
||||
}
|
||||
|
||||
public record ScadaContext(
|
||||
string[] DeclaredParameters,
|
||||
ScriptAnalysis.ScriptShape[] SiblingScripts,
|
||||
ScriptAnalysis.ParameterShape[] DeclaredParameterShapes,
|
||||
ScriptAnalysis.AttributeShape[] SelfAttributes,
|
||||
ScriptAnalysis.CompositionContext[] Children,
|
||||
ScriptAnalysis.CompositionContext? Parent,
|
||||
ScriptAnalysis.ScriptKind ScriptKind);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
@if (IsVisible)
|
||||
{
|
||||
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title">Move '@FolderName' to…</h6>
|
||||
<button type="button" class="btn-close" @onclick="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<select class="form-select form-select-sm" @bind="_targetParentId">
|
||||
@foreach (var opt in FolderOptions)
|
||||
{
|
||||
<option value="@opt.Id">@opt.Label</option>
|
||||
}
|
||||
</select>
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="Submit">Move</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public bool IsVisible { get; set; }
|
||||
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
|
||||
[Parameter] public int FolderId { get; set; }
|
||||
[Parameter] public string FolderName { get; set; } = string.Empty;
|
||||
[Parameter] public IEnumerable<(int? Id, string Label)> FolderOptions { get; set; } = Array.Empty<(int?, string)>();
|
||||
[Parameter] public string? ErrorMessage { get; set; }
|
||||
[Parameter] public EventCallback<(int FolderId, int? NewParentId)> OnSubmit { get; set; }
|
||||
|
||||
private bool _wasVisible;
|
||||
private int? _targetParentId;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (IsVisible && !_wasVisible)
|
||||
{
|
||||
_targetParentId = null;
|
||||
}
|
||||
_wasVisible = IsVisible;
|
||||
}
|
||||
|
||||
private async Task Close()
|
||||
{
|
||||
await IsVisibleChanged.InvokeAsync(false);
|
||||
}
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
await OnSubmit.InvokeAsync((FolderId, _targetParentId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
@if (IsVisible)
|
||||
{
|
||||
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title">Move '@TemplateName' to…</h6>
|
||||
<button type="button" class="btn-close" @onclick="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<select class="form-select form-select-sm" @bind="_targetFolderId">
|
||||
@foreach (var opt in FolderOptions)
|
||||
{
|
||||
<option value="@opt.Id">@opt.Label</option>
|
||||
}
|
||||
</select>
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="Submit">Move</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public bool IsVisible { get; set; }
|
||||
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
|
||||
[Parameter] public int TemplateId { get; set; }
|
||||
[Parameter] public string TemplateName { get; set; } = string.Empty;
|
||||
[Parameter] public IEnumerable<(int? Id, string Label)> FolderOptions { get; set; } = Array.Empty<(int?, string)>();
|
||||
[Parameter] public string? ErrorMessage { get; set; }
|
||||
[Parameter] public EventCallback<(int TemplateId, int? NewFolderId)> OnSubmit { get; set; }
|
||||
|
||||
private bool _wasVisible;
|
||||
private int? _targetFolderId;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// Reset internal state on transition from hidden -> visible.
|
||||
if (IsVisible && !_wasVisible)
|
||||
{
|
||||
_targetFolderId = null;
|
||||
}
|
||||
_wasVisible = IsVisible;
|
||||
}
|
||||
|
||||
private async Task Close()
|
||||
{
|
||||
await IsVisibleChanged.InvokeAsync(false);
|
||||
}
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
await OnSubmit.InvokeAsync((TemplateId, _targetFolderId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
@typeparam TValue
|
||||
@*
|
||||
Compact multi-select control: a Bootstrap dropdown whose toggle button
|
||||
summarises the current selection over a checkbox menu. Replaces a wrapped
|
||||
block of chip buttons with a single control of one row's height.
|
||||
*@
|
||||
<div class="dropdown msd" data-test="@DataTest">
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-secondary dropdown-toggle msd-toggle text-start"
|
||||
data-bs-toggle="dropdown"
|
||||
data-bs-auto-close="outside"
|
||||
aria-expanded="false"
|
||||
disabled="@(Items.Count == 0)"
|
||||
data-test="@($"{DataTest}-toggle")">
|
||||
<span class="msd-summary">@Summary()</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu msd-menu">
|
||||
@if (Items.Count == 0)
|
||||
{
|
||||
<li><span class="dropdown-item-text text-muted small">@EmptyText</span></li>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var item in Items)
|
||||
{
|
||||
var isSelected = Selected.Contains(item);
|
||||
<li>
|
||||
<label class="dropdown-item msd-item">
|
||||
<input type="checkbox"
|
||||
class="form-check-input msd-check"
|
||||
checked="@isSelected"
|
||||
@onchange="() => Toggle(item)"
|
||||
data-test="@($"{DataTest}-opt-{item}")" />
|
||||
<span>@Display(item)</span>
|
||||
</label>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -0,0 +1,95 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// A compact multi-select control: a Bootstrap dropdown whose toggle button
|
||||
/// summarises the current selection ("All" when empty, the single item's label
|
||||
/// when one is picked, or "N selected" otherwise) over a checkbox menu.
|
||||
///
|
||||
/// <para>
|
||||
/// It exists to keep multi-value filter controls one row tall instead of a
|
||||
/// wrapped block of chip buttons. The component mutates the caller-owned
|
||||
/// <see cref="Selected"/> collection in place and raises
|
||||
/// <see cref="SelectionChanged"/> after every toggle so the parent can react
|
||||
/// (re-render, prune dependent selections, …).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Requires the Bootstrap JS bundle (loaded in <c>App.razor</c>) for the
|
||||
/// dropdown toggle; <c>data-bs-auto-close="outside"</c> keeps the menu open
|
||||
/// while the operator ticks several boxes.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <typeparam name="TValue">The option value type (an enum or string).</typeparam>
|
||||
public partial class MultiSelectDropdown<TValue> where TValue : notnull
|
||||
{
|
||||
/// <summary>The options shown in the menu, in display order.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public IReadOnlyList<TValue> Items { get; set; } = Array.Empty<TValue>();
|
||||
|
||||
/// <summary>
|
||||
/// The caller-owned selection set. Mutated in place by <see cref="Toggle"/>.
|
||||
/// </summary>
|
||||
[Parameter, EditorRequired]
|
||||
public ICollection<TValue> Selected { get; set; } = default!;
|
||||
|
||||
/// <summary>Maps an option to its display label. Defaults to <c>ToString()</c>.</summary>
|
||||
[Parameter]
|
||||
public Func<TValue, string> Display { get; set; } = static v => v.ToString() ?? string.Empty;
|
||||
|
||||
/// <summary>Raised after each toggle, once <see cref="Selected"/> has been updated.</summary>
|
||||
[Parameter]
|
||||
public EventCallback SelectionChanged { get; set; }
|
||||
|
||||
/// <summary>Summary text shown on the toggle button when nothing is selected.</summary>
|
||||
[Parameter]
|
||||
public string AllLabel { get; set; } = "All";
|
||||
|
||||
/// <summary>Text shown in the menu when there are no options.</summary>
|
||||
[Parameter]
|
||||
public string EmptyText { get; set; } = "None available";
|
||||
|
||||
/// <summary><c>data-test</c> root for this control, its toggle and its options.</summary>
|
||||
[Parameter]
|
||||
public string DataTest { get; set; } = "multi-select";
|
||||
|
||||
private async Task Toggle(TValue item)
|
||||
{
|
||||
// ICollection.Remove returns false when the item was absent — that is the
|
||||
// "not currently selected" case, so add it. This is a plain toggle.
|
||||
if (!Selected.Remove(item))
|
||||
{
|
||||
Selected.Add(item);
|
||||
}
|
||||
|
||||
await SelectionChanged.InvokeAsync();
|
||||
}
|
||||
|
||||
private string Summary()
|
||||
{
|
||||
var count = Selected.Count;
|
||||
if (count == 0)
|
||||
{
|
||||
return AllLabel;
|
||||
}
|
||||
|
||||
if (count == 1)
|
||||
{
|
||||
// Prefer the single selection's label over a bare "1 selected".
|
||||
foreach (var item in Items)
|
||||
{
|
||||
if (Selected.Contains(item))
|
||||
{
|
||||
return Display(item);
|
||||
}
|
||||
}
|
||||
|
||||
// The one selected value is not in the current Items list (e.g. a Kind
|
||||
// narrowed out by a Channel change before the parent pruned it).
|
||||
return "1 selected";
|
||||
}
|
||||
|
||||
return $"{count} selected";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/* Compact multi-select dropdown. Tuned to sit inline with form-select-sm /
|
||||
form-control-sm controls in a filter row. */
|
||||
|
||||
.msd-toggle {
|
||||
min-width: 9rem;
|
||||
max-width: 15rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Keep a long option list from running off-screen — scroll within the menu. */
|
||||
.msd-menu {
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* The whole row is a <label> so a click anywhere toggles the checkbox; the
|
||||
menu stays open thanks to data-bs-auto-close="outside". */
|
||||
.msd-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Neutralise the default form-check-input top margin so the box lines up with
|
||||
the option text inside the dropdown-item. */
|
||||
.msd-check {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<div class="d-flex align-items-center justify-content-center min-vh-100">
|
||||
<div class="card shadow-sm" style="max-width: 480px; width: 100%;">
|
||||
<div class="card-body p-4">
|
||||
<h4 class="card-title mb-3 text-center">ScadaBridge</h4>
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<h5 class="alert-heading">Not Authorized</h5>
|
||||
<p class="mb-0">You do not have permission to access this page. Contact your administrator if you believe this is an error.</p>
|
||||
</div>
|
||||
<div class="d-flex justify-content-center">
|
||||
<a href="/" class="btn btn-outline-primary btn-sm">Return to Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Pure helper for windowed pagination (CentralUI-016). Computes the set of
|
||||
/// page numbers a pager should render: always the first and last page plus a
|
||||
/// small range around the current page, with the rest elided. Keeps the
|
||||
/// rendered button count bounded regardless of the total page count, instead
|
||||
/// of emitting one <c><li></c> per page.
|
||||
/// </summary>
|
||||
public static class PagerWindow
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the page numbers to render. A value of <c>0</c> in the result is
|
||||
/// an ellipsis placeholder (a gap between non-contiguous page numbers).
|
||||
/// <paramref name="radius"/> is how many pages to show on each side of the
|
||||
/// current page.
|
||||
/// </summary>
|
||||
/// <param name="currentPage">The currently active page (1-based).</param>
|
||||
/// <param name="totalPages">The total number of pages.</param>
|
||||
/// <param name="radius">Number of page buttons to show on each side of the current page; default 2.</param>
|
||||
/// <returns>An ordered list of page numbers and <c>0</c> ellipsis placeholders.</returns>
|
||||
public static IReadOnlyList<int> Build(int currentPage, int totalPages, int radius = 2)
|
||||
{
|
||||
if (totalPages <= 1)
|
||||
{
|
||||
return totalPages == 1 ? new[] { 1 } : Array.Empty<int>();
|
||||
}
|
||||
|
||||
currentPage = Math.Clamp(currentPage, 1, totalPages);
|
||||
|
||||
// Small enough that windowing buys nothing — render every page.
|
||||
var maxUnwindowed = 2 * radius + 5;
|
||||
if (totalPages <= maxUnwindowed)
|
||||
{
|
||||
return Enumerable.Range(1, totalPages).ToList();
|
||||
}
|
||||
|
||||
var pages = new SortedSet<int> { 1, totalPages };
|
||||
for (var p = currentPage - radius; p <= currentPage + radius; p++)
|
||||
{
|
||||
if (p >= 1 && p <= totalPages)
|
||||
{
|
||||
pages.Add(p);
|
||||
}
|
||||
}
|
||||
|
||||
// Walk the sorted set, inserting an ellipsis (0) wherever a gap exists.
|
||||
var result = new List<int>();
|
||||
var previous = 0;
|
||||
foreach (var page in pages)
|
||||
{
|
||||
if (previous != 0 && page - previous > 1)
|
||||
{
|
||||
result.Add(0); // ellipsis
|
||||
}
|
||||
result.Add(page);
|
||||
previous = page;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis
|
||||
@using System.Text.Json
|
||||
|
||||
@*
|
||||
Renders an input row per declared parameter so the user can supply values
|
||||
for a script test run. Primitive types get typed inputs (text / number /
|
||||
checkbox); Object and List fall back to a JSON textarea with inline parse
|
||||
errors. The companion SchemaBuilder edits the schema; this edits values.
|
||||
*@
|
||||
|
||||
@if (Shapes.Count == 0)
|
||||
{
|
||||
<div class="text-muted small fst-italic">No parameters declared.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex flex-column gap-2">
|
||||
@foreach (var shape in Shapes)
|
||||
{
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label small mb-0" for="@FieldId(shape)">
|
||||
<code>@shape.Name</code>
|
||||
<span class="text-muted ms-1">@shape.Type@(shape.Required ? "" : "?")</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-sm-8">
|
||||
@RenderInput(shape)
|
||||
@if (_parseErrors.TryGetValue(shape.Name, out var err))
|
||||
{
|
||||
<div class="text-danger small mt-1">@err</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string? ParameterDefinitions { get; set; }
|
||||
[Parameter] public Dictionary<string, object?> Values { get; set; } = new();
|
||||
[Parameter] public EventCallback<Dictionary<string, object?>> ValuesChanged { get; set; }
|
||||
|
||||
private IReadOnlyList<ParameterShape> Shapes =>
|
||||
ScriptParameterNames.ParseShapes(ParameterDefinitions);
|
||||
|
||||
private readonly Dictionary<string, string> _rawText = new();
|
||||
private readonly Dictionary<string, string> _parseErrors = new();
|
||||
|
||||
private static string FieldId(ParameterShape shape) => $"param-{shape.Name}";
|
||||
|
||||
private RenderFragment RenderInput(ParameterShape shape) => __builder =>
|
||||
{
|
||||
switch (shape.Type)
|
||||
{
|
||||
case "Boolean":
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="@FieldId(shape)"
|
||||
checked="@AsBool(shape.Name)"
|
||||
@onchange="e => SetBool(shape.Name, (bool)(e.Value ?? false))" />
|
||||
</div>
|
||||
break;
|
||||
|
||||
case "Integer":
|
||||
<input class="form-control form-control-sm" type="number" step="1" id="@FieldId(shape)"
|
||||
value="@AsRaw(shape.Name)"
|
||||
@oninput="e => SetNumeric(shape.Name, (string?)e.Value, integerOnly: true)" />
|
||||
break;
|
||||
|
||||
case "Float":
|
||||
<input class="form-control form-control-sm" type="number" step="any" id="@FieldId(shape)"
|
||||
value="@AsRaw(shape.Name)"
|
||||
@oninput="e => SetNumeric(shape.Name, (string?)e.Value, integerOnly: false)" />
|
||||
break;
|
||||
|
||||
case "String":
|
||||
<input class="form-control form-control-sm" type="text" id="@FieldId(shape)"
|
||||
value="@AsRaw(shape.Name)"
|
||||
@oninput="e => SetString(shape.Name, (string?)e.Value)" />
|
||||
break;
|
||||
|
||||
default: // Object, List, List<...>, unknown
|
||||
<textarea class="form-control form-control-sm font-monospace" rows="3" id="@FieldId(shape)"
|
||||
placeholder='@($"JSON {shape.Type.ToLowerInvariant()}")'
|
||||
@oninput="e => SetJson(shape.Name, (string?)e.Value)">@AsRaw(shape.Name)</textarea>
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
private string AsRaw(string name) =>
|
||||
_rawText.TryGetValue(name, out var raw) ? raw : "";
|
||||
|
||||
private bool AsBool(string name) =>
|
||||
Values.TryGetValue(name, out var v) && v is bool b && b;
|
||||
|
||||
private async Task SetString(string name, string? raw)
|
||||
{
|
||||
_rawText[name] = raw ?? "";
|
||||
_parseErrors.Remove(name);
|
||||
Values[name] = raw ?? "";
|
||||
await ValuesChanged.InvokeAsync(Values);
|
||||
}
|
||||
|
||||
private async Task SetBool(string name, bool value)
|
||||
{
|
||||
_parseErrors.Remove(name);
|
||||
Values[name] = value;
|
||||
await ValuesChanged.InvokeAsync(Values);
|
||||
}
|
||||
|
||||
private async Task SetNumeric(string name, string? raw, bool integerOnly)
|
||||
{
|
||||
_rawText[name] = raw ?? "";
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
_parseErrors.Remove(name);
|
||||
Values.Remove(name);
|
||||
await ValuesChanged.InvokeAsync(Values);
|
||||
return;
|
||||
}
|
||||
if (integerOnly && long.TryParse(raw, out var i))
|
||||
{
|
||||
_parseErrors.Remove(name);
|
||||
Values[name] = i;
|
||||
}
|
||||
else if (!integerOnly && double.TryParse(raw,
|
||||
System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var d))
|
||||
{
|
||||
_parseErrors.Remove(name);
|
||||
Values[name] = d;
|
||||
}
|
||||
else
|
||||
{
|
||||
_parseErrors[name] = integerOnly ? "Not a valid integer." : "Not a valid number.";
|
||||
Values.Remove(name);
|
||||
}
|
||||
await ValuesChanged.InvokeAsync(Values);
|
||||
}
|
||||
|
||||
private async Task SetJson(string name, string? raw)
|
||||
{
|
||||
_rawText[name] = raw ?? "";
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
_parseErrors.Remove(name);
|
||||
Values.Remove(name);
|
||||
await ValuesChanged.InvokeAsync(Values);
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
Values[name] = JsonElementToObject(doc.RootElement.Clone());
|
||||
_parseErrors.Remove(name);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_parseErrors[name] = $"JSON parse error: {ex.Message}";
|
||||
Values.Remove(name);
|
||||
}
|
||||
await ValuesChanged.InvokeAsync(Values);
|
||||
}
|
||||
|
||||
private static object? JsonElementToObject(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => element.GetString(),
|
||||
JsonValueKind.Number => element.TryGetInt64(out var i) ? (object)i : element.GetDouble(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null => null,
|
||||
JsonValueKind.Array => element.EnumerateArray().Select(JsonElementToObject).ToList(),
|
||||
JsonValueKind.Object => element.EnumerateObject()
|
||||
.ToDictionary(p => p.Name, p => JsonElementToObject(p.Value)),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis
|
||||
|
||||
@if (Markers.Count > 0)
|
||||
{
|
||||
<div class="card mt-2 mb-3">
|
||||
<div class="card-header py-1 small d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
@if (_errorCount > 0)
|
||||
{
|
||||
<span class="badge bg-danger me-1">@_errorCount error@(_errorCount == 1 ? "" : "s")</span>
|
||||
}
|
||||
@if (_warningCount > 0)
|
||||
{
|
||||
<span class="badge bg-warning text-dark me-1">@_warningCount warning@(_warningCount == 1 ? "" : "s")</span>
|
||||
}
|
||||
@if (_infoCount > 0)
|
||||
{
|
||||
<span class="badge bg-info text-dark me-1">@_infoCount info</span>
|
||||
}
|
||||
</span>
|
||||
<span class="text-muted">Problems</span>
|
||||
</div>
|
||||
<ul class="list-unstyled mb-0 small" style="max-height: 180px; overflow-y: auto;">
|
||||
@foreach (var m in Markers)
|
||||
{
|
||||
<li class="d-flex gap-2 align-items-start px-2 py-1 border-bottom">
|
||||
<span class="badge @SeverityBadge(m.Severity)" style="min-width: 60px;">@SeverityLabel(m.Severity)</span>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none flex-grow-1 text-start"
|
||||
@onclick="@(() => OnNavigate.InvokeAsync(m))">
|
||||
<span class="text-muted me-2">Line @m.StartLineNumber</span>@m.Message
|
||||
</button>
|
||||
<code class="text-muted small">@m.Code</code>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public IReadOnlyList<DiagnosticMarker> Markers { get; set; } = Array.Empty<DiagnosticMarker>();
|
||||
[Parameter] public EventCallback<DiagnosticMarker> OnNavigate { get; set; }
|
||||
|
||||
private int _errorCount;
|
||||
private int _warningCount;
|
||||
private int _infoCount;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_errorCount = Markers.Count(m => m.Severity >= 8);
|
||||
_warningCount = Markers.Count(m => m.Severity == 4);
|
||||
_infoCount = Markers.Count(m => m.Severity > 0 && m.Severity < 4);
|
||||
}
|
||||
|
||||
private static string SeverityBadge(int sev) => sev switch
|
||||
{
|
||||
>= 8 => "bg-danger",
|
||||
4 => "bg-warning text-dark",
|
||||
_ => "bg-info text-dark"
|
||||
};
|
||||
|
||||
private static string SeverityLabel(int sev) => sev switch
|
||||
{
|
||||
>= 8 => "Error",
|
||||
4 => "Warning",
|
||||
_ => "Info"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
@code {
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Navigation.NavigateTo("/login", forceLoad: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
@if (IsVisible)
|
||||
{
|
||||
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title">Rename Folder</h6>
|
||||
<button type="button" class="btn-close" @onclick="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input class="form-control form-control-sm" @bind="_name" />
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="Submit">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public bool IsVisible { get; set; }
|
||||
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
|
||||
[Parameter] public int FolderId { get; set; }
|
||||
[Parameter] public string InitialName { get; set; } = string.Empty;
|
||||
[Parameter] public string? ErrorMessage { get; set; }
|
||||
[Parameter] public EventCallback<(int FolderId, string NewName)> OnSubmit { get; set; }
|
||||
|
||||
private bool _wasVisible;
|
||||
private string _name = string.Empty;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// Reset internal state on transition from hidden -> visible.
|
||||
if (IsVisible && !_wasVisible)
|
||||
{
|
||||
_name = InitialName;
|
||||
}
|
||||
_wasVisible = IsVisible;
|
||||
}
|
||||
|
||||
private async Task Close()
|
||||
{
|
||||
await IsVisibleChanged.InvokeAsync(false);
|
||||
}
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
await OnSubmit.InvokeAsync((FolderId, _name.Trim()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
@namespace ZB.MOM.WW.ScadaBridge.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,213 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.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();
|
||||
/// <summary>Property name as it appears in the JSON Schema <c>properties</c> map.</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>When true, the property is listed in the JSON Schema <c>required</c> array.</summary>
|
||||
public bool Required { get; set; } = true;
|
||||
/// <summary>The JSON Schema node describing this property's type and structure.</summary>
|
||||
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>
|
||||
/// <param name="json">JSON Schema string to parse, or null/empty to return the fallback.</param>
|
||||
/// <param name="fallback">The <see cref="SchemaNode"/> to return when the input cannot be parsed.</param>
|
||||
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" };
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a <see cref="SchemaNode"/> tree to its canonical JSON Schema string.
|
||||
/// </summary>
|
||||
/// <param name="node">The schema node to serialize.</param>
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses a parameter definitions JSON Schema and returns the declared parameter names.
|
||||
/// </summary>
|
||||
/// <param name="json">JSON Schema or legacy flat-array string; null/empty returns an empty list.</param>
|
||||
public static IReadOnlyList<string> Parse(string? json) =>
|
||||
JsonSchemaShapeParser.ParseParameters(json)
|
||||
.Select(p => p.Name)
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Parses a parameter definitions JSON Schema and returns the full parameter shape objects.
|
||||
/// </summary>
|
||||
/// <param name="json">JSON Schema or legacy flat-array string; null/empty returns an empty list.</param>
|
||||
public static IReadOnlyList<ParameterShape> ParseShapes(string? json) =>
|
||||
JsonSchemaShapeParser.ParseParameters(json);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.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, Expression, Unknown }
|
||||
|
||||
/// <summary>
|
||||
/// When a Conditional/Expression trigger fires. <see cref="OnTrue"/> fires once
|
||||
/// as the condition becomes true; <see cref="WhileTrue"/> re-fires on a timer
|
||||
/// (cadence = the script's MinTimeBetweenRuns) while the condition stays true.
|
||||
/// </summary>
|
||||
internal enum ScriptTriggerMode { OnTrue, WhileTrue }
|
||||
|
||||
/// <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>Boolean C# expression (Expression).</summary>
|
||||
public string? Expression { get; set; }
|
||||
|
||||
/// <summary>Fire mode (Conditional + Expression). Defaults to <see cref="ScriptTriggerMode.OnTrue"/>.</summary>
|
||||
public ScriptTriggerMode Mode { get; set; } = ScriptTriggerMode.OnTrue;
|
||||
}
|
||||
|
||||
/// <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, mode }
|
||||
/// Call { }
|
||||
/// Expression { expression, mode }
|
||||
///
|
||||
/// <c>mode</c> (Conditional + Expression) is <c>OnTrue</c> or <c>WhileTrue</c>;
|
||||
/// an absent or unrecognized value parses as <c>OnTrue</c>.
|
||||
///
|
||||
/// 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>
|
||||
/// <param name="triggerType">The raw trigger type string from the template script entity.</param>
|
||||
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,
|
||||
"expression" => ScriptTriggerKind.Expression,
|
||||
_ => ScriptTriggerKind.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a trigger type honours the script's <c>MinTimeBetweenRuns</c>.
|
||||
/// True for the auto-firing triggers it throttles — ValueChange, Conditional,
|
||||
/// Expression. False for Interval (its own period is the cadence), Call
|
||||
/// (invoked explicitly, never throttled), and None/Unknown.
|
||||
/// </summary>
|
||||
/// <param name="triggerType">The raw trigger type string to classify.</param>
|
||||
internal static bool SupportsMinTimeBetweenRuns(string? triggerType) =>
|
||||
ParseKind(triggerType) is ScriptTriggerKind.ValueChange
|
||||
or ScriptTriggerKind.Conditional
|
||||
or ScriptTriggerKind.Expression;
|
||||
|
||||
/// <summary>Canonical <c>TriggerType</c> string for a kind; null for None/Unknown.</summary>
|
||||
/// <param name="kind">The trigger kind to convert.</param>
|
||||
internal static string? KindToString(ScriptTriggerKind kind) => kind switch
|
||||
{
|
||||
ScriptTriggerKind.Interval => "Interval",
|
||||
ScriptTriggerKind.ValueChange => "ValueChange",
|
||||
ScriptTriggerKind.Conditional => "Conditional",
|
||||
ScriptTriggerKind.Call => "Call",
|
||||
ScriptTriggerKind.Expression => "Expression",
|
||||
_ => 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>
|
||||
/// <param name="json">The raw JSON trigger configuration string.</param>
|
||||
/// <param name="kind">The trigger kind, used to determine which fields to parse.</param>
|
||||
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");
|
||||
model.Mode = ReadMode(root);
|
||||
break;
|
||||
|
||||
case ScriptTriggerKind.Expression:
|
||||
model.Expression = root.TryGetProperty("expression", out var e) ? e.GetString() : null;
|
||||
model.Mode = ReadMode(root);
|
||||
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>
|
||||
/// <param name="model">The trigger model to serialize.</param>
|
||||
/// <param name="kind">The trigger kind, used to determine which fields to emit.</param>
|
||||
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);
|
||||
w.WriteString("mode", model.Mode.ToString());
|
||||
break;
|
||||
|
||||
case ScriptTriggerKind.Expression:
|
||||
w.WriteString("expression", model.Expression ?? "");
|
||||
w.WriteString("mode", model.Mode.ToString());
|
||||
break;
|
||||
|
||||
// Call → empty object.
|
||||
}
|
||||
w.WriteEndObject();
|
||||
}
|
||||
return Encoding.UTF8.GetString(stream.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the optional <c>mode</c> property; an absent or unrecognized value
|
||||
/// (case-insensitive) yields <see cref="ScriptTriggerMode.OnTrue"/>.
|
||||
/// </summary>
|
||||
private static ScriptTriggerMode ReadMode(JsonElement root)
|
||||
{
|
||||
var raw = root.TryGetProperty("mode", out var m) ? m.GetString() : null;
|
||||
return string.Equals(raw?.Trim(), "WhileTrue", StringComparison.OrdinalIgnoreCase)
|
||||
? ScriptTriggerMode.WhileTrue
|
||||
: ScriptTriggerMode.OnTrue;
|
||||
}
|
||||
|
||||
/// <summary>Returns <paramref name="raw"/> if it is a recognized operator, else ">".</summary>
|
||||
/// <param name="raw">The raw operator string to normalize.</param>
|
||||
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,413 @@
|
||||
@namespace ZB.MOM.WW.ScadaBridge.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, mode }
|
||||
Expression { expression, mode }
|
||||
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="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)
|
||||
{
|
||||
<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.Expression:
|
||||
@RenderExpression();
|
||||
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;
|
||||
}
|
||||
|
||||
@* ── Fire mode (Conditional + Expression) ──────────────────────────── *@
|
||||
@if (_kind is ScriptTriggerKind.Conditional or ScriptTriggerKind.Expression)
|
||||
{
|
||||
@RenderMode();
|
||||
}
|
||||
|
||||
@* ── Hint ──────────────────────────────────────────────────────────── *@
|
||||
@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>
|
||||
|
||||
@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();
|
||||
}
|
||||
|
||||
// ── 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();
|
||||
}
|
||||
|
||||
// ── Fire mode (Conditional + Expression) ───────────────────────────────
|
||||
|
||||
private RenderFragment RenderMode() => __builder =>
|
||||
{
|
||||
<div class="mt-3">
|
||||
<label for="script-trigger-mode" class="form-label small text-uppercase text-muted fw-semibold mb-1">
|
||||
Fire mode
|
||||
</label>
|
||||
<select id="script-trigger-mode" class="form-select form-select-sm"
|
||||
style="max-width: 420px;"
|
||||
@bind="_model.Mode" @bind:after="OnModeChanged">
|
||||
<option value="@ScriptTriggerMode.OnTrue">
|
||||
Once — when the condition becomes true
|
||||
</option>
|
||||
<option value="@ScriptTriggerMode.WhileTrue">
|
||||
Repeatedly — while the condition stays true
|
||||
</option>
|
||||
</select>
|
||||
@if (_model.Mode == ScriptTriggerMode.WhileTrue)
|
||||
{
|
||||
<div class="form-text">
|
||||
Re-runs on a timer while the condition holds, at the script's
|
||||
<strong>Min time between runs</strong> interval — set that field below
|
||||
the script editor, or the trigger fires only once.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
};
|
||||
|
||||
private async Task OnModeChanged() => 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
|
||||
? (_model.Mode == ScriptTriggerMode.WhileTrue
|
||||
? $"Re-runs while {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.",
|
||||
|
||||
ScriptTriggerKind.Expression =>
|
||||
_model.Mode == ScriptTriggerMode.WhileTrue
|
||||
? "Re-runs while this expression stays true."
|
||||
: "Runs once each time this expression becomes true.",
|
||||
|
||||
_ => 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",
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
@implements IDisposable
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JS
|
||||
|
||||
@code {
|
||||
// CentralUI-005 / CentralUI-020: session expiry is a sliding window owned by
|
||||
// the cookie authentication middleware (ZB.MOM.WW.ScadaBridge.Security AddCookie:
|
||||
// ExpireTimeSpan = idle timeout, SlidingExpiration = true). An active user's
|
||||
// cookie is continually renewed; an idle user's cookie lapses after the idle
|
||||
// timeout. There is no fixed login-time deadline to redirect at.
|
||||
//
|
||||
// This component must NOT poll the Blazor AuthenticationStateProvider:
|
||||
// CookieAuthenticationStateProvider serves a frozen constructor-time
|
||||
// principal for the whole circuit (CentralUI-004), so the polled auth state
|
||||
// can never transition to "expired" and the redirect would never fire
|
||||
// (CentralUI-020).
|
||||
//
|
||||
// Instead it polls the server endpoint GET /auth/ping via fetch(). Being a
|
||||
// normal HTTP request, the cookie middleware re-validates — and slides — the
|
||||
// cookie on every hit, and answers 200 while the session is live or 401 once
|
||||
// it has lapsed. A genuine idle user's circuit produces no other HTTP
|
||||
// traffic, so once the cookie lapses the next ping returns 401 and this
|
||||
// component redirects to /login. (The ping itself slides the cookie, but the
|
||||
// poll interval is well under the idle timeout, so an idle session still
|
||||
// lapses on schedule once the poll catches the lapsed state — the ping only
|
||||
// ever observes expiry, it does not keep a dead session alive.)
|
||||
|
||||
/// <summary>Server endpoint that reports live session validity.</summary>
|
||||
internal const string PingUrl = "/auth/ping";
|
||||
|
||||
/// <summary>HTTP status returned by <see cref="PingUrl"/> once the cookie has lapsed.</summary>
|
||||
private const int Unauthorized = 401;
|
||||
|
||||
private const string ModulePath = "./_content/ZB.MOM.WW.ScadaBridge.CentralUI/js/session-expiry.js";
|
||||
|
||||
/// <summary>How often the session validity is re-checked.</summary>
|
||||
internal static readonly TimeSpan PollInterval = TimeSpan.FromMinutes(1);
|
||||
|
||||
private CancellationTokenSource? _cts;
|
||||
private IJSObjectReference? _module;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// The login page uses the same layout, so this component renders there
|
||||
// too. Polling/redirecting on /login → /login would loop.
|
||||
if (IsOnLoginPage) return;
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
_ = PollSessionAsync(_cts.Token);
|
||||
}
|
||||
|
||||
private bool IsOnLoginPage =>
|
||||
Navigation.ToBaseRelativePath(Navigation.Uri)
|
||||
.StartsWith("login", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private async Task PollSessionAsync(CancellationToken token)
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try { await Task.Delay(PollInterval, token); }
|
||||
catch (TaskCanceledException) { return; }
|
||||
|
||||
if (token.IsCancellationRequested) return;
|
||||
await CheckSessionAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs one liveness check: pings the server and, if the session has lapsed
|
||||
/// server-side (HTTP 401), redirects to the login page. Exposed for tests
|
||||
/// (CentralUI-025) so the redirect path can be exercised without waiting on
|
||||
/// the poll interval.
|
||||
/// </summary>
|
||||
internal async Task CheckSessionAsync()
|
||||
{
|
||||
if (IsOnLoginPage) return;
|
||||
|
||||
int status;
|
||||
try
|
||||
{
|
||||
_module ??= await JS.InvokeAsync<IJSObjectReference>("import", ModulePath);
|
||||
status = await _module.InvokeAsync<int>("ping", PingUrl);
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// Circuit gone — nothing to redirect.
|
||||
return;
|
||||
}
|
||||
catch (JSException)
|
||||
{
|
||||
// Network blip or fetch failure: treat as inconclusive and retry on
|
||||
// the next poll rather than logging an authenticated user out on a
|
||||
// transient error.
|
||||
return;
|
||||
}
|
||||
|
||||
if (status == Unauthorized)
|
||||
{
|
||||
await InvokeAsync(() => Navigation.NavigateTo("/login", forceLoad: true));
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
// The module reference is owned by the circuit's JS runtime; once the
|
||||
// circuit is disposed disposing it would throw — fire-and-forget and
|
||||
// swallow the expected disconnect.
|
||||
if (_module is not null)
|
||||
{
|
||||
_ = DisposeModuleAsync(_module);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task DisposeModuleAsync(IJSObjectReference module)
|
||||
{
|
||||
try { await module.DisposeAsync(); }
|
||||
catch (JSDisconnectedException) { /* circuit already gone */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
@* Shared template folder/template tree.
|
||||
|
||||
Wraps TreeView<TemplateTreeNode> with template-folder-specific layout: folder
|
||||
nodes carry their child folders + templates, leaves are templates. Used by:
|
||||
- Templates page (Single mode, navigation on click)
|
||||
- Transport Export wizard (Checkbox mode, bulk selection)
|
||||
|
||||
Optional ExtraTemplateChildren lets callers nest extra leaves (e.g. composition
|
||||
slots) under a template without forking the component. *@
|
||||
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
|
||||
|
||||
<TreeView @ref="_tree" TItem="TemplateTreeNode"
|
||||
Items="_visibleRoots"
|
||||
ChildrenSelector="n => n.Children"
|
||||
HasChildrenSelector="n => n.Children.Count > 0"
|
||||
KeySelector="n => (object)n.Key"
|
||||
Selectable="@(SelectionMode == TreeViewSelectionMode.Single)"
|
||||
SelectionMode="SelectionMode"
|
||||
SelectedKeys="SelectedKeys"
|
||||
SelectedKeysChanged="SelectedKeysChanged"
|
||||
InitiallyExpanded="@(_initiallyExpanded)"
|
||||
StorageKey="@StorageKey">
|
||||
<NodeContent Context="node">
|
||||
@if (NodeContent != null)
|
||||
{
|
||||
@NodeContent(node)
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="tv-glyph">
|
||||
<i class="bi @(NodeGlyph(node))"></i>
|
||||
</span>
|
||||
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
|
||||
title="@node.Name"
|
||||
@onclick="() => InvokeNodeClick(node)">@node.Name</span>
|
||||
@if (NodeExtras != null)
|
||||
{
|
||||
@NodeExtras(node)
|
||||
}
|
||||
}
|
||||
</NodeContent>
|
||||
<ContextMenu Context="node">
|
||||
@if (ContextMenu != null)
|
||||
{
|
||||
@ContextMenu(node)
|
||||
}
|
||||
</ContextMenu>
|
||||
<EmptyContent>
|
||||
@if (EmptyContent != null)
|
||||
{
|
||||
@EmptyContent
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted fst-italic">No templates.</span>
|
||||
}
|
||||
</EmptyContent>
|
||||
</TreeView>
|
||||
|
||||
@code {
|
||||
[Parameter] public IReadOnlyList<TemplateFolder> Folders { get; set; } = Array.Empty<TemplateFolder>();
|
||||
[Parameter] public IReadOnlyList<Template> Templates { get; set; } = Array.Empty<Template>();
|
||||
[Parameter] public TreeViewSelectionMode SelectionMode { get; set; } = TreeViewSelectionMode.Single;
|
||||
[Parameter] public HashSet<object>? SelectedKeys { get; set; }
|
||||
[Parameter] public EventCallback<HashSet<object>> SelectedKeysChanged { get; set; }
|
||||
[Parameter] public string Filter { get; set; } = "";
|
||||
[Parameter] public RenderFragment<TemplateTreeNode>? NodeExtras { get; set; }
|
||||
[Parameter] public RenderFragment<TemplateTreeNode>? NodeContent { get; set; }
|
||||
[Parameter] public RenderFragment<TemplateTreeNode>? ContextMenu { get; set; }
|
||||
[Parameter] public RenderFragment? EmptyContent { get; set; }
|
||||
[Parameter] public EventCallback<TemplateTreeNode> OnNodeClick { get; set; }
|
||||
[Parameter] public string? StorageKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional: caller-supplied extra leaves to nest under a template. Used by
|
||||
/// the Templates page to surface composition slots; left null by the
|
||||
/// Transport Export wizard (compositions aren't exportable as standalone
|
||||
/// items, so the wizard's checkbox tree intentionally hides them).
|
||||
/// </summary>
|
||||
[Parameter] public Func<Template, IReadOnlyList<TemplateTreeNode>>? ExtraTemplateChildren { get; set; }
|
||||
|
||||
private TreeView<TemplateTreeNode>? _tree;
|
||||
private List<TemplateTreeNode> _allRoots = new();
|
||||
private List<TemplateTreeNode> _visibleRoots = new();
|
||||
private HashSet<string>? _filterRevealed;
|
||||
private Func<TemplateTreeNode, bool>? _initiallyExpanded;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
BuildTree();
|
||||
ApplyFilter();
|
||||
}
|
||||
|
||||
private void BuildTree()
|
||||
{
|
||||
var folderNodes = Folders.ToDictionary(
|
||||
f => f.Id,
|
||||
f => new TemplateTreeNode
|
||||
{
|
||||
Kind = TemplateTreeNodeKind.Folder,
|
||||
Id = f.Id,
|
||||
Name = f.Name,
|
||||
});
|
||||
|
||||
var roots = new List<TemplateTreeNode>();
|
||||
foreach (var f in Folders.OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var node = folderNodes[f.Id];
|
||||
if (f.ParentFolderId is int pid && folderNodes.TryGetValue(pid, out var parent))
|
||||
parent.Children.Add(node);
|
||||
else
|
||||
roots.Add(node);
|
||||
}
|
||||
|
||||
foreach (var t in Templates
|
||||
.Where(t => !t.IsDerived)
|
||||
.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var tNode = new TemplateTreeNode
|
||||
{
|
||||
Kind = TemplateTreeNodeKind.Template,
|
||||
Id = t.Id,
|
||||
Name = t.Name,
|
||||
};
|
||||
|
||||
if (ExtraTemplateChildren != null)
|
||||
{
|
||||
foreach (var extra in ExtraTemplateChildren(t))
|
||||
{
|
||||
tNode.Children.Add(extra);
|
||||
}
|
||||
}
|
||||
|
||||
if (t.FolderId is int fid && folderNodes.TryGetValue(fid, out var parentFolder))
|
||||
parentFolder.Children.Add(tNode);
|
||||
else
|
||||
roots.Add(tNode);
|
||||
}
|
||||
|
||||
SortChildren(roots);
|
||||
foreach (var node in folderNodes.Values)
|
||||
SortChildren(node.Children);
|
||||
|
||||
_allRoots = roots;
|
||||
}
|
||||
|
||||
private static void SortChildren(List<TemplateTreeNode> children)
|
||||
{
|
||||
children.Sort((a, b) =>
|
||||
{
|
||||
var kindOrder = (int)a.Kind - (int)b.Kind;
|
||||
if (kindOrder != 0) return kindOrder;
|
||||
return string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase);
|
||||
});
|
||||
}
|
||||
|
||||
private void ApplyFilter()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Filter))
|
||||
{
|
||||
_visibleRoots = _allRoots;
|
||||
_filterRevealed = null;
|
||||
_initiallyExpanded = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var needle = Filter.Trim();
|
||||
var revealed = new HashSet<string>(StringComparer.Ordinal);
|
||||
var filtered = new List<TemplateTreeNode>();
|
||||
foreach (var root in _allRoots)
|
||||
{
|
||||
if (CopyMatching(root, needle, revealed) is { } copy)
|
||||
filtered.Add(copy);
|
||||
}
|
||||
|
||||
_visibleRoots = filtered;
|
||||
_filterRevealed = revealed;
|
||||
// Force every ancestor of a match to be expanded so the matched leaf is
|
||||
// visible without the user clicking through.
|
||||
_initiallyExpanded = n => revealed.Contains(n.Key);
|
||||
}
|
||||
|
||||
private static TemplateTreeNode? CopyMatching(TemplateTreeNode node, string needle, HashSet<string> revealed)
|
||||
{
|
||||
var selfMatch = node.Name.Contains(needle, StringComparison.OrdinalIgnoreCase);
|
||||
var keptChildren = new List<TemplateTreeNode>();
|
||||
foreach (var child in node.Children)
|
||||
{
|
||||
var copy = CopyMatching(child, needle, revealed);
|
||||
if (copy != null) keptChildren.Add(copy);
|
||||
}
|
||||
|
||||
if (!selfMatch && keptChildren.Count == 0) return null;
|
||||
|
||||
var clone = new TemplateTreeNode
|
||||
{
|
||||
Kind = node.Kind,
|
||||
Id = node.Id,
|
||||
Name = node.Name,
|
||||
};
|
||||
foreach (var k in keptChildren) clone.Children.Add(k);
|
||||
if (keptChildren.Count > 0)
|
||||
{
|
||||
// Mark this node as an ancestor on the match path so it auto-expands.
|
||||
revealed.Add(clone.Key);
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
|
||||
private static string NodeGlyph(TemplateTreeNode node) =>
|
||||
node.Kind == TemplateTreeNodeKind.Folder ? "bi-folder" : "bi-file-earmark-text";
|
||||
|
||||
private async Task InvokeNodeClick(TemplateTreeNode node)
|
||||
{
|
||||
if (OnNodeClick.HasDelegate)
|
||||
{
|
||||
await OnNodeClick.InvokeAsync(node);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Forwarded to the inner TreeView so callers can drive expand/collapse.</summary>
|
||||
public void ExpandAll() => _tree?.ExpandAll();
|
||||
|
||||
/// <summary>Forwarded to the inner TreeView so callers can drive expand/collapse.</summary>
|
||||
public void CollapseAll() => _tree?.CollapseAll();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
|
||||
public enum TemplateTreeNodeKind
|
||||
{
|
||||
Folder,
|
||||
Template,
|
||||
|
||||
/// <summary>
|
||||
/// Composition slot under a parent Template — produced only by callers that
|
||||
/// supply <c>TemplateFolderTree.ExtraTemplateChildren</c>. The Transport
|
||||
/// Export wizard intentionally never emits this kind (compositions aren't
|
||||
/// independently exportable); the Templates page uses it to surface slots.
|
||||
/// </summary>
|
||||
Composition,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adapter node used by <c>TemplateFolderTree</c> to model the template-folder
|
||||
/// hierarchy in a TreeView. Folder nodes carry sub-folders + their templates as
|
||||
/// children; template nodes are leaves unless the caller injects extras via
|
||||
/// <c>TemplateFolderTree.ExtraTemplateChildren</c> (e.g. composition slots).
|
||||
/// </summary>
|
||||
public sealed class TemplateTreeNode
|
||||
{
|
||||
/// <summary>Discriminator indicating whether this node represents a folder, template, or composition slot.</summary>
|
||||
public required TemplateTreeNodeKind Kind { get; init; }
|
||||
/// <summary>Database id of the underlying folder, template, or composition record.</summary>
|
||||
public required int Id { get; init; }
|
||||
/// <summary>Display name of the node.</summary>
|
||||
public required string Name { get; init; }
|
||||
/// <summary>Child nodes (sub-folders, templates, or composition slots).</summary>
|
||||
public List<TemplateTreeNode> Children { get; } = new();
|
||||
|
||||
/// <summary>Stable key for TreeView selection / expansion tracking.</summary>
|
||||
public string Key => Kind switch
|
||||
{
|
||||
TemplateTreeNodeKind.Folder => $"f:{Id}",
|
||||
TemplateTreeNodeKind.Template => $"t:{Id}",
|
||||
TemplateTreeNodeKind.Composition => $"c:{Id}",
|
||||
_ => $"x:{Id}",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
@* Displays a UTC DateTimeOffset formatted for display. Tooltip shows UTC value.
|
||||
A null Value renders as a plain "never" placeholder — used for timestamps that
|
||||
have not happened yet (e.g. a heartbeat-only site with no full report). *@
|
||||
|
||||
@if (Value is { } value)
|
||||
{
|
||||
<span title="@value.UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss") UTC">@value.LocalDateTime.ToString(Format)</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">@NullText</span>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public DateTimeOffset? Value { get; set; }
|
||||
[Parameter] public string Format { get; set; } = "yyyy-MM-dd HH:mm:ss";
|
||||
|
||||
/// <summary>Text shown when <see cref="Value"/> is null.</summary>
|
||||
[Parameter] public string NullText { get; set; } = "never";
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
@*
|
||||
Reusable toast notification component.
|
||||
|
||||
z-index ladder:
|
||||
Toast container 1090 (this component, on top)
|
||||
ConfirmDialog modal element 1055 (Bootstrap default for .modal)
|
||||
ConfirmDialog backdrop 1040 (Bootstrap default for .modal-backdrop)
|
||||
|
||||
Toasts intentionally float above ConfirmDialog so confirmation feedback
|
||||
(Success/Error) is visible even while a dialog is open.
|
||||
*@
|
||||
@implements IDisposable
|
||||
|
||||
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 1090;" aria-live="polite" aria-atomic="true">
|
||||
@foreach (var toast in _toasts)
|
||||
{
|
||||
<div class="toast show mb-2" role="alert">
|
||||
<div class="toast-header @GetHeaderClass(toast.Type)">
|
||||
<strong class="me-auto">@toast.Title</strong>
|
||||
<button type="button" class="btn-close btn-close-white" @onclick="() => Dismiss(toast)"></button>
|
||||
</div>
|
||||
<div class="toast-body">@toast.Message</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private const int DefaultAutoDismissMs = 5000;
|
||||
|
||||
private readonly List<ToastItem> _toasts = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
// Cancels all pending auto-dismiss delays when the component is disposed
|
||||
// (CentralUI-010) so their continuations never touch a disposed component.
|
||||
private readonly CancellationTokenSource _disposalCts = new();
|
||||
|
||||
/// <summary>Number of toasts currently displayed.</summary>
|
||||
public int ToastCount
|
||||
{
|
||||
get { lock (_lock) { return _toasts.Count; } }
|
||||
}
|
||||
|
||||
public void ShowSuccess(string message, string title = "Success", int? autoDismissMs = null)
|
||||
{
|
||||
AddToast(title, message, ToastType.Success, autoDismissMs);
|
||||
}
|
||||
|
||||
public void ShowError(string message, string title = "Error", int? autoDismissMs = null)
|
||||
{
|
||||
AddToast(title, message, ToastType.Error, autoDismissMs);
|
||||
}
|
||||
|
||||
public void ShowWarning(string message, string title = "Warning", int? autoDismissMs = null)
|
||||
{
|
||||
AddToast(title, message, ToastType.Warning, autoDismissMs);
|
||||
}
|
||||
|
||||
public void ShowInfo(string message, string title = "Info", int? autoDismissMs = null)
|
||||
{
|
||||
AddToast(title, message, ToastType.Info, autoDismissMs);
|
||||
}
|
||||
|
||||
private void AddToast(string title, string message, ToastType type, int? autoDismissMs)
|
||||
{
|
||||
// If the component is already disposed, do not add or schedule anything.
|
||||
if (_disposalCts.IsCancellationRequested) return;
|
||||
|
||||
var toast = new ToastItem { Title = title, Message = message, Type = type };
|
||||
lock (_lock)
|
||||
{
|
||||
_toasts.Add(toast);
|
||||
}
|
||||
StateHasChanged();
|
||||
|
||||
var dismissMs = autoDismissMs ?? DefaultAutoDismissMs;
|
||||
_ = AutoDismissAsync(toast, dismissMs, _disposalCts.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a toast after its dismiss delay. The delay is bound to the
|
||||
/// component's disposal token (CentralUI-010): if the host page is disposed
|
||||
/// first, the delay is cancelled and the continuation never touches the
|
||||
/// disposed component — no <see cref="ObjectDisposedException"/> escapes.
|
||||
/// </summary>
|
||||
private async Task AutoDismissAsync(ToastItem toast, int dismissMs, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(dismissMs, token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (token.IsCancellationRequested) return;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_toasts.Remove(toast);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Component disposed between the token check and the render — ignore.
|
||||
}
|
||||
}
|
||||
|
||||
private void Dismiss(ToastItem toast)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_toasts.Remove(toast);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetHeaderClass(ToastType type) => type switch
|
||||
{
|
||||
ToastType.Success => "bg-success text-white",
|
||||
ToastType.Error => "bg-danger text-white",
|
||||
ToastType.Warning => "bg-warning text-dark",
|
||||
ToastType.Info => "bg-info text-dark",
|
||||
_ => "bg-secondary text-white"
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposalCts.Cancel();
|
||||
_disposalCts.Dispose();
|
||||
}
|
||||
|
||||
private enum ToastType { Success, Error, Warning, Info }
|
||||
|
||||
private class ToastItem
|
||||
{
|
||||
public string Title { get; init; } = "";
|
||||
public string Message { get; init; } = "";
|
||||
public ToastType Type { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
@* Reusable hierarchical tree view with expand/collapse, ARIA roles, and guide lines *@
|
||||
@typeparam TItem
|
||||
@inject IJSRuntime JSRuntime
|
||||
|
||||
@if (_items is null || _items.Count == 0)
|
||||
{
|
||||
if (EmptyContent != null)
|
||||
{
|
||||
@EmptyContent
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul role="tree" class="tv-root @(ShowGuideLines ? "tv-guides" : "")">
|
||||
@foreach (var item in _items)
|
||||
{
|
||||
RenderNode(item, 0);
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@if (_showContextMenu && _contextMenuItem != null && ContextMenu != null)
|
||||
{
|
||||
<div class="tv-ctx-overlay" @onclick="DismissContextMenu" style="position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:1049;background:transparent;"></div>
|
||||
<div class="dropdown-menu show" tabindex="-1" @ref="_contextMenuRef" @onkeydown="OnContextMenuKeyDown"
|
||||
@onclick="DismissContextMenu"
|
||||
style="position:fixed;top:@(_contextMenuY)px;left:@(_contextMenuX)px;z-index:1050;outline:none;">
|
||||
@ContextMenu(_contextMenuItem)
|
||||
</div>
|
||||
}
|
||||
|
||||
@{ void RenderNode(TItem item, int depth)
|
||||
{
|
||||
var key = KeySelector(item);
|
||||
var children = ChildrenSelector(item);
|
||||
var isBranch = HasChildrenSelector(item);
|
||||
var isExpanded = _expandedKeys.Contains(KeyStr(key));
|
||||
var isSelected = Selectable && SelectedKey != null && SelectedKey.Equals(key);
|
||||
var rowClasses = "tv-row" + (isSelected ? " tv-selected " + SelectedCssClass : "");
|
||||
|
||||
// Checkbox-mode tri-state computed for this node (folder = aggregate of
|
||||
// descendant leaves; leaf = present-in-SelectedKeys).
|
||||
var checkState = SelectionMode == TreeViewSelectionMode.Checkbox
|
||||
? ComputeCheckState(item)
|
||||
: CheckState.Unchecked;
|
||||
|
||||
<li role="treeitem" @key="key"
|
||||
aria-expanded="@(isBranch ? (isExpanded ? "true" : "false") : null)"
|
||||
aria-selected="@(isSelected ? "true" : null)">
|
||||
<div class="@rowClasses" style="padding-left: @(depth * IndentPx)px; --tv-depth: @depth;"
|
||||
@oncontextmenu="(e) => OnContextMenu(e, item)" @oncontextmenu:preventDefault="@(ContextMenu != null)">
|
||||
@if (isBranch)
|
||||
{
|
||||
<span class="tv-toggle" @onclick="() => ToggleExpand(key)" @onclick:stopPropagation><i class="bi bi-chevron-right"></i></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="tv-spacer"></span>
|
||||
}
|
||||
@if (SelectionMode == TreeViewSelectionMode.Checkbox)
|
||||
{
|
||||
<input type="checkbox"
|
||||
class="form-check-input tv-checkbox @(checkState == CheckState.Indeterminate ? "tv-checkbox-indeterminate" : "")"
|
||||
@ref="_checkboxRefs[KeyStr(key)]"
|
||||
checked="@(checkState == CheckState.Checked)"
|
||||
@onchange="() => OnCheckboxToggle(item)"
|
||||
@onclick:stopPropagation />
|
||||
}
|
||||
<span class="tv-content" @onclick="() => OnContentClick(key)" @onclick:stopPropagation>
|
||||
@NodeContent(item)
|
||||
</span>
|
||||
</div>
|
||||
@if (isBranch && isExpanded && children is { Count: > 0 })
|
||||
{
|
||||
<ul role="group">
|
||||
@foreach (var child in children)
|
||||
{
|
||||
RenderNode(child, depth + 1);
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
private IReadOnlyList<TItem>? _items;
|
||||
private HashSet<string> _expandedKeys = new();
|
||||
|
||||
/// <summary>Normalize any key object to a string for consistent comparison with sessionStorage.</summary>
|
||||
private string KeyStr(object key) => key.ToString()!;
|
||||
private bool _initialExpansionApplied;
|
||||
private bool _storageLoaded;
|
||||
private TItem? _contextMenuItem;
|
||||
private double _contextMenuX;
|
||||
private double _contextMenuY;
|
||||
private bool _showContextMenu;
|
||||
private bool _contextMenuNeedsFocus;
|
||||
private ElementReference _contextMenuRef;
|
||||
|
||||
[Parameter, EditorRequired] public IReadOnlyList<TItem> Items { get; set; } = [];
|
||||
[Parameter, EditorRequired] public Func<TItem, IReadOnlyList<TItem>> ChildrenSelector { get; set; } = default!;
|
||||
[Parameter, EditorRequired] public Func<TItem, bool> HasChildrenSelector { get; set; } = default!;
|
||||
[Parameter, EditorRequired] public Func<TItem, object> KeySelector { get; set; } = default!;
|
||||
[Parameter, EditorRequired] public RenderFragment<TItem> NodeContent { get; set; } = default!;
|
||||
[Parameter] public RenderFragment? EmptyContent { get; set; }
|
||||
[Parameter] public RenderFragment<TItem>? ContextMenu { get; set; }
|
||||
[Parameter] public int IndentPx { get; set; } = 24;
|
||||
[Parameter] public bool ShowGuideLines { get; set; } = true;
|
||||
[Parameter] public Func<TItem, bool>? InitiallyExpanded { get; set; }
|
||||
[Parameter] public bool Selectable { get; set; }
|
||||
[Parameter] public object? SelectedKey { get; set; }
|
||||
[Parameter] public EventCallback<object?> SelectedKeyChanged { get; set; }
|
||||
[Parameter] public string SelectedCssClass { get; set; } = "bg-primary bg-opacity-10";
|
||||
[Parameter] public string? StorageKey { get; set; }
|
||||
|
||||
// ── Checkbox-selection mode (additive; SelectionMode=Single keeps prior behaviour) ──
|
||||
[Parameter] public TreeViewSelectionMode SelectionMode { get; set; } = TreeViewSelectionMode.Single;
|
||||
[Parameter] public HashSet<object>? SelectedKeys { get; set; }
|
||||
[Parameter] public EventCallback<HashSet<object>> SelectedKeysChanged { get; set; }
|
||||
|
||||
private readonly Dictionary<string, ElementReference> _checkboxRefs = new();
|
||||
|
||||
private enum CheckState { Unchecked, Checked, Indeterminate }
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_items = Items;
|
||||
|
||||
if (!_initialExpansionApplied && InitiallyExpanded != null && _items is { Count: > 0 })
|
||||
{
|
||||
// Only apply InitiallyExpanded when there is no StorageKey, or storage
|
||||
// has already been checked and returned nothing (no prior state).
|
||||
if (StorageKey == null || (_storageLoaded && _expandedKeys.Count == 0))
|
||||
{
|
||||
_initialExpansionApplied = true;
|
||||
ApplyInitialExpansion(_items);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear selection if the selected key no longer exists in the current items tree
|
||||
if (Selectable && SelectedKey != null && _items is not null && !KeyExistsInTree(_items, SelectedKey))
|
||||
{
|
||||
_ = SelectedKeyChanged.InvokeAsync(null);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (_contextMenuNeedsFocus && _showContextMenu)
|
||||
{
|
||||
_contextMenuNeedsFocus = false;
|
||||
// The context-menu element may have been removed (menu dismissed
|
||||
// during render) or the circuit disconnected — both are expected.
|
||||
try { await _contextMenuRef.FocusAsync(); }
|
||||
catch (Microsoft.JSInterop.JSException) { }
|
||||
catch (Microsoft.JSInterop.JSDisconnectedException) { }
|
||||
catch (InvalidOperationException) { }
|
||||
}
|
||||
|
||||
if (firstRender && StorageKey != null)
|
||||
{
|
||||
string? json = null;
|
||||
try
|
||||
{
|
||||
json = await JSRuntime.InvokeAsync<string?>("treeviewStorage.load", StorageKey);
|
||||
}
|
||||
catch (Microsoft.JSInterop.JSDisconnectedException)
|
||||
{
|
||||
// Circuit disconnected before the storage read completed — there
|
||||
// is nothing to restore and nothing to log.
|
||||
}
|
||||
_storageLoaded = true;
|
||||
|
||||
// CentralUI-018: a corrupt or wrong-shaped treeviewStorage payload
|
||||
// must not throw out of OnAfterRenderAsync. Guard the deserialize
|
||||
// and treat an unparseable payload as "no prior state".
|
||||
List<string>? keys = null;
|
||||
if (json != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
keys = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
keys = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (keys != null)
|
||||
{
|
||||
// Union (don't replace): callers may have invoked RevealNode before
|
||||
// this async storage load completed. Preserving those reveal-added
|
||||
// keys ensures deep-link reveal isn't clobbered by the restore.
|
||||
foreach (var k in keys) _expandedKeys.Add(k);
|
||||
_initialExpansionApplied = true;
|
||||
}
|
||||
else if (InitiallyExpanded != null && _items is { Count: > 0 } && !_initialExpansionApplied)
|
||||
{
|
||||
// Storage returned null or a corrupt payload (no usable prior
|
||||
// state) — fall back to InitiallyExpanded.
|
||||
_initialExpansionApplied = true;
|
||||
ApplyInitialExpansion(_items);
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
// Apply checkbox tri-state (`indeterminate`) after every render in
|
||||
// Checkbox mode. Blazor doesn't bind input.indeterminate natively.
|
||||
if (SelectionMode == TreeViewSelectionMode.Checkbox && _items is { Count: > 0 })
|
||||
{
|
||||
await ApplyIndeterminateStateAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApplyIndeterminateStateAsync()
|
||||
{
|
||||
foreach (var (keyStr, elemRef) in _checkboxRefs)
|
||||
{
|
||||
var item = FindItemByKey(_items!, keyStr);
|
||||
if (item is null) continue;
|
||||
var state = ComputeCheckState(item);
|
||||
try
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync(
|
||||
"treeviewStorage.setIndeterminate",
|
||||
elemRef,
|
||||
state == CheckState.Indeterminate);
|
||||
}
|
||||
catch (Microsoft.JSInterop.JSDisconnectedException) { /* circuit gone */ }
|
||||
catch (Microsoft.JSInterop.JSException) { /* element gone */ }
|
||||
}
|
||||
}
|
||||
|
||||
private bool KeyExistsInTree(IReadOnlyList<TItem> items, object key)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (key.Equals(KeySelector(item)))
|
||||
return true;
|
||||
|
||||
var children = ChildrenSelector(item);
|
||||
if (children is { Count: > 0 } && KeyExistsInTree(children, key))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void ApplyInitialExpansion(IReadOnlyList<TItem> items)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (InitiallyExpanded!(item))
|
||||
{
|
||||
_expandedKeys.Add(KeyStr(KeySelector(item)));
|
||||
}
|
||||
|
||||
var children = ChildrenSelector(item);
|
||||
if (children is { Count: > 0 })
|
||||
{
|
||||
ApplyInitialExpansion(children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleExpand(object key)
|
||||
{
|
||||
var k = KeyStr(key);
|
||||
if (!_expandedKeys.Remove(k))
|
||||
{
|
||||
_expandedKeys.Add(k);
|
||||
}
|
||||
|
||||
PersistExpandedState();
|
||||
}
|
||||
|
||||
private void PersistExpandedState()
|
||||
{
|
||||
if (StorageKey != null)
|
||||
{
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(_expandedKeys.ToList());
|
||||
_ = JSRuntime.InvokeVoidAsync("treeviewStorage.save", StorageKey, json);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnContentClick(object key)
|
||||
{
|
||||
if (Selectable)
|
||||
{
|
||||
await SelectedKeyChanged.InvokeAsync(key);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnContextMenu(MouseEventArgs e, TItem item)
|
||||
{
|
||||
if (ContextMenu == null) return;
|
||||
|
||||
_contextMenuItem = item;
|
||||
_contextMenuX = e.ClientX;
|
||||
_contextMenuY = e.ClientY;
|
||||
_showContextMenu = true;
|
||||
_contextMenuNeedsFocus = true;
|
||||
}
|
||||
|
||||
private void DismissContextMenu()
|
||||
{
|
||||
_showContextMenu = false;
|
||||
_contextMenuItem = default;
|
||||
}
|
||||
|
||||
private void OnContextMenuKeyDown(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Escape")
|
||||
{
|
||||
DismissContextMenu();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Whether the node with the given key is currently expanded.</summary>
|
||||
public bool IsExpanded(object key) => _expandedKeys.Contains(KeyStr(key));
|
||||
|
||||
/// <summary>Expand every branch node in the tree.</summary>
|
||||
public void ExpandAll()
|
||||
{
|
||||
if (_items is { Count: > 0 })
|
||||
{
|
||||
ExpandAllRecursive(_items);
|
||||
}
|
||||
|
||||
PersistExpandedState();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void ExpandAllRecursive(IReadOnlyList<TItem> items)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (HasChildrenSelector(item))
|
||||
{
|
||||
_expandedKeys.Add(KeyStr(KeySelector(item)));
|
||||
}
|
||||
|
||||
var children = ChildrenSelector(item);
|
||||
if (children is { Count: > 0 })
|
||||
{
|
||||
ExpandAllRecursive(children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Collapse every node in the tree.</summary>
|
||||
public void CollapseAll()
|
||||
{
|
||||
_expandedKeys.Clear();
|
||||
PersistExpandedState();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expand all ancestors of the given key so it becomes visible.
|
||||
/// Optionally select the node.
|
||||
/// </summary>
|
||||
public async Task RevealNode(object key, bool select = false)
|
||||
{
|
||||
var parentLookup = BuildParentLookup();
|
||||
var k = KeyStr(key);
|
||||
|
||||
// If key is not in the tree at all, no-op
|
||||
if (!parentLookup.ContainsKey(k))
|
||||
return;
|
||||
|
||||
// Walk up through ancestors
|
||||
var current = k;
|
||||
while (parentLookup.TryGetValue(current, out var parentKey) && parentKey != null)
|
||||
{
|
||||
_expandedKeys.Add(parentKey);
|
||||
current = parentKey;
|
||||
}
|
||||
|
||||
if (select && Selectable)
|
||||
{
|
||||
await SelectedKeyChanged.InvokeAsync(key);
|
||||
}
|
||||
|
||||
PersistExpandedState();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private Dictionary<string, string?> BuildParentLookup()
|
||||
{
|
||||
var lookup = new Dictionary<string, string?>();
|
||||
if (_items is { Count: > 0 })
|
||||
{
|
||||
BuildParentLookupRecursive(_items, null, lookup);
|
||||
}
|
||||
return lookup;
|
||||
}
|
||||
|
||||
private void BuildParentLookupRecursive(IReadOnlyList<TItem> items, string? parentKey, Dictionary<string, string?> lookup)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
var key = KeyStr(KeySelector(item));
|
||||
lookup[key] = parentKey;
|
||||
|
||||
var children = ChildrenSelector(item);
|
||||
if (children is { Count: > 0 })
|
||||
{
|
||||
BuildParentLookupRecursive(children, key, lookup);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Checkbox-selection helpers ──────────────────────────────────────────
|
||||
// Folder = aggregate of its descendant LEAVES (we don't track folder keys
|
||||
// in SelectedKeys — only leaf keys are persisted). A folder is Checked when
|
||||
// every descendant leaf is in SelectedKeys, Unchecked when none are, and
|
||||
// Indeterminate otherwise.
|
||||
private CheckState ComputeCheckState(TItem item)
|
||||
{
|
||||
var children = ChildrenSelector(item);
|
||||
var isBranch = HasChildrenSelector(item);
|
||||
if (!isBranch || children is null || children.Count == 0)
|
||||
{
|
||||
var leafKey = KeySelector(item);
|
||||
return SelectedKeys != null && SelectedKeys.Contains(leafKey)
|
||||
? CheckState.Checked
|
||||
: CheckState.Unchecked;
|
||||
}
|
||||
|
||||
var total = 0;
|
||||
var selected = 0;
|
||||
CountDescendantLeaves(item, ref total, ref selected);
|
||||
if (total == 0) return CheckState.Unchecked;
|
||||
if (selected == 0) return CheckState.Unchecked;
|
||||
if (selected == total) return CheckState.Checked;
|
||||
return CheckState.Indeterminate;
|
||||
}
|
||||
|
||||
private void CountDescendantLeaves(TItem item, ref int total, ref int selected)
|
||||
{
|
||||
var children = ChildrenSelector(item);
|
||||
var hasChildren = HasChildrenSelector(item) && children is { Count: > 0 };
|
||||
if (!hasChildren)
|
||||
{
|
||||
total++;
|
||||
if (SelectedKeys != null && SelectedKeys.Contains(KeySelector(item)))
|
||||
{
|
||||
selected++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var child in children!)
|
||||
{
|
||||
CountDescendantLeaves(child, ref total, ref selected);
|
||||
}
|
||||
}
|
||||
|
||||
private void CollectDescendantLeafKeys(TItem item, List<object> sink)
|
||||
{
|
||||
var children = ChildrenSelector(item);
|
||||
var hasChildren = HasChildrenSelector(item) && children is { Count: > 0 };
|
||||
if (!hasChildren)
|
||||
{
|
||||
sink.Add(KeySelector(item));
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var child in children!)
|
||||
{
|
||||
CollectDescendantLeafKeys(child, sink);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnCheckboxToggle(TItem item)
|
||||
{
|
||||
// SelectedKeys is the source of truth — copy-on-write so consumers
|
||||
// observe a fresh reference and Blazor reliably re-renders.
|
||||
var current = SelectedKeys is null
|
||||
? new HashSet<object>()
|
||||
: new HashSet<object>(SelectedKeys);
|
||||
|
||||
var leaves = new List<object>();
|
||||
CollectDescendantLeafKeys(item, leaves);
|
||||
if (leaves.Count == 0) return;
|
||||
|
||||
// Folder-toggle semantics: if every descendant leaf is currently selected,
|
||||
// uncheck them all; otherwise select all. Leaf nodes have leaves = { self }
|
||||
// so this simplifies to a plain toggle.
|
||||
var allSelected = leaves.All(current.Contains);
|
||||
if (allSelected)
|
||||
{
|
||||
foreach (var k in leaves) current.Remove(k);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var k in leaves) current.Add(k);
|
||||
}
|
||||
|
||||
SelectedKeys = current;
|
||||
await SelectedKeysChanged.InvokeAsync(current);
|
||||
}
|
||||
|
||||
private TItem? FindItemByKey(IReadOnlyList<TItem> items, string keyStr)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (KeyStr(KeySelector(item)) == keyStr) return item;
|
||||
var children = ChildrenSelector(item);
|
||||
if (children is { Count: > 0 })
|
||||
{
|
||||
var found = FindItemByKey(children, keyStr);
|
||||
if (found is not null) return found;
|
||||
}
|
||||
}
|
||||
return default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/* TreeView component styling — see docs/requirements/Component-TreeView.md "Visual Design Guide" (V1–V7). */
|
||||
|
||||
/* Root list — no list styling. */
|
||||
.tv-root,
|
||||
.tv-root ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* V1 — Row anatomy. Flex container; full-width hit surface; ~32px row. */
|
||||
.tv-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
cursor: default;
|
||||
border-radius: 0.25rem;
|
||||
transition: background-color 0.08s linear;
|
||||
}
|
||||
|
||||
/* V1 — slot widths. Toggle and glyph are always present so labels align across siblings. */
|
||||
.tv-row .tv-toggle,
|
||||
.tv-row .tv-spacer {
|
||||
flex: 0 0 auto;
|
||||
width: 1.25rem; /* 20px */
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--bs-secondary-color);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tv-row .tv-spacer {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tv-row .tv-content {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0; /* required so child label can ellipsis-truncate inside a flex item */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* V5 — primary label truncation. Consumers add their own bold class for branches. */
|
||||
.tv-row .tv-label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* V1 — meta slot right-aligns trailing badges/text against row edge. */
|
||||
.tv-row .tv-meta {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* V4 — glyph slot. Bootstrap Icons render at 1em, inherit text color. */
|
||||
.tv-row .tv-glyph {
|
||||
flex: 0 0 auto;
|
||||
width: 1.25rem; /* 20px, same slot size as toggle */
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* V3 — hover. Subtle gray wash; suppressed when row is selected. */
|
||||
.tv-row:hover:not(.tv-selected) {
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
/* V3 — selected. Bootstrap utility `bg-primary bg-opacity-10` is the SelectedCssClass default;
|
||||
the .tv-selected hook is provided for consumers that prefer scoped styling. */
|
||||
.tv-row.tv-selected {
|
||||
background-color: rgba(var(--bs-primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
/* V3 — keyboard focus. Inset ring composes with hover/selected without layout shift. */
|
||||
.tv-row:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px var(--bs-primary);
|
||||
}
|
||||
|
||||
/* V3 — drop-target (valid). Overrides hover/selected. */
|
||||
.tv-row.tv-drop-target {
|
||||
background-color: rgba(var(--bs-info-rgb), 0.25);
|
||||
}
|
||||
|
||||
/* V3 — dimmed (e.g. non-droppable while a drag is in progress; reserved for future use). */
|
||||
.tv-row.tv-dimmed {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* V2 — guide lines. A pseudo-element overlays the row's depth gutter and draws
|
||||
one vertical line per ancestor depth at 24px intervals. The `--tv-depth` variable
|
||||
is set inline per row; lines never extend into the content area. */
|
||||
.tv-guides .tv-row {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tv-guides .tv-row::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: calc(var(--tv-depth, 0) * 1.5rem);
|
||||
pointer-events: none;
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
transparent calc(0.625rem - 0.5px),
|
||||
var(--bs-border-color) calc(0.625rem - 0.5px),
|
||||
var(--bs-border-color) calc(0.625rem + 0.5px),
|
||||
transparent calc(0.625rem + 0.5px)
|
||||
);
|
||||
background-size: 1.5rem 100%;
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
|
||||
/* Branch chevron rotates on expand via the aria-expanded attribute on the parent treeitem.
|
||||
Consumers using `bi-chevron-right` get the down-rotation for free. */
|
||||
.tv-row .tv-toggle .bi-chevron-right {
|
||||
transition: transform 0.1s linear;
|
||||
}
|
||||
|
||||
[role="treeitem"][aria-expanded="true"] > .tv-row .tv-toggle .bi-chevron-right {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Per-row kebab (More actions): hidden by default, revealed on row hover or when
|
||||
the dropdown is open. Consumers render `<span class="tv-kebab ...">` inside
|
||||
NodeContent to opt in. */
|
||||
.tv-row .tv-kebab {
|
||||
flex: 0 0 auto;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s linear;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.tv-row:hover .tv-kebab,
|
||||
.tv-row:focus-within .tv-kebab,
|
||||
.tv-row .tv-kebab.show,
|
||||
.tv-row .tv-kebab .show {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Selection mode for <see cref="TreeView{TItem}"/>. <see cref="Single"/> is the
|
||||
/// default click-to-select behaviour (preserves legacy callers). <see cref="Checkbox"/>
|
||||
/// renders an input checkbox per node with tri-state propagation on folders.
|
||||
/// </summary>
|
||||
public enum TreeViewSelectionMode
|
||||
{
|
||||
Single,
|
||||
Checkbox,
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.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>
|
||||
/// <param name="choices">The full flattened attribute choice list from the trigger editor.</param>
|
||||
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>
|
||||
/// <param name="choices">The full flattened attribute choice list from the trigger editor.</param>
|
||||
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