Templates page is now a tree-only browser; editing happens on a dedicated TemplateEdit page. Drag-drop is replaced by context-menu Move-to-Folder. TreeView gains Bootstrap Icons (chevron + per-kind glyphs), ancestor guide lines, defined hover/selected/focus tokens, and Escape-dismisses-menu per the new Visual Design Guide (V1-V7) in Component-TreeView.md.
491 lines
18 KiB
Plaintext
491 lines
18 KiB
Plaintext
@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
|
|
|
|
<div class="container-fluid mt-3">
|
|
<ToastNotification @ref="_toast" />
|
|
<ConfirmDialog @ref="_confirmDialog" />
|
|
|
|
<RenameFolderDialog @bind-IsVisible="_showRenameFolderDialog"
|
|
FolderId="_renameFolderId"
|
|
InitialName="@_renameFolderInitialName"
|
|
ErrorMessage="@_renameFolderError"
|
|
OnSubmit="SubmitRenameFolder" />
|
|
|
|
<NewFolderDialog @bind-IsVisible="_showNewFolderDialog"
|
|
ParentFolderId="_newFolderParentId"
|
|
ErrorMessage="@_newFolderError"
|
|
OnSubmit="SubmitNewFolder" />
|
|
|
|
<MoveTemplateDialog @bind-IsVisible="_showMoveTemplateDialog"
|
|
TemplateId="_moveTemplateId"
|
|
TemplateName="@_moveTemplateName"
|
|
FolderOptions="EnumerateFolderOptions()"
|
|
ErrorMessage="@_moveTemplateError"
|
|
OnSubmit="SubmitMoveTemplate" />
|
|
|
|
<MoveFolderDialog @bind-IsVisible="_showMoveFolderDialog"
|
|
FolderId="_moveFolderId"
|
|
FolderName="@_moveFolderName"
|
|
FolderOptions="EnumerateFolderOptionsExcluding(_moveFolderId)"
|
|
ErrorMessage="@_moveFolderError"
|
|
OnSubmit="SubmitMoveFolder" />
|
|
|
|
@if (_loading)
|
|
{
|
|
<LoadingSpinner IsLoading="true" />
|
|
}
|
|
else if (_errorMessage != null)
|
|
{
|
|
<div class="alert alert-danger">@_errorMessage</div>
|
|
}
|
|
else
|
|
{
|
|
<h6 class="mb-2">Templates</h6>
|
|
<div class="btn-group btn-group-sm mb-2">
|
|
<button class="btn btn-outline-secondary" title="New folder at root"
|
|
@onclick="() => OpenNewFolderDialog(null)">+ Folder</button>
|
|
<button class="btn btn-outline-secondary" title="New template at root"
|
|
@onclick='() => NavigationManager.NavigateTo("/design/templates/create")'>+ Template</button>
|
|
<button class="btn btn-outline-secondary" @onclick="() => _tree.ExpandAll()">Expand</button>
|
|
<button class="btn btn-outline-secondary" @onclick="() => _tree.CollapseAll()">Collapse</button>
|
|
</div>
|
|
|
|
<div style="max-height: calc(100vh - 160px); overflow-y: auto; padding: 4px;">
|
|
<TreeView @ref="_tree" TItem="TmplNode" Items="_treeRoots"
|
|
ChildrenSelector="n => n.Children"
|
|
HasChildrenSelector="n => n.Kind != TmplNodeKind.Composition && n.Children.Count > 0"
|
|
KeySelector="n => (object)n.Key"
|
|
StorageKey="templates-tree"
|
|
Selectable="true"
|
|
SelectedKeyChanged="OnTreeNodeSelected">
|
|
<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 to create a folder or template.</span>
|
|
</EmptyContent>
|
|
</TreeView>
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
@code {
|
|
private async Task<string> GetCurrentUserAsync()
|
|
{
|
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
|
return authState.User.FindFirst("Username")?.Value ?? "unknown";
|
|
}
|
|
|
|
private List<Template> _templates = new();
|
|
private List<TemplateFolder> _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<TmplNode> Children);
|
|
|
|
private List<TmplNode> _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<TmplNode>()));
|
|
|
|
// 2. Attach folder nodes by ParentFolderId
|
|
var roots = new List<TmplNode>();
|
|
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<TmplNode>()))
|
|
.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<TmplNode> 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<TmplNode> _tree = default!;
|
|
|
|
private RenderFragment RenderNodeLabel(TmplNode node) => __builder =>
|
|
{
|
|
switch (node.Kind)
|
|
{
|
|
case TmplNodeKind.Folder:
|
|
var folderOpen = _tree?.IsExpanded(node.Key) ?? false;
|
|
<span class="tv-glyph"><i class="bi @(folderOpen ? "bi-folder2-open" : "bi-folder")"></i></span>
|
|
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
|
|
title="@node.Label">@node.Label</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 TmplNodeKind.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.Label">@node.Label</span>
|
|
break;
|
|
|
|
case TmplNodeKind.Composition:
|
|
var composedName = _templates.FirstOrDefault(t => t.Id == node.Composition!.ComposedTemplateId)?.Name
|
|
?? $"#{node.Composition!.ComposedTemplateId}";
|
|
<span class="tv-glyph"><i class="bi bi-arrow-return-right"></i></span>
|
|
<span class="tv-label" title="@node.Label">
|
|
@node.Label
|
|
<span class="text-muted small ms-1">→ @composedName</span>
|
|
</span>
|
|
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:
|
|
<button class="dropdown-item" @onclick="() => OpenNewFolderDialog(node.EntityId)">New Folder</button>
|
|
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/create?folderId={node.EntityId}")'>New Template</button>
|
|
<button class="dropdown-item" @onclick="() => OpenRenameFolderDialog(node.EntityId, node.Label)">Rename</button>
|
|
<button class="dropdown-item" @onclick="() => OpenMoveFolderDialog(node.EntityId, node.Label)">Move to Folder…</button>
|
|
<div class="dropdown-divider"></div>
|
|
<button class="dropdown-item text-danger" @onclick="() => DeleteFolder(node.EntityId, node.Label)">Delete</button>
|
|
break;
|
|
|
|
case TmplNodeKind.Template:
|
|
<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>
|
|
break;
|
|
|
|
case TmplNodeKind.Composition:
|
|
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/{node.Composition!.ComposedTemplateId}")'>Open composed template</button>
|
|
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<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 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}");
|
|
}
|
|
}
|
|
}
|