Files
scadalink-design/src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor
Joseph Doherty f3b33e7e1d fix(ui/treeview): union sessionStorage keys instead of overwriting
The previous fix tried to defer page-side RevealNode to the second
render so TreeView's async sessionStorage load could finish first. In
practice Blazor Server didn't always fire a second OnAfterRenderAsync
on the page after the deep-link load, so the reveal never ran.

Real fix: change TreeView's storage-load to UNION the restored keys
with whatever's already in _expandedKeys, instead of REPLACING. That
way the page can call RevealNode whenever it wants and the storage
restore can't clobber the reveal regardless of completion order. The
page-side guard simplifies back to a one-shot reveal on first render.

Semantic note: if a deep-link reveal expands an ancestor that the user
had previously collapsed, the deep link wins. Intentional — the URL
expresses the navigation intent.
2026-05-11 12:42:38 -04:00

1373 lines
56 KiB
Plaintext

@page "/design/templates"
@page "/design/templates/{TemplateIdParam:int}"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Templates
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Commons.Types.Enums
@using ScadaLink.TemplateEngine
@using ScadaLink.TemplateEngine.Services
@using ScadaLink.TemplateEngine.Validation
@using ScadaLink.TemplateEngine.Flattening
@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" />
<NewTemplateDialog @bind-IsVisible="_showNewTemplateDialog"
FolderId="_newTemplateFolderId"
ErrorMessage="@_newTemplateError"
OnSubmit="SubmitNewTemplate" />
<MoveTemplateDialog @bind-IsVisible="_showMoveTemplateDialog"
TemplateId="_moveTemplateId"
TemplateName="@_moveTemplateName"
FolderOptions="EnumerateFolderOptions()"
ErrorMessage="@_moveTemplateError"
OnSubmit="SubmitMoveTemplate" />
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else
{
<div class="row g-2">
<div class="col-md-4 col-lg-3">
<h6 class="mb-2">Templates</h6>
<div class="btn-group btn-group-sm w-100 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="() => OpenNewTemplateDialog(null)">+ 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;">
<div class="templates-root-dropzone"
@ondragover:preventDefault="true" @ondrop="OnDropOnRoot"
style="min-height: 100%; 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>
</div>
<div class="col-md-8 col-lg-9">
@if (_selectedTemplate == null)
{
<div class="text-muted fst-italic mt-3">
Select a template on the left to view or edit.
</div>
}
else
{
@RenderTemplateDetail()
}
</div>
</div>
}
</div>
@code {
private async Task<string> GetCurrentUserAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
return authState.User.FindFirst("Username")?.Value ?? "unknown";
}
[Parameter] public int TemplateIdParam { get; set; }
private List<Template> _templates = new();
private List<TemplateFolder> _folders = new();
private Template? _selectedTemplate;
private List<TemplateAttribute> _attributes = new();
private List<TemplateAlarm> _alarms = new();
private List<TemplateScript> _scripts = new();
private List<TemplateComposition> _compositions = new();
private bool _loading = true;
private string? _errorMessage;
private string _activeTab = "attributes";
// Edit properties
private string _editName = string.Empty;
private string? _editDescription;
private int _editParentId;
// Validation
private bool _validating;
private Commons.Types.Flattening.ValidationResult? _validationResult;
// Member add forms
private bool _showAttrForm;
private string _attrName = string.Empty;
private string? _attrValue;
private DataType _attrDataType;
private bool _attrIsLocked;
private string? _attrDataSourceRef;
private string? _attrFormError;
private bool _showAlarmForm;
private string _alarmName = string.Empty;
private int _alarmPriority;
private AlarmTriggerType _alarmTriggerType;
private string? _alarmTriggerConfig;
private bool _alarmIsLocked;
private string? _alarmFormError;
private bool _showScriptForm;
private string _scriptName = string.Empty;
private string _scriptCode = string.Empty;
private string? _scriptTriggerType;
private string? _scriptTriggerConfig;
private bool _scriptIsLocked;
private string? _scriptFormError;
private bool _showCompForm;
private int _compComposedTemplateId;
private string _compInstanceName = string.Empty;
private string? _compFormError;
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!;
protected override async Task OnInitializedAsync()
{
await LoadTemplatesAsync();
if (TemplateIdParam > 0)
{
await SelectTemplate(TemplateIdParam);
}
}
private bool _revealApplied;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!_revealApplied && TemplateIdParam > 0 && _tree != null)
{
_revealApplied = true;
await _tree.RevealNode($"t:{TemplateIdParam}", select: true);
}
}
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 async Task SelectTemplate(int templateId)
{
try
{
_selectedTemplate = await TemplateEngineRepository.GetTemplateWithChildrenAsync(templateId)
?? _templates.FirstOrDefault(t => t.Id == templateId);
if (_selectedTemplate == null) return;
_editName = _selectedTemplate.Name;
_editDescription = _selectedTemplate.Description;
_editParentId = _selectedTemplate.ParentTemplateId ?? 0;
_attributes = (await TemplateEngineRepository.GetAttributesByTemplateIdAsync(templateId)).ToList();
_alarms = (await TemplateEngineRepository.GetAlarmsByTemplateIdAsync(templateId)).ToList();
_scripts = (await TemplateEngineRepository.GetScriptsByTemplateIdAsync(templateId)).ToList();
_compositions = (await TemplateEngineRepository.GetCompositionsByTemplateIdAsync(templateId)).ToList();
_validationResult = null;
}
catch (Exception ex)
{
_toast.ShowError($"Failed to load template: {ex.Message}");
}
}
private TreeView<TmplNode> _tree = default!;
// ---- Drag-and-drop state ----
private (TmplNodeKind kind, int id)? _dragPayload;
private string? _dragOverKey;
private void OnDragStart(TmplNode node)
{
if (node.Kind == TmplNodeKind.Composition) return;
_dragPayload = (node.Kind, node.EntityId);
}
private void OnDragEnd()
{
_dragPayload = null;
_dragOverKey = null;
}
private void OnDragEnter(TmplNode targetFolder)
{
if (_dragPayload == null) return;
if (targetFolder.Kind != TmplNodeKind.Folder) return;
_dragOverKey = targetFolder.Key;
}
private void OnDragLeave(TmplNode targetFolder)
{
if (_dragOverKey == targetFolder.Key) _dragOverKey = null;
}
private async Task OnDrop(TmplNode targetFolder)
{
if (_dragPayload is not { } payload) return;
if (targetFolder.Kind != TmplNodeKind.Folder) return;
var user = await GetCurrentUserAsync();
if (payload.kind == TmplNodeKind.Folder)
{
var result = await TemplateFolderService.MoveFolderAsync(payload.id, targetFolder.EntityId, user);
if (result.IsFailure) _toast.ShowError(result.Error);
}
else if (payload.kind == TmplNodeKind.Template)
{
var result = await TemplateService.MoveTemplateAsync(payload.id, targetFolder.EntityId, user);
if (result.IsFailure) _toast.ShowError(result.Error);
}
OnDragEnd();
await LoadTemplatesAsync();
}
private async Task OnDropOnRoot()
{
if (_dragPayload is not { } payload) return;
var user = await GetCurrentUserAsync();
if (payload.kind == TmplNodeKind.Folder)
{
var result = await TemplateFolderService.MoveFolderAsync(payload.id, null, user);
if (result.IsFailure) _toast.ShowError(result.Error);
}
else if (payload.kind == TmplNodeKind.Template)
{
var result = await TemplateService.MoveTemplateAsync(payload.id, null, user);
if (result.IsFailure) _toast.ShowError(result.Error);
}
OnDragEnd();
await LoadTemplatesAsync();
}
private RenderFragment RenderTemplateDetail() => __builder =>
{
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="d-inline mb-0">@_selectedTemplate!.Name</h4>
@if (_selectedTemplate.ParentTemplateId.HasValue)
{
<span class="text-muted ms-2">inherits @(_templates.FirstOrDefault(t => t.Id == _selectedTemplate.ParentTemplateId)?.Name)</span>
}
</div>
<div>
<button class="btn btn-outline-info btn-sm me-1" @onclick="RunValidation" disabled="@_validating">
@if (_validating)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
Validate
</button>
</div>
</div>
@* Validation results *@
@if (_validationResult != null)
{
<div class="mb-3">
@if (_validationResult.Errors.Count > 0)
{
<div class="alert alert-danger py-2">
<strong>Validation Errors (@_validationResult.Errors.Count)</strong>
<ul class="mb-0 small">
@foreach (var err in _validationResult.Errors)
{
<li>[@err.Category] @err.Message @(err.EntityName != null ? $"({err.EntityName})" : "")</li>
}
</ul>
</div>
}
@if (_validationResult.Warnings.Count > 0)
{
<div class="alert alert-warning py-2">
<strong>Warnings (@_validationResult.Warnings.Count)</strong>
<ul class="mb-0 small">
@foreach (var warn in _validationResult.Warnings)
{
<li>[@warn.Category] @warn.Message</li>
}
</ul>
</div>
}
@if (_validationResult.Errors.Count == 0 && _validationResult.Warnings.Count == 0)
{
<div class="alert alert-success py-2">Validation passed with no errors or warnings.</div>
}
</div>
}
@* Template info edit *@
<div class="card mb-3">
<div class="card-header">Template Properties</div>
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_editName" />
</div>
<div class="col-md-4">
<label class="form-label small">Description</label>
<input type="text" class="form-control form-control-sm" @bind="_editDescription" />
</div>
<div class="col-md-3">
<label class="form-label small">Parent Template</label>
<select class="form-select form-select-sm" @bind="_editParentId">
<option value="0">(None)</option>
@foreach (var t in _templates.Where(t => t.Id != _selectedTemplate.Id))
{
<option value="@t.Id">@t.Name</option>
}
</select>
</div>
<div class="col-md-2">
<button class="btn btn-primary btn-sm" @onclick="UpdateTemplateProperties">Save Properties</button>
</div>
</div>
</div>
</div>
@* Tabs: Attributes, Alarms, Scripts, Compositions *@
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<button class="nav-link @(_activeTab == "attributes" ? "active" : "")" @onclick='() => _activeTab = "attributes"'>
Attributes <span class="badge bg-secondary">@_attributes.Count</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link @(_activeTab == "alarms" ? "active" : "")" @onclick='() => _activeTab = "alarms"'>
Alarms <span class="badge bg-secondary">@_alarms.Count</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link @(_activeTab == "scripts" ? "active" : "")" @onclick='() => _activeTab = "scripts"'>
Scripts <span class="badge bg-secondary">@_scripts.Count</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link @(_activeTab == "compositions" ? "active" : "")" @onclick='() => _activeTab = "compositions"'>
Compositions <span class="badge bg-secondary">@_compositions.Count</span>
</button>
</li>
</ul>
@if (_activeTab == "attributes")
{
@RenderAttributesTab()
}
else if (_activeTab == "alarms")
{
@RenderAlarmsTab()
}
else if (_activeTab == "scripts")
{
@RenderScriptsTab()
}
else if (_activeTab == "compositions")
{
@RenderCompositionsTab()
}
};
private RenderFragment RenderNodeLabel(TmplNode node) => __builder =>
{
var draggable = node.Kind != TmplNodeKind.Composition;
var isDropTarget = node.Kind == TmplNodeKind.Folder;
var classes = "d-inline-block " + (_dragOverKey == node.Key ? "bg-info bg-opacity-25" : "");
var style = node.Kind == TmplNodeKind.Composition && _dragPayload != null
? "opacity: 0.5;" : "";
<div class="@classes" style="@style"
draggable="@(draggable ? "true" : "false")"
@ondragstart="() => OnDragStart(node)"
@ondragend="OnDragEnd"
@ondragenter="() => OnDragEnter(node)"
@ondragleave="() => OnDragLeave(node)"
@ondragover:preventDefault="@isDropTarget"
@ondrop="() => OnDrop(node)"
@ondrop:stopPropagation="true">
@switch (node.Kind)
{
case TmplNodeKind.Folder:
<span class="me-1">📁</span>
<span>@node.Label</span>
<span class="badge bg-light text-dark ms-2">@node.Children.Count</span>
break;
case TmplNodeKind.Template:
<strong>@node.Label</strong>
if (node.Template?.ParentTemplateId is int pid)
{
<span class="text-muted small ms-1">inherits @(_templates.FirstOrDefault(t => t.Id == pid)?.Name)</span>
}
<span class="badge bg-light text-dark ms-2">
@node.Template!.Attributes.Count attr,
@node.Template.Alarms.Count alm,
@node.Template.Scripts.Count scr
</span>
if (node.Template.Compositions.Count > 0)
{
<span class="badge bg-info text-dark ms-1">@node.Template.Compositions.Count comp</span>
}
break;
case TmplNodeKind.Composition:
var composedName = _templates.FirstOrDefault(t => t.Id == node.Composition!.ComposedTemplateId)?.Name
?? $"#{node.Composition!.ComposedTemplateId}";
<span>@node.Label</span>
<span class="text-muted small ms-1">→ @composedName</span>
break;
}
</div>
};
private async Task OnTreeNodeSelected(object? key)
{
if (key is not string s) return;
if (s.StartsWith("t:") && int.TryParse(s[2..], out var tid))
{
await SelectTemplate(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)
{
// Reveal + select the composed template.
await _tree.RevealNode($"t:{comp.ComposedTemplateId}", select: true);
await SelectTemplate(comp.ComposedTemplateId);
}
}
// Folder selection: no-op (Section 4 design — folder click does not load detail).
}
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="() => OpenNewTemplateDialog(node.EntityId)">New Template</button>
<button class="dropdown-item" @onclick="() => OpenRenameFolderDialog(node.EntityId, node.Label)">Rename</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="() => SelectTemplate(node.EntityId)">Edit</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>
break;
case TmplNodeKind.Composition:
var composedKey = $"t:{node.Composition!.ComposedTemplateId}";
<button class="dropdown-item" @onclick="() => OnTreeNodeSelected(composedKey)">Open composed template</button>
<button class="dropdown-item text-danger" @onclick="() => DeleteComposition(node.Composition)">Remove composition</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;
}
}
// New-template dialog state
private bool _showNewTemplateDialog;
private int? _newTemplateFolderId;
private string? _newTemplateError;
private void OpenNewTemplateDialog(int? folderId)
{
_newTemplateFolderId = folderId;
_newTemplateError = null;
_showNewTemplateDialog = true;
}
private async Task SubmitNewTemplate((int? FolderId, string Name, string? Description) req)
{
_newTemplateError = null;
var user = await GetCurrentUserAsync();
var result = await TemplateService.CreateTemplateAsync(
req.Name, req.Description, null, user, folderId: req.FolderId);
if (result.IsSuccess)
{
_showNewTemplateDialog = false;
_toast.ShowSuccess($"Template '{result.Value.Name}' created.");
await LoadTemplatesAsync();
await SelectTemplate(result.Value.Id);
}
else
{
_newTemplateError = 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))
yield return f;
}
private IEnumerable<(int? Id, string Label)> WalkFolderHierarchy(IEnumerable<TemplateFolder> level, int depth)
{
foreach (var f in level.OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase))
{
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))
yield return sub;
}
}
// 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}");
}
}
private async Task UpdateTemplateProperties()
{
if (_selectedTemplate == null) return;
try
{
var user = await GetCurrentUserAsync();
var result = await TemplateService.UpdateTemplateAsync(
_selectedTemplate.Id, _editName.Trim(), _editDescription?.Trim(),
_editParentId == 0 ? null : _editParentId, user);
if (result.IsSuccess)
{
_toast.ShowSuccess("Template properties updated.");
await LoadTemplatesAsync();
_selectedTemplate = result.Value;
}
else
{
_toast.ShowError(result.Error);
}
}
catch (Exception ex)
{
_toast.ShowError($"Update failed: {ex.Message}");
}
}
private async Task RunValidation()
{
if (_selectedTemplate == null) return;
_validating = true;
_validationResult = null;
try
{
// Use the full validation pipeline via TemplateService
// This performs flattening, collision detection, script compilation,
// trigger reference validation, and connection binding checks
var validationService = new ValidationService();
var flatConfig = new Commons.Types.Flattening.FlattenedConfiguration
{
InstanceUniqueName = $"validation-{_selectedTemplate.Name}",
TemplateId = _selectedTemplate.Id,
Attributes = _attributes.Select(a => new Commons.Types.Flattening.ResolvedAttribute
{
CanonicalName = a.Name,
Value = a.Value,
DataType = a.DataType.ToString(),
IsLocked = a.IsLocked,
DataSourceReference = a.DataSourceReference
}).ToList(),
Alarms = _alarms.Select(a => new Commons.Types.Flattening.ResolvedAlarm
{
CanonicalName = a.Name,
PriorityLevel = a.PriorityLevel,
IsLocked = a.IsLocked,
TriggerType = a.TriggerType.ToString(),
TriggerConfiguration = a.TriggerConfiguration
}).ToList(),
Scripts = _scripts.Select(s => new Commons.Types.Flattening.ResolvedScript
{
CanonicalName = s.Name,
Code = s.Code,
IsLocked = s.IsLocked,
TriggerType = s.TriggerType,
TriggerConfiguration = s.TriggerConfiguration,
ParameterDefinitions = s.ParameterDefinitions,
ReturnDefinition = s.ReturnDefinition
}).ToList()
};
_validationResult = validationService.Validate(flatConfig);
// Also check for naming collisions across the inheritance/composition graph
var collisions = await TemplateService.DetectCollisionsAsync(_selectedTemplate.Id);
if (collisions.Count > 0)
{
var collisionErrors = collisions.Select(c =>
Commons.Types.Flattening.ValidationEntry.Error(
Commons.Types.Flattening.ValidationCategory.NamingCollision, c)).ToArray();
var collisionResult = new Commons.Types.Flattening.ValidationResult { Errors = collisionErrors };
_validationResult = Commons.Types.Flattening.ValidationResult.Merge(_validationResult, collisionResult);
}
}
catch (Exception ex)
{
_toast.ShowError($"Validation error: {ex.Message}");
}
_validating = false;
}
// ---- Attributes Tab ----
private RenderFragment RenderAttributesTab() => __builder =>
{
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Attributes</h6>
<button class="btn btn-primary btn-sm" @onclick="() => { _showAttrForm = true; _attrFormError = null; _attrName = string.Empty; _attrValue = null; _attrIsLocked = false; _attrDataSourceRef = null; }">Add Attribute</button>
</div>
@if (_showAttrForm)
{
<div class="card mb-2">
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-2">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_attrName" />
</div>
<div class="col-md-2">
<label class="form-label small">Data Type</label>
<select class="form-select form-select-sm" @bind="_attrDataType">
@foreach (var dt in Enum.GetValues<DataType>())
{
<option value="@dt">@dt</option>
}
</select>
</div>
<div class="col-md-2">
<label class="form-label small">Value</label>
<input type="text" class="form-control form-control-sm" @bind="_attrValue" />
</div>
<div class="col-md-2">
<label class="form-label small">Data Source Ref</label>
<input type="text" class="form-control form-control-sm" @bind="_attrDataSourceRef" placeholder="Tag path" />
</div>
<div class="col-md-1">
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="_attrIsLocked" id="attrLocked" />
<label class="form-check-label small" for="attrLocked">Locked</label>
</div>
</div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="AddAttribute">Add</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showAttrForm = false">Cancel</button>
</div>
</div>
@if (_attrFormError != null) { <div class="text-danger small mt-1">@_attrFormError</div> }
</div>
</div>
}
<table class="table table-sm table-striped">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Type</th>
<th>Value</th>
<th>Data Source</th>
<th>Lock</th>
<th style="width: 80px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var attr in _attributes)
{
<tr>
<td>@attr.Name</td>
<td><span class="badge bg-light text-dark">@attr.DataType</span></td>
<td class="small">@(attr.Value ?? "—")</td>
<td class="small text-muted">@(attr.DataSourceReference ?? "—")</td>
<td>
@if (attr.IsLocked)
{
<span class="badge bg-danger" title="Locked">L</span>
}
else
{
<span class="badge bg-light text-dark" title="Unlocked">U</span>
}
</td>
<td>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteAttribute(attr)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
};
// ---- Alarms Tab ----
private RenderFragment RenderAlarmsTab() => __builder =>
{
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Alarms</h6>
<button class="btn btn-primary btn-sm" @onclick="() => { _showAlarmForm = true; _alarmFormError = null; _alarmName = string.Empty; _alarmPriority = 500; _alarmTriggerConfig = null; _alarmIsLocked = false; }">Add Alarm</button>
</div>
@if (_showAlarmForm)
{
<div class="card mb-2">
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-2">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_alarmName" />
</div>
<div class="col-md-2">
<label class="form-label small">Trigger Type</label>
<select class="form-select form-select-sm" @bind="_alarmTriggerType">
@foreach (var tt in Enum.GetValues<AlarmTriggerType>())
{
<option value="@tt">@tt</option>
}
</select>
</div>
<div class="col-md-1">
<label class="form-label small">Priority</label>
<input type="number" class="form-control form-control-sm" @bind="_alarmPriority" min="0" max="1000" />
</div>
<div class="col-md-3">
<label class="form-label small">Trigger Config (JSON)</label>
<input type="text" class="form-control form-control-sm" @bind="_alarmTriggerConfig" />
</div>
<div class="col-md-1">
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="_alarmIsLocked" id="alarmLocked" />
<label class="form-check-label small" for="alarmLocked">Locked</label>
</div>
</div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="AddAlarm">Add</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showAlarmForm = false">Cancel</button>
</div>
</div>
@if (_alarmFormError != null) { <div class="text-danger small mt-1">@_alarmFormError</div> }
</div>
</div>
}
<table class="table table-sm table-striped">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Trigger</th>
<th>Priority</th>
<th>Config</th>
<th>Lock</th>
<th style="width: 80px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var alarm in _alarms)
{
<tr>
<td>@alarm.Name</td>
<td><span class="badge bg-light text-dark">@alarm.TriggerType</span></td>
<td>@alarm.PriorityLevel</td>
<td class="small text-muted text-truncate" style="max-width: 200px;">@(alarm.TriggerConfiguration ?? "—")</td>
<td>
@if (alarm.IsLocked) { <span class="badge bg-danger">L</span> }
else { <span class="badge bg-light text-dark">U</span> }
</td>
<td>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteAlarm(alarm)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
};
// ---- Scripts Tab ----
private RenderFragment RenderScriptsTab() => __builder =>
{
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Scripts</h6>
<button class="btn btn-primary btn-sm" @onclick="() => { _showScriptForm = true; _scriptFormError = null; _scriptName = string.Empty; _scriptCode = string.Empty; _scriptTriggerType = null; _scriptTriggerConfig = null; _scriptIsLocked = false; }">Add Script</button>
</div>
@if (_showScriptForm)
{
<div class="card mb-2">
<div class="card-body">
<div class="row g-2">
<div class="col-md-3">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_scriptName" />
</div>
<div class="col-md-2">
<label class="form-label small">Trigger Type</label>
<input type="text" class="form-control form-control-sm" @bind="_scriptTriggerType" placeholder="e.g. ValueChange" />
</div>
<div class="col-md-3">
<label class="form-label small">Trigger Config (JSON)</label>
<input type="text" class="form-control form-control-sm" @bind="_scriptTriggerConfig" />
</div>
<div class="col-md-1">
<div class="form-check mt-4">
<input class="form-check-input" type="checkbox" @bind="_scriptIsLocked" id="scriptLocked" />
<label class="form-check-label small" for="scriptLocked">Locked</label>
</div>
</div>
</div>
<div class="mt-2">
<label class="form-label small">Code</label>
<textarea class="form-control form-control-sm font-monospace" rows="6" @bind="_scriptCode"
style="font-size: 0.8rem;"></textarea>
</div>
<div class="mt-2">
<button class="btn btn-success btn-sm me-1" @onclick="AddScript">Add</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showScriptForm = false">Cancel</button>
</div>
@if (_scriptFormError != null) { <div class="text-danger small mt-1">@_scriptFormError</div> }
</div>
</div>
}
<table class="table table-sm table-striped">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Trigger</th>
<th>Code (preview)</th>
<th>Lock</th>
<th style="width: 80px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var script in _scripts)
{
<tr>
<td>@script.Name</td>
<td class="small">@(script.TriggerType ?? "—")</td>
<td class="small text-muted text-truncate font-monospace" style="max-width: 300px;">@script.Code[..Math.Min(80, script.Code.Length)]@(script.Code.Length > 80 ? "..." : "")</td>
<td>
@if (script.IsLocked) { <span class="badge bg-danger">L</span> }
else { <span class="badge bg-light text-dark">U</span> }
</td>
<td>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteScript(script)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
};
// ---- Compositions Tab ----
private RenderFragment RenderCompositionsTab() => __builder =>
{
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Compositions</h6>
<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-2">
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small">Instance Name</label>
<input type="text" class="form-control form-control-sm" @bind="_compInstanceName" />
</div>
<div class="col-md-4">
<label class="form-label small">Composed Template</label>
<select class="form-select form-select-sm" @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>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="AddComposition">Add</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showCompForm = false">Cancel</button>
</div>
</div>
@if (_compFormError != null) { <div class="text-danger small mt-1">@_compFormError</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: 80px;">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>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteComposition(comp)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
};
// ---- CRUD handlers ----
private async Task AddAttribute()
{
if (_selectedTemplate == null) return;
_attrFormError = null;
if (string.IsNullOrWhiteSpace(_attrName)) { _attrFormError = "Name is required."; return; }
var attr = new TemplateAttribute(_attrName.Trim())
{
DataType = _attrDataType,
Value = _attrValue?.Trim(),
IsLocked = _attrIsLocked,
DataSourceReference = _attrDataSourceRef?.Trim()
};
var user = await GetCurrentUserAsync();
var result = await TemplateService.AddAttributeAsync(_selectedTemplate.Id, attr, user);
if (result.IsSuccess)
{
_showAttrForm = false;
_toast.ShowSuccess($"Attribute '{_attrName}' added.");
await SelectTemplate(_selectedTemplate.Id);
}
else
{
_attrFormError = result.Error;
}
}
private async Task DeleteAttribute(TemplateAttribute attr)
{
var confirmed = await _confirmDialog.ShowAsync($"Delete attribute '{attr.Name}'?", "Delete Attribute");
if (!confirmed) return;
var user = await GetCurrentUserAsync();
var result = await TemplateService.DeleteAttributeAsync(attr.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Attribute '{attr.Name}' deleted.");
if (_selectedTemplate != null) await SelectTemplate(_selectedTemplate.Id);
}
else
{
_toast.ShowError(result.Error);
}
}
private async Task AddAlarm()
{
if (_selectedTemplate == null) return;
_alarmFormError = null;
if (string.IsNullOrWhiteSpace(_alarmName)) { _alarmFormError = "Name is required."; return; }
var alarm = new TemplateAlarm(_alarmName.Trim())
{
TriggerType = _alarmTriggerType,
PriorityLevel = _alarmPriority,
TriggerConfiguration = _alarmTriggerConfig?.Trim(),
IsLocked = _alarmIsLocked
};
var user = await GetCurrentUserAsync();
var result = await TemplateService.AddAlarmAsync(_selectedTemplate.Id, alarm, user);
if (result.IsSuccess)
{
_showAlarmForm = false;
_toast.ShowSuccess($"Alarm '{_alarmName}' added.");
await SelectTemplate(_selectedTemplate.Id);
}
else
{
_alarmFormError = result.Error;
}
}
private async Task DeleteAlarm(TemplateAlarm alarm)
{
var confirmed = await _confirmDialog.ShowAsync($"Delete alarm '{alarm.Name}'?", "Delete Alarm");
if (!confirmed) return;
var user = await GetCurrentUserAsync();
var result = await TemplateService.DeleteAlarmAsync(alarm.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Alarm '{alarm.Name}' deleted.");
if (_selectedTemplate != null) await SelectTemplate(_selectedTemplate.Id);
}
else { _toast.ShowError(result.Error); }
}
private async Task AddScript()
{
if (_selectedTemplate == null) return;
_scriptFormError = null;
if (string.IsNullOrWhiteSpace(_scriptName)) { _scriptFormError = "Name is required."; return; }
if (string.IsNullOrWhiteSpace(_scriptCode)) { _scriptFormError = "Code is required."; return; }
var script = new TemplateScript(_scriptName.Trim(), _scriptCode)
{
TriggerType = _scriptTriggerType?.Trim(),
TriggerConfiguration = _scriptTriggerConfig?.Trim(),
IsLocked = _scriptIsLocked
};
var user = await GetCurrentUserAsync();
var result = await TemplateService.AddScriptAsync(_selectedTemplate.Id, script, user);
if (result.IsSuccess)
{
_showScriptForm = false;
_toast.ShowSuccess($"Script '{_scriptName}' added.");
await SelectTemplate(_selectedTemplate.Id);
}
else
{
_scriptFormError = result.Error;
}
}
private async Task DeleteScript(TemplateScript script)
{
var confirmed = await _confirmDialog.ShowAsync($"Delete script '{script.Name}'?", "Delete Script");
if (!confirmed) return;
var user = await GetCurrentUserAsync();
var result = await TemplateService.DeleteScriptAsync(script.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Script '{script.Name}' deleted.");
if (_selectedTemplate != null) await SelectTemplate(_selectedTemplate.Id);
}
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 SelectTemplate(_selectedTemplate.Id);
}
else
{
_compFormError = result.Error;
}
}
private async Task DeleteComposition(TemplateComposition comp)
{
var confirmed = await _confirmDialog.ShowAsync($"Remove composition '{comp.InstanceName}'?", "Delete Composition");
if (!confirmed) return;
var user = await GetCurrentUserAsync();
var result = await TemplateService.DeleteCompositionAsync(comp.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Composition '{comp.InstanceName}' removed.");
if (_selectedTemplate != null) await SelectTemplate(_selectedTemplate.Id);
}
else { _toast.ShowError(result.Error); }
}
}