From c9f1dd3d94718f363f36be1f9ad32108460ffab1 Mon Sep 17 00:00:00 2001 From: render93 Date: Wed, 3 Dec 2025 12:27:31 +0100 Subject: [PATCH] add pagination for restaurants in home page (#26) * add pagination into homepage * fix review count display * update model version in Grafana agent and release manager prompt --- .../1-pagination-homepage-20251111-000000.md | 51 +++ specs/1/US-1.md | 24 ++ .../Interfaces/IRestaurantService.cs | 1 + .../Services/RestaurantService.cs | 26 ++ src/DevEats.Web/Pages/Index.razor | 359 ++++++++++++++++-- .../Services/RestaurantServiceTests.cs | 230 +++++++++++ 6 files changed, 668 insertions(+), 23 deletions(-) create mode 100644 decision/1-pagination-homepage-20251111-000000.md create mode 100644 specs/1/US-1.md diff --git a/decision/1-pagination-homepage-20251111-000000.md b/decision/1-pagination-homepage-20251111-000000.md new file mode 100644 index 0000000..feb6b9d --- /dev/null +++ b/decision/1-pagination-homepage-20251111-000000.md @@ -0,0 +1,51 @@ +# Solution Decision Document + +## Problem Statement +Aggiungere la paginazione dei ristoranti nella home page (`Index.razor`), utilizzando una UX classica con controlli di paginazione e gestione della pagina corrente tramite query string nell’URL. Il backend supporta già la paginazione. + +## Goal +Permettere agli utenti di navigare tra più di 100 ristoranti tramite paginazione visibile e condivisibile via URL, mantenendo una UX familiare e ottimizzata per SEO. + +## Selected Approach +Paginazione classica con controlli "Precedente/Successivo", gestione della pagina corrente tramite query string (`?page=2`), stile e dimensione pagina coerenti con la paginazione già presente in `RestaurantDetails.razor`. + +## Alternatives Considered +- Option A: Infinite scroll — rejected because non richiesto, più complesso, meno SEO-friendly. +- Option B: Paginazione lato client — rejected perché non scalabile con >100 ristoranti. +- Option C: Paginazione senza query string — rejected perché non permette deep linking e condivisione. +- Option D: Paginazione classica con query string — selezionata per coerenza, SEO, UX e supporto backend. + +## Key Requirements + +### Functional +- Visualizzazione paginata dei ristoranti. +- Controlli di navigazione pagina (precedente/successivo). +- Aggiornamento della lista in base alla pagina selezionata. +- Gestione della pagina corrente tramite query string. +- Deep linking e condivisione della pagina. + +### Non-functional +- Performance: Caricamento solo dei ristoranti della pagina corrente. +- Scalabilità: Supporto a >100 ristoranti. +- Security: Nessun dato sensibile esposto. +- Reliability: Gestione robusta di errori e pagine vuote. +- Observability: Log di errori e pagine non trovate. + +## Constraints / Assumptions +- Il backend espone un metodo per ottenere ristoranti paginati. +- La UI può essere aggiornata per mostrare i controlli di paginazione. +- La dimensione pagina sarà coerente con quella delle recensioni (es. 5 o 10). +- La query string viene gestita correttamente dal routing Blazor. + +## Risks & Mitigations +- **Rischio:** Errori di sincronizzazione tra stato UI e URL. + - **Mitigazione:** Gestire OnParametersSetAsync per aggiornare la pagina corrente. +- **Rischio:** Navigazione su pagina non esistente. + - **Mitigazione:** Mostrare stato vuoto o tornare alla prima pagina. +- **Rischio:** Caricamento lento con molte pagine. + - **Mitigazione:** Limitare la dimensione pagina e ottimizzare query backend. + +## Open Questions +- La dimensione pagina preferita è 5, 10 o altro? +- Serve mostrare il numero totale di pagine/ristoranti? +- I controlli di paginazione devono essere visibili anche se c’è solo una pagina? diff --git a/specs/1/US-1.md b/specs/1/US-1.md new file mode 100644 index 0000000..923b560 --- /dev/null +++ b/specs/1/US-1.md @@ -0,0 +1,24 @@ +# User Story + +**Summary:** Paginate the homepage restaurant list + +**As a** user +**I want** to see restaurants on the homepage paginated, with controls at the bottom +**So that** I can easily browse large numbers of restaurants + +## Requirements +- Display restaurants on the homepage in pages of 3 items each +- Pagination controls are always shown at the bottom of the list +- Accessibility: controls must support keyboard navigation and ARIA labels +- Show a loading indicator when switching pages +- If a page fails to load, throw an error +- If there are zero restaurants or only one page, do not show pagination buttons + +## Non-functional considerations +- Accessibility: keyboard navigation, ARIA labels +- Performance: loading indicator for page transitions +- UX: clear error handling, no unnecessary controls + +## Notes +- Source: Solution Decision Document + approved clarifications +- These requirements are locked and not subject to change here diff --git a/src/DevEats.Core/Interfaces/IRestaurantService.cs b/src/DevEats.Core/Interfaces/IRestaurantService.cs index 7a63c1e..62a1534 100644 --- a/src/DevEats.Core/Interfaces/IRestaurantService.cs +++ b/src/DevEats.Core/Interfaces/IRestaurantService.cs @@ -6,6 +6,7 @@ public interface IRestaurantService { Task GetByIdAsync(int id); Task> GetAllAsync(); + Task> GetRestaurantsPagedAsync(int pageNumber, int pageSize); Task AddAsync(Restaurant restaurant); Task UpdateAsync(Restaurant restaurant); Task DeleteAsync(int id); diff --git a/src/DevEats.Infrastructure/Services/RestaurantService.cs b/src/DevEats.Infrastructure/Services/RestaurantService.cs index 00fbca3..69d62db 100644 --- a/src/DevEats.Infrastructure/Services/RestaurantService.cs +++ b/src/DevEats.Infrastructure/Services/RestaurantService.cs @@ -34,6 +34,32 @@ public async Task> GetAllAsync() .ToListAsync(); } + public async Task> GetRestaurantsPagedAsync(int pageNumber, int pageSize) + { + if (pageNumber < 1) pageNumber = 1; + if (pageSize < 1) pageSize = 1; + + var query = _context.Restaurants + .Include(r => r.Reviews) + .OrderByDescending(r => r.AverageRating); + + var totalCount = await query.CountAsync(); + + var items = await query + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + _logger.LogInformation("Fetched {RestaurantCount} restaurants for page {PageNumber}", items.Count, pageNumber); + return new PagedResult + { + Items = items, + TotalCount = totalCount, + PageNumber = pageNumber, + PageSize = pageSize + }; + } + public async Task AddAsync(Restaurant restaurant) { _context.Restaurants.Add(restaurant); diff --git a/src/DevEats.Web/Pages/Index.razor b/src/DevEats.Web/Pages/Index.razor index 52447ca..0604b88 100644 --- a/src/DevEats.Web/Pages/Index.razor +++ b/src/DevEats.Web/Pages/Index.razor @@ -1,10 +1,15 @@ @page "/" @using DevEats.Core.Interfaces @using DevEats.Core.Models +@using Microsoft.AspNetCore.Components.Web @inject IRestaurantService RestaurantService +@inject NavigationManager Navigation DevEats - Perché il refactoring a stomaco vuoto è pericoloso + + +
@@ -18,9 +23,13 @@
- @if (restaurants == null) +
+

Esplora i Ristoranti

+
+
+ @if (isLoading || pagedRestaurants == null) { -
+
@@ -29,7 +38,15 @@

Caricamento dei migliori ristoranti...

} - else if (!restaurants.Any()) + else if (!string.IsNullOrEmpty(errorMessage)) + { + + } + else if (pagedRestaurants.Items.Count() == 0) {
@@ -39,26 +56,21 @@ } else { -
-

Esplora i Ristoranti

-
-
-
- @foreach (var restaurant in restaurants) + @foreach (var restaurant in pagedRestaurants.Items) { -
+
-
+

@restaurant.Name

- + @restaurant.Address
@@ -68,20 +80,22 @@
@if (restaurant.AverageRating > 0) { -
+ @@ -91,25 +105,86 @@ else { - Nessuna recensione + Nessuna recensione }
- + Scopri - +
-
+
}
+ + @if (pagedRestaurants.TotalPages > 1) + { + + } }
@code { - private List? restaurants; + private PagedResult? pagedRestaurants; + private int currentPage = 1; + private int pageSize = 3; + private bool isLoading = false; + private string? errorMessage = null; + + [SupplyParameterFromQuery] + public int? Page { get; set; } protected override async Task OnInitializedAsync() { - var restaurantsDb = await RestaurantService.GetAllAsync(); - restaurants = restaurantsDb.ToList(); + if (Page.HasValue && Page.Value > 0) + { + currentPage = Page.Value; + } + + await LoadRestaurants(); + } + + protected override async Task OnParametersSetAsync() + { + if (Page.HasValue && Page.Value != currentPage && Page.Value > 0) + { + currentPage = Page.Value; + await LoadRestaurants(); + } + } + + private async Task LoadRestaurants() + { + try + { + isLoading = true; + errorMessage = null; + StateHasChanged(); + + pagedRestaurants = await RestaurantService.GetRestaurantsPagedAsync(currentPage, pageSize); + } + catch (Exception) + { + errorMessage = "Si è verificato un errore durante il caricamento dei ristoranti. Riprova più tardi."; + pagedRestaurants = new PagedResult + { + Items = new List(), + TotalCount = 0, + PageNumber = currentPage, + PageSize = pageSize + }; + } + finally + { + isLoading = false; + StateHasChanged(); + } + } + + private async Task ChangePage(int newPage) + { + if (newPage < 1 || (pagedRestaurants != null && newPage > pagedRestaurants.TotalPages) || newPage == currentPage) + { + return; + } + + currentPage = newPage; + Navigation.NavigateTo($"/?page={currentPage}"); + await LoadRestaurants(); + } + + private async Task HandleKeyDown(KeyboardEventArgs e) + { + if (pagedRestaurants == null) + { + return; + } + + switch (e.Key) + { + case "ArrowLeft": + if (pagedRestaurants.HasPreviousPage) + { + await ChangePage(currentPage - 1); + } + break; + case "ArrowRight": + if (pagedRestaurants.HasNextPage) + { + await ChangePage(currentPage + 1); + } + break; + case "Home": + if (currentPage != 1) + { + await ChangePage(1); + } + break; + case "End": + if (currentPage != pagedRestaurants.TotalPages) + { + await ChangePage(pagedRestaurants.TotalPages); + } + break; + } } } diff --git a/tests/DevEats.Tests/Services/RestaurantServiceTests.cs b/tests/DevEats.Tests/Services/RestaurantServiceTests.cs index 694950b..0abcac8 100644 --- a/tests/DevEats.Tests/Services/RestaurantServiceTests.cs +++ b/tests/DevEats.Tests/Services/RestaurantServiceTests.cs @@ -181,4 +181,234 @@ public async Task DeleteAsync_DoesNothingForNonExistentRestaurant() // Act & Assert - Should not throw exception Assert.DoesNotThrowAsync(async () => await _restaurantService.DeleteAsync(999)); } + + [Test] + public async Task GetRestaurantsPagedAsync_ReturnsCorrectPage() + { + // Arrange - Create 10 restaurants + for (int i = 1; i <= 10; i++) + { + var restaurant = new Restaurant + { + Name = $"Restaurant {i}", + Address = $"Address {i}", + Description = $"Description {i}", + AverageRating = i * 0.5 + }; + await _context.Restaurants.AddAsync(restaurant); + } + await _context.SaveChangesAsync(); + + // Act - Get page 2 with 3 items per page + var result = await _restaurantService.GetRestaurantsPagedAsync(2, 3); + + // Assert + Assert.That(result.Items.Count(), Is.EqualTo(3)); + Assert.That(result.PageNumber, Is.EqualTo(2)); + Assert.That(result.PageSize, Is.EqualTo(3)); + Assert.That(result.TotalCount, Is.EqualTo(10)); + Assert.That(result.TotalPages, Is.EqualTo(4)); + } + + [Test] + public async Task GetRestaurantsPagedAsync_ReturnsCorrectTotalCount() + { + // Arrange - Create 7 restaurants + for (int i = 1; i <= 7; i++) + { + var restaurant = new Restaurant + { + Name = $"Restaurant {i}", + Address = $"Address {i}", + Description = $"Description {i}", + AverageRating = 4.0 + }; + await _context.Restaurants.AddAsync(restaurant); + } + await _context.SaveChangesAsync(); + + // Act + var result = await _restaurantService.GetRestaurantsPagedAsync(1, 3); + + // Assert + Assert.That(result.TotalCount, Is.EqualTo(7)); + Assert.That(result.TotalPages, Is.EqualTo(3)); + } + + [Test] + public async Task GetRestaurantsPagedAsync_HandlesEmptyResults() + { + // Act + var result = await _restaurantService.GetRestaurantsPagedAsync(1, 3); + + // Assert + Assert.That(result.Items, Is.Empty); + Assert.That(result.TotalCount, Is.EqualTo(0)); + Assert.That(result.TotalPages, Is.EqualTo(0)); + Assert.That(result.HasPreviousPage, Is.False); + Assert.That(result.HasNextPage, Is.False); + } + + [Test] + public async Task GetRestaurantsPagedAsync_HandlesSinglePageScenario() + { + // Arrange - Create only 2 restaurants (less than page size of 3) + var restaurants = new[] + { + new Restaurant { Name = "Restaurant 1", Address = "Address 1", Description = "Desc 1", AverageRating = 4.5 }, + new Restaurant { Name = "Restaurant 2", Address = "Address 2", Description = "Desc 2", AverageRating = 3.5 } + }; + await _context.Restaurants.AddRangeAsync(restaurants); + await _context.SaveChangesAsync(); + + // Act + var result = await _restaurantService.GetRestaurantsPagedAsync(1, 3); + + // Assert + Assert.That(result.Items.Count(), Is.EqualTo(2)); + Assert.That(result.TotalPages, Is.EqualTo(1)); + Assert.That(result.HasPreviousPage, Is.False); + Assert.That(result.HasNextPage, Is.False); + } + + [Test] + public async Task GetRestaurantsPagedAsync_HandlesMultiplePages() + { + // Arrange - Create 9 restaurants (3 full pages) + for (int i = 1; i <= 9; i++) + { + var restaurant = new Restaurant + { + Name = $"Restaurant {i}", + Address = $"Address {i}", + Description = $"Description {i}", + AverageRating = 4.0 + }; + await _context.Restaurants.AddAsync(restaurant); + } + await _context.SaveChangesAsync(); + + // Act + var result = await _restaurantService.GetRestaurantsPagedAsync(1, 3); + + // Assert + Assert.That(result.TotalPages, Is.EqualTo(3)); + Assert.That(result.Items.Count(), Is.EqualTo(3)); + } + + [Test] + public async Task GetRestaurantsPagedAsync_OrdersByAverageRatingDescending() + { + // Arrange + var restaurants = new[] + { + new Restaurant { Name = "Low Rated", Address = "Address 1", Description = "Desc 1", AverageRating = 2.5 }, + new Restaurant { Name = "High Rated", Address = "Address 2", Description = "Desc 2", AverageRating = 4.8 }, + new Restaurant { Name = "Medium Rated", Address = "Address 3", Description = "Desc 3", AverageRating = 3.5 } + }; + await _context.Restaurants.AddRangeAsync(restaurants); + await _context.SaveChangesAsync(); + + // Act + var result = await _restaurantService.GetRestaurantsPagedAsync(1, 3); + + // Assert + var restaurantList = result.Items.ToList(); + Assert.That(restaurantList[0].Name, Is.EqualTo("High Rated")); + Assert.That(restaurantList[1].Name, Is.EqualTo("Medium Rated")); + Assert.That(restaurantList[2].Name, Is.EqualTo("Low Rated")); + } + + [Test] + public async Task GetRestaurantsPagedAsync_HandlesInvalidPageNumber() + { + // Arrange - Create 5 restaurants + for (int i = 1; i <= 5; i++) + { + var restaurant = new Restaurant + { + Name = $"Restaurant {i}", + Address = $"Address {i}", + Description = $"Description {i}", + AverageRating = 4.0 + }; + await _context.Restaurants.AddAsync(restaurant); + } + await _context.SaveChangesAsync(); + + // Act - Request page 0 (invalid, should default to 1) + var result = await _restaurantService.GetRestaurantsPagedAsync(0, 3); + + // Assert + Assert.That(result.PageNumber, Is.EqualTo(1)); + Assert.That(result.Items.Count(), Is.EqualTo(3)); + } + + [Test] + public async Task GetRestaurantsPagedAsync_HasPreviousPageAndNextPageMetadataIsCorrect() + { + // Arrange - Create 9 restaurants (3 pages of 3 items each) + for (int i = 1; i <= 9; i++) + { + var restaurant = new Restaurant + { + Name = $"Restaurant {i}", + Address = $"Address {i}", + Description = $"Description {i}", + AverageRating = 4.0 + }; + await _context.Restaurants.AddAsync(restaurant); + } + await _context.SaveChangesAsync(); + + // Act - Get page 1 + var page1 = await _restaurantService.GetRestaurantsPagedAsync(1, 3); + + // Assert page 1 metadata + Assert.That(page1.HasPreviousPage, Is.False); + Assert.That(page1.HasNextPage, Is.True); + + // Act - Get page 2 + var page2 = await _restaurantService.GetRestaurantsPagedAsync(2, 3); + + // Assert page 2 metadata + Assert.That(page2.HasPreviousPage, Is.True); + Assert.That(page2.HasNextPage, Is.True); + + // Act - Get page 3 + var page3 = await _restaurantService.GetRestaurantsPagedAsync(3, 3); + + // Assert page 3 metadata + Assert.That(page3.HasPreviousPage, Is.True); + Assert.That(page3.HasNextPage, Is.False); + } + + [Test] + public async Task GetRestaurantsPagedAsync_HandlesLastPageWithPartialResults() + { + // Arrange - Create 8 restaurants (last page will have 2 items) + for (int i = 1; i <= 8; i++) + { + var restaurant = new Restaurant + { + Name = $"Restaurant {i}", + Address = $"Address {i}", + Description = $"Description {i}", + AverageRating = 4.0 + }; + await _context.Restaurants.AddAsync(restaurant); + } + await _context.SaveChangesAsync(); + + // Act - Get page 3 (last page) with 3 items per page + var result = await _restaurantService.GetRestaurantsPagedAsync(3, 3); + + // Assert + Assert.That(result.Items.Count(), Is.EqualTo(2)); + Assert.That(result.PageNumber, Is.EqualTo(3)); + Assert.That(result.TotalCount, Is.EqualTo(8)); + Assert.That(result.TotalPages, Is.EqualTo(3)); + Assert.That(result.HasPreviousPage, Is.True); + Assert.That(result.HasNextPage, Is.False); + } }