diff --git a/projects/example-app/src/app/app-routing.module.ts b/projects/example-app/src/app/app-routing.module.ts index 5bdf5b3b72..129deaa655 100644 --- a/projects/example-app/src/app/app-routing.module.ts +++ b/projects/example-app/src/app/app-routing.module.ts @@ -11,7 +11,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 6c35f153bf..9b32d33917 100644 --- a/projects/example-app/src/app/app.module.ts +++ b/projects/example-app/src/app/app.module.ts @@ -15,6 +15,7 @@ import { ROOT_REDUCERS, metaReducers } from '@example-app/reducers'; import { CoreModule } from '@example-app/core'; import { AppRoutingModule } from '@example-app/app-routing.module'; +import { UserEffects, RouterEffects } from '@example-app/core/effects'; import { AppComponent } from '@example-app/core/containers'; @NgModule({ @@ -69,7 +70,7 @@ import { AppComponent } from '@example-app/core/containers'; * * See: https://ngrx.io/guide/effects#registering-root-effects */ - EffectsModule.forRoot([]), + EffectsModule.forRoot([UserEffects, RouterEffects]), CoreModule, ], bootstrap: [AppComponent], 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 5235a46dd5..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 { @@ -69,6 +70,13 @@ export class AuthEffects { ) ); + 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 32776c2814..8ffc5d2cb9 100644 --- a/projects/example-app/src/app/books/books-routing.module.ts +++ b/projects/example-app/src/app/books/books-routing.module.ts @@ -9,13 +9,22 @@ import { import { BookExistsGuard } from '@example-app/books/guards'; 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()) + ) + ); +}