Angular Notes

Based on Udemy Course Angular 8 The Complete Guide by Maximilian Schwarzmüller

Introduction

What is Angular

Reactive Single-Page Applications

Angular Versioning

CLI

Data-binding

Selectors

ngModel

Typescript

Bootstrap Styling

The Basics

Components

Create a new Component

// server/server.component.ts
@Component({
  // decorators - all start with @
  selector: 'app-server',
  templateUrl: './server.component.html'
})
export class ServerComponent {}

AppModule

Component templates

Component Styles

Component Selector

Databinding

Directives

ngIf

ngStyle

ngClass

ngFor

Course Project, Planning

Creating a Model

export class Recipe {
  public name: string;
  public description: string;
  public imagePath: string;

  constructor(name: string, desc: string, imagePath: string) {}
}

Debugging

Components & Data-binding Deep Dive

View Encapsulation

Load references in Templates

@ViewChild

@ViewChild('serverContentInput', {static: true})

serverControlInput: ElementRef;

this.serverContentInput.nativeElement.value

Projecting content into Components with ng-content

Component Lifecycle

lifecycle

Concept: CONTENT/VIEW

<app-component>
<!-- CONTENT -->
</app-component>

Directives Deep Dive

Creating a Basic Attribute Directive

<div [ngSwitch]="value">
  <p *ngSwitchCase="5">Value is 5</p>
  <p *ngSwitchDefault>Default</p>

Using Services & Dependency Injection

Creating a Service

Cross-Component Communication with Services

Changing Pages with Routing

Why

Setting up, in AppModule

const appRoutes: Routes = [
  { path: 'user', component: UsersComponent }, // no slash
  { path: '', component: HomeComponent } // home page: '/'
];
onLoadServer() {
  // calculations
  this.router.navigate(['/servers']);
}
this.route.params   // Observable
  .subscribe( // update user object when params change
    (params: Params) => { // import
      this.user.id = params['id'];
    }
  );

Setting up Child (nested) Routes

{ path: 'servers', component: ServersComponent,
  children: [
    { path: ':id', component: ServerComponent }, // servers will be pre-appended
    { // ... }
  ]
}
this.router.navigate(
  ['edit'], { relativeTo: this.route,
  queryParamsHandling: 'preserve' } 
  // blank for new, keep old overwrite new, merge for no overwrite
);

Outsourcing Route Config

Guards

Controlling navigation with canDeactivate

Passing Static Data to a Route

Resolving Dynamic Data with the resolve Guard

this.route.data.subscribe((data: Data) => {
  this.server = data['server']; // choice of name "server" here
});

Location Strategies

Understanding Observables

Handling Forms in Angular Apps

@ViewChild('f', { static: false })
signupForm: NgForm;
<form (ngSubmit)="onSubmit(f)" #f="ngForm">

Validation

Setting & Fetching Form Values

Reactive Approach

// in OnInit:
this.signupForm = new FormGroup({
  'username': new FormControl(null),
  'email': new FormControl(null),
  'gender': new FormControl('male')
});

Arrays of Form Control

'hobbies': new FormArray([]) // empty

onAddHobby() {
  const control = new FormControl(null);
  (<FormArray>this.signupForm.get('hobbies')).push(control);
}
<button
  (click)="onAddHobby()">
<div
  *ngFor="let hobbyControl of signupForm.get('hobbies').controls;
  let i = index"
>
<input [formControlName]="i">

Custom Validators

forbiddenNames(control: FormControl): {[s: string]: boolean} { }
forbiddenEmails(control: FormControl): Promise<any> | Observable<any> {
  const promise = new Promise<any>((resolve, reject) => {
    setTimeout(() => {
      if (control.value === 'test@test.com') {
        resolve({ 'emailIsForbidden': true });
      } else {
        resolve(null);
      }
    }, 1500);
  });
  return promise;
}
this.signupForm.statusChanges.subscribe(
  (value) => console.log(value)
);

Using Pipes to Transform Output

// shorten.pipe.ts
@Pipe({
  name: 'shorten'
})
export class ShortenPipe implements PipeTransform {
  transform(value: any) {
    return value.substr(0, 10);
  }
}

Making Http Requests

How does Angular interact with backends?

Server

Interceptors

Authentication and Route Protection in Angular

How it works

Adding Auth Page

Switch between Auth Modes

Handling Form Input

<div class="row">
    <div class="col-xs-12 col-md-6 col-md-offset-3">
        <div class="alert alert-danger" *ngIf="error">
            <p>{{ error }}</p>
        </div>
        <div *ngIf="isLoading" style="text-align: center">
            <app-loading-spinner></app-loading-spinner>
        </div>
        <form #authForm="ngForm" (ngSubmit)="onSubmit(authForm)" *ngIf="!isLoading">
            <div class="form-group">
                <label for="email">E-Mail</label>
                <input
                    type="email"
                    id="email"
                    class="form-control"
                    ngModel
                    name="email"
                    required
                    email
                >
            </div>
            <div class="form-group">
                <label for="password">Password</label>
                <input
                    type="password"
                    id="password"
                    class="form-control"
                    ngModel
                    name="password"
                    required
                    minlength="6"
                >
            </div>
            <div>
                <button class="btn btn-primary" type="submit" [disabled]="!authForm.valid">
                    {{ isLoginMode ? 'Login' : 'Sign Up' }}
                </button>
                |
                <button class="btn btn-primary" (click)="onSwitchMode()" type="button">
                    Switch to {{ isLoginMode ? 'Sign Up' : 'Login' }}
                </button>
            </div>
        </form>
    </div>
</div>
export interface AuthResponseData {
    kind: string;
    idToken: string;
    email: string;
    refreshToken: string;
    expiresIn: string;
    localId: string;
    registered?: boolean; // optional, is only in login res
}
onSubmit(form: NgForm) {
    if (!form.valid) {
        return;
    }
    const email = form.value.email;
    const password = form.value.password;

    let authObs: Observable<AuthResponseData>;

    this.isLoading = true;
    if (this.isLoginMode) {
        authObs = this.authService.login(email, password);
    } else {
        authObs = this.authService.signup(email, password);
    }

    authObs.subscribe(resData => {
        console.log(resData);
        this.isLoading = false;
    }, errorMessage => {
        console.log(errorMessage);
        this.error = errorMessage;
        this.isLoading = false;
    });

    form.reset();
}
private handleError(errorRes: HttpErrorResponse) {
    let errorMessage = 'An unknown error occurred!';
    if (!errorRes.error || !errorRes.error.error) {
        return throwError(errorMessage);
    }
    switch (errorRes.error.error.message) {
        case 'EMAIL_EXISTS':
            errorMessage = 'This email exists already.';
            break;
        case 'EMAIL_NOT_FOUND':
            errorMessage = 'This email does not exist';
            break;
        case 'INVALID_PASSWORD':
            errorMessage = 'This password is not correct';
            break;
    }
    return throwError(errorMessage);
}

Creating and Storing the User Data

// src/app/auth/user.model.ts
export class User {
    constructor(
        public email: string,
        public id: string,
        // underscore & private for validity
        private _token: string,
        private _tokenExpirationDate: Date
    ) {}

    /*
        getter using get keyword
        access like a property
        allows for logic in validity and checks
    */
    get token() {
        if (!this._tokenExpirationDate || new Date() > this._tokenExpirationDate) {
            return null;
        }
        return this._token;
    }
}
// src/app/auth/auth.service.ts
user = new Subject<User>();
//  ...
private handleAuthentication(
    email: string,
    userId: string,
    token: string,
    expiresIn: number
  ) {
    const expirationDate = new Date(new Date().getTime() + expiresIn * 1000);
    const user = new User(email, userId, token, expirationDate);
    this.user.next(user);
  }

// integrate to login and signup, from pipe
.pipe(
    catchError(this.handleError),
    tap(resData => {
    this.handleAuthentication(
        resData.email,
        resData.localId,
        resData.idToken,
        +resData.expiresIn
    );
    })
);

Reflecting the Auth State in the UI

isAuthenticated = false;
private userSub = Subscription;
// inject auth service
// implement ngOnInit and ngOnDestroy
ngOnInit() {
    this.userSub = this.authService.user.subscribe(user => {
        this.isAuthenticated = !!user;
        console.log(!user);
        console.log(!!user);
    });
}
// then use ngIf in template

Adding the Token to Outgoing Requests

fetchRecipes() {
  return this.authService.user.pipe(
    take(1),
    exhaustMap(user => {
      return this.http.get<Recipe[]>(
      'https://ng-learn-practice.firebaseio.com/recipes.json'
    );
    }),
    map(recipes => {
      return recipes.map(recipe => {
        return {
          ...recipe,
          ingredients: recipe.ingredients ? recipe.ingredients : []
        };
      });
    }),
    tap(recipes => {
      this.recipeService.setRecipes(recipes);
    })
  );
}

Attaching the Token with an Interceptor

// src/app/auth/auth-interceptor.service.ts
import { Injectable } from '@angular/core';
import {
  HttpInterceptor,
  HttpRequest,
  HttpHandler,
  HttpParams
} from '@angular/common/http';

import { AuthService } from './auth.service';
import { take, exhaustMap } from 'rxjs/operators';

@Injectable()
export class AuthInterceptorService implements HttpInterceptor {
  constructor(private authService: AuthService) {}

  intercept(req: HttpRequest<any>, next: HttpHandler) {
    return this.authService.user.pipe(
      take(1),
      exhaustMap(user => {
        // must check, only add token if we have a user! otherwise send original req
        if (!user) {
          return next.handle(req);
        }
        const modifiedRequest = req.clone({
          params: new HttpParams().set('auth', user.token)
        });
        return next.handle(modifiedRequest);
      })
    );
  }
}

Adding Logout

// src/app/auth/auth.service.ts
logout() {
  this.user.next(null);
  this.router.navigate(['/auth']);
}

// src/app/header/header.component.ts
onLogout() {
  this.authService.logout();
}

Adding Auto-Login

autoLogin(){
    const userData: {
      email: string,
      id: string,
      _token: string,
      _tokenExpirationDate: string;
    } = JSON.parse(localStorage.getItem('userData'));
    if (!userData) {
      return;
    }

    const loadedUser = new User(userData.email, userData.id, userData._token, new Date(userData._tokenExpirationDate));

    if (loadedUser.token) {
      this.user.next(loadedUser);
    }
}

Adding Auto-Logout

private tokenExpirationTimer: any;

logout() {
  this.user.next(null);
  this.router.navigate(['/auth']);
  localStorage.removeItem('userData');
  if (this.tokenExpirationTimer) {
    clearTimeout(this.tokenExpirationTimer);
  }
  this.tokenExpirationTimer = null;
}

autoLogout(expirationDuration: number) {
  this.tokenExpirationTimer = setTimeout(() => {
    this.logout();
  }, expirationDuration);
}
// in handleAuthentication method
this.user.next(user);
this.autoLogout(expiresIn * 1000);
// in autoLogin method
if (loadedUser.token) {
  this.user.next(loadedUser);
  const expirationDuration =
    new Date(userData._tokenExpirationDate).getTime() -
    new Date().getTime();
  this.autoLogout(expirationDuration);
}

Adding an Auth Guard

// ./src/app/auth/auth.guard.ts
import {
  CanActivate,
  ActivatedRouteSnapshot,
  RouterStateSnapshot
} from '@angular/router';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

import { AuthService } from './auth.service';

@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
  constructor(private authService: AuthService) {}

  canActivate(
    route: ActivatedRouteSnapshot,
    router: RouterStateSnapshot
  ): boolean | Promise<boolean> | Observable<boolean> {
    return this.authService.user.pipe(
      map(user => {
        return !!user;
      })
    );
  }
}
// ./src/app/app-routing.module
path: 'recipes',
    component: RecipesComponent,
    canActivate: [AuthGuard],
    children: [
    // ...
import {
  CanActivate,
  ActivatedRouteSnapshot,
  RouterStateSnapshot,
  Router,
  UrlTree
} from '@angular/router';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

import { AuthService } from './auth.service';

@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
  constructor(private authService: AuthService, private router: Router) {}

  canActivate(
    route: ActivatedRouteSnapshot,
    router: RouterStateSnapshot
  ):
    | boolean
    | UrlTree
    | Promise<boolean | UrlTree>
    | Observable<boolean | UrlTree> {
    return this.authService.user.pipe(
      take(1),
      map((user) => {
        const isAuth = !!user;
        if (isAuth) {
          return true;
        }
        return this.router.createUrlTree(['/auth']);
      })
    );
  }
}

