feat(templates/ui): manage compositions from the tree

Move composition CRUD off the TemplateEdit page and onto the tree
context menu, matching Aveva's Template Toolbox flow.

- New ComposeIntoDialog: pick a parent template, slot name (defaults
  to the source template's name).
- "Compose into…" on every base template's context menu (kebab + right
  click) opens the dialog and calls AddCompositionAsync.
- "Rename…" on composition leaves opens a prompt and calls
  TemplateService.RenameCompositionAsync. The owning composition row
  AND its owned derived template are renamed atomically; duplicate
  slot names or derived-name collisions abort with a clear error.
- "Delete" on composition leaves confirms + cascade-deletes the
  composition (and its derived template via DeleteCompositionAsync).
- "New Derived Template" menu item renamed to "New Inheriting Template"
  to disambiguate from the new derive-on-compose meaning.

TemplateEdit's Compositions tab, Add Composition form, and
Add/DeleteComposition handlers + state fields are deleted — the tree
is now the single source of truth.
This commit is contained in:
Joseph Doherty
2026-05-12 09:22:55 -04:00
parent 552c9e4065
commit 5c3dc79b8a
5 changed files with 253 additions and 131 deletions

View File

@@ -354,6 +354,45 @@ public class TemplateServiceTests
Assert.Contains("already exists", result.Error);
}
[Fact]
public async Task RenameComposition_RenamesSlotAndDerivedTemplate()
{
var composition = new TemplateComposition("OldSlot") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
var owner = new Template("Pump") { Id = 1 };
owner.Compositions.Add(composition);
var derived = new Template("Pump.OldSlot") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(owner);
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { owner, derived });
var result = await _service.RenameCompositionAsync(50, "NewSlot", "admin");
Assert.True(result.IsSuccess);
Assert.Equal("NewSlot", result.Value.InstanceName);
Assert.Equal("Pump.NewSlot", derived.Name);
}
[Fact]
public async Task RenameComposition_DuplicateName_Fails()
{
var composition = new TemplateComposition("OldSlot") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
var sibling = new TemplateComposition("NewSlot") { Id = 51, TemplateId = 1, ComposedTemplateId = 78 };
var owner = new Template("Pump") { Id = 1 };
owner.Compositions.Add(composition);
owner.Compositions.Add(sibling);
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(owner);
var result = await _service.RenameCompositionAsync(50, "NewSlot", "admin");
Assert.True(result.IsFailure);
Assert.Contains("already exists", result.Error);
}
[Fact]
public async Task DeleteComposition_CascadesDerivedTemplate()
{