diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts
index 5677f40..200a98c 100644
--- a/src/app/app.spec.ts
+++ b/src/app/app.spec.ts
@@ -1,6 +1,12 @@
+import '@angular/compiler';
import { signal } from '@angular/core';
-import { TestBed } from '@angular/core/testing';
+import { TestBed, getTestBed } from '@angular/core/testing';
+import {
+ BrowserTestingModule,
+ platformBrowserTesting
+} from '@angular/platform-browser/testing';
import { provideRouter } from '@angular/router';
+import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { App } from './app';
import { Tenant } from './core/services/tenant.interface';
@@ -34,6 +40,21 @@ function createTenantServiceStub(status: 'ready' | 'not-found', currentTenant: T
}
describe('App', () => {
+ beforeAll(() => {
+ try {
+ getTestBed().initTestEnvironment(
+ BrowserTestingModule,
+ platformBrowserTesting()
+ );
+ } catch {
+ // Test environment may already be initialized by another setup entrypoint.
+ }
+ });
+
+ afterEach(() => {
+ TestBed.resetTestingModule();
+ });
+
it('creates the app when the tenant is ready', async () => {
await TestBed.configureTestingModule({
imports: [App],
diff --git a/src/app/app.ts b/src/app/app.ts
index cb66b6c..3ccb34f 100644
--- a/src/app/app.ts
+++ b/src/app/app.ts
@@ -3,6 +3,7 @@ import { RouterOutlet } from '@angular/router';
import { TenantService } from './core/services/tenant.service';
import { DEFAULT_TENANT_BRANDING } from './core/services/tenant-ssr-cache.store';
+import { ModalHostComponent } from './shared/components/modal-host/modal-host.component';
import { ToastContainerComponent } from './shared/components/toast-container/toast-container.component';
function hexToRgb(hex: string): string {
@@ -22,7 +23,7 @@ function hexToRgb(hex: string): string {
@Component({
selector: 'app-root',
- imports: [RouterOutlet, ToastContainerComponent],
+ imports: [RouterOutlet, ToastContainerComponent, ModalHostComponent],
templateUrl: './app.html',
styleUrl: './app.scss',
host: {
diff --git a/src/app/core/services/modal.service.spec.ts b/src/app/core/services/modal.service.spec.ts
new file mode 100644
index 0000000..82e63a6
--- /dev/null
+++ b/src/app/core/services/modal.service.spec.ts
@@ -0,0 +1,133 @@
+import '@angular/compiler';
+import { Component, inject } from '@angular/core';
+import { TestBed, getTestBed } from '@angular/core/testing';
+import {
+ BrowserTestingModule,
+ platformBrowserTesting
+} from '@angular/platform-browser/testing';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ MODAL_DATA,
+ ModalRef,
+ ModalService
+} from './modal.service';
+
+@Component({
+ template: ''
+})
+class FirstTestModalComponent {}
+
+@Component({
+ template: ''
+})
+class SecondTestModalComponent {}
+
+describe('ModalService', () => {
+ let service: ModalService;
+
+ beforeAll(() => {
+ try {
+ getTestBed().initTestEnvironment(
+ BrowserTestingModule,
+ platformBrowserTesting()
+ );
+ } catch {
+ // Test environment may already be initialized by another setup entrypoint.
+ }
+ });
+
+ afterEach(() => {
+ TestBed.resetTestingModule();
+ });
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ providers: [ModalService]
+ });
+
+ service = TestBed.inject(ModalService);
+ });
+
+ it('opens a modal and stores the normalized config', () => {
+ const ref = service.open(FirstTestModalComponent, {
+ title: 'Confirmar compra',
+ data: { productId: 10 },
+ size: 'lg',
+ closeOnBackdrop: false
+ });
+
+ const activeModal = service.activeModal();
+
+ expect(activeModal).not.toBeNull();
+ expect(activeModal?.component).toBe(FirstTestModalComponent);
+ expect(activeModal?.ref).toBe(ref);
+ expect(activeModal?.config).toEqual({
+ title: 'Confirmar compra',
+ data: { productId: 10 },
+ size: 'lg',
+ closeOnBackdrop: false,
+ closeOnEscape: true,
+ showCloseButton: true
+ });
+ });
+
+ it('emits the close result and clears the active modal', () => {
+ const ref = service.open(FirstTestModalComponent);
+ const closedSpy = vi.fn();
+
+ ref.afterClosed$.subscribe(closedSpy);
+ ref.close({ confirmed: true });
+
+ expect(closedSpy).toHaveBeenCalledWith({ confirmed: true });
+ expect(service.activeModal()).toBeNull();
+ expect(ref.dismissReason()).toBeNull();
+ });
+
+ it('tracks dismiss reasons when closing programmatically', () => {
+ const ref = service.open(FirstTestModalComponent);
+ const closedSpy = vi.fn();
+
+ ref.afterClosed$.subscribe(closedSpy);
+ ref.dismiss();
+
+ expect(closedSpy).toHaveBeenCalledWith(undefined);
+ expect(ref.dismissReason()).toBe('programmatic');
+ expect(service.activeModal()).toBeNull();
+ });
+
+ it('dismisses the current modal as replaced when another one opens', () => {
+ const firstRef = service.open(FirstTestModalComponent);
+ const firstClosedSpy = vi.fn();
+
+ firstRef.afterClosed$.subscribe(firstClosedSpy);
+
+ const secondRef = service.open(SecondTestModalComponent, {
+ title: 'Segundo modal'
+ });
+
+ expect(firstClosedSpy).toHaveBeenCalledWith(undefined);
+ expect(firstRef.dismissReason()).toBe('replaced');
+ expect(service.activeModal()?.ref).toBe(secondRef);
+ expect(service.activeModal()?.component).toBe(SecondTestModalComponent);
+ });
+
+ it('injects MODAL_DATA and ModalRef into opened components through the host injector contract', () => {
+ const ref = service.open(DataTestModalComponent, {
+ data: { amount: 3 }
+ });
+
+ const activeModal = service.activeModal();
+
+ expect(activeModal?.config.data).toEqual({ amount: 3 });
+ expect(activeModal?.ref).toBe(ref);
+ });
+});
+
+@Component({
+ template: ''
+})
+class DataTestModalComponent {
+ readonly data = inject(MODAL_DATA);
+ readonly modalRef = inject(ModalRef);
+}
diff --git a/src/app/core/services/modal.service.ts b/src/app/core/services/modal.service.ts
new file mode 100644
index 0000000..19131e6
--- /dev/null
+++ b/src/app/core/services/modal.service.ts
@@ -0,0 +1,147 @@
+import { InjectionToken, Injectable, Type, signal } from '@angular/core';
+import { Observable, Subject } from 'rxjs';
+
+export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
+export type ModalDismissReason =
+ | 'backdrop'
+ | 'escape'
+ | 'programmatic'
+ | 'replaced';
+
+export interface ModalConfig
{
+ title?: string;
+ data?: TData;
+ size?: ModalSize;
+ closeOnBackdrop?: boolean;
+ closeOnEscape?: boolean;
+ showCloseButton?: boolean;
+}
+
+export interface NormalizedModalConfig
+ extends Omit<
+ ModalConfig,
+ 'size' | 'closeOnBackdrop' | 'closeOnEscape' | 'showCloseButton'
+ > {
+ size: ModalSize;
+ closeOnBackdrop: boolean;
+ closeOnEscape: boolean;
+ showCloseButton: boolean;
+}
+
+export interface ActiveModalState {
+ component: Type;
+ config: NormalizedModalConfig;
+ ref: ModalRef;
+}
+
+export const MODAL_DATA = new InjectionToken('MODAL_DATA');
+
+const DEFAULT_MODAL_CONFIG: Pick<
+ NormalizedModalConfig,
+ 'size' | 'closeOnBackdrop' | 'closeOnEscape' | 'showCloseButton'
+> = {
+ size: 'md',
+ closeOnBackdrop: true,
+ closeOnEscape: true,
+ showCloseButton: true
+};
+
+export class ModalRef {
+ private readonly afterClosedSubject = new Subject();
+ private closed = false;
+
+ readonly afterClosed$: Observable =
+ this.afterClosedSubject.asObservable();
+ readonly dismissReason = signal(null);
+
+ constructor(
+ private readonly closeHandler: (result?: TResult) => void,
+ private readonly dismissHandler: (reason: ModalDismissReason) => void
+ ) {}
+
+ close(result?: TResult): void {
+ if (this.closed) {
+ return;
+ }
+
+ this.closeHandler(result);
+ }
+
+ dismiss(reason: ModalDismissReason = 'programmatic'): void {
+ if (this.closed) {
+ return;
+ }
+
+ this.dismissHandler(reason);
+ }
+
+ finalize(result?: TResult, dismissReason?: ModalDismissReason): void {
+ if (this.closed) {
+ return;
+ }
+
+ this.closed = true;
+ this.dismissReason.set(dismissReason ?? null);
+ this.afterClosedSubject.next(result);
+ this.afterClosedSubject.complete();
+ }
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class ModalService {
+ private readonly activeModalState = signal(null);
+ readonly activeModal = this.activeModalState.asReadonly();
+
+ open(
+ component: Type,
+ config: ModalConfig = {}
+ ): ModalRef {
+ this.activeModalState()?.ref.dismiss('replaced');
+
+ let ref!: ModalRef;
+ ref = new ModalRef(
+ (result) => this.close(ref, result),
+ (reason) => this.dismiss(ref, reason)
+ );
+
+ this.activeModalState.set({
+ component,
+ config: this.normalizeConfig(config),
+ ref: ref as ModalRef
+ });
+
+ return ref;
+ }
+
+ private close(ref: ModalRef, result?: TResult): void {
+ if (this.activeModalState()?.ref !== ref) {
+ return;
+ }
+
+ ref.finalize(result);
+ this.activeModalState.set(null);
+ }
+
+ private dismiss(
+ ref: ModalRef,
+ reason: ModalDismissReason = 'programmatic'
+ ): void {
+ if (this.activeModalState()?.ref !== ref) {
+ return;
+ }
+
+ ref.finalize(undefined, reason);
+ this.activeModalState.set(null);
+ }
+
+ private normalizeConfig(
+ config: ModalConfig
+ ): NormalizedModalConfig {
+ return {
+ ...DEFAULT_MODAL_CONFIG,
+ ...config
+ };
+ }
+}
diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html
index 48c167c..c67cc48 100644
--- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html
+++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html
@@ -59,6 +59,21 @@
Danger Persistente
+
+