Compare commits
15 Commits
91b786eb1c
...
783da8e21a
| Author | SHA1 | Date | |
|---|---|---|---|
| 783da8e21a | |||
| 57f477fd28 | |||
| 85769486df | |||
| 4f90f952d0 | |||
| 1f86945d46 | |||
| 54338abdce | |||
| 78de4a6492 | |||
| 5c3dc79b8a | |||
| 552c9e4065 | |||
| a965d4a5bd | |||
| f05b03f1cc | |||
| f599809486 | |||
| 8b8b85c839 | |||
| 03a8c4a632 | |||
| fa86750717 |
@@ -1,22 +1,23 @@
|
|||||||
# Derive-on-compose: implementation status + remaining plan
|
# Derive-on-compose: implementation status
|
||||||
|
|
||||||
> **For Claude resuming after compaction:** This is the single starting point.
|
> **For Claude resuming later:** All nine phases are implemented. This
|
||||||
> Read it in full. Then read the companion design doc
|
> file is the change-record for the work, not a plan. See the companion
|
||||||
> `2026-05-12-derive-on-compose-design.md` for the architectural rationale.
|
> design doc `2026-05-12-derive-on-compose-design.md` for rationale.
|
||||||
> Do NOT make code changes until you've also confirmed which phase the user
|
|
||||||
> wants next.
|
|
||||||
|
|
||||||
## Where we are
|
## Where we are
|
||||||
|
|
||||||
**Branch**: `feature/templates-folder-hierarchy`. Already pushed.
|
**Branch**: `feature/templates-folder-hierarchy`.
|
||||||
|
|
||||||
**Last commit on this feature**: `5615f3d` — *Phase 1 complete, additive
|
**Last commit on this feature**: `a965d4a` — *Phase 9 complete,
|
||||||
schema only*.
|
single-parent editor context*.
|
||||||
|
|
||||||
|
**All nine phases done**. Live verification against SQL Server (phase-3
|
||||||
|
migration shape) and a UI smoke test are still recommended before merge.
|
||||||
|
|
||||||
**All test suites currently green**:
|
**All test suites currently green**:
|
||||||
- `tests/ScadaLink.CentralUI.Tests` — 159 passing
|
- `tests/ScadaLink.CentralUI.Tests` — 159 passing
|
||||||
- `tests/ScadaLink.SiteRuntime.Tests` — 129 passing
|
- `tests/ScadaLink.SiteRuntime.Tests` — 129 passing
|
||||||
- `tests/ScadaLink.TemplateEngine.Tests` — 199 passing
|
- `tests/ScadaLink.TemplateEngine.Tests` — 212 passing (+13 derive-on-compose tests)
|
||||||
|
|
||||||
## Design decisions already made (from the brainstorm)
|
## Design decisions already made (from the brainstorm)
|
||||||
|
|
||||||
@@ -34,6 +35,40 @@ User picked the **full Aveva model** with all four customization scopes:
|
|||||||
|
|
||||||
Commits: `6854843` (design doc) + `a968cef` (decisions recorded) + `5615f3d`.
|
Commits: `6854843` (design doc) + `a968cef` (decisions recorded) + `5615f3d`.
|
||||||
|
|
||||||
|
## Done — Phase 2: Compose flow change
|
||||||
|
|
||||||
|
Commit: `fa86750`.
|
||||||
|
|
||||||
|
- `TemplateService.AddCompositionAsync` builds a derived template
|
||||||
|
(`"<parent>.<slot>"`), copies base attributes/scripts with
|
||||||
|
`IsInherited=true`, then composes the derived (not the base). Sets
|
||||||
|
`OwnerCompositionId` back-ref after the composition's Id is known.
|
||||||
|
- Composing a derived template is rejected — only bases can be composed.
|
||||||
|
- `DeleteCompositionAsync` cascade-deletes the slot-owned derived
|
||||||
|
template (`IsDerived=true` and `OwnerCompositionId==compositionId`).
|
||||||
|
- `DeleteTemplateAsync` blocks direct deletion of derived templates and
|
||||||
|
splits the inheritor check into regular children vs. derivatives — the
|
||||||
|
derivative branch labels each by `'OwnerName' (as 'SlotName')`.
|
||||||
|
- `TemplateDeletionService.CanDeleteTemplateAsync` mirrors the same
|
||||||
|
derivative-aware checks.
|
||||||
|
|
||||||
|
## Done — Phase 3: Migration of existing compositions
|
||||||
|
|
||||||
|
Commit: `03a8c4a`. Migration `20260512122746_MigrateCompositionsToDerived`.
|
||||||
|
|
||||||
|
- Pre-flight aborts with a descriptive error if any
|
||||||
|
`<parent>.<slot>` derived name would collide.
|
||||||
|
- Cursor-walks every `TemplateComposition` whose target is `IsDerived=0`,
|
||||||
|
inserts a derived template, copies attributes/scripts with
|
||||||
|
`IsInherited=1`, then repoints `ComposedTemplateId`.
|
||||||
|
- Idempotent (only touches non-derived targets), so re-runs are safe.
|
||||||
|
- `Down()` reverses by repointing compositions to `ParentTemplateId` and
|
||||||
|
dropping the derived templates.
|
||||||
|
|
||||||
|
The migration was NOT verified against a live SQL Server in this
|
||||||
|
session — run `bash docker/deploy.sh` (or `dotnet ef database update`)
|
||||||
|
once with seeded test data to confirm shape.
|
||||||
|
|
||||||
Files touched in `5615f3d`:
|
Files touched in `5615f3d`:
|
||||||
|
|
||||||
- `src/ScadaLink.Commons/Entities/Templates/Template.cs`
|
- `src/ScadaLink.Commons/Entities/Templates/Template.cs`
|
||||||
@@ -51,220 +86,99 @@ Files touched in `5615f3d`:
|
|||||||
|
|
||||||
**No behavior changes**. New fields are never read or written yet.
|
**No behavior changes**. New fields are never read or written yet.
|
||||||
|
|
||||||
## Remaining — phases 2 through 9
|
## Done — Phase 4+5: Flattening + lock enforcement
|
||||||
|
|
||||||
Each phase ships independently. The cumulative behavior change fully lands at
|
Commit: `f599809`.
|
||||||
phase 3.
|
|
||||||
|
|
||||||
### Phase 2: Compose flow change
|
- `FlatteningService.ResolveInheritedAttributes` / `ResolveInheritedScripts`
|
||||||
|
treat `IsInherited=true` rows as placeholders that don't shadow the
|
||||||
|
resolved base value. Override (`IsInherited=false`) wins as before.
|
||||||
|
- `ValidateLockedInDerived` runs once per chain (main + every composed
|
||||||
|
chain) and returns a flatten-time failure if a derived row overrides
|
||||||
|
a `LockedInDerived` base member.
|
||||||
|
- `TemplateService.UpdateAttributeAsync` / `UpdateScriptAsync` reject
|
||||||
|
derived-side overrides of `LockedInDerived` base members, and now
|
||||||
|
persist `IsInherited` (on derived) / `LockedInDerived` (on base) from
|
||||||
|
the proposed payload so the UI can drive override state.
|
||||||
|
|
||||||
`TemplateService.AddCompositionAsync` currently creates a `TemplateComposition`
|
## Done — Phase 6: Template tree hides derived
|
||||||
that references a base template directly. Change it so NEW compositions:
|
|
||||||
|
|
||||||
1. Create a new `Template` entity:
|
Commit: `f05b03f` (combined with phases 7+8).
|
||||||
- `Name = $"{parentTemplate.Name}.{instanceName}"`
|
|
||||||
- `ParentTemplateId = composedTemplateId` (the base)
|
|
||||||
- `IsDerived = true`
|
|
||||||
- `FolderId = null` (or inherit from parent? — pick null; derived
|
|
||||||
templates aren't visible in the tree)
|
|
||||||
2. Copy the base's `Attributes` into the new template marked
|
|
||||||
`IsInherited = true`. Same for `Scripts` and `Alarms`.
|
|
||||||
3. Create the `TemplateComposition` with `ComposedTemplateId = newTemplate.Id`
|
|
||||||
(the derived, not the base).
|
|
||||||
4. Set the derived template's `OwnerCompositionId = composition.Id` (back-ref).
|
|
||||||
|
|
||||||
Files to touch:
|
`Templates.razor` filters `t.IsDerived` from the main tree. A "Show
|
||||||
|
derived" form-switch in the page header flips the filter — derived
|
||||||
|
templates surface in the flat list so users can still reach them.
|
||||||
|
|
||||||
- `src/ScadaLink.TemplateEngine/TemplateService.cs` — extend
|
## Done — Phase 7+8: Derived/base TemplateEdit UI
|
||||||
`AddCompositionAsync` (currently around line 290+).
|
|
||||||
- `src/ScadaLink.TemplateEngine/TemplateService.cs` — also extend
|
|
||||||
`DeleteCompositionAsync` to cascade-delete the derived template.
|
|
||||||
- `src/ScadaLink.TemplateEngine/TemplateService.cs` — extend `DeleteTemplateAsync`
|
|
||||||
to block when the template is composed (already happens) AND when
|
|
||||||
derivatives exist; reword the error.
|
|
||||||
- Validation: derived template names must remain unique (the base existing
|
|
||||||
uniqueness index on `Template.Name` covers this — but if `Pump.TempSensor`
|
|
||||||
collides with a user-created template of that name, the add fails. Add a
|
|
||||||
guard with a clear error message.)
|
|
||||||
|
|
||||||
Tests to add (`tests/ScadaLink.TemplateEngine.Tests/`):
|
Commit: `f05b03f`.
|
||||||
|
|
||||||
- AddCompositionAsync creates a derived template.
|
- Derived banner: links to base + slot owner / instance name from
|
||||||
- The derived template inherits attributes / scripts with `IsInherited = true`.
|
`OwnerCompositionId`.
|
||||||
- DeleteCompositionAsync cascades to remove the derived template.
|
- Attributes / Scripts tables grew a context-aware column:
|
||||||
- Deleting a base with derivatives is blocked with a message that lists them.
|
* Derived: Source badge (Inherited / Override / Local), plus a
|
||||||
|
"🔒 Base-locked" badge when `LockedInDerived`.
|
||||||
|
* Base: a form-switch that flips `LockedInDerived` through
|
||||||
|
`UpdateAttribute` / `UpdateScript`.
|
||||||
|
- Effective Value / Code resolves from the base when the derived row
|
||||||
|
carries an inherited (potentially stale) copy — matches the runtime
|
||||||
|
flatten behavior so the UI doesn't lie.
|
||||||
|
- Override and Revert-to-base actions live on the row kebab. Delete is
|
||||||
|
hidden on inherited rows (the base owns those).
|
||||||
|
- "When a base toggles LockedInDerived while derivatives override the
|
||||||
|
field, warn via toast" is NOT implemented — kept out of scope; flatten
|
||||||
|
validation already surfaces it at deploy time.
|
||||||
|
|
||||||
### Phase 3: Migration of existing compositions
|
## Done — Phase 9: Single-parent editor context
|
||||||
|
|
||||||
EF Core data migration that runs on next startup. Two paths:
|
Commit: `a965d4a`.
|
||||||
|
|
||||||
**Option A (simpler) — write the migration as a custom SQL/code step**
|
- `BuildParentContextsAsync` resolves the editor's `Parent.*` context
|
||||||
|
to exactly one entry for derived templates (via `OwnerCompositionId`)
|
||||||
|
and to an empty list for base templates.
|
||||||
|
- Multi-parent `<select>` dropdown removed from the Add Script form.
|
||||||
|
- `_selectedParentIndex` / `OnParentContextChanged` deleted;
|
||||||
|
`ActiveEditorParent` collapses to `_editorParents.FirstOrDefault()`.
|
||||||
|
- The SCADA008 hint diagnostic on `Parent.*` use within base templates
|
||||||
|
was NOT added in this pass — the analyzer simply emits no completions
|
||||||
|
when the parent context is empty. Add it later if users want a
|
||||||
|
positive nudge.
|
||||||
|
|
||||||
In `src/ScadaLink.ConfigurationDatabase/Migrations/`, generate a new migration
|
## Still to verify
|
||||||
called e.g. `MigrateCompositionsToDerived`. In its `Up` method, use raw
|
|
||||||
`migrationBuilder.Sql(...)` or a delegate to:
|
|
||||||
|
|
||||||
```
|
- Apply the Phase-3 migration against a real SQL Server (run
|
||||||
FOREACH composition IN TemplateComposition WHERE ComposedTemplate.IsDerived = false:
|
`bash docker/deploy.sh` or `dotnet ef database update`) with seeded
|
||||||
derived := INSERT INTO Templates (
|
data to confirm `MigrateCompositionsToDerived` produces the right
|
||||||
Name = parent.Name || '.' || composition.InstanceName,
|
shape and respects the collision pre-check.
|
||||||
ParentTemplateId = composition.ComposedTemplateId,
|
- Smoke-test the UI flows: add a composition, override an attribute,
|
||||||
IsDerived = true,
|
revert, toggle `LockedInDerived` on a base, edit a script on a
|
||||||
OwnerCompositionId = composition.Id)
|
derived template (single-parent context).
|
||||||
INSERT INTO TemplateAttributes (Name, Value, DataType, IsLocked, Description,
|
|
||||||
DataSourceReference, IsInherited=true,
|
|
||||||
LockedInDerived=false, TemplateId=derived.Id)
|
|
||||||
FROM TemplateAttributes WHERE TemplateId = composition.ComposedTemplateId
|
|
||||||
same for TemplateScripts and TemplateAlarms
|
|
||||||
UPDATE TemplateComposition SET ComposedTemplateId = derived.Id WHERE Id = composition.Id
|
|
||||||
```
|
|
||||||
|
|
||||||
Run as raw SQL to keep the migration deterministic across providers (SQL
|
|
||||||
Server vs SQLite if used in dev).
|
|
||||||
|
|
||||||
**Option B — service-layer one-time migration**
|
|
||||||
|
|
||||||
Add a startup hook that runs once (controlled by a flag column) and uses
|
|
||||||
`TemplateService` directly. Cleaner for complex logic but more code.
|
|
||||||
|
|
||||||
**Pick Option A** unless the logic doesn't fit comfortably in SQL.
|
|
||||||
|
|
||||||
Validation post-migration: every `TemplateComposition.ComposedTemplateId`
|
|
||||||
should now reference a template with `IsDerived = true`.
|
|
||||||
|
|
||||||
Tests: integration test that seeds pre-migration data, runs the migration,
|
|
||||||
asserts the new shape.
|
|
||||||
|
|
||||||
### Phase 4: Inherit/override resolution in flattening
|
|
||||||
|
|
||||||
`src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs`:
|
|
||||||
|
|
||||||
- The service currently walks `Template.ParentTemplateId` chains in
|
|
||||||
`ResolveInheritedScripts`. That same chain now naturally includes the
|
|
||||||
base→derived link. No special handling needed for the chain itself.
|
|
||||||
- BUT when a derived template's attribute is `IsInherited = true`, the
|
|
||||||
effective value comes from the base (resolved through the chain). If
|
|
||||||
`IsInherited = false`, the derived's own value is the override and wins.
|
|
||||||
- Treat `LockedInDerived` as an enforcement check: if a derived row has
|
|
||||||
`IsInherited = false` (override) AND the base row has `LockedInDerived =
|
|
||||||
true`, emit a flattening validation error.
|
|
||||||
|
|
||||||
Tests: round-trip flattening with overridden + inherited combinations.
|
|
||||||
|
|
||||||
### Phase 5: Lock semantics enforcement
|
|
||||||
|
|
||||||
In `TemplateService.UpdateAttributeAsync` and `UpdateScriptAsync`:
|
|
||||||
|
|
||||||
- If the target template has `IsDerived = true`, look up the base via
|
|
||||||
`ParentTemplateId`.
|
|
||||||
- If the same-named attribute on the base has `LockedInDerived = true`,
|
|
||||||
reject the update with a clear error: *"This attribute is locked by the
|
|
||||||
base template '$Sensor' and cannot be overridden."*
|
|
||||||
- If the update is being applied on a base template AND there are
|
|
||||||
derivatives that have `IsInherited = false` for this field (overrides), the
|
|
||||||
caller may want to know — surface a warning result. Out of scope for now;
|
|
||||||
flag as TODO.
|
|
||||||
|
|
||||||
Tests: lock-blocked updates fail with the expected message.
|
|
||||||
|
|
||||||
### Phase 6: Template tree UI — hide derived
|
|
||||||
|
|
||||||
`src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor`:
|
|
||||||
|
|
||||||
- Filter `_templates` to exclude `t.IsDerived` by default.
|
|
||||||
- Add a "Show derived templates" checkbox at the top.
|
|
||||||
- When shown, render derived templates indented under their base
|
|
||||||
(`t.ParentTemplateId == base.Id && t.IsDerived`).
|
|
||||||
- Compositions tab on `TemplateEdit` should link each composition row to the
|
|
||||||
derived template's edit page (the existing link likely points to the base
|
|
||||||
— needs to follow `composition.ComposedTemplateId` which after phase 3 is
|
|
||||||
the derived).
|
|
||||||
|
|
||||||
### Phase 7: Derived TemplateEdit UI
|
|
||||||
|
|
||||||
`src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor`:
|
|
||||||
|
|
||||||
- Top banner when `_selectedTemplate.IsDerived`:
|
|
||||||
*"Derived from `[base.Name]` — composed inside `[parent.Name]` as
|
|
||||||
`[composition.InstanceName]`."*
|
|
||||||
- Attributes table: new column showing **Inherited** or **Overridden** badge.
|
|
||||||
- Locked-from-base attributes (base has `LockedInDerived = true`) render
|
|
||||||
readonly with a 🔒 icon and tooltip *"Locked by base — cannot override."*
|
|
||||||
- Editing an inherited row flips it to override (`IsInherited = false`).
|
|
||||||
- "Revert to base" button per row when `IsInherited = false` — clears the
|
|
||||||
override; row reverts to the base value (`IsInherited = true`, value
|
|
||||||
cleared).
|
|
||||||
- Adding a new attribute/script: creates a row with `IsInherited = false`
|
|
||||||
(it's not from the base).
|
|
||||||
- Removing an inherited row should be blocked (the row exists because it's
|
|
||||||
on the base; can't remove it on the derived). Removing an own-added row
|
|
||||||
deletes normally.
|
|
||||||
|
|
||||||
### Phase 8: Base TemplateEdit UI — lock toggle
|
|
||||||
|
|
||||||
Same `TemplateEdit.razor`, when editing a base template:
|
|
||||||
|
|
||||||
- New column on Attributes table: a 🔒 toggle representing `LockedInDerived`.
|
|
||||||
- Same on Scripts table.
|
|
||||||
- Tooltip on the toggle: *"Lock this against per-slot override in derived
|
|
||||||
templates."*
|
|
||||||
|
|
||||||
When a base template is open and the user toggles `LockedInDerived = true`
|
|
||||||
while derived templates exist with overrides, surface a warning toast:
|
|
||||||
*"N derived templates currently override this. Existing overrides won't be
|
|
||||||
silently reverted but deploy validation will flag them."*
|
|
||||||
|
|
||||||
### Phase 9: Editor metadata simplification
|
|
||||||
|
|
||||||
Now that derived templates have a single owner, `BuildParentContextsAsync`
|
|
||||||
in `TemplateEdit.razor` simplifies:
|
|
||||||
|
|
||||||
- **If the open template `IsDerived`**: parent = the single template that
|
|
||||||
owns the `OwnerCompositionId`. Always one — no picker.
|
|
||||||
- **If the open template is a base** (`!IsDerived`): suppress `Parent.*`
|
|
||||||
assistance entirely. Add a SCADA008 hint diagnostic on `Parent.*` use:
|
|
||||||
*"Parent access on a base template is ambiguous — override this script in
|
|
||||||
a derived template instead."*
|
|
||||||
|
|
||||||
Remove the multi-parent `select` dropdown that was added in commit `0139c9c`
|
|
||||||
(or keep it but guard with a check that should never fire after phase 3).
|
|
||||||
|
|
||||||
`GetTemplatesComposingAsync` (added in `0139c9c`) is still useful elsewhere —
|
|
||||||
keep it.
|
|
||||||
|
|
||||||
## How to resume
|
## How to resume
|
||||||
|
|
||||||
After compaction, the future session should:
|
A future session should:
|
||||||
|
|
||||||
1. Read this file. Read the design doc
|
1. Read this file and the design doc.
|
||||||
`2026-05-12-derive-on-compose-design.md` for context.
|
2. Run `git log --oneline -15` to confirm the branch is at `a965d4a` or
|
||||||
2. Run `git log --oneline -15` to confirm the branch is at `5615f3d` or
|
|
||||||
later.
|
later.
|
||||||
3. Check test status: `dotnet test` across the three suites named above.
|
3. Run the three test suites named above.
|
||||||
4. Ask the user which phase to tackle next (or proceed from phase 2 if the
|
4. Ask the user whether to ship or to address one of the deferred items
|
||||||
user has explicitly said "continue").
|
("when base toggles LockedInDerived while derivatives override",
|
||||||
5. Each phase is its own commit. Prefer phase 2 + phase 3 together because
|
SCADA008 base-Parent hint, or the live-DB / UI smoke verifications).
|
||||||
phase 2 alone leaves new and existing compositions in different shapes
|
|
||||||
until phase 3's migration runs.
|
|
||||||
|
|
||||||
## Pre-existing capability worth knowing
|
## Quick sanity script
|
||||||
|
|
||||||
The script-scope editor work (commits `3ed05f0` → `0139c9c`) is already in
|
|
||||||
place. Scripts on derived templates will automatically benefit from the
|
|
||||||
single-parent context simplification in phase 9. The runtime accessors
|
|
||||||
(`Attributes` / `Children` / `Parent` / `Scope`) defined there continue to
|
|
||||||
work unchanged — the canonical-name paths they resolve are stable across
|
|
||||||
the derivation change.
|
|
||||||
|
|
||||||
The multi-parent picker introduced in `0139c9c` becomes mostly dormant
|
|
||||||
after phase 3 (no template should be composed by multiple parents anymore
|
|
||||||
through the new flow). Plan to remove it in phase 9.
|
|
||||||
|
|
||||||
## Quick sanity script (run before phase 2)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git status --short # should be clean
|
git status --short # should be clean
|
||||||
git log --oneline -5 # top should include 5615f3d
|
git log --oneline -10 # top should include a965d4a
|
||||||
dotnet build # 0 errors, 0 warnings
|
dotnet build src/ScadaLink.CentralUI src/ScadaLink.TemplateEngine src/ScadaLink.ConfigurationDatabase
|
||||||
dotnet test tests/ScadaLink.TemplateEngine.Tests/ScadaLink.TemplateEngine.Tests.csproj
|
dotnet test tests/ScadaLink.TemplateEngine.Tests/ScadaLink.TemplateEngine.Tests.csproj
|
||||||
|
dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj
|
||||||
|
dotnet test tests/ScadaLink.SiteRuntime.Tests/ScadaLink.SiteRuntime.Tests.csproj
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Note: the full `dotnet build` of the solution fails with NU1608 in
|
||||||
|
`ScadaLink.IntegrationTests` and `ScadaLink.Host.Tests` due to a
|
||||||
|
pre-existing `Microsoft.CodeAnalysis.Common` 4.13 vs 5.0 mismatch — not
|
||||||
|
related to the derive-on-compose work. Build the three suites listed in
|
||||||
|
"Where we are" individually.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,13 @@
|
|||||||
ErrorMessage="@_moveFolderError"
|
ErrorMessage="@_moveFolderError"
|
||||||
OnSubmit="SubmitMoveFolder" />
|
OnSubmit="SubmitMoveFolder" />
|
||||||
|
|
||||||
|
<ComposeIntoDialog @bind-IsVisible="_showComposeDialog"
|
||||||
|
SourceTemplateId="_composeSourceId"
|
||||||
|
SourceName="@_composeSourceName"
|
||||||
|
ParentOptions="EnumerateComposableParents(_composeSourceId)"
|
||||||
|
ErrorMessage="@_composeError"
|
||||||
|
OnSubmit="SubmitCompose" />
|
||||||
|
|
||||||
@if (_loading)
|
@if (_loading)
|
||||||
{
|
{
|
||||||
<LoadingSpinner IsLoading="true" />
|
<LoadingSpinner IsLoading="true" />
|
||||||
@@ -74,11 +81,9 @@
|
|||||||
<div style="max-height: calc(100vh - 160px); overflow-y: auto; padding: 4px;">
|
<div style="max-height: calc(100vh - 160px); overflow-y: auto; padding: 4px;">
|
||||||
<TreeView @ref="_tree" TItem="TmplNode" Items="_treeRoots"
|
<TreeView @ref="_tree" TItem="TmplNode" Items="_treeRoots"
|
||||||
ChildrenSelector="n => n.Children"
|
ChildrenSelector="n => n.Children"
|
||||||
HasChildrenSelector="n => n.Kind != TmplNodeKind.Composition && n.Children.Count > 0"
|
HasChildrenSelector="n => n.Children.Count > 0"
|
||||||
KeySelector="n => (object)n.Key"
|
KeySelector="n => (object)n.Key"
|
||||||
StorageKey="templates-tree"
|
StorageKey="templates-tree">
|
||||||
Selectable="true"
|
|
||||||
SelectedKeyChanged="OnTreeNodeSelected">
|
|
||||||
<NodeContent Context="node">
|
<NodeContent Context="node">
|
||||||
@RenderNodeLabel(node)
|
@RenderNodeLabel(node)
|
||||||
</NodeContent>
|
</NodeContent>
|
||||||
@@ -171,22 +176,15 @@
|
|||||||
roots.Add(node);
|
roots.Add(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Template nodes with composition leaves
|
// 3. Template nodes with composition leaves. Derived templates are
|
||||||
foreach (var t in _templates.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase))
|
// slot-owned and reached via their parent's composition leaf — never
|
||||||
|
// shown as standalone tree nodes. Composition leaves recurse so a
|
||||||
|
// composite slot (e.g. Pump composed with TempSensor) reveals its own
|
||||||
|
// child slots when expanded.
|
||||||
|
var templatesById = _templates.ToDictionary(t => t.Id);
|
||||||
|
foreach (var t in _templates.Where(t => !t.IsDerived).OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
var compChildren = t.Compositions
|
var compChildren = BuildCompositionLeaves(t, templatesById);
|
||||||
.OrderBy(c => c.InstanceName, StringComparer.OrdinalIgnoreCase)
|
|
||||||
.Select(c => new TmplNode(
|
|
||||||
Key: $"c:{c.Id}",
|
|
||||||
Kind: TmplNodeKind.Composition,
|
|
||||||
EntityId: c.Id,
|
|
||||||
Label: c.InstanceName,
|
|
||||||
ParentFolderId: null,
|
|
||||||
OwnerTemplateId: t.Id,
|
|
||||||
Template: null,
|
|
||||||
Composition: c,
|
|
||||||
Children: new List<TmplNode>()))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var tNode = new TmplNode(
|
var tNode = new TmplNode(
|
||||||
Key: $"t:{t.Id}",
|
Key: $"t:{t.Id}",
|
||||||
@@ -213,6 +211,33 @@
|
|||||||
_treeRoots = roots;
|
_treeRoots = roots;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recursive: each composition leaf's children are the composed-template's
|
||||||
|
// own composition leaves. Cascaded derived templates carry their slot
|
||||||
|
// compositions, so walking ComposedTemplateId surfaces the full nested
|
||||||
|
// structure.
|
||||||
|
private static List<TmplNode> BuildCompositionLeaves(Template owner, IReadOnlyDictionary<int, Template> templatesById)
|
||||||
|
{
|
||||||
|
var result = new List<TmplNode>();
|
||||||
|
foreach (var c in owner.Compositions.OrderBy(c => c.InstanceName, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var nestedChildren = templatesById.TryGetValue(c.ComposedTemplateId, out var composed)
|
||||||
|
? BuildCompositionLeaves(composed, templatesById)
|
||||||
|
: new List<TmplNode>();
|
||||||
|
|
||||||
|
result.Add(new TmplNode(
|
||||||
|
Key: $"c:{c.Id}",
|
||||||
|
Kind: TmplNodeKind.Composition,
|
||||||
|
EntityId: c.Id,
|
||||||
|
Label: c.InstanceName,
|
||||||
|
ParentFolderId: null,
|
||||||
|
OwnerTemplateId: owner.Id,
|
||||||
|
Template: null,
|
||||||
|
Composition: c,
|
||||||
|
Children: nestedChildren));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private static void SortChildren(List<TmplNode> children)
|
private static void SortChildren(List<TmplNode> children)
|
||||||
{
|
{
|
||||||
children.Sort((a, b) =>
|
children.Sort((a, b) =>
|
||||||
@@ -225,6 +250,9 @@
|
|||||||
|
|
||||||
private TreeView<TmplNode> _tree = default!;
|
private TreeView<TmplNode> _tree = default!;
|
||||||
|
|
||||||
|
private void OpenTemplate(int templateId) =>
|
||||||
|
NavigationManager.NavigateTo($"/design/templates/{templateId}");
|
||||||
|
|
||||||
private RenderFragment RenderNodeLabel(TmplNode node) => __builder =>
|
private RenderFragment RenderNodeLabel(TmplNode node) => __builder =>
|
||||||
{
|
{
|
||||||
switch (node.Kind)
|
switch (node.Kind)
|
||||||
@@ -240,79 +268,23 @@
|
|||||||
<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis">@node.Children.Count</span>
|
<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis">@node.Children.Count</span>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@RenderNodeKebab(node)
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case TmplNodeKind.Template:
|
case TmplNodeKind.Template:
|
||||||
<span class="tv-glyph"><i class="bi bi-file-earmark-text"></i></span>
|
<span class="tv-glyph"><i class="bi bi-file-earmark-text"></i></span>
|
||||||
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
|
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
|
||||||
title="@node.Label">@node.Label</span>
|
title="@node.Label"
|
||||||
@RenderNodeKebab(node)
|
@ondblclick="() => OpenTemplate(node.EntityId)">@node.Label</span>
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case TmplNodeKind.Composition:
|
case TmplNodeKind.Composition:
|
||||||
<span class="tv-glyph"><i class="bi bi-arrow-return-right"></i></span>
|
<span class="tv-glyph"><i class="bi bi-arrow-return-right"></i></span>
|
||||||
<span class="tv-label" title="@node.Label">@node.Label</span>
|
<span class="tv-label" title="@node.Label"
|
||||||
@RenderNodeKebab(node)
|
@ondblclick="() => OpenTemplate(node.Composition!.ComposedTemplateId)">@node.Label</span>
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private RenderFragment RenderNodeKebab(TmplNode node) => __builder =>
|
|
||||||
{
|
|
||||||
<span class="tv-kebab dropdown ms-auto" @onclick:stopPropagation="true">
|
|
||||||
<button type="button"
|
|
||||||
class="btn btn-link btn-sm p-0 px-1 text-secondary tv-kebab-toggle"
|
|
||||||
data-bs-toggle="dropdown"
|
|
||||||
aria-expanded="false"
|
|
||||||
aria-label="@($"More actions for {node.Label}")">
|
|
||||||
<i class="bi bi-three-dots-vertical"></i>
|
|
||||||
</button>
|
|
||||||
<ul class="dropdown-menu dropdown-menu-end">
|
|
||||||
@switch (node.Kind)
|
|
||||||
{
|
|
||||||
case TmplNodeKind.Folder:
|
|
||||||
<li><button class="dropdown-item" @onclick="() => OpenNewFolderDialog(node.EntityId)">New Folder</button></li>
|
|
||||||
<li><button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/create?folderId={node.EntityId}")'>New Template</button></li>
|
|
||||||
<li><button class="dropdown-item" @onclick="() => OpenRenameFolderDialog(node.EntityId, node.Label)">Rename</button></li>
|
|
||||||
<li><button class="dropdown-item" @onclick="() => OpenMoveFolderDialog(node.EntityId, node.Label)">Move to Folder…</button></li>
|
|
||||||
<li><hr class="dropdown-divider" /></li>
|
|
||||||
<li><button class="dropdown-item text-danger" @onclick="() => DeleteFolder(node.EntityId, node.Label)">Delete</button></li>
|
|
||||||
break;
|
|
||||||
|
|
||||||
case TmplNodeKind.Template:
|
|
||||||
<li><button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/create?parentId={node.EntityId}")'>New Derived Template</button></li>
|
|
||||||
<li><button class="dropdown-item" @onclick="() => OpenMoveTemplateDialog(node.EntityId, node.Label)">Move to Folder…</button></li>
|
|
||||||
<li><hr class="dropdown-divider" /></li>
|
|
||||||
<li><button class="dropdown-item text-danger" @onclick="() => DeleteTemplate(node.Template!)">Delete</button></li>
|
|
||||||
break;
|
|
||||||
|
|
||||||
case TmplNodeKind.Composition:
|
|
||||||
<li><button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/{node.Composition!.ComposedTemplateId}")'>Open composed template</button></li>
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</span>
|
|
||||||
};
|
|
||||||
|
|
||||||
private void OnTreeNodeSelected(object? key)
|
|
||||||
{
|
|
||||||
if (key is not string s) return;
|
|
||||||
if (s.StartsWith("t:") && int.TryParse(s[2..], out var tid))
|
|
||||||
{
|
|
||||||
NavigationManager.NavigateTo($"/design/templates/{tid}");
|
|
||||||
}
|
|
||||||
else if (s.StartsWith("c:") && int.TryParse(s[2..], out var cid))
|
|
||||||
{
|
|
||||||
var comp = _templates.SelectMany(t => t.Compositions).FirstOrDefault(c => c.Id == cid);
|
|
||||||
if (comp != null)
|
|
||||||
{
|
|
||||||
NavigationManager.NavigateTo($"/design/templates/{comp.ComposedTemplateId}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Folder selection is intentionally a no-op (use right-click for folder actions).
|
|
||||||
}
|
|
||||||
|
|
||||||
private RenderFragment RenderNodeContextMenu(TmplNode node) => __builder =>
|
private RenderFragment RenderNodeContextMenu(TmplNode node) => __builder =>
|
||||||
{
|
{
|
||||||
switch (node.Kind)
|
switch (node.Kind)
|
||||||
@@ -327,7 +299,8 @@
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case TmplNodeKind.Template:
|
case TmplNodeKind.Template:
|
||||||
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/create?parentId={node.EntityId}")'>New Derived Template</button>
|
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/create?parentId={node.EntityId}")'>New Inheriting Template</button>
|
||||||
|
<button class="dropdown-item" @onclick="() => OpenComposeDialog(node.Template!)">Compose into…</button>
|
||||||
<button class="dropdown-item" @onclick="() => OpenMoveTemplateDialog(node.EntityId, node.Label)">Move to Folder…</button>
|
<button class="dropdown-item" @onclick="() => OpenMoveTemplateDialog(node.EntityId, node.Label)">Move to Folder…</button>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<button class="dropdown-item text-danger" @onclick="() => DeleteTemplate(node.Template!)">Delete</button>
|
<button class="dropdown-item text-danger" @onclick="() => DeleteTemplate(node.Template!)">Delete</button>
|
||||||
@@ -335,6 +308,9 @@
|
|||||||
|
|
||||||
case TmplNodeKind.Composition:
|
case TmplNodeKind.Composition:
|
||||||
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/{node.Composition!.ComposedTemplateId}")'>Open composed template</button>
|
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/{node.Composition!.ComposedTemplateId}")'>Open composed template</button>
|
||||||
|
<button class="dropdown-item" @onclick="() => RenameComposition(node.Composition!)">Rename…</button>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<button class="dropdown-item text-danger" @onclick="() => DeleteComposition(node.Composition!)">Delete</button>
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -526,4 +502,89 @@
|
|||||||
_toast.ShowError($"Delete failed: {ex.Message}");
|
_toast.ShowError($"Delete failed: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Compose-into dialog ----
|
||||||
|
private bool _showComposeDialog;
|
||||||
|
private int _composeSourceId;
|
||||||
|
private string _composeSourceName = string.Empty;
|
||||||
|
private string? _composeError;
|
||||||
|
|
||||||
|
private void OpenComposeDialog(Template source)
|
||||||
|
{
|
||||||
|
_composeSourceId = source.Id;
|
||||||
|
_composeSourceName = source.Name;
|
||||||
|
_composeError = null;
|
||||||
|
_showComposeDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Possible parents for a compose: every non-derived template except the source itself.
|
||||||
|
// Server still validates cycles + collisions; the picker just trims obvious bad choices.
|
||||||
|
private IEnumerable<(int Id, string Label)> EnumerateComposableParents(int sourceId)
|
||||||
|
{
|
||||||
|
return _templates
|
||||||
|
.Where(t => !t.IsDerived && t.Id != sourceId)
|
||||||
|
.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(t => (t.Id, t.Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SubmitCompose((int SourceTemplateId, int ParentTemplateId, string SlotName) req)
|
||||||
|
{
|
||||||
|
_composeError = null;
|
||||||
|
var user = await GetCurrentUserAsync();
|
||||||
|
var result = await TemplateService.AddCompositionAsync(req.ParentTemplateId, req.SourceTemplateId, req.SlotName, user);
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
_showComposeDialog = false;
|
||||||
|
_toast.ShowSuccess($"Composed '{_composeSourceName}' as '{req.SlotName}'.");
|
||||||
|
await LoadTemplatesAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_composeError = result.Error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Composition leaf: rename + delete ----
|
||||||
|
private async Task RenameComposition(TemplateComposition composition)
|
||||||
|
{
|
||||||
|
var newName = await Dialog.PromptAsync(
|
||||||
|
"Rename slot",
|
||||||
|
$"New name for slot '{composition.InstanceName}':",
|
||||||
|
initialValue: composition.InstanceName,
|
||||||
|
placeholder: "Slot name");
|
||||||
|
if (string.IsNullOrWhiteSpace(newName) || newName.Trim() == composition.InstanceName) return;
|
||||||
|
|
||||||
|
var user = await GetCurrentUserAsync();
|
||||||
|
var result = await TemplateService.RenameCompositionAsync(composition.Id, newName.Trim(), user);
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
_toast.ShowSuccess($"Slot renamed to '{newName.Trim()}'.");
|
||||||
|
await LoadTemplatesAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_toast.ShowError(result.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteComposition(TemplateComposition composition)
|
||||||
|
{
|
||||||
|
var confirmed = await Dialog.ConfirmAsync(
|
||||||
|
"Delete composition",
|
||||||
|
$"Delete slot '{composition.InstanceName}'? This removes the derived template and any overrides on it.",
|
||||||
|
danger: true);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
var user = await GetCurrentUserAsync();
|
||||||
|
var result = await TemplateService.DeleteCompositionAsync(composition.Id, user);
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
_toast.ShowSuccess($"Composition '{composition.InstanceName}' removed.");
|
||||||
|
await LoadTemplatesAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_toast.ShowError(result.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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
@if (IsVisible)
|
||||||
|
{
|
||||||
|
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h6 class="modal-title">Compose '@SourceName' into…</h6>
|
||||||
|
<button type="button" class="btn-close" @onclick="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small text-muted mb-1">Parent template</label>
|
||||||
|
<select class="form-select form-select-sm" @bind="_parentTemplateId">
|
||||||
|
<option value="0" disabled selected>Select a parent template…</option>
|
||||||
|
@foreach (var opt in ParentOptions)
|
||||||
|
{
|
||||||
|
<option value="@opt.Id">@opt.Label</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-1">
|
||||||
|
<label class="form-label small text-muted mb-1">Slot name</label>
|
||||||
|
<input type="text" class="form-control form-control-sm"
|
||||||
|
placeholder="Slot name"
|
||||||
|
@bind="_slotName" />
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-2">@ErrorMessage</div> }
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
|
||||||
|
<button class="btn btn-primary btn-sm" @onclick="Submit" disabled="@(_parentTemplateId == 0 || string.IsNullOrWhiteSpace(_slotName))">Compose</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public bool IsVisible { get; set; }
|
||||||
|
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
|
||||||
|
[Parameter] public int SourceTemplateId { get; set; }
|
||||||
|
[Parameter] public string SourceName { get; set; } = string.Empty;
|
||||||
|
[Parameter] public IEnumerable<(int Id, string Label)> ParentOptions { get; set; } = Array.Empty<(int, string)>();
|
||||||
|
[Parameter] public string? ErrorMessage { get; set; }
|
||||||
|
[Parameter] public EventCallback<(int SourceTemplateId, int ParentTemplateId, string SlotName)> OnSubmit { get; set; }
|
||||||
|
|
||||||
|
private bool _wasVisible;
|
||||||
|
private int _parentTemplateId;
|
||||||
|
private string _slotName = string.Empty;
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
if (IsVisible && !_wasVisible)
|
||||||
|
{
|
||||||
|
_parentTemplateId = 0;
|
||||||
|
_slotName = SourceName;
|
||||||
|
}
|
||||||
|
_wasVisible = IsVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Close() => await IsVisibleChanged.InvokeAsync(false);
|
||||||
|
|
||||||
|
private async Task Submit()
|
||||||
|
{
|
||||||
|
if (_parentTemplateId == 0 || string.IsNullOrWhiteSpace(_slotName)) return;
|
||||||
|
await OnSubmit.InvokeAsync((SourceTemplateId, _parentTemplateId, _slotName.Trim()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ else
|
|||||||
{
|
{
|
||||||
<div class="tv-ctx-overlay" @onclick="DismissContextMenu" style="position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:1049;background:transparent;"></div>
|
<div class="tv-ctx-overlay" @onclick="DismissContextMenu" style="position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:1049;background:transparent;"></div>
|
||||||
<div class="dropdown-menu show" tabindex="-1" @ref="_contextMenuRef" @onkeydown="OnContextMenuKeyDown"
|
<div class="dropdown-menu show" tabindex="-1" @ref="_contextMenuRef" @onkeydown="OnContextMenuKeyDown"
|
||||||
|
@onclick="DismissContextMenu"
|
||||||
style="position:fixed;top:@(_contextMenuY)px;left:@(_contextMenuX)px;z-index:1050;outline:none;">
|
style="position:fixed;top:@(_contextMenuY)px;left:@(_contextMenuX)px;z-index:1050;outline:none;">
|
||||||
@ContextMenu(_contextMenuItem)
|
@ContextMenu(_contextMenuItem)
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+117
@@ -0,0 +1,117 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class MigrateCompositionsToDerived : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// Re-shape every pre-Phase-2 TemplateComposition so it points at a
|
||||||
|
// newly created derived template ("<parent>.<slot>") that inherits
|
||||||
|
// from the original base. Attribute and script rows are copied with
|
||||||
|
// IsInherited=1; the composition's ComposedTemplateId is repointed.
|
||||||
|
//
|
||||||
|
// Idempotent: only rows whose target is still IsDerived=0 are touched.
|
||||||
|
// Aborts the migration if any derived name would collide with an
|
||||||
|
// existing template, so the operator can resolve manually.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
DECLARE @collisions NVARCHAR(MAX) = (
|
||||||
|
SELECT STRING_AGG(owner.Name + N'.' + c.InstanceName, N', ')
|
||||||
|
FROM TemplateCompositions c
|
||||||
|
INNER JOIN Templates base_t ON base_t.Id = c.ComposedTemplateId
|
||||||
|
INNER JOIN Templates owner ON owner.Id = c.TemplateId
|
||||||
|
INNER JOIN Templates existing ON existing.Name = owner.Name + N'.' + c.InstanceName
|
||||||
|
WHERE base_t.IsDerived = 0
|
||||||
|
);
|
||||||
|
IF @collisions IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DECLARE @msg NVARCHAR(MAX) =
|
||||||
|
N'MigrateCompositionsToDerived: cannot create derived templates — these names already exist: '
|
||||||
|
+ @collisions
|
||||||
|
+ N'. Rename the conflicting templates and retry the migration.';
|
||||||
|
THROW 50000, @msg, 1;
|
||||||
|
END
|
||||||
|
|
||||||
|
DECLARE @CompId INT, @BaseId INT, @OwnerName NVARCHAR(200), @SlotName NVARCHAR(200);
|
||||||
|
DECLARE @NewId INT, @NewName NVARCHAR(200);
|
||||||
|
|
||||||
|
DECLARE map_cursor CURSOR FAST_FORWARD FOR
|
||||||
|
SELECT c.Id, c.ComposedTemplateId, owner.Name, c.InstanceName
|
||||||
|
FROM TemplateCompositions c
|
||||||
|
INNER JOIN Templates base_t ON base_t.Id = c.ComposedTemplateId
|
||||||
|
INNER JOIN Templates owner ON owner.Id = c.TemplateId
|
||||||
|
WHERE base_t.IsDerived = 0;
|
||||||
|
|
||||||
|
OPEN map_cursor;
|
||||||
|
FETCH NEXT FROM map_cursor INTO @CompId, @BaseId, @OwnerName, @SlotName;
|
||||||
|
|
||||||
|
WHILE @@FETCH_STATUS = 0
|
||||||
|
BEGIN
|
||||||
|
SET @NewName = @OwnerName + N'.' + @SlotName;
|
||||||
|
|
||||||
|
INSERT INTO Templates (Name, Description, ParentTemplateId, FolderId, IsDerived, OwnerCompositionId)
|
||||||
|
SELECT @NewName, b.Description, b.Id, NULL, 1, @CompId
|
||||||
|
FROM Templates b
|
||||||
|
WHERE b.Id = @BaseId;
|
||||||
|
|
||||||
|
SET @NewId = SCOPE_IDENTITY();
|
||||||
|
|
||||||
|
INSERT INTO TemplateAttributes
|
||||||
|
(TemplateId, Name, Value, DataType, IsLocked, Description, DataSourceReference, IsInherited, LockedInDerived)
|
||||||
|
SELECT @NewId, a.Name, a.Value, a.DataType, a.IsLocked, a.Description, a.DataSourceReference, 1, 0
|
||||||
|
FROM TemplateAttributes a
|
||||||
|
WHERE a.TemplateId = @BaseId;
|
||||||
|
|
||||||
|
INSERT INTO TemplateScripts
|
||||||
|
(TemplateId, Name, Code, IsLocked, TriggerType, TriggerConfiguration, ParameterDefinitions, ReturnDefinition, MinTimeBetweenRuns, IsInherited, LockedInDerived)
|
||||||
|
SELECT @NewId, s.Name, s.Code, s.IsLocked, s.TriggerType, s.TriggerConfiguration, s.ParameterDefinitions, s.ReturnDefinition, s.MinTimeBetweenRuns, 1, 0
|
||||||
|
FROM TemplateScripts s
|
||||||
|
WHERE s.TemplateId = @BaseId;
|
||||||
|
|
||||||
|
UPDATE TemplateCompositions
|
||||||
|
SET ComposedTemplateId = @NewId
|
||||||
|
WHERE Id = @CompId;
|
||||||
|
|
||||||
|
FETCH NEXT FROM map_cursor INTO @CompId, @BaseId, @OwnerName, @SlotName;
|
||||||
|
END
|
||||||
|
|
||||||
|
CLOSE map_cursor;
|
||||||
|
DEALLOCATE map_cursor;
|
||||||
|
");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// Reverse: repoint each composition back to the derived template's
|
||||||
|
// base, then drop the derived templates (with their copied rows).
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
UPDATE c
|
||||||
|
SET c.ComposedTemplateId = d.ParentTemplateId
|
||||||
|
FROM TemplateCompositions c
|
||||||
|
INNER JOIN Templates d ON d.Id = c.ComposedTemplateId
|
||||||
|
WHERE d.IsDerived = 1
|
||||||
|
AND d.OwnerCompositionId = c.Id
|
||||||
|
AND d.ParentTemplateId IS NOT NULL;
|
||||||
|
|
||||||
|
DELETE a FROM TemplateAttributes a
|
||||||
|
INNER JOIN Templates t ON t.Id = a.TemplateId
|
||||||
|
WHERE t.IsDerived = 1;
|
||||||
|
|
||||||
|
DELETE s FROM TemplateScripts s
|
||||||
|
INNER JOIN Templates t ON t.Id = s.TemplateId
|
||||||
|
WHERE t.IsDerived = 1;
|
||||||
|
|
||||||
|
DELETE FROM Templates WHERE IsDerived = 1;
|
||||||
|
");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1300
File diff suppressed because it is too large
Load Diff
+196
@@ -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);
|
||||||
|
|||||||
@@ -54,6 +54,17 @@ public class FlatteningService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Step 0: Validate LockedInDerived isn't violated by any chain.
|
||||||
|
var lockError = ValidateLockedInDerived(templateChain);
|
||||||
|
if (lockError != null)
|
||||||
|
return Result<FlattenedConfiguration>.Failure(lockError);
|
||||||
|
foreach (var composedChain in composedTemplateChains.Values)
|
||||||
|
{
|
||||||
|
lockError = ValidateLockedInDerived(composedChain);
|
||||||
|
if (lockError != null)
|
||||||
|
return Result<FlattenedConfiguration>.Failure(lockError);
|
||||||
|
}
|
||||||
|
|
||||||
// Step 1: Resolve attributes from inheritance chain (most-derived-first wins for same name)
|
// Step 1: Resolve attributes from inheritance chain (most-derived-first wins for same name)
|
||||||
var attributes = ResolveInheritedAttributes(templateChain);
|
var attributes = ResolveInheritedAttributes(templateChain);
|
||||||
|
|
||||||
@@ -124,7 +135,10 @@ public class FlatteningService
|
|||||||
{
|
{
|
||||||
var result = new Dictionary<string, ResolvedAttribute>(StringComparer.Ordinal);
|
var result = new Dictionary<string, ResolvedAttribute>(StringComparer.Ordinal);
|
||||||
|
|
||||||
// Walk from base (last) to most-derived (first) so derived values win
|
// Walk from base (last) to most-derived (first) so derived values win.
|
||||||
|
// IsInherited rows on a derived template are placeholders that should
|
||||||
|
// not shadow the live base value; they only contribute a row when the
|
||||||
|
// base lacks one.
|
||||||
for (int i = templateChain.Count - 1; i >= 0; i--)
|
for (int i = templateChain.Count - 1; i >= 0; i--)
|
||||||
{
|
{
|
||||||
var template = templateChain[i];
|
var template = templateChain[i];
|
||||||
@@ -132,9 +146,13 @@ public class FlatteningService
|
|||||||
|
|
||||||
foreach (var attr in template.Attributes)
|
foreach (var attr in template.Attributes)
|
||||||
{
|
{
|
||||||
// If a parent defined this attribute as locked, derived cannot change the value
|
if (result.TryGetValue(attr.Name, out var existing))
|
||||||
if (result.TryGetValue(attr.Name, out var existing) && existing.IsLocked)
|
{
|
||||||
continue;
|
if (existing.IsLocked)
|
||||||
|
continue;
|
||||||
|
if (attr.IsInherited)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
result[attr.Name] = new ResolvedAttribute
|
result[attr.Name] = new ResolvedAttribute
|
||||||
{
|
{
|
||||||
@@ -152,6 +170,42 @@ public class FlatteningService
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reports any LockedInDerived violations across the chain — i.e., a base
|
||||||
|
/// attribute/script marked LockedInDerived that a downstream derived
|
||||||
|
/// template overrides (IsInherited=false). Returns null on success or an
|
||||||
|
/// error message describing the first offending entries.
|
||||||
|
/// </summary>
|
||||||
|
private static string? ValidateLockedInDerived(IReadOnlyList<Template> templateChain)
|
||||||
|
{
|
||||||
|
var attrLocks = new Dictionary<string, Template>(StringComparer.Ordinal);
|
||||||
|
var scriptLocks = new Dictionary<string, Template>(StringComparer.Ordinal);
|
||||||
|
var errors = new List<string>();
|
||||||
|
|
||||||
|
for (int i = templateChain.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var template = templateChain[i];
|
||||||
|
|
||||||
|
foreach (var attr in template.Attributes)
|
||||||
|
{
|
||||||
|
if (attr.LockedInDerived)
|
||||||
|
attrLocks[attr.Name] = template;
|
||||||
|
else if (!attr.IsInherited && attrLocks.TryGetValue(attr.Name, out var lockingTemplate) && lockingTemplate.Id != template.Id)
|
||||||
|
errors.Add($"Attribute '{attr.Name}' is LockedInDerived by base template '{lockingTemplate.Name}' and cannot be overridden by '{template.Name}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var script in template.Scripts)
|
||||||
|
{
|
||||||
|
if (script.LockedInDerived)
|
||||||
|
scriptLocks[script.Name] = template;
|
||||||
|
else if (!script.IsInherited && scriptLocks.TryGetValue(script.Name, out var lockingTemplate) && lockingTemplate.Id != template.Id)
|
||||||
|
errors.Add($"Script '{script.Name}' is LockedInDerived by base template '{lockingTemplate.Name}' and cannot be overridden by '{template.Name}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Count == 0 ? null : string.Join(" ", errors);
|
||||||
|
}
|
||||||
|
|
||||||
private static void ResolveComposedAttributes(
|
private static void ResolveComposedAttributes(
|
||||||
IReadOnlyList<Template> templateChain,
|
IReadOnlyList<Template> templateChain,
|
||||||
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
|
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
|
||||||
@@ -343,8 +397,13 @@ public class FlatteningService
|
|||||||
|
|
||||||
foreach (var script in template.Scripts)
|
foreach (var script in template.Scripts)
|
||||||
{
|
{
|
||||||
if (result.TryGetValue(script.Name, out var existing) && existing.IsLocked)
|
if (result.TryGetValue(script.Name, out var existing))
|
||||||
continue;
|
{
|
||||||
|
if (existing.IsLocked)
|
||||||
|
continue;
|
||||||
|
if (script.IsInherited)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
result[script.Name] = new ResolvedScript
|
result[script.Name] = new ResolvedScript
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ public class TemplateDeletionService
|
|||||||
if (template == null)
|
if (template == null)
|
||||||
return Result<bool>.Failure($"Template with ID {templateId} not found.");
|
return Result<bool>.Failure($"Template with ID {templateId} not found.");
|
||||||
|
|
||||||
|
if (template.IsDerived)
|
||||||
|
return Result<bool>.Failure(
|
||||||
|
$"Cannot delete template '{template.Name}': it is a derived template. " +
|
||||||
|
"Remove the owning composition on its parent template instead.");
|
||||||
|
|
||||||
var errors = new List<string>();
|
var errors = new List<string>();
|
||||||
|
|
||||||
// Check 1: Instances reference this template
|
// Check 1: Instances reference this template
|
||||||
@@ -40,16 +45,33 @@ public class TemplateDeletionService
|
|||||||
errors.Add($"Cannot delete template '{template.Name}': {instances.Count} instance(s) reference it ({names}{(instances.Count > 10 ? "..." : "")}).");
|
errors.Add($"Cannot delete template '{template.Name}': {instances.Count} instance(s) reference it ({names}{(instances.Count > 10 ? "..." : "")}).");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check 2: Child templates reference it as parent
|
// Check 2: Child templates reference it as parent. Split derived vs. regular.
|
||||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||||
var childTemplates = allTemplates.Where(t => t.ParentTemplateId == templateId).ToList();
|
var inheritors = allTemplates.Where(t => t.ParentTemplateId == templateId).ToList();
|
||||||
if (childTemplates.Count > 0)
|
var regularChildren = inheritors.Where(t => !t.IsDerived).ToList();
|
||||||
|
var derivatives = inheritors.Where(t => t.IsDerived).ToList();
|
||||||
|
|
||||||
|
if (regularChildren.Count > 0)
|
||||||
{
|
{
|
||||||
var names = string.Join(", ", childTemplates.Select(t => t.Name).Take(10));
|
var names = string.Join(", ", regularChildren.Select(t => t.Name).Take(10));
|
||||||
errors.Add($"Cannot delete template '{template.Name}': {childTemplates.Count} child template(s) inherit from it ({names}{(childTemplates.Count > 10 ? "..." : "")}).");
|
errors.Add($"Cannot delete template '{template.Name}': {regularChildren.Count} child template(s) inherit from it ({names}{(regularChildren.Count > 10 ? "..." : "")}).");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check 3: Other templates compose it
|
if (derivatives.Count > 0)
|
||||||
|
{
|
||||||
|
var compIds = derivatives.Select(d => d.OwnerCompositionId).Where(id => id.HasValue).Select(id => id!.Value).ToHashSet();
|
||||||
|
var ownerLookup = allTemplates
|
||||||
|
.SelectMany(t => t.Compositions.Select(c => new { Owner = t, Composition = c }))
|
||||||
|
.Where(x => compIds.Contains(x.Composition.Id))
|
||||||
|
.ToDictionary(x => x.Composition.Id, x => $"'{x.Owner.Name}' (as '{x.Composition.InstanceName}')");
|
||||||
|
var details = string.Join(", ", derivatives.Take(10).Select(d =>
|
||||||
|
d.OwnerCompositionId.HasValue && ownerLookup.TryGetValue(d.OwnerCompositionId.Value, out var label)
|
||||||
|
? label
|
||||||
|
: $"'{d.Name}'"));
|
||||||
|
errors.Add($"Cannot delete template '{template.Name}': it is the base of {derivatives.Count} derived template(s) used in {details}{(derivatives.Count > 10 ? "..." : "")}. Remove those compositions first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check 3: Other templates compose it directly (e.g., pre-Phase-3 data).
|
||||||
var composingTemplates = new List<(string TemplateName, string InstanceName)>();
|
var composingTemplates = new List<(string TemplateName, string InstanceName)>();
|
||||||
foreach (var t in allTemplates)
|
foreach (var t in allTemplates)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -115,19 +115,48 @@ public class TemplateService
|
|||||||
if (template == null)
|
if (template == null)
|
||||||
return Result<bool>.Failure($"Template with ID {templateId} not found.");
|
return Result<bool>.Failure($"Template with ID {templateId} not found.");
|
||||||
|
|
||||||
|
// Derived templates are owned by their composition row and must be removed
|
||||||
|
// by deleting the composition (which cascades) — block direct deletion.
|
||||||
|
if (template.IsDerived)
|
||||||
|
return Result<bool>.Failure(
|
||||||
|
$"Cannot delete template '{template.Name}': it is a derived template. " +
|
||||||
|
"Remove the owning composition on its parent template instead.");
|
||||||
|
|
||||||
// Check for instances referencing this template
|
// Check for instances referencing this template
|
||||||
var instances = await _repository.GetInstancesByTemplateIdAsync(templateId, cancellationToken);
|
var instances = await _repository.GetInstancesByTemplateIdAsync(templateId, cancellationToken);
|
||||||
if (instances.Count > 0)
|
if (instances.Count > 0)
|
||||||
return Result<bool>.Failure(
|
return Result<bool>.Failure(
|
||||||
$"Cannot delete template '{template.Name}': it is referenced by {instances.Count} instance(s).");
|
$"Cannot delete template '{template.Name}': it is referenced by {instances.Count} instance(s).");
|
||||||
|
|
||||||
// Check for child templates inheriting from this template
|
// Check for child templates inheriting from this template.
|
||||||
|
// Split derived vs. regular children — the message and remediation differ.
|
||||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||||
var children = allTemplates.Where(t => t.ParentTemplateId == templateId).ToList();
|
var inheritors = allTemplates.Where(t => t.ParentTemplateId == templateId).ToList();
|
||||||
if (children.Count > 0)
|
var derivatives = inheritors.Where(t => t.IsDerived).ToList();
|
||||||
|
var regularChildren = inheritors.Where(t => !t.IsDerived).ToList();
|
||||||
|
|
||||||
|
if (regularChildren.Count > 0)
|
||||||
return Result<bool>.Failure(
|
return Result<bool>.Failure(
|
||||||
$"Cannot delete template '{template.Name}': it is inherited by {children.Count} child template(s): " +
|
$"Cannot delete template '{template.Name}': it is inherited by {regularChildren.Count} child template(s): " +
|
||||||
string.Join(", ", children.Select(c => $"'{c.Name}'")));
|
string.Join(", ", regularChildren.Select(c => $"'{c.Name}'")));
|
||||||
|
|
||||||
|
if (derivatives.Count > 0)
|
||||||
|
{
|
||||||
|
// Name each derivative by its owning parent template + composition slot.
|
||||||
|
var ownerCompIds = derivatives.Select(d => d.OwnerCompositionId).Where(id => id.HasValue).Select(id => id!.Value).ToHashSet();
|
||||||
|
var ownerLookup = allTemplates
|
||||||
|
.SelectMany(t => t.Compositions.Select(c => new { Owner = t, Composition = c }))
|
||||||
|
.Where(x => ownerCompIds.Contains(x.Composition.Id))
|
||||||
|
.ToDictionary(x => x.Composition.Id, x => $"'{x.Owner.Name}' (as '{x.Composition.InstanceName}')");
|
||||||
|
|
||||||
|
var details = derivatives
|
||||||
|
.Select(d => d.OwnerCompositionId.HasValue && ownerLookup.TryGetValue(d.OwnerCompositionId.Value, out var label)
|
||||||
|
? label
|
||||||
|
: $"'{d.Name}'");
|
||||||
|
return Result<bool>.Failure(
|
||||||
|
$"Cannot delete template '{template.Name}': it is the base of {derivatives.Count} derived template(s) used in: " +
|
||||||
|
string.Join(", ", details) + ". Remove those compositions first.");
|
||||||
|
}
|
||||||
|
|
||||||
// Check for templates composing this template
|
// Check for templates composing this template
|
||||||
var composedBy = allTemplates
|
var composedBy = allTemplates
|
||||||
@@ -235,6 +264,16 @@ public class TemplateService
|
|||||||
if (parentMember != null && parentMember.IsLocked)
|
if (parentMember != null && parentMember.IsLocked)
|
||||||
return Result<TemplateAttribute>.Failure(
|
return Result<TemplateAttribute>.Failure(
|
||||||
$"Attribute '{existing.Name}' is locked in parent and cannot be overridden.");
|
$"Attribute '{existing.Name}' is locked in parent and cannot be overridden.");
|
||||||
|
|
||||||
|
// Derived templates may not override fields the base marked LockedInDerived.
|
||||||
|
if (template.IsDerived)
|
||||||
|
{
|
||||||
|
var baseTemplate = await _repository.GetTemplateByIdAsync(template.ParentTemplateId.Value, cancellationToken);
|
||||||
|
var baseAttr = baseTemplate?.Attributes.FirstOrDefault(a => a.Name == existing.Name);
|
||||||
|
if (baseAttr != null && baseAttr.LockedInDerived)
|
||||||
|
return Result<TemplateAttribute>.Failure(
|
||||||
|
$"Attribute '{existing.Name}' is locked by base template '{baseTemplate!.Name}' and cannot be overridden.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate lock change rules
|
// Validate lock change rules
|
||||||
@@ -253,6 +292,10 @@ public class TemplateService
|
|||||||
existing.IsLocked = proposed.IsLocked;
|
existing.IsLocked = proposed.IsLocked;
|
||||||
existing.DataType = proposed.DataType;
|
existing.DataType = proposed.DataType;
|
||||||
existing.DataSourceReference = proposed.DataSourceReference;
|
existing.DataSourceReference = proposed.DataSourceReference;
|
||||||
|
if (template?.IsDerived == true)
|
||||||
|
existing.IsInherited = proposed.IsInherited;
|
||||||
|
else
|
||||||
|
existing.LockedInDerived = proposed.LockedInDerived;
|
||||||
|
|
||||||
await _repository.UpdateTemplateAttributeAsync(existing, cancellationToken);
|
await _repository.UpdateTemplateAttributeAsync(existing, cancellationToken);
|
||||||
await _auditService.LogAsync(user, "Update", "TemplateAttribute", attributeId.ToString(), existing.Name, existing, cancellationToken);
|
await _auditService.LogAsync(user, "Update", "TemplateAttribute", attributeId.ToString(), existing.Name, existing, cancellationToken);
|
||||||
@@ -464,6 +507,16 @@ public class TemplateService
|
|||||||
if (parentMember != null && parentMember.IsLocked)
|
if (parentMember != null && parentMember.IsLocked)
|
||||||
return Result<TemplateScript>.Failure(
|
return Result<TemplateScript>.Failure(
|
||||||
$"Script '{existing.Name}' is locked in parent and cannot be overridden.");
|
$"Script '{existing.Name}' is locked in parent and cannot be overridden.");
|
||||||
|
|
||||||
|
// Derived templates may not override scripts the base marked LockedInDerived.
|
||||||
|
if (template.IsDerived)
|
||||||
|
{
|
||||||
|
var baseTemplate = await _repository.GetTemplateByIdAsync(template.ParentTemplateId.Value, cancellationToken);
|
||||||
|
var baseScript = baseTemplate?.Scripts.FirstOrDefault(s => s.Name == existing.Name);
|
||||||
|
if (baseScript != null && baseScript.LockedInDerived)
|
||||||
|
return Result<TemplateScript>.Failure(
|
||||||
|
$"Script '{existing.Name}' is locked by base template '{baseTemplate!.Name}' and cannot be overridden.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate fixed fields
|
// Validate fixed fields
|
||||||
@@ -479,6 +532,10 @@ public class TemplateService
|
|||||||
existing.ParameterDefinitions = proposed.ParameterDefinitions;
|
existing.ParameterDefinitions = proposed.ParameterDefinitions;
|
||||||
existing.ReturnDefinition = proposed.ReturnDefinition;
|
existing.ReturnDefinition = proposed.ReturnDefinition;
|
||||||
existing.IsLocked = proposed.IsLocked;
|
existing.IsLocked = proposed.IsLocked;
|
||||||
|
if (template?.IsDerived == true)
|
||||||
|
existing.IsInherited = proposed.IsInherited;
|
||||||
|
else
|
||||||
|
existing.LockedInDerived = proposed.LockedInDerived;
|
||||||
// Name is NOT updated (fixed)
|
// Name is NOT updated (fixed)
|
||||||
|
|
||||||
await _repository.UpdateTemplateScriptAsync(existing, cancellationToken);
|
await _repository.UpdateTemplateScriptAsync(existing, cancellationToken);
|
||||||
@@ -533,45 +590,165 @@ public class TemplateService
|
|||||||
if (template == null)
|
if (template == null)
|
||||||
return Result<TemplateComposition>.Failure($"Template with ID {templateId} not found.");
|
return Result<TemplateComposition>.Failure($"Template with ID {templateId} not found.");
|
||||||
|
|
||||||
var composedTemplate = await _repository.GetTemplateByIdAsync(composedTemplateId, cancellationToken);
|
var baseTemplate = await _repository.GetTemplateByIdAsync(composedTemplateId, cancellationToken);
|
||||||
if (composedTemplate == null)
|
if (baseTemplate == null)
|
||||||
return Result<TemplateComposition>.Failure($"Composed template with ID {composedTemplateId} not found.");
|
return Result<TemplateComposition>.Failure($"Composed template with ID {composedTemplateId} not found.");
|
||||||
|
|
||||||
|
// Only base templates can be composed; derived templates are slot-owned.
|
||||||
|
if (baseTemplate.IsDerived)
|
||||||
|
return Result<TemplateComposition>.Failure(
|
||||||
|
$"Cannot compose template '{baseTemplate.Name}': it is a derived template. Compose its base instead.");
|
||||||
|
|
||||||
// Check for duplicate instance name
|
// Check for duplicate instance name
|
||||||
if (template.Compositions.Any(c => c.InstanceName == instanceName))
|
if (template.Compositions.Any(c => c.InstanceName == instanceName))
|
||||||
return Result<TemplateComposition>.Failure(
|
return Result<TemplateComposition>.Failure(
|
||||||
$"Composition instance name '{instanceName}' already exists on template '{template.Name}'.");
|
$"Composition instance name '{instanceName}' already exists on template '{template.Name}'.");
|
||||||
|
|
||||||
// Check composition acyclicity
|
// Acyclicity is checked against the base, not the to-be-created derived template —
|
||||||
|
// the derived inherits from the base, so a base→base cycle is the meaningful check.
|
||||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||||
var cycleError = CycleDetector.DetectCompositionCycle(templateId, composedTemplateId, allTemplates);
|
var cycleError = CycleDetector.DetectCompositionCycle(templateId, composedTemplateId, allTemplates);
|
||||||
if (cycleError != null)
|
if (cycleError != null)
|
||||||
return Result<TemplateComposition>.Failure(cycleError);
|
return Result<TemplateComposition>.Failure(cycleError);
|
||||||
|
|
||||||
// Check cross-graph cycle
|
|
||||||
var crossCycleError = CycleDetector.DetectCrossGraphCycle(templateId, null, composedTemplateId, allTemplates);
|
var crossCycleError = CycleDetector.DetectCrossGraphCycle(templateId, null, composedTemplateId, allTemplates);
|
||||||
if (crossCycleError != null)
|
if (crossCycleError != null)
|
||||||
return Result<TemplateComposition>.Failure(crossCycleError);
|
return Result<TemplateComposition>.Failure(crossCycleError);
|
||||||
|
|
||||||
var composition = new TemplateComposition(instanceName)
|
var probeComposition = new TemplateComposition(instanceName)
|
||||||
{
|
{
|
||||||
TemplateId = templateId,
|
TemplateId = templateId,
|
||||||
ComposedTemplateId = composedTemplateId
|
ComposedTemplateId = composedTemplateId
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check for naming collisions with the new composition
|
var testTemplate = CloneTemplateWithNewComposition(template, probeComposition);
|
||||||
var testTemplate = CloneTemplateWithNewComposition(template, composition);
|
|
||||||
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
|
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
|
||||||
if (collisions.Count > 0)
|
if (collisions.Count > 0)
|
||||||
return Result<TemplateComposition>.Failure(string.Join(" ", collisions));
|
return Result<TemplateComposition>.Failure(string.Join(" ", collisions));
|
||||||
|
|
||||||
await _repository.AddTemplateCompositionAsync(composition, cancellationToken);
|
// Derived template name uses dot-separated path: "<parent>.<slot>". The
|
||||||
|
// cascade may create additional derived templates one level per slot
|
||||||
|
// (composing $Sensor with a Probe1 slot into $Pump produces both
|
||||||
|
// $Pump.TempSensor and $Pump.TempSensor.Probe1). Pre-check every name
|
||||||
|
// the cascade is about to introduce so a deep collision aborts before
|
||||||
|
// any rows mutate.
|
||||||
|
var byId = allTemplates.ToDictionary(t => t.Id);
|
||||||
|
var cascadeNames = EnumerateCascadeNames(template.Name, instanceName, baseTemplate, byId).ToList();
|
||||||
|
var existingNames = allTemplates.Select(t => t.Name).ToHashSet(StringComparer.Ordinal);
|
||||||
|
var nameCollision = cascadeNames.FirstOrDefault(n => existingNames.Contains(n));
|
||||||
|
if (nameCollision != null)
|
||||||
|
return Result<TemplateComposition>.Failure(
|
||||||
|
$"Cannot create derived template '{nameCollision}': a template with that name already exists.");
|
||||||
|
|
||||||
|
var composition = await CreateCascadedCompositionAsync(template, baseTemplate, instanceName, user, cancellationToken);
|
||||||
await _auditService.LogAsync(user, "Create", "TemplateComposition", "0", instanceName, composition, cancellationToken);
|
await _auditService.LogAsync(user, "Create", "TemplateComposition", "0", instanceName, composition, cancellationToken);
|
||||||
await _repository.SaveChangesAsync(cancellationToken);
|
await _repository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
return Result<TemplateComposition>.Success(composition);
|
return Result<TemplateComposition>.Success(composition);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a derived template under <paramref name="outerTemplate"/> that
|
||||||
|
/// wraps <paramref name="source"/>, then recursively cascades the source's
|
||||||
|
/// own compositions so the slot graph is replicated under the new derived.
|
||||||
|
/// Used both for the user-initiated top-level compose and the recursive
|
||||||
|
/// children — neither path re-validates (caller pre-flights).
|
||||||
|
/// </summary>
|
||||||
|
private async Task<TemplateComposition> CreateCascadedCompositionAsync(
|
||||||
|
Template outerTemplate,
|
||||||
|
Template source,
|
||||||
|
string instanceName,
|
||||||
|
string user,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var derivedName = $"{outerTemplate.Name}.{instanceName}";
|
||||||
|
var derived = BuildDerivedTemplate(source, derivedName);
|
||||||
|
|
||||||
|
await _repository.AddTemplateAsync(derived, cancellationToken);
|
||||||
|
await _repository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
var composition = new TemplateComposition(instanceName)
|
||||||
|
{
|
||||||
|
TemplateId = outerTemplate.Id,
|
||||||
|
ComposedTemplateId = derived.Id
|
||||||
|
};
|
||||||
|
await _repository.AddTemplateCompositionAsync(composition, cancellationToken);
|
||||||
|
await _repository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
derived.OwnerCompositionId = composition.Id;
|
||||||
|
await _repository.UpdateTemplateAsync(derived, cancellationToken);
|
||||||
|
await _repository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Cascade — replicate each of the source's compositions onto the new
|
||||||
|
// derived. The child's ParentTemplateId points at the source-side
|
||||||
|
// child (so override chains stay intact across nesting).
|
||||||
|
foreach (var childComp in source.Compositions.ToList())
|
||||||
|
{
|
||||||
|
var childSource = await _repository.GetTemplateByIdAsync(childComp.ComposedTemplateId, cancellationToken);
|
||||||
|
if (childSource == null) continue;
|
||||||
|
await CreateCascadedCompositionAsync(derived, childSource, childComp.InstanceName, user, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return composition;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> EnumerateCascadeNames(
|
||||||
|
string outerName, string instanceName, Template source, IReadOnlyDictionary<int, Template> byId)
|
||||||
|
{
|
||||||
|
var derivedName = $"{outerName}.{instanceName}";
|
||||||
|
yield return derivedName;
|
||||||
|
foreach (var comp in source.Compositions)
|
||||||
|
{
|
||||||
|
if (!byId.TryGetValue(comp.ComposedTemplateId, out var child)) continue;
|
||||||
|
foreach (var name in EnumerateCascadeNames(derivedName, comp.InstanceName, child, byId))
|
||||||
|
yield return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<TemplateComposition>> RenameCompositionAsync(
|
||||||
|
int compositionId,
|
||||||
|
string newInstanceName,
|
||||||
|
string user,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(newInstanceName))
|
||||||
|
return Result<TemplateComposition>.Failure("Slot name is required.");
|
||||||
|
|
||||||
|
var composition = await _repository.GetTemplateCompositionByIdAsync(compositionId, cancellationToken);
|
||||||
|
if (composition == null)
|
||||||
|
return Result<TemplateComposition>.Failure($"Composition with ID {compositionId} not found.");
|
||||||
|
|
||||||
|
if (composition.InstanceName == newInstanceName) return Result<TemplateComposition>.Success(composition);
|
||||||
|
|
||||||
|
var owner = await _repository.GetTemplateByIdAsync(composition.TemplateId, cancellationToken);
|
||||||
|
if (owner == null)
|
||||||
|
return Result<TemplateComposition>.Failure($"Owning template with ID {composition.TemplateId} not found.");
|
||||||
|
|
||||||
|
if (owner.Compositions.Any(c => c.Id != compositionId && c.InstanceName == newInstanceName))
|
||||||
|
return Result<TemplateComposition>.Failure(
|
||||||
|
$"Slot name '{newInstanceName}' already exists on '{owner.Name}'.");
|
||||||
|
|
||||||
|
var derived = await _repository.GetTemplateByIdAsync(composition.ComposedTemplateId, cancellationToken);
|
||||||
|
if (derived != null && derived.IsDerived && derived.OwnerCompositionId == compositionId)
|
||||||
|
{
|
||||||
|
var newDerivedName = $"{owner.Name}.{newInstanceName}";
|
||||||
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||||
|
if (allTemplates.Any(t => t.Id != derived.Id && t.Name == newDerivedName))
|
||||||
|
return Result<TemplateComposition>.Failure(
|
||||||
|
$"Cannot rename derived template to '{newDerivedName}': a template with that name already exists.");
|
||||||
|
|
||||||
|
derived.Name = newDerivedName;
|
||||||
|
await _repository.UpdateTemplateAsync(derived, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
composition.InstanceName = newInstanceName;
|
||||||
|
await _repository.UpdateTemplateCompositionAsync(composition, cancellationToken);
|
||||||
|
await _auditService.LogAsync(user, "Update", "TemplateComposition", compositionId.ToString(), newInstanceName, composition, cancellationToken);
|
||||||
|
await _repository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result<TemplateComposition>.Success(composition);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Result<bool>> DeleteCompositionAsync(
|
public async Task<Result<bool>> DeleteCompositionAsync(
|
||||||
int compositionId,
|
int compositionId,
|
||||||
string user,
|
string user,
|
||||||
@@ -581,13 +758,85 @@ public class TemplateService
|
|||||||
if (composition == null)
|
if (composition == null)
|
||||||
return Result<bool>.Failure($"Composition with ID {compositionId} not found.");
|
return Result<bool>.Failure($"Composition with ID {compositionId} not found.");
|
||||||
|
|
||||||
|
// Identify the slot-owned derived template (post Phase-3 migration this is the
|
||||||
|
// typical case; pre-migration the composition may still point at a base).
|
||||||
|
var composedTemplate = await _repository.GetTemplateByIdAsync(composition.ComposedTemplateId, cancellationToken);
|
||||||
|
|
||||||
await _repository.DeleteTemplateCompositionAsync(compositionId, cancellationToken);
|
await _repository.DeleteTemplateCompositionAsync(compositionId, cancellationToken);
|
||||||
await _auditService.LogAsync(user, "Delete", "TemplateComposition", compositionId.ToString(), composition.InstanceName, null, cancellationToken);
|
await _auditService.LogAsync(user, "Delete", "TemplateComposition", compositionId.ToString(), composition.InstanceName, null, cancellationToken);
|
||||||
|
|
||||||
|
if (composedTemplate != null && composedTemplate.IsDerived && composedTemplate.OwnerCompositionId == compositionId)
|
||||||
|
{
|
||||||
|
await CascadeDeleteDerivedAsync(composedTemplate, user, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
await _repository.SaveChangesAsync(cancellationToken);
|
await _repository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
return Result<bool>.Success(true);
|
return Result<bool>.Success(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recursively deletes a derived template along with the cascade of inner
|
||||||
|
/// derived templates the compose flow created. Each composition row on the
|
||||||
|
/// derived has its slot-owned child template removed first, then the row,
|
||||||
|
/// then the derived itself.
|
||||||
|
/// </summary>
|
||||||
|
private async Task CascadeDeleteDerivedAsync(Template derived, string user, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
foreach (var child in derived.Compositions.ToList())
|
||||||
|
{
|
||||||
|
var childDerived = await _repository.GetTemplateByIdAsync(child.ComposedTemplateId, cancellationToken);
|
||||||
|
await _repository.DeleteTemplateCompositionAsync(child.Id, cancellationToken);
|
||||||
|
await _auditService.LogAsync(user, "Delete", "TemplateComposition", child.Id.ToString(), child.InstanceName, null, cancellationToken);
|
||||||
|
if (childDerived != null && childDerived.IsDerived && childDerived.OwnerCompositionId == child.Id)
|
||||||
|
await CascadeDeleteDerivedAsync(childDerived, user, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _repository.DeleteTemplateAsync(derived.Id, cancellationToken);
|
||||||
|
await _auditService.LogAsync(user, "Delete", "Template", derived.Id.ToString(), derived.Name, null, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Template BuildDerivedTemplate(Template baseTemplate, string derivedName)
|
||||||
|
{
|
||||||
|
var derived = new Template(derivedName)
|
||||||
|
{
|
||||||
|
Description = baseTemplate.Description,
|
||||||
|
ParentTemplateId = baseTemplate.Id,
|
||||||
|
IsDerived = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var attr in baseTemplate.Attributes)
|
||||||
|
{
|
||||||
|
derived.Attributes.Add(new TemplateAttribute(attr.Name)
|
||||||
|
{
|
||||||
|
Value = attr.Value,
|
||||||
|
DataType = attr.DataType,
|
||||||
|
IsLocked = attr.IsLocked,
|
||||||
|
Description = attr.Description,
|
||||||
|
DataSourceReference = attr.DataSourceReference,
|
||||||
|
IsInherited = true,
|
||||||
|
LockedInDerived = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var script in baseTemplate.Scripts)
|
||||||
|
{
|
||||||
|
derived.Scripts.Add(new TemplateScript(script.Name, script.Code)
|
||||||
|
{
|
||||||
|
IsLocked = script.IsLocked,
|
||||||
|
TriggerType = script.TriggerType,
|
||||||
|
TriggerConfiguration = script.TriggerConfiguration,
|
||||||
|
ParameterDefinitions = script.ParameterDefinitions,
|
||||||
|
ReturnDefinition = script.ReturnDefinition,
|
||||||
|
MinTimeBetweenRuns = script.MinTimeBetweenRuns,
|
||||||
|
IsInherited = true,
|
||||||
|
LockedInDerived = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return derived;
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// WP-7: Path-Qualified Canonical Naming (via TemplateResolver)
|
// WP-7: Path-Qualified Canonical Naming (via TemplateResolver)
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -262,4 +262,112 @@ public class FlatteningServiceTests
|
|||||||
Assert.Equal(5, result.Value.SiteId);
|
Assert.Equal(5, result.Value.SiteId);
|
||||||
Assert.Equal(3, result.Value.AreaId);
|
Assert.Equal(3, result.Value.AreaId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Flatten_InheritedAttributeOnDerived_BaseValueWins()
|
||||||
|
{
|
||||||
|
var baseTemplate = CreateTemplate(2, "Sensor");
|
||||||
|
baseTemplate.Attributes.Add(new TemplateAttribute("SetPoint") { DataType = DataType.Double, Value = "100.0" });
|
||||||
|
|
||||||
|
var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2);
|
||||||
|
derived.Attributes.Add(new TemplateAttribute("SetPoint")
|
||||||
|
{
|
||||||
|
DataType = DataType.Double,
|
||||||
|
Value = "STALE",
|
||||||
|
IsInherited = true
|
||||||
|
});
|
||||||
|
|
||||||
|
var instance = CreateInstance();
|
||||||
|
var result = _sut.Flatten(
|
||||||
|
instance,
|
||||||
|
[derived, baseTemplate],
|
||||||
|
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||||
|
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||||
|
new Dictionary<int, DataConnection>());
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
var setPoint = result.Value.Attributes.First(a => a.CanonicalName == "SetPoint");
|
||||||
|
Assert.Equal("100.0", setPoint.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Flatten_OverriddenAttributeOnDerived_DerivedValueWins()
|
||||||
|
{
|
||||||
|
var baseTemplate = CreateTemplate(2, "Sensor");
|
||||||
|
baseTemplate.Attributes.Add(new TemplateAttribute("SetPoint") { DataType = DataType.Double, Value = "100.0" });
|
||||||
|
|
||||||
|
var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2);
|
||||||
|
derived.Attributes.Add(new TemplateAttribute("SetPoint")
|
||||||
|
{
|
||||||
|
DataType = DataType.Double,
|
||||||
|
Value = "150.0",
|
||||||
|
IsInherited = false
|
||||||
|
});
|
||||||
|
|
||||||
|
var instance = CreateInstance();
|
||||||
|
var result = _sut.Flatten(
|
||||||
|
instance,
|
||||||
|
[derived, baseTemplate],
|
||||||
|
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||||
|
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||||
|
new Dictionary<int, DataConnection>());
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
var setPoint = result.Value.Attributes.First(a => a.CanonicalName == "SetPoint");
|
||||||
|
Assert.Equal("150.0", setPoint.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Flatten_LockedInDerivedOverride_Fails()
|
||||||
|
{
|
||||||
|
var baseTemplate = CreateTemplate(2, "Sensor");
|
||||||
|
baseTemplate.Attributes.Add(new TemplateAttribute("SetPoint")
|
||||||
|
{
|
||||||
|
DataType = DataType.Double,
|
||||||
|
Value = "100.0",
|
||||||
|
LockedInDerived = true
|
||||||
|
});
|
||||||
|
|
||||||
|
var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2);
|
||||||
|
derived.Attributes.Add(new TemplateAttribute("SetPoint")
|
||||||
|
{
|
||||||
|
DataType = DataType.Double,
|
||||||
|
Value = "150.0",
|
||||||
|
IsInherited = false
|
||||||
|
});
|
||||||
|
|
||||||
|
var instance = CreateInstance();
|
||||||
|
var result = _sut.Flatten(
|
||||||
|
instance,
|
||||||
|
[derived, baseTemplate],
|
||||||
|
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||||
|
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||||
|
new Dictionary<int, DataConnection>());
|
||||||
|
|
||||||
|
Assert.True(result.IsFailure);
|
||||||
|
Assert.Contains("LockedInDerived", result.Error);
|
||||||
|
Assert.Contains("SetPoint", result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Flatten_InheritedScriptOnDerived_BaseCodeWins()
|
||||||
|
{
|
||||||
|
var baseTemplate = CreateTemplate(2, "Sensor");
|
||||||
|
baseTemplate.Scripts.Add(new TemplateScript("Sample", "return base;"));
|
||||||
|
|
||||||
|
var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2);
|
||||||
|
derived.Scripts.Add(new TemplateScript("Sample", "stale code") { IsInherited = true });
|
||||||
|
|
||||||
|
var instance = CreateInstance();
|
||||||
|
var result = _sut.Flatten(
|
||||||
|
instance,
|
||||||
|
[derived, baseTemplate],
|
||||||
|
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||||
|
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||||
|
new Dictionary<int, DataConnection>());
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
var script = result.Value.Scripts.First(s => s.CanonicalName == "Sample");
|
||||||
|
Assert.Equal("return base;", script.Code);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -288,9 +288,11 @@ public class TemplateServiceTests
|
|||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddComposition_Success()
|
public async Task AddComposition_Success_DerivesTemplate()
|
||||||
{
|
{
|
||||||
var moduleTemplate = new Template("Module") { Id = 2 };
|
var moduleTemplate = new Template("Module") { Id = 2 };
|
||||||
|
moduleTemplate.Attributes.Add(new TemplateAttribute("SetPoint") { Id = 10, TemplateId = 2, Value = "75", DataType = DataType.Float });
|
||||||
|
moduleTemplate.Scripts.Add(new TemplateScript("Compute", "return 1;") { Id = 20, TemplateId = 2 });
|
||||||
var template = new Template("Parent") { Id = 1 };
|
var template = new Template("Parent") { Id = 1 };
|
||||||
|
|
||||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||||
@@ -298,11 +300,296 @@ public class TemplateServiceTests
|
|||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { template, moduleTemplate });
|
.ReturnsAsync(new List<Template> { template, moduleTemplate });
|
||||||
|
|
||||||
|
Template? captured = null;
|
||||||
|
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<Template, CancellationToken>((t, _) => captured = t)
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
var result = await _service.AddCompositionAsync(1, 2, "myModule", "admin");
|
var result = await _service.AddCompositionAsync(1, 2, "myModule", "admin");
|
||||||
|
|
||||||
Assert.True(result.IsSuccess);
|
Assert.True(result.IsSuccess);
|
||||||
Assert.Equal("myModule", result.Value.InstanceName);
|
Assert.Equal("myModule", result.Value.InstanceName);
|
||||||
Assert.Equal(2, result.Value.ComposedTemplateId);
|
Assert.NotNull(captured);
|
||||||
|
Assert.True(captured!.IsDerived);
|
||||||
|
Assert.Equal("Parent.myModule", captured.Name);
|
||||||
|
Assert.Equal(2, captured.ParentTemplateId);
|
||||||
|
Assert.Single(captured.Attributes);
|
||||||
|
Assert.True(captured.Attributes.First().IsInherited);
|
||||||
|
Assert.Equal("SetPoint", captured.Attributes.First().Name);
|
||||||
|
Assert.Single(captured.Scripts);
|
||||||
|
Assert.True(captured.Scripts.First().IsInherited);
|
||||||
|
_repoMock.Verify(r => r.AddTemplateCompositionAsync(It.IsAny<TemplateComposition>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddComposition_CascadesChildCompositions()
|
||||||
|
{
|
||||||
|
// $Probe (base) → $Sensor.Probe1 (derived) ← $Sensor composes "Probe1"
|
||||||
|
// Composing $Sensor into $Pump as "TempSensor" should produce:
|
||||||
|
// $Pump.TempSensor (derived from $Sensor)
|
||||||
|
// $Pump.TempSensor.Probe1 (derived from $Sensor.Probe1)
|
||||||
|
// plus a composition row on $Pump.TempSensor pointing at the new inner derived.
|
||||||
|
var probe = new Template("Probe") { Id = 10 };
|
||||||
|
var sensorProbe1 = new Template("Sensor.Probe1") { Id = 11, IsDerived = true, ParentTemplateId = 10, OwnerCompositionId = 1 };
|
||||||
|
var sensor = new Template("Sensor") { Id = 2 };
|
||||||
|
sensor.Compositions.Add(new TemplateComposition("Probe1") { Id = 1, TemplateId = 2, ComposedTemplateId = 11 });
|
||||||
|
var pump = new Template("Pump") { Id = 1 };
|
||||||
|
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(pump);
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(sensor);
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(11, It.IsAny<CancellationToken>())).ReturnsAsync(sensorProbe1);
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template> { pump, sensor, probe, sensorProbe1 });
|
||||||
|
|
||||||
|
var captured = new List<Template>();
|
||||||
|
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<Template, CancellationToken>((t, _) => captured.Add(t))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var capturedCompositions = new List<TemplateComposition>();
|
||||||
|
_repoMock.Setup(r => r.AddTemplateCompositionAsync(It.IsAny<TemplateComposition>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<TemplateComposition, CancellationToken>((c, _) => capturedCompositions.Add(c))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var result = await _service.AddCompositionAsync(1, 2, "TempSensor", "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(2, captured.Count);
|
||||||
|
Assert.Equal("Pump.TempSensor", captured[0].Name);
|
||||||
|
Assert.Equal(2, captured[0].ParentTemplateId);
|
||||||
|
Assert.Equal("Pump.TempSensor.Probe1", captured[1].Name);
|
||||||
|
Assert.Equal(11, captured[1].ParentTemplateId);
|
||||||
|
Assert.Equal(2, capturedCompositions.Count);
|
||||||
|
Assert.Equal("TempSensor", capturedCompositions[0].InstanceName);
|
||||||
|
Assert.Equal("Probe1", capturedCompositions[1].InstanceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddComposition_BaseIsDerived_Fails()
|
||||||
|
{
|
||||||
|
var derivedBase = new Template("Sensor") { Id = 2, IsDerived = true };
|
||||||
|
var template = new Template("Parent") { Id = 1 };
|
||||||
|
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(derivedBase);
|
||||||
|
|
||||||
|
var result = await _service.AddCompositionAsync(1, 2, "slot", "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsFailure);
|
||||||
|
Assert.Contains("derived template", result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddComposition_DerivedNameCollision_Fails()
|
||||||
|
{
|
||||||
|
var existing = new Template("Parent.myModule") { Id = 99 };
|
||||||
|
var moduleTemplate = new Template("Module") { Id = 2 };
|
||||||
|
var template = new Template("Parent") { Id = 1 };
|
||||||
|
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template> { template, moduleTemplate, existing });
|
||||||
|
|
||||||
|
var result = await _service.AddCompositionAsync(1, 2, "myModule", "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsFailure);
|
||||||
|
Assert.Contains("already exists", result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RenameComposition_RenamesSlotAndDerivedTemplate()
|
||||||
|
{
|
||||||
|
var composition = new TemplateComposition("OldSlot") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
|
||||||
|
var owner = new Template("Pump") { Id = 1 };
|
||||||
|
owner.Compositions.Add(composition);
|
||||||
|
var derived = new Template("Pump.OldSlot") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
|
||||||
|
|
||||||
|
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(owner);
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template> { owner, derived });
|
||||||
|
|
||||||
|
var result = await _service.RenameCompositionAsync(50, "NewSlot", "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal("NewSlot", result.Value.InstanceName);
|
||||||
|
Assert.Equal("Pump.NewSlot", derived.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RenameComposition_DuplicateName_Fails()
|
||||||
|
{
|
||||||
|
var composition = new TemplateComposition("OldSlot") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
|
||||||
|
var sibling = new TemplateComposition("NewSlot") { Id = 51, TemplateId = 1, ComposedTemplateId = 78 };
|
||||||
|
var owner = new Template("Pump") { Id = 1 };
|
||||||
|
owner.Compositions.Add(composition);
|
||||||
|
owner.Compositions.Add(sibling);
|
||||||
|
|
||||||
|
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(owner);
|
||||||
|
|
||||||
|
var result = await _service.RenameCompositionAsync(50, "NewSlot", "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsFailure);
|
||||||
|
Assert.Contains("already exists", result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteComposition_CascadesNestedDerivedTemplates()
|
||||||
|
{
|
||||||
|
// Pump.TempSensor is a cascaded derived under Pump (outer derived) that
|
||||||
|
// is owned by composition 50. Deleting composition 50 must drop:
|
||||||
|
// - the outer derived (Pump)
|
||||||
|
// - its nested composition (TempSensor on Pump)
|
||||||
|
// - the nested derived (Pump.TempSensor)
|
||||||
|
var nestedComp = new TemplateComposition("TempSensor") { Id = 51, TemplateId = 77, ComposedTemplateId = 78 };
|
||||||
|
var nestedDerived = new Template("Tank.Pump.TempSensor") { Id = 78, IsDerived = true, OwnerCompositionId = 51, ParentTemplateId = 3 };
|
||||||
|
var outerComposition = new TemplateComposition("Pump") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
|
||||||
|
var outerDerived = new Template("Tank.Pump") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
|
||||||
|
outerDerived.Compositions.Add(nestedComp);
|
||||||
|
|
||||||
|
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(outerComposition);
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(outerDerived);
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(78, It.IsAny<CancellationToken>())).ReturnsAsync(nestedDerived);
|
||||||
|
|
||||||
|
var result = await _service.DeleteCompositionAsync(50, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
_repoMock.Verify(r => r.DeleteTemplateCompositionAsync(50, It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
_repoMock.Verify(r => r.DeleteTemplateCompositionAsync(51, It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
_repoMock.Verify(r => r.DeleteTemplateAsync(77, It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
_repoMock.Verify(r => r.DeleteTemplateAsync(78, It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteComposition_CascadesDerivedTemplate()
|
||||||
|
{
|
||||||
|
var composition = new TemplateComposition("slot") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
|
||||||
|
var derived = new Template("Parent.slot") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
|
||||||
|
|
||||||
|
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
|
||||||
|
|
||||||
|
var result = await _service.DeleteCompositionAsync(50, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
_repoMock.Verify(r => r.DeleteTemplateCompositionAsync(50, It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
_repoMock.Verify(r => r.DeleteTemplateAsync(77, It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteComposition_LegacyDirectBase_DoesNotCascade()
|
||||||
|
{
|
||||||
|
var composition = new TemplateComposition("slot") { Id = 60, TemplateId = 1, ComposedTemplateId = 5 };
|
||||||
|
var baseTemplate = new Template("Module") { Id = 5, IsDerived = false };
|
||||||
|
|
||||||
|
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(60, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(5, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
|
||||||
|
|
||||||
|
var result = await _service.DeleteCompositionAsync(60, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
_repoMock.Verify(r => r.DeleteTemplateCompositionAsync(60, It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
_repoMock.Verify(r => r.DeleteTemplateAsync(5, It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteTemplate_DerivedTemplate_DirectDeleteBlocked()
|
||||||
|
{
|
||||||
|
var derived = new Template("Parent.slot") { Id = 77, IsDerived = true };
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
|
||||||
|
|
||||||
|
var result = await _service.DeleteTemplateAsync(77, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsFailure);
|
||||||
|
Assert.Contains("derived template", result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAttribute_LockedInDerivedBase_RejectsOnDerived()
|
||||||
|
{
|
||||||
|
var existing = new TemplateAttribute("SetPoint") { Id = 100, TemplateId = 77, DataType = DataType.Float, IsInherited = true };
|
||||||
|
var baseTemplate = new Template("Sensor") { Id = 2 };
|
||||||
|
baseTemplate.Attributes.Add(new TemplateAttribute("SetPoint") { Id = 10, TemplateId = 2, LockedInDerived = true });
|
||||||
|
var derived = new Template("Parent.slot") { Id = 77, ParentTemplateId = 2, IsDerived = true };
|
||||||
|
|
||||||
|
_repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(100, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template> { baseTemplate, derived });
|
||||||
|
|
||||||
|
var proposed = new TemplateAttribute("SetPoint") { Value = "99", DataType = DataType.Float, IsInherited = false };
|
||||||
|
var result = await _service.UpdateAttributeAsync(100, proposed, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsFailure);
|
||||||
|
Assert.Contains("locked by base template 'Sensor'", result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateScript_LockedInDerivedBase_RejectsOnDerived()
|
||||||
|
{
|
||||||
|
var existing = new TemplateScript("Sample", "stale") { Id = 200, TemplateId = 77, IsInherited = true };
|
||||||
|
var baseTemplate = new Template("Sensor") { Id = 2 };
|
||||||
|
baseTemplate.Scripts.Add(new TemplateScript("Sample", "return 1;") { Id = 20, TemplateId = 2, LockedInDerived = true });
|
||||||
|
var derived = new Template("Parent.slot") { Id = 77, ParentTemplateId = 2, IsDerived = true };
|
||||||
|
|
||||||
|
_repoMock.Setup(r => r.GetTemplateScriptByIdAsync(200, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template> { baseTemplate, derived });
|
||||||
|
|
||||||
|
var proposed = new TemplateScript("Sample", "return 2;") { IsInherited = false };
|
||||||
|
var result = await _service.UpdateScriptAsync(200, proposed, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsFailure);
|
||||||
|
Assert.Contains("locked by base template 'Sensor'", result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAttribute_DerivedOverride_PersistsIsInheritedFalse()
|
||||||
|
{
|
||||||
|
var existing = new TemplateAttribute("SetPoint") { Id = 100, TemplateId = 77, DataType = DataType.Float, IsInherited = true };
|
||||||
|
var baseTemplate = new Template("Sensor") { Id = 2 };
|
||||||
|
baseTemplate.Attributes.Add(new TemplateAttribute("SetPoint") { Id = 10, TemplateId = 2 });
|
||||||
|
var derived = new Template("Parent.slot") { Id = 77, ParentTemplateId = 2, IsDerived = true };
|
||||||
|
|
||||||
|
_repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(100, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template> { baseTemplate, derived });
|
||||||
|
|
||||||
|
var proposed = new TemplateAttribute("SetPoint") { Value = "99", DataType = DataType.Float, IsInherited = false };
|
||||||
|
var result = await _service.UpdateAttributeAsync(100, proposed, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.False(result.Value.IsInherited);
|
||||||
|
Assert.Equal("99", result.Value.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteTemplate_BaseWithDerivatives_BlockedWithSlotNames()
|
||||||
|
{
|
||||||
|
var baseTemplate = new Template("Sensor") { Id = 5 };
|
||||||
|
var parent = new Template("Pump") { Id = 1 };
|
||||||
|
var composition = new TemplateComposition("TempSensor") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
|
||||||
|
parent.Compositions.Add(composition);
|
||||||
|
var derived = new Template("Pump.TempSensor") { Id = 77, ParentTemplateId = 5, IsDerived = true, OwnerCompositionId = 50 };
|
||||||
|
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(5, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
|
||||||
|
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(5, It.IsAny<CancellationToken>())).ReturnsAsync(new List<Instance>());
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template> { baseTemplate, parent, derived });
|
||||||
|
|
||||||
|
var result = await _service.DeleteTemplateAsync(5, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsFailure);
|
||||||
|
Assert.Contains("derived", result.Error);
|
||||||
|
Assert.Contains("'Pump' (as 'TempSensor')", result.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user