Dynamic Components

What are Dynamic Components(?)

Adding an Alert Modal Component

// ./src/app/shared/alert/alert.component.ts
import { Component, Input } from '@angular/core';

@Component({
  selector: 'app-alert',
  templateUrl: './alert.component.html',
  styleUrls: ['./alert.component.css']
})

export class AlertComponent {
  @Input() message: string;
}
<!-- ./src/app/shared/alert/alert.component.html -->
<div class="backdrop"></div>
<div class="alert-box">
  <p>{{ message }}</p>
  <div class="alert-box-actions">
    <button class="btn btn-primary">Close</button>
  </div>
</div>
/* ./src/app/shared/alert/alert.component.css */
.backdrop {
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  background: rgba(0, 0, 0, 0.75);
  z-index: 50;
}

.alert-box {
  position: fixed;
  top: 30vh;
  left: 20vw;
  width: 60vw;
  padding: 16px;
  z-index: 100;
  background: white;
  box-shadow: 0 2px 8px rbga(0, 0, 0, 0.26);
}

.alert-box-actions {
  text-align: right;
}
<!-- ./src/app/auth/auth.component.html -->

<!-- <div class="alert alert-danger" *ngIf="error">
    <p>{{ error }}</p>
</div> -->
<app-alert [message]="error" *ngIf="error"></app-alert>

Understanding the Different Approaches

Using ngIf

<!-- ./src/app/auth/auth.component.html -->
<app-alert [message]="error" *ngIf="error" (close)="onHandleError()"></app-alert>
// ./src/app/auth/auth.component.ts
onHandleError() {
  this.error = null;
}
// ./src/app/shared/alert/alert.component.ts
@Input() message: string;
@Output() close = new EventEmitter<void>();

onClose() {
  this.close.emit();
}
<!-- ./src/app/shared/alert/alert.component.html -->
<div class="backdrop" (click)="onClose()"></div>
<div class="alert-box">
  <p>{{ message }}</p>
  <div class="alert-box-actions">
    <button class="btn btn-primary" (click)="onClose()">Close</button>
  </div>
</div>

Preparing Programmatic Creation

// ./src/app/auth/auth.component.ts
// on error in authObs:
this.error = errorMessage;
this.showErrorAlert(errorMessage);
this.isLoading = false;
// Use component factory resolver, inject into constructor
private showErrorAlert(message: string) {
  const alertCmpFactory = this.componentFactoryResolver.resolveComponentFactory(
    AlertComponent
  );

}
// ./src/app/shared/placeholder/placeholder.directive.ts
import { Directive, ViewContainerRef } from '@angular/core';

@Directive({
  selector: '[appPlaceholder]'
})
export class PlaceholderDirective {
  constructor(public viewContainerRef: ViewContainerRef) {}
}

Creating a Component Programmatically

