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:
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user