diff --git a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor index f66c4e10..82f096d8 100644 --- a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor +++ b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor @@ -1,8 +1,11 @@ -
+
+ +
- -
diff --git a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.cs b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.cs index 0527e17d..c746aa64 100644 --- a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.cs +++ b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.cs @@ -15,8 +15,8 @@ public sealed partial class DatabaseEntryRow : ComponentBase 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 _nameButtonRef; + private ElementReference _removeButtonRef; private bool _shouldFocusNameAfterRender; private enum ActionKind @@ -37,6 +37,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 +51,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,8 +63,6 @@ private enum ActionKind private string BadgeLabel => DatabaseStatusLabels.GetRowBadgeLabel(Entry); - private bool IsRemoveBlocked => IsUpgrading || UpgradeProgress is not null; - private bool IsRestoreBlocked => IsUpgradeBlocked || IsUpgrading || UpgradeProgress is not null; private ActionKind PrimaryAction @@ -96,6 +98,8 @@ UpgradeProgress is null && [Inject] private ITraceLogger TraceLogger { get; init; } = null!; + public ValueTask FocusRemoveButtonAsync() => _removeButtonRef.FocusAsync(preventScroll: true); + protected override async Task OnAfterRenderAsync(bool firstRender) { if (!_shouldFocusNameAfterRender) { return; } @@ -115,10 +119,6 @@ protected override async Task OnAfterRenderAsync(bool firstRender) _ => "Upgrading" }; - private void HandleNameClick() => _isMouseRevealed = true; - - private void HandleRowMouseLeave() => _isMouseRevealed = false; - private void OnCancelClick() { _shouldFocusNameAfterRender = true; @@ -132,9 +132,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..2f17af33 100644 --- a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.css +++ b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.css @@ -1,46 +1,28 @@ .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); +} +.db-entry-row-content { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 1rem; - /* 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; - - transition: transform 200ms ease; -} - -/* 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)); + padding: .25rem 0; } .db-entry-info { @@ -48,8 +30,6 @@ align-items: center; gap: .5rem; min-width: 0; - - padding-left: .5rem; } .db-entry-name { @@ -155,34 +135,14 @@ } .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-upgrading, .db-entry-upgrading-text, .db-entry-cancel-btn { @@ -211,10 +171,6 @@ border-radius: 4px; } -.db-entry-actions--pending ::deep .option-select { - box-shadow: 0 0 0 1px var(--clr-lightblue); -} - .visually-hidden { position: absolute; 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..e00537ca 100644 --- a/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor.cs +++ b/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor.cs @@ -7,21 +7,39 @@ using EventLogExpert.Runtime.Banner; using EventLogExpert.Runtime.Database; using EventLogExpert.Runtime.Database.Upgrade; +using EventLogExpert.Runtime.EventLog; +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 +53,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; } @@ -49,6 +69,8 @@ public bool HasDatabaseStateChanged private bool IsUpgradeBlocked => IsUpgradeInFlight; + [Inject] private ILogReloadCoordinator LogReloadCoordinator { get; init; } = null!; + [Inject] private IProgressBannerService ProgressBannerService { get; init; } = null!; [Inject] private ITraceLogger TraceLogger { get; init; } = null!; @@ -108,6 +130,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 +175,33 @@ protected override void OnInitialized() base.OnInitialized(); } + private static string BuildBulkPlainMessage(IReadOnlyList fileNames) + { + 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 string AppendCloseReopenWarningIfNeeded(string baseMessage, IReadOnlyList fileNames) + { + if (!LogReloadCoordinator.HasActiveLogs) { return baseMessage; } + + bool anyRemovalAffectsActiveLog = fileNames.Any(f => + DatabaseService.Entries.Any(e => + string.Equals(e.FileName, f, StringComparison.OrdinalIgnoreCase) && + e.IsEnabled && + e.Status == DatabaseStatus.Ready)); + + if (!anyRemovalAffectsActiveLog) { return baseMessage; } + + string warning = fileNames.Count == 1 + ? "Removing will close and reopen any affected log views." + : "Removing these databases will close and reopen any affected log views."; + + return $"{baseMessage} {warning}"; + } + private async Task AskOverwriteAsync(string fileName, CancellationToken cancellationToken) { if (_disposed || AlertSurface is null) { return false; } @@ -147,12 +223,209 @@ private async Task AskOverwriteAsync(string fileName, CancellationToken ca catch (ObjectDisposedException) { return false; } } + private 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; + + string baseMessage = $"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?"; + + return AppendCloseReopenWarningIfNeeded(baseMessage, fileNames); + } + + private string BuildPlainRemoveMessage(IReadOnlyList fileNames) + { + string baseMessage = fileNames.Count == 1 + ? $"Are you sure you want to remove {fileNames[0]}?" + : BuildBulkPlainMessage(fileNames); + + return AppendCloseReopenWarningIfNeeded(baseMessage, fileNames); + } + + 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 +481,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 +522,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 +575,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 +587,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 +600,96 @@ 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 succeeded = new List(); + var failed = new List<(string FileName, string Reason)>(); + bool anyLogsReopened = false; - var outcome = await Coordinator.RemoveDatabaseAsync( - fileName, - async (showCloseReopenWarning, cancellationToken) => + 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.Removed) + { + succeeded.Add(fileName); + _pendingToggles.Remove(fileName); - try + if (outcome.LogsReopened) { anyLogsReopened = true; } + } + else if (outcome.Confirmed) { - 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 after confirmation")); + TraceLogger.Warning( + $"{nameof(ManageDatabasesTab)}.{nameof(RemoveDatabasesAsync)}: removal of '{fileName}' was confirmed but did not complete."); } - catch (ObjectDisposedException) { return false; } - }); - - if (_disposed) { return; } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + failed.Add((fileName, ex.Message)); + TraceLogger.Warning( + $"{nameof(ManageDatabasesTab)}.{nameof(RemoveDatabasesAsync)}: removal of '{fileName}' threw: {ex}"); + } + } - if (outcome.Confirmed) { _pendingToggles.Remove(fileName); } + foreach (var fileName in fileNames) + { + _selectedForRemoval.Remove(fileName); + } + 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) + { + var (firstFailedFile, firstFailedReason) = failed[0]; + AnnouncementService.Announce( + $"{failed.Count} removal{(failed.Count == 1 ? "" : "s")} failed. First: {firstFailedFile} ({firstFailedReason})."); + } + + 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 +698,7 @@ private async Task RestoreFromBackup(DatabaseEntry entry) } bool restored; + try { restored = await DatabaseService.RestoreFromBackupAsync(entry.FileName, CancellationToken.None); @@ -396,6 +759,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/Inputs/Checkbox.razor b/src/EventLogExpert.UI/Inputs/Checkbox.razor new file mode 100644 index 00000000..efafd1af --- /dev/null +++ b/src/EventLogExpert.UI/Inputs/Checkbox.razor @@ -0,0 +1,15 @@ +@inherits InputComponent + + diff --git a/src/EventLogExpert.UI/Inputs/Checkbox.razor.cs b/src/EventLogExpert.UI/Inputs/Checkbox.razor.cs new file mode 100644 index 00000000..4669d4bc --- /dev/null +++ b/src/EventLogExpert.UI/Inputs/Checkbox.razor.cs @@ -0,0 +1,20 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using Microsoft.AspNetCore.Components; + +namespace EventLogExpert.UI.Inputs; + +public sealed partial class Checkbox : InputComponent +{ + [Parameter] public bool Disabled { get; set; } + + private async Task UpdateValue(ChangeEventArgs args) + { + if (args.Value is not bool value) { return; } + if (Disabled) { return; } + + Value = value; + await ValueChanged.InvokeAsync(Value); + } +} diff --git a/src/EventLogExpert.UI/Inputs/Checkbox.razor.css b/src/EventLogExpert.UI/Inputs/Checkbox.razor.css new file mode 100644 index 00000000..bc1bfeeb --- /dev/null +++ b/src/EventLogExpert.UI/Inputs/Checkbox.razor.css @@ -0,0 +1,60 @@ +.checkbox { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.75rem; + min-height: 1.75rem; + padding: .25rem; + + cursor: pointer; + user-select: none; +} + +.checkbox-input { + position: absolute !important; + width: 1px !important; + height: 1px !important; + margin: -1px; + padding: 0; + border: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +.checkbox-icon { + color: var(--text-secondary); + transition: color 150ms ease; +} + +.checkbox-icon i { + font-size: 1rem; +} + +.checkbox-input:checked + .checkbox-icon { + color: var(--clr-lightblue); +} + +.checkbox-input:focus-visible + .checkbox-icon { + outline: 2px solid var(--clr-lightblue); + outline-offset: 2px; + border-radius: 2px; +} + +.checkbox-input:disabled + .checkbox-icon { + opacity: 0.5; +} + +.checkbox-input:disabled ~ * { + pointer-events: none; +} + +.checkbox:has(.checkbox-input:disabled) { + cursor: not-allowed; +} + +@media (prefers-reduced-motion: reduce) { + .checkbox-icon { + transition: none; + } +} diff --git a/tests/Unit/EventLogExpert.UI.Tests/Database/DatabaseEntryRowTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Database/DatabaseEntryRowTests.cs index bba8c380..27db48f9 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/Database/DatabaseEntryRowTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/Database/DatabaseEntryRowTests.cs @@ -36,88 +36,81 @@ public async Task CancelButton_Click_InvokesProgressCancelDelegate() } [Fact] - public void RemoveButton_AriaDisabled_DuringBackgroundUpgrade() + public void Checkbox_RenderedWithAriaLabelInRow() { - 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-row input[type='checkbox']"); + Assert.Equal("Select MyDb.evtx", checkbox.GetAttribute("aria-label")); } [Fact] - public void RemoveButton_AriaDisabled_DuringManageUpgrade() + public async Task CheckboxChange_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-row input[type='checkbox']") + .ChangeAsync(new ChangeEventArgs { Value = true }); - 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 void RemoveButton_AlwaysVisible_RowDoesNotHaveSlideRevealClass() { var entry = MakeEntry(DatabaseStatus.Ready); var component = RenderRow(entry); - var button = component.Find(".db-entry-remove-btn"); - Assert.False(button.HasAttribute("aria-disabled")); + 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 async Task RemoveButtonClick_InvokesOnRemove() + public void RemoveButton_DuringBackgroundUpgrade_NotAriaDisabled() { - // Arrange - var entry = MakeEntry(DatabaseStatus.Ready); - int invocationCount = 0; - var component = Render(parameters => parameters - .Add(p => p.Entry, entry) - .Add(p => p.OnRemove, () => invocationCount++)); + var entry = MakeEntry(DatabaseStatus.UpgradeRequired); + var progress = MakeProgress(currentEntryName: "a.db", scope: UpgradeProgressScope.Background); - // Act - await component.Find(".db-entry-remove-btn").ClickAsync(new MouseEventArgs()); + var component = RenderRow(entry, upgradeProgress: progress); - // Assert - Assert.Equal(1, invocationCount); + var button = component.Find(".db-entry-remove-btn"); + Assert.False(button.HasAttribute("aria-disabled")); } [Fact] - public async Task RemoveButtonClick_WhenIsUpgrading_DoesNotInvokeOnRemove() + public void RemoveButton_DuringManageUpgrade_NotAriaDisabled() { - // 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); - int invocationCount = 0; - var component = Render(parameters => parameters - .Add(p => p.Entry, entry) - .Add(p => p.IsUpgrading, true) - .Add(p => p.OnRemove, () => invocationCount++)); - await component.Find(".db-entry-remove-btn").ClickAsync(new MouseEventArgs()); + var component = RenderRow(entry, isUpgrading: true); - Assert.Equal(0, invocationCount); + var button = component.Find(".db-entry-remove-btn"); + Assert.False(button.HasAttribute("aria-disabled")); } [Fact] - public async Task RemoveButtonClick_WhenUpgradeProgressNotNull_DoesNotInvokeOnRemove() + public async Task RemoveButtonClick_InvokesOnRemove() { - var entry = MakeEntry(DatabaseStatus.UpgradeRequired); - var progress = MakeProgress(currentEntryName: "a.db", scope: UpgradeProgressScope.Background); + // 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 +245,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-row input[type='checkbox']"); + Assert.False(checkbox.HasAttribute("checked")); + } + [Fact] public void Render_DisabledEntries_ShowTrashButton() { @@ -270,43 +274,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-row input[type='checkbox']"); + Assert.True(checkbox.HasAttribute("checked")); + + var row = component.Find(".db-entry-row"); + Assert.Contains("db-entry-row--selected", row.GetAttribute("class") ?? string.Empty); } [Fact] @@ -828,6 +819,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 +916,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 +925,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..25b8ecca 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/DatabaseTools/Tabs/ManageDatabasesTabTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/DatabaseTools/Tabs/ManageDatabasesTabTests.cs @@ -3,11 +3,15 @@ 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.Runtime.EventLog; +using EventLogExpert.UI.DatabaseTools; using EventLogExpert.UI.DatabaseTools.Tabs; +using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; using Microsoft.Extensions.DependencyInjection; using NSubstitute; @@ -21,16 +25,19 @@ public sealed class ManageDatabasesTabTests : BunitContext private readonly IAnnouncementService _announcementService = Substitute.For(); private readonly IDatabaseOperationCoordinator _coordinator = Substitute.For(); private readonly FakeDatabaseService _databaseService = new(); + private readonly ILogReloadCoordinator _logReloadCoordinator = Substitute.For(); private readonly IProgressBannerService _progressBannerService = Substitute.For(); private readonly ITraceLogger _traceLogger = Substitute.For(); public ManageDatabasesTabTests() { _progressBannerService.ManageDatabasesProgress.Returns((BannerProgressEntry?)null); + _logReloadCoordinator.HasActiveLogs.Returns(false); Services.AddSingleton(_announcementService); Services.AddSingleton(_coordinator); Services.AddSingleton(_databaseService); + Services.AddSingleton(_logReloadCoordinator); Services.AddSingleton(_progressBannerService); Services.AddSingleton(_traceLogger); @@ -60,6 +67,223 @@ 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-row input[type='checkbox']"); + await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); + await component.InvokeAsync(() => checkboxes[1].ChangeAsync(new ChangeEventArgs { Value = true })); + + 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-row input[type='checkbox']"); + await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); + await component.InvokeAsync(() => checkboxes[1].ChangeAsync(new ChangeEventArgs { Value = true })); + + 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-row input[type='checkbox']").ChangeAsync(new ChangeEventArgs { Value = true })); + 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-row input[type='checkbox']"); + await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); + await component.InvokeAsync(() => checkboxes[1].ChangeAsync(new ChangeEventArgs { Value = true })); + + var bulkRemove = component.FindAll(".manage-databases-bulk-strip button")[1]; + await component.InvokeAsync(() => bulkRemove.Click()); + + Assert.False(component.Instance.HasDatabaseStateChanged); + } + + [Fact] + public async Task BulkRemove_PerFileFailure_AnnouncesFirstFailureDetail() + { + _databaseService.Entries = [ + Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready), + Entry("b.db", isEnabled: false, status: DatabaseStatus.Ready)]; + _coordinator.RemoveDatabaseAsync("a.db", Arg.Any>>(), Arg.Any()) + .Returns(_ => throw new InvalidOperationException("disk full")); + _coordinator.RemoveDatabaseAsync("b.db", 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-row input[type='checkbox']"); + await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); + await component.InvokeAsync(() => checkboxes[1].ChangeAsync(new ChangeEventArgs { Value = true })); + + var bulkRemove = component.FindAll(".manage-databases-bulk-strip button")[1]; + await component.InvokeAsync(() => bulkRemove.Click()); + + _announcementService.Received().Announce(Arg.Is(s => s.Contains("a.db") && s.Contains("disk full"))); + } + + [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-row input[type='checkbox']"); + await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); + await component.InvokeAsync(() => checkboxes[1].ChangeAsync(new ChangeEventArgs { Value = true })); + 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-row input[type='checkbox']").ChangeAsync(new ChangeEventArgs { Value = true })); + 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 ConfirmationMessage_WhenNoActiveLogs_DoesNotIncludeCloseReopenWarning() + { + _databaseService.Entries = [Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready)]; + _logReloadCoordinator.HasActiveLogs.Returns(false); + + var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(false, null) }; + var component = RenderWithAlertSurface(alertSurface); + + var removeBtn = component.Find(".db-entry-row .db-entry-remove-btn"); + await component.InvokeAsync(() => removeBtn.Click()); + + var captured = Assert.Single(alertSurface.Requests); + Assert.DoesNotContain("close and reopen", captured.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ConfirmationMessage_WhenRemovingActiveDbWithOpenLogs_IncludesCloseReopenWarning() + { + _databaseService.Entries = [Entry("a.db", isEnabled: true, status: DatabaseStatus.Ready)]; + _logReloadCoordinator.HasActiveLogs.Returns(true); + + var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(false, null) }; + var component = RenderWithAlertSurface(alertSurface); + + var removeBtn = component.Find(".db-entry-row .db-entry-remove-btn"); + await component.InvokeAsync(() => removeBtn.Click()); + + var captured = Assert.Single(alertSurface.Requests); + Assert.Contains("close and reopen", captured.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ConfirmationMessage_WhenRemovingDisabledDb_DoesNotIncludeCloseReopenWarning() + { + _databaseService.Entries = [Entry("a.db", isEnabled: false, status: DatabaseStatus.Ready)]; + _logReloadCoordinator.HasActiveLogs.Returns(true); + + var alertSurface = new FakeInlineAlertSurface { Result = new InlineAlertResult(false, null) }; + var component = RenderWithAlertSurface(alertSurface); + + var removeBtn = component.Find(".db-entry-row .db-entry-remove-btn"); + await component.InvokeAsync(() => removeBtn.Click()); + + var captured = Assert.Single(alertSurface.Requests); + Assert.DoesNotContain("close and reopen", captured.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task DisposeAsync_UnsubscribesAllEvents() { @@ -232,6 +456,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 +517,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 +633,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-row input[type='checkbox']").ChangeAsync(new ChangeEventArgs { Value = true })); + + 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-row input[type='checkbox']"); + await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); + await component.InvokeAsync(() => checkboxes[1].ChangeAsync(new ChangeEventArgs { Value = true })); + 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-row input[type='checkbox']").ChangeAsync(new ChangeEventArgs { Value = true })); + Assert.True(component.Instance.HasSelectedForRemoval); + + await component.InvokeAsync(() => component.Find(".db-entry-row input[type='checkbox']").ChangeAsync(new ChangeEventArgs { Value = true })); + + 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-row input[type='checkbox']"); + await component.InvokeAsync(() => checkboxes[0].ChangeAsync(new ChangeEventArgs { Value = true })); + 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].ChangeAsync(new ChangeEventArgs { Value = true })); + 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 +744,8 @@ private static BannerProgressEntry MakeProgress( currentPhase, queuedBatchesAfter, () => { }); + + private IRenderedComponent RenderWithAlertSurface(IInlineAlertSurface alertSurface) => + Render(parameters => parameters + .AddCascadingValue(alertSurface)); } diff --git a/tests/Unit/EventLogExpert.UI.Tests/Inputs/CheckboxTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Inputs/CheckboxTests.cs new file mode 100644 index 00000000..67fdc6ea --- /dev/null +++ b/tests/Unit/EventLogExpert.UI.Tests/Inputs/CheckboxTests.cs @@ -0,0 +1,180 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using Bunit; +using EventLogExpert.UI.Inputs; +using Microsoft.AspNetCore.Components; + +namespace EventLogExpert.UI.Tests.Inputs; + +public sealed class CheckboxTests : BunitContext +{ + [Fact] + public async Task Change_InvokesValueChangedWithNewValue() + { + bool? observedValue = null; + var component = Render(parameters => parameters + .Add(p => p.Value, false) + .Add(p => p.ValueChanged, v => + { + observedValue = v; + return Task.CompletedTask; + })); + + var input = component.Find("input[type='checkbox']"); + await input.ChangeAsync(new ChangeEventArgs { Value = true }); + + Assert.True(observedValue); + } + + [Fact] + public async Task Change_WhenDisabled_DoesNotInvokeValueChanged() + { + bool invoked = false; + var component = Render(parameters => parameters + .Add(p => p.Value, false) + .Add(p => p.Disabled, true) + .Add(p => p.ValueChanged, _ => + { + invoked = true; + return Task.CompletedTask; + })); + + var input = component.Find("input[type='checkbox']"); + await input.ChangeAsync(new ChangeEventArgs { Value = true }); + + Assert.False(invoked); + } + + [Fact] + public void Render_AriaDescribedBy_AppliedToInput() + { + var component = Render(parameters => parameters + .Add(p => p.Value, false) + .Add(p => p.AriaDescribedBy, "help-text-id")); + + var input = component.Find("input[type='checkbox']"); + Assert.Equal("help-text-id", input.GetAttribute("aria-describedby")); + } + + [Fact] + public void Render_AriaLabel_AppliedToInput() + { + var component = Render(parameters => parameters + .Add(p => p.Value, false) + .Add(p => p.AriaLabel, "Select database foo.db")); + + var input = component.Find("input[type='checkbox']"); + Assert.Equal("Select database foo.db", input.GetAttribute("aria-label")); + } + + [Fact] + public void Render_AriaLabelledBy_AppliedToInput() + { + var component = Render(parameters => parameters + .Add(p => p.Value, false) + .Add(p => p.AriaLabelledBy, "external-label-id")); + + var input = component.Find("input[type='checkbox']"); + Assert.Equal("external-label-id", input.GetAttribute("aria-labelledby")); + } + + [Fact] + public void Render_AriaLabelledByAndAriaLabel_SuppressesAriaLabelPerWaiAriaPrecedence() + { + var component = Render(parameters => parameters + .Add(p => p.Value, false) + .Add(p => p.AriaLabel, "Should be suppressed") + .Add(p => p.AriaLabelledBy, "external-label-id")); + + var input = component.Find("input[type='checkbox']"); + Assert.False(input.HasAttribute("aria-label")); + Assert.Equal("external-label-id", input.GetAttribute("aria-labelledby")); + } + + [Fact] + public void Render_CssClass_AppendedToLabel() + { + var component = Render(parameters => parameters + .Add(p => p.Value, false) + .Add(p => p.CssClass, "extra-class")); + + var label = component.Find("label"); + Assert.Contains("checkbox", label.ClassList); + Assert.Contains("extra-class", label.ClassList); + } + + [Fact] + public void Render_DisabledFalse_InputNotDisabled() + { + var component = Render(parameters => parameters + .Add(p => p.Value, false) + .Add(p => p.Disabled, false)); + + var input = component.Find("input[type='checkbox']"); + Assert.False(input.HasAttribute("disabled")); + } + + [Fact] + public void Render_DisabledTrue_InputDisabled() + { + var component = Render(parameters => parameters + .Add(p => p.Value, false) + .Add(p => p.Disabled, true)); + + var input = component.Find("input[type='checkbox']"); + Assert.True(input.HasAttribute("disabled")); + } + + [Fact] + public void Render_TitleOnLabel_AppliedFromAriaLabel() + { + var component = Render(parameters => parameters + .Add(p => p.Value, false) + .Add(p => p.AriaLabel, "Tooltip text")); + + var label = component.Find("label.checkbox"); + Assert.Equal("Tooltip text", label.GetAttribute("title")); + } + + [Fact] + public void Render_ValueFalse_IconIsSquare() + { + var component = Render(parameters => parameters + .Add(p => p.Value, false)); + + var icon = component.Find(".checkbox-icon i"); + Assert.Contains("bi-square", icon.ClassList); + Assert.DoesNotContain("bi-check-square-fill", icon.ClassList); + } + + [Fact] + public void Render_ValueFalse_InputDoesNotHaveCheckedAttribute() + { + var component = Render(parameters => parameters + .Add(p => p.Value, false)); + + var input = component.Find("input[type='checkbox']"); + Assert.False(input.HasAttribute("checked")); + } + + [Fact] + public void Render_ValueTrue_IconIsCheckSquareFill() + { + var component = Render(parameters => parameters + .Add(p => p.Value, true)); + + var icon = component.Find(".checkbox-icon i"); + Assert.Contains("bi-check-square-fill", icon.ClassList); + } + + [Fact] + public void Render_ValueTrue_InputHasCheckedAttribute() + { + var component = Render(parameters => parameters + .Add(p => p.Value, true)); + + var input = component.Find("input[type='checkbox']"); + Assert.True(input.HasAttribute("checked")); + } +}