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