# 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`.