<!-- ./src/app/auth/auth.component.html -->
<!-- Get access to this place in the DOM -->
<ng-template appPlaceholder></ng-template>
// ./src/app/auth/auth.component.ts
// Access directive with @ViewChild
@ViewChild(PlaceholderDirective, { static: false }) alertHost: PlaceholderDirective;
// ...
private showErrorAlert(message: string) {
  const alertCmpFactory = this.componentFactoryResolver.resolveComponentFactory(
    AlertComponent
  );
  const hostViewContainerRef = this.alertHost.viewContainerRef;
  hostViewContainerRef.clear();

  hostViewContainerRef.createComponent(alertCmpFactory);
}
// ERROR: No component factory found for AlertComponent. Did you add it to @NgMOdule.entryComponents?

Understanding entryComponents

// ./src/app/app.module.ts
// in NgModule declaration
bootstrap: [AppComponent],
entryComponents: [
  AlertComponent
]

Data Binding and Event Binding

// ./src/app/auth/auth.component.ts
private showErrorAlert(message: string) {
  const alertCmpFactory = this.componentFactoryResolver.resolveComponentFactory(
    AlertComponent
  );
  const hostViewContainerRef = this.alertHost.viewContainerRef;
  hostViewContainerRef.clear();
  const componentRef = hostViewContainerRef.createComponent(alertCmpFactory);
  componentRef.instance.message = message;
  // Must create closeSub: Subscription
  this.closeSub = componentRef.instance.close.subscribe(() => {
    this.closeSub.unsubscribe();
    hostViewContainerRef.clear();
  });
}

Angular Modules & Optimizing Angular Apps

What are Modules

Analyzing the AppModule

Getting Started with Feature Modules

Initial Recipes Module

// ./src/app/recipes/recipes.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { ReactiveFormsModule } from '@angular/forms';

import { RecipesComponent } from './recipes.component';
import { RecipeListComponent } from './recipe-list/recipe-list.component';
import { RecipeDetailComponent } from './recipe-detail/recipe-detail.component';
import { RecipeItemComponent } from './recipe-list/recipe-item/recipe-item.component';
import { RecipeStartComponent } from './recipe-start/recipe-start.component';
import { RecipeEditComponent } from './recipe-edit/recipe-edit.component';

@NgModule({
  declarations: [
    RecipesComponent,
    RecipeListComponent,
    RecipeDetailComponent,
    RecipeItemComponent,
    RecipeStartComponent,
    RecipeEditComponent,
  ],
  exports: [
    RecipesComponent,
    RecipeListComponent,
    RecipeDetailComponent,
    RecipeItemComponent,
    RecipeStartComponent,
    RecipeEditComponent,
  ]
})
export class RecipesModule {}

Splitting Modules Correctly

Recipes Module

// src/app/recipes/recipes.module.ts

// imports

@NgModule({
  // ...
  imports: [RouterModule, CommonModule, ReactiveFormsModule],
  // ...
})
export class RecipesModule {}

Adding Routes to Feature Modules

RecipesRoutingModule

// ./src/app/recipes/recipes.module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

import { RecipesComponent } from './recipes.component';
import { AuthGuard } from '../auth/auth.guard';
import { RecipeStartComponent } from './recipe-start/recipe-start.component';
import { RecipeEditComponent } from './recipe-edit/recipe-edit.component';
import { RecipeDetailComponent } from './recipe-detail/recipe-detail.component';
import { RecipesResolverService } from './recipes-resolver.service';

const routes: Routes = [
  {
    path: 'recipes',
    component: RecipesComponent,
    canActivate: [AuthGuard],
    children: [
      { path: '', component: RecipeStartComponent},
      { path: 'new', component: RecipeEditComponent },
      {
        path: ':id',
        component: RecipeDetailComponent,
        resolve: [RecipesResolverService]
      },
      {
        path: ':id/edit',
        component: RecipeEditComponent,
        resolve: [RecipesResolverService]
      }
    ]
  }
];

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule]
})
export class RecipesRoutingModule {}

Component Declarations

Shopping List Feature Module

// src/app/shopping-list/shopping-list.module.ts
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';

import { ShoppingListComponent } from './shopping-list.component';
import { ShoppingEditComponent } from './shopping-edit/shopping-edit.component';

@NgModule({
  declarations: [ShoppingListComponent, ShoppingEditComponent,],
  imports: [
    CommonModule,
    FormsModule,
    RouterModule.forChild([
      { path: 'shopping-list', component: ShoppingListComponent }
    ])
  ]
})
export class ShoppingListModule {}

Understanding Shared Modules

// src/app/shared/shared.module.ts
import { NgModule } from '@angular/core';

import { AlertComponent } from './alert/alert.component';
import { LoadingSpinnerComponent } from './loading-spinner/loading-spinner.component';
import { PlaceholderDirective } from './placeholder/placeholder.directive';
import { DropdownDirective } from './dropdown.directive';
import { CommonModule } from '@angular/common';

@NgModule({
  declarations: [
    AlertComponent,
    LoadingSpinnerComponent,
    PlaceholderDirective,
    DropdownDirective
  ],
  imports: [
    CommonModule
  ],
  exports: [
    AlertComponent,
    LoadingSpinnerComponent,
    PlaceholderDirective,
    DropdownDirective,
    CommonModule
  ],
  entryComponents: [
    AlertComponent
  ]
})
export class SharedModule {}
// import into shopping list module instead of common module

Understanding the Core Module

// src/app/core.module.ts
import { NgModule } from '@angular/core';
import { HTTP_INTERCEPTORS } from '@angular/common/http';

import { ShoppingListService } from './shopping-list/shopping-list.service';
import { RecipeService } from './recipes/recipe.service';
import { AuthInterceptorService } from './auth/auth-interceptor.service';

@NgModule({
  providers: [
    ShoppingListService,
    RecipeService,
    {
      provide: HTTP_INTERCEPTORS,
      useClass: AuthInterceptorService,
      multi: true
    }
  ]
})
export class CoreModule {}

Adding an Auth Feature Module

// src/app/auth/auth.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';

import { AuthComponent } from './auth.component';
import { RouterModule } from '@angular/router';
import { SharedModule } from '../shared/shared.module';

@NgModule({
  declarations: [AuthComponent],
  imports: [
    CommonModule,
    FormsModule,
    RouterModule.forChild([
      { path: 'auth', component: AuthComponent }
    ]),
    SharedModule
  ]
})
export class AuthModule {}

Understanding Lazy Loading

Implementing Lazy Loading

More Lazy Loading

// src/app/app-routing.module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

const appRoutes: Routes = [
  { path: '', redirectTo: '/recipes', pathMatch: 'full' },
  {
    path: 'recipes',
    loadChildren: () =>
      import('./recipes/recipes.module').then(m => m.RecipesModule)
  },
  {
    path: 'shopping-list',
    loadChildren: () =>
      import('./shopping-list/shopping-list.module').then(m => m.ShoppingListModule)
  },
  {
    path: 'auth',
    loadChildren: () =>
      import('./auth/auth.module').then(m => m.AuthModule)
  }
];

@NgModule({
  imports: [RouterModule.forRoot(appRoutes)],
  exports: [RouterModule]
})
export class AppRoutingModule {}

Preloading Lazy-Loaded Code

Modules and Services

Loading Services Differently

// Service loading demo
// src/app/logging.service.ts
import { Injectable } from '@angular/core';

// @Injectable({ providedIn: 'root' })
export class LoggingService {
  lastLog: string;

  printLog(message: string) {
    console.log(message);
    console.log(this.lastLog);
    this.lastLog = message;
  }
}

Ahead-of-Time Compilation

Deploying an Angular App

Preparation

Using Environment Variables

src/environments

Deployment Example: Firebase Hosting

Server Routing vs Browser Routing

Bonus: Working with NgRx in our Project

What is Application State

What is NgRx

Getting Started with Reducers

