@page "/design/templates"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Templates
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.TemplateEngine
@using ScadaLink.TemplateEngine.Services
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject TemplateService TemplateService
@inject TemplateFolderService TemplateFolderService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@if (_loading)
{
}
else if (_errorMessage != null)
{
@_errorMessage
}
else
{
Templates
@RenderNodeLabel(node)
@RenderNodeContextMenu(node)
No templates yet. Use the buttons above to create a folder or template.
}
@code {
private async Task GetCurrentUserAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
return authState.User.FindFirst("Username")?.Value ?? "unknown";
}
private List _templates = new();
private List _folders = new();
private bool _loading = true;
private string? _errorMessage;
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = 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();
BuildTemplateTree();
}
catch (Exception ex)
{
_errorMessage = $"Failed to load templates: {ex.Message}";
}
_loading = false;
}
private enum TmplNodeKind { Folder, Template, Composition }
private record TmplNode(
string Key,
TmplNodeKind Kind,
int EntityId,
string Label,
int? ParentFolderId,
int? OwnerTemplateId,
Template? Template,
TemplateComposition? Composition,
List Children);
private List _treeRoots = new();
private void BuildTemplateTree()
{
// 1. Folder nodes keyed by id
var folderNodes = _folders.ToDictionary(
f => f.Id,
f => new TmplNode(
Key: $"f:{f.Id}",
Kind: TmplNodeKind.Folder,
EntityId: f.Id,
Label: f.Name,
ParentFolderId: f.ParentFolderId,
OwnerTemplateId: null,
Template: null,
Composition: null,
Children: new List()));
// 2. Attach folder nodes by ParentFolderId
var roots = new List();
foreach (var f in _folders.OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase))
{
var node = folderNodes[f.Id];
if (f.ParentFolderId is int pid && folderNodes.TryGetValue(pid, out var parent))
parent.Children.Add(node);
else
roots.Add(node);
}
// 3. Template nodes with composition leaves
foreach (var t in _templates.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase))
{
var compChildren = t.Compositions
.OrderBy(c => c.InstanceName, StringComparer.OrdinalIgnoreCase)
.Select(c => new TmplNode(
Key: $"c:{c.Id}",
Kind: TmplNodeKind.Composition,
EntityId: c.Id,
Label: c.InstanceName,
ParentFolderId: null,
OwnerTemplateId: t.Id,
Template: null,
Composition: c,
Children: new List()))
.ToList();
var tNode = new TmplNode(
Key: $"t:{t.Id}",
Kind: TmplNodeKind.Template,
EntityId: t.Id,
Label: t.Name,
ParentFolderId: t.FolderId,
OwnerTemplateId: null,
Template: t,
Composition: null,
Children: compChildren);
if (t.FolderId is int fid && folderNodes.TryGetValue(fid, out var parentFolder))
parentFolder.Children.Add(tNode);
else
roots.Add(tNode);
}
// 4. Sort each level: folders before templates, alphabetical
SortChildren(roots);
foreach (var node in folderNodes.Values)
SortChildren(node.Children);
_treeRoots = roots;
}
private static void SortChildren(List children)
{
children.Sort((a, b) =>
{
var kindOrder = (int)a.Kind - (int)b.Kind;
if (kindOrder != 0) return kindOrder;
return string.Compare(a.Label, b.Label, StringComparison.OrdinalIgnoreCase);
});
}
private TreeView _tree = default!;
private RenderFragment RenderNodeLabel(TmplNode node) => __builder =>
{
switch (node.Kind)
{
case TmplNodeKind.Folder:
var folderOpen = _tree?.IsExpanded(node.Key) ?? false;
@node.Label
@if (node.Children.Count > 0)
{
@node.Children.Count
}
break;
case TmplNodeKind.Template:
@node.Label
break;
case TmplNodeKind.Composition:
var composedName = _templates.FirstOrDefault(t => t.Id == node.Composition!.ComposedTemplateId)?.Name
?? $"#{node.Composition!.ComposedTemplateId}";
@node.Label
→ @composedName
break;
}
};
private void OnTreeNodeSelected(object? key)
{
if (key is not string s) return;
if (s.StartsWith("t:") && int.TryParse(s[2..], out var tid))
{
NavigationManager.NavigateTo($"/design/templates/{tid}");
}
else if (s.StartsWith("c:") && int.TryParse(s[2..], out var cid))
{
var comp = _templates.SelectMany(t => t.Compositions).FirstOrDefault(c => c.Id == cid);
if (comp != null)
{
NavigationManager.NavigateTo($"/design/templates/{comp.ComposedTemplateId}");
}
}
// Folder selection is intentionally a no-op (use right-click for folder actions).
}
private RenderFragment RenderNodeContextMenu(TmplNode node) => __builder =>
{
switch (node.Kind)
{
case TmplNodeKind.Folder:
break;
case TmplNodeKind.Template:
break;
case TmplNodeKind.Composition:
break;
}
};
// New-folder dialog state
private bool _showNewFolderDialog;
private int? _newFolderParentId;
private string? _newFolderError;
private void OpenNewFolderDialog(int? parentFolderId)
{
_newFolderParentId = parentFolderId;
_newFolderError = null;
_showNewFolderDialog = true;
}
private async Task SubmitNewFolder((int? ParentFolderId, string Name) req)
{
_newFolderError = null;
var user = await GetCurrentUserAsync();
var result = await TemplateFolderService.CreateFolderAsync(req.Name, req.ParentFolderId, user);
if (result.IsSuccess)
{
_showNewFolderDialog = false;
_toast.ShowSuccess($"Folder '{result.Value.Name}' created.");
await LoadTemplatesAsync();
}
else
{
_newFolderError = result.Error;
}
}
// Move-template dialog state
private bool _showMoveTemplateDialog;
private int _moveTemplateId;
private string _moveTemplateName = string.Empty;
private string? _moveTemplateError;
private void OpenMoveTemplateDialog(int templateId, string label)
{
_moveTemplateId = templateId;
_moveTemplateName = label;
_moveTemplateError = null;
_showMoveTemplateDialog = true;
}
private async Task SubmitMoveTemplate((int TemplateId, int? NewFolderId) req)
{
_moveTemplateError = null;
var user = await GetCurrentUserAsync();
var result = await TemplateService.MoveTemplateAsync(req.TemplateId, req.NewFolderId, user);
if (result.IsSuccess)
{
_showMoveTemplateDialog = false;
_toast.ShowSuccess($"Template '{_moveTemplateName}' moved.");
await LoadTemplatesAsync();
}
else
{
_moveTemplateError = result.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 state
private bool _showMoveFolderDialog;
private int _moveFolderId;
private string _moveFolderName = string.Empty;
private string? _moveFolderError;
private void OpenMoveFolderDialog(int folderId, string label)
{
_moveFolderId = folderId;
_moveFolderName = label;
_moveFolderError = null;
_showMoveFolderDialog = true;
}
private async Task SubmitMoveFolder((int FolderId, int? NewParentId) req)
{
_moveFolderError = null;
var user = await GetCurrentUserAsync();
var result = await TemplateFolderService.MoveFolderAsync(req.FolderId, req.NewParentId, user);
if (result.IsSuccess)
{
_showMoveFolderDialog = false;
_toast.ShowSuccess($"Folder '{_moveFolderName}' moved.");
await LoadTemplatesAsync();
}
else
{
_moveFolderError = result.Error;
}
}
// Rename folder dialog state
private bool _showRenameFolderDialog;
private int _renameFolderId;
private string _renameFolderInitialName = string.Empty;
private string? _renameFolderError;
private void OpenRenameFolderDialog(int folderId, string currentName)
{
_renameFolderId = folderId;
_renameFolderInitialName = currentName;
_renameFolderError = null;
_showRenameFolderDialog = true;
}
private async Task SubmitRenameFolder((int FolderId, string NewName) req)
{
_renameFolderError = null;
var user = await GetCurrentUserAsync();
var result = await TemplateFolderService.RenameFolderAsync(req.FolderId, req.NewName, user);
if (result.IsSuccess)
{
_showRenameFolderDialog = false;
_toast.ShowSuccess("Folder renamed.");
await LoadTemplatesAsync();
}
else
{
_renameFolderError = result.Error;
}
}
private async Task DeleteFolder(int folderId, string label)
{
var confirmed = await _confirmDialog.ShowAsync($"Delete folder '{label}'?", "Delete Folder");
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 _confirmDialog.ShowAsync(
$"Delete template '{template.Name}'? This will fail if instances or child templates reference it.",
"Delete Template");
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}");
}
}
}