fix(centralui): grouped bindings review fixes — Apply with the header at none/mixed keeps each member's connection (never unbinds; upsert Save could not persist it), object inference runs over effective references (bare-default members make an overridden group mixed, default-only prefixes infer), bindings to a deleted connection count as unbound, 'n changed · unsaved' diffs against last-saved state; bUnit behaviour tests for fallback banner, grouping and mixed-group Apply+Save

This commit is contained in:
Joseph Doherty
2026-08-20 16:09:34 -04:00
parent 5e04e81ba0
commit 546250096d
6 changed files with 387 additions and 43 deletions
@@ -177,7 +177,7 @@
{
<span class="badge text-bg-secondary" data-test="binding-group-mixed">mixed</span>
}
@if (_groupChangedCounts.TryGetValue(g.Key, out var changed) && changed > 0)
@if (GroupChangedCount(g) is var changed && changed > 0)
{
<span class="badge border text-muted">@changed changed · unsaved</span>
}
@@ -981,7 +981,9 @@
{
_flattenedForBindings = flat.Value.Configuration;
source = flat.Value.Configuration.Attributes
.Where(a => !string.IsNullOrEmpty(a.DefaultDataSourceReference) || !string.IsNullOrEmpty(a.DataSourceReference))
// Same predicate as the deploy gate (ValidateConnectionBindingCompleteness:
// DataSourceReference != null), so every row the gate will check has a row here.
.Where(a => a.DefaultDataSourceReference is not null || a.DataSourceReference is not null)
.Select(a => (a.CanonicalName, a.DefaultDataSourceReference ?? a.DataSourceReference));
}
}
@@ -998,10 +1000,11 @@
return new BindingRow(s.Name, s.Default, null, null);
}).ToList();
RebuildBindingGroups();
SnapshotSavedBindingState();
}
private static IEnumerable<(string Name, string? Default)> DirectAttributeRows(IReadOnlyList<TemplateAttribute> directAttrs)
=> directAttrs.Where(a => !string.IsNullOrEmpty(a.DataSourceReference)).Select(a => (a.Name, a.DataSourceReference));
=> directAttrs.Where(a => a.DataSourceReference is not null).Select(a => (a.Name, a.DataSourceReference));
/// <summary>Re-projects the current selections/overrides into the rows, then regroups.</summary>
private void RebuildBindingGroups()
@@ -1013,9 +1016,29 @@
Override = _bindingOverrides.GetValueOrDefault(r.CanonicalName)
})
.ToList();
_bindingGroups = BindingGroupBuilder.Build(_bindingRows);
// A binding to a connection that no longer exists at the site renders as
// "— none —" and the deploy gate rejects it — count it as unbound.
_bindingGroups = BindingGroupBuilder.Build(_bindingRows, _siteConnections.Select(c => c.Id).ToHashSet());
}
/// <summary>Per-row (connection, override) as last loaded/saved — the baseline for the "n changed · unsaved" pill.</summary>
private Dictionary<string, (int? Conn, string? Override)> _savedBindingState = new(StringComparer.Ordinal);
private void SnapshotSavedBindingState()
=> _savedBindingState = _bindingRows.ToDictionary(
r => r.CanonicalName,
r => (_bindingSelections.TryGetValue(r.CanonicalName, out var c) ? c : (int?)null, _bindingOverrides.GetValueOrDefault(r.CanonicalName)),
StringComparer.Ordinal);
/// <summary>Members whose current state differs from the last loaded/saved state (per-row edits and group Applies alike).</summary>
private int GroupChangedCount(BindingGroup g)
=> g.Members.Count(m =>
{
var name = m.Row.CanonicalName;
var current = (_bindingSelections.TryGetValue(name, out var c) ? c : (int?)null, _bindingOverrides.GetValueOrDefault(name));
return !_savedBindingState.TryGetValue(name, out var saved) || saved != current;
});
private int TotalUnbound => _bindingGroups.Sum(g => g.UnboundCount);
private int MixedGroups => _bindingGroups.Count(g => g.IsObjectMixed || g.ConnectionState == BindingConnectionState.Mixed);
@@ -1023,7 +1046,6 @@
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); }
@@ -1040,26 +1062,21 @@
/// <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.
/// the overrides; a header left at "— none —"/"— mixed —" keeps each member's
/// current connection — Apply never unbinds a group). Edits only the in-memory
/// selection/override maps; Save Bindings persists them.
/// </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);
@@ -1199,7 +1216,7 @@
var result = await InstanceService.SetConnectionBindingsAsync(Id, bindings, user);
if (result.IsSuccess)
{
_groupChangedCounts.Clear();
SnapshotSavedBindingState();
_toast.ShowSuccess($"Saved {bindings.Count} connection binding(s).");
}
else