feat: implement global loading state with interceptor and component
This commit is contained in:
@@ -10,6 +10,7 @@ import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/p
|
|||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { authBootstrap } from './core/services/auth/auth-bootstrap';
|
import { authBootstrap } from './core/services/auth/auth-bootstrap';
|
||||||
import { authInterceptor } from './core/services/auth/auth.interceptor';
|
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';
|
import { tenantBootstrap } from './core/services/tenant-bootstrap';
|
||||||
|
|
||||||
function isStoreCatalogRequest(request: HttpRequest<unknown>): boolean {
|
function isStoreCatalogRequest(request: HttpRequest<unknown>): boolean {
|
||||||
@@ -29,7 +30,10 @@ export const appConfig: ApplicationConfig = {
|
|||||||
filter: isStoreCatalogRequest,
|
filter: isStoreCatalogRequest,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
|
provideHttpClient(
|
||||||
|
withFetch(),
|
||||||
|
withInterceptors([authInterceptor, globalLoadingInterceptor])
|
||||||
|
),
|
||||||
provideAppInitializer(authBootstrap),
|
provideAppInitializer(authBootstrap),
|
||||||
provideAppInitializer(tenantBootstrap),
|
provideAppInitializer(tenantBootstrap),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<router-outlet />
|
<router-outlet />
|
||||||
<app-toast-container />
|
<app-toast-container />
|
||||||
<app-modal-host />
|
<app-modal-host />
|
||||||
|
<app-global-loading />
|
||||||
} @else if (status() === 'not-found') {
|
} @else if (status() === 'not-found') {
|
||||||
<section class="tenant-status">
|
<section class="tenant-status">
|
||||||
<div class="tenant-status__card">
|
<div class="tenant-status__card">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { RouterOutlet } from '@angular/router';
|
|||||||
|
|
||||||
import { TenantService } from './core/services/tenant.service';
|
import { TenantService } from './core/services/tenant.service';
|
||||||
import { DEFAULT_TENANT_BRANDING } from './core/services/tenant-ssr-cache.store';
|
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 { ModalHostComponent } from './shared/components/modal-host/modal-host.component';
|
||||||
import { ToastContainerComponent } from './shared/components/toast-container/toast-container.component';
|
import { ToastContainerComponent } from './shared/components/toast-container/toast-container.component';
|
||||||
|
|
||||||
@@ -23,7 +24,12 @@ function hexToRgb(hex: string): string {
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [RouterOutlet, ToastContainerComponent, ModalHostComponent],
|
imports: [
|
||||||
|
RouterOutlet,
|
||||||
|
ToastContainerComponent,
|
||||||
|
ModalHostComponent,
|
||||||
|
GlobalLoadingComponent
|
||||||
|
],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss',
|
styleUrl: './app.scss',
|
||||||
host: {
|
host: {
|
||||||
@@ -88,4 +94,4 @@ export class App {
|
|||||||
this.tenantService.tenant()?.footer_bg_color ??
|
this.tenantService.tenant()?.footer_bg_color ??
|
||||||
DEFAULT_TENANT_BRANDING.footerBgColor
|
DEFAULT_TENANT_BRANDING.footerBgColor
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<boolean>(() => false);
|
||||||
|
|
||||||
|
export const globalLoadingInterceptor: HttpInterceptorFn = (
|
||||||
|
request: HttpRequest<unknown>,
|
||||||
|
next: HttpHandlerFn
|
||||||
|
): Observable<HttpEvent<unknown>> => {
|
||||||
|
if (request.context.get(SKIP_GLOBAL_LOADING)) {
|
||||||
|
return next(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingService = inject(GlobalLoadingService);
|
||||||
|
loadingService.start();
|
||||||
|
|
||||||
|
return next(request).pipe(finalize(() => loadingService.stop()));
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof setTimeout> | 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
@if (isLoading()) {
|
||||||
|
<div
|
||||||
|
class="global-loading"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label="Cargando"
|
||||||
|
>
|
||||||
|
<span class="global-loading__spinner" aria-hidden="true"></span>
|
||||||
|
<span class="visually-hidden">Cargando...</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user