Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .squad/agents/legolas/history.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
41 changes: 25 additions & 16 deletions src/Web/Features/BlogPosts/Edit/Edit.razor
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@
</div>
}

@if (_model is null && _error is null)
@if (_isLoading)
{
<p class="text-gray-600 dark:text-gray-400">Loading...</p>
<p class="text-gray-600 dark:text-gray-400" role="status">Loading...</p>
}
else if (_model is not null)
{
Expand Down Expand Up @@ -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)
Comment on lines 67 to +72
{
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;
}
}

Expand Down
24 changes: 24 additions & 0 deletions tests/Web.Tests.Bunit/Features/EditAclTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,30 @@ public EditAclTests()
Services.AddSingleton<AuthenticationStateProvider>(_authProvider);
}

[Fact]
public void EditRedirectsToBlogWhenPostNotFound()
{
// Arrange
var sender = Substitute.For<ISender>();
var postId = Guid.NewGuid();

sender.Send(Arg.Any<GetBlogPostByIdQuery>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(Result.Ok<BlogPostDto?>(null)));

Services.AddSingleton(sender);

var navigation = Services.GetRequiredService<NavigationManager>();

// Act
var cut = RenderWithUser<Edit>(
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()
{
Expand Down
Loading