From cc53fc8feb68ebc72ea5d578652884e5061778f2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 9 Jun 2026 09:11:02 -0400 Subject: [PATCH] docs(plan): implementation plan for driver-typed tag editors (F-uns-1 / #135) 10-task plan: (1) surface DriverType to the TagModal driver dropdown, (2) shared TagConfigJson util + empty TagConfigEditorMap + DynamicComponent dispatch scaffold, (3) Modbus editor as the worked example, (4-8) S7/AbCip/ AbLegacy/TwinCAT/Focas editors (parallelizable, disjoint files), (9) register the five in the map, (10) docker-dev live verify (needs a non-Galaxy driver in the rig). Each editor = pure FromJson/ToJson/Validate model (unit-tested) + thin razor shell; preserves unknown JSON keys; driver pages untouched. Co-located .tasks.json for resume. --- .../2026-06-09-driver-typed-tag-editors.md | 411 ++++++++++++++++++ ...-09-driver-typed-tag-editors.md.tasks.json | 16 + 2 files changed, 427 insertions(+) create mode 100644 docs/plans/2026-06-09-driver-typed-tag-editors.md create mode 100644 docs/plans/2026-06-09-driver-typed-tag-editors.md.tasks.json diff --git a/docs/plans/2026-06-09-driver-typed-tag-editors.md b/docs/plans/2026-06-09-driver-typed-tag-editors.md new file mode 100644 index 00000000..4950fd45 --- /dev/null +++ b/docs/plans/2026-06-09-driver-typed-tag-editors.md @@ -0,0 +1,411 @@ +# Driver-typed Tag Editors (F-uns-1) 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:** Replace the UNS `TagModal`'s single generic raw-JSON `TagConfig` textarea with per-driver typed editors that dispatch on the selected driver's `DriverType` (typed for Modbus/S7/AbCip/AbLegacy/TwinCAT/Focas; generic textarea for OpcUaClient/Galaxy/Historian.Wonderware). + +**Architecture:** Each typed driver gets a small Razor editor component under `Components/Shared/Uns/TagEditors/` that is a thin shell over a pure, unit-testable model with `FromJson`/`ToJson`/`Validate`. A static `TagConfigEditorMap` (`DriverType → Type`, mirroring `DriverEditRouter._componentMap`) drives a `` dispatch in `TagModal`. The editors reuse the drivers' enums + camelCase JSON property names (driver pages untouched) and preserve unrecognised JSON keys across a load→save. No data-model or migration change. + +**Tech Stack:** .NET 10 Blazor Server (`InteractiveServer`), `System.Text.Json` (incl. `JsonObject`), xUnit + Shouldly (no bUnit), docker-dev rig for live verify. + +**Design doc:** `docs/plans/2026-06-09-driver-typed-tag-editors-design.md` + +**Hard rules (every task):** AdminUI-only, no Configuration entity/migration change; driver pages NOT modified; stage by path (never `git add .`); never stage `sql_login.txt` / `src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`; never echo the gateway API key into a new tracked file; no force-push, no `--no-verify`. + +--- + +### Task 1: Surface DriverType to the TagModal driver dropdown + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none (foundational — Task 2 depends on it) + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs` (the `LoadTagDriversForEquipmentAsync` signature) +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs` (impl ~line 707-734) +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor` (the `_tagModalDriverOptions` field + its two assignment sites in `HandleAddChild` and `HandleEdit`) +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor` (the `Drivers` `[Parameter]` type + the driver `` loop) +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceTagDriversTests.cs` (new; or extend an existing `*Tests.cs` in that folder) + +**Context:** Today `LoadTagDriversForEquipmentAsync` returns `IReadOnlyList<(string DriverInstanceId, string Display)>`. The TagModal needs the driver's `DriverType` to choose an editor. Change the tuple to carry it. The service already loads `DriverInstance` rows — just project `DriverType` too (see `UnsTreeService.cs:725-733`). + +**Step 1 — Write the failing test.** In the new test file, using the in-memory EF pattern from the existing `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/` suites (find one that seeds a cluster + Equipment-namespace driver, e.g. `EquipmentTests`/`TagTests`, and copy its `UnsTreeTestDb` setup): seed an equipment in a cluster that has one Equipment-namespace driver of `DriverType="ModbusTcp"`, call `LoadTagDriversForEquipmentAsync(equipmentId)`, and assert the returned item exposes `DriverType == "ModbusTcp"` (e.g. `result[0].DriverType.ShouldBe("ModbusTcp")`). + +**Step 2 — Run it; expect compile failure** (`DriverType` member doesn't exist on the tuple). +Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter FullyQualifiedName~UnsTreeServiceTagDriversTests` + +**Step 3 — Implement.** +- `IUnsTreeService.cs`: change the return type to `Task>`. +- `UnsTreeService.cs`: in the final projection add `d.DriverType`, and return `(d.DriverInstanceId, Display: $"{d.DriverInstanceId} — {d.Name}", d.DriverType)`. +- `GlobalUns.razor`: change `private IReadOnlyList<(string Id, string Display)> _tagModalDriverOptions = Array.Empty<(string, string)>();` to `(string Id, string Display, string DriverType)` (and the `Array.Empty<(string, string, string)>()` initialiser). The two assignment sites already call `await Svc.LoadTagDriversForEquipmentAsync(...)` so they compile unchanged. +- `TagModal.razor`: change `[Parameter] public IReadOnlyList<(string Id, string Display)> Drivers { get; set; } = Array.Empty<(string, string)>();` to the 3-tuple; the dropdown `@foreach (var (id, display) in Drivers)` becomes `@foreach (var (id, display, _) in Drivers)`. + +**Step 4 — Run tests + build.** +Run: `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj && dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests` +Expected: build clean; new test passes; existing 216 still pass. + +**Step 5 — Commit** (stage the 4 source files + the test file by path). + +--- + +### Task 2: Shared JSON util + TagConfigEditorMap + TagModal dispatch scaffold + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none (Task 3 depends on it) + +**Files:** +- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigJson.cs` (shared parse/serialize util) +- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs` (the dispatch map — starts EMPTY) +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor` (replace the raw `InputTextArea` block with the dispatch) +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigJsonTests.cs` (new) + +**Context:** The shared util gives every editor a round-trip-safe way to read/write known fields while **preserving unrecognised keys** (mirrors how `ModbusTagRow` keeps its `_source` record). The map is the dispatch table; it starts empty so this task is a no-behaviour-change scaffold (everything still falls to the generic textarea). + +**Step 1 — `TagConfigJson.cs`** (full): + +```csharp +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; + +/// +/// Helpers for per-driver tag-config editors: parse a TagConfig JSON string into a mutable +/// (preserving every key, so fields the editor doesn't expose survive a +/// load→save), read typed scalars, and serialise back. +/// +public static class TagConfigJson +{ + private static readonly JsonSerializerOptions Compact = new() { WriteIndented = false }; + + /// Parses into a mutable object; returns a fresh empty object on null/blank/malformed/non-object input. + public static JsonObject ParseOrNew(string? json) + { + if (string.IsNullOrWhiteSpace(json)) { return new JsonObject(); } + try { return JsonNode.Parse(json) as JsonObject ?? new JsonObject(); } + catch (JsonException) { return new JsonObject(); } + } + + /// Serialises the object to compact JSON. + public static string Serialize(JsonObject obj) => obj.ToJsonString(Compact); + + /// Reads a string value, or null if absent/null. + public static string? GetString(JsonObject o, string name) + => o.TryGetPropertyValue(name, out var n) && n is not null ? n.GetValue() : null; + + /// Reads an int value, or if absent/null/non-numeric. + public static int GetInt(JsonObject o, string name, int fallback = 0) + => o.TryGetPropertyValue(name, out var n) && n is not null && n.AsValue().TryGetValue(out var v) ? v : fallback; + + /// Reads an enum by its serialised name, or if absent/unparseable. + public static TEnum GetEnum(JsonObject o, string name, TEnum fallback) where TEnum : struct, Enum + => GetString(o, name) is { } s && Enum.TryParse(s, ignoreCase: true, out var v) ? v : fallback; + + /// Sets a string/number/enum-name value (enums via ToString()). Null removes the key. + public static void Set(JsonObject o, string name, object? value) + => o[name] = value is null ? null : JsonValue.Create(value is Enum e ? e.ToString() : value); +} +``` + +**Step 2 — `TagConfigEditorMap.cs`** (starts empty; editors register here as later tasks land): + +```csharp +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; + +/// +/// Maps a driver's DriverType string to its typed tag-config editor component (mirrors +/// DriverEditRouter._componentMap). Drivers absent from the map fall back to the generic +/// raw-JSON textarea in the TagModal. +/// +public static class TagConfigEditorMap +{ + private static readonly IReadOnlyDictionary Map = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + // Editors registered by later tasks, e.g.: + // ["ModbusTcp"] = typeof(Components.Shared.Uns.TagEditors.ModbusTagConfigEditor), + }; + + /// Returns the editor component type for a driver type, or null if none is registered. + public static Type? Resolve(string? driverType) + => driverType is not null && Map.TryGetValue(driverType, out var t) ? t : null; +} +``` + +> Note on the registration-coupling: the `Map` dictionary literal is the single edit point for registering an editor. Tasks 4–8 deliberately do NOT touch it (so they're parallel-safe); Task 9 registers their five editors in one edit. + +**Step 3 — TagModal dispatch.** In `TagModal.razor`, replace the current `Tag config (JSON)` block (the `