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 @@
-
+
+
+
-
+
@if (ShouldShowBadge)
{
@BadgeLabel
@@ -111,14 +114,14 @@
break;
}
+
+
-
-
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"));
+ }
+}