fix(ui): clear page-scoped detail state on paging where no keyed detail exists
Gating the detail modal/drawer on a held id rather than on the row resolving (d14e0ee4) made it the surface's own job to clear that id when navigation invalidates page-scoped state. ParkedMessages and ConfigurationAuditLog did not, so paging away from an open row left the surface mounted on a notice it could never recover from — reachable only by paging back. The criterion is per-surface and comes down to whether the modal has content of its own: ParkedMessages, ConfigurationAuditLog — no keyed detail fetch; content resolves from the loaded page alone. An entry paged out of view can never resolve again, so these must clear on paging. They already cleared it on Search/OnSiteChanged for the same reason, and clear _selectedIds on paging for the same reason again; paging was simply missed. NotificationReport, SiteCallsReport — fetch detail by id, so the modal still shows real content after its row leaves the page. These deliberately do NOT clear on paging and are unchanged. Clearing on an explicit navigation action is user intent, not a resolve-driven unmount, so this cannot reopen the handler-disposal race thatd14e0ee4closed. PageScopedDetailStateTests covers all three paging entry points and records the criterion so the next reader can tell why two surfaces clear and two do not. Run against both clears reverted, all three fail; restored, all three pass. CentralUI.Tests 994/994, solution build 0/0. Also corrects two comments in ParkedMessages left stale byd14e0ee4— they still described the drawer as self-closing when a row stops resolving, which is the behaviour that change deliberately removed.
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using AuditLogPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit.ConfigurationAuditLog;
|
||||
using ParkedMessagesPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Monitoring.ParkedMessages;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// The detail modal/drawer on these surfaces is gated on a held id — user intent —
|
||||
/// rather than on the row still resolving, so that a data refresh can never unmount
|
||||
/// the subtree mid-render and dispose its event handlers (see
|
||||
/// NotificationReportDetailModalTests.Modal_StaysOpen_WhenItsRowLeavesThePage).
|
||||
///
|
||||
/// That makes it the surface's own job to clear the id on navigation actions that
|
||||
/// invalidate page-scoped state, and the criterion is per-surface:
|
||||
///
|
||||
/// • ParkedMessages and ConfigurationAuditLog have NO keyed detail fetch — their
|
||||
/// modal content resolves from the loaded page alone. An entry paged out of view
|
||||
/// can never resolve again, so the surface would sit on a permanently empty
|
||||
/// notice recoverable only by paging back. These must clear on paging.
|
||||
///
|
||||
/// • NotificationReport and SiteCallsReport DO fetch detail by id. Their modal
|
||||
/// still shows real content after the row leaves the page, so they deliberately
|
||||
/// do NOT clear on paging, and are not covered here.
|
||||
///
|
||||
/// Clearing on an explicit navigation action is intent, not a resolve-driven
|
||||
/// unmount, so it does not reopen the handler-disposal race.
|
||||
/// </summary>
|
||||
public class PageScopedDetailStateTests : BunitContext
|
||||
{
|
||||
private static void SetPrivate(object target, string field, object? value) =>
|
||||
target.GetType()
|
||||
.GetField(field, BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||
.SetValue(target, value);
|
||||
|
||||
private static object? GetPrivate(object target, string field) =>
|
||||
target.GetType()
|
||||
.GetField(field, BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||
.GetValue(target);
|
||||
|
||||
private static Task InvokePrivate(object target, string method, params object[] args) =>
|
||||
(Task)target.GetType()
|
||||
.GetMethod(method, BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||
.Invoke(target, args)!;
|
||||
|
||||
private void RegisterCommonServices()
|
||||
{
|
||||
var identity = new ClaimsIdentity(
|
||||
new[] { new Claim(ClaimTypes.Name, "deployer") }, "TestCookie");
|
||||
var stubAuth = new StubAuthStateProvider(
|
||||
new AuthenticationState(new ClaimsPrincipal(identity)));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(stubAuth);
|
||||
Services.AddScoped(_ => new SiteScopeService(stubAuth));
|
||||
Services.AddScoped<IDialogService, DialogService>();
|
||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PrevPage")]
|
||||
[InlineData("NextPage")]
|
||||
public async Task ParkedMessages_Paging_ClosesTheDrawer(string pagingMethod)
|
||||
{
|
||||
RegisterCommonServices();
|
||||
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetAllSitesAsync().Returns(new List<Site>());
|
||||
Services.AddSingleton(siteRepo);
|
||||
Services.AddSingleton(new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.Instance));
|
||||
|
||||
var cut = Render<ParkedMessagesPage>();
|
||||
|
||||
// Drawer open on some message.
|
||||
await cut.InvokeAsync(() =>
|
||||
SetPrivate(cut.Instance, "_drawerMessageId", "msg-being-viewed"));
|
||||
Assert.Equal("msg-being-viewed", GetPrivate(cut.Instance, "_drawerMessageId"));
|
||||
|
||||
await cut.InvokeAsync(() => InvokePrivate(cut.Instance, pagingMethod));
|
||||
|
||||
// Paging away invalidates it — this surface has no keyed detail to fall back
|
||||
// on, so leaving it set would strand the drawer on an empty notice.
|
||||
Assert.Null(GetPrivate(cut.Instance, "_drawerMessageId"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigurationAuditLog_Paging_ClosesTheStateModal()
|
||||
{
|
||||
RegisterCommonServices();
|
||||
Services.AddSingleton(Substitute.For<ICentralUiRepository>());
|
||||
|
||||
var cut = Render<AuditLogPage>();
|
||||
|
||||
await cut.InvokeAsync(() => SetPrivate(cut.Instance, "_modalEntryId", 4242));
|
||||
Assert.Equal(4242, GetPrivate(cut.Instance, "_modalEntryId"));
|
||||
|
||||
await cut.InvokeAsync(() => InvokePrivate(cut.Instance, "OnPageChanged", 2));
|
||||
|
||||
Assert.Null(GetPrivate(cut.Instance, "_modalEntryId"));
|
||||
}
|
||||
|
||||
private sealed class StubAuthStateProvider : AuthenticationStateProvider
|
||||
{
|
||||
private readonly AuthenticationState _state;
|
||||
public StubAuthStateProvider(AuthenticationState state) => _state = state;
|
||||
public override Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||
=> Task.FromResult(_state);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user