From bf5fd83f9853f601b278d354eb7614a7dfe674ca Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 2 Jul 2026 12:04:29 -0300 Subject: [PATCH] feat(cart): integrate confirmation modals for item removal in cart component --- src/app/core/services/modal.service.spec.ts | 31 +++- src/app/core/services/modal.service.ts | 18 ++- .../reutilizables-test-page.component.spec.ts | 16 +- .../reutilizables-test-page.component.ts | 20 +-- .../components/cart/cart.component.spec.ts | 144 ++++++++++++++++++ .../shared/components/cart/cart.component.ts | 85 +++++++---- 6 files changed, 253 insertions(+), 61 deletions(-) create mode 100644 src/app/shared/components/cart/cart.component.spec.ts diff --git a/src/app/core/services/modal.service.spec.ts b/src/app/core/services/modal.service.spec.ts index 98b125c..f49fb48 100644 --- a/src/app/core/services/modal.service.spec.ts +++ b/src/app/core/services/modal.service.spec.ts @@ -14,6 +14,7 @@ import { it, vi } from 'vitest'; +import { firstValueFrom } from 'rxjs'; import { ConfirmDeleteModalComponent } from '../../shared/components/confirm-delete-modal/confirm-delete-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', () => { - const ref = service.openConfirm({ + const result$ = service.openConfirm({ title: 'Confirmar compra', content: 'Esto confirmara la compra actual.' }); @@ -142,7 +143,7 @@ describe('ModalService', () => { const activeModal = service.activeModal(); expect(activeModal?.component).toBe(ConfirmModalComponent); - expect(activeModal?.ref).toBe(ref); + expect(result$).toBeDefined(); expect(activeModal?.config).toEqual({ title: 'Confirmar compra', 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', () => { service.openConfirmDelete({ title: 'Eliminar producto', diff --git a/src/app/core/services/modal.service.ts b/src/app/core/services/modal.service.ts index 256d4ce..02d1534 100644 --- a/src/app/core/services/modal.service.ts +++ b/src/app/core/services/modal.service.ts @@ -1,5 +1,5 @@ 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 { ConfirmModalComponent } from '../../shared/components/confirm-modal/confirm-modal.component'; @@ -135,11 +135,23 @@ export class ModalService { return ref; } - openConfirm(config: ConfirmModalConfig): ModalRef { + openConfirm(config: ConfirmModalConfig): Observable { + return this.openConfirmRef(config).afterClosed$.pipe( + map((result) => result === true) + ); + } + + openConfirmDelete(config: ConfirmModalConfig): Observable { + return this.openConfirmDeleteRef(config).afterClosed$.pipe( + map((result) => result === true) + ); + } + + openConfirmRef(config: ConfirmModalConfig): ModalRef { return this.open(ConfirmModalComponent, this.buildConfirmModalConfig(config)); } - openConfirmDelete(config: ConfirmModalConfig): ModalRef { + openConfirmDeleteRef(config: ConfirmModalConfig): ModalRef { return this.open( ConfirmDeleteModalComponent, this.buildConfirmModalConfig(config) diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts index aa158cd..793a80c 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts @@ -4,6 +4,7 @@ import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; +import { of } from 'rxjs'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; 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() { return { - open: vi.fn().mockReturnValue(createModalRefStub()), - openConfirm: vi.fn().mockReturnValue(createModalRefStub()), - openConfirmDelete: vi.fn().mockReturnValue(createModalRefStub()) + open: vi.fn(), + openConfirm: vi.fn().mockReturnValue(of(true)), + openConfirmDelete: vi.fn().mockReturnValue(of(false)) }; } diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts index 4c65f52..3ea52d4 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts @@ -198,15 +198,15 @@ export class ReutilizablesTestPageComponent { } protected openConfirmDeleteModal(): void { - const ref = this.modalService.openConfirmDelete({ + this.modalService.openConfirmDelete({ title: 'Eliminar producto', content: 'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.', confirmLabel: 'Eliminar', cancelLabel: 'Conservar' + }).subscribe((confirmed) => { + this.lastModalResult = `Resultado: ${confirmed}`; }); - - this.handleModalResult(ref); } protected openLockedModal(): void { @@ -230,18 +230,8 @@ export class ReutilizablesTestPageComponent { } private openConfirmModal(config: Parameters[0]): void { - const ref = this.modalService.openConfirm(config); - - this.handleModalResult(ref); - } - - private handleModalResult(ref: ReturnType): void { - ref.afterClosed$.subscribe((result) => { - const dismissReason = ref.dismissReason(); - this.lastModalResult = - typeof result === 'boolean' - ? `Resultado: ${result}` - : `Cerrado sin resultado${dismissReason ? ` (${dismissReason})` : ''}.`; + this.modalService.openConfirm(config).subscribe((confirmed) => { + this.lastModalResult = `Resultado: ${confirmed}`; }); } } diff --git a/src/app/shared/components/cart/cart.component.spec.ts b/src/app/shared/components/cart/cart.component.spec.ts new file mode 100644 index 0000000..75928f2 --- /dev/null +++ b/src/app/shared/components/cart/cart.component.spec.ts @@ -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(); + }); +}); diff --git a/src/app/shared/components/cart/cart.component.ts b/src/app/shared/components/cart/cart.component.ts index dba8065..9eb2d12 100644 --- a/src/app/shared/components/cart/cart.component.ts +++ b/src/app/shared/components/cart/cart.component.ts @@ -4,6 +4,7 @@ import { Subject, EMPTY } from 'rxjs'; import { catchError, debounceTime, groupBy, mergeMap, switchMap, tap } from 'rxjs'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; 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 { ToastService } from '../../../core/services/toast.service'; @@ -28,6 +29,7 @@ export interface CartItemMock { }) export class CartComponent { private readonly cartService = inject(CartService); + private readonly modalService = inject(ModalService); private readonly toastService = inject(ToastService); private readonly quantityUpdates$ = new Subject<{ productVariantId: number; quantity: number }>(); @@ -88,36 +90,22 @@ export class CartComponent { } protected onItemRemove(index: number): void { - const mockItem = this.items()[index]; - const productVariantId = mockItem?.productVariantId; - if (productVariantId) { - this.cartService.removeItem(productVariantId).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); - } - }); - } 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); - } - }); - } + const target = this.resolveRemoveTarget(index); + + if (!target) { + return; } + + 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())); @@ -130,5 +118,44 @@ export class CartComponent { parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, '.'); 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); + } + }); + } }