580 lines
25 KiB
Plaintext
580 lines
25 KiB
Plaintext
@page "/design/templates"
|
|
@using ZB.MOM.WW.ScadaBridge.Security
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
|
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
|
|
@using ZB.MOM.WW.ScadaBridge.TemplateEngine
|
|
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services
|
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
|
@inject ITemplateEngineRepository TemplateEngineRepository
|
|
@inject TemplateService TemplateService
|
|
@inject TemplateFolderService TemplateFolderService
|
|
@inject AuthenticationStateProvider AuthStateProvider
|
|
@inject NavigationManager NavigationManager
|
|
@inject IDialogService Dialog
|
|
|
|
<div class="container-fluid mt-3">
|
|
<ToastNotification @ref="_toast" />
|
|
|
|
@if (_loading)
|
|
{
|
|
<LoadingSpinner IsLoading="true" />
|
|
}
|
|
else if (_errorMessage != null)
|
|
{
|
|
<div class="alert alert-danger">@_errorMessage</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h4 class="mb-0">Templates</h4>
|
|
<div class="d-flex gap-2">
|
|
<div class="dropdown">
|
|
<button class="btn btn-outline-secondary btn-sm dropdown-toggle"
|
|
data-bs-toggle="dropdown" aria-expanded="false">
|
|
Bulk actions
|
|
</button>
|
|
<ul class="dropdown-menu dropdown-menu-end">
|
|
<li>
|
|
<button class="dropdown-item" @onclick="() => _tree.ExpandAll()">Expand all folders</button>
|
|
</li>
|
|
<li>
|
|
<button class="dropdown-item" @onclick="() => _tree.CollapseAll()">Collapse all folders</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<button class="btn btn-outline-secondary btn-sm"
|
|
title="New folder at root"
|
|
@onclick="() => OpenNewFolderDialog(null)">+ Folder</button>
|
|
<button class="btn btn-primary btn-sm"
|
|
title="New template at root"
|
|
@onclick='() => NavigationManager.NavigateTo("/design/templates/create")'>+ Template</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-3" style="max-width: 320px;">
|
|
<div class="input-group input-group-sm">
|
|
<input type="text" class="form-control form-control-sm"
|
|
placeholder="Search templates..."
|
|
value="@_searchText"
|
|
@oninput="e => _searchText = e.Value?.ToString() ?? string.Empty" />
|
|
@if (!string.IsNullOrEmpty(_searchText))
|
|
{
|
|
<button class="btn btn-sm btn-outline-secondary" type="button"
|
|
title="Clear search"
|
|
@onclick="() => _searchText = string.Empty">✕</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
@* The root zone fills the scroll area so a right-click on empty space
|
|
(or below the tree) opens a root-level context menu — New Folder /
|
|
New Template at root. Node-level right-clicks are handled by the
|
|
TreeView's own context menu (preventDefault'd there), so they don't
|
|
bubble to this handler. *@
|
|
<div class="tv-root-zone" style="max-height: calc(100vh - 200px); min-height: 120px; overflow-y: auto; padding: 4px;"
|
|
@oncontextmenu="OpenRootContextMenu" @oncontextmenu:preventDefault="true">
|
|
<TemplateFolderTree @ref="_tree"
|
|
Folders="_folders"
|
|
Templates="_templates"
|
|
SelectionMode="TreeViewSelectionMode.Single"
|
|
ExtraTemplateChildren="BuildCompositionLeavesFor"
|
|
Filter="@_searchText"
|
|
StorageKey="templates-tree">
|
|
<NodeContent Context="node">
|
|
@RenderNodeLabel(node)
|
|
</NodeContent>
|
|
<ContextMenu Context="node">
|
|
@RenderNodeContextMenu(node)
|
|
</ContextMenu>
|
|
<EmptyContent>
|
|
<span class="text-muted fst-italic">No templates yet. Use the buttons above (or right-click here) to create a folder or template.</span>
|
|
</EmptyContent>
|
|
</TemplateFolderTree>
|
|
</div>
|
|
|
|
@if (_showRootMenu)
|
|
{
|
|
<div class="tv-ctx-overlay" @onclick="DismissRootContextMenu"
|
|
style="position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:1049;background:transparent;"></div>
|
|
<div class="dropdown-menu show tv-root-menu" @onclick="DismissRootContextMenu"
|
|
style="position:fixed;top:@(_rootMenuY)px;left:@(_rootMenuX)px;z-index:1050;">
|
|
<button class="dropdown-item" @onclick="() => OpenNewFolderDialog(null)">New Folder</button>
|
|
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo("/design/templates/create")'>New Template</button>
|
|
</div>
|
|
}
|
|
}
|
|
</div>
|
|
|
|
@code {
|
|
// CentralUI-024: delegates to the shared helper so the claim type stays
|
|
// resolved through JwtTokenService rather than a duplicated magic string.
|
|
private Task<string> GetCurrentUserAsync()
|
|
=> AuthStateProvider.GetCurrentUsernameAsync();
|
|
|
|
private List<Template> _templates = new();
|
|
private List<TemplateFolder> _folders = new();
|
|
|
|
// Search text bound to the filter input; passed as Filter to TemplateFolderTree
|
|
// which handles substring matching and ancestor auto-expand internally.
|
|
private string _searchText = string.Empty;
|
|
|
|
private bool _loading = true;
|
|
private string? _errorMessage;
|
|
|
|
private ToastNotification _toast = default!;
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
await LoadTemplatesAsync();
|
|
}
|
|
|
|
private async Task LoadTemplatesAsync()
|
|
{
|
|
_loading = true;
|
|
try
|
|
{
|
|
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
|
|
_folders = (await TemplateEngineRepository.GetAllFoldersAsync()).ToList();
|
|
_templatesById = _templates.ToDictionary(t => t.Id);
|
|
_compositionsById = _templates
|
|
.SelectMany(t => t.Compositions)
|
|
.GroupBy(c => c.Id)
|
|
.ToDictionary(g => g.Key, g => g.First());
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_errorMessage = $"Failed to load templates: {ex.Message}";
|
|
}
|
|
_loading = false;
|
|
}
|
|
|
|
// ID lookups so RenderNodeLabel / RenderNodeContextMenu can resolve the
|
|
// entity behind a TemplateTreeNode (whose payload is just Id+Kind+Name).
|
|
private Dictionary<int, Template> _templatesById = new();
|
|
private Dictionary<int, TemplateComposition> _compositionsById = new();
|
|
|
|
// Composition-leaf builder for TemplateFolderTree's ExtraTemplateChildren
|
|
// hook: walks each template's compositions recursively so cascaded slots
|
|
// appear as nested children. The Transport Export wizard intentionally
|
|
// does NOT supply this hook — compositions aren't independently exportable.
|
|
private IReadOnlyList<TemplateTreeNode> BuildCompositionLeavesFor(Template owner)
|
|
{
|
|
var result = new List<TemplateTreeNode>();
|
|
foreach (var c in owner.Compositions.OrderBy(c => c.InstanceName, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
var node = new TemplateTreeNode
|
|
{
|
|
Kind = TemplateTreeNodeKind.Composition,
|
|
Id = c.Id,
|
|
Name = c.InstanceName,
|
|
};
|
|
|
|
if (_templatesById.TryGetValue(c.ComposedTemplateId, out var composed))
|
|
{
|
|
foreach (var nested in BuildCompositionLeavesFor(composed))
|
|
{
|
|
node.Children.Add(nested);
|
|
}
|
|
}
|
|
|
|
result.Add(node);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private TemplateFolderTree _tree = default!;
|
|
|
|
private void OpenTemplate(int templateId) =>
|
|
NavigationManager.NavigateTo($"/design/templates/{templateId}");
|
|
|
|
private RenderFragment RenderNodeLabel(TemplateTreeNode node) => __builder =>
|
|
{
|
|
switch (node.Kind)
|
|
{
|
|
case TemplateTreeNodeKind.Folder:
|
|
<span class="tv-glyph"><i class="bi bi-folder"></i></span>
|
|
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
|
|
title="@node.Name">@node.Name</span>
|
|
@if (node.Children.Count > 0)
|
|
{
|
|
<span class="tv-meta">
|
|
<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis">@node.Children.Count</span>
|
|
</span>
|
|
}
|
|
break;
|
|
|
|
case TemplateTreeNodeKind.Template:
|
|
<span class="tv-glyph"><i class="bi bi-file-earmark-text"></i></span>
|
|
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
|
|
title="@node.Name"
|
|
@ondblclick="() => OpenTemplate(node.Id)">@node.Name</span>
|
|
break;
|
|
|
|
case TemplateTreeNodeKind.Composition:
|
|
var composedId = _compositionsById.TryGetValue(node.Id, out var comp) ? comp.ComposedTemplateId : 0;
|
|
<span class="tv-glyph"><i class="bi bi-arrow-return-right"></i></span>
|
|
<span class="tv-label" title="@node.Name"
|
|
@ondblclick="() => OpenTemplate(composedId)">@node.Name</span>
|
|
break;
|
|
}
|
|
};
|
|
|
|
private RenderFragment RenderNodeContextMenu(TemplateTreeNode node) => __builder =>
|
|
{
|
|
switch (node.Kind)
|
|
{
|
|
case TemplateTreeNodeKind.Folder:
|
|
<button class="dropdown-item" @onclick="() => OpenNewFolderDialog(node.Id)">New Folder</button>
|
|
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/create?folderId={node.Id}")'>New Template</button>
|
|
<button class="dropdown-item" @onclick="() => OpenRenameFolderDialog(node.Id, node.Name)">Rename</button>
|
|
<button class="dropdown-item" @onclick="() => OpenMoveFolderDialog(node.Id, node.Name)">Move to Folder…</button>
|
|
<div class="dropdown-divider"></div>
|
|
<button class="dropdown-item" disabled="@IsFirstSibling(node.Id)"
|
|
@onclick="() => ReorderFolder(node.Id, ReorderDirection.Up)">Move up</button>
|
|
<button class="dropdown-item" disabled="@IsLastSibling(node.Id)"
|
|
@onclick="() => ReorderFolder(node.Id, ReorderDirection.Down)">Move down</button>
|
|
<div class="dropdown-divider"></div>
|
|
<button class="dropdown-item text-danger" @onclick="() => DeleteFolder(node.Id, node.Name)">Delete</button>
|
|
break;
|
|
|
|
case TemplateTreeNodeKind.Template:
|
|
var tmpl = _templatesById.TryGetValue(node.Id, out var t) ? t : null;
|
|
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/create?parentId={node.Id}")'>New Inheriting Template</button>
|
|
@if (tmpl != null)
|
|
{
|
|
<button class="dropdown-item" @onclick="() => OpenComposeDialog(tmpl)">Compose into…</button>
|
|
}
|
|
<button class="dropdown-item" @onclick="() => OpenMoveTemplateDialog(node.Id, node.Name)">Move to Folder…</button>
|
|
<div class="dropdown-divider"></div>
|
|
@if (tmpl != null)
|
|
{
|
|
<button class="dropdown-item text-danger" @onclick="() => DeleteTemplate(tmpl)">Delete</button>
|
|
}
|
|
break;
|
|
|
|
case TemplateTreeNodeKind.Composition:
|
|
if (_compositionsById.TryGetValue(node.Id, out var ctx))
|
|
{
|
|
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/{ctx.ComposedTemplateId}")'>Open composed template</button>
|
|
<button class="dropdown-item" @onclick="() => RenameComposition(ctx)">Rename…</button>
|
|
<div class="dropdown-divider"></div>
|
|
<button class="dropdown-item text-danger" @onclick="() => DeleteComposition(ctx)">Delete</button>
|
|
}
|
|
break;
|
|
}
|
|
};
|
|
|
|
// New-folder dialog: replaced the dedicated <NewFolderDialog> component with
|
|
// IDialogService.PromptAsync. Validation failures surface via toast instead of
|
|
// inline error text — the prompt UI doesn't have a slot for an error message.
|
|
private async Task OpenNewFolderDialog(int? parentFolderId)
|
|
{
|
|
var name = await Dialog.PromptAsync("New folder", "Folder name", placeholder: "Folder name");
|
|
if (string.IsNullOrWhiteSpace(name)) return;
|
|
|
|
var user = await GetCurrentUserAsync();
|
|
var result = await TemplateFolderService.CreateFolderAsync(name.Trim(), parentFolderId, user);
|
|
if (result.IsSuccess)
|
|
{
|
|
_toast.ShowSuccess($"Folder '{result.Value.Name}' created.");
|
|
await LoadTemplatesAsync();
|
|
}
|
|
else
|
|
{
|
|
_toast.ShowError(result.Error);
|
|
}
|
|
}
|
|
|
|
// Move-template dialog: opened via IDialogService.ShowAsync. The body returns
|
|
// the picked folder id (null = Root) on Move, or null on Cancel; validation runs
|
|
// here after the dialog closes and surfaces server guard failures via a toast.
|
|
private async Task OpenMoveTemplateDialog(int templateId, string label)
|
|
{
|
|
var result = await Dialog.ShowAsync<MoveTemplateDialog.MoveTemplateResult>(
|
|
$"Move '{label}' to…",
|
|
ctx => @<MoveTemplateDialog Context="ctx" FolderOptions="EnumerateFolderOptions()" />);
|
|
if (result is null) return;
|
|
|
|
var user = await GetCurrentUserAsync();
|
|
var moved = await TemplateService.MoveTemplateAsync(templateId, result.NewFolderId, user);
|
|
if (moved.IsSuccess)
|
|
{
|
|
_toast.ShowSuccess($"Template '{label}' moved.");
|
|
await LoadTemplatesAsync();
|
|
}
|
|
else
|
|
{
|
|
_toast.ShowError(moved.Error);
|
|
}
|
|
}
|
|
|
|
// Flat list of folders with indentation labels, for the picker.
|
|
private IEnumerable<(int? Id, string Label)> EnumerateFolderOptions()
|
|
{
|
|
yield return (null, "(Root)");
|
|
foreach (var f in WalkFolderHierarchy(_folders.Where(f => f.ParentFolderId == null), 0, excludeFolderId: null))
|
|
yield return f;
|
|
}
|
|
|
|
// Same as EnumerateFolderOptions, but prunes the given folder and all its descendants
|
|
// so the move dialog can't surface an obvious cycle target (server still validates).
|
|
private IEnumerable<(int? Id, string Label)> EnumerateFolderOptionsExcluding(int excludeFolderId)
|
|
{
|
|
yield return (null, "(Root)");
|
|
foreach (var f in WalkFolderHierarchy(_folders.Where(f => f.ParentFolderId == null), 0, excludeFolderId))
|
|
yield return f;
|
|
}
|
|
|
|
private IEnumerable<(int? Id, string Label)> WalkFolderHierarchy(IEnumerable<TemplateFolder> level, int depth, int? excludeFolderId)
|
|
{
|
|
foreach (var f in level.OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
if (excludeFolderId.HasValue && f.Id == excludeFolderId.Value) continue;
|
|
yield return ((int?)f.Id, new string(' ', depth * 2) + f.Name);
|
|
foreach (var sub in WalkFolderHierarchy(_folders.Where(c => c.ParentFolderId == f.Id), depth + 1, excludeFolderId))
|
|
yield return sub;
|
|
}
|
|
}
|
|
|
|
// Move-folder dialog: opened via IDialogService.ShowAsync. The picker prunes the
|
|
// folder + its descendants (server still validates cycles). The body returns the
|
|
// picked parent id (null = Root) on Move, or null on Cancel; server guard failures
|
|
// surface via a toast.
|
|
private async Task OpenMoveFolderDialog(int folderId, string label)
|
|
{
|
|
var result = await Dialog.ShowAsync<MoveFolderDialog.MoveFolderResult>(
|
|
$"Move '{label}' to…",
|
|
ctx => @<MoveFolderDialog Context="ctx" FolderOptions="EnumerateFolderOptionsExcluding(folderId)" />);
|
|
if (result is null) return;
|
|
|
|
var user = await GetCurrentUserAsync();
|
|
var moved = await TemplateFolderService.MoveFolderAsync(folderId, result.NewParentId, user);
|
|
if (moved.IsSuccess)
|
|
{
|
|
_toast.ShowSuccess($"Folder '{label}' moved.");
|
|
await LoadTemplatesAsync();
|
|
}
|
|
else
|
|
{
|
|
_toast.ShowError(moved.Error);
|
|
}
|
|
}
|
|
|
|
// ---- Sibling reorder (T23b) ----
|
|
// Dispatches the reorder the same way Move/Rename do — directly through
|
|
// TemplateFolderService (the backend swaps SortOrder with the adjacent
|
|
// same-parent sibling, no-op at the ends) — then reloads so the new order
|
|
// shows. Folder listing is ordered by SortOrder, so the swap is visible
|
|
// after reload.
|
|
private async Task ReorderFolder(int folderId, ReorderDirection direction)
|
|
{
|
|
var user = await GetCurrentUserAsync();
|
|
var result = await TemplateFolderService.ReorderFolderAsync(folderId, direction, user);
|
|
if (result.IsSuccess)
|
|
{
|
|
await LoadTemplatesAsync();
|
|
}
|
|
else
|
|
{
|
|
_toast.ShowError(result.Error);
|
|
}
|
|
}
|
|
|
|
// Sibling-order helpers for disabling Move up/down at the ends. Mirrors the
|
|
// backend ordering (ascending SortOrder, then Name). Returns false when the
|
|
// folder is unknown so the menu stays enabled and the backend no-op guards.
|
|
private bool IsFirstSibling(int folderId)
|
|
{
|
|
var ordered = OrderedSiblings(folderId);
|
|
return ordered.Count > 0 && ordered[0].Id == folderId;
|
|
}
|
|
|
|
private bool IsLastSibling(int folderId)
|
|
{
|
|
var ordered = OrderedSiblings(folderId);
|
|
return ordered.Count > 0 && ordered[^1].Id == folderId;
|
|
}
|
|
|
|
private List<TemplateFolder> OrderedSiblings(int folderId)
|
|
{
|
|
var folder = _folders.FirstOrDefault(f => f.Id == folderId);
|
|
if (folder == null) return new List<TemplateFolder>();
|
|
return _folders
|
|
.Where(f => f.ParentFolderId == folder.ParentFolderId)
|
|
.OrderBy(f => f.SortOrder)
|
|
.ThenBy(f => f.Name, StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
}
|
|
|
|
// ---- Root-level context menu (T23b) ----
|
|
// Right-click on the tree zone (empty space or below the tree) offers
|
|
// New Folder / New Template at root. Node right-clicks are handled by the
|
|
// TreeView's own context menu and never reach here.
|
|
private bool _showRootMenu;
|
|
private double _rootMenuX;
|
|
private double _rootMenuY;
|
|
|
|
private void OpenRootContextMenu(MouseEventArgs e)
|
|
{
|
|
_rootMenuX = e.ClientX;
|
|
_rootMenuY = e.ClientY;
|
|
_showRootMenu = true;
|
|
}
|
|
|
|
private void DismissRootContextMenu() => _showRootMenu = false;
|
|
|
|
// Rename folder dialog: opened via IDialogService.ShowAsync. The body seeds the
|
|
// input from the current name and returns the trimmed new name on Save, or null on
|
|
// Cancel; server guard failures surface via a toast.
|
|
private async Task OpenRenameFolderDialog(int folderId, string currentName)
|
|
{
|
|
var newName = await Dialog.ShowAsync<string>(
|
|
"Rename Folder",
|
|
ctx => @<RenameFolderDialog Context="ctx" InitialName="@currentName" />,
|
|
size: "modal-sm");
|
|
if (string.IsNullOrWhiteSpace(newName)) return;
|
|
|
|
var user = await GetCurrentUserAsync();
|
|
var result = await TemplateFolderService.RenameFolderAsync(folderId, newName, user);
|
|
if (result.IsSuccess)
|
|
{
|
|
_toast.ShowSuccess("Folder renamed.");
|
|
await LoadTemplatesAsync();
|
|
}
|
|
else
|
|
{
|
|
_toast.ShowError(result.Error);
|
|
}
|
|
}
|
|
|
|
private async Task DeleteFolder(int folderId, string label)
|
|
{
|
|
var confirmed = await Dialog.ConfirmAsync("Delete Folder", $"Delete folder '{label}'?", danger: true);
|
|
if (!confirmed) return;
|
|
|
|
var user = await GetCurrentUserAsync();
|
|
var result = await TemplateFolderService.DeleteFolderAsync(folderId, user);
|
|
if (result.IsSuccess)
|
|
{
|
|
_toast.ShowSuccess($"Folder '{label}' deleted.");
|
|
await LoadTemplatesAsync();
|
|
}
|
|
else
|
|
{
|
|
_toast.ShowError(result.Error);
|
|
}
|
|
}
|
|
|
|
private async Task DeleteTemplate(Template template)
|
|
{
|
|
var confirmed = await Dialog.ConfirmAsync(
|
|
"Delete Template",
|
|
$"Delete template '{template.Name}'? This will fail if instances or child templates reference it.",
|
|
danger: true);
|
|
if (!confirmed) return;
|
|
|
|
try
|
|
{
|
|
var user = await GetCurrentUserAsync();
|
|
var result = await TemplateService.DeleteTemplateAsync(template.Id, user);
|
|
if (result.IsSuccess)
|
|
{
|
|
_toast.ShowSuccess($"Template '{template.Name}' deleted.");
|
|
await LoadTemplatesAsync();
|
|
}
|
|
else
|
|
{
|
|
_toast.ShowError(result.Error);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_toast.ShowError($"Delete failed: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
// ---- Compose-into dialog ----
|
|
// Opened via IDialogService.ShowAsync. The body returns the chosen parent template
|
|
// + slot name on Compose (its own client-side guard keeps the button disabled until
|
|
// both are set), or null on Cancel; server guard failures surface via a toast.
|
|
private async Task OpenComposeDialog(Template source)
|
|
{
|
|
var sourceId = source.Id;
|
|
var sourceName = source.Name;
|
|
var result = await Dialog.ShowAsync<ComposeIntoDialog.ComposeResult>(
|
|
$"Compose '{sourceName}' into…",
|
|
ctx => @<ComposeIntoDialog Context="ctx"
|
|
SourceName="@sourceName"
|
|
ParentOptions="EnumerateComposableParents(sourceId)" />);
|
|
if (result is null) return;
|
|
|
|
var user = await GetCurrentUserAsync();
|
|
var composed = await TemplateService.AddCompositionAsync(result.ParentTemplateId, sourceId, result.SlotName, user);
|
|
if (composed.IsSuccess)
|
|
{
|
|
_toast.ShowSuccess($"Composed '{sourceName}' as '{result.SlotName}'.");
|
|
await LoadTemplatesAsync();
|
|
}
|
|
else
|
|
{
|
|
_toast.ShowError(composed.Error);
|
|
}
|
|
}
|
|
|
|
// 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));
|
|
}
|
|
|
|
// ---- 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);
|
|
}
|
|
}
|
|
}
|