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 -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.