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

@@ -113,11 +113,6 @@
private ScadaLink.CentralUI.ScriptAnalysis.CompositionContext? ActiveEditorParent =>
_editorParents.FirstOrDefault();
private bool _showCompForm;
private int _compComposedTemplateId;
private string _compInstanceName = string.Empty;
private string? _compFormError;
private ToastNotification _toast = default!;
protected override async Task OnParametersSetAsync()
@@ -341,15 +336,6 @@
Scripts <span class="badge bg-secondary">@_scripts.Count</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link @(_activeTab == "compositions" ? "active" : "")"
role="tab"
aria-selected="@(_activeTab == "compositions" ? "true" : "false")"
aria-controls="tmpl-tab-compositions"
@onclick='() => _activeTab = "compositions"'>
Compositions <span class="badge bg-secondary">@_compositions.Count</span>
</button>
</li>
</ul>
@if (_activeTab == "attributes")
@@ -364,10 +350,6 @@
{
<div role="tabpanel" id="tmpl-tab-scripts">@RenderScriptsTab()</div>
}
else if (_activeTab == "compositions")
{
<div role="tabpanel" id="tmpl-tab-compositions">@RenderCompositionsTab()</div>
}
};
private async Task DeleteTemplate()
@@ -1029,81 +1011,6 @@
else _toast.ShowError(result.Error);
}
// ---- Compositions Tab ----
private RenderFragment RenderCompositionsTab() => __builder =>
{
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="mb-0">Compositions</h5>
<button class="btn btn-primary btn-sm" @onclick="() => { _showCompForm = true; _compFormError = null; _compInstanceName = string.Empty; _compComposedTemplateId = 0; }">Add Composition</button>
</div>
@if (_showCompForm)
{
<div class="card mb-3">
<div class="card-header">Add Composition</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12">
<label class="form-label">Instance Name</label>
<input type="text" class="form-control" @bind="_compInstanceName" />
</div>
<div class="col-12">
<label class="form-label">Composed Template</label>
<select class="form-select" @bind="_compComposedTemplateId">
<option value="0">Select template...</option>
@foreach (var t in _templates.Where(t => _selectedTemplate == null || t.Id != _selectedTemplate.Id))
{
<option value="@t.Id">@t.Name</option>
}
</select>
</div>
@if (_compFormError != null)
{
<div class="col-12"><div class="text-danger small">@_compFormError</div></div>
}
<div class="col-12 text-end">
<button class="btn btn-outline-secondary me-1" @onclick="() => _showCompForm = false">Cancel</button>
<button class="btn btn-success" @onclick="AddComposition">Add</button>
</div>
</div>
</div>
</div>
}
<table class="table table-sm table-striped">
<thead class="table-light">
<tr>
<th>Instance Name</th>
<th>Composed Template</th>
<th style="width: 60px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var comp in _compositions)
{
<tr>
<td><code>@comp.InstanceName</code></td>
<td>@(_templates.FirstOrDefault(t => t.Id == comp.ComposedTemplateId)?.Name ?? $"#{comp.ComposedTemplateId}")</td>
<td>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm py-0 px-1"
data-bs-toggle="dropdown"
aria-expanded="false"
aria-label="@($"More actions for {comp.InstanceName}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item text-danger"
@onclick="() => DeleteComposition(comp)">Delete</button>
</li>
</ul>
</div>
</td>
</tr>
}
</tbody>
</table>
};
// ---- CRUD handlers ----
private async Task AddAttribute()
@@ -1237,42 +1144,6 @@
else { _toast.ShowError(result.Error); }
}
private async Task AddComposition()
{
if (_selectedTemplate == null) return;
_compFormError = null;
if (string.IsNullOrWhiteSpace(_compInstanceName)) { _compFormError = "Instance name is required."; return; }
if (_compComposedTemplateId == 0) { _compFormError = "Select a template."; return; }
var user = await GetCurrentUserAsync();
var result = await TemplateService.AddCompositionAsync(
_selectedTemplate.Id, _compComposedTemplateId, _compInstanceName.Trim(), user);
if (result.IsSuccess)
{
_showCompForm = false;
_toast.ShowSuccess($"Composition '{_compInstanceName}' added.");
await LoadAsync();
}
else
{
_compFormError = result.Error;
}
}
private async Task DeleteComposition(TemplateComposition comp)
{
var confirmed = await Dialog.ConfirmAsync("Delete Composition", $"Remove composition '{comp.InstanceName}'?", danger: true);
if (!confirmed) return;
var user = await GetCurrentUserAsync();
var result = await TemplateService.DeleteCompositionAsync(comp.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Composition '{comp.InstanceName}' removed.");
await LoadAsync();
}
else { _toast.ShowError(result.Error); }
}
// ---- Editor metadata builders ----
private async Task<IReadOnlyList<ScadaLink.CentralUI.ScriptAnalysis.CompositionContext>> BuildChildContextsAsync(

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,68 @@
@if (IsVisible)
{
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">Compose '@SourceName' into…</h6>
<button type="button" class="btn-close" @onclick="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label small text-muted mb-1">Parent template</label>
<select class="form-select form-select-sm" @bind="_parentTemplateId">
<option value="0" disabled selected>Select a parent template…</option>
@foreach (var opt in ParentOptions)
{
<option value="@opt.Id">@opt.Label</option>
}
</select>
</div>
<div class="mb-1">
<label class="form-label small text-muted mb-1">Slot name</label>
<input type="text" class="form-control form-control-sm"
placeholder="Slot name"
@bind="_slotName" />
</div>
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-2">@ErrorMessage</div> }
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="Submit" disabled="@(_parentTemplateId == 0 || string.IsNullOrWhiteSpace(_slotName))">Compose</button>
</div>
</div>
</div>
</div>
}
@code {
[Parameter] public bool IsVisible { get; set; }
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
[Parameter] public int SourceTemplateId { get; set; }
[Parameter] public string SourceName { get; set; } = string.Empty;
[Parameter] public IEnumerable<(int Id, string Label)> ParentOptions { get; set; } = Array.Empty<(int, string)>();
[Parameter] public string? ErrorMessage { get; set; }
[Parameter] public EventCallback<(int SourceTemplateId, int ParentTemplateId, string SlotName)> OnSubmit { get; set; }
private bool _wasVisible;
private int _parentTemplateId;
private string _slotName = string.Empty;
protected override void OnParametersSet()
{
if (IsVisible && !_wasVisible)
{
_parentTemplateId = 0;
_slotName = SourceName;
}
_wasVisible = IsVisible;
}
private async Task Close() => await IsVisibleChanged.InvokeAsync(false);
private async Task Submit()
{
if (_parentTemplateId == 0 || string.IsNullOrWhiteSpace(_slotName)) return;
await OnSubmit.InvokeAsync((SourceTemplateId, _parentTemplateId, _slotName.Trim()));
}
}