diff --git a/.squad/agents/legolas/history.md b/.squad/agents/legolas/history.md index 7c364bb2..8aedd847 100644 --- a/.squad/agents/legolas/history.md +++ b/.squad/agents/legolas/history.md @@ -851,3 +851,33 @@ Then each variant only declares its colour-specific overrides. This is idiomatic - `cut.WaitForAssertion(...)` is required after `button[type='submit'].Click()` to await the async handler **Branch:** `squad/300-restrict-blog-post-edit-to-author-or-admin` + +--- + +## 2025-07-19 — Issue #307: Edit Page Loading State Fix + +### Problem + +`Edit.razor` used `_model is null && _error is null` as the "Loading..." condition. When a post is not found, `OnParametersSetAsync` calls `NavigateTo("/blog")` and `return`s early — never setting `_model` or `_error`. In bUnit (and briefly in real Blazor before circuit navigation completes), the page was permanently stuck on "Loading...". + +### Fix Applied + +- Added `private bool _isLoading = true;` field +- Changed template condition from `_model is null && _error is null` to `_isLoading` +- Added `role="status"` ARIA attribute to the loading paragraph +- Wrapped `OnParametersSetAsync` body in `try/finally { _isLoading = false; }` to guarantee clearing on ALL exit paths (including early `return` via `NavigateTo`) +- Updated `EditRedirectsToBlogWhenPostNotFound` bUnit test to assert `cut.Markup.Should().NotContain("Loading...")` after null-post result + +### Learnings + +**Pattern: `_isLoading` flag for async-loaded Blazor pages** + +- Never derive "is loading" from `_model is null && _error is null` — it fails on early-return paths +- Always use a dedicated `_isLoading` flag with `try/finally` in `OnParametersSetAsync` / `OnInitializedAsync` +- This pattern must be applied to all async-loaded pages in the codebase (List, Create, ManageRoles, Profile) + +**bUnit behaviour** + +- `NavigationManager.NavigateTo` in bUnit changes `.Uri` but does NOT unmount the component — so loading states must be explicitly cleared, not relied on navigation to resolve them + +**Filed:** `.squad/decisions/inbox/legolas-issue307-ui.md` diff --git a/src/Web/Features/BlogPosts/Edit/Edit.razor b/src/Web/Features/BlogPosts/Edit/Edit.razor index a51f58e9..e13db35e 100644 --- a/src/Web/Features/BlogPosts/Edit/Edit.razor +++ b/src/Web/Features/BlogPosts/Edit/Edit.razor @@ -26,9 +26,9 @@ } -@if (_model is null && _error is null) +@if (_isLoading) { -

Loading...

+

Loading...

} else if (_model is not null) { @@ -60,38 +60,47 @@ else if (_model is not null) private PostFormModel? _model; private string? _error; private bool _concurrencyError; + private bool _isLoading = true; private string _callerUserId = string.Empty; private bool _callerIsAdmin; protected override async Task OnParametersSetAsync() { - var result = await Sender.Send(new GetBlogPostByIdQuery(Id)); - if (result.Success) + try { - if (result.Value is not null) + var result = await Sender.Send(new GetBlogPostByIdQuery(Id)); + if (result.Success) { - var authState = await AuthStateProvider.GetAuthenticationStateAsync(); - var user = authState.User; - _callerUserId = user.FindFirst("sub")?.Value ?? string.Empty; - _callerIsAdmin = user.IsInRole("Admin"); - var isAuthor = result.Value.AuthorId == _callerUserId; + if (result.Value is not null) + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + _callerUserId = user.FindFirst("sub")?.Value ?? string.Empty; + _callerIsAdmin = user.IsInRole("Admin"); + var isAuthor = result.Value.AuthorId == _callerUserId; + + if (!_callerIsAdmin && !isAuthor) + { + Navigation.NavigateTo("/blog"); + return; + } - if (!_callerIsAdmin && !isAuthor) + _model = new PostFormModel { Title = result.Value.Title, Content = result.Value.Content }; + } + else { Navigation.NavigateTo("/blog"); return; } - - _model = new PostFormModel { Title = result.Value.Title, Content = result.Value.Content }; } else { - _model = null; + _error = result.Error; } } - else + finally { - _error = result.Error; + _isLoading = false; } } diff --git a/tests/Web.Tests.Bunit/Features/EditAclTests.cs b/tests/Web.Tests.Bunit/Features/EditAclTests.cs index 3e90c815..ad9021b8 100644 --- a/tests/Web.Tests.Bunit/Features/EditAclTests.cs +++ b/tests/Web.Tests.Bunit/Features/EditAclTests.cs @@ -31,6 +31,30 @@ public EditAclTests() Services.AddSingleton(_authProvider); } + [Fact] + public void EditRedirectsToBlogWhenPostNotFound() + { + // Arrange + var sender = Substitute.For(); + var postId = Guid.NewGuid(); + + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok(null))); + + Services.AddSingleton(sender); + + var navigation = Services.GetRequiredService(); + + // Act + var cut = RenderWithUser( + CreatePrincipalWithSub("auth0|some-user", ["Author"]), + parameters => parameters.Add(p => p.Id, postId)); + + // Assert + navigation.Uri.Should().EndWith("/blog"); + cut.Markup.Should().NotContain("Loading..."); + } + [Fact] public void EditRedirectsToBlogWhenAuthorIsNotPostOwner() {