feat(m9/T23b): folder reorder menu items + root context menu
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
@using ZB.MOM.WW.ScadaBridge.Security
|
@using ZB.MOM.WW.ScadaBridge.Security
|
||||||
@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.Types.Enums
|
||||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
|
||||||
@using ZB.MOM.WW.ScadaBridge.TemplateEngine
|
@using ZB.MOM.WW.ScadaBridge.TemplateEngine
|
||||||
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services
|
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services
|
||||||
@@ -94,7 +95,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="max-height: calc(100vh - 200px); overflow-y: auto; padding: 4px;">
|
@* The root zone fills the scroll area so a right-click on empty space
|
||||||
|
(or below the tree) opens a root-level context menu — New Folder /
|
||||||
|
New Template at root. Node-level right-clicks are handled by the
|
||||||
|
TreeView's own context menu (preventDefault'd there), so they don't
|
||||||
|
bubble to this handler. *@
|
||||||
|
<div class="tv-root-zone" style="max-height: calc(100vh - 200px); min-height: 120px; overflow-y: auto; padding: 4px;"
|
||||||
|
@oncontextmenu="OpenRootContextMenu" @oncontextmenu:preventDefault="true">
|
||||||
<TemplateFolderTree @ref="_tree"
|
<TemplateFolderTree @ref="_tree"
|
||||||
Folders="_folders"
|
Folders="_folders"
|
||||||
Templates="_templates"
|
Templates="_templates"
|
||||||
@@ -109,10 +116,21 @@
|
|||||||
@RenderNodeContextMenu(node)
|
@RenderNodeContextMenu(node)
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
<EmptyContent>
|
<EmptyContent>
|
||||||
<span class="text-muted fst-italic">No templates yet. Use the buttons above to create a folder or template.</span>
|
<span class="text-muted fst-italic">No templates yet. Use the buttons above (or right-click here) to create a folder or template.</span>
|
||||||
</EmptyContent>
|
</EmptyContent>
|
||||||
</TemplateFolderTree>
|
</TemplateFolderTree>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (_showRootMenu)
|
||||||
|
{
|
||||||
|
<div class="tv-ctx-overlay" @onclick="DismissRootContextMenu"
|
||||||
|
style="position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:1049;background:transparent;"></div>
|
||||||
|
<div class="dropdown-menu show tv-root-menu" @onclick="DismissRootContextMenu"
|
||||||
|
style="position:fixed;top:@(_rootMenuY)px;left:@(_rootMenuX)px;z-index:1050;">
|
||||||
|
<button class="dropdown-item" @onclick="() => OpenNewFolderDialog(null)">New Folder</button>
|
||||||
|
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo("/design/templates/create")'>New Template</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -240,6 +258,11 @@
|
|||||||
<button class="dropdown-item" @onclick="() => OpenRenameFolderDialog(node.Id, node.Name)">Rename</button>
|
<button class="dropdown-item" @onclick="() => OpenRenameFolderDialog(node.Id, node.Name)">Rename</button>
|
||||||
<button class="dropdown-item" @onclick="() => OpenMoveFolderDialog(node.Id, node.Name)">Move to Folder…</button>
|
<button class="dropdown-item" @onclick="() => OpenMoveFolderDialog(node.Id, node.Name)">Move to Folder…</button>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
|
<button class="dropdown-item" disabled="@IsFirstSibling(node.Id)"
|
||||||
|
@onclick="() => ReorderFolder(node.Id, ReorderDirection.Up)">Move up</button>
|
||||||
|
<button class="dropdown-item" disabled="@IsLastSibling(node.Id)"
|
||||||
|
@onclick="() => ReorderFolder(node.Id, ReorderDirection.Down)">Move down</button>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
<button class="dropdown-item text-danger" @onclick="() => DeleteFolder(node.Id, node.Name)">Delete</button>
|
<button class="dropdown-item text-danger" @onclick="() => DeleteFolder(node.Id, node.Name)">Delete</button>
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -381,6 +404,69 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Sibling reorder (T23b) ----
|
||||||
|
// Dispatches the reorder the same way Move/Rename do — directly through
|
||||||
|
// TemplateFolderService (the backend swaps SortOrder with the adjacent
|
||||||
|
// same-parent sibling, no-op at the ends) — then reloads so the new order
|
||||||
|
// shows. Folder listing is ordered by SortOrder, so the swap is visible
|
||||||
|
// after reload.
|
||||||
|
private async Task ReorderFolder(int folderId, ReorderDirection direction)
|
||||||
|
{
|
||||||
|
var user = await GetCurrentUserAsync();
|
||||||
|
var result = await TemplateFolderService.ReorderFolderAsync(folderId, direction, user);
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
await LoadTemplatesAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_toast.ShowError(result.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sibling-order helpers for disabling Move up/down at the ends. Mirrors the
|
||||||
|
// backend ordering (ascending SortOrder, then Name). Returns false when the
|
||||||
|
// folder is unknown so the menu stays enabled and the backend no-op guards.
|
||||||
|
private bool IsFirstSibling(int folderId)
|
||||||
|
{
|
||||||
|
var ordered = OrderedSiblings(folderId);
|
||||||
|
return ordered.Count > 0 && ordered[0].Id == folderId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsLastSibling(int folderId)
|
||||||
|
{
|
||||||
|
var ordered = OrderedSiblings(folderId);
|
||||||
|
return ordered.Count > 0 && ordered[^1].Id == folderId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TemplateFolder> OrderedSiblings(int folderId)
|
||||||
|
{
|
||||||
|
var folder = _folders.FirstOrDefault(f => f.Id == folderId);
|
||||||
|
if (folder == null) return new List<TemplateFolder>();
|
||||||
|
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
|
// Rename folder dialog state
|
||||||
private bool _showRenameFolderDialog;
|
private bool _showRenameFolderDialog;
|
||||||
private int _renameFolderId;
|
private int _renameFolderId;
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ else
|
|||||||
aria-expanded="@(isBranch ? (isExpanded ? "true" : "false") : null)"
|
aria-expanded="@(isBranch ? (isExpanded ? "true" : "false") : null)"
|
||||||
aria-selected="@(isSelected ? "true" : null)">
|
aria-selected="@(isSelected ? "true" : null)">
|
||||||
<div class="@rowClasses" style="padding-left: @(depth * IndentPx)px; --tv-depth: @depth;"
|
<div class="@rowClasses" style="padding-left: @(depth * IndentPx)px; --tv-depth: @depth;"
|
||||||
@oncontextmenu="(e) => OnContextMenu(e, item)" @oncontextmenu:preventDefault="@(ContextMenu != null)">
|
@oncontextmenu="(e) => OnContextMenu(e, item)" @oncontextmenu:preventDefault="@(ContextMenu != null)" @oncontextmenu:stopPropagation="@(ContextMenu != null)">
|
||||||
@if (isBranch)
|
@if (isBranch)
|
||||||
{
|
{
|
||||||
<span class="tv-toggle" @onclick="() => ToggleExpand(key)" @onclick:stopPropagation><i class="bi bi-chevron-right"></i></span>
|
<span class="tv-toggle" @onclick="() => ToggleExpand(key)" @onclick:stopPropagation><i class="bi bi-chevron-right"></i></span>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using ZB.MOM.WW.ScadaBridge.Security;
|
using ZB.MOM.WW.ScadaBridge.Security;
|
||||||
using Bunit;
|
using Bunit;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
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.CentralUI.Components.Shared;
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
||||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
|
||||||
@@ -176,6 +178,149 @@ public class TemplatesPageTests : BunitContext
|
|||||||
Assert.Contains("BetaDevice", cut.Markup);
|
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<TemplateFolder> { alpha, beta };
|
||||||
|
|
||||||
|
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template>()));
|
||||||
|
_repo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(_ => Task.FromResult<IReadOnlyList<TemplateFolder>>(all));
|
||||||
|
_repo.GetFolderByIdAsync(1, Arg.Any<CancellationToken>()).Returns(Task.FromResult<TemplateFolder?>(alpha));
|
||||||
|
_repo.GetFolderByIdAsync(2, Arg.Any<CancellationToken>()).Returns(Task.FromResult<TemplateFolder?>(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<TemplatesPage> 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<TemplatesPage>();
|
||||||
|
|
||||||
|
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<TemplatesPage>();
|
||||||
|
|
||||||
|
// 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<CancellationToken>());
|
||||||
|
_repo.Received().UpdateFolderAsync(alpha, Arg.Any<CancellationToken>());
|
||||||
|
_repo.Received().UpdateFolderAsync(beta, Arg.Any<CancellationToken>());
|
||||||
|
_repo.Received().SaveChangesAsync(Arg.Any<CancellationToken>());
|
||||||
|
|
||||||
|
// 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<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FolderContextMenu_MoveUp_DispatchesReorderUp_AndReloads()
|
||||||
|
{
|
||||||
|
var (alpha, beta) = SeedTwoRootSiblings();
|
||||||
|
|
||||||
|
var cut = Render<TemplatesPage>();
|
||||||
|
|
||||||
|
// 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<CancellationToken>());
|
||||||
|
_repo.Received().UpdateFolderAsync(alpha, Arg.Any<CancellationToken>());
|
||||||
|
_repo.Received().UpdateFolderAsync(beta, Arg.Any<CancellationToken>());
|
||||||
|
|
||||||
|
// 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<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RootContextMenu_OffersNewFolderAndNewTemplate()
|
||||||
|
{
|
||||||
|
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template>()));
|
||||||
|
_repo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(new List<TemplateFolder>()));
|
||||||
|
|
||||||
|
var cut = Render<TemplatesPage>();
|
||||||
|
|
||||||
|
// 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<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template>()));
|
||||||
|
_repo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(new List<TemplateFolder>()));
|
||||||
|
|
||||||
|
var nav = Services.GetRequiredService<NavigationManager>();
|
||||||
|
|
||||||
|
var cut = Render<TemplatesPage>();
|
||||||
|
|
||||||
|
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
|
internal sealed class TestAuthStateProvider : AuthenticationStateProvider
|
||||||
|
|||||||
Reference in New Issue
Block a user