From e3bc19c6736923bb715d2f51173d82618237446a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 11:00:57 -0400 Subject: [PATCH] feat(m9/T23a): folder sibling reorder (ReorderFolderAsync + command + handler) --- .../Management/TemplateFolderCommands.cs | 3 + .../Types/Enums/ReorderDirection.cs | 16 ++ .../Repositories/TemplateEngineRepository.cs | 5 +- .../ManagementActor.cs | 10 +- .../Services/TemplateFolderService.cs | 51 ++++++ .../Services/TemplateFolderServiceTests.cs | 162 ++++++++++++++++++ 6 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/ReorderDirection.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TemplateFolderCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TemplateFolderCommands.cs index a60c2f73..9ed9ebbf 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TemplateFolderCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TemplateFolderCommands.cs @@ -1,8 +1,11 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; + namespace ZB.MOM.WW.ScadaBridge.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 ReorderTemplateFolderCommand(int FolderId, ReorderDirection Direction); public record DeleteTemplateFolderCommand(int FolderId); public record MoveTemplateToFolderCommand(int TemplateId, int? NewFolderId); diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/ReorderDirection.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/ReorderDirection.cs new file mode 100644 index 00000000..f4ef9ea5 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/ReorderDirection.cs @@ -0,0 +1,16 @@ +namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; + +/// +/// Direction for a sibling reorder operation. Ordering is ascending by +/// SortOrder, so moves an item toward a lower sort +/// order (earlier in the list) and toward a higher sort +/// order (later in the list). +/// +public enum ReorderDirection +{ + /// Move toward a lower sort order (swap with the previous sibling). + Up, + + /// Move toward a higher sort order (swap with the next sibling). + Down +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs index ac5e8647..a6657368 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs @@ -632,7 +632,10 @@ public class TemplateEngineRepository : ITemplateEngineRepository /// public async Task> GetAllFoldersAsync(CancellationToken cancellationToken = default) - => await _context.TemplateFolders.ToListAsync(cancellationToken); + => await _context.TemplateFolders + .OrderBy(f => f.SortOrder) + .ThenBy(f => f.Name) + .ToListAsync(cancellationToken); /// public async Task AddFolderAsync(TemplateFolder folder, CancellationToken cancellationToken = default) diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index f7c5924c..3f4a60f2 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -198,7 +198,7 @@ public class ManagementActor : ReceiveActor or CreateApiMethodCommand or UpdateApiMethodCommand or DeleteApiMethodCommand or UpdateAreaCommand or CreateTemplateFolderCommand or RenameTemplateFolderCommand - or MoveTemplateFolderCommand or DeleteTemplateFolderCommand + or MoveTemplateFolderCommand or ReorderTemplateFolderCommand or DeleteTemplateFolderCommand or MoveTemplateToFolderCommand or ExportBundleCommand => Roles.Designer, @@ -267,6 +267,7 @@ public class ManagementActor : ReceiveActor CreateTemplateFolderCommand cmd => await HandleCreateTemplateFolder(sp, cmd, user.Username), RenameTemplateFolderCommand cmd => await HandleRenameTemplateFolder(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), 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); } + private static async Task HandleReorderTemplateFolder(IServiceProvider sp, ReorderTemplateFolderCommand cmd, string user) + { + var svc = sp.GetRequiredService(); + var result = await svc.ReorderFolderAsync(cmd.FolderId, cmd.Direction, user); + return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error); + } + private static async Task HandleDeleteTemplateFolder(IServiceProvider sp, DeleteTemplateFolderCommand cmd, string user) { var svc = sp.GetRequiredService(); diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/TemplateFolderService.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/TemplateFolderService.cs index 788d0cca..10a1d443 100644 --- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/TemplateFolderService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/TemplateFolderService.cs @@ -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.Services; using ZB.MOM.WW.ScadaBridge.Commons.Types; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Services; @@ -152,6 +153,56 @@ public class TemplateFolderService return Result.Success(folder); } + /// + /// Reorders a template folder relative to its siblings under the same parent by + /// swapping with the adjacent sibling in the + /// requested direction. Siblings are ordered ascending by + /// then ; swaps with the + /// previous sibling and with the next. Reordering past + /// either end is a no-op (returns success without persisting or auditing). + /// + /// Id of the folder to reorder. + /// Direction to move the folder within its sibling set. + /// Username of the actor performing the operation (for audit). + /// Cancellation token. + /// A task that resolves to the reordered , or a failure result if the folder is not found. + public async Task> ReorderFolderAsync( + int folderId, ReorderDirection direction, string user, + CancellationToken cancellationToken = default) + { + var folder = await _repository.GetFolderByIdAsync(folderId, cancellationToken); + if (folder == null) + return Result.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.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.Success(folder); + } + /// /// Deletes an empty template folder. Fails if the folder contains sub-folders or templates. /// diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs index e5011df8..af2a5e51 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Services/TemplateFolderServiceTests.cs @@ -2,6 +2,7 @@ using Moq; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services; namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Services; @@ -236,4 +237,165 @@ public class TemplateFolderServiceTests Assert.True(result.IsFailure); 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())).ReturnsAsync(b); + _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())) + .ReturnsAsync(new List { 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())).ReturnsAsync(b); + _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())) + .ReturnsAsync(new List { 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())).ReturnsAsync(a); + _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())) + .ReturnsAsync(new List { 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(), It.IsAny()), Times.Never); + _repoMock.Verify(r => r.SaveChangesAsync(It.IsAny()), Times.Never); + _auditMock.Verify(a => a.LogAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), 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())).ReturnsAsync(b); + _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())) + .ReturnsAsync(new List { 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(), It.IsAny()), Times.Never); + _repoMock.Verify(r => r.SaveChangesAsync(It.IsAny()), 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())).ReturnsAsync(x); + _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())) + .ReturnsAsync(new List { 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 { a, b, c }; + _repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny())).ReturnsAsync(a); + _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())).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())) + .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())).ReturnsAsync(b); + _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny())) + .ReturnsAsync(new List { 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()), Times.Once); + _repoMock.Verify(r => r.UpdateFolderAsync(b, It.IsAny()), Times.Once); + _repoMock.Verify(r => r.SaveChangesAsync(It.IsAny()), Times.Once); + // Audit entry written (mirrors Move/Rename audit assertions). + _auditMock.Verify(au => au.LogAsync("admin", "Reorder", "TemplateFolder", "2", "B", + It.IsAny(), It.IsAny()), Times.Once); + } }