feat(m9/T23a): folder sibling reorder (ReorderFolderAsync + command + handler)

This commit is contained in:
Joseph Doherty
2026-06-18 11:00:57 -04:00
parent 0bd5e0986f
commit e3bc19c673
6 changed files with 245 additions and 2 deletions
@@ -1,8 +1,11 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
public record ListTemplateFoldersCommand; public record ListTemplateFoldersCommand;
public record CreateTemplateFolderCommand(string Name, int? ParentFolderId); public record CreateTemplateFolderCommand(string Name, int? ParentFolderId);
public record RenameTemplateFolderCommand(int FolderId, string NewName); public record RenameTemplateFolderCommand(int FolderId, string NewName);
public record MoveTemplateFolderCommand(int FolderId, int? NewParentFolderId); public record MoveTemplateFolderCommand(int FolderId, int? NewParentFolderId);
public record ReorderTemplateFolderCommand(int FolderId, ReorderDirection Direction);
public record DeleteTemplateFolderCommand(int FolderId); public record DeleteTemplateFolderCommand(int FolderId);
public record MoveTemplateToFolderCommand(int TemplateId, int? NewFolderId); public record MoveTemplateToFolderCommand(int TemplateId, int? NewFolderId);
@@ -0,0 +1,16 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
/// <summary>
/// Direction for a sibling reorder operation. Ordering is ascending by
/// <c>SortOrder</c>, so <see cref="Up"/> moves an item toward a lower sort
/// order (earlier in the list) and <see cref="Down"/> toward a higher sort
/// order (later in the list).
/// </summary>
public enum ReorderDirection
{
/// <summary>Move toward a lower sort order (swap with the previous sibling).</summary>
Up,
/// <summary>Move toward a higher sort order (swap with the next sibling).</summary>
Down
}
@@ -632,7 +632,10 @@ public class TemplateEngineRepository : ITemplateEngineRepository
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<TemplateFolder>> GetAllFoldersAsync(CancellationToken cancellationToken = default) public async Task<IReadOnlyList<TemplateFolder>> GetAllFoldersAsync(CancellationToken cancellationToken = default)
=> await _context.TemplateFolders.ToListAsync(cancellationToken); => await _context.TemplateFolders
.OrderBy(f => f.SortOrder)
.ThenBy(f => f.Name)
.ToListAsync(cancellationToken);
/// <inheritdoc /> /// <inheritdoc />
public async Task AddFolderAsync(TemplateFolder folder, CancellationToken cancellationToken = default) public async Task AddFolderAsync(TemplateFolder folder, CancellationToken cancellationToken = default)
@@ -198,7 +198,7 @@ public class ManagementActor : ReceiveActor
or CreateApiMethodCommand or UpdateApiMethodCommand or DeleteApiMethodCommand or CreateApiMethodCommand or UpdateApiMethodCommand or DeleteApiMethodCommand
or UpdateAreaCommand or UpdateAreaCommand
or CreateTemplateFolderCommand or RenameTemplateFolderCommand or CreateTemplateFolderCommand or RenameTemplateFolderCommand
or MoveTemplateFolderCommand or DeleteTemplateFolderCommand or MoveTemplateFolderCommand or ReorderTemplateFolderCommand or DeleteTemplateFolderCommand
or MoveTemplateToFolderCommand or MoveTemplateToFolderCommand
or ExportBundleCommand => Roles.Designer, or ExportBundleCommand => Roles.Designer,
@@ -267,6 +267,7 @@ public class ManagementActor : ReceiveActor
CreateTemplateFolderCommand cmd => await HandleCreateTemplateFolder(sp, cmd, user.Username), CreateTemplateFolderCommand cmd => await HandleCreateTemplateFolder(sp, cmd, user.Username),
RenameTemplateFolderCommand cmd => await HandleRenameTemplateFolder(sp, cmd, user.Username), RenameTemplateFolderCommand cmd => await HandleRenameTemplateFolder(sp, cmd, user.Username),
MoveTemplateFolderCommand cmd => await HandleMoveTemplateFolder(sp, cmd, user.Username), MoveTemplateFolderCommand cmd => await HandleMoveTemplateFolder(sp, cmd, user.Username),
ReorderTemplateFolderCommand cmd => await HandleReorderTemplateFolder(sp, cmd, user.Username),
DeleteTemplateFolderCommand cmd => await HandleDeleteTemplateFolder(sp, cmd, user.Username), DeleteTemplateFolderCommand cmd => await HandleDeleteTemplateFolder(sp, cmd, user.Username),
MoveTemplateToFolderCommand cmd => await HandleMoveTemplateToFolder(sp, cmd, user.Username), MoveTemplateToFolderCommand cmd => await HandleMoveTemplateToFolder(sp, cmd, user.Username),
@@ -619,6 +620,13 @@ public class ManagementActor : ReceiveActor
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error); return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
} }
private static async Task<object?> HandleReorderTemplateFolder(IServiceProvider sp, ReorderTemplateFolderCommand cmd, string user)
{
var svc = sp.GetRequiredService<TemplateFolderService>();
var result = await svc.ReorderFolderAsync(cmd.FolderId, cmd.Direction, user);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
private static async Task<object?> HandleDeleteTemplateFolder(IServiceProvider sp, DeleteTemplateFolderCommand cmd, string user) private static async Task<object?> HandleDeleteTemplateFolder(IServiceProvider sp, DeleteTemplateFolderCommand cmd, string user)
{ {
var svc = sp.GetRequiredService<TemplateFolderService>(); var svc = sp.GetRequiredService<TemplateFolderService>();
@@ -2,6 +2,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Services; namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
@@ -152,6 +153,56 @@ public class TemplateFolderService
return Result<TemplateFolder>.Success(folder); return Result<TemplateFolder>.Success(folder);
} }
/// <summary>
/// Reorders a template folder relative to its siblings under the same parent by
/// swapping <see cref="TemplateFolder.SortOrder"/> with the adjacent sibling in the
/// requested direction. Siblings are ordered ascending by <see cref="TemplateFolder.SortOrder"/>
/// then <see cref="TemplateFolder.Name"/>; <see cref="ReorderDirection.Up"/> swaps with the
/// previous sibling and <see cref="ReorderDirection.Down"/> with the next. Reordering past
/// either end is a no-op (returns success without persisting or auditing).
/// </summary>
/// <param name="folderId">Id of the folder to reorder.</param>
/// <param name="direction">Direction to move the folder within its sibling set.</param>
/// <param name="user">Username of the actor performing the operation (for audit).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that resolves to the reordered <see cref="TemplateFolder"/>, or a failure result if the folder is not found.</returns>
public async Task<Result<TemplateFolder>> ReorderFolderAsync(
int folderId, ReorderDirection direction, 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.");
var all = await _repository.GetAllFoldersAsync(cancellationToken);
// Deterministic sibling ordering: ascending SortOrder, then Name as tie-breaker.
var siblings = all
.Where(f => f.ParentFolderId == folder.ParentFolderId)
.OrderBy(f => f.SortOrder)
.ThenBy(f => f.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
var index = siblings.FindIndex(f => f.Id == folderId);
var adjacentIndex = direction == ReorderDirection.Up ? index - 1 : index + 1;
// At the end of the sibling set in the requested direction: no-op.
if (adjacentIndex < 0 || adjacentIndex >= siblings.Count)
return Result<TemplateFolder>.Success(folder);
var adjacent = siblings[adjacentIndex];
// Swap sort order with the adjacent sibling.
(folder.SortOrder, adjacent.SortOrder) = (adjacent.SortOrder, folder.SortOrder);
await _repository.UpdateFolderAsync(folder, cancellationToken);
await _repository.UpdateFolderAsync(adjacent, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "Reorder", "TemplateFolder", folder.Id.ToString(), folder.Name, folder, cancellationToken);
return Result<TemplateFolder>.Success(folder);
}
/// <summary> /// <summary>
/// Deletes an empty template folder. Fails if the folder contains sub-folders or templates. /// Deletes an empty template folder. Fails if the folder contains sub-folders or templates.
/// </summary> /// </summary>
@@ -2,6 +2,7 @@ using Moq;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services; using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Services; namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Services;
@@ -236,4 +237,165 @@ public class TemplateFolderServiceTests
Assert.True(result.IsFailure); Assert.True(result.IsFailure);
Assert.Contains("1 template", result.Error); Assert.Contains("1 template", result.Error);
} }
// ========================================================================
// ReorderFolderAsync — sibling reorder by SortOrder (Up = lower, Down = higher)
// ========================================================================
[Fact]
public async Task ReorderFolder_Up_SwapsSortOrderWithPreviousSibling()
{
// Siblings under root: A(0), B(1), C(2). Move B up -> swaps with A.
var a = new TemplateFolder("A") { Id = 1, ParentFolderId = null, SortOrder = 0 };
var b = new TemplateFolder("B") { Id = 2, ParentFolderId = null, SortOrder = 1 };
var c = new TemplateFolder("C") { Id = 3, ParentFolderId = null, SortOrder = 2 };
_repoMock.Setup(r => r.GetFolderByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(b);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { a, b, c });
var result = await _sut.ReorderFolderAsync(2, ReorderDirection.Up, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(0, b.SortOrder);
Assert.Equal(1, a.SortOrder);
Assert.Equal(2, c.SortOrder);
}
[Fact]
public async Task ReorderFolder_Down_SwapsSortOrderWithNextSibling()
{
// Siblings under root: A(0), B(1), C(2). Move B down -> swaps with C.
var a = new TemplateFolder("A") { Id = 1, ParentFolderId = null, SortOrder = 0 };
var b = new TemplateFolder("B") { Id = 2, ParentFolderId = null, SortOrder = 1 };
var c = new TemplateFolder("C") { Id = 3, ParentFolderId = null, SortOrder = 2 };
_repoMock.Setup(r => r.GetFolderByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(b);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { a, b, c });
var result = await _sut.ReorderFolderAsync(2, ReorderDirection.Down, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(0, a.SortOrder);
Assert.Equal(2, b.SortOrder);
Assert.Equal(1, c.SortOrder);
}
[Fact]
public async Task ReorderFolder_UpAtTop_IsNoOp()
{
var a = new TemplateFolder("A") { Id = 1, ParentFolderId = null, SortOrder = 0 };
var b = new TemplateFolder("B") { Id = 2, ParentFolderId = null, SortOrder = 1 };
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(a);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { a, b });
var result = await _sut.ReorderFolderAsync(1, ReorderDirection.Up, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(0, a.SortOrder);
Assert.Equal(1, b.SortOrder);
// No-op: nothing persisted, nothing audited.
_repoMock.Verify(r => r.UpdateFolderAsync(It.IsAny<TemplateFolder>(), It.IsAny<CancellationToken>()), Times.Never);
_repoMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Never);
_auditMock.Verify(a => a.LogAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<object?>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task ReorderFolder_DownAtBottom_IsNoOp()
{
var a = new TemplateFolder("A") { Id = 1, ParentFolderId = null, SortOrder = 0 };
var b = new TemplateFolder("B") { Id = 2, ParentFolderId = null, SortOrder = 1 };
_repoMock.Setup(r => r.GetFolderByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(b);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { a, b });
var result = await _sut.ReorderFolderAsync(2, ReorderDirection.Down, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(0, a.SortOrder);
Assert.Equal(1, b.SortOrder);
_repoMock.Verify(r => r.UpdateFolderAsync(It.IsAny<TemplateFolder>(), It.IsAny<CancellationToken>()), Times.Never);
_repoMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task ReorderFolder_OnlyReordersWithinSameParent()
{
// root: A(0), B(1); under parent 99: X(0), Y(1).
// Moving X down must swap with Y, NOT with any root sibling.
var a = new TemplateFolder("A") { Id = 1, ParentFolderId = null, SortOrder = 0 };
var b = new TemplateFolder("B") { Id = 2, ParentFolderId = null, SortOrder = 1 };
var x = new TemplateFolder("X") { Id = 10, ParentFolderId = 99, SortOrder = 0 };
var y = new TemplateFolder("Y") { Id = 11, ParentFolderId = 99, SortOrder = 1 };
_repoMock.Setup(r => r.GetFolderByIdAsync(10, It.IsAny<CancellationToken>())).ReturnsAsync(x);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { a, b, x, y });
var result = await _sut.ReorderFolderAsync(10, ReorderDirection.Down, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(1, x.SortOrder);
Assert.Equal(0, y.SortOrder);
// Root siblings untouched.
Assert.Equal(0, a.SortOrder);
Assert.Equal(1, b.SortOrder);
}
[Fact]
public async Task ReorderFolder_ThreeSiblings_DownThenUp_YieldsExpectedOrder()
{
// A(0), B(1), C(2). Move A down -> B,A,C ; then move A (now order 1) down -> B,C,A.
var a = new TemplateFolder("A") { Id = 1, ParentFolderId = null, SortOrder = 0 };
var b = new TemplateFolder("B") { Id = 2, ParentFolderId = null, SortOrder = 1 };
var c = new TemplateFolder("C") { Id = 3, ParentFolderId = null, SortOrder = 2 };
var all = new List<TemplateFolder> { a, b, c };
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(a);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>())).ReturnsAsync(all);
var first = await _sut.ReorderFolderAsync(1, ReorderDirection.Down, "admin");
Assert.True(first.IsSuccess);
// Order by SortOrder: B(0), A(1), C(2)
var ordered1 = all.OrderBy(f => f.SortOrder).Select(f => f.Name).ToList();
Assert.Equal(new[] { "B", "A", "C" }, ordered1);
var second = await _sut.ReorderFolderAsync(1, ReorderDirection.Down, "admin");
Assert.True(second.IsSuccess);
// Order by SortOrder: B(0), C(1), A(2)
var ordered2 = all.OrderBy(f => f.SortOrder).Select(f => f.Name).ToList();
Assert.Equal(new[] { "B", "C", "A" }, ordered2);
}
[Fact]
public async Task ReorderFolder_NotFound_ReturnsFailure()
{
_repoMock.Setup(r => r.GetFolderByIdAsync(99, It.IsAny<CancellationToken>()))
.ReturnsAsync((TemplateFolder?)null);
var result = await _sut.ReorderFolderAsync(99, ReorderDirection.Up, "admin");
Assert.True(result.IsFailure);
Assert.Contains("not found", result.Error);
}
[Fact]
public async Task ReorderFolder_Persists_And_WritesAudit()
{
var a = new TemplateFolder("A") { Id = 1, ParentFolderId = null, SortOrder = 0 };
var b = new TemplateFolder("B") { Id = 2, ParentFolderId = null, SortOrder = 1 };
_repoMock.Setup(r => r.GetFolderByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(b);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { a, b });
var result = await _sut.ReorderFolderAsync(2, ReorderDirection.Up, "admin");
Assert.True(result.IsSuccess);
// Both swapped siblings persisted.
_repoMock.Verify(r => r.UpdateFolderAsync(a, It.IsAny<CancellationToken>()), Times.Once);
_repoMock.Verify(r => r.UpdateFolderAsync(b, It.IsAny<CancellationToken>()), Times.Once);
_repoMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
// Audit entry written (mirrors Move/Rename audit assertions).
_auditMock.Verify(au => au.LogAsync("admin", "Reorder", "TemplateFolder", "2", "B",
It.IsAny<object?>(), It.IsAny<CancellationToken>()), Times.Once);
}
} }