Skip to content
Open
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
14 changes: 14 additions & 0 deletions e2e/BrowseItemTest.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,18 @@ test('Browse Items', async ({ page }) => {

//Expect
await expect(page.getByRole('heading', { name: 'Adventurer GPS Watch' })).toBeVisible();
});

test('Favorites redirect to sign-in when signed out', async ({ page }) => {
await page.goto('/');

await expect(page.getByRole('heading', { name: 'Ready for a new adventure?' })).toBeVisible();

const productName = 'Adventurer GPS Watch';
const card = page.locator('.catalog-item-with-favorites').filter({
has: page.getByRole('link', { name: productName }),
});

await card.locator('button[aria-label="Log in to save favorites"]').first().click();
await expect(page.getByRole('heading', { name: 'Login' })).toBeVisible();
});
45 changes: 45 additions & 0 deletions e2e/FavoritesTest.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { test, expect } from '@playwright/test';

test('Shopper Favorites: save from catalog, view in header, toggle off/on', async ({ page }) => {
await page.goto('/');

const productName = 'Adventurer GPS Watch';

await expect(page.getByRole('heading', { name: 'Ready for a new adventure?' })).toBeVisible();

// Favorite from catalog card.
const card = page.locator('.catalog-item-with-favorites').filter({
has: page.getByRole('link', { name: productName }),
});

const urlBefore = page.url();
await card.locator('button[aria-label="Save to favorites"]').first().click();
await expect(page).toHaveURL(urlBefore);

// Header should reflect the new favorites count.
const headerFavoritesButton = page.getByRole('button', { name: 'Favorites' });
await expect(page.locator('.favorites-badge')).toHaveText('1');

// Open favorites quick list.
await headerFavoritesButton.click();
const dropdown = page.locator('.favorites-dropdown-content');
await expect(dropdown.getByRole('link', { name: productName })).toBeVisible();

// Navigate back to the favorited item.
await dropdown.getByRole('link', { name: productName }).click();
await expect(page.getByRole('heading', { name: productName })).toBeVisible();

// Toggle off from item page.
const removeButton = page.getByRole('button', { name: 'Remove from favorites' });
await removeButton.click();
await headerFavoritesButton.click();
await expect(page.locator('.favorites-badge')).toHaveCount(0);

// Toggle back on; duplicates must not accumulate.
await page.locator('button[aria-label="Save to favorites"]').first().click();
await headerFavoritesButton.click();

const occurrencesInDropdown = page.locator('.favorites-dropdown-content').getByRole('link', { name: productName });
await expect(occurrencesInDropdown).toHaveCount(1);
});

