15 Commits

Author SHA1 Message Date
Joseph Doherty 783da8e21a feat(ui): structured editors for script schemas and alarm triggers
Replace raw-JSON text inputs with rich UI: script parameter/return types use
a JSON Schema builder (SchemaBuilder + JsonSchemaShapeParser, with a migration
to convert existing definitions); alarm trigger config uses a type-aware
editor with a flattened attribute picker (AlarmTriggerEditor). AlarmActor
gains optional direction (rising/falling/either) on RateOfChange triggers.
2026-05-13 00:33:00 -04:00
Joseph Doherty 57f477fd28 fix(templates): cascade delete through nested derived templates
DeleteCompositionAsync only dropped the top-level derived template — the
cascaded inner derived rows (created when composing a composite source)
were left orphaned with dangling OwnerCompositionId references. Any
subsequent attempt to recompose the same source hit the name-collision
guard ('Motor Controller.Pump.TempSensor' already exists).

New CascadeDeleteDerivedAsync walks each composition on the derived
template, recursively removes the slot-owned child derived first, then
the composition row, then the derived itself. Mirrors the recursive
shape of CreateCascadedCompositionAsync.
2026-05-12 10:34:55 -04:00
Joseph Doherty 85769486df fix(ui/templates): expand composition leaves to show cascaded slots
Composition leaves were rendered flat — the cascaded inner derived
templates existed in the DB but the tree only showed the outer slot
name (e.g. "Tank Monitor > DrivePump") with no way to see DrivePump's
own TempSensor + AlarmSensor slots.

BuildCompositionLeaves now recurses: for each composition under a
template, look up the composed template (which after derive-on-compose
is a derived row carrying its own Compositions) and build its slot
leaves as children. HasChildrenSelector loses the
"not a composition" guard so nested leaves render with the expand
chevron.
2026-05-12 10:29:52 -04:00
Joseph Doherty 4f90f952d0 fix(templates): cascade child compositions when composing a composite
When the user composes a template that already has compositions of its
own (e.g. \$Sensor → Probe1 slot), only the outer derived was created
— the source's children weren't replicated. AddCompositionAsync now
walks the source's composition graph and creates a parallel derived for
every slot it encounters, each linked back through ParentTemplateId so
override chains stay intact (\$Probe → \$Sensor.Probe1 → \$Pump.TempSensor.Probe1).

The cascade pre-flights every name it would create — a deep collision
aborts before any rows mutate. Internal helper
CreateCascadedCompositionAsync skips the "base templates only" check
since it operates on the source side which may legitimately reference
derived rows.
2026-05-12 09:57:07 -04:00
Joseph Doherty 1f86945d46 refactor(ui/templates): drop row kebabs; double-click opens templates
The right-click context menu is now the single entry point for every
per-row action — folders, templates, and composition leaves. Drop the
⋮ kebab buttons that duplicated the menu and the click-to-open
behavior that was easy to trigger by accident while navigating the
tree. Templates and composition slots open on double-click instead.

- RenderNodeKebab removed entirely.
- Selectable / SelectedKeyChanged / OnTreeNodeSelected dropped from
  the TreeView wiring — single-click no longer navigates.
- New OpenTemplate(id) helper bound to @ondblclick on Template and
  Composition labels.
2026-05-12 09:50:22 -04:00
Joseph Doherty 54338abdce refactor(ui/templates): drop the "Show derived" toggle
Derived templates are slot-owned and reached only via their owning
parent's composition leaf in the tree — there's no scenario where
listing them as standalone root nodes is useful, so the toggle was
dead UI. Remove the form-switch, the _showDerived state, and the
OnToggleShowDerived handler; BuildTemplateTree filters derived
templates out unconditionally.
2026-05-12 09:46:26 -04:00
Joseph Doherty 78de4a6492 fix(ui/treeview): dismiss right-click context menu when a menu item runs
The custom right-click context menu didn't close after a menu item
opened a modal dialog (e.g. "Compose into…"), leaving the menu
floating behind the modal until the user clicked elsewhere or hit
Escape. Add @onclick="DismissContextMenu" on the menu container so
any click inside it (button, divider, padding) closes the menu after
the button's own handler bubbles up.
2026-05-12 09:30:26 -04:00
Joseph Doherty 5c3dc79b8a feat(templates/ui): manage compositions from the tree
Move composition CRUD off the TemplateEdit page and onto the tree
context menu, matching Aveva's Template Toolbox flow.

- New ComposeIntoDialog: pick a parent template, slot name (defaults
  to the source template's name).
- "Compose into…" on every base template's context menu (kebab + right
  click) opens the dialog and calls AddCompositionAsync.
- "Rename…" on composition leaves opens a prompt and calls
  TemplateService.RenameCompositionAsync. The owning composition row
  AND its owned derived template are renamed atomically; duplicate
  slot names or derived-name collisions abort with a clear error.
- "Delete" on composition leaves confirms + cascade-deletes the
  composition (and its derived template via DeleteCompositionAsync).
- "New Derived Template" menu item renamed to "New Inheriting Template"
  to disambiguate from the new derive-on-compose meaning.

