Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions src/app/app-routing.module.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { LoaderTestComponent } from './loader-test/loader-test.component';

const routes: Routes = [];
const routes: Routes = [
{
path: 'feat/loader-interceptor-service',
component: LoaderTestComponent,
},
{
path: '**',
redirectTo: 'feat/loader-interceptor-service',
},
];

@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
exports: [RouterModule],
})
export class AppRoutingModule { }
export class AppRoutingModule {}
3 changes: 2 additions & 1 deletion src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { Component } from '@angular/core';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
// templateUrl: './app.component.html',
template: `<router-outlet></router-outlet>`,
styleUrls: ['./app.component.scss']
})
export class AppComponent {
Expand Down
23 changes: 14 additions & 9 deletions src/app/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { DedicatedLoaderDirective } from './core/directives';
import { HttpLoaderInterceptor } from './core/interceptors';
import { LoaderTestComponent } from './loader-test/loader-test.component';

@NgModule({
declarations: [
AppComponent
declarations: [AppComponent, LoaderTestComponent, DedicatedLoaderDirective],
imports: [BrowserModule, AppRoutingModule, HttpClientModule],
providers: [
{
provide: HTTP_INTERCEPTORS,
useClass: HttpLoaderInterceptor,
multi: true,
},
],
imports: [
BrowserModule,
AppRoutingModule
],
providers: [],
bootstrap: [AppComponent]
bootstrap: [AppComponent],
})
export class AppModule { }
export class AppModule {}
8 changes: 8 additions & 0 deletions src/app/core/directives/dedicated-loader.directive.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { DedicatedLoaderDirective } from './dedicated-loader.directive';

describe('DedicatedLoaderDirective', () => {
it('should create an instance', () => {
const directive = new DedicatedLoaderDirective();
expect(directive).toBeTruthy();
});
});
20 changes: 20 additions & 0 deletions src/app/core/directives/dedicated-loader.directive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Directive, ElementRef, Input, Renderer2 } from '@angular/core';

@Directive({
selector: '[appDedicatedLoader]',
})
export class DedicatedLoaderDirective {
private readonly loaderClass = 'directive-loader'; // defined in styles.scss

@Input() set loaderStatus(value: boolean) {
if (this.el.nativeElement) {
if (value) {
this.el.nativeElement.classList.add(this.loaderClass);
} else {
this.el.nativeElement.classList.remove(this.loaderClass);
}
}
}

constructor(private el: ElementRef<HTMLElement>) {}
}
1 change: 1 addition & 0 deletions src/app/core/directives/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './dedicated-loader.directive';
16 changes: 16 additions & 0 deletions src/app/core/interceptors/http-loader.interceptor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';

import { HttpLoaderInterceptor } from './http-loader.interceptor';

describe('HttLoaderInterceptor', () => {
beforeEach(() => TestBed.configureTestingModule({
providers: [
HttpLoaderInterceptor
]
}));

it('should be created', () => {
const interceptor: HttpLoaderInterceptor = TestBed.inject(HttpLoaderInterceptor);
expect(interceptor).toBeTruthy();
});
});
101 changes: 101 additions & 0 deletions src/app/core/interceptors/http-loader.interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { Injectable } from '@angular/core';
import {
HttpRequest,
HttpHandler,
HttpEvent,
HttpInterceptor,
} from '@angular/common/http';
import { Observable } from 'rxjs';
import { HeaderName } from 'src/app/shared/enums';
import { finalize } from 'rxjs/operators';
import { LoaderStateService } from '../services/loader-state.service';

@Injectable()
export class HttpLoaderInterceptor implements HttpInterceptor {
/**
* Array of requests that are ignored by default, e.g. login, logout, etc...
*/
private readonly requestForIgnore = ['v1/login', 'v2/logut'];

constructor(private loaderStateService: LoaderStateService) {}

intercept(
request: HttpRequest<unknown>,
next: HttpHandler
): Observable<HttpEvent<unknown>> {
const req =
this.handleIgnoredRequest(request, next) ||
this.handleUserIgnoredRequest(request, next) ||
this.handleDedicatedRequest(request, next) ||
this.handleRequestWithLoader(request, next);

return req;
}

private handleUserIgnoredRequest(
request: HttpRequest<unknown>,
next: HttpHandler
): Observable<HttpEvent<unknown>> | null {
let output = null;

if (request.headers.get(HeaderName.userIgnoredLoader)) {
const req = request.clone({
headers: request.headers.delete(HeaderName.userIgnoredLoader),
});

output = next.handle(req);
}

return output;
}

private handleDedicatedRequest(
request: HttpRequest<unknown>,
next: HttpHandler
): Observable<HttpEvent<unknown>> | null {
let output = null;
const key = request.headers.get(HeaderName.showDedicatedLoader);

if (key) {
const req = request.clone({
headers: request.headers.delete(HeaderName.showDedicatedLoader),
});

this.loaderStateService.showDedicatedFor(key);
output = next
.handle(req)
.pipe(finalize(() => this.loaderStateService.hideDedicatedFor(key)));
}

return output;
}

private handleIgnoredRequest(
request: HttpRequest<unknown>,
next: HttpHandler
): Observable<HttpEvent<unknown>> | null {
let output = next.handle(request);
const url = request.url.toLowerCase();

const requestIgnored = !this.requestForIgnore.filter((ignoreReq) =>
url.toLowerCase().endsWith(ignoreReq)
).length;

if (requestIgnored) {
output = null;
}

return output;
}

private handleRequestWithLoader(
request: HttpRequest<unknown>,
next: HttpHandler
): Observable<HttpEvent<unknown>> {
const uniqueKey = this.loaderStateService.showMain();

return next
.handle(request)
.pipe(finalize(() => this.loaderStateService.hideMain(uniqueKey)));
}
}
1 change: 1 addition & 0 deletions src/app/core/interceptors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './http-loader.interceptor';
Empty file added src/app/core/services/index.ts
Empty file.
16 changes: 16 additions & 0 deletions src/app/core/services/loader-state.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';

import { LoaderStateService } from './loader-state.service';

describe('LoaderStateServiceService', () => {
let service: LoaderStateService;

beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(LoaderStateService);
});

it('should be created', () => {
expect(service).toBeTruthy();
});
});
149 changes: 149 additions & 0 deletions src/app/core/services/loader-state.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { map, skip } from 'rxjs/operators';
import { HeaderName } from 'src/app/shared/enums';
import { LoaderHeader } from 'src/app/shared/models';