6 changes: 3 additions & 3 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export default defineConfig({
},
{
name: 'e2e tests logged in',
testMatch: ['**/AddItemTest.spec.ts', '**/RemoveItemTest.spec.ts'],
testMatch: ['**/AddItemTest.spec.ts', '**/RemoveItemTest.spec.ts', '**/FavoritesTest.spec.ts'],
dependencies: ['setup'],
use: {
storageState: STORAGE_STATE,
Expand Down Expand Up @@ -85,11 +85,11 @@ export default defineConfig({

/* Run your local dev server before starting the tests */
webServer: {
command: 'dotnet run --project src/eShop.AppHost/eShop.AppHost.csproj',
command: 'ESHOP_USE_HTTP_ENDPOINTS=1 dotnet run --project src/eShop.AppHost/eShop.AppHost.csproj -p:NuGetAudit=false -p:LibraryRestore=False',
url: 'http://localhost:5045',
reuseExistingServer: !process.env.CI,
stderr: 'pipe',
stdout: 'pipe',
timeout: process.env.CI ? (5 * 60_000) : 60_000,
timeout: process.env.CI ? (5 * 60_000) : 180_000,
},
});
77 changes: 77 additions & 0 deletions src/WebApp/Components/Catalog/CatalogListItemWithFavorites.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
@using eShop.WebAppComponents.Item
@using eShop.WebAppComponents.Catalog
@inject IProductImageUrlProvider ProductImages
@inject eShop.WebApp.Services.IFavoritesState FavoritesState
@inject NavigationManager Nav

@implements IDisposable

<div class="catalog-item catalog-item-with-favorites">
<a class="catalog-product" href="@ItemHelper.Url(Item)" data-enhance-nav="false">
<span class='catalog-product-image'>
<img alt="@Item.Name" src='@ProductImages.GetProductImageUrl(Item)' />
</span>
<span class='catalog-product-content'>
<span class='name'>@Item.Name</span>
<span class='price'>$@Item.Price.ToString("0.00")</span>
</span>
</a>

<button type="button"
class="favorites-heart-button"
aria-label="@(IsLoggedIn ? (isFavorited ? "Remove from favorites" : "Save to favorites") : "Log in to save favorites")"
title="@(IsLoggedIn ? (isFavorited ? "Remove from favorites" : "Save to favorites") : "Log in to save favorites")"
@onclick="ToggleFavoriteAsync">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Static components use click handlers

High Severity

CatalogListItemWithFavorites and FavoritesMenu wire @onclick handlers but neither component declares an interactive render mode, while catalog and layout pages render statically. In Blazor Web Apps, static SSR does not dispatch those events, so catalog hearts, the header favorites control, and signed-out login redirects from the catalog will not run.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit cf51688. Configure here.

<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M12 21s-7-4.35-9.33-8.28C.72 9.23 2.05 6 5.5 6c1.74 0 3.17.92 4 2.08C10.33 6.92 11.76 6 13.5 6c3.45 0 4.78 3.23 2.83 6.72C19 16.65 12 21 12 21z"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
fill="@HeartFill" />
</svg>
</button>
</div>

@code {
[Parameter, EditorRequired]
public required CatalogItem Item { get; set; }

[Parameter]
public bool IsLoggedIn { get; set; }

private IDisposable? favoritesSubscription;
private bool isFavorited;

private string HeartFill => IsLoggedIn && isFavorited ? "currentColor" : "none";

protected override void OnInitialized()
{
isFavorited = IsLoggedIn && FavoritesState.Contains(Item.Id);
favoritesSubscription = FavoritesState.NotifyOnChange(
EventCallback.Factory.Create(this, UpdateIsFavoritedAsync));
}

private Task ToggleFavoriteAsync()
{
if (!IsLoggedIn)
{
Nav.NavigateTo(Pages.User.LogIn.Url(Nav));
return Task.CompletedTask;
}

return FavoritesState.ToggleAsync(Item);
}

private Task UpdateIsFavoritedAsync()
{
isFavorited = IsLoggedIn && FavoritesState.Contains(Item.Id);
return InvokeAsync(StateHasChanged);
}

public void Dispose()
{
favoritesSubscription?.Dispose();
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
.catalog-item {
flex-basis: calc(33.33% - 2.5rem);
flex-shrink: 0;
box-sizing: border-box;
padding: 2px;
position: relative;
}

.catalog-item:hover {
cursor: pointer;
padding: 0;
border: 2px solid #000;
}

.catalog-product {
background-color: transparent;
padding: 0;
margin: 0;
border: 0;
display: block;
}

.catalog-product-image img {
max-width: 100%;
}

.catalog-product .catalog-product-content {
display: flex;
padding: 0 0.75rem;
align-items: center;
gap: 0.5rem;
align-self: stretch;
}

.catalog-product-content .name {
color: #000;
font-size: 1rem;
font-style: normal;
font-weight: 600;
line-height: 150%;
text-align: left;
}

.catalog-product-content .price {
color: #444;
text-align: right;
font-size: 1rem;
font-style: normal;
font-weight: 600;
line-height: 150%;
margin-left: auto;
}

.favorites-heart-button {
position: absolute;
top: 0.75rem;
right: 0.75rem;
z-index: 2;
background: transparent;
border: 0;
padding: 0.25rem;
cursor: pointer;
color: #000;
}

.favorites-heart-button:hover {
background: #f0efeb;
border-radius: 0.25rem;
}

@media only screen and (max-width: 480px) {
.catalog-item {
flex-basis: calc(100% - 2rem);
}
}

@media only screen and (min-width: 481px) and (max-width: 1024px) {
.catalog-item {
flex-basis: calc(50% - 3rem);
}
}

91 changes: 91 additions & 0 deletions src/WebApp/Components/Layout/FavoritesMenu.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
@using eShop.WebAppComponents.Catalog
@using eShop.WebAppComponents.Item
@inject eShop.WebApp.Services.IFavoritesState FavoritesState
@inject NavigationManager Nav

@attribute [Microsoft.AspNetCore.Components.StreamRendering]
@implements IDisposable

<div class="favorites-menu" @onkeydown="OnKeyDown" tabindex="-1">
<button class="favorites-button" type="button" @onclick="OnFavoritesClicked" aria-label="Favorites" aria-haspopup="menu" aria-expanded="@_isOpen">
<svg class="favorites-heart" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M12 21s-7-4.35-9.33-8.28C.72 9.23 2.05 6 5.5 6c1.74 0 3.17.92 4 2.08C10.33 6.92 11.76 6 13.5 6c3.45 0 4.78 3.23 2.83 6.72C19 16.65 12 21 12 21z"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
fill="@HeartFill" />
</svg>
@if (IsLoggedInAndHasFavorites)
{
<span class="favorites-badge">@favoritesCount</span>
}
</button>

@if (_isOpen && IsLoggedInAndHasFavorites)
{
<div class="favorites-dropdown-content" role="menu" aria-label="Favorites list" tabindex="0">
<div class="favorites-dropdown-heading">Favorites</div>
@foreach (var item in favorites!)
{
<a class="favorites-dropdown-item" href="@ItemHelper.Url(item)" data-enhance-nav="false">
<span class="favorites-dropdown-item-name">@item.Name</span>
<span class="favorites-dropdown-item-price">$@item.Price.ToString("0.00")</span>
</a>
}
</div>
}
</div>

@code {
[Microsoft.AspNetCore.Components.CascadingParameter]
public HttpContext? HttpContext { get; set; }

private IDisposable? favoritesSubscription;
private IReadOnlyList<CatalogItem>? favorites;
private bool _isOpen;
private bool IsLoggedInAndHasFavorites => HttpContext?.User.Identity?.IsAuthenticated == true && favoritesCount > 0;
private int favoritesCount => favorites?.Count ?? 0;
private string HeartFill => IsLoggedInAndHasFavorites ? "currentColor" : "none";

protected override async Task OnInitializedAsync()
{
favoritesSubscription = FavoritesState.NotifyOnChange(
EventCallback.Factory.Create(this, UpdateFavoritesAsync));

await UpdateFavoritesAsync();
}

private async Task UpdateFavoritesAsync()
{
favorites = await FavoritesState.GetFavoritesAsync();
await InvokeAsync(StateHasChanged);
}

private void OnFavoritesClicked()
{
if (HttpContext?.User.Identity?.IsAuthenticated != true)
{
Nav.NavigateTo(Pages.User.LogIn.Url(Nav));
return;
}

_isOpen = !_isOpen;
}

private Task OnKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Escape")
{
_isOpen = false;
}

return Task.CompletedTask;
}

public void Dispose()
{
favoritesSubscription?.Dispose();
}
}

Loading
Loading