Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8155dbc411 | |||
| d54013cb88 | |||
| ca3b34223d | |||
| c60aad9df4 | |||
| fc105acd7c | |||
| 39e6e0a525 | |||
| 4977f99a74 | |||
| 78165b3d99 | |||
| 20f60c88f9 | |||
| 3d28f0d2eb |
@@ -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. |
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user