From 38784a9d348f6550451fd82d3ecd96b3eed36ea8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20F=C3=A2ciu?= Date: Wed, 8 May 2019 21:57:55 +0300 Subject: [PATCH] feat(example): add examples of effects not based on the Actions stream As suggested, added two examples of effects not based on Actions stream: - listen for router navigation events and update page title - log out the user after a specified period of inactivity Closes #1830 --- .../example-app/src/app/app-routing.module.ts | 6 +- projects/example-app/src/app/app.module.ts | 3 +- .../src/app/auth/auth-routing.module.ts | 4 +- .../src/app/auth/effects/auth.effects.ts | 15 ++++- .../src/app/books/books-routing.module.ts | 13 +++- .../example-app/src/app/core/actions/index.ts | 3 +- .../src/app/core/actions/user.actions.ts | 3 + .../example-app/src/app/core/effects/index.ts | 2 + .../app/core/effects/router.effects.spec.ts | 41 ++++++++++++ .../src/app/core/effects/router.effects.ts | 34 ++++++++++ .../src/app/core/effects/user.effects.spec.ts | 65 +++++++++++++++++++ .../src/app/core/effects/user.effects.ts | 21 ++++++ 12 files changed, 202 insertions(+), 8 deletions(-) create mode 100644 projects/example-app/src/app/core/actions/user.actions.ts create mode 100644 projects/example-app/src/app/core/effects/index.ts create mode 100644 projects/example-app/src/app/core/effects/router.effects.spec.ts create mode 100644 projects/example-app/src/app/core/effects/router.effects.ts create mode 100644 projects/example-app/src/app/core/effects/user.effects.spec.ts create mode 100644 projects/example-app/src/app/core/effects/user.effects.ts diff --git a/projects/example-app/src/app/app-routing.module.ts b/projects/example-app/src/app/app-routing.module.ts index 502db16a02..229cbad863 100644 --- a/projects/example-app/src/app/app-routing.module.ts +++ b/projects/example-app/src/app/app-routing.module.ts @@ -10,7 +10,11 @@ export const routes: Routes = [ loadChildren: '@example-app/books/books.module#BooksModule', canActivate: [AuthGuard], }, - { path: '**', component: NotFoundPageComponent }, + { + path: '**', + component: NotFoundPageComponent, + data: { title: 'Not found' }, + }, ]; @NgModule({ diff --git a/projects/example-app/src/app/app.module.ts b/projects/example-app/src/app/app.module.ts index e38901ef4c..91c4af18fc 100644 --- a/projects/example-app/src/app/app.module.ts +++ b/projects/example-app/src/app/app.module.ts @@ -16,6 +16,7 @@ import { ROOT_REDUCERS, metaReducers } from '@example-app/reducers'; import { AppComponent } from '@example-app/core/containers/app.component'; import { AppRoutingModule } from '@example-app/app-routing.module'; +import { UserEffects, RouterEffects } from '@example-app/core/effects'; @NgModule({ imports: [ @@ -69,7 +70,7 @@ import { AppRoutingModule } from '@example-app/app-routing.module'; * * See: https://ngrx.io/guide/effects#registering-root-effects */ - EffectsModule.forRoot([]), + EffectsModule.forRoot([UserEffects, RouterEffects]), CoreModule, ], diff --git a/projects/example-app/src/app/auth/auth-routing.module.ts b/projects/example-app/src/app/auth/auth-routing.module.ts index 1f5cf7a237..62ceda18b6 100644 --- a/projects/example-app/src/app/auth/auth-routing.module.ts +++ b/projects/example-app/src/app/auth/auth-routing.module.ts @@ -2,7 +2,9 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; import { LoginPageComponent } from '@example-app/auth/containers'; -const routes: Routes = [{ path: 'login', component: LoginPageComponent }]; +const routes: Routes = [ + { path: 'login', component: LoginPageComponent, data: { title: 'Login' } }, +]; @NgModule({ imports: [RouterModule.forChild(routes)], diff --git a/projects/example-app/src/app/auth/effects/auth.effects.ts b/projects/example-app/src/app/auth/effects/auth.effects.ts index 39cf0ac0a2..fd23bf3260 100644 --- a/projects/example-app/src/app/auth/effects/auth.effects.ts +++ b/projects/example-app/src/app/auth/effects/auth.effects.ts @@ -12,6 +12,7 @@ import { import { Credentials } from '@example-app/auth/models'; import { AuthService } from '@example-app/auth/services'; import { LogoutConfirmationDialogComponent } from '@example-app/auth/components'; +import { UserActions } from '@example-app/core/actions'; @Injectable() export class AuthEffects { @@ -60,12 +61,22 @@ export class AuthEffects { return dialogRef.afterClosed(); }), - map(result => - result ? AuthActions.logout() : AuthActions.logoutConfirmationDismiss() + map( + result => + result + ? AuthActions.logout() + : AuthActions.logoutConfirmationDismiss() ) ) ); + logoutIdleUser$ = createEffect(() => + this.actions$.pipe( + ofType(UserActions.idleTimeout), + map(() => AuthActions.logout()) + ) + ); + constructor( private actions$: Actions, private authService: AuthService, diff --git a/projects/example-app/src/app/books/books-routing.module.ts b/projects/example-app/src/app/books/books-routing.module.ts index a7ca393e48..ba1a73e8c7 100644 --- a/projects/example-app/src/app/books/books-routing.module.ts +++ b/projects/example-app/src/app/books/books-routing.module.ts @@ -6,13 +6,22 @@ import { CollectionPageComponent } from '@example-app/books/containers/collectio import { BookExistsGuard } from '@example-app/books/guards/book-exists.guard'; export const routes: Routes = [ - { path: 'find', component: FindBookPageComponent }, + { + path: 'find', + component: FindBookPageComponent, + data: { title: 'Find book' }, + }, { path: ':id', component: ViewBookPageComponent, canActivate: [BookExistsGuard], + data: { title: 'Book details' }, + }, + { + path: '', + component: CollectionPageComponent, + data: { title: 'Collection' }, }, - { path: '', component: CollectionPageComponent }, ]; @NgModule({ diff --git a/projects/example-app/src/app/core/actions/index.ts b/projects/example-app/src/app/core/actions/index.ts index 4790ae9420..e6323848b0 100644 --- a/projects/example-app/src/app/core/actions/index.ts +++ b/projects/example-app/src/app/core/actions/index.ts @@ -1,3 +1,4 @@ import * as LayoutActions from './layout.actions'; +import * as UserActions from './user.actions'; -export { LayoutActions }; +export { LayoutActions, UserActions }; diff --git a/projects/example-app/src/app/core/actions/user.actions.ts b/projects/example-app/src/app/core/actions/user.actions.ts new file mode 100644 index 0000000000..324a97e1d4 --- /dev/null +++ b/projects/example-app/src/app/core/actions/user.actions.ts @@ -0,0 +1,3 @@ +import { createAction } from '@ngrx/store'; + +export const idleTimeout = createAction('[User] Idle Timeout'); diff --git a/projects/example-app/src/app/core/effects/index.ts b/projects/example-app/src/app/core/effects/index.ts new file mode 100644 index 0000000000..2330b0c6cc --- /dev/null +++ b/projects/example-app/src/app/core/effects/index.ts @@ -0,0 +1,2 @@ +export * from './user.effects'; +export * from './router.effects'; diff --git a/projects/example-app/src/app/core/effects/router.effects.spec.ts b/projects/example-app/src/app/core/effects/router.effects.spec.ts new file mode 100644 index 0000000000..71ea5de9a1 --- /dev/null +++ b/projects/example-app/src/app/core/effects/router.effects.spec.ts @@ -0,0 +1,41 @@ +import { Router, ActivatedRoute, NavigationEnd } from '@angular/router'; +import { Title } from '@angular/platform-browser'; +import { TestBed } from '@angular/core/testing'; + +import { of } from 'rxjs'; + +import { RouterEffects } from '@example-app/core/effects'; + +describe('RouterEffects', () => { + let effects: RouterEffects; + let titleService: Title; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + RouterEffects, + { + provide: Router, + useValue: { events: of(new NavigationEnd(1, '', '')) }, + }, + { + provide: ActivatedRoute, + useValue: { data: of({ title: 'Search' }) }, + }, + { provide: Title, useValue: { setTitle: jest.fn() } }, + ], + }); + + effects = TestBed.get(RouterEffects); + titleService = TestBed.get(Title); + }); + + describe('updateTitle$', () => { + it('should update the title on router navigation', () => { + effects.updateTitle$.subscribe(); + expect(titleService.setTitle).toHaveBeenCalledWith( + 'Book Collection - Search' + ); + }); + }); +}); diff --git a/projects/example-app/src/app/core/effects/router.effects.ts b/projects/example-app/src/app/core/effects/router.effects.ts new file mode 100644 index 0000000000..740f274196 --- /dev/null +++ b/projects/example-app/src/app/core/effects/router.effects.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@angular/core'; +import { Router, NavigationEnd, ActivatedRoute } from '@angular/router'; +import { Title } from '@angular/platform-browser'; + +import { tap, filter, map, mergeMap } from 'rxjs/operators'; + +import { createEffect } from '@ngrx/effects'; + +@Injectable() +export class RouterEffects { + updateTitle$ = createEffect( + () => + this.router.events.pipe( + filter(event => event instanceof NavigationEnd), + map(() => { + let route = this.activatedRoute; + while (route.firstChild) route = route.firstChild; + return route; + }), + mergeMap(route => route.data), + map(data => `Book Collection - ${data['title']}`), + tap(title => this.titleService.setTitle(title)) + ), + { + dispatch: false, + } + ); + + constructor( + private router: Router, + private titleService: Title, + private activatedRoute: ActivatedRoute + ) {} +} diff --git a/projects/example-app/src/app/core/effects/user.effects.spec.ts b/projects/example-app/src/app/core/effects/user.effects.spec.ts new file mode 100644 index 0000000000..6e06615f54 --- /dev/null +++ b/projects/example-app/src/app/core/effects/user.effects.spec.ts @@ -0,0 +1,65 @@ +import { Action } from '@ngrx/store'; +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; + +import { UserEffects } from '@example-app/core/effects'; +import { UserActions } from '@example-app/core/actions'; + +describe('UserEffects', () => { + let effects: UserEffects; + const eventsMap: { [key: string]: any } = {}; + + beforeAll(() => { + document.addEventListener = jest.fn((event, cb) => { + eventsMap[event] = cb; + }); + }); + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [UserEffects], + }); + + effects = TestBed.get(UserEffects); + }); + + describe('idle$', () => { + it( + 'should trigger idleTimeout action after 5 minutes', + fakeAsync(() => { + let action: Action | undefined; + effects.idle$.subscribe(res => (action = res)); + + // Initial action to trigger the effect + eventsMap['click'](); + + tick(2 * 60 * 1000); + expect(action).toBeUndefined(); + + tick(3 * 60 * 1000); + expect(action).toBeDefined(); + expect(action!.type).toBe(UserActions.idleTimeout.type); + }) + ); + + it( + 'should reset timeout on user activity', + fakeAsync(() => { + let action: Action | undefined; + effects.idle$.subscribe(res => (action = res)); + + // Initial action to trigger the effect + eventsMap['keydown'](); + + tick(4 * 60 * 1000); + eventsMap['mousemove'](); + + tick(4 * 60 * 1000); + expect(action).toBeUndefined(); + + tick(1 * 60 * 1000); + expect(action).toBeDefined(); + expect(action!.type).toBe(UserActions.idleTimeout.type); + }) + ); + }); +}); diff --git a/projects/example-app/src/app/core/effects/user.effects.ts b/projects/example-app/src/app/core/effects/user.effects.ts new file mode 100644 index 0000000000..5d11660efb --- /dev/null +++ b/projects/example-app/src/app/core/effects/user.effects.ts @@ -0,0 +1,21 @@ +import { Injectable, Inject } from '@angular/core'; + +import { fromEvent, merge, timer } from 'rxjs'; +import { map, switchMapTo } from 'rxjs/operators'; + +import { createEffect } from '@ngrx/effects'; +import { UserActions } from '@example-app/core/actions'; + +@Injectable() +export class UserEffects { + clicks$ = fromEvent(document, 'click'); + keys$ = fromEvent(document, 'keydown'); + mouse$ = fromEvent(document, 'mousemove'); + + idle$ = createEffect(() => + merge(this.clicks$, this.keys$, this.mouse$).pipe( + switchMapTo(timer(5 * 60 * 1000)), // 5 minute inactivity timeout + map(() => UserActions.idleTimeout()) + ) + ); +}