Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions src/EventLogExpert.UI/FilterPane/FilterPane.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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!;

Expand Down Expand Up @@ -284,7 +283,7 @@ private async Task OpenAddFilterMenuAtAsync(bool focusFirst)
StateHasChanged();
}

private async Task OpenCachedFiltersModal() => await ModalService.Show<FilterCacheModal, bool>();
private Task OpenCachedFiltersModal() => ModalCoordinator.OpenFilterCacheAsync();

private void OpenFilterGroupPicker()
{
Expand All @@ -303,7 +302,7 @@ private void OpenFilterGroupPicker()
_isFilterListVisible = true;
}

private async Task OpenFilterGroupsModal() => await ModalService.Show<FilterGroupModal, bool>();
private Task OpenFilterGroupsModal() => ModalCoordinator.OpenFilterGroupAsync();

private void RemoveDateFilter()
{
Expand Down
62 changes: 62 additions & 0 deletions src/EventLogExpert.UI/Modal/ModalCoordinatorLaunchers.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Typed launchers that route production modal opens through the coordinator's veto pipeline.</summary>
public static class ModalCoordinatorLaunchers
{
extension(IModalCoordinator coordinator)
{
public Task<ModalOpenResult<bool>> OpenDatabaseToolsAsync()
{
ArgumentNullException.ThrowIfNull(coordinator);

return coordinator.PushAsync<DatabaseToolsModal, bool>();
}

public Task<ModalOpenResult<bool>> OpenDebugLogsAsync()
{
ArgumentNullException.ThrowIfNull(coordinator);

return coordinator.PushAsync<DebugLogModal, bool>();
}

public Task<ModalOpenResult<bool>> OpenFilterCacheAsync()
{
ArgumentNullException.ThrowIfNull(coordinator);

return coordinator.PushAsync<FilterCacheModal, bool>();
}

public Task<ModalOpenResult<bool>> OpenFilterGroupAsync()
{
ArgumentNullException.ThrowIfNull(coordinator);

return coordinator.PushAsync<FilterGroupModal, bool>();
}

public Task<ModalOpenResult<bool>> OpenReleaseNotesAsync(ReleaseNotesContent content)
{
ArgumentNullException.ThrowIfNull(coordinator);

return coordinator.PushAsync<ReleaseNotesModal, bool>(new Dictionary<string, object?> { [nameof(ReleaseNotesModal.Content)] = content });
}
Comment thread
jschick04 marked this conversation as resolved.

public Task<ModalOpenResult<bool>> OpenSettingsAsync()
{
ArgumentNullException.ThrowIfNull(coordinator);

return coordinator.PushAsync<SettingsModal, bool>();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ namespace EventLogExpert.Adapters.Input;
/// </summary>
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;
Expand Down Expand Up @@ -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)
{
Expand Down
28 changes: 15 additions & 13 deletions src/EventLogExpert/Adapters/Menu/MauiMenuActionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -37,7 +36,7 @@ public sealed class MauiMenuActionService(
IFilterPaneCommands filterPaneCommands,
IClipboardService clipboardService,
IAlertDialogService dialogService,
IModalService modalService,
IModalCoordinator modalCoordinator,
ISettingsService settings,
IUpdateService updateService,
ICurrentVersionProvider currentVersionProvider,
Expand All @@ -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;
Expand Down Expand Up @@ -141,7 +140,8 @@ public async Task<IReadOnlyList<string>> GetOtherLogNamesAsync()

public void LoadNewEvents() => _eventLogCommands.LoadNewEvents();

public Task OpenDatabaseToolsAsync() => ShowModalAsync<DatabaseToolsModal>("database tools");
public Task OpenDatabaseToolsAsync() =>
TryOpenModalAsync(_modalCoordinator.OpenDatabaseToolsAsync, nameof(DatabaseToolsModal));

public Task OpenDocsAsync() =>
OpenBrowserAsync("https://github.com/microsoft/EventLogExpert/blob/main/docs/Home.md");
Expand Down Expand Up @@ -296,7 +296,8 @@ await _dialogService.ShowAlert(
}
}

public Task<bool> OpenSettingsAsync() => ShowModalAsync<SettingsModal>("settings");
public Task<bool> OpenSettingsAsync() =>
TryOpenModalAsync(_modalCoordinator.OpenSettingsAsync, nameof(SettingsModal));

public async Task SaveFiltersAsGroupAsync()
{
Expand All @@ -313,7 +314,8 @@ public async Task SaveFiltersAsGroupAsync()
public void SetContinuouslyUpdate(bool value) =>
_eventLogCommands.SetContinuouslyUpdate(value);

public Task ShowDebugLogsAsync() => ShowModalAsync<DebugLogModal>("debug logs");
public Task ShowDebugLogsAsync() =>
TryOpenModalAsync(_modalCoordinator.OpenDebugLogsAsync, nameof(DebugLogModal));

public async Task ShowReleaseNotesAsync()
{
Expand All @@ -323,8 +325,7 @@ public async Task ShowReleaseNotesAsync()

if (content is null) { return; }

await _modalService.Show<ReleaseNotesModal, bool>(
new Dictionary<string, object?> { ["Content"] = content.Value });
await _modalCoordinator.OpenReleaseNotesAsync(content.Value);
}
catch (Exception ex)
{
Expand Down Expand Up @@ -355,18 +356,19 @@ private async Task OpenBrowserAsync(string url)
}
}

private async Task<bool> ShowModalAsync<TModal>(string label)
where TModal : IComponent
private async Task<bool> TryOpenModalAsync(Func<Task<ModalOpenResult<bool>>> open, string modalName)
{
try
{
await _modalService.Show<TModal, bool>();
ModalOpenResult<bool> 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;
}
Expand Down
14 changes: 10 additions & 4 deletions src/EventLogExpert/MauiProgram.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,6 @@ public static MauiApp CreateMauiApp()

builder.Services.AddSingleton<IAlertDialogService>(static provider =>
{
var modalService = provider.GetRequiredService<IModalService>();
var modalCoordinator = provider.GetRequiredService<IModalCoordinator>();
var mainThreadService = provider.GetRequiredService<IMainThreadService>();
var bannerService = provider.GetRequiredService<IBannerService>();
Expand All @@ -128,12 +127,19 @@ public static MauiApp CreateMauiApp()
modalCoordinator,
mainThreadService,
bannerService,
parameters => modalService.Show<AlertModal, bool>(parameters.ToDictionary(static kvp => kvp.Key, static kvp => kvp.Value)),
async parameters =>
{
string? result = await modalService.Show<PromptModal, string>(parameters.ToDictionary(static kvp => kvp.Key, static kvp => kvp.Value));
ModalOpenResult<bool> result = await modalCoordinator.PushAsync<AlertModal, bool>(
parameters as IDictionary<string, object?> ?? new Dictionary<string, object?>(parameters));

Comment thread
jschick04 marked this conversation as resolved.
return result ?? string.Empty;
return result is { WasOpened: true, Result: true };
},
async parameters =>
{
ModalOpenResult<string> result = await modalCoordinator.PushAsync<PromptModal, string>(
parameters as IDictionary<string, object?> ?? new Dictionary<string, object?>(parameters));

Comment thread
jschick04 marked this conversation as resolved.
return result.WasOpened ? result.Result ?? string.Empty : string.Empty;
});
Comment thread
jschick04 marked this conversation as resolved.
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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<IModalCoordinator>();
coordinator.PushAsync<DatabaseToolsModal, bool>(Arg.Any<IDictionary<string, object?>?>())
.Returns(new ModalOpenResult<bool>(false, WasOpened: true));

// Act
await coordinator.OpenDatabaseToolsAsync();

// Assert
await coordinator.Received(1).PushAsync<DatabaseToolsModal, bool>(Arg.Any<IDictionary<string, object?>?>());
}

[Fact]
public async Task OpenDebugLogsAsync_DelegatesToPushAsync()
{
// Arrange
var coordinator = Substitute.For<IModalCoordinator>();
coordinator.PushAsync<DebugLogModal, bool>(Arg.Any<IDictionary<string, object?>?>())
.Returns(new ModalOpenResult<bool>(false, WasOpened: true));

// Act
await coordinator.OpenDebugLogsAsync();

// Assert
await coordinator.Received(1).PushAsync<DebugLogModal, bool>(Arg.Any<IDictionary<string, object?>?>());
}

[Fact]
public async Task OpenFilterCacheAsync_DelegatesToPushAsync()
{
// Arrange
var coordinator = Substitute.For<IModalCoordinator>();
coordinator.PushAsync<FilterCacheModal, bool>(Arg.Any<IDictionary<string, object?>?>())
.Returns(new ModalOpenResult<bool>(false, WasOpened: true));

// Act
await coordinator.OpenFilterCacheAsync();

// Assert
await coordinator.Received(1).PushAsync<FilterCacheModal, bool>(Arg.Any<IDictionary<string, object?>?>());
}

[Fact]
public async Task OpenFilterGroupAsync_DelegatesToPushAsync()
{
// Arrange
var coordinator = Substitute.For<IModalCoordinator>();
coordinator.PushAsync<FilterGroupModal, bool>(Arg.Any<IDictionary<string, object?>?>())
.Returns(new ModalOpenResult<bool>(false, WasOpened: true));

// Act
await coordinator.OpenFilterGroupAsync();

// Assert
await coordinator.Received(1).PushAsync<FilterGroupModal, bool>(Arg.Any<IDictionary<string, object?>?>());
}

[Fact]
public async Task OpenReleaseNotesAsync_PassesContentParameter()
{
// Arrange
var coordinator = Substitute.For<IModalCoordinator>();
coordinator.PushAsync<ReleaseNotesModal, bool>(Arg.Any<IDictionary<string, object?>?>())
.Returns(new ModalOpenResult<bool>(false, WasOpened: true));
var content = new ReleaseNotesContent("v1.0", "## Notes");

// Act
await coordinator.OpenReleaseNotesAsync(content);

// Assert
await coordinator.Received(1).PushAsync<ReleaseNotesModal, bool>(
Arg.Is<IDictionary<string, object?>?>(d => d != null && d.ContainsKey(nameof(ReleaseNotesModal.Content)) && content.Equals((ReleaseNotesContent)d[nameof(ReleaseNotesModal.Content)]!)));
}
Comment thread
jschick04 marked this conversation as resolved.

[Fact]
public async Task OpenSettingsAsync_DelegatesToPushAsync()
{
// Arrange
var coordinator = Substitute.For<IModalCoordinator>();
coordinator.PushAsync<SettingsModal, bool>(Arg.Any<IDictionary<string, object?>?>())
.Returns(new ModalOpenResult<bool>(false, WasOpened: true));

// Act
await coordinator.OpenSettingsAsync();

// Assert
await coordinator.Received(1).PushAsync<SettingsModal, bool>(Arg.Any<IDictionary<string, object?>?>());
}

[Fact]
public void OpenSettingsAsync_NullCoordinator_ThrowsArgumentNullException()
{
// Arrange + Act + Assert — discard the Task to avoid xUnit2014; the throw happens synchronously in the guard.
Assert.Throws<ArgumentNullException>(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<IModalCoordinator>();
coordinator.PushAsync<SettingsModal, bool>(Arg.Any<IDictionary<string, object?>?>())
.Returns(new ModalOpenResult<bool>(false, WasOpened: false));

// Act
ModalOpenResult<bool> result = await coordinator.OpenSettingsAsync();

// Assert
Assert.False(result.WasOpened);
}
}