feat(centralui): DialogHost ShowAsync<T> custom-content + focus trap/restore + backdrop hook (T33a)
This commit is contained in:
@@ -196,5 +196,11 @@ public class SchemaLibraryPageTests : BunitContext
|
||||
public Task<string?> PromptAsync(
|
||||
string title, string label, string initialValue = "", string? placeholder = null)
|
||||
=> Task.FromResult<string?>(null);
|
||||
|
||||
public Task<TResult?> ShowAsync<TResult>(
|
||||
string title,
|
||||
Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body,
|
||||
string? size = null)
|
||||
=> Task.FromResult<TResult?>(default);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,5 +107,11 @@ public class NotificationListsPageTests : BunitContext
|
||||
public Task<string?> PromptAsync(
|
||||
string title, string label, string initialValue = "", string? placeholder = null)
|
||||
=> Task.FromResult<string?>(null);
|
||||
|
||||
public Task<TResult?> ShowAsync<TResult>(
|
||||
string title,
|
||||
Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body,
|
||||
string? size = null)
|
||||
=> Task.FromResult<TResult?>(default);
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -289,5 +289,11 @@ public class NotificationReportDetailModalTests : BunitContext
|
||||
public Task<string?> PromptAsync(
|
||||
string title, string label, string initialValue = "", string? placeholder = null)
|
||||
=> Task.FromResult<string?>(null);
|
||||
|
||||
public Task<TResult?> ShowAsync<TResult>(
|
||||
string title,
|
||||
Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body,
|
||||
string? size = null)
|
||||
=> Task.FromResult<TResult?>(default);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,5 +277,11 @@ public class NotificationReportPageTests : BunitContext
|
||||
public Task<string?> PromptAsync(
|
||||
string title, string label, string initialValue = "", string? placeholder = null)
|
||||
=> Task.FromResult<string?>(null);
|
||||
|
||||
public Task<TResult?> ShowAsync<TResult>(
|
||||
string title,
|
||||
Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body,
|
||||
string? size = null)
|
||||
=> Task.FromResult<TResult?>(default);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +211,12 @@ public sealed class QueryStringDrillInTests
|
||||
=> Task.FromResult(true);
|
||||
public Task<string?> PromptAsync(string title, string label, string initialValue = "", string? placeholder = null)
|
||||
=> Task.FromResult<string?>(null);
|
||||
|
||||
public Task<TResult?> ShowAsync<TResult>(
|
||||
string title,
|
||||
Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body,
|
||||
string? size = null)
|
||||
=> Task.FromResult<TResult?>(default);
|
||||
}
|
||||
|
||||
private sealed class TransportImportFixture : BunitContext
|
||||
|
||||
@@ -660,5 +660,11 @@ public class SiteCallsReportPageTests : BunitContext
|
||||
public Task<string?> PromptAsync(
|
||||
string title, string label, string initialValue = "", string? placeholder = null)
|
||||
=> Task.FromResult<string?>(null);
|
||||
|
||||
public Task<TResult?> ShowAsync<TResult>(
|
||||
string title,
|
||||
Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body,
|
||||
string? size = null)
|
||||
=> Task.FromResult<TResult?>(default);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit tests for the third <c>DialogHost</c> dialog kind (T33a):
|
||||
/// <see cref="IDialogService.ShowAsync{TResult}"/> renders arbitrary custom
|
||||
/// body content, omits the standard Cancel/Confirm footer (the body supplies
|
||||
/// its own buttons), and resolves a typed result through the
|
||||
/// <see cref="DialogContext{TResult}"/> the body is handed. The tests render a
|
||||
/// real <see cref="DialogService"/> behind a live <c>DialogHost</c> so the
|
||||
/// interface contract is exercised end to end.
|
||||
///
|
||||
/// bUnit cannot run real JS interop, so the host's <c>sbDialog.*</c> focus-trap
|
||||
/// / focus-restoration calls are exercised in <see cref="JSRuntimeMode.Loose"/>
|
||||
/// mode where they become no-ops.
|
||||
/// </summary>
|
||||
public class DialogHostShowAsyncTests : BunitContext
|
||||
{
|
||||
private readonly DialogService _service = new();
|
||||
|
||||
public DialogHostShowAsyncTests()
|
||||
{
|
||||
// Register the concrete service as the interface so DialogHost (which
|
||||
// injects IDialogService and casts to DialogService) resolves the same
|
||||
// instance the test drives.
|
||||
Services.AddSingleton<IDialogService>(_service);
|
||||
// Focus trap / restoration / body-lock interop are no-ops under test.
|
||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShowAsync_RendersCustomBody_AndResolvesTypedResult()
|
||||
{
|
||||
var cut = Render<DialogHost>();
|
||||
|
||||
// Open a custom dialog whose body renders a single button that closes
|
||||
// the dialog with a typed string result via the supplied context.
|
||||
Task<string?> pending = null!;
|
||||
await cut.InvokeAsync(() =>
|
||||
{
|
||||
pending = _service.ShowAsync<string>("Title", ctx => builder =>
|
||||
{
|
||||
builder.OpenElement(0, "button");
|
||||
builder.AddAttribute(1, "data-test", "ok");
|
||||
builder.AddAttribute(2, "onclick",
|
||||
EventCallback.Factory.Create(this, () => ctx.Close("done")));
|
||||
builder.CloseElement();
|
||||
});
|
||||
});
|
||||
cut.Render();
|
||||
|
||||
// The custom body renders, and the standard footer is NOT present.
|
||||
Assert.NotNull(cut.Find("button[data-test='ok']"));
|
||||
Assert.DoesNotContain(cut.FindAll("button"),
|
||||
b => b.TextContent.Trim() is "Cancel" or "Confirm");
|
||||
|
||||
Assert.False(pending.IsCompleted, "Dialog must stay open until the body acts.");
|
||||
|
||||
// Clicking the custom button resolves the awaited task with the value.
|
||||
cut.Find("button[data-test='ok']").Click();
|
||||
|
||||
var completed = await Task.WhenAny(pending, Task.Delay(TimeSpan.FromSeconds(2)));
|
||||
Assert.Same(pending, completed);
|
||||
Assert.Equal("done", await pending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShowAsync_CancelResolvesNull()
|
||||
{
|
||||
var cut = Render<DialogHost>();
|
||||
|
||||
Task<string?> pending = null!;
|
||||
await cut.InvokeAsync(() =>
|
||||
{
|
||||
pending = _service.ShowAsync<string>("Title", ctx => builder =>
|
||||
{
|
||||
builder.OpenElement(0, "button");
|
||||
builder.AddAttribute(1, "data-test", "cancel");
|
||||
builder.AddAttribute(2, "onclick",
|
||||
EventCallback.Factory.Create(this, () => ctx.Cancel()));
|
||||
builder.CloseElement();
|
||||
});
|
||||
});
|
||||
cut.Render();
|
||||
|
||||
cut.Find("button[data-test='cancel']").Click();
|
||||
|
||||
var completed = await Task.WhenAny(pending, Task.Delay(TimeSpan.FromSeconds(2)));
|
||||
Assert.Same(pending, completed);
|
||||
Assert.Null(await pending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShowAsync_EscapeResolvesNull()
|
||||
{
|
||||
var cut = Render<DialogHost>();
|
||||
|
||||
Task<string?> pending = null!;
|
||||
await cut.InvokeAsync(() =>
|
||||
{
|
||||
pending = _service.ShowAsync<string>("Title", _ => builder =>
|
||||
{
|
||||
builder.OpenElement(0, "span");
|
||||
builder.AddContent(1, "no buttons");
|
||||
builder.CloseElement();
|
||||
});
|
||||
});
|
||||
cut.Render();
|
||||
|
||||
// The host's Escape handler cancels custom dialogs the same way.
|
||||
cut.Find("div.modal").KeyDown(new Microsoft.AspNetCore.Components.Web.KeyboardEventArgs { Key = "Escape" });
|
||||
|
||||
var completed = await Task.WhenAny(pending, Task.Delay(TimeSpan.FromSeconds(2)));
|
||||
Assert.Same(pending, completed);
|
||||
Assert.Null(await pending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShowAsync_AppliesSizeClassToModalDialog()
|
||||
{
|
||||
var cut = Render<DialogHost>();
|
||||
|
||||
await cut.InvokeAsync(() =>
|
||||
{
|
||||
_ = _service.ShowAsync<string>("Title", _ => builder =>
|
||||
{
|
||||
builder.AddContent(0, "body");
|
||||
}, size: "modal-lg");
|
||||
});
|
||||
cut.Render();
|
||||
|
||||
var dialog = cut.Find("div.modal-dialog");
|
||||
Assert.Contains("modal-lg", dialog.ClassList);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Backdrop_HasTokenHookClass()
|
||||
{
|
||||
var cut = Render<DialogHost>();
|
||||
|
||||
await cut.InvokeAsync(() =>
|
||||
{
|
||||
_ = _service.ShowAsync<string>("Title", _ => builder =>
|
||||
{
|
||||
builder.AddContent(0, "body");
|
||||
});
|
||||
});
|
||||
cut.Render();
|
||||
|
||||
// The backdrop carries the tokenized hook class so a later task can
|
||||
// attach the actual var-driven background rule without touching markup.
|
||||
var backdrop = cut.Find("div.modal-backdrop");
|
||||
Assert.Contains("sb-modal-backdrop", backdrop.ClassList);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Confirm_StillResolvesTrue_NoRegression()
|
||||
{
|
||||
var cut = Render<DialogHost>();
|
||||
|
||||
Task<bool> pending = null!;
|
||||
await cut.InvokeAsync(() => { pending = _service.ConfirmAsync("Title", "Sure?"); });
|
||||
cut.Render();
|
||||
|
||||
// The standard footer's Confirm button is present for Confirm-kind dialogs.
|
||||
cut.FindAll("button").First(b => b.TextContent.Trim() == "Confirm").Click();
|
||||
|
||||
var completed = await Task.WhenAny(pending, Task.Delay(TimeSpan.FromSeconds(2)));
|
||||
Assert.Same(pending, completed);
|
||||
Assert.True(await pending);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user