Files
scadalink-design/docs/plans/2026-05-11-templates-folder-hierarchy.md

2219 lines
79 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Templates Folder Hierarchy Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Replace the flat `/design/templates` list with a Wonderware-style folder tree: nested `TemplateFolder` entity, `Template.FolderId`, composition children as inline tree leaves, split-pane editor on the right, context menus + drag-drop reorganization.
**Architecture:** New `TemplateFolder` entity (self-referencing `ParentFolderId`); nullable `FolderId` on `Template`. A new `TemplateFolderService` mirrors the existing `TemplateService` pattern (Result<T>, audit, repository). Repository methods are added to `ITemplateEngineRepository`. Management commands follow the existing `<Verb><Noun>Command` record convention auto-registered by `ManagementCommandRegistry`. The Blazor page uses the existing generic `TreeView<TItem>` with a unified `TmplNode` discriminated by kind (Folder / Template / Composition) and a split-pane Bootstrap layout. Drag-drop uses native HTML5; server enforces all invariants (cycle detection, sibling-name uniqueness, non-empty-on-delete).
**Tech Stack:** .NET 9, Akka.NET, EF Core (SQLite), Blazor Server, Bootstrap 5, bUnit + xUnit + Moq.
**Design doc:** [`docs/plans/2026-05-11-templates-folder-hierarchy-design.md`](2026-05-11-templates-folder-hierarchy-design.md)
**Conventions confirmed from codebase exploration:**
- Service file: `src/ScadaLink.TemplateEngine/Services/TemplateFolderService.cs` (matches AreaService/InstanceService/SiteService convention).
- Audit signature: `IAuditService.LogAsync(user, operation, entityType, entityId, displayName, payload, ct)`.
- Repository: extend `ITemplateEngineRepository` + `TemplateEngineRepository.cs` (single concrete impl).
- DbContext: `src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs`, configurations in `Configurations/`.
- Management commands: simple records in `src/ScadaLink.Commons/Messages/Management/*.cs`; auto-discovered by reflection.
- ManagementActor dispatch + authorization both via `switch` on command type.
- UI injects `TemplateService` directly (and will inject `TemplateFolderService` the same way) — no HTTP round trip.
---
## Task 0: Confirm baseline + create work branch
**Files:** none modified — verification only.
**Step 1: Verify clean working tree**
Run: `git status`
Expected: `nothing to commit, working tree clean` (the design doc was committed in commit `daa0126`).
**Step 2: Verify the design doc is present**
Run: `ls docs/plans/2026-05-11-templates-folder-hierarchy-design.md`
Expected: file exists.
**Step 3: Build baseline passes**
Run: `dotnet build ScadaLink.sln`
Expected: build succeeds with 0 errors.
**Step 4: Existing tests pass**
Run: `dotnet test ScadaLink.sln --filter "FullyQualifiedName~TemplateEngine.Tests|FullyQualifiedName~CentralUI.Tests" --nologo`
Expected: all green.
**No commit at this task** — it's verification only.
---
## Task 1: Add `TemplateFolder` entity + `Template.FolderId`
**Files:**
- Create: `src/ScadaLink.Commons/Entities/Templates/TemplateFolder.cs`
- Modify: `src/ScadaLink.Commons/Entities/Templates/Template.cs`
**Step 1: Write the failing test**
Add to `tests/ScadaLink.Commons.Tests/Entities/TemplateFolderTests.cs` (create new file):
```csharp
using ScadaLink.Commons.Entities.Templates;
using Xunit;
namespace ScadaLink.Commons.Tests.Entities;
public class TemplateFolderTests
{
[Fact]
public void Constructor_SetsName()
{
var folder = new TemplateFolder("Dev");
Assert.Equal("Dev", folder.Name);
Assert.Null(folder.ParentFolderId);
Assert.Equal(0, folder.SortOrder);
}
[Fact]
public void Constructor_NullName_Throws()
{
Assert.Throws<ArgumentNullException>(() => new TemplateFolder(null!));
}
[Fact]
public void Template_FolderId_DefaultsToNull()
{
var template = new Template("X");
Assert.Null(template.FolderId);
}
}
```
**Step 2: Run test — confirm fail**
Run: `dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter "FullyQualifiedName~TemplateFolderTests" --nologo`
Expected: FAIL (`TemplateFolder` type not found, `Template.FolderId` not found).
**Step 3: Implement the entity**
Create `src/ScadaLink.Commons/Entities/Templates/TemplateFolder.cs`:
```csharp
namespace ScadaLink.Commons.Entities.Templates;
public class TemplateFolder
{
public int Id { get; set; }
public string Name { get; set; }
public int? ParentFolderId { get; set; }
public int SortOrder { get; set; }
public TemplateFolder(string name)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
}
}
```
**Step 4: Add `FolderId` to `Template`**
Edit `src/ScadaLink.Commons/Entities/Templates/Template.cs` — add this property after `ParentTemplateId`:
```csharp
public int? FolderId { get; set; }
```
**Step 5: Run tests — confirm pass**
Run: `dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter "FullyQualifiedName~TemplateFolderTests" --nologo`
Expected: PASS.
**Step 6: Commit**
```bash
git add src/ScadaLink.Commons/Entities/Templates/TemplateFolder.cs \
src/ScadaLink.Commons/Entities/Templates/Template.cs \
tests/ScadaLink.Commons.Tests/Entities/TemplateFolderTests.cs
git commit -m "feat(templates): add TemplateFolder entity and Template.FolderId"
```
---
## Task 2: EF configuration for `TemplateFolder` + `Template.FolderId`
**Files:**
- Modify: `src/ScadaLink.ConfigurationDatabase/Configurations/TemplateConfiguration.cs`
- Modify: `src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs`
**Step 1: Add `DbSet<TemplateFolder>` to DbContext**
Edit `src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs` — under the `// Templates` region, add:
```csharp
public DbSet<TemplateFolder> TemplateFolders => Set<TemplateFolder>();
```
**Step 2: Add `TemplateFolderConfiguration` class**
Append to `src/ScadaLink.ConfigurationDatabase/Configurations/TemplateConfiguration.cs`:
```csharp
public class TemplateFolderConfiguration : IEntityTypeConfiguration<TemplateFolder>
{
public void Configure(EntityTypeBuilder<TemplateFolder> builder)
{
builder.HasKey(f => f.Id);
builder.Property(f => f.Name)
.IsRequired()
.HasMaxLength(200);
builder.HasOne<TemplateFolder>()
.WithMany()
.HasForeignKey(f => f.ParentFolderId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
// Unique sibling name (case-insensitive enforced at service layer; this index is for fast lookup)
builder.HasIndex(f => new { f.ParentFolderId, f.Name }).IsUnique();
}
}
```
**Step 3: Add `FolderId` FK to `TemplateConfiguration`**
In the same file, inside `TemplateConfiguration.Configure`, add after the parent-template FK block:
```csharp
builder.HasOne<TemplateFolder>()
.WithMany()
.HasForeignKey(t => t.FolderId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
```
**Step 4: Verify build**
Run: `dotnet build src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj`
Expected: 0 errors.
**Step 5: Commit**
```bash
git add src/ScadaLink.ConfigurationDatabase/Configurations/TemplateConfiguration.cs \
src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs
git commit -m "feat(db): map TemplateFolder entity and Template.FolderId"
```
---
## Task 3: Generate EF migration `AddTemplateFolders`
**Files:**
- Create: `src/ScadaLink.ConfigurationDatabase/Migrations/<timestamp>_AddTemplateFolders.cs` (generated)
- Create: `src/ScadaLink.ConfigurationDatabase/Migrations/<timestamp>_AddTemplateFolders.Designer.cs` (generated)
- Modify: `src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs` (generated)
**Step 1: Run migration generator**
Run: `dotnet ef migrations add AddTemplateFolders --project src/ScadaLink.ConfigurationDatabase --startup-project src/ScadaLink.Host`
Expected: three files created/updated under `Migrations/`. Dev DB (auto-apply on startup per CLAUDE.md) will apply on next host start.
**Step 2: Inspect the generated `Up()` method**
Open the new `*_AddTemplateFolders.cs` and confirm:
- `CreateTable("TemplateFolders", ...)` with columns `Id`, `Name`, `ParentFolderId` (nullable), `SortOrder`.
- Self-referencing FK on `ParentFolderId` with `onDelete: Restrict`.
- Unique index on `(ParentFolderId, Name)`.
- `AddColumn<int>("FolderId", "Templates", nullable: true)`.
- FK on `Templates.FolderId``TemplateFolders.Id` with `onDelete: Restrict`.
If any are missing, the entity config in Task 2 was wrong — fix and regenerate (delete the migration files, redo Task 2 fix, rerun the migration command).
**Step 3: Build to verify**
Run: `dotnet build src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj`
Expected: 0 errors.
**Step 4: Commit**
```bash
git add src/ScadaLink.ConfigurationDatabase/Migrations/
git commit -m "feat(db): EF migration AddTemplateFolders"
```
---
## Task 4: Repository methods on `ITemplateEngineRepository`
**Files:**
- Modify: `src/ScadaLink.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs`
- Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs`
**Step 1: Add interface methods**
Edit `src/ScadaLink.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs`. Above the trailing `Task<int> SaveChangesAsync(...)`, insert:
```csharp
// TemplateFolder
Task<TemplateFolder?> GetFolderByIdAsync(int id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<TemplateFolder>> GetAllFoldersAsync(CancellationToken cancellationToken = default);
Task AddFolderAsync(TemplateFolder folder, CancellationToken cancellationToken = default);
Task UpdateFolderAsync(TemplateFolder folder, CancellationToken cancellationToken = default);
Task DeleteFolderAsync(int id, CancellationToken cancellationToken = default);
```
(Move-template is just an `UpdateTemplateAsync` of the `FolderId` field — no new method needed.)
**Step 2: Implement on `TemplateEngineRepository`**
Edit `src/ScadaLink.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs`. Append (above the closing brace of the class):
```csharp
// TemplateFolder
public async Task<TemplateFolder?> GetFolderByIdAsync(int id, CancellationToken cancellationToken = default)
=> await _context.TemplateFolders.FirstOrDefaultAsync(f => f.Id == id, cancellationToken);
public async Task<IReadOnlyList<TemplateFolder>> GetAllFoldersAsync(CancellationToken cancellationToken = default)
=> await _context.TemplateFolders.AsNoTracking().ToListAsync(cancellationToken);
public async Task AddFolderAsync(TemplateFolder folder, CancellationToken cancellationToken = default)
=> await _context.TemplateFolders.AddAsync(folder, cancellationToken);
public async Task UpdateFolderAsync(TemplateFolder folder, CancellationToken cancellationToken = default)
{
_context.TemplateFolders.Update(folder);
await Task.CompletedTask;
}
public async Task DeleteFolderAsync(int id, CancellationToken cancellationToken = default)
{
var folder = await _context.TemplateFolders.FirstOrDefaultAsync(f => f.Id == id, cancellationToken);
if (folder != null) _context.TemplateFolders.Remove(folder);
}
```
**Step 3: Build**
Run: `dotnet build ScadaLink.sln`
Expected: 0 errors. Any compile failure means a mock somewhere implements `ITemplateEngineRepository` without these methods — search and fix:
Run: `grep -rln "ITemplateEngineRepository" tests/ --include="*.cs"` and confirm Moq mocks don't need stubs (Moq generates defaults).
**Step 4: Commit**
```bash
git add src/ScadaLink.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs \
src/ScadaLink.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs
git commit -m "feat(repo): add TemplateFolder repository methods"
```
---
## Task 5: `TemplateFolderService` — CreateFolderAsync (TDD)
**Files:**
- Create: `tests/ScadaLink.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs`
- Create: `src/ScadaLink.TemplateEngine/Services/TemplateFolderService.cs`
**Step 1: Write failing tests**
Create `tests/ScadaLink.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs`:
```csharp
using Moq;
using ScadaLink.Commons.Entities.Templates;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.TemplateEngine.Services;
namespace ScadaLink.TemplateEngine.Tests.Services;
public class TemplateFolderServiceTests
{
private readonly Mock<ITemplateEngineRepository> _repoMock = new();
private readonly Mock<IAuditService> _auditMock = new();
private readonly TemplateFolderService _sut;
public TemplateFolderServiceTests()
{
_sut = new TemplateFolderService(_repoMock.Object, _auditMock.Object);
}
[Fact]
public async Task CreateFolder_ValidInput_ReturnsSuccess()
{
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder>());
var result = await _sut.CreateFolderAsync("Dev", null, "admin");
Assert.True(result.IsSuccess);
Assert.Equal("Dev", result.Value.Name);
Assert.Null(result.Value.ParentFolderId);
_repoMock.Verify(r => r.AddFolderAsync(It.IsAny<TemplateFolder>(), It.IsAny<CancellationToken>()), Times.Once);
_repoMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CreateFolder_EmptyName_ReturnsFailure()
{
var result = await _sut.CreateFolderAsync(" ", null, "admin");
Assert.True(result.IsFailure);
Assert.Contains("required", result.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task CreateFolder_DuplicateSiblingName_CaseInsensitive_ReturnsFailure()
{
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder>
{
new("Dev") { Id = 1, ParentFolderId = null }
});
var result = await _sut.CreateFolderAsync("dev", null, "admin");
Assert.True(result.IsFailure);
Assert.Contains("already exists", result.Error);
}
[Fact]
public async Task CreateFolder_ParentNotFound_ReturnsFailure()
{
_repoMock.Setup(r => r.GetFolderByIdAsync(99, It.IsAny<CancellationToken>()))
.ReturnsAsync((TemplateFolder?)null);
var result = await _sut.CreateFolderAsync("Sub", 99, "admin");
Assert.True(result.IsFailure);
Assert.Contains("not found", result.Error);
}
}
```
**Step 2: Run — confirm fail**
Run: `dotnet test tests/ScadaLink.TemplateEngine.Tests/ --filter "FullyQualifiedName~TemplateFolderServiceTests" --nologo`
Expected: FAIL (`TemplateFolderService` not found).
**Step 3: Implement minimal service**
Create `src/ScadaLink.TemplateEngine/Services/TemplateFolderService.cs`:
```csharp
using ScadaLink.Commons.Entities.Templates;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Types;
namespace ScadaLink.TemplateEngine.Services;
public class TemplateFolderService
{
private readonly ITemplateEngineRepository _repository;
private readonly IAuditService _auditService;
public TemplateFolderService(ITemplateEngineRepository repository, IAuditService auditService)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
}
public async Task<Result<TemplateFolder>> CreateFolderAsync(
string name, int? parentFolderId, string user,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(name))
return Result<TemplateFolder>.Failure("Folder name is required.");
if (parentFolderId.HasValue)
{
var parent = await _repository.GetFolderByIdAsync(parentFolderId.Value, cancellationToken);
if (parent == null)
return Result<TemplateFolder>.Failure($"Parent folder with ID {parentFolderId.Value} not found.");
}
var all = await _repository.GetAllFoldersAsync(cancellationToken);
if (all.Any(f => f.ParentFolderId == parentFolderId
&& string.Equals(f.Name, name, StringComparison.OrdinalIgnoreCase)))
return Result<TemplateFolder>.Failure($"A folder named '{name}' already exists at this level.");
var folder = new TemplateFolder(name) { ParentFolderId = parentFolderId };
await _repository.AddFolderAsync(folder, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "Create", "TemplateFolder", folder.Id.ToString(), name, folder, cancellationToken);
return Result<TemplateFolder>.Success(folder);
}
}
```
**Step 4: Run — confirm pass**
Run: `dotnet test tests/ScadaLink.TemplateEngine.Tests/ --filter "FullyQualifiedName~TemplateFolderServiceTests" --nologo`
Expected: PASS (4 tests).
**Step 5: Commit**
```bash
git add tests/ScadaLink.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs \
src/ScadaLink.TemplateEngine/Services/TemplateFolderService.cs
git commit -m "feat(template-folder): add TemplateFolderService.CreateFolderAsync with validation"
```
---
## Task 6: `TemplateFolderService.RenameFolderAsync`
**Files:**
- Modify: `tests/ScadaLink.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs`
- Modify: `src/ScadaLink.TemplateEngine/Services/TemplateFolderService.cs`
**Step 1: Add failing tests**
Append to `TemplateFolderServiceTests.cs`:
```csharp
[Fact]
public async Task RenameFolder_ValidInput_ReturnsSuccess()
{
var folder = new TemplateFolder("Old") { Id = 1, ParentFolderId = null };
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { folder });
var result = await _sut.RenameFolderAsync(1, "New", "admin");
Assert.True(result.IsSuccess);
Assert.Equal("New", result.Value.Name);
}
[Fact]
public async Task RenameFolder_NotFound_ReturnsFailure()
{
_repoMock.Setup(r => r.GetFolderByIdAsync(99, It.IsAny<CancellationToken>()))
.ReturnsAsync((TemplateFolder?)null);
var result = await _sut.RenameFolderAsync(99, "New", "admin");
Assert.True(result.IsFailure);
}
[Fact]
public async Task RenameFolder_DuplicateSibling_ReturnsFailure()
{
var folder = new TemplateFolder("Old") { Id = 1, ParentFolderId = null };
var sibling = new TemplateFolder("Other") { Id = 2, ParentFolderId = null };
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { folder, sibling });
var result = await _sut.RenameFolderAsync(1, "Other", "admin");
Assert.True(result.IsFailure);
Assert.Contains("already exists", result.Error);
}
```
**Step 2: Run — confirm fail**
Run: `dotnet test tests/ScadaLink.TemplateEngine.Tests/ --filter "FullyQualifiedName~TemplateFolderServiceTests.RenameFolder" --nologo`
Expected: FAIL (method missing).
**Step 3: Implement `RenameFolderAsync`**
Add to `TemplateFolderService.cs`:
```csharp
public async Task<Result<TemplateFolder>> RenameFolderAsync(
int folderId, string newName, string user,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(newName))
return Result<TemplateFolder>.Failure("Folder name is required.");
var folder = await _repository.GetFolderByIdAsync(folderId, cancellationToken);
if (folder == null)
return Result<TemplateFolder>.Failure($"Folder with ID {folderId} not found.");
var all = await _repository.GetAllFoldersAsync(cancellationToken);
if (all.Any(f => f.Id != folderId
&& f.ParentFolderId == folder.ParentFolderId
&& string.Equals(f.Name, newName, StringComparison.OrdinalIgnoreCase)))
return Result<TemplateFolder>.Failure($"A folder named '{newName}' already exists at this level.");
folder.Name = newName;
await _repository.UpdateFolderAsync(folder, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "Update", "TemplateFolder", folder.Id.ToString(), newName, folder, cancellationToken);
return Result<TemplateFolder>.Success(folder);
}
```
**Step 4: Run — confirm pass**
Run: `dotnet test tests/ScadaLink.TemplateEngine.Tests/ --filter "FullyQualifiedName~TemplateFolderServiceTests" --nologo`
Expected: PASS (7 tests now).
**Step 5: Commit**
```bash
git add tests/ScadaLink.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs \
src/ScadaLink.TemplateEngine/Services/TemplateFolderService.cs
git commit -m "feat(template-folder): rename folder with sibling uniqueness check"
```
---
## Task 7: `TemplateFolderService.MoveFolderAsync` with cycle detection
**Files:**
- Modify: `tests/ScadaLink.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs`
- Modify: `src/ScadaLink.TemplateEngine/Services/TemplateFolderService.cs`
**Step 1: Add failing tests**
Append:
```csharp
[Fact]
public async Task MoveFolder_ValidParent_ReturnsSuccess()
{
var f1 = new TemplateFolder("A") { Id = 1, ParentFolderId = null };
var f2 = new TemplateFolder("B") { Id = 2, ParentFolderId = null };
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(f1);
_repoMock.Setup(r => r.GetFolderByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(f2);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { f1, f2 });
var result = await _sut.MoveFolderAsync(1, 2, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(2, result.Value.ParentFolderId);
}
[Fact]
public async Task MoveFolder_OntoSelf_ReturnsFailure()
{
var f1 = new TemplateFolder("A") { Id = 1 };
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(f1);
var result = await _sut.MoveFolderAsync(1, 1, "admin");
Assert.True(result.IsFailure);
Assert.Contains("cycle", result.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task MoveFolder_OntoDescendant_ReturnsFailure()
{
// A -> B -> C; attempting to move A under C must fail.
var fa = new TemplateFolder("A") { Id = 1, ParentFolderId = null };
var fb = new TemplateFolder("B") { Id = 2, ParentFolderId = 1 };
var fc = new TemplateFolder("C") { Id = 3, ParentFolderId = 2 };
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(fa);
_repoMock.Setup(r => r.GetFolderByIdAsync(3, It.IsAny<CancellationToken>())).ReturnsAsync(fc);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { fa, fb, fc });
var result = await _sut.MoveFolderAsync(1, 3, "admin");
Assert.True(result.IsFailure);
Assert.Contains("cycle", result.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task MoveFolder_ToRoot_ReturnsSuccess()
{
var f = new TemplateFolder("Sub") { Id = 1, ParentFolderId = 99 };
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(f);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { f });
var result = await _sut.MoveFolderAsync(1, null, "admin");
Assert.True(result.IsSuccess);
Assert.Null(result.Value.ParentFolderId);
}
```
**Step 2: Run — confirm fail**
Run: `dotnet test tests/ScadaLink.TemplateEngine.Tests/ --filter "FullyQualifiedName~MoveFolder" --nologo`
Expected: FAIL (method missing).
**Step 3: Implement**
Add to `TemplateFolderService.cs`:
```csharp
public async Task<Result<TemplateFolder>> MoveFolderAsync(
int folderId, int? newParentId, string user,
CancellationToken cancellationToken = default)
{
var folder = await _repository.GetFolderByIdAsync(folderId, cancellationToken);
if (folder == null)
return Result<TemplateFolder>.Failure($"Folder with ID {folderId} not found.");
if (newParentId.HasValue)
{
if (newParentId.Value == folderId)
return Result<TemplateFolder>.Failure("Cannot move a folder into itself (cycle).");
var newParent = await _repository.GetFolderByIdAsync(newParentId.Value, cancellationToken);
if (newParent == null)
return Result<TemplateFolder>.Failure($"Target folder with ID {newParentId.Value} not found.");
var all = await _repository.GetAllFoldersAsync(cancellationToken);
// Walk up from newParentId — if we encounter folderId, the move would create a cycle.
var byId = all.ToDictionary(f => f.Id);
var cursor = newParentId;
while (cursor.HasValue)
{
if (cursor.Value == folderId)
return Result<TemplateFolder>.Failure("Cannot move a folder under one of its descendants (cycle).");
cursor = byId.TryGetValue(cursor.Value, out var node) ? node.ParentFolderId : null;
}
// Sibling-name uniqueness in destination.
if (all.Any(f => f.Id != folderId
&& f.ParentFolderId == newParentId
&& string.Equals(f.Name, folder.Name, StringComparison.OrdinalIgnoreCase)))
return Result<TemplateFolder>.Failure($"A folder named '{folder.Name}' already exists in the target folder.");
}
else
{
var all = await _repository.GetAllFoldersAsync(cancellationToken);
if (all.Any(f => f.Id != folderId
&& f.ParentFolderId == null
&& string.Equals(f.Name, folder.Name, StringComparison.OrdinalIgnoreCase)))
return Result<TemplateFolder>.Failure($"A folder named '{folder.Name}' already exists at the root.");
}
folder.ParentFolderId = newParentId;
await _repository.UpdateFolderAsync(folder, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "Move", "TemplateFolder", folder.Id.ToString(), folder.Name, folder, cancellationToken);
return Result<TemplateFolder>.Success(folder);
}
```
**Step 4: Run — confirm pass**
Run: `dotnet test tests/ScadaLink.TemplateEngine.Tests/ --filter "FullyQualifiedName~TemplateFolderServiceTests" --nologo`
Expected: PASS (11 tests).
**Step 5: Commit**
```bash
git add tests/ScadaLink.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs \
src/ScadaLink.TemplateEngine/Services/TemplateFolderService.cs
git commit -m "feat(template-folder): move with cycle detection and sibling uniqueness"
```
---
## Task 8: `TemplateFolderService.DeleteFolderAsync` (non-empty check)
**Files:**
- Modify: `tests/ScadaLink.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs`
- Modify: `src/ScadaLink.TemplateEngine/Services/TemplateFolderService.cs`
**Step 1: Add failing tests**
Append:
```csharp
[Fact]
public async Task DeleteFolder_Empty_ReturnsSuccess()
{
var f = new TemplateFolder("Empty") { Id = 1 };
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(f);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { f });
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template>());
var result = await _sut.DeleteFolderAsync(1, "admin");
Assert.True(result.IsSuccess);
_repoMock.Verify(r => r.DeleteFolderAsync(1, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task DeleteFolder_HasChildFolders_ReturnsFailure_WithCounts()
{
var parent = new TemplateFolder("P") { Id = 1 };
var child = new TemplateFolder("C") { Id = 2, ParentFolderId = 1 };
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(parent);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { parent, child });
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template>());
var result = await _sut.DeleteFolderAsync(1, "admin");
Assert.True(result.IsFailure);
Assert.Contains("1 subfolder", result.Error);
}
[Fact]
public async Task DeleteFolder_HasTemplates_ReturnsFailure_WithCounts()
{
var f = new TemplateFolder("P") { Id = 1 };
var t = new Template("X") { Id = 5, FolderId = 1 };
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(f);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { f });
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { t });
var result = await _sut.DeleteFolderAsync(1, "admin");
Assert.True(result.IsFailure);
Assert.Contains("1 template", result.Error);
}
```
**Step 2: Run — confirm fail**
Run: `dotnet test tests/ScadaLink.TemplateEngine.Tests/ --filter "FullyQualifiedName~DeleteFolder" --nologo`
Expected: FAIL.
**Step 3: Implement**
Add to `TemplateFolderService.cs`:
```csharp
public async Task<Result<bool>> DeleteFolderAsync(
int folderId, string user,
CancellationToken cancellationToken = default)
{
var folder = await _repository.GetFolderByIdAsync(folderId, cancellationToken);
if (folder == null)
return Result<bool>.Failure($"Folder with ID {folderId} not found.");
var allFolders = await _repository.GetAllFoldersAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var childFolderCount = allFolders.Count(f => f.ParentFolderId == folderId);
var childTemplateCount = allTemplates.Count(t => t.FolderId == folderId);
if (childFolderCount > 0 || childTemplateCount > 0)
{
var parts = new List<string>();
if (childTemplateCount > 0)
parts.Add($"{childTemplateCount} template{(childTemplateCount == 1 ? "" : "s")}");
if (childFolderCount > 0)
parts.Add($"{childFolderCount} subfolder{(childFolderCount == 1 ? "" : "s")}");
return Result<bool>.Failure(
$"Cannot delete folder '{folder.Name}': it contains {string.Join(" and ", parts)}. " +
"Move or delete contents first.");
}
await _repository.DeleteFolderAsync(folderId, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "Delete", "TemplateFolder", folderId.ToString(), folder.Name, null, cancellationToken);
return Result<bool>.Success(true);
}
```
**Step 4: Run — confirm pass**
Run: `dotnet test tests/ScadaLink.TemplateEngine.Tests/ --filter "FullyQualifiedName~TemplateFolderServiceTests" --nologo`
Expected: PASS (14 tests).
**Step 5: Commit**
```bash
git add tests/ScadaLink.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs \
src/ScadaLink.TemplateEngine/Services/TemplateFolderService.cs
git commit -m "feat(template-folder): delete folder blocked if non-empty"
```
---
## Task 9: `TemplateService.MoveTemplateAsync`
**Files:**
- Modify: `tests/ScadaLink.TemplateEngine.Tests/TemplateServiceTests.cs`
- Modify: `src/ScadaLink.TemplateEngine/TemplateService.cs`
**Step 1: Add failing tests**
Append to `tests/ScadaLink.TemplateEngine.Tests/TemplateServiceTests.cs` (inside the existing test class):
```csharp
[Fact]
public async Task MoveTemplate_ToFolder_ReturnsSuccess()
{
var t = new Template("X") { Id = 1, FolderId = null };
var folder = new TemplateFolder("Dev") { Id = 7 };
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(t);
_repoMock.Setup(r => r.GetFolderByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
var result = await _sut.MoveTemplateAsync(1, 7, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(7, result.Value.FolderId);
}
[Fact]
public async Task MoveTemplate_ToRoot_ReturnsSuccess()
{
var t = new Template("X") { Id = 1, FolderId = 7 };
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(t);
var result = await _sut.MoveTemplateAsync(1, null, "admin");
Assert.True(result.IsSuccess);
Assert.Null(result.Value.FolderId);
}
[Fact]
public async Task MoveTemplate_TargetFolderMissing_ReturnsFailure()
{
var t = new Template("X") { Id = 1 };
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(t);
_repoMock.Setup(r => r.GetFolderByIdAsync(99, It.IsAny<CancellationToken>())).ReturnsAsync((TemplateFolder?)null);
var result = await _sut.MoveTemplateAsync(1, 99, "admin");
Assert.True(result.IsFailure);
Assert.Contains("not found", result.Error);
}
```
(If `_repoMock` / `_sut` are named differently in the existing file, mirror the existing convention there.)
**Step 2: Run — confirm fail**
Run: `dotnet test tests/ScadaLink.TemplateEngine.Tests/ --filter "FullyQualifiedName~MoveTemplate" --nologo`
Expected: FAIL.
**Step 3: Implement on `TemplateService`**
Add to `src/ScadaLink.TemplateEngine/TemplateService.cs` (above the helper methods section):
```csharp
public async Task<Result<Template>> MoveTemplateAsync(
int templateId, int? newFolderId, string user,
CancellationToken cancellationToken = default)
{
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
if (template == null)
return Result<Template>.Failure($"Template with ID {templateId} not found.");
if (newFolderId.HasValue)
{
var folder = await _repository.GetFolderByIdAsync(newFolderId.Value, cancellationToken);
if (folder == null)
return Result<Template>.Failure($"Target folder with ID {newFolderId.Value} not found.");
}
template.FolderId = newFolderId;
await _repository.UpdateTemplateAsync(template, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "Move", "Template", template.Id.ToString(), template.Name, template, cancellationToken);
return Result<Template>.Success(template);
}
```
**Step 4: Run — confirm pass**
Run: `dotnet test tests/ScadaLink.TemplateEngine.Tests/ --filter "FullyQualifiedName~TemplateServiceTests" --nologo`
Expected: all pass (existing + 3 new).
**Step 5: Commit**
```bash
git add tests/ScadaLink.TemplateEngine.Tests/TemplateServiceTests.cs \
src/ScadaLink.TemplateEngine/TemplateService.cs
git commit -m "feat(template-engine): TemplateService.MoveTemplateAsync"
```
---
## Task 10: DI registration
**Files:**
- Modify: `src/ScadaLink.TemplateEngine/ServiceCollectionExtensions.cs`
**Step 1: Add registration**
Inside `AddTemplateEngine`, alongside the other `AddScoped<...>()` lines:
```csharp
services.AddScoped<TemplateFolderService>();
```
**Step 2: Build**
Run: `dotnet build src/ScadaLink.TemplateEngine/ScadaLink.TemplateEngine.csproj`
Expected: 0 errors.
**Step 3: Commit**
```bash
git add src/ScadaLink.TemplateEngine/ServiceCollectionExtensions.cs
git commit -m "feat(di): register TemplateFolderService"
```
---
## Task 11: Management command records
**Files:**
- Create: `src/ScadaLink.Commons/Messages/Management/TemplateFolderCommands.cs`
**Step 1: Create file**
```csharp
namespace ScadaLink.Commons.Messages.Management;
public record ListTemplateFoldersCommand;
public record CreateTemplateFolderCommand(string Name, int? ParentFolderId);
public record RenameTemplateFolderCommand(int FolderId, string NewName);
public record MoveTemplateFolderCommand(int FolderId, int? NewParentFolderId);
public record DeleteTemplateFolderCommand(int FolderId);
public record MoveTemplateToFolderCommand(int TemplateId, int? NewFolderId);
```
**Step 2: Verify auto-registration**
Run: `dotnet test tests/ScadaLink.Commons.Tests/ --nologo` — if a `ManagementCommandRegistry` smoke test exists it picks up these new types automatically (registration is reflection-based).
If no existing smoke test catches missing dispatch, this is just a build check:
Run: `dotnet build src/ScadaLink.Commons/ScadaLink.Commons.csproj`
Expected: 0 errors.
**Step 3: Commit**
```bash
git add src/ScadaLink.Commons/Messages/Management/TemplateFolderCommands.cs
git commit -m "feat(management): add TemplateFolder command records"
```
---
## Task 12: `ManagementActor` authorization + handlers
**Files:**
- Modify: `src/ScadaLink.ManagementService/ManagementActor.cs`
**Step 1: Add to `GetRequiredRole`**
In `GetRequiredRole`, inside the `=> "Design"` pattern-match block (around line 113), append the new commands:
```csharp
or CreateTemplateFolderCommand or RenameTemplateFolderCommand
or MoveTemplateFolderCommand or DeleteTemplateFolderCommand
or MoveTemplateToFolderCommand => "Design",
```
(`ListTemplateFoldersCommand` is a read-only query and falls through to the `_ => null` branch — any authenticated user can list.)
**Step 2: Add dispatch cases**
Inside `DispatchCommand`, alongside the existing template-member entries, add:
```csharp
// 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),
```
**Step 3: Add handler methods**
Near the existing `HandleListTemplates` block in `ManagementActor.cs`, add:
```csharp
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);
}
```
**Step 4: Add missing `using` if needed**
If `TemplateFolderService` isn't already imported, add `using ScadaLink.TemplateEngine.Services;` near the top.
**Step 5: Build + test**
Run: `dotnet build src/ScadaLink.ManagementService/ScadaLink.ManagementService.csproj`
Expected: 0 errors.
Run: `dotnet test tests/ScadaLink.ManagementService.Tests/ --nologo`
Expected: all pass.
**Step 6: Commit**
```bash
git add src/ScadaLink.ManagementService/ManagementActor.cs
git commit -m "feat(management): handler + authorization for TemplateFolder commands"
```
---
## Task 13: Templates.razor — extend data loading to include folders
**Files:**
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor`
This task only changes the `@code` block. The markup is rewritten in Task 14.
**Step 1: Inject the new service**
Near the top of the file, add:
```razor
@inject TemplateFolderService TemplateFolderService
```
And the using:
```razor
@using ScadaLink.TemplateEngine.Services
```
**Step 2: Add folder field**
In the `@code` block, alongside `_templates`:
```csharp
private List<TemplateFolder> _folders = new();
```
**Step 3: Load folders alongside templates**
Replace the body of `LoadTemplatesAsync` so it loads both:
```csharp
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;
}
```
**Step 4: Build verify**
Run: `dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj`
Expected: 0 errors. (`BuildTemplateTree` will be rewritten next task — current impl still compiles.)
**Step 5: Commit**
```bash
git add src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor
git commit -m "feat(ui/templates): load folders alongside templates"
```
---
## Task 14: Build the new `TmplNode` tree model
**Files:**
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor`
**Step 1: Replace the existing `TmplTreeNode` record + `BuildTemplateTree` / `BuildTmplChildren`**
Delete the existing `TmplTreeNode` record and the `BuildTemplateTree` / `BuildTmplChildren` helpers. Replace with:
```csharp
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) =>
{
// Folders first
var kindOrder = (int)a.Kind - (int)b.Kind; // Folder=0, Template=1, Composition=2
if (kindOrder != 0) return kindOrder;
return string.Compare(a.Label, b.Label, StringComparison.OrdinalIgnoreCase);
});
}
```
**Step 2: Build**
Run: `dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj`
Expected: build error in the existing `<TreeView TItem="TmplTreeNode" ...>` — it references the removed type. That's expected; will be fixed in Task 15.
**Step 3: Don't commit yet — Task 15 finishes the markup change before this compiles.**
---
## Task 15: Templates.razor — split-pane layout + new TreeView wiring
**Files:**
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor`
**Step 1: Replace the page body markup**
Replace the entire `<div class="container-fluid mt-3">` block with this skeleton — note that the **template detail markup** (properties card, validation block, four tabs) moves verbatim from the old `_selectedTemplate != null` branch into the new `else` branch inside the right column.
```razor
<div class="container-fluid mt-3">
<ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" />
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else
{
<div class="row g-2">
<div class="col-md-4 col-lg-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<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 style="max-height: calc(100vh - 160px); overflow-y: auto;">
<TreeView @ref="_tree" TItem="TmplNode" Items="_treeRoots"
ChildrenSelector="n => n.Children"
HasChildrenSelector="n => n.Kind != TmplNodeKind.Composition && n.Children.Count > 0"
KeySelector="n => (object)n.Key"
StorageKey="templates-tree"
Selectable="true"
SelectedKeyChanged="OnTreeNodeSelected">
<NodeContent Context="node">
@RenderNodeLabel(node)
</NodeContent>
<ContextMenu Context="node">
@RenderNodeContextMenu(node)
</ContextMenu>
<EmptyContent>
<span class="text-muted fst-italic">No templates yet. Use the buttons above to create a folder or template.</span>
</EmptyContent>
</TreeView>
</div>
</div>
<div 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
{
@* === existing template detail markup goes here, unchanged === *@
@RenderTemplateDetail()
}
</div>
</div>
}
</div>
```
**Step 2: Move the existing template detail markup into a method**
Wrap the existing properties card + validation block + tab nav + tab bodies (everything inside the original `else { ... template detail ... }` block, except the "Back to List" button which is removed) into a `RenderFragment RenderTemplateDetail()` method. The four tab `RenderFragment` methods already exist and stay as-is.
```csharp
private RenderFragment RenderTemplateDetail() => __builder =>
{
// Paste the existing detail markup here (properties card, validation results,
// <ul class="nav nav-tabs">, and the @if (_activeTab == ...) branches that
// call RenderAttributesTab/RenderAlarmsTab/RenderScriptsTab/RenderCompositionsTab).
// DELETE the "Back to List" button — it's no longer needed.
};
```
**Step 3: Add tree node label helpers**
```csharp
private TreeView<TmplNode> _tree = default!;
private RenderFragment RenderNodeLabel(TmplNode node) => __builder =>
{
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:
<span>@node.Label</span>
<span class="text-muted small ms-1"> @(_templates.FirstOrDefault(t => t.Id == node.Composition!.ComposedTemplateId)?.Name ?? $"#{node.Composition.ComposedTemplateId}")</span>
break;
}
};
private async Task OnTreeNodeSelected(object? key)
{
if (key is not string s) return;
if (s.StartsWith("t:") && int.TryParse(s[2..], out var tid))
{
await SelectTemplate(tid);
}
else if (s.StartsWith("c:") && int.TryParse(s[2..], out var cid))
{
var comp = _templates.SelectMany(t => t.Compositions).FirstOrDefault(c => c.Id == cid);
if (comp != null)
{
// Reveal + select the composed template.
_tree.RevealNode($"t:{comp.ComposedTemplateId}", select: true);
await SelectTemplate(comp.ComposedTemplateId);
}
}
// Folder selection: no-op (Section 4 design — folder click does not load detail).
}
```
**Step 4: Stub the dialog/menu helpers (real impls in Tasks 1618)**
```csharp
private RenderFragment RenderNodeContextMenu(TmplNode node) => __builder => { };
private void OpenNewFolderDialog(int? parentFolderId) { /* Task 17 */ }
private void OpenNewTemplateDialog(int? parentFolderId) { /* Task 17 */ }
```
**Step 5: Remove now-orphaned `BackToList`**
Delete `BackToList()` method and any remaining references. The "Back to List" button was removed in Step 2.
**Step 6: Build + smoke**
Run: `dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj`
Expected: 0 errors.
Manual smoke (no DB changes needed beyond Task 3's migration — auto-applied on host start):
Run: `bash docker/deploy.sh` to rebuild the cluster.
Then open http://localhost:9000/design/templates (login `multi-role` / `password`).
Expected: tree shows existing templates at the root (none in folders yet). Selecting a template loads its detail pane on the right.
**Step 7: Commit**
```bash
git add src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor
git commit -m "feat(ui/templates): split-pane layout with folder + composition tree"
```
---
## Task 16: Per-kind context menus
**Files:**
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor`
**Step 1: Implement `RenderNodeContextMenu`**
Replace the stub with:
```csharp
private RenderFragment RenderNodeContextMenu(TmplNode node) => __builder =>
{
switch (node.Kind)
{
case TmplNodeKind.Folder:
<button class="dropdown-item" @onclick="() => OpenNewFolderDialog(node.EntityId)">New Folder</button>
<button class="dropdown-item" @onclick="() => OpenNewTemplateDialog(node.EntityId)">New Template</button>
<button class="dropdown-item" @onclick="() => OpenRenameFolderDialog(node.EntityId, node.Label)">Rename</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger" @onclick="() => DeleteFolder(node.EntityId, node.Label)">Delete</button>
break;
case TmplNodeKind.Template:
<button class="dropdown-item" @onclick="() => SelectTemplate(node.EntityId)">Edit</button>
<button class="dropdown-item" @onclick="() => OpenMoveTemplateDialog(node.EntityId, node.Label)">Move to Folder</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger" @onclick="() => DeleteTemplate(node.Template!)">Delete</button>
break;
case TmplNodeKind.Composition:
var composedId = node.Composition!.ComposedTemplateId;
<button class="dropdown-item" @onclick="() => OnTreeNodeSelected($"t:{composedId}")">Open composed template</button>
<button class="dropdown-item text-danger" @onclick="() => DeleteComposition(node.Composition)">Remove composition</button>
break;
}
};
```
**Step 2: Implement folder rename + delete handlers**
```csharp
// Rename 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);
}
}
```
**Step 3: Render the rename dialog**
Add inside the top-level `<div class="container-fluid mt-3">` (after `<ConfirmDialog />`):
```razor
@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>
}
```
**Step 4: Build + smoke**
Run: `dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj`
Expected: 0 errors.
Manual smoke: right-click a template → Edit / Move (stub) / Delete render correctly. Right-click a (future) folder → New Folder / New Template / Rename / Delete. Rename and Delete work end-to-end.
**Step 5: Commit**
```bash
git add src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor
git commit -m "feat(ui/templates): per-kind context menus + folder rename/delete"
```
---
## Task 17: New-folder / new-template / move-template dialogs
**Files:**
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor`
**Step 1: New-folder dialog state + handler**
```csharp
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();
}
else
{
_newFolderError = result.Error;
}
}
```
**Step 2: New-template dialog state + handler**
The existing `/design/templates/create` route is the canonical create flow (the page currently navigates to it from the root toolbar). To keep one flow, the "New Template" dialog from a folder context **pre-selects the folder** and navigates to the create page with a query string. Simpler v1 option: dialog inline that calls `TemplateService.CreateTemplateAsync` directly, passing `FolderId`.
Since `TemplateService.CreateTemplateAsync` currently does not accept `FolderId`, extend it:
In `TemplateService.CreateTemplateAsync` signature, add an optional `int? folderId = null` parameter and apply it: `template.FolderId = folderId;` before `AddTemplateAsync`. (Single one-line change. Add a test for it in `TemplateServiceTests.cs` alongside the existing create tests.)
Then in the page:
```csharp
private bool _showNewTemplateDialog;
private int? _newTemplateFolderId;
private string _newTemplateName = string.Empty;
private string? _newTemplateDescription;
private string? _newTemplateError;
private void OpenNewTemplateDialog(int? folderId)
{
_newTemplateFolderId = folderId;
_newTemplateName = string.Empty;
_newTemplateDescription = null;
_newTemplateError = null;
_showNewTemplateDialog = true;
}
private async Task SubmitNewTemplate()
{
_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
{
_newTemplateError = result.Error;
}
}
```
**Step 3: Move-template dialog state + folder picker**
```csharp
private bool _showMoveTemplateDialog;
private int _moveTemplateId;
private string _moveTemplateName = string.Empty;
private int? _moveTemplateTargetFolderId;
private string? _moveTemplateError;
private void OpenMoveTemplateDialog(int templateId, string label)
{
_moveTemplateId = templateId;
_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;
}
}
```
**Step 4: Render the three modals**
Append to the same top-level container after the rename modal:
```razor
@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>
}
```
**Step 5: Build + smoke**
Run: `dotnet build ScadaLink.sln`
Expected: 0 errors. Run the cluster (`bash docker/deploy.sh`), exercise the three modals manually.
**Step 6: Commit**
```bash
git add src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor \
src/ScadaLink.TemplateEngine/TemplateService.cs \
tests/ScadaLink.TemplateEngine.Tests/TemplateServiceTests.cs
git commit -m "feat(ui/templates): new-folder, new-template, move-template dialogs"
```
---
## Task 18: Drag-drop reorganization
**Files:**
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor`
**Step 1: Drag state**
```csharp
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();
}
```
**Step 2: Apply drag attributes inside `NodeContent` (in `RenderNodeLabel`)**
Wrap the label markup in a `<div>` carrying the events. Change the start of `RenderNodeLabel`:
```csharp
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)">
@* existing per-kind label switch (folder/template/composition) goes here *@
</div>
};
```
(Move the existing switch body inside the `<div>`.)
**Step 3: Add a root-level drop zone**
Wrap the `<TreeView>` in a div that accepts drops onto the root:
```razor
<div @ondragover:preventDefault="true" @ondrop="OnDropOnRoot"
style="min-height: 100%; padding: 4px;">
<TreeView ... />
</div>
```
**Step 4: Build + smoke**
Run: `dotnet build ScadaLink.sln`
Expected: 0 errors.
Manual smoke (after `bash docker/deploy.sh`):
- Drag a template into a folder → tree reloads, template moves.
- Drag a folder into one of its descendants → toast error appears (cycle).
- Drag a template onto the empty sidebar area → moves to root.
**Step 5: Commit**
```bash
git add src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor
git commit -m "feat(ui/templates): native HTML5 drag-drop reorganization"
```
---
## Task 19: Deep-link reveal on load
**Files:**
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor`
**Step 1: Call `RevealNode` after initial load when route param is set**
Modify `OnInitializedAsync`:
```csharp
protected override async Task OnInitializedAsync()
{
await LoadTemplatesAsync();
if (TemplateIdParam > 0)
{
await SelectTemplate(TemplateIdParam);
}
}
protected override void OnAfterRender(bool firstRender)
{
if (firstRender && TemplateIdParam > 0 && _tree != null)
{
_tree.RevealNode($"t:{TemplateIdParam}", select: true);
}
}
```
**Step 2: Build + smoke**
Run: `dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj`
Expected: 0 errors.
Manual smoke: navigate directly to `/design/templates/{existing-id}` — the tree reveals the template and selects it.
**Step 3: Commit**
```bash
git add src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor
git commit -m "feat(ui/templates): reveal deep-linked template on initial render"
```
---
## Task 20: bUnit tests for the new page
**Files:**
- Create: `tests/ScadaLink.CentralUI.Tests/TemplatesPageTests.cs`
**Step 1: Write rendering tests**
```csharp
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using ScadaLink.CentralUI.Components.Pages.Design;
using ScadaLink.Commons.Entities.Templates;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.TemplateEngine;
using ScadaLink.TemplateEngine.Services;
namespace ScadaLink.CentralUI.Tests;
public class TemplatesPageTests : BunitContext
{
private readonly Mock<ITemplateEngineRepository> _repo = new();
private readonly Mock<IAuditService> _audit = new();
public TemplatesPageTests()
{
Services.AddSingleton(_repo.Object);
Services.AddSingleton(_audit.Object);
Services.AddScoped<TemplateService>();
Services.AddScoped<TemplateFolderService>();
AddTestAuth();
}
private void AddTestAuth()
{
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.Setup(r => r.GetAllTemplatesAsync(default)).ReturnsAsync(new List<Template>());
_repo.Setup(r => r.GetAllFoldersAsync(default)).ReturnsAsync(new List<TemplateFolder>());
var cut = Render<Templates>();
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.Setup(r => r.GetAllTemplatesAsync(default)).ReturnsAsync(new List<Template> { template });
_repo.Setup(r => r.GetAllFoldersAsync(default)).ReturnsAsync(new List<TemplateFolder> { folder });
var cut = Render<Templates>();
Assert.Contains("Dev", cut.Markup);
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 });
_repo.Setup(r => r.GetAllTemplatesAsync(default))
.ReturnsAsync(new List<Template> { template, new("Other") { Id = 99 } });
_repo.Setup(r => r.GetAllFoldersAsync(default)).ReturnsAsync(new List<TemplateFolder>());
var cut = Render<Templates>();
Assert.Contains("DelmiaReceiver", cut.Markup);
Assert.Contains("→", cut.Markup);
}
}
internal class TestAuthStateProvider : AuthenticationStateProvider
{
private readonly ClaimsPrincipal _user;
public TestAuthStateProvider(ClaimsPrincipal user) => _user = user;
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(new AuthenticationState(_user));
}
```
**Step 2: Run**
Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TemplatesPageTests" --nologo`
Expected: PASS (3 tests).
**Step 3: Commit**
```bash
git add tests/ScadaLink.CentralUI.Tests/TemplatesPageTests.cs
git commit -m "test(ui/templates): bUnit rendering tests for folder tree"
```
---
## Task 21: Documentation updates
**Files:**
- Modify: `docs/requirements/Component-CentralUI.md`
- Modify: `docs/requirements/Component-TemplateEngine.md`
- Modify: `docs/requirements/Component-ConfigurationDatabase.md`
- Modify: `docs/requirements/Component-ManagementService.md`
- Modify: `README.md`
For each, do a targeted edit. Use `grep -n` to find the right section, then `Edit` with surgical replacements. Examples:
**Step 1: Component-CentralUI.md** — find the section describing the templates page (the current page has a "list view" description). Replace it with a paragraph describing: split-pane layout, folder tree, composition leaves, context menus, drag-drop.
**Step 2: Component-TemplateEngine.md** — under "Key Entities", add a `TemplateFolder` subsection between Template and Attribute. Under "Responsibilities", add: "Organize templates into nested folders (`TemplateFolder` entity) and validate folder hierarchy invariants (acyclicity, sibling uniqueness, non-empty-on-delete)."
**Step 3: Component-ConfigurationDatabase.md** — find the table listing entities. Add a row for `TemplateFolders`. Mention the new `Templates.FolderId` nullable column.
**Step 4: Component-ManagementService.md** — under the design/operations commands section, add the new commands (`CreateTemplateFolder`, `RenameTemplateFolder`, `MoveTemplateFolder`, `DeleteTemplateFolder`, `MoveTemplateToFolder`).
**Step 5: README.md** — find the component table row for Template Engine; append "folder organization (folders, nested hierarchy)" to its Responsibilities cell.
**Step 6: Verify** by re-reading the modified sections in each file (just `Read` with offset to confirm the wording sticks).
**Step 7: Commit**
```bash
git add docs/requirements/Component-CentralUI.md \
docs/requirements/Component-TemplateEngine.md \
docs/requirements/Component-ConfigurationDatabase.md \
docs/requirements/Component-ManagementService.md \
README.md
git commit -m "docs(templates): describe folder hierarchy and management commands"
```
---
## Task 22: Final smoke + green-suite check
**Files:** none modified.
**Step 1: Full test pass**
Run: `dotnet test ScadaLink.sln --nologo`
Expected: all green.
**Step 2: Build cluster image and run**
Run: `bash docker/deploy.sh`
Expected: image rebuilds, 5-container cluster starts.
**Step 3: Manual smoke checklist**
Open http://localhost:9000/design/templates (login `multi-role` / `password`). Verify:
- [ ] Existing templates appear at root (no folder).
- [ ] Click `+ Folder` toolbar button → modal opens → create "Dev" → folder appears at root.
- [ ] Right-click "Dev" → "New Folder" → create "Sub" → nested under "Dev".
- [ ] Right-click "Dev" → "New Template" → creates template inside "Dev".
- [ ] Right-click a template → "Move to Folder…" → pick "Sub" → moves.
- [ ] Drag a template onto "Dev" → moves.
- [ ] Drag "Dev" onto "Sub" (its descendant) → toast error mentions cycle.
- [ ] Add a composition to an existing template → composition appears as a leaf under the template.
- [ ] Right-click composition → "Open composed template" → reveals and selects target.
- [ ] Right-click "Dev" (now non-empty) → "Delete" → error toast lists counts.
- [ ] Delete contents, then delete "Dev" → succeeds.
- [ ] Refresh the page → expansion state persists (sessionStorage).
- [ ] Navigate to `/design/templates/{id}` directly → tree reveals and selects.
**Step 4: No commit** — this is verification only. If something fails, return to the relevant task and fix.
---
## Out of scope (per design)
- CLI commands for folder operations (Management Service contracts now exist; CLI follows in a future plan).
- Tree search / filter input.
- Sibling reordering via drag-drop (alphabetical sort is fixed).
- Root-area context menu (right-click in empty tree space).
- Bootstrap Icons CDN (use Unicode `📁` glyph).
---
## Risks & rollback
- **Migration is purely additive** (new table + nullable column). Down-migration: `dotnet ef migrations remove ...` while disconnected.
- The page is server-rendered Blazor with no JS interop beyond what `TreeView` already uses for sessionStorage. Drag-drop uses native HTML5 `@ondragstart/@ondrop` — no new JS files.
- The `TemplateService.CreateTemplateAsync` signature gains an optional `folderId` parameter (defaults to `null`) — backward-compatible at call sites.
- If the cluster needs a clean DB after the migration, `docker/deploy.sh` recreates volumes by default; data is dev-only.