diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 0297262..3548250 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -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 {} diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 57f36a6..8a0861a 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -2,7 +2,8 @@ import { Component } from '@angular/core'; @Component({ selector: 'app-root', - templateUrl: './app.component.html', + // templateUrl: './app.component.html', + template: ``, styleUrls: ['./app.component.scss'] }) export class AppComponent { diff --git a/src/app/app.module.ts b/src/app/app.module.ts index b1c6c96..ee1e003 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -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 {} diff --git a/src/app/core/directives/dedicated-loader.directive.spec.ts b/src/app/core/directives/dedicated-loader.directive.spec.ts new file mode 100644 index 0000000..d7203b8 --- /dev/null +++ b/src/app/core/directives/dedicated-loader.directive.spec.ts @@ -0,0 +1,8 @@ +import { DedicatedLoaderDirective } from './dedicated-loader.directive'; + +describe('DedicatedLoaderDirective', () => { + it('should create an instance', () => { + const directive = new DedicatedLoaderDirective(); + expect(directive).toBeTruthy(); + }); +}); diff --git a/src/app/core/directives/dedicated-loader.directive.ts b/src/app/core/directives/dedicated-loader.directive.ts new file mode 100644 index 0000000..627fd89 --- /dev/null +++ b/src/app/core/directives/dedicated-loader.directive.ts @@ -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) {} +} diff --git a/src/app/core/directives/index.ts b/src/app/core/directives/index.ts new file mode 100644 index 0000000..fdeba89 --- /dev/null +++ b/src/app/core/directives/index.ts @@ -0,0 +1 @@ +export * from './dedicated-loader.directive'; diff --git a/src/app/core/interceptors/http-loader.interceptor.spec.ts b/src/app/core/interceptors/http-loader.interceptor.spec.ts new file mode 100644 index 0000000..34616c7 --- /dev/null +++ b/src/app/core/interceptors/http-loader.interceptor.spec.ts @@ -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(); + }); +}); diff --git a/src/app/core/interceptors/http-loader.interceptor.ts b/src/app/core/interceptors/http-loader.interceptor.ts new file mode 100644 index 0000000..4baf0a5 --- /dev/null +++ b/src/app/core/interceptors/http-loader.interceptor.ts @@ -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, + next: HttpHandler + ): Observable> { + const req = + this.handleIgnoredRequest(request, next) || + this.handleUserIgnoredRequest(request, next) || + this.handleDedicatedRequest(request, next) || + this.handleRequestWithLoader(request, next); + + return req; + } + + private handleUserIgnoredRequest( + request: HttpRequest, + next: HttpHandler + ): Observable> | 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, + next: HttpHandler + ): Observable> | 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, + next: HttpHandler + ): Observable> | 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, + next: HttpHandler + ): Observable> { + const uniqueKey = this.loaderStateService.showMain(); + + return next + .handle(request) + .pipe(finalize(() => this.loaderStateService.hideMain(uniqueKey))); + } +} diff --git a/src/app/core/interceptors/index.ts b/src/app/core/interceptors/index.ts new file mode 100644 index 0000000..86981ce --- /dev/null +++ b/src/app/core/interceptors/index.ts @@ -0,0 +1 @@ +export * from './http-loader.interceptor'; diff --git a/src/app/core/services/index.ts b/src/app/core/services/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/app/core/services/loader-state.service.spec.ts b/src/app/core/services/loader-state.service.spec.ts new file mode 100644 index 0000000..7245ea1 --- /dev/null +++ b/src/app/core/services/loader-state.service.spec.ts @@ -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(); + }); +}); diff --git a/src/app/core/services/loader-state.service.ts b/src/app/core/services/loader-state.service.ts new file mode 100644 index 0000000..349b13c --- /dev/null +++ b/src/app/core/services/loader-state.service.ts @@ -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({}); + private dedicatedLoaderState$ = new BehaviorSubject( + {} + ); + + //#endregion + + //#region Getters for main/dedicated loader + + public get showMainLoader$(): Observable { + 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 { + return this.dedicatedLoaderState$.asObservable(); + } + + public dedicatedLoaderFor(key: string): Observable { + 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.showDedicatedLoader, this.generateUniqueKey()], + ]); + + for (let headerName of names) { + output[headerName] = nonDefaultValues.get(headerName) ?? 'present'; + } + + return output; + } + + public initLoaderForDedicatedHeader(): [LoaderHeader, Observable] { + const header = this.generateHeaderFor([HeaderName.showDedicatedLoader]); + + return [ + header, + this.dedicatedLoaderFor(header[HeaderName.showDedicatedLoader]), + ]; + } + + //#endregion + + //#region State utility methods + + private modifyStateViaAdd( + state$: BehaviorSubject, + 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, + 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 +} diff --git a/src/app/loader-test/loader-test.component.html b/src/app/loader-test/loader-test.component.html new file mode 100644 index 0000000..2231573 --- /dev/null +++ b/src/app/loader-test/loader-test.component.html @@ -0,0 +1,53 @@ +

loader-test works!

+ +
+
+
main state
+ {{ mainState$ | async | json }} +
+
+
dedicated state:
+ {{ dedicatedState$ | async | json }} +
+
+ +
+
+ +
+ + + +
+ + + + +
+ + diff --git a/src/app/loader-test/loader-test.component.scss b/src/app/loader-test/loader-test.component.scss new file mode 100644 index 0000000..d89087f --- /dev/null +++ b/src/app/loader-test/loader-test.component.scss @@ -0,0 +1,19 @@ +#main-loader { + display: block; + width: 100vw; + height: 50px; + background-color: darkcyan; + position: absolute; + bottom: 0px; + left: 0px; +} + +#dedicated-loader { + display: block; + width: 100vw; + height: 25px; + background-color: chocolate; + position: absolute; + bottom: 50px; + left: 0px; +} diff --git a/src/app/loader-test/loader-test.component.spec.ts b/src/app/loader-test/loader-test.component.spec.ts new file mode 100644 index 0000000..572026e --- /dev/null +++ b/src/app/loader-test/loader-test.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LoaderTestComponent } from './loader-test.component'; + +describe('LoaderTestComponent', () => { + let component: LoaderTestComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ LoaderTestComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(LoaderTestComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/loader-test/loader-test.component.ts b/src/app/loader-test/loader-test.component.ts new file mode 100644 index 0000000..5e47981 --- /dev/null +++ b/src/app/loader-test/loader-test.component.ts @@ -0,0 +1,238 @@ +import { HttpClient } from '@angular/common/http'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + OnInit, +} from '@angular/core'; +import { Observable } from 'rxjs'; +import { LoaderStateService } from '../core/services/loader-state.service'; +import { HeaderName } from '../shared/enums'; +import { LoaderHeader } from '../shared/models'; + +@Component({ + selector: 'app-loader-test', + templateUrl: './loader-test.component.html', + styleUrls: ['./loader-test.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LoaderTestComponent implements OnInit { + //#region Don't do this at home + public mainState$ = this.loaderStateService['mainLoaderState$']; + public dedicatedState$ = this.loaderStateService['dedicatedLoaderState$']; + //#endregion + + public loader$ = this.loaderStateService.showMainLoader$; + public dedicatedLoader$: Observable; + + private dedicatedHeader: LoaderHeader; + + constructor( + private httpClient: HttpClient, + private loaderStateService: LoaderStateService, + private cdr: ChangeDetectorRef + ) {} + + ngOnInit(): void { + // just test loaders + // setTimeout(() => this.initFakeLoaders(), 1_500); + + // test main loader + // setTimeout(() => this.initMainLoader(), 3_000); + + // test dedicated loader + // setTimeout( + // () => this.initDedicatedHeaderLoader().initDedicatedLoader(), + // 3_500 + // ); + + // test w/o loader + // setTimeout(() => this.initRequestWithoutLoader(), 4_500); + } + + //#region Init methods + + private initFakeLoaders(): this { + const dedicatedLoaderKey = 'lorem-ipsum'; + const mainLoaderKey = 'aaaaaa'; + + this.loaderStateService.showMain(mainLoaderKey); + this.loaderStateService.showDedicatedFor(dedicatedLoaderKey); + this.dedicatedLoader$ = + this.loaderStateService.dedicatedLoaderFor(dedicatedLoaderKey); + + setTimeout(() => { + this.loaderStateService.hideMain(mainLoaderKey); + }, 500); + setTimeout( + () => this.loaderStateService.hideDedicatedFor(dedicatedLoaderKey), + 700 + ); + + return this; + } + + private initRequestWithoutLoader(): this { + this.dedicatedHeader = this.loaderStateService.generateHeaderFor([ + HeaderName.userIgnoredLoader, + ]); + + this.httpClient + .get('http://dev.qposoft.com:4082/api/users', { + headers: this.dedicatedHeader, + }) + .subscribe(console.log); + + return this; + } + + private initDedicatedLoader(): this { + const uniqueId = this.loaderStateService.generateUniqueKey(); + this.dedicatedLoader$ = + this.loaderStateService.dedicatedLoaderFor(uniqueId); + + this.httpClient + // .get('http://dev.qposoft.com:4082/api/users', { + .get('http://dev.qposoft.com:4082/api/sleep/3', { + headers: { + [HeaderName.showDedicatedLoader]: uniqueId, + }, + }) + .subscribe(console.log); + + return this; + } + + private initDedicatedHeaderLoader(): this { + return this; + [this.dedicatedHeader, this.dedicatedLoader$] = + this.loaderStateService.initLoaderForDedicatedHeader(); + + /** + * HEADS UP: + * Because of the "changeDetection: ChangeDetectionStrategy.OnPush" + * inside component decorator, above line is updated property that + * is used inside tpl, and we need to manually trigger update. + * When we remove OnPush this isn't the case. + * But, IMHO there is no need to worry about this code, because, + * as Nebojsa suggested, we should create directive for dedicated loader, + * so this piece of code will be part only of that directive, and it should + * receive as @input value for showing/hiding dedicated loader. + */ + this.cdr.detectChanges(); + + return this; + } + + private initMainLoader(): this { + this.httpClient + .get('http://dev.qposoft.com:4082/api/sleep/5') + .subscribe(console.log); + + return this; + } + + //#endregion + + //#region UI events + + public async onTestMainLoader(): Promise { + this.initMainLoader(); + } + + //#endregion + + //#region Ad-hock dedicated loaders testing + + public dedicatedLoader1$: Observable; + public dedicatedLoader2$: Observable; + public async onTestDedicatedLoader(id: string): Promise { + const uniqueId = this.loaderStateService.generateUniqueKey() + id; + if (id === '1') { + this.dedicatedLoader1$ = + this.loaderStateService.dedicatedLoaderFor(uniqueId); + } else { + this.dedicatedLoader2$ = + this.loaderStateService.dedicatedLoaderFor(uniqueId); + } + + this.httpClient + // .get('http://dev.qposoft.com:4082/api/users', { + .get('http://dev.qposoft.com:4082/api/sleep/3', { + headers: { + [HeaderName.showDedicatedLoader]: uniqueId, + }, + }) + .subscribe(console.log); + } + + //#endregion + + //#region Proposals on how to use dedicated loader + + public dedicatedLoaderVisible$: Observable; + + public async onDedicatedLoaderEventProposal1(): Promise { + // prepare unique id + const uniqueId = this.loaderStateService.generateUniqueKey(); + // prepare loader visibility Observable + this.dedicatedLoaderVisible$ = + this.loaderStateService.dedicatedLoaderFor(uniqueId); + // make call + this.httpClient + .get('http://dev.qposoft.com:4082/api/sleep/3', { + headers: { + [HeaderName.showDedicatedLoader]: uniqueId, + }, + }) + .subscribe(console.log); + } + + public async onDedicatedLoaderEventProposal2(): Promise { + // prepare unique id + const uniqueId = `some-random-and-unique-id-${Date.now()}`; + // prepare loader visibility Observable + this.dedicatedLoaderVisible$ = + this.loaderStateService.dedicatedLoaderFor(uniqueId); + // make call + this.httpClient + .get('http://dev.qposoft.com:4082/api/sleep/3', { + headers: { + [HeaderName.showDedicatedLoader]: uniqueId, + }, + }) + .subscribe(console.log); + } + + public async onDedicatedLoaderEventProposal3(): Promise { + // prepare header and unique id + const headers = this.loaderStateService.generateHeaderFor([ + HeaderName.showDedicatedLoader, + ]); + // prepare visibility Observable + this.dedicatedLoaderVisible$ = this.loaderStateService.dedicatedLoaderFor( + headers[HeaderName.showDedicatedLoader] + ); + // make call + this.httpClient + .get('http://dev.qposoft.com:4082/api/sleep/3', { + headers, + }) + .subscribe(console.log); + } + + public async onDedicatedLoaderEventProposal4(): Promise { + // prepare header, unique id and visibility Observable + let headers = {}; + [headers, this.dedicatedLoaderVisible$] = + this.loaderStateService.initLoaderForDedicatedHeader(); + // make call + this.httpClient + .get('http://dev.qposoft.com:4082/api/sleep/3', { + headers, + }) + .subscribe(console.log); + } + + //#endregion +} diff --git a/src/app/shared/enums/header-name.enum.ts b/src/app/shared/enums/header-name.enum.ts new file mode 100644 index 0000000..a52de38 --- /dev/null +++ b/src/app/shared/enums/header-name.enum.ts @@ -0,0 +1,4 @@ +export enum HeaderName { + userIgnoredLoader = 'PROJECT-User-Ignored-Loader', + showDedicatedLoader = 'PROJECT-Show-Dedicated-Loader', +} diff --git a/src/app/shared/enums/index.ts b/src/app/shared/enums/index.ts new file mode 100644 index 0000000..2586c51 --- /dev/null +++ b/src/app/shared/enums/index.ts @@ -0,0 +1 @@ +export * from './header-name.enum'; diff --git a/src/app/shared/models/index.ts b/src/app/shared/models/index.ts new file mode 100644 index 0000000..9fba242 --- /dev/null +++ b/src/app/shared/models/index.ts @@ -0,0 +1 @@ +export * from './loader-header.model'; diff --git a/src/app/shared/models/loader-header.model.ts b/src/app/shared/models/loader-header.model.ts new file mode 100644 index 0000000..905e9f2 --- /dev/null +++ b/src/app/shared/models/loader-header.model.ts @@ -0,0 +1,3 @@ +import { HeaderName } from '../enums/header-name.enum'; + +export type LoaderHeader = { [key in HeaderName]?: string }; diff --git a/src/styles.scss b/src/styles.scss index 90d4ee0..76e3a2c 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -1 +1,5 @@ /* You can add global styles to this file, and also import other style files */ +.directive-loader { + background-color: chocolate; + color: white; +}