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

79 KiB
Raw Blame History

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 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.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 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.FolderIdTemplateFolders.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

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 1618)

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"

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 + 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.