feat(ui): structured editors for script schemas and alarm triggers

Replace raw-JSON text inputs with rich UI: script parameter/return types use
a JSON Schema builder (SchemaBuilder + JsonSchemaShapeParser, with a migration
to convert existing definitions); alarm trigger config uses a type-aware
editor with a flattened attribute picker (AlarmTriggerEditor). AlarmActor
gains optional direction (rising/falling/either) on RateOfChange triggers.
This commit is contained in:
Joseph Doherty
2026-05-13 00:33:00 -04:00
parent 57f477fd28
commit 783da8e21a
25 changed files with 3609 additions and 861 deletions

View File

@@ -30,11 +30,15 @@
</div>
<div class="mb-3">
<label class="form-label">Parameters</label>
<ParameterListEditor Json="@_params" JsonChanged="@(v => _params = v)" />
<SchemaBuilder Mode="object"
Value="@_params"
ValueChanged="@(v => _params = v)" />
</div>
<div class="mb-3">
<label class="form-label">Return value</label>
<ReturnTypeEditor Json="@_returns" JsonChanged="@(v => _returns = v)" />
<SchemaBuilder Mode="value"
Value="@_returns"
ValueChanged="@(v => _returns = v)" />
</div>
<div class="mb-3">
<label class="form-label">Script</label>

View File

@@ -32,11 +32,15 @@
</div>
<div class="mb-3">
<label class="form-label small">Parameters</label>
<ParameterListEditor Json="@_formParameters" JsonChanged="@(v => _formParameters = v)" />
<SchemaBuilder Mode="object"
Value="@_formParameters"
ValueChanged="@(v => _formParameters = v)" />
</div>
<div class="mb-3">
<label class="form-label small">Return value</label>
<ReturnTypeEditor Json="@_formReturn" JsonChanged="@(v => _formReturn = v)" />
<SchemaBuilder Mode="value"
Value="@_formReturn"
ValueChanged="@(v => _formReturn = v)" />
</div>
<div class="mb-2">
<label class="form-label small">Code</label>

View File

