diff --git a/src/EventLogExpert.UI/FilterPane/FilterPane.razor.cs b/src/EventLogExpert.UI/FilterPane/FilterPane.razor.cs index f59af71a..1843adc2 100644 --- a/src/EventLogExpert.UI/FilterPane/FilterPane.razor.cs +++ b/src/EventLogExpert.UI/FilterPane/FilterPane.razor.cs @@ -14,8 +14,7 @@ using EventLogExpert.Runtime.Menu; using EventLogExpert.Runtime.Modal; using EventLogExpert.Runtime.Settings; -using EventLogExpert.UI.FilterCache; -using EventLogExpert.UI.FilterGroup; +using EventLogExpert.UI.Modal; using Fluxor; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; @@ -78,7 +77,7 @@ public sealed partial class FilterPane : IDisposable private string MenuState => HasFilters ? _isFilterListVisible.ToString().ToLower() : "false"; - [Inject] private IModalService ModalService { get; init; } = null!; + [Inject] private IModalCoordinator ModalCoordinator { get; init; } = null!; [Inject] private ISettingsService Settings { get; init; } = null!; @@ -284,7 +283,7 @@ private async Task OpenAddFilterMenuAtAsync(bool focusFirst) StateHasChanged(); } - private async Task OpenCachedFiltersModal() => await ModalService.Show(); + private Task OpenCachedFiltersModal() => ModalCoordinator.OpenFilterCacheAsync(); private void OpenFilterGroupPicker() { @@ -303,7 +302,7 @@ private void OpenFilterGroupPicker() _isFilterListVisible = true; } - private async Task OpenFilterGroupsModal() => await ModalService.Show(); + private Task OpenFilterGroupsModal() => ModalCoordinator.OpenFilterGroupAsync(); private void RemoveDateFilter() { diff --git a/src/EventLogExpert.UI/Modal/ModalCoordinatorLaunchers.cs b/src/EventLogExpert.UI/Modal/ModalCoordinatorLaunchers.cs new file mode 100644 index 00000000..7aa85f5d --- /dev/null +++ b/src/EventLogExpert.UI/Modal/ModalCoordinatorLaunchers.cs @@ -0,0 +1,62 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.Modal; +using EventLogExpert.Runtime.Update.ReleaseNotes; +using EventLogExpert.UI.DatabaseTools; +using EventLogExpert.UI.DebugLog; +using EventLogExpert.UI.FilterCache; +using EventLogExpert.UI.FilterGroup; +using EventLogExpert.UI.Settings; +using EventLogExpert.UI.Update; + +namespace EventLogExpert.UI.Modal; + +/// Typed launchers that route production modal opens through the coordinator's veto pipeline. +public static class ModalCoordinatorLaunchers +{ + extension(IModalCoordinator coordinator) + { + public Task> OpenDatabaseToolsAsync() + { + ArgumentNullException.ThrowIfNull(coordinator); + + return coordinator.PushAsync(); + } + + public Task> OpenDebugLogsAsync() + { + ArgumentNullException.ThrowIfNull(coordinator); + + return coordinator.PushAsync(); + } + + public Task> OpenFilterCacheAsync() + { + ArgumentNullException.ThrowIfNull(coordinator); + + return coordinator.PushAsync(); + } + + public Task> OpenFilterGroupAsync() + { + ArgumentNullException.ThrowIfNull(coordinator); + + return coordinator.PushAsync(); + } + + public Task> OpenReleaseNotesAsync(ReleaseNotesContent content) + { + ArgumentNullException.ThrowIfNull(coordinator); + + return coordinator.PushAsync(new Dictionary { [nameof(ReleaseNotesModal.Content)] = content }); + } + + public Task> OpenSettingsAsync() + { + ArgumentNullException.ThrowIfNull(coordinator); + + return coordinator.PushAsync(); + } + } +} diff --git a/src/EventLogExpert/Adapters/Input/KeyboardShortcutService.cs b/src/EventLogExpert/Adapters/Input/KeyboardShortcutService.cs index d41965e6..c5c0436b 100644 --- a/src/EventLogExpert/Adapters/Input/KeyboardShortcutService.cs +++ b/src/EventLogExpert/Adapters/Input/KeyboardShortcutService.cs @@ -15,11 +15,11 @@ namespace EventLogExpert.Adapters.Input; /// public sealed class KeyboardShortcutService( IMenuActionService actions, - IModalService modalService, + IModalCoordinator modalCoordinator, ISettingsService settings) : IAsyncDisposable { private readonly IMenuActionService _actions = actions; - private readonly IModalService _modalService = modalService; + private readonly IModalCoordinator _modalCoordinator = modalCoordinator; private readonly ISettingsService _settings = settings; private IJSRuntime? _jsRuntime; @@ -75,7 +75,7 @@ public async Task HandleShortcutAsync(string code, bool ctrl, bool alt, bool shi if (!ctrl || alt || shift || meta) { return; } // Modal-gating happens here, not in JS, so a misbehaving (or stale) bridge can't bypass it. - if (_modalService.ActiveModalType is not null) { return; } + if (_modalCoordinator.ActiveSession is not null) { return; } switch (code) { diff --git a/src/EventLogExpert/Adapters/Menu/MauiMenuActionService.cs b/src/EventLogExpert/Adapters/Menu/MauiMenuActionService.cs index 3367ecd5..6e302d7f 100644 --- a/src/EventLogExpert/Adapters/Menu/MauiMenuActionService.cs +++ b/src/EventLogExpert/Adapters/Menu/MauiMenuActionService.cs @@ -16,10 +16,9 @@ using EventLogExpert.Runtime.Update; using EventLogExpert.UI.DatabaseTools; using EventLogExpert.UI.DebugLog; +using EventLogExpert.UI.Modal; using EventLogExpert.UI.Settings; -using EventLogExpert.UI.Update; using Fluxor; -using Microsoft.AspNetCore.Components; using Application = Microsoft.Maui.Controls.Application; using IDispatcher = Fluxor.IDispatcher; @@ -37,7 +36,7 @@ public sealed class MauiMenuActionService( IFilterPaneCommands filterPaneCommands, IClipboardService clipboardService, IAlertDialogService dialogService, - IModalService modalService, + IModalCoordinator modalCoordinator, ISettingsService settings, IUpdateService updateService, ICurrentVersionProvider currentVersionProvider, @@ -54,7 +53,7 @@ public sealed class MauiMenuActionService( private readonly IFilterPaneCommands _filterPaneCommands = filterPaneCommands; private readonly IFolderPickerService _folderPickerService = folderPickerService; private readonly SemaphoreSlim _logNamesLock = new(1, 1); - private readonly IModalService _modalService = modalService; + private readonly IModalCoordinator _modalCoordinator = modalCoordinator; private readonly ISettingsService _settings = settings; private readonly ITraceLogger _traceLogger = traceLogger; private readonly IUpdateService _updateService = updateService; @@ -141,7 +140,8 @@ public async Task> GetOtherLogNamesAsync() public void LoadNewEvents() => _eventLogCommands.LoadNewEvents(); - public Task OpenDatabaseToolsAsync() => ShowModalAsync("database tools"); + public Task OpenDatabaseToolsAsync() => + TryOpenModalAsync(_modalCoordinator.OpenDatabaseToolsAsync, nameof(DatabaseToolsModal)); public Task OpenDocsAsync() => OpenBrowserAsync("https://github.com/microsoft/EventLogExpert/blob/main/docs/Home.md"); @@ -296,7 +296,8 @@ await _dialogService.ShowAlert( } } - public Task OpenSettingsAsync() => ShowModalAsync("settings"); + public Task OpenSettingsAsync() => + TryOpenModalAsync(_modalCoordinator.OpenSettingsAsync, nameof(SettingsModal)); public async Task SaveFiltersAsGroupAsync() { @@ -313,7 +314,8 @@ public async Task SaveFiltersAsGroupAsync() public void SetContinuouslyUpdate(bool value) => _eventLogCommands.SetContinuouslyUpdate(value); - public Task ShowDebugLogsAsync() => ShowModalAsync("debug logs"); + public Task ShowDebugLogsAsync() => + TryOpenModalAsync(_modalCoordinator.OpenDebugLogsAsync, nameof(DebugLogModal)); public async Task ShowReleaseNotesAsync() { @@ -323,8 +325,7 @@ public async Task ShowReleaseNotesAsync() if (content is null) { return; } - await _modalService.Show( - new Dictionary { ["Content"] = content.Value }); + await _modalCoordinator.OpenReleaseNotesAsync(content.Value); } catch (Exception ex) { @@ -355,18 +356,19 @@ private async Task OpenBrowserAsync(string url) } } - private async Task ShowModalAsync(string label) - where TModal : IComponent + private async Task TryOpenModalAsync(Func>> open, string modalName) { try { - await _modalService.Show(); + ModalOpenResult result = await open(); + + if (!result.WasOpened) { _traceLogger.Trace($"{modalName} open preempted by an active modal."); } return true; } catch (Exception ex) { - _traceLogger.Error($"Failed to open {label} modal: {ex}"); + _traceLogger.Error($"Failed to open {modalName}: {ex}"); return false; } diff --git a/src/EventLogExpert/MauiProgram.cs b/src/EventLogExpert/MauiProgram.cs index 3ad58b30..416e17b7 100644 --- a/src/EventLogExpert/MauiProgram.cs +++ b/src/EventLogExpert/MauiProgram.cs @@ -119,7 +119,6 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(static provider => { - var modalService = provider.GetRequiredService(); var modalCoordinator = provider.GetRequiredService(); var mainThreadService = provider.GetRequiredService(); var bannerService = provider.GetRequiredService(); @@ -128,12 +127,19 @@ public static MauiApp CreateMauiApp() modalCoordinator, mainThreadService, bannerService, - parameters => modalService.Show(parameters.ToDictionary(static kvp => kvp.Key, static kvp => kvp.Value)), async parameters => { - string? result = await modalService.Show(parameters.ToDictionary(static kvp => kvp.Key, static kvp => kvp.Value)); + ModalOpenResult result = await modalCoordinator.PushAsync( + parameters as IDictionary ?? new Dictionary(parameters)); - return result ?? string.Empty; + return result is { WasOpened: true, Result: true }; + }, + async parameters => + { + ModalOpenResult result = await modalCoordinator.PushAsync( + parameters as IDictionary ?? new Dictionary(parameters)); + + return result.WasOpened ? result.Result ?? string.Empty : string.Empty; }); }); diff --git a/tests/Unit/EventLogExpert.UI.Tests/Modal/ModalCoordinatorLaunchersTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Modal/ModalCoordinatorLaunchersTests.cs new file mode 100644 index 00000000..66795584 --- /dev/null +++ b/tests/Unit/EventLogExpert.UI.Tests/Modal/ModalCoordinatorLaunchersTests.cs @@ -0,0 +1,133 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.Modal; +using EventLogExpert.Runtime.Update.ReleaseNotes; +using EventLogExpert.UI.DatabaseTools; +using EventLogExpert.UI.DebugLog; +using EventLogExpert.UI.FilterCache; +using EventLogExpert.UI.FilterGroup; +using EventLogExpert.UI.Modal; +using EventLogExpert.UI.Settings; +using EventLogExpert.UI.Update; +using NSubstitute; + +namespace EventLogExpert.UI.Tests.Modal; + +public sealed class ModalCoordinatorLaunchersTests +{ + [Fact] + public async Task OpenDatabaseToolsAsync_DelegatesToPushAsync() + { + // Arrange + var coordinator = Substitute.For(); + coordinator.PushAsync(Arg.Any?>()) + .Returns(new ModalOpenResult(false, WasOpened: true)); + + // Act + await coordinator.OpenDatabaseToolsAsync(); + + // Assert + await coordinator.Received(1).PushAsync(Arg.Any?>()); + } + + [Fact] + public async Task OpenDebugLogsAsync_DelegatesToPushAsync() + { + // Arrange + var coordinator = Substitute.For(); + coordinator.PushAsync(Arg.Any?>()) + .Returns(new ModalOpenResult(false, WasOpened: true)); + + // Act + await coordinator.OpenDebugLogsAsync(); + + // Assert + await coordinator.Received(1).PushAsync(Arg.Any?>()); + } + + [Fact] + public async Task OpenFilterCacheAsync_DelegatesToPushAsync() + { + // Arrange + var coordinator = Substitute.For(); + coordinator.PushAsync(Arg.Any?>()) + .Returns(new ModalOpenResult(false, WasOpened: true)); + + // Act + await coordinator.OpenFilterCacheAsync(); + + // Assert + await coordinator.Received(1).PushAsync(Arg.Any?>()); + } + + [Fact] + public async Task OpenFilterGroupAsync_DelegatesToPushAsync() + { + // Arrange + var coordinator = Substitute.For(); + coordinator.PushAsync(Arg.Any?>()) + .Returns(new ModalOpenResult(false, WasOpened: true)); + + // Act + await coordinator.OpenFilterGroupAsync(); + + // Assert + await coordinator.Received(1).PushAsync(Arg.Any?>()); + } + + [Fact] + public async Task OpenReleaseNotesAsync_PassesContentParameter() + { + // Arrange + var coordinator = Substitute.For(); + coordinator.PushAsync(Arg.Any?>()) + .Returns(new ModalOpenResult(false, WasOpened: true)); + var content = new ReleaseNotesContent("v1.0", "## Notes"); + + // Act + await coordinator.OpenReleaseNotesAsync(content); + + // Assert + await coordinator.Received(1).PushAsync( + Arg.Is?>(d => d != null && d.ContainsKey(nameof(ReleaseNotesModal.Content)) && content.Equals((ReleaseNotesContent)d[nameof(ReleaseNotesModal.Content)]!))); + } + + [Fact] + public async Task OpenSettingsAsync_DelegatesToPushAsync() + { + // Arrange + var coordinator = Substitute.For(); + coordinator.PushAsync(Arg.Any?>()) + .Returns(new ModalOpenResult(false, WasOpened: true)); + + // Act + await coordinator.OpenSettingsAsync(); + + // Assert + await coordinator.Received(1).PushAsync(Arg.Any?>()); + } + + [Fact] + public void OpenSettingsAsync_NullCoordinator_ThrowsArgumentNullException() + { + // Arrange + Act + Assert — discard the Task to avoid xUnit2014; the throw happens synchronously in the guard. + Assert.Throws(static () => { _ = ModalCoordinatorLaunchers.OpenSettingsAsync(coordinator: null!); }); + } + + [Fact] + public async Task OpenSettingsAsync_WhenActiveModalVetoesPreemption_ReturnsNotOpened() + { + // Arrange — simulates PR 4's veto-preempt path: PushAsync returns WasOpened=false when the existing modal + // vetoes via OnRequestCloseAsync (e.g., SettingsModal IsCloseBlocked, DatabaseToolsModal AnyTabIsRunning). + var coordinator = Substitute.For(); + coordinator.PushAsync(Arg.Any?>()) + .Returns(new ModalOpenResult(false, WasOpened: false)); + + // Act + ModalOpenResult result = await coordinator.OpenSettingsAsync(); + + // Assert + Assert.False(result.WasOpened); + } +}