();
+ return _folders
+ .Where(f => f.ParentFolderId == folder.ParentFolderId)
+ .OrderBy(f => f.SortOrder)
+ .ThenBy(f => f.Name, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ // ---- Root-level context menu (T23b) ----
+ // Right-click on the tree zone (empty space or below the tree) offers
+ // New Folder / New Template at root. Node right-clicks are handled by the
+ // TreeView's own context menu and never reach here.
+ private bool _showRootMenu;
+ private double _rootMenuX;
+ private double _rootMenuY;
+
+ private void OpenRootContextMenu(MouseEventArgs e)
+ {
+ _rootMenuX = e.ClientX;
+ _rootMenuY = e.ClientY;
+ _showRootMenu = true;
+ }
+
+ private void DismissRootContextMenu() => _showRootMenu = false;
+
// Rename folder dialog state
private bool _showRenameFolderDialog;
private int _renameFolderId;
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor
index 40954c1a..0d1be6fd 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor
@@ -48,7 +48,7 @@ else
aria-expanded="@(isBranch ? (isExpanded ? "true" : "false") : null)"
aria-selected="@(isSelected ? "true" : null)">
OnContextMenu(e, item)" @oncontextmenu:preventDefault="@(ContextMenu != null)">
+ @oncontextmenu="(e) => OnContextMenu(e, item)" @oncontextmenu:preventDefault="@(ContextMenu != null)" @oncontextmenu:stopPropagation="@(ContextMenu != null)">
@if (isBranch)
{
ToggleExpand(key)" @onclick:stopPropagation>
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TemplatesPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TemplatesPageTests.cs
index d8e067ce..ea535ca8 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TemplatesPageTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TemplatesPageTests.cs
@@ -1,12 +1,14 @@
using System.Security.Claims;
using ZB.MOM.WW.ScadaBridge.Security;
using Bunit;
+using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
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.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
@@ -176,6 +178,149 @@ public class TemplatesPageTests : BunitContext
Assert.Contains("BetaDevice", cut.Markup);
}
+ // ========================================================================
+ // M9-T23b: folder sibling reorder menu items + root context menu
+ // ========================================================================
+
+ // Seeds two root sibling folders (Alpha @ SortOrder 0, Beta @ SortOrder 1) and
+ // wires the repo so TemplateFolderService.ReorderFolderAsync runs end-to-end.
+ private (TemplateFolder alpha, TemplateFolder beta) SeedTwoRootSiblings()
+ {
+ var alpha = new TemplateFolder("Alpha") { Id = 1, ParentFolderId = null, SortOrder = 0 };
+ var beta = new TemplateFolder("Beta") { Id = 2, ParentFolderId = null, SortOrder = 1 };
+ var all = new List { alpha, beta };
+
+ _repo.GetAllTemplatesAsync(Arg.Any())
+ .Returns(Task.FromResult>(new List()));
+ _repo.GetAllFoldersAsync(Arg.Any())
+ .Returns(_ => Task.FromResult>(all));
+ _repo.GetFolderByIdAsync(1, Arg.Any()).Returns(Task.FromResult(alpha));
+ _repo.GetFolderByIdAsync(2, Arg.Any()).Returns(Task.FromResult(beta));
+
+ return (alpha, beta);
+ }
+
+ // Right-clicks the folder row whose label contains the given text, opening the
+ // TreeView context menu, then returns the rendered context-menu container.
+ private static AngleSharp.Dom.IElement OpenFolderContextMenu(IRenderedComponent cut, string folderLabel)
+ {
+ var row = cut.FindAll("li[role='treeitem']")
+ .First(li => li.TextContent.Contains(folderLabel))
+ .QuerySelector(".tv-row")!;
+ row.ContextMenu();
+ return cut.Find(".dropdown-menu.show");
+ }
+
+ [Fact]
+ public void FolderContextMenu_ExposesMoveUpAndMoveDown()
+ {
+ SeedTwoRootSiblings();
+
+ var cut = Render();
+
+ var menu = OpenFolderContextMenu(cut, "Beta");
+ var labels = menu.QuerySelectorAll("button.dropdown-item").Select(b => b.TextContent.Trim()).ToList();
+
+ Assert.Contains("Move up", labels);
+ Assert.Contains("Move down", labels);
+ }
+
+ [Fact]
+ public void FolderContextMenu_MoveDown_DispatchesReorderDown_AndReloads()
+ {
+ var (alpha, beta) = SeedTwoRootSiblings();
+
+ var cut = Render();
+
+ // Move Alpha (first sibling) down -> swaps SortOrder with Beta (the next sibling).
+ var menu = OpenFolderContextMenu(cut, "Alpha");
+ menu.QuerySelectorAll("button.dropdown-item")
+ .First(b => b.TextContent.Trim() == "Move down")
+ .Click();
+
+ // Reorder was dispatched the same way other folder commands are — via
+ // TemplateFolderService, which resolves + persists both swapped siblings.
+ _repo.Received().GetFolderByIdAsync(1, Arg.Any());
+ _repo.Received().UpdateFolderAsync(alpha, Arg.Any());
+ _repo.Received().UpdateFolderAsync(beta, Arg.Any());
+ _repo.Received().SaveChangesAsync(Arg.Any());
+
+ // Down on Alpha swapped sort orders: Alpha now after Beta.
+ Assert.Equal(1, alpha.SortOrder);
+ Assert.Equal(0, beta.SortOrder);
+
+ // Tree reloaded after the mutation: LoadTemplatesAsync re-fetched (initial
+ // load + post-reorder reload). GetAllTemplatesAsync is only touched by the
+ // page's load path, never by ReorderFolderAsync, so 2 calls == one reload.
+ _repo.Received(2).GetAllTemplatesAsync(Arg.Any());
+ }
+
+ [Fact]
+ public void FolderContextMenu_MoveUp_DispatchesReorderUp_AndReloads()
+ {
+ var (alpha, beta) = SeedTwoRootSiblings();
+
+ var cut = Render();
+
+ // Move Beta (second sibling) up -> swaps SortOrder with Alpha (the previous sibling).
+ var menu = OpenFolderContextMenu(cut, "Beta");
+ menu.QuerySelectorAll("button.dropdown-item")
+ .First(b => b.TextContent.Trim() == "Move up")
+ .Click();
+
+ _repo.Received().GetFolderByIdAsync(2, Arg.Any());
+ _repo.Received().UpdateFolderAsync(alpha, Arg.Any());
+ _repo.Received().UpdateFolderAsync(beta, Arg.Any());
+
+ // Up on Beta swapped sort orders: Beta now before Alpha.
+ Assert.Equal(0, beta.SortOrder);
+ Assert.Equal(1, alpha.SortOrder);
+
+ // Tree reloaded after the mutation (one reload == GetAllTemplatesAsync twice).
+ _repo.Received(2).GetAllTemplatesAsync(Arg.Any());
+ }
+
+ [Fact]
+ public void RootContextMenu_OffersNewFolderAndNewTemplate()
+ {
+ _repo.GetAllTemplatesAsync(Arg.Any())
+ .Returns(Task.FromResult>(new List()));
+ _repo.GetAllFoldersAsync(Arg.Any())
+ .Returns(Task.FromResult>(new List()));
+
+ var cut = Render();
+
+ // Right-click the root tree zone to open the root context menu.
+ cut.Find(".tv-root-zone").ContextMenu();
+ var menu = cut.Find(".tv-root-menu");
+ var labels = menu.QuerySelectorAll("button.dropdown-item").Select(b => b.TextContent.Trim()).ToList();
+
+ Assert.Contains("New Folder", labels);
+ Assert.Contains("New Template", labels);
+ }
+
+ [Fact]
+ public void RootContextMenu_NewTemplate_NavigatesToRootCreate()
+ {
+ _repo.GetAllTemplatesAsync(Arg.Any())
+ .Returns(Task.FromResult>(new List()));
+ _repo.GetAllFoldersAsync(Arg.Any())
+ .Returns(Task.FromResult>(new List()));
+
+ var nav = Services.GetRequiredService();
+
+ var cut = Render();
+
+ cut.Find(".tv-root-zone").ContextMenu();
+ cut.Find(".tv-root-menu")
+ .QuerySelectorAll("button.dropdown-item")
+ .First(b => b.TextContent.Trim() == "New Template")
+ .Click();
+
+ // Root-level New Template navigates with no folderId query — a root create.
+ Assert.EndsWith("/design/templates/create", nav.Uri);
+ }
+
}
internal sealed class TestAuthStateProvider : AuthenticationStateProvider