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
@@ -35,6 +35,13 @@
ErrorMessage="@_moveFolderError"
OnSubmit="SubmitMoveFolder" />
<ComposeIntoDialog @bind-IsVisible="_showComposeDialog"
SourceTemplateId="_composeSourceId"
SourceName="@_composeSourceName"
ParentOptions="EnumerateComposableParents(_composeSourceId)"
ErrorMessage="@_composeError"
OnSubmit="SubmitCompose" />
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
@@ -299,7 +306,8 @@
break;
case TmplNodeKind.Template:
<li><button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/create?parentId={node.EntityId}")'>New Derived Template</button></li>
<li><button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/create?parentId={node.EntityId}")'>New Inheriting Template</button></li>
<li><button class="dropdown-item" @onclick="() => OpenComposeDialog(node.Template!)">Compose into…</button></li>
<li><button class="dropdown-item" @onclick="() => OpenMoveTemplateDialog(node.EntityId, node.Label)">Move to Folder…</button></li>
<li><hr class="dropdown-divider" /></li>
<li><button class="dropdown-item text-danger" @onclick="() => DeleteTemplate(node.Template!)">Delete</button></li>
@@ -307,6 +315,9 @@
case TmplNodeKind.Composition:
<li><button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/{node.Composition!.ComposedTemplateId}")'>Open composed template</button></li>
<li><button class="dropdown-item" @onclick="() => RenameComposition(node.Composition!)">Rename…</button></li>
<li><hr class="dropdown-divider" /></li>
<li><button class="dropdown-item text-danger" @onclick="() => DeleteComposition(node.Composition!)">Delete</button></li>
break;
}
</ul>
@@ -345,7 +356,8 @@
break;
case TmplNodeKind.Template:
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/create?parentId={node.EntityId}")'>New Derived Template</button>
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/create?parentId={node.EntityId}")'>New Inheriting Template</button>
<button class="dropdown-item" @onclick="() => OpenComposeDialog(node.Template!)">Compose into…</button>
<button class="dropdown-item" @onclick="() => OpenMoveTemplateDialog(node.EntityId, node.Label)">Move to Folder…</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger" @onclick="() => DeleteTemplate(node.Template!)">Delete</button>
@@ -353,6 +365,9 @@
case TmplNodeKind.Composition:
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/{node.Composition!.ComposedTemplateId}")'>Open composed template</button>
<button class="dropdown-item" @onclick="() => RenameComposition(node.Composition!)">Rename…</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger" @onclick="() => DeleteComposition(node.Composition!)">Delete</button>
break;
}
};
@@ -544,4 +559,89 @@
_toast.ShowError($"Delete failed: {ex.Message}");
}
}
// ---- Compose-into dialog ----
private bool _showComposeDialog;
private int _composeSourceId;
private string _composeSourceName = string.Empty;
private string? _composeError;
private void OpenComposeDialog(Template source)
{
_composeSourceId = source.Id;
_composeSourceName = source.Name;
_composeError = null;
_showComposeDialog = true;
}
// Possible parents for a compose: every non-derived template except the source itself.
// Server still validates cycles + collisions; the picker just trims obvious bad choices.
private IEnumerable<(int Id, string Label)> EnumerateComposableParents(int sourceId)
{
return _templates
.Where(t => !t.IsDerived && t.Id != sourceId)
.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
.Select(t => (t.Id, t.Name));
}
private async Task SubmitCompose((int SourceTemplateId, int ParentTemplateId, string SlotName) req)
{
_composeError = null;
var user = await GetCurrentUserAsync();
var result = await TemplateService.AddCompositionAsync(req.ParentTemplateId, req.SourceTemplateId, req.SlotName, user);
if (result.IsSuccess)
{
_showComposeDialog = false;
_toast.ShowSuccess($"Composed '{_composeSourceName}' as '{req.SlotName}'.");
await LoadTemplatesAsync();
}
else
{
_composeError = result.Error;
}
}
// ---- Composition leaf: rename + delete ----
private async Task RenameComposition(TemplateComposition composition)
{
var newName = await Dialog.PromptAsync(
"Rename slot",
$"New name for slot '{composition.InstanceName}':",
initialValue: composition.InstanceName,
placeholder: "Slot name");
if (string.IsNullOrWhiteSpace(newName) || newName.Trim() == composition.InstanceName) return;
var user = await GetCurrentUserAsync();
var result = await TemplateService.RenameCompositionAsync(composition.Id, newName.Trim(), user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Slot renamed to '{newName.Trim()}'.");
await LoadTemplatesAsync();
}
else
{
_toast.ShowError(result.Error);
}
}
private async Task DeleteComposition(TemplateComposition composition)
{
var confirmed = await Dialog.ConfirmAsync(
"Delete composition",
$"Delete slot '{composition.InstanceName}'? This removes the derived template and any overrides on it.",
danger: true);
if (!confirmed) return;
var user = await GetCurrentUserAsync();
var result = await TemplateService.DeleteCompositionAsync(composition.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Composition '{composition.InstanceName}' removed.");
await LoadTemplatesAsync();
}
else
{
_toast.ShowError(result.Error);
}
}
}