Skip to content

Commit

Permalink
feat(example): add examples of effects not based on the Actions stream (
Browse files Browse the repository at this point in the history
#1845)

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
  • Loading branch information
Adrian Fâciu authored and brandonroberts committed May 21, 2019
1 parent d874cfc commit 3454e70
Show file tree
Hide file tree
Showing 12 changed files with 197 additions and 6 deletions.
6 changes: 5 additions & 1 deletion projects/example-app/src/app/app-routing.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
3 changes: 2 additions & 1 deletion projects/example-app/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -73,7 +74,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],
Expand Down
4 changes: 3 additions & 1 deletion projects/example-app/src/app/auth/auth-routing.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)],
Expand Down
8 changes: 8 additions & 0 deletions projects/example-app/src/app/auth/effects/auth.effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 11 additions & 2 deletions projects/example-app/src/app/books/books-routing.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
3 changes: 2 additions & 1 deletion projects/example-app/src/app/core/actions/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as LayoutActions from './layout.actions';
import * as UserActions from './user.actions';

export { LayoutActions };
export { LayoutActions, UserActions };
3 changes: 3 additions & 0 deletions projects/example-app/src/app/core/actions/user.actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createAction } from '@ngrx/store';

export const idleTimeout = createAction('[User] Idle Timeout');
2 changes: 2 additions & 0 deletions projects/example-app/src/app/core/effects/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './user.effects';
export * from './router.effects';
41 changes: 41 additions & 0 deletions projects/example-app/src/app/core/effects/router.effects.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
);
});
});
});
34 changes: 34 additions & 0 deletions projects/example-app/src/app/core/effects/router.effects.ts
Original file line number Diff line number Diff line change
@@ -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
) {}
}
65 changes: 65 additions & 0 deletions projects/example-app/src/app/core/effects/user.effects.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
})
);
});
});
21 changes: 21 additions & 0 deletions projects/example-app/src/app/core/effects/user.effects.ts
Original file line number Diff line number Diff line change
@@ -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())
)
);
}

0 comments on commit 3454e70

Please sign in to comment.