feat(modal): add confirm and confirm delete modals with tests

- Implemented ConfirmModalComponent and ConfirmDeleteModalComponent for confirmation dialogs.
- Added modal service methods to open confirm and confirm delete modals.
- Created corresponding HTML and SCSS files for both modal components.
- Updated ReutilizablesTestPageComponent to utilize new modals.
- Enhanced modal service tests to cover new functionality.
- Refactored existing tests to accommodate changes in modal behavior and structure.
This commit is contained in:
2026-07-02 11:45:47 -03:00
parent b3782a6cc1
commit 446d1b8970
14 changed files with 677 additions and 164 deletions

View File

@@ -5,8 +5,18 @@ import {
BrowserTestingModule,
platformBrowserTesting
} from '@angular/platform-browser/testing';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import {
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { ConfirmDeleteModalComponent } from '../../shared/components/confirm-delete-modal/confirm-delete-modal.component';
import { ConfirmModalComponent } from '../../shared/components/confirm-modal/confirm-modal.component';
import {
MODAL_DATA,
ModalRef,
@@ -122,6 +132,59 @@ describe('ModalService', () => {
expect(activeModal?.config.data).toEqual({ amount: 3 });
expect(activeModal?.ref).toBe(ref);
});
it('opens the standard confirm modal with default labels', () => {
const ref = service.openConfirm({
title: 'Confirmar compra',
content: 'Esto confirmara la compra actual.'
});
const activeModal = service.activeModal();
expect(activeModal?.component).toBe(ConfirmModalComponent);
expect(activeModal?.ref).toBe(ref);
expect(activeModal?.config).toEqual({
title: 'Confirmar compra',
data: {
content: 'Esto confirmara la compra actual.',
confirmLabel: 'Confirmar',
cancelLabel: 'Cancelar'
},
size: 'md',
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true
});
});
it('opens the delete confirm modal preserving modal overrides', () => {
service.openConfirmDelete({
title: 'Eliminar producto',
content: 'Se eliminara el producto.',
confirmLabel: 'Eliminar',
cancelLabel: 'Conservar',
size: 'lg',
closeOnBackdrop: false,
closeOnEscape: false,
showCloseButton: false
});
const activeModal = service.activeModal();
expect(activeModal?.component).toBe(ConfirmDeleteModalComponent);
expect(activeModal?.config).toEqual({
title: 'Eliminar producto',
data: {
content: 'Se eliminara el producto.',
confirmLabel: 'Eliminar',
cancelLabel: 'Conservar'
},
size: 'lg',
closeOnBackdrop: false,
closeOnEscape: false,
showCloseButton: false
});
});
});
@Component({

View File

@@ -1,5 +1,7 @@
import { InjectionToken, Injectable, Type, signal } from '@angular/core';
import { Observable, Subject } from 'rxjs';
import { ConfirmDeleteModalComponent } from '../../shared/components/confirm-delete-modal/confirm-delete-modal.component';
import { ConfirmModalComponent } from '../../shared/components/confirm-modal/confirm-modal.component';
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
export type ModalDismissReason =
@@ -28,6 +30,19 @@ export interface NormalizedModalConfig<TData = unknown>
showCloseButton: boolean;
}
export interface ConfirmModalData {
content: string;
confirmLabel: string;
cancelLabel: string;
}
export interface ConfirmModalConfig
extends Omit<ModalConfig<ConfirmModalData>, 'data'> {
content: string;
confirmLabel?: string;
cancelLabel?: string;
}
export interface ActiveModalState<TResult = unknown, TData = unknown> {
component: Type<unknown>;
config: NormalizedModalConfig<TData>;
@@ -46,6 +61,11 @@ const DEFAULT_MODAL_CONFIG: Pick<
showCloseButton: true
};
const DEFAULT_CONFIRM_MODAL_LABELS = {
confirmLabel: 'Confirmar',
cancelLabel: 'Cancelar'
} satisfies Pick<ConfirmModalData, 'confirmLabel' | 'cancelLabel'>;
export class ModalRef<TResult = unknown> {
private readonly afterClosedSubject = new Subject<TResult | undefined>();
private closed = false;
@@ -115,6 +135,17 @@ export class ModalService {
return ref;
}
openConfirm(config: ConfirmModalConfig): ModalRef<boolean> {
return this.open(ConfirmModalComponent, this.buildConfirmModalConfig(config));
}
openConfirmDelete(config: ConfirmModalConfig): ModalRef<boolean> {
return this.open(
ConfirmDeleteModalComponent,
this.buildConfirmModalConfig(config)
);
}
private close<TResult>(ref: ModalRef<TResult>, result?: TResult): void {
if (this.activeModalState()?.ref !== ref) {
return;
@@ -144,4 +175,24 @@ export class ModalService {
...config
};
}
private buildConfirmModalConfig(
config: ConfirmModalConfig
): ModalConfig<ConfirmModalData> {
const {
content,
confirmLabel = DEFAULT_CONFIRM_MODAL_LABELS.confirmLabel,
cancelLabel = DEFAULT_CONFIRM_MODAL_LABELS.cancelLabel,
...modalConfig
} = config;
return {
...modalConfig,
data: {
content,
confirmLabel,
cancelLabel
}
};
}
}

View File

@@ -26,7 +26,9 @@
<app-button variant="secondary" [disabled]="true">Secondary</app-button>
<app-button variant="danger" [disabled]="true">Danger</app-button>
<app-button variant="danger-secondary" [disabled]="true">Danger secondary</app-button>
<app-button variant="danger-secondary" [disabled]="true">
Danger secondary
</app-button>
<app-button variant="cancel" [disabled]="true">Cancel</app-button>
</div>
</div>
@@ -46,29 +48,49 @@
<h2>Toasts</h2>
<div class="button-grid">
<app-button (click)="triggerToast('info')">Trigger Info</app-button>
<app-button variant="secondary" (click)="triggerToast('success')">Trigger Success</app-button>
<app-button variant="danger" (click)="triggerToast('danger')">Trigger Danger</app-button>
<app-button variant="secondary" (click)="triggerToast('success')">
Trigger Success
</app-button>
<app-button variant="danger" (click)="triggerToast('danger')">
Trigger Danger
</app-button>
</div>
</div>
<div class="button-group">
<h2>Toasts Persistentes (No se van)</h2>
<div class="button-grid">
<app-button (click)="triggerPersistentToast('info')">Info Persistente</app-button>
<app-button variant="secondary" (click)="triggerPersistentToast('success')">Success Persistente</app-button>
<app-button variant="danger" (click)="triggerPersistentToast('danger')">Danger Persistente</app-button>
<app-button (click)="triggerPersistentToast('info')">
Info Persistente
</app-button>
<app-button
variant="secondary"
(click)="triggerPersistentToast('success')"
>
Success Persistente
</app-button>
<app-button variant="danger" (click)="triggerPersistentToast('danger')">
Danger Persistente
</app-button>
</div>
</div>
<div class="button-group">
<h2>Modal</h2>
<p class="text-muted mb-0">
Demo del modal global con servicio, host y devolución de resultado.
Demo del modal global con servicio, host y devolucion de resultado.
</p>
<div class="button-grid">
<app-button (click)="openBasicModal()">Abrir modal simple</app-button>
<app-button variant="secondary" (click)="openLockedModal()">Abrir modal bloqueado</app-button>
<app-button variant="danger-secondary" (click)="openWideModal()">Abrir modal ancho</app-button>
<app-button (click)="openBasicModal()">Abrir confirm modal</app-button>
<app-button variant="danger" (click)="openConfirmDeleteModal()">
Abrir confirm delete
</app-button>
<app-button variant="secondary" (click)="openLockedModal()">
Abrir modal bloqueado
</app-button>
<app-button variant="danger-secondary" (click)="openWideModal()">
Abrir modal ancho
</app-button>
</div>
<div class="modal-showcase__result" data-testid="modal-last-result">
{{ lastModalResult }}
@@ -81,11 +103,12 @@
<div class="icon-button-showcase">
<h2>Icon Buttons</h2>
<p class="text-muted mb-4">
Botones de ícono pequeños con color base <code>#666666</code>, deshabilitados con <code>#A0A0A0</code> y estados hover/active con color de marca (a excepción de trash que usa danger).
Botones de icono pequenos con color base <code>#666666</code>,
deshabilitados con <code>#A0A0A0</code> y estados hover/active con color
de marca (a excepcion de trash que usa danger).
</p>
<div class="icon-button-showcase__grid">
<!-- Disabled Row -->
<div class="icon-button-showcase__row">
<span class="icon-button-showcase__label">Disabled</span>
<div class="icon-button-showcase__items">
@@ -99,7 +122,6 @@
</div>
</div>
<!-- Normal Row -->
<div class="icon-button-showcase__row">
<span class="icon-button-showcase__label">Normal</span>
<div class="icon-button-showcase__items">
@@ -113,7 +135,6 @@
</div>
</div>
<!-- Hover/Active Row -->
<div class="icon-button-showcase__row">
<span class="icon-button-showcase__label">Active / Hover</span>
<div class="icon-button-showcase__items">
@@ -136,7 +157,6 @@
</p>
<div class="icon-button-showcase__grid">
<!-- Disabled Row -->
<div class="icon-button-showcase__row">
<span class="icon-button-showcase__label">Disabled</span>
<div class="icon-button-showcase__items">
@@ -145,7 +165,6 @@
</div>
</div>
<!-- Normal Row -->
<div class="icon-button-showcase__row">
<span class="icon-button-showcase__label">Normal</span>
<div class="icon-button-showcase__items">
@@ -154,7 +173,6 @@
</div>
</div>
<!-- Hover/Active Row -->
<div class="icon-button-showcase__row">
<span class="icon-button-showcase__label">Active / Hover</span>
<div class="icon-button-showcase__items">
@@ -292,7 +310,9 @@
/>
</div>
<app-button variant="secondary" (click)="clearFile()">Limpiar archivo</app-button>
<app-button variant="secondary" (click)="clearFile()">
Limpiar archivo
</app-button>
</div>
</div>
@@ -301,7 +321,8 @@
<div class="paginator-showcase">
<h2>Paginator</h2>
<p class="text-muted mb-0">
Componente shared para navegar por paginas con primera, anterior, siguiente y ultima.
Componente shared para navegar por paginas con primera, anterior,
siguiente y ultima.
</p>
<app-paginator
[page]="currentPage"
@@ -322,7 +343,9 @@
</div>
@if (!cartVisible) {
<app-button variant="secondary" (click)="showCart()">Volver a mostrar carrito</app-button>
<app-button variant="secondary" (click)="showCart()">
Volver a mostrar carrito
</app-button>
}
</div>
@@ -346,16 +369,19 @@
<section class="component-demo card shadow-sm mt-4 configuracion-tennant">
<div class="card-body">
<span class="eyebrow">Tenant Configuration</span>
<h2 class="mb-4">Configuración Tenant</h2>
<h2 class="mb-4">Configuracion Tenant</h2>
<!-- Logos section -->
<div class="row mb-4">
<div class="col-md-6 mb-3 mb-md-0">
<div class="tenant-logo-card">
<div class="tenant-logo-card__label">Logo del Header</div>
<div class="tenant-logo-card__container header-bg">
@if (tenant()?.header_logo) {
<img [src]="tenant()?.header_logo" alt="Header Logo" class="tenant-logo-card__img" />
<img
[src]="tenant()?.header_logo"
alt="Header Logo"
class="tenant-logo-card__img"
/>
} @else {
<div class="tenant-logo-card__placeholder">
<i class="fa-solid fa-image mb-2"></i>
@@ -371,7 +397,11 @@
<div class="tenant-logo-card__label">Logo del Footer</div>
<div class="tenant-logo-card__container footer-bg">
@if (tenant()?.footer_logo) {
<img [src]="tenant()?.footer_logo" alt="Footer Logo" class="tenant-logo-card__img" />
<img
[src]="tenant()?.footer_logo"
alt="Footer Logo"
class="tenant-logo-card__img"
/>
} @else {
<div class="tenant-logo-card__placeholder">
<i class="fa-solid fa-image mb-2"></i>
@@ -385,66 +415,95 @@
<hr class="section-divider" />
<!-- Colors section -->
<div class="colors-section">
<h3 class="mb-3">Colores de Marca</h3>
<div class="row">
<div class="col-md-3 mb-3">
<div class="color-swatch-card shadow-sm">
<div class="color-swatch-card__preview" [style.background-color]="'var(--tenant-primary)'"></div>
<div
class="color-swatch-card__preview"
[style.background-color]="'var(--tenant-primary)'"
></div>
<div class="color-swatch-card__body">
<span class="color-swatch-card__title">Primario</span>
<code class="color-swatch-card__value">{{ tenant()?.primary_color || 'Default' }}</code>
<code class="color-swatch-card__value">
{{ tenant()?.primary_color || 'Default' }}
</code>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="color-swatch-card shadow-sm">
<div class="color-swatch-card__preview" [style.background-color]="'var(--tenant-secondary)'"></div>
<div
class="color-swatch-card__preview"
[style.background-color]="'var(--tenant-secondary)'"
></div>
<div class="color-swatch-card__body">
<span class="color-swatch-card__title">Secundario</span>
<code class="color-swatch-card__value">{{ tenant()?.secondary_color || 'Default' }}</code>
<code class="color-swatch-card__value">
{{ tenant()?.secondary_color || 'Default' }}
</code>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="color-swatch-card shadow-sm">
<div class="color-swatch-card__preview" [style.background-color]="'var(--tenant-danger)'"></div>
<div
class="color-swatch-card__preview"
[style.background-color]="'var(--tenant-danger)'"
></div>
<div class="color-swatch-card__body">
<span class="color-swatch-card__title">Danger</span>
<code class="color-swatch-card__value">{{ tenant()?.danger_color || 'Default' }}</code>
<code class="color-swatch-card__value">
{{ tenant()?.danger_color || 'Default' }}
</code>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="color-swatch-card shadow-sm">
<div class="color-swatch-card__preview" [style.background-color]="'var(--tenant-success)'"></div>
<div
class="color-swatch-card__preview"
[style.background-color]="'var(--tenant-success)'"
></div>
<div class="color-swatch-card__body">
<span class="color-swatch-card__title">Success</span>
<code class="color-swatch-card__value">{{ tenant()?.success_color || 'Default' }}</code>
<code class="color-swatch-card__value">
{{ tenant()?.success_color || 'Default' }}
</code>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="color-swatch-card shadow-sm">
<div class="color-swatch-card__preview" [style.background-color]="'var(--tenant-header-bg)'"></div>
<div
class="color-swatch-card__preview"
[style.background-color]="'var(--tenant-header-bg)'"
></div>
<div class="color-swatch-card__body">
<span class="color-swatch-card__title">Header BG</span>
<code class="color-swatch-card__value">{{ tenant()?.header_bg_color || 'Default' }}</code>
<code class="color-swatch-card__value">
{{ tenant()?.header_bg_color || 'Default' }}
</code>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="color-swatch-card shadow-sm">
<div class="color-swatch-card__preview" [style.background-color]="'var(--tenant-footer-bg)'"></div>
<div
class="color-swatch-card__preview"
[style.background-color]="'var(--tenant-footer-bg)'"
></div>
<div class="color-swatch-card__body">
<span class="color-swatch-card__title">Footer BG</span>
<code class="color-swatch-card__value">{{ tenant()?.footer_bg_color || 'Default' }}</code>
<code class="color-swatch-card__value">
{{ tenant()?.footer_bg_color || 'Default' }}
</code>
</div>
</div>
</div>
@@ -457,9 +516,10 @@
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<h2 class="mb-4">Tarjetas de Producto (ProductCard)</h2>
<p class="text-muted mb-4">
Las tarjetas se adaptan al tamaño del contenedor padre. A continuación se presentan dentro de una grilla de Bootstrap.
Las tarjetas se adaptan al tamano del contenedor padre. A continuacion se
presentan dentro de una grilla de Bootstrap.
</p>
<div class="row">
@@ -479,10 +539,15 @@
</div>
</section>
<app-store-section title="PRODUCTOS" class="component-demo store-section-demo mt-5">
<app-store-section
title="PRODUCTOS"
class="component-demo store-section-demo mt-5"
>
<div class="row">
@for (product of testProducts; track product.title) {
<div class="col-12 col-md-6 col-lg-4 mb-4 d-flex align-items-stretch store-section-demo__item">
<div
class="col-12 col-md-6 col-lg-4 mb-4 d-flex align-items-stretch store-section-demo__item"
>
<app-product-card
[imageUrl]="product.imageUrl"
[title]="product.title"
@@ -496,4 +561,3 @@
</div>
</app-store-section>
</main>

View File

@@ -1,11 +1,16 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { describe, expect, it, vi } from 'vitest';
import { ReutilizablesTestPageComponent } from './reutilizables-test-page.component';
import { TestBed, getTestBed } from '@angular/core/testing';
import {
BrowserTestingModule,
platformBrowserTesting
} from '@angular/platform-browser/testing';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { ModalService } from '../../../../core/services/modal.service';
import { Tenant } from '../../../../core/services/tenant.interface';
import { TenantService } from '../../../../core/services/tenant.service';
import { ToastService } from '../../../../core/services/toast.service';
import { ReutilizablesTestPageComponent } from './reutilizables-test-page.component';
const tenant: Tenant = {
id: 1,
@@ -42,18 +47,39 @@ function createToastServiceStub() {
};
}
function createModalRefStub() {
return {
afterClosed$: {
subscribe: vi.fn()
},
dismissReason: vi.fn().mockReturnValue(null)
};
}
function createModalServiceStub() {
return {
open: vi.fn().mockReturnValue({
afterClosed$: {
subscribe: vi.fn()
},
dismissReason: vi.fn().mockReturnValue(null)
})
open: vi.fn().mockReturnValue(createModalRefStub()),
openConfirm: vi.fn().mockReturnValue(createModalRefStub()),
openConfirmDelete: vi.fn().mockReturnValue(createModalRefStub())
};
}
describe('ReutilizablesTestPageComponent', () => {
beforeAll(() => {
try {
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting()
);
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
});
afterEach(() => {
TestBed.resetTestingModule();
});
it('renders the tenant configuration section with logo sources and swatch colors', async () => {
const modalServiceStub = createModalServiceStub();
await TestBed.configureTestingModule({
@@ -78,20 +104,20 @@ describe('ReutilizablesTestPageComponent', () => {
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
// Check section title
const sectionTitle = element.querySelector('.configuracion-tennant h2');
expect(sectionTitle?.textContent).toContain('Configuración Tenant');
// Check header logo source
const headerImg = element.querySelector('.tenant-logo-card img[alt="Header Logo"]') as HTMLImageElement;
expect(sectionTitle?.textContent).toContain('Configuracion Tenant');
const headerImg = element.querySelector(
'.tenant-logo-card img[alt="Header Logo"]'
) as HTMLImageElement;
expect(headerImg?.src).toBe(tenant.header_logo);
// Check footer logo source
const footerImg = element.querySelector('.tenant-logo-card img[alt="Footer Logo"]') as HTMLImageElement;
const footerImg = element.querySelector(
'.tenant-logo-card img[alt="Footer Logo"]'
) as HTMLImageElement;
expect(footerImg?.src).toBe(tenant.footer_logo);
// Check color hex values
expect(element.textContent).toContain(tenant.primary_color);
expect(element.textContent).toContain(tenant.secondary_color);
expect(element.textContent).toContain(tenant.danger_color);
@@ -124,10 +150,12 @@ describe('ReutilizablesTestPageComponent', () => {
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('app-paginator')).not.toBeNull();
expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe('1/8');
expect(
element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()
).toBe('1/8');
});
it('renders the modal showcase and opens the basic modal from the demo button', async () => {
it('renders the modal showcase and opens the confirm demos from the buttons', async () => {
const modalServiceStub = createModalServiceStub();
await TestBed.configureTestingModule({
imports: [ReutilizablesTestPageComponent],
@@ -151,15 +179,24 @@ describe('ReutilizablesTestPageComponent', () => {
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const modalButtons = Array.from(element.querySelectorAll('.button-group app-button button'))
.filter((button) => button.textContent?.includes('Abrir modal'));
expect(element.querySelector('[data-testid="modal-last-result"]')?.textContent).toContain(
'Todavía no se abrió ningún modal.'
const buttons = Array.from(
element.querySelectorAll('.button-group app-button button')
);
const confirmButton = buttons.find((button) =>
button.textContent?.includes('Abrir confirm modal')
) as HTMLButtonElement;
const deleteButton = buttons.find((button) =>
button.textContent?.includes('Abrir confirm delete')
) as HTMLButtonElement;
(modalButtons[0] as HTMLButtonElement).click();
expect(
element.querySelector('[data-testid="modal-last-result"]')?.textContent
).toContain('Todavia no se abrio ningun modal.');
expect(modalServiceStub.open).toHaveBeenCalled();
confirmButton.click();
deleteButton.click();
expect(modalServiceStub.openConfirm).toHaveBeenCalled();
expect(modalServiceStub.openConfirmDelete).toHaveBeenCalled();
});
});

View File

@@ -1,55 +1,19 @@
import { Component, inject } from '@angular/core';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component';
import {
CartComponent,
CartItemMock
} from '../../../../shared/components/cart/cart.component';
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
import { InputComponent } from '../../../../shared/components/input/input.component';
import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component';
import { ProductCardComponent } from '../../../../shared/components/product-card/product-card.component';
import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
import {
MODAL_DATA,
ModalRef,
ModalService
} from '../../../../core/services/modal.service';
import { ModalService } from '../../../../core/services/modal.service';
import { TenantService } from '../../../../core/services/tenant.service';
import { ToastService } from '../../../../core/services/toast.service';
interface ModalDemoData {
title: string;
description: string;
confirmLabel: string;
}
@Component({
selector: 'app-modal-demo-content',
imports: [ButtonComponent],
template: `
<div class="modal-demo-content">
<p class="modal-demo-content__eyebrow">Demo modal</p>
<h3 class="modal-demo-content__title">{{ data.title }}</h3>
<p class="modal-demo-content__description">{{ data.description }}</p>
<div class="modal-demo-content__actions">
<app-button variant="secondary" (click)="dismiss()">Cancelar</app-button>
<app-button (click)="confirm()">{{ data.confirmLabel }}</app-button>
</div>
</div>
`
})
export class ModalDemoContentComponent {
readonly data = inject<ModalDemoData>(MODAL_DATA);
private readonly modalRef = inject<ModalRef<string>>(ModalRef);
protected confirm(): void {
this.modalRef.close(`confirm:${this.data.title}`);
}
protected dismiss(): void {
this.modalRef.dismiss('programmatic');
}
}
@Component({
selector: 'app-reutilizables-test-page',
imports: [
@@ -87,13 +51,13 @@ export class ReutilizablesTestPageComponent {
protected editableDisabled = true;
protected currentPage = 1;
protected cartVisible = true;
protected lastModalResult = 'Todavía no se abrió ningún modal.';
protected lastModalResult = 'Todavia no se abrio ningun modal.';
protected readonly paginatorTotalPages = 8;
protected readonly cartBackgroundColor = '#ffffff';
protected readonly testProducts = [
{
title: 'Pantalón recto de scuba negro',
title: 'Pantalon recto de scuba negro',
originalPrice: 95000,
discount: 20,
transferPrice: 74800,
@@ -107,7 +71,7 @@ export class ReutilizablesTestPageComponent {
imageUrl: '/images/zapatillas-running.png'
},
{
title: 'Remera básica blanca (Sin Descuento)',
title: 'Remera basica blanca (Sin Descuento)',
originalPrice: 32000,
discount: null,
transferPrice: 30000,
@@ -118,7 +82,7 @@ export class ReutilizablesTestPageComponent {
protected readonly cartMockItems: CartItemMock[] = [
{
imageUrl: null,
product: 'Calza térmica unisex con proceso sense y cintura con mayor agarre',
product: 'Calza termica unisex con proceso sense y cintura con mayor agarre',
originalPrice: 95000,
discountedPrice: 76000,
discountPercentage: 20,
@@ -130,7 +94,7 @@ export class ReutilizablesTestPageComponent {
},
{
imageUrl: null,
product: 'Pantalón recto de scuba negro',
product: 'Pantalon recto de scuba negro',
originalPrice: null,
discountedPrice: 89000,
discountPercentage: null,
@@ -186,83 +150,98 @@ export class ReutilizablesTestPageComponent {
protected hideCart(): void {
this.cartVisible = false;
this.toastService.info('Se disparó el evento de cerrado del carrito mock.');
this.toastService.info('Se disparo el evento de cerrado del carrito mock.');
}
protected triggerToast(type: 'success' | 'danger' | 'info'): void {
if (type === 'success') {
this.toastService.success('¡Operación exitosa! El producto se ha guardado correctamente.');
this.toastService.success(
'Operacion exitosa. El producto se ha guardado correctamente.'
);
} else if (type === 'danger') {
this.toastService.danger('¡Alerta de error! Hubo un problema al procesar la solicitud.');
this.toastService.danger(
'Alerta de error. Hubo un problema al procesar la solicitud.'
);
} else if (type === 'info') {
this.toastService.info('Información del sistema: Hay una nueva actualización disponible.');
this.toastService.info(
'Informacion del sistema: Hay una nueva actualizacion disponible.'
);
}
}
protected triggerPersistentToast(type: 'success' | 'danger' | 'info'): void {
if (type === 'success') {
this.toastService.success('¡Éxito persistente! Esta alerta no se cerrará sola.', 0);
this.toastService.success(
'Exito persistente. Esta alerta no se cerrara sola.',
0
);
} else if (type === 'danger') {
this.toastService.danger('¡Error persistente! Por favor, atienda este problema y ciérrelo manualmente.', 0);
this.toastService.danger(
'Error persistente. Por favor, atienda este problema y cierrelo manualmente.',
0
);
} else if (type === 'info') {
this.toastService.info('Información persistente: Mantendremos este aviso en pantalla.', 0);
this.toastService.info(
'Informacion persistente: Mantendremos este aviso en pantalla.',
0
);
}
}
protected openBasicModal(): void {
this.openDemoModal({
title: 'Modal simple',
description: 'Caso base para verificar apertura, cierre y devolución de resultado.',
this.openConfirmModal({
title: 'Confirmar accion',
content:
'Caso base para verificar apertura, cierre y devolucion de resultado.',
confirmLabel: 'Confirmar'
});
}
protected openConfirmDeleteModal(): void {
const ref = this.modalService.openConfirmDelete({
title: 'Eliminar producto',
content:
'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.',
confirmLabel: 'Eliminar',
cancelLabel: 'Conservar'
});
this.handleModalResult(ref);
}
protected openLockedModal(): void {
this.openDemoModal(
{
title: 'Modal bloqueado',
description: 'Este modal no se cierra tocando el backdrop ni con la tecla Escape.',
confirmLabel: 'Entendido'
},
{
closeOnBackdrop: false,
closeOnEscape: false
}
);
this.openConfirmModal({
title: 'Modal bloqueado',
content:
'Este modal no se cierra tocando el backdrop ni con la tecla Escape.',
confirmLabel: 'Entendido',
closeOnBackdrop: false,
closeOnEscape: false
});
}
protected openWideModal(): void {
this.openDemoModal(
{
title: 'Modal ancho',
description: 'Demuestra una variante visual más amplia para contenido más pesado.',
confirmLabel: 'Seguir'
},
{
size: 'xl'
}
);
this.openConfirmModal({
title: 'Modal ancho',
content: 'Demuestra una variante visual mas amplia para contenido mas pesado.',
confirmLabel: 'Seguir',
size: 'xl'
});
}
private openDemoModal(
data: ModalDemoData,
overrides: Partial<{
size: 'sm' | 'md' | 'lg' | 'xl' | 'full';
closeOnBackdrop: boolean;
closeOnEscape: boolean;
}> = {}
): void {
const ref = this.modalService.open(ModalDemoContentComponent, {
title: data.title,
data,
...overrides
});
private openConfirmModal(config: Parameters<ModalService['openConfirm']>[0]): void {
const ref = this.modalService.openConfirm(config);
this.handleModalResult(ref);
}
private handleModalResult(ref: ReturnType<ModalService['openConfirm']>): void {
ref.afterClosed$.subscribe((result) => {
const dismissReason = ref.dismissReason();
this.lastModalResult = result
? `Resultado: ${result}`
: `Cerrado sin resultado${dismissReason ? ` (${dismissReason})` : ''}.`;
this.lastModalResult =
typeof result === 'boolean'
? `Resultado: ${result}`
: `Cerrado sin resultado${dismissReason ? ` (${dismissReason})` : ''}.`;
});
}
}

View File

@@ -0,0 +1,12 @@
<div class="confirm-modal">
<p class="confirm-modal__content">{{ data.content }}</p>
<div class="confirm-modal__actions">
<app-button variant="danger-secondary" (click)="cancel()">
{{ data.cancelLabel }}
</app-button>
<app-button variant="danger" (click)="confirm()">
{{ data.confirmLabel }}
</app-button>
</div>
</div>

View File

@@ -0,0 +1,19 @@
.confirm-modal {
display: grid;
gap: 1.5rem;
justify-items: center;
text-align: center;
}
.confirm-modal__content {
margin: 0;
color: #666666;
line-height: 1.5;
}
.confirm-modal__actions {
display: flex;
justify-content: center;
gap: 0.75rem;
flex-wrap: wrap;
}

View File

@@ -0,0 +1,96 @@
import '@angular/compiler';
import { TestBed, getTestBed } from '@angular/core/testing';
import {
BrowserTestingModule,
platformBrowserTesting
} from '@angular/platform-browser/testing';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import {
ConfirmModalData,
MODAL_DATA,
ModalRef
} from '../../../core/services/modal.service';
import { ConfirmDeleteModalComponent } from './confirm-delete-modal.component';
describe('ConfirmDeleteModalComponent', () => {
const data: ConfirmModalData = {
content: 'Se eliminara el elemento seleccionado.',
confirmLabel: 'Eliminar',
cancelLabel: 'Cancelar'
};
beforeAll(() => {
try {
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting()
);
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
});
afterEach(() => {
TestBed.resetTestingModule();
});
it('renders the configured content and uses danger for the confirm action', async () => {
await TestBed.configureTestingModule({
imports: [ConfirmDeleteModalComponent],
providers: [
{
provide: MODAL_DATA,
useValue: data
},
{
provide: ModalRef,
useValue: {
close: vi.fn()
}
}
]
}).compileComponents();
const fixture = TestBed.createComponent(ConfirmDeleteModalComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const buttons = element.querySelectorAll('button');
expect(element.textContent).toContain(data.content);
expect(element.textContent).toContain(data.confirmLabel);
expect(buttons[1].className).toContain('btn-danger');
});
it('closes with false on cancel and true on confirm', async () => {
const closeSpy = vi.fn();
await TestBed.configureTestingModule({
imports: [ConfirmDeleteModalComponent],
providers: [
{
provide: MODAL_DATA,
useValue: data
},
{
provide: ModalRef,
useValue: {
close: closeSpy
}
}
]
}).compileComponents();
const fixture = TestBed.createComponent(ConfirmDeleteModalComponent);
fixture.detectChanges();
const buttons = fixture.nativeElement.querySelectorAll('button');
buttons[0].click();
buttons[1].click();
expect(closeSpy).toHaveBeenNthCalledWith(1, false);
expect(closeSpy).toHaveBeenNthCalledWith(2, true);
});
});

View File

@@ -0,0 +1,28 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import {
ConfirmModalData,
MODAL_DATA,
ModalRef
} from '../../../core/services/modal.service';
import { ButtonComponent } from '../button/button.component';
@Component({
selector: 'app-confirm-delete-modal',
imports: [ButtonComponent],
templateUrl: './confirm-delete-modal.component.html',
styleUrl: './confirm-delete-modal.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ConfirmDeleteModalComponent {
protected readonly data = inject<ConfirmModalData>(MODAL_DATA);
private readonly modalRef = inject<ModalRef<boolean>>(ModalRef);
protected cancel(): void {
this.modalRef.close(false);
}
protected confirm(): void {
this.modalRef.close(true);
}
}

View File

@@ -0,0 +1,12 @@
<div class="confirm-modal">
<p class="confirm-modal__content">{{ data.content }}</p>
<div class="confirm-modal__actions">
<app-button variant="secondary" (click)="cancel()">
{{ data.cancelLabel }}
</app-button>
<app-button (click)="confirm()">
{{ data.confirmLabel }}
</app-button>
</div>
</div>

View File

@@ -0,0 +1,19 @@
.confirm-modal {
display: grid;
gap: 1.5rem;
justify-items: center;
text-align: center;
}
.confirm-modal__content {
margin: 0;
color: #666666;
line-height: 1.5;
}
.confirm-modal__actions {
display: flex;
justify-content: center;
gap: 0.75rem;
flex-wrap: wrap;
}

View File

@@ -0,0 +1,95 @@
import '@angular/compiler';
import { TestBed, getTestBed } from '@angular/core/testing';
import {
BrowserTestingModule,
platformBrowserTesting
} from '@angular/platform-browser/testing';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import {
ConfirmModalData,
MODAL_DATA,
ModalRef
} from '../../../core/services/modal.service';
import { ConfirmModalComponent } from './confirm-modal.component';
describe('ConfirmModalComponent', () => {
const data: ConfirmModalData = {
content: 'Se confirmara la operacion seleccionada.',
confirmLabel: 'Aceptar',
cancelLabel: 'Volver'
};
beforeAll(() => {
try {
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting()
);
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
});
afterEach(() => {
TestBed.resetTestingModule();
});
it('renders the configured content and labels', async () => {
await TestBed.configureTestingModule({
imports: [ConfirmModalComponent],
providers: [
{
provide: MODAL_DATA,
useValue: data
},
{
provide: ModalRef,
useValue: {
close: vi.fn()
}
}
]
}).compileComponents();
const fixture = TestBed.createComponent(ConfirmModalComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain(data.content);
expect(element.textContent).toContain(data.confirmLabel);
expect(element.textContent).toContain(data.cancelLabel);
});
it('closes with false on cancel and true on confirm', async () => {
const closeSpy = vi.fn();
await TestBed.configureTestingModule({
imports: [ConfirmModalComponent],
providers: [
{
provide: MODAL_DATA,
useValue: data
},
{
provide: ModalRef,
useValue: {
close: closeSpy
}
}
]
}).compileComponents();
const fixture = TestBed.createComponent(ConfirmModalComponent);
fixture.detectChanges();
const buttons = fixture.nativeElement.querySelectorAll('button');
buttons[0].click();
buttons[1].click();
expect(closeSpy).toHaveBeenNthCalledWith(1, false);
expect(closeSpy).toHaveBeenNthCalledWith(2, true);
});
});

View File

@@ -0,0 +1,28 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import {
ConfirmModalData,
MODAL_DATA,
ModalRef
} from '../../../core/services/modal.service';
import { ButtonComponent } from '../button/button.component';
@Component({
selector: 'app-confirm-modal',
imports: [ButtonComponent],
templateUrl: './confirm-modal.component.html',
styleUrl: './confirm-modal.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ConfirmModalComponent {
protected readonly data = inject<ConfirmModalData>(MODAL_DATA);
private readonly modalRef = inject<ModalRef<boolean>>(ModalRef);
protected cancel(): void {
this.modalRef.close(false);
}
protected confirm(): void {
this.modalRef.close(true);
}
}

View File

@@ -49,16 +49,26 @@
}
.modal-shell__header {
position: relative;
justify-content: center;
align-items: flex-start;
padding: 1.25rem 1.25rem 0.75rem;
}
.modal-shell__title {
margin: 0;
width: 100%;
font-size: clamp(1.15rem, 1rem + 0.4vw, 1.5rem);
font-weight: 700;
line-height: 1.15;
color: #202020;
text-align: center;
}
.modal-shell__header .btn-close {
position: absolute;
top: 1.25rem;
right: 1.25rem;
}
.modal-shell__body {