From 125d9137fba0f6ed8de7c5837232aa5adc6d58b2 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 29 Jul 2026 11:03:35 -0300 Subject: [PATCH] feat: implement global loading state with interceptor and component --- src/app/app.config.ts | 6 +- src/app/app.html | 1 + src/app/app.ts | 10 ++- .../global-loading.interceptor.spec.ts | 79 +++++++++++++++++++ .../global-loading.interceptor.ts | 27 +++++++ .../global-loading.service.spec.ts | 65 +++++++++++++++ .../global-loading/global-loading.service.ts | 58 ++++++++++++++ .../global-loading.component.html | 11 +++ .../global-loading.component.scss | 31 ++++++++ .../global-loading.component.spec.ts | 54 +++++++++++++ .../global-loading.component.ts | 15 ++++ 11 files changed, 354 insertions(+), 3 deletions(-) create mode 100644 src/app/core/services/global-loading/global-loading.interceptor.spec.ts create mode 100644 src/app/core/services/global-loading/global-loading.interceptor.ts create mode 100644 src/app/core/services/global-loading/global-loading.service.spec.ts create mode 100644 src/app/core/services/global-loading/global-loading.service.ts create mode 100644 src/app/shared/components/global-loading/global-loading.component.html create mode 100644 src/app/shared/components/global-loading/global-loading.component.scss create mode 100644 src/app/shared/components/global-loading/global-loading.component.spec.ts create mode 100644 src/app/shared/components/global-loading/global-loading.component.ts diff --git a/src/app/app.config.ts b/src/app/app.config.ts index a00ccb3..0d355f5 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -10,6 +10,7 @@ import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/p import { routes } from './app.routes'; import { authBootstrap } from './core/services/auth/auth-bootstrap'; import { authInterceptor } from './core/services/auth/auth.interceptor'; +import { globalLoadingInterceptor } from './core/services/global-loading/global-loading.interceptor'; import { tenantBootstrap } from './core/services/tenant-bootstrap'; function isStoreCatalogRequest(request: HttpRequest): boolean { @@ -29,7 +30,10 @@ export const appConfig: ApplicationConfig = { filter: isStoreCatalogRequest, }), ), - provideHttpClient(withFetch(), withInterceptors([authInterceptor])), + provideHttpClient( + withFetch(), + withInterceptors([authInterceptor, globalLoadingInterceptor]) + ), provideAppInitializer(authBootstrap), provideAppInitializer(tenantBootstrap), ], diff --git a/src/app/app.html b/src/app/app.html index 78d7d40..c1d2a35 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -2,6 +2,7 @@ + } @else if (status() === 'not-found') {
diff --git a/src/app/app.ts b/src/app/app.ts index 5b97050..b03a2f8 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 { GlobalLoadingComponent } from './shared/components/global-loading/global-loading.component'; import { ModalHostComponent } from './shared/components/modal-host/modal-host.component'; import { ToastContainerComponent } from './shared/components/toast-container/toast-container.component'; @@ -23,7 +24,12 @@ function hexToRgb(hex: string): string { @Component({ selector: 'app-root', - imports: [RouterOutlet, ToastContainerComponent, ModalHostComponent], + imports: [ + RouterOutlet, + ToastContainerComponent, + ModalHostComponent, + GlobalLoadingComponent + ], templateUrl: './app.html', styleUrl: './app.scss', host: { @@ -88,4 +94,4 @@ export class App { this.tenantService.tenant()?.footer_bg_color ?? DEFAULT_TENANT_BRANDING.footerBgColor ); -} \ No newline at end of file +} diff --git a/src/app/core/services/global-loading/global-loading.interceptor.spec.ts b/src/app/core/services/global-loading/global-loading.interceptor.spec.ts new file mode 100644 index 0000000..bd031e7 --- /dev/null +++ b/src/app/core/services/global-loading/global-loading.interceptor.spec.ts @@ -0,0 +1,79 @@ +import '@angular/compiler'; +import { HttpClient, HttpContext, provideHttpClient, withInterceptors } from '@angular/common/http'; +import { + HttpTestingController, + provideHttpClientTesting +} from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + SKIP_GLOBAL_LOADING, + globalLoadingInterceptor +} from './global-loading.interceptor'; +import { GlobalLoadingService } from './global-loading.service'; + +describe('globalLoadingInterceptor', () => { + let client: HttpClient; + let httpTesting: HttpTestingController; + let loadingService: GlobalLoadingService; + + beforeEach(() => { + vi.useFakeTimers(); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([globalLoadingInterceptor])), + provideHttpClientTesting() + ] + }); + + client = TestBed.inject(HttpClient); + httpTesting = TestBed.inject(HttpTestingController); + loadingService = TestBed.inject(GlobalLoadingService); + }); + + afterEach(() => { + httpTesting.verify(); + TestBed.resetTestingModule(); + vi.useRealTimers(); + }); + + it('tracks concurrent requests until the last one completes', () => { + client.get('/api/first').subscribe(); + client.get('/api/second').subscribe(); + vi.advanceTimersByTime(150); + + expect(loadingService.isLoading()).toBe(true); + + httpTesting.expectOne('/api/first').flush({}); + expect(loadingService.isLoading()).toBe(true); + + httpTesting.expectOne('/api/second').flush({}); + expect(loadingService.isLoading()).toBe(false); + }); + + it('stops loading when a request fails', () => { + client.get('/api/failing').subscribe({ error: () => undefined }); + vi.advanceTimersByTime(150); + + expect(loadingService.isLoading()).toBe(true); + + httpTesting.expectOne('/api/failing').flush( + { message: 'Error' }, + { status: 500, statusText: 'Server Error' } + ); + + expect(loadingService.isLoading()).toBe(false); + }); + + it('allows background requests to opt out', () => { + const context = new HttpContext().set(SKIP_GLOBAL_LOADING, true); + + client.get('/api/background', { context }).subscribe(); + vi.advanceTimersByTime(150); + + expect(loadingService.isLoading()).toBe(false); + + httpTesting.expectOne('/api/background').flush({}); + }); +}); diff --git a/src/app/core/services/global-loading/global-loading.interceptor.ts b/src/app/core/services/global-loading/global-loading.interceptor.ts new file mode 100644 index 0000000..e4f88d4 --- /dev/null +++ b/src/app/core/services/global-loading/global-loading.interceptor.ts @@ -0,0 +1,27 @@ +import { + HttpContextToken, + HttpEvent, + HttpHandlerFn, + HttpInterceptorFn, + HttpRequest +} from '@angular/common/http'; +import { inject } from '@angular/core'; +import { Observable, finalize } from 'rxjs'; + +import { GlobalLoadingService } from './global-loading.service'; + +export const SKIP_GLOBAL_LOADING = new HttpContextToken(() => false); + +export const globalLoadingInterceptor: HttpInterceptorFn = ( + request: HttpRequest, + next: HttpHandlerFn +): Observable> => { + if (request.context.get(SKIP_GLOBAL_LOADING)) { + return next(request); + } + + const loadingService = inject(GlobalLoadingService); + loadingService.start(); + + return next(request).pipe(finalize(() => loadingService.stop())); +}; diff --git a/src/app/core/services/global-loading/global-loading.service.spec.ts b/src/app/core/services/global-loading/global-loading.service.spec.ts new file mode 100644 index 0000000..2e4d003 --- /dev/null +++ b/src/app/core/services/global-loading/global-loading.service.spec.ts @@ -0,0 +1,65 @@ +import '@angular/compiler'; +import { TestBed } from '@angular/core/testing'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { GlobalLoadingService } from './global-loading.service'; + +describe('GlobalLoadingService', () => { + let service: GlobalLoadingService; + + beforeEach(() => { + vi.useFakeTimers(); + TestBed.configureTestingModule({}); + service = TestBed.inject(GlobalLoadingService); + }); + + afterEach(() => { + TestBed.resetTestingModule(); + vi.useRealTimers(); + }); + + it('delays the loading state to avoid flashes on fast operations', () => { + service.start(); + + expect(service.isLoading()).toBe(false); + + service.stop(); + vi.advanceTimersByTime(150); + + expect(service.isLoading()).toBe(false); + }); + + it('shows the loading state for an operation that exceeds the delay', () => { + service.start(); + vi.advanceTimersByTime(150); + + expect(service.isLoading()).toBe(true); + + service.stop(); + + expect(service.isLoading()).toBe(false); + }); + + it('stays visible until all concurrent operations finish', () => { + service.start(); + service.start(); + vi.advanceTimersByTime(150); + + service.stop(); + expect(service.isLoading()).toBe(true); + + service.stop(); + expect(service.isLoading()).toBe(false); + }); + + it('ignores extra stop calls without corrupting the counter', () => { + service.stop(); + service.start(); + vi.advanceTimersByTime(150); + + expect(service.isLoading()).toBe(true); + + service.stop(); + expect(service.isLoading()).toBe(false); + }); +}); diff --git a/src/app/core/services/global-loading/global-loading.service.ts b/src/app/core/services/global-loading/global-loading.service.ts new file mode 100644 index 0000000..7371d27 --- /dev/null +++ b/src/app/core/services/global-loading/global-loading.service.ts @@ -0,0 +1,58 @@ +import { isPlatformBrowser } from '@angular/common'; +import { Injectable, PLATFORM_ID, inject, signal } from '@angular/core'; + +const SHOW_DELAY_MS = 150; + +@Injectable({ providedIn: 'root' }) +export class GlobalLoadingService { + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + private readonly loadingState = signal(false); + private pendingOperations = 0; + private showTimer: ReturnType | undefined; + + readonly isLoading = this.loadingState.asReadonly(); + + start(): void { + if (!this.isBrowser) { + return; + } + + this.pendingOperations += 1; + + if (this.pendingOperations > 1 || this.loadingState()) { + return; + } + + this.showTimer = setTimeout(() => { + this.showTimer = undefined; + + if (this.pendingOperations > 0) { + this.loadingState.set(true); + } + }, SHOW_DELAY_MS); + } + + stop(): void { + if (!this.isBrowser || this.pendingOperations === 0) { + return; + } + + this.pendingOperations -= 1; + + if (this.pendingOperations > 0) { + return; + } + + this.cancelShowTimer(); + this.loadingState.set(false); + } + + private cancelShowTimer(): void { + if (this.showTimer === undefined) { + return; + } + + clearTimeout(this.showTimer); + this.showTimer = undefined; + } +} diff --git a/src/app/shared/components/global-loading/global-loading.component.html b/src/app/shared/components/global-loading/global-loading.component.html new file mode 100644 index 0000000..8f8b830 --- /dev/null +++ b/src/app/shared/components/global-loading/global-loading.component.html @@ -0,0 +1,11 @@ +@if (isLoading()) { +
+ + Cargando... +
+} diff --git a/src/app/shared/components/global-loading/global-loading.component.scss b/src/app/shared/components/global-loading/global-loading.component.scss new file mode 100644 index 0000000..1b319be --- /dev/null +++ b/src/app/shared/components/global-loading/global-loading.component.scss @@ -0,0 +1,31 @@ +.global-loading { + position: fixed; + inset: 0; + z-index: 3000; + display: grid; + place-items: center; + background: rgba(255, 255, 255, 0.64); + backdrop-filter: blur(2px); + cursor: wait; +} + +.global-loading__spinner { + width: 3rem; + height: 3rem; + border: 0.25rem solid rgba(32, 32, 32, 0.12); + border-top-color: var(--tenant-primary, #6376f3); + border-radius: 50%; + animation: global-loading-spin 0.75s linear infinite; +} + +@keyframes global-loading-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .global-loading__spinner { + animation-duration: 1.5s; + } +} diff --git a/src/app/shared/components/global-loading/global-loading.component.spec.ts b/src/app/shared/components/global-loading/global-loading.component.spec.ts new file mode 100644 index 0000000..8c54594 --- /dev/null +++ b/src/app/shared/components/global-loading/global-loading.component.spec.ts @@ -0,0 +1,54 @@ +import '@angular/compiler'; +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { GlobalLoadingService } from '../../../core/services/global-loading/global-loading.service'; +import { GlobalLoadingComponent } from './global-loading.component'; + +describe('GlobalLoadingComponent', () => { + afterEach(() => { + TestBed.resetTestingModule(); + }); + + it('renders an accessible overlay while the app is loading', async () => { + const loadingState = signal(true); + + await TestBed.configureTestingModule({ + imports: [GlobalLoadingComponent], + providers: [ + { + provide: GlobalLoadingService, + useValue: { isLoading: loadingState.asReadonly() } + } + ] + }).compileComponents(); + + const fixture = TestBed.createComponent(GlobalLoadingComponent); + fixture.detectChanges(); + + const overlay = fixture.nativeElement.querySelector('.global-loading'); + expect(overlay).not.toBeNull(); + expect(overlay.getAttribute('role')).toBe('status'); + expect(overlay.getAttribute('aria-label')).toBe('Cargando'); + }); + + it('does not render the overlay while the app is idle', async () => { + const loadingState = signal(false); + + await TestBed.configureTestingModule({ + imports: [GlobalLoadingComponent], + providers: [ + { + provide: GlobalLoadingService, + useValue: { isLoading: loadingState.asReadonly() } + } + ] + }).compileComponents(); + + const fixture = TestBed.createComponent(GlobalLoadingComponent); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.global-loading')).toBeNull(); + }); +}); diff --git a/src/app/shared/components/global-loading/global-loading.component.ts b/src/app/shared/components/global-loading/global-loading.component.ts new file mode 100644 index 0000000..d6262a0 --- /dev/null +++ b/src/app/shared/components/global-loading/global-loading.component.ts @@ -0,0 +1,15 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; + +import { GlobalLoadingService } from '../../../core/services/global-loading/global-loading.service'; + +@Component({ + selector: 'app-global-loading', + templateUrl: './global-loading.component.html', + styleUrl: './global-loading.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class GlobalLoadingComponent { + private readonly loadingService = inject(GlobalLoadingService); + + protected readonly isLoading = this.loadingService.isLoading; +}