Angular - Bootstrap Modals With Ngrx Effects
While displaying a Bootstrap modal dialog in Angular is fairly simple, doing so inside an NgRx effect is a little tricker. This post walks you through all necessary step to get the job done inside a simple Angular counter application.
Prerequisites
You need Angular CLI and NPM if you want to follow the examples locally. The examples were written with NodeJS version v10.17.0, NPM version 6.13.1, and Angular CLI version 8.3.2.
Setting Up a Counter Application
The counter application consists of two buttons that will allow you to increment and decrement a counter at your heart's delight.
If you are familiar with NgRx and NgBootstrap, feel free to jump directly to the section Modal Dialog! You will also find a link to a github repository so that you can check out the result of the steps you had skipped.
Create an Angular Project
First you have to create a new project. In a directory of your choice run
$ ng new --defaults ngbmodal-ngrx
... lots of output
$ cd ngbmodal-ngrx
This will create an Angular project with default options.
Add Twitter Bootstrap
We also want ngbootstrap, which is Twitter Bootstrap for Angular.
$ npm add --save @ng-bootstrap/ng-bootstrap bootstrap
... lots of output
The bootstrap style sheets have to be added to the project CSS. Open angular.json
in the top-level directory, and change project.architect.build.styles
to this:
"styles": [
"node_modules/bootstrap/dist/css/bootstrap.min.css",
"src/styles.css"
],
Add NgbModule
to the application in src/app/app.module.ts
:
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { AppComponent } from './app.component';
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
NgbModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
Of course, you should also overwrite the generated default content in src/app/app.component.html
:
<main role="main" class="container">
<h1>Counter</h1>
</main>
And finally change the start
script in package.json
to "ng serve --open", so that the default browser automatically opens, when running the app.
Now start the application:
$ npm start
...
After some time, your browser should open http://localhost:4200 and display the headline "Counter" with Bootstrap's default font (a sans-serif font).
Make the Counter Application
Add NgRx to the Project
We want to use NgRx. Do not add it with npm
or yarn
but with ng
:
$ npm run ng add @ngrx/store
Installing packages for tooling via npm.
Installed packages for tooling via npm.
CREATE src/app/reducers/index.ts (359 bytes)
UPDATE src/app/app.module.ts (654 bytes)
UPDATE package.json (1392 bytes)
...
This has not only installed ngrx
, but also created a skeleton for your app's reducers in src/app/reducers/index.tx
, and has modified src/app/app.module.ts
to import the store module and the reducers.
Actions
It is good practice to start with the actions that you want to use in your application. We will need one action for incrementing the counter, one for decrementing, and one for resetting it.
Create a file src/app/counter.actions.ts
:
import { createAction } from '@ngrx/store';
export const increment = createAction('[Counter Component] Increment');
export const decrement = createAction('[Counter Component] Decrement');
export const reset = createAction('[Counter Component] Reset');
Store and Reducers
The counter must live somewhere, more precisely in the store src/app/counter.reducer.ts
:
import * as CounterActions from './counter.actions';
import { on, createReducer } from '@ngrx/store';
export const counterFeatureKey = 'counter';
export interface State {
current: number;
}
export const initialState: State = {
current: 0
};
export const reducer = createReducer(
initialState,
on(CounterActions.increment, (state) => ({ current: state.current + 1})),
on(CounterActions.decrement, (state) => ({ current: state.current - 1})),
on(CounterActions.reset, () => initialState),
);
And now add it to the store tree in src/app/reducers/index.ts
:
<pre data="qgoda-remove">import {
ActionReducer,
ActionReducerMap,
createFeatureSelector,
createSelector,
MetaReducer
} from '@ngrx/store';
import { environment } from '../../environments/environment';
import * as fromCounter from '../counter.reducer';
export interface AppState {
/* Omitting the space in front of the colon outsmarts my markdown processor,
sorry ... */
[fromCounter.counterFeatureKey] : fromCounter.State
}
export const reducers: ActionReducerMap<AppState> = {
[fromCounter.counterFeatureKey] : fromCounter.reducer
};
export function logger(reducer: ActionReducer<AppState>): ActionReducer<AppState> {
return (state, action) => {
const result = reducer(state, action);
console.groupCollapsed(action.type);
console.log('prev state', state);
console.log('action', action);
console.log('next state', result);
console.groupEnd();
return result;
};
}
export const getCounter = (state:AppState) => state.counter;
export const selectCounterState = createFeatureSelector<AppState, fromCounter.State>(
fromCounter.counterFeatureKey
);
export const selectCounterCurrent = createSelector(
getCounter,
(state:fromCounter.State) => state.current
)
export const metaReducers: MetaReducer<AppState>[] = !environment.production
? [logger]
: [];
</pre>
While being at it, we also added a nice logger()
function for tracking the actions being dispatched in the console.
Add the Counter Component
Next generate the component with the buttons and the counter display:
$ npm run ng generate component counter
CREATE src/app/counter/counter.component.css (0 bytes)
CREATE src/app/counter/counter.component.html (22 bytes)
CREATE src/app/counter/counter.component.spec.ts (635 bytes)
CREATE src/app/counter/counter.component.ts (273 bytes)
UPDATE src/app/app.module.ts (740 bytes)
...
In order to display the component, change src/app/app.component.html
like this:
<main role="main" class="container">
<h1>Counter</h1>
<app-counter></app-counter>
</main>
In the component we want to trigger the three actions created above. Change src/app/counter/counter.component.ts
:
<pre data="qgoda-remove">import { Component } from '@angular/core';
import { Store, select } from '@ngrx/store';
import { AppState, selectCounterCurrent } from '../reducers';
import * as CounterActions from '../counter.actions';
@Component({
selector: 'app-counter',
templateUrl: './counter.component.html',
styleUrls: ['./counter.component.css']
})
export class CounterComponent {
counter$ = this.store.pipe(select(selectCounterCurrent));
constructor(
private store: Store<AppState>
) {}
onIncrement() {
this.store.dispatch(CounterActions.increment());
}
onDecrement() {
this.store.dispatch(CounterActions.decrement());
}
onReset() {
this.store.dispatch(CounterActions.reset());
}
}
</pre>
Now throw together a couple of buttons in src/app/counter/counter.component.html
:
<pre data="qgoda-remove"><div class="row">
<div class="mr current-counter">
Current value: {{ counter$ | async }}
</div>
</div>
<div class="row">
<div class="btn-toolbar" role="toolbar" aria-label="Toolbar with button groups">
<div class="btn-group mr-2" role="group" aria-label="Modifier group">
<button type="button" class="btn btn-primary"
(click)="onDecrement()">-</button>
<button type="button" class="btn btn-primary"
(click)="onIncrement()">+</button>
</div>
<div class="btn-group mr-2" role="group" aria-label="Reset group">
<button type="button" class="btn btn-secondary"
(click)="onReset()">Reset</button>
</div>
</div>
</div>
</pre>
The counter app is ready and functional.
Modal Dialog
You can check out the current state of the app from github:
<pre data="qgoda-remove">$ git clone https://github.com/gflohr/ngbmodal-ngrx.git
...
$ cd ngbmodal-ngrx
$ git fetch && git fetch --tags && git checkout step1
</pre>
Users love your app, you're doing great in the counter business if it weren't for these complaints that people sometimes hit the reset button unintentionally and lose one entire day of counting with one single mouse click.
You need a confirmation dialog! But where do you trigger opening the dialog?
One possibility would be the click handler for the button, something like this:
<pre data="qgoda-remove">onReset() {
this.modalService.open(ResetConfirmationComponent)
.then(() => this.store.dispatch(CounterActions.reset());
}
</pre>
But what about separation of concerns? And what if you want to trigger the same confirmation dialog from somewhere else? A better place to implement that functionality is clearly an NgRx effect. Let's go for that option!
Split Reset Action in Three
Add two more actions to src/app/counter.actions.ts
:
<pre data="qgoda-remove">import { createAction } from '@ngrx/store';
export const increment = createAction('[Counter Component] Increment');
export const decrement = createAction('[Counter Component] Decrement');
export const reset = createAction('[Counter Component] Reset');
export const resetConfirmation
= createAction('[Counter Component] Reset Confirmation');
export const resetConfirmationDismiss
= createAction('[Counter Component] Reset Confirmation Dismissed');
</pre>
And now you simply change the action that is triggered, when the reset button is clicked to resetConfirmation
instead of just reset
in src/app/counter/counter.component.ts
:
<pre data="qgoda-remove">onReset() {
this.store.dispatch(CounterActions.resetConfirmation());
}
</pre>
Dialog Content Component
There are multiple ways to fill a Bootstrap NgbModal
dialog with content. Using a separate component is the most versatile option:
$ npm run ng generate component reset-confirmation
> ngbmodal-ngrx@0.0.0 ng /Users/guido/javascript/ngbmodal-ngrx
> ng "generate" "component" "reset-confirmation"
CREATE src/app/reset-confirmation/reset-confirmation.component.css (0 bytes)
CREATE src/app/reset-confirmation/reset-confirmation.component.html (33 bytes)
CREATE src/app/reset-confirmation/reset-confirmation.component.spec.ts (706 bytes)
CREATE src/app/reset-confirmation/reset-confirmation.component.ts (316 bytes)
UPDATE src/app/app.module.ts (831 bytes)
The dialog contents goes into the newly generated reset-confirmation-component.html
:
<div class="modal-header">
<h4 class="modal-title">Reset</h4>
<button type="button" class="close" aria-label="Close"
(click)="activeModal.dismiss('closed')">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<p translate>Are you sure to reset the counter?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary"
(click)="activeModal.dismiss('cancel')"
[attr.arial-label]="Cancel" translate>
Cancel
</button>
<button type="button" class="btn btn-primary btn-default"
(click)="activeModal.close('reset')"
ngbAutofocus [attr.arial-label]="Reset" translate>
Reset
</button>
</div>
You also have to inject the NgbActiveModal
into the component's constructor in reset-confirmation-component.ts
:
import { Component } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
@Component({
selector: 'app-reset-confirmation',
templateUrl: './reset-confirmation.component.html',
styleUrls: ['./reset-confirmation.component.css']
})
export class ResetConfirmationComponent {
constructor(
public activeModal: NgbActiveModal
) { }
}
Since the new component is not referenced in a template, it has to be loaded imperatively. You therefore have to add it to the module's entryComponents
in src/app/app.module.ts
:
...
@NgModule({
declarations: [
AppComponent,
CounterComponent,
ResetConfirmationComponent
],
entryComponents: [
ResetConfirmationComponent
],
...
Define Effects
NgRx effects come in a separate package. You have to add it as a dependency to the application:
$ npm run ng add @ngrx/effects
> ngbmodal-ngrx@0.0.0 ng /Users/guido/javascript/ngbmodal-ngrx
> ng "add" "@ngrx/effects"
Installing packages for tooling via npm.
Installed packages for tooling via npm.
CREATE src/app/app.effects.spec.ts (583 bytes)
CREATE src/app/app.effects.ts (186 bytes)
UPDATE src/app/app.module.ts (961 bytes)
UPDATE package.json (1357 bytes)
And finally create a class CounterEffects
in src/app/counter.effects.ts
that defines the effect:
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { map, exhaustMap } from 'rxjs/operators';
import * as CounterActions from './counter.actions';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ResetConfirmationComponent } from './reset-confirmation/reset-confirmation.component';
import { from } from 'rxjs';
@Injectable()
export class AppEffects {
constructor(
private actions$: Actions,
private modalService: NgbModal
) {}
runDialog = content => {
const modalRef = this.modalService.open(content, { centered: true });
return from(modalRef.result);
};
resetConfirmation$ = createEffect(() => this.actions$.pipe(
ofType(CounterActions.resetConfirmation),
exhaustMap(() => this.runDialog(ResetConfirmationComponent)),
map(() => CounterActions.reset())
));
}
The NgbModal
service gets injected into the constructor. The service's open()
method displays and manages the modal dialog. It returns an object with a property result
that is a Promise
.
In case you wonder why in line 25 exhaustMap()
and not just map()
is used, you have to keep in mind that effects run synchronously. The method runDialog()
returns a stream (an Observable
). With map()
you would get an Observable
of Observable
s, and this has has to be flattened with one of the flattening operators mergeMap()
, switchMap()
, exhaustMap()
, or concatMap()
.
For this case exhaustMap()
is the right flattening strategy because it ensures that only one dialog is opened at once, which is what we want.
The reset dialog should now work as expected. But when you cancel the confirmation, you see an ugly error message in the console. The reason is that dismissing an NgbModal
is considered an error.
Let's try to remedy that with the catchError
operator:
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { map, exhaustMap, catchError } from 'rxjs/operators';
import * as CounterActions from './counter.actions';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ResetConfirmationComponent } from './reset-confirmation/reset-confirmation.component';
import { from, of } from 'rxjs';
@Injectable()
export class AppEffects {
constructor(
private actions$: Actions,
private modalService: NgbModal
) {}
runDialog = content => {
const modalRef = this.modalService.open(content, { centered: true });
return from(modalRef.result);
};
resetConfirmation$ = createEffect(() => this.actions$.pipe(
ofType(CounterActions.resetConfirmation),
exhaustMap(() => this.runDialog(ResetConfirmationComponent)),
map(() => CounterActions.reset()),
catchError(() => of(CounterActions.resetConfirmationDismiss()))
));
}
Cool! The error message has gone. But there is a glitch. If you cancel the action, subsequent clicks on the Reset button no longer open the confirmation dialog, and it is impossible to reset the counter.
The problem is caused by catchError()
which replaces the incoming observable with a new observable, in our case the return value of of()
which emits here exactly one item and then completes. And that means that the entire effect is completed.
In order to fix this, you have to wrap the thing that may fail and the error handler together into one flattening operator:
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { map, exhaustMap, catchError } from 'rxjs/operators';
import * as CounterActions from './counter.actions';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ResetConfirmationComponent } from './reset-confirmation/reset-confirmation.component';
import { from, of } from 'rxjs';
@Injectable()
export class AppEffects {
constructor(
private actions$: Actions,
private modalService: NgbModal
) {}
runDialog = function(content) {
const modalRef = this.modalService.open(content, { centered: true });
return from(modalRef.result);
};
resetConfirmation$ = createEffect(() => this.actions$.pipe(
ofType(CounterActions.resetConfirmation),
exhaustMap(() => this.runDialog(ResetConfirmationComponent).pipe(
map((result) => CounterActions.reset()),
catchError(() => of(CounterActions.resetConfirmationDismiss()))
))
));
}
The difference here is that exhaustMap()
will not complete, when the incoming stream is completed but will wait for the next item to process.
The variable result
in line 26 is not used here. It is actually the argument to activeModal.close()
in the html file, in our case always "reset". If there are multiple close buttons, then check the value here in order to find out which button was clicked.
You can checkout the final version from https://github.com/gflohr/ngbmodal-ngrx.
Leave a comment