From 6a80f9efd70f82437ce35de47a0bf89aa2c57357 Mon Sep 17 00:00:00 2001 From: jschick04 Date: Fri, 29 May 2026 07:50:32 -0500 Subject: [PATCH 1/5] Changed how removing databases is handled and added multi select for removal --- .../Database/DatabaseEntryRow.razor | 32 +- .../Database/DatabaseEntryRow.razor.cs | 59 ++- .../Database/DatabaseEntryRow.razor.css | 103 ++--- .../Tabs/ManageDatabasesTab.razor | 26 ++ .../Tabs/ManageDatabasesTab.razor.cs | 412 ++++++++++++++++-- .../Tabs/ManageDatabasesTab.razor.css | 34 ++ .../wwwroot/Database/DatabaseEntryRow.js | 14 + .../Database/DatabaseEntryRowTests.cs | 166 ++++--- .../Tabs/FakeInlineAlertSurface.cs | 22 + .../Tabs/ManageDatabasesTabTests.cs | 267 ++++++++++++ 10 files changed, 964 insertions(+), 171 deletions(-) create mode 100644 src/EventLogExpert.UI/wwwroot/Database/DatabaseEntryRow.js create mode 100644 tests/Unit/EventLogExpert.UI.Tests/DatabaseTools/Tabs/FakeInlineAlertSurface.cs diff --git a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor index f66c4e10..32a76d22 100644 --- a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor +++ b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor @@ -1,8 +1,18 @@ -
+
+ +
- -
diff --git a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.cs b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.cs index 0527e17d..d930b9fb 100644 --- a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.cs +++ b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.cs @@ -6,17 +6,21 @@ using EventLogExpert.Runtime.Database; using EventLogExpert.Runtime.Database.Upgrade; using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; using Microsoft.JSInterop; namespace EventLogExpert.UI.Database; -public sealed partial class DatabaseEntryRow : ComponentBase +public sealed partial class DatabaseEntryRow : ComponentBase, IAsyncDisposable { private readonly string _nameButtonId = $"db-row-{Guid.NewGuid():N}-name"; private readonly string _pendingStatusId = $"db-row-{Guid.NewGuid():N}-pending"; - private bool _isMouseRevealed; + private ElementReference _checkboxRef; + private bool _disposed; + private IJSObjectReference? _jsModule; private ElementReference _nameButtonRef; + private ElementReference _removeButtonRef; private bool _shouldFocusNameAfterRender; private enum ActionKind @@ -37,6 +41,8 @@ private enum ActionKind [Parameter] public bool IsClassificationPending { get; set; } + [Parameter] public bool IsSelected { get; set; } + [Parameter] public bool IsTogglePending { get; set; } [Parameter] public bool IsUpgradeBlocked { get; set; } @@ -49,6 +55,8 @@ private enum ActionKind [Parameter] public EventCallback OnRetryClassification { get; set; } + [Parameter] public EventCallback OnSelectionToggle { get; set; } + [Parameter] public EventCallback OnToggle { get; set; } [Parameter] public EventCallback OnUpgrade { get; set; } @@ -59,7 +67,7 @@ private enum ActionKind private string BadgeLabel => DatabaseStatusLabels.GetRowBadgeLabel(Entry); - private bool IsRemoveBlocked => IsUpgrading || UpgradeProgress is not null; + [Inject] private IJSRuntime JSRuntime { get; init; } = null!; private bool IsRestoreBlocked => IsUpgradeBlocked || IsUpgrading || UpgradeProgress is not null; @@ -96,8 +104,37 @@ UpgradeProgress is null && [Inject] private ITraceLogger TraceLogger { get; init; } = null!; + public async ValueTask DisposeAsync() + { + if (_disposed) { return; } + _disposed = true; + + if (_jsModule is not null) + { + try { await _jsModule.DisposeAsync(); } + catch (JSDisconnectedException) { } + catch (ObjectDisposedException) { } + _jsModule = null; + } + } + + public ValueTask FocusRemoveButtonAsync() => _removeButtonRef.FocusAsync(preventScroll: true); + protected override async Task OnAfterRenderAsync(bool firstRender) { + if (firstRender) + { + try + { + _jsModule = await JSRuntime.InvokeAsync( + "import", + "./_content/EventLogExpert.UI/Database/DatabaseEntryRow.js"); + await _jsModule.InvokeVoidAsync("attachCheckboxKeyHandler", _checkboxRef); + } + catch (JSDisconnectedException) { } + catch (ObjectDisposedException) { } + } + if (!_shouldFocusNameAfterRender) { return; } _shouldFocusNameAfterRender = false; @@ -115,9 +152,15 @@ protected override async Task OnAfterRenderAsync(bool firstRender) _ => "Upgrading" }; - private void HandleNameClick() => _isMouseRevealed = true; + private async Task HandleCheckboxClick() => await OnSelectionToggle.InvokeAsync(); - private void HandleRowMouseLeave() => _isMouseRevealed = false; + private async Task HandleCheckboxKeyDown(KeyboardEventArgs e) + { + if (e.Key is " " or "Spacebar" or "Enter") + { + await OnSelectionToggle.InvokeAsync(); + } + } private void OnCancelClick() { @@ -132,9 +175,5 @@ private void OnCancelClick() } } - private async Task OnRemoveClick() - { - if (IsRemoveBlocked) { return; } - await OnRemove.InvokeAsync(); - } + private async Task OnRemoveClick() => await OnRemove.InvokeAsync(); } diff --git a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.css b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.css index 8b10b79c..56890ede 100644 --- a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.css +++ b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.css @@ -1,46 +1,56 @@ .db-entry-row { - --db-entry-trash-reveal: 2.5rem; + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: .5rem; - position: relative; - overflow: hidden; + padding: .25rem .5rem; + background-color: var(--background-dark); + + border-left: 3px solid transparent; + + transition: background-color 180ms ease, border-left-color 180ms ease; } -.db-entry-row-content { - position: relative; +.db-entry-row--selected { + border-left-color: var(--clr-lightblue); +} - display: grid; - grid-template-columns: 1fr auto; +.db-entry-checkbox { + display: inline-flex; align-items: center; - gap: 1rem; + justify-content: center; + min-width: 1.75rem; + min-height: 1.75rem; + padding: .25rem; - /* DOM order is now name-info → actions → trash-remove (last). The trash button is a - LATER sibling than .db-entry-row-content but must paint UNDER it at rest. z-index:1 - promotes content into painting group 7 above trash's group 6 (where z-index:0/auto - siblings paint in tree order). Without this, the later-in-DOM trash would obscure - the leftmost 2.5rem of the row at rest. Background fill remains required to cover - the absolutely-positioned trash strip underneath; z-index alone wouldn't be enough - if the content were transparent. */ - padding: .25rem .5rem .25rem 0; - background-color: var(--background-dark); - z-index: 1; + cursor: pointer; + color: var(--text-secondary); + + transition: color 150ms ease; +} + +.db-entry-checkbox:focus-visible { + outline: 2px solid var(--clr-lightblue); + outline-offset: 2px; + border-radius: 2px; +} - transition: transform 200ms ease; +.db-entry-checkbox[aria-checked="true"] { + color: var(--clr-lightblue); +} + +.db-entry-checkbox i { + font-size: 1rem; } -/* Reveal logic: - - Mouse: clicking the name button sets the .db-entry-row--revealed flag in - code-behind, which keeps the slide open until the cursor leaves the row - (@onmouseleave clears the flag). Re-entering the row without re-clicking - does NOT re-open the slide, even if the name button still has DOM focus. - - Keyboard: :focus-visible on the name button (matched only when focus - came from a key press, not a mouse click) opens the slide independently - of hover/state, so keyboard users can see what's there. - - Trash :focus keeps the strip open during the click (mouse focus or - keyboard focus), so the click reliably lands. */ -.db-entry-row.db-entry-row--revealed .db-entry-row-content, -.db-entry-row:has(.db-entry-name:focus-visible) .db-entry-row-content, -.db-entry-row:has(.db-entry-remove-btn:focus) .db-entry-row-content { - transform: translateX(var(--db-entry-trash-reveal)); +.db-entry-row-content { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 1rem; + + padding: .25rem 0; } .db-entry-info { @@ -48,8 +58,6 @@ align-items: center; gap: .5rem; min-width: 0; - - padding-left: .5rem; } .db-entry-name { @@ -155,34 +163,15 @@ } .db-entry-remove-btn { - position: absolute; - left: 0; - top: 0; - bottom: 0; - width: var(--db-entry-trash-reveal); - + flex: 0 0 auto; display: flex; align-items: center; justify-content: center; - - /* Recessed-well treatment: darker fill plus a soft inner shadow makes the - exposed trash strip read as a depression cut into the row, rather than - a button floating on top. */ - background-color: var(--surface-inset); - box-shadow: inset 0 0 .35rem rgba(0, 0, 0, .55); -} - -@media (hover: none) { - .db-entry-row-content { - transform: translateX(var(--db-entry-trash-reveal)); - } } @media (prefers-reduced-motion: reduce) { - .db-entry-row-content { - transition: none; - } - + .db-entry-row, + .db-entry-checkbox, .db-entry-upgrading, .db-entry-upgrading-text, .db-entry-cancel-btn { diff --git a/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor b/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor index 87c6b4ec..37817105 100644 --- a/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor +++ b/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor @@ -9,10 +9,13 @@
} + @_selectionAnnouncement +
@@ -26,6 +29,7 @@ } } @@ -44,6 +50,26 @@ }
+ @if (HasSelectedForRemoval) + { +
+ + @_selectedForRemoval.Count selected + + + +
+ } + @if (HasPendingChanges) {
diff --git a/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor.cs b/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor.cs index 97c1950a..22e4c1ce 100644 --- a/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor.cs +++ b/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor.cs @@ -7,21 +7,38 @@ using EventLogExpert.Runtime.Banner; using EventLogExpert.Runtime.Database; using EventLogExpert.Runtime.Database.Upgrade; +using EventLogExpert.UI.Database; using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; using System.Collections.Immutable; namespace EventLogExpert.UI.DatabaseTools.Tabs; public sealed partial class ManageDatabasesTab : ComponentBase, IAsyncDisposable { + private static readonly TimeSpan s_cancelTimeout = TimeSpan.FromSeconds(30); + private readonly Dictionary _pendingToggles = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _removeButtonRefs = new(StringComparer.OrdinalIgnoreCase); private readonly string _saveBlockedHelpId = $"manage-save-blocked-{Guid.NewGuid():N}"; + private readonly HashSet _selectedForRemoval = new(StringComparer.OrdinalIgnoreCase); + private ElementReference _bulkRemoveButtonRef; private CancellationTokenSource? _classificationObservationCts; private volatile bool _disposed; + private (string FileName, FocusTarget Target)? _focusRestorationTarget; + private ElementReference _importButtonRef; private ImmutableHashSet _initialActiveSnapshot = ImmutableHashSet.Empty; private bool _restorationOccurred; private bool _schemaUpgradeOccurred; + private string _selectionAnnouncement = string.Empty; + + private enum FocusTarget + { + SameRowRemove, + BulkRemoveButton, + ImportButton + } public bool HasDatabaseStateChanged { @@ -35,6 +52,8 @@ public bool HasDatabaseStateChanged public bool HasPendingChanges => _pendingToggles.Count > 0; + public bool HasSelectedForRemoval => _selectedForRemoval.Count > 0; + public bool IsUpgradeInFlight => Coordinator.IsAnyUpgradeInFlight; [CascadingParameter] internal IInlineAlertSurface? AlertSurface { get; set; } @@ -108,6 +127,33 @@ internal async Task ApplyPendingTogglesAsync() return true; } + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (_focusRestorationTarget is { } target) + { + _focusRestorationTarget = null; + + try + { + await (target.Target switch + { + FocusTarget.SameRowRemove => + _removeButtonRefs.GetValueOrDefault(target.FileName)?.FocusRemoveButtonAsync() ?? _importButtonRef.FocusAsync(preventScroll: true), + FocusTarget.BulkRemoveButton when HasSelectedForRemoval => + _bulkRemoveButtonRef.FocusAsync(preventScroll: true), + FocusTarget.ImportButton => + _importButtonRef.FocusAsync(preventScroll: true), + _ => ValueTask.CompletedTask + }); + } + catch (ObjectDisposedException) { } + catch (JSDisconnectedException) { } + catch (JSException) { } + } + + await base.OnAfterRenderAsync(firstRender); + } + protected override void OnInitialized() { _initialActiveSnapshot = ComputeActiveSet(); @@ -126,6 +172,31 @@ protected override void OnInitialized() base.OnInitialized(); } + private static string BuildCancelThenRemoveMessage(IReadOnlyList fileNames, IReadOnlyList upgradingFiles) + { + var upgradingList = string.Join(", ", upgradingFiles.Take(5)); + var moreUpgrading = upgradingFiles.Count > 5 ? $", and {upgradingFiles.Count - 5} more" : string.Empty; + var fileList = string.Join(", ", fileNames.Take(5)); + var moreFiles = fileNames.Count > 5 ? $", and {fileNames.Count - 5} more" : string.Empty; + + return $"Upgrade in progress for: {upgradingList}{moreUpgrading}. " + + $"This will cancel the upgrade batch(es) \u2014 which may include other files not in your selection \u2014 " + + $"and then remove: {fileList}{moreFiles}. Are you sure?"; + } + + private static string BuildPlainRemoveMessage(IReadOnlyList fileNames) + { + if (fileNames.Count == 1) + { + return $"Are you sure you want to remove {fileNames[0]}?"; + } + + var list = string.Join(", ", fileNames.Take(5)); + var more = fileNames.Count > 5 ? $", and {fileNames.Count - 5} more" : string.Empty; + + return $"Are you sure you want to remove these {fileNames.Count} databases? ({list}{more})"; + } + private async Task AskOverwriteAsync(string fileName, CancellationToken cancellationToken) { if (_disposed || AlertSurface is null) { return false; } @@ -147,12 +218,186 @@ private async Task AskOverwriteAsync(string fileName, CancellationToken ca catch (ObjectDisposedException) { return false; } } + private async Task CancelUpgradesAndAwaitCompletionAsync(IReadOnlyList fileNames) + { + var batchesToCancel = new Dictionary(); + var batchToFiles = new Dictionary>(); + var entries = DatabaseService.Entries.ToList(); + + foreach (var fileName in fileNames) + { + var entry = entries.FirstOrDefault( + e => string.Equals(e.FileName, fileName, StringComparison.OrdinalIgnoreCase)); + + if (entry is null) { continue; } + + var progress = GetUpgradeProgressForEntry(entry); + + if (progress is null) { continue; } + + batchesToCancel.TryAdd(progress.BatchId, progress.Cancel); + + if (!batchToFiles.TryGetValue(progress.BatchId, out var files)) + { + files = []; + batchToFiles[progress.BatchId] = files; + } + + files.Add(fileName); + } + + bool coordinatorTrackedAny = fileNames.Any(f => Coordinator.IsUpgradeInFlight(f)); + + if (batchesToCancel.Count == 0 && !coordinatorTrackedAny) { return; } + + var pendingBatches = new Dictionary(); + + foreach (var batchId in batchesToCancel.Keys) + { + pendingBatches[batchId] = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + } + + var coordinatorSettleTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void CompletionHandler(object? sender, UpgradeBatchCompletedEventArgs args) + { + if (pendingBatches.TryGetValue(args.BatchId, out var tcs)) + { + tcs.TrySetResult(); + } + } + + void StateChangedHandler() + { + if (!fileNames.Any(f => Coordinator.IsUpgradeInFlight(f))) + { + coordinatorSettleTcs.TrySetResult(); + } + } + + DatabaseService.UpgradeBatchCompleted += CompletionHandler; + Coordinator.UpgradeStateChanged += StateChangedHandler; + + try + { + foreach (var (batchId, files) in batchToFiles) + { + bool stillAlive = files.Any(file => + { + var entry = DatabaseService.Entries.FirstOrDefault( + e => string.Equals(e.FileName, file, StringComparison.OrdinalIgnoreCase)); + return Coordinator.IsUpgradeInFlight(file) || + (entry is not null && GetUpgradeProgressForEntry(entry) is not null); + }); + if (!stillAlive) { pendingBatches[batchId].TrySetResult(); } + } + + StateChangedHandler(); + + foreach (var cancel in batchesToCancel.Values) + { + try { cancel(); } + catch (Exception ex) when (ex is not OperationCanceledException) + { + TraceLogger.Warning( + $"{nameof(ManageDatabasesTab)}.{nameof(CancelUpgradesAndAwaitCompletionAsync)}: cancel threw: {ex}"); + } + } + + using var timeoutCts = new CancellationTokenSource(s_cancelTimeout); + + var batchTasks = pendingBatches.Values.Select(tcs => tcs.Task); + var allTasks = batchTasks.Append(coordinatorSettleTcs.Task).ToArray(); + + try + { + await Task.WhenAll(allTasks).WaitAsync(timeoutCts.Token); + } + catch (OperationCanceledException) + { + TraceLogger.Warning( + $"{nameof(ManageDatabasesTab)}.{nameof(CancelUpgradesAndAwaitCompletionAsync)}: timeout waiting for upgrade cancellation; proceeding anyway."); + } + } + finally + { + DatabaseService.UpgradeBatchCompleted -= CompletionHandler; + Coordinator.UpgradeStateChanged -= StateChangedHandler; + } + } + + private void ClearSelection() + { + if (_selectedForRemoval.Count == 0) { return; } + _selectedForRemoval.Clear(); + UpdateSelectionAnnouncement(); + } + private ImmutableHashSet ComputeActiveSet() => DatabaseService.Entries .Where(entry => entry is { IsEnabled: true, Status: DatabaseStatus.Ready }) .Select(entry => entry.FileName) .ToImmutableHashSet(StringComparer.OrdinalIgnoreCase); + private async Task ConfirmAndRemoveAsync( + IReadOnlyList fileNames, + FocusTarget cancelTarget, + FocusTarget completeTarget) + { + if (_disposed || AlertSurface is null || fileNames.Count == 0) { return; } + + var validFileNames = fileNames + .Where(f => DatabaseService.Entries.Any(e => + string.Equals(e.FileName, f, StringComparison.OrdinalIgnoreCase))) + .ToList(); + if (validFileNames.Count == 0) { return; } + + bool requiresCancelFirst = IsAnyFileUpgrading(validFileNames, out var upgradingFiles); + + string title = validFileNames.Count == 1 + ? "Remove Database" + : $"Remove {validFileNames.Count} Databases"; + + string acceptLabel = requiresCancelFirst + ? $"Cancel {upgradingFiles.Count} upgrade{(upgradingFiles.Count == 1 ? "" : "s")} and remove {validFileNames.Count} database{(validFileNames.Count == 1 ? "" : "s")}" + : "Remove"; + + string message = requiresCancelFirst + ? BuildCancelThenRemoveMessage(validFileNames, upgradingFiles) + : BuildPlainRemoveMessage(validFileNames); + + InlineAlertResult result; + try + { + result = await AlertSurface.ShowInlineAlertAsync( + new InlineAlertRequest( + Title: title, + Message: message, + AcceptLabel: acceptLabel, + CancelLabel: "Cancel", + IsPrompt: false, + PromptInitialValue: null), + CancellationToken.None); + } + catch (ObjectDisposedException) { return; } + + if (!result.Accepted) + { + _focusRestorationTarget = (validFileNames[0], cancelTarget); + return; + } + if (_disposed) { return; } + + if (requiresCancelFirst) + { + await CancelUpgradesAndAwaitCompletionAsync(upgradingFiles); + if (_disposed) { return; } + } + + await RemoveDatabasesAsync(validFileNames, completeTarget); + } + private void ConsumeReopenedAsBaseline() { _initialActiveSnapshot = ComputeActiveSet(); @@ -208,6 +453,29 @@ private async Task InvokeAsyncSafe() catch (ObjectDisposedException) { } } + private bool IsAnyFileUpgrading(IReadOnlyCollection fileNames, out IReadOnlyList upgradingFiles) + { + var upgrading = new List(); + var entries = DatabaseService.Entries.ToList(); + + foreach (var fileName in fileNames) + { + var entry = entries.FirstOrDefault( + e => string.Equals(e.FileName, fileName, StringComparison.OrdinalIgnoreCase)); + + if (entry is null) { continue; } + + bool coordinatorSays = Coordinator.IsUpgradeInFlight(fileName); + bool bannerSays = GetUpgradeProgressForEntry(entry) is not null; + + if (coordinatorSays || bannerSays) { upgrading.Add(fileName); } + } + + upgradingFiles = upgrading; + + return upgrading.Count > 0; + } + private async Task ObserveClassificationCompletionAsync(CancellationToken cancellationToken) { try @@ -226,24 +494,52 @@ await DatabaseService.InitialClassificationTask if (_disposed) { return; } _initialActiveSnapshot = ComputeActiveSet(); + await InvokeAsyncSafe(); } private void OnBannerStateChanged() { if (_disposed) { return; } + _ = InvokeAsyncSafe(); } + private async Task OnBulkRemoveClickAsync() + { + var snapshot = _selectedForRemoval.ToArray(); + + await ConfirmAndRemoveAsync(snapshot, FocusTarget.BulkRemoveButton, FocusTarget.SameRowRemove); + } + private void OnCoordinatorStateChanged() { if (_disposed) { return; } + _ = InvokeAsyncSafe(); } private void OnDatabaseEntriesChanged(object? sender, EventArgs e) { if (_disposed) { return; } + + var currentNames = new HashSet( + DatabaseService.Entries.Select(entry => entry.FileName), + StringComparer.OrdinalIgnoreCase); + + var orphans = _selectedForRemoval.Where(name => !currentNames.Contains(name)).ToArray(); + + foreach (var orphan in orphans) { _selectedForRemoval.Remove(orphan); } + + if (orphans.Length > 0) { UpdateSelectionAnnouncement(); } + + var deadRefs = _removeButtonRefs + .Where(kvp => !currentNames.Contains(kvp.Key) || kvp.Value is null) + .Select(kvp => kvp.Key) + .ToArray(); + + foreach (var key in deadRefs) { _removeButtonRefs.Remove(key); } + _ = InvokeAsyncSafe(); } @@ -251,8 +547,6 @@ private async Task OnSaveClickAsync() { if (IsUpgradeBlocked) { - // aria-live="polite" may not re-announce identical text on repeated blocked clicks; - // force an explicit announcement to guarantee SR feedback on every attempt. AnnouncementService.Announce("Cannot save: a database upgrade is in progress."); return; @@ -265,8 +559,6 @@ private void OnUpgradeBatchCompleted(object? sender, UpgradeBatchCompletedEventA { if (_disposed) { return; } - // Only set the sticky flag when an upgraded file is currently in the active set; background - // upgrades of disabled/non-Ready DBs don't affect open-log resolution and shouldn't prompt. if (args.Result.Succeeded.Count > 0) { var activeNow = ComputeActiveSet(); @@ -280,54 +572,88 @@ private void OnUpgradeBatchCompleted(object? sender, UpgradeBatchCompletedEventA _ = InvokeAsyncSafe(); } - private async Task RemoveDatabase(DatabaseEntry entry) + private async Task RemoveDatabase(DatabaseEntry entry) => + await ConfirmAndRemoveAsync([entry.FileName], FocusTarget.SameRowRemove, FocusTarget.SameRowRemove); + + private async Task RemoveDatabasesAsync(IReadOnlyList fileNames, FocusTarget completeTarget) { - var fileName = entry.FileName; + var preRemovalEntries = DatabaseService.Entries.ToList(); + + int anchorIdx = fileNames + .Select(f => preRemovalEntries.FindIndex(e => + string.Equals(e.FileName, f, StringComparison.OrdinalIgnoreCase))) + .Where(i => i >= 0) + .DefaultIfEmpty(-1) + .Min(); - var outcome = await Coordinator.RemoveDatabaseAsync( - fileName, - async (showCloseReopenWarning, cancellationToken) => + var succeeded = new List(); + var failed = new List<(string FileName, string Reason)>(); + bool anyLogsReopened = false; + + foreach (var fileName in fileNames) + { + try { - if (_disposed || AlertSurface is null) { return false; } + var outcome = await Coordinator.RemoveDatabaseAsync( + fileName, + static (_, _) => Task.FromResult(true)); - var message = showCloseReopenWarning - ? $"{fileName} is currently enabled. Removing will close and reopen any affected log views. Are you sure?" - : $"Are you sure you want to remove {fileName}?"; + if (outcome.Confirmed) + { + succeeded.Add(fileName); + _pendingToggles.Remove(fileName); - try + if (outcome.LogsReopened) { anyLogsReopened = true; } + } + else { - var result = await AlertSurface.ShowInlineAlertAsync( - new InlineAlertRequest( - Title: "Remove Database", - Message: message, - AcceptLabel: "Remove", - CancelLabel: "Cancel", - IsPrompt: false, - PromptInitialValue: null), - cancellationToken); - - return result.Accepted; + failed.Add((fileName, "removal failed")); } - catch (ObjectDisposedException) { return false; } - }); - - if (_disposed) { return; } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + failed.Add((fileName, ex.Message)); + } + } - if (outcome.Confirmed) { _pendingToggles.Remove(fileName); } + _selectedForRemoval.Clear(); + UpdateSelectionAnnouncement(); - if (outcome.LogsReopened) + if (anyLogsReopened) { ConsumeReopenedAsBaseline(); } + + if (succeeded.Count > 0) + { + AnnouncementService.Announce( + $"Removed {succeeded.Count} database{(succeeded.Count == 1 ? "" : "s")}."); + } + + if (failed.Count > 0) + { + AnnouncementService.Announce( + $"{failed.Count} removal{(failed.Count == 1 ? "" : "s")} failed."); + } + + var remainingEntries = DatabaseService.Entries.ToList(); + + if (remainingEntries.Count == 0) + { + _focusRestorationTarget = (string.Empty, FocusTarget.ImportButton); + } + else + { + int clampedIdx = Math.Clamp(anchorIdx, 0, remainingEntries.Count - 1); + string anchorFileName = remainingEntries[clampedIdx].FileName; + _focusRestorationTarget = (anchorFileName, completeTarget); + } } private async Task RestoreFromBackup(DatabaseEntry entry) { if (_disposed) { return; } - // DatabaseRecoveryService.RestoreFromBackupAsync runs a full ClassifyEntriesAsync pass, whose - // ProbeOrCleanupBackup deletes .upgrade.bak files for Ready entries — including a backup an - // in-flight upgrade still needs for rollback/verify. Block at the handler as well as the button. if (IsUpgradeBlocked) { AnnouncementService.Announce("Cannot restore: a database upgrade is in progress."); @@ -336,6 +662,7 @@ private async Task RestoreFromBackup(DatabaseEntry entry) } bool restored; + try { restored = await DatabaseService.RestoreFromBackupAsync(entry.FileName, CancellationToken.None); @@ -396,6 +723,23 @@ private void ToggleDatabase(string fileName) } } + private void ToggleSelection(string fileName) + { + bool added = _selectedForRemoval.Add(fileName); + + if (!added) { _selectedForRemoval.Remove(fileName); } + + UpdateSelectionAnnouncement(); + } + + private void UpdateSelectionAnnouncement() + { + int count = _selectedForRemoval.Count; + _selectionAnnouncement = count == 0 + ? "Selection cleared." + : $"{count} database{(count == 1 ? "" : "s")} selected."; + } + private async Task UpgradeEntry(string fileName) { await Coordinator.UpgradeDatabaseAsync(fileName); diff --git a/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor.css b/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor.css index dee5e65c..6c1ed51f 100644 --- a/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor.css +++ b/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor.css @@ -41,6 +41,40 @@ flex: 1 1 auto; } +.manage-databases-bulk-strip { + flex: none; + position: sticky; + bottom: 0; + z-index: 1; + + display: flex; + gap: .5rem; + align-items: center; + padding: .5rem 1rem; + + background-color: var(--clr-statusbar); + color: var(--text-on-statusbar); + box-shadow: 0 -1px 0 var(--clr-border); + + animation: manage-databases-bulk-strip-fade-in 200ms ease-out; +} + +.manage-databases-bulk-count { + flex: 1 1 auto; + font-size: .9rem; +} + +@keyframes manage-databases-bulk-strip-fade-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +@media (prefers-reduced-motion: reduce) { + .manage-databases-bulk-strip { + animation: none; + } +} + .visually-hidden { position: absolute; width: 1px; diff --git a/src/EventLogExpert.UI/wwwroot/Database/DatabaseEntryRow.js b/src/EventLogExpert.UI/wwwroot/Database/DatabaseEntryRow.js new file mode 100644 index 00000000..51462291 --- /dev/null +++ b/src/EventLogExpert.UI/wwwroot/Database/DatabaseEntryRow.js @@ -0,0 +1,14 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +export function attachCheckboxKeyHandler(checkboxEl) { + if (!checkboxEl) { + return; + } + + checkboxEl.addEventListener('keydown', (e) => { + if (e.key === ' ' || e.key === 'Spacebar') { + e.preventDefault(); + } + }); +} diff --git a/tests/Unit/EventLogExpert.UI.Tests/Database/DatabaseEntryRowTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Database/DatabaseEntryRowTests.cs index bba8c380..251769eb 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/Database/DatabaseEntryRowTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/Database/DatabaseEntryRowTests.cs @@ -36,88 +36,125 @@ public async Task CancelButton_Click_InvokesProgressCancelDelegate() } [Fact] - public void RemoveButton_AriaDisabled_DuringBackgroundUpgrade() + public void Checkbox_HasRoleCheckboxAndAriaLabelAndTabIndex() { - var entry = MakeEntry(DatabaseStatus.UpgradeRequired); - var progress = MakeProgress(currentEntryName: "a.db", scope: UpgradeProgressScope.Background); + var entry = MakeEntry(DatabaseStatus.Ready, "MyDb.evtx"); - var component = RenderRow(entry, upgradeProgress: progress); + var component = RenderRow(entry); - var button = component.Find(".db-entry-remove-btn"); - Assert.Equal("true", button.GetAttribute("aria-disabled")); + var checkbox = component.Find(".db-entry-checkbox"); + Assert.Equal("checkbox", checkbox.GetAttribute("role")); + Assert.Equal("Select MyDb.evtx", checkbox.GetAttribute("aria-label")); + Assert.Equal("0", checkbox.GetAttribute("tabindex")); } [Fact] - public void RemoveButton_AriaDisabled_DuringManageUpgrade() + public async Task CheckboxClick_InvokesOnSelectionToggle() { - var entry = MakeEntry(DatabaseStatus.UpgradeRequired); + var entry = MakeEntry(DatabaseStatus.Ready); + int invocationCount = 0; + var component = Render(parameters => parameters + .Add(p => p.Entry, entry) + .Add(p => p.OnSelectionToggle, () => invocationCount++)); - var component = RenderRow(entry, isUpgrading: true); + await component.Find(".db-entry-checkbox").ClickAsync(new MouseEventArgs()); - var button = component.Find(".db-entry-remove-btn"); - Assert.Equal("true", button.GetAttribute("aria-disabled")); + Assert.Equal(1, invocationCount); } [Fact] - public void RemoveButton_NotAriaDisabled_WhenIdle() + public async Task CheckboxEnterKey_InvokesOnSelectionToggle() { var entry = MakeEntry(DatabaseStatus.Ready); + int invocationCount = 0; + var component = Render(parameters => parameters + .Add(p => p.Entry, entry) + .Add(p => p.OnSelectionToggle, () => invocationCount++)); - var component = RenderRow(entry); + await component.Find(".db-entry-checkbox").KeyDownAsync(new KeyboardEventArgs { Key = "Enter" }); - var button = component.Find(".db-entry-remove-btn"); - Assert.False(button.HasAttribute("aria-disabled")); + Assert.Equal(1, invocationCount); } [Fact] - public async Task RemoveButtonClick_InvokesOnRemove() + public async Task CheckboxKeyDown_NonSpaceOrEnter_DoesNotInvokeOnSelectionToggle() { - // Arrange var entry = MakeEntry(DatabaseStatus.Ready); int invocationCount = 0; var component = Render(parameters => parameters .Add(p => p.Entry, entry) - .Add(p => p.OnRemove, () => invocationCount++)); + .Add(p => p.OnSelectionToggle, () => invocationCount++)); - // Act - await component.Find(".db-entry-remove-btn").ClickAsync(new MouseEventArgs()); + await component.Find(".db-entry-checkbox").KeyDownAsync(new KeyboardEventArgs { Key = "Tab" }); + await component.Find(".db-entry-checkbox").KeyDownAsync(new KeyboardEventArgs { Key = "a" }); - // Assert - Assert.Equal(1, invocationCount); + Assert.Equal(0, invocationCount); } [Fact] - public async Task RemoveButtonClick_WhenIsUpgrading_DoesNotInvokeOnRemove() + public async Task CheckboxSpaceKey_InvokesOnSelectionToggle() { - // Defense-in-depth: DatabaseRegistry.ReserveFileOperation is the corruption mutex; - // this UI guard prevents the confusing "Failed to Remove Database" banner that would - // otherwise surface from a Remove click during an in-flight upgrade. - var entry = MakeEntry(DatabaseStatus.UpgradeRequired); + var entry = MakeEntry(DatabaseStatus.Ready); int invocationCount = 0; var component = Render(parameters => parameters .Add(p => p.Entry, entry) - .Add(p => p.IsUpgrading, true) - .Add(p => p.OnRemove, () => invocationCount++)); + .Add(p => p.OnSelectionToggle, () => invocationCount++)); - await component.Find(".db-entry-remove-btn").ClickAsync(new MouseEventArgs()); + await component.Find(".db-entry-checkbox").KeyDownAsync(new KeyboardEventArgs { Key = " " }); - Assert.Equal(0, invocationCount); + Assert.Equal(1, invocationCount); } [Fact] - public async Task RemoveButtonClick_WhenUpgradeProgressNotNull_DoesNotInvokeOnRemove() + public void RemoveButton_AlwaysVisible_RowDoesNotHaveSlideRevealClass() + { + var entry = MakeEntry(DatabaseStatus.Ready); + + var component = RenderRow(entry); + + var row = component.Find(".db-entry-row"); + Assert.DoesNotContain("db-entry-row--revealed", row.GetAttribute("class") ?? string.Empty); + Assert.Single(component.FindAll(".db-entry-remove-btn")); + } + + [Fact] + public void RemoveButton_DuringBackgroundUpgrade_NotAriaDisabled() { var entry = MakeEntry(DatabaseStatus.UpgradeRequired); var progress = MakeProgress(currentEntryName: "a.db", scope: UpgradeProgressScope.Background); + + var component = RenderRow(entry, upgradeProgress: progress); + + var button = component.Find(".db-entry-remove-btn"); + Assert.False(button.HasAttribute("aria-disabled")); + } + + [Fact] + public void RemoveButton_DuringManageUpgrade_NotAriaDisabled() + { + var entry = MakeEntry(DatabaseStatus.UpgradeRequired); + + var component = RenderRow(entry, isUpgrading: true); + + var button = component.Find(".db-entry-remove-btn"); + Assert.False(button.HasAttribute("aria-disabled")); + } + + [Fact] + public async Task RemoveButtonClick_InvokesOnRemove() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.Ready); int invocationCount = 0; var component = Render(parameters => parameters .Add(p => p.Entry, entry) - .Add(p => p.UpgradeProgress, progress) .Add(p => p.OnRemove, () => invocationCount++)); + // Act await component.Find(".db-entry-remove-btn").ClickAsync(new MouseEventArgs()); - Assert.Equal(0, invocationCount); + // Assert + Assert.Equal(1, invocationCount); } [Fact] @@ -252,6 +289,17 @@ public void Render_ClassificationFailedEntry_ShowsRetryClassificationButton() Assert.Contains("Retry classification", retryBtn.TextContent); } + [Fact] + public void Render_DefaultIsSelectedFalse_CheckboxUnchecked() + { + var entry = MakeEntry(DatabaseStatus.Ready); + + var component = RenderRow(entry); + + var checkbox = component.Find(".db-entry-checkbox"); + Assert.Equal("false", checkbox.GetAttribute("aria-checked")); + } + [Fact] public void Render_DisabledEntries_ShowTrashButton() { @@ -270,43 +318,30 @@ public void Render_DisabledEntries_ShowTrashButton() } [Fact] - public void Render_DOMOrder_NameInfoPrecedesActionsPrecedesRemove() + public void Render_FileName_AppearsInRow() { - // Arrange — guard the keyboard tab-order contract: name button must come before - // the toggle/upgrade action, which must come before the trash. The trash stays - // visually pinned to the left edge via CSS (position:absolute; left:0), so this - // DOM contract decouples a11y/tab order from visual layout. - var entry = MakeEntry(DatabaseStatus.Ready); + // Arrange + var entry = MakeEntry(DatabaseStatus.Ready, "MyProvider.db"); // Act var component = RenderRow(entry); // Assert - var row = component.Find(".db-entry-row"); - var children = row.Children; - - Assert.Equal(2, children.Length); - Assert.Contains("db-entry-row-content", children[0].GetAttribute("class") ?? string.Empty); - Assert.Contains("db-entry-remove-btn", children[1].GetAttribute("class") ?? string.Empty); - - var content = children[0]; - var contentChildren = content.Children; - Assert.Equal(2, contentChildren.Length); - Assert.Contains("db-entry-info", contentChildren[0].GetAttribute("class") ?? string.Empty); - Assert.Contains("db-entry-actions", contentChildren[1].GetAttribute("class") ?? string.Empty); + Assert.Equal("MyProvider.db", component.Find(".db-entry-name").TextContent); } [Fact] - public void Render_FileName_AppearsInRow() + public void Render_IsSelectedTrue_CheckboxCheckedAndRowHasSelectedClass() { - // Arrange - var entry = MakeEntry(DatabaseStatus.Ready, "MyProvider.db"); + var entry = MakeEntry(DatabaseStatus.Ready); - // Act - var component = RenderRow(entry); + var component = RenderRow(entry, isSelected: true); - // Assert - Assert.Equal("MyProvider.db", component.Find(".db-entry-name").TextContent); + var checkbox = component.Find(".db-entry-checkbox"); + Assert.Equal("true", checkbox.GetAttribute("aria-checked")); + + var row = component.Find(".db-entry-row"); + Assert.Contains("db-entry-row--selected", row.GetAttribute("class") ?? string.Empty); } [Fact] @@ -828,6 +863,17 @@ public async Task RetryClassificationButtonClick_InvokesOnRetryClassification() Assert.Equal(1, invocationCount); } + [Fact] + public void Row_NoAriaSelectedAttribute() + { + var entry = MakeEntry(DatabaseStatus.Ready); + + var component = RenderRow(entry, isSelected: true); + + var row = component.Find(".db-entry-row"); + Assert.False(row.HasAttribute("aria-selected")); + } + [Fact] public async Task TogglingTrueRadio_InvokesOnToggle_OnReadyEntry() { @@ -914,6 +960,7 @@ private IRenderedComponent RenderRow( bool isUpgradeBlocked = false, bool effectiveEnabled = false, bool isTogglePending = false, + bool isSelected = false, BannerProgressEntry? upgradeProgress = null) => Render(parameters => parameters .Add(p => p.Entry, entry) @@ -922,5 +969,6 @@ private IRenderedComponent RenderRow( .Add(p => p.IsUpgradeBlocked, isUpgradeBlocked) .Add(p => p.EffectiveEnabled, effectiveEnabled) .Add(p => p.IsTogglePending, isTogglePending) + .Add(p => p.IsSelected, isSelected) .Add(p => p.UpgradeProgress, upgradeProgress)); } diff --git a/tests/Unit/EventLogExpert.UI.Tests/DatabaseTools/Tabs/FakeInlineAlertSurface.cs b/tests/Unit/EventLogExpert.UI.Tests/DatabaseTools/Tabs/FakeInlineAlertSurface.cs new file mode 100644 index 00000000..8ba4bc3a --- /dev/null +++ b/tests/Unit/EventLogExpert.UI.Tests/DatabaseTools/Tabs/FakeInlineAlertSurface.cs @@ -0,0 +1,22 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.Alerts; +using EventLogExpert.UI.DatabaseTools; + +namespace EventLogExpert.UI.Tests.DatabaseTools.Tabs; + +internal sealed class FakeInlineAlertSurface : IInlineAlertSurface +{ + public List Requests { get; } = []; + + public InlineAlertResult Result { get; set; } = new(false, null); + + public Task ShowInlineAlertAsync( + InlineAlertRequest request, + CancellationToken cancellationToken) + { + Requests.Add(request); + return Task.FromResult(Result); + } +} diff --git a/tests/Unit/EventLogExpert.UI.Tests/DatabaseTools/Tabs/ManageDatabasesTabTests.cs b/tests/Unit/EventLogExpert.UI.Tests/DatabaseTools/Tabs/ManageDatabasesTabTests.cs index 04e711c9..ebd4b42e 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/DatabaseTools/Tabs/ManageDatabasesTabTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/DatabaseTools/Tabs/ManageDatabasesTabTests.cs @@ -3,10 +3,12 @@ using Bunit; using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Runtime.Alerts; using EventLogExpert.Runtime.Announcement; using EventLogExpert.Runtime.Banner; using EventLogExpert.Runtime.Database; using EventLogExpert.Runtime.Database.Upgrade; +using EventLogExpert.UI.DatabaseTools; using EventLogExpert.UI.DatabaseTools.Tabs; using Microsoft.AspNetCore.Components.Web; using Microsoft.Extensions.DependencyInjection; @@ -60,6 +62,152 @@ public async Task ApplyPendingTogglesAsync_WithNoPendingToggles_ReturnsTrue_NoAn _announcementService.DidNotReceive().Announce(Arg.Any()); } + [Fact] + public async Task BulkRemove_ConfirmationAccepted_InvokesCoordinatorPerFile() + { + _databaseService.Entries = [ + Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready), + Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready)]; + _coordinator.RemoveDatabaseAsync( + Arg.Any(), + Arg.Any>>(), + Arg.Any()) + .Returns(new RemoveOutcome(RemoveOutcomeStatus.Confirmed, true, false)); + var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(true, null) }; + var component = RenderWithAlertSurface(alertSurface); + + var checkboxes = component.FindAll(".db-entry-checkbox"); + await component.InvokeAsync(() => checkboxes[0].Click()); + await component.InvokeAsync(() => checkboxes[1].Click()); + + var bulkRemove = component.FindAll(".manage-databases-bulk-strip button")[1]; + await component.InvokeAsync(() => bulkRemove.Click()); + + await _coordinator.Received(1).RemoveDatabaseAsync( + "a.db", + Arg.Any>>(), + Arg.Any()); + await _coordinator.Received(1).RemoveDatabaseAsync( + "b.db", + Arg.Any>>(), + Arg.Any()); + } + + [Fact] + public async Task BulkRemove_ConfirmationDeclined_DoesNotInvokeCoordinator() + { + _databaseService.Entries = [ + Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready), + Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready)]; + var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(false, null) }; + var component = RenderWithAlertSurface(alertSurface); + + var checkboxes = component.FindAll(".db-entry-checkbox"); + await component.InvokeAsync(() => checkboxes[0].Click()); + await component.InvokeAsync(() => checkboxes[1].Click()); + + var bulkRemove = component.FindAll(".manage-databases-bulk-strip button")[1]; + await component.InvokeAsync(() => bulkRemove.Click()); + + await _coordinator.DidNotReceive().RemoveDatabaseAsync( + Arg.Any(), + Arg.Any>>(), + Arg.Any()); + } + + [Fact] + public async Task BulkRemove_DuringBackgroundUpgrade_DualSignal_AcceptanceLabelMentionsCancel() + { + var entry = Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready); + _databaseService.Entries = [entry]; + + _coordinator.IsUpgradeInFlight("a.db").Returns(false); + _progressBannerService.BackgroundProgress.Returns(MakeProgress( + currentEntryName: "a.db", + scope: UpgradeProgressScope.Background)); + + var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(false, null) }; + var component = RenderWithAlertSurface(alertSurface); + + await component.InvokeAsync(() => component.Find(".db-entry-checkbox").Click()); + var bulkRemove = component.FindAll(".manage-databases-bulk-strip button")[1]; + await component.InvokeAsync(() => bulkRemove.Click()); + + var captured = Assert.Single(alertSurface.Requests); + Assert.Contains("Cancel", captured.AcceptLabel ?? string.Empty, StringComparison.OrdinalIgnoreCase); + Assert.Contains("upgrade", captured.AcceptLabel ?? string.Empty, StringComparison.OrdinalIgnoreCase); + Assert.Contains("a.db", captured.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task BulkRemove_LogsReopened_CallsConsumeReopenedAsBaseline() + { + _databaseService.Entries = [ + Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready), + Entry("b.db", isEnabled: true, status: DatabaseStatus.Ready)]; + + _coordinator.RemoveDatabaseAsync( + Arg.Any(), + Arg.Any>>(), + Arg.Any()) + .Returns(new RemoveOutcome(RemoveOutcomeStatus.Confirmed, true, true)); + var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(true, null) }; + var component = RenderWithAlertSurface(alertSurface); + + _databaseService.RaiseUpgradeBatchCompleted( + new UpgradeBatchCompletedEventArgs( + UpgradeBatchId.Create(), + new UpgradeBatchResult(["a.db"], [], []), + wasCancelled: false)); + Assert.True(component.Instance.HasDatabaseStateChanged); + + var checkboxes = component.FindAll(".db-entry-checkbox"); + await component.InvokeAsync(() => checkboxes[0].Click()); + await component.InvokeAsync(() => checkboxes[1].Click()); + + var bulkRemove = component.FindAll(".manage-databases-bulk-strip button")[1]; + await component.InvokeAsync(() => bulkRemove.Click()); + + Assert.False(component.Instance.HasDatabaseStateChanged); + } + + [Fact] + public async Task ClearSelection_EmptiesSet_HidesBulkStrip() + { + _databaseService.Entries = [ + Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready), + Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready)]; + var component = Render(); + + var checkboxes = component.FindAll(".db-entry-checkbox"); + await component.InvokeAsync(() => checkboxes[0].Click()); + await component.InvokeAsync(() => checkboxes[1].Click()); + Assert.Contains("2 selected", component.Find(".manage-databases-bulk-count").TextContent); + + var clearBtn = component.FindAll(".manage-databases-bulk-strip button")[0]; + await component.InvokeAsync(() => clearBtn.Click()); + + Assert.False(component.Instance.HasSelectedForRemoval); + Assert.Empty(component.FindAll(".manage-databases-bulk-strip")); + } + + [Fact] + public async Task ClearSelection_UpdatesAriaLiveAnnouncement() + { + _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; + var component = Render(); + + await component.InvokeAsync(() => component.Find(".db-entry-checkbox").Click()); + var liveRegion = component.Find(".manage-databases-tab > span[role='status'][aria-live='polite']"); + Assert.NotEqual(string.Empty, liveRegion.TextContent.Trim()); + + var clearBtn = component.FindAll(".manage-databases-bulk-strip button")[0]; + await component.InvokeAsync(() => clearBtn.Click()); + + liveRegion = component.Find(".manage-databases-tab > span[role='status'][aria-live='polite']"); + Assert.DoesNotContain("selected", liveRegion.TextContent, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task DisposeAsync_UnsubscribesAllEvents() { @@ -232,6 +380,17 @@ public async Task RebaselineActiveSnapshotOnly_PreservesStickyFlags() Assert.True(component.Instance.HasDatabaseStateChanged); } + [Fact] + public void Render_BulkStrip_HiddenWhenNoSelection() + { + _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; + + var component = Render(); + + Assert.Empty(component.FindAll(".manage-databases-bulk-strip")); + Assert.False(component.Instance.HasSelectedForRemoval); + } + [Fact] public void Render_ClassificationPendingNotice_WhenClassificationInProgress() { @@ -282,6 +441,18 @@ public void Render_ImportButton_Present() Assert.Single(component.FindAll("#manage-import-button")); } + [Fact] + public void Render_PersistentAriaLiveRegion_PresentRegardlessOfSelection() + { + _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; + + var component = Render(); + + var statusRegion = component.Find(".manage-databases-tab > span[role='status'][aria-live='polite']"); + Assert.NotNull(statusRegion); + Assert.Equal("true", statusRegion.GetAttribute("aria-atomic")); + } + [Fact] public async Task RestoreFromBackup_ServiceReturnsFalse_DoesNotSetRestorationFlag() { @@ -386,6 +557,98 @@ public void RowReceivesUpgradeProgress_WhenManageDatabasesSlotMatches() Assert.Contains("Migrating schema 1 of 2", text.TextContent); } + [Fact] + public async Task SingleRowRemove_DuringCoordinatorUpgrade_DualSignal_AcceptanceLabelMentionsCancel() + { + var entry = Entry("a.db", isEnabled: false, status: DatabaseStatus.UpgradeRequired); + _databaseService.Entries = [entry]; + + _coordinator.IsUpgradeInFlight("a.db").Returns(true); + _progressBannerService.ManageDatabasesProgress.Returns(MakeProgress( + currentEntryName: "a.db", + scope: UpgradeProgressScope.ManageDatabasesTriggered)); + + var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(false, null) }; + var component = RenderWithAlertSurface(alertSurface); + + await component.InvokeAsync(() => component.Find(".db-entry-remove-btn").Click()); + + var captured = Assert.Single(alertSurface.Requests); + Assert.Contains("Cancel", captured.AcceptLabel ?? string.Empty, StringComparison.OrdinalIgnoreCase); + Assert.Contains("upgrade", captured.AcceptLabel ?? string.Empty, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ToggleSelection_AddsToSelection_RevealsBulkStrip() + { + _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; + var component = Render(); + + await component.InvokeAsync(() => component.Find(".db-entry-checkbox").Click()); + + Assert.True(component.Instance.HasSelectedForRemoval); + Assert.Single(component.FindAll(".manage-databases-bulk-strip")); + Assert.Contains("1 selected", component.Find(".manage-databases-bulk-count").TextContent); + } + + [Fact] + public async Task ToggleSelection_OnRemovedEntry_PrunedFromSelection() + { + _databaseService.Entries = [ + Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready), + Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready)]; + var component = Render(); + + var checkboxes = component.FindAll(".db-entry-checkbox"); + await component.InvokeAsync(() => checkboxes[0].Click()); + await component.InvokeAsync(() => checkboxes[1].Click()); + Assert.Contains("2 selected", component.Find(".manage-databases-bulk-count").TextContent); + + _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; + _databaseService.RaiseEntriesChanged(); + await component.InvokeAsync(() => { }); + + Assert.True(component.Instance.HasSelectedForRemoval); + Assert.Contains("1 selected", component.Find(".manage-databases-bulk-count").TextContent); + } + + [Fact] + public async Task ToggleSelection_TwiceOnSameRow_RemovesFromSelection() + { + _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; + var component = Render(); + + await component.InvokeAsync(() => component.Find(".db-entry-checkbox").Click()); + Assert.True(component.Instance.HasSelectedForRemoval); + + await component.InvokeAsync(() => component.Find(".db-entry-checkbox").Click()); + + Assert.False(component.Instance.HasSelectedForRemoval); + Assert.Empty(component.FindAll(".manage-databases-bulk-strip")); + } + + [Fact] + public async Task ToggleSelection_UpdatesAriaLiveAnnouncement() + { + _databaseService.Entries = [ + Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready), + Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready)]; + var component = Render(); + + var liveRegion = component.Find(".manage-databases-tab > span[role='status'][aria-live='polite']"); + Assert.Equal(string.Empty, liveRegion.TextContent.Trim()); + + var checkboxes = component.FindAll(".db-entry-checkbox"); + await component.InvokeAsync(() => checkboxes[0].Click()); + liveRegion = component.Find(".manage-databases-tab > span[role='status'][aria-live='polite']"); + Assert.Contains("1", liveRegion.TextContent); + Assert.Contains("selected", liveRegion.TextContent, StringComparison.OrdinalIgnoreCase); + + await component.InvokeAsync(() => checkboxes[1].Click()); + liveRegion = component.Find(".manage-databases-tab > span[role='status'][aria-live='polite']"); + Assert.Contains("2", liveRegion.TextContent); + } + private static DatabaseEntry Entry(string fileName, bool isEnabled, DatabaseStatus status, bool backupExists = false) => new(fileName, $@"C:\dbs\{fileName}", isEnabled, status, backupExists); @@ -405,4 +668,8 @@ private static BannerProgressEntry MakeProgress( currentPhase, queuedBatchesAfter, () => { }); + + private IRenderedComponent RenderWithAlertSurface(IInlineAlertSurface alertSurface) => + Render(parameters => parameters + .AddCascadingValue(alertSurface)); } From cca9eade1c933c5d0a16da993244b5ac285c9c62 Mon Sep 17 00:00:00 2001 From: jschick04 Date: Fri, 29 May 2026 08:41:16 -0500 Subject: [PATCH 2/5] Extracted reusable Checkbox component and removed pending toggle highlight --- .../Database/DatabaseEntryRow.razor | 13 +- .../Database/DatabaseEntryRow.razor.cs | 45 +---- .../Database/DatabaseEntryRow.razor.css | 33 ---- src/EventLogExpert.UI/Inputs/Checkbox.razor | 15 ++ .../Inputs/Checkbox.razor.cs | 20 ++ .../Inputs/Checkbox.razor.css | 60 ++++++ .../wwwroot/Database/DatabaseEntryRow.js | 14 -- .../Database/DatabaseEntryRowTests.cs | 62 +----- .../Tabs/ManageDatabasesTabTests.cs | 47 ++--- .../Inputs/CheckboxTests.cs | 180 ++++++++++++++++++ 10 files changed, 312 insertions(+), 177 deletions(-) create mode 100644 src/EventLogExpert.UI/Inputs/Checkbox.razor create mode 100644 src/EventLogExpert.UI/Inputs/Checkbox.razor.cs create mode 100644 src/EventLogExpert.UI/Inputs/Checkbox.razor.css delete mode 100644 src/EventLogExpert.UI/wwwroot/Database/DatabaseEntryRow.js create mode 100644 tests/Unit/EventLogExpert.UI.Tests/Inputs/CheckboxTests.cs diff --git a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor index 32a76d22..82f096d8 100644 --- a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor +++ b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor @@ -1,14 +1,7 @@
- +