fix(centralui): surface inherited compositions in the templates tree (followup #9)
The templates tree rendered a derived/composed member (e.g. LeftReactorSide, derived from ReactorSide) as a flat leaf, omitting compositions it inherits from its base (e.g. LeakTest composed onto ReactorSide). BuildCompositionLeavesFor recursed only over a template's OWN composition rows; an inherited composition row lives on the ancestor, and TemplateComposition has no IsInherited placeholder (unlike attributes/alarms/scripts/native-sources), so the child's own Compositions was empty. Same 'derived templates don't surface inherited members' family as followups #1/#2, but for compositions. Deploy/flatten was always correct (TemplateResolver.ResolveAllMembers walks the chain) — display-only. Fix: - BuildCompositionLeavesFor now renders the EFFECTIVE composition set (own + inherited) via EffectiveCompositionsFor, which walks the inheritance chain (leaf->root, child wins on InstanceName), mirroring the resolver. - Inherited slots are flagged (TemplateTreeNode.IsInherited), badged 'inherited' in the label, and their context menu offers only 'Open composed template' (Rename/Delete edit the ancestor's slot, so suppressed on inherited nodes). - The same inherited row can appear under several derived members (LeakTest under both LeftReactorSide and RightReactorSide), so composition nodes use a path-qualified KeyOverride to keep TreeView selection/expansion keys unique; recursion is cycle-guarded. Tests: +1 bUnit (TemplatesPageTests.Renders_InheritedComposition_UnderDerivedComposedMember); CentralUI suite 867 green; full solution builds 0/0. Docs: Component-CentralUI.md (effective composition set in tree); known-issues tracker #9 recorded + resolved. Note: CentralUI change — shows on wonder-app-vd03 only after that host is redeployed.
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
**Status:** RESOLVED · **Found:** 2026-06-24 · **Context:** live ops session on `wonder-app-vd03` (CvdReactor / Z28061 / Z28061Sim) — renaming the template, adding the LeakTest module, and adding MoveInType to the MESReceiver children.
|
||||
**Components:** Central UI (#9), Template Engine (#1), CLI (#19), Configuration Database (#17), Deployment Manager (#2)
|
||||
|
||||
**Resolved:** #3 (collision detector) and #7 (sandbox compile surface) on branch `fix/followups-3-7`; #1 + #2 (inherited-member propagation & resync) on branch `fix/followups-1-2`; #4 + #5 + #6 + #8 (CLI ergonomics + structured deploy validation error) on branch `fix/followups-4-5-6-8` (all 2026-06-24). All items resolved.
|
||||
**Resolved:** #3 (collision detector) and #7 (sandbox compile surface) on branch `fix/followups-3-7`; #1 + #2 (inherited-member propagation & resync) on branch `fix/followups-1-2`; #4 + #5 + #6 + #8 (CLI ergonomics + structured deploy validation error) and #9 (inherited compositions in the templates tree) on branch `fix/followups-4-5-6-8` (all 2026-06-24). All items resolved.
|
||||
|
||||
Issues are listed worst-first. Severities are author estimates. None caused data loss; the runtime/flattened config and deployed instances are correct.
|
||||
|
||||
@@ -150,3 +150,16 @@ message fallback, mixed categories).
|
||||
**Root cause:** `ValidateConnectionBindingCompleteness` emits one clause per unbound attribute and joins them into a flat string; there is no grouping or count.
|
||||
|
||||
**Suggested fix:** return a structured/summarized error — leading count (`52 attributes are unbound`) + grouped-by-module breakdown (or a capped list with "…and N more") — instead of the flat semicolon-joined dump. Keep the full list available in a detail/expandable view or the deploy log.
|
||||
|
||||
---
|
||||
|
||||
## 9. Templates tree omits inherited compositions under derived composed-members (user-reported)
|
||||
**Severity:** Low-Medium · **Components:** Central UI (#9) · **✅ RESOLVED 2026-06-24 (branch `fix/followups-4-5-6-8`)**
|
||||
|
||||
**Fix:** `Templates.razor` → `BuildCompositionLeavesFor` now renders the **effective** composition set (own + inherited) via a new `EffectiveCompositionsFor` that walks the inheritance chain (leaf→root, child wins on `InstanceName`), mirroring `TemplateResolver.ResolveAllMembers`. Inherited slots are flagged (`TemplateTreeNode.IsInherited`), badged **"inherited"** in the label, and their context menu offers only **Open composed template** (Rename/Delete edit the ancestor's slot, so they're suppressed on inherited nodes). Because the same inherited row can now appear under several derived members (LeakTest under both LeftReactorSide and RightReactorSide), composition nodes use a path-qualified `KeyOverride` (`t:{owner}/c:{id}/…`) so TreeView selection/expansion keys stay unique; the recursion is cycle-guarded. Covered by `TemplatesPageTests.Renders_InheritedComposition_UnderDerivedComposedMember`.
|
||||
|
||||
**Symptom:** On `/design/templates`, the base `ReactorSide` node expands to show its composed `↳ LeakTest`. But under `CvdReactor`, the composed members `LeftReactorSide` / `RightReactorSide` (derived from `ReactorSide`) render as **flat leaves with no LeakTest** — even though they inherit it. Observed live on `wonder-app-vd03`.
|
||||
|
||||
**Root cause:** `BuildCompositionLeavesFor(owner)` recursed only over `owner.Compositions` (the template's **own** rows). A derived template's inherited composition row lives on its ancestor, and `TemplateComposition` has no `IsInherited` placeholder (unlike attributes/alarms/scripts/native-sources, which `BuildDerivedTemplate`/the #1/#2 reconcile materialize) — so the derived child's own `Compositions` is empty and the recursion found nothing. Same "derived templates don't surface inherited members" family as #1/#2, but for compositions, which the #1/#2 fix did not cover. Deploy/flatten was always correct (`TemplateResolver.ResolveAllMembers` walks the chain), so this was display-only.
|
||||
|
||||
**Note:** this is a Central UI change — it shows on `wonder-app-vd03` only after that host is redeployed with the new build.
|
||||
|
||||
@@ -38,7 +38,7 @@ Central cluster only. Sites have no user interface.
|
||||
|
||||
### Template Authoring (Design Role)
|
||||
- The `/design/templates` page uses a **split-pane layout**: a folder/template tree sidebar on the left and the editor on the right.
|
||||
- The tree shows nested `TemplateFolder` entities with their templates underneath; composition children render inline as leaf nodes beneath their owning template (right-click "Open composed template" reveals and selects the target).
|
||||
- The tree shows nested `TemplateFolder` entities with their templates underneath; composition children render inline beneath their owning template (right-click "Open composed template" reveals and selects the target). Compositions are shown by **effective set** (own + inherited): a derived/composed member surfaces the slots it inherits from its base — e.g. `LeakTest` composed onto base `ReactorSide` appears under each derived `…ReactorSide` member — badged **"inherited"** and read-only (Rename/Delete are offered on the base's own slot, not the inherited copy).
|
||||
- **Per-kind context menus** on folder, template, and composition nodes expose the relevant operations (new folder, new template, rename, move, delete, move to folder). Root-level folders also carry a context menu. **Folder sibling reorder** is done via **Move up / Move down** menu items (M9/T23, `ReorderTemplateFolderCommand`); drag-drop is **not implemented** (permanently deferred). Tree expansion state persists in `sessionStorage`, and deep links (`/design/templates/{id}`) reveal and select the target node.
|
||||
- A **search box** above the tree (M9/T22) filters visible nodes by substring match; it is wired to `TemplateFolderTree.Filter`.
|
||||
- The `TemplateEdit` page shows a read-only **"Inherited members" panel** (M9/T26) listing the full multi-level effective inherited member set (origin, locked state, merged HiLo config) resolved by `GetResolvedTemplateMembersCommand` / `TemplateInheritanceResolver`. A **"Base changed" banner** appears when the resolver's staleness summary indicates the parent template has changed since the child was last edited. This is read-only — no "update-derived" mutation is exposed; the child is redeployed through the normal flow to pick up base changes.
|
||||
|
||||
@@ -162,24 +162,46 @@
|
||||
// hook: walks each template's compositions recursively so cascaded slots
|
||||
// appear as nested children. The Transport Export wizard intentionally
|
||||
// does NOT supply this hook — compositions aren't independently exportable.
|
||||
//
|
||||
// followup #9: a derived template inherits its ancestors' compositions (e.g.
|
||||
// LeftReactorSide, derived from ReactorSide, inherits ReactorSide's LeakTest
|
||||
// slot). The inherited TemplateComposition row lives on the ancestor — there is
|
||||
// no IsInherited placeholder row on the child — so the recursion uses the
|
||||
// EFFECTIVE composition set (own + inherited), mirroring
|
||||
// TemplateResolver.ResolveAllMembers which walks the inheritance chain. Inherited
|
||||
// slots are flagged so the label badges them and the menu suppresses edits.
|
||||
private IReadOnlyList<TemplateTreeNode> BuildCompositionLeavesFor(Template owner)
|
||||
=> BuildCompositionLeavesFor(owner, $"t:{owner.Id}", new HashSet<int>());
|
||||
|
||||
private IReadOnlyList<TemplateTreeNode> BuildCompositionLeavesFor(
|
||||
Template owner, string parentKey, HashSet<int> compositionPath)
|
||||
{
|
||||
var result = new List<TemplateTreeNode>();
|
||||
foreach (var c in owner.Compositions.OrderBy(c => c.InstanceName, StringComparer.OrdinalIgnoreCase))
|
||||
foreach (var (composition, inherited) in EffectiveCompositionsFor(owner)
|
||||
.OrderBy(x => x.Composition.InstanceName, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
// Path-qualified key: the same inherited row can appear under multiple
|
||||
// derived members, so qualify by the parent path to keep keys unique.
|
||||
var key = $"{parentKey}/c:{composition.Id}";
|
||||
var node = new TemplateTreeNode
|
||||
{
|
||||
Kind = TemplateTreeNodeKind.Composition,
|
||||
Id = c.Id,
|
||||
Name = c.InstanceName,
|
||||
Id = composition.Id,
|
||||
Name = composition.InstanceName,
|
||||
IsInherited = inherited,
|
||||
KeyOverride = key,
|
||||
};
|
||||
|
||||
if (_templatesById.TryGetValue(c.ComposedTemplateId, out var composed))
|
||||
// Cycle guard: the composition graph is acyclic by construction, but
|
||||
// pulling in inherited compositions means guarding the recursion defensively.
|
||||
if (_templatesById.TryGetValue(composition.ComposedTemplateId, out var composed)
|
||||
&& compositionPath.Add(composition.ComposedTemplateId))
|
||||
{
|
||||
foreach (var nested in BuildCompositionLeavesFor(composed))
|
||||
foreach (var nested in BuildCompositionLeavesFor(composed, key, compositionPath))
|
||||
{
|
||||
node.Children.Add(nested);
|
||||
}
|
||||
compositionPath.Remove(composition.ComposedTemplateId);
|
||||
}
|
||||
|
||||
result.Add(node);
|
||||
@@ -187,6 +209,26 @@
|
||||
return result;
|
||||
}
|
||||
|
||||
// The compositions visible on a template: its own rows plus those inherited from
|
||||
// ancestors. Walks leaf -> root so a child's own slot wins over an inherited slot
|
||||
// of the same InstanceName (child-overrides-parent, matching the resolver). The
|
||||
// Inherited flag is true when the row's owning template is an ancestor, not `owner`.
|
||||
private IEnumerable<(TemplateComposition Composition, bool Inherited)> EffectiveCompositionsFor(Template owner)
|
||||
{
|
||||
var byName = new Dictionary<string, (TemplateComposition Composition, bool Inherited)>(
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
var visited = new HashSet<int>();
|
||||
for (Template? t = owner;
|
||||
t is not null && visited.Add(t.Id);
|
||||
t = t.ParentTemplateId is int pid && _templatesById.TryGetValue(pid, out var parent) ? parent : null)
|
||||
{
|
||||
var inherited = t.Id != owner.Id;
|
||||
foreach (var c in t.Compositions)
|
||||
byName.TryAdd(c.InstanceName, (c, inherited)); // leaf seen first -> wins
|
||||
}
|
||||
return byName.Values;
|
||||
}
|
||||
|
||||
private TemplateFolderTree _tree = default!;
|
||||
|
||||
private void OpenTemplate(int templateId) =>
|
||||
@@ -220,6 +262,13 @@
|
||||
<span class="tv-glyph"><i class="bi bi-arrow-return-right"></i></span>
|
||||
<span class="tv-label" title="@node.Name"
|
||||
@ondblclick="() => OpenTemplate(composedId)">@node.Name</span>
|
||||
@if (node.IsInherited)
|
||||
{
|
||||
<span class="tv-meta">
|
||||
<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis"
|
||||
title="Inherited from a base template">inherited</span>
|
||||
</span>
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
@@ -261,9 +310,20 @@
|
||||
if (_compositionsById.TryGetValue(node.Id, out var ctx))
|
||||
{
|
||||
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/{ctx.ComposedTemplateId}")'>Open composed template</button>
|
||||
<button class="dropdown-item" @onclick="() => RenameComposition(ctx)">Rename…</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item text-danger" @onclick="() => DeleteComposition(ctx)">Delete</button>
|
||||
@if (node.IsInherited)
|
||||
{
|
||||
// Inherited slot — Rename/Delete would mutate the ancestor's
|
||||
// composition (affecting every derivation), so they are offered
|
||||
// on the base template's own slot, not here (followup #9).
|
||||
<div class="dropdown-divider"></div>
|
||||
<span class="dropdown-item-text text-muted small">Inherited from a base template — edit on the base.</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="dropdown-item" @onclick="() => RenameComposition(ctx)">Rename…</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item text-danger" @onclick="() => DeleteComposition(ctx)">Delete</button>
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -31,8 +31,26 @@ public sealed class TemplateTreeNode
|
||||
/// <summary>Child nodes (sub-folders, templates, or composition slots).</summary>
|
||||
public List<TemplateTreeNode> Children { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// True when this composition node is <em>inherited</em> — its underlying
|
||||
/// <c>TemplateComposition</c> row belongs to an ancestor of the template it is
|
||||
/// rendered under, not to that template itself (followup #9). Inherited nodes are
|
||||
/// badged read-only and their context menu suppresses Rename/Delete (those edit the
|
||||
/// base). Always false for folders and templates.
|
||||
/// </summary>
|
||||
public bool IsInherited { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Explicit, path-qualified key override. The same inherited composition row can
|
||||
/// surface under several derived members (e.g. LeakTest under both LeftReactorSide
|
||||
/// and RightReactorSide), so a plain <c>c:{Id}</c> key would collide and break the
|
||||
/// TreeView's selection/expansion tracking. Composition nodes set this to a
|
||||
/// parent-path-qualified value; folders/templates leave it null and use the default.
|
||||
/// </summary>
|
||||
public string? KeyOverride { get; init; }
|
||||
|
||||
/// <summary>Stable key for TreeView selection / expansion tracking.</summary>
|
||||
public string Key => Kind switch
|
||||
public string Key => KeyOverride ?? Kind switch
|
||||
{
|
||||
TemplateTreeNodeKind.Folder => $"f:{Id}",
|
||||
TemplateTreeNodeKind.Template => $"t:{Id}",
|
||||
|
||||
@@ -129,6 +129,51 @@ public class TemplatesPageTests : BunitContext
|
||||
Assert.Contains("bi-arrow-return-right", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renders_InheritedComposition_UnderDerivedComposedMember()
|
||||
{
|
||||
// followup #9: LeakTest is composed onto the BASE ReactorSide. LeftReactorSide is
|
||||
// DERIVED from ReactorSide and composed into CvdReactor, so the tree must surface
|
||||
// the INHERITED LeakTest slot under LeftReactorSide — the composition row lives on
|
||||
// ReactorSide (no IsInherited placeholder on the child), so the builder must use
|
||||
// the effective (own + inherited) composition set, mirroring the resolver.
|
||||
var leakTest = new Template("LeakTest") { Id = 50 };
|
||||
var reactorSide = new Template("ReactorSide") { Id = 7 };
|
||||
reactorSide.Compositions.Add(
|
||||
new TemplateComposition("LeakTest") { Id = 100, TemplateId = 7, ComposedTemplateId = 50 });
|
||||
var leftReactorSide = new Template("LeftReactorSide") { Id = 8, ParentTemplateId = 7, IsDerived = true };
|
||||
var cvdReactor = new Template("CvdReactor") { Id = 1 };
|
||||
cvdReactor.Compositions.Add(
|
||||
new TemplateComposition("LeftReactorSide") { Id = 200, TemplateId = 1, ComposedTemplateId = 8 });
|
||||
|
||||
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Template>>(
|
||||
new List<Template> { cvdReactor, reactorSide, leftReactorSide, leakTest }));
|
||||
_repo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(new List<TemplateFolder>()));
|
||||
|
||||
var cut = Render<TemplatesPage>();
|
||||
|
||||
// Expand CvdReactor to reveal its composed member LeftReactorSide.
|
||||
cut.FindAll("li[role='treeitem']")
|
||||
.First(li => li.QuerySelector(".tv-label")?.TextContent.Trim() == "CvdReactor")
|
||||
.QuerySelector(".tv-toggle")!.Click();
|
||||
|
||||
// LeftReactorSide must be expandable: before the fix it was a flat leaf with no
|
||||
// toggle because its own Compositions are empty (LeakTest is inherited, not owned).
|
||||
var leftLi = cut.FindAll("li[role='treeitem']")
|
||||
.First(li => li.QuerySelector(".tv-label")?.TextContent.Trim() == "LeftReactorSide");
|
||||
var leftToggle = leftLi.QuerySelector(".tv-toggle");
|
||||
Assert.NotNull(leftToggle);
|
||||
leftToggle!.Click();
|
||||
|
||||
// The inherited LeakTest slot now renders under LeftReactorSide, badged "inherited".
|
||||
var leftLiAfter = cut.FindAll("li[role='treeitem']")
|
||||
.First(li => li.QuerySelector(".tv-label")?.TextContent.Trim() == "LeftReactorSide");
|
||||
Assert.Contains("LeakTest", leftLiAfter.TextContent);
|
||||
Assert.Contains("inherited", leftLiAfter.TextContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SearchBox_IsPresentAndBound_ToTemplateFolderTreeFilter()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user