feat(cart): implement optimistic quantity updates with rollback on error
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="d-grid gap-2 flex-grow-1 overflow-y-auto cart-items-container">
|
<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
|
<app-cart-item
|
||||||
[imageUrl]="item.imageUrl"
|
[imageUrl]="item.imageUrl"
|
||||||
[product]="item.product"
|
[product]="item.product"
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
[discountedPrice]="item.discountedPrice"
|
[discountedPrice]="item.discountedPrice"
|
||||||
[discountPercentage]="item.discountPercentage"
|
[discountPercentage]="item.discountPercentage"
|
||||||
[attributes]="item.attributes"
|
[attributes]="item.attributes"
|
||||||
[quantity]="item.quantity"
|
[quantity]="getItemQuantity(item)"
|
||||||
(quantityChange)="onItemQuantityChange(idx, $event)"
|
(quantityChange)="onItemQuantityChange(idx, $event)"
|
||||||
(remove)="onItemRemove(idx)"
|
(remove)="onItemRemove(idx)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
BrowserTestingModule,
|
BrowserTestingModule,
|
||||||
platformBrowserTesting
|
platformBrowserTesting
|
||||||
} from '@angular/platform-browser/testing';
|
} from '@angular/platform-browser/testing';
|
||||||
import { of } from 'rxjs';
|
import { of, throwError } from 'rxjs';
|
||||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { CartService } from '../../../core/services/cart/cart.service';
|
import { CartService } from '../../../core/services/cart/cart.service';
|
||||||
@@ -141,4 +141,136 @@ describe('CartComponent', () => {
|
|||||||
expect(openConfirmDelete).toHaveBeenCalled();
|
expect(openConfirmDelete).toHaveBeenCalled();
|
||||||
expect(removeItem).not.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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export class CartComponent {
|
|||||||
|
|
||||||
readonly closed = output<void>();
|
readonly closed = output<void>();
|
||||||
|
|
||||||
protected readonly resetKey = signal(0);
|
protected readonly quantityOverrides = signal<Record<number, number>>({});
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.quantityUpdates$.pipe(
|
this.quantityUpdates$.pipe(
|
||||||
@@ -55,12 +55,13 @@ export class CartComponent {
|
|||||||
next: (res) => {
|
next: (res) => {
|
||||||
const msg = res.message || 'Cantidad de producto actualizada.';
|
const msg = res.message || 'Cantidad de producto actualizada.';
|
||||||
this.toastService.success(msg);
|
this.toastService.success(msg);
|
||||||
|
this.clearOverride(update.productVariantId);
|
||||||
},
|
},
|
||||||
error: (err: HttpErrorResponse) => {
|
error: (err: HttpErrorResponse) => {
|
||||||
console.error('Error updating cart quantity', err);
|
console.error('Error updating cart quantity', err);
|
||||||
const msg = err.error?.message || 'Error al actualizar la cantidad del producto.';
|
const msg = err.error?.message || 'Error al actualizar la cantidad del producto.';
|
||||||
this.toastService.danger(msg);
|
this.toastService.danger(msg);
|
||||||
this.resetKey.update(k => k + 1);
|
this.clearOverride(update.productVariantId);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
catchError(() => EMPTY)
|
catchError(() => EMPTY)
|
||||||
@@ -70,10 +71,29 @@ export class CartComponent {
|
|||||||
).subscribe();
|
).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 {
|
protected onItemQuantityChange(index: number, newQuantity: number): void {
|
||||||
const mockItem = this.items()[index];
|
const mockItem = this.items()[index];
|
||||||
const productVariantId = mockItem?.productVariantId;
|
const productVariantId = mockItem?.productVariantId;
|
||||||
if (productVariantId) {
|
if (productVariantId) {
|
||||||
|
this.quantityOverrides.update((overrides) => ({
|
||||||
|
...overrides,
|
||||||
|
[productVariantId]: newQuantity
|
||||||
|
}));
|
||||||
this.quantityUpdates$.next({
|
this.quantityUpdates$.next({
|
||||||
productVariantId,
|
productVariantId,
|
||||||
quantity: newQuantity
|
quantity: newQuantity
|
||||||
@@ -81,6 +101,10 @@ export class CartComponent {
|
|||||||
} else {
|
} else {
|
||||||
const item = this.cartService.cart()?.items[index];
|
const item = this.cartService.cart()?.items[index];
|
||||||
if (item) {
|
if (item) {
|
||||||
|
this.quantityOverrides.update((overrides) => ({
|
||||||
|
...overrides,
|
||||||
|
[item.product_variant_id]: newQuantity
|
||||||
|
}));
|
||||||
this.quantityUpdates$.next({
|
this.quantityUpdates$.next({
|
||||||
productVariantId: item.product_variant_id,
|
productVariantId: item.product_variant_id,
|
||||||
quantity: newQuantity
|
quantity: newQuantity
|
||||||
|
|||||||
Reference in New Issue
Block a user