2 Commits

Author SHA1 Message Date
Joseph Doherty 3587ab4fcb refactor(ui/templates): extract dialog modals into shared components 2026-05-11 12:03:35 -04:00
Joseph Doherty 17e690f6ef test(ui/templates): cover drag-template-to-root via bUnit DragEventArgs 2026-05-11 12:00:07 -04:00
6 changed files with 293 additions and 115 deletions
@@ -19,105 +19,28 @@
<ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" />
@if (_showRenameFolderDialog)
{
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">Rename Folder</h6>
<button type="button" class="btn-close" @onclick="() => _showRenameFolderDialog = false"></button>
</div>
<div class="modal-body">
<input class="form-control form-control-sm" @bind="_renameFolderName" />
@if (_renameFolderError != null) { <div class="text-danger small mt-1">@_renameFolderError</div> }
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showRenameFolderDialog = false">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="SubmitRenameFolder">Save</button>
</div>
</div>
</div>
</div>
}
<RenameFolderDialog @bind-IsVisible="_showRenameFolderDialog"
FolderId="_renameFolderId"
InitialName="_renameFolderInitialName"
ErrorMessage="_renameFolderError"
OnSubmit="SubmitRenameFolder" />
@if (_showNewFolderDialog)
{
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">New Folder</h6>
<button type="button" class="btn-close" @onclick="() => _showNewFolderDialog = false"></button>
</div>
<div class="modal-body">
<input class="form-control form-control-sm" placeholder="Folder name" @bind="_newFolderName" />
@if (_newFolderError != null) { <div class="text-danger small mt-1">@_newFolderError</div> }
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showNewFolderDialog = false">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="SubmitNewFolder">Create</button>
</div>
</div>
</div>
</div>
}
<NewFolderDialog @bind-IsVisible="_showNewFolderDialog"
ParentFolderId="_newFolderParentId"
ErrorMessage="_newFolderError"
OnSubmit="SubmitNewFolder" />
@if (_showNewTemplateDialog)
{
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">New Template</h6>
<button type="button" class="btn-close" @onclick="() => _showNewTemplateDialog = false"></button>
</div>
<div class="modal-body">
<div class="mb-2">
<label class="form-label small">Name</label>
<input class="form-control form-control-sm" @bind="_newTemplateName" />
</div>
<div class="mb-2">
<label class="form-label small">Description</label>
<input class="form-control form-control-sm" @bind="_newTemplateDescription" />
</div>
@if (_newTemplateError != null) { <div class="text-danger small mt-1">@_newTemplateError</div> }
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showNewTemplateDialog = false">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="SubmitNewTemplate">Create</button>
</div>
</div>
</div>
</div>
}
<NewTemplateDialog @bind-IsVisible="_showNewTemplateDialog"
FolderId="_newTemplateFolderId"
ErrorMessage="_newTemplateError"
OnSubmit="SubmitNewTemplate" />
@if (_showMoveTemplateDialog)
{
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">Move '@_moveTemplateName' to…</h6>
<button type="button" class="btn-close" @onclick="() => _showMoveTemplateDialog = false"></button>
</div>
<div class="modal-body">
<select class="form-select form-select-sm" @bind="_moveTemplateTargetFolderId">
@foreach (var opt in EnumerateFolderOptions())
{
<option value="@opt.Id">@opt.Label</option>
}
</select>
@if (_moveTemplateError != null) { <div class="text-danger small mt-1">@_moveTemplateError</div> }
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showMoveTemplateDialog = false">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="SubmitMoveTemplate">Move</button>
</div>
</div>
</div>
</div>
}
<MoveTemplateDialog @bind-IsVisible="_showMoveTemplateDialog"
TemplateId="_moveTemplateId"
TemplateName="_moveTemplateName"
FolderOptions="EnumerateFolderOptions()"
ErrorMessage="_moveTemplateError"
OnSubmit="SubmitMoveTemplate" />
@if (_loading)
{
@@ -144,7 +67,8 @@
</div>
<div style="max-height: calc(100vh - 160px); overflow-y: auto;">
<div @ondragover:preventDefault="true" @ondrop="OnDropOnRoot"
<div class="templates-root-dropzone"
@ondragover:preventDefault="true" @ondrop="OnDropOnRoot"
style="min-height: 100%; padding: 4px;">
<TreeView @ref="_tree" TItem="TmplNode" Items="_treeRoots"
ChildrenSelector="n => n.Children"
@@ -696,22 +620,20 @@
// New-folder dialog state
private bool _showNewFolderDialog;
private int? _newFolderParentId;
private string _newFolderName = string.Empty;
private string? _newFolderError;
private void OpenNewFolderDialog(int? parentFolderId)
{
_newFolderParentId = parentFolderId;
_newFolderName = string.Empty;
_newFolderError = null;
_showNewFolderDialog = true;
}
private async Task SubmitNewFolder()
private async Task SubmitNewFolder((int? ParentFolderId, string Name) req)
{
_newFolderError = null;
var user = await GetCurrentUserAsync();
var result = await TemplateFolderService.CreateFolderAsync(_newFolderName.Trim(), _newFolderParentId, user);
var result = await TemplateFolderService.CreateFolderAsync(req.Name, req.ParentFolderId, user);
if (result.IsSuccess)
{
_showNewFolderDialog = false;
@@ -727,25 +649,21 @@
// New-template dialog state
private bool _showNewTemplateDialog;
private int? _newTemplateFolderId;
private string _newTemplateName = string.Empty;
private string? _newTemplateDescription;
private string? _newTemplateError;
private void OpenNewTemplateDialog(int? folderId)
{
_newTemplateFolderId = folderId;
_newTemplateName = string.Empty;
_newTemplateDescription = null;
_newTemplateError = null;
_showNewTemplateDialog = true;
}
private async Task SubmitNewTemplate()
private async Task SubmitNewTemplate((int? FolderId, string Name, string? Description) req)
{
_newTemplateError = null;
var user = await GetCurrentUserAsync();
var result = await TemplateService.CreateTemplateAsync(
_newTemplateName.Trim(), _newTemplateDescription?.Trim(), null, user, folderId: _newTemplateFolderId);
req.Name, req.Description, null, user, folderId: req.FolderId);
if (result.IsSuccess)
{
_showNewTemplateDialog = false;
@@ -763,23 +681,21 @@
private bool _showMoveTemplateDialog;
private int _moveTemplateId;
private string _moveTemplateName = string.Empty;
private int? _moveTemplateTargetFolderId;
private string? _moveTemplateError;
private void OpenMoveTemplateDialog(int templateId, string label)
{
_moveTemplateId = templateId;
_moveTemplateName = label;
_moveTemplateTargetFolderId = null;
_moveTemplateError = null;
_showMoveTemplateDialog = true;
}
private async Task SubmitMoveTemplate()
private async Task SubmitMoveTemplate((int TemplateId, int? NewFolderId) req)
{
_moveTemplateError = null;
var user = await GetCurrentUserAsync();
var result = await TemplateService.MoveTemplateAsync(_moveTemplateId, _moveTemplateTargetFolderId, user);
var result = await TemplateService.MoveTemplateAsync(req.TemplateId, req.NewFolderId, user);
if (result.IsSuccess)
{
_showMoveTemplateDialog = false;
@@ -813,22 +729,22 @@
// Rename folder dialog state
private bool _showRenameFolderDialog;
private int _renameFolderId;
private string _renameFolderName = string.Empty;
private string _renameFolderInitialName = string.Empty;
private string? _renameFolderError;
private void OpenRenameFolderDialog(int folderId, string currentName)
{
_renameFolderId = folderId;
_renameFolderName = currentName;
_renameFolderInitialName = currentName;
_renameFolderError = null;
_showRenameFolderDialog = true;
}
private async Task SubmitRenameFolder()
private async Task SubmitRenameFolder((int FolderId, string NewName) req)
{
_renameFolderError = null;
var user = await GetCurrentUserAsync();
var result = await TemplateFolderService.RenameFolderAsync(_renameFolderId, _renameFolderName.Trim(), user);
var result = await TemplateFolderService.RenameFolderAsync(req.FolderId, req.NewName, user);
if (result.IsSuccess)
{
_showRenameFolderDialog = false;
@@ -0,0 +1,59 @@
@if (IsVisible)
{
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">Move '@TemplateName' to…</h6>
<button type="button" class="btn-close" @onclick="Close"></button>
</div>
<div class="modal-body">
<select class="form-select form-select-sm" @bind="_targetFolderId">
@foreach (var opt in FolderOptions)
{
<option value="@opt.Id">@opt.Label</option>
}
</select>
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="Submit">Move</button>
</div>
</div>
</div>
</div>
}
@code {
[Parameter] public bool IsVisible { get; set; }
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
[Parameter] public int TemplateId { get; set; }
[Parameter] public string TemplateName { get; set; } = string.Empty;
[Parameter] public IEnumerable<(int? Id, string Label)> FolderOptions { get; set; } = Array.Empty<(int?, string)>();
[Parameter] public string? ErrorMessage { get; set; }
[Parameter] public EventCallback<(int TemplateId, int? NewFolderId)> OnSubmit { get; set; }
private bool _wasVisible;
private int? _targetFolderId;
protected override void OnParametersSet()
{
// Reset internal state on transition from hidden -> visible.
if (IsVisible && !_wasVisible)
{
_targetFolderId = null;
}
_wasVisible = IsVisible;
}
private async Task Close()
{
await IsVisibleChanged.InvokeAsync(false);
}
private async Task Submit()
{
await OnSubmit.InvokeAsync((TemplateId, _targetFolderId));
}
}
@@ -0,0 +1,52 @@
@if (IsVisible)
{
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">New Folder</h6>
<button type="button" class="btn-close" @onclick="Close"></button>
</div>
<div class="modal-body">
<input class="form-control form-control-sm" placeholder="Folder name" @bind="_name" />
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="Submit">Create</button>
</div>
</div>
</div>
</div>
}
@code {
[Parameter] public bool IsVisible { get; set; }
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
[Parameter] public int? ParentFolderId { get; set; }
[Parameter] public string? ErrorMessage { get; set; }
[Parameter] public EventCallback<(int? ParentFolderId, string Name)> OnSubmit { get; set; }
private bool _wasVisible;
private string _name = string.Empty;
protected override void OnParametersSet()
{
// Reset internal state on transition from hidden -> visible.
if (IsVisible && !_wasVisible)
{
_name = string.Empty;
}
_wasVisible = IsVisible;
}
private async Task Close()
{
await IsVisibleChanged.InvokeAsync(false);
}
private async Task Submit()
{
await OnSubmit.InvokeAsync((ParentFolderId, _name.Trim()));
}
}
@@ -0,0 +1,61 @@
@if (IsVisible)
{
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">New Template</h6>
<button type="button" class="btn-close" @onclick="Close"></button>
</div>
<div class="modal-body">
<div class="mb-2">
<label class="form-label small">Name</label>
<input class="form-control form-control-sm" @bind="_name" />
</div>
<div class="mb-2">
<label class="form-label small">Description</label>
<input class="form-control form-control-sm" @bind="_description" />
</div>
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="Submit">Create</button>
</div>
</div>
</div>
</div>
}
@code {
[Parameter] public bool IsVisible { get; set; }
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
[Parameter] public int? FolderId { get; set; }
[Parameter] public string? ErrorMessage { get; set; }
[Parameter] public EventCallback<(int? FolderId, string Name, string? Description)> OnSubmit { get; set; }
private bool _wasVisible;
private string _name = string.Empty;
private string? _description;
protected override void OnParametersSet()
{
// Reset internal state on transition from hidden -> visible.
if (IsVisible && !_wasVisible)
{
_name = string.Empty;
_description = null;
}
_wasVisible = IsVisible;
}
private async Task Close()
{
await IsVisibleChanged.InvokeAsync(false);
}
private async Task Submit()
{
await OnSubmit.InvokeAsync((FolderId, _name.Trim(), _description?.Trim()));
}
}
@@ -0,0 +1,53 @@
@if (IsVisible)
{
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">Rename Folder</h6>
<button type="button" class="btn-close" @onclick="Close"></button>
</div>
<div class="modal-body">
<input class="form-control form-control-sm" @bind="_name" />
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="Submit">Save</button>
</div>
</div>
</div>
</div>
}
@code {
[Parameter] public bool IsVisible { get; set; }
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
[Parameter] public int FolderId { get; set; }
[Parameter] public string InitialName { get; set; } = string.Empty;
[Parameter] public string? ErrorMessage { get; set; }
[Parameter] public EventCallback<(int FolderId, string NewName)> OnSubmit { get; set; }
private bool _wasVisible;
private string _name = string.Empty;
protected override void OnParametersSet()
{
// Reset internal state on transition from hidden -> visible.
if (IsVisible && !_wasVisible)
{
_name = InitialName;
}
_wasVisible = IsVisible;
}
private async Task Close()
{
await IsVisibleChanged.InvokeAsync(false);
}
private async Task Submit()
{
await OnSubmit.InvokeAsync((FolderId, _name.Trim()));
}
}
@@ -1,6 +1,7 @@
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ScadaLink.Commons.Entities.Templates;
@@ -118,6 +119,42 @@ public class TemplatesPageTests : BunitContext
Assert.Contains("DelmiaReceiver", cut.Markup);
Assert.Contains("→", cut.Markup);
}
[Fact]
public async Task DragTemplateOntoRoot_CallsMoveTemplateAsync_WithNullFolderId()
{
// Arrange: one template currently parented to a folder; the test simulates
// the user dragging that template onto the root drop-zone, which should
// result in TemplateService.MoveTemplateAsync(..., newFolderId: null) being
// invoked. We keep the template at the root in the rendered tree (FolderId
// null) so it renders without needing an expand-click; the drag payload only
// cares about the in-memory id captured by OnDragStart, not the visual
// parent.
var template = new Template("RootDragTarget") { Id = 5, FolderId = null };
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template> { template }));
_repo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(new List<TemplateFolder>()));
_repo.GetTemplateByIdAsync(5, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<Template?>(template));
var cut = Render<TemplatesPage>();
// Act: fire ondragstart on the template's draggable <div> (RenderNodeLabel
// emits draggable="true" for template/folder nodes), then ondrop on the
// root wrapper marked with the test-affordance class added to the page.
var templateNode = cut.Find("div[draggable='true']");
await templateNode.TriggerEventAsync("ondragstart", new DragEventArgs());
var rootDropZone = cut.Find("div.templates-root-dropzone");
await rootDropZone.TriggerEventAsync("ondrop", new DragEventArgs());
// Assert: TemplateService.MoveTemplateAsync delegates to the repository's
// UpdateTemplateAsync with FolderId set to null.
await _repo.Received(1).UpdateTemplateAsync(
Arg.Is<Template>(t => t.Id == 5 && t.FolderId == null),
Arg.Any<CancellationToken>());
}
}
internal sealed class TestAuthStateProvider : AuthenticationStateProvider