feat(store-home): implement resolver for product data and enhance component logic for improved error handling and loading states

This commit is contained in:
2026-07-08 16:04:11 -03:00
parent efa0d48a40
commit 6c00a3e2bb
8 changed files with 285 additions and 136 deletions

View File

@@ -1,42 +1,44 @@
<app-store-section title="Productos">
<div class="d-grid gap-4">
@if (error()) {
<p class="alert text-center mb-0 store-home__alert-error">{{ error() }}</p>
} @else if (loading() && !productCards().length) {
<p class="alert alert-light border text-center mb-0">Cargando productos...</p>
} @else if (!productCards().length) {
<p class="alert alert-light border text-center mb-0">No hay productos disponibles en este momento.</p>
} @else {
<div class="row row-cols-1 row-cols-md-2 row-cols-xl-4 g-4">
@for (product of productCards(); track product.id) {
<div class="col">
<app-product-card
[title]="product.title"
[originalPrice]="product.originalPrice"
[discount]="product.discount"
[transferPrice]="product.transferPrice"
[imageUrl]="product.imageUrl"
(buy)="onBuyProduct(product.id)"
/>
</div>
}
</div>
}
@if (loading() && productCards().length) {
<p class="alert alert-light border text-center mb-0 py-2">Actualizando productos...</p>
}
@if (totalPages() > 1) {
<div class="d-flex justify-content-center">
<app-paginator
[page]="currentPage()"
[totalPages]="totalPages()"
[disabled]="loading()"
(pageChange)="onPageChange($event)"
<app-store-section title="Productos">
<div class="d-grid gap-4">
@if (error()) {
<p class="alert text-center mb-0 store-home__alert-error">{{ error() }}</p>
} @else if (loading() && !productCards().length) {
<p class="alert alert-light border text-center mb-0">Cargando productos...</p>
} @else if (!productCards().length) {
<p class="alert alert-light border text-center mb-0">
No hay productos disponibles en este momento.
</p>
} @else {
<div class="row row-cols-1 row-cols-md-2 row-cols-xl-4 g-4">
@for (product of productCards(); track product.id; let index = $index) {
<div class="col">
<app-product-card
[title]="product.title"
[originalPrice]="product.originalPrice"
[discount]="product.discount"
[transferPrice]="product.transferPrice"
[imageUrl]="product.imageUrl"
[imagePriority]="index < priorityImageCount"
(buy)="onBuyProduct(product.id)"
/>
</div>
}
</div>
</app-store-section>
}
@if (loading() && productCards().length) {
<p class="alert alert-light border text-center mb-0 py-2">Actualizando productos...</p>
}
@if (totalPages() > 1) {
<div class="d-flex justify-content-center">
<app-paginator
[page]="currentPage()"
[totalPages]="totalPages()"
[disabled]="loading()"
(pageChange)="onPageChange($event)"
/>
</div>
}
</div>
</app-store-section>

View File

@@ -1,16 +1,21 @@
import { TestBed } from '@angular/core/testing';
import { Subject, of, throwError } from 'rxjs';
import { ActivatedRoute } from '@angular/router';
import { Subject, of } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiPaginatedResponse } from '../../../../core/services/api-paginated-response.interface';
import { Product } from '../../../../core/services/catalog/catalog.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { StoreHomePageComponent } from './store-home-page.component';
import {
STORE_HOME_PRODUCTS_ERROR_MESSAGE,
StoreHomeProductsResolvedData,
} from './store-home-page.resolver';
function createPaginatedResponse(
products: Product[],
currentPage: number,
lastPage: number
lastPage: number,
): ApiPaginatedResponse<Product[]> {
return {
data: products,
@@ -22,14 +27,34 @@ function createPaginatedResponse(
path: '/productos',
per_page: 12,
to: products.length || null,
total: products.length
total: products.length,
},
links: {
first: '/productos?page=1',
last: `/productos?page=${lastPage}`,
prev: currentPage > 1 ? `/productos?page=${currentPage - 1}` : null,
next: currentPage < lastPage ? `/productos?page=${currentPage + 1}` : null
}
next: currentPage < lastPage ? `/productos?page=${currentPage + 1}` : null,
},
};
}
function createResolvedData(
response: ApiPaginatedResponse<Product[]>,
): StoreHomeProductsResolvedData {
return {
response,
error: null,
};
}
function provideActivatedRoute(productsData: StoreHomeProductsResolvedData) {
return {
provide: ActivatedRoute,
useValue: {
snapshot: {
data: { productsData },
},
},
};
}
@@ -43,9 +68,9 @@ describe('StoreHomePageComponent', () => {
nombre: 'Auriculares Bluetooth',
descripcion: 'Auriculares bluetooth de prueba',
precio: '24999',
category: 'Tecnología',
category: 'Tecnologia',
brand: null,
images: []
images: ['/catalog/auriculares.jpg'],
},
{
id: 2,
@@ -53,31 +78,32 @@ describe('StoreHomePageComponent', () => {
brand_id: null,
slug: 'teclado-mecanico',
nombre: 'Teclado Mecanico',
descripcion: 'Teclado mecánico de prueba',
descripcion: 'Teclado mecanico de prueba',
precio: '18999',
category: 'Tecnología',
category: 'Tecnologia',
brand: null,
images: []
}
images: [],
},
];
beforeEach(() => {
vi.restoreAllMocks();
});
it('loads page 1 on init, renders the products title and the fetched product cards', async () => {
it('renders the products resolved by the route before component init', async () => {
const catalogServiceStub = {
getProductos: vi.fn().mockReturnValue(of(createPaginatedResponse(pageOneProducts, 1, 3)))
getProductos: vi.fn(),
};
await TestBed.configureTestingModule({
imports: [StoreHomePageComponent],
providers: [
provideActivatedRoute(createResolvedData(createPaginatedResponse(pageOneProducts, 1, 3))),
{
provide: CatalogService,
useValue: catalogServiceStub
}
]
useValue: catalogServiceStub,
},
],
}).compileComponents();
const fixture = TestBed.createComponent(StoreHomePageComponent);
@@ -85,51 +111,52 @@ describe('StoreHomePageComponent', () => {
const element = fixture.nativeElement as HTMLElement;
expect(catalogServiceStub.getProductos).toHaveBeenCalledWith({ page: 1 });
expect(catalogServiceStub.getProductos).not.toHaveBeenCalled();
expect(element.querySelector('.store-section__title')?.textContent?.trim()).toBe('Productos');
expect(element.querySelectorAll('app-product-card')).toHaveLength(2);
expect(element.textContent).toContain('Auriculares Bluetooth');
expect(element.textContent).toContain('Teclado Mecanico');
expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe('1/3');
expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe(
'1/3',
);
expect(element.querySelector('img')?.getAttribute('fetchpriority')).toBe('high');
});
it('requests the next page when the paginator emits a page change', async () => {
const catalogServiceStub = {
getProductos: vi
.fn()
.mockReturnValueOnce(of(createPaginatedResponse(pageOneProducts, 1, 3)))
.mockReturnValueOnce(
of(
createPaginatedResponse(
[
{
id: 3,
category_id: 12,
brand_id: null,
slug: 'mouse-gamer',
nombre: 'Mouse Gamer',
descripcion: 'Mouse gamer de prueba',
precio: '15999',
category: 'Tecnología',
brand: null,
images: []
}
],
2,
3
)
)
)
getProductos: vi.fn().mockReturnValueOnce(
of(
createPaginatedResponse(
[
{
id: 3,
category_id: 12,
brand_id: null,
slug: 'mouse-gamer',
nombre: 'Mouse Gamer',
descripcion: 'Mouse gamer de prueba',
precio: '15999',
category: 'Tecnologia',
brand: null,
images: [],
},
],
2,
3,
),
),
),
};
await TestBed.configureTestingModule({
imports: [StoreHomePageComponent],
providers: [
provideActivatedRoute(createResolvedData(createPaginatedResponse(pageOneProducts, 1, 3))),
{
provide: CatalogService,
useValue: catalogServiceStub
}
]
useValue: catalogServiceStub,
},
],
}).compileComponents();
const fixture = TestBed.createComponent(StoreHomePageComponent);
@@ -139,29 +166,29 @@ describe('StoreHomePageComponent', () => {
(element.querySelector('[data-testid="paginator-next"]') as HTMLButtonElement).click();
fixture.detectChanges();
expect(catalogServiceStub.getProductos).toHaveBeenNthCalledWith(1, { page: 1 });
expect(catalogServiceStub.getProductos).toHaveBeenNthCalledWith(2, { page: 2 });
expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe('2/3');
expect(catalogServiceStub.getProductos).toHaveBeenCalledTimes(1);
expect(catalogServiceStub.getProductos).toHaveBeenCalledWith({ page: 2 });
expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe(
'2/3',
);
expect(element.textContent).toContain('Mouse Gamer');
});
it('disables the paginator while a new page request is in flight', async () => {
const nextPageSubject = new Subject<ApiPaginatedResponse<Product[]>>();
const catalogServiceStub = {
getProductos: vi
.fn()
.mockReturnValueOnce(of(createPaginatedResponse(pageOneProducts, 1, 3)))
.mockReturnValueOnce(nextPageSubject.asObservable())
getProductos: vi.fn().mockReturnValueOnce(nextPageSubject.asObservable()),
};
await TestBed.configureTestingModule({
imports: [StoreHomePageComponent],
providers: [
provideActivatedRoute(createResolvedData(createPaginatedResponse(pageOneProducts, 1, 3))),
{
provide: CatalogService,
useValue: catalogServiceStub
}
]
useValue: catalogServiceStub,
},
],
}).compileComponents();
const fixture = TestBed.createComponent(StoreHomePageComponent);
@@ -173,8 +200,8 @@ describe('StoreHomePageComponent', () => {
expect(
Array.from(element.querySelectorAll('app-paginator button')).every(
(button) => (button as HTMLButtonElement).disabled
)
(button) => (button as HTMLButtonElement).disabled,
),
).toBe(true);
expect(element.textContent).toContain('Actualizando productos...');
@@ -184,24 +211,25 @@ describe('StoreHomePageComponent', () => {
expect(
Array.from(element.querySelectorAll('app-paginator button')).some(
(button) => !(button as HTMLButtonElement).disabled
)
(button) => !(button as HTMLButtonElement).disabled,
),
).toBe(true);
});
it('shows an empty-state message and hides the paginator when there are no products', async () => {
const catalogServiceStub = {
getProductos: vi.fn().mockReturnValue(of(createPaginatedResponse([], 1, 1)))
getProductos: vi.fn(),
};
await TestBed.configureTestingModule({
imports: [StoreHomePageComponent],
providers: [
provideActivatedRoute(createResolvedData(createPaginatedResponse([], 1, 1))),
{
provide: CatalogService,
useValue: catalogServiceStub
}
]
useValue: catalogServiceStub,
},
],
}).compileComponents();
const fixture = TestBed.createComponent(StoreHomePageComponent);
@@ -211,21 +239,26 @@ describe('StoreHomePageComponent', () => {
expect(element.textContent).toContain('No hay productos disponibles en este momento.');
expect(element.querySelector('app-paginator')).toBeNull();
expect(catalogServiceStub.getProductos).not.toHaveBeenCalled();
});
it('shows an error message when the catalog request fails', async () => {
it('shows an error message when the route resolver cannot load the catalog', async () => {
const catalogServiceStub = {
getProductos: vi.fn().mockReturnValue(throwError(() => new Error('boom')))
getProductos: vi.fn(),
};
await TestBed.configureTestingModule({
imports: [StoreHomePageComponent],
providers: [
provideActivatedRoute({
response: null,
error: STORE_HOME_PRODUCTS_ERROR_MESSAGE,
}),
{
provide: CatalogService,
useValue: catalogServiceStub
}
]
useValue: catalogServiceStub,
},
],
}).compileComponents();
const fixture = TestBed.createComponent(StoreHomePageComponent);
@@ -235,5 +268,6 @@ describe('StoreHomePageComponent', () => {
expect(element.textContent).toContain('No pudimos cargar los productos en este momento.');
expect(element.querySelectorAll('app-product-card')).toHaveLength(0);
expect(catalogServiceStub.getProductos).not.toHaveBeenCalled();
});
});

View File

@@ -1,12 +1,25 @@
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
import { Router } from '@angular/router';
import {
ChangeDetectionStrategy,
Component,
OnDestroy,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Subscription } from 'rxjs';
import { ApiPaginatedResponse } from '../../../../core/services/api-paginated-response.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { Product } from '../../../../core/services/catalog/catalog.interface';
import { ProductCardComponent } from '../../../../shared/components/product-card/product-card.component';
import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component';
import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component';
import {
STORE_HOME_PRODUCTS_ERROR_MESSAGE,
StoreHomeProductsResolvedData,
} from './store-home-page.resolver';
interface StoreHomeProductCardViewModel {
id: number;
@@ -22,14 +35,16 @@ interface StoreHomeProductCardViewModel {
imports: [StoreSectionComponent, ProductCardComponent, PaginatorComponent],
templateUrl: './store-home-page.component.html',
styleUrl: './store-home-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StoreHomePageComponent implements OnInit, OnDestroy {
private readonly catalogService = inject(CatalogService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private activeRequestId = 0;
private productsRequestSubscription: Subscription | null = null;
protected readonly priorityImageCount = 4;
protected readonly currentPage = signal(1);
protected readonly products = signal<Product[]>([]);
@@ -44,11 +59,21 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
originalPrice: this.parseProductPrice(product.precio),
discount: null,
transferPrice: null,
imageUrl: product.images?.[0] ?? null
}))
imageUrl: product.images?.[0] ?? null,
})),
);
ngOnInit(): void {
const resolvedData = this.route.snapshot.data['productsData'] as
| StoreHomeProductsResolvedData
| undefined;
if (resolvedData) {
this.applyResolvedData(resolvedData);
return;
}
this.loadProducts(1);
}
@@ -82,9 +107,7 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
return;
}
this.products.set(response.data ?? []);
this.currentPage.set(response.meta.current_page);
this.totalPages.set(response.meta.last_page);
this.applyProductsResponse(response);
},
error: () => {
if (requestId !== this.activeRequestId) {
@@ -94,7 +117,7 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
this.products.set([]);
this.totalPages.set(0);
this.loading.set(false);
this.error.set('No pudimos cargar los productos en este momento.');
this.error.set(STORE_HOME_PRODUCTS_ERROR_MESSAGE);
},
complete: () => {
if (requestId !== this.activeRequestId) {
@@ -102,10 +125,33 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
}
this.loading.set(false);
}
},
});
}
private applyResolvedData(resolvedData: StoreHomeProductsResolvedData): void {
if (resolvedData.error) {
this.products.set([]);
this.totalPages.set(0);
this.loading.set(false);
this.error.set(resolvedData.error);
return;
}
if (resolvedData.response) {
this.applyProductsResponse(resolvedData.response);
this.loading.set(false);
this.error.set(null);
}
}
private applyProductsResponse(response: ApiPaginatedResponse<Product[]>): void {
this.products.set(response.data ?? []);
this.currentPage.set(response.meta.current_page);
this.totalPages.set(response.meta.last_page);
}
private parseProductPrice(price: string): number {
const parsedPrice = Number(price);

View File

@@ -0,0 +1,33 @@
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { catchError, map, of } from 'rxjs';
import { ApiPaginatedResponse } from '../../../../core/services/api-paginated-response.interface';
import { Product } from '../../../../core/services/catalog/catalog.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
export const STORE_HOME_PRODUCTS_ERROR_MESSAGE = 'No pudimos cargar los productos en este momento.';
export interface StoreHomeProductsResolvedData {
response: ApiPaginatedResponse<Product[]> | null;
error: string | null;
}
export const storeHomeProductsResolver: ResolveFn<StoreHomeProductsResolvedData> = () => {
return inject(CatalogService)
.getProductos({ page: 1 })
.pipe(
map(
(response): StoreHomeProductsResolvedData => ({
response,
error: null,
}),
),
catchError(() =>
of({
response: null,
error: STORE_HOME_PRODUCTS_ERROR_MESSAGE,
}),
),
);
};

View File

@@ -6,6 +6,7 @@ import { authGuard, guestOnlyGuard } from '../../core/services/auth/auth.guards'
import { LoginPageComponent } from './pages/login-page/login-page.component';
import { RegisterPageComponent } from './pages/register-page/register-page.component';
import { StoreHomePageComponent } from './pages/store-home-page/store-home-page.component';
import { storeHomeProductsResolver } from './pages/store-home-page/store-home-page.resolver';
export const routes: Routes = [
{
@@ -14,7 +15,10 @@ export const routes: Routes = [
children: [
{
path: '',
component: StoreHomePageComponent
component: StoreHomePageComponent,
resolve: {
productsData: storeHomeProductsResolver
}
},
{
path: 'login',