diff --git a/docs/en/UI/Angular/Router-Events.md b/docs/en/UI/Angular/Router-Events.md new file mode 100644 index 0000000000..011d405b22 --- /dev/null +++ b/docs/en/UI/Angular/Router-Events.md @@ -0,0 +1,146 @@ +# Router Events Simplified + +`RouterEvents` is a utility service to provide an easy implementation for one of the most frequent needs in Angular templates: `TrackByFunction`. Please see [this page in Angular docs](https://angular.io/guide/template-syntax#ngfor-with-trackby) for its purpose. + + + + +## Benefit + +You can use router events directly and filter them as seen below: + +```js +import { + NavigationEnd, + NavigationError, + NavigationCancel, + Router, +} from '@angular/router'; +import { filter } from 'rxjs/operators'; + +@Injectable() +class SomeService { + navigationFinish$ = this.router.events.pipe( + filter( + event => + event instanceof NavigationEnd || + event instanceof NavigationError || + event instanceof NavigationCancel, + ), + ); + /* Observable */ + + constructor(private router: Router) {} +} +``` + +However, `RouterEvents` makes filtering router events easier. + +```js +import { RouterEvents } from '@abp/ng.core'; + +@Injectable() +class SomeService { + navigationFinish$ = this.routerEvents.getNavigationEvents('End', 'Error', 'Cancel'); + /* Observable */ + + constructor(private routerEvents: RouterEvents) {} +} +``` + +`RouterEvents` also delivers improved type-safety. In the example above, `navigationFinish$` has inferred type of `Observable` whereas it would have `Observable` when router events are filtered directly. + + + + +## Usage + +You do not have to provide `RouterEvents` at the module or component level, because it is already **provided in root**. You can inject and start using it immediately in your components. + + +### How to Get Specific Navigation Events + +You can use `getNavigationEvents` to get a stream of navigation events matching given event keys. + +```js +import { RouterEvents } from '@abp/ng.core'; +import { merge } from 'rxjs'; +import { mapTo } from 'rxjs/operators'; + +@Injectable() +class SomeService { + navigationStart$ = this.routerEvents.getNavigationEvents('Start'); + /* Observable */ + + navigationFinish$ = this.routerEvents.getNavigationEvents('End', 'Error', 'Cancel'); + /* Observable */ + + loading$ = merge( + this.navigationStart$.pipe(mapTo(true)), + this.navigationFinish$.pipe(mapTo(false)), + ); + /* Observable */ + + constructor(private routerEvents: RouterEvents) {} +} +``` + + +### How to Get All Navigation Events + +You can use `getAllNavigationEvents` to get a stream of all navigation events without passing any keys. + +```js +import { RouterEvents, NavigationStart } from '@abp/ng.core'; +import { map } from 'rxjs/operators'; + +@Injectable() +class SomeService { + navigationEvent$ = this.routerEvents.getAllNavigationEvents(); + /* Observable */ + + loading$ = this.navigationEvent$.pipe( + map(event => event instanceof NavigationStart), + ); + /* Observable */ + + constructor(private routerEvents: RouterEvents) {} +} +``` + + +### How to Get Specific Router Events + +You can use `getEvents` to get a stream of router events matching given event constructors. + +```js +import { RouterEvents } from '@abp/ng.core'; +import { ActivationEnd, ChildActivationEnd } from '@angular/router'; + +@Injectable() +class SomeService { + moduleActivation$ = this.routerEvents.getEvents(ActivationEnd, ChildActivationEnd); + /* Observable */ + + constructor(private routerEvents: RouterEvents) {} +} +``` + + +### How to Get All Router Events + +You can use `getEvents` to get a stream of all router events without passing any event constructors. This is nothing different from accessing `events` property of `Router` and is added to the service just for convenience. + +```js +import { RouterEvents } from '@abp/ng.core'; +import { ActivationEnd, ChildActivationEnd } from '@angular/router'; + +@Injectable() +class SomeService { + routerEvent$ = this.routerEvents.getAllEvents(); + /* Observable */ + + constructor(private routerEvents: RouterEvents) {} +} +``` + diff --git a/docs/en/docs-nav.json b/docs/en/docs-nav.json index 06594f8e7e..7fd7762ad4 100644 --- a/docs/en/docs-nav.json +++ b/docs/en/docs-nav.json @@ -807,6 +807,10 @@ "text": "Easy *ngFor trackBy", "path": "UI/Angular/Track-By-Service.md" }, + { + "text": "Router Events", + "path": "UI/Angular/Router-Events.md" + }, { "text": "Inserting Scripts & Styles to DOM", "path": "UI/Angular/Dom-Insertion-Service.md" diff --git a/npm/ng-packs/packages/core/src/lib/components/dynamic-layout.component.ts b/npm/ng-packs/packages/core/src/lib/components/dynamic-layout.component.ts index 87ad976491..c9a852ae08 100644 --- a/npm/ng-packs/packages/core/src/lib/components/dynamic-layout.component.ts +++ b/npm/ng-packs/packages/core/src/lib/components/dynamic-layout.component.ts @@ -1,11 +1,11 @@ import { Component, Injector, Optional, SkipSelf, Type } from '@angular/core'; -import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; -import { filter } from 'rxjs/operators'; +import { ActivatedRoute, Router } from '@angular/router'; import { eLayoutType } from '../enums/common'; import { ABP } from '../models'; import { ReplaceableComponents } from '../models/replaceable-components'; import { LocalizationService } from '../services/localization.service'; import { ReplaceableComponentsService } from '../services/replaceable-components.service'; +import { RouterEvents } from '../services/router-events.service'; import { RoutesService } from '../services/routes.service'; import { SubscriptionService } from '../services/subscription.service'; import { findRoute, getRoutePath } from '../utils/route-utils'; @@ -44,6 +44,7 @@ export class DynamicLayoutComponent { private localizationService: LocalizationService, private replaceableComponents: ReplaceableComponentsService, private subscription: SubscriptionService, + private routerEvents: RouterEvents, @Optional() @SkipSelf() dynamicLayoutComponent: DynamicLayoutComponent, ) { if (dynamicLayoutComponent) return; @@ -52,16 +53,16 @@ export class DynamicLayoutComponent { this.routes = injector.get(RoutesService); this.getLayout(); - this.subscription.addOne( - this.router.events.pipe(filter(event => event instanceof NavigationEnd)), - () => { - this.getLayout(); - }, - ); + this.checkLayoutOnNavigationEnd(); this.listenToLanguageChange(); } + private checkLayoutOnNavigationEnd() { + const navigationEnd$ = this.routerEvents.getNavigationEvents('End'); + this.subscription.addOne(navigationEnd$, () => this.getLayout()); + } + private getLayout() { let expectedLayout = (this.route.snapshot.data || {}).layout; diff --git a/npm/ng-packs/packages/core/src/lib/services/index.ts b/npm/ng-packs/packages/core/src/lib/services/index.ts index 37bc78d606..55000edde1 100644 --- a/npm/ng-packs/packages/core/src/lib/services/index.ts +++ b/npm/ng-packs/packages/core/src/lib/services/index.ts @@ -15,6 +15,7 @@ export * from './profile.service'; export * from './replaceable-components.service'; export * from './resource-wait.service'; export * from './rest.service'; +export * from './router-events.service'; export * from './router-wait.service'; export * from './routes.service'; export * from './session-state.service'; diff --git a/npm/ng-packs/packages/core/src/lib/services/router-events.service.ts b/npm/ng-packs/packages/core/src/lib/services/router-events.service.ts new file mode 100644 index 0000000000..9acee09aa7 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/services/router-events.service.ts @@ -0,0 +1,61 @@ +import { Injectable, Type } from '@angular/core'; +import { + NavigationCancel, + NavigationEnd, + NavigationError, + NavigationStart, + Router, + RouterEvent, +} from '@angular/router'; +import { filter } from 'rxjs/operators'; + +export const NavigationEvent = { + Cancel: NavigationCancel, + End: NavigationEnd, + Error: NavigationError, + Start: NavigationStart, +}; + +@Injectable({ providedIn: 'root' }) +export class RouterEvents { + constructor(private router: Router) {} + + getEvents(...eventTypes: T) { + type FilteredRouterEvent = T extends Type[] ? Ctor : never; + + const filterRouterEvents = (event: RouterEvent): event is FilteredRouterEvent => + eventTypes.some(type => event instanceof type); + + return this.router.events.pipe(filter(filterRouterEvents)); + } + + getNavigationEvents(...navigationEventKeys: T) { + type FilteredNavigationEvent = T extends (infer Key)[] + ? Key extends NavigationEventKey + ? InstanceType + : never + : never; + + const filterNavigationEvents = (event: RouterEvent): event is FilteredNavigationEvent => + navigationEventKeys.some(key => event instanceof NavigationEvent[key]); + + return this.router.events.pipe(filter(filterNavigationEvents)); + } + + getAllEvents() { + return this.router.events; + } + + getAllNavigationEvents() { + const keys = Object.keys(NavigationEvent) as NavigationEventKeys; + return this.getNavigationEvents(...keys); + } +} + +type RouterEventConstructors = [Type, ...Type[]]; + +type NavigationEventKeys = [NavigationEventKey, ...NavigationEventKey[]]; + +type NavigationEventType = typeof NavigationEvent; + +export type NavigationEventKey = keyof NavigationEventType; diff --git a/npm/ng-packs/packages/core/src/lib/services/router-wait.service.ts b/npm/ng-packs/packages/core/src/lib/services/router-wait.service.ts index 56240d8ca2..e53ca3a1b1 100644 --- a/npm/ng-packs/packages/core/src/lib/services/router-wait.service.ts +++ b/npm/ng-packs/packages/core/src/lib/services/router-wait.service.ts @@ -1,9 +1,10 @@ import { Injectable, Injector } from '@angular/core'; -import { NavigationCancel, NavigationEnd, NavigationError, NavigationStart, Router } from '@angular/router'; -import { filter, map, mapTo, switchMap, takeUntil, tap } from 'rxjs/operators'; -import { InternalStore } from '../utils/internal-store-utils'; +import { NavigationStart } from '@angular/router'; import { of, Subject, timer } from 'rxjs'; +import { map, mapTo, switchMap, takeUntil, tap } from 'rxjs/operators'; import { LOADER_DELAY } from '../tokens/lodaer-delay.token'; +import { InternalStore } from '../utils/internal-store-utils'; +import { RouterEvents } from './router-events.service'; export interface RouterWaitState { loading: boolean; @@ -16,17 +17,15 @@ export class RouterWaitService { private store = new InternalStore({ loading: false }); private destroy$ = new Subject(); private delay: number; - constructor(private router: Router, injector: Injector) { + constructor(private routerEvents: RouterEvents, injector: Injector) { this.delay = injector.get(LOADER_DELAY, 500); - this.router.events + this.updateLoadingStatusOnNavigationEvents(); + } + + private updateLoadingStatusOnNavigationEvents() { + this.routerEvents + .getAllNavigationEvents() .pipe( - filter( - event => - event instanceof NavigationStart || - event instanceof NavigationEnd || - event instanceof NavigationError || - event instanceof NavigationCancel, - ), map(event => event instanceof NavigationStart), switchMap(condition => condition diff --git a/npm/ng-packs/packages/core/src/lib/tests/router-events.service.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/router-events.service.spec.ts new file mode 100644 index 0000000000..0226c09d1e --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/tests/router-events.service.spec.ts @@ -0,0 +1,111 @@ +import { + NavigationCancel, + NavigationEnd, + NavigationError, + NavigationStart, + ResolveEnd, + ResolveStart, + Router, + RouterEvent, +} from '@angular/router'; +import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest'; +import { Subject } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { NavigationEventKey, RouterEvents } from '../services/router-events.service'; + +describe('RouterEvents', () => { + let spectator: SpectatorService; + let service: RouterEvents; + const events = new Subject(); + const emitRouterEvents = () => { + events.next(new RouterEvent(0, null)); + events.next(new NavigationStart(1, null, null)); + events.next(new ResolveStart(2, null, null, null)); + events.next(new RouterEvent(3, null)); + events.next(new NavigationError(4, null, null)); + events.next(new NavigationEnd(5, null, null)); + events.next(new ResolveEnd(6, null, null, null)); + events.next(new NavigationCancel(7, null, null)); + }; + + const createService = createServiceFactory({ + service: RouterEvents, + providers: [ + { + provide: Router, + useValue: { events }, + }, + ], + }); + + beforeEach(() => { + spectator = createService(); + service = spectator.service; + }); + + describe('getNavigationEvents', () => { + test.each` + filtered | expected + ${['Start', 'Cancel']} | ${[1, 7]} + ${['Error', 'Cancel']} | ${[4, 7]} + ${['Start', 'End']} | ${[1, 5]} + ${['Error', 'End']} | ${[4, 5]} + `( + 'should return a stream of given navigation events', + ({ filtered, expected }: NavigationEventTest) => { + const stream = service.getNavigationEvents(...filtered); + const collected: number[] = []; + + stream.pipe(take(2)).subscribe(event => collected.push(event.id)); + + emitRouterEvents(); + + expect(collected).toEqual(expected); + }, + ); + }); + + describe('getAnyNavigationEvent', () => { + it('should return a stream of any navigation event', () => { + const stream = service.getAllNavigationEvents(); + const collected: number[] = []; + + stream.pipe(take(4)).subscribe(event => collected.push(event.id)); + + emitRouterEvents(); + + expect(collected).toEqual([1, 4, 5, 7]); + }); + }); + + describe('getEvents', () => { + it('should return a stream of given router events', () => { + const stream = service.getEvents(ResolveEnd, ResolveStart); + const collected: number[] = []; + + stream.pipe(take(2)).subscribe(event => collected.push(event.id)); + + emitRouterEvents(); + + expect(collected).toEqual([2, 6]); + }); + }); + + describe('getAnyEvent', () => { + it('should return a stream of any router event', () => { + const stream = service.getAllEvents(); + const collected: number[] = []; + + stream.pipe(take(8)).subscribe((event: RouterEvent) => collected.push(event.id)); + + emitRouterEvents(); + + expect(collected).toEqual([0, 1, 2, 3, 4, 5, 6, 7]); + }); + }); +}); + +interface NavigationEventTest { + filtered: [NavigationEventKey, ...NavigationEventKey[]]; + expected: number[]; +} diff --git a/npm/ng-packs/packages/theme-shared/src/lib/components/breadcrumb/breadcrumb.component.ts b/npm/ng-packs/packages/theme-shared/src/lib/components/breadcrumb/breadcrumb.component.ts index 11270c6dc1..65c3c0a0af 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/components/breadcrumb/breadcrumb.component.ts +++ b/npm/ng-packs/packages/theme-shared/src/lib/components/breadcrumb/breadcrumb.component.ts @@ -1,7 +1,14 @@ -import { ABP, getRoutePath, RoutesService, TreeNode, SubscriptionService } from '@abp/ng.core'; +import { + ABP, + getRoutePath, + RouterEvents, + RoutesService, + SubscriptionService, + TreeNode, +} from '@abp/ng.core'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core'; -import { NavigationEnd, Router } from '@angular/router'; -import { filter, map, startWith } from 'rxjs/operators'; +import { Router } from '@angular/router'; +import { map, startWith } from 'rxjs/operators'; import { eThemeSharedRouteNames } from '../../enums'; @Component({ @@ -18,12 +25,12 @@ export class BreadcrumbComponent implements OnInit { private router: Router, private routes: RoutesService, private subscription: SubscriptionService, + private routerEvents: RouterEvents, ) {} ngOnInit(): void { this.subscription.addOne( - this.router.events.pipe( - filter(event => event instanceof NavigationEnd), + this.routerEvents.getNavigationEvents('End').pipe( // tslint:disable-next-line:deprecation startWith(null), map(() => this.routes.search({ path: getRoutePath(this.router) })), diff --git a/npm/ng-packs/packages/theme-shared/src/lib/handlers/error.handler.ts b/npm/ng-packs/packages/theme-shared/src/lib/handlers/error.handler.ts index 97f9533cf7..1835b80eef 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/handlers/error.handler.ts +++ b/npm/ng-packs/packages/theme-shared/src/lib/handlers/error.handler.ts @@ -1,4 +1,4 @@ -import { AuthService, LocalizationParam, RestOccurError } from '@abp/ng.core'; +import { AuthService, LocalizationParam, RestOccurError, RouterEvents } from '@abp/ng.core'; import { HttpErrorResponse } from '@angular/common/http'; import { ApplicationRef, @@ -10,7 +10,7 @@ import { Injector, RendererFactory2, } from '@angular/core'; -import { NavigationError, ResolveEnd, Router } from '@angular/router'; +import { NavigationError, ResolveEnd } from '@angular/router'; import { Actions, ofActionSuccessful } from '@ngxs/store'; import { Observable, Subject } from 'rxjs'; import { filter, map } from 'rxjs/operators'; @@ -72,7 +72,7 @@ export class ErrorHandler { constructor( private actions: Actions, - private router: Router, + private routerEvents: RouterEvents, private confirmationService: ConfirmationService, private cfRes: ComponentFactoryResolver, private rendererFactory: RendererFactory2, @@ -85,20 +85,16 @@ export class ErrorHandler { } private listenToRouterError() { - this.router.events - .pipe( - filter(event => event instanceof NavigationError), - filter(this.filterRouteErrors), - ) + this.routerEvents + .getNavigationEvents('Error') + .pipe(filter(this.filterRouteErrors)) .subscribe(() => this.show404Page()); } private listenToRouterDataResolved() { - this.router.events - .pipe( - filter(event => event instanceof ResolveEnd), - filter(() => !!this.componentRef), - ) + this.routerEvents + .getEvents(ResolveEnd) + .pipe(filter(() => !!this.componentRef)) .subscribe(() => { this.componentRef.destroy(); this.componentRef = null;