diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor index 7dc112e4..6c800325 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor @@ -12,6 +12,8 @@ @using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services @using ZB.MOM.WW.ScadaBridge.DeploymentManager @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)] @inject ITemplateEngineRepository TemplateEngineRepository @inject ISiteRepository SiteRepository @@ -79,106 +81,194 @@ - @* Connection Bindings *@ -
-
+ @* Connection Bindings — grouped by module (design 2026-08-20). Rows come + from the flattened configuration so composed module members appear; + each group header carries a connection + Object apply that rewrites + every member to .. *@ +
+
Connection Bindings - @if (_bindingDataSourceAttrs.Count > 0 && _siteConnections.Count > 0) + @_bindingRows.Count data-sourced attribute(s) in @_bindingGroups.Count group(s) + + @if (TotalUnbound > 0) { -
- - -
+ @TotalUnbound unbound + } + @if (MixedGroups > 0) + { + @MixedGroups group(s) mixed }
-
- @if (_bindingDataSourceAttrs.Count == 0) + @if (_bindingSourceWarning is not null) + { +
@_bindingSourceWarning
+ } + @if (_bindingRows.Count == 0) + { +
No data-sourced attributes in this template.
+ } + else + { +
+ @if (_siteConnections.Count > 0) + { + + } + + + +
+ + @foreach (var g in VisibleGroups) { -

No data-sourced attributes in this template.

- } - else - { - - - - - - - - - - - - @foreach (var attr in _bindingDataSourceAttrs) + var open = _expandedGroups.Contains(g.Key); +
+
+ + @g.Key + @if (g.IsInstanceGroup) + { + direct attributes + } + else if (g.NestedModules.Count > 0) + { + incl. @string.Join(", ", g.NestedModules) + } + + @g.Members.Count + + + + @if (g.UnboundCount > 0) { - var connId = GetBindingConnectionId(attr.Name); - var canBrowse = connId > 0; - var isBrowsable = IsBrowsable(connId); -
- - @* Tag paths are OPC UA node ids (nsu=…;s=…) — unbounded - text that would otherwise set the table's width. - .cell-clip is display:block, hence the span. *@ - - - - - + @g.UnboundCount unbound + } + else + { + bound + } + @if (g.IsObjectMixed || g.ConnectionState == BindingConnectionState.Mixed) + { + mixed + } + @if (_groupChangedCounts.TryGetValue(g.Key, out var changed) && changed > 0) + { + @changed changed · unsaved } - -
AttributeTag PathConnectionOverride
@attr.Name - @attr.DataSourceReference - - - - - - @if (isBrowsable) - { - - } -
-
-
- - @* 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. *@ -
+ @if (open) + { +
+ + + + + + + + + + + + @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); + + + @* Defaults can be OPC UA node ids (nsu=…;s=…) — unbounded + text that would otherwise set the table's width. *@ + + + + + + } + +
MemberTemplate defaultConnectionReference override
@m.MemberTail + @m.Row.DefaultReference + @if (bare) + { + ⚠ + } + + + + + + @if (isBrowsable) + { + + } +
+
+ }
} -
+ + }
@* Attribute Overrides *@ @@ -629,8 +719,17 @@ private string? _errorMessage; private ToastNotification _toast = default!; - // Bindings - private List _bindingDataSourceAttrs = new(); + // ── Bindings (design 2026-08-20: rows = flattened attributes, grouped by module) ── + private List _bindingRows = new(); + private IReadOnlyList _bindingGroups = Array.Empty(); + private string? _bindingSourceWarning; + /// Template default per canonical name (placeholder text). + private readonly Dictionary _bindingDefaults = new(StringComparer.Ordinal); + /// + /// The flattened configuration the binding rows were built from; reused by the + /// alarm attribute picker so the page flattens once, not twice. + /// + private FlattenedConfiguration? _flattenedForBindings; private List _siteConnections = new(); private Dictionary _bindingSelections = new(); /// @@ -758,9 +857,9 @@ _reassignAreaId = _instance.AreaId ?? 0; _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); - _bindingDataSourceAttrs = attrs.Where(a => !string.IsNullOrEmpty(a.DataSourceReference)).ToList(); _siteConnections = (await SiteRepository.GetDataConnectionsBySiteIdAsync(_instance.SiteId)).ToList(); var existingBindings = await TemplateEngineRepository.GetBindingsByInstanceIdAsync(Id); foreach (var b in existingBindings) @@ -769,6 +868,7 @@ if (!string.IsNullOrEmpty(b.DataSourceReferenceOverride)) _bindingOverrides[b.AttributeName] = b.DataSourceReferenceOverride; } + await LoadBindingRowsAsync(attrs); // Overrides _overrideAttrs = attrs.Where(a => !a.IsLocked).ToList(); @@ -846,15 +946,132 @@ var val = int.TryParse(e.Value?.ToString(), out var id) ? id : 0; if (val == 0) _bindingSelections.Remove(attrName); else _bindingSelections[attrName] = val; + RebuildBindingGroups(); } + /// All-groups connection select: binds every row to one connection (overrides untouched). private void ApplyBulkBinding() { if (_bulkConnectionId == 0) return; - foreach (var attr in _bindingDataSourceAttrs) - _bindingSelections[attr.Name] = _bulkConnectionId; + foreach (var row in _bindingRows) + _bindingSelections[row.CanonicalName] = _bulkConnectionId; + RebuildBindingGroups(); } + /// + /// 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). + /// + private async Task LoadBindingRowsAsync(IReadOnlyList 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 directAttrs) + => directAttrs.Where(a => !string.IsNullOrEmpty(a.DataSourceReference)).Select(a => (a.Name, a.DataSourceReference)); + + /// Re-projects the current selections/overrides into the rows, then regroups. + 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 _expandedGroups = new(StringComparer.Ordinal); + private readonly Dictionary _groupObjectInput = new(StringComparer.Ordinal); + private readonly Dictionary _groupConnectionInput = new(StringComparer.Ordinal); + private readonly Dictionary _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); } + + /// Header Object input: the operator's pending edit, else the inferred shared prefix. + private string? GroupObjectInput(BindingGroup g) + => _groupObjectInput.TryGetValue(g.Key, out var v) ? v : g.InferredObject; + + /// Header connection select: the operator's pending edit, else the group's common connection. + private int GroupConnectionInput(BindingGroup g) + => _groupConnectionInput.TryGetValue(g.Key, out var v) ? v ?? 0 : g.CommonConnectionId ?? 0; + + /// + /// Per-module bulk apply: rewrites every member of the group to + /// {object}.{memberTail} 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. + /// + 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 VisibleMembers(BindingGroup g) + => _showOnlyUnbound ? g.Members.Where(m => m.IsUnbound) : g.Members; + + private IEnumerable VisibleGroups + => _showOnlyUnbound ? _bindingGroups.Where(g => g.UnboundCount > 0) : _bindingGroups; + // ── Task 18: per-attribute override input + OPC UA tag browser ────────── private string? GetOverrideForAttr(string attrName) @@ -867,11 +1084,12 @@ _bindingOverrides.Remove(attrName); else _bindingOverrides[attrName] = val; + RebuildBindingGroups(); } /// Looks up the template default DataSourceReference for an attribute. private string? GetTemplateDefault(string attrName) - => _bindingDataSourceAttrs.FirstOrDefault(a => a.Name == attrName)?.DataSourceReference; + => _bindingDefaults.GetValueOrDefault(attrName); /// /// True when the row's selected data connection supports address-space browsing @@ -913,6 +1131,7 @@ if (_browserAttrInEdit is null) return; _bindingOverrides[_browserAttrInEdit] = nodeId; _browserAttrInEdit = null; + RebuildBindingGroups(); } // ── Test Bindings (one-shot live read of bound tags) ──────────────────── @@ -927,9 +1146,11 @@ private List BuildTestableRows() { var rows = new List(); - 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; var conn = _siteConnections.FirstOrDefault(c => c.Id == connId); @@ -941,11 +1162,11 @@ // or unsupported connection short-circuits to a typed banner in the // dialog rather than being filtered out here — mirrors IsBrowsable. - var effectivePath = _bindingOverrides.GetValueOrDefault(attr.Name) - ?? GetTemplateDefault(attr.Name); + var effectivePath = _bindingOverrides.GetValueOrDefault(name) + ?? GetTemplateDefault(name); 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; } @@ -977,7 +1198,10 @@ var user = await GetCurrentUserAsync(); var result = await InstanceService.SetConnectionBindingsAsync(Id, bindings, user); if (result.IsSuccess) + { + _groupChangedCounts.Clear(); _toast.ShowSuccess($"Saved {bindings.Count} connection binding(s)."); + } else _toast.ShowError($"Save failed: {result.Error}"); } @@ -1649,10 +1873,18 @@ try { - var flat = await FlatteningPipeline.FlattenAndValidateAsync(Id); - if (flat.IsFailure) return fallback; + // Reuse the flatten the bindings panel already performed (design + // 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( a.CanonicalName, MapDataType(a.DataType), diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor.css b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor.css new file mode 100644 index 00000000..f4a9fa8a --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor.css @@ -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; +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs index 12124d3a..45363f63 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs @@ -158,7 +158,7 @@ public class InstanceConfigureAuditDrillinTests : BunitContext 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"), $"Test Bindings should be enabled for a {protocol} binding."); }); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureBindingGroupsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureBindingGroupsTests.cs new file mode 100644 index 00000000..86d4fa60 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureBindingGroupsTests.cs @@ -0,0 +1,62 @@ +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; + +/// +/// 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. +/// +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); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureBindingRowsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureBindingRowsTests.cs new file mode 100644 index 00000000..1b4bc591 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureBindingRowsTests.cs @@ -0,0 +1,49 @@ +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; + +/// +/// 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. +/// +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); + } +}