feat(cart): integrate confirmation modals for item removal in cart component

This commit is contained in:
2026-07-02 12:04:29 -03:00
parent 446d1b8970
commit bf5fd83f98
6 changed files with 253 additions and 61 deletions

View File

@@ -14,6 +14,7 @@ import {
it, it,
vi vi
} from 'vitest'; } from 'vitest';
import { firstValueFrom } from 'rxjs';
import { ConfirmDeleteModalComponent } from '../../shared/components/confirm-delete-modal/confirm-delete-modal.component'; import { ConfirmDeleteModalComponent } from '../../shared/components/confirm-delete-modal/confirm-delete-modal.component';
import { ConfirmModalComponent } from '../../shared/components/confirm-modal/confirm-modal.component'; import { ConfirmModalComponent } from '../../shared/components/confirm-modal/confirm-modal.component';
@@ -134,7 +135,7 @@ describe('ModalService', () => {
}); });
it('opens the standard confirm modal with default labels', () => { it('opens the standard confirm modal with default labels', () => {
const ref = service.openConfirm({ const result$ = service.openConfirm({
title: 'Confirmar compra', title: 'Confirmar compra',
content: 'Esto confirmara la compra actual.' content: 'Esto confirmara la compra actual.'
}); });
@@ -142,7 +143,7 @@ describe('ModalService', () => {
const activeModal = service.activeModal(); const activeModal = service.activeModal();
expect(activeModal?.component).toBe(ConfirmModalComponent); expect(activeModal?.component).toBe(ConfirmModalComponent);
expect(activeModal?.ref).toBe(ref); expect(result$).toBeDefined();
expect(activeModal?.config).toEqual({ expect(activeModal?.config).toEqual({
title: 'Confirmar compra', title: 'Confirmar compra',
data: { data: {
@@ -157,6 +158,32 @@ describe('ModalService', () => {
}); });
}); });
it('maps the confirm modal close result to true', async () => {
const result$ = service.openConfirm({
title: 'Confirmar compra',
content: 'Esto confirmara la compra actual.'
});
const activeModal = service.activeModal();
const resultPromise = firstValueFrom(result$);
activeModal?.ref.close(true);
await expect(resultPromise).resolves.toBe(true);
});
it('maps dismissing a confirm modal to false', async () => {
const result$ = service.openConfirmDelete({
title: 'Eliminar producto',
content: 'Se eliminara el producto.'
});
const activeModal = service.activeModal();
const resultPromise = firstValueFrom(result$);
activeModal?.ref.dismiss('escape');
await expect(resultPromise).resolves.toBe(false);
});
it('opens the delete confirm modal preserving modal overrides', () => { it('opens the delete confirm modal preserving modal overrides', () => {
service.openConfirmDelete({ service.openConfirmDelete({
title: 'Eliminar producto', title: 'Eliminar producto',

View File

@@ -1,5 +1,5 @@
import { InjectionToken, Injectable, Type, signal } from '@angular/core'; import { InjectionToken, Injectable, Type, signal } from '@angular/core';
import { Observable, Subject } from 'rxjs'; import { Observable, Subject, map } from 'rxjs';
import { ConfirmDeleteModalComponent } from '../../shared/components/confirm-delete-modal/confirm-delete-modal.component'; import { ConfirmDeleteModalComponent } from '../../shared/components/confirm-delete-modal/confirm-delete-modal.component';
import { ConfirmModalComponent } from '../../shared/components/confirm-modal/confirm-modal.component'; import { ConfirmModalComponent } from '../../shared/components/confirm-modal/confirm-modal.component';
@@ -135,11 +135,23 @@ export class ModalService {
return ref; return ref;
} }
openConfirm(config: ConfirmModalConfig): ModalRef<boolean> { openConfirm(config: ConfirmModalConfig): Observable<boolean> {
return this.openConfirmRef(config).afterClosed$.pipe(
map((result) => result === true)
);
}
openConfirmDelete(config: ConfirmModalConfig): Observable<boolean> {
return this.openConfirmDeleteRef(config).afterClosed$.pipe(
map((result) => result === true)
);
}
openConfirmRef(config: ConfirmModalConfig): ModalRef<boolean> {
return this.open(ConfirmModalComponent, this.buildConfirmModalConfig(config)); return this.open(ConfirmModalComponent, this.buildConfirmModalConfig(config));
} }
openConfirmDelete(config: ConfirmModalConfig): ModalRef<boolean> { openConfirmDeleteRef(config: ConfirmModalConfig): ModalRef<boolean> {
return this.open( return this.open(
ConfirmDeleteModalComponent, ConfirmDeleteModalComponent,
this.buildConfirmModalConfig(config) this.buildConfirmModalConfig(config)

View File

@@ -4,6 +4,7 @@ import {
BrowserTestingModule, BrowserTestingModule,
platformBrowserTesting platformBrowserTesting
} from '@angular/platform-browser/testing'; } from '@angular/platform-browser/testing';
import { of } from 'rxjs';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { ModalService } from '../../../../core/services/modal.service'; import { ModalService } from '../../../../core/services/modal.service';
@@ -47,20 +48,11 @@ function createToastServiceStub() {
}; };
} }
function createModalRefStub() {
return {
afterClosed$: {
subscribe: vi.fn()
},
dismissReason: vi.fn().mockReturnValue(null)
};
}
function createModalServiceStub() { function createModalServiceStub() {
return { return {
open: vi.fn().mockReturnValue(createModalRefStub()), open: vi.fn(),
openConfirm: vi.fn().mockReturnValue(createModalRefStub()), openConfirm: vi.fn().mockReturnValue(of(true)),
openConfirmDelete: vi.fn().mockReturnValue(createModalRefStub()) openConfirmDelete: vi.fn().mockReturnValue(of(false))
}; };
} }

View File

@@ -198,15 +198,15 @@ export class ReutilizablesTestPageComponent {
} }
protected openConfirmDeleteModal(): void { protected openConfirmDeleteModal(): void {
const ref = this.modalService.openConfirmDelete({ this.modalService.openConfirmDelete({
title: 'Eliminar producto', title: 'Eliminar producto',
content: content:
'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.', 'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.',
confirmLabel: 'Eliminar', confirmLabel: 'Eliminar',
cancelLabel: 'Conservar' cancelLabel: 'Conservar'
}).subscribe((confirmed) => {
this.lastModalResult = `Resultado: ${confirmed}`;
}); });
this.handleModalResult(ref);
} }
protected openLockedModal(): void { protected openLockedModal(): void {
@@ -230,18 +230,8 @@ export class ReutilizablesTestPageComponent {
} }
private openConfirmModal(config: Parameters<ModalService['openConfirm']>[0]): void { private openConfirmModal(config: Parameters<ModalService['openConfirm']>[0]): void {
const ref = this.modalService.openConfirm(config); this.modalService.openConfirm(config).subscribe((confirmed) => {
this.lastModalResult = `Resultado: ${confirmed}`;
this.handleModalResult(ref);
}
private handleModalResult(ref: ReturnType<ModalService['openConfirm']>): void {
ref.afterClosed$.subscribe((result) => {
const dismissReason = ref.dismissReason();
this.lastModalResult =
typeof result === 'boolean'
? `Resultado: ${result}`
: `Cerrado sin resultado${dismissReason ? ` (${dismissReason})` : ''}.`;
}); });
} }
} }

View File

@@ -0,0 +1,144 @@
import '@angular/compiler';
import { signal } from '@angular/core';
import { TestBed, getTestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import {
BrowserTestingModule,
platformBrowserTesting
} from '@angular/platform-browser/testing';
import { of } from 'rxjs';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { CartService } from '../../../core/services/cart/cart.service';
import { ModalService } from '../../../core/services/modal.service';
import { ToastService } from '../../../core/services/toast.service';
import { CartComponent } from './cart.component';
describe('CartComponent', () => {
beforeAll(() => {
try {
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting()
);
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
});
afterEach(() => {
TestBed.resetTestingModule();
});
it('opens a confirm delete modal before removing an item', async () => {
const removeItem = vi.fn().mockReturnValue(of({ message: 'Producto eliminado.' }));
const openConfirmDelete = vi.fn().mockReturnValue(of(true));
await TestBed.configureTestingModule({
imports: [CartComponent],
providers: [
{
provide: CartService,
useValue: {
cart: signal(null).asReadonly(),
updateItemQuantity: vi.fn(),
removeItem
}
},
{
provide: ModalService,
useValue: {
openConfirmDelete
}
},
{
provide: ToastService,
useValue: {
success: vi.fn(),
info: vi.fn(),
danger: vi.fn()
}
}
]
}).compileComponents();
const fixture = TestBed.createComponent(CartComponent);
fixture.componentRef.setInput('items', [
{
productVariantId: 10,
imageUrl: null,
product: 'Producto de prueba',
originalPrice: null,
discountedPrice: 1000,
discountPercentage: null,
attributes: [],
quantity: 1
}
]);
fixture.detectChanges();
fixture.debugElement.query(By.css('app-cart-item')).triggerEventHandler('remove');
expect(openConfirmDelete).toHaveBeenCalledWith({
title: 'Eliminar producto',
content:
'Se eliminara "Producto de prueba" del carrito. Esta accion no se puede deshacer.',
confirmLabel: 'Eliminar',
cancelLabel: 'Cancelar'
});
expect(removeItem).toHaveBeenCalledWith(10);
});
it('does not remove the item when the delete confirmation is cancelled', async () => {
const removeItem = vi.fn().mockReturnValue(of({}));
const openConfirmDelete = vi.fn().mockReturnValue(of(false));
await TestBed.configureTestingModule({
imports: [CartComponent],
providers: [
{
provide: CartService,
useValue: {
cart: signal(null).asReadonly(),
updateItemQuantity: vi.fn(),
removeItem
}
},
{
provide: ModalService,
useValue: {
openConfirmDelete
}
},
{
provide: ToastService,
useValue: {
success: vi.fn(),
info: vi.fn(),
danger: vi.fn()
}
}
]
}).compileComponents();
const fixture = TestBed.createComponent(CartComponent);
fixture.componentRef.setInput('items', [
{
productVariantId: 10,
imageUrl: null,
product: 'Producto de prueba',
originalPrice: null,
discountedPrice: 1000,
discountPercentage: null,
attributes: [],
quantity: 1
}
]);
fixture.detectChanges();
fixture.debugElement.query(By.css('app-cart-item')).triggerEventHandler('remove');
expect(openConfirmDelete).toHaveBeenCalled();
expect(removeItem).not.toHaveBeenCalled();
});
});

View File

@@ -4,6 +4,7 @@ import { Subject, EMPTY } from 'rxjs';
import { catchError, debounceTime, groupBy, mergeMap, switchMap, tap } from 'rxjs'; import { catchError, debounceTime, groupBy, mergeMap, switchMap, tap } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CartItemAttribute, CartItemComponent } from '../cart-item/cart-item.component'; import { CartItemAttribute, CartItemComponent } from '../cart-item/cart-item.component';
import { ModalService } from '../../../core/services/modal.service';
import { CartService } from '../../../core/services/cart/cart.service'; import { CartService } from '../../../core/services/cart/cart.service';
import { ToastService } from '../../../core/services/toast.service'; import { ToastService } from '../../../core/services/toast.service';
@@ -28,6 +29,7 @@ export interface CartItemMock {
}) })
export class CartComponent { export class CartComponent {
private readonly cartService = inject(CartService); private readonly cartService = inject(CartService);
private readonly modalService = inject(ModalService);
private readonly toastService = inject(ToastService); private readonly toastService = inject(ToastService);
private readonly quantityUpdates$ = new Subject<{ productVariantId: number; quantity: number }>(); private readonly quantityUpdates$ = new Subject<{ productVariantId: number; quantity: number }>();
@@ -88,36 +90,22 @@ export class CartComponent {
} }
protected onItemRemove(index: number): void { protected onItemRemove(index: number): void {
const mockItem = this.items()[index]; const target = this.resolveRemoveTarget(index);
const productVariantId = mockItem?.productVariantId;
if (productVariantId) { if (!target) {
this.cartService.removeItem(productVariantId).subscribe({ return;
next: (res) => {
const msg = res.message || 'Producto eliminado del carrito.';
this.toastService.success(msg);
},
error: (err: HttpErrorResponse) => {
console.error('Error removing item from cart', err);
const msg = err.error?.message || 'Error al eliminar el producto del carrito.';
this.toastService.danger(msg);
}
});
} else {
const item = this.cartService.cart()?.items[index];
if (item) {
this.cartService.removeItem(item.product_variant_id).subscribe({
next: (res) => {
const msg = res.message || 'Producto eliminado del carrito.';
this.toastService.success(msg);
},
error: (err: HttpErrorResponse) => {
console.error('Error removing item from cart', err);
const msg = err.error?.message || 'Error al eliminar el producto del carrito.';
this.toastService.danger(msg);
}
});
}
} }
this.modalService.openConfirmDelete({
title: 'Eliminar producto',
content: `Se eliminara "${target.productName}" del carrito. Esta accion no se puede deshacer.`,
confirmLabel: 'Eliminar',
cancelLabel: 'Cancelar'
}).subscribe((confirmed) => {
if (confirmed) {
this.removeItem(target.productVariantId);
}
});
} }
protected readonly formattedSubtotal = computed(() => this.formatCurrency(this.subtotal())); protected readonly formattedSubtotal = computed(() => this.formatCurrency(this.subtotal()));
@@ -130,5 +118,44 @@ export class CartComponent {
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, '.'); parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, '.');
return `$ ${parts.join(',')}`; return `$ ${parts.join(',')}`;
} }
private resolveRemoveTarget(
index: number
): { productVariantId: number; productName: string } | null {
const mockItem = this.items()[index];
const productVariantId = mockItem?.productVariantId;
if (productVariantId) {
return {
productVariantId,
productName: mockItem.product
};
}
const item = this.cartService.cart()?.items[index];
if (!item) {
return null;
}
return {
productVariantId: item.product_variant_id,
productName: item.product?.nombre ?? 'este producto'
};
}
private removeItem(productVariantId: number): void {
this.cartService.removeItem(productVariantId).subscribe({
next: (res) => {
const msg = res.message || 'Producto eliminado del carrito.';
this.toastService.info(msg);
},
error: (err: HttpErrorResponse) => {
console.error('Error removing item from cart', err);
const msg = err.error?.message || 'Error al eliminar el producto del carrito.';
this.toastService.danger(msg);
}
});
}
} }