From 68c0f7ac598bdfad35f18ce59565a8758bf8d2f5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 13:15:54 -0400 Subject: [PATCH] feat(m9/T31): Monaco JSON-schema hover/completion on value-entry surface --- .../Components/Shared/MonacoEditor.razor | 29 ++++- .../Shared/ParameterValueForm.razor | 93 +++++++++++++++- .../wwwroot/js/monaco-init.js | 76 ++++++++++++- .../ParameterValueFormMonacoSchemaTests.cs | 101 ++++++++++++++++++ 4 files changed, 286 insertions(+), 13 deletions(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ParameterValueFormMonacoSchemaTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/MonacoEditor.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/MonacoEditor.razor index 9e1b6443..857438ee 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/MonacoEditor.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/MonacoEditor.razor @@ -28,6 +28,16 @@ [Parameter] public bool ReadOnly { get; set; } = false; [Parameter] public bool ShowToolbar { get; set; } = true; + /// + /// Optional JSON Schema (the resolved schema text — any + /// {"$ref":"lib:Name"} already inlined) applied to this editor's + /// model when is json. 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. + /// + [Parameter] public string? JsonSchema { get; set; } + /// /// Runtime globals surface the script is analyzed against. Defaults to /// template/shared-script globals; set to InboundApi on the API @@ -87,6 +97,7 @@ private DotNetObjectReference? _dotNetRef; private readonly string _id = Guid.NewGuid().ToString("N"); private string _lastSentValue = ""; + private string? _lastSentSchema; private bool _initialized; private bool _wrap; private bool _minimap; @@ -108,10 +119,12 @@ { value = Value ?? "", language = Language, - readOnly = ReadOnly + readOnly = ReadOnly, + jsonSchema = JsonSchema }, _dotNetRef); _initialized = true; + _lastSentSchema = JsonSchema; } catch (InvalidOperationException) { @@ -128,10 +141,18 @@ Logger.LogError(ex, "Monaco editor {EditorId} failed to initialize.", _id); } } - else if (_initialized && (Value ?? "") != _lastSentValue) + else if (_initialized) { - _lastSentValue = Value ?? ""; - await SafeInvokeAsync("MonacoBlazor.setValue", "set editor value", _id, _lastSentValue); + if ((Value ?? "") != _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); + } } } diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ParameterValueForm.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ParameterValueForm.razor index bbb7e6b4..38d36ef9 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ParameterValueForm.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ParameterValueForm.razor @@ -53,6 +53,12 @@ else private InboundApiSchema? _rootSchema; 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 // (e.g. "order.id", "tags[0]") so nested inputs each carry their own state. private readonly Dictionary _rawText = new(); @@ -70,6 +76,82 @@ else _shapes = ScriptParameterNames.ParseShapes(ParameterDefinitions); _rootSchema = await ParseRootAsync(); _topLevelFields = _rootSchema?.Type == "object" ? _rootSchema.Fields : Array.Empty(); + _resolvedSchemaJson = _rootSchema is null ? null : ToJsonSchema(_rootSchema); + } + + /// + /// Serializes a (resolved) back to a JSON Schema + /// string for the escape-hatch Monaco editor. Any {"$ref":"lib:Name"} the + /// parse already inlined is emitted as its resolved shape (no $ref 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 + /// {} so the editor still validates well-formed JSON without false errors. + /// + 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; + } } /// @@ -165,10 +247,13 @@ else @RenderArray(schema, path) break; - default: // unresolved $ref / unknown — raw-JSON escape hatch. - + default: // unresolved $ref / unknown — raw-JSON escape hatch (Monaco). +
+ +
@RenderError(path) break; } diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/js/monaco-init.js b/src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/js/monaco-init.js index 3d513730..695a2b3b 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/js/monaco-init.js +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/js/monaco-init.js @@ -1,5 +1,5 @@ // 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. (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) { await ensureLoaded(); 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, { - value: options.value || "", - language: options.language || "csharp", + value: model ? undefined : (options.value || ""), + model: model || undefined, + language: model ? undefined : language, theme: "vs", minimap: { enabled: false }, scrollBeyondLastLine: false, @@ -321,10 +371,16 @@ dotNetRef.invokeMethodAsync("OnValueChanged", value).catch(function () {}); 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. - 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) { @@ -377,6 +433,15 @@ function dispose(id) { const entry = editors[id]; 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) {} delete editors[id]; } @@ -386,6 +451,7 @@ setValue: setValue, getValue: getValue, setMarkers: setMarkers, + setJsonSchema: setJsonSchema, setEditorOption: setEditorOption, format: format, revealLine: revealLine, diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ParameterValueFormMonacoSchemaTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ParameterValueFormMonacoSchemaTests.cs new file mode 100644 index 00000000..1a0adc1e --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ParameterValueFormMonacoSchemaTests.cs @@ -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; + +/// +/// M9-T31: the raw-JSON escape hatch in (the +/// fallback for an unresolved $ref / unknown-type node) is a Monaco +/// json 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 +/// {"$ref":"lib:Name"} inlined) reaches the editor's JsonSchema +/// parameter — the JS hover/completion itself is Monaco-built-in and not +/// unit-testable here. +/// +public class ParameterValueFormMonacoSchemaTests : BunitContext +{ + private readonly ISchemaLibraryQueryService _library = Substitute.For(); + + 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()) + .Returns(new Dictionary()); + 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(p => p + .Add(x => x.ParameterDefinitions, schema) + .Add(x => x.Values, new Dictionary())); + + var editor = cut.FindComponent(); + Assert.Equal("json", editor.Instance.Language); + // No plain