diff --git a/e2e/BrowseItemTest.spec.ts b/e2e/BrowseItemTest.spec.ts index d702dbd..c14f67b 100644 --- a/e2e/BrowseItemTest.spec.ts +++ b/e2e/BrowseItemTest.spec.ts @@ -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(); }); \ No newline at end of file diff --git a/e2e/FavoritesTest.spec.ts b/e2e/FavoritesTest.spec.ts new file mode 100644 index 0000000..2e5364d --- /dev/null +++ b/e2e/FavoritesTest.spec.ts @@ -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); +}); + diff --git a/playwright.config.ts b/playwright.config.ts index 9728afb..3b7a9c7 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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, @@ -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, }, }); diff --git a/src/WebApp/Components/Catalog/CatalogListItemWithFavorites.razor b/src/WebApp/Components/Catalog/CatalogListItemWithFavorites.razor new file mode 100644 index 0000000..14b4974 --- /dev/null +++ b/src/WebApp/Components/Catalog/CatalogListItemWithFavorites.razor @@ -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 + +
+ +@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(); + } +} + diff --git a/src/WebApp/Components/Catalog/CatalogListItemWithFavorites.razor.css b/src/WebApp/Components/Catalog/CatalogListItemWithFavorites.razor.css new file mode 100644 index 0000000..70b396f --- /dev/null +++ b/src/WebApp/Components/Catalog/CatalogListItemWithFavorites.razor.css @@ -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); + } +} + diff --git a/src/WebApp/Components/Layout/FavoritesMenu.razor b/src/WebApp/Components/Layout/FavoritesMenu.razor new file mode 100644 index 0000000..b4ff552 --- /dev/null +++ b/src/WebApp/Components/Layout/FavoritesMenu.razor @@ -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 + + + +@code { + [Microsoft.AspNetCore.Components.CascadingParameter] + public HttpContext? HttpContext { get; set; } + + private IDisposable? favoritesSubscription; + private IReadOnlyListBrand: @item.CatalogBrand?.Brand
-