10 Commits

10 changed files with 912 additions and 170 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ This document serves as the master index for the SCADA system design. The system
| # | Component | Document | Description | | # | Component | Document | Description |
|---|-----------|----------|-------------| |---|-----------|----------|-------------|
| 1 | Template Engine | [docs/requirements/Component-TemplateEngine.md](docs/requirements/Component-TemplateEngine.md) | Template modeling, inheritance, composition, path-qualified member addressing, override granularity, locking, alarms, flattening, semantic validation, revision hashing, and diff calculation. | | 1 | Template Engine | [docs/requirements/Component-TemplateEngine.md](docs/requirements/Component-TemplateEngine.md) | Template modeling, inheritance, composition, path-qualified member addressing, override granularity, locking, alarms, flattening, semantic validation, revision hashing, diff calculation, and folder organization (nested folders, drag-drop). |
| 2 | Deployment Manager | [docs/requirements/Component-DeploymentManager.md](docs/requirements/Component-DeploymentManager.md) | Central-side deployment pipeline with deployment ID/idempotency, per-instance operation lock, state transition matrix, all-or-nothing site apply, system-wide artifact deployment with per-site status. | | 2 | Deployment Manager | [docs/requirements/Component-DeploymentManager.md](docs/requirements/Component-DeploymentManager.md) | Central-side deployment pipeline with deployment ID/idempotency, per-instance operation lock, state transition matrix, all-or-nothing site apply, system-wide artifact deployment with per-site status. |
| 3 | Site Runtime | [docs/requirements/Component-SiteRuntime.md](docs/requirements/Component-SiteRuntime.md) | Site-side actor hierarchy with explicit supervision strategies, staggered startup, script trust model (constrained APIs), Tell/Ask conventions, concurrency serialization, and site-wide Akka stream with per-subscriber backpressure. | | 3 | Site Runtime | [docs/requirements/Component-SiteRuntime.md](docs/requirements/Component-SiteRuntime.md) | Site-side actor hierarchy with explicit supervision strategies, staggered startup, script trust model (constrained APIs), Tell/Ask conventions, concurrency serialization, and site-wide Akka stream with per-subscriber backpressure. |
| 4 | Data Connection Layer | [docs/requirements/Component-DataConnectionLayer.md](docs/requirements/Component-DataConnectionLayer.md) | Common data connection interface (OPC UA, custom), Become/Stash connection actor model, auto-reconnect, immediate bad quality on disconnect, transparent re-subscribe, synchronous write failures, tag path resolution retry. | | 4 | Data Connection Layer | [docs/requirements/Component-DataConnectionLayer.md](docs/requirements/Component-DataConnectionLayer.md) | Common data connection interface (OPC UA, custom), Become/Stash connection actor model, auto-reconnect, immediate bad quality on disconnect, transparent re-subscribe, synchronous write failures, tag path resolution retry. |
+3
View File
@@ -37,6 +37,9 @@ Central cluster only. Sites have no user interface.
## Workflows / Pages ## Workflows / Pages
### Template Authoring (Design Role) ### Template Authoring (Design Role)
- The `/design/templates` page uses a **split-pane layout**: a folder/template tree sidebar on the left and the editor on the right.
- The tree shows nested `TemplateFolder` entities with their templates underneath; composition children render inline as leaf nodes beneath their owning template (right-click "Open composed template" reveals and selects the target).
- **Per-kind context menus** on folder, template, and composition nodes expose the relevant operations (new folder, new template, rename, move, delete, move to folder). Native HTML5 **drag-drop** reorganizes templates between folders and reparents folders, with cycle detection rejected via toast on drop. Tree expansion state persists in `sessionStorage`, and deep links (`/design/templates/{id}`) reveal and select the target node.
- Create, edit, and delete templates. - Create, edit, and delete templates.
- **Template deletion** is blocked if any instances or child templates reference the template. The UI displays the references preventing deletion. - **Template deletion** is blocked if any instances or child templates reference the template. The UI displays the references preventing deletion.
- Manage template hierarchy (inheritance) — visual tree of parent/child relationships. - Manage template hierarchy (inheritance) — visual tree of parent/child relationships.
@@ -28,7 +28,8 @@ Central cluster only. Site clusters do not access the configuration database —
The configuration database stores all central system data, organized by domain area: The configuration database stores all central system data, organized by domain area:
### Template & Modeling ### Template & Modeling
- **Templates**: Template definitions (name, parent template reference, description). - **Templates**: Template definitions (name, parent template reference, description, nullable `FolderId` FK to `TemplateFolders` — null means the template lives at the tree root).
- **TemplateFolders**: Hierarchical organizational folders for templates (`Id`, `Name`, nullable `ParentFolderId` self-reference, `SortOrder`). Unique index on `(ParentFolderId, Name)` enforces case-insensitive sibling uniqueness. Folders are UI-only — they have no effect on template resolution or flattening.
- **Template Attributes**: Attribute definitions per template (name, value, data type, lock flag, description, data source reference). - **Template Attributes**: Attribute definitions per template (name, value, data type, lock flag, description, data source reference).
- **Template Alarms**: Alarm definitions per template (name, description, priority, lock flag, trigger type, trigger configuration, on-trigger script reference). - **Template Alarms**: Alarm definitions per template (name, description, priority, lock flag, trigger type, trigger configuration, on-trigger script reference).
- **Template Scripts**: Script definitions per template (name, lock flag, C# source code, trigger type, trigger configuration, minimum time between runs, parameter definitions, return value definitions). - **Template Scripts**: Script definitions per template (name, lock flag, C# source code, trigger type, trigger configuration, minimum time between runs, parameter definitions, return value definitions).
@@ -83,6 +83,15 @@ The endpoint performs LDAP authentication and role resolution server-side, colla
- **ValidateTemplate**: Run on-demand pre-deployment validation (flattening, naming collisions, script compilation). - **ValidateTemplate**: Run on-demand pre-deployment validation (flattening, naming collisions, script compilation).
- **GetTemplateDiff**: Compare deployed vs. template-derived configuration for an instance. - **GetTemplateDiff**: Compare deployed vs. template-derived configuration for an instance.
### Template Folders
- **ListTemplateFolders**: List all template folders (read-only; any authenticated user).
- **CreateTemplateFolder** (`Name`, `ParentFolderId?`): Create a folder, optionally nested under a parent (Design role).
- **RenameTemplateFolder** (`FolderId`, `NewName`): Rename a folder; enforces sibling uniqueness (Design role).
- **MoveTemplateFolder** (`FolderId`, `NewParentFolderId?`): Move a folder to a new parent (or root); rejects cycles (Design role).
- **DeleteTemplateFolder** (`FolderId`): Delete a folder; blocked if the folder contains any subfolders or templates (Design role).
- **MoveTemplateToFolder** (`TemplateId`, `NewFolderId?`): Move a template into a folder, or to the root when null (Design role).
### Template Members ### Template Members
- **AddTemplateAttribute** / **UpdateTemplateAttribute** / **DeleteTemplateAttribute**: Manage attributes on a template. - **AddTemplateAttribute** / **UpdateTemplateAttribute** / **DeleteTemplateAttribute**: Manage attributes on a template.
@@ -23,6 +23,7 @@ Central cluster only. Sites receive flattened output and have no awareness of te
- Perform comprehensive pre-deployment validation (see Validation section). - Perform comprehensive pre-deployment validation (see Validation section).
- Provide on-demand validation for Design users during template authoring. - Provide on-demand validation for Design users during template authoring.
- Enforce template deletion constraints — templates cannot be deleted if any instances or child templates reference them. - Enforce template deletion constraints — templates cannot be deleted if any instances or child templates reference them.
- Organize templates into nested folders (`TemplateFolder` entity) and validate folder hierarchy invariants (acyclicity, sibling uniqueness, non-empty-on-delete).
## Key Entities ## Key Entities
@@ -33,6 +34,14 @@ Central cluster only. Sites receive flattened output and have no awareness of te
- Defines attributes, alarms, and scripts as first-class members. - Defines attributes, alarms, and scripts as first-class members.
- Cannot be deleted if referenced by instances or child templates. - Cannot be deleted if referenced by instances or child templates.
- Concurrent editing uses **last-write-wins** — no pessimistic locking or conflict detection. - Concurrent editing uses **last-write-wins** — no pessimistic locking or conflict detection.
- May belong to a `TemplateFolder` via nullable `FolderId`, or live at the tree root when null.
### TemplateFolder
- Hierarchical organizational entity with a self-referencing `ParentFolderId` (null at the root).
- Sibling folder names are unique (case-insensitive) within the same parent.
- Folders carry **no semantic meaning** for template resolution, flattening, validation, or inheritance — they exist purely for UI organization.
- Folder deletion is blocked if the folder contains any subfolders or templates.
- The folder graph is enforced acyclic on move (a folder cannot become its own descendant).
### Attribute ### Attribute
- Name, Value, Data Type (Boolean, Integer, Float, String), Lock Flag, Description. - Name, Value, Data Type (Boolean, Integer, Float, String), Lock Flag, Description.
@@ -5,11 +5,13 @@
@using ScadaLink.Commons.Interfaces.Repositories @using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Commons.Types.Enums @using ScadaLink.Commons.Types.Enums
@using ScadaLink.TemplateEngine @using ScadaLink.TemplateEngine
@using ScadaLink.TemplateEngine.Services
@using ScadaLink.TemplateEngine.Validation @using ScadaLink.TemplateEngine.Validation
@using ScadaLink.TemplateEngine.Flattening @using ScadaLink.TemplateEngine.Flattening
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)] @attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject ITemplateEngineRepository TemplateEngineRepository @inject ITemplateEngineRepository TemplateEngineRepository
@inject TemplateService TemplateService @inject TemplateService TemplateService
@inject TemplateFolderService TemplateFolderService
@inject AuthenticationStateProvider AuthStateProvider @inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@@ -17,6 +19,106 @@
<ToastNotification @ref="_toast" /> <ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" /> <ConfirmDialog @ref="_confirmDialog" />
@if (_showRenameFolderDialog)
{
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">Rename Folder</h6>
<button type="button" class="btn-close" @onclick="() => _showRenameFolderDialog = false"></button>
</div>
<div class="modal-body">
<input class="form-control form-control-sm" @bind="_renameFolderName" />
@if (_renameFolderError != null) { <div class="text-danger small mt-1">@_renameFolderError</div> }
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showRenameFolderDialog = false">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="SubmitRenameFolder">Save</button>
</div>
</div>
</div>
</div>
}
@if (_showNewFolderDialog)
{
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">New Folder</h6>
<button type="button" class="btn-close" @onclick="() => _showNewFolderDialog = false"></button>
</div>
<div class="modal-body">
<input class="form-control form-control-sm" placeholder="Folder name" @bind="_newFolderName" />
@if (_newFolderError != null) { <div class="text-danger small mt-1">@_newFolderError</div> }
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showNewFolderDialog = false">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="SubmitNewFolder">Create</button>
</div>
</div>
</div>
</div>
}
@if (_showNewTemplateDialog)
{
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">New Template</h6>
<button type="button" class="btn-close" @onclick="() => _showNewTemplateDialog = false"></button>
</div>
<div class="modal-body">
<div class="mb-2">
<label class="form-label small">Name</label>
<input class="form-control form-control-sm" @bind="_newTemplateName" />
</div>
<div class="mb-2">
<label class="form-label small">Description</label>
<input class="form-control form-control-sm" @bind="_newTemplateDescription" />
</div>
@if (_newTemplateError != null) { <div class="text-danger small mt-1">@_newTemplateError</div> }
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showNewTemplateDialog = false">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="SubmitNewTemplate">Create</button>
</div>
</div>
</div>
</div>
}
@if (_showMoveTemplateDialog)
{
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">Move '@_moveTemplateName' to…</h6>
<button type="button" class="btn-close" @onclick="() => _showMoveTemplateDialog = false"></button>
</div>
<div class="modal-body">
<select class="form-select form-select-sm" @bind="_moveTemplateTargetFolderId">
@foreach (var opt in EnumerateFolderOptions())
{
<option value="@opt.Id">@opt.Label</option>
}
</select>
@if (_moveTemplateError != null) { <div class="text-danger small mt-1">@_moveTemplateError</div> }
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showMoveTemplateDialog = false">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="SubmitMoveTemplate">Move</button>
</div>
</div>
</div>
</div>
}
@if (_loading) @if (_loading)
{ {
<LoadingSpinner IsLoading="true" /> <LoadingSpinner IsLoading="true" />
@@ -25,60 +127,353 @@
{ {
<div class="alert alert-danger">@_errorMessage</div> <div class="alert alert-danger">@_errorMessage</div>
} }
else if (_selectedTemplate == null) else
{ {
@* Template list view *@ <div class="row g-2">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="col-md-4 col-lg-3">
<h4 class="mb-0">Templates</h4> <div class="d-flex justify-content-between align-items-center mb-2">
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/templates/create")'>New Template</button> <h6 class="mb-0">Templates</h6>
<div class="btn-group btn-group-sm">
<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> </div>
<TreeView TItem="TmplTreeNode" Items="_templateTreeRoots" <div style="max-height: calc(100vh - 160px); overflow-y: auto;">
<div @ondragover:preventDefault="true" @ondrop="OnDropOnRoot"
style="min-height: 100%; padding: 4px;">
<TreeView @ref="_tree" TItem="TmplNode" Items="_treeRoots"
ChildrenSelector="n => n.Children" ChildrenSelector="n => n.Children"
HasChildrenSelector="n => n.Children.Count > 0" HasChildrenSelector="n => n.Kind != TmplNodeKind.Composition && n.Children.Count > 0"
KeySelector="n => (object)n.Template.Id" KeySelector="n => (object)n.Key"
StorageKey="templates-tree" StorageKey="templates-tree"
Selectable="true" Selectable="true"
SelectedKeyChanged="key => { if (key is int id) _ = SelectTemplate(id); }"> SelectedKeyChanged="OnTreeNodeSelected">
<NodeContent Context="node"> <NodeContent Context="node">
<strong>@node.Template.Name</strong> @RenderNodeLabel(node)
@if (node.Template.ParentTemplateId.HasValue)
{
<span class="text-muted small ms-1">inherits @(_templates.FirstOrDefault(t => t.Id == node.Template.ParentTemplateId)?.Name)</span>
}
@if (!string.IsNullOrEmpty(node.Template.Description))
{
<span class="text-muted small ms-2">@node.Template.Description</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>
}
</NodeContent> </NodeContent>
<ContextMenu Context="node"> <ContextMenu Context="node">
<button class="dropdown-item" @onclick="() => SelectTemplate(node.Template.Id)"> @RenderNodeContextMenu(node)
Edit
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger" @onclick="() => DeleteTemplate(node.Template)">
Delete
</button>
</ContextMenu> </ContextMenu>
<EmptyContent> <EmptyContent>
<span class="text-muted fst-italic">No templates. Create one to get started.</span> <span class="text-muted fst-italic">No templates yet. Use the buttons above to create a folder or template.</span>
</EmptyContent> </EmptyContent>
</TreeView> </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 else
{ {
@* Template detail/edit view *@ @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);
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && TemplateIdParam > 0 && _tree != null)
{
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 class="d-flex justify-content-between align-items-center mb-3">
<div> <div>
<button class="btn btn-outline-secondary btn-sm me-2" @onclick="BackToList">Back to List</button> <h4 class="d-inline mb-0">@_selectedTemplate!.Name</h4>
<h4 class="d-inline mb-0">@_selectedTemplate.Name</h4>
@if (_selectedTemplate.ParentTemplateId.HasValue) @if (_selectedTemplate.ParentTemplateId.HasValue)
{ {
<span class="text-muted ms-2">inherits @(_templates.FirstOrDefault(t => t.Id == _selectedTemplate.ParentTemplateId)?.Name)</span> <span class="text-muted ms-2">inherits @(_templates.FirstOrDefault(t => t.Id == _selectedTemplate.ParentTemplateId)?.Name)</span>
@@ -200,142 +595,268 @@
{ {
@RenderCompositionsTab() @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)">
@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> </div>
};
@code { private async Task OnTreeNodeSelected(object? key)
private async Task<string> GetCurrentUserAsync()
{ {
var authState = await AuthStateProvider.GetAuthenticationStateAsync(); if (key is not string s) return;
return authState.User.FindFirst("Username")?.Value ?? "unknown"; 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).
} }
[Parameter] public int TemplateIdParam { get; set; } private RenderFragment RenderNodeContextMenu(TmplNode node) => __builder =>
private List<Template> _templates = 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()
{ {
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 _newFolderName = string.Empty;
private string? _newFolderError;
private void OpenNewFolderDialog(int? parentFolderId)
{
_newFolderParentId = parentFolderId;
_newFolderName = string.Empty;
_newFolderError = null;
_showNewFolderDialog = true;
}
private async Task SubmitNewFolder()
{
_newFolderError = null;
var user = await GetCurrentUserAsync();
var result = await TemplateFolderService.CreateFolderAsync(_newFolderName.Trim(), _newFolderParentId, user);
if (result.IsSuccess)
{
_showNewFolderDialog = false;
_toast.ShowSuccess($"Folder '{result.Value.Name}' created.");
await LoadTemplatesAsync(); await LoadTemplatesAsync();
if (TemplateIdParam > 0) }
else
{ {
await SelectTemplate(TemplateIdParam); _newFolderError = result.Error;
} }
} }
private async Task LoadTemplatesAsync() // New-template dialog state
private bool _showNewTemplateDialog;
private int? _newTemplateFolderId;
private string _newTemplateName = string.Empty;
private string? _newTemplateDescription;
private string? _newTemplateError;
private void OpenNewTemplateDialog(int? folderId)
{ {
_loading = true; _newTemplateFolderId = folderId;
try _newTemplateName = string.Empty;
{ _newTemplateDescription = null;
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList(); _newTemplateError = null;
BuildTemplateTree(); _showNewTemplateDialog = true;
}
catch (Exception ex)
{
_errorMessage = $"Failed to load templates: {ex.Message}";
}
_loading = false;
} }
private record TmplTreeNode(Template Template, List<TmplTreeNode> Children); private async Task SubmitNewTemplate()
private List<TmplTreeNode> _templateTreeRoots = new();
private void BuildTemplateTree()
{ {
_templateTreeRoots = BuildTmplChildren(null); _newTemplateError = null;
var user = await GetCurrentUserAsync();
var result = await TemplateService.CreateTemplateAsync(
_newTemplateName.Trim(), _newTemplateDescription?.Trim(), null, user, folderId: _newTemplateFolderId);
if (result.IsSuccess)
{
_showNewTemplateDialog = false;
_toast.ShowSuccess($"Template '{result.Value.Name}' created.");
await LoadTemplatesAsync();
await SelectTemplate(result.Value.Id);
} }
else
private List<TmplTreeNode> BuildTmplChildren(int? parentId)
{ {
return _templates _newTemplateError = result.Error;
.Where(t => t.ParentTemplateId == parentId)
.OrderBy(t => t.Name)
.Select(t => new TmplTreeNode(t, BuildTmplChildren(t.Id)))
.ToList();
}
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 void BackToList() // Move-template dialog state
private bool _showMoveTemplateDialog;
private int _moveTemplateId;
private string _moveTemplateName = string.Empty;
private int? _moveTemplateTargetFolderId;
private string? _moveTemplateError;
private void OpenMoveTemplateDialog(int templateId, string label)
{ {
_selectedTemplate = null; _moveTemplateId = templateId;
_validationResult = null; _moveTemplateName = label;
_moveTemplateTargetFolderId = null;
_moveTemplateError = null;
_showMoveTemplateDialog = true;
}
private async Task SubmitMoveTemplate()
{
_moveTemplateError = null;
var user = await GetCurrentUserAsync();
var result = await TemplateService.MoveTemplateAsync(_moveTemplateId, _moveTemplateTargetFolderId, 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 _renameFolderName = string.Empty;
private string? _renameFolderError;
private void OpenRenameFolderDialog(int folderId, string currentName)
{
_renameFolderId = folderId;
_renameFolderName = currentName;
_renameFolderError = null;
_showRenameFolderDialog = true;
}
private async Task SubmitRenameFolder()
{
_renameFolderError = null;
var user = await GetCurrentUserAsync();
var result = await TemplateFolderService.RenameFolderAsync(_renameFolderId, _renameFolderName.Trim(), 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) private async Task DeleteTemplate(Template template)
@@ -110,7 +110,10 @@ public class ManagementActor : ReceiveActor
or CreateSharedScriptCommand or UpdateSharedScriptCommand or DeleteSharedScriptCommand or CreateSharedScriptCommand or UpdateSharedScriptCommand or DeleteSharedScriptCommand
or CreateDatabaseConnectionDefCommand or UpdateDatabaseConnectionDefCommand or DeleteDatabaseConnectionDefCommand or CreateDatabaseConnectionDefCommand or UpdateDatabaseConnectionDefCommand or DeleteDatabaseConnectionDefCommand
or CreateApiMethodCommand or UpdateApiMethodCommand or DeleteApiMethodCommand or CreateApiMethodCommand or UpdateApiMethodCommand or DeleteApiMethodCommand
or UpdateAreaCommand => "Design", or UpdateAreaCommand
or CreateTemplateFolderCommand or RenameTemplateFolderCommand
or MoveTemplateFolderCommand or DeleteTemplateFolderCommand
or MoveTemplateToFolderCommand => "Design",
// Deployment operations // Deployment operations
CreateInstanceCommand or MgmtDeployInstanceCommand or MgmtEnableInstanceCommand CreateInstanceCommand or MgmtDeployInstanceCommand or MgmtEnableInstanceCommand
@@ -150,6 +153,14 @@ public class ManagementActor : ReceiveActor
AddTemplateCompositionCommand cmd => await HandleAddComposition(sp, cmd, user.Username), AddTemplateCompositionCommand cmd => await HandleAddComposition(sp, cmd, user.Username),
DeleteTemplateCompositionCommand cmd => await HandleDeleteComposition(sp, cmd, user.Username), DeleteTemplateCompositionCommand cmd => await HandleDeleteComposition(sp, cmd, user.Username),
// Template folders
ListTemplateFoldersCommand => await HandleListTemplateFolders(sp),
CreateTemplateFolderCommand cmd => await HandleCreateTemplateFolder(sp, cmd, user.Username),
RenameTemplateFolderCommand cmd => await HandleRenameTemplateFolder(sp, cmd, user.Username),
MoveTemplateFolderCommand cmd => await HandleMoveTemplateFolder(sp, cmd, user.Username),
DeleteTemplateFolderCommand cmd => await HandleDeleteTemplateFolder(sp, cmd, user.Username),
MoveTemplateToFolderCommand cmd => await HandleMoveTemplateToFolder(sp, cmd, user.Username),
// Instances // Instances
ListInstancesCommand cmd => await HandleListInstances(sp, cmd, user), ListInstancesCommand cmd => await HandleListInstances(sp, cmd, user),
GetInstanceCommand cmd => await HandleGetInstance(sp, cmd), GetInstanceCommand cmd => await HandleGetInstance(sp, cmd),
@@ -430,6 +441,51 @@ public class ManagementActor : ReceiveActor
return validationResult; return validationResult;
} }
// ========================================================================
// Template folder handlers
// ========================================================================
private static async Task<object?> HandleListTemplateFolders(IServiceProvider sp)
{
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
return await repo.GetAllFoldersAsync();
}
private static async Task<object?> HandleCreateTemplateFolder(IServiceProvider sp, CreateTemplateFolderCommand cmd, string user)
{
var svc = sp.GetRequiredService<TemplateFolderService>();
var result = await svc.CreateFolderAsync(cmd.Name, cmd.ParentFolderId, user);
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
}
private static async Task<object?> HandleRenameTemplateFolder(IServiceProvider sp, RenameTemplateFolderCommand cmd, string user)
{
var svc = sp.GetRequiredService<TemplateFolderService>();
var result = await svc.RenameFolderAsync(cmd.FolderId, cmd.NewName, user);
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
}
private static async Task<object?> HandleMoveTemplateFolder(IServiceProvider sp, MoveTemplateFolderCommand cmd, string user)
{
var svc = sp.GetRequiredService<TemplateFolderService>();
var result = await svc.MoveFolderAsync(cmd.FolderId, cmd.NewParentFolderId, user);
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
}
private static async Task<object?> HandleDeleteTemplateFolder(IServiceProvider sp, DeleteTemplateFolderCommand cmd, string user)
{
var svc = sp.GetRequiredService<TemplateFolderService>();
var result = await svc.DeleteFolderAsync(cmd.FolderId, user);
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
}
private static async Task<object?> HandleMoveTemplateToFolder(IServiceProvider sp, MoveTemplateToFolderCommand cmd, string user)
{
var svc = sp.GetRequiredService<TemplateService>();
var result = await svc.MoveTemplateAsync(cmd.TemplateId, cmd.NewFolderId, user);
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
}
// ======================================================================== // ========================================================================
// Instance handlers // Instance handlers
// ======================================================================== // ========================================================================
@@ -30,6 +30,7 @@ public class TemplateService
string? description, string? description,
int? parentTemplateId, int? parentTemplateId,
string user, string user,
int? folderId = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
@@ -46,7 +47,8 @@ public class TemplateService
var template = new Template(name) var template = new Template(name)
{ {
Description = description, Description = description,
ParentTemplateId = parentTemplateId ParentTemplateId = parentTemplateId,
FolderId = folderId
}; };
// Check acyclicity (inheritance) — for new templates this is mostly a parent-exists check, // Check acyclicity (inheritance) — for new templates this is mostly a parent-exists check,
@@ -0,0 +1,129 @@
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ScadaLink.Commons.Entities.Templates;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.TemplateEngine;
using ScadaLink.TemplateEngine.Services;
using TemplatesPage = ScadaLink.CentralUI.Components.Pages.Design.Templates;
namespace ScadaLink.CentralUI.Tests;
/// <summary>
/// bUnit rendering tests for the Templates page that verify the folder/template
/// tree builds the expected DOM for the main shape categories: empty state,
/// folder-containing-template nesting, and composition leaves under their owner.
/// </summary>
public class TemplatesPageTests : BunitContext
{
private readonly ITemplateEngineRepository _repo = Substitute.For<ITemplateEngineRepository>();
private readonly IAuditService _audit = Substitute.For<IAuditService>();
public TemplatesPageTests()
{
// The page's TemplateService / TemplateFolderService are constructed via DI
// from the repository and audit service, mirroring real Host wiring.
Services.AddSingleton(_repo);
Services.AddSingleton(_audit);
Services.AddScoped<TemplateService>();
Services.AddScoped<TemplateFolderService>();
AddTestAuth();
// The TreeView inside the page persists expansion state via JS interop
// against sessionStorage (`templates-tree` key). bUnit requires explicit
// stubs for all JS interop calls, otherwise rendering throws.
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
}
private void AddTestAuth()
{
// The page resolves the current user via the "Username" claim in
// GetCurrentUserAsync(); supply a stub so OnInitializedAsync doesn't crash.
var claims = new[]
{
new Claim("Username", "tester"),
new Claim(ClaimTypes.Role, "Design")
};
var identity = new ClaimsIdentity(claims, "TestAuth");
var user = new ClaimsPrincipal(identity);
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
}
[Fact]
public void Renders_EmptyState_WhenNoTemplatesOrFolders()
{
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template>()));
_repo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(new List<TemplateFolder>()));
var cut = Render<TemplatesPage>();
Assert.Contains("No templates yet", cut.Markup);
}
[Fact]
public void Renders_FolderAndTemplate_AtCorrectNesting()
{
var folder = new TemplateFolder("Dev") { Id = 1 };
var template = new Template("TestMachine") { Id = 5, FolderId = 1 };
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template> { template }));
_repo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(new List<TemplateFolder> { folder }));
var cut = Render<TemplatesPage>();
// The folder is rendered collapsed; assert the folder label is present,
// then expand it and assert the nested template label appears.
Assert.Contains("Dev", cut.Markup);
var folderToggle = cut.FindAll("li[role='treeitem']")
.FirstOrDefault(li => li.TextContent.Contains("Dev"))
?.QuerySelector(".tv-toggle");
Assert.NotNull(folderToggle);
folderToggle!.Click();
Assert.Contains("TestMachine", cut.Markup);
}
[Fact]
public void Renders_CompositionChildren_UnderOwningTemplate()
{
var template = new Template("TestMachine") { Id = 5 };
template.Compositions.Add(
new TemplateComposition("DelmiaReceiver") { Id = 10, ComposedTemplateId = 99 });
var composed = new Template("Other") { Id = 99 };
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template> { template, composed }));
_repo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(new List<TemplateFolder>()));
var cut = Render<TemplatesPage>();
// The owning template must be expanded for its composition leaves to be
// in the DOM — composition children only render under an expanded parent.
var ownerToggle = cut.FindAll("li[role='treeitem']")
.FirstOrDefault(li => li.TextContent.Contains("TestMachine"))
?.QuerySelector(".tv-toggle");
Assert.NotNull(ownerToggle);
ownerToggle!.Click();
Assert.Contains("DelmiaReceiver", cut.Markup);
Assert.Contains("→", cut.Markup);
}
}
internal sealed class TestAuthStateProvider : AuthenticationStateProvider
{
private readonly ClaimsPrincipal _user;
public TestAuthStateProvider(ClaimsPrincipal user) => _user = user;
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(new AuthenticationState(_user));
}
@@ -70,6 +70,18 @@ public class TemplateServiceTests
Assert.Contains("not found", result.Error); Assert.Contains("not found", result.Error);
} }
[Fact]
public async Task CreateTemplate_WithFolderId_SetsFolderId()
{
var folder = new TemplateFolder("Dev") { Id = 7 };
_repoMock.Setup(r => r.GetFolderByIdAsync(It.IsAny<int>(), It.IsAny<CancellationToken>())).ReturnsAsync(folder);
var result = await _service.CreateTemplateAsync("X", null, null, "admin", folderId: 7);
Assert.True(result.IsSuccess);
Assert.Equal(7, result.Value.FolderId);
}
[Fact] [Fact]
public async Task DeleteTemplate_Success() public async Task DeleteTemplate_Success()
{ {