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:
177
docs/plans/2026-05-12-script-schema-editor.md
Normal file
177
docs/plans/2026-05-12-script-schema-editor.md
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
# Script parameter / return: JSON Schema + JSONJoy editor
|
||||||
|
|
||||||
|
**Date:** 2026-05-12
|
||||||
|
**Status:** Superseded — see "Reversal: native Blazor SchemaBuilder" below.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Replace the custom `ParameterListEditor` / `ReturnTypeEditor` Blazor components
|
||||||
|
with [`jsonjoy-builder`](https://github.com/lovasoa/jsonjoy-builder) (`SchemaVisualEditor`),
|
||||||
|
embedded as a React island. The on-disk format for `TemplateScript.ParameterDefinitions`
|
||||||
|
and `TemplateScript.ReturnDefinition` changes from the project-local flat shape
|
||||||
|
(`[{name,type,required,itemType?}]` / `{type,itemType?}`) to standard JSON Schema.
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
|
||||||
|
The existing flat shape lacked descriptions, defaults, enums, nested objects,
|
||||||
|
and arrays of structured items. JSON Schema covers all of that, is the
|
||||||
|
industry vocabulary other tooling already speaks (OpenAPI 3.1, function-calling
|
||||||
|
APIs, validators), and `jsonjoy-builder` is a polished pre-built visual editor
|
||||||
|
for it.
|
||||||
|
|
||||||
|
## Trade-offs
|
||||||
|
|
||||||
|
- **Breaks the no-UI-framework rule for this feature.** `jsonjoy-builder` is
|
||||||
|
React 19 + Radix UI + Tailwind. Accepted: the island is isolated to one
|
||||||
|
modal panel, Tailwind is shipped pre-built (no toolchain shared with the
|
||||||
|
Blazor side), and the visual delta is contained.
|
||||||
|
- **New build pipeline.** A small Vite project under `src/ScadaLink.CentralUI/Schema.Editor/`
|
||||||
|
builds a single IIFE bundle into `wwwroot/lib/schema-editor/`. Output is
|
||||||
|
committed so `dotnet build` doesn't require Node.
|
||||||
|
- **Monaco overlap.** `jsonjoy-builder` depends on `@monaco-editor/react`,
|
||||||
|
which depends on `monaco-editor`. We already load Monaco globally for the
|
||||||
|
script code editor. The island calls `@monaco-editor/react`'s `loader.config({ monaco: window.monaco })`
|
||||||
|
at boot to reuse the same instance — no duplicate Monaco download.
|
||||||
|
|
||||||
|
## Storage format change
|
||||||
|
|
||||||
|
| Field | Before | After |
|
||||||
|
| ---------------------- | --------------------------------- | ----------------------------------------------------------- |
|
||||||
|
| `ParameterDefinitions` | `[{name,type,required,itemType?}]` | `{"type":"object","properties":{...},"required":[...]}` |
|
||||||
|
| `ReturnDefinition` | `{type,itemType?}` | Any JSON Schema (root `type` describes the returned value) |
|
||||||
|
|
||||||
|
Per the chosen rollout: **one-shot migration** rewrites all existing rows on
|
||||||
|
deploy. After the migration, the analysis pipeline reads JSON Schema only —
|
||||||
|
no dual-format support code.
|
||||||
|
|
||||||
|
Type mapping (flat → JSON Schema):
|
||||||
|
|
||||||
|
| Flat type | JSON Schema |
|
||||||
|
| --------- | ----------- |
|
||||||
|
| `Boolean` | `{"type":"boolean"}` |
|
||||||
|
| `Integer` | `{"type":"integer"}` |
|
||||||
|
| `Float` | `{"type":"number"}` |
|
||||||
|
| `String` | `{"type":"string"}` |
|
||||||
|
| `Object` | `{"type":"object"}` |
|
||||||
|
| `List` of X | `{"type":"array","items":{"type":<X>}}` |
|
||||||
|
|
||||||
|
`required: false` ⇒ name omitted from the `required` array.
|
||||||
|
`required: true` (default) ⇒ name added to `required`.
|
||||||
|
|
||||||
|
## Component layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/ScadaLink.CentralUI/Schema.Editor/ ← new Vite project (committed)
|
||||||
|
package.json
|
||||||
|
vite.config.ts
|
||||||
|
tsconfig.json
|
||||||
|
src/main.tsx ← exposes window.ScadaSchemaEditor
|
||||||
|
src/SchemaEditorApp.tsx
|
||||||
|
src/index.css
|
||||||
|
.gitignore ← node_modules only
|
||||||
|
dist/ ← (Vite outputs to wwwroot, not here)
|
||||||
|
|
||||||
|
src/ScadaLink.CentralUI/wwwroot/lib/schema-editor/
|
||||||
|
schema-editor.js ← built IIFE, committed
|
||||||
|
schema-editor.css
|
||||||
|
|
||||||
|
src/ScadaLink.CentralUI/Components/Shared/
|
||||||
|
SchemaEditor.razor ← Blazor wrapper; mirrors MonacoEditor.razor
|
||||||
|
|
||||||
|
src/ScadaLink.CentralUI/ScriptAnalysis/
|
||||||
|
ScriptShapeParser.cs ← rewrite to read JSON Schema
|
||||||
|
src/ScadaLink.CentralUI/Components/Shared/
|
||||||
|
ScriptParameterNames.cs ← rewrite to read JSON Schema
|
||||||
|
```
|
||||||
|
|
||||||
|
Removed after rollout: `ParameterListEditor.razor`, `ReturnTypeEditor.razor`.
|
||||||
|
|
||||||
|
## JS interop contract
|
||||||
|
|
||||||
|
```ts
|
||||||
|
window.ScadaSchemaEditor = {
|
||||||
|
mount(id: string, host: HTMLElement, options: {
|
||||||
|
value: string; // current schema JSON (may be empty)
|
||||||
|
mode: 'parameters' | 'return';
|
||||||
|
readOnly?: boolean;
|
||||||
|
}, dotNetRef: { invokeMethodAsync(name: 'OnValueChanged', json: string): Promise<void> }): void;
|
||||||
|
setValue(id: string, value: string): void;
|
||||||
|
dispose(id: string): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
EF Core migration in `ScadaLink.ConfigurationDatabase` reads
|
||||||
|
`TemplateScripts.ParameterDefinitions` and `ReturnDefinition` from every row,
|
||||||
|
sniffs format (array vs object), translates if legacy, writes back. Idempotent:
|
||||||
|
re-running a row already in JSON Schema is a no-op. Runs once at deploy via
|
||||||
|
the existing auto-apply path.
|
||||||
|
|
||||||
|
## Out of scope (deferred)
|
||||||
|
|
||||||
|
- Schema-driven value-entry forms (e.g. Inbound API tester) — would also use
|
||||||
|
`jsonjoy-builder`'s value-editor mode, but no caller surface needs it today.
|
||||||
|
- Hover/completion enhancements derived from JSON Schema descriptions or
|
||||||
|
defaults. Today's pipeline only needs name + type + required.
|
||||||
|
- Reuse of JSON Schema `$ref` across templates — could be a future template-level
|
||||||
|
schema library.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reversal: native Blazor SchemaBuilder (2026-05-12, same day)
|
||||||
|
|
||||||
|
JSONJoy worked but felt heavy for the actual data we author here. Specifically:
|
||||||
|
|
||||||
|
- The "Add Field" modal flow is two clicks per parameter where the legacy
|
||||||
|
inline-row editor was zero. For the common 1-3 scalar-param case, a visible
|
||||||
|
modal dialog every time is friction.
|
||||||
|
- JSONJoy's value-mode UX is awkward — it always renders an "Add Field" button
|
||||||
|
even when the schema's root type is `string` / `integer` / etc., so the
|
||||||
|
Return-type tab is mismatched to the underlying single-value model.
|
||||||
|
- React 19 + Radix + Tailwind for one form field is a lot of build pipeline
|
||||||
|
surface to maintain.
|
||||||
|
|
||||||
|
**Decision:** replace JSONJoy with a Bootstrap-only Blazor component
|
||||||
|
(`SchemaBuilder.razor`) that recurses through its own render methods.
|
||||||
|
Storage format unchanged — still JSON Schema. The migration, parser, and
|
||||||
|
downstream analysis code are untouched.
|
||||||
|
|
||||||
|
**Scope decisions (from refinement session):**
|
||||||
|
|
||||||
|
- Type set: only the six JSON Schema primitives
|
||||||
|
(`string · integer · number · boolean · object · array`). No `date-time` /
|
||||||
|
`format`, no `enum` / `pattern` / `min/max`, no `$ref` / `oneOf` /
|
||||||
|
`anyOf` / `allOf`, no `additionalProperties`. Power-user expansion can
|
||||||
|
come later behind a per-row "more options" toggle.
|
||||||
|
- No description support per property. The row stays a single horizontal
|
||||||
|
line: name + type + (items: type if array) + required + remove.
|
||||||
|
- Nested objects and arrays-of-objects recurse — same editor renders at any
|
||||||
|
depth.
|
||||||
|
|
||||||
|
**Files added:**
|
||||||
|
|
||||||
|
- `src/ScadaLink.CentralUI/Components/Shared/SchemaBuilderModel.cs` —
|
||||||
|
in-memory `SchemaNode` / `SchemaProperty` tree plus pure-static
|
||||||
|
parse / serialize. Round-trips through the canonical JSON Schema text and
|
||||||
|
tolerates legacy flat-array shape as a parse fallback.
|
||||||
|
- `src/ScadaLink.CentralUI/Components/Shared/SchemaBuilder.razor` —
|
||||||
|
recursive renderer driven by `Mode="object"` (parameter list) or
|
||||||
|
`Mode="value"` (single value, with object/array falling back to the
|
||||||
|
property editor).
|
||||||
|
- `tests/ScadaLink.CentralUI.Tests/Shared/SchemaBuilderModelTests.cs` —
|
||||||
|
parse / serialize / round-trip / legacy-array coverage.
|
||||||
|
|
||||||
|
**Files removed:**
|
||||||
|
|
||||||
|
- `src/ScadaLink.CentralUI/Schema.Editor/` (Vite project, node_modules, etc.)
|
||||||
|
- `src/ScadaLink.CentralUI/wwwroot/lib/schema-editor/` (built bundle)
|
||||||
|
- `src/ScadaLink.CentralUI/Components/Shared/SchemaEditor.razor` (Blazor wrapper)
|
||||||
|
- `<script>` / `<link>` references to schema-editor in `App.razor`
|
||||||
|
- `<DefaultItemExcludes>Schema.Editor/**` from CentralUI csproj
|
||||||
|
|
||||||
|
**Forms updated:** `TemplateEdit.razor`, `SharedScriptForm.razor`,
|
||||||
|
`ApiMethodForm.razor` now use `<SchemaBuilder>` directly.
|
||||||
|
|
||||||
|
The original `jsonjoy-builder` integration sections above are kept for
|
||||||
|
historical context but no longer reflect what's in the codebase.
|
||||||
@@ -30,11 +30,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Parameters</label>
|
<label class="form-label">Parameters</label>
|
||||||
<ParameterListEditor Json="@_params" JsonChanged="@(v => _params = v)" />
|
<SchemaBuilder Mode="object"
|
||||||
|
Value="@_params"
|
||||||
|
ValueChanged="@(v => _params = v)" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Return value</label>
|
<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>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Script</label>
|
<label class="form-label">Script</label>
|
||||||
|
|||||||
@@ -32,11 +32,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label small">Parameters</label>
|
<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>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label small">Return value</label>
|
<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>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label small">Code</label>
|
<label class="form-label small">Code</label>
|
||||||
|
|||||||
@@ -70,8 +70,9 @@
|
|||||||
private bool _validating;
|
private bool _validating;
|
||||||
private Commons.Types.Flattening.ValidationResult? _validationResult;
|
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 bool _showAttrForm;
|
||||||
|
private int? _editAttrId;
|
||||||
private string _attrName = string.Empty;
|
private string _attrName = string.Empty;
|
||||||
private string? _attrValue;
|
private string? _attrValue;
|
||||||
private DataType _attrDataType;
|
private DataType _attrDataType;
|
||||||
@@ -80,6 +81,7 @@
|
|||||||
private string? _attrFormError;
|
private string? _attrFormError;
|
||||||
|
|
||||||
private bool _showAlarmForm;
|
private bool _showAlarmForm;
|
||||||
|
private int? _editAlarmId;
|
||||||
private string _alarmName = string.Empty;
|
private string _alarmName = string.Empty;
|
||||||
private int _alarmPriority;
|
private int _alarmPriority;
|
||||||
private AlarmTriggerType _alarmTriggerType;
|
private AlarmTriggerType _alarmTriggerType;
|
||||||
@@ -88,6 +90,7 @@
|
|||||||
private string? _alarmFormError;
|
private string? _alarmFormError;
|
||||||
|
|
||||||
private bool _showScriptForm;
|
private bool _showScriptForm;
|
||||||
|
private int? _editScriptId;
|
||||||
private string _scriptName = string.Empty;
|
private string _scriptName = string.Empty;
|
||||||
private string _scriptCode = string.Empty;
|
private string _scriptCode = string.Empty;
|
||||||
private string? _scriptTriggerType;
|
private string? _scriptTriggerType;
|
||||||
@@ -96,6 +99,7 @@
|
|||||||
private string? _scriptReturn;
|
private string? _scriptReturn;
|
||||||
private bool _scriptIsLocked;
|
private bool _scriptIsLocked;
|
||||||
private string? _scriptFormError;
|
private string? _scriptFormError;
|
||||||
|
private string _scriptModalTab = "code"; // "code" | "parameters" | "return"
|
||||||
private MonacoEditor? _scriptEditor;
|
private MonacoEditor? _scriptEditor;
|
||||||
private IReadOnlyList<ScadaLink.CentralUI.ScriptAnalysis.DiagnosticMarker> _scriptMarkers
|
private IReadOnlyList<ScadaLink.CentralUI.ScriptAnalysis.DiagnosticMarker> _scriptMarkers
|
||||||
= Array.Empty<ScadaLink.CentralUI.ScriptAnalysis.DiagnosticMarker>();
|
= Array.Empty<ScadaLink.CentralUI.ScriptAnalysis.DiagnosticMarker>();
|
||||||
@@ -470,49 +474,57 @@
|
|||||||
{
|
{
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
<h5 class="mb-0">Attributes</h5>
|
<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>
|
</div>
|
||||||
|
|
||||||
@if (_showAttrForm)
|
@if (_showAttrForm)
|
||||||
{
|
{
|
||||||
<div class="card mb-3">
|
var editing = _editAttrId.HasValue;
|
||||||
<div class="card-header">Add Attribute</div>
|
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
|
||||||
<div class="card-body">
|
<div class="modal-dialog modal-dialog-scrollable">
|
||||||
<div class="row g-3">
|
<div class="modal-content">
|
||||||
<div class="col-12">
|
<div class="modal-header">
|
||||||
<label class="form-label">Name</label>
|
<h6 class="modal-title">@(editing ? "Edit Attribute" : "Add Attribute")</h6>
|
||||||
<input type="text" class="form-control" @bind="_attrName" />
|
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAttributeForm"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="modal-body">
|
||||||
<label class="form-label">Data Type</label>
|
<div class="row g-3">
|
||||||
<select class="form-select" @bind="_attrDataType">
|
<div class="col-12">
|
||||||
@foreach (var dt in Enum.GetValues<DataType>())
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@if (_attrFormError != null)
|
<div class="modal-footer">
|
||||||
{
|
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelAttributeForm">Cancel</button>
|
||||||
<div class="col-12"><div class="text-danger small">@_attrFormError</div></div>
|
<button class="btn btn-success btn-sm" @onclick="SaveAttribute">@(editing ? "Save" : "Add")</button>
|
||||||
}
|
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -616,6 +628,10 @@
|
|||||||
}
|
}
|
||||||
@if (!(derived && baseAttr != null))
|
@if (!(derived && baseAttr != null))
|
||||||
{
|
{
|
||||||
|
<li>
|
||||||
|
<button class="dropdown-item" @onclick="() => BeginEditAttribute(attr)">Edit…</button>
|
||||||
|
</li>
|
||||||
|
<li><hr class="dropdown-divider" /></li>
|
||||||
<li>
|
<li>
|
||||||
<button class="dropdown-item text-danger"
|
<button class="dropdown-item text-danger"
|
||||||
@onclick="() => DeleteAttribute(attr)">Delete</button>
|
@onclick="() => DeleteAttribute(attr)">Delete</button>
|
||||||
@@ -701,49 +717,60 @@
|
|||||||
{
|
{
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
<h5 class="mb-0">Alarms</h5>
|
<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>
|
</div>
|
||||||
|
|
||||||
@if (_showAlarmForm)
|
@if (_showAlarmForm)
|
||||||
{
|
{
|
||||||
<div class="card mb-3">
|
var editing = _editAlarmId.HasValue;
|
||||||
<div class="card-header">Add Alarm</div>
|
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
|
||||||
<div class="card-body">
|
<div class="modal-dialog modal-dialog-scrollable">
|
||||||
<div class="row g-3">
|
<div class="modal-content">
|
||||||
<div class="col-12">
|
<div class="modal-header">
|
||||||
<label class="form-label">Name</label>
|
<h6 class="modal-title">@(editing ? "Edit Alarm" : "Add Alarm")</h6>
|
||||||
<input type="text" class="form-control" @bind="_alarmName" />
|
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAlarmForm"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="modal-body">
|
||||||
<label class="form-label">Trigger Type</label>
|
<div class="row g-3">
|
||||||
<select class="form-select" @bind="_alarmTriggerType">
|
<div class="col-12">
|
||||||
@foreach (var tt in Enum.GetValues<AlarmTriggerType>())
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@if (_alarmFormError != null)
|
<div class="modal-footer">
|
||||||
{
|
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelAlarmForm">Cancel</button>
|
||||||
<div class="col-12"><div class="text-danger small">@_alarmFormError</div></div>
|
<button class="btn btn-success btn-sm" @onclick="SaveAlarm">@(editing ? "Save" : "Add")</button>
|
||||||
}
|
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -786,6 +813,10 @@
|
|||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
aria-label="@($"More actions for {alarm.Name}")">⋮</button>
|
aria-label="@($"More actions for {alarm.Name}")">⋮</button>
|
||||||
<ul class="dropdown-menu dropdown-menu-end">
|
<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>
|
<li>
|
||||||
<button class="dropdown-item text-danger"
|
<button class="dropdown-item text-danger"
|
||||||
@onclick="() => DeleteAlarm(alarm)">Delete</button>
|
@onclick="() => DeleteAlarm(alarm)">Delete</button>
|
||||||
@@ -804,61 +835,100 @@
|
|||||||
{
|
{
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
<h5 class="mb-0">Scripts</h5>
|
<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>
|
</div>
|
||||||
|
|
||||||
@if (_showScriptForm)
|
@if (_showScriptForm)
|
||||||
{
|
{
|
||||||
<div class="card mb-3">
|
var editingScript = _editScriptId.HasValue;
|
||||||
<div class="card-header">Add Script</div>
|
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
|
||||||
<div class="card-body">
|
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||||
<div class="row g-3">
|
<div class="modal-content">
|
||||||
<div class="col-12">
|
<div class="modal-header">
|
||||||
<label class="form-label">Name</label>
|
<h6 class="modal-title">@(editingScript ? "Edit Script" : "Add Script")</h6>
|
||||||
<input type="text" class="form-control" @bind="_scriptName" />
|
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelScriptForm"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="modal-body">
|
||||||
<label class="form-label">Trigger Type</label>
|
<div class="row g-3 mb-3">
|
||||||
<input type="text" class="form-control" @bind="_scriptTriggerType" placeholder="e.g. ValueChange" />
|
<div class="col-12">
|
||||||
</div>
|
<label class="form-label">Name</label>
|
||||||
<div class="col-12">
|
<input type="text" class="form-control" @bind="_scriptName" readonly="@editingScript" />
|
||||||
<label class="form-label">Trigger Config (JSON)</label>
|
</div>
|
||||||
<input type="text" class="form-control" @bind="_scriptTriggerConfig" />
|
<div class="col-md-6">
|
||||||
</div>
|
<label class="form-label">Trigger Type</label>
|
||||||
<div class="col-12">
|
<input type="text" class="form-control" @bind="_scriptTriggerType" placeholder="e.g. ValueChange" />
|
||||||
<label class="form-label">Parameters</label>
|
</div>
|
||||||
<ParameterListEditor Json="@_scriptParameters" JsonChanged="@(v => _scriptParameters = v)" />
|
<div class="col-md-6">
|
||||||
</div>
|
<label class="form-label">Trigger Config (JSON)</label>
|
||||||
<div class="col-12">
|
<input type="text" class="form-control" @bind="_scriptTriggerConfig" />
|
||||||
<label class="form-label">Return value</label>
|
</div>
|
||||||
<ReturnTypeEditor Json="@_scriptReturn" JsonChanged="@(v => _scriptReturn = v)" />
|
<div class="col-12">
|
||||||
</div>
|
<div class="form-check">
|
||||||
<div class="col-12">
|
<input class="form-check-input" type="checkbox" @bind="_scriptIsLocked" id="scriptLocked" />
|
||||||
<div class="form-check">
|
<label class="form-check-label" for="scriptLocked">Locked</label>
|
||||||
<input class="form-check-input" type="checkbox" @bind="_scriptIsLocked" id="scriptLocked" />
|
</div>
|
||||||
<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>
|
||||||
<div class="col-12">
|
<div class="modal-footer">
|
||||||
<label class="form-label">Code</label>
|
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelScriptForm">Cancel</button>
|
||||||
<MonacoEditor @ref="_scriptEditor" Value="@_scriptCode" ValueChanged="@(v => _scriptCode = v)"
|
<button class="btn btn-success btn-sm" @onclick="SaveScript">@(editingScript ? "Save" : "Add")</button>
|
||||||
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -952,6 +1022,10 @@
|
|||||||
}
|
}
|
||||||
@if (!(derivedScripts && baseScript != null))
|
@if (!(derivedScripts && baseScript != null))
|
||||||
{
|
{
|
||||||
|
<li>
|
||||||
|
<button class="dropdown-item" @onclick="() => BeginEditScript(script)">Edit…</button>
|
||||||
|
</li>
|
||||||
|
<li><hr class="dropdown-divider" /></li>
|
||||||
<li>
|
<li>
|
||||||
<button class="dropdown-item text-danger"
|
<button class="dropdown-item text-danger"
|
||||||
@onclick="() => DeleteScript(script)">Delete</button>
|
@onclick="() => DeleteScript(script)">Delete</button>
|
||||||
@@ -1013,12 +1087,74 @@
|
|||||||
|
|
||||||
// ---- CRUD handlers ----
|
// ---- 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;
|
if (_selectedTemplate == null) return;
|
||||||
_attrFormError = null;
|
_attrFormError = null;
|
||||||
if (string.IsNullOrWhiteSpace(_attrName)) { _attrFormError = "Name is required."; return; }
|
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())
|
var attr = new TemplateAttribute(_attrName.Trim())
|
||||||
{
|
{
|
||||||
DataType = _attrDataType,
|
DataType = _attrDataType,
|
||||||
@@ -1027,9 +1163,8 @@
|
|||||||
DataSourceReference = _attrDataSourceRef?.Trim()
|
DataSourceReference = _attrDataSourceRef?.Trim()
|
||||||
};
|
};
|
||||||
|
|
||||||
var user = await GetCurrentUserAsync();
|
var addResult = await TemplateService.AddAttributeAsync(_selectedTemplate.Id, attr, user);
|
||||||
var result = await TemplateService.AddAttributeAsync(_selectedTemplate.Id, attr, user);
|
if (addResult.IsSuccess)
|
||||||
if (result.IsSuccess)
|
|
||||||
{
|
{
|
||||||
_showAttrForm = false;
|
_showAttrForm = false;
|
||||||
_toast.ShowSuccess($"Attribute '{_attrName}' added.");
|
_toast.ShowSuccess($"Attribute '{_attrName}' added.");
|
||||||
@@ -1037,7 +1172,7 @@
|
|||||||
}
|
}
|
||||||
else
|
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;
|
if (_selectedTemplate == null) return;
|
||||||
_alarmFormError = null;
|
_alarmFormError = null;
|
||||||
if (string.IsNullOrWhiteSpace(_alarmName)) { _alarmFormError = "Name is required."; return; }
|
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())
|
var alarm = new TemplateAlarm(_alarmName.Trim())
|
||||||
{
|
{
|
||||||
TriggerType = _alarmTriggerType,
|
TriggerType = _alarmTriggerType,
|
||||||
@@ -1072,9 +1300,8 @@
|
|||||||
IsLocked = _alarmIsLocked
|
IsLocked = _alarmIsLocked
|
||||||
};
|
};
|
||||||
|
|
||||||
var user = await GetCurrentUserAsync();
|
var addResult = await TemplateService.AddAlarmAsync(_selectedTemplate.Id, alarm, user);
|
||||||
var result = await TemplateService.AddAlarmAsync(_selectedTemplate.Id, alarm, user);
|
if (addResult.IsSuccess)
|
||||||
if (result.IsSuccess)
|
|
||||||
{
|
{
|
||||||
_showAlarmForm = false;
|
_showAlarmForm = false;
|
||||||
_toast.ShowSuccess($"Alarm '{_alarmName}' added.");
|
_toast.ShowSuccess($"Alarm '{_alarmName}' added.");
|
||||||
@@ -1082,7 +1309,7 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_alarmFormError = result.Error;
|
_alarmFormError = addResult.Error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1100,13 +1327,82 @@
|
|||||||
else { _toast.ShowError(result.Error); }
|
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;
|
if (_selectedTemplate == null) return;
|
||||||
_scriptFormError = null;
|
_scriptFormError = null;
|
||||||
if (string.IsNullOrWhiteSpace(_scriptName)) { _scriptFormError = "Name is required."; return; }
|
if (string.IsNullOrWhiteSpace(_scriptName)) { _scriptFormError = "Name is required."; return; }
|
||||||
if (string.IsNullOrWhiteSpace(_scriptCode)) { _scriptFormError = "Code 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)
|
var script = new TemplateScript(_scriptName.Trim(), _scriptCode)
|
||||||
{
|
{
|
||||||
TriggerType = _scriptTriggerType?.Trim(),
|
TriggerType = _scriptTriggerType?.Trim(),
|
||||||
@@ -1116,9 +1412,8 @@
|
|||||||
IsLocked = _scriptIsLocked
|
IsLocked = _scriptIsLocked
|
||||||
};
|
};
|
||||||
|
|
||||||
var user = await GetCurrentUserAsync();
|
var addResult = await TemplateService.AddScriptAsync(_selectedTemplate.Id, script, user);
|
||||||
var result = await TemplateService.AddScriptAsync(_selectedTemplate.Id, script, user);
|
if (addResult.IsSuccess)
|
||||||
if (result.IsSuccess)
|
|
||||||
{
|
{
|
||||||
_showScriptForm = false;
|
_showScriptForm = false;
|
||||||
_toast.ShowSuccess($"Script '{_scriptName}' added.");
|
_toast.ShowSuccess($"Script '{_scriptName}' added.");
|
||||||
@@ -1126,7 +1421,7 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_scriptFormError = result.Error;
|
_scriptFormError = addResult.Error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,8 @@
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
<label class="form-label">@state.Body</label>
|
<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"
|
placeholder="@state.Placeholder"
|
||||||
value="@_promptValue"
|
value="@_promptValue"
|
||||||
@oninput="OnPromptInput" />
|
@oninput="OnPromptInput" />
|
||||||
@@ -50,8 +51,10 @@
|
|||||||
|
|
||||||
@code {
|
@code {
|
||||||
private ElementReference _modalRef;
|
private ElementReference _modalRef;
|
||||||
|
private ElementReference _promptInputRef;
|
||||||
private string _promptValue = string.Empty;
|
private string _promptValue = string.Empty;
|
||||||
private DialogState? _lastSeenState;
|
private DialogState? _lastSeenState;
|
||||||
|
private DialogState? _focusedForState;
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
@@ -85,11 +88,26 @@
|
|||||||
{
|
{
|
||||||
try { await JS.InvokeVoidAsync("document.body.classList.add", "modal-open"); }
|
try { await JS.InvokeVoidAsync("document.body.classList.add", "modal-open"); }
|
||||||
catch { /* prerender: no JS — ignore */ }
|
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
|
else
|
||||||
{
|
{
|
||||||
|
_focusedForState = null;
|
||||||
try { await JS.InvokeVoidAsync("document.body.classList.remove", "modal-open"); }
|
try { await JS.InvokeVoidAsync("document.body.classList.remove", "modal-open"); }
|
||||||
catch { /* prerender: no JS — ignore */ }
|
catch { /* prerender: no JS — ignore */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,9 +28,9 @@
|
|||||||
[Parameter] public bool ShowToolbar { get; set; } = true;
|
[Parameter] public bool ShowToolbar { get; set; } = true;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parameter names declared on the form (from the ParameterListEditor),
|
/// Parameter names declared on the form (derived from the SchemaBuilder's
|
||||||
/// surfaced as completions inside Parameters["..."] literals and used by
|
/// JSON Schema), surfaced as completions inside Parameters["..."] literals
|
||||||
/// the unknown-key diagnostic.
|
/// and used by the unknown-key diagnostic.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Parameter] public IReadOnlyList<string>? DeclaredParameters { get; set; }
|
[Parameter] public IReadOnlyList<string>? DeclaredParameters { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
207
src/ScadaLink.CentralUI/Components/Shared/SchemaBuilder.razor
Normal file
207
src/ScadaLink.CentralUI/Components/Shared/SchemaBuilder.razor
Normal 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)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
204
src/ScadaLink.CentralUI/Components/Shared/SchemaBuilderModel.cs
Normal file
204
src/ScadaLink.CentralUI/Components/Shared/SchemaBuilderModel.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,51 +1,20 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using ScadaLink.CentralUI.ScriptAnalysis;
|
using ScadaLink.CentralUI.ScriptAnalysis;
|
||||||
|
|
||||||
namespace ScadaLink.CentralUI.Components.Shared;
|
namespace ScadaLink.CentralUI.Components.Shared;
|
||||||
|
|
||||||
/// <summary>
|
/// <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
|
/// returns the declared parameter names (and shapes). Used by script-edit
|
||||||
/// pages to feed the Monaco editor's Parameters["..."] context.
|
/// pages to feed the Monaco editor's Parameters["..."] context.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ScriptParameterNames
|
public static class ScriptParameterNames
|
||||||
{
|
{
|
||||||
public static IReadOnlyList<string> Parse(string? json)
|
public static IReadOnlyList<string> Parse(string? json) =>
|
||||||
{
|
JsonSchemaShapeParser.ParseParameters(json)
|
||||||
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<string>();
|
.Select(p => p.Name)
|
||||||
try
|
.Where(s => !string.IsNullOrEmpty(s))
|
||||||
{
|
.ToList();
|
||||||
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<ParameterShape> ParseShapes(string? json)
|
public static IReadOnlyList<ParameterShape> ParseShapes(string? json) =>
|
||||||
{
|
JsonSchemaShapeParser.ParseParameters(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>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,10 @@
|
|||||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="ScadaLink.CentralUI.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="4.13.0" />
|
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="4.13.0" />
|
||||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.13.0" />
|
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.13.0" />
|
||||||
|
|||||||
177
src/ScadaLink.CentralUI/ScriptAnalysis/JsonSchemaShapeParser.cs
Normal file
177
src/ScadaLink.CentralUI/ScriptAnalysis/JsonSchemaShapeParser.cs
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ using Microsoft.CodeAnalysis;
|
|||||||
using Microsoft.CodeAnalysis.CSharp;
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
||||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
using Microsoft.CodeAnalysis.Formatting;
|
|
||||||
using Microsoft.CodeAnalysis.Scripting;
|
using Microsoft.CodeAnalysis.Scripting;
|
||||||
using Microsoft.CodeAnalysis.Text;
|
using Microsoft.CodeAnalysis.Text;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
@@ -334,11 +333,13 @@ public class ScriptAnalysisService
|
|||||||
return new FormatResponse(request.Code);
|
return new FormatResponse(request.Code);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var workspace = new AdhocWorkspace();
|
|
||||||
var tree = CSharpSyntaxTree.ParseText(
|
var tree = CSharpSyntaxTree.ParseText(
|
||||||
request.Code,
|
request.Code,
|
||||||
new CSharpParseOptions(LanguageVersion.Latest, kind: SourceCodeKind.Script));
|
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());
|
return new FormatResponse(formatted.ToFullString());
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|||||||
@@ -1,59 +1,17 @@
|
|||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses the parameter-definitions and return-definition JSON written by
|
/// Parses the parameter-definitions and return-definition JSON Schema written
|
||||||
/// ParameterListEditor / ReturnTypeEditor into a <see cref="ScriptShape"/>.
|
/// by SchemaBuilder into a <see cref="ScriptShape"/>. Delegates to
|
||||||
/// Lenient: malformed JSON yields an empty parameter list, not an exception.
|
/// <see cref="JsonSchemaShapeParser"/>, which also handles legacy flat-shape
|
||||||
|
/// rows during the transition window.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ScriptShapeParser
|
public static class ScriptShapeParser
|
||||||
{
|
{
|
||||||
public static ScriptShape Parse(string name, string? parametersJson, string? returnJson)
|
public static ScriptShape Parse(string name, string? parametersJson, string? returnJson)
|
||||||
{
|
{
|
||||||
var parameters = ParseParameters(parametersJson);
|
var parameters = JsonSchemaShapeParser.ParseParameters(parametersJson);
|
||||||
var returnType = ParseReturnType(returnJson);
|
var returnType = JsonSchemaShapeParser.ParseReturnType(returnJson);
|
||||||
return new ScriptShape(name, parameters, returnType);
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
1300
src/ScadaLink.ConfigurationDatabase/Migrations/20260512211204_MigrateParametersToJsonSchema.Designer.cs
generated
Normal file
1300
src/ScadaLink.ConfigurationDatabase/Migrations/20260512211204_MigrateParametersToJsonSchema.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,196 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class MigrateParametersToJsonSchema : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// Convert legacy flat-shape parameter / return JSON in TemplateScripts,
|
||||||
|
// SharedScripts, and ApiMethods to JSON Schema.
|
||||||
|
//
|
||||||
|
// Parameters [{name,type,required,itemType?}]
|
||||||
|
// → {"type":"object","properties":{<name>:{"type":<jsType>}},"required":[...]}
|
||||||
|
//
|
||||||
|
// Return {type,itemType?}
|
||||||
|
// → {"type":<jsType>} or {"type":"array","items":{"type":<inner>}}
|
||||||
|
//
|
||||||
|
// Idempotent: only rows whose value starts with '[' (parameters) or that
|
||||||
|
// contain the legacy 'List' sentinel (return) are touched. Already-converted
|
||||||
|
// rows are skipped.
|
||||||
|
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
IF OBJECT_ID('dbo.fn_LegacyTypeToJsonSchemaType', 'FN') IS NOT NULL
|
||||||
|
DROP FUNCTION dbo.fn_LegacyTypeToJsonSchemaType;
|
||||||
|
");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
CREATE FUNCTION dbo.fn_LegacyTypeToJsonSchemaType(@legacy NVARCHAR(50))
|
||||||
|
RETURNS NVARCHAR(50)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
RETURN
|
||||||
|
CASE LOWER(ISNULL(@legacy, 'string'))
|
||||||
|
WHEN 'boolean' THEN 'boolean'
|
||||||
|
WHEN 'bool' THEN 'boolean'
|
||||||
|
WHEN 'integer' THEN 'integer'
|
||||||
|
WHEN 'int' THEN 'integer'
|
||||||
|
WHEN 'int32' THEN 'integer'
|
||||||
|
WHEN 'int64' THEN 'integer'
|
||||||
|
WHEN 'float' THEN 'number'
|
||||||
|
WHEN 'double' THEN 'number'
|
||||||
|
WHEN 'decimal' THEN 'number'
|
||||||
|
WHEN 'number' THEN 'number'
|
||||||
|
WHEN 'string' THEN 'string'
|
||||||
|
WHEN 'datetime' THEN 'string'
|
||||||
|
WHEN 'object' THEN 'object'
|
||||||
|
WHEN 'list' THEN 'array'
|
||||||
|
WHEN 'array' THEN 'array'
|
||||||
|
ELSE 'string'
|
||||||
|
END;
|
||||||
|
END;
|
||||||
|
");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
IF OBJECT_ID('dbo.fn_LegacyParametersToJsonSchema', 'FN') IS NOT NULL
|
||||||
|
DROP FUNCTION dbo.fn_LegacyParametersToJsonSchema;
|
||||||
|
");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
CREATE FUNCTION dbo.fn_LegacyParametersToJsonSchema(@legacy NVARCHAR(MAX))
|
||||||
|
RETURNS NVARCHAR(MAX)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
IF @legacy IS NULL OR LTRIM(@legacy) = '' RETURN NULL;
|
||||||
|
IF LEFT(LTRIM(@legacy), 1) <> '[' RETURN @legacy; -- already schema-shaped
|
||||||
|
|
||||||
|
DECLARE @props NVARCHAR(MAX) = (
|
||||||
|
SELECT STRING_AGG(
|
||||||
|
CONCAT(
|
||||||
|
'""',
|
||||||
|
STRING_ESCAPE(JSON_VALUE(p.value, '$.name'), 'json'),
|
||||||
|
'"":',
|
||||||
|
CASE
|
||||||
|
WHEN LOWER(ISNULL(JSON_VALUE(p.value, '$.type'), 'string')) IN ('list', 'array')
|
||||||
|
THEN CONCAT(
|
||||||
|
'{""type"":""array"",""items"":{""type"":""',
|
||||||
|
dbo.fn_LegacyTypeToJsonSchemaType(JSON_VALUE(p.value, '$.itemType')),
|
||||||
|
'""}}')
|
||||||
|
ELSE CONCAT(
|
||||||
|
'{""type"":""',
|
||||||
|
dbo.fn_LegacyTypeToJsonSchemaType(JSON_VALUE(p.value, '$.type')),
|
||||||
|
'""}')
|
||||||
|
END),
|
||||||
|
',')
|
||||||
|
WITHIN GROUP (ORDER BY p.[key])
|
||||||
|
FROM OPENJSON(@legacy) p
|
||||||
|
WHERE JSON_VALUE(p.value, '$.name') IS NOT NULL
|
||||||
|
AND JSON_VALUE(p.value, '$.name') <> ''
|
||||||
|
);
|
||||||
|
|
||||||
|
DECLARE @required NVARCHAR(MAX) = (
|
||||||
|
SELECT STRING_AGG(
|
||||||
|
CONCAT('""', STRING_ESCAPE(JSON_VALUE(p.value, '$.name'), 'json'), '""'),
|
||||||
|
',')
|
||||||
|
WITHIN GROUP (ORDER BY p.[key])
|
||||||
|
FROM OPENJSON(@legacy) p
|
||||||
|
WHERE JSON_VALUE(p.value, '$.name') IS NOT NULL
|
||||||
|
AND JSON_VALUE(p.value, '$.name') <> ''
|
||||||
|
AND LOWER(ISNULL(JSON_VALUE(p.value, '$.required'), 'true')) <> 'false'
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN
|
||||||
|
'{""type"":""object"",""properties"":{' + ISNULL(@props, '') + '}'
|
||||||
|
+ CASE WHEN @required IS NULL OR @required = '' THEN ''
|
||||||
|
ELSE ',""required"":[' + @required + ']'
|
||||||
|
END
|
||||||
|
+ '}';
|
||||||
|
END;
|
||||||
|
");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
IF OBJECT_ID('dbo.fn_LegacyReturnToJsonSchema', 'FN') IS NOT NULL
|
||||||
|
DROP FUNCTION dbo.fn_LegacyReturnToJsonSchema;
|
||||||
|
");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
CREATE FUNCTION dbo.fn_LegacyReturnToJsonSchema(@legacy NVARCHAR(MAX))
|
||||||
|
RETURNS NVARCHAR(MAX)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
IF @legacy IS NULL OR LTRIM(@legacy) = '' RETURN NULL;
|
||||||
|
IF LEFT(LTRIM(@legacy), 1) <> '{' RETURN @legacy;
|
||||||
|
|
||||||
|
DECLARE @legacyType NVARCHAR(50) = JSON_VALUE(@legacy, '$.type');
|
||||||
|
IF @legacyType IS NULL RETURN @legacy;
|
||||||
|
|
||||||
|
-- Already JSON Schema (lowercase types, no itemType legacy sentinel): leave it.
|
||||||
|
IF @legacyType IN ('boolean','integer','number','string','object','array')
|
||||||
|
AND JSON_VALUE(@legacy, '$.itemType') IS NULL
|
||||||
|
RETURN @legacy;
|
||||||
|
|
||||||
|
IF LOWER(@legacyType) = 'list'
|
||||||
|
BEGIN
|
||||||
|
DECLARE @inner NVARCHAR(50) =
|
||||||
|
dbo.fn_LegacyTypeToJsonSchemaType(JSON_VALUE(@legacy, '$.itemType'));
|
||||||
|
RETURN CONCAT('{""type"":""array"",""items"":{""type"":""', @inner, '""}}');
|
||||||
|
END;
|
||||||
|
|
||||||
|
RETURN CONCAT('{""type"":""', dbo.fn_LegacyTypeToJsonSchemaType(@legacyType), '""}');
|
||||||
|
END;
|
||||||
|
");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
UPDATE TemplateScripts
|
||||||
|
SET ParameterDefinitions = dbo.fn_LegacyParametersToJsonSchema(ParameterDefinitions)
|
||||||
|
WHERE ParameterDefinitions IS NOT NULL
|
||||||
|
AND LEFT(LTRIM(ParameterDefinitions), 1) = '[';
|
||||||
|
|
||||||
|
UPDATE TemplateScripts
|
||||||
|
SET ReturnDefinition = dbo.fn_LegacyReturnToJsonSchema(ReturnDefinition)
|
||||||
|
WHERE ReturnDefinition IS NOT NULL
|
||||||
|
AND LEFT(LTRIM(ReturnDefinition), 1) = '{';
|
||||||
|
|
||||||
|
UPDATE SharedScripts
|
||||||
|
SET ParameterDefinitions = dbo.fn_LegacyParametersToJsonSchema(ParameterDefinitions)
|
||||||
|
WHERE ParameterDefinitions IS NOT NULL
|
||||||
|
AND LEFT(LTRIM(ParameterDefinitions), 1) = '[';
|
||||||
|
|
||||||
|
UPDATE SharedScripts
|
||||||
|
SET ReturnDefinition = dbo.fn_LegacyReturnToJsonSchema(ReturnDefinition)
|
||||||
|
WHERE ReturnDefinition IS NOT NULL
|
||||||
|
AND LEFT(LTRIM(ReturnDefinition), 1) = '{';
|
||||||
|
|
||||||
|
UPDATE ApiMethods
|
||||||
|
SET ParameterDefinitions = dbo.fn_LegacyParametersToJsonSchema(ParameterDefinitions)
|
||||||
|
WHERE ParameterDefinitions IS NOT NULL
|
||||||
|
AND LEFT(LTRIM(ParameterDefinitions), 1) = '[';
|
||||||
|
|
||||||
|
UPDATE ApiMethods
|
||||||
|
SET ReturnDefinition = dbo.fn_LegacyReturnToJsonSchema(ReturnDefinition)
|
||||||
|
WHERE ReturnDefinition IS NOT NULL
|
||||||
|
AND LEFT(LTRIM(ReturnDefinition), 1) = '{';
|
||||||
|
");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
DROP FUNCTION IF EXISTS dbo.fn_LegacyParametersToJsonSchema;
|
||||||
|
DROP FUNCTION IF EXISTS dbo.fn_LegacyReturnToJsonSchema;
|
||||||
|
DROP FUNCTION IF EXISTS dbo.fn_LegacyTypeToJsonSchemaType;
|
||||||
|
");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// Lossy: JSON Schema can express fields (descriptions, defaults, enums,
|
||||||
|
// nested objects) that the legacy flat shape cannot represent. Reverse
|
||||||
|
// migration is not supported.
|
||||||
|
throw new System.NotSupportedException(
|
||||||
|
"Reverse migration from JSON Schema to legacy flat shape is not supported because the conversion is lossy.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -239,8 +239,13 @@ public class AlarmActor : ReceiveActor
|
|||||||
var timeDelta = (timestamp - oldest.Timestamp).TotalSeconds;
|
var timeDelta = (timestamp - oldest.Timestamp).TotalSeconds;
|
||||||
if (timeDelta <= 0) return false;
|
if (timeDelta <= 0) return false;
|
||||||
|
|
||||||
var rate = Math.Abs(numericValue - oldest.Value) / timeDelta;
|
var signedRate = (numericValue - oldest.Value) / timeDelta;
|
||||||
return rate > config.ThresholdPerSecond;
|
return config.Direction switch
|
||||||
|
{
|
||||||
|
RateOfChangeDirection.Rising => signedRate > config.ThresholdPerSecond,
|
||||||
|
RateOfChangeDirection.Falling => -signedRate > config.ThresholdPerSecond,
|
||||||
|
_ => Math.Abs(signedRate) > config.ThresholdPerSecond
|
||||||
|
};
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -309,7 +314,10 @@ public class AlarmActor : ReceiveActor
|
|||||||
root.TryGetProperty("thresholdPerSecond", out var tps) ? tps.GetDouble() : 10.0,
|
root.TryGetProperty("thresholdPerSecond", out var tps) ? tps.GetDouble() : 10.0,
|
||||||
root.TryGetProperty("windowSeconds", out var ws)
|
root.TryGetProperty("windowSeconds", out var ws)
|
||||||
? TimeSpan.FromSeconds(ws.GetDouble())
|
? TimeSpan.FromSeconds(ws.GetDouble())
|
||||||
: TimeSpan.FromSeconds(1)),
|
: TimeSpan.FromSeconds(1),
|
||||||
|
root.TryGetProperty("direction", out var dirEl)
|
||||||
|
? ParseDirection(dirEl.GetString())
|
||||||
|
: RateOfChangeDirection.Either),
|
||||||
|
|
||||||
_ => new ValueMatchEvalConfig(attr, null)
|
_ => new ValueMatchEvalConfig(attr, null)
|
||||||
};
|
};
|
||||||
@@ -321,12 +329,25 @@ public class AlarmActor : ReceiveActor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static RateOfChangeDirection ParseDirection(string? raw) => raw?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"rising" or "up" or "positive" => RateOfChangeDirection.Rising,
|
||||||
|
"falling" or "down" or "negative" => RateOfChangeDirection.Falling,
|
||||||
|
_ => RateOfChangeDirection.Either
|
||||||
|
};
|
||||||
|
|
||||||
// ── Internal messages ──
|
// ── Internal messages ──
|
||||||
internal record AlarmExecutionCompleted(string AlarmName, bool Success);
|
internal record AlarmExecutionCompleted(string AlarmName, bool Success);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal enum RateOfChangeDirection { Either, Rising, Falling }
|
||||||
|
|
||||||
// ── Alarm evaluation config types ──
|
// ── Alarm evaluation config types ──
|
||||||
internal abstract record AlarmEvalConfig(string MonitoredAttributeName);
|
internal abstract record AlarmEvalConfig(string MonitoredAttributeName);
|
||||||
internal record ValueMatchEvalConfig(string MonitoredAttributeName, string? MatchValue) : AlarmEvalConfig(MonitoredAttributeName);
|
internal record ValueMatchEvalConfig(string MonitoredAttributeName, string? MatchValue) : AlarmEvalConfig(MonitoredAttributeName);
|
||||||
internal record RangeViolationEvalConfig(string MonitoredAttributeName, double Min, double Max) : AlarmEvalConfig(MonitoredAttributeName);
|
internal record RangeViolationEvalConfig(string MonitoredAttributeName, double Min, double Max) : AlarmEvalConfig(MonitoredAttributeName);
|
||||||
internal record RateOfChangeEvalConfig(string MonitoredAttributeName, double ThresholdPerSecond, TimeSpan WindowDuration) : AlarmEvalConfig(MonitoredAttributeName);
|
internal record RateOfChangeEvalConfig(
|
||||||
|
string MonitoredAttributeName,
|
||||||
|
double ThresholdPerSecond,
|
||||||
|
TimeSpan WindowDuration,
|
||||||
|
RateOfChangeDirection Direction) : AlarmEvalConfig(MonitoredAttributeName);
|
||||||
|
|||||||
@@ -195,7 +195,17 @@ public class SemanticValidator
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var doc = JsonDocument.Parse(parameterDefinitionsJson);
|
using var doc = JsonDocument.Parse(parameterDefinitionsJson);
|
||||||
if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
// JSON Schema: { type:"object", properties:{ name:{...}, ... }, required:[...] }
|
||||||
|
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
if (doc.RootElement.TryGetProperty("properties", out var props)
|
||||||
|
&& props.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
return props.EnumerateObject().Select(p => p.Name).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Legacy flat form: [{ name, type, required? }]
|
||||||
|
else if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
||||||
{
|
{
|
||||||
return doc.RootElement.EnumerateArray()
|
return doc.RootElement.EnumerateArray()
|
||||||
.Select(e => e.TryGetProperty("type", out var t) ? t.GetString() ?? "unknown" : "unknown")
|
.Select(e => e.TryGetProperty("type", out var t) ? t.GetString() ?? "unknown" : "unknown")
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using ScadaLink.CentralUI.ScriptAnalysis;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.Tests.ScriptAnalysis;
|
||||||
|
|
||||||
|
public class JsonSchemaShapeParserTests
|
||||||
|
{
|
||||||
|
// ── JSON Schema (post-migration) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parameters_JsonSchema_ScalarsAndRequired()
|
||||||
|
{
|
||||||
|
const string json = """
|
||||||
|
{"type":"object","properties":{
|
||||||
|
"id":{"type":"integer"},
|
||||||
|
"label":{"type":"string"},
|
||||||
|
"active":{"type":"boolean"}
|
||||||
|
},"required":["id","active"]}
|
||||||
|
""";
|
||||||
|
var result = JsonSchemaShapeParser.ParseParameters(json);
|
||||||
|
|
||||||
|
Assert.Collection(result,
|
||||||
|
p => { Assert.Equal("id", p.Name); Assert.Equal("Integer", p.Type); Assert.True(p.Required); },
|
||||||
|
p => { Assert.Equal("label", p.Name); Assert.Equal("String", p.Type); Assert.False(p.Required); },
|
||||||
|
p => { Assert.Equal("active", p.Name); Assert.Equal("Boolean", p.Type); Assert.True(p.Required); });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parameters_JsonSchema_ArrayOfStringsBecomesListString()
|
||||||
|
{
|
||||||
|
const string json = """
|
||||||
|
{"type":"object","properties":{
|
||||||
|
"tags":{"type":"array","items":{"type":"string"}}
|
||||||
|
}}
|
||||||
|
""";
|
||||||
|
var result = JsonSchemaShapeParser.ParseParameters(json);
|
||||||
|
|
||||||
|
var tags = Assert.Single(result);
|
||||||
|
Assert.Equal("tags", tags.Name);
|
||||||
|
Assert.Equal("List<String>", tags.Type);
|
||||||
|
Assert.False(tags.Required);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Return_JsonSchema_Number()
|
||||||
|
{
|
||||||
|
Assert.Equal("Float", JsonSchemaShapeParser.ParseReturnType(@"{""type"":""number""}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Return_JsonSchema_ArrayOfIntegers()
|
||||||
|
{
|
||||||
|
Assert.Equal("List<Integer>",
|
||||||
|
JsonSchemaShapeParser.ParseReturnType(@"{""type"":""array"",""items"":{""type"":""integer""}}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Legacy flat shape (pre-migration safety net) ─────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parameters_Legacy_FlatArrayStillParses()
|
||||||
|
{
|
||||||
|
const string json = """[{"name":"x","type":"Integer"},{"name":"y","type":"String","required":false}]""";
|
||||||
|
var result = JsonSchemaShapeParser.ParseParameters(json);
|
||||||
|
|
||||||
|
Assert.Collection(result,
|
||||||
|
p => { Assert.Equal("x", p.Name); Assert.Equal("Integer", p.Type); Assert.True(p.Required); },
|
||||||
|
p => { Assert.Equal("y", p.Name); Assert.Equal("String", p.Type); Assert.False(p.Required); });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Return_Legacy_ListSentinelStillParses()
|
||||||
|
{
|
||||||
|
Assert.Equal("List<String>",
|
||||||
|
JsonSchemaShapeParser.ParseReturnType(@"{""type"":""List"",""itemType"":""String""}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edge cases ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parameters_Null_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
Assert.Empty(JsonSchemaShapeParser.ParseParameters(null));
|
||||||
|
Assert.Empty(JsonSchemaShapeParser.ParseParameters(""));
|
||||||
|
Assert.Empty(JsonSchemaShapeParser.ParseParameters(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parameters_Malformed_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
Assert.Empty(JsonSchemaShapeParser.ParseParameters("{not json"));
|
||||||
|
Assert.Empty(JsonSchemaShapeParser.ParseParameters("42"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Return_Null_ReturnsNull()
|
||||||
|
{
|
||||||
|
Assert.Null(JsonSchemaShapeParser.ParseReturnType(null));
|
||||||
|
Assert.Null(JsonSchemaShapeParser.ParseReturnType(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parameters_SchemaWithNoProperties_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
Assert.Empty(JsonSchemaShapeParser.ParseParameters(@"{""type"":""object""}"));
|
||||||
|
Assert.Empty(JsonSchemaShapeParser.ParseParameters(@"{""type"":""object"",""properties"":{}}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
using Bunit;
|
|
||||||
using Microsoft.AspNetCore.Components;
|
|
||||||
using ScadaLink.CentralUI.Components.Shared;
|
|
||||||
|
|
||||||
namespace ScadaLink.CentralUI.Tests.Shared;
|
|
||||||
|
|
||||||
public class ParameterListEditorTests : BunitContext
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void NullJson_RendersEmptyState()
|
|
||||||
{
|
|
||||||
var cut = Render<ParameterListEditor>(p => p.Add(c => c.Json, (string?)null));
|
|
||||||
|
|
||||||
Assert.Contains("No parameters defined", cut.Markup);
|
|
||||||
Assert.DoesNotContain("alert-warning", cut.Markup);
|
|
||||||
Assert.DoesNotContain("alert-info", cut.Markup);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ValidJson_RendersOneRowPerParameter()
|
|
||||||
{
|
|
||||||
var json = """[{"name":"id","type":"Integer"},{"name":"label","type":"String"}]""";
|
|
||||||
var cut = Render<ParameterListEditor>(p => p.Add(c => c.Json, json));
|
|
||||||
|
|
||||||
var nameInputs = cut.FindAll("input[aria-label='Parameter name']");
|
|
||||||
Assert.Equal(2, nameInputs.Count);
|
|
||||||
Assert.Equal("id", nameInputs[0].GetAttribute("value"));
|
|
||||||
Assert.Equal("label", nameInputs[1].GetAttribute("value"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void LegacyLowercaseType_NormalizedAndFlagged()
|
|
||||||
{
|
|
||||||
var json = """[{"name":"x","type":"string"}]""";
|
|
||||||
var cut = Render<ParameterListEditor>(p => p.Add(c => c.Json, json));
|
|
||||||
|
|
||||||
var typeSelect = cut.Find("select[aria-label='Parameter type']");
|
|
||||||
Assert.Equal("String", typeSelect.GetAttribute("value"));
|
|
||||||
Assert.Contains("normalized", cut.Markup);
|
|
||||||
Assert.Contains("alert-info", cut.Markup);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("int32", "Integer")]
|
|
||||||
[InlineData("Int64", "Integer")]
|
|
||||||
[InlineData("Double", "Float")]
|
|
||||||
[InlineData("DateTime", "String")]
|
|
||||||
[InlineData("bool", "Boolean")]
|
|
||||||
public void LegacyDotNetType_NormalizedToCanonical(string raw, string expected)
|
|
||||||
{
|
|
||||||
var json = "[{\"name\":\"x\",\"type\":\"" + raw + "\"}]";
|
|
||||||
var cut = Render<ParameterListEditor>(p => p.Add(c => c.Json, json));
|
|
||||||
|
|
||||||
Assert.Equal(expected, cut.Find("select[aria-label='Parameter type']").GetAttribute("value"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Canonical_TypesDoNotTriggerNormalizedNotice()
|
|
||||||
{
|
|
||||||
var json = """[{"name":"x","type":"Integer"}]""";
|
|
||||||
var cut = Render<ParameterListEditor>(p => p.Add(c => c.Json, json));
|
|
||||||
|
|
||||||
Assert.DoesNotContain("alert-info", cut.Markup);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void AddParameter_EmitsJsonWithNewRow()
|
|
||||||
{
|
|
||||||
string? captured = null;
|
|
||||||
var cut = Render<ParameterListEditor>(p => p
|
|
||||||
.Add(c => c.Json, (string?)null)
|
|
||||||
.Add(c => c.JsonChanged, EventCallback.Factory.Create<string?>(this, v => captured = v)));
|
|
||||||
|
|
||||||
cut.Find("button.btn-outline-secondary").Click();
|
|
||||||
|
|
||||||
Assert.NotNull(captured);
|
|
||||||
Assert.Contains("\"type\":\"String\"", captured);
|
|
||||||
Assert.Contains("\"name\":\"\"", captured);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void RemoveParameter_EmitsNullWhenLastRowRemoved()
|
|
||||||
{
|
|
||||||
string? captured = "initial";
|
|
||||||
var json = """[{"name":"x","type":"Integer"}]""";
|
|
||||||
var cut = Render<ParameterListEditor>(p => p
|
|
||||||
.Add(c => c.Json, json)
|
|
||||||
.Add(c => c.JsonChanged, EventCallback.Factory.Create<string?>(this, v => captured = v)));
|
|
||||||
|
|
||||||
cut.Find("button[aria-label^='Remove parameter']").Click();
|
|
||||||
|
|
||||||
Assert.Null(captured);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ListType_RendersItemTypeSelect()
|
|
||||||
{
|
|
||||||
var json = """[{"name":"tags","type":"List","itemType":"String"}]""";
|
|
||||||
var cut = Render<ParameterListEditor>(p => p.Add(c => c.Json, json));
|
|
||||||
|
|
||||||
var itemTypeSelect = cut.Find("select[aria-label='List item type']");
|
|
||||||
Assert.Equal("String", itemTypeSelect.GetAttribute("value"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void NonListType_HidesItemTypeSelect()
|
|
||||||
{
|
|
||||||
var json = """[{"name":"x","type":"Integer"}]""";
|
|
||||||
var cut = Render<ParameterListEditor>(p => p.Add(c => c.Json, json));
|
|
||||||
|
|
||||||
Assert.Empty(cut.FindAll("select[aria-label='List item type']"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void RequiredFalseInJson_RendersUncheckedCheckbox()
|
|
||||||
{
|
|
||||||
var json = """[{"name":"x","type":"Integer","required":false}]""";
|
|
||||||
var cut = Render<ParameterListEditor>(p => p.Add(c => c.Json, json));
|
|
||||||
|
|
||||||
var checkbox = cut.Find("input[type='checkbox'][aria-label='Required']");
|
|
||||||
Assert.Null(checkbox.GetAttribute("checked"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void RequiredOmitted_DefaultsToChecked()
|
|
||||||
{
|
|
||||||
var json = """[{"name":"x","type":"Integer"}]""";
|
|
||||||
var cut = Render<ParameterListEditor>(p => p.Add(c => c.Json, json));
|
|
||||||
|
|
||||||
var checkbox = cut.Find("input[type='checkbox'][aria-label='Required']");
|
|
||||||
Assert.NotNull(checkbox.GetAttribute("checked"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void InvalidJson_RendersStartFreshButton()
|
|
||||||
{
|
|
||||||
var cut = Render<ParameterListEditor>(p => p.Add(c => c.Json, "not valid json"));
|
|
||||||
|
|
||||||
Assert.Contains("alert-warning", cut.Markup);
|
|
||||||
Assert.Contains("Start fresh", cut.Markup);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void NonArrayJson_RendersExpectedArrayError()
|
|
||||||
{
|
|
||||||
var cut = Render<ParameterListEditor>(p => p.Add(c => c.Json, """{"not":"an array"}"""));
|
|
||||||
|
|
||||||
Assert.Contains("Expected a JSON array", cut.Markup);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
using Bunit;
|
|
||||||
using Microsoft.AspNetCore.Components;
|
|
||||||
using ScadaLink.CentralUI.Components.Shared;
|
|
||||||
|
|
||||||
namespace ScadaLink.CentralUI.Tests.Shared;
|
|
||||||
|
|
||||||
public class ReturnTypeEditorTests : BunitContext
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void NullJson_RendersNoReturnSelected()
|
|
||||||
{
|
|
||||||
var cut = Render<ReturnTypeEditor>(p => p.Add(c => c.Json, (string?)null));
|
|
||||||
|
|
||||||
Assert.Equal("", cut.Find("select[aria-label='Return type']").GetAttribute("value"));
|
|
||||||
Assert.Empty(cut.FindAll("select[aria-label='List item type']"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void SimpleType_RendersSelected()
|
|
||||||
{
|
|
||||||
var cut = Render<ReturnTypeEditor>(p => p.Add(c => c.Json, """{"type":"Boolean"}"""));
|
|
||||||
|
|
||||||
Assert.Equal("Boolean", cut.Find("select[aria-label='Return type']").GetAttribute("value"));
|
|
||||||
Assert.Empty(cut.FindAll("select[aria-label='List item type']"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ListType_RendersItemTypeSelect()
|
|
||||||
{
|
|
||||||
var cut = Render<ReturnTypeEditor>(p => p.Add(c => c.Json, """{"type":"List","itemType":"Integer"}"""));
|
|
||||||
|
|
||||||
Assert.Equal("List", cut.Find("select[aria-label='Return type']").GetAttribute("value"));
|
|
||||||
Assert.Equal("Integer", cut.Find("select[aria-label='List item type']").GetAttribute("value"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void LegacyLowercaseType_NormalizedAndFlagged()
|
|
||||||
{
|
|
||||||
var cut = Render<ReturnTypeEditor>(p => p.Add(c => c.Json, """{"type":"string"}"""));
|
|
||||||
|
|
||||||
Assert.Equal("String", cut.Find("select[aria-label='Return type']").GetAttribute("value"));
|
|
||||||
Assert.Contains("normalized", cut.Markup);
|
|
||||||
Assert.Contains("alert-info", cut.Markup);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("Int32", "Integer")]
|
|
||||||
[InlineData("Double", "Float")]
|
|
||||||
[InlineData("DateTime", "String")]
|
|
||||||
[InlineData("bool", "Boolean")]
|
|
||||||
public void LegacyDotNetType_NormalizedToCanonical(string raw, string expected)
|
|
||||||
{
|
|
||||||
var json = "{\"type\":\"" + raw + "\"}";
|
|
||||||
var cut = Render<ReturnTypeEditor>(p => p.Add(c => c.Json, json));
|
|
||||||
|
|
||||||
Assert.Equal(expected, cut.Find("select[aria-label='Return type']").GetAttribute("value"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void CanonicalType_DoesNotTriggerNormalizedNotice()
|
|
||||||
{
|
|
||||||
var cut = Render<ReturnTypeEditor>(p => p.Add(c => c.Json, """{"type":"Integer"}"""));
|
|
||||||
|
|
||||||
Assert.DoesNotContain("alert-info", cut.Markup);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ChangeType_EmitsCanonicalJson()
|
|
||||||
{
|
|
||||||
string? captured = null;
|
|
||||||
var cut = Render<ReturnTypeEditor>(p => p
|
|
||||||
.Add(c => c.Json, (string?)null)
|
|
||||||
.Add(c => c.JsonChanged, EventCallback.Factory.Create<string?>(this, v => captured = v)));
|
|
||||||
|
|
||||||
cut.Find("select[aria-label='Return type']").Change("Boolean");
|
|
||||||
|
|
||||||
Assert.Equal("""{"type":"Boolean"}""", captured);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ChangeTypeToList_EmitsWithItemType()
|
|
||||||
{
|
|
||||||
string? captured = null;
|
|
||||||
var cut = Render<ReturnTypeEditor>(p => p
|
|
||||||
.Add(c => c.Json, (string?)null)
|
|
||||||
.Add(c => c.JsonChanged, EventCallback.Factory.Create<string?>(this, v => captured = v)));
|
|
||||||
|
|
||||||
cut.Find("select[aria-label='Return type']").Change("List");
|
|
||||||
|
|
||||||
Assert.NotNull(captured);
|
|
||||||
Assert.Contains("\"type\":\"List\"", captured);
|
|
||||||
Assert.Contains("\"itemType\":\"String\"", captured);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ClearType_EmitsNull()
|
|
||||||
{
|
|
||||||
string? captured = "initial";
|
|
||||||
var cut = Render<ReturnTypeEditor>(p => p
|
|
||||||
.Add(c => c.Json, """{"type":"Boolean"}""")
|
|
||||||
.Add(c => c.JsonChanged, EventCallback.Factory.Create<string?>(this, v => captured = v)));
|
|
||||||
|
|
||||||
cut.Find("select[aria-label='Return type']").Change("");
|
|
||||||
|
|
||||||
Assert.Null(captured);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void InvalidJson_RendersStartFreshButton()
|
|
||||||
{
|
|
||||||
var cut = Render<ReturnTypeEditor>(p => p.Add(c => c.Json, "not valid json"));
|
|
||||||
|
|
||||||
Assert.Contains("alert-warning", cut.Markup);
|
|
||||||
Assert.Contains("Start fresh", cut.Markup);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void NonObjectJson_RendersExpectedObjectError()
|
|
||||||
{
|
|
||||||
var cut = Render<ReturnTypeEditor>(p => p.Add(c => c.Json, """["array","not","object"]"""));
|
|
||||||
|
|
||||||
Assert.Contains("Expected a JSON object", cut.Markup);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
using ScadaLink.CentralUI.Components.Shared;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.Tests.Shared;
|
||||||
|
|
||||||
|
public class SchemaBuilderModelTests
|
||||||
|
{
|
||||||
|
// ── Parse ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_Empty_ReturnsFallback()
|
||||||
|
{
|
||||||
|
var fallback = SchemaBuilderModel.NewObject();
|
||||||
|
Assert.Same(fallback, SchemaBuilderModel.Parse(null, fallback));
|
||||||
|
Assert.Same(fallback, SchemaBuilderModel.Parse("", fallback));
|
||||||
|
Assert.Same(fallback, SchemaBuilderModel.Parse(" ", fallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_Malformed_ReturnsFallback()
|
||||||
|
{
|
||||||
|
var fallback = SchemaBuilderModel.NewObject();
|
||||||
|
Assert.Same(fallback, SchemaBuilderModel.Parse("{not json", fallback));
|
||||||
|
Assert.Same(fallback, SchemaBuilderModel.Parse("42", fallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_ObjectSchema_ExtractsPropertiesAndRequired()
|
||||||
|
{
|
||||||
|
const string json = """
|
||||||
|
{"type":"object","properties":{
|
||||||
|
"id":{"type":"integer"},
|
||||||
|
"label":{"type":"string"},
|
||||||
|
"active":{"type":"boolean"}
|
||||||
|
},"required":["id","active"]}
|
||||||
|
""";
|
||||||
|
var node = SchemaBuilderModel.Parse(json, SchemaBuilderModel.NewObject());
|
||||||
|
|
||||||
|
Assert.Equal("object", node.Type);
|
||||||
|
Assert.Collection(node.Properties,
|
||||||
|
p => { Assert.Equal("id", p.Name); Assert.Equal("integer", p.Schema.Type); Assert.True(p.Required); },
|
||||||
|
p => { Assert.Equal("label", p.Name); Assert.Equal("string", p.Schema.Type); Assert.False(p.Required); },
|
||||||
|
p => { Assert.Equal("active", p.Name); Assert.Equal("boolean", p.Schema.Type); Assert.True(p.Required); });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_ArrayOfPrimitive_PreservesItemType()
|
||||||
|
{
|
||||||
|
var node = SchemaBuilderModel.Parse(
|
||||||
|
@"{""type"":""array"",""items"":{""type"":""integer""}}",
|
||||||
|
SchemaBuilderModel.NewValue());
|
||||||
|
|
||||||
|
Assert.Equal("array", node.Type);
|
||||||
|
Assert.NotNull(node.Items);
|
||||||
|
Assert.Equal("integer", node.Items!.Type);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_LegacyFlatArray_TranslatedToObjectSchema()
|
||||||
|
{
|
||||||
|
const string json = """[{"name":"x","type":"Integer"},{"name":"y","type":"String","required":false}]""";
|
||||||
|
var node = SchemaBuilderModel.Parse(json, SchemaBuilderModel.NewObject());
|
||||||
|
|
||||||
|
Assert.Equal("object", node.Type);
|
||||||
|
Assert.Collection(node.Properties,
|
||||||
|
p => { Assert.Equal("x", p.Name); Assert.Equal("integer", p.Schema.Type); Assert.True(p.Required); },
|
||||||
|
p => { Assert.Equal("y", p.Name); Assert.Equal("string", p.Schema.Type); Assert.False(p.Required); });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_NestedObjects_Recurses()
|
||||||
|
{
|
||||||
|
const string json = """
|
||||||
|
{"type":"object","properties":{
|
||||||
|
"outer":{"type":"object","properties":{
|
||||||
|
"inner":{"type":"integer"}
|
||||||
|
},"required":["inner"]}
|
||||||
|
}}
|
||||||
|
""";
|
||||||
|
var node = SchemaBuilderModel.Parse(json, SchemaBuilderModel.NewObject());
|
||||||
|
|
||||||
|
var outer = Assert.Single(node.Properties);
|
||||||
|
Assert.Equal("outer", outer.Name);
|
||||||
|
Assert.Equal("object", outer.Schema.Type);
|
||||||
|
var inner = Assert.Single(outer.Schema.Properties);
|
||||||
|
Assert.Equal("inner", inner.Name);
|
||||||
|
Assert.Equal("integer", inner.Schema.Type);
|
||||||
|
Assert.True(inner.Required);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Serialize ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Serialize_EmptyObject_OmitsRequired()
|
||||||
|
{
|
||||||
|
var node = new SchemaNode { Type = "object" };
|
||||||
|
var json = SchemaBuilderModel.Serialize(node);
|
||||||
|
Assert.Equal("""{"type":"object","properties":{}}""", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Serialize_ObjectWithMixedRequired_EmitsOnlyRequiredNames()
|
||||||
|
{
|
||||||
|
var node = new SchemaNode { Type = "object" };
|
||||||
|
node.Properties.Add(new SchemaProperty { Name = "id", Required = true, Schema = new SchemaNode { Type = "integer" } });
|
||||||
|
node.Properties.Add(new SchemaProperty { Name = "label", Required = false, Schema = new SchemaNode { Type = "string" } });
|
||||||
|
|
||||||
|
var json = SchemaBuilderModel.Serialize(node);
|
||||||
|
Assert.Equal(
|
||||||
|
"""{"type":"object","properties":{"id":{"type":"integer"},"label":{"type":"string"}},"required":["id"]}""",
|
||||||
|
json);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Serialize_Array_IncludesItems()
|
||||||
|
{
|
||||||
|
var node = new SchemaNode { Type = "array", Items = new SchemaNode { Type = "string" } };
|
||||||
|
Assert.Equal("""{"type":"array","items":{"type":"string"}}""", SchemaBuilderModel.Serialize(node));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Serialize_PropertiesWithBlankName_Skipped()
|
||||||
|
{
|
||||||
|
var node = new SchemaNode { Type = "object" };
|
||||||
|
node.Properties.Add(new SchemaProperty { Name = "", Schema = new SchemaNode { Type = "integer" } });
|
||||||
|
node.Properties.Add(new SchemaProperty { Name = "valid", Schema = new SchemaNode { Type = "string" } });
|
||||||
|
|
||||||
|
var json = SchemaBuilderModel.Serialize(node);
|
||||||
|
Assert.Equal("""{"type":"object","properties":{"valid":{"type":"string"}},"required":["valid"]}""", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Round-trip ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoundTrip_Parse_Then_Serialize_Stable()
|
||||||
|
{
|
||||||
|
const string original = """{"type":"object","properties":{"id":{"type":"integer"},"tags":{"type":"array","items":{"type":"string"}}},"required":["id"]}""";
|
||||||
|
var node = SchemaBuilderModel.Parse(original, SchemaBuilderModel.NewObject());
|
||||||
|
var roundTripped = SchemaBuilderModel.Serialize(node);
|
||||||
|
Assert.Equal(original, roundTripped);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user