79 KiB
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, 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
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 inConfigurations/. - Management commands: simple records in
src/ScadaLink.Commons/Messages/Management/*.cs; auto-discovered by reflection. - ManagementActor dispatch + authorization both via
switchon command type. - UI injects
TemplateServicedirectly (and will injectTemplateFolderServicethe 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.slnx
Expected: build succeeds with 0 errors.
Step 4: Existing tests pass
Run: dotnet test ScadaLink.slnx --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):
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:
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:
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
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:
public DbSet<TemplateFolder> TemplateFolders => Set<TemplateFolder>();
Step 2: Add TemplateFolderConfiguration class
Append to src/ScadaLink.ConfigurationDatabase/Configurations/TemplateConfiguration.cs:
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:
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
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 columnsId,Name,ParentFolderId(nullable),SortOrder.- Self-referencing FK on
ParentFolderIdwithonDelete: Restrict. - Unique index on
(ParentFolderId, Name). AddColumn<int>("FolderId", "Templates", nullable: true).- FK on
Templates.FolderId→TemplateFolders.IdwithonDelete: 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
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:
// 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):
// 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.slnx
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
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:
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:
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
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:
[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:
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
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:
[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:
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
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:
[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:
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
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):
[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):
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
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:
services.AddScoped<TemplateFolderService>();
Step 2: Build
Run: dotnet build src/ScadaLink.TemplateEngine/ScadaLink.TemplateEngine.csproj
Expected: 0 errors.
Step 3: Commit
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
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
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:
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:
// 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:
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
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:
@inject TemplateFolderService TemplateFolderService
And the using:
@using ScadaLink.TemplateEngine.Services
Step 2: Add folder field
In the @code block, alongside _templates:
private List<TemplateFolder> _folders = new();
Step 3: Load folders alongside templates
Replace the body of LoadTemplatesAsync so it loads both:
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
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:
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.
<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.
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
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 16–18)
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
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:
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
// 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 />):
@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
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
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:
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
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:
@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.slnx
Expected: 0 errors. Run the cluster (bash docker/deploy.sh), exercise the three modals manually.
Step 6: Commit
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
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:
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:
<div @ondragover:preventDefault="true" @ondrop="OnDropOnRoot"
style="min-height: 100%; padding: 4px;">
<TreeView ... />
</div>
Step 4: Build + smoke
Run: dotnet build ScadaLink.slnx
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
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:
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
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
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
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
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.slnx --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
+ Foldertoolbar 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
TreeViewalready uses for sessionStorage. Drag-drop uses native HTML5@ondragstart/@ondrop— no new JS files. - The
TemplateService.CreateTemplateAsyncsignature gains an optionalfolderIdparameter (defaults tonull) — backward-compatible at call sites. - If the cluster needs a clean DB after the migration,
docker/deploy.shrecreates volumes by default; data is dev-only.