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
@@ -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,
};
}
}
@@ -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
@@ -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;
}
}
}