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
@@ -1,15 +1,77 @@
{
"planPath": "docs/plans/2026-08-20-grouped-connection-bindings.md",
"tasks": [
{"id": 1, "subject": "Task 1: ResolvedAttribute.DefaultDataSourceReference (TemplateEngine)", "status": "pending"},
{"id": 2, "subject": "Task 2: BindingGroupBuilder — grouping + object-prefix inference", "status": "pending"},
{"id": 3, "subject": "Task 3: BindingGroupBuilder.ApplyObject", "status": "pending", "blockedBy": [2]},
{"id": 4, "subject": "Task 4: Configure page — source binding rows from the flattened config", "status": "pending", "blockedBy": [1, 3]},
{"id": 5, "subject": "Task 5: Configure page — grouped markup with per-module Object apply", "status": "pending", "blockedBy": [4]},
{"id": 6, "subject": "Task 6: Live check on the docker rig", "status": "pending", "blockedBy": [5]},
{"id": 7, "subject": "Task 7: CLI instance bind-module", "status": "pending"},
{"id": 8, "subject": "Task 8: Docs", "status": "pending", "blockedBy": [5]},
{"id": 9, "subject": "Task 9: Solution build + impacted suites", "status": "pending", "blockedBy": [1,2,3,4,5,6,7,8]}
{
"id": 1,
"subject": "Task 1: ResolvedAttribute.DefaultDataSourceReference (TemplateEngine)",
"status": "completed"
},
{
"id": 2,
"subject": "Task 2: BindingGroupBuilder \u2014 grouping + object-prefix inference",
"status": "completed"
},
{
"id": 3,
"subject": "Task 3: BindingGroupBuilder.ApplyObject",
"status": "completed",
"blockedBy": [
2
]
},
{
"id": 4,
"subject": "Task 4: Configure page \u2014 source binding rows from the flattened config",
"status": "completed",
"blockedBy": [
1,
3
]
},
{
"id": 5,
"subject": "Task 5: Configure page \u2014 grouped markup with per-module Object apply",
"status": "completed",
"blockedBy": [
4
]
},
{
"id": 6,
"subject": "Task 6: Live check on the docker rig",
"status": "pending",
"blockedBy": [
5
]
},
{
"id": 7,
"subject": "Task 7: CLI instance bind-module",
"status": "completed"
},
{
"id": 8,
"subject": "Task 8: Docs",
"status": "pending",
"blockedBy": [
5
]
},
{
"id": 9,
"subject": "Task 9: Solution build + impacted suites",
"status": "pending",
"blockedBy": [
1,
2,
3,
4,
5,
6,
7,
8
]
}
],
"lastUpdated": "2026-08-20T19:45:00Z"
}
"lastUpdated": "2026-08-20T21:30:00Z"
}
+1 -1
View File
@@ -121,7 +121,7 @@ Central cluster only. Sites have no user interface.
- Assign instances to areas.
- Bind data connections — **per-attribute binding** where each attribute with a data source reference individually selects its data connection from the site's available connections. **The binding rows come from the instance's flattened configuration** (`IFlatteningPipeline.FlattenAndValidateAsync(id, validateScripts:false)`), so every data-sourced attribute the deploy gate will check appears — composed feature-module members (`LeftMESReceiver.MoveInFlag`, `LeftReactorSide.LeakTest.DeltaVac`, …) included. (Until 2026-08-20 the panel was built from the template's *direct* attributes only, which left 264 of a composed QA instance's 280 bindings invisible and made the instance undeployable from the UI; design `docs/plans/2026-08-20-grouped-connection-bindings-design.md`.) Only a genuine flatten *failure* falls back to the direct-attribute list, with a visible warning banner; a validation failure (e.g. unbound attributes — the very thing the panel exists to fix) is not a reason to fall back.
- **Grouped by module.** Rows are grouped by the first segment of the canonical name — `(instance)` for direct attributes first, then one collapsible group per composed module in name order; nested modules fold into their first segment (`LeftReactorSide` shows *incl. LeakTest*) and members keep their nested tail (`LeakTest.DeltaVac`). The pure grouping/inference/apply logic is `BindingGroupBuilder` (CentralUI `Services/`), unit-tested without rendering the page.
- **Group header = connection + Object + Apply.** Each group header carries a connection select and an **Object** input; **Apply to n** rewrites every member of the group to `<object>.<memberTail>` on that connection (a blank Object clears the overrides so the template default applies; surrounding whitespace and a trailing `.` are tolerated). The Object input is pre-filled with the **inferred** shared prefix when every overridden member agrees (`MESReceiver_022.MoveInFlag` + `MESReceiver_022.ScanStateCmd` ⇒ `MESReceiver_022`; nested object paths such as `Left_002.DelmiaReceiver` infer too); when members disagree, or an override does not end in its member tail, the group shows a **mixed** pill and the input's placeholder invites an object to unify them. Apply edits the in-memory maps only — **Save Bindings** persists every row (hidden rows included) through the existing `SetConnectionBindings` upsert, and the header shows an `n changed · unsaved` pill until then.
- **Group header = connection + Object + Apply.** Each group header carries a connection select and an **Object** input; **Apply to n** rewrites every member of the group to `<object>.<memberTail>` on that connection (a blank Object clears the overrides so the template default applies; surrounding whitespace and a trailing `.` are tolerated). A header connection left at `— none —`/`— mixed —` **keeps each member's current connection** — Apply never unbinds a group (the Save path is an upsert, so an unbinding could not persist anyway). The Object input is pre-filled with the **inferred** shared prefix over every member's *effective* reference (override if set, else the default): `MESReceiver_022.MoveInFlag` + `MESReceiver_022.ScanStateCmd` ⇒ `MESReceiver_022`; nested object paths such as `Left_002.DelmiaReceiver` infer too; a direct-attribute group whose defaults already carry a shared prefix infers it with no overrides at all. Once any member has an override, the group shows a **mixed** pill — and the input's placeholder invites an object to unify it — when members disagree, an override does not end in its member tail, *or a member still sits on a bare default the deploy gate would reject*; a group with no overrides and bare defaults is merely "unset", not mixed. A binding to a connection that no longer exists at the site counts as **unbound** (it renders as `— none —` and the gate rejects it). Apply edits the in-memory maps only — **Save Bindings** persists every row (hidden rows included) through the existing `SetConnectionBindings` upsert; until then the header shows an `n changed · unsaved` pill counting members whose (connection, override) differs from the last loaded/saved state, so per-row edits and group Applies both surface.
- **Pills and filters.** The card header totals unbound rows and mixed groups; each group shows `n unbound` (red) or `bound`, plus `mixed`; unbound rows render highlighted. A **Show only unbound** filter hides bound rows and fully-bound groups; **Expand all** opens every group. The legacy all-groups connection select (**Assign all to…**) is retained. A composed member whose template default is the bare member name (`MoveInFlag`) carries a ⚠ marker — such a default cannot be a real address and the deploy gate will reject the instance until an override (or a group Object) is set.
- **Template default** column shows `ResolvedAttribute.DefaultDataSourceReference` — the reference as resolved from the template chain *before* the per-instance override — so the operator always sees both the default and the effective override. **Test Bindings** (button label carries the testable-row count) reads every bound row with an effective path, composed rows included.
- **Override** — optional per-attribute OPC UA node id (or other protocol address). When set, replaces the template's `DataSourceReference` at flattening time; when blank, the template default is used. The greyed placeholder shows the template default for context.