Angular - Bootstrap modale Dialoge mit Ngrx-Effects

Es ist einfach, einen modalen Bootstrap-Dialog in Angular darzustellen. Innerhalb eines NgRx-Effekts ist es jedoch schon etwas komplizierter. Dieser Artikel erläutert alle notwendigen Schritte, um dies innerhalb einer einfachen Angular-Applikation zum Zählen zu realisieren.

Voraussetzungen

Man braucht Angular CLI und NPM, um die Beispiele lokal nachzuvollziehen. Sie wurden mit NodeJS v10.17.0, NPM Version 6.13.1 und Angular CLI Version 8.3.2 getestet.

Aufsetzen der Counter-Applikation.

Die Counter-Applikation enthält nichts als zwei Buttons, mit denen man einen Counter nach Herzenslust hoch- oder heruntersetzen kann.

Wer sich schon gut mit NgRx und NgBootstrap auskennt, kann direkt zum Abschnitt Modal Dialog! Dort gibt es auch einen Link zu einem Github-Repostory, so dass man die übersprungenen Schritte auschecken kann.

Ein Angular-Projekt erstellen

Zunächst muss ein neues Projekt erzeugt werden. In einem frei wählbaren Verzeichnis muss dafür dieses Kommando abgesetzt werden:

$ ng new --defaults ngbmodal-ngrx
... Programmausgabe
$ cd ngbmodal-ngrx

Dies erzeugt ein Angular-Projekt mit Default-Optionen.

Twitter-Bootstrap zufügen

Benötigt wird weiterhin ngbootstrap, die Angular-Version von Twitter Bootstrap.

$ npm add --save @ng-bootstrap/ng-bootstrap bootstrap
... Programmausgabe

Die Bootstrap Stylesheets müssen noch zum Projekt-CSS zugefügt werden. Das passiert in angular.json im Wurzelverzeichnis. Die Variable project.architect.build.styles wird dafür folgendermaßen geändert:

"styles": [
  "node_modules/bootstrap/dist/css/bootstrap.min.css",
  "src/styles.css"
],

Das Modul NgbModule muss noch in src/app/app.module.ts eingebunden werden:

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 { }

Natürlich muss auch noch der generierte Default-Content in src/app/app.component.html überschrieben werden:

<main role="main" class="container">
  <h1>Counter</h1>
</main>

Und schließlich ändern wir das Skript start in package.json zu ng serve --open, damit der Default-Browser sich automatisch öffet, wenn die Applikation lokal gestartet wird.

Und genau das machen wir jetzt:

$ npm start
...

Nach kurzer Zeit sollte der Browser http://localhost:4200 öffnen und die Überschrift Counter im Default-Font von Bootstrap's anzeigen.

Die Counter-Applikation

NgRx zum Projekt hinzufügen

Natürlich wird auch NgRx benötigt, dass allerdings nicht mit npm/yarn, sondern mit ng zugefügt werden sollte:

$ 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)
...

Auf diese Weiste wurde nicht nur ngrx installiert, sondern gleichzeitig ein Gerüst für Reducer der Applikation in src/app/reducers/index.tx erstellt. Außerdem wurde auch noch src/app/app.module.ts dahingehend geändert, dass das Store-Module und die Reducer eingebunden werden.

Aktionen

Es empfiehlt sich, immer mit der Definitionen zu beginnen. Es wird eine Aktion zum Erhöhen des Zählers, eine zum Heruntersetzen, und eine zum Zurücksetzen benötigt.

Dazu wird eine Datei src/app/counter.actions.ts erzeugt:

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 und Reducer

Der Zähler muss irgendwo existieren, genauer gesagt im 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),
);

Der Baum des Stores muss noch zu src/app/reducers/index.ts hinzugefügt werden;

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]
  : [];

Wo wir schon einmal dabei sind, wird noch eine praktische Funktion logger() eingebaut, mit der sich das Triggern der Aktionen in der Konsole verfolgen lässt.

Die Counter-Komponente

Als nächstes wird die Komponente mit den Button und der Anzeige des aktuellen Counter-Standes generiert:

$ 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)
...

Damit diese Komponente angezeigt wird, muss auc noch src/app/app.component.html erweitert werden:

<main role="main" class="container">
  <h1>Counter</h1>
  <app-counter></app-counter>
</main>

In der Komponente sollen die drei oben erzeugten Aktionen getriggert werden. Das passiert in src/app/counter/counter.component.ts:

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());
  }
}

Und schließlich müssen noch die Buttons und die Anzeige des Zählers in src/app/counter/counter.component.html zusammengeklöppelt werden:

<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>

Die Counter-Applikation ist damit erst einmal fertig und voll funktional.

Counter App

Modaler Dialog

Der aktuelle Stand kann von GitHub ausgecheckt werden:

$ git clone https://github.com/gflohr/ngbmodal-ngrx.git
...
$ cd ngbmodal-ngrx
$ git fetch && git fetch --tags && git checkout step1

Die User lieben die Counter-Applikation, und überhaupt läuft das ganze Zähl-Business großartig, wären da nicht die ständigen Beschwerden von Leuten, die mit der Maus ausrutschen und aus Versehen den Reset-Button drücken. Damit kann man die Zählarbeit ganzer Tage verlieren, eine Katastrophe!