@@ -70,8 +70,9 @@
private bool _validating;
private Commons.Types.Flattening.ValidationResult? _validationResult;
// Member add forms
// Member add/edit forms. _edit*Id null = adding; non-null = editing that row.
private bool _showAttrForm;
private int? _editAttrId;
private string _attrName = string.Empty;
private string? _attrValue;
private DataType _attrDataType;
@@ -80,6 +81,7 @@
private string? _attrFormError;
private bool _showAlarmForm;
private int? _editAlarmId;
private string _alarmName = string.Empty;
private int _alarmPriority;
private AlarmTriggerType _alarmTriggerType;
@@ -88,6 +90,7 @@
private string? _alarmFormError;
private bool _showScriptForm;
private int? _editScriptId;
private string _scriptName = string.Empty;
private string _scriptCode = string.Empty;
private string? _scriptTriggerType;
@@ -96,6 +99,7 @@
private string? _scriptReturn;
private bool _scriptIsLocked;
private string? _scriptFormError;
private string _scriptModalTab = "code"; // "code" | "parameters" | "return"
private MonacoEditor? _scriptEditor;
private IReadOnlyList<ScadaLink.CentralUI.ScriptAnalysis.DiagnosticMarker> _scriptMarkers
= Array.Empty<ScadaLink.CentralUI.ScriptAnalysis.DiagnosticMarker>();
@@ -470,49 +474,57 @@
{
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="mb-0">Attributes</h5>
<button class="btn btn-primary btn-sm" @onclick="() => { _showAttrForm = true; _attrFormError = null; _attrName = string.Empty; _attrValue = null; _attrIsLocked = false; _attrDataSourceRef = null; }">Add Attribute</button>
<button class="btn btn-primary btn-sm" @onclick="BeginAddAttribute">Add Attribute</button>
</div>
@if (_showAttrForm)
{
<div class="card mb-3">
<div class="card-header">Add Attribute</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="_attrName" />
var editing = _editAttrId.HasValue;
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">@(editing ? "Edit Attribute" : "Add Attribute")</h6>
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAttributeForm"></button>
</div>
<div class="col-12">
<label class="form-label">Data Type</label>
<select class="form-select" @bind="_attrDataType">
@foreach (var dt in Enum.GetValues<DataType>())
<div class="modal-body">
<div class="row g-3">
<div class="col-12">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="_attrName" readonly="@editing" />
</div>
<div class="col-12">
<label class="form-label">Data Type</label>
<select class="form-select" @bind="_attrDataType">
@foreach (var dt in Enum.GetValues<DataType>())
{
<option value="@dt">@dt</option>
}
</select>
</div>
<div class="col-12">
<label class="form-label">Value</label>
<input type="text" class="form-control" @bind="_attrValue" />
</div>
<div class="col-12">
<label class="form-label">Data Source Ref</label>
<input type="text" class="form-control" @bind="_attrDataSourceRef" placeholder="Tag path" />
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="_attrIsLocked" id="attrLocked" />
<label class="form-check-label" for="attrLocked">Locked</label>
</div>
</div>
@if (_attrFormError != null)
{
<option value="@dt">@dt</option>
<div class="col-12"><div class="text-danger small">@_attrFormError</div></div>
}
</select>
</div>
<div class="col-12">
<label class="form-label">Value</label>
<input type="text" class="form-control" @bind="_attrValue" />
</div>
<div class="col-12">
<label class="form-label">Data Source Ref</label>
<input type="text" class="form-control" @bind="_attrDataSourceRef" placeholder="Tag path" />
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="_attrIsLocked" id="attrLocked" />
<label class="form-check-label" for="attrLocked">Locked</label>
</div>
</div>
@if (_attrFormError != null)
{
<div class="col-12"><div class="text-danger small">@_attrFormError</div></div>
}
<div class="col-12 text-end">
<button class="btn btn-outline-secondary me-1" @onclick="() => _showAttrForm = false">Cancel</button>
<button class="btn btn-success" @onclick="AddAttribute">Add</button>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelAttributeForm">Cancel</button>
<button class="btn btn-success btn-sm" @onclick="SaveAttribute">@(editing ? "Save" : "Add")</button>
</div>
</div>
</div>
@@ -616,6 +628,10 @@
}
@if (!(derived && baseAttr != null))
{
<li>
<button class="dropdown-item" @onclick="() => BeginEditAttribute(attr)">Edit…</button>
</li>
<li><hr class="dropdown-divider" /></li>
<li>
<button class="dropdown-item text-danger"
@onclick="() => DeleteAttribute(attr)">Delete</button>
@@ -701,49 +717,60 @@
{
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="mb-0">Alarms</h5>
<button class="btn btn-primary btn-sm" @onclick="() => { _showAlarmForm = true; _alarmFormError = null; _alarmName = string.Empty; _alarmPriority = 500; _alarmTriggerConfig = null; _alarmIsLocked = false; }">Add Alarm</button>
<button class="btn btn-primary btn-sm" @onclick="BeginAddAlarm">Add Alarm</button>
</div>
@if (_showAlarmForm)
{
<div class="card mb-3">
<div class="card-header">Add Alarm</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="_alarmName" />
var editing = _editAlarmId.HasValue;
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">@(editing ? "Edit Alarm" : "Add Alarm")</h6>
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAlarmForm"></button>
</div>
<div class="col-12">
<label class="form-label">Trigger Type</label>
<select class="form-select" @bind="_alarmTriggerType">
@foreach (var tt in Enum.GetValues<AlarmTriggerType>())
<div class="modal-body">
<div class="row g-3">
<div class="col-12">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="_alarmName" readonly="@editing" />
</div>
<div class="col-12">
<label class="form-label">Trigger Type</label>
<select class="form-select" @bind="_alarmTriggerType" disabled="@editing">
@foreach (var tt in Enum.GetValues<AlarmTriggerType>())
{
<option value="@tt">@tt</option>
}
</select>
</div>
<div class="col-12">
<label class="form-label">Priority</label>
<input type="number" class="form-control" @bind="_alarmPriority" min="0" max="1000" />
</div>
<div class="col-12">
<label class="form-label">Trigger Configuration</label>
<AlarmTriggerEditor TriggerType="@_alarmTriggerType"
Value="@_alarmTriggerConfig"
ValueChanged="@(v => _alarmTriggerConfig = v)"
AvailableAttributes="@BuildAlarmAttributeChoices()" />
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="_alarmIsLocked" id="alarmLocked" />
<label class="form-check-label" for="alarmLocked">Locked</label>
</div>
</div>
@if (_alarmFormError != null)
{
<option value="@tt">@tt</option>
<div class="col-12"><div class="text-danger small">@_alarmFormError</div></div>
}
</select>
</div>
<div class="col-12">
<label class="form-label">Priority</label>
<input type="number" class="form-control" @bind="_alarmPriority" min="0" max="1000" />
</div>
<div class="col-12">
<label class="form-label">Trigger Config (JSON)</label>
<input type="text" class="form-control" @bind="_alarmTriggerConfig" />
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="_alarmIsLocked" id="alarmLocked" />
<label class="form-check-label" for="alarmLocked">Locked</label>
</div>
</div>
@if (_alarmFormError != null)
{
<div class="col-12"><div class="text-danger small">@_alarmFormError</div></div>
}
<div class="col-12 text-end">
<button class="btn btn-outline-secondary me-1" @onclick="() => _showAlarmForm = false">Cancel</button>
<button class="btn btn-success" @onclick="AddAlarm">Add</button>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelAlarmForm">Cancel</button>
<button class="btn btn-success btn-sm" @onclick="SaveAlarm">@(editing ? "Save" : "Add")</button>
</div>
</div>
</div>
@@ -786,6 +813,10 @@
aria-expanded="false"
aria-label="@($"More actions for {alarm.Name}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item" @onclick="() => BeginEditAlarm(alarm)">Edit…</button>
</li>
<li><hr class="dropdown-divider" /></li>
<li>
<button class="dropdown-item text-danger"
@onclick="() => DeleteAlarm(alarm)">Delete</button>
@@ -804,61 +835,100 @@
{
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="mb-0">Scripts</h5>
<button class="btn btn-primary btn-sm" @onclick="() => { _showScriptForm = true; _scriptFormError = null; _scriptName = string.Empty; _scriptCode = string.Empty; _scriptTriggerType = null; _scriptTriggerConfig = null; _scriptParameters = null; _scriptReturn = null; _scriptIsLocked = false; }">Add Script</button>
<button class="btn btn-primary btn-sm" @onclick="BeginAddScript">Add Script</button>
</div>
@if (_showScriptForm)
{
<div class="card mb-3">
<div class="card-header">Add Script</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="_scriptName" />
var editingScript = _editScriptId.HasValue;
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">@(editingScript ? "Edit Script" : "Add Script")</h6>
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelScriptForm"></button>
</div>
<div class="col-12">
<label class="form-label">Trigger Type</label>
<input type="text" class="form-control" @bind="_scriptTriggerType" placeholder="e.g. ValueChange" />
</div>
<div class="col-12">
<label class="form-label">Trigger Config (JSON)</label>
<input type="text" class="form-control" @bind="_scriptTriggerConfig" />
</div>
<div class="col-12">
<label class="form-label">Parameters</label>
<ParameterListEditor Json="@_scriptParameters" JsonChanged="@(v => _scriptParameters = v)" />
</div>
<div class="col-12">
<label class="form-label">Return value</label>
<ReturnTypeEditor Json="@_scriptReturn" JsonChanged="@(v => _scriptReturn = v)" />
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="_scriptIsLocked" id="scriptLocked" />
<label class="form-check-label" for="scriptLocked">Locked</label>
<div class="modal-body">
<div class="row g-3 mb-3">
<div class="col-12">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="_scriptName" readonly="@editingScript" />
</div>
<div class="col-md-6">
<label class="form-label">Trigger Type</label>
<input type="text" class="form-control" @bind="_scriptTriggerType" placeholder="e.g. ValueChange" />
</div>
<div class="col-md-6">
<label class="form-label">Trigger Config (JSON)</label>
<input type="text" class="form-control" @bind="_scriptTriggerConfig" />
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="_scriptIsLocked" id="scriptLocked" />
<label class="form-check-label" for="scriptLocked">Locked</label>
</div>
</div>
</div>
@* Tabs: Code, Parameters, Return. Both editor panels stay
mounted (toggled via display:none) so Monaco and the
JSONJoy React island don't tear down on tab switch. *@
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button type="button"
class="nav-link @(_scriptModalTab == "code" ? "active" : "")"
role="tab"
aria-selected="@(_scriptModalTab == "code" ? "true" : "false")"
@onclick='() => _scriptModalTab = "code"'>Code</button>
</li>
<li class="nav-item" role="presentation">
<button type="button"
class="nav-link @(_scriptModalTab == "parameters" ? "active" : "")"
role="tab"
aria-selected="@(_scriptModalTab == "parameters" ? "true" : "false")"
@onclick='() => _scriptModalTab = "parameters"'>Parameters</button>
</li>
<li class="nav-item" role="presentation">
<button type="button"
class="nav-link @(_scriptModalTab == "return" ? "active" : "")"
role="tab"
aria-selected="@(_scriptModalTab == "return" ? "true" : "false")"
@onclick='() => _scriptModalTab = "return"'>Return type</button>
</li>
</ul>
<div class="border border-top-0 rounded-bottom p-3">
<div style="display: @(_scriptModalTab == "code" ? "block" : "none")">
<MonacoEditor @ref="_scriptEditor" Value="@_scriptCode" ValueChanged="@(v => _scriptCode = v)"
Language="csharp" Height="360px"
DeclaredParameters="@ScriptParameterNames.Parse(_scriptParameters)"
DeclaredParameterShapes="@ScriptParameterNames.ParseShapes(_scriptParameters)"
SiblingScripts="@(_scripts.Select(s => ScadaLink.CentralUI.ScriptAnalysis.ScriptShapeParser.Parse(s.Name, s.ParameterDefinitions, s.ReturnDefinition)).ToArray())"
SelfAttributes="@(_attributes.Select(a => new ScadaLink.CentralUI.ScriptAnalysis.AttributeShape(a.Name, MapDataType(a.DataType))).ToArray())"
Children="@_editorChildren"
Parent="@ActiveEditorParent"
MarkersChanged="@(m => { _scriptMarkers = m; StateHasChanged(); })" />
<ProblemsPanel Markers="@_scriptMarkers" OnNavigate="@(m => _scriptEditor?.RevealLineAsync(m.StartLineNumber, m.StartColumn) ?? Task.CompletedTask)" />
</div>
<div style="display: @(_scriptModalTab == "parameters" ? "block" : "none")">
<SchemaBuilder Mode="object"
Value="@_scriptParameters"
ValueChanged="@(v => _scriptParameters = v)" />
</div>
<div style="display: @(_scriptModalTab == "return" ? "block" : "none")">
<SchemaBuilder Mode="value"
Value="@_scriptReturn"
ValueChanged="@(v => _scriptReturn = v)" />
</div>
</div>
@if (_scriptFormError != null)
{
<div class="text-danger small mt-2">@_scriptFormError</div>
}
</div>
<div class="col-12">
<label class="form-label">Code</label>
<MonacoEditor @ref="_scriptEditor" Value="@_scriptCode" ValueChanged="@(v => _scriptCode = v)"
Language="csharp" Height="320px"
DeclaredParameters="@ScriptParameterNames.Parse(_scriptParameters)"
DeclaredParameterShapes="@ScriptParameterNames.ParseShapes(_scriptParameters)"
SiblingScripts="@(_scripts.Select(s => ScadaLink.CentralUI.ScriptAnalysis.ScriptShapeParser.Parse(s.Name, s.ParameterDefinitions, s.ReturnDefinition)).ToArray())"
SelfAttributes="@(_attributes.Select(a => new ScadaLink.CentralUI.ScriptAnalysis.AttributeShape(a.Name, MapDataType(a.DataType))).ToArray())"
Children="@_editorChildren"
Parent="@ActiveEditorParent"
MarkersChanged="@(m => { _scriptMarkers = m; StateHasChanged(); })" />
<ProblemsPanel Markers="@_scriptMarkers" OnNavigate="@(m => _scriptEditor?.RevealLineAsync(m.StartLineNumber, m.StartColumn) ?? Task.CompletedTask)" />
</div>
@if (_scriptFormError != null)
{
<div class="col-12"><div class="text-danger small">@_scriptFormError</div></div>
}
<div class="col-12 text-end">
<button class="btn btn-outline-secondary me-1" @onclick="() => _showScriptForm = false">Cancel</button>
<button class="btn btn-success" @onclick="AddScript">Add</button>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelScriptForm">Cancel</button>
<button class="btn btn-success btn-sm" @onclick="SaveScript">@(editingScript ? "Save" : "Add")</button>
</div>
</div>
</div>
@@ -952,6 +1022,10 @@
}
@if (!(derivedScripts && baseScript != null))
{
<li>
<button class="dropdown-item" @onclick="() => BeginEditScript(script)">Edit…</button>
</li>
<li><hr class="dropdown-divider" /></li>
<li>
<button class="dropdown-item text-danger"
@onclick="() => DeleteScript(script)">Delete</button>
@@ -1013,12 +1087,74 @@
// ---- CRUD handlers ----
private async Task AddAttribute()
private void BeginAddAttribute()
{
_showAttrForm = true;
_editAttrId = null;
_attrFormError = null;
_attrName = string.Empty;
_attrValue = null;
_attrDataType = default;
_attrIsLocked = false;
_attrDataSourceRef = null;
}
private void BeginEditAttribute(TemplateAttribute attr)
{
_showAttrForm = true;
_editAttrId = attr.Id;
_attrFormError = null;
_attrName = attr.Name;
_attrValue = attr.Value;
_attrDataType = attr.DataType;
_attrIsLocked = attr.IsLocked;
_attrDataSourceRef = attr.DataSourceReference;
}
private void CancelAttributeForm()
{
_showAttrForm = false;
_editAttrId = null;
_attrFormError = null;
}
private async Task SaveAttribute()
{
if (_selectedTemplate == null) return;
_attrFormError = null;
if (string.IsNullOrWhiteSpace(_attrName)) { _attrFormError = "Name is required."; return; }
var user = await GetCurrentUserAsync();
if (_editAttrId is int id)
{
var existing = _attributes.FirstOrDefault(a => a.Id == id);
if (existing == null) { _attrFormError = "Attribute no longer exists."; return; }
var proposed = new TemplateAttribute(existing.Name)
{
DataType = _attrDataType,
Value = _attrValue?.Trim(),
IsLocked = _attrIsLocked,
DataSourceReference = _attrDataSourceRef?.Trim(),
Description = existing.Description,
IsInherited = existing.IsInherited,
LockedInDerived = existing.LockedInDerived,
};
var result = await TemplateService.UpdateAttributeAsync(id, proposed, user);
if (result.IsSuccess)
{
_showAttrForm = false;
_editAttrId = null;
_toast.ShowSuccess($"Attribute '{existing.Name}' updated.");
await LoadAsync();
}
else
{
_attrFormError = result.Error;
}
return;
}
var attr = new TemplateAttribute(_attrName.Trim())
{
DataType = _attrDataType,
@@ -1027,9 +1163,8 @@
DataSourceReference = _attrDataSourceRef?.Trim()
};
var user = await GetCurrentUserAsync();
var result = await TemplateService.AddAttributeAsync(_selectedTemplate.Id, attr, user);
if (result.IsSuccess)
var addResult = await TemplateService.AddAttributeAsync(_selectedTemplate.Id, attr, user);
if (addResult.IsSuccess)
{
_showAttrForm = false;
_toast.ShowSuccess($"Attribute '{_attrName}' added.");
@@ -1037,7 +1172,7 @@
}
else
{
_attrFormError = result.Error;
_attrFormError = addResult.Error;
}
}
@@ -1058,12 +1193,105 @@
}
}
private async Task AddAlarm()
private void BeginAddAlarm()
{
_showAlarmForm = true;
_editAlarmId = null;
_alarmFormError = null;
_alarmName = string.Empty;
_alarmPriority = 500;
_alarmTriggerType = default;
_alarmTriggerConfig = null;
_alarmIsLocked = false;
}
/// <summary>
/// Builds the attribute choice list shown in the alarm trigger editor's
/// picker. Combines direct + inherited attributes (from <c>_attributes</c>)
/// with composed children's attributes (from <c>_editorChildren</c>,
/// path-qualified as <c>[ChildInstance].[AttributeName]</c>).
/// </summary>
private IReadOnlyList<AlarmAttributeChoice> BuildAlarmAttributeChoices()
{
var list = new List<AlarmAttributeChoice>(capacity: _attributes.Count + 8);
foreach (var a in _attributes)
{
list.Add(new AlarmAttributeChoice(
a.Name,
MapDataType(a.DataType),
a.IsInherited ? "Inherited" : "Direct"));
}
foreach (var child in _editorChildren)
{
foreach (var shape in child.Attributes)
{
list.Add(new AlarmAttributeChoice(
$"{child.Name}.{shape.Name}",
shape.Type,
"Composed"));
}
}
return list;
}
private void BeginEditAlarm(TemplateAlarm alarm)
{
_showAlarmForm = true;
_editAlarmId = alarm.Id;
_alarmFormError = null;
_alarmName = alarm.Name;
_alarmPriority = alarm.PriorityLevel;
_alarmTriggerType = alarm.TriggerType;
_alarmTriggerConfig = alarm.TriggerConfiguration;
_alarmIsLocked = alarm.IsLocked;
}
private void CancelAlarmForm()
{
_showAlarmForm = false;
_editAlarmId = null;
_alarmFormError = null;
}
private async Task SaveAlarm()
{
if (_selectedTemplate == null) return;
_alarmFormError = null;
if (string.IsNullOrWhiteSpace(_alarmName)) { _alarmFormError = "Name is required."; return; }
var user = await GetCurrentUserAsync();
if (_editAlarmId is int id)
{
var existing = _alarms.FirstOrDefault(a => a.Id == id);
if (existing == null) { _alarmFormError = "Alarm no longer exists."; return; }
var proposed = new TemplateAlarm(existing.Name)
{
TriggerType = existing.TriggerType, // fixed
PriorityLevel = _alarmPriority,
TriggerConfiguration = _alarmTriggerConfig?.Trim(),
IsLocked = _alarmIsLocked,
Description = existing.Description,
OnTriggerScriptId = existing.OnTriggerScriptId,
};
var result = await TemplateService.UpdateAlarmAsync(id, proposed, user);
if (result.IsSuccess)
{
_showAlarmForm = false;
_editAlarmId = null;
_toast.ShowSuccess($"Alarm '{existing.Name}' updated.");
await LoadAsync();
}
else
{
_alarmFormError = result.Error;
}
return;
}
var alarm = new TemplateAlarm(_alarmName.Trim())
{
TriggerType = _alarmTriggerType,
@@ -1072,9 +1300,8 @@
IsLocked = _alarmIsLocked
};
var user = await GetCurrentUserAsync();
var result = await TemplateService.AddAlarmAsync(_selectedTemplate.Id, alarm, user);
if (result.IsSuccess)
var addResult = await TemplateService.AddAlarmAsync(_selectedTemplate.Id, alarm, user);
if (addResult.IsSuccess)
{
_showAlarmForm = false;
_toast.ShowSuccess($"Alarm '{_alarmName}' added.");
@@ -1082,7 +1309,7 @@
}
else
{
_alarmFormError = result.Error;
_alarmFormError = addResult.Error;
}
}
@@ -1100,13 +1327,82 @@
else { _toast.ShowError(result.Error); }
}
private async Task AddScript()
private void BeginAddScript()
{
_showScriptForm = true;
_editScriptId = null;
_scriptFormError = null;
_scriptName = string.Empty;
_scriptCode = string.Empty;
_scriptTriggerType = null;
_scriptTriggerConfig = null;
_scriptParameters = null;
_scriptReturn = null;
_scriptIsLocked = false;
_scriptModalTab = "code";
}
private void BeginEditScript(TemplateScript script)
{
_showScriptForm = true;
_editScriptId = script.Id;
_scriptFormError = null;
_scriptName = script.Name;
_scriptCode = script.Code;
_scriptTriggerType = script.TriggerType;
_scriptTriggerConfig = script.TriggerConfiguration;
_scriptParameters = script.ParameterDefinitions;
_scriptReturn = script.ReturnDefinition;
_scriptIsLocked = script.IsLocked;
_scriptModalTab = "code";
}
private void CancelScriptForm()
{
_showScriptForm = false;
_editScriptId = null;
_scriptFormError = null;
}
private async Task SaveScript()
{
if (_selectedTemplate == null) return;
_scriptFormError = null;
if (string.IsNullOrWhiteSpace(_scriptName)) { _scriptFormError = "Name is required."; return; }
if (string.IsNullOrWhiteSpace(_scriptCode)) { _scriptFormError = "Code is required."; return; }
var user = await GetCurrentUserAsync();
if (_editScriptId is int id)
{
var existing = _scripts.FirstOrDefault(s => s.Id == id);
if (existing == null) { _scriptFormError = "Script no longer exists."; return; }
var proposed = new TemplateScript(existing.Name, _scriptCode)
{
TriggerType = _scriptTriggerType?.Trim(),
TriggerConfiguration = _scriptTriggerConfig?.Trim(),
ParameterDefinitions = _scriptParameters,
ReturnDefinition = _scriptReturn,
IsLocked = _scriptIsLocked,
MinTimeBetweenRuns = existing.MinTimeBetweenRuns,
IsInherited = existing.IsInherited,
LockedInDerived = existing.LockedInDerived,
};
var result = await TemplateService.UpdateScriptAsync(id, proposed, user);
if (result.IsSuccess)
{
_showScriptForm = false;
_editScriptId = null;
_toast.ShowSuccess($"Script '{existing.Name}' updated.");
await LoadAsync();
}
else
{
_scriptFormError = result.Error;
}
return;
}
var script = new TemplateScript(_scriptName.Trim(), _scriptCode)
{
TriggerType = _scriptTriggerType?.Trim(),
@@ -1116,9 +1412,8 @@
IsLocked = _scriptIsLocked
};
var user = await GetCurrentUserAsync();
var result = await TemplateService.AddScriptAsync(_selectedTemplate.Id, script, user);
if (result.IsSuccess)
var addResult = await TemplateService.AddScriptAsync(_selectedTemplate.Id, script, user);
if (addResult.IsSuccess)
{
_showScriptForm = false;
_toast.ShowSuccess($"Script '{_scriptName}' added.");
@@ -1126,7 +1421,7 @@
}
else
{
_scriptFormError = result.Error;
_scriptFormError = addResult.Error;
}
}

View File

@@ -0,0 +1,8 @@
namespace ScadaLink.CentralUI.Components.Shared;
/// <summary>
/// One option in the alarm trigger editor's attribute picker.
/// <see cref="Source"/> is one of "Direct", "Inherited", or "Composed" —
/// used to group entries in the dropdown.
/// </summary>
public record AlarmAttributeChoice(string CanonicalName, string DataType, string Source);

View File

@@ -0,0 +1,572 @@
@namespace ScadaLink.CentralUI.Components.Shared
@using System.Globalization
@using System.IO
@using System.Text
@using System.Text.Json
@using ScadaLink.Commons.Types.Enums
@* Rich alarm trigger configuration editor. Replaces the raw JSON text field
used for TemplateAlarm.TriggerConfiguration. The editor emits the same JSON
shape that AlarmActor.ParseEvalConfig consumes:
ValueMatch { attributeName, matchValue } ("!=X" prefix = not equals)
RangeViolation { attributeName, min, max }
RateOfChange { attributeName, thresholdPerSecond, windowSeconds, direction } *@
<div class="border rounded bg-white p-3">
@* ── Monitored attribute ───────────────────────────────────────────── *@
<div class="mb-3">
<label for="alarm-attr-select" class="form-label small text-uppercase text-muted fw-semibold mb-1">
Monitored attribute
</label>
<div class="input-group input-group-sm">
<select id="alarm-attr-select"
class="form-select"
@bind="_attributeName"
@bind:after="OnAttributeChanged">
<option value="">— select attribute —</option>
@{
var groups = AvailableAttributes
.GroupBy(c => c.Source)
.OrderBy(g => SourceOrder(g.Key))
.ToList();
}
@foreach (var grp in groups)
{
<optgroup label="@grp.Key">
@foreach (var choice in grp.OrderBy(c => c.CanonicalName, StringComparer.Ordinal))
{
var label = $"{choice.CanonicalName} ({choice.DataType})";
var disabled = !IsAttributeCompatible(choice);
<option value="@choice.CanonicalName" disabled="@disabled">@label</option>
}
</optgroup>
}
@* If the saved attribute name isn't in the current list, keep it selectable so it's visible. *@
@if (!string.IsNullOrEmpty(_model.AttributeName) && _selectedChoice == null)
{
<optgroup label="Unknown">
<option value="@_model.AttributeName">@_model.AttributeName (not found)</option>
</optgroup>
}
</select>
@if (_selectedDataType is { } dt)
{
<span class="input-group-text bg-light text-muted small">@dt</span>
}
</div>
@if (_selectedChoice != null && !IsAttributeCompatible(_selectedChoice))
{
<div class="form-text text-danger">
Selected attribute is @_selectedChoice.DataType — this trigger type requires a numeric attribute.
</div>
}
else if (_selectedChoice == null && !string.IsNullOrWhiteSpace(_model.AttributeName))
{
<div class="form-text text-warning-emphasis">
"@_model.AttributeName" is not in the current template. Save will still write it as-is.
</div>
}
</div>
@* ── Type-specific block ───────────────────────────────────────────── *@
@switch (TriggerType)
{
case AlarmTriggerType.ValueMatch:
@RenderValueMatch();
break;
case AlarmTriggerType.RangeViolation:
@RenderRangeViolation();
break;
case AlarmTriggerType.RateOfChange:
@RenderRateOfChange();
break;
}
@* ── Hint ──────────────────────────────────────────────────────────── *@
<div class="mt-3 pt-2 border-top small text-muted">
@BuildHint()
</div>
</div>
@code {
// ── Parameters ─────────────────────────────────────────────────────────
[Parameter] public AlarmTriggerType TriggerType { get; set; }
[Parameter] public string? Value { get; set; }
[Parameter] public EventCallback<string?> ValueChanged { get; set; }
/// <summary>
/// Flattened attribute list (direct + inherited + composed). Used to drive
/// the picker and to determine the selected attribute's data type for
/// type-aware inputs.
/// </summary>
[Parameter] public IReadOnlyList<AlarmAttributeChoice> AvailableAttributes { get; set; } =
Array.Empty<AlarmAttributeChoice>();
// ── Internal state ─────────────────────────────────────────────────────
private TriggerModel _model = new();
private AlarmTriggerType _lastSeenType;
private string? _lastSeenJson;
/// <summary>The choice currently selected from <see cref="AvailableAttributes"/>, if any.</summary>
private AlarmAttributeChoice? _selectedChoice;
private string? _selectedDataType => _selectedChoice?.DataType;
// ── Parse / serialize lifecycle ────────────────────────────────────────
protected override void OnParametersSet()
{
var typeChanged = _lastSeenType != TriggerType;
var jsonChanged = Value != _lastSeenJson;
if (!typeChanged && !jsonChanged) return;
_lastSeenType = TriggerType;
_lastSeenJson = Value;
// Preserve attribute name across type changes — re-parse the JSON in
// the context of the new type. Missing/unparseable keys fall back to
// empty defaults.
var preservedAttr = _model.AttributeName;
_model = Parse(Value, TriggerType);
if (jsonChanged == false && typeChanged && !string.IsNullOrEmpty(preservedAttr))
_model.AttributeName = preservedAttr;
RefreshSelectedChoice();
SyncTextMirrors();
}
private void RefreshSelectedChoice()
{
_selectedChoice = AvailableAttributes.FirstOrDefault(
c => string.Equals(c.CanonicalName, _model.AttributeName, StringComparison.Ordinal));
}
private async Task Emit()
{
var json = Serialize(_model, TriggerType);
_lastSeenJson = json;
await ValueChanged.InvokeAsync(json);
}
// ── Attribute picker ───────────────────────────────────────────────────
/// <summary>
/// String mirror for the attribute picker — required because @bind needs a
/// settable backing field, not a computed expression.
/// </summary>
private string _attributeName = string.Empty;
private async Task OnAttributeChanged()
{
_model.AttributeName = _attributeName;
RefreshSelectedChoice();
await Emit();
}
private static int SourceOrder(string source) => source switch
{
"Direct" => 0,
"Inherited" => 1,
"Composed" => 2,
_ => 3
};
private bool IsAttributeCompatible(AlarmAttributeChoice choice) =>
TriggerType == AlarmTriggerType.ValueMatch
|| IsNumericType(choice.DataType);
private static bool IsNumericType(string dataType) => dataType switch
{
"Integer" or "Int32" or "Int64" or "Float" or "Double" or "Number" => true,
_ => false
};
// ── ValueMatch ─────────────────────────────────────────────────────────
private RenderFragment RenderValueMatch() => __builder =>
{
<div class="row g-2">
<div class="col-md-4">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Operator
</label>
<select class="form-select form-select-sm"
@bind="_operatorText"
@bind:after="OnOperatorChanged">
<option value="eq">equals</option>
<option value="ne">not equals</option>
</select>
</div>
<div class="col-md-8">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Match value
</label>
@{
var t = _selectedChoice?.DataType;
if (t == "Boolean")
{
<select class="form-select form-select-sm"
@bind="_matchValueText"
@bind:after="OnMatchValueChanged">
<option value="True">True</option>
<option value="False">False</option>
</select>
}
else if (IsNumericType(t ?? ""))
{
<input type="number" step="any" class="form-control form-control-sm"
@bind="_matchValueText"
@bind:event="oninput"
@bind:after="OnMatchValueChanged" />
}
else
{
<input type="text" class="form-control form-control-sm"
placeholder="value"
@bind="_matchValueText"
@bind:event="oninput"
@bind:after="OnMatchValueChanged" />
}
}
</div>
</div>
};
// ── RangeViolation ─────────────────────────────────────────────────────
private RenderFragment RenderRangeViolation() => __builder =>
{
<div class="row g-2 align-items-end">
<div class="col-md-5">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Minimum
</label>
<input type="number" step="any" class="form-control form-control-sm"
@bind="_minText"
@bind:event="oninput"
@bind:after="OnMinChanged" />
</div>
<div class="col-md-2 text-center pb-1 text-muted small">to</div>
<div class="col-md-5">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Maximum
</label>
<input type="number" step="any" class="form-control form-control-sm"
@bind="_maxText"
@bind:event="oninput"
@bind:after="OnMaxChanged" />
</div>
</div>
<div class="mt-3" aria-hidden="true">
<svg viewBox="0 0 200 12" preserveAspectRatio="none"
style="width:100%; height:10px; border-radius:5px; overflow:hidden;">
<rect x="0" y="0" width="20" height="12" fill="#f8d7da" />
<rect x="20" y="0" width="160" height="12" fill="#d1e7dd" />
<rect x="180" y="0" width="20" height="12" fill="#f8d7da" />
</svg>
<div class="d-flex justify-content-between small text-muted mt-1">
<span>alarm</span>
<span>normal</span>
<span>alarm</span>
</div>
</div>
};
private async Task OnMinChanged()
{
_model.Min = ParseDouble(_minText);
await Emit();
}
private async Task OnMaxChanged()
{
_model.Max = ParseDouble(_maxText);
await Emit();
}
// ── RateOfChange ───────────────────────────────────────────────────────
private RenderFragment RenderRateOfChange() => __builder =>
{
<div class="row g-2 align-items-end">
<div class="col-md-6">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Rate threshold
</label>
<div class="input-group input-group-sm">
<input type="number" step="any" class="form-control"
@bind="_thresholdText"
@bind:event="oninput"
@bind:after="OnThresholdChanged" />
<span class="input-group-text">units / sec</span>
</div>
</div>
<div class="col-md-6">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Sampling window
</label>
<div class="input-group input-group-sm">
<input type="number" step="any" min="0" class="form-control"
@bind="_windowText"
@bind:event="oninput"
@bind:after="OnWindowChanged" />
<span class="input-group-text">sec</span>
</div>
</div>
</div>
<div class="mt-3 row g-2">
<div class="col-md-6">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Direction
</label>
<select class="form-select form-select-sm"
@bind="_directionText"
@bind:after="OnDirectionChanged">
<option value="rising">Rising only</option>
<option value="falling">Falling only</option>
<option value="either">Either direction</option>
</select>
</div>
</div>
};
private async Task OnThresholdChanged()
{
_model.ThresholdPerSecond = ParseDouble(_thresholdText);
await Emit();
}
private async Task OnWindowChanged()
{
_model.WindowSeconds = ParseDouble(_windowText);
await Emit();
}
private async Task OnDirectionChanged()
{
_model.Direction = _directionText;
await Emit();
}
private string _directionText = "either";
// ── Text mirrors for typed inputs ──────────────────────────────────────
// @bind requires a settable backing field that round-trips text. We keep
// these in sync with the model and re-parse on @bind:after.
private string? _minText;
private string? _maxText;
private string? _thresholdText;
private string? _windowText;
protected override void OnInitialized()
{
SyncTextMirrors();
}
private void SyncTextMirrors()
{
_attributeName = _model.AttributeName ?? string.Empty;
_matchValueText = _model.MatchValue ?? string.Empty;
_operatorText = _model.NotEquals ? "ne" : "eq";
_minText = FormatNullable(_model.Min);
_maxText = FormatNullable(_model.Max);
_thresholdText = FormatNullable(_model.ThresholdPerSecond);
_windowText = FormatNullable(_model.WindowSeconds);
_directionText = _model.Direction;
}
private string _operatorText = "eq";
private string _matchValueText = string.Empty;
private async Task OnOperatorChanged()
{
_model.NotEquals = (_operatorText == "ne");
await Emit();
}
private async Task OnMatchValueChanged()
{
_model.MatchValue = _matchValueText;
await Emit();
}
// ── Hint text ──────────────────────────────────────────────────────────
private string BuildHint()
{
var attr = string.IsNullOrWhiteSpace(_model.AttributeName)
? "the selected attribute"
: $"\"{_model.AttributeName}\"";
return TriggerType switch
{
AlarmTriggerType.ValueMatch =>
$"Triggers when {attr} {(_model.NotEquals ? "is not equal to" : "equals")} \"{_model.MatchValue ?? ""}\".",
AlarmTriggerType.RangeViolation =>
_model.Min.HasValue && _model.Max.HasValue
? $"Triggers when {attr} < {Fmt(_model.Min)} or > {Fmt(_model.Max)}."
: $"Triggers when {attr} goes outside the configured range.",
AlarmTriggerType.RateOfChange =>
$"Triggers when {attr} changes faster than {Fmt(_model.ThresholdPerSecond) ?? "?"} units/sec ({_model.Direction}) over a {Fmt(_model.WindowSeconds) ?? "?"} sec window.",
_ => string.Empty
};
}
private static string Fmt(double? v) =>
v.HasValue ? v.Value.ToString("0.###", CultureInfo.InvariantCulture) : "";
private static string FormatNullable(double? v) =>
v.HasValue ? v.Value.ToString("R", CultureInfo.InvariantCulture) : "";
private static double? ParseDouble(string? s) =>
double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : null;
// ── Model + parse/serialize ────────────────────────────────────────────
private sealed class TriggerModel
{
public string? AttributeName { get; set; }
// ValueMatch
public string? MatchValue { get; set; }
public bool NotEquals { get; set; }
// RangeViolation
public double? Min { get; set; }
public double? Max { get; set; }
// RateOfChange
public double? ThresholdPerSecond { get; set; }
public double? WindowSeconds { get; set; }
public string Direction { get; set; } = "either";
}
/// <summary>
/// Parses an existing trigger configuration JSON in the context of the
/// given trigger type. Returns sensible defaults on parse failure or for
/// missing keys.
/// </summary>
private static TriggerModel Parse(string? json, AlarmTriggerType type)
{
var model = new TriggerModel();
if (string.IsNullOrWhiteSpace(json)) return model;
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
model.AttributeName =
root.TryGetProperty("attributeName", out var a) ? a.GetString()
: root.TryGetProperty("attribute", out var a2) ? a2.GetString()
: null;
switch (type)
{
case AlarmTriggerType.ValueMatch:
{
var raw = root.TryGetProperty("matchValue", out var mv) ? mv.GetString()
: root.TryGetProperty("value", out var mv2) ? mv2.GetString()
: null;
if (raw != null && raw.StartsWith("!=", StringComparison.Ordinal))
{
model.NotEquals = true;
model.MatchValue = raw[2..];
}
else
{
model.MatchValue = raw;
}
break;
}
case AlarmTriggerType.RangeViolation:
model.Min = TryReadDouble(root, "min") ?? TryReadDouble(root, "low");
model.Max = TryReadDouble(root, "max") ?? TryReadDouble(root, "high");
break;
case AlarmTriggerType.RateOfChange:
model.ThresholdPerSecond = TryReadDouble(root, "thresholdPerSecond");
model.WindowSeconds = TryReadDouble(root, "windowSeconds");
var dir = root.TryGetProperty("direction", out var d) ? d.GetString() : null;
model.Direction = NormalizeDirection(dir);
break;
}
}
catch (JsonException)
{
// Malformed JSON — fall through with default model.
}
return model;
}
private static string NormalizeDirection(string? raw) => raw?.ToLowerInvariant() switch
{
"rising" or "up" or "positive" => "rising",
"falling" or "down" or "negative" => "falling",
_ => "either"
};
private static double? TryReadDouble(JsonElement el, string name)
{
if (!el.TryGetProperty(name, out var p)) return null;
return p.ValueKind switch
{
JsonValueKind.Number => p.GetDouble(),
JsonValueKind.String when double.TryParse(p.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var v) => v,
_ => null
};
}
/// <summary>
/// Serializes the model to the JSON shape AlarmActor.ParseEvalConfig
/// expects. Always writes <c>attributeName</c> (canonical key) and only
/// the keys relevant to the current trigger type.
/// </summary>
private static string Serialize(TriggerModel model, AlarmTriggerType type)
{
using var stream = new MemoryStream();
using (var w = new Utf8JsonWriter(stream))
{
w.WriteStartObject();
w.WriteString("attributeName", model.AttributeName ?? "");
switch (type)
{
case AlarmTriggerType.ValueMatch:
var mv = model.MatchValue ?? "";
if (model.NotEquals) mv = "!=" + mv;
w.WriteString("matchValue", mv);
break;
case AlarmTriggerType.RangeViolation:
if (model.Min.HasValue) w.WriteNumber("min", model.Min.Value);
if (model.Max.HasValue) w.WriteNumber("max", model.Max.Value);
break;
case AlarmTriggerType.RateOfChange:
if (model.ThresholdPerSecond.HasValue)
w.WriteNumber("thresholdPerSecond", model.ThresholdPerSecond.Value);
if (model.WindowSeconds.HasValue)
w.WriteNumber("windowSeconds", model.WindowSeconds.Value);
w.WriteString("direction", model.Direction);
break;
}
w.WriteEndObject();
}
return System.Text.Encoding.UTF8.GetString(stream.ToArray());
}
}

View File

@@ -29,7 +29,8 @@
else
{
<label class="form-label">@state.Body</label>
<input class="form-control form-control-sm"
<input @ref="_promptInputRef"
class="form-control form-control-sm"
placeholder="@state.Placeholder"
value="@_promptValue"
@oninput="OnPromptInput" />
@@ -50,8 +51,10 @@
@code {
private ElementReference _modalRef;
private ElementReference _promptInputRef;
private string _promptValue = string.Empty;
private DialogState? _lastSeenState;
private DialogState? _focusedForState;
protected override void OnInitialized()
{
@@ -85,11 +88,26 @@
{
try { await JS.InvokeVoidAsync("document.body.classList.add", "modal-open"); }
catch { /* prerender: no JS — ignore */ }
try { await _modalRef.FocusAsync(); }
catch { /* element not yet attached: ignore */ }
// Focus once per opened dialog. Without this guard, every input
// keystroke triggers a re-render which would re-focus the modal
// element and yank the caret off the prompt input.
if (!ReferenceEquals(current, _focusedForState))
{
_focusedForState = current;
try
{
if (current.Kind == DialogKind.Prompt)
await _promptInputRef.FocusAsync();
else
await _modalRef.FocusAsync();
}
catch { /* element not yet attached: ignore */ }
}
}
else
{
_focusedForState = null;
try { await JS.InvokeVoidAsync("document.body.classList.remove", "modal-open"); }
catch { /* prerender: no JS — ignore */ }
}

View File

@@ -28,9 +28,9 @@
[Parameter] public bool ShowToolbar { get; set; } = true;
/// <summary>
/// Parameter names declared on the form (from the ParameterListEditor),
/// surfaced as completions inside Parameters["..."] literals and used by
/// the unknown-key diagnostic.
/// Parameter names declared on the form (derived from the SchemaBuilder's
/// JSON Schema), surfaced as completions inside Parameters["..."] literals
/// and used by the unknown-key diagnostic.
/// </summary>
[Parameter] public IReadOnlyList<string>? DeclaredParameters { get; set; }

View File

@@ -1,218 +0,0 @@
@namespace ScadaLink.CentralUI.Components.Shared
@using System.Text.Json
@if (_parseError != null)
{
<div class="alert alert-warning py-2 small mb-2">
Could not parse existing parameter JSON: <code>@_parseError</code>
<button class="btn btn-link btn-sm p-0 ms-2" type="button" @onclick="StartFresh">Start fresh</button>
</div>
}
@if (_normalized)
{
<div class="alert alert-info py-2 small mb-2">
Some parameter types were normalized to the current type set. Save to persist the canonical form.
</div>
}
@if (_rows.Count > 0)
{
<div class="table-responsive">
<table class="table table-sm align-middle mb-2">
<thead class="table-light">
<tr>
<th>Name</th>
<th style="width: 160px;">Type</th>
<th style="width: 160px;">Item type</th>
<th class="text-center" style="width: 100px;">Required</th>
<th style="width: 50px;"></th>
</tr>
</thead>
<tbody>
@foreach (var row in _rows)
{
var r = row;
<tr @key="r">
<td>
<input class="form-control form-control-sm" @bind="r.Name" @bind:event="oninput" @bind:after="Emit"
placeholder="e.g. id" aria-label="Parameter name" />
</td>
<td>
<select class="form-select form-select-sm" @bind="r.Type" @bind:after="Emit"
aria-label="Parameter type">
@foreach (var t in Types)
{
<option value="@t">@t</option>
}
</select>
</td>
<td>
@if (r.Type == "List")
{
<select class="form-select form-select-sm" @bind="r.ItemType" @bind:after="Emit"
aria-label="List item type">
@foreach (var t in ItemTypes)
{
<option value="@t">@t</option>
}
</select>
}
else
{
<span class="text-muted small">—</span>
}
</td>
<td class="text-center">
<input type="checkbox" class="form-check-input" @bind="r.Required" @bind:after="Emit"
aria-label="Required" />
</td>
<td>
<button type="button" class="btn btn-link btn-sm p-0 text-danger"
@onclick="() => Remove(r)"
aria-label="@($"Remove parameter {r.Name}")">✕</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
else if (_parseError == null)
{
<p class="text-muted small fst-italic mb-2">No parameters defined.</p>
}
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="Add">+ Add parameter</button>
@code {
[Parameter] public string? Json { get; set; }
[Parameter] public EventCallback<string?> JsonChanged { get; set; }
private static readonly string[] Types = { "Boolean", "Integer", "Float", "String", "Object", "List" };
private static readonly string[] ItemTypes = { "Boolean", "Integer", "Float", "String", "Object" };
private List<ParamRow> _rows = new();
private string? _parseError;
private bool _normalized;
private string? _lastSeenJson;
protected override void OnParametersSet()
{
if (Json != _lastSeenJson)
{
_lastSeenJson = Json;
ParseFromJson();
}
}
private void ParseFromJson()
{
_parseError = null;
_normalized = false;
_rows = new();
if (string.IsNullOrWhiteSpace(Json)) return;
try
{
using var doc = JsonDocument.Parse(Json);
if (doc.RootElement.ValueKind != JsonValueKind.Array)
{
_parseError = "Expected a JSON array of parameter objects.";
return;
}
foreach (var el in doc.RootElement.EnumerateArray())
{
var name = el.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
var rawType = el.TryGetProperty("type", out var t) ? t.GetString() ?? "String" : "String";
var rawItem = el.TryGetProperty("itemType", out var it) ? it.GetString() ?? "String" : "String";
var required = !el.TryGetProperty("required", out var rq) || rq.ValueKind != JsonValueKind.False;
var normType = NormalizeType(rawType);
var normItem = NormalizeType(rawItem);
if (normType != rawType || (rawType == "List" && normItem != rawItem))
{
_normalized = true;
}
_rows.Add(new ParamRow
{
Name = name,
Type = normType,
ItemType = normItem,
Required = required
});
}
}
catch (JsonException ex)
{
_parseError = ex.Message;
}
}
private static string NormalizeType(string raw)
{
if (string.IsNullOrEmpty(raw)) return "String";
return raw.ToLowerInvariant() switch
{
"boolean" or "bool" => "Boolean",
"integer" or "int" or "int32" or "int64" or "int16" or "byte" or "sbyte" or "uint32" or "uint64" or "uint16" => "Integer",
"float" or "double" or "single" or "decimal" => "Float",
"string" or "datetime" => "String",
"object" => "Object",
"list" => "List",
_ => raw
};
}
private async Task StartFresh()
{
_parseError = null;
_rows = new();
await Emit();
}
private async Task Add()
{
_rows.Add(new ParamRow { Type = "String", ItemType = "String", Required = true });
await Emit();
}
private async Task Remove(ParamRow row)
{
_rows.Remove(row);
await Emit();
}
private async Task Emit()
{
var json = SerializeToJson();
_lastSeenJson = json;
_normalized = false;
await JsonChanged.InvokeAsync(json);
}
private string? SerializeToJson()
{
if (_rows.Count == 0) return null;
var list = new List<Dictionary<string, object>>();
foreach (var r in _rows)
{
var obj = new Dictionary<string, object>
{
["name"] = r.Name,
["type"] = r.Type,
};
if (r.Type == "List") obj["itemType"] = r.ItemType;
if (!r.Required) obj["required"] = false;
list.Add(obj);
}
return JsonSerializer.Serialize(list);
}
private class ParamRow
{
public string Name { get; set; } = "";
public string Type { get; set; } = "String";
public string ItemType { get; set; } = "String";
public bool Required { get; set; } = true;
}
}

View File

@@ -1,132 +0,0 @@
@namespace ScadaLink.CentralUI.Components.Shared
@using System.Text.Json
@if (_parseError != null)
{
<div class="alert alert-warning py-2 small mb-2">
Could not parse existing return JSON: <code>@_parseError</code>
<button class="btn btn-link btn-sm p-0 ms-2" type="button" @onclick="StartFresh">Start fresh</button>
</div>
}
@if (_normalized)
{
<div class="alert alert-info py-2 small mb-2">
Return type was normalized to the current type set. Save to persist the canonical form.
</div>
}
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small">Type</label>
<select class="form-select form-select-sm" @bind="_type" @bind:after="Emit" aria-label="Return type">
<option value="">(no return value)</option>
@foreach (var t in Types)
{
<option value="@t">@t</option>
}
</select>
</div>
@if (_type == "List")
{
<div class="col-md-3">
<label class="form-label small">Item type</label>
<select class="form-select form-select-sm" @bind="_itemType" @bind:after="Emit" aria-label="List item type">
@foreach (var t in ItemTypes)
{
<option value="@t">@t</option>
}
</select>
</div>
}
</div>
@code {
[Parameter] public string? Json { get; set; }
[Parameter] public EventCallback<string?> JsonChanged { get; set; }
private static readonly string[] Types = { "Boolean", "Integer", "Float", "String", "Object", "List" };
private static readonly string[] ItemTypes = { "Boolean", "Integer", "Float", "String", "Object" };
private string _type = "";
private string _itemType = "String";
private string? _parseError;
private bool _normalized;
private string? _lastSeenJson;
protected override void OnParametersSet()
{
if (Json != _lastSeenJson)
{
_lastSeenJson = Json;
ParseFromJson();
}
}
private void ParseFromJson()
{
_parseError = null;
_normalized = false;
_type = "";
_itemType = "String";
if (string.IsNullOrWhiteSpace(Json)) return;
try
{
using var doc = JsonDocument.Parse(Json);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
{
_parseError = "Expected a JSON object with a type field.";
return;
}
var rawType = doc.RootElement.TryGetProperty("type", out var t) ? t.GetString() ?? "" : "";
var rawItem = doc.RootElement.TryGetProperty("itemType", out var it) ? it.GetString() ?? "String" : "String";
_type = NormalizeType(rawType);
_itemType = NormalizeType(rawItem);
if (_type != rawType || (rawType == "List" && _itemType != rawItem))
{
_normalized = true;
}
}
catch (JsonException ex)
{
_parseError = ex.Message;
}
}
private static string NormalizeType(string raw)
{
if (string.IsNullOrEmpty(raw)) return "";
return raw.ToLowerInvariant() switch
{
"boolean" or "bool" => "Boolean",
"integer" or "int" or "int32" or "int64" or "int16" or "byte" or "sbyte" or "uint32" or "uint64" or "uint16" => "Integer",
"float" or "double" or "single" or "decimal" => "Float",
"string" or "datetime" => "String",
"object" => "Object",
"list" => "List",
_ => raw
};
}
private async Task StartFresh()
{
_parseError = null;
_type = "";
_itemType = "String";
await Emit();
}
private async Task Emit()
{
string? json = null;
if (!string.IsNullOrEmpty(_type))
{
var obj = new Dictionary<string, object> { ["type"] = _type };
if (_type == "List") obj["itemType"] = _itemType;
json = JsonSerializer.Serialize(obj);
}
_lastSeenJson = json;
_normalized = false;
await JsonChanged.InvokeAsync(json);
}
}

View File

@@ -0,0 +1,207 @@
@namespace ScadaLink.CentralUI.Components.Shared
@* Bootstrap-only JSON Schema editor. Two modes:
- "object" parameters: edits a top-level object schema (named properties).
- "value" return type: edits a single value schema; object/array fall back
to the same property editor as Mode=object.
Recurses through methods (not nested components) so we stay in one file. *@
@if (_root.Type == "object" && Mode == "object")
{
@PropertyList(_root, isRoot: true)
}
else
{
@ValueRoot(_root)
}
@code {
/// <summary><c>"object"</c> for parameters, <c>"value"</c> for return type.</summary>
[Parameter] public string Mode { get; set; } = "object";
/// <summary>JSON Schema text. Empty/null seeds the mode's default.</summary>
[Parameter] public string? Value { get; set; }
[Parameter] public EventCallback<string?> ValueChanged { get; set; }
private SchemaNode _root = new();
private string? _lastSeenJson;
private bool _initialized;
protected override void OnParametersSet()
{
// OnInitialized fires before this on first mount; OnParametersSet runs
// on every parameter change. Guard against the initial null==null case
// where the early-exit would skip applying the mode-appropriate default.
if (_initialized && Value == _lastSeenJson) return;
_initialized = true;
_lastSeenJson = Value;
_root = SchemaBuilderModel.Parse(
Value,
Mode == "object" ? SchemaBuilderModel.NewObject() : SchemaBuilderModel.NewValue());
}
private async Task Emit()
{
var json = SchemaBuilderModel.Serialize(_root);
_lastSeenJson = json;
await ValueChanged.InvokeAsync(json);
}
private async Task OnTypeChange(SchemaNode node)
{
if (node.Type == "array" && node.Items == null)
node.Items = new SchemaNode { Type = "string" };
await Emit();
}
private async Task AddProperty(SchemaNode parent)
{
parent.Properties.Add(new SchemaProperty { Schema = new SchemaNode { Type = "string" } });
await Emit();
}
private async Task RemoveProperty(SchemaNode parent, SchemaProperty prop)
{
parent.Properties.Remove(prop);
await Emit();
}
// ── Render helpers ─────────────────────────────────────────────────────────
/// <summary>
/// Renders the property list for an object schema node. <paramref name="isRoot"/>
/// just tweaks the wording on the Add button ("parameter" at root vs "field"
/// inside a nested object).
/// </summary>
private RenderFragment PropertyList(SchemaNode node, bool isRoot = false) => __builder =>
{
<div class="border rounded bg-white p-2">
@if (node.Properties.Count == 0)
{
<div class="text-muted small fst-italic px-1 py-2">
@(isRoot ? "No parameters defined." : "No fields defined.")
</div>
}
@foreach (var prop in node.Properties)
{
<div @key="prop.Id" class="border rounded p-2 mb-2 bg-light-subtle">
@PropertyRow(node, prop)
@NestedEditor(prop.Schema)
</div>
}
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="() => AddProperty(node)">
+ Add @(isRoot ? "parameter" : "field")
</button>
</div>
};
/// <summary>
/// One property's compact horizontal row: name, type, (items type if array),
/// required toggle, remove button. Nested object / array-of-object editors
/// render below the row via <see cref="NestedEditor"/>.
/// </summary>
private RenderFragment PropertyRow(SchemaNode parent, SchemaProperty prop) => __builder =>
{
<div class="d-flex flex-wrap align-items-center gap-2">
<input type="text" class="form-control form-control-sm"
style="max-width: 14rem;" placeholder="name"
@bind="prop.Name" @bind:event="oninput" @bind:after="Emit" />
<select class="form-select form-select-sm" style="max-width: 9rem;"
@bind="prop.Schema.Type" @bind:after="() => OnTypeChange(prop.Schema)">
@foreach (var t in SchemaBuilderModel.PrimitiveTypes)
{
<option value="@t">@t</option>
}
</select>
@if (prop.Schema.Type == "array")
{
<span class="small text-muted">items:</span>
<select class="form-select form-select-sm" style="max-width: 9rem;"
@bind="prop.Schema.Items!.Type" @bind:after="() => OnTypeChange(prop.Schema.Items!)">
@foreach (var t in SchemaBuilderModel.PrimitiveTypes)
{
<option value="@t">@t</option>
}
</select>
}
<div class="form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox" id="req-@prop.Id"
@bind="prop.Required" @bind:after="Emit" />
<label class="form-check-label small" for="req-@prop.Id">required</label>
</div>
<button type="button"
class="btn btn-link btn-sm text-danger p-0 ms-auto"
title="Remove" aria-label="Remove field"
@onclick="() => RemoveProperty(parent, prop)">
<i class="bi bi-x-lg"></i>
</button>
</div>
};
/// <summary>
/// Renders the indented sub-editor for object / array-of-object properties.
/// No-op for scalar properties.
/// </summary>
private RenderFragment NestedEditor(SchemaNode schema) => __builder =>
{
if (schema.Type == "object")
{
<div class="ms-3 mt-2">
@PropertyList(schema)
</div>
}
else if (schema.Type == "array" && schema.Items?.Type == "object")
{
<div class="ms-3 mt-2">
<div class="small text-muted mb-1">item properties:</div>
@PropertyList(schema.Items)
</div>
}
};
/// <summary>
/// Mode=value root: a single type picker. When the user picks <c>object</c>
/// or <c>array</c> we expose the same nested editors used by Mode=object.
/// </summary>
private RenderFragment ValueRoot(SchemaNode node) => __builder =>
{
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
<label class="form-label mb-0">Return type:</label>
<select class="form-select form-select-sm" style="max-width: 10rem;"
@bind="node.Type" @bind:after="() => OnTypeChange(node)">
@foreach (var t in SchemaBuilderModel.PrimitiveTypes)
{
<option value="@t">@t</option>
}
</select>
@if (node.Type == "array")
{
<label class="form-label mb-0 ms-2">Item type:</label>
<select class="form-select form-select-sm" style="max-width: 10rem;"
@bind="node.Items!.Type" @bind:after="() => OnTypeChange(node.Items!)">
@foreach (var t in SchemaBuilderModel.PrimitiveTypes)
{
<option value="@t">@t</option>
}
</select>
}
</div>
@if (node.Type == "object")
{
<div class="text-muted small mb-1">Properties of return value:</div>
@PropertyList(node)
}
else if (node.Type == "array" && node.Items?.Type == "object")
{
<div class="text-muted small mb-1">Item properties:</div>
@PropertyList(node.Items)
}
};
}

View File

@@ -0,0 +1,204 @@
using System.Text.Json;
namespace ScadaLink.CentralUI.Components.Shared;
/// <summary>
/// In-memory JSON Schema tree used by <see cref="SchemaBuilder"/>. The editor
/// mutates this graph directly; <see cref="SchemaBuilderModel"/> handles
/// parse / serialize round-tripping to the canonical JSON Schema text stored
/// in TemplateScript / SharedScript / ApiMethod columns.
/// </summary>
internal sealed class SchemaNode
{
/// <summary>One of: <c>string · integer · number · boolean · object · array</c>.</summary>
public string Type { get; set; } = "string";
/// <summary>For <c>type=array</c>: the schema of the array's items.</summary>
public SchemaNode? Items { get; set; }
/// <summary>For <c>type=object</c>: ordered list of named properties.</summary>
public List<SchemaProperty> Properties { get; } = new();
}
internal sealed class SchemaProperty
{
/// <summary>Stable identity for Blazor <c>@key</c> across renames.</summary>
public Guid Id { get; } = Guid.NewGuid();
public string Name { get; set; } = string.Empty;
public bool Required { get; set; } = true;
public SchemaNode Schema { get; set; } = new();
}
internal static class SchemaBuilderModel
{
public static readonly string[] PrimitiveTypes =
{ "string", "integer", "number", "boolean", "object", "array" };
/// <summary>
/// Parse a JSON Schema string into a <see cref="SchemaNode"/> tree.
/// Returns the supplied <paramref name="fallback"/> when the input is
/// empty or malformed. Also accepts the legacy flat-array parameter
/// shape (<c>[{name,type,required,itemType?}]</c>) for safety during the
/// transition window — translates it into an equivalent object schema.
/// </summary>
public static SchemaNode Parse(string? json, SchemaNode fallback)
{
if (string.IsNullOrWhiteSpace(json)) return fallback;
try
{
using var doc = JsonDocument.Parse(json);
return doc.RootElement.ValueKind switch
{
JsonValueKind.Object => ParseSchema(doc.RootElement),
JsonValueKind.Array => ParseLegacyArray(doc.RootElement),
_ => fallback,
};
}
catch
{
return fallback;
}
}
/// <summary>Default empty object schema (parameters mode default).</summary>
public static SchemaNode NewObject() => new() { Type = "object" };
/// <summary>Default scalar schema (return mode default).</summary>
public static SchemaNode NewValue() => new() { Type = "string" };
public static string Serialize(SchemaNode node)
{
using var stream = new System.IO.MemoryStream();
using (var writer = new Utf8JsonWriter(stream))
{
WriteNode(writer, node);
}
return System.Text.Encoding.UTF8.GetString(stream.ToArray());
}
// ── Parse helpers ─────────────────────────────────────────────────────────
private static SchemaNode ParseSchema(JsonElement el)
{
var node = new SchemaNode { Type = "string" };
if (el.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String)
{
node.Type = NormalizeType(t.GetString());
}
if (node.Type == "array")
{
node.Items = el.TryGetProperty("items", out var items) && items.ValueKind == JsonValueKind.Object
? ParseSchema(items)
: new SchemaNode { Type = "string" };
}
else if (node.Type == "object")
{
var requiredSet = new HashSet<string>(StringComparer.Ordinal);
if (el.TryGetProperty("required", out var req) && req.ValueKind == JsonValueKind.Array)
{
foreach (var r in req.EnumerateArray())
{
if (r.ValueKind == JsonValueKind.String)
{
var s = r.GetString();
if (!string.IsNullOrEmpty(s)) requiredSet.Add(s);
}
}
}
if (el.TryGetProperty("properties", out var props) && props.ValueKind == JsonValueKind.Object)
{
foreach (var prop in props.EnumerateObject())
{
node.Properties.Add(new SchemaProperty
{
Name = prop.Name,
Required = requiredSet.Contains(prop.Name),
Schema = prop.Value.ValueKind == JsonValueKind.Object
? ParseSchema(prop.Value)
: new SchemaNode { Type = "string" },
});
}
}
}
return node;
}
private static SchemaNode ParseLegacyArray(JsonElement arr)
{
var root = new SchemaNode { Type = "object" };
foreach (var item in arr.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Object) continue;
var name = item.TryGetProperty("name", out var n) ? n.GetString() : null;
if (string.IsNullOrEmpty(name)) continue;
var rawType = item.TryGetProperty("type", out var t) ? t.GetString() : "string";
var required = !item.TryGetProperty("required", out var rq) || rq.ValueKind != JsonValueKind.False;
var schema = new SchemaNode { Type = NormalizeType(rawType) };
if (schema.Type == "array")
{
var inner = item.TryGetProperty("itemType", out var it) ? it.GetString() : "string";
schema.Items = new SchemaNode { Type = NormalizeType(inner) };
}
root.Properties.Add(new SchemaProperty
{
Name = name,
Required = required,
Schema = schema,
});
}
return root;
}
private static string NormalizeType(string? raw) => raw?.ToLowerInvariant() switch
{
"boolean" or "bool" => "boolean",
"integer" or "int" or "int32" or "int64" => "integer",
"number" or "float" or "double" or "decimal" => "number",
"string" or "datetime" => "string",
"object" => "object",
"array" or "list" => "array",
_ => "string",
};
// ── Serialize helpers ─────────────────────────────────────────────────────
private static void WriteNode(Utf8JsonWriter w, SchemaNode node)
{
w.WriteStartObject();
w.WriteString("type", node.Type);
if (node.Type == "array")
{
w.WritePropertyName("items");
WriteNode(w, node.Items ?? new SchemaNode { Type = "string" });
}
else if (node.Type == "object")
{
w.WritePropertyName("properties");
w.WriteStartObject();
foreach (var p in node.Properties.Where(p => !string.IsNullOrWhiteSpace(p.Name)))
{
w.WritePropertyName(p.Name);
WriteNode(w, p.Schema);
}
w.WriteEndObject();
var required = node.Properties
.Where(p => p.Required && !string.IsNullOrWhiteSpace(p.Name))
.Select(p => p.Name)
.ToArray();
if (required.Length > 0)
{
w.WritePropertyName("required");
w.WriteStartArray();
foreach (var r in required) w.WriteStringValue(r);
w.WriteEndArray();
}
}
w.WriteEndObject();
}
}

View File

@@ -1,51 +1,20 @@
using System.Text.Json;
using ScadaLink.CentralUI.ScriptAnalysis;
namespace ScadaLink.CentralUI.Components.Shared;
/// <summary>
/// Parses the parameter-definitions JSON written by ParameterListEditor and
/// Parses the parameter-definitions JSON Schema written by SchemaBuilder and
/// returns the declared parameter names (and shapes). Used by script-edit
/// pages to feed the Monaco editor's Parameters["..."] context.
/// </summary>
public static class ScriptParameterNames
{
public static IReadOnlyList<string> Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<string>();
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Array) return Array.Empty<string>();
return doc.RootElement.EnumerateArray()
.Select(e => e.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "")
.Where(s => !string.IsNullOrEmpty(s))
.ToList();
}
catch
{
return Array.Empty<string>();
}
}
public static IReadOnlyList<string> Parse(string? json) =>
JsonSchemaShapeParser.ParseParameters(json)
.Select(p => p.Name)
.Where(s => !string.IsNullOrEmpty(s))
.ToList();
public static IReadOnlyList<ParameterShape> ParseShapes(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<ParameterShape>();
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Array) return Array.Empty<ParameterShape>();
return doc.RootElement.EnumerateArray()
.Select(el => new ParameterShape(
Name: el.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "",
Type: el.TryGetProperty("type", out var t) ? t.GetString() ?? "String" : "String",
Required: !el.TryGetProperty("required", out var rq) || rq.ValueKind != JsonValueKind.False))
.Where(p => !string.IsNullOrEmpty(p.Name))
.ToList();
}
catch
{
return Array.Empty<ParameterShape>();
}
}
public static IReadOnlyList<ParameterShape> ParseShapes(string? json) =>
JsonSchemaShapeParser.ParseParameters(json);
}

View File

@@ -11,6 +11,10 @@
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ScadaLink.CentralUI.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="4.13.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.13.0" />

View File

@@ -0,0 +1,177 @@
using System.Text.Json;
namespace ScadaLink.CentralUI.ScriptAnalysis;
/// <summary>
/// Translates JSON Schema documents stored in
/// <c>TemplateScript.ParameterDefinitions</c> / <c>ReturnDefinition</c> into
/// the flat <see cref="ParameterShape"/> / type-name vocabulary used by the
/// rest of the script-analysis pipeline (completions, inlay hints, signature
/// help, hover).
///
/// Lenient: malformed JSON yields an empty result, never an exception.
///
/// Also accepts the legacy pre-migration flat shape
/// (<c>[{name,type,required,itemType?}]</c> for parameters,
/// <c>{type,itemType?}</c> for return) so partially migrated rows don't crash
/// the editor.
/// </summary>
public static class JsonSchemaShapeParser
{
public static IReadOnlyList<ParameterShape> ParseParameters(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<ParameterShape>();
try
{
using var doc = JsonDocument.Parse(json);
return doc.RootElement.ValueKind switch
{
JsonValueKind.Array => ParseLegacyParameterArray(doc.RootElement),
JsonValueKind.Object => ParseJsonSchemaObject(doc.RootElement),
_ => Array.Empty<ParameterShape>(),
};
}
catch
{
return Array.Empty<ParameterShape>();
}
}
public static string? ParseReturnType(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return null;
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
return ParseReturnSchema(doc.RootElement);
}
catch
{
return null;
}
}
// ---- JSON Schema branch -------------------------------------------------
private static IReadOnlyList<ParameterShape> ParseJsonSchemaObject(JsonElement root)
{
if (!root.TryGetProperty("properties", out var props) || props.ValueKind != JsonValueKind.Object)
return Array.Empty<ParameterShape>();
var requiredSet = new HashSet<string>(StringComparer.Ordinal);
if (root.TryGetProperty("required", out var req) && req.ValueKind == JsonValueKind.Array)
{
foreach (var item in req.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
var s = item.GetString();
if (!string.IsNullOrEmpty(s)) requiredSet.Add(s);
}
}
}
var result = new List<ParameterShape>();
foreach (var prop in props.EnumerateObject())
{
var name = prop.Name;
if (string.IsNullOrEmpty(name)) continue;
var type = MapJsonSchemaType(prop.Value);
result.Add(new ParameterShape(name, type, requiredSet.Contains(name)));
}
return result;
}
private static string? ParseReturnSchema(JsonElement schema)
{
if (!schema.TryGetProperty("type", out var typeEl)) return null;
if (typeEl.ValueKind != JsonValueKind.String) return null;
var type = typeEl.GetString();
if (string.IsNullOrEmpty(type)) return null;
// Legacy form: `{type:"List", itemType:"Integer"}` (post-migration this
// should be `{type:"array", items:{type:"integer"}}`, handled below).
if (type.Equals("List", StringComparison.OrdinalIgnoreCase))
{
if (schema.TryGetProperty("itemType", out var it) && it.ValueKind == JsonValueKind.String)
return $"List<{NormalizeLegacyType(it.GetString())}>";
return "List<Object>";
}
if (type.Equals("array", StringComparison.OrdinalIgnoreCase))
{
if (schema.TryGetProperty("items", out var items) && items.ValueKind == JsonValueKind.Object)
{
var inner = MapJsonSchemaType(items);
return $"List<{inner}>";
}
return "List<Object>";
}
return MapJsonSchemaTypeName(type);
}
private static string MapJsonSchemaType(JsonElement schema)
{
if (schema.ValueKind != JsonValueKind.Object) return "Object";
if (!schema.TryGetProperty("type", out var typeEl) || typeEl.ValueKind != JsonValueKind.String)
return "Object";
var type = typeEl.GetString() ?? "";
if (type.Equals("array", StringComparison.OrdinalIgnoreCase))
{
if (schema.TryGetProperty("items", out var items) && items.ValueKind == JsonValueKind.Object)
return $"List<{MapJsonSchemaType(items)}>";
return "List<Object>";
}
return MapJsonSchemaTypeName(type);
}
private static string MapJsonSchemaTypeName(string type) => type.ToLowerInvariant() switch
{
"boolean" => "Boolean",
"integer" => "Integer",
"number" => "Float",
"string" => "String",
"object" => "Object",
"array" => "List",
// Legacy aliases (in case a row's been edited by hand pre-migration):
"bool" => "Boolean",
"int" or "int32" or "int64" => "Integer",
"float" or "double" or "decimal" => "Float",
_ => type,
};
// ---- Legacy flat-array branch ------------------------------------------
private static IReadOnlyList<ParameterShape> ParseLegacyParameterArray(JsonElement root)
{
var result = new List<ParameterShape>();
foreach (var el in root.EnumerateArray())
{
if (el.ValueKind != JsonValueKind.Object) continue;
var name = el.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
if (string.IsNullOrEmpty(name)) continue;
var rawType = el.TryGetProperty("type", out var t) ? t.GetString() ?? "String" : "String";
var required = !el.TryGetProperty("required", out var rq) || rq.ValueKind != JsonValueKind.False;
result.Add(new ParameterShape(name, NormalizeLegacyType(rawType), required));
}
return result;
}
private static string NormalizeLegacyType(string? raw)
{
if (string.IsNullOrEmpty(raw)) return "String";
return raw.ToLowerInvariant() switch
{
"boolean" or "bool" => "Boolean",
"integer" or "int" or "int32" or "int64" => "Integer",
"float" or "double" or "decimal" or "number" => "Float",
"string" or "datetime" => "String",
"object" => "Object",
"list" or "array" => "List",
_ => raw,
};
}
}

