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 -
Sql tag editor — coming soon (Task 20).
+
+
+
+
+
+
+
+
+ +@if (_m.Model == SqlTagModel.KeyValue) +{ +
+
+
+
+ +
Bound as a parameter — never interpolated.
+
+
+
+} +else +{ +
+
+ +
The column this tag reads from the selected row.
+
+
+ @if (_selectorMode == "Where") + { +
+
+
+
+ } + else + { +
+
+ } +
+} + +
+
+ +
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(); } }