TemplateEdit's Compositions tab, Add Composition form, and
Add/DeleteComposition handlers + state fields are deleted — the tree
is now the single source of truth.
2026-05-12 09:22:55 -04:00
Joseph Doherty 552c9e4065 docs(templates): record phase 4-9 completion + verification TODOs
All nine derive-on-compose phases are now implemented. The status doc
captures what shipped per phase, what was deferred (LockedInDerived
override warning toast, SCADA008 base-Parent hint), and the live-DB /
UI smoke checks worth running before merge.
2026-05-12 08:59:19 -04:00
Joseph Doherty a965d4a5bd feat(templates/ui): phase 9 — single-parent editor context
Derive-on-compose guarantees at most one slot owner per template, so the
Parent.* context in the Monaco editor resolves directly via
OwnerCompositionId without a picker. Base templates suppress Parent.*
assistance entirely (empty context).

Removed the multi-parent <select> dropdown from the Add Script form and
the now-redundant _selectedParentIndex / OnParentContextChanged plumbing.
ActiveEditorParent collapses to _editorParents.FirstOrDefault().
2026-05-12 08:57:42 -04:00
Joseph Doherty f05b03f1cc feat(templates/ui): phase 6-8 — derived template UX
Templates tree hides IsDerived templates by default. A "Show derived"
form-switch in the page header toggles them into the listing so users
can reach orphaned derived templates when they need to.

TemplateEdit:
- Banner on derived templates: links to the base + the composing owner /
  slot name pulled from OwnerCompositionId.
- Attributes/Scripts tables grew a context-aware column:
  * On derived templates: a Source badge (Inherited / Override / Local)
    plus a 🔒 Base-locked badge when the base marks LockedInDerived.
  * On base templates: a switch that flips LockedInDerived through
    UpdateAttribute/UpdateScript.
- Effective Value / Code now resolves from the base when an inherited row
  carries a stale snapshot — matches the flatten-time behavior so the UI
  doesn't lie.
- Override / Revert-to-base actions added to the row kebab; delete is
  hidden on inherited rows (the base owns those).
2026-05-12 08:55:20 -04:00
Joseph Doherty f599809486 feat(templates): phase 4+5 — inherit/override resolution + lock enforcement
FlatteningService now treats IsInherited rows as placeholders: when a
derived template carries an inherited attribute or script, the live base
value resolves through the ParentTemplateId chain instead of the
(possibly stale) copy. An IsInherited=false row is a real override and
wins as before.

ValidateLockedInDerived runs once per chain (main + composed) and returns
a flatten-time failure if a derived template overrides a base row that
the base marked LockedInDerived.

TemplateService.Update{Attribute,Script}Async reject mid-flight when a
derived target tries to override a LockedInDerived base member, and now
persist IsInherited/LockedInDerived from the proposed payload so the UI
can flip override state or set base-locks via the same endpoints.
2026-05-12 08:50:49 -04:00
Joseph Doherty 8b8b85c839 docs(templates): record phase 2+3 completion in status doc
Phase 1 → 3 marked done; remaining work is phases 4-9. Sanity script now
targets the post-Phase-3 commit (03a8c4a) and notes the pre-existing
NU1608 build error in IntegrationTests / Host.Tests so future sessions
don't chase a phantom regression.
2026-05-12 08:31:20 -04:00
Joseph Doherty 03a8c4a632 feat(templates): phase 3 — migrate existing compositions to derived
EF migration MigrateCompositionsToDerived. Aborts with a clear error if
any '<parent>.<slot>' derived name would collide with an existing
template. Otherwise it cursor-walks every TemplateComposition that still
points at a non-derived template:

  1. Insert a derived Template (name "<parent>.<slot>",
     ParentTemplateId=base, IsDerived=1, OwnerCompositionId=composition).
  2. Copy base attributes / scripts into the derived row with
     IsInherited=1, LockedInDerived=0.
  3. Repoint TemplateComposition.ComposedTemplateId at the new derived.

Idempotent: only touches compositions whose target is IsDerived=0, so
re-runs and freshly-created Phase 2 compositions are skipped.

Down() reverses by repointing compositions back to derived.ParentTemplateId
and dropping all derived templates (with cascade copy rows).
2026-05-12 08:30:17 -04:00
Joseph Doherty fa86750717 feat(templates): phase 2 — derive-on-compose for new compositions
AddCompositionAsync creates a derived Template ("<parent>.<slot>") that
inherits from the base via ParentTemplateId. Base attributes and scripts
are copied with IsInherited=true so the derived template carries its own
override-able rows. The composition row points at the derived template,
and the derived's OwnerCompositionId back-refs the composition for cascade
delete.

DeleteCompositionAsync cascade-deletes the owned derived template.
DeleteTemplateAsync blocks direct deletion of derived templates and
distinguishes derivatives from regular children, listing slot owners
("'Pump' (as 'TempSensor')") in the error.

Composing a derived template is rejected — only bases can be composed.
Existing compositions still resolve until phase 3 migrates them.
2026-05-12 08:27:13 -04:00
36 changed files with 6410 additions and 1343 deletions
+117 -203
View File
@@ -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.
## Remainingphases 2 through 9 ## DonePhase 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;
}
}
} }
@@ -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;
");
}
}
}
@@ -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.");
}
}
}
+25 -4
View File
@@ -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)
{ {
+262 -13
View File
@@ -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]