@* 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. *@
@RenderNodeLabel(node)
@RenderNodeContextMenu(node)
No templates yet. Use the buttons above (or right-click here) to create a folder or template.
@if (_showRootMenu)
{
}
}
@code {
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
private List _templates = new();
private List _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 _templatesById = new();
private Dictionary _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 BuildCompositionLeavesFor(Template owner)
{
var result = new List();
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:
@node.Name
@if (node.Children.Count > 0)
{
@node.Children.Count
}
break;
case TemplateTreeNodeKind.Template:
OpenTemplate(node.Id)">@node.Name
break;
case TemplateTreeNodeKind.Composition:
var composedId = _compositionsById.TryGetValue(node.Id, out var comp) ? comp.ComposedTemplateId : 0;
OpenTemplate(composedId)">@node.Name
break;
}
};
private RenderFragment RenderNodeContextMenu(TemplateTreeNode node) => __builder =>
{
switch (node.Kind)
{
case TemplateTreeNodeKind.Folder:
break;
case TemplateTreeNodeKind.Template:
var tmpl = _templatesById.TryGetValue(node.Id, out var t) ? t : null;
@if (tmpl != null)
{
}
@if (tmpl != null)
{
}
break;
case TemplateTreeNodeKind.Composition:
if (_compositionsById.TryGetValue(node.Id, out var ctx))
{
}
break;
}
};
// New-folder dialog: replaced the dedicated 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(
$"Move '{label}' to…",
ctx => @);
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 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(
$"Move '{label}' to…",
ctx => @);
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 OrderedSiblings(int folderId)
{
var folder = _folders.FirstOrDefault(f => f.Id == folderId);
if (folder == null) return new List();
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(
"Rename Folder",
ctx => @,
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(
$"Compose '{sourceName}' into…",
ctx => @);
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);
}
}
}