Eine Sicherheitsabfrage muss her! Aber wo soll das Öffnen des Dialogs ausgelöst werden?

Eine Möglichkeit ist der Click-Handler des entsprechenden Buttons:

onReset() {
  this.modalService.open(ResetConfirmationComponent)
  .then(() => this.store.dispatch(CounterActions.reset());
}

Aber was ist mit Separation of concerns? Was, wenn der Dialog von verschiedenen Stellen aus geöffnet werden soll? Es ist deshalb besser, die entsprechende Funktionalität in einem NgRx-Effekt zu implementieren. Für diese Option entscheiden wir uns hier.

Auftrennen der Reset-Aktion in drei Teile

Es werden zwei neue Aktionen zu src/app/counter.actions.ts hinzugefügt:

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');

Und jetzt muss lediglich noch der Click-Handler für den Reset-Button in src/app/counter/counter.component.ts auf die neue, vorgeschaltete Aktion resetConfirmation umgebogen werden:

onReset() {
  this.store.dispatch(CounterActions.resetConfirmation());
}

Anzeige-Komponente des Dialogs

Es gibt verschiedene Möglichkeiten, um einen modalen Bootstrap-Dialog mittels NgbModal mit Inhalt zu füllen. Eine separate Komponente ist die flexibelste 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)

Der Inhalt des Dialogs kommt in die gerade generierte Datei 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">&times;</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>

Weiterhin muss NgbActiveModal in reset-confirmation-component.ts noch in den Konstruktor injiziert werden:

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
  ) { }

}

Weil die neue Komponente nicht in einem Template referenziert wird, muss sie imperativ geladen werden. Dazu muss sie dem Array entryComponents in src/app/app.module.ts hinzugefügt werden

...
@NgModule({
    declarations: [
        AppComponent,
        CounterComponent,
        ResetConfirmationComponent
    ],
    entryComponents: [
        ResetConfirmationComponent
    ],
...

Definition der Effekte

NgRx-Effekte werden in einem separaten Paket, dass der Applikation als Abhängigkeit hinzugefügt werden muss, ausgeliefert:

$ 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)

Und schließlich muss noch die gerade generierte Klasse AppEffects in src/app/app.effects.ts angepasst werden:

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())
  ));
}

Der Service NgbModal wird in den Konstruktor injizierte. Die Service-Methode `open() zeigt den Dialog an, und verwaltet die Interaktion. Die Methode liefert ein Objekt mit der Eigenschaft result zurück, bei dem es sich um ein Promise handelt.

Weshalb wird in Zeile 25 exhaustMap() und nicht einfach map() verwendet? Effekte laufen immer synchron. Die Methode runDialog() liefert aber einen Stream (ein Observable). Mit map() würde man daher ein Observable von Observables erhalten. Dies muss aber mit einem der Flattening-Operatoren mergeMap(), switchMap(), exhaustMap() oder concatMap() wieder eindimensional gemacht werden.

Im vorliegenden Fall ist exhaustMap() die korrekte Flattening-Option, weil sie sicherstellt, dass immer nur eine Dialoginstanz geöffnet werden kann.

Der Reset-Dialog sollte jetzt wie gewünscht funktionieren. Nur leider sieht man eine hässliche Fehlermeldung in der Konsole, wenn das Zurücksetzen zum Beispiel mitdem Cancel-Button abgebrochen wird. Dies rührt daher, dass der Abruch (Dismiss) eines NgbModal als Fehler aufgefasst wird.

Das versuchen wir jetzt mithilfe des Operators catchError zu heilen:

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()))
  ));
}

Schon besser! Die Fehlermeldung ist weg. Es gibt aber ein neues, schwerwiegenderes Problem. Wird die Aktion auch nur ein einziges Mal abgebrochen, funktioniert der Reset-Button nicht mehr. Der Dialog wird nicht mehr angezeigt, und es ist nicht möglich den Zähler zurückzuseten.

Das Problem rührt daher, dass catchError() das Eingangs-Observable mit einem neuen Observable ersetzt, im vorliegenden Fall mit dem Return-Wert von of(), dass hier exakt ein Element emittiert, und dann terminiert. Und damit ist auch der komplette NgRx-Effekt abgelaufen.

Die Strategie, um dies zu verhindern besteht immer darin, das, was fehlschlagen kann und die Fehlerbehandlung zusammen in einen Flattening-Operator einzubetten:

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()))
        ))
    ));
}

Der Unterschied hier besteht darin, dass exhaustMap() nicht terminiert, wenn der Eingabstrom terminert, sondern stattdessen auf das nächste Item wartet, und dann mit der Verarbeitung fortfährt..

Die Variable result in Zeile 26 wird hier nicht verwendet. Es handelt sich dabei um das Argument zu activeModal.close() im HTML-Template, im vorliegenden Fall immer die Zeichenkette reset. Gibt es allerdings mehrere Buttons, mit denen der Dialog verlassen werden kann, lässt sich mit Hilfe des zurückgegebenen Werts herausbekommen, welcher Button gedrückt worden.

Die finale Version der Applikation kann unter https://github.com/gflohr/ngbmodal-ngrx ausgecheckt werden.


blog comments powered by Disqus