feat(m9/T23a): folder sibling reorder (ReorderFolderAsync + command + handler)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+4
-1
@@ -632,7 +632,10 @@ public class TemplateEngineRepository : ITemplateEngineRepository
|
||||
|
||||
/// <inheritdoc />
|
||||
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 />
|
||||
public async Task AddFolderAsync(TemplateFolder folder, CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -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<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)
|
||||
{
|
||||
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.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<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>
|
||||
/// Deletes an empty template folder. Fails if the folder contains sub-folders or templates.
|
||||
/// </summary>
|
||||
|
||||
+162
@@ -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<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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user