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()
{