feat(centralui): grouped Connection Bindings panel — rows sourced from the flattened config (composed module members were invisible; flatten failure falls back to direct attributes with a banner), per-module connection + Object apply, unbound/mixed pills, show-only-unbound filter, Test Bindings covers composed rows, per-row Browse retained

This commit is contained in:
Joseph Doherty
2026-08-20 15:59:51 -04:00
parent 02a374259b
commit 77f4af0d64
5 changed files with 466 additions and 106 deletions
@@ -12,6 +12,8 @@
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services @using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services
@using ZB.MOM.WW.ScadaBridge.DeploymentManager @using ZB.MOM.WW.ScadaBridge.DeploymentManager
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Dialogs @using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Dialogs
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)] @attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
@inject ITemplateEngineRepository TemplateEngineRepository @inject ITemplateEngineRepository TemplateEngineRepository
@inject ISiteRepository SiteRepository @inject ISiteRepository SiteRepository
@@ -79,106 +81,194 @@
</div> </div>
</div> </div>
@* Connection Bindings *@ @* Connection Bindings — grouped by module (design 2026-08-20). Rows come
<div class="card mb-3"> from the flattened configuration so composed module members appear;
<div class="card-header py-2 d-flex justify-content-between align-items-center"> each group header carries a connection + Object apply that rewrites
every member to <object>.<memberTail>. *@
<div class="card mb-3 binding-panel">
<div class="card-header py-2 d-flex align-items-center gap-3 flex-wrap">
<strong>Connection Bindings</strong> <strong>Connection Bindings</strong>
@if (_bindingDataSourceAttrs.Count > 0 && _siteConnections.Count > 0) <span class="text-muted small">@_bindingRows.Count data-sourced attribute(s) in @_bindingGroups.Count group(s)</span>
<span class="ms-auto"></span>
@if (TotalUnbound > 0)
{ {
<div class="d-flex align-items-center gap-1"> <span class="badge text-bg-warning" data-test="binding-total-unbound">@TotalUnbound unbound</span>
<select class="form-select form-select-sm" style="width: auto;" data-test="binding-bulk-select" @bind="_bulkConnectionId"> }
<option value="0">Assign all to...</option> @if (MixedGroups > 0)
@foreach (var c in _siteConnections) {
{ <span class="badge text-bg-secondary">@MixedGroups group(s) mixed</span>
<option value="@c.Id">@c.Name (@c.Protocol)</option>
}
</select>
<button class="btn btn-outline-primary btn-sm" @onclick="ApplyBulkBinding"
disabled="@(_bulkConnectionId == 0)">Apply</button>
</div>
} }
</div> </div>
<div class="card-body p-0"> @if (_bindingSourceWarning is not null)
@if (_bindingDataSourceAttrs.Count == 0) {
<div class="alert alert-warning rounded-0 mb-0 py-2 small" data-test="binding-source-warning">@_bindingSourceWarning</div>
}
@if (_bindingRows.Count == 0)
{
<div class="card-body text-muted small">No data-sourced attributes in this template.</div>
}
else
{
<div class="card-body border-bottom py-2 d-flex align-items-center gap-3 flex-wrap small">
@if (_siteConnections.Count > 0)
{
<label class="d-flex align-items-center gap-2 mb-0">All groups
<select class="form-select form-select-sm" style="width: auto;" data-test="binding-bulk-select" @bind="_bulkConnectionId">
<option value="0">Assign all to...</option>
@foreach (var c in _siteConnections)
{
<option value="@c.Id">@c.Name (@c.Protocol)</option>
}
</select>
<button class="btn btn-outline-primary btn-sm" @onclick="ApplyBulkBinding"
disabled="@(_bulkConnectionId == 0)">Apply connection</button>
</label>
}
<span class="ms-auto"></span>
<label class="form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox" @bind="_showOnlyUnbound" /> Show only unbound
</label>
<button class="btn btn-outline-secondary btn-sm" data-test="binding-expand-all" @onclick="ExpandAll">Expand all</button>
</div>
@foreach (var g in VisibleGroups)
{ {
<p class="text-muted small p-3 mb-0">No data-sourced attributes in this template.</p> var open = _expandedGroups.Contains(g.Key);
} <div class="binding-group border-bottom">
else <div class="binding-group-header d-flex align-items-center gap-2 px-3 py-2 @(open ? "bg-light" : "")" data-test="binding-group-header">
{ <button class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="() => ToggleGroup(g.Key)"
<table class="table table-sm table-bordered mb-0"> aria-expanded="@(open ? "true" : "false")" title="@(open ? "Collapse" : "Expand")">@(open ? "▼" : "▶")</button>
<thead class="table-light"> <span class="fw-semibold binding-group-name">@g.Key
<tr> @if (g.IsInstanceGroup)
<th>Attribute</th> {
<th>Tag Path</th> <small class="text-muted fw-normal ms-1">direct attributes</small>
<th style="width: 280px;">Connection</th> }
<th>Override</th> else if (g.NestedModules.Count > 0)
<th style="width: 110px;"></th> {
</tr> <small class="text-muted fw-normal ms-1">incl. @string.Join(", ", g.NestedModules)</small>
</thead> }
<tbody> </span>
@foreach (var attr in _bindingDataSourceAttrs) <span class="text-muted small">@g.Members.Count</span>
<select class="form-select form-select-sm" style="width: 160px;" data-test="binding-group-connection"
value="@GroupConnectionInput(g)"
@onchange="e => _groupConnectionInput[g.Key] = int.TryParse(e.Value?.ToString(), out var v) ? v : 0">
<option value="0">@(g.ConnectionState == BindingConnectionState.Mixed ? "— mixed —" : "— none —")</option>
@foreach (var c in _siteConnections)
{
<option value="@c.Id">@c.Name</option>
}
</select>
<input class="form-control form-control-sm font-monospace" style="max-width: 260px;" data-test="binding-group-object"
value="@GroupObjectInput(g)"
placeholder="@(g.IsObjectMixed ? "mixed — enter object to unify" : "object, e.g. MESReceiver_022")"
title="Object path the module's members hang off; Apply rewrites every member to <object>.<member>. Blank clears the overrides."
@onchange="e => _groupObjectInput[g.Key] = e.Value?.ToString()" />
<button class="btn btn-outline-primary btn-sm text-nowrap" data-test="binding-group-apply"
@onclick="() => ApplyGroupObject(g)" disabled="@_saving">Apply to @g.Members.Count</button>
@if (g.UnboundCount > 0)
{ {
var connId = GetBindingConnectionId(attr.Name); <span class="badge text-bg-danger" data-test="binding-group-unbound">@g.UnboundCount unbound</span>
var canBrowse = connId > 0; }
var isBrowsable = IsBrowsable(connId); else
<tr> {
<td class="small">@attr.Name</td> <span class="badge text-bg-success">bound</span>
@* Tag paths are OPC UA node ids (nsu=…;s=…) — unbounded }
text that would otherwise set the table's width. @if (g.IsObjectMixed || g.ConnectionState == BindingConnectionState.Mixed)
.cell-clip is display:block, hence the span. *@ {
<td class="small text-muted font-monospace"> <span class="badge text-bg-secondary" data-test="binding-group-mixed">mixed</span>
<span class="cell-clip" title="@attr.DataSourceReference">@attr.DataSourceReference</span> }
</td> @if (_groupChangedCounts.TryGetValue(g.Key, out var changed) && changed > 0)
<td> {
<select class="form-select form-select-sm" <span class="badge border text-muted">@changed changed · unsaved</span>
value="@connId"
@onchange="(e) => OnBindingChanged(attr.Name, e)">
<option value="0">— none —</option>
@foreach (var c in _siteConnections)
{
<option value="@c.Id">@c.Name</option>
}
</select>
</td>
<td>
<input class="form-control form-control-sm"
value="@GetOverrideForAttr(attr.Name)"
@onchange="(e) => OnOverrideForAttrChanged(attr.Name, e)"
placeholder="@(attr.DataSourceReference ?? "(no default)")" />
</td>
<td>
@if (isBrowsable)
{
<button class="btn btn-sm btn-outline-primary"
disabled="@(!canBrowse)"
title="@(canBrowse ? "Browse address space" : "Pick a connection first")"
@onclick="() => OpenBrowser(attr.Name)">
Browse…
</button>
}
</td>
</tr>
} }
</tbody>
</table>
<div class="p-2">
<div class="btn-group btn-group-sm" role="group" aria-label="Binding actions">
<button class="btn btn-success" @onclick="SaveBindings" disabled="@_saving">Save Bindings</button>
@* Test Bindings: one-shot live read of every bound attribute
whose row has a connection picked AND an effective tag
path. Disabled when no testable rows. Protocol-agnostic —
any connection whose adapter implements ReadBatchAsync
(OPC UA and MxGateway today) round-trips through
ReadTagValuesCommand. *@
<button class="btn btn-outline-primary"
@onclick="OpenTestBindings"
disabled="@(!HasTestableBindings())">
Test Bindings
</button>
</div> </div>
@if (open)
{
<div class="table-responsive">
<table class="table table-sm table-bordered mb-0 align-middle">
<thead class="table-light">
<tr>
<th>Member</th>
<th>Template default</th>
<th style="width: 200px;">Connection</th>
<th>Reference override</th>
<th style="width: 110px;"></th>
</tr>
</thead>
<tbody>
@foreach (var m in VisibleMembers(g))
{
var name = m.Row.CanonicalName;
var connId = GetBindingConnectionId(name);
var canBrowse = connId > 0;
var isBrowsable = IsBrowsable(connId);
var bare = !g.IsInstanceGroup && string.Equals(m.Row.DefaultReference, m.MemberTail, StringComparison.Ordinal);
<tr class="@(m.IsUnbound ? "table-danger" : "")">
<td class="small font-monospace ps-4">@m.MemberTail</td>
@* Defaults can be OPC UA node ids (nsu=…;s=…) — unbounded
text that would otherwise set the table's width. *@
<td class="small text-muted font-monospace">
<span class="cell-clip" title="@m.Row.DefaultReference">@m.Row.DefaultReference</span>
@if (bare)
{
<span class="ms-1 text-warning" data-test="binding-bare-default"
title="Composed members share a bare default name; set an override (or apply an Object on the group) or the deploy gate rejects the instance.">⚠</span>
}
</td>
<td>
<select class="form-select form-select-sm"
value="@connId"
@onchange="(e) => OnBindingChanged(name, e)">
<option value="0">— none —</option>
@foreach (var c in _siteConnections)
{
<option value="@c.Id">@c.Name</option>
}
</select>
</td>
<td>
<input class="form-control form-control-sm font-monospace"
value="@GetOverrideForAttr(name)"
@onchange="(e) => OnOverrideForAttrChanged(name, e)"
placeholder="@(m.Row.DefaultReference ?? "(no default)")" />
</td>
<td>
@if (isBrowsable)
{
<button class="btn btn-sm btn-outline-primary"
disabled="@(!canBrowse)"
title="@(canBrowse ? "Browse address space" : "Pick a connection first")"
@onclick="() => OpenBrowser(name)">
Browse…
</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div> </div>
} }
</div> <div class="card-footer d-flex align-items-center gap-2 flex-wrap">
<div class="btn-group btn-group-sm" role="group" aria-label="Binding actions">
<button class="btn btn-success" @onclick="SaveBindings" disabled="@_saving">Save Bindings</button>
@* Test Bindings: one-shot live read of every bound attribute
whose row has a connection picked AND an effective tag
path — composed rows included. Protocol-agnostic — any
connection whose adapter implements ReadBatchAsync
(OPC UA and MxGateway today) round-trips through
ReadTagValuesCommand. *@
<button class="btn btn-outline-primary"
@onclick="OpenTestBindings"
disabled="@(!HasTestableBindings())">
Test Bindings (@BuildTestableRows().Count)
</button>
</div>
<span class="text-muted small ms-2">Saved bindings reach the site on the next Deploy.@(TotalUnbound > 0 ? $" {TotalUnbound} row(s) are unbound — Deploy will be rejected until they are bound." : "")</span>
</div>
}
</div> </div>
@* Attribute Overrides *@ @* Attribute Overrides *@
@@ -629,8 +719,17 @@
private string? _errorMessage; private string? _errorMessage;
private ToastNotification _toast = default!; private ToastNotification _toast = default!;
// Bindings // ── Bindings (design 2026-08-20: rows = flattened attributes, grouped by module) ──
private List<TemplateAttribute> _bindingDataSourceAttrs = new(); private List<BindingRow> _bindingRows = new();
private IReadOnlyList<BindingGroup> _bindingGroups = Array.Empty<BindingGroup>();
private string? _bindingSourceWarning;
/// <summary>Template default per canonical name (placeholder text).</summary>
private readonly Dictionary<string, string?> _bindingDefaults = new(StringComparer.Ordinal);
/// <summary>
/// The flattened configuration the binding rows were built from; reused by the
/// alarm attribute picker so the page flattens once, not twice.
/// </summary>
private FlattenedConfiguration? _flattenedForBindings;
private List<DataConnection> _siteConnections = new(); private List<DataConnection> _siteConnections = new();
private Dictionary<string, int> _bindingSelections = new(); private Dictionary<string, int> _bindingSelections = new();
/// <summary> /// <summary>
@@ -758,9 +857,9 @@
_reassignAreaId = _instance.AreaId ?? 0; _reassignAreaId = _instance.AreaId ?? 0;
_areaName = _siteAreas.FirstOrDefault(a => a.Id == _reassignAreaId)?.Name ?? ""; _areaName = _siteAreas.FirstOrDefault(a => a.Id == _reassignAreaId)?.Name ?? "";
// Bindings // Bindings — existing rows first, then the flattened row source
// (composed module members included) projected over them.
var attrs = await TemplateEngineRepository.GetAttributesByTemplateIdAsync(_instance.TemplateId); var attrs = await TemplateEngineRepository.GetAttributesByTemplateIdAsync(_instance.TemplateId);
_bindingDataSourceAttrs = attrs.Where(a => !string.IsNullOrEmpty(a.DataSourceReference)).ToList();
_siteConnections = (await SiteRepository.GetDataConnectionsBySiteIdAsync(_instance.SiteId)).ToList(); _siteConnections = (await SiteRepository.GetDataConnectionsBySiteIdAsync(_instance.SiteId)).ToList();
var existingBindings = await TemplateEngineRepository.GetBindingsByInstanceIdAsync(Id); var existingBindings = await TemplateEngineRepository.GetBindingsByInstanceIdAsync(Id);
foreach (var b in existingBindings) foreach (var b in existingBindings)
@@ -769,6 +868,7 @@
if (!string.IsNullOrEmpty(b.DataSourceReferenceOverride)) if (!string.IsNullOrEmpty(b.DataSourceReferenceOverride))
_bindingOverrides[b.AttributeName] = b.DataSourceReferenceOverride; _bindingOverrides[b.AttributeName] = b.DataSourceReferenceOverride;
} }
await LoadBindingRowsAsync(attrs);
// Overrides // Overrides
_overrideAttrs = attrs.Where(a => !a.IsLocked).ToList(); _overrideAttrs = attrs.Where(a => !a.IsLocked).ToList();
@@ -846,15 +946,132 @@
var val = int.TryParse(e.Value?.ToString(), out var id) ? id : 0; var val = int.TryParse(e.Value?.ToString(), out var id) ? id : 0;
if (val == 0) _bindingSelections.Remove(attrName); if (val == 0) _bindingSelections.Remove(attrName);
else _bindingSelections[attrName] = val; else _bindingSelections[attrName] = val;
RebuildBindingGroups();
} }
/// <summary>All-groups connection select: binds every row to one connection (overrides untouched).</summary>
private void ApplyBulkBinding() private void ApplyBulkBinding()
{ {
if (_bulkConnectionId == 0) return; if (_bulkConnectionId == 0) return;
foreach (var attr in _bindingDataSourceAttrs) foreach (var row in _bindingRows)
_bindingSelections[attr.Name] = _bulkConnectionId; _bindingSelections[row.CanonicalName] = _bulkConnectionId;
RebuildBindingGroups();
} }
/// <summary>
/// Builds the binding rows from the flattened configuration so composed module
/// members appear. Falls back to the template's direct attributes — with a visible
/// warning — only when the flatten itself fails; a validation failure (e.g. unbound
/// attributes, which is exactly what this panel exists to fix) is NOT a reason to
/// fall back (the pipeline reports validation inside a Success result).
/// </summary>
private async Task LoadBindingRowsAsync(IReadOnlyList<TemplateAttribute> directAttrs)
{
_bindingSourceWarning = null;
IEnumerable<(string Name, string? Default)> source;
try
{
var flat = await FlatteningPipeline.FlattenAndValidateAsync(Id, validateScripts: false);
if (flat.IsFailure)
{
_bindingSourceWarning = $"Could not flatten this instance ({flat.Error}); showing the template's direct attributes only.";
source = DirectAttributeRows(directAttrs);
}
else
{
_flattenedForBindings = flat.Value.Configuration;
source = flat.Value.Configuration.Attributes
.Where(a => !string.IsNullOrEmpty(a.DefaultDataSourceReference) || !string.IsNullOrEmpty(a.DataSourceReference))
.Select(a => (a.CanonicalName, a.DefaultDataSourceReference ?? a.DataSourceReference));
}
}
catch (Exception ex)
{
_bindingSourceWarning = $"Could not flatten this instance ({ex.Message}); showing the template's direct attributes only.";
source = DirectAttributeRows(directAttrs);
}
_bindingDefaults.Clear();
_bindingRows = source.Select(s =>
{
_bindingDefaults[s.Name] = s.Default;
return new BindingRow(s.Name, s.Default, null, null);
}).ToList();
RebuildBindingGroups();
}
private static IEnumerable<(string Name, string? Default)> DirectAttributeRows(IReadOnlyList<TemplateAttribute> directAttrs)
=> directAttrs.Where(a => !string.IsNullOrEmpty(a.DataSourceReference)).Select(a => (a.Name, a.DataSourceReference));
/// <summary>Re-projects the current selections/overrides into the rows, then regroups.</summary>
private void RebuildBindingGroups()
{
_bindingRows = _bindingRows
.Select(r => r with
{
ConnectionId = _bindingSelections.TryGetValue(r.CanonicalName, out var c) ? c : null,
Override = _bindingOverrides.GetValueOrDefault(r.CanonicalName)
})
.ToList();
_bindingGroups = BindingGroupBuilder.Build(_bindingRows);
}
private int TotalUnbound => _bindingGroups.Sum(g => g.UnboundCount);
private int MixedGroups => _bindingGroups.Count(g => g.IsObjectMixed || g.ConnectionState == BindingConnectionState.Mixed);
// ── Group UI state (expand/collapse, per-group header inputs, unsaved counters) ──
private readonly HashSet<string> _expandedGroups = new(StringComparer.Ordinal);
private readonly Dictionary<string, string?> _groupObjectInput = new(StringComparer.Ordinal);
private readonly Dictionary<string, int?> _groupConnectionInput = new(StringComparer.Ordinal);
private readonly Dictionary<string, int> _groupChangedCounts = new(StringComparer.Ordinal);
private bool _showOnlyUnbound;
private void ToggleGroup(string key) { if (!_expandedGroups.Remove(key)) _expandedGroups.Add(key); }
private void ExpandAll() { foreach (var g in _bindingGroups) _expandedGroups.Add(g.Key); }
/// <summary>Header Object input: the operator's pending edit, else the inferred shared prefix.</summary>
private string? GroupObjectInput(BindingGroup g)
=> _groupObjectInput.TryGetValue(g.Key, out var v) ? v : g.InferredObject;
/// <summary>Header connection select: the operator's pending edit, else the group's common connection.</summary>
private int GroupConnectionInput(BindingGroup g)
=> _groupConnectionInput.TryGetValue(g.Key, out var v) ? v ?? 0 : g.CommonConnectionId ?? 0;
/// <summary>
/// Per-module bulk apply: rewrites every member of the group to
/// <c>{object}.{memberTail}</c> on the header's connection (blank object clears
/// the overrides). Edits only the in-memory selection/override maps — Save
/// Bindings persists them — and counts rows that actually changed for the
/// "n changed · unsaved" pill.
/// </summary>
private void ApplyGroupObject(BindingGroup g)
{
var connId = GroupConnectionInput(g);
var changes = BindingGroupBuilder.ApplyObject(g, connId > 0 ? connId : null, GroupObjectInput(g));
var changed = 0;
foreach (var c in changes)
{
var before = (_bindingSelections.GetValueOrDefault(c.CanonicalName), _bindingOverrides.GetValueOrDefault(c.CanonicalName));
if (c.ConnectionId is > 0) _bindingSelections[c.CanonicalName] = c.ConnectionId.Value;
else _bindingSelections.Remove(c.CanonicalName);
if (!string.IsNullOrEmpty(c.Override)) _bindingOverrides[c.CanonicalName] = c.Override;
else _bindingOverrides.Remove(c.CanonicalName);
if (before != (_bindingSelections.GetValueOrDefault(c.CanonicalName), _bindingOverrides.GetValueOrDefault(c.CanonicalName)))
changed++;
}
_groupChangedCounts[g.Key] = _groupChangedCounts.GetValueOrDefault(g.Key) + changed;
_groupObjectInput.Remove(g.Key);
_groupConnectionInput.Remove(g.Key);
_expandedGroups.Add(g.Key);
RebuildBindingGroups();
}
private IEnumerable<BindingGroupMember> VisibleMembers(BindingGroup g)
=> _showOnlyUnbound ? g.Members.Where(m => m.IsUnbound) : g.Members;
private IEnumerable<BindingGroup> VisibleGroups
=> _showOnlyUnbound ? _bindingGroups.Where(g => g.UnboundCount > 0) : _bindingGroups;
// ── Task 18: per-attribute override input + OPC UA tag browser ────────── // ── Task 18: per-attribute override input + OPC UA tag browser ──────────
private string? GetOverrideForAttr(string attrName) private string? GetOverrideForAttr(string attrName)
@@ -867,11 +1084,12 @@
_bindingOverrides.Remove(attrName); _bindingOverrides.Remove(attrName);
else else
_bindingOverrides[attrName] = val; _bindingOverrides[attrName] = val;
RebuildBindingGroups();
} }
/// <summary>Looks up the template default <c>DataSourceReference</c> for an attribute.</summary> /// <summary>Looks up the template default <c>DataSourceReference</c> for an attribute.</summary>
private string? GetTemplateDefault(string attrName) private string? GetTemplateDefault(string attrName)
=> _bindingDataSourceAttrs.FirstOrDefault(a => a.Name == attrName)?.DataSourceReference; => _bindingDefaults.GetValueOrDefault(attrName);
/// <summary> /// <summary>
/// True when the row's selected data connection supports address-space browsing /// True when the row's selected data connection supports address-space browsing
@@ -913,6 +1131,7 @@
if (_browserAttrInEdit is null) return; if (_browserAttrInEdit is null) return;
_bindingOverrides[_browserAttrInEdit] = nodeId; _bindingOverrides[_browserAttrInEdit] = nodeId;
_browserAttrInEdit = null; _browserAttrInEdit = null;
RebuildBindingGroups();
} }
// ── Test Bindings (one-shot live read of bound tags) ──────────────────── // ── Test Bindings (one-shot live read of bound tags) ────────────────────
@@ -927,9 +1146,11 @@
private List<TestBindingsDialog.BindingRowToTest> BuildTestableRows() private List<TestBindingsDialog.BindingRowToTest> BuildTestableRows()
{ {
var rows = new List<TestBindingsDialog.BindingRowToTest>(); var rows = new List<TestBindingsDialog.BindingRowToTest>();
foreach (var attr in _bindingDataSourceAttrs) foreach (var g in _bindingGroups)
foreach (var m in g.Members)
{ {
var connId = GetBindingConnectionId(attr.Name); var name = m.Row.CanonicalName;
var connId = GetBindingConnectionId(name);
if (connId <= 0) continue; if (connId <= 0) continue;
var conn = _siteConnections.FirstOrDefault(c => c.Id == connId); var conn = _siteConnections.FirstOrDefault(c => c.Id == connId);
@@ -941,11 +1162,11 @@
// or unsupported connection short-circuits to a typed banner in the // or unsupported connection short-circuits to a typed banner in the
// dialog rather than being filtered out here — mirrors IsBrowsable. // dialog rather than being filtered out here — mirrors IsBrowsable.
var effectivePath = _bindingOverrides.GetValueOrDefault(attr.Name) var effectivePath = _bindingOverrides.GetValueOrDefault(name)
?? GetTemplateDefault(attr.Name); ?? GetTemplateDefault(name);
if (string.IsNullOrWhiteSpace(effectivePath)) continue; if (string.IsNullOrWhiteSpace(effectivePath)) continue;
rows.Add(new TestBindingsDialog.BindingRowToTest(attr.Name, conn.Name, effectivePath)); rows.Add(new TestBindingsDialog.BindingRowToTest(name, conn.Name, effectivePath));
} }
return rows; return rows;
} }
@@ -977,7 +1198,10 @@
var user = await GetCurrentUserAsync(); var user = await GetCurrentUserAsync();
var result = await InstanceService.SetConnectionBindingsAsync(Id, bindings, user); var result = await InstanceService.SetConnectionBindingsAsync(Id, bindings, user);
if (result.IsSuccess) if (result.IsSuccess)
{
_groupChangedCounts.Clear();
_toast.ShowSuccess($"Saved {bindings.Count} connection binding(s)."); _toast.ShowSuccess($"Saved {bindings.Count} connection binding(s).");
}
else else
_toast.ShowError($"Save failed: {result.Error}"); _toast.ShowError($"Save failed: {result.Error}");
} }
@@ -1649,10 +1873,18 @@
try try
{ {
var flat = await FlatteningPipeline.FlattenAndValidateAsync(Id); // Reuse the flatten the bindings panel already performed (design
if (flat.IsFailure) return fallback; // 2026-08-20) so the page flattens once; flatten afresh only when
// that path fell back.
var configuration = _flattenedForBindings;
if (configuration is null)
{
var flat = await FlatteningPipeline.FlattenAndValidateAsync(Id, validateScripts: false);
if (flat.IsFailure) return fallback;
configuration = flat.Value.Configuration;
}
return flat.Value.Configuration.Attributes return configuration.Attributes
.Select(a => new AlarmAttributeChoice( .Select(a => new AlarmAttributeChoice(
a.CanonicalName, a.CanonicalName,
MapDataType(a.DataType), MapDataType(a.DataType),
@@ -0,0 +1,17 @@
/* Connection Bindings — grouped panel (design 2026-08-20) */
.binding-group-header {
flex-wrap: wrap;
}
.binding-group-header .form-select,
.binding-group-header .form-control {
min-width: 0;
}
.binding-group-name {
min-width: 10rem;
}
.binding-group:last-of-type {
border-bottom: 0 !important;
}
@@ -158,7 +158,7 @@ public class InstanceConfigureAuditDrillinTests : BunitContext
cut.WaitForAssertion(() => cut.WaitForAssertion(() =>
{ {
var testButton = cut.FindAll("button").Single(b => b.TextContent.Trim() == "Test Bindings"); var testButton = cut.FindAll("button").Single(b => b.TextContent.Trim().StartsWith("Test Bindings", StringComparison.Ordinal));
Assert.False(testButton.HasAttribute("disabled"), Assert.False(testButton.HasAttribute("disabled"),
$"Test Bindings should be enabled for a {protocol} binding."); $"Test Bindings should be enabled for a {protocol} binding.");
}); });
@@ -0,0 +1,62 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Design 2026-08-20: the Connection Bindings panel renders one collapsible group
/// per module with a header-level connection + Object apply, unbound/mixed pills and
/// a show-only-unbound filter. Structural pins over the page source.
/// </summary>
public class InstanceConfigureBindingGroupsTests
{
private static string Markup => InstanceConfigureBindingRowsTests.ReadMarkup();
[Fact]
public void GroupHeader_HasConnectionObjectAndApply()
{
var m = Markup;
Assert.Contains("data-test=\"binding-group-header\"", m);
Assert.Contains("data-test=\"binding-group-connection\"", m);
Assert.Contains("data-test=\"binding-group-object\"", m);
Assert.Contains("data-test=\"binding-group-apply\"", m);
Assert.Contains("ApplyGroupObject(", m);
Assert.Contains("BindingGroupBuilder.ApplyObject(", m);
}
[Fact]
public void GroupHeader_ShowsUnboundAndMixedPills()
{
var m = Markup;
Assert.Contains("data-test=\"binding-group-unbound\"", m);
Assert.Contains("data-test=\"binding-group-mixed\"", m);
Assert.Contains("data-test=\"binding-total-unbound\"", m);
}
[Fact]
public void Toolbar_HasUnboundFilterAndExpandAll()
{
var m = Markup;
Assert.Contains("_showOnlyUnbound", m);
Assert.Contains("data-test=\"binding-expand-all\"", m);
Assert.Contains("data-test=\"binding-bulk-select\"", m); // existing all-groups connection select retained
}
[Fact]
public void ComposedBareDefault_IsFlagged()
{
Assert.Contains("data-test=\"binding-bare-default\"", Markup);
}
[Fact]
public void OldFlatTable_IsGone()
{
Assert.DoesNotContain("_bindingDataSourceAttrs", Markup);
}
[Fact]
public void PerRowBrowseButton_IsRetained()
{
// The OPC UA / MxGateway tag browser per row predates the grouped panel and must survive it.
var m = Markup;
Assert.Contains("OpenBrowser(name)", m);
Assert.Contains("IsBrowsable(", m);
}
}
@@ -0,0 +1,49 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Design 2026-08-20: the Connection Bindings panel must be driven by the flattened
/// configuration (every composed module member), not by the template's direct
/// attributes — 264 of Z28061's 280 bindings were invisible on QA. Structural pins
/// over the page source, matching the existing InstanceConfigure coverage style.
/// </summary>
public class InstanceConfigureBindingRowsTests
{
internal static string ReadMarkup()
{
var dir = AppContext.BaseDirectory;
for (var i = 0; i < 6 && dir is not null; i++) dir = Directory.GetParent(dir)?.FullName;
return File.ReadAllText(Path.Combine(dir!, "src", "ZB.MOM.WW.ScadaBridge.CentralUI",
"Components", "Pages", "Deployment", "InstanceConfigure.razor"));
}
private static string Markup => ReadMarkup();
[Fact]
public void BindingRows_ComeFromFlattenedConfiguration_NotTemplateAttributes()
{
var m = Markup;
Assert.Contains("LoadBindingRowsAsync", m);
Assert.Contains("BindingGroupBuilder.Build(", m);
// The old direct-attribute projection must be gone from the bindings path.
Assert.DoesNotContain("_bindingDataSourceAttrs = attrs.Where(a => !string.IsNullOrEmpty(a.DataSourceReference))", m);
Assert.Contains("DefaultDataSourceReference", m);
}
[Fact]
public void BindingRows_FallBackToDirectAttributes_WithBanner_WhenFlattenFails()
{
var m = Markup;
Assert.Contains("_bindingSourceWarning", m);
Assert.Contains("data-test=\"binding-source-warning\"", m);
}
[Fact]
public void SaveAndTest_CoverEveryRow()
{
var m = Markup.Replace("\r\n", "\n");
// Save posts the whole selection dictionary (hidden-row survival property).
Assert.Contains("_bindingSelections\n .Select(kv => new ConnectionBinding(", m);
// Test Bindings iterates the grouped rows, not the legacy attr list.
Assert.Contains("foreach (var g in _bindingGroups)", m);
}
}