// src/app/shopping-list/shopping-list.reducer.ts
import { Ingredient } from '../shared/ingredient.model';

const initialState = {
  ingredients: [
    new Ingredient('Apples', 5),
    new Ingredient('Tomatoes', 10),
]
};

export function shoppingListReducer(state = initialState, action) {}

Adding Logic to the Reducer

import { Ingredient } from '../shared/ingredient.model';
import { Action } from '@ngrx/store';

const initialState = {
  ingredients: [
    new Ingredient('Apples', 5),
    new Ingredient('Tomatoes', 10),
]
};

export function shoppingListReducer(state = initialState, action: Action) {
  switch (action.type) {
    case 'ADD_INGREDIENT':
      return {
        ...state,     // good practice to always copy over old state
        ingredients: [...state.ingredients, action]
      };
  }
}

Understanding and Adding Actions

// src/app/shopping-list/store/shopping-list.actions.ts
import { Action } from '@ngrx/store';
import { Ingredient } from 'src/app/shared/ingredient.model';

export const ADD_INGREDIENT = 'ADD_INGREDIENT';

export class AddIngredient implements Action {
  readonly type = ADD_INGREDIENT;
  payload: Ingredient;
  // "payload" not a required name, can use any. only "type" property is required.
}

Setting up the NgRx Store

// src/app/shopping-list/store/shopping-list.reducer.ts
import { Ingredient } from '../../shared/ingredient.model';
import * as ShoppingListActions from './shopping-list.actions';

const initialState = {
  ingredients: [
    new Ingredient('Apples', 5),
    new Ingredient('Tomatoes', 10),
]
};

export function shoppingListReducer(state = initialState, action: ShoppingListActions.AddIngredient) {
  switch (action.type) {
    case ShoppingListActions.ADD_INGREDIENT:
      return {
        ...state,     // good practice to always copy over old state
        ingredients: [...state.ingredients, action.payload]
      };
  }
}

Selecting State

import { Ingredient } from '../../shared/ingredient.model';
import * as ShoppingListActions from './shopping-list.actions';

const initialState = {
  ingredients: [new Ingredient('Apples', 5), new Ingredient('Tomatoes', 10)]
};

export function shoppingListReducer(
  state = initialState,
  action: ShoppingListActions.AddIngredient
) {
  switch (action.type) {
    case ShoppingListActions.ADD_INGREDIENT:
      return {
        ...state, // good practice to always copy over old state
        ingredients: [...state.ingredients, action.payload]
      };
    default:
      return state;
  }
}

Dispatching Actions

Multiple Actions

// src/app/shopping-list/store/shopping-list.actions.ts
export const ADD_INGREDIENTS = 'ADD_INGREDIENTS';

export class AddIngredients implements Action {
  readonly type = ADD_INGREDIENTS;
  constructor(public payload: Ingredient[]) {}
}

// create union of diff action types
export type ShoppingListActions = AddIngredient | AddIngredients;
// src/app/recipes/recipe.service.ts
addIngredientsToShoppingList(ingredients: Ingredient[]) {
  // this.sLService.addIngredients(ingredients);
  this.store.dispatch(new ShoppingListActions.AddIngredients(ingredients));
}

Preparing Update & Delete Actions

// src/app/shopping-list/store/shopping-list.actions.ts
export const UPDATE_INGREDIENT = 'UPDATE_INGREDIENT';
export const DELETE_INGREDIENT = 'DELETE_INGREDIENT';

export class UpdateIngredient implements Action {
  readonly type = UPDATE_INGREDIENT;
  constructor(public payload: { index: number; ingredient: Ingredient }) {}
}

export class DeleteIngredient implements Action {
  readonly type = DELETE_INGREDIENT;
  constructor(public payload: number) {}
}

export type ShoppingListActions =
  | AddIngredient
  | AddIngredients
  | UpdateIngredient
  | DeleteIngredient;

Updating & Deleting Ingredients

// src/app/shopping-list/store/shopping-list.reducer.ts
case ShoppingListActions.UPDATE_INGREDIENT:
  const ingredient = state.ingredients[action.payload.index];
  const updatedIngredient = {
    ...ingredient,
    ...action.payload.ingredient
  };
  const updatedIngredients = [...state.ingredients];
  updatedIngredients[action.payload.index] = updatedIngredient;

  return {
    ...state,
    ingredients: [updatedIngredients]
  };
// src/app/shopping-list/store/shopping-list.reducer.ts
case ShoppingListActions.DELETE_INGREDIENT:
  return {
    ...state,
    ingredients: state.ingredients.filter((ig, igIndex) => {
      return igIndex !== action.payload;
    })
  };

Expanding the State

// src/app/shopping-list/store/shopping-list.reducer.ts
const initialState = {
  ingredients: [new Ingredient('Apples', 5), new Ingredient('Tomatoes', 10)],
  editedIngredient: null,
  editedIngredientIndex: -1
};
// src/app/shopping-list/store/shopping-list.reducer.ts
export interface State {
  ingredients: Ingredient[];
  editedIngredient: Ingredient;
  editedIngredientIndex: number;
}
export interface AppState {
  shoppingList: State;
}

Managing More State via NgRx

// src/app/shopping-list/store/shopping-list.actions.ts
export const START_EDIT = 'START_EDIT';
export const STOP_EDIT = 'STOP_EDIT';
// ...
export class StartEdit implements Action {
  readonly type = START_EDIT;

  constructor(public payload: number) {}
}

export class StopEdit implements Action {
  readonly type = STOP_EDIT;
}
// src/app/shopping-list/store/shopping-list.reducer.ts
case ShoppingListActions.START_EDIT:
  return {
    ...state,
    editedIngredientIndex: action.payload,
    editedIngredient: { ...state.ingredients[action.payload] }
  };

case ShoppingListActions.STOP_EDIT:
  return {
    ...state,
    editedIngredient: null,
    editedIngredientIndex: -1
  };
// src/app/shopping-list/shopping-list.component.ts
onEditItem(index: number) {
  // this.slService.startedEditing.next(index);
  this.store.dispatch(new ShoppingListActions.StartEdit(index));
}
// src/app/shopping-list/shopping-edit/shopping-edit.component.ts
constructor(
  private slService: ShoppingListService,
  private store: Store<fromShoppingList.AppState>
) {}

ngOnInit() {
  this.subscription = this.store.select('shoppingList').subscribe(stateData => {
    if (stateData.editedIngredientIndex > -1) {
      this.editMode = true;
      this.editedItem = stateData.editedIngredient;
      this.slForm.setValue({
        name: this.editedItem.name,
        amount: this.editedItem.amount
      });
    } else {
      this.editMode = false;
    }
  });
}

Removing Redundant Component State Management

// src/app/shopping-list/store/shopping-list.actions.ts
import { Action } from '@ngrx/store';
import { Ingredient } from 'src/app/shared/ingredient.model';

export const ADD_INGREDIENT = 'ADD_INGREDIENT';
export const ADD_INGREDIENTS = 'ADD_INGREDIENTS';
export const UPDATE_INGREDIENT = 'UPDATE_INGREDIENT';
export const DELETE_INGREDIENT = 'DELETE_INGREDIENT';
export const START_EDIT = 'START_EDIT';
export const STOP_EDIT = 'STOP_EDIT';

export class AddIngredient implements Action {
  readonly type = ADD_INGREDIENT;
  // payload: Ingredient;
  // "payload" not a required name, can use any. only "type" property is required.
  constructor(public payload: Ingredient) {}
}

export class AddIngredients implements Action {
  readonly type = ADD_INGREDIENTS;
  constructor(public payload: Ingredient[]) {}
}

export class UpdateIngredient implements Action {
  readonly type = UPDATE_INGREDIENT;
  constructor(public payload: Ingredient) {}
}

