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:
@@ -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}",
|
||||
|
||||
Reference in New Issue
Block a user