From 3f1ad08f422f32e3827792dfd908dba7929249de Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 22 May 2026 01:46:12 -0400 Subject: [PATCH] feat(centralui): open ExecutionDetailModal on tree-node double-click --- .../Pages/Audit/ExecutionTreePage.razor | 9 ++- .../Pages/Audit/ExecutionTreePage.razor.cs | 27 ++++++++ .../Pages/ExecutionTreePageTests.cs | 64 +++++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor b/src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor index 1883faa..8bedc93 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor @@ -52,7 +52,14 @@ View this execution in the Audit Log - + + + @* Double-clicking a tree node raises OnNodeActivated, which opens this + modal for that execution. The modal renders nothing while IsOpen is + false, so it is safe to place unconditionally here. *@ + } else { diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor.cs b/src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor.cs index 84ac269..b760939 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor.cs +++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor.cs @@ -40,6 +40,15 @@ public partial class ExecutionTreePage private bool _loading; private string? _error; + // Execution-Tree Node Detail Modal feature (Task 4) — state backing the + // . A double-click on a tree node sets + // _modalExecutionId + flips _modalOpen true; the modal loads that + // execution's audit rows on the closed → open transition. _modalOpen is the + // visibility gate — _modalExecutionId is left intact across a close (it is + // harmless while the modal is hidden and avoids a flicker if reopened). + private Guid? _modalExecutionId; + private bool _modalOpen; + protected override async Task OnInitializedAsync() { _executionId = ParseExecutionId(); @@ -90,4 +99,22 @@ public partial class ExecutionTreePage _loading = false; } } + + /// + /// Raised by ExecutionTree (bubbled up from a node double-click) with + /// the activated node's ExecutionId. Opens the + /// ExecutionDetailModal for that execution — the modal loads its + /// audit rows on the closed → open transition. + /// + private void HandleNodeActivated(Guid executionId) + { + _modalExecutionId = executionId; + _modalOpen = true; + } + + /// + /// Raised by ExecutionDetailModal when the user dismisses it. Flips + /// the visibility gate closed; is left as-is. + /// + private void HandleModalClose() => _modalOpen = false; } diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/ExecutionTreePageTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/ExecutionTreePageTests.cs index c69d10b..16b29c3 100644 --- a/tests/ScadaLink.CentralUI.Tests/Pages/ExecutionTreePageTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Pages/ExecutionTreePageTests.cs @@ -7,7 +7,9 @@ using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using ScadaLink.CentralUI.Services; +using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; using ScadaLink.Security; using ExecutionTreePage = ScadaLink.CentralUI.Components.Pages.Audit.ExecutionTreePage; @@ -111,6 +113,68 @@ public class ExecutionTreePageTests : BunitContext _queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any(), Arg.Any()); } + [Fact] + public void DoubleClickTreeNode_OpensExecutionDetailModal() + { + var root = Guid.Parse("33333333-3333-3333-3333-333333333333"); + var child = Guid.Parse("44444444-4444-4444-4444-444444444444"); + _queryService = Substitute.For(); + _queryService.GetExecutionTreeAsync(child, Arg.Any()) + .Returns(Task.FromResult>(new List + { + Node(root, null), + Node(child, root), + })); + // The modal loads the double-clicked execution's audit rows on open. + _queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(Array.Empty())); + // AuditEventDetail (reachable from the modal) owns a clipboard interop call. + JSInterop.Mode = JSRuntimeMode.Loose; + + var cut = RenderPage($"executionId={child}", "Admin"); + + // The modal is absent until a node is activated. + Assert.Empty(cut.FindAll("[data-test=\"execution-detail-modal\"]")); + + var body = cut.Find($"[data-test=\"tree-node-{child}\"] .execution-tree-body"); + body.DoubleClick(); + + cut.WaitForAssertion(() => + Assert.NotEmpty(cut.FindAll("[data-test=\"execution-detail-modal\"]"))); + _queryService.Received().QueryAsync( + Arg.Is(f => f.ExecutionId == child), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public void ClosingExecutionDetailModal_HidesIt() + { + var root = Guid.Parse("55555555-5555-5555-5555-555555555555"); + var child = Guid.Parse("66666666-6666-6666-6666-666666666666"); + _queryService = Substitute.For(); + _queryService.GetExecutionTreeAsync(child, Arg.Any()) + .Returns(Task.FromResult>(new List + { + Node(root, null), + Node(child, root), + })); + _queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(Array.Empty())); + JSInterop.Mode = JSRuntimeMode.Loose; + + var cut = RenderPage($"executionId={child}", "Admin"); + + cut.Find($"[data-test=\"tree-node-{child}\"] .execution-tree-body").DoubleClick(); + cut.WaitForAssertion(() => + Assert.NotEmpty(cut.FindAll("[data-test=\"execution-detail-modal\"]"))); + + cut.Find("[data-test=\"execution-detail-close\"]").Click(); + + cut.WaitForAssertion(() => + Assert.Empty(cut.FindAll("[data-test=\"execution-detail-modal\"]"))); + } + [Fact] public void ExecutionTreePage_HasOperationalAuditAuthorizeAttribute() {