export class DeleteIngredient implements Action {
  readonly type = DELETE_INGREDIENT;
}

export class StartEdit implements Action {
  readonly type = START_EDIT;

  constructor(public payload: number) {}
}

export class StopEdit implements Action {
  readonly type = STOP_EDIT;
}

export type ShoppingListActions =
  | AddIngredient
  | AddIngredients
  | UpdateIngredient
  | DeleteIngredient
  | StartEdit
  | StopEdit;

First Summary & Clean Up

One Root State

// src/app/auth/store/auth.reducer.ts
import { User } from '../user.model';

export interface State {
  user: User;
}

const initialState: State = {
  user: null
};

export function authReducer(state = initialState, action) {
  return state;
}
// src/app/store/app.reducer.ts
import { ActionReducerMap } from '@ngrx/store';

import * as fromShoppingList from '../shopping-list/store/shopping-list.reducer';
import * as fromAuth from '../auth/store/auth.reducer';

export interface AppState {
  shoppingList: fromShoppingList.State;
  auth: fromAuth.State;
}

export const appReducer: ActionReducerMap<AppState> = {
  shoppingList: fromShoppingList.shoppingListReducer,
  auth: fromAuth.authReducer
};
// src/app/app.module.ts
import * as fromApp from './store/app.reducer';
// ...
@NgModule({
  // ...
  imports: [
    // ...
    StoreModule.forRoot(fromApp.appReducer),
    // ...
  ]
  // ...
})

Setting up Auth Reducer & Actions

// src/app/auth/store/auth.actions.ts
import { Action } from '@ngrx/store';

export const LOGIN = 'LOGIN';
export const LOGOUT = 'LOGOUT';

export class Login implements Action {
  readonly type = LOGIN;

  constructor(
    public payload: {
      email: string;
      userId: string;
      token: string;
      expirationDate: Date;
    }
  ) {}
}

export class Logout implements Action {
  readonly type = LOGOUT;
}

export type AuthActions = Login | Logout;
// src/app/auth/store/auth.reducer.ts
import { User } from '../user.model';
import * as AuthActions from './auth.actions';

export interface State {
  user: User;
}

const initialState: State = {
  user: null
};

export function authReducer(
  state = initialState,
  action: AuthActions.AuthActions
) {
  switch (action.type) {
    case AuthActions.LOGIN:
      const user = new User(
        action.payload.email,
        action.payload.userId,
        action.payload.token,
        action.payload.expirationDate
      );
      return {
        ...state,
        user
      };
    case AuthActions.LOGOUT:
      return {
        ...state,
        user: null
      };
    default:
      return state;
  }
}

Dispatching Auth Actions

// src/app/auth/auth.service.ts
  // in autoLogin:

  // this.user.next(loadedUser);
  this.store.dispatch(
    new AuthActions.Login({
      email: loadedUser.email,
      userId: loadedUser.id,
      token: loadedUser.token,
      expirationDate: new Date(userData._tokenExpirationDate)
    })
  );

  // in logout:

  // this.user.next(null);
  this.store.dispatch(new AuthActions.Logout());

  // in handleAuthentication:

  // this.user.next(user);
  this.store.dispatch(new AuthActions.Login({email, userId, token, expirationDate}));

Auth Finished (for now...)

