docs(plan): design for driver-typed tag editors in the UNS TagModal (F-uns-1 / #135)

Approved design: 6 new per-driver TagConfig editor components
(Modbus/S7/AbCip/AbLegacy/TwinCAT/Focas) dispatched by the selected driver's
DriverType via a TagConfigEditorMap + DynamicComponent; the 3 unmapped drivers
keep the generic raw-JSON editor; no raw-JSON toggle on typed drivers. Editors
reuse the drivers' enums + JSON property names (not razor markup); driver pages
untouched. Pure FromJson/ToJson/Validate helpers are unit-tested (no bUnit);
live verify needs a non-Galaxy driver added to docker-dev. AdminUI-only, no
data-model change.
This commit is contained in:
Joseph Doherty
2026-06-09 09:03:22 -04:00
parent 157a6571c7
commit 913fea7a3c
@@ -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<string> 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<string, Type>` 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` → `<DynamicComponent Type="…"
Parameters="…">` passing `ConfigJson = _form.TagConfig` and a
`ConfigJsonChanged` `EventCallback<string>` 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`.