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:
+337
-105
@@ -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),
|
||||||
|
|||||||
+17
@@ -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;
|
||||||
|
}
|
||||||
+1
-1
@@ -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.");
|
||||||
});
|
});
|
||||||
|
|||||||
+62
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user