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

@@ -654,6 +654,50 @@ public class TemplateService
return Result<TemplateComposition>.Success(composition);
}
public async Task<Result<TemplateComposition>> RenameCompositionAsync(
int compositionId,
string newInstanceName,
string user,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(newInstanceName))
return Result<TemplateComposition>.Failure("Slot name is required.");
var composition = await _repository.GetTemplateCompositionByIdAsync(compositionId, cancellationToken);
if (composition == null)
return Result<TemplateComposition>.Failure($"Composition with ID {compositionId} not found.");
if (composition.InstanceName == newInstanceName) return Result<TemplateComposition>.Success(composition);
var owner = await _repository.GetTemplateByIdAsync(composition.TemplateId, cancellationToken);
if (owner == null)
return Result<TemplateComposition>.Failure($"Owning template with ID {composition.TemplateId} not found.");
if (owner.Compositions.Any(c => c.Id != compositionId && c.InstanceName == newInstanceName))
return Result<TemplateComposition>.Failure(
$"Slot name '{newInstanceName}' already exists on '{owner.Name}'.");
var derived = await _repository.GetTemplateByIdAsync(composition.ComposedTemplateId, cancellationToken);
if (derived != null && derived.IsDerived && derived.OwnerCompositionId == compositionId)
{
var newDerivedName = $"{owner.Name}.{newInstanceName}";
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
if (allTemplates.Any(t => t.Id != derived.Id && t.Name == newDerivedName))
return Result<TemplateComposition>.Failure(
$"Cannot rename derived template to '{newDerivedName}': a template with that name already exists.");
derived.Name = newDerivedName;
await _repository.UpdateTemplateAsync(derived, cancellationToken);
}
composition.InstanceName = newInstanceName;
await _repository.UpdateTemplateCompositionAsync(composition, cancellationToken);
await _auditService.LogAsync(user, "Update", "TemplateComposition", compositionId.ToString(), newInstanceName, composition, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<TemplateComposition>.Success(composition);
}
public async Task<Result<bool>> DeleteCompositionAsync(
int compositionId,
string user,