diff --git a/docs/plans/2026-06-09-driver-typed-tag-editors-design.md b/docs/plans/2026-06-09-driver-typed-tag-editors-design.md new file mode 100644 index 00000000..4634dcb0 --- /dev/null +++ b/docs/plans/2026-06-09-driver-typed-tag-editors-design.md @@ -0,0 +1,170 @@ +# Driver-typed Tag Editors in the UNS TagModal — Design + +**Date:** 2026-06-09 +**Task:** #135 (F-uns-1, deferred follow-up from the global-UNS feature) +**Status:** Approved (design); pending implementation plan +**Scope:** AdminUI only. No data-model / migration change. + +## Goal + +Replace the single generic raw-`TagConfig`-JSON textarea in the global UNS +`TagModal` (`Components/Shared/Uns/TagModal.razor`) with **per-driver typed tag +editors** that dispatch on the selected driver's `DriverType`, so an operator +authoring an equipment-bound tag fills in driver-shaped fields +(Modbus register/address/byte-order, S7 address, AbCip tag path, …) instead of +hand-writing JSON. + +This was specified at a high level in the global-UNS design +(`docs/plans/2026-06-08-global-uns-management-design.md`, "TagModal — +driver-typed, mirroring DriverEditRouter"). This document settles the +implementation decisions. + +## Decisions locked during brainstorming + +1. **New UNS editors; reuse contracts, not markup.** Build per-driver tag-config + editor components specifically for the TagModal. They **reuse each driver's + enums** (`ModbusRegion`, `ModbusDataType`, …) and emit the driver's + **camelCase JSON property names**, but do **not** reuse the driver pages' + razor markup. The existing driver edit pages are left untouched (their field + set differs — see §"Field mapping"). Lower risk, contained. +2. **All 6 typed editors this pass.** Modbus, S7, AbCip, AbLegacy, TwinCAT, + Focas each get a typed editor. OpcUaClient, Galaxy (`GalaxyMxGateway`), and + Historian.Wonderware keep the existing generic raw-JSON editor (they have no + tag editor in their driver pages either; Galaxy tags are browse-sourced). +3. **No raw-JSON escape hatch on typed drivers.** Typed drivers render typed + fields only — there is no per-modal "edit raw JSON" toggle. Consequence: each + typed editor must cover the driver's **full curated field set** — the same + fields the driver page's tag `EditTemplate` exposes (minus the fields the + TagModal owns at top level). The generic textarea remains only for the 3 + unmapped drivers. + +## Architecture + +### Components (`Components/Shared/Uns/TagEditors/`) + +One Razor component per typed driver: +`ModbusTagConfigEditor.razor`, `S7TagConfigEditor.razor`, +`AbCipTagConfigEditor.razor`, `AbLegacyTagConfigEditor.razor`, +`TwinCATTagConfigEditor.razor`, `FocasTagConfigEditor.razor`. + +**Component contract (convention, not a C# interface — these are components):** + +``` +[Parameter] public string? ConfigJson { get; set; } +[Parameter] public EventCallback ConfigJsonChanged { get; set; } +``` + +Behaviour: on `OnParametersSet`, parse `ConfigJson` → a typed working model +(falling back to defaults on null/blank/malformed). Render the fields bound to +the model. On any field change, serialize the model → JSON and raise +`ConfigJsonChanged`. The TagModal binds it as `@bind-ConfigJson="_form.TagConfig"`. + +**Pure mapping helpers.** Each editor's JSON↔model mapping lives in a pure, +static, side-effect-free helper (e.g. `ModbusTagConfigModel.FromJson(string?)`, +`.ToJson()`, `.Validate()`), so the (de)serialization and validation are +unit-testable in `AdminUI.Tests` **without bUnit** (which this project lacks). +The razor component is a thin shell over the helper. + +### Dispatch map + +A static `TagConfigEditorMap` — `IReadOnlyDictionary` keyed by +`DriverType` (case-insensitive), mirroring `DriverEditRouter._componentMap`: + +``` +["ModbusTcp"] = typeof(ModbusTagConfigEditor), +["S7"] = typeof(S7TagConfigEditor), +["AbCip"] = typeof(AbCipTagConfigEditor), +["AbLegacy"] = typeof(AbLegacyTagConfigEditor), +["TwinCat"] = typeof(TwinCATTagConfigEditor), +["Focas"] = typeof(FocasTagConfigEditor), +``` + +Drivers not in the map → the generic textarea. (Kept deliberately separate from +`DriverEditRouter._componentMap` to avoid coupling tag editing to driver-page +editing; both are short and stable.) + +### TagModal integration — learning the driver type + +- `IUnsTreeService.LoadTagDriversForEquipmentAsync` changes its return shape from + `(string DriverInstanceId, string Display)` to + `(string DriverInstanceId, string Display, string DriverType)`. (`UnsTreeService` + already loads `DriverInstance`; it just projects `DriverType` too.) +- The TagModal keeps a lookup `DriverInstanceId → DriverType` from those options + and resolves the **currently-selected** driver's type (the driver is chosen in + the modal's existing dropdown and can change while open). +- Render logic in the modal body, where the raw textarea is today: + - no driver selected → a muted "pick a driver to configure this tag" hint; + - selected type in `TagConfigEditorMap` → `` passing `ConfigJson = _form.TagConfig` and a + `ConfigJsonChanged` `EventCallback` that writes back to + `_form.TagConfig`; + - otherwise → the existing raw `InputTextArea` over `_form.TagConfig`. +- When the selected driver changes to a different `DriverType`, the editor swaps; + `_form.TagConfig` carries over (a mismatched blob is simply re-parsed to + defaults by the new editor, and the user re-enters fields). + +## Field mapping (Tag columns vs TagConfig) + +These stay **top-level TagModal fields** (already present): `Tag.Name`, +`Tag.DataType` (the OPC UA type), `Tag.AccessLevel`, `Tag.WriteIdempotent`, +`Tag.PollGroupId`. + +`TagConfig` holds **only the driver-specific addressing/encoding fields** — the +driver page's tag `EditTemplate` fields minus the ones the modal owns: + +| Driver | TagConfig fields (editor) | Excluded (lives elsewhere) | +|---|---|---| +| Modbus (`ModbusTcp`) | region, address, dataType (wire), byteOrder, bitIndex, stringLength | name → Tag.Name; writable → AccessLevel | +| S7 | address, stringLength | name; dataType → Tag.DataType; writable | +| AbCip | deviceHostAddress, tagPath | name; dataType; writable | +| AbLegacy | deviceHostAddress, address | name; dataType; writable | +| TwinCAT | deviceHostAddress, symbolPath | name; dataType; writable | +| Focas | deviceHostAddress, address | name; dataType; writable | + +> Note: a driver's *wire* data type (e.g. `ModbusDataType` = how to decode the +> register) is distinct from `Tag.DataType` (the OPC UA type) and belongs in +> TagConfig; S7/AbCip/… have no separate wire type, so their data type is the +> top-level `Tag.DataType` only. The exact per-driver field list is finalized in +> the plan against each driver's tag DTO property names. + +**Assumption / out of scope:** this assumes the runtime materialises a per-driver +`TagDefinition` for equipment-bound tags from the `Tag` columns + `TagConfig` +JSON. Confirming or wiring that runtime consumption is **not** part of this UI +feature — the editors faithfully capture the driver-shaped fields under the +driver's JSON names so the config is correct when consumed. + +## Validation + +Each editor helper exposes a light `Validate()` (required fields / simple ranges, +e.g. Modbus requires an address, AbCip requires a tag path) returning an error +string or null. The TagModal surfaces it as its existing inline error and blocks +Save. The current "valid JSON" guard (`JsonException`) still protects the generic +textarea path. **Server-side deploy validation remains authoritative.** + +## Testing + +No bUnit in this project, so: + +- **Unit tests** (`AdminUI.Tests`, xUnit + Shouldly): each editor's + `FromJson` / `ToJson` / `Validate` helper — round-trip fidelity, defaults on + null/blank/malformed, property-name correctness (the emitted JSON matches the + driver's DTO names), and validation pass/fail cases. +- **Live verify** (docker-dev `/run`): the rig's Main cluster currently has only + the Galaxy driver (no typed editor), so verifying a typed editor first needs a + **non-Galaxy driver** in the rig. Steps: add a Modbus driver instance to the + Main cluster, bind an equipment to it, open the equipment's `+ Tag` modal → + confirm the Modbus typed fields render, fill them, Save, and inspect the + persisted `TagConfig` JSON. Spot-check a second driver (AbCip) and confirm an + OpcUaClient/Galaxy-bound equipment still shows the generic textarea. + +Gate = build clean + `dotnet test` green + the docker-dev live verify above. + +## Constraints / non-goals + +- AdminUI only; **no** Configuration entity or migration change. +- Driver edit pages are **not** modified (their inline tag editors stay). +- Not changing how the runtime consumes tags; not adding fields the driver pages + don't already expose. +- Git safety: stage by path (never `git add .`), never stage `sql_login.txt` / + `pki/`, never echo the gateway API key into a new tracked file, no force-push / + no `--no-verify`.