type LoaderKeyTimeoutState = { [uniqueKey: string]: number };

// todo:
// - navigation & tab change
// - ngb-nav

@Injectable({
providedIn: 'root',
})
export class LoaderStateService {
//#region Class properties

private readonly maxRequestLength = 61_000; // [ms];

private mainLoaderState$ = new BehaviorSubject<LoaderKeyTimeoutState>({});
private dedicatedLoaderState$ = new BehaviorSubject<LoaderKeyTimeoutState>(
{}
);

//#endregion

//#region Getters for main/dedicated loader

public get showMainLoader$(): Observable<boolean> {
return this.mainLoaderState$.asObservable().pipe(
skip(1), // we're not interested in the default state
map(
(currentState: LoaderKeyTimeoutState) =>
Object.keys(currentState).length !== 0
)
);
}

public get dedicatedLoaders$(): Observable<LoaderKeyTimeoutState> {
return this.dedicatedLoaderState$.asObservable();
}

public dedicatedLoaderFor(key: string): Observable<boolean> {
return this.dedicatedLoaders$.pipe(
map((state: LoaderKeyTimeoutState) => key in state)
);
}

//#endregion

//#region Toggling methods

public showMain(key?: string): string {
if (!key) {
key = this.generateUniqueKey();
}
this.modifyStateViaAdd(this.mainLoaderState$, key, 'main');

return key;
}

public hideMain(key: string): void {
this.modifyStateViaRmv(this.mainLoaderState$, key);
}

public showDedicatedFor(key: string): void {
this.modifyStateViaAdd(this.dedicatedLoaderState$, key, 'dedicated');
}

public hideDedicatedFor(key: string): void {
this.modifyStateViaRmv(this.dedicatedLoaderState$, key);
}

//#endregion

//#region Header utility methods

public generateHeaderFor(names: HeaderName[]): LoaderHeader {
const output: LoaderHeader = {};

const nonDefaultValues = new Map<HeaderName, string>([
[HeaderName.showDedicatedLoader, this.generateUniqueKey()],
]);

for (let headerName of names) {
output[headerName] = nonDefaultValues.get(headerName) ?? 'present';
}

return output;
}

public initLoaderForDedicatedHeader(): [LoaderHeader, Observable<boolean>] {
const header = this.generateHeaderFor([HeaderName.showDedicatedLoader]);

return [
header,
this.dedicatedLoaderFor(header[HeaderName.showDedicatedLoader]),
];
}

//#endregion

//#region State utility methods

private modifyStateViaAdd(
state$: BehaviorSubject<LoaderKeyTimeoutState>,
key: string,
state: 'main' | 'dedicated' = 'main'
): LoaderKeyTimeoutState {
const current = state$.getValue();

current[key] = window.setTimeout(
() =>
state === 'main' ? this.hideMain(key) : this.hideDedicatedFor(key),
this.maxRequestLength
);

state$.next(current);

return current;
}

private modifyStateViaRmv(
state$: BehaviorSubject<LoaderKeyTimeoutState>,
key: string
): LoaderKeyTimeoutState {
const current = state$.getValue();

if (current[key]) {
window.clearTimeout(current[key]);
delete current[key];

state$.next(current);
}

return current;
}

//#endregion

//#region Utility methods

public generateUniqueKey(): string {
return `present-and-unique-key-${Date.now() + Math.random()}`;
}

//#endregion
}
Loading