feat(sql): SqlTagConfigEditor razor shell
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
+185
-9
@@ -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
|
||||
|
||||
<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 {
|
||||
/// <summary>The tag's current TagConfig JSON.</summary>
|
||||
@@ -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<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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user