diff --git a/docs/plans/2026-06-16-multivalue-attribute-design.md b/docs/plans/2026-06-16-multivalue-attribute-design.md new file mode 100644 index 00000000..9946bcd9 --- /dev/null +++ b/docs/plans/2026-06-16-multivalue-attribute-design.md @@ -0,0 +1,216 @@ +# Structured Multi-Value Attribute — Design + +**Date:** 2026-06-16 +**Status:** Implemented (MV-1 … MV-15, branch `feature/multivalue-attribute`) — full solution builds clean; feature-targeted tests green across Commons, TemplateEngine, SiteRuntime, DataConnectionLayer, Communication, Transport, ManagementService, CLI, and CentralUI. +**Branch:** `feature/multivalue-attribute` + +## Problem + +ScadaBridge's `DataType` enum (`Boolean·Int32·Float·Double·String·DateTime·Binary`) has no +collection type. An attribute is persisted as a single `string? Value` plus a `DataType` +discriminator. So a Galaxy/object attribute that is conceptually a list of values — e.g. +`MoveInWorkOrderNumbers`, `MoveInPartNumbers` (string arrays arriving via the +`IpsenMESMoveIn` inbound-API method) — can only be stored by collapsing it to a single +`DataType.String` blob. We want these stored as a **first-class structured multi-value +attribute** that round-trips through every path. + +## Scope (decided) + +- **Element types:** a *homogeneous* list of any existing **scalar** `DataType` — + `String, Int32, Float, Double, Boolean, DateTime`. **Excluded:** `Binary` elements and + nested lists (no lists-of-lists, no lists-of-objects). +- **Lifecycle paths — all four in scope:** + 1. Script writes the attribute at runtime and reads it back (the `IpsenMESMoveIn` case); + persists as a static attribute (site SQLite, survives failover). + 2. Statically authored default in a template, optionally overridden per instance, via + Central UI and CLI. + 3. Read in from an OPC UA array node (DCL subscription → live attribute value). + 4. Written out to an OPC UA array node via the DCL write path. + +## Decision summary (defaults locked in) + +| Decision | Choice | +|---|---| +| Type modeling | One `DataType.List` member + nullable `ElementDataType` companion | +| Value encoding | JSON array, invariant culture (round-trippable) | +| Display encoding | `ValueFormatter.FormatDisplayValue` stays comma-joined, display-only | +| Element types | 6 scalars; no `Binary`, no nesting | +| Override granularity | Whole-list replacement (no per-element override) | +| Element type mutability | Fixed by base attribute; not overridable on derived/instance | +| Bad OPC UA element | Coerce/validate; mismatch → Bad quality + log (non-fatal) | +| Empty vs unset | `null` = unset/cleared; `"[]"` = explicit empty list | +| gRPC wire | Existing `string value` field carries the canonical JSON (additive) | + +## Architecture + +The key insight from the blast-radius survey: most of the runtime is already +collection-capable. `ScriptParameters` already converts to typed `List`/`T[]`; the OPC UA +write path wraps values in a `Variant` (which supports arrays natively); +`AttributeValueChanged` already carries `object?`. The real work concentrates in **(a)** the +type model, **(b)** a canonical encode/decode codec, **(c)** the InstanceActor / script-accessor +boundary, **(d)** validation, and **(e)** the UI/CLI editors. + +### 1. Type model + +- Add **one** enum member: `DataType.List` + (`Commons/Types/Enums/DataType.cs`). +- Add nullable `ElementDataType` (`DataType?`) to: + - `Commons/Entities/Templates/TemplateAttribute.cs` + - `Commons/Entities/Instances/InstanceAttributeOverride.cs` + - `Commons/Types/Flattening/FlattenedConfiguration.cs` → `ResolvedAttribute` +- **Invariant:** `ElementDataType` required when `DataType == List`, null otherwise; element + type must be one of the 6 allowed scalars. + +### 2. Canonical value codec — new `AttributeValueCodec` + +One round-trippable codec used everywhere a value is **stored or transmitted**: + +- `Encode(object? value)`: + - scalar → invariant-culture string (**identical to today's behavior**; existing scalars + unchanged) + - non-string `IEnumerable` → JSON array, invariant culture + (`["WO-1","WO-2"]`, `[1,2,3]`, `["2026-06-16T00:00:00Z"]`) +- `Decode(string? value, DataType dataType, DataType? elementType)`: + - `List` → typed `List` (reuse the existing `ScriptParameters` element-conversion + machinery where practical) + - scalar → string (unchanged) +- `null` value = unset/cleared; `"[]"` = explicit empty list. + +`ValueFormatter.FormatDisplayValue` (comma-joined) remains **display-only** — diff text, +log lines, KPI strings — and is *not* the persisted/wire form. + +### 3. Persistence & migration + +- One EF Core migration, **written idempotent** (per the open follow-up #70 lesson — + guard inserts/alters so re-running against a partially-migrated DB is safe): + - add `ElementDataType nvarchar(50) NULL` to `TemplateAttributes` and + `InstanceAttributeOverrides` + - widen `Value` / `OverrideValue` from `nvarchar(4000)` → `nvarchar(max)` (lists can + exceed 4000) +- Site **SQLite** static-attribute store: no schema change (already a string column) — it + simply stores the canonical JSON. +- EF config: `TemplateConfiguration.cs`, `InstanceConfiguration.cs` map the new column + (`HasConversion().HasMaxLength(50)` for `ElementDataType`). + +### 4. Flatten / resolve + +- `TemplateEngine/Flattening/FlatteningService.cs` carries `ElementDataType` into + `ResolvedAttribute` next to `DataType`. +- Inheritance / compose / override shape unchanged. +- **Override replaces the whole list** (no per-element merge). +- A derived template or instance override **cannot change `ElementDataType`** — it is fixed + by the base attribute, enforced like the existing `LockedInDerived` path. + +### 5. Site runtime (the core change) + +- **`SiteRuntime/Scripts/ScopeAccessors.cs:56,73`** — `AttributeAccessor` set / `SetAsync`: + replace `value?.ToString()` with `AttributeValueCodec.Encode(value)`. (Today a + `List` would `.ToString()` to `"System.Collections.Generic.List`1[...]"` — the + central bug this fixes.) Scalars produce the same string as before. +- **`SiteRuntime/Actors/InstanceActor.cs:116-121`** — on load, for `List` attributes + `Decode` the JSON `Value` into a typed `List` and store *that* in `_attributes` + (scalars unchanged). +- **`HandleSetStaticAttribute`** — for `List` attributes: decode the canonical string → + typed list for `_attributes`, validate element types, persist the canonical JSON to + SQLite, stream the canonical JSON. +- **`HandleGetAttribute`** — unchanged; returns the live `_attributes` object, now a real + `List` for list attributes. Scripts read a first-class list. + +### 6. Data Connection Layer (OPC UA read + write) + +- **Read:** an OPC UA array node yields a CLR array → attribute value object. Coerce / + validate element type against `ElementDataType`; on mismatch → **Bad quality** + log, + never crash the actor. +- **Write:** `DataConnectionLayer/Adapters/RealOpcUaClient.cs:619-628` already wraps values + in `new Variant(value)`, which supports arrays natively; a `List`/`T[]` writes as an + OPC UA array. Minimal change beyond passing the typed list through. + +### 7. Validation (pre-deploy semantic) + +`TemplateEngine/Validation/SemanticValidator.cs` + `ValidationService.cs`: + +- `ElementDataType` present and a valid scalar when `DataType == List`; absent otherwise. +- Authored default `Value` (if present) must parse as a JSON array whose elements match + `ElementDataType`. +- List attributes **rejected as operands** in numeric alarm triggers (HiLo / + RangeViolation) and binary triggers — extends the existing `NumericDataTypes` guard. + +### 8. Streaming / DebugView + +- `Communication/Actors/StreamRelayActor.cs:48` — encode via `AttributeValueCodec` (JSON for + lists) instead of `FormatDisplayValue`. Additive: lists are a new type, so no existing wire + consumer breaks; the proto `string value` field (`sitestream.proto:57`) is unchanged. +- Central DebugView renders the canonical string; for lists it shows the JSON array. + *Optional polish:* chip rendering of list elements. + +### 9. Central UI + +- **`CentralUI/Components/Pages/Design/TemplateEdit.razor`** — attribute form: when + type = `List`, reveal an `ElementDataType` dropdown and a **list editor** (repeatable + add/remove rows) bound to the JSON value; inline per-element validation. +- **`CentralUI/Components/Pages/Deployment/InstanceConfigure.razor`** — override panel: the + same list editor for overriding a list attribute. + +### 10. CLI + +- **`CLI/Commands/TemplateCommands.cs`** (and the instance-override command): add + `--element-type `; `--value` accepts a JSON array (`'["a","b"]'`). Optional + ergonomic alternative: repeatable `--value`. Validate element type before submit. +- `ManagementService/ManagementActor.cs` add/update attribute handlers: parse + validate + `ElementDataType`, validate `Value` against it before persisting. + +### 11. Transport (import/export) + +- `Transport/Serialization/EntityDtos.cs` `TemplateAttributeDto` already carries the + `DataType` enum + `string? Value`; add `ElementDataType`. Enum/value round-trip is + otherwise automatic. `BundleImporter` carries the new field through. + +## Error handling + +- Malformed JSON in a persisted `Value`: caught at deploy-time validation; if it ever + reaches runtime decode, log + treat the attribute as Bad quality — never crash the + Instance Actor (audit/best-effort principle). +- OPC UA element-type mismatch on read: Bad quality + log (non-fatal). +- Script writes a non-list to a list attribute (or vice versa): rejected at + `HandleSetStaticAttribute` validation, surfaced to the script as an error. + +## Testing strategy + +- **Codec round-trip:** every element type; empty list; embedded commas/quotes/escaping; + `DateTime`; culture-invariance. +- **Flatten:** override replaces whole list; element-type lock enforced. +- **Validation:** good/bad authored defaults; trigger-operand rejection; missing/extra + `ElementDataType`. +- **InstanceActor:** set/get typed list; SQLite persistence survives simulated failover. +- **DCL:** OPC UA array read coercion; Bad quality on element mismatch; `Variant` array + write. +- **Streaming:** `StreamRelayActor` emits canonical JSON for a list attribute. +- **CLI:** `--element-type` + JSON `--value` parse/validate. +- **UI:** list-editor component behavior (if harness present). + +## Out of scope (deferred) + +- Nested objects / lists-of-records; heterogeneous lists. +- `Binary` element lists. +- Per-element overrides; element-level alarm triggers. + +## Blast-radius reference (file:area) + +| Area | Key locations | Change size | +|---|---|---| +| Enum + entities | `DataType.cs`, `TemplateAttribute.cs`, `InstanceAttributeOverride.cs`, `FlattenedConfiguration.cs` | small | +| Codec (new) | `Commons/Types/AttributeValueCodec.cs` (new) | small–med | +| EF + migration | `TemplateConfiguration.cs`, `InstanceConfiguration.cs`, new migration | small | +| Flatten | `FlatteningService.cs:177` | small | +| Runtime boundary | `ScopeAccessors.cs:56,73`, `InstanceActor.cs:116-121,246-315` | **med (core)** | +| DCL | `RealOpcUaClient.cs` read coercion (write already array-capable) | small–med | +| Validation | `SemanticValidator.cs:18-21,130-193`, `ValidationService.cs` | small–med | +| Streaming | `StreamRelayActor.cs:48` | small | +| UI | `TemplateEdit.razor`, `InstanceConfigure.razor` | **med (largest)** | +| CLI + mgmt | `TemplateCommands.cs`, `ManagementActor.cs:1441-1461` | small–med | +| Transport | `EntityDtos.cs:77-83`, `BundleImporter.cs:2302` | small | + +**Note:** the Inbound API type system (`ParameterDefinition` with `Object`/`List`, +`InboundApiSchema`) is genuinely separate from attribute `DataType` and is **not** modified; +its `ScriptParameters.ConvertToList/ConvertToArray` conversion helpers are reusable by the +codec. diff --git a/docs/plans/2026-06-16-multivalue-attribute.md b/docs/plans/2026-06-16-multivalue-attribute.md new file mode 100644 index 00000000..8605725c --- /dev/null +++ b/docs/plans/2026-06-16-multivalue-attribute.md @@ -0,0 +1,626 @@ +# Structured Multi-Value (List) Attribute — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task. + +**Goal:** Add a first-class `DataType.List` attribute type (a homogeneous list of any scalar element type) that round-trips through authoring, flatten, site runtime, OPC UA read/write, streaming, UI, and CLI. + +**Architecture:** One new `DataType.List` enum member + a nullable `ElementDataType` companion on the attribute entities and `ResolvedAttribute`. A single round-trippable `AttributeValueCodec` (JSON array, invariant culture) encodes/decodes list values everywhere they are stored or transmitted; scalars keep their current string behavior unchanged. The script-accessor `.ToString()` boundary and the InstanceActor in-memory store are the core runtime changes. + +**Tech Stack:** C#/.NET 10, Akka.NET 1.5, EF Core 10 (MS SQL + SQLite), gRPC, Blazor Server, System.CommandLine CLI. + +**Design doc:** `docs/plans/2026-06-16-multivalue-attribute-design.md` (approved). +**Branch:** `feature/multivalue-attribute` (off main; design committed `b238228`). + +**Conventions for every task:** +- TDD: write the failing test, see it fail, implement, see it pass, commit. +- Targeted builds/tests only — build the affected project(s) and run the filtered test(s); a full-solution build runs once in the final task. +- Build a project: `dotnet build src//.csproj` +- Run filtered tests: `dotnet test tests//.csproj --filter ` +- Allowed element types (the 6 scalars): `String, Int32, Float, Double, Boolean, DateTime`. **Not** `Binary`, **not** nested `List`. + +--- + +### Task MV-1: Type model — enum member + ElementDataType companion + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** none (foundation; everything else depends on it) + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/DataType.cs` +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Templates/TemplateAttribute.cs` +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Instances/InstanceAttributeOverride.cs` +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/FlattenedConfiguration.cs` (the `ResolvedAttribute` record, ~line 57) + +**Step 1 — Add the enum member.** Append `List` as the last member of `DataType` (append-only; do not reorder — the enum is persisted by name via `HasConversion`, but appended-last is the safe convention): + +```csharp +public enum DataType +{ + Boolean, + Int32, + Float, + Double, + String, + DateTime, + Binary, + List +} +``` + +**Step 2 — Add `ElementDataType` to the two entities and the resolved record.** + +`TemplateAttribute.cs` (after the `DataType DataType` property, ~line 26): +```csharp +/// +/// For attributes: the scalar type of each +/// element (String, Int32, Float, Double, Boolean, DateTime). Null for scalar +/// attributes. The element type is fixed by the base attribute and cannot be +/// changed on a derived template or instance override. +/// +public DataType? ElementDataType { get; set; } +``` + +`InstanceAttributeOverride.cs` (after `OverrideValue`, ~line 12): add the same `public DataType? ElementDataType { get; set; }` property (add `using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;`). + +`FlattenedConfiguration.cs` — `ResolvedAttribute` record (after `DataType` at ~line 68). NOTE: `ResolvedAttribute.DataType` is a **string**; keep `ElementDataType` a nullable string here for symmetry with that record's existing style: +```csharp +/// For List attributes: the element scalar type name; null otherwise. +public string? ElementDataType { get; init; } +``` + +**Step 3 — Build the project.** +Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj` +Expected: 0 errors (purely additive). + +**Step 4 — Commit.** +```bash +git add src/ZB.MOM.WW.ScadaBridge.Commons +git commit -m "feat(commons): add DataType.List + ElementDataType companion for multi-value attributes" +``` + +**Acceptance:** Commons compiles; `DataType.List` exists; both entities and `ResolvedAttribute` carry the nullable element-type field. + +--- + +### Task MV-2: AttributeValueCodec (round-trippable JSON encode/decode) + tests + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** MV-3, MV-4 +**Blocked by:** MV-1 + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs` +- Create: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs` + +This codec is the single canonical encoder used for: persisted attribute `Value`, the gRPC wire value, and decode-on-load. `ValueFormatter` stays display-only and is untouched. + +**Step 1 — Write failing tests.** Cover: scalar passthrough (string returned as-is; int/double/bool/DateTime → invariant string), list encode (`List` → `["a","b"]`), embedded comma/quote escaping, empty list → `"[]"`, null → null, DateTime list round-trips ISO-8601, culture-invariance (set `CultureInfo.CurrentCulture` to `de-DE` and assert `Encode(1.5)` is `"1.5"`), decode round-trip for each element type, and decode of malformed JSON throws `FormatException` (caught by callers). + +```csharp +using System.Globalization; +using ZB.MOM.WW.ScadaBridge.Commons.Types; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using Xunit; + +public class AttributeValueCodecTests +{ + [Fact] + public void Encode_StringList_ProducesJsonArray() => + Assert.Equal("[\"WO-1\",\"WO-2\"]", + AttributeValueCodec.Encode(new List { "WO-1", "WO-2" })); + + [Fact] + public void Encode_Scalar_String_ReturnedAsIs() => + Assert.Equal("hello", AttributeValueCodec.Encode("hello")); + + [Fact] + public void Encode_Scalar_Double_IsInvariant() + { + CultureInfo.CurrentCulture = new CultureInfo("de-DE"); + Assert.Equal("1.5", AttributeValueCodec.Encode(1.5)); + } + + [Fact] + public void Encode_EmptyList_IsBracketPair() => + Assert.Equal("[]", AttributeValueCodec.Encode(new List())); + + [Fact] + public void Encode_StringWithComma_IsEscaped() => + Assert.Equal("[\"ACME, Inc.\"]", + AttributeValueCodec.Encode(new List { "ACME, Inc." })); + + [Fact] + public void RoundTrip_Int32List() + { + var json = AttributeValueCodec.Encode(new List { 1, 2, 3 }); + var back = (IList)AttributeValueCodec.Decode(json, DataType.List, DataType.Int32)!; + Assert.Equal(new[] { 1, 2, 3 }, back); + } + + [Fact] + public void Decode_Scalar_ReturnsString() => + Assert.Equal("42", AttributeValueCodec.Decode("42", DataType.Int32, null)); + + [Fact] + public void Decode_MalformedJson_Throws() => + Assert.Throws(() => + AttributeValueCodec.Decode("not json", DataType.List, DataType.String)); +} +``` + +**Step 2 — Run, expect FAIL** (`AttributeValueCodec` not defined). +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ZB.MOM.WW.ScadaBridge.Commons.Tests.csproj --filter AttributeValueCodecTests` + +**Step 3 — Implement.** Use `System.Text.Json` with invariant formatting. For decode, reuse the element-conversion idea from `ScriptParameters.ConvertScalar` (`src/ZB.MOM.WW.ScadaBridge.Commons/Types/ScriptParameters.cs:161`) — keep the codec self-contained (small private scalar-parser switch on `DataType`). + +```csharp +using System.Collections; +using System.Globalization; +using System.Text.Json; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Types; + +/// +/// Canonical, round-trippable codec for attribute values. Scalars encode to an +/// invariant-culture string (identical to the historical representation); List +/// attributes encode to a JSON array. Used wherever a value is stored or +/// transmitted (DB Value column, site SQLite, gRPC wire). +/// remains a separate, display-only (comma-joined) formatter. +/// +public static class AttributeValueCodec +{ + private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = false }; + + /// Encodes a value to its canonical string form. + public static string? Encode(object? value) + { + switch (value) + { + case null: return null; + case string s: return s; // already canonical + case IFormattable f: return f.ToString(null, CultureInfo.InvariantCulture); + case IEnumerable e: + var items = e.Cast() + .Select(x => x is IFormattable xf + ? xf.ToString(null, CultureInfo.InvariantCulture) + : x?.ToString()); + return JsonSerializer.Serialize(items, JsonOpts); + default: return value.ToString(); + } + } + + /// + /// Decodes a canonical string. For returns a typed + /// List<T>; for scalars returns the string unchanged. Throws + /// on malformed list JSON or an un-parseable element. + /// + public static object? Decode(string? value, DataType dataType, DataType? elementType) + { + if (dataType != DataType.List) return value; // scalar: unchanged + if (string.IsNullOrEmpty(value)) return null; + if (elementType is null) + throw new FormatException("List attribute requires an element type."); + + string?[] raw; + try { raw = JsonSerializer.Deserialize(value) ?? []; } + catch (JsonException ex) { throw new FormatException("Malformed list JSON.", ex); } + + var clrType = ElementClrType(elementType.Value); + var listType = typeof(List<>).MakeGenericType(clrType); + var result = (IList)Activator.CreateInstance(listType)!; + foreach (var item in raw) + result.Add(ParseScalar(item, elementType.Value)); + return result; + } + + private static Type ElementClrType(DataType t) => t switch + { + DataType.String => typeof(string), + DataType.Int32 => typeof(int), + DataType.Float => typeof(float), + DataType.Double => typeof(double), + DataType.Boolean => typeof(bool), + DataType.DateTime => typeof(DateTime), + _ => throw new FormatException($"Unsupported list element type '{t}'.") + }; + + private static object? ParseScalar(string? s, DataType t) + { + if (s is null) throw new FormatException("List elements may not be null."); + var c = CultureInfo.InvariantCulture; + try + { + return t switch + { + DataType.String => s, + DataType.Int32 => int.Parse(s, c), + DataType.Float => float.Parse(s, c), + DataType.Double => double.Parse(s, c), + DataType.Boolean => bool.Parse(s), + DataType.DateTime => DateTime.Parse(s, c, DateTimeStyles.RoundtripKind), + _ => throw new FormatException($"Unsupported list element type '{t}'.") + }; + } + catch (Exception ex) when (ex is FormatException or OverflowException) + { + throw new FormatException($"List element '{s}' is not a valid {t}.", ex); + } + } + + /// True if the type may be a List element scalar. + public static bool IsValidElementType(DataType t) => + t is DataType.String or DataType.Int32 or DataType.Float + or DataType.Double or DataType.Boolean or DataType.DateTime; +} +``` + +**Step 4 — Run tests, expect PASS.** + +**Step 5 — Commit.** +```bash +git add src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs +git commit -m "feat(commons): AttributeValueCodec for canonical list value encode/decode" +``` + +**Acceptance:** all codec tests pass; scalars unchanged; lists round-trip; malformed JSON throws `FormatException`. + +--- + +### Task MV-3: EF mapping + idempotent migration + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** MV-2, MV-4 +**Blocked by:** MV-1 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/TemplateConfiguration.cs:111-122` +- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/InstanceConfiguration.cs:102-103` +- Create: a new migration under `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/` + +**Step 1 — EF config.** In `TemplateConfiguration.Configure`: widen `Value` and map the new column: +```csharp +builder.Property(a => a.Value); // remove .HasMaxLength(4000) → defaults to nvarchar(max) + +builder.Property(a => a.ElementDataType) + .HasConversion() + .HasMaxLength(50); +``` +In `InstanceConfiguration` (the `InstanceAttributeOverride` config): drop `.HasMaxLength(4000)` from `OverrideValue`, and add the same `ElementDataType` mapping. + +**Step 2 — Generate the migration.** +```bash +dotnet ef migrations add AddListAttributeElementType \ + --project src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase \ + --startup-project src/ZB.MOM.WW.ScadaBridge.Host +``` + +**Step 3 — Make `Up`/`Down` idempotent** (per open follow-up #70 — re-running against a partially-migrated prod DB must not throw). Wrap the generated `AddColumn`/`AlterColumn` calls with existence guards via `migrationBuilder.Sql(...)`: +```csharp +migrationBuilder.Sql(@" +IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE Name='ElementDataType' AND Object_ID=Object_ID('TemplateAttributes')) + ALTER TABLE [TemplateAttributes] ADD [ElementDataType] nvarchar(50) NULL;"); +migrationBuilder.Sql(@" +IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE Name='ElementDataType' AND Object_ID=Object_ID('InstanceAttributeOverrides')) + ALTER TABLE [InstanceAttributeOverrides] ADD [ElementDataType] nvarchar(50) NULL;"); +migrationBuilder.Sql("ALTER TABLE [TemplateAttributes] ALTER COLUMN [Value] nvarchar(max) NULL;"); +migrationBuilder.Sql("ALTER TABLE [InstanceAttributeOverrides] ALTER COLUMN [OverrideValue] nvarchar(max) NULL;"); +``` +Replace the auto-generated `AddColumn`/`AlterColumn` statements with the guarded SQL above (keep the `.Designer.cs` snapshot the tool generated — only the `Up`/`Down` body becomes guarded SQL). Provide a `Down` that drops the columns if present and restores `nvarchar(4000)`. + +**Step 4 — Verify no model drift.** +Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.csproj` +Then confirm there are no pending model changes: +```bash +dotnet ef migrations has-pending-model-changes \ + --project src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase \ + --startup-project src/ZB.MOM.WW.ScadaBridge.Host +``` +Expected: "No changes have been made to the model since the last migration" (or exit 0). + +**Step 5 — Commit.** +```bash +git add src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase +git commit -m "feat(db): migration for ElementDataType + widen attribute Value to nvarchar(max) (idempotent)" +``` + +**Acceptance:** project builds; no pending model changes; migration `Up`/`Down` are guarded/idempotent. + +--- + +### Task MV-4: Flatten carries ElementDataType into ResolvedAttribute + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** MV-2, MV-3 +**Blocked by:** MV-1 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs:177` (and any other site that constructs a `ResolvedAttribute`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs` + +**Step 1 — Failing test:** flatten a template whose attribute is `DataType.List` / `ElementDataType=String` and assert the `ResolvedAttribute` has `DataType=="List"` and `ElementDataType=="String"`; assert an instance override of that attribute keeps `ElementDataType` (override replaces value, not element type). + +**Step 2 — Run, expect FAIL.** + +**Step 3 — Implement:** wherever `ResolvedAttribute` is built (e.g. `DataType = attr.DataType.ToString()`), also set `ElementDataType = attr.ElementDataType?.ToString()`. Grep for `new ResolvedAttribute` and `DataType = ` in `FlatteningService.cs` to find every construction site and the override-merge path. + +**Step 4 — Run, expect PASS.** + +**Step 5 — Commit:** `feat(template): carry ElementDataType through flatten/override`. + +--- + +### Task MV-5: Semantic validation for List attributes + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** MV-6, MV-8, MV-9 (different files) +**Blocked by:** MV-1, MV-2 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs:18-21,130-193` +- Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs` +- Test: `tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SemanticValidatorTests.cs` + +**Rules to enforce (write a failing test for each first):** +1. `DataType.List` requires a non-null `ElementDataType` that is a valid element scalar (`AttributeValueCodec.IsValidElementType`); a scalar attribute must have `ElementDataType == null`. Violation → validation Error. +2. An authored default `Value` on a List attribute, if present, must `AttributeValueCodec.Decode` without throwing (catch `FormatException` → Error with the element type and message). +3. A List attribute used as an operand in a numeric trigger (HiLo / RangeViolation) or a binary trigger → Error. Extend the existing `NumericDataTypes` operand check (~line 130-193): a `List` operand is never numeric and never a valid binary operand. + +**Steps:** failing tests → run (FAIL) → implement the three checks → run (PASS) → commit `feat(validation): semantic checks for List attributes (element type, default value, trigger operands)`. + +--- + +### Task MV-6: Script-accessor encode boundary + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** MV-8, MV-9 +**Blocked by:** MV-2 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs:56,73` +- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/...` (accessor or runtime-context test; create if absent) + +**Problem:** `AttributeAccessor` set/`SetAsync` currently do `value?.ToString()`, which turns a `List` into `"System.Collections.Generic.List`1[System.String]"`. + +**Step 1 — Failing test:** a fake/seam `ScriptRuntimeContext.SetAttribute(name, encoded)` capturing the encoded string; assert setting a `List{"a","b"}` sends `["a","b"]` and setting a scalar `"x"` still sends `"x"`. + +**Step 2 — Run, expect FAIL.** + +**Step 3 — Implement:** replace both `.ToString()` sites with the codec: +```csharp +set => _ctx.SetAttribute(Resolve(key), AttributeValueCodec.Encode(value) ?? string.Empty) + .GetAwaiter().GetResult(); +// and in SetAsync: +=> _ctx.SetAttribute(Resolve(key), AttributeValueCodec.Encode(value) ?? string.Empty); +``` +Add `using ZB.MOM.WW.ScadaBridge.Commons.Types;`. + +**Step 4 — Run, expect PASS.** + +**Step 5 — Commit:** `fix(siteruntime): encode list attribute writes via AttributeValueCodec (was .ToString())`. + +--- + +### Task MV-7: InstanceActor decode (load + static set + override merge) + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** none +**Blocked by:** MV-1, MV-2, MV-4, MV-6 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs:114-122` (load), `:307-320` (`HandleSetStaticAttributeCore`), and the static-override merge on load (grep `GetStaticOverridesAsync` usage) +- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs` + +**Goal:** `_attributes` holds a typed `List` for List attributes so `HandleGetAttribute` returns a real list to scripts, and the canonical JSON string is what gets persisted + streamed. + +**Step 1 — Failing tests** (use the existing InstanceActor test harness / TestKit): +- Load a flattened config with a List attribute default `["a","b"]` → `GetAttributeRequest` returns an `IEnumerable` of `{"a","b"}` (not the raw string). +- `SetStaticAttributeCommand` with `Value = "[\"x\",\"y\"]"` on a List attribute → subsequent get returns `{"x","y"}`; assert `SetStaticOverrideAsync` was called with the JSON string `["x","y"]` (persisted form is canonical JSON). +- A persisted SQLite override `["p","q"]` applied on load decodes to a list. +- Malformed stored value → attribute loads with Bad quality, actor does not throw (wrap `Decode` in try/catch → log + set quality Bad). + +**Step 2 — Run, expect FAIL.** + +**Step 3 — Implement.** +- Add a small private helper on the actor: + ```csharp + private object? DecodeAttributeValue(ResolvedAttribute attr, string? raw) + { + var dataType = Enum.Parse(attr.DataType, ignoreCase: true); + var elementType = string.IsNullOrEmpty(attr.ElementDataType) + ? (DataType?)null + : Enum.Parse(attr.ElementDataType, ignoreCase: true); + try { return AttributeValueCodec.Decode(raw, dataType, elementType); } + catch (FormatException ex) + { + _logger.LogWarning(ex, "Attribute '{Attr}' on '{Instance}' has an undecodable value; marking Bad quality", + attr.CanonicalName, _instanceUniqueName); + return null; // caller sets quality Bad + } + } + ``` +- Load loop (`:116-121`): `_attributes[attr.CanonicalName] = DecodeAttributeValue(attr, attr.Value);` and set quality `Bad` when a List value failed to decode (non-null raw but null result). +- Apply the same decode when merging persisted static overrides (the `GetStaticOverridesAsync` merge). +- `HandleSetStaticAttributeCore` (`:309`): look up the `ResolvedAttribute` for `command.AttributeName`; store `DecodeAttributeValue(resolved, command.Value)` in `_attributes` (so reads are typed) while continuing to **persist and publish `command.Value`** (the canonical JSON string) unchanged. The published `AttributeValueChanged` keeps carrying the canonical string — `StreamRelayActor` (MV-9) handles encoding uniformly. + +**Step 4 — Run, expect PASS.** + +**Step 5 — Commit:** `feat(siteruntime): decode List attributes to typed lists in InstanceActor (load/set/override)`. + +**Acceptance:** scripts read List attributes as `List`; persisted + streamed form is canonical JSON; undecodable values degrade to Bad quality without crashing the actor. + +--- + +### Task MV-8: DCL OPC UA array read coercion + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** MV-5, MV-6, MV-9 +**Blocked by:** MV-1, MV-2 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs` (read/subscription value handling) — and the InstanceActor handler that ingests a DCL value for a data-sourced attribute (grep for where incoming tag values update `_attributes` / publish `AttributeValueChanged`). +- Test: `tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/...` + +**Goal:** when a data-sourced attribute is declared `DataType.List`, an incoming OPC UA array value becomes a typed list with each element coerced to `ElementDataType`; element-type mismatch → Bad quality + log (non-fatal). The **write** path needs no change (`RealOpcUaClient.WriteValueAsync` already wraps in `Variant`, which serializes a `List`/`T[]` as an OPC UA array — add a test asserting an array value writes without exception). + +**Steps:** failing tests (array read → typed list of correct element type; mismatched element → Bad quality; array write via Variant succeeds) → run (FAIL) → implement coercion using `AttributeValueCodec`/`Convert` per element → run (PASS) → commit `feat(dcl): coerce OPC UA array reads to typed list attributes; Bad quality on element mismatch`. + +--- + +### Task MV-9: StreamRelayActor canonical-JSON encode + +**Classification:** small +**Estimated implement time:** ~2 min +**Parallelizable with:** MV-5, MV-6, MV-8 +**Blocked by:** MV-2 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/StreamRelayActor.cs:48` +- Test: `tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/...` + +**Step 1 — Failing test:** relay an `AttributeValueChanged` whose `Value` is a `List{"a","b"}` → assert the produced `AttributeValueUpdate.Value == "[\"a\",\"b\"]"`; relay a scalar string `"x"` → assert `Value == "x"` (unchanged). + +**Step 2 — Run, expect FAIL.** + +**Step 3 — Implement:** replace `Value = ValueFormatter.FormatDisplayValue(msg.Value),` with `Value = AttributeValueCodec.Encode(msg.Value) ?? string.Empty,` (add the `using`). This is additive — List is a new type, no existing wire consumer relies on comma-joined lists; the proto `string value` field is unchanged. + +**Step 4 — Run, expect PASS.** + +**Step 5 — Commit:** `feat(comm): stream List attribute values as canonical JSON`. + +--- + +### Task MV-10: ManagementActor add/update attribute handlers + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** MV-11 not (blocks it); MV-12/13 share contract +**Blocked by:** MV-1, MV-2, MV-5 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs:~1441-1461` (add/update attribute), `:526` (the `DataType.ToString()` serialization for read-back) +- Modify: the management command/message contract that carries an attribute (add `ElementDataType` — additive field; grep for the `AddAttribute`/`UpdateAttribute` command records in `Commons/Messages/...`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/...` + +**Goal:** the add/update-attribute path accepts an optional element-type, validates `(DataType, ElementDataType, Value)` via the MV-5 rules + `AttributeValueCodec`, and persists both columns. Read-back includes `ElementDataType`. + +**Steps:** failing tests (add a List attribute with element type String + JSON default → persisted with both columns; add List without element type → rejected; add List with bad default → rejected) → run (FAIL) → implement parse/validate/persist → run (PASS) → commit `feat(mgmt): accept + validate ElementDataType on attribute add/update`. + +--- + +### Task MV-11: CLI element-type + JSON value + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** MV-12, MV-13 +**Blocked by:** MV-10 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs:140-199` (attribute add/update) and the instance-override command if present +- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/README.md` (document `--element-type` + JSON `--value`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/...` (option parsing) + +**Goal:** add `--element-type `; `--value` accepts a JSON array for List attributes. Validate element type client-side before sending; surface server validation errors. + +**Steps:** failing test (parse `--data-type List --element-type String --value '["a","b"]'` → request carries List + String + JSON) → run (FAIL) → implement option + plumb to the management call → run (PASS) → commit `feat(cli): --element-type and JSON --value for List attributes`. + +--- + +### Task MV-12: Transport DTO + importer field + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** MV-4, MV-9, MV-11, MV-13 +**Blocked by:** MV-1 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/...` or `src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs:77-83` (`TemplateAttributeDto`) +- Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs:~2300-2306` +- Test: `tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/...` (round-trip export→import of a List attribute) + +**Goal:** the export/import DTO carries `ElementDataType`; a List attribute survives an export→import round-trip. Old bundles without the field import as scalars (null element type) — assert backward-compat. + +**Steps:** failing round-trip test → run (FAIL) → add `ElementDataType` to the DTO + importer mapping → run (PASS) → commit `feat(transport): round-trip ElementDataType for List attributes`. + +--- + +### Task MV-13: Central UI — TemplateEdit list editor + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** MV-11, MV-12, MV-14 +**Blocked by:** MV-10 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateEdit.razor:82,484-491,556-566` +- (Optional) Create: a small `AttributeListEditor.razor` shared component under `CentralUI/Components/Shared/` +- Test/verify: Playwright fixture if the harness covers TemplateEdit; otherwise manual verification noted in the task. + +Use the **frontend-design** skill for the editor UI (clean corporate Bootstrap, no third-party component frameworks — per CLAUDE.md). + +**Goal:** when the attribute `DataType` dropdown = `List`, reveal an `ElementDataType` dropdown (the 6 scalars) and a repeatable add/remove row editor bound to the JSON value. Inline per-element validation by element type. The dropdown already enumerates `Enum.GetValues()`, so `List` appears automatically — gate the element-type + list editor on `_attrDataType == DataType.List`. + +**Steps:** implement the conditional editor; bind to `AttributeValueCodec.Encode` of the rows for submit; decode existing JSON into rows on edit; build `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/...`; verify (Playwright or manual) → commit `feat(ui): List attribute editor in TemplateEdit`. + +--- + +### Task MV-14: Central UI — InstanceConfigure override list editor + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** MV-13 +**Blocked by:** MV-10 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor` +- Reuse: the `AttributeListEditor.razor` component from MV-13 if created. + +**Goal:** overriding a List attribute on an instance uses the same list editor (whole-list replacement; element type shown read-only — fixed by base). Clearing the override removes it. + +**Steps:** implement using the shared editor; element-type dropdown is read-only here; build CentralUI; verify → commit `feat(ui): List attribute override editor in InstanceConfigure`. + +--- + +### Task MV-15: Integration verification + docs/README sync + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none +**Blocked by:** all (MV-1 … MV-14) + +**Files:** +- Modify: `README.md` and/or `docs/requirements/Component-*.md` if a DataType/attribute capability note needs updating (Template Engine / Commons). +- Modify: `docs/plans/2026-06-16-multivalue-attribute-design.md` — mark Status complete. + +**Steps:** +1. Full-solution build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — expect 0 errors. +2. Run the feature's targeted test classes across the touched test projects (codec, flatten, validation, InstanceActor, DCL, comm, transport, CLI). +3. End-to-end smoke (optional, if exercising the cluster): `bash docker/deploy.sh`, then via CLI create a template with a List attribute (`--data-type List --element-type String --value '["WO-1","WO-2"]'`), deploy an instance, and confirm the value flows to the DebugView as `["WO-1","WO-2"]`. +4. Update docs; sync the design-decision note if the high-level requirements track the DataType set. +5. Commit: `docs: mark multi-value attribute feature complete; sync README/component notes`. + +**Acceptance:** full build green; all targeted tests pass; (if run) the value round-trips end-to-end through the cluster. + +--- + +## Parallelization summary + +- **Wave 1:** MV-1 (foundation, solo). +- **Wave 2 (after MV-1):** MV-2, MV-3, MV-4, MV-12 in parallel (disjoint files). +- **Wave 3 (after MV-2):** MV-5, MV-6, MV-8, MV-9 in parallel; MV-7 after MV-6. +- **Wave 4 (after MV-5):** MV-10; then MV-11, MV-13, MV-14 in parallel after MV-10. +- **Wave 5:** MV-15 (final, solo). + +## Risk notes + +- **MV-3** (migration) and **MV-7** (actor model) are the high-risk tasks — full review chain. +- The migration must be idempotent (open follow-up #70 is the cautionary precedent). +- The gRPC change (MV-9) is additive — `List` is a brand-new type, so no existing wire consumer breaks; the proto field is unchanged. diff --git a/docs/plans/2026-06-16-multivalue-attribute.md.tasks.json b/docs/plans/2026-06-16-multivalue-attribute.md.tasks.json new file mode 100644 index 00000000..fbe584dc --- /dev/null +++ b/docs/plans/2026-06-16-multivalue-attribute.md.tasks.json @@ -0,0 +1,24 @@ +{ + "planPath": "docs/plans/2026-06-16-multivalue-attribute.md", + "designDoc": "docs/plans/2026-06-16-multivalue-attribute-design.md", + "branch": "feature/multivalue-attribute", + "status": "complete", + "tasks": [ + {"id": 77, "ref": "MV-1", "subject": "Type model — DataType.List + ElementDataType companion", "class": "standard", "status": "completed", "commits": ["70fa0e7"]}, + {"id": 78, "ref": "MV-2", "subject": "AttributeValueCodec + tests", "class": "standard", "status": "completed", "commits": ["8bd8079", "492b41f"]}, + {"id": 79, "ref": "MV-3", "subject": "EF mapping + idempotent migration", "class": "high-risk", "status": "completed", "commits": ["4a4b3d6"]}, + {"id": 80, "ref": "MV-4", "subject": "Flatten carries ElementDataType", "class": "small", "status": "completed", "commits": ["02aff24", "492b41f"]}, + {"id": 81, "ref": "MV-5", "subject": "Semantic validation for List attributes", "class": "standard", "status": "completed", "commits": ["872ce2b", "6ef6bab"]}, + {"id": 82, "ref": "MV-6", "subject": "Script-accessor encode boundary", "class": "small", "status": "completed", "commits": ["a1d464b"]}, + {"id": 83, "ref": "MV-7", "subject": "InstanceActor decode (load + set + override)", "class": "high-risk", "status": "completed", "commits": ["7f97780", "ad6bfc8"]}, + {"id": 84, "ref": "MV-8", "subject": "DCL OPC UA array read coercion", "class": "standard", "status": "completed", "commits": ["4765706", "96e817a"]}, + {"id": 85, "ref": "MV-9", "subject": "StreamRelayActor canonical-JSON encode", "class": "small", "status": "completed", "commits": ["ba414cb"]}, + {"id": 86, "ref": "MV-10", "subject": "ManagementActor add/update attribute handlers", "class": "standard", "status": "completed", "commits": ["1525670", "0164f8a"]}, + {"id": 87, "ref": "MV-11", "subject": "CLI --element-type + JSON --value", "class": "standard", "status": "completed", "commits": ["85db457", "100540b"]}, + {"id": 88, "ref": "MV-12", "subject": "Transport DTO + importer field", "class": "small", "status": "completed", "commits": ["e7e34b2", "492b41f"]}, + {"id": 89, "ref": "MV-13", "subject": "Central UI — TemplateEdit list editor", "class": "standard", "status": "completed", "commits": ["ba7331e", "100540b"]}, + {"id": 90, "ref": "MV-14", "subject": "Central UI — InstanceConfigure override list editor", "class": "standard", "status": "completed", "commits": ["ae2e1ef", "ca9ee5e"]}, + {"id": 91, "ref": "MV-15", "subject": "Integration verification + docs/README sync", "class": "standard", "status": "completed"} + ], + "lastUpdated": "2026-06-16" +} diff --git a/docs/requirements/Component-Commons.md b/docs/requirements/Component-Commons.md index 44270741..87853db4 100644 --- a/docs/requirements/Component-Commons.md +++ b/docs/requirements/Component-Commons.md @@ -26,7 +26,7 @@ Referenced by all component libraries and the Host. Commons must define shared primitive and utility types used across multiple components, including but not limited to: -- **`DataType` enum**: Enumerates the data types supported by the system (e.g., Boolean, Int32, Float, Double, String, DateTime, Binary). +- **`DataType` enum**: Enumerates the data types supported by the system: Boolean, Int32, Float, Double, String, DateTime, Binary, and **List** (a homogeneous multi-value attribute). A `List` attribute carries a companion `ElementDataType` (one of the scalar types — String, Int32, Float, Double, Boolean, DateTime; not Binary, not nested List) on `TemplateAttribute` / `InstanceAttributeOverride` and the flattened `ResolvedAttribute`. List values are stored and transmitted as a canonical JSON array via the `AttributeValueCodec` helper (scalars keep their historical invariant-culture string form); the `ElementDataType` is fixed by the defining level and cannot be changed on a derived template or instance override. - **`RetryPolicy`**: A record or immutable class describing retry behavior (max retries, fixed delay between retries). - **`Result`**: A discriminated result type that represents either a success value or an error, enabling consistent error handling across component boundaries without exceptions. - **`InstanceState` enum**: Enabled, Disabled. diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs index 5201e225..d1491916 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs @@ -138,9 +138,10 @@ public static class TemplateCommands var templateIdOption = new Option("--template-id") { Description = "Template ID", Required = true }; var nameOption = new Option("--name") { Description = "Attribute name", Required = true }; var dataTypeOption = new Option("--data-type") { Description = "Data type", Required = true }; - var valueOption = new Option("--value") { Description = "Default value" }; + var valueOption = new Option("--value") { Description = "Default value. For a List attribute, supply a JSON array (e.g. '[\"WO-1\",\"WO-2\"]')." }; var descOption = new Option("--description") { Description = "Description" }; var sourceOption = new Option("--data-source") { Description = "Data source reference" }; + var elementTypeOption = new Option("--element-type") { Description = ElementTypeOptionDescription }; var lockedOption = new Option("--locked") { Description = "Lock status" }; lockedOption.DefaultValueFactory = _ => false; @@ -151,28 +152,39 @@ public static class TemplateCommands addCmd.Add(valueOption); addCmd.Add(descOption); addCmd.Add(sourceOption); + addCmd.Add(elementTypeOption); addCmd.Add(lockedOption); addCmd.SetAction(async (ParseResult result) => { + var dataType = result.GetValue(dataTypeOption)!; + var elementType = result.GetValue(elementTypeOption); + if (!TryValidateElementType(dataType, elementType, out var error)) + { + OutputFormatter.WriteError(error!, "INVALID_ARGUMENT"); + return 1; + } + return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, - new AddTemplateAttributeCommand( + BuildAddAttributeCommand( result.GetValue(templateIdOption), result.GetValue(nameOption)!, - result.GetValue(dataTypeOption)!, + dataType, result.GetValue(valueOption), result.GetValue(descOption), result.GetValue(sourceOption), - result.GetValue(lockedOption))); + result.GetValue(lockedOption), + elementType)); }); group.Add(addCmd); var updateIdOption = new Option("--id") { Description = "Attribute ID", Required = true }; var updateNameOption = new Option("--name") { Description = "Attribute name", Required = true }; var updateDataTypeOption = new Option("--data-type") { Description = "Data type", Required = true }; - var updateValueOption = new Option("--value") { Description = "Default value" }; + var updateValueOption = new Option("--value") { Description = "Default value. For a List attribute, supply a JSON array (e.g. '[\"WO-1\",\"WO-2\"]')." }; var updateDescOption = new Option("--description") { Description = "Description" }; var updateSourceOption = new Option("--data-source") { Description = "Data source reference" }; + var updateElementTypeOption = new Option("--element-type") { Description = ElementTypeOptionDescription }; var updateLockedOption = new Option("--locked") { Description = "Lock status" }; updateLockedOption.DefaultValueFactory = _ => false; @@ -183,19 +195,29 @@ public static class TemplateCommands updateCmd.Add(updateValueOption); updateCmd.Add(updateDescOption); updateCmd.Add(updateSourceOption); + updateCmd.Add(updateElementTypeOption); updateCmd.Add(updateLockedOption); updateCmd.SetAction(async (ParseResult result) => { + var dataType = result.GetValue(updateDataTypeOption)!; + var elementType = result.GetValue(updateElementTypeOption); + if (!TryValidateElementType(dataType, elementType, out var error)) + { + OutputFormatter.WriteError(error!, "INVALID_ARGUMENT"); + return 1; + } + return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, - new UpdateTemplateAttributeCommand( + BuildUpdateAttributeCommand( result.GetValue(updateIdOption), result.GetValue(updateNameOption)!, - result.GetValue(updateDataTypeOption)!, + dataType, result.GetValue(updateValueOption), result.GetValue(updateDescOption), result.GetValue(updateSourceOption), - result.GetValue(updateLockedOption))); + result.GetValue(updateLockedOption), + elementType)); }); group.Add(updateCmd); @@ -213,6 +235,82 @@ public static class TemplateCommands return group; } + /// Shared description for the --element-type option on attribute add/update. + internal const string ElementTypeOptionDescription = + "Element scalar type for a List attribute (String, Int32, Float, Double, Boolean, DateTime). Required when --data-type is List."; + + /// The element scalar types permitted for a List attribute (matches the Management API). + private static readonly string[] ValidElementScalars = + { "String", "Int32", "Float", "Double", "Boolean", "DateTime" }; + + /// + /// Validates the --data-type / --element-type combination client-side so + /// the CLI fails fast with a clear message before contacting the Management API (the + /// server validates independently). A List attribute requires a valid element scalar; + /// a non-List attribute must not carry an element type. Comparison is case-insensitive. + /// + /// The raw --data-type value. + /// The raw --element-type value, or null if absent. + /// A descriptive error message when validation fails; otherwise null. + /// true when the combination is valid; otherwise false. + internal static bool TryValidateElementType(string dataType, string? elementType, out string? error) + { + error = null; + var isList = string.Equals(dataType, "List", StringComparison.OrdinalIgnoreCase); + var hasElementType = !string.IsNullOrWhiteSpace(elementType); + + if (isList) + { + if (!hasElementType) + { + error = "--element-type is required when --data-type is List " + + "(one of: String, Int32, Float, Double, Boolean, DateTime)."; + return false; + } + + if (!ValidElementScalars.Contains(elementType!.Trim(), StringComparer.OrdinalIgnoreCase)) + { + error = $"Invalid --element-type '{elementType}'. Valid List element scalars are: " + + string.Join(", ", ValidElementScalars) + "."; + return false; + } + + return true; + } + + if (hasElementType) + { + error = "--element-type is only valid when --data-type is List."; + return false; + } + + return true; + } + + /// + /// Builds the payload sent to the Management API. + /// The raw string is forwarded unchanged — for a List attribute it + /// is a JSON array, which the API/codec parses; the CLI does not reshape it. + /// + internal static AddTemplateAttributeCommand BuildAddAttributeCommand( + int templateId, string name, string dataType, string? value, + string? description, string? dataSource, bool isLocked, string? elementType) + => new(templateId, name, dataType, value, description, dataSource, isLocked, NormalizeElementType(elementType)); + + /// + /// Builds the payload sent to the Management API. + /// The raw string is forwarded unchanged — for a List attribute it + /// is a JSON array, which the API/codec parses; the CLI does not reshape it. + /// + internal static UpdateTemplateAttributeCommand BuildUpdateAttributeCommand( + int attributeId, string name, string dataType, string? value, + string? description, string? dataSource, bool isLocked, string? elementType) + => new(attributeId, name, dataType, value, description, dataSource, isLocked, NormalizeElementType(elementType)); + + /// Trims a non-empty element type; an empty/whitespace value becomes null (no element type). + private static string? NormalizeElementType(string? elementType) + => string.IsNullOrWhiteSpace(elementType) ? null : elementType.Trim(); + private static Command BuildAlarm(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var group = new Command("alarm") { Description = "Manage template alarms" }; diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md index 76706512..e98ea94e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md @@ -164,45 +164,62 @@ scadabridge --url template validate --id Add an attribute to a template. ```sh -scadabridge --url template attribute add --template-id --name --data-type [--default-value ] [--tag-path ] +scadabridge --url template attribute add --template-id --name --data-type [--value ] [--element-type ] [--description ] [--data-source ] [--locked] ``` | Option | Required | Description | |--------|----------|-------------| | `--template-id` | yes | Template ID | | `--name` | yes | Attribute name | -| `--data-type` | yes | Attribute data type (e.g. `Float`, `Int`, `String`, `Bool`) | -| `--default-value` | no | Default value | -| `--tag-path` | no | Data connection tag path | +| `--data-type` | yes | Attribute data type (`Boolean`, `Int32`, `Float`, `Double`, `String`, `DateTime`, `List`) | +| `--value` | no | Default value. For a `List` attribute, supply a JSON array (e.g. `'["WO-1","WO-2"]'`); the raw string is forwarded to the API, which parses it | +| `--element-type` | no | Element scalar type for a `List` attribute (`String`, `Int32`, `Float`, `Double`, `Boolean`, `DateTime`). **Required when `--data-type` is `List`**; must be omitted otherwise | +| `--description` | no | Description | +| `--data-source` | no | Data source reference | +| `--locked` | no | Lock the attribute in derived templates | + +**List example** — add a multi-value String attribute with two default elements: + +```sh +scadabridge --url template attribute add --template-id 7 --name WorkOrders \ + --data-type List --element-type String --value '["WO-1","WO-2"]' +``` + +The CLI validates the data-type / element-type combination locally before calling the +API: `--data-type List` requires a valid `--element-type`, and `--element-type` may only +be supplied when `--data-type` is `List`. The Management API re-validates server-side. #### `template attribute update` -Update an attribute on a template. +Update an attribute on a template. An update **replaces** the whole entity — every +required field below must be supplied with its post-update value, even if unchanged. ```sh -scadabridge --url template attribute update --template-id --name [--data-type ] [--default-value ] [--tag-path ] +scadabridge --url template attribute update --id --name --data-type [--value ] [--element-type ] [--description ] [--data-source ] [--locked] ``` | Option | Required | Description | |--------|----------|-------------| -| `--template-id` | yes | Template ID | -| `--name` | yes | Attribute name to update | -| `--data-type` | no | Updated data type | -| `--default-value` | no | Updated default value | -| `--tag-path` | no | Updated tag path | +| `--id` | yes | Attribute ID | +| `--name` | yes | Attribute name | +| `--data-type` | yes | Attribute data type (`Boolean`, `Int32`, `Float`, `Double`, `String`, `DateTime`, `List`) | +| `--value` | no | Default value. For a `List` attribute, supply a JSON array (e.g. `'["WO-1","WO-2"]'`) | +| `--element-type` | no | Element scalar type for a `List` attribute (`String`, `Int32`, `Float`, `Double`, `Boolean`, `DateTime`). **Required when `--data-type` is `List`**; must be omitted otherwise | +| `--description` | no | Description | +| `--data-source` | no | Data source reference | +| `--locked` | no | Lock the attribute in derived templates | #### `template attribute delete` Remove an attribute from a template. ```sh -scadabridge --url template attribute delete --template-id --name +scadabridge --url template attribute delete --id ``` | Option | Required | Description | |--------|----------|-------------| -| `--template-id` | yes | Template ID | -| `--name` | yes | Attribute name to delete | +| `--id` | yes | Attribute ID | #### `template alarm add` diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor index 6842e5be..582438d4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor @@ -5,6 +5,7 @@ @using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates @using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories @using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management +@using ZB.MOM.WW.ScadaBridge.Commons.Types @using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums @using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening @using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services @@ -184,26 +185,63 @@ } else { - +
- + @foreach (var attr in _overrideAttrs) { - + - + } @@ -507,6 +545,17 @@ // Overrides private List _overrideAttrs = new(); private Dictionary _overrideValues = new(); + // MV-14: existing override rows keyed by attribute name — tracks which + // attributes already have a persisted override (so List rows know whether a + // Clear is available) and carries the row Id for repository-direct delete. + private Dictionary _existingOverrides = new(); + // MV-14: per-List working rows (whole-list replacement), keyed by attribute + // name. Seeded on load from the effective value; encoded to canonical JSON on + // save. Element type is fixed by the base attribute. + private Dictionary> _listRows = new(); + // MV-14: per-attribute validation errors surfaced inline (e.g. an + // un-parseable List element caught on the pre-submit round-trip). + private Dictionary _overrideErrors = new(); // Alarm overrides — read-only state pulled from the repo. The edit modal // is the only mutation path (one alarm at a time). @@ -595,7 +644,23 @@ _overrideAttrs = attrs.Where(a => !a.IsLocked).ToList(); var existingOverrides = await TemplateEngineRepository.GetOverridesByInstanceIdAsync(Id); foreach (var o in existingOverrides) + { _overrideValues[o.AttributeName] = o.OverrideValue; + _existingOverrides[o.AttributeName] = o; + } + + // MV-14: seed the per-List working rows. A List attribute's editor is + // initialized from the effective value — the existing override JSON if + // present, otherwise the template default — decoded into string rows + // using the element type fixed by the base attribute. A malformed + // stored value falls back to empty rows (the editor still opens). + foreach (var attr in _overrideAttrs.Where(a => a.DataType == DataType.List)) + { + var effective = _existingOverrides.TryGetValue(attr.Name, out var ovr) + ? ovr.OverrideValue + : attr.Value; + _listRows[attr.Name] = DecodeListRows(effective, attr.ElementDataType); + } // Alarm overrides — load all non-locked template alarms and // existing override rows. Pre-seed the dirty maps from existing @@ -805,15 +870,123 @@ else _overrideValues[attrName] = val; } + // ── MV-14: structured List (multi-value) overrides ────────── + + /// Working rows for a List attribute's override (whole-list replacement). + private List GetListRows(string attrName) + => _listRows.TryGetValue(attrName, out var rows) ? rows : (_listRows[attrName] = new()); + + private void OnListRowsChanged(string attrName, List rows) + { + _listRows[attrName] = rows; + // A fresh edit clears any stale validation error for this attribute. + _overrideErrors.Remove(attrName); + } + + /// True if a persisted override row exists for the attribute (so Clear is offered). + private bool HasOverrideRow(string attrName) => _existingOverrides.ContainsKey(attrName); + + /// + /// Decodes a stored List JSON value into editable string rows using the + /// element type fixed by the base attribute. A malformed stored value (e.g. + /// hand-edited or an element-type mismatch) falls back to empty rows rather + /// than crashing the editor — mirrors TemplateEdit.DecodeListRows. + /// + private static List DecodeListRows(string? value, DataType? elementType) + { + if (string.IsNullOrEmpty(value)) return new(); + try + { + var decoded = AttributeValueCodec.Decode(value, DataType.List, elementType ?? DataType.String); + if (decoded is System.Collections.IEnumerable items) + return items.Cast() + .Select(x => AttributeValueCodec.Encode(x) ?? string.Empty) + .ToList(); + } + catch (FormatException) + { + // Malformed stored value — start from empty so the editor still opens. + } + return new(); + } + + /// + /// Removes a List attribute's override row entirely (repository-direct, the + /// same pattern as native-alarm-source overrides) and resets the editor to + /// the inherited template value. + /// + private async Task ClearListOverride(string attrName) + { + _saving = true; + try + { + if (_existingOverrides.TryGetValue(attrName, out var ovr)) + { + await TemplateEngineRepository.DeleteInstanceAttributeOverrideAsync(ovr.Id); + await TemplateEngineRepository.SaveChangesAsync(); + _existingOverrides.Remove(attrName); + } + _overrideValues.Remove(attrName); + _overrideErrors.Remove(attrName); + + // Reset the editor to the inherited template default. + var attr = _overrideAttrs.FirstOrDefault(a => a.Name == attrName); + _listRows[attrName] = DecodeListRows(attr?.Value, attr?.ElementDataType); + _toast.ShowSuccess($"Cleared override on '{attrName}'."); + } + catch (Exception ex) + { + _toast.ShowError($"Clear failed: {ex.Message}"); + } + _saving = false; + } + private async Task SaveOverrides() { _saving = true; try { + _overrideErrors.Clear(); var user = await GetCurrentUserAsync(); - foreach (var (attrName, value) in _overrideValues) - await InstanceService.SetAttributeOverrideAsync(Id, attrName, value, user); - _toast.ShowSuccess($"Saved {_overrideValues.Count} override(s)."); + + // Build the set of override values to persist. Scalars come straight + // from the single-input map (unchanged). List attributes encode their + // working rows to canonical JSON; each is round-tripped through Decode + // first to surface any un-parseable element (mirrors TemplateEdit) — + // an invalid element aborts the whole save and is shown inline. + var toSave = new Dictionary(_overrideValues); + var listAttrs = _overrideAttrs.Where(a => a.DataType == DataType.List).ToList(); + var hasError = false; + foreach (var attr in listAttrs) + { + var elementType = attr.ElementDataType ?? DataType.String; + var json = AttributeValueCodec.Encode(GetListRows(attr.Name)); + try { AttributeValueCodec.Decode(json, DataType.List, elementType); } + catch (FormatException ex) { _overrideErrors[attr.Name] = ex.Message; hasError = true; continue; } + toSave[attr.Name] = json; + } + + if (hasError) + { + _toast.ShowError("Some List overrides have invalid elements — see the highlighted rows."); + _saving = false; + return; + } + + var failures = new List(); + foreach (var (attrName, value) in toSave) + { + var result = await InstanceService.SetAttributeOverrideAsync(Id, attrName, value, user); + if (result.IsSuccess) + _existingOverrides[attrName] = result.Value!; + else + failures.Add($"{attrName}: {result.Error}"); + } + if (failures.Count > 0) + _toast.ShowError($"Failed to save {failures.Count} override(s): {string.Join("; ", failures)}"); + var savedCount = toSave.Count - failures.Count; + if (savedCount > 0) + _toast.ShowSuccess($"Saved {savedCount} override(s)."); } catch (Exception ex) { diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateEdit.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateEdit.razor index 76ac00d0..24cea2d7 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateEdit.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateEdit.razor @@ -4,6 +4,7 @@ @using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates @using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites @using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories +@using ZB.MOM.WW.ScadaBridge.Commons.Types @using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums @using ZB.MOM.WW.ScadaBridge.TemplateEngine @using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services @@ -80,6 +81,10 @@ private string _attrName = string.Empty; private string? _attrValue; private DataType _attrDataType; + // List-attribute authoring state (DataType.List only): the element scalar + // type + the per-element string rows. Encoded to canonical JSON on submit. + private DataType _attrElementDataType = DataType.String; + private List _attrListRows = new(); private bool _attrIsLocked; private string? _attrDataSourceRef; private string? _attrFormError; @@ -553,17 +558,34 @@
- @foreach (var dt in Enum.GetValues()) { } + @if (editing) + { +
Data type is fixed once the attribute is created.
+ }
-
- - -
+ @if (_attrDataType == DataType.List) + { +
+ @* List VALUE stays editable when editing; only DataType/ElementDataType are server-fixed (ShowElementType hides the type select on edit). *@ + +
+ } + else + { +
+ + +
+ }
@@ -1535,6 +1557,8 @@ _attrName = string.Empty; _attrValue = null; _attrDataType = default; + _attrElementDataType = DataType.String; + _attrListRows = new(); _attrIsLocked = false; _attrDataSourceRef = null; } @@ -1547,6 +1571,8 @@ _attrName = attr.Name; _attrValue = attr.Value; _attrDataType = attr.DataType; + _attrElementDataType = attr.ElementDataType ?? DataType.String; + _attrListRows = DecodeListRows(attr.Value, attr.ElementDataType); _attrIsLocked = attr.IsLocked; _attrDataSourceRef = attr.DataSourceReference; } @@ -1558,12 +1584,72 @@ _attrFormError = null; } + // Switching the data type clears stale list state so a List ⇄ scalar + // toggle never carries the other mode's value into the submit. + private void OnAttrDataTypeChanged(ChangeEventArgs e) + { + if (!Enum.TryParse((string?)e.Value, out var dt) || dt == _attrDataType) return; + _attrDataType = dt; + if (dt == DataType.List) + { + _attrValue = null; + _attrListRows = new(); + if (!AttributeValueCodec.IsValidElementType(_attrElementDataType)) + _attrElementDataType = DataType.String; + } + else + { + _attrListRows = new(); + } + } + + // Decodes a stored List JSON value into editable string rows. A malformed + // stored value (e.g. hand-edited / element-type mismatch) is shown as empty + // rather than crashing the editor — the user can rebuild it. + private List DecodeListRows(string? value, DataType? elementType) + { + if (string.IsNullOrEmpty(value)) return new(); + try + { + var decoded = AttributeValueCodec.Decode(value, DataType.List, elementType ?? DataType.String); + if (decoded is System.Collections.IEnumerable items) + return items.Cast() + .Select(x => AttributeValueCodec.Encode(x) ?? string.Empty) + .ToList(); + } + catch (FormatException) + { + // Malformed stored value — start from empty so the editor still opens. + } + return new(); + } + private async Task SaveAttribute() { if (_selectedTemplate == null) return; _attrFormError = null; if (string.IsNullOrWhiteSpace(_attrName)) { _attrFormError = "Name is required."; return; } + // Resolve the value + element type per data type. List attributes encode + // their rows to canonical JSON and validate them locally before submit + // (TemplateService persists directly and does not list-validate). + string? attrValue; + DataType? elementType; + if (_attrDataType == DataType.List) + { + elementType = _attrElementDataType; + attrValue = AttributeValueCodec.Encode(_attrListRows); + // Round-trip through Decode to surface any un-parseable element + // (e.g. non-numeric in an Int32 list) before hitting the server. + try { AttributeValueCodec.Decode(attrValue, DataType.List, elementType); } + catch (FormatException ex) { _attrFormError = ex.Message; return; } + } + else + { + elementType = null; + attrValue = _attrValue?.Trim(); + } + var user = await GetCurrentUserAsync(); if (_editAttrId is int id) @@ -1573,7 +1659,8 @@ var proposed = new TemplateAttribute(existing.Name) { DataType = _attrDataType, - Value = _attrValue?.Trim(), + ElementDataType = elementType, + Value = attrValue, IsLocked = _attrIsLocked, DataSourceReference = _attrDataSourceRef?.Trim(), Description = existing.Description, @@ -1598,7 +1685,8 @@ var attr = new TemplateAttribute(_attrName.Trim()) { DataType = _attrDataType, - Value = _attrValue?.Trim(), + ElementDataType = elementType, + Value = attrValue, IsLocked = _attrIsLocked, DataSourceReference = _attrDataSourceRef?.Trim() }; diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/AttributeListEditor.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/AttributeListEditor.razor new file mode 100644 index 00000000..34e0dd26 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/AttributeListEditor.razor @@ -0,0 +1,130 @@ +@using ZB.MOM.WW.ScadaBridge.Commons.Types +@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums + +@* + Repeatable list-value editor for a structured multi-value (List) attribute. + Reveals an element-type when the + element type is fixed (e.g. an + instance override inherits it). + - Disabled (default false) : render read-only. + Both ElementDataTypeChanged and RowsChanged fire on every edit. +*@ + +
+ @if (ShowElementType) + { +
+ + +
+ } + + + @if (Rows.Count == 0) + { +

No elements. Use “Add element” to add one.

+ } + else + { +
+ @for (var i = 0; i < Rows.Count; i++) + { + var index = i; +
+ @index + + @if (!Disabled) + { + + } +
+ } +
+ } + + @if (!Disabled) + { + + } +
+ +@code { + /// The chosen element scalar type. Two-way bound. + [Parameter] public DataType ElementDataType { get; set; } = DataType.String; + [Parameter] public EventCallback ElementDataTypeChanged { get; set; } + + /// The per-element string values. Two-way bound. + [Parameter] public List Rows { get; set; } = new(); + [Parameter] public EventCallback> RowsChanged { get; set; } + + /// + /// When false, the element-type
Attribute Type Template ValueOverride ValueOverride Value
@attr.Name@attr.DataType + @attr.DataType + @if (attr.DataType == DataType.List) + { + @* Element type is fixed by the base attribute — shown + read-only here (the List editor renders it hidden via + ShowElementType="false"). *@ + + of @(attr.ElementDataType ?? DataType.String) + + } + @(attr.Value ?? "—") - + @if (attr.DataType == DataType.List) + { + @* Whole-list replacement: the shared editor renders the + element-type select hidden (fixed by the base) plus the + repeatable rows. Clearing removes the override row. *@ + + @if (_overrideErrors.TryGetValue(attr.Name, out var listErr)) + { +
@listErr
+ } + @if (HasOverrideRow(attr.Name)) + { + + } + } + else + { + + }