View File

@@ -4,7 +4,6 @@ using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Formatting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.CodeAnalysis.Text;
using Microsoft.Extensions.Caching.Memory;
@@ -334,11 +333,13 @@ public class ScriptAnalysisService
return new FormatResponse(request.Code);
try
{
using var workspace = new AdhocWorkspace();
var tree = CSharpSyntaxTree.ParseText(
request.Code,
new CSharpParseOptions(LanguageVersion.Latest, kind: SourceCodeKind.Script));
var formatted = Formatter.Format(tree.GetRoot(), workspace);
// NormalizeWhitespace produces canonical layout (indentation + line
// breaks). Formatter.Format alone with an empty workspace only
// normalizes inter-token spacing — it won't split crammed lines.
var formatted = tree.GetRoot().NormalizeWhitespace(indentation: " ", eol: "\n");
return new FormatResponse(formatted.ToFullString());
}
catch

View File

@@ -1,59 +1,17 @@
using System.Text.Json;
namespace ScadaLink.CentralUI.ScriptAnalysis;
/// <summary>
/// Parses the parameter-definitions and return-definition JSON written by
/// ParameterListEditor / ReturnTypeEditor into a <see cref="ScriptShape"/>.
/// Lenient: malformed JSON yields an empty parameter list, not an exception.
/// Parses the parameter-definitions and return-definition JSON Schema written
/// by SchemaBuilder into a <see cref="ScriptShape"/>. Delegates to
/// <see cref="JsonSchemaShapeParser"/>, which also handles legacy flat-shape
/// rows during the transition window.
/// </summary>
public static class ScriptShapeParser
{
public static ScriptShape Parse(string name, string? parametersJson, string? returnJson)
{
var parameters = ParseParameters(parametersJson);
var returnType = ParseReturnType(returnJson);
var parameters = JsonSchemaShapeParser.ParseParameters(parametersJson);
var returnType = JsonSchemaShapeParser.ParseReturnType(returnJson);
return new ScriptShape(name, parameters, returnType);
}
private static IReadOnlyList<ParameterShape> ParseParameters(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<ParameterShape>();
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Array) return Array.Empty<ParameterShape>();
return doc.RootElement.EnumerateArray()
.Select(el => new ParameterShape(
Name: el.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "",
Type: el.TryGetProperty("type", out var t) ? t.GetString() ?? "String" : "String",
Required: !el.TryGetProperty("required", out var rq) || rq.ValueKind != JsonValueKind.False))
.Where(p => !string.IsNullOrEmpty(p.Name))
.ToList();
}
catch
{
return Array.Empty<ParameterShape>();
}
}
private static string? ParseReturnType(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return null;
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
if (!doc.RootElement.TryGetProperty("type", out var t)) return null;
var type = t.GetString();
if (string.IsNullOrEmpty(type)) return null;
if (type == "List" && doc.RootElement.TryGetProperty("itemType", out var it))
return $"List<{it.GetString() ?? "Object"}>";
return type;
}
catch
{
return null;
}
}
}