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:
Joseph Doherty
2026-06-24 19:29:48 -04:00
parent 7747f25c9e
commit 1f261263b2
5 changed files with 147 additions and 11 deletions
@@ -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}",