feat(m9/T31): Monaco JSON-schema hover/completion on value-entry surface
This commit is contained in:
@@ -28,6 +28,16 @@
|
|||||||
[Parameter] public bool ReadOnly { get; set; } = false;
|
[Parameter] public bool ReadOnly { get; set; } = false;
|
||||||
[Parameter] public bool ShowToolbar { get; set; } = true;
|
[Parameter] public bool ShowToolbar { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional JSON Schema (the resolved schema text — any
|
||||||
|
/// <c>{"$ref":"lib:Name"}</c> already inlined) applied to this editor's
|
||||||
|
/// model when <see cref="Language"/> is <c>json</c>. Wires Monaco's
|
||||||
|
/// built-in JSON language so the editor offers schema-driven hover,
|
||||||
|
/// completion, and diagnostics on the entered JSON. Ignored for non-JSON
|
||||||
|
/// languages. Updating it re-applies the schema to the live model.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter] public string? JsonSchema { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Runtime globals surface the script is analyzed against. Defaults to
|
/// Runtime globals surface the script is analyzed against. Defaults to
|
||||||
/// template/shared-script globals; set to <c>InboundApi</c> on the API
|
/// template/shared-script globals; set to <c>InboundApi</c> on the API
|
||||||
@@ -87,6 +97,7 @@
|
|||||||
private DotNetObjectReference<MonacoEditor>? _dotNetRef;
|
private DotNetObjectReference<MonacoEditor>? _dotNetRef;
|
||||||
private readonly string _id = Guid.NewGuid().ToString("N");
|
private readonly string _id = Guid.NewGuid().ToString("N");
|
||||||
private string _lastSentValue = "";
|
private string _lastSentValue = "";
|
||||||
|
private string? _lastSentSchema;
|
||||||
private bool _initialized;
|
private bool _initialized;
|
||||||
private bool _wrap;
|
private bool _wrap;
|
||||||
private bool _minimap;
|
private bool _minimap;
|
||||||
@@ -108,10 +119,12 @@
|
|||||||
{
|
{
|
||||||
value = Value ?? "",
|
value = Value ?? "",
|
||||||
language = Language,
|
language = Language,
|
||||||
readOnly = ReadOnly
|
readOnly = ReadOnly,
|
||||||
|
jsonSchema = JsonSchema
|
||||||
},
|
},
|
||||||
_dotNetRef);
|
_dotNetRef);
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
|
_lastSentSchema = JsonSchema;
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException)
|
catch (InvalidOperationException)
|
||||||
{
|
{
|
||||||
@@ -128,10 +141,18 @@
|
|||||||
Logger.LogError(ex, "Monaco editor {EditorId} failed to initialize.", _id);
|
Logger.LogError(ex, "Monaco editor {EditorId} failed to initialize.", _id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (_initialized && (Value ?? "") != _lastSentValue)
|
else if (_initialized)
|
||||||
{
|
{
|
||||||
_lastSentValue = Value ?? "";
|
if ((Value ?? "") != _lastSentValue)
|
||||||
await SafeInvokeAsync("MonacoBlazor.setValue", "set editor value", _id, _lastSentValue);
|
{
|
||||||
|
_lastSentValue = Value ?? "";
|
||||||
|
await SafeInvokeAsync("MonacoBlazor.setValue", "set editor value", _id, _lastSentValue);
|
||||||
|
}
|
||||||
|
if (JsonSchema != _lastSentSchema)
|
||||||
|
{
|
||||||
|
_lastSentSchema = JsonSchema;
|
||||||
|
await SafeInvokeAsync("MonacoBlazor.setJsonSchema", "set JSON schema", _id, JsonSchema);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ else
|
|||||||
private InboundApiSchema? _rootSchema;
|
private InboundApiSchema? _rootSchema;
|
||||||
private string? _parsedFor;
|
private string? _parsedFor;
|
||||||
|
|
||||||
|
// The resolved root schema serialized back to a JSON Schema string (with every
|
||||||
|
// {"$ref":"lib:Name"} inlined). Fed to the raw-JSON escape-hatch Monaco editor
|
||||||
|
// so Monaco's built-in JSON language offers schema-driven hover + completion on
|
||||||
|
// the entered JSON. Computed once per parse alongside the resolved schema.
|
||||||
|
private string? _resolvedSchemaJson;
|
||||||
|
|
||||||
// Raw text and per-field parse/validation errors keyed by canonical PATH
|
// Raw text and per-field parse/validation errors keyed by canonical PATH
|
||||||
// (e.g. "order.id", "tags[0]") so nested inputs each carry their own state.
|
// (e.g. "order.id", "tags[0]") so nested inputs each carry their own state.
|
||||||
private readonly Dictionary<string, string> _rawText = new();
|
private readonly Dictionary<string, string> _rawText = new();
|
||||||
@@ -70,6 +76,82 @@ else
|
|||||||
_shapes = ScriptParameterNames.ParseShapes(ParameterDefinitions);
|
_shapes = ScriptParameterNames.ParseShapes(ParameterDefinitions);
|
||||||
_rootSchema = await ParseRootAsync();
|
_rootSchema = await ParseRootAsync();
|
||||||
_topLevelFields = _rootSchema?.Type == "object" ? _rootSchema.Fields : Array.Empty<InboundApiSchemaField>();
|
_topLevelFields = _rootSchema?.Type == "object" ? _rootSchema.Fields : Array.Empty<InboundApiSchemaField>();
|
||||||
|
_resolvedSchemaJson = _rootSchema is null ? null : ToJsonSchema(_rootSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serializes a (resolved) <see cref="InboundApiSchema"/> back to a JSON Schema
|
||||||
|
/// string for the escape-hatch Monaco editor. Any <c>{"$ref":"lib:Name"}</c> the
|
||||||
|
/// parse already inlined is emitted as its resolved shape (no <c>$ref</c> remains),
|
||||||
|
/// so Monaco's JSON language sees the full type. The shape-only / unresolved-ref
|
||||||
|
/// placeholder (Type not in the JSON Schema set) collapses to a permissive
|
||||||
|
/// <c>{}</c> so the editor still validates well-formed JSON without false errors.
|
||||||
|
/// </summary>
|
||||||
|
private static string ToJsonSchema(InboundApiSchema schema)
|
||||||
|
{
|
||||||
|
using var stream = new System.IO.MemoryStream();
|
||||||
|
using (var writer = new System.Text.Json.Utf8JsonWriter(stream))
|
||||||
|
{
|
||||||
|
WriteSchema(writer, schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
return System.Text.Encoding.UTF8.GetString(stream.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteSchema(System.Text.Json.Utf8JsonWriter writer, InboundApiSchema schema)
|
||||||
|
{
|
||||||
|
switch (schema.Type)
|
||||||
|
{
|
||||||
|
case "boolean":
|
||||||
|
case "integer":
|
||||||
|
case "number":
|
||||||
|
case "string":
|
||||||
|
writer.WriteStartObject();
|
||||||
|
writer.WriteString("type", schema.Type);
|
||||||
|
writer.WriteEndObject();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "object":
|
||||||
|
writer.WriteStartObject();
|
||||||
|
writer.WriteString("type", "object");
|
||||||
|
writer.WritePropertyName("properties");
|
||||||
|
writer.WriteStartObject();
|
||||||
|
foreach (var field in schema.Fields)
|
||||||
|
{
|
||||||
|
writer.WritePropertyName(field.Name);
|
||||||
|
WriteSchema(writer, field.Schema);
|
||||||
|
}
|
||||||
|
writer.WriteEndObject();
|
||||||
|
var required = schema.Fields.Where(f => f.Required).Select(f => f.Name).ToList();
|
||||||
|
if (required.Count > 0)
|
||||||
|
{
|
||||||
|
writer.WritePropertyName("required");
|
||||||
|
writer.WriteStartArray();
|
||||||
|
foreach (var name in required)
|
||||||
|
{
|
||||||
|
writer.WriteStringValue(name);
|
||||||
|
}
|
||||||
|
writer.WriteEndArray();
|
||||||
|
}
|
||||||
|
writer.WriteEndObject();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "array":
|
||||||
|
writer.WriteStartObject();
|
||||||
|
writer.WriteString("type", "array");
|
||||||
|
if (schema.Items is not null)
|
||||||
|
{
|
||||||
|
writer.WritePropertyName("items");
|
||||||
|
WriteSchema(writer, schema.Items);
|
||||||
|
}
|
||||||
|
writer.WriteEndObject();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default: // unresolved $ref / unknown — permissive (accept any JSON).
|
||||||
|
writer.WriteStartObject();
|
||||||
|
writer.WriteEndObject();
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -165,10 +247,13 @@ else
|
|||||||
@RenderArray(schema, path)
|
@RenderArray(schema, path)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default: // unresolved $ref / unknown — raw-JSON escape hatch.
|
default: // unresolved $ref / unknown — raw-JSON escape hatch (Monaco).
|
||||||
<textarea class="form-control form-control-sm font-monospace" rows="3" id="@id"
|
<div class="param-json-editor" data-path="@path">
|
||||||
placeholder='@($"JSON {DisplayType(schema).ToLowerInvariant()}")'
|
<MonacoEditor Language="json" Height="120px" ShowToolbar="false"
|
||||||
@oninput="e => SetJson(path, (string?)e.Value)">@AsRaw(path)</textarea>
|
JsonSchema="@_resolvedSchemaJson"
|
||||||
|
Value="@AsRaw(path)"
|
||||||
|
ValueChanged="@(v => SetJson(path, v))" />
|
||||||
|
</div>
|
||||||
@RenderError(path)
|
@RenderError(path)
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Blazor bridge for Monaco editor.
|
// Blazor bridge for Monaco editor.
|
||||||
// Exposes window.MonacoBlazor with createEditor / setValue / getValue / dispose / setMarkers.
|
// Exposes window.MonacoBlazor with createEditor / setValue / getValue / dispose / setMarkers / setJsonSchema.
|
||||||
// Lazy-loads Monaco's AMD bundle the first time createEditor is called.
|
// Lazy-loads Monaco's AMD bundle the first time createEditor is called.
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
@@ -285,12 +285,62 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- JSON schema wiring --------------------------------------------------
|
||||||
|
|
||||||
|
// Each json editor gets its own model URI so a schema can be scoped to just
|
||||||
|
// that model via fileMatch — registering it in Monaco's global json defaults
|
||||||
|
// without leaking onto other json editors on the page.
|
||||||
|
function modelUriFor(id) {
|
||||||
|
return monaco.Uri.parse("inmemory://scadabridge/value-" + id + ".json");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace (or remove) this editor's entry in the global json schema list,
|
||||||
|
// then push the merged list back into jsonDefaults so Monaco's built-in JSON
|
||||||
|
// language provides schema-driven hover/completion/diagnostics. A null/blank
|
||||||
|
// schema simply drops the entry (the editor falls back to plain json).
|
||||||
|
function applyJsonSchema(id, schemaJson) {
|
||||||
|
if (!monaco.languages.json) return;
|
||||||
|
const uri = modelUriFor(id).toString();
|
||||||
|
const fileMatch = ["value-" + id + ".json"];
|
||||||
|
const defaults = monaco.languages.json.jsonDefaults;
|
||||||
|
const existing = (defaults.diagnosticsOptions && defaults.diagnosticsOptions.schemas) || [];
|
||||||
|
const others = existing.filter(function (s) { return s.uri !== uri; });
|
||||||
|
let schemas = others;
|
||||||
|
if (schemaJson && schemaJson.trim().length > 0) {
|
||||||
|
let parsed = null;
|
||||||
|
try { parsed = JSON.parse(schemaJson); } catch (e) { parsed = null; }
|
||||||
|
if (parsed) {
|
||||||
|
schemas = others.concat([{ uri: uri, fileMatch: fileMatch, schema: parsed }]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defaults.setDiagnosticsOptions({
|
||||||
|
validate: true,
|
||||||
|
enableSchemaRequest: false,
|
||||||
|
schemas: schemas
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function createEditor(id, host, options, dotNetRef) {
|
async function createEditor(id, host, options, dotNetRef) {
|
||||||
await ensureLoaded();
|
await ensureLoaded();
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
|
const language = options.language || "csharp";
|
||||||
|
const isJson = language === "json";
|
||||||
|
|
||||||
|
// A json editor with a schema needs an explicitly-URI'd model so the
|
||||||
|
// schema's fileMatch can target exactly this editor.
|
||||||
|
let model = null;
|
||||||
|
if (isJson) {
|
||||||
|
const uri = modelUriFor(id);
|
||||||
|
model = monaco.editor.getModel(uri);
|
||||||
|
if (model) { model.setValue(options.value || ""); }
|
||||||
|
else { model = monaco.editor.createModel(options.value || "", "json", uri); }
|
||||||
|
if (options.jsonSchema) { applyJsonSchema(id, options.jsonSchema); }
|
||||||
|
}
|
||||||
|
|
||||||
const editor = monaco.editor.create(host, {
|
const editor = monaco.editor.create(host, {
|
||||||
value: options.value || "",
|
value: model ? undefined : (options.value || ""),
|
||||||
language: options.language || "csharp",
|
model: model || undefined,
|
||||||
|
language: model ? undefined : language,
|
||||||
theme: "vs",
|
theme: "vs",
|
||||||
minimap: { enabled: false },
|
minimap: { enabled: false },
|
||||||
scrollBeyondLastLine: false,
|
scrollBeyondLastLine: false,
|
||||||
@@ -321,10 +371,16 @@
|
|||||||
dotNetRef.invokeMethodAsync("OnValueChanged", value).catch(function () {});
|
dotNetRef.invokeMethodAsync("OnValueChanged", value).catch(function () {});
|
||||||
if (options.language === "csharp") scheduleDiagnostics();
|
if (options.language === "csharp") scheduleDiagnostics();
|
||||||
});
|
});
|
||||||
editors[id] = { editor: editor, dotNetRef: dotNetRef };
|
editors[id] = { editor: editor, dotNetRef: dotNetRef, isJson: isJson };
|
||||||
|
|
||||||
// Run an initial diagnostic pass so existing scripts show their markers.
|
// Run an initial diagnostic pass so existing scripts show their markers.
|
||||||
if (options.language === "csharp") scheduleDiagnostics();
|
if (language === "csharp") scheduleDiagnostics();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setJsonSchema(id, schemaJson) {
|
||||||
|
const entry = editors[id];
|
||||||
|
if (!entry || !entry.isJson) return;
|
||||||
|
applyJsonSchema(id, schemaJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setEditorOption(id, optionName, value) {
|
function setEditorOption(id, optionName, value) {
|
||||||
@@ -377,6 +433,15 @@
|
|||||||
function dispose(id) {
|
function dispose(id) {
|
||||||
const entry = editors[id];
|
const entry = editors[id];
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
|
// Drop this editor's json schema entry and its explicitly-URI'd model so
|
||||||
|
// a disposed json editor leaves nothing behind in the global defaults.
|
||||||
|
if (entry.isJson) {
|
||||||
|
try { applyJsonSchema(id, null); } catch (e) {}
|
||||||
|
try {
|
||||||
|
const model = monaco.editor.getModel(modelUriFor(id));
|
||||||
|
if (model) model.dispose();
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
try { entry.editor.dispose(); } catch (e) {}
|
try { entry.editor.dispose(); } catch (e) {}
|
||||||
delete editors[id];
|
delete editors[id];
|
||||||
}
|
}
|
||||||
@@ -386,6 +451,7 @@
|
|||||||
setValue: setValue,
|
setValue: setValue,
|
||||||
getValue: getValue,
|
getValue: getValue,
|
||||||
setMarkers: setMarkers,
|
setMarkers: setMarkers,
|
||||||
|
setJsonSchema: setJsonSchema,
|
||||||
setEditorOption: setEditorOption,
|
setEditorOption: setEditorOption,
|
||||||
format: format,
|
format: format,
|
||||||
revealLine: revealLine,
|
revealLine: revealLine,
|
||||||
|
|||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
using Bunit;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NSubstitute;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M9-T31: the raw-JSON escape hatch in <see cref="ParameterValueForm"/> (the
|
||||||
|
/// fallback for an unresolved <c>$ref</c> / unknown-type node) is a Monaco
|
||||||
|
/// <c>json</c> editor wired with the form's RESOLVED schema, so Monaco's
|
||||||
|
/// built-in JSON language gives schema-driven hover + completion. These tests
|
||||||
|
/// assert at the C#/interop boundary that the resolved schema (with
|
||||||
|
/// <c>{"$ref":"lib:Name"}</c> inlined) reaches the editor's <c>JsonSchema</c>
|
||||||
|
/// parameter — the JS hover/completion itself is Monaco-built-in and not
|
||||||
|
/// unit-testable here.
|
||||||
|
/// </summary>
|
||||||
|
public class ParameterValueFormMonacoSchemaTests : BunitContext
|
||||||
|
{
|
||||||
|
private readonly ISchemaLibraryQueryService _library = Substitute.For<ISchemaLibraryQueryService>();
|
||||||
|
|
||||||
|
public ParameterValueFormMonacoSchemaTests()
|
||||||
|
{
|
||||||
|
// The escape-hatch surface renders a MonacoEditor whose JS interop is not
|
||||||
|
// exercised here — Loose mode lets the unconfigured createEditor call no-op
|
||||||
|
// so the render completes and we can assert on the editor's parameters.
|
||||||
|
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||||
|
_library.GetSchemaMapAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new Dictionary<string, string>());
|
||||||
|
Services.AddSingleton(_library);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EscapeHatch_RendersMonacoJsonEditor_NotPlainTextarea()
|
||||||
|
{
|
||||||
|
// A nested field whose $ref cannot be resolved drops to the raw-JSON
|
||||||
|
// escape hatch. That surface must now be a Monaco json editor.
|
||||||
|
const string schema = """
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"payload": { "$ref": "lib:Missing" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var cut = Render<ParameterValueForm>(p => p
|
||||||
|
.Add(x => x.ParameterDefinitions, schema)
|
||||||
|
.Add(x => x.Values, new Dictionary<string, object?>()));
|
||||||
|
|
||||||
|
var editor = cut.FindComponent<MonacoEditor>();
|
||||||
|
Assert.Equal("json", editor.Instance.Language);
|
||||||
|
// No plain <textarea> fallback remains for the escape hatch.
|
||||||
|
Assert.Empty(cut.FindAll("textarea"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EscapeHatch_MonacoEditor_ReceivesResolvedSchema_WithRefInlined()
|
||||||
|
{
|
||||||
|
// The library entry "Address" resolves to an object with street/city.
|
||||||
|
const string libSchema = """
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"street": { "type": "string" },
|
||||||
|
"city": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
_library.GetSchemaMapAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new Dictionary<string, string> { ["Address"] = libSchema });
|
||||||
|
|
||||||
|
// Root has a RESOLVABLE $ref field (shipTo → Address) plus an
|
||||||
|
// UNRESOLVABLE one (payload) that forces the escape-hatch Monaco editor.
|
||||||
|
const string schema = """
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"shipTo": { "$ref": "lib:Address" },
|
||||||
|
"payload": { "$ref": "lib:Missing" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var cut = Render<ParameterValueForm>(p => p
|
||||||
|
.Add(x => x.ParameterDefinitions, schema)
|
||||||
|
.Add(x => x.Values, new Dictionary<string, object?>()));
|
||||||
|
|
||||||
|
var editor = cut.FindComponent<MonacoEditor>();
|
||||||
|
var jsonSchema = editor.Instance.JsonSchema;
|
||||||
|
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(jsonSchema));
|
||||||
|
// The RESOLVED shape is fed to Monaco: the $ref target's fields are
|
||||||
|
// inlined (street/city present), and the lib: pointer text is gone.
|
||||||
|
Assert.Contains("street", jsonSchema!);
|
||||||
|
Assert.Contains("city", jsonSchema!);
|
||||||
|
Assert.DoesNotContain("lib:Address", jsonSchema!);
|
||||||
|
Assert.DoesNotContain("$ref", jsonSchema!);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user