diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/SqlTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/SqlTagConfigEditor.razor
index a0016cff..6a148034 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/SqlTagConfigEditor.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/SqlTagConfigEditor.razor
@@ -1,12 +1,90 @@
-@* Typed TagConfig editor for the read-only Sql driver. MINIMAL PLACEHOLDER (Task 19) — this shell only
- wires the standard editor parameter contract and round-trips the config through SqlTagConfigModel so no
- edit is lost while the full UI is pending. Task 20 replaces the body with the model/table/column fields
- (KeyValue vs WideRow row-selector). Dispatched from the TagModal via TagConfigEditorMap, so it takes the
- same (ConfigJson/ConfigJsonChanged/DriverType/GetDriverConfigJson) parameter shape every typed editor
- takes; DriverType/GetDriverConfigJson are accepted for dispatch uniformity. *@
+@* Typed TagConfig editor for the read-only Sql driver. Thin shell over SqlTagConfigModel: every field edit
+ goes model → ToJson → ConfigJsonChanged (the model is the single source of the blob shape + enum-name
+ serialisation; never hand-composed here). The Model dropdown (KeyValue/WideRow) shows the matching field
+ group; the "Build address" picker is the ASSISTED path — every field is also hand-editable. Dispatched
+ from the TagModal via TagConfigEditorMap with the same (ConfigJson/ConfigJsonChanged/DriverType/
+ GetDriverConfigJson) parameter shape every typed editor takes; validation is surfaced centrally by
+ TagConfigValidator (save-gate), exactly as the sibling editors do — no inline validation here. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
+@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
+@using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts
+@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
+@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
-
Source-timestamp column; blank ⇒ the driver stamps the poll time.
+
+
+
+
+
+@if (_showPicker)
+{
+
+
+
+}
@code {
/// The tag's current TagConfig JSON.
@@ -23,13 +101,111 @@
private SqlTagConfigModel _m = new();
private string? _lastConfigJson;
+ private bool _showPicker;
+ private string _pickerAddress = "";
- // Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render can't reset
- // an in-progress edit (mirrors the other typed editors).
+ // Local UI discriminator for the WideRow row selector (where-pair vs newest-by-timestamp). The model
+ // carries no explicit selector "mode" — it is inferred from which selector fields are populated. Kept in
+ // the editor (not the model) because an operator mid-edit may have BOTH selector fields blank, a state
+ // the mode can't be re-derived from; DeriveSelectorMode only overrides it when the model unambiguously
+ // says otherwise, so switching to "Newest" then round-tripping doesn't snap back to "Where".
+ private string _selectorMode = "Where";
+
+ // Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render (Blazor Server
+ // live-status pushes do this) can't reset the user's in-progress edits (mirrors the other typed editors).
protected override void OnParametersSet()
{
if (ConfigJson == _lastConfigJson) { return; }
_lastConfigJson = ConfigJson;
_m = SqlTagConfigModel.FromJson(ConfigJson);
+ DeriveSelectorMode();
+ }
+
+ // Sets the row-selector mode from the model ONLY when the populated fields make it unambiguous; leaves
+ // the operator's current choice untouched when both selector fields are blank (an in-progress edit).
+ private void DeriveSelectorMode()
+ {
+ if (!string.IsNullOrWhiteSpace(_m.RowSelector.TopByTimestamp) && string.IsNullOrWhiteSpace(_m.RowSelector.WhereColumn))
+ {
+ _selectorMode = "Newest";
+ }
+ else if (!string.IsNullOrWhiteSpace(_m.RowSelector.WhereColumn))
+ {
+ _selectorMode = "Where";
+ }
+ }
+
+ // TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back. Reads by
+ // NAME (never an ordinal cast), matching the model's strict name-string serialisation.
+ private static TEnum ParseEnum(object? v, TEnum fallback) where TEnum : struct, Enum
+ => Enum.TryParse(v?.ToString(), out var r) ? r : fallback;
+
+ // "" (the "(infer)" option) ⇒ null; a parseable NAME ⇒ that type; anything else ⇒ null. Enum round-trip
+ // is by name — never an ordinal cast — so the model re-serialises `type` as its strict name string.
+ private static TEnum? ParseNullableEnum(object? v) where TEnum : struct, Enum
+ {
+ var s = v?.ToString();
+ if (string.IsNullOrEmpty(s)) { return null; }
+ return Enum.TryParse(s, out var r) ? r : null;
+ }
+
+ // Identifier fields (table/column names): a blank input means "absent" — store null so ToJson omits the
+ // key. (KeyValue/WhereValue are deliberately NOT run through this: the model treats an empty-string value
+ // as present-and-empty, distinct from absent, so those keep e.Value verbatim.)
+ private static string? NullIfBlank(object? v)
+ {
+ var s = v?.ToString();
+ return string.IsNullOrWhiteSpace(s) ? null : s;
+ }
+
+ private async Task Update(Action apply)
+ {
+ apply();
+ await ConfigJsonChanged.InvokeAsync(_m.ToJson());
+ }
+
+ // The WideRow selector is either a where-pair OR newest-by-timestamp — never both. Clear the fields of
+ // the mode being left so the composed blob carries exactly one selector (a stale where-pair would win
+ // over topByTimestamp at read time, silently overriding the operator's choice).
+ private async Task OnSelectorModeChanged(string? mode)
+ {
+ _selectorMode = mode == "Newest" ? "Newest" : "Where";
+ await Update(() =>
+ {
+ if (_selectorMode == "Newest")
+ {
+ _m.RowSelector.WhereColumn = null;
+ _m.RowSelector.WhereValue = null;
+ }
+ else
+ {
+ _m.RowSelector.TopByTimestamp = null;
+ }
+ });
+ }
+
+ // The Sql picker emits a COMPLETE parser-shaped TagConfig blob (not a single address token like the
+ // other drivers' pickers). Parse it through the model and adopt its mapping fields onto the working
+ // model — mutating _m in place preserves any platform keys already on the tag (e.g. isHistorized) that
+ // the picker never authors.
+ private async Task OnAddressPicked(string blob)
+ {
+ if (string.IsNullOrWhiteSpace(blob)) { return; }
+ var picked = SqlTagConfigModel.FromJson(blob);
+ await Update(() =>
+ {
+ _m.Model = picked.Model;
+ _m.Table = picked.Table;
+ _m.KeyColumn = picked.KeyColumn;
+ _m.KeyValue = picked.KeyValue;
+ _m.ValueColumn = picked.ValueColumn;
+ _m.ColumnName = picked.ColumnName;
+ _m.TimestampColumn = picked.TimestampColumn;
+ _m.Type = picked.Type;
+ _m.RowSelector.WhereColumn = picked.RowSelector.WhereColumn;
+ _m.RowSelector.WhereValue = picked.RowSelector.WhereValue;
+ _m.RowSelector.TopByTimestamp = picked.RowSelector.TopByTimestamp;
+ });
+ DeriveSelectorMode();
}
}