feat(sql): SqlTagConfigEditor razor shell

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 15:35:25 -04:00
parent 9b5a96876e
commit be1df2d1e5
@@ -1,12 +1,90 @@
@* Typed TagConfig editor for the read-only Sql driver. MINIMAL PLACEHOLDER (Task 19) — this shell only @* Typed TagConfig editor for the read-only Sql driver. Thin shell over SqlTagConfigModel: every field edit
wires the standard editor parameter contract and round-trips the config through SqlTagConfigModel so no goes model → ToJson → ConfigJsonChanged (the model is the single source of the blob shape + enum-name
edit is lost while the full UI is pending. Task 20 replaces the body with the model/table/column fields serialisation; never hand-composed here). The Model dropdown (KeyValue/WideRow) shows the matching field
(KeyValue vs WideRow row-selector). Dispatched from the TagModal via TagConfigEditorMap, so it takes the group; the "Build address" picker is the ASSISTED path — every field is also hand-editable. Dispatched
same (ConfigJson/ConfigJsonChanged/DriverType/GetDriverConfigJson) parameter shape every typed editor from the TagModal via TagConfigEditorMap with the same (ConfigJson/ConfigJsonChanged/DriverType/
takes; DriverType/GetDriverConfigJson are accepted for dispatch uniformity. *@ 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.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
<div class="text-muted small">Sql tag editor — coming soon (Task 20).</div> <div class="row g-2">
<div class="col-md-4"><label class="form-label">Model</label>
<select class="form-select form-select-sm" value="@_m.Model" @onchange="@(e => Update(() => _m.Model = ParseEnum(e.Value, SqlTagModel.KeyValue)))">
@* Query is deferred (P3) and rejected by the parser/validator — only offer the two v1 models. *@
<option value="@SqlTagModel.KeyValue">KeyValue</option>
<option value="@SqlTagModel.WideRow">WideRow</option>
</select></div>
<div class="col-md-5"><label class="form-label">Table or view</label>
<input class="form-control form-control-sm mono" value="@_m.Table" placeholder="dbo.TagValues" @onchange="@(e => Update(() => _m.Table = NullIfBlank(e.Value)))" /></div>
<div class="col-md-3"><label class="form-label">Type override</label>
<select class="form-select form-select-sm" value="@(_m.Type?.ToString() ?? "")" @onchange="@(e => Update(() => _m.Type = ParseNullableEnum<DriverDataType>(e.Value)))">
<option value="">(infer from column)</option>
@foreach (var v in Enum.GetValues<DriverDataType>()) { <option value="@v">@v</option> }
</select></div>
</div>
@if (_m.Model == SqlTagModel.KeyValue)
{
<div class="row g-2 mt-1">
<div class="col-md-4"><label class="form-label">Key column</label>
<input class="form-control form-control-sm mono" value="@_m.KeyColumn" placeholder="tag_name" @onchange="@(e => Update(() => _m.KeyColumn = NullIfBlank(e.Value)))" /></div>
<div class="col-md-4"><label class="form-label">Key value</label>
<input class="form-control form-control-sm mono" value="@_m.KeyValue" placeholder="Line1.Speed" @onchange="@(e => Update(() => _m.KeyValue = e.Value?.ToString()))" />
<div class="form-text">Bound as a parameter — never interpolated.</div></div>
<div class="col-md-4"><label class="form-label">Value column</label>
<input class="form-control form-control-sm mono" value="@_m.ValueColumn" placeholder="num_value" @onchange="@(e => Update(() => _m.ValueColumn = NullIfBlank(e.Value)))" /></div>
</div>
}
else
{
<div class="row g-2 mt-1">
<div class="col-md-4"><label class="form-label">Value column</label>
<input class="form-control form-control-sm mono" value="@_m.ColumnName" placeholder="oven_temp" @onchange="@(e => Update(() => _m.ColumnName = NullIfBlank(e.Value)))" />
<div class="form-text">The column this tag reads from the selected row.</div></div>
<div class="col-md-4"><label class="form-label">Row selector</label>
<select class="form-select form-select-sm" value="@_selectorMode" @onchange="@(e => OnSelectorModeChanged(e.Value?.ToString()))">
<option value="Where">Where column = value</option>
<option value="Newest">Newest by timestamp</option>
</select></div>
@if (_selectorMode == "Where")
{
<div class="col-md-2"><label class="form-label">Where column</label>
<input class="form-control form-control-sm mono" value="@_m.RowSelector.WhereColumn" placeholder="station_id" @onchange="@(e => Update(() => _m.RowSelector.WhereColumn = NullIfBlank(e.Value)))" /></div>
<div class="col-md-2"><label class="form-label">Where value</label>
<input class="form-control form-control-sm mono" value="@_m.RowSelector.WhereValue" placeholder="7" @onchange="@(e => Update(() => _m.RowSelector.WhereValue = e.Value?.ToString()))" /></div>
}
else
{
<div class="col-md-4"><label class="form-label">Order-by (timestamp) column</label>
<input class="form-control form-control-sm mono" value="@_m.RowSelector.TopByTimestamp" placeholder="sample_ts" @onchange="@(e => Update(() => _m.RowSelector.TopByTimestamp = NullIfBlank(e.Value)))" /></div>
}
</div>
}
<div class="row g-2 mt-1">
<div class="col-md-6"><label class="form-label">Timestamp column <span class="text-muted small">(optional)</span></label>
<input class="form-control form-control-sm mono" value="@_m.TimestampColumn" placeholder="sample_ts" @onchange="@(e => Update(() => _m.TimestampColumn = NullIfBlank(e.Value)))" />
<div class="form-text">Source-timestamp column; blank ⇒ the driver stamps the poll time.</div></div>
<div class="col-12 mt-1">
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="@(() => _showPicker = true)">Build address</button>
</div>
</div>
@if (_showPicker)
{
<DriverTagPicker @bind-Visible="_showPicker"
Title="Sql tag builder"
CurrentAddress="@_pickerAddress"
OnPickAddress="@OnAddressPicked">
<SqlAddressPickerBody CurrentAddress="@_pickerAddress"
CurrentAddressChanged="@((s) => _pickerAddress = s)"
GetConfigJson="@GetDriverConfigJson" />
</DriverTagPicker>
}
@code { @code {
/// <summary>The tag's current TagConfig JSON.</summary> /// <summary>The tag's current TagConfig JSON.</summary>
@@ -23,13 +101,111 @@
private SqlTagConfigModel _m = new(); private SqlTagConfigModel _m = new();
private string? _lastConfigJson; 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 // Local UI discriminator for the WideRow row selector (where-pair vs newest-by-timestamp). The model
// an in-progress edit (mirrors the other typed editors). // 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() protected override void OnParametersSet()
{ {
if (ConfigJson == _lastConfigJson) { return; } if (ConfigJson == _lastConfigJson) { return; }
_lastConfigJson = ConfigJson; _lastConfigJson = ConfigJson;
_m = SqlTagConfigModel.FromJson(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<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
=> Enum.TryParse<TEnum>(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<TEnum>(object? v) where TEnum : struct, Enum
{
var s = v?.ToString();
if (string.IsNullOrEmpty(s)) { return null; }
return Enum.TryParse<TEnum>(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();
} }
} }