feat(cart): integrate confirmation modals for item removal in cart component
This commit is contained in:
@@ -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',
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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})` : ''}.`;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
144
src/app/shared/components/cart/cart.component.spec.ts
Normal file
144
src/app/shared/components/cart/cart.component.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user