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.
This commit is contained in:
@@ -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 `<DynamicComponent>` 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 `<InputSelect>` 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<IReadOnlyList<(string DriverInstanceId, string Display, string DriverType)>>`.
|
||||
- `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;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for per-driver tag-config editors: parse a TagConfig JSON string into a mutable
|
||||
/// <see cref="JsonObject"/> (preserving every key, so fields the editor doesn't expose survive a
|
||||
/// load→save), read typed scalars, and serialise back.
|
||||
/// </summary>
|
||||
public static class TagConfigJson
|
||||
{
|
||||
private static readonly JsonSerializerOptions Compact = new() { WriteIndented = false };
|
||||
|
||||
/// <summary>Parses <paramref name="json"/> into a mutable object; returns a fresh empty object on null/blank/malformed/non-object input.</summary>
|
||||
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(); }
|
||||
}
|
||||
|
||||
/// <summary>Serialises the object to compact JSON.</summary>
|
||||
public static string Serialize(JsonObject obj) => obj.ToJsonString(Compact);
|
||||
|
||||
/// <summary>Reads a string value, or null if absent/null.</summary>
|
||||
public static string? GetString(JsonObject o, string name)
|
||||
=> o.TryGetPropertyValue(name, out var n) && n is not null ? n.GetValue<string>() : null;
|
||||
|
||||
/// <summary>Reads an int value, or <paramref name="fallback"/> if absent/null/non-numeric.</summary>
|
||||
public static int GetInt(JsonObject o, string name, int fallback = 0)
|
||||
=> o.TryGetPropertyValue(name, out var n) && n is not null && n.AsValue().TryGetValue<int>(out var v) ? v : fallback;
|
||||
|
||||
/// <summary>Reads an enum by its serialised name, or <paramref name="fallback"/> if absent/unparseable.</summary>
|
||||
public static TEnum GetEnum<TEnum>(JsonObject o, string name, TEnum fallback) where TEnum : struct, Enum
|
||||
=> GetString(o, name) is { } s && Enum.TryParse<TEnum>(s, ignoreCase: true, out var v) ? v : fallback;
|
||||
|
||||
/// <summary>Sets a string/number/enum-name value (enums via ToString()). Null removes the key.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a driver's <c>DriverType</c> string to its typed tag-config editor component (mirrors
|
||||
/// <c>DriverEditRouter._componentMap</c>). Drivers absent from the map fall back to the generic
|
||||
/// raw-JSON textarea in the TagModal.
|
||||
/// </summary>
|
||||
public static class TagConfigEditorMap
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, Type> Map =
|
||||
new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// Editors registered by later tasks, e.g.:
|
||||
// ["ModbusTcp"] = typeof(Components.Shared.Uns.TagEditors.ModbusTagConfigEditor),
|
||||
};
|
||||
|
||||
/// <summary>Returns the editor component type for a driver type, or null if none is registered.</summary>
|
||||
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 `<label> + <InputTextArea @bind-Value="_form.TagConfig" …>` + form-text + `ValidationMessage`) with:
|
||||
|
||||
```razor
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Tag config</label>
|
||||
@{
|
||||
var editorType = TagConfigEditorMap.Resolve(SelectedDriverType);
|
||||
}
|
||||
@if (string.IsNullOrEmpty(_form.DriverInstanceId))
|
||||
{
|
||||
<div class="form-text">Pick a driver above to configure this tag.</div>
|
||||
}
|
||||
else if (editorType is not null)
|
||||
{
|
||||
<DynamicComponent Type="editorType" Parameters="BuildEditorParameters()" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<InputTextArea id="tag-config" @bind-Value="_form.TagConfig" rows="6"
|
||||
class="form-control form-control-sm mono"
|
||||
placeholder='{ "register": 40001, "scale": 0.1 }' />
|
||||
<div class="form-text">Schemaless per driver type. Validated server-side at deploy.</div>
|
||||
}
|
||||
<ValidationMessage For="@(() => _form.TagConfig)" />
|
||||
</div>
|
||||
```
|
||||
|
||||
Add to `TagModal.razor` `@code` (and `@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors`):
|
||||
|
||||
```csharp
|
||||
// The DriverType of the currently-selected driver (drives editor dispatch). Null when no driver chosen.
|
||||
private string? SelectedDriverType =>
|
||||
Drivers.FirstOrDefault(d => d.Id == _form.DriverInstanceId).DriverType;
|
||||
|
||||
private IDictionary<string, object> BuildEditorParameters() => new Dictionary<string, object>
|
||||
{
|
||||
["ConfigJson"] = _form.TagConfig,
|
||||
["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _form.TagConfig = v),
|
||||
};
|
||||
```
|
||||
|
||||
> `Drivers.FirstOrDefault(...)` returns a default tuple (all-null) when nothing matches, so `.DriverType` is null — `Resolve(null)` returns null → generic textarea. Good.
|
||||
|
||||
**Step 4 — `TagConfigJsonTests.cs`:** `ParseOrNew` on null/""/"{bad"/"[1,2]" → empty object; round-trip preserves unknown keys (`{"foo":"bar","region":"X"}` → set `region` → still has `foo`); `GetEnum` parses by name + falls back; `Set` writes enum as its name string.
|
||||
|
||||
**Step 5 — Build + test** (216 + new pass; UI unchanged because the map is empty). **Commit** by path.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Modbus tag editor (the worked example)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (establishes the pattern Tasks 4–8 copy)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/ModbusTagConfigModel.cs`
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/ModbusTagConfigEditor.razor`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs` (register `ModbusTcp`)
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusTagConfigModelTests.cs`
|
||||
|
||||
**Reference (read these, do not edit):** `ModbusDriverPage.razor:286-313` (the EditTemplate fields) and `:481-532` (`ModbusTagRow`); `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs:282-319` (`ModbusTagDto` — the canonical JSON names). Enums (`ModbusRegion`, `ModbusDataType`, `ModbusByteOrder`) are in namespace `ZB.MOM.WW.OtOpcUa.Driver.Modbus` (the driver page `@using`es it; the AdminUI csproj already references the Modbus contracts — confirm the `@using`/reference compiles).
|
||||
|
||||
**Fields the editor owns (→ TagConfig JSON, camelCase names):**
|
||||
|
||||
| UI field | Model type | JSON name | Default |
|
||||
|---|---|---|---|
|
||||
| Region | `ModbusRegion` | `region` | `HoldingRegisters` |
|
||||
| Address | `int` | `address` | `0` |
|
||||
| Data type (wire) | `ModbusDataType` | `dataType` | `Int16` |
|
||||
| Byte order | `ModbusByteOrder` | `byteOrder` | `BigEndian` |
|
||||
| Bit index | `int` | `bitIndex` | `0` |
|
||||
| String length | `int` | `stringLength` | `0` |
|
||||
|
||||
Excluded (owned by TagModal/Tag columns): `name` (Tag.Name), `writable`/`writeIdempotent` (AccessLevel/WriteIdempotent). Enum values serialise as their NAME (e.g. `"HoldingRegisters"`) to match `ModbusTagDto` (its `Region`/`DataType`/`ByteOrder` are `string?`).
|
||||
|
||||
**Step 1 — Write `ModbusTagConfigModelTests.cs` (failing):**
|
||||
- `FromJson("{}")` → defaults (Region `HoldingRegisters`, DataType `Int16`, ByteOrder `BigEndian`, Address 0).
|
||||
- Round-trip: `FromJson(m.ToJson())` equals `m` for a populated model.
|
||||
- `ToJson()` emits exactly the 6 camelCase keys with enum NAMES (assert the JSON string contains `"region":"HoldingRegisters"` etc.).
|
||||
- **Preserve unknown:** `FromJson("""{"region":"InputRegisters","addressString":"40001:F"}""").ToJson()` still contains `addressString` (the editor must not drop keys it doesn't expose).
|
||||
- `Validate()` returns null for a default model (Modbus needs no required free-text — address defaults 0). (If you add a rule, test it.)
|
||||
|
||||
**Step 2 — Run; expect compile failure.**
|
||||
|
||||
**Step 3 — Implement `ModbusTagConfigModel.cs`:** hold the 6 typed properties + a private `JsonObject _bag` (the parsed source, for key preservation). `FromJson(string?)` → `TagConfigJson.ParseOrNew`, read the 6 fields via `TagConfigJson.GetEnum/GetInt`, stash the bag. `ToJson()` → `TagConfigJson.Set` the 6 fields onto the bag, then `TagConfigJson.Serialize(bag)`. `Validate()` → null (or required-field checks). Example:
|
||||
|
||||
```csharp
|
||||
using System.Text.Json.Nodes;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
public sealed class ModbusTagConfigModel
|
||||
{
|
||||
public ModbusRegion Region { get; set; } = ModbusRegion.HoldingRegisters;
|
||||
public int Address { get; set; }
|
||||
public ModbusDataType DataType { get; set; } = ModbusDataType.Int16;
|
||||
public ModbusByteOrder ByteOrder { get; set; } = ModbusByteOrder.BigEndian;
|
||||
public int BitIndex { get; set; }
|
||||
public int StringLength { get; set; }
|
||||
|
||||
private JsonObject _bag = new();
|
||||
|
||||
public static ModbusTagConfigModel FromJson(string? json)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
return new ModbusTagConfigModel
|
||||
{
|
||||
Region = TagConfigJson.GetEnum(o, "region", ModbusRegion.HoldingRegisters),
|
||||
Address = TagConfigJson.GetInt(o, "address"),
|
||||
DataType = TagConfigJson.GetEnum(o, "dataType", ModbusDataType.Int16),
|
||||
ByteOrder = TagConfigJson.GetEnum(o, "byteOrder", ModbusByteOrder.BigEndian),
|
||||
BitIndex = TagConfigJson.GetInt(o, "bitIndex"),
|
||||
StringLength = TagConfigJson.GetInt(o, "stringLength"),
|
||||
_bag = o,
|
||||
};
|
||||
}
|
||||
|
||||
public string ToJson()
|
||||
{
|
||||
TagConfigJson.Set(_bag, "region", Region);
|
||||
TagConfigJson.Set(_bag, "address", Address);
|
||||
TagConfigJson.Set(_bag, "dataType", DataType);
|
||||
TagConfigJson.Set(_bag, "byteOrder", ByteOrder);
|
||||
TagConfigJson.Set(_bag, "bitIndex", BitIndex);
|
||||
TagConfigJson.Set(_bag, "stringLength", StringLength);
|
||||
return TagConfigJson.Serialize(_bag);
|
||||
}
|
||||
|
||||
public string? Validate() => null;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4 — Implement `ModbusTagConfigEditor.razor`** — the thin shell. Parse on `OnParametersSet`, bind fields, raise `ConfigJsonChanged` on change:
|
||||
|
||||
```razor
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.Modbus
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4"><label class="form-label">Region</label>
|
||||
<select class="form-select form-select-sm" value="@_m.Region" @onchange="@(e => Update(() => _m.Region = Enum.Parse<ModbusRegion>(e.Value!.ToString()!)))">
|
||||
@foreach (var v in Enum.GetValues<ModbusRegion>()) { <option value="@v">@v</option> }
|
||||
</select></div>
|
||||
<div class="col-md-2"><label class="form-label">Address</label>
|
||||
<input type="number" class="form-control form-control-sm" value="@_m.Address" @onchange="@(e => Update(() => _m.Address = ParseInt(e.Value)))" /></div>
|
||||
<div class="col-md-3"><label class="form-label">Data type</label>
|
||||
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => Update(() => _m.DataType = Enum.Parse<ModbusDataType>(e.Value!.ToString()!)))">
|
||||
@foreach (var v in Enum.GetValues<ModbusDataType>()) { <option value="@v">@v</option> }
|
||||
</select></div>
|
||||
<div class="col-md-3"><label class="form-label">Byte order</label>
|
||||
<select class="form-select form-select-sm" value="@_m.ByteOrder" @onchange="@(e => Update(() => _m.ByteOrder = Enum.Parse<ModbusByteOrder>(e.Value!.ToString()!)))">
|
||||
@foreach (var v in Enum.GetValues<ModbusByteOrder>()) { <option value="@v">@v</option> }
|
||||
</select></div>
|
||||
<div class="col-md-2"><label class="form-label">Bit index</label>
|
||||
<input type="number" class="form-control form-control-sm" value="@_m.BitIndex" @onchange="@(e => Update(() => _m.BitIndex = ParseInt(e.Value)))" /></div>
|
||||
<div class="col-md-2"><label class="form-label">String len</label>
|
||||
<input type="number" class="form-control form-control-sm" value="@_m.StringLength" @onchange="@(e => Update(() => _m.StringLength = ParseInt(e.Value)))" /></div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string? ConfigJson { get; set; }
|
||||
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
||||
|
||||
private ModbusTagConfigModel _m = new();
|
||||
|
||||
protected override void OnParametersSet() => _m = ModbusTagConfigModel.FromJson(ConfigJson);
|
||||
|
||||
private static int ParseInt(object? v) => int.TryParse(v?.ToString(), out var i) ? i : 0;
|
||||
|
||||
private async Task Update(Action apply)
|
||||
{
|
||||
apply();
|
||||
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Use plain `<select>`/`<input>` + `@onchange` (not `@bind`) so each edit immediately pushes the serialised JSON up via `ConfigJsonChanged` — that's what the TagModal's `_form.TagConfig` binds to. (DynamicComponent can't forward `@bind-Value`, so we drive it through the `ConfigJsonChanged` callback explicitly.)
|
||||
|
||||
**Step 5 — Register Modbus** in `TagConfigEditorMap.Map`: `["ModbusTcp"] = typeof(Components.Shared.Uns.TagEditors.ModbusTagConfigEditor),`. **Build + test. Commit** by path.
|
||||
|
||||
---
|
||||
|
||||
### Tasks 4–8: S7 / AbCip / AbLegacy / TwinCAT / Focas tag editors
|
||||
|
||||
Each of these is the SAME shape as Task 3 (model + razor shell + tests), parameterised by the driver's fields below. **Do NOT edit `TagConfigEditorMap.cs`** (Task 9 registers all five at once, so these tasks stay parallel-safe). Reuse `TagConfigJson` and copy the Modbus model/editor/test structure exactly. All five drivers' enums live in the driver namespace listed; all serialise enum values as their NAME; all preserve unknown keys; all exclude `name`/`writable`/`writeIdempotent`.
|
||||
|
||||
Each task's **Files:** Create `Uns/TagEditors/<Driver>TagConfigModel.cs` + `Components/Shared/Uns/TagEditors/<Driver>TagConfigEditor.razor`; Test `tests/.../Uns/<Driver>TagConfigModelTests.cs`. Each task's tests mirror Task 3's (defaults, round-trip, JSON-name correctness, preserve-unknown, validate). Build + test + commit per task.
|
||||
|
||||
#### Task 4: S7
|
||||
**Classification:** standard · **~4 min** · **Parallelizable with:** Tasks 5, 6, 7, 8
|
||||
- `@using ZB.MOM.WW.OtOpcUa.Driver.S7`. Read `S7DriverPage.razor:339-381` (`S7TagRow`) + the S7 tag DTO in `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/`.
|
||||
- Fields: `address` (string, required — `Validate` returns "Address is required." when blank), `dataType` (`S7DataType` enum, default the enum's first/`Int16`-equivalent — confirm against the enum), `stringLength` (int).
|
||||
- Excluded: name, writable, writeIdempotent.
|
||||
|
||||
#### Task 5: AbCip
|
||||
**Classification:** standard · **~4 min** · **Parallelizable with:** Tasks 4, 6, 7, 8
|
||||
- `@using ZB.MOM.WW.OtOpcUa.Driver.AbCip`. Read `AbCipDriverPage.razor:425-467` + the AbCip tag DTO.
|
||||
- Fields: `deviceHostAddress` (string), `tagPath` (string, required), `dataType` (`AbCipDataType` enum). (Do NOT add the `members`/`safetyTag` advanced fields — the driver page's tag EditTemplate doesn't expose them; preserve-unknown keeps any that exist.)
|
||||
- Excluded: name, writable, writeIdempotent.
|
||||
|
||||
#### Task 6: AbLegacy
|
||||
**Classification:** standard · **~4 min** · **Parallelizable with:** Tasks 4, 5, 7, 8
|
||||
- `@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy`. Read `AbLegacyDriverPage.razor:391-433` + the AbLegacy tag DTO.
|
||||
- Fields: `deviceHostAddress` (string), `address` (string, required — PCCC file address e.g. `N7:0`), `dataType` (`AbLegacyDataType` enum).
|
||||
- Excluded: name, writable, writeIdempotent.
|
||||
|
||||
#### Task 7: TwinCAT
|
||||
**Classification:** standard · **~4 min** · **Parallelizable with:** Tasks 4, 5, 6, 8
|
||||
- `@using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT`. Read `TwinCATDriverPage.razor:395-437` + the TwinCAT tag DTO.
|
||||
- Fields: `deviceHostAddress` (string), `symbolPath` (string, required — e.g. `MAIN.bStart`), `dataType` (`TwinCATDataType` enum).
|
||||
- Excluded: name, writable, writeIdempotent.
|
||||
|
||||
#### Task 8: Focas
|
||||
**Classification:** standard · **~4 min** · **Parallelizable with:** Tasks 4, 5, 6, 7
|
||||
- `@using ZB.MOM.WW.OtOpcUa.Driver.FOCAS`. Read `FocasDriverPage.razor:457-499` + the Focas tag DTO.
|
||||
- Fields: `deviceHostAddress` (string), `address` (string, required — e.g. `R100`, `MACRO:500`), `dataType` (`FocasDataType` enum).
|
||||
- Excluded: name, writable, writeIdempotent.
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Register the five editors in TagConfigEditorMap
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~2 min
|
||||
**Parallelizable with:** none (depends on Tasks 4–8)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs`
|
||||
|
||||
Add the five entries (Modbus already there from Task 3):
|
||||
|
||||
```csharp
|
||||
["S7"] = typeof(Components.Shared.Uns.TagEditors.S7TagConfigEditor),
|
||||
["AbCip"] = typeof(Components.Shared.Uns.TagEditors.AbCipTagConfigEditor),
|
||||
["AbLegacy"] = typeof(Components.Shared.Uns.TagEditors.AbLegacyTagConfigEditor),
|
||||
["TwinCat"] = typeof(Components.Shared.Uns.TagEditors.TwinCATTagConfigEditor),
|
||||
["Focas"] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor),
|
||||
```
|
||||
|
||||
Build + run full `AdminUI.Tests` suite (all editor-model tests + 216 baseline). Commit by path.
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Live-verify in docker-dev
|
||||
|
||||
**Classification:** standard (verification — no production code)
|
||||
**Estimated implement time:** ~6 min (interactive; allowed to exceed the 5-min guidance because it's a manual run, not a code edit)
|
||||
**Parallelizable with:** none (depends on Task 9)
|
||||
|
||||
**Context:** The docker-dev Main cluster has only the Galaxy driver (no typed editor), so a typed editor can't be exercised until a non-Galaxy driver exists. Galaxy lives in a `SystemPlatform` namespace; a Modbus driver needs an **Equipment-kind** namespace (decision #110) so equipment can bind to it.
|
||||
|
||||
**Steps (no repo change unless a step says so):**
|
||||
1. Ensure the rig is up and central runs the latest master (rebuild central per `docs/v2/dev-environment.md` / the project's docker-dev recipe if needed). Sign-in is the user's action (agent must not enter the password).
|
||||
2. In AdminUI: confirm the Main cluster has an **Equipment-kind** namespace; if not, create one (`/clusters/MAIN/namespaces` → New, Kind=Equipment). Then create a **Modbus** driver instance in that namespace (`/clusters/MAIN/drivers` → New driver → Modbus), endpoint can be the docker host `10.100.0.35:5020` (the rig's Modbus sim) or any placeholder — it isn't connected for this UI check.
|
||||
3. In `/uns`: pick an equipment, **Edit** it, bind it to the new Modbus driver, Save. Then **+ Tag** on that equipment → confirm the driver dropdown lists the Modbus driver, select it, and confirm the **Modbus typed fields** (Region / Address / Data type / Byte order / Bit index / String len) render instead of the JSON textarea. Fill Name + a driver + the fields, Create.
|
||||
4. Verify persistence: query `dbo.Tag` (via the sql container, as in the cluster-seed verification) for the new tag's `TagConfig` and confirm it's the structured JSON (`{"region":"…","address":…,"dataType":"…",…}`).
|
||||
5. Spot-check **AbCip**: temporarily add an AbCip driver + bind, open + Tag, confirm AbCip typed fields render. (Tear down the temp AbCip driver after.)
|
||||
6. Confirm **fallback**: a Galaxy-bound equipment's + Tag still shows the generic JSON textarea (no typed editor for `GalaxyMxGateway`).
|
||||
7. Record the outcome; if any editor mis-renders, that's a bug to fix before marking #135 done (per the no-bUnit live-verify rule — `dotnet test` green is necessary but not sufficient for AdminUI razor).
|
||||
|
||||
**Done gate for the whole feature:** build clean + full `AdminUI.Tests` green + the live verify above passes.
|
||||
|
||||
---
|
||||
|
||||
## Notes for the executor
|
||||
|
||||
- **Parallel dispatch:** Tasks 4–8 are mutually parallelizable (disjoint files; the shared map is untouched until Task 9). Dispatch their implementers concurrently.
|
||||
- **Serial spine:** 1 → 2 → 3 → {4,5,6,7,8} → 9 → 10.
|
||||
- **No bUnit:** every razor editor's logic lives in its `*Model` helper so it's unit-tested; the razor shell itself is only proven by Task 10's live run.
|
||||
- **Preserve-unknown** is a real requirement, not a nicety: a tag may carry config keys the editor doesn't surface (e.g. Modbus `addressString`, AbCip `members`); the model must round-trip them.
|
||||
- **Known limitation (out of scope):** for Modbus, an existing `addressString` takes runtime precedence over the structured fields; this editor edits the structured fields and preserves `addressString` but does not surface it. A future follow-up could add an addressString field.
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-09-driver-typed-tag-editors.md",
|
||||
"tasks": [
|
||||
{"id": 1, "nativeId": 146, "subject": "Task 1: Surface DriverType to TagModal driver dropdown", "classification": "standard", "status": "pending"},
|
||||
{"id": 2, "nativeId": 147, "subject": "Task 2: TagConfigJson util + EditorMap + TagModal dispatch scaffold", "classification": "standard", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "nativeId": 148, "subject": "Task 3: Modbus tag editor (worked example)", "classification": "standard", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 4, "nativeId": 149, "subject": "Task 4: S7 tag editor", "classification": "standard", "status": "pending", "blockedBy": [3], "parallelizableWith": [5, 6, 7, 8]},
|
||||
{"id": 5, "nativeId": 150, "subject": "Task 5: AbCip tag editor", "classification": "standard", "status": "pending", "blockedBy": [3], "parallelizableWith": [4, 6, 7, 8]},
|
||||
{"id": 6, "nativeId": 151, "subject": "Task 6: AbLegacy tag editor", "classification": "standard", "status": "pending", "blockedBy": [3], "parallelizableWith": [4, 5, 7, 8]},
|
||||
{"id": 7, "nativeId": 152, "subject": "Task 7: TwinCAT tag editor", "classification": "standard", "status": "pending", "blockedBy": [3], "parallelizableWith": [4, 5, 6, 8]},
|
||||
{"id": 8, "nativeId": 153, "subject": "Task 8: Focas tag editor", "classification": "standard", "status": "pending", "blockedBy": [3], "parallelizableWith": [4, 5, 6, 7]},
|
||||
{"id": 9, "nativeId": 154, "subject": "Task 9: Register the 5 editors in TagConfigEditorMap", "classification": "small", "status": "pending", "blockedBy": [4, 5, 6, 7, 8]},
|
||||
{"id": 10, "nativeId": 155, "subject": "Task 10: Live-verify typed editors in docker-dev", "classification": "verification", "status": "pending", "blockedBy": [9]}
|
||||
],
|
||||
"lastUpdated": "2026-06-09"
|
||||
}
|
||||
Reference in New Issue
Block a user