feat(cart): implement optimistic quantity updates with rollback on error

This commit is contained in:
2026-07-03 08:56:54 -03:00
parent 6bab2e9ab2
commit bedb74ebd5
3 changed files with 161 additions and 5 deletions

View File

@@ -10,7 +10,7 @@
</header>
<div class="d-grid gap-2 flex-grow-1 overflow-y-auto cart-items-container">
@for (item of items(); track item.product + item.discountedPrice + resetKey(); let idx = $index) {
@for (item of items(); track item.productVariantId || item.product + item.discountedPrice; let idx = $index) {
<app-cart-item
[imageUrl]="item.imageUrl"
[product]="item.product"
@@ -18,7 +18,7 @@
[discountedPrice]="item.discountedPrice"
[discountPercentage]="item.discountPercentage"
[attributes]="item.attributes"
[quantity]="item.quantity"
[quantity]="getItemQuantity(item)"
(quantityChange)="onItemQuantityChange(idx, $event)"
(remove)="onItemRemove(idx)"
/>

View File

@@ -6,7 +6,7 @@ import {
BrowserTestingModule,
platformBrowserTesting
} from '@angular/platform-browser/testing';
import { of } from 'rxjs';
import { of, throwError } from 'rxjs';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { CartService } from '../../../core/services/cart/cart.service';
@@ -141,4 +141,136 @@ describe('CartComponent', () => {
expect(openConfirmDelete).toHaveBeenCalled();
expect(removeItem).not.toHaveBeenCalled();
});
it('optimistically updates quantity and rolls back on error', async () => {
vi.useFakeTimers();
const updateItemQuantity = vi.fn().mockReturnValue(throwError(() => new Error('Error')));
const danger = vi.fn();
await TestBed.configureTestingModule({
imports: [CartComponent],
providers: [
{
provide: CartService,
useValue: {
cart: signal(null).asReadonly(),
updateItemQuantity,
removeItem: vi.fn()
}
},
{
provide: ModalService,
useValue: {}
},
{
provide: ToastService,
useValue: {
success: vi.fn(),
info: vi.fn(),
danger
}
}
]
}).compileComponents();
const fixture = TestBed.createComponent(CartComponent);
const component = fixture.componentInstance;
const item = {
productVariantId: 10,
imageUrl: null,
product: 'Producto de prueba',
originalPrice: null,
discountedPrice: 1000,
discountPercentage: null,
attributes: [],
quantity: 1
};
fixture.componentRef.setInput('items', [item]);
fixture.detectChanges();
// Trigger quantity change to 3
fixture.debugElement.query(By.css('app-cart-item')).triggerEventHandler('quantityChange', 3);
fixture.detectChanges();
// Optimistic update should be active immediately in local getter
expect((component as any).getItemQuantity(item)).toBe(3);
// Wait for the debounce time (1000ms)
vi.advanceTimersByTime(1000);
fixture.detectChanges();
// After failure, it should roll back to original quantity (1)
expect((component as any).getItemQuantity(item)).toBe(1);
expect(danger).toHaveBeenCalled();
vi.useRealTimers();
});
it('optimistically updates quantity and clears override on success', async () => {
vi.useFakeTimers();
const updateItemQuantity = vi.fn().mockReturnValue(of({ message: 'Success', data: {} }));
const success = vi.fn();
await TestBed.configureTestingModule({
imports: [CartComponent],
providers: [
{
provide: CartService,
useValue: {
cart: signal(null).asReadonly(),
updateItemQuantity,
removeItem: vi.fn()
}
},
{
provide: ModalService,
useValue: {}
},
{
provide: ToastService,
useValue: {
success,
info: vi.fn(),
danger: vi.fn()
}
}
]
}).compileComponents();
const fixture = TestBed.createComponent(CartComponent);
const component = fixture.componentInstance;
const item = {
productVariantId: 10,
imageUrl: null,
product: 'Producto de prueba',
originalPrice: null,
discountedPrice: 1000,
discountPercentage: null,
attributes: [],
quantity: 1
};
fixture.componentRef.setInput('items', [item]);
fixture.detectChanges();
// Trigger quantity change to 3
fixture.debugElement.query(By.css('app-cart-item')).triggerEventHandler('quantityChange', 3);
fixture.detectChanges();
// Optimistic update should be active immediately in local getter
expect((component as any).getItemQuantity(item)).toBe(3);
// Wait for the debounce time (1000ms)
vi.advanceTimersByTime(1000);
fixture.detectChanges();
// After success, it should clear override and use input quantity (which is 1 since we didn't update items input here)
expect((component as any).getItemQuantity(item)).toBe(1);
expect(success).toHaveBeenCalled();
vi.useRealTimers();
});
});

View File

@@ -43,7 +43,7 @@ export class CartComponent {
readonly closed = output<void>();
protected readonly resetKey = signal(0);
protected readonly quantityOverrides = signal<Record<number, number>>({});
constructor() {
this.quantityUpdates$.pipe(
@@ -55,12 +55,13 @@ export class CartComponent {
next: (res) => {
const msg = res.message || 'Cantidad de producto actualizada.';
this.toastService.success(msg);
this.clearOverride(update.productVariantId);
},
error: (err: HttpErrorResponse) => {
console.error('Error updating cart quantity', err);
const msg = err.error?.message || 'Error al actualizar la cantidad del producto.';
this.toastService.danger(msg);
this.resetKey.update(k => k + 1);
this.clearOverride(update.productVariantId);
}
}),
catchError(() => EMPTY)
@@ -70,10 +71,29 @@ export class CartComponent {
).subscribe();
}
protected getItemQuantity(item: CartItemMock): number {
if (item.productVariantId !== undefined && this.quantityOverrides()[item.productVariantId] !== undefined) {
return this.quantityOverrides()[item.productVariantId];
}
return item.quantity;
}
private clearOverride(productVariantId: number): void {
this.quantityOverrides.update((overrides) => {
const copy = { ...overrides };
delete copy[productVariantId];
return copy;
});
}
protected onItemQuantityChange(index: number, newQuantity: number): void {
const mockItem = this.items()[index];
const productVariantId = mockItem?.productVariantId;
if (productVariantId) {
this.quantityOverrides.update((overrides) => ({
...overrides,
[productVariantId]: newQuantity
}));
this.quantityUpdates$.next({
productVariantId,
quantity: newQuantity
@@ -81,6 +101,10 @@ export class CartComponent {
} else {
const item = this.cartService.cart()?.items[index];
if (item) {
this.quantityOverrides.update((overrides) => ({
...overrides,
[item.product_variant_id]: newQuantity
}));
this.quantityUpdates$.next({
productVariantId: item.product_variant_id,
quantity: newQuantity