diff --git a/src/EventLogExpert.UI.Tests/Store/EventTable/EventTableEffectsTests.cs b/src/EventLogExpert.UI.Tests/Store/EventTable/EventTableEffectsTests.cs index 9337ad09..6ca40855 100644 --- a/src/EventLogExpert.UI.Tests/Store/EventTable/EventTableEffectsTests.cs +++ b/src/EventLogExpert.UI.Tests/Store/EventTable/EventTableEffectsTests.cs @@ -5,6 +5,7 @@ using EventLogExpert.UI.Store.EventTable; using Fluxor; using NSubstitute; +using System.Collections.Immutable; namespace EventLogExpert.UI.Tests.Store.EventTable; @@ -27,8 +28,29 @@ public async Task HandleLoadColumns_ShouldLoadAllColumnsFromPreferences() await effects.HandleLoadColumns(mockDispatcher); // Assert - mockDispatcher.Received(1).Dispatch(Arg.Is(a => - a.LoadedColumns.Count == Enum.GetValues().Length)); + mockDispatcher.Received(1).Dispatch(Arg.Is(action => + action.LoadedColumns.Count == Enum.GetValues().Length)); + } + + [Fact] + public async Task HandleLoadColumns_ShouldLoadWidthsFromPreferences() + { + // Arrange + var enabledColumns = new List { ColumnName.Level }; + var savedWidths = new Dictionary + { + { ColumnName.Level, 150 } + }; + + var (effects, mockDispatcher, mockPreferencesProvider) = CreateEffects(enabledColumns); + mockPreferencesProvider.ColumnWidthsPreference.Returns(savedWidths); + + // Act + await effects.HandleLoadColumns(mockDispatcher); + + // Assert + mockDispatcher.Received(1).Dispatch(Arg.Is(action => + action.ColumnWidths[ColumnName.Level] == 150)); } [Fact] @@ -46,9 +68,9 @@ public async Task HandleLoadColumns_ShouldMarkDisabledColumnsAsFalse() await effects.HandleLoadColumns(mockDispatcher); // Assert - mockDispatcher.Received(1).Dispatch(Arg.Is(a => - a.LoadedColumns[ColumnName.Source] == false && - a.LoadedColumns[ColumnName.EventId] == false)); + mockDispatcher.Received(1).Dispatch(Arg.Is(action => + action.LoadedColumns[ColumnName.Source] == false && + action.LoadedColumns[ColumnName.EventId] == false)); } [Fact] @@ -67,9 +89,60 @@ public async Task HandleLoadColumns_ShouldMarkEnabledColumnsAsTrue() await effects.HandleLoadColumns(mockDispatcher); // Assert - mockDispatcher.Received(1).Dispatch(Arg.Is(a => - a.LoadedColumns[ColumnName.Level] == true && - a.LoadedColumns[ColumnName.DateAndTime] == true)); + mockDispatcher.Received(1).Dispatch(Arg.Is(action => + action.LoadedColumns[ColumnName.Level] == true && + action.LoadedColumns[ColumnName.DateAndTime] == true)); + } + + [Fact] + public async Task HandleLoadColumns_ShouldUseDefaultOrderWhenNotSaved() + { + // Arrange + var enabledColumns = new List { ColumnName.Level }; + + var (effects, mockDispatcher, _) = CreateEffects(enabledColumns); + + // Act + await effects.HandleLoadColumns(mockDispatcher); + + // Assert + mockDispatcher.Received(1).Dispatch(Arg.Is(action => + action.ColumnOrder.SequenceEqual(ColumnDefaults.Order))); + } + + [Fact] + public async Task HandleLoadColumns_ShouldUseDefaultWidthsWhenNotSaved() + { + // Arrange + var enabledColumns = new List { ColumnName.Level }; + + var (effects, mockDispatcher, _) = CreateEffects(enabledColumns); + + // Act + await effects.HandleLoadColumns(mockDispatcher); + + // Assert + mockDispatcher.Received(1).Dispatch(Arg.Is(action => + action.ColumnWidths[ColumnName.Level] == ColumnDefaults.GetWidth(ColumnName.Level))); + } + + [Fact] + public async Task HandleLoadColumns_ShouldUseSavedOrderWhenPresent() + { + // Arrange + var enabledColumns = new List { ColumnName.Source, ColumnName.Level }; + var savedOrder = new List { ColumnName.Source, ColumnName.Level }; + + var (effects, mockDispatcher, mockPreferencesProvider) = CreateEffects(enabledColumns); + mockPreferencesProvider.ColumnOrderPreference.Returns(savedOrder); + + // Act + await effects.HandleLoadColumns(mockDispatcher); + + // Assert + mockDispatcher.Received(1).Dispatch(Arg.Is(action => + action.ColumnOrder[0] == ColumnName.Source && + action.ColumnOrder[1] == ColumnName.Level)); } [Fact] @@ -84,8 +157,86 @@ public async Task HandleLoadColumns_WhenNoColumnsEnabled_ShouldMarkAllAsFalse() await effects.HandleLoadColumns(mockDispatcher); // Assert - mockDispatcher.Received(1).Dispatch(Arg.Is(a => - a.LoadedColumns.All(kvp => kvp.Value == false))); + mockDispatcher.Received(1).Dispatch(Arg.Is(action => + action.LoadedColumns.All(kvp => kvp.Value == false))); + } + + [Fact] + public async Task HandleReorderColumn_ShouldPersistToPreferences() + { + // Arrange - state reflects post-reducer result (Source moved to index 0) + var postReducerState = new EventTableState + { + ColumnOrder = [ColumnName.Source, ColumnName.Level, ColumnName.DateAndTime] + }; + + var (effects, mockDispatcher, mockPreferencesProvider) = CreateEffects(state: postReducerState); + + var action = new EventTableAction.ReorderColumn(ColumnName.Source, ColumnName.Level, false); + + // Act + await effects.HandleReorderColumn(action, mockDispatcher); + + // Assert + _ = mockPreferencesProvider.Received(1).ColumnOrderPreference = + Arg.Is>(order => order.First() == ColumnName.Source); + } + + [Fact] + public async Task HandleResetColumnDefaults_ShouldResetAllColumnSettingsToDefaults() + { + // Arrange + var enabledColumns = new List { ColumnName.Level, ColumnName.Source }; + var (effects, mockDispatcher, mockPreferencesProvider) = CreateEffects(enabledColumns); + + // Act + await effects.HandleResetColumnDefaults(mockDispatcher); + + // Assert + _ = mockPreferencesProvider.Received(1).EnabledEventTableColumnsPreference = + Arg.Is>(c => c.SequenceEqual(ColumnDefaults.EnabledColumns)); + _ = mockPreferencesProvider.Received(1).ColumnWidthsPreference = + Arg.Is>(w => w.Count == 0); + _ = mockPreferencesProvider.Received(1).ColumnOrderPreference = + Arg.Is>(o => !o.Any()); + + var expectedWidths = ColumnDefaults.Widths.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + mockDispatcher.Received(1).Dispatch(Arg.Is(action => + action.ColumnWidths.Count == expectedWidths.Count && + action.ColumnWidths.All(kvp => + expectedWidths.ContainsKey(kvp.Key) && expectedWidths[kvp.Key] == kvp.Value) && + action.ColumnOrder.SequenceEqual(ColumnDefaults.Order) && + action.LoadedColumns[ColumnName.Level] == true && + action.LoadedColumns[ColumnName.DateAndTime] == true && + action.LoadedColumns[ColumnName.Source] == true && + action.LoadedColumns[ColumnName.EventId] == true && + action.LoadedColumns[ColumnName.TaskCategory] == true && + action.LoadedColumns[ColumnName.ActivityId] == false)); + } + + [Fact] + public async Task HandleSetColumnWidth_ShouldPersistToPreferences() + { + // Arrange + var postReducerState = new EventTableState + { + ColumnWidths = new Dictionary + { + { ColumnName.Level, 200 } + }.ToImmutableDictionary() + }; + + var (effects, mockDispatcher, mockPreferencesProvider) = CreateEffects(state: postReducerState); + + var action = new EventTableAction.SetColumnWidth(ColumnName.Level, 200); + + // Act + await effects.HandleSetColumnWidth(action, mockDispatcher); + + // Assert + _ = mockPreferencesProvider.Received(1).ColumnWidthsPreference = + Arg.Is>(width => width[ColumnName.Level] == 200); } [Fact] @@ -107,11 +258,11 @@ public async Task HandleToggleColumn_ShouldOnlyChangeToggledColumn() await effects.HandleToggleColumn(action, mockDispatcher); // Assert - mockDispatcher.Received(1).Dispatch(Arg.Is(a => - a.LoadedColumns[ColumnName.Level] == true && - a.LoadedColumns[ColumnName.DateAndTime] == false && - a.LoadedColumns[ColumnName.Source] == true && - a.LoadedColumns[ColumnName.EventId] == true)); + mockDispatcher.Received(1).Dispatch(Arg.Is(action => + action.LoadedColumns[ColumnName.Level] == true && + action.LoadedColumns[ColumnName.DateAndTime] == false && + action.LoadedColumns[ColumnName.Source] == true && + action.LoadedColumns[ColumnName.EventId] == true)); } [Fact] @@ -132,8 +283,8 @@ public async Task HandleToggleColumn_ShouldUpdatePreferences() // Assert _ = mockPreferencesProvider.Received(1).EnabledEventTableColumnsPreference = - Arg.Is>(cols => - cols.Contains(ColumnName.Source)); + Arg.Is>(columns => + columns.Contains(ColumnName.Source)); } [Fact] @@ -153,10 +304,10 @@ public async Task HandleToggleColumn_WhenColumnDisabled_ShouldEnableIt() await effects.HandleToggleColumn(action, mockDispatcher); // Assert - mockDispatcher.Received(1).Dispatch(Arg.Is(a => - a.LoadedColumns[ColumnName.Level] == true && - a.LoadedColumns[ColumnName.DateAndTime] == true && - a.LoadedColumns[ColumnName.Source] == true)); + mockDispatcher.Received(1).Dispatch(Arg.Is(action => + action.LoadedColumns[ColumnName.Level] == true && + action.LoadedColumns[ColumnName.DateAndTime] == true && + action.LoadedColumns[ColumnName.Source] == true)); } [Fact] @@ -177,10 +328,10 @@ public async Task HandleToggleColumn_WhenColumnEnabled_ShouldDisableIt() await effects.HandleToggleColumn(action, mockDispatcher); // Assert - mockDispatcher.Received(1).Dispatch(Arg.Is(a => - a.LoadedColumns[ColumnName.Level] == false && - a.LoadedColumns[ColumnName.DateAndTime] == true && - a.LoadedColumns[ColumnName.Source] == true)); + mockDispatcher.Received(1).Dispatch(Arg.Is(action => + action.LoadedColumns[ColumnName.Level] == false && + action.LoadedColumns[ColumnName.DateAndTime] == true && + action.LoadedColumns[ColumnName.Source] == true)); } [Fact] @@ -202,11 +353,11 @@ public async Task HandleToggleColumn_WhenDisabling_ShouldRemoveFromPreferences() // Assert var _ = mockPreferencesProvider.Received(1).EnabledEventTableColumnsPreference = - Arg.Is>(cols => - cols.Contains(ColumnName.Level) && - cols.Contains(ColumnName.DateAndTime) && - !cols.Contains(ColumnName.Source) && - cols.Count() == 2); + Arg.Is>(columns => + columns.Contains(ColumnName.Level) && + columns.Contains(ColumnName.DateAndTime) && + !columns.Contains(ColumnName.Source) && + columns.Count() == 2); } [Fact] @@ -226,10 +377,10 @@ public async Task HandleToggleColumn_WhenEnabling_ShouldPersistToPreferences() // Assert _ = mockPreferencesProvider.Received(1).EnabledEventTableColumnsPreference = - Arg.Is>(cols => - cols.Contains(ColumnName.Level) && - cols.Contains(ColumnName.Source) && - cols.Count() == 2); + Arg.Is>(columns => + columns.Contains(ColumnName.Level) && + columns.Contains(ColumnName.Source) && + columns.Count() == 2); } [Fact] @@ -259,12 +410,17 @@ public async Task HandleUpdateTable_ShouldDispatchUpdateCombinedEvents() } private static (EventTableEffects effects, IDispatcher mockDispatcher, IPreferencesProvider mockPreferencesProvider) - CreateEffects(List? enabledColumns = null) + CreateEffects(List? enabledColumns = null, EventTableState? state = null) { var mockPreferencesProvider = Substitute.For(); mockPreferencesProvider.EnabledEventTableColumnsPreference.Returns(enabledColumns ?? []); + mockPreferencesProvider.ColumnWidthsPreference.Returns(new Dictionary()); + mockPreferencesProvider.ColumnOrderPreference.Returns([]); + + var mockState = Substitute.For>(); + mockState.Value.Returns(state ?? new EventTableState()); - var effects = new EventTableEffects(mockPreferencesProvider); + var effects = new EventTableEffects(mockPreferencesProvider, mockState); var mockDispatcher = Substitute.For(); return (effects, mockDispatcher, mockPreferencesProvider); diff --git a/src/EventLogExpert.UI.Tests/Store/EventTable/EventTableStoreTests.cs b/src/EventLogExpert.UI.Tests/Store/EventTable/EventTableStoreTests.cs index c6fd2fd7..5ad992d1 100644 --- a/src/EventLogExpert.UI.Tests/Store/EventTable/EventTableStoreTests.cs +++ b/src/EventLogExpert.UI.Tests/Store/EventTable/EventTableStoreTests.cs @@ -7,6 +7,7 @@ using EventLogExpert.UI.Store.EventTable; using EventLogExpert.UI.Tests.TestUtils; using EventLogExpert.UI.Tests.TestUtils.Constants; +using System.Collections.Immutable; namespace EventLogExpert.UI.Tests.Store.EventTable; @@ -48,8 +49,16 @@ public void EventTableAction_LoadColumnsCompleted_ShouldStoreColumns() { ColumnName.DateAndTime, true } }; + var widths = new Dictionary + { + { ColumnName.Level, 100 }, + { ColumnName.DateAndTime, 160 } + }; + + var order = ColumnDefaults.Order; + // Act - var action = new EventTableAction.LoadColumnsCompleted(columns); + var action = new EventTableAction.LoadColumnsCompleted(columns, widths, order); // Assert Assert.Equal(2, action.LoadedColumns.Count); @@ -209,6 +218,8 @@ public void EventTableState_DefaultState_ShouldHaveCorrectDefaults() Assert.Empty(state.EventTables); Assert.Null(state.ActiveEventLogId); Assert.Empty(state.Columns); + Assert.Empty(state.ColumnWidths); + Assert.Empty(state.ColumnOrder); Assert.Null(state.OrderBy); Assert.True(state.IsDescending); } @@ -229,7 +240,7 @@ public void IntegrationTest_ColumnManagement() state = EventTableReducers.ReduceLoadColumnsCompleted( state, - new EventTableAction.LoadColumnsCompleted(columns)); + new EventTableAction.LoadColumnsCompleted(columns, new Dictionary(), ColumnDefaults.Order)); // Assert Assert.Equal(3, state.Columns.Count); @@ -623,7 +634,14 @@ public void ReduceLoadColumnsCompleted_ShouldUpdateColumns() { ColumnName.Source, false } }; - var action = new EventTableAction.LoadColumnsCompleted(columns); + var widths = new Dictionary + { + { ColumnName.Level, 120 }, + { ColumnName.Source, 250 } + }; + + var order = ColumnDefaults.Order; + var action = new EventTableAction.LoadColumnsCompleted(columns, widths, order); // Act var newState = EventTableReducers.ReduceLoadColumnsCompleted(state, action); @@ -632,6 +650,110 @@ public void ReduceLoadColumnsCompleted_ShouldUpdateColumns() Assert.Equal(2, newState.Columns.Count); Assert.True(newState.Columns[ColumnName.Level]); Assert.False(newState.Columns[ColumnName.Source]); + Assert.Equal(120, newState.ColumnWidths[ColumnName.Level]); + Assert.Equal(250, newState.ColumnWidths[ColumnName.Source]); + Assert.Equal(order, newState.ColumnOrder); + } + + [Fact] + public void ReduceReorderColumn_ShouldInsertAfterTarget() + { + // Arrange + var state = new EventTableState + { + ColumnOrder = [ColumnName.Level, ColumnName.DateAndTime, ColumnName.Source, ColumnName.EventId] + }; + + // Move Level after Source (drag right, insertAfter = true) + var action = new EventTableAction.ReorderColumn(ColumnName.Level, ColumnName.Source, true); + + // Act + var newState = EventTableReducers.ReduceReorderColumn(state, action); + + // Assert + Assert.Equal(ColumnName.DateAndTime, newState.ColumnOrder[0]); + Assert.Equal(ColumnName.Source, newState.ColumnOrder[1]); + Assert.Equal(ColumnName.Level, newState.ColumnOrder[2]); + Assert.Equal(ColumnName.EventId, newState.ColumnOrder[3]); + } + + [Fact] + public void ReduceReorderColumn_ShouldInsertBeforeTarget() + { + // Arrange + var state = new EventTableState + { + ColumnOrder = [ColumnName.Level, ColumnName.DateAndTime, ColumnName.Source, ColumnName.EventId] + }; + + // Move Source before Level (drag left, insertAfter = false) + var action = new EventTableAction.ReorderColumn(ColumnName.Source, ColumnName.Level, false); + + // Act + var newState = EventTableReducers.ReduceReorderColumn(state, action); + + // Assert + Assert.Equal(ColumnName.Source, newState.ColumnOrder[0]); + Assert.Equal(ColumnName.Level, newState.ColumnOrder[1]); + Assert.Equal(ColumnName.DateAndTime, newState.ColumnOrder[2]); + Assert.Equal(ColumnName.EventId, newState.ColumnOrder[3]); + } + + [Fact] + public void ReduceReorderColumn_WhenColumnNotInOrder_ShouldReturnUnchanged() + { + // Arrange + var state = new EventTableState + { + ColumnOrder = [ColumnName.Level, ColumnName.DateAndTime] + }; + + var action = new EventTableAction.ReorderColumn(ColumnName.Source, ColumnName.Level, true); + + // Act + var newState = EventTableReducers.ReduceReorderColumn(state, action); + + // Assert + Assert.Equal(state.ColumnOrder, newState.ColumnOrder); + } + + [Fact] + public void ReduceReorderColumn_WhenTargetMissing_ShouldReturnUnchanged() + { + // Arrange + var state = new EventTableState + { + ColumnOrder = [ColumnName.Level, ColumnName.DateAndTime, ColumnName.Source] + }; + + var action = new EventTableAction.ReorderColumn(ColumnName.Level, ColumnName.EventId, true); + + // Act + var newState = EventTableReducers.ReduceReorderColumn(state, action); + + // Assert + Assert.Equal(state.ColumnOrder, newState.ColumnOrder); + } + + [Fact] + public void ReduceReorderColumn_WithDisabledColumnsInterleaved_ShouldInsertRelativeToTarget() + { + // Arrange — full order has disabled columns interleaved among visible ones + var state = new EventTableState + { + ColumnOrder = [ColumnName.Level, ColumnName.ActivityId, ColumnName.DateAndTime, ColumnName.Log, ColumnName.Source] + }; + + // Drag Level right and drop "after Source" (insertAfter = true) + var action = new EventTableAction.ReorderColumn(ColumnName.Level, ColumnName.Source, true); + + // Act + var newState = EventTableReducers.ReduceReorderColumn(state, action); + + // Assert — Level should land immediately after Source, regardless of disabled columns + var levelIndex = newState.ColumnOrder.IndexOf(ColumnName.Level); + var sourceIndex = newState.ColumnOrder.IndexOf(ColumnName.Source); + Assert.Equal(sourceIndex + 1, levelIndex); } [Fact] @@ -688,6 +810,44 @@ public void ReduceSetActiveTable_WhenTableNotFound_ShouldReturnStateUnchanged() Assert.Same(state, newState); } + [Fact] + public void ReduceSetColumnWidth_ShouldUpdateWidth() + { + // Arrange + var state = new EventTableState + { + ColumnWidths = new Dictionary + { + { ColumnName.Level, 100 }, + { ColumnName.Source, 250 } + }.ToImmutableDictionary() + }; + + var action = new EventTableAction.SetColumnWidth(ColumnName.Level, 150); + + // Act + var newState = EventTableReducers.ReduceSetColumnWidth(state, action); + + // Assert + Assert.Equal(150, newState.ColumnWidths[ColumnName.Level]); + Assert.Equal(250, newState.ColumnWidths[ColumnName.Source]); + } + + [Fact] + public void ReduceSetColumnWidth_WhenColumnNotYetInWidths_ShouldAddIt() + { + // Arrange + var state = new EventTableState(); + + var action = new EventTableAction.SetColumnWidth(ColumnName.EventId, 90); + + // Act + var newState = EventTableReducers.ReduceSetColumnWidth(state, action); + + // Assert + Assert.Equal(90, newState.ColumnWidths[ColumnName.EventId]); + } + [Fact] public void ReduceSetOrderBy_WithNewColumn_ShouldUpdateOrderBy() { diff --git a/src/EventLogExpert.UI/ColumnDefaults.cs b/src/EventLogExpert.UI/ColumnDefaults.cs new file mode 100644 index 00000000..dee55cb1 --- /dev/null +++ b/src/EventLogExpert.UI/ColumnDefaults.cs @@ -0,0 +1,54 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using System.Collections.Frozen; +using System.Collections.Immutable; + +namespace EventLogExpert.UI; + +public static class ColumnDefaults +{ + public static readonly ImmutableList EnabledColumns = + [ + ColumnName.Level, + ColumnName.DateAndTime, + ColumnName.Source, + ColumnName.EventId, + ColumnName.TaskCategory + ]; + + public static readonly ImmutableList Order = + [ + ColumnName.Level, + ColumnName.DateAndTime, + ColumnName.ActivityId, + ColumnName.Log, + ColumnName.ComputerName, + ColumnName.Source, + ColumnName.EventId, + ColumnName.TaskCategory, + ColumnName.Keywords, + ColumnName.ProcessId, + ColumnName.ThreadId, + ColumnName.User + ]; + + public static readonly FrozenDictionary Widths = new Dictionary + { + [ColumnName.Level] = 100, + [ColumnName.DateAndTime] = 160, + [ColumnName.ActivityId] = 270, + [ColumnName.Log] = 100, + [ColumnName.ComputerName] = 100, + [ColumnName.Source] = 250, + [ColumnName.EventId] = 80, + [ColumnName.TaskCategory] = 180, + [ColumnName.Keywords] = 100, + [ColumnName.ProcessId] = 80, + [ColumnName.ThreadId] = 80, + [ColumnName.User] = 180 + }.ToFrozenDictionary(); + + public static int GetWidth(ColumnName column) => + Widths.TryGetValue(column, out int width) ? width : 100; +} diff --git a/src/EventLogExpert.UI/Interfaces/IPreferencesProvider.cs b/src/EventLogExpert.UI/Interfaces/IPreferencesProvider.cs index 24beb3af..0b92fdbd 100644 --- a/src/EventLogExpert.UI/Interfaces/IPreferencesProvider.cs +++ b/src/EventLogExpert.UI/Interfaces/IPreferencesProvider.cs @@ -8,6 +8,12 @@ namespace EventLogExpert.UI.Interfaces; public interface IPreferencesProvider { + IEnumerable ColumnOrderPreference { get; set; } + + IDictionary ColumnWidthsPreference { get; set; } + + int DetailsPaneHeightPreference { get; set; } + IEnumerable DisabledDatabasesPreference { get; set; } bool DisplayPaneSelectionPreference { get; set; } diff --git a/src/EventLogExpert.UI/Store/EventTable/EventTableAction.cs b/src/EventLogExpert.UI/Store/EventTable/EventTableAction.cs index 87d07254..b64df5bd 100644 --- a/src/EventLogExpert.UI/Store/EventTable/EventTableAction.cs +++ b/src/EventLogExpert.UI/Store/EventTable/EventTableAction.cs @@ -3,6 +3,7 @@ using EventLogExpert.Eventing.Models; using EventLogExpert.UI.Models; +using System.Collections.Immutable; namespace EventLogExpert.UI.Store.EventTable; @@ -18,10 +19,19 @@ public sealed record CloseLog(EventLogId LogId); public sealed record LoadColumns; - public sealed record LoadColumnsCompleted(IDictionary LoadedColumns); + public sealed record LoadColumnsCompleted( + IDictionary LoadedColumns, + IDictionary ColumnWidths, + ImmutableList ColumnOrder); + + public sealed record ReorderColumn(ColumnName ColumnName, ColumnName TargetColumn, bool InsertAfter); + + public sealed record ResetColumnDefaults; public sealed record SetActiveTable(EventLogId LogId); + public sealed record SetColumnWidth(ColumnName ColumnName, int Width); + public sealed record SetOrderBy(ColumnName? OrderBy); public sealed record ToggleColumn(ColumnName ColumnName); @@ -32,7 +42,8 @@ public sealed record ToggleSorting; public sealed record UpdateCombinedEvents; - public sealed record UpdateDisplayedEvents(IReadOnlyDictionary> ActiveLogs); + public sealed record UpdateDisplayedEvents( + IReadOnlyDictionary> ActiveLogs); public sealed record UpdateTable(EventLogId LogId, IReadOnlyList Events); } diff --git a/src/EventLogExpert.UI/Store/EventTable/EventTableEffects.cs b/src/EventLogExpert.UI/Store/EventTable/EventTableEffects.cs index fe209ab0..61fa82ca 100644 --- a/src/EventLogExpert.UI/Store/EventTable/EventTableEffects.cs +++ b/src/EventLogExpert.UI/Store/EventTable/EventTableEffects.cs @@ -3,11 +3,13 @@ using EventLogExpert.UI.Interfaces; using Fluxor; +using System.Collections.Immutable; namespace EventLogExpert.UI.Store.EventTable; -public sealed class EventTableEffects(IPreferencesProvider preferencesProvider) +public sealed class EventTableEffects(IPreferencesProvider preferencesProvider, IState eventTableState) { + private readonly IState _eventTableState = eventTableState; private readonly IPreferencesProvider _preferencesProvider = preferencesProvider; [EffectMethod(typeof(EventTableAction.AppendTableEvents))] @@ -45,7 +47,49 @@ public Task HandleLoadColumns(IDispatcher dispatcher) columns.Add(column, enabledColumns.Contains(column)); } - dispatcher.Dispatch(new EventTableAction.LoadColumnsCompleted(columns)); + var widths = BuildWidths(); + var order = BuildOrder(); + + dispatcher.Dispatch(new EventTableAction.LoadColumnsCompleted(columns, widths, order)); + + return Task.CompletedTask; + } + + [EffectMethod] + public Task HandleReorderColumn(EventTableAction.ReorderColumn action, IDispatcher dispatcher) + { + // Read from post-reducer state to avoid race conditions with rapid reorder actions + _preferencesProvider.ColumnOrderPreference = _eventTableState.Value.ColumnOrder; + + return Task.CompletedTask; + } + + [EffectMethod(typeof(EventTableAction.ResetColumnDefaults))] + public Task HandleResetColumnDefaults(IDispatcher dispatcher) + { + var columns = new Dictionary(); + + foreach (ColumnName column in Enum.GetValues()) + { + columns.Add(column, ColumnDefaults.EnabledColumns.Contains(column)); + } + + _preferencesProvider.EnabledEventTableColumnsPreference = ColumnDefaults.EnabledColumns; + _preferencesProvider.ColumnWidthsPreference = new Dictionary(); + _preferencesProvider.ColumnOrderPreference = []; + + var widths = new Dictionary(ColumnDefaults.Widths); + + dispatcher.Dispatch(new EventTableAction.LoadColumnsCompleted(columns, widths, ColumnDefaults.Order)); + + return Task.CompletedTask; + } + + [EffectMethod] + public Task HandleSetColumnWidth(EventTableAction.SetColumnWidth action, IDispatcher dispatcher) + { + // Read from post-reducer state to avoid race conditions + _preferencesProvider.ColumnWidthsPreference = new Dictionary(_eventTableState.Value.ColumnWidths); return Task.CompletedTask; } @@ -66,8 +110,41 @@ public Task HandleToggleColumn(EventTableAction.ToggleColumn action, IDispatcher _preferencesProvider.EnabledEventTableColumnsPreference = columns.Keys.Where(column => columns[column]); - dispatcher.Dispatch(new EventTableAction.LoadColumnsCompleted(columns)); + var widths = BuildWidths(); + var order = BuildOrder(); + + dispatcher.Dispatch(new EventTableAction.LoadColumnsCompleted(columns, widths, order)); return Task.CompletedTask; } + + private ImmutableList BuildOrder() + { + var savedOrder = _preferencesProvider.ColumnOrderPreference.ToList(); + + if (savedOrder.Count == 0) + { + return ColumnDefaults.Order; + } + + // Start with saved order, then append any new columns not in saved order + var allColumns = Enum.GetValues().ToHashSet(); + var ordered = savedOrder.Where(allColumns.Contains).ToList(); + var missing = ColumnDefaults.Order.Where(c => !ordered.Contains(c)); + + return [.. ordered, .. missing]; + } + + private Dictionary BuildWidths() + { + var savedWidths = _preferencesProvider.ColumnWidthsPreference; + var widths = new Dictionary(); + + foreach (ColumnName column in Enum.GetValues()) + { + widths[column] = savedWidths.TryGetValue(column, out int width) ? width : ColumnDefaults.GetWidth(column); + } + + return widths; + } } diff --git a/src/EventLogExpert.UI/Store/EventTable/EventTableReducers.cs b/src/EventLogExpert.UI/Store/EventTable/EventTableReducers.cs index 11f7559d..b5c4a7c0 100644 --- a/src/EventLogExpert.UI/Store/EventTable/EventTableReducers.cs +++ b/src/EventLogExpert.UI/Store/EventTable/EventTableReducers.cs @@ -117,9 +117,30 @@ public static EventTableState ReduceLoadColumnsCompleted( EventTableAction.LoadColumnsCompleted action) => state with { - Columns = action.LoadedColumns.ToImmutableDictionary() + Columns = action.LoadedColumns.ToImmutableDictionary(), + ColumnWidths = action.ColumnWidths.ToImmutableDictionary(), + ColumnOrder = action.ColumnOrder }; + [ReducerMethod] + public static EventTableState ReduceReorderColumn(EventTableState state, EventTableAction.ReorderColumn action) + { + var order = state.ColumnOrder; + + if (!order.Contains(action.ColumnName) || !order.Contains(action.TargetColumn) || + action.ColumnName == action.TargetColumn) + { + return state; + } + + order = order.Remove(action.ColumnName); + var targetIndex = order.IndexOf(action.TargetColumn); + var insertIndex = action.InsertAfter ? targetIndex + 1 : targetIndex; + order = order.Insert(insertIndex, action.ColumnName); + + return state with { ColumnOrder = order }; + } + [ReducerMethod] public static EventTableState ReduceSetActiveTable(EventTableState state, EventTableAction.SetActiveTable action) { @@ -130,6 +151,10 @@ public static EventTableState ReduceSetActiveTable(EventTableState state, EventT return state with { ActiveEventLogId = activeTable.Id }; } + [ReducerMethod] + public static EventTableState ReduceSetColumnWidth(EventTableState state, EventTableAction.SetColumnWidth action) => + state with { ColumnWidths = state.ColumnWidths.SetItem(action.ColumnName, action.Width) }; + [ReducerMethod] public static EventTableState ReduceSetOrderBy(EventTableState state, EventTableAction.SetOrderBy action) => state.OrderBy.Equals(action.OrderBy) ? diff --git a/src/EventLogExpert.UI/Store/EventTable/EventTableState.cs b/src/EventLogExpert.UI/Store/EventTable/EventTableState.cs index cbec0008..a5d39861 100644 --- a/src/EventLogExpert.UI/Store/EventTable/EventTableState.cs +++ b/src/EventLogExpert.UI/Store/EventTable/EventTableState.cs @@ -16,6 +16,10 @@ public sealed record EventTableState public ImmutableDictionary Columns { get; init; } = ImmutableDictionary.Empty; + public ImmutableDictionary ColumnWidths { get; init; } = ImmutableDictionary.Empty; + + public ImmutableList ColumnOrder { get; init; } = []; + public ColumnName? OrderBy { get; init; } public bool IsDescending { get; init; } = true; diff --git a/src/EventLogExpert/Components/DetailsPane.razor.cs b/src/EventLogExpert/Components/DetailsPane.razor.cs index a71c3362..438a0a66 100644 --- a/src/EventLogExpert/Components/DetailsPane.razor.cs +++ b/src/EventLogExpert/Components/DetailsPane.razor.cs @@ -15,8 +15,9 @@ namespace EventLogExpert.Components; -public sealed partial class DetailsPane : IDisposable +public sealed partial class DetailsPane { + private DotNetObjectReference? _dotNetRef; private bool _hasOpened = false; private bool _isVisible = false; private bool _isXmlVisible = false; @@ -27,6 +28,8 @@ public sealed partial class DetailsPane : IDisposable [Inject] private IJSRuntime JSRuntime { get; init; } = null!; + [Inject] private IPreferencesProvider PreferencesProvider { get; init; } = null!; + private DisplayEventModel? SelectedEvent { get; set; } [Inject] private IStateSelection> SelectedEventSelection { get; init; } = null!; @@ -35,13 +38,42 @@ public sealed partial class DetailsPane : IDisposable [Inject] private ITraceLogger TraceLogger { get; init; } = null!; - public void Dispose() => SelectedEventSelection.SelectedValueChanged -= OnSelectedEventChanged; + [JSInvokable] + public void OnDetailsPaneHeightChanged(int height) + { + if (height > 0) + { + PreferencesProvider.DetailsPaneHeightPreference = height; + } + } + + protected override async ValueTask DisposeAsyncCore(bool disposing) + { + if (disposing) + { + SelectedEventSelection.SelectedValueChanged -= OnSelectedEventChanged; + + try + { + await JSRuntime.InvokeVoidAsync("disposeDetailsPaneResizer"); + } + catch (JSDisconnectedException) { } + + _dotNetRef?.Dispose(); + } + + await base.DisposeAsyncCore(disposing); + } protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { - await JSRuntime.InvokeVoidAsync("enableDetailsPaneResizer"); + _dotNetRef = DotNetObjectReference.Create(this); + await JSRuntime.InvokeVoidAsync( + "enableDetailsPaneResizer", + _dotNetRef, + PreferencesProvider.DetailsPaneHeightPreference); } await base.OnAfterRenderAsync(firstRender); @@ -60,6 +92,8 @@ protected override void OnInitialized() private void HandleKeyDown(KeyboardEventArgs e) { + if (e.Repeat) { return; } + if (e.Key is "Enter" or " ") { ToggleMenu(); @@ -68,6 +102,8 @@ private void HandleKeyDown(KeyboardEventArgs e) private void HandleKeyDownXml(KeyboardEventArgs e) { + if (e.Repeat) { return; } + if (e.Key is "Enter" or " ") { ToggleXml(); diff --git a/src/EventLogExpert/Components/EventTable.razor b/src/EventLogExpert/Components/EventTable.razor index 1c27f350..afbbb3a0 100644 --- a/src/EventLogExpert/Components/EventTable.razor +++ b/src/EventLogExpert/Components/EventTable.razor @@ -12,9 +12,10 @@ { var columnHeader = _enabledColumns[columnIndex]; _headerName = columnHeader.ToFullString(); + var width = GetColumnWidth(columnHeader); - + @if (columnHeader == ColumnName.DateAndTime) { @GetDateColumnHeader() diff --git a/src/EventLogExpert/Components/EventTable.razor.cs b/src/EventLogExpert/Components/EventTable.razor.cs index c7b5ba3a..66d73007 100644 --- a/src/EventLogExpert/Components/EventTable.razor.cs +++ b/src/EventLogExpert/Components/EventTable.razor.cs @@ -22,10 +22,12 @@ namespace EventLogExpert.Components; public sealed partial class EventTable { private EventTableModel? _currentTable; + private DotNetObjectReference? _dotNetRef; private ColumnName[] _enabledColumns = null!; private EventTableState _eventTableState = null!; private string _headerName = string.Empty; private IReadOnlyList? _lastDisplayedEvents; + private ColumnName[] _previousEnabledColumns = []; private Dictionary _rowIndexMap = new(ReferenceEqualityComparer.Instance); private ImmutableList _selectedEventState = []; private TimeZoneInfo _timeZoneSettings = null!; @@ -46,19 +48,74 @@ public sealed partial class EventTable [Inject] private ITraceLogger TraceLogger { get; init; } = null!; + [JSInvokable] + public void OnColumnReordered(string columnName, string targetColumn, bool insertAfter) + { + if (Enum.TryParse(columnName, out var column) && + Enum.TryParse(targetColumn, out var target)) + { + Dispatcher.Dispatch(new EventTableAction.ReorderColumn(column, target, insertAfter)); + } + } + + [JSInvokable] + public void OnColumnResized(string columnName, int width) + { + if (Enum.TryParse(columnName, out var column)) + { + Dispatcher.Dispatch(new EventTableAction.SetColumnWidth(column, width)); + } + } + + protected override async ValueTask DisposeAsyncCore(bool disposing) + { + if (disposing) + { + try + { + await JSRuntime.InvokeVoidAsync("disposeTableEvents"); + } + catch (JSDisconnectedException) { } + + _dotNetRef?.Dispose(); + } + + await base.DisposeAsyncCore(disposing); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + // Reinitialize JS when columns change (add/remove/reorder). This + // ensures resize dividers and event listeners target the current DOM. + if (firstRender || !_enabledColumns.SequenceEqual(_previousEnabledColumns)) + { + _previousEnabledColumns = _enabledColumns.ToArray(); + + try + { + await InitializeTableEventHandlers(); + } + catch (Exception e) + { + TraceLogger.Error($"Failed to initialize table event handlers: {e}"); + } + } + + await base.OnAfterRenderAsync(firstRender); + } + protected override async Task OnInitializedAsync() { SelectedEventState.Select(s => s.SelectedEvents); SubscribeToAction(OnSetActiveTable); - SubscribeToAction(OnLoadColumnsCompleted); SubscribeToAction(OnUpdateCombinedEvents); SubscribeToAction(OnUpdateDisplayedEvents); _eventTableState = EventTableState.Value; _currentTable = _eventTableState.EventTables.FirstOrDefault(x => x.Id == _eventTableState.ActiveEventLogId); - _enabledColumns = _eventTableState.Columns.Where(column => column.Value).Select(column => column.Key).ToArray(); + _enabledColumns = GetOrderedEnabledColumns(); _selectedEventState = SelectedEventState.Value; _timeZoneSettings = Settings.TimeZoneInfo; @@ -76,7 +133,7 @@ protected override bool ShouldRender() _eventTableState = EventTableState.Value; _currentTable = _eventTableState.EventTables.FirstOrDefault(x => x.Id == _eventTableState.ActiveEventLogId); - _enabledColumns = _eventTableState.Columns.Where(column => column.Value).Select(column => column.Key).ToArray(); + _enabledColumns = GetOrderedEnabledColumns(); _selectedEventState = SelectedEventState.Value; _timeZoneSettings = Settings.TimeZoneInfo; @@ -94,6 +151,9 @@ private static string GetLevelClass(string level) => _ => string.Empty, }; + private int GetColumnWidth(ColumnName column) => + _eventTableState.ColumnWidths.TryGetValue(column, out int width) ? width : ColumnDefaults.GetWidth(column); + private string GetCss(DisplayEventModel @event) => _selectedEventState.Contains(@event) ? "table-row selected" : $"table-row {GetHighlightedColor(@event)}"; @@ -113,6 +173,25 @@ private string GetHighlightedColor(DisplayEventModel @event) return string.Empty; } + private ColumnName[] GetOrderedEnabledColumns() + { + var enabledSet = _eventTableState.Columns + .Where(column => column.Value) + .Select(column => column.Key) + .ToHashSet(); + + if (_eventTableState.ColumnOrder.IsEmpty) + { + // Use ColumnDefaults.Order for a deterministic fallback rather than + // HashSet iteration order, which is not guaranteed. + return ColumnDefaults.Order.Where(enabledSet.Contains).ToArray(); + } + + return _eventTableState.ColumnOrder + .Where(enabledSet.Contains) + .ToArray(); + } + private int GetRowIndex(DisplayEventModel evt) => _rowIndexMap.TryGetValue(evt, out int index) ? index + 2 : 2; @@ -160,24 +239,19 @@ private void HandleKeyDown(KeyboardEventArgs args) } } + private async Task InitializeTableEventHandlers() + { + _dotNetRef?.Dispose(); + _dotNetRef = DotNetObjectReference.Create(this); + await JSRuntime.InvokeVoidAsync("initializeTableEvents", _dotNetRef); + } + private async Task InvokeContextMenu(MouseEventArgs args) => await JSRuntime.InvokeVoidAsync("invokeContextMenu", args.ClientX, args.ClientY); private async Task InvokeTableColumnMenu(MouseEventArgs args) => await JSRuntime.InvokeVoidAsync("invokeTableColumnMenu", args.ClientX, args.ClientY); - private async void OnLoadColumnsCompleted(EventTableAction.LoadColumnsCompleted action) - { - try - { - await InvokeAsync(RegisterTableEventHandlers); - } - catch (Exception e) - { - TraceLogger.Error($"Failed to register table event handlers: {e}"); - } - } - private async void OnSetActiveTable(EventTableAction.SetActiveTable action) { try @@ -231,8 +305,6 @@ private void RebuildRowIndexMap() } } - private async Task RegisterTableEventHandlers() => await JSRuntime.InvokeVoidAsync("registerTableEvents"); - private async Task ScrollToSelectedEvent() { var entry = _currentTable?.DisplayedEvents.FirstOrDefault(x => diff --git a/src/EventLogExpert/Components/EventTable.razor.css b/src/EventLogExpert/Components/EventTable.razor.css index ec6536f1..7d58b8e7 100644 --- a/src/EventLogExpert/Components/EventTable.razor.css +++ b/src/EventLogExpert/Components/EventTable.razor.css @@ -156,28 +156,8 @@ tr { .table-row.darkpink { background-color: var(--highlight-darkpink) !important; } -.level { width: 100px; } - -.dateandtime { width: 160px; } - -.activityid { width: 270px; } - -.log { width: 100px; } - -.computername { width: 100px; } - -.source { width: 250px; } - -.eventid { width: 80px; } - -.taskcategory { width: 180px; } - -.keywords { width: 100px; } - -.processid { width: 80px; } - -.threadid { width: 80px; } - -.user { width: 180px; } - .description { width: 100%; } + +th.dragging { + opacity: 0.5; +} diff --git a/src/EventLogExpert/Services/PreferencesProvider.cs b/src/EventLogExpert/Services/PreferencesProvider.cs index a361c020..b4f244ba 100644 --- a/src/EventLogExpert/Services/PreferencesProvider.cs +++ b/src/EventLogExpert/Services/PreferencesProvider.cs @@ -11,6 +11,9 @@ namespace EventLogExpert.Services; public sealed class PreferencesProvider : IPreferencesProvider { + private const string ColumnOrder = "column-order"; + private const string ColumnWidths = "column-widths"; + private const string DetailsPaneHeight = "details-pane-height"; private const string DisabledDatabases = "disabled-databases"; private const string DisplaySelectionEnabled = "display-selection-enabled"; private const string EnabledEventTableColumns = "enabled-event-table-columns"; @@ -22,6 +25,24 @@ public sealed class PreferencesProvider : IPreferencesProvider private const string SavedFilters = "saved-filters"; private const string TimeZone = "timezone"; + public IEnumerable ColumnOrderPreference + { + get => JsonSerializer.Deserialize>(Preferences.Default.Get(ColumnOrder, "[]")) ?? []; + set => Preferences.Default.Set(ColumnOrder, JsonSerializer.Serialize(value)); + } + + public IDictionary ColumnWidthsPreference + { + get => JsonSerializer.Deserialize>(Preferences.Default.Get(ColumnWidths, "{}")) ?? new Dictionary(); + set => Preferences.Default.Set(ColumnWidths, JsonSerializer.Serialize(value)); + } + + public int DetailsPaneHeightPreference + { + get => Preferences.Default.Get(DetailsPaneHeight, 0); + set => Preferences.Default.Set(DetailsPaneHeight, value); + } + public IEnumerable DisabledDatabasesPreference { get => JsonSerializer.Deserialize>(Preferences.Default.Get(DisabledDatabases, "[]")) ?? []; diff --git a/src/EventLogExpert/Shared/Components/TableColumnMenu.razor b/src/EventLogExpert/Shared/Components/TableColumnMenu.razor index a5cc5176..7a53bb7d 100644 --- a/src/EventLogExpert/Shared/Components/TableColumnMenu.razor +++ b/src/EventLogExpert/Shared/Components/TableColumnMenu.razor @@ -3,7 +3,10 @@
@foreach (var column in EventTableColumnsState.Value) { -
+
@column.Key.ToFullString() @if (column.Value) { @@ -15,11 +18,14 @@
+ +
+ +
+ Reset Column Defaults +
diff --git a/src/EventLogExpert/Shared/Components/TableColumnMenu.razor.cs b/src/EventLogExpert/Shared/Components/TableColumnMenu.razor.cs index 7640c839..28d3eaac 100644 --- a/src/EventLogExpert/Shared/Components/TableColumnMenu.razor.cs +++ b/src/EventLogExpert/Shared/Components/TableColumnMenu.razor.cs @@ -5,6 +5,7 @@ using EventLogExpert.UI.Store.EventTable; using Fluxor; using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; using System.Collections.Immutable; using IDispatcher = Fluxor.IDispatcher; @@ -14,12 +15,12 @@ public sealed partial class TableColumnMenu { [Inject] private IDispatcher Dispatcher { get; init; } = null!; - [Inject] private IState EventTableState { get; init; } = null!; - [Inject] private IStateSelection> EventTableColumnsState { get; init; } = null!; + [Inject] private IState EventTableState { get; init; } = null!; + protected override void OnInitialized() { EventTableColumnsState.Select(s => s.Columns); @@ -27,11 +28,25 @@ protected override void OnInitialized() base.OnInitialized(); } + private static void HandleActivationKey(KeyboardEventArgs args, Action action) + { + // Ignore auto-repeat so holding Enter/Space doesn't dispatch the action + // repeatedly while the key is held down. + if (args.Repeat) + { + return; + } + + if (args.Key is "Enter" or " ") + { + action(); + } + } + private void OrderColumn(ColumnName columnName) => Dispatcher.Dispatch(new EventTableAction.SetOrderBy(columnName)); - private void ToggleColumn(ColumnName columnName) - { + private void ResetDefaults() => Dispatcher.Dispatch(new EventTableAction.ResetColumnDefaults()); + + private void ToggleColumn(ColumnName columnName) => Dispatcher.Dispatch(new EventTableAction.ToggleColumn(columnName)); - Dispatcher.Dispatch(new EventTableAction.LoadColumns()); - } } diff --git a/src/EventLogExpert/Shared/Components/TableColumnMenu.razor.css b/src/EventLogExpert/Shared/Components/TableColumnMenu.razor.css index 2bbd8608..9bb1f0e0 100644 --- a/src/EventLogExpert/Shared/Components/TableColumnMenu.razor.css +++ b/src/EventLogExpert/Shared/Components/TableColumnMenu.razor.css @@ -54,8 +54,10 @@ border: 1px solid var(--clr-lightblue); background-color: var(--background-dark); + /* Hidden by default. visibility:hidden removes the items from the + tab order so keyboard focus can't land on invisible options. */ + visibility: hidden; opacity: 0; - pointer-events: none; & > li { width: 9em; @@ -70,8 +72,11 @@ } } - .sub-menu:hover > ul { + /* Reveal submenu on hover OR when keyboard focus is anywhere within it, + so keyboard users can open it by tabbing to the trigger. */ + .sub-menu:hover > ul, + .sub-menu:focus-within > ul { + visibility: visible; opacity: 1; - pointer-events: all; } } diff --git a/src/EventLogExpert/wwwroot/index.html b/src/EventLogExpert/wwwroot/index.html index bc3decdb..676b573c 100644 --- a/src/EventLogExpert/wwwroot/index.html +++ b/src/EventLogExpert/wwwroot/index.html @@ -24,6 +24,7 @@ + diff --git a/src/EventLogExpert/wwwroot/js/app.js b/src/EventLogExpert/wwwroot/js/app.js new file mode 100644 index 00000000..cc3adb6b --- /dev/null +++ b/src/EventLogExpert/wwwroot/js/app.js @@ -0,0 +1,29 @@ +// App-global keydown handlers. Loaded once via index.html and intentionally +// not tied to any component lifecycle, since the behavior they suppress is +// WebView-wide (F5/Ctrl+R reload) or applies to the always-present +// TableColumnMenu in MainLayout (Space-key default scroll). + +// Prevent F5 and Ctrl+R from refreshing the WebView +document.addEventListener("keydown", + function(e) { + if (e.key === "F5" || (e.ctrlKey && (e.key === "r" || e.key === "R"))) { + e.preventDefault(); + } + }, + true); + +// Prevent the browser's default Space-key scroll when activating a +// role="button"/menuitem inside the column menu (via keyboard). This runs +// natively alongside Blazor's @onkeydown so Tab navigation still works. +document.addEventListener("keydown", + function(e) { + if (e.key !== " ") { + return; + } + + const target = e.target; + if (target && target.closest && + target.closest('#table-column-menu [role="button"], #table-column-menu [role="menuitem"]')) { + e.preventDefault(); + } + }); diff --git a/src/EventLogExpert/wwwroot/js/details_pane.js b/src/EventLogExpert/wwwroot/js/details_pane.js index dad8a555..36544e19 100644 --- a/src/EventLogExpert/wwwroot/js/details_pane.js +++ b/src/EventLogExpert/wwwroot/js/details_pane.js @@ -1,31 +1,102 @@ -window.enableDetailsPaneResizer = () => { - const detailsPane = document.getElementById("details-pane"); - const resizer = document.getElementById("details-resizer"); +(() => { + const detailsPaneState = { + activeDocumentListeners: [], + controller: null, + dotNetRef: null + }; - if (detailsPane == null || resizer == null) { return; } + function trackDetailsDocumentListener(event, handler) { + const entry = { event, handler }; + const options = detailsPaneState.controller ? { signal: detailsPaneState.controller.signal } : undefined; + document.addEventListener(event, handler, options); + detailsPaneState.activeDocumentListeners.push(entry); - let y, h = 0; + return () => { + document.removeEventListener(event, handler); + const i = detailsPaneState.activeDocumentListeners.indexOf(entry); + if (i !== -1) { + detailsPaneState.activeDocumentListeners.splice(i, 1); + } + }; + } - const mouseMoveHandler = function(e) { - const distance = e.clientY - y; + window.enableDetailsPaneResizer = (dotNetRef, savedHeight) => { + window.disposeDetailsPaneResizer(); - detailsPane.style.height = `${h - distance}px`; - }; + const detailsPane = document.getElementById("details-pane"); + const resizer = document.getElementById("details-resizer"); - const mouseUpHandler = function() { - document.removeEventListener("mousemove", mouseMoveHandler); - document.removeEventListener("mouseup", mouseUpHandler); - }; + if (detailsPane == null || resizer == null) { + return; + } + + // Apply persisted height (if any) before user interaction. CSS supplies + // the default height when no saved value exists. Clamp so a height saved + // on a larger window can't overwhelm a smaller viewport on next launch. + if (savedHeight && savedHeight > 0) { + const maxHeight = Math.max(60, Math.floor(window.innerHeight * 0.8)); + detailsPane.style.height = `${Math.min(savedHeight, maxHeight)}px`; + } + + detailsPaneState.dotNetRef = dotNetRef; + detailsPaneState.controller = new AbortController(); + const signal = detailsPaneState.controller.signal; + + let y = 0; + let h = 0; + let untrackMove = null; + let untrackUp = null; + + const mouseMoveHandler = function(e) { + const distance = e.clientY - y; + // Match the column-resize minimum (avoids the pane vanishing entirely + // and being un-grabbable). CSS min-height still applies on top. + const newHeight = Math.max(30, h - distance); + + detailsPane.style.height = `${newHeight}px`; + }; - const mouseDownHandler = function(e) { - y = e.clientY; + const mouseUpHandler = function() { + if (untrackMove) { untrackMove(); untrackMove = null; } + if (untrackUp) { untrackUp(); untrackUp = null; } - const styles = window.getComputedStyle(detailsPane); - h = parseInt(styles.height, 10); + const ref = detailsPaneState.dotNetRef; - document.addEventListener("mousemove", mouseMoveHandler); - document.addEventListener("mouseup", mouseUpHandler); + if (ref && detailsPane.isConnected) { + const newHeight = parseInt(window.getComputedStyle(detailsPane).height, 10); + // Catch rejection in case the .NET object was disposed mid-drag. + ref.invokeMethodAsync("OnDetailsPaneHeightChanged", newHeight).catch(() => { }); + } + }; + + const mouseDownHandler = function(e) { + // Only respond to primary (left) button so right-click context menus + // and middle-click are not intercepted. + if (e.button !== 0) { return; } + + y = e.clientY; + + const styles = window.getComputedStyle(detailsPane); + h = parseInt(styles.height, 10); + + untrackMove = trackDetailsDocumentListener("mousemove", mouseMoveHandler); + untrackUp = trackDetailsDocumentListener("mouseup", mouseUpHandler); + }; + + resizer.addEventListener("mousedown", mouseDownHandler, { signal }); }; - resizer.addEventListener("mousedown", mouseDownHandler); -}; + window.disposeDetailsPaneResizer = () => { + if (detailsPaneState.controller) { + detailsPaneState.controller.abort(); + detailsPaneState.controller = null; + } + + for (const { event, handler } of detailsPaneState.activeDocumentListeners) { + document.removeEventListener(event, handler); + } + + detailsPaneState.activeDocumentListeners = []; + detailsPaneState.dotNetRef = null; + }; +})(); diff --git a/src/EventLogExpert/wwwroot/js/event_table.js b/src/EventLogExpert/wwwroot/js/event_table.js index 65a1dbb0..bfa202f0 100644 --- a/src/EventLogExpert/wwwroot/js/event_table.js +++ b/src/EventLogExpert/wwwroot/js/event_table.js @@ -1,114 +1,413 @@ -window.registerTableEvents = () => { - const table = document.getElementById("eventTable"); +(() => { + let activeDocumentListeners = []; + let controller = null; + let dotNetRef = null; + let keyboardResizeTimer = null; - if (!table) { return; } + // Attaches a document-level listener tied to the current AbortController + // signal so dispose() guarantees cleanup even if the caller forgets to + // untrack. Returns an untrack function that both removes the listener and + // drops the tracking entry to prevent unbounded growth across drags. + function trackDocumentListener(event, handler) { + const entry = { event, handler }; + const options = controller ? { signal: controller.signal } : undefined; + document.addEventListener(event, handler, options); + activeDocumentListeners.push(entry); - deleteColumnResize(table); - enableColumnResize(table); + return () => { + document.removeEventListener(event, handler); + const i = activeDocumentListeners.indexOf(entry); + if (i !== -1) { + activeDocumentListeners.splice(i, 1); + } + }; + } - registerKeyHandlers(table); -}; + function removeTrackedListeners() { + for (const { event, handler } of activeDocumentListeners) { + document.removeEventListener(event, handler); + } -window.deleteColumnResize = (table) => { - table.querySelectorAll(".table-divider").forEach(x => x.remove()); -}; + activeDocumentListeners = []; + } -window.enableColumnResize = (table) => { - const columns = table.querySelectorAll("th"); + window.initializeTableEvents = (ref) => { + window.disposeTableEvents(); - if (columns != null) { - const createResizableColumn = function(column) { - let x = 0; - let w = 0; + dotNetRef = ref; + controller = new AbortController(); + const signal = controller.signal; - const divider = document.createElement("div"); - divider.classList.add("table-divider"); + const table = document.getElementById("eventTable"); - column.appendChild(divider); - divider.tabIndex = 0; + if (!table) { + return; + } - const mouseMoveHandler = function(e) { - const distance = e.clientX - x; + enableColumnResize(table, signal); + enableColumnReorder(table, signal); + registerKeyHandlers(table, signal); + }; - column.style.width = `${w + distance}px`; - }; + window.disposeTableEvents = () => { + if (controller) { + controller.abort(); + controller = null; + } - const mouseUpHandler = function() { - document.removeEventListener("mousemove", mouseMoveHandler); - document.removeEventListener("mouseup", mouseUpHandler); + removeTrackedListeners(); - window.deleteColumnResize(table); - window.enableColumnResize(table); - }; + dotNetRef = null; - const mouseDownHandler = function(e) { - x = e.clientX; + if (keyboardResizeTimer) { + clearTimeout(keyboardResizeTimer); + keyboardResizeTimer = null; + } - const styles = window.getComputedStyle(column); - w = parseInt(styles.width, 10); + const table = document.getElementById("eventTable"); + if (table) { + table.querySelectorAll(".table-divider").forEach(x => x.remove()); + // Clear any in-progress drag styling so headers don't remain + // semi-transparent if dispose runs mid-drag. + table.querySelectorAll("th.dragging").forEach(x => x.classList.remove("dragging")); + } - document.addEventListener("mousemove", mouseMoveHandler); - document.addEventListener("mouseup", mouseUpHandler); - }; + // Drag indicators are appended to document.body, so clean them up there + // in case dispose runs mid-drag (before mouseup). + document.body.querySelectorAll(".drag-indicator").forEach(x => x.remove()); + }; - const keyboardResizeHandler = function (e) { - const styles = window.getComputedStyle(column); - w = parseInt(styles.width, 10); + window.refreshColumnResize = () => { + const table = document.getElementById("eventTable"); - if (e.key === "ArrowRight") { - column.style.width = `${w + 10}px`; - } else if (e.key === "ArrowLeft") { - column.style.width = `${w - 10}px`; - } - }; + if (!table || !controller) { + return; + } - divider.addEventListener("mousedown", mouseDownHandler); - divider.addEventListener("keydown", keyboardResizeHandler); - }; + table.querySelectorAll(".table-divider").forEach(x => x.remove()); + enableColumnResize(table, controller.signal); + }; + + function getColumnName(th) { + return th.getAttribute("data-column"); + } + + function enableColumnResize(table, signal) { + const columns = table.querySelectorAll("th[data-column]"); - for (let i = 0; i < columns.length - 1; i++) { - createResizableColumn(columns[i]); + for (const column of columns) { + createResizableColumn(table, column, signal); } } -}; -window.registerKeyHandlers = (table) => { - const selectAdjacentRow = function(direction) { - const tableRows = table.getElementsByTagName("tr"); - const focusedRow = document.activeElement; + function createResizableColumn(table, column, signal) { + let startX = 0; + let startW = 0; + let untrackMove = null; + let untrackUp = null; + + const divider = document.createElement("div"); + divider.classList.add("table-divider"); + column.appendChild(divider); + divider.tabIndex = 0; + + const mouseMoveHandler = function(e) { + const distance = e.clientX - startX; + const newWidth = Math.max(30, startW + distance); + column.style.width = `${newWidth}px`; + }; - if (focusedRow.tagName.toLowerCase() !== "tr") { return; } + const mouseUpHandler = function() { + if (untrackMove) { untrackMove(); untrackMove = null; } + if (untrackUp) { untrackUp(); untrackUp = null; } - for (let i = 0; i < tableRows.length; i++) { - if (tableRows[i] === focusedRow) { - tableRows[i + direction].focus(); + const colName = getColumnName(column); + const newWidth = parseInt(window.getComputedStyle(column).width, 10); - break; + if (dotNetRef && colName) { + // Catch rejection in case the .NET object was disposed mid-drag + // (component teardown, column set change, etc.). + dotNetRef.invokeMethodAsync("OnColumnResized", colName, newWidth).catch(() => { }); + } + + // Rebuild dividers after resize + window.refreshColumnResize(); + }; + + const mouseDownHandler = function(e) { + if (e.button !== 0) { + return; } - } - }; - const keyDownHandler = function(e) { - if (e.key === "ArrowUp") { + e.stopPropagation(); + startX = e.clientX; + startW = parseInt(window.getComputedStyle(column).width, 10); + + untrackMove = trackDocumentListener("mousemove", mouseMoveHandler); + untrackUp = trackDocumentListener("mouseup", mouseUpHandler); + }; + + const keyboardResizeHandler = function(e) { + const w = parseInt(window.getComputedStyle(column).width, 10); + + if (e.key === "ArrowRight") { + column.style.width = `${w + 10}px`; + } else if (e.key === "ArrowLeft") { + column.style.width = `${Math.max(30, w - 10)}px`; + } else { + return; + } + + // Suppress the WebView's default arrow-key scroll while a focused + // divider is being keyboard-resized. e.preventDefault(); - selectAdjacentRow(-1); + e.stopPropagation(); + + // Debounce keyboard resize persistence + if (keyboardResizeTimer) { + clearTimeout(keyboardResizeTimer); + } + + keyboardResizeTimer = setTimeout(() => { + const colName = getColumnName(column); + const newWidth = parseInt(window.getComputedStyle(column).width, 10); + + if (dotNetRef && colName) { + dotNetRef.invokeMethodAsync("OnColumnResized", colName, newWidth).catch(() => { }); + } + + keyboardResizeTimer = null; + }, + 300); + }; + + divider.addEventListener("mousedown", mouseDownHandler, { signal }); + divider.addEventListener("keydown", keyboardResizeHandler, { signal }); + } + + function enableColumnReorder(table, signal) { + const headerRow = table.querySelector("thead tr"); + + if (!headerRow) { + return; } - if (e.key === "ArrowDown") { - e.preventDefault(); - selectAdjacentRow(+1); + let dragSource = null; + let dragIndicator = null; + let pendingTarget = null; + let pendingInsertAfter = false; + + // Event delegation: single listener on the header row handles all columns. + // This survives Blazor DOM updates that may recreate th elements. + headerRow.addEventListener("mousedown", + function(e) { + // Only start drag on primary (left) button so right-click for + // the column context menu isn't intercepted. + if (e.button !== 0) { + return; + } + + if (e.target.classList.contains("table-divider") || + e.target.closest(".menu-toggle")) { + return; + } + + const th = e.target.closest("th[data-column]"); + + if (!th) { + return; + } + + dragSource = th; + + const startX = e.clientX; + let hasMoved = false; + let untrackMove = null; + let untrackUp = null; + pendingTarget = null; + + const moveHandler = function(e) { + const distance = Math.abs(e.clientX - startX); + + if (distance < 5) { + return; + } + + if (!hasMoved) { + hasMoved = true; + dragSource.classList.add("dragging"); + } + + const allHeaders = Array.from(headerRow.querySelectorAll("th[data-column]")); + const sourceIndex = allHeaders.indexOf(dragSource); + const drop = computeDropInfo(allHeaders, e.clientX, e.clientY, sourceIndex); + + if (drop) { + removeIndicator(); + pendingTarget = drop.targetColumn; + pendingInsertAfter = drop.insertAfter; + + dragIndicator = document.createElement("div"); + dragIndicator.classList.add("drag-indicator"); + + const refRect = drop.refRect; + + dragIndicator.style.position = "fixed"; + dragIndicator.style.top = `${refRect.top}px`; + dragIndicator.style.height = `${refRect.height}px`; + dragIndicator.style.width = "2px"; + dragIndicator.style.backgroundColor = "var(--clr-lightblue)"; + dragIndicator.style.zIndex = "100"; + dragIndicator.style.pointerEvents = "none"; + dragIndicator.style.left = `${drop.indicatorX}px`; + + document.body.appendChild(dragIndicator); + } else { + // Either explicit cancel (drop === false, cursor over + // source) or no header under cursor (drop === null, + // e.g. dragged into the table body). Clear any stale + // pendingTarget/indicator so mouseup doesn't act on it. + removeIndicator(); + pendingTarget = null; + } + }; + + const upHandler = function() { + if (untrackMove) { untrackMove(); untrackMove = null; } + if (untrackUp) { untrackUp(); untrackUp = null; } + + if (dragSource) { + dragSource.classList.remove("dragging"); + } + + removeIndicator(); + + if (hasMoved && dragSource && pendingTarget) { + const sourceColName = getColumnName(dragSource); + + if (dotNetRef && sourceColName) { + // Catch rejection in case the .NET object was disposed + // mid-drag (component teardown, column set change, etc.). + dotNetRef.invokeMethodAsync("OnColumnReordered", sourceColName, pendingTarget, pendingInsertAfter).catch(() => { }); + } + } + + dragSource = null; + pendingTarget = null; + }; + + untrackMove = trackDocumentListener("mousemove", moveHandler); + untrackUp = trackDocumentListener("mouseup", upHandler); + }, + { signal }); + + // Returns: + // { targetColumn, insertAfter, indicatorX, refRect } — valid drop position + // false — cursor is over the source column (cancel) + // null — no column found under cursor + function computeDropInfo(allHeaders, clientX, clientY, sourceIndex) { + let targetIndex = -1; + + const el = document.elementFromPoint(clientX, clientY); + + if (el) { + const th = el.closest("th[data-column]"); + + if (th) { + targetIndex = allHeaders.indexOf(th); + } + } + + if (targetIndex === -1) { + const firstRect = allHeaders[0].getBoundingClientRect(); + const lastRect = allHeaders[allHeaders.length - 1].getBoundingClientRect(); + + if (clientX < firstRect.left) { + targetIndex = 0; + } else if (clientX > lastRect.right) { + targetIndex = allHeaders.length - 1; + } else { + return null; + } + } + + if (targetIndex === sourceIndex) { + return false; + } + + const targetTh = allHeaders[targetIndex]; + const targetColumn = getColumnName(targetTh); + const targetRect = targetTh.getBoundingClientRect(); + const insertAfter = targetIndex > sourceIndex; + + // Indicator at the target's far edge (away from source). Drop inserts + // the source column on that same far side of target. + const indicatorX = insertAfter ? targetRect.right : targetRect.left; + + return { targetColumn, insertAfter, indicatorX, refRect: targetRect }; } - }; - table.addEventListener("keydown", keyDownHandler); -}; + function removeIndicator() { + if (dragIndicator) { + dragIndicator.remove(); + dragIndicator = null; + } + } + } + + function registerKeyHandlers(table, signal) { + const selectAdjacentRow = function(direction) { + const tableRows = table.getElementsByTagName("tr"); + const focusedRow = document.activeElement; + + if (focusedRow.tagName.toLowerCase() !== "tr") { + return; + } + + for (let i = 0; i < tableRows.length; i++) { + if (tableRows[i] === focusedRow) { + const next = tableRows[i + direction]; -window.scrollToRow = (offset) => { - const table = document.getElementById("eventTable"); - const row = table.getElementsByTagName("tr")[0]; + if (next) { + next.focus(); + } - table.parentNode.scrollTo({ - top: row.offsetHeight * offset - (table.parentNode.offsetHeight / 3), - behavior: "smooth" - }); -}; + break; + } + } + }; + + table.addEventListener("keydown", + function(e) { + if (e.key === "ArrowUp") { + e.preventDefault(); + selectAdjacentRow(-1); + } + + if (e.key === "ArrowDown") { + e.preventDefault(); + selectAdjacentRow(+1); + } + }, + { signal }); + } + + window.scrollToRow = (offset) => { + const table = document.getElementById("eventTable"); + + if (!table) { + return; + } + + const row = table.getElementsByTagName("tr")[0]; + + if (!row) { + return; + } + + table.parentNode.scrollTo({ + top: row.offsetHeight * offset - (table.parentNode.offsetHeight / 3), + behavior: "smooth" + }); + }; +})();