// src/app/auth/auth-interceptor.service.ts
return this.store.select('auth').pipe(
  take(1),
  map(authState => {
    return authState.user;
  }),
  exhaustMap(user => {
    // ...
// src/app/auth/auth.guard.ts
return this.store.select('auth').pipe(
  take(1),
  map(authState => {
    return authState.user;
  }),
  map(user => {
    // ...
// src/app/header/header.component.ts
ngOnInit() {
  this.userSub = this.store
    .select('auth')
    .pipe(map(authState => authState.user))
    .subscribe(user => {
      this.isAuthenticated = !!user;
    });
}

An Important Note on Actions

Exploring NgRx Side Effects

Defining the First Effect

// src/app/auth/store/auth.effects.ts
import { Actions, ofType } from '@ngrx/effects';

import * as AuthActions from './auth.actions';

export class AuthEffects {
  authLogin = this.actions$.pipe(
    ofType(AuthActions.LOGIN_START)
  );

  constructor(private actions$: Actions) {}
}

Effects & Error-Handling

// src/app/auth/store/auth.actions.ts

// ...
export class LoginStart implements Action {
  readonly type = LOGIN_START;

  constructor(public payload: { email: string, password: string }) {}
}
// src/app/auth/store/auth.effects.ts
import { Actions, ofType, Effect } from '@ngrx/effects';
import { switchMap, catchError, map } from 'rxjs/operators';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../../environments/environment';
import { of } from 'rxjs';

import * as AuthActions from './auth.actions';

export interface AuthResponseData {
  kind: string;
  idToken: string;
  email: string;
  refreshToken: string;
  expiresIn: string;
  localId: string;
  registered?: boolean;
}

export class AuthEffects {
  @Effect()
  authLogin = this.actions$.pipe(
    ofType(AuthActions.LOGIN_START),
    switchMap((authData: AuthActions.LoginStart) => {
      return this.http
      .post<AuthResponseData>(
        'https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=' +
          environment.firebaseAPIKey,
        {
          email: authData.payload.email,
          password: authData.payload.password,
          returnSecureToken: true
        }
      ).pipe(catchError(error => {
        // ...
        of();
      }), map(resData => {
        of();
      })
      );
    }),
  );

  constructor(private actions$: Actions, private http: HttpClient) {}
}

Login via NgRx Effects

Managing UI State in NgRx

// src/app/auth/store/auth.actions.ts
export const LOGIN_FAIL = '[Auth] Login Fail';
// ...
export class LoginFail implements Action {
  readonly type = LOGIN_FAIL;

  constructor(public payload: string) {}
}
// src/app/auth/store/auth.reducer.ts
export interface State {
  user: User;
  authError: string;
  loading: boolean;
}

const initialState: State = {
  user: null,
  authError: null,
  loading: false;
};

export function authReducer(
  state = initialState,
  action: AuthActions.AuthActions
) {
  switch (action.type) {
    case AuthActions.LOGIN:
      const user = new User(
        action.payload.email,
        action.payload.userId,
        action.payload.token,
        action.payload.expirationDate
      );
      return {
        ...state,
        authError: null,
        user,
        loading: false
      };
    case AuthActions.LOGOUT:
      return {
        ...state,
        user: null
      };
    case AuthActions.LOGIN_START:
      return {
        ...state,
        authError: null,
        loading: true
      };
    case AuthActions.LOGIN_FAIL:
      return {
        ...state,
        user: null,
        authError: action.payload,
        loading: false
      };
    default:
      return state;
  }
}
ngOnInit() {
  this.store.select('auth').subscribe(authState => {
    this.isLoading = authState.loading;
    this.error = authState.authError;
  });
}

Finishing the Login Effect

// src/app/auth/store/auth.effects.ts

// fix map in authLogin
map(resData => {
  const expirationDate = new Date(
    new Date().getTime() + +resData.expiresIn * 1000
  );
  return new AuthActions.Login({
      email: resData.email,
      userId: resData.localId,
      token: resData.idToken,
      expirationDate
    }
  );
}),
// ...
@Effect({dispatch: false})
// ^ let angular know this effect will not yield dispatchable action
authSuccess = this.actions$.pipe(
  ofType(AuthActions.LOGIN),
  tap(() => {
    this.router.navigate(['/']);
  })
);
catchError(errorRes => {
  let errorMessage = 'An unknown error occurred!';
  if (!errorRes.error || !errorRes.error.error) {
    // return throwError(errorMessage);
    return of(new AuthActions.LoginFail(errorMessage));
  }
  switch (errorRes.error.error.message) {
    case 'EMAIL_EXISTS':
      errorMessage = 'This email exists already.';
      break;
    case 'EMAIL_NOT_FOUND':
      errorMessage = 'This email does not exist';
      break;
    case 'INVALID_PASSWORD':
      errorMessage = 'This password is not correct';
      break;
  }
  return of(new AuthActions.LoginFail(errorMessage));
})

Preparing Other Auth Actions

// src/app/auth/store/auth.actions.ts
export class SignupStart implements Action {
  readonly type = SIGNUP_START;

  constructor(public payload: { email: string; password: string }) {}
}

export type AuthActions =
  | AuthenticateSuccess
  | Logout
  | LoginStart
  | AuthenticateFail
  | SignupStart;

// src/app/auth/store/auth.effects.ts
@Effect()
authSignup = this.actions$.pipe(
  ofType(AuthActions.SIGNUP_START)
);

Adding Signup

// src/app/auth/store/auth.effects.ts
const handleAuthentication = (
  expiresIn: number,
  email: string,
  userId: string,
  token: string
) => {
  const expirationDate = new Date(new Date().getTime() + expiresIn * 1000);
  return new AuthActions.AuthenticateSuccess({
    email,
    userId,
    token,
    expirationDate
  });
};

const handleError = (errorRes: any) => {
  let errorMessage = 'An unknown error occurred!';
  if (!errorRes.error || !errorRes.error.error) {
    // return throwError(errorMessage);
    return of(new AuthActions.AuthenticateFail(errorMessage));
  }
  switch (errorRes.error.error.message) {
    case 'EMAIL_EXISTS':
      errorMessage = 'This email exists already.';
      break;
    case 'EMAIL_NOT_FOUND':
      errorMessage = 'This email does not exist';
      break;
    case 'INVALID_PASSWORD':
      errorMessage = 'This password is not correct';
      break;
  }
  return of(new AuthActions.AuthenticateFail(errorMessage));
};

@Injectable()
export class AuthEffects {
  @Effect()
  authSignup = this.actions$.pipe(
    ofType(AuthActions.SIGNUP_START),
    switchMap((signupAction: AuthActions.SignupStart) => {
      return this.http
        .post<AuthResponseData>(
          'https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=' +
            environment.firebaseAPIKey,
          {
            email: signupAction.payload.email,
            password: signupAction.payload.password,
            returnSecureToken: true
          }
        )
        .pipe(
          map(resData => {
            return handleAuthentication(
              +resData.expiresIn,
              resData.email,
              resData.localId,
              resData.idToken
            );
          }),
          catchError(errorRes => {
            return handleError(errorRes);
          })
        );
    })
  );

// ...
}

Further Auth Effects

// src/app/auth/auth.component.ts
onHandleError() {
  this.store.dispatch(new AuthActions.ClearError());
}

ngOnDestroy() {
  if (this.closeSub) {
    this.closeSub.unsubscribe();
  }

  if (this.storeSub){
    this.storeSub.unsubscribe();
  }
}

Adding Auto-Login with NgRx

@Effect()
autoLogin = this.actions$.pipe(
  ofType(AuthActions.AUTO_LOGIN),
  map(() => {
    const userData: {
      email: string;
      id: string;
      _token: string;
      _tokenExpirationDate: string;
    } = JSON.parse(localStorage.getItem('userData'));
    if (!userData) {
      return { type: 'DUMMY' };
    }

    const loadedUser = new User(
      userData.email,
      userData.id,
      userData._token,
      new Date(userData._tokenExpirationDate)
    );
    if (loadedUser.token) {
      // this.user.next(loadedUser);
      return new AuthActions.AuthenticateSuccess({
          email: loadedUser.email,
          userId: loadedUser.id,
          token: loadedUser.token,
          expirationDate: new Date(userData._tokenExpirationDate)
      });
    }
    return { type: 'DUMMY' };
    // const expirationDuration =
    //   new Date(userData._tokenExpirationDate).getTime() -
    //   new Date().getTime();
    // this.autoLogout(expirationDuration);
  })
);

Adding Auto-Logout (NgRx)

// src/app/auth/auth.service.ts
setLogoutTimer(expirationDuration: number) {
  console.log(expirationDuration);
  this.tokenExpirationTimer = setTimeout(() => {
    this.store.dispatch(new AuthActions.Logout());
  }, expirationDuration);
}

clearLogoutTimer() {
  if (this.tokenExpirationTimer) {
    clearTimeout(this.tokenExpirationTimer);
    this.tokenExpirationTimer = null;
  }
}

Finishing Auth Effects

// src/app/auth/auth.service.ts
import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';

import * as fromApp from '../store/app.reducer';
import * as AuthActions from '../auth/store/auth.actions';

@Injectable({ providedIn: 'root' })
export class AuthService {
  private tokenExpirationTimer: any;

  constructor(
    private store: Store<fromApp.AppState>
  ) {}

  setLogoutTimer(expirationDuration: number) {
    console.log(expirationDuration);
    this.tokenExpirationTimer = setTimeout(() => {
      this.store.dispatch(new AuthActions.Logout());
    }, expirationDuration);
  }

  clearLogoutTimer() {
    if (this.tokenExpirationTimer) {
      clearTimeout(this.tokenExpirationTimer);
      this.tokenExpirationTimer = null;
    }
  }
}
// src/app/auth/store/auth.actions.ts
import { Action } from '@ngrx/store';

export const LOGIN_START = '[Auth] Login Start';
export const AUTHENTICATE_SUCCESS = '[Auth] Login';
export const AUTHENTICATE_FAIL = '[Auth] Login Fail';
export const SIGNUP_START = '[Auth] Signup Start';
export const CLEAR_ERROR = '[Auth] Clear Error';
export const AUTO_LOGIN = '[Auth] Auto Login';
export const LOGOUT = '[Auth] Logout';

export class AuthenticateSuccess implements Action {
  readonly type = AUTHENTICATE_SUCCESS;

  constructor(
    public payload: {
      email: string;
      userId: string;
      token: string;
      expirationDate: Date;
    }
  ) {}
}

export class Logout implements Action {
  readonly type = LOGOUT;
}

export class LoginStart implements Action {
  readonly type = LOGIN_START;

  constructor(public payload: { email: string; password: string }) {}
}

export class AuthenticateFail implements Action {
  readonly type = AUTHENTICATE_FAIL;

  constructor(public payload: string) {}
}

export class SignupStart implements Action {
  readonly type = SIGNUP_START;

  constructor(public payload: { email: string; password: string }) {}
}

export class ClearError implements Action {
  readonly type = CLEAR_ERROR;
}

export class AutoLogin implements Action {
  readonly type = AUTO_LOGIN;
}

export type AuthActions =
  | AuthenticateSuccess
  | Logout
  | LoginStart
  | AuthenticateFail
  | SignupStart
  | ClearError
  | AutoLogin;
// src/app/auth/store/auth.effects.ts
import { Injectable } from '@angular/core';
import { Actions, ofType, Effect } from '@ngrx/effects';
import { switchMap, catchError, map, tap } from 'rxjs/operators';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../../environments/environment';
import { of } from 'rxjs';
import { Router } from '@angular/router';

import * as AuthActions from './auth.actions';
import { User } from '../user.model';
import { AuthService } from '../auth.service';

export interface AuthResponseData {
  kind: string;
  idToken: string;
  email: string;
  refreshToken: string;
  expiresIn: string;
  localId: string;
  registered?: boolean;
}

const handleAuthentication = (
  expiresIn: number,
  email: string,
  userId: string,
  token: string
) => {
  const expirationDate = new Date(new Date().getTime() + expiresIn * 1000);
  const user = new User(email, userId, token, expirationDate);
  localStorage.setItem('userData', JSON.stringify(user));
  return new AuthActions.AuthenticateSuccess({
    email,
    userId,
    token,
    expirationDate
  });
};

const handleError = (errorRes: any) => {
  let errorMessage = 'An unknown error occurred!';
  if (!errorRes.error || !errorRes.error.error) {
    // return throwError(errorMessage);
    return of(new AuthActions.AuthenticateFail(errorMessage));
  }
  switch (errorRes.error.error.message) {
    case 'EMAIL_EXISTS':
      errorMessage = 'This email exists already.';
      break;
    case 'EMAIL_NOT_FOUND':
      errorMessage = 'This email does not exist';
      break;
    case 'INVALID_PASSWORD':
      errorMessage = 'This password is not correct';
      break;
  }
  return of(new AuthActions.AuthenticateFail(errorMessage));
};

@Injectable()
export class AuthEffects {
  @Effect()
  authSignup = this.actions$.pipe(
    ofType(AuthActions.SIGNUP_START),
    switchMap((signupAction: AuthActions.SignupStart) => {
      return this.http
        .post<AuthResponseData>(
          'https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=' +
            environment.firebaseAPIKey,
          {
            email: signupAction.payload.email,
            password: signupAction.payload.password,
            returnSecureToken: true
          }
        )
        .pipe(
          tap(resData => {
            this.authService.setLogoutTimer(+resData.expiresIn * 1000);
          }),
          map(resData => {
            return handleAuthentication(
              +resData.expiresIn,
              resData.email,
              resData.localId,
              resData.idToken
            );
          }),
          catchError(errorRes => {
            return handleError(errorRes);
          })
        );
    })
  );

  @Effect()
  authLogin = this.actions$.pipe(
    ofType(AuthActions.LOGIN_START),
    switchMap((authData: AuthActions.LoginStart) => {
      return this.http
        .post<AuthResponseData>(
          'https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=' +
            environment.firebaseAPIKey,
          {
            email: authData.payload.email,
            password: authData.payload.password,
            returnSecureToken: true
          }
        )
        .pipe(
          tap(resData => {
            this.authService.setLogoutTimer(+resData.expiresIn * 1000);
          }),
          map(resData => {
            return handleAuthentication(
              +resData.expiresIn,
              resData.email,
              resData.localId,
              resData.idToken
            );
          }),
          catchError(errorRes => {
            return handleError(errorRes);
          })
        );
    })
  );

  @Effect({ dispatch: false })
  // ^ let angular know this effect will not yield dispatchable action
  authRedirect = this.actions$.pipe(
    ofType(AuthActions.AUTHENTICATE_SUCCESS),
    tap(() => {
      this.router.navigate(['/']);
    })
  );

  @Effect()
  autoLogin = this.actions$.pipe(
    ofType(AuthActions.AUTO_LOGIN),
    map(() => {
      const userData: {
        email: string;
        id: string;
        _token: string;
        _tokenExpirationDate: string;
      } = JSON.parse(localStorage.getItem('userData'));
      if (!userData) {
        return { type: 'DUMMY' };
      }

      const loadedUser = new User(
        userData.email,
        userData.id,
        userData._token,
        new Date(userData._tokenExpirationDate)
      );
      if (loadedUser.token) {
        // this.user.next(loadedUser);
        const expirationDuration =
          new Date(userData._tokenExpirationDate).getTime() -
          new Date().getTime();
        this.authService.setLogoutTimer(expirationDuration);
        return new AuthActions.AuthenticateSuccess({
            email: loadedUser.email,
            userId: loadedUser.id,
            token: loadedUser.token,
            expirationDate: new Date(userData._tokenExpirationDate)
        });
      }
      return { type: 'DUMMY' };
      // const expirationDuration =
      //   new Date(userData._tokenExpirationDate).getTime() -
      //   new Date().getTime();
      // this.autoLogout(expirationDuration);
    })
  );

  @Effect({ dispatch: false })
  authLogout = this.actions$.pipe(
    ofType(AuthActions.LOGOUT),
    tap(() => {
      this.authService.clearLogoutTimer();
      localStorage.removeItem('userData');
      this.router.navigate(['/auth']);
    })
  );

  constructor(
    private actions$: Actions,
    private http: HttpClient,
    private router: Router,
    private authService: AuthService
  ) {}
}
// src/app/auth/store/auth.reducer.ts

import { User } from '../user.model';
import * as AuthActions from './auth.actions';

export interface State {
  user: User;
  authError: string;
  loading: boolean;
}

const initialState: State = {
  user: null,
  authError: null,
  loading: false
};

export function authReducer(
  state = initialState,
  action: AuthActions.AuthActions
) {
  switch (action.type) {
    case AuthActions.AUTHENTICATE_SUCCESS:
      const user = new User(
        action.payload.email,
        action.payload.userId,
        action.payload.token,
        action.payload.expirationDate
      );
      return {
        ...state,
        authError: null,
        user,
        loading: false
      };
    case AuthActions.LOGOUT:
      return {
        ...state,
        user: null
      };
    case AuthActions.LOGIN_START:
    case AuthActions.SIGNUP_START:
      return {
        ...state,
        authError: null,
        loading: true
      };
    case AuthActions.AUTHENTICATE_FAIL:
      return {
        ...state,
        user: null,
        authError: action.payload,
        loading: false
      };
    case AuthActions.CLEAR_ERROR:
      return {
        ...state,
        authError: null
      }
    default:
      return state;
  }
}

Using the Store Devtools

import { StoreDevtoolsModule } from '@ngrx/store-devtools';
// ...
StoreDevtoolsModule.instrument({ logOnly: environment.production }),

The Router Store

npm i --save @ngrx/router-store

import { StoreRouterConnectingModule } from '@ngrx/router-store';
// ...
StoreRouterConnectingModule.forRoot(),

Getting Started with NgRx for Recipes

import { Recipe } from '../recipe.model';
import * as RecipeActions from './recipe.actions';

export interface State {
  recipes: Recipe[];
}

const initialState: State = {
  recipes: []
};

export function recipeReducer(
  state = initialState,
  action: RecipeActions.RecipesActions
) {
  switch (action.type) {
    case RecipeActions.SET_RECIPES:
      return {
        ...state,
        recipes: [...action.payload]
      };
    default:
      return state;
  }
}
import { Action } from '@ngrx/store';

import { Recipe } from '../recipe.model';

export const SET_RECIPES = '[Recipes] Set Recipes';

export class SetRecipes implements Action {
  readonly type = SET_RECIPES;

  constructor(public payload: Recipe[]) {}
}

export type RecipesActions = SetRecipes;
// src/app/recipes/recipe-list/recipe-list.component.ts
ngOnInit() {
  this.subscription = this.store
    .select('recipes')
    .pipe(map(recipesState => recipesState.recipes))
    .subscribe((recipes: Recipe[]) => {
      this.recipes = recipes;
    });
}

// src/app/shared/data-storage.service.ts
fetchRecipes() {
  // ...
      tap(recipes => {
        // this.recipeService.setRecipes(recipes);
        this.store.dispatch(new RecipesActions.SetRecipes(recipes));
      })
    );
}

Fetching Recipe Detail Data

// src/app/recipes/recipe-detail/recipe-detail.component.ts
ngOnInit() {
  this.route.params
    .pipe(
      map(params => {
        return +params['id'];
      }),
      switchMap(id => {
        this.id = id;
        return this.store.select('recipes');
      }),
      map(recipesState => {
        return recipesState.recipes.find((recipe, index) => {
          return index === this.id;
        });
      })
    )
    .subscribe(recipe => {
      this.recipe = recipe;
    });
}
// src/app/recipes/recipe-detail/recipe-detail.component.ts
// in initForm():
// const recipe = this.recipeService.getRecipe(this.id);
this.store
  .select('recipes')
  .pipe(
    map(recipeState => {
      return recipeState.recipes.find((recipe, index) => {
        return index === this.id;
      });
    })
  )
  .subscribe(recipe => {
    recipeName = recipe.name;
    recipeImagePath = recipe.imagePath;
    recipeDescription = recipe.description;
    if (recipe['ingredients']) {
      for (let ingredient of recipe.ingredients) {
        recipeIngredients.push(
          new FormGroup({
            name: new FormControl(ingredient.name, Validators.required),
            amount: new FormControl(ingredient.amount, [
              Validators.required,
              Validators.pattern(/^[1-9]+[0-9]*$/)
            ])
          })
        );
      }
    }
  });

Fetching Recipes Using the Resolver

// src/app/recipes/store/recipe.effects.ts
import { Actions, Effect, ofType } from '@ngrx/effects';
import { HttpClient } from '@angular/common/http';
import { switchMap, map } from 'rxjs/operators';

import * as RecipesActions from './recipe.actions';
import { Recipe } from '../recipe.model';
import { Injectable } from '@angular/core';

@Injectable()
export class RecipeEffects {
  @Effect()
  fetchRecipes = this.actions$.pipe(
    ofType(RecipesActions.FETCH_RECIPES),
    switchMap(fetchAction => {
      return this.http.get<Recipe[]>(
        'https://ng-learn-practice.firebaseio.com/recipes.json'
      );
    }),
    map(recipes => {
      return recipes.map(recipe => {
        return {
          ...recipe,
          ingredients: recipe.ingredients ? recipe.ingredients : []
        };
      });
    }),
    map(recipes => {
      return new RecipesActions.SetRecipes(recipes);
    }),
  );

  constructor(private actions$: Actions, private http: HttpClient) {}
}
// src/app/app.module.ts
import { RecipeEffects } from './recipes/store/recipe.effects';
// ...
@NgModule({
  // ...
  imports: [
    // ...
    EffectsModule.forRoot([AuthEffects, RecipeEffects]),
    // ...
  ],
  // ...
})
// src/app/recipes/store/recipe.actions.ts
export const FETCH_RECIPES = '[Recipes] Fetch Recipes';
// ...
export class FetchRecipes implements Action {
  readonly type = FETCH_RECIPES;
}
// src/app/header/header.component.ts
onFetchData() {
  // this.dataStorageService.fetchRecipes().subscribe();
  this.store.dispatch(new RecipesActions.FetchRecipes());
}
import { Injectable } from '@angular/core';
import {
  Resolve,
  ActivatedRouteSnapshot,
  RouterStateSnapshot
} from '@angular/router';
import { Store } from '@ngrx/store';
import { Actions, ofType } from '@ngrx/effects';
import { take } from 'rxjs/operators';

import { Recipe } from './recipe.model';
import * as fromApp from '../store/app.reducer';
import * as RecipesActions from '../recipes/store/recipe.actions';

@Injectable({
  providedIn: 'root'
})
export class RecipesResolverService implements Resolve<Recipe[]> {
  constructor(
    private store: Store<fromApp.AppState>,
    private actions$: Actions
  ) {}

  resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
    this.store.dispatch(new RecipesActions.FetchRecipes());
    return this.actions$.pipe(
      ofType(RecipesActions.SET_RECIPES),
      take(1)
    );
  }
}

Fixing the Auth Redirect

// src/app/auth/store/auth.actions.ts
export class AuthenticateSuccess implements Action {
  readonly type = AUTHENTICATE_SUCCESS;

  constructor(
    public payload: {
      email: string;
      userId: string;
      token: string;
      expirationDate: Date;
      redirect: boolean;
    }
  ) {}
}
@Effect({ dispatch: false })
// ^ let angular know this effect will not yield dispatchable action
authRedirect = this.actions$.pipe(
  ofType(AuthActions.AUTHENTICATE_SUCCESS),
  tap((authSuccessAction: AuthActions.AuthenticateSuccess) => {
    if (authSuccessAction.payload.redirect) {
      this.router.navigate(['/']);
    }
  })
);

Update, Delete, and Add Recipes

resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
  return this.store.select('recipes').pipe(
    take(1),
    map(recipesState => {
      return recipesState.recipes;
    }),
    switchMap(recipes => {
      if (recipes.length === 0) {
        this.store.dispatch(new RecipesActions.FetchRecipes());
        return this.actions$.pipe(
          ofType(RecipesActions.SET_RECIPES),
          take(1)
        );
      } else {
        return of(recipes);
      }
    })
  );
}
// src/app/recipes/store/recipe.actions.ts
export const ADD_RECIPE = '[Recipe] Add Recipe';
export const UPDATE_RECIPE = '[Recipe] Update Recipe';
export const DELETE_RECIPE = '[Recipe] Delete Recipe';
// ...
export class AddRecipe implements Action {
  readonly type = ADD_RECIPE;

  constructor(public payload: Recipe) {}
}

export class UpdateRecipe implements Action {
  readonly type = UPDATE_RECIPE;

  constructor(public payload: { index: number; newRecipe: Recipe }) {}
}

export class DeleteRecipe implements Action {
  readonly type = DELETE_RECIPE;

  constructor(public payload: number) {}
}

export type RecipesActions =
  | SetRecipes
  | FetchRecipes
  | AddRecipe
  | UpdateRecipe
  | DeleteRecipe;
// src/app/recipes/store/recipe.reducer.ts
case RecipeActions.ADD_RECIPE:
  return {
    ...state,
    recipes: [...state.recipes, action.payload]
  };
case RecipeActions.UPDATE_RECIPE:
  const updatedRecipe = {
    ...state.recipes[action.payload.index],
    ...action.payload.newRecipe
  };

  const updatedRecipes = [...state.recipes];
  updatedRecipes[action.payload.index] = updatedRecipe;
  return {
    ...state,
    recipes: updatedRecipes
  };
case RecipeActions.DELETE_RECIPE:
  return {
    ...state,
    recipes: state.recipes.filter((recipe, index) => {
      return index !== action.payload;
    })
  };
// src/app/recipes/recipe-detail/recipe-detail.component.ts
onDeleteRecipe() {
  // this.recipeService.deleteRecipe(this.id);
  this.store.dispatch(new RecipesActions.DeleteRecipe(this.id));
  this.router.navigate(['/recipes']);
}
// src/app/recipes/recipe-edit/recipe-edit.component.ts
// ...
onSubmit() {
  if (this.editMode) {
    // this.recipeService.updateRecipe(this.id, this.recipeForm.value);
    this.store.dispatch(
      new RecipesActions.UpdateRecipe({
        index: this.id,
        newRecipe: this.recipeForm.value
      })
    );
  } else {
    // this.recipeService.addRecipe(this.recipeForm.value);
    this.store.dispatch(new RecipesActions.AddRecipe(this.recipeForm.value));
  }
  this.onCancel();
}
// ...
// must also implement unsubscribe, or get error on delete after cancelling edit:

ngOnDestroy() {
  if (this.storeSub){
    this.storeSub.unsubscribe();
  }
}

Storing Recipes via Effects

// src/app/recipes/store/recipe.actions.ts
export const STORE_RECIPES = '[Recipe] Store Recipes';
// ...
export class StoreRecipes implements Action {
  readonly type = STORE_RECIPES;
}
// src/app/recipes/store/recipe.effects.ts
@Effect()
storeRecipes = this.actions$.pipe(
  ofType(RecipesActions.STORE_RECIPES),
  withLatestFrom(this.store.select('recipes')),
  switchMap(([actionData, recipesState]) => {
    return this.http.put(
      'https://ng-learn-practice.firebaseio.com/recipes.json',
      recipesState.recipes
    );
  })
);
// src/app/header/header.component.ts
onSaveData() {
  // this.dataStorageService.storeRecipes();
  this.store.dispatch(new RecipesActions.StoreRecipes());
}

Cleanup Work

Wrap Up