From d9a0b8fa95216a7788b2d540608dc24a19692677 Mon Sep 17 00:00:00 2001 From: Todd Motto Date: Sun, 26 Nov 2017 17:08:09 +0000 Subject: [PATCH 01/26] Step 1: First Actions --- src/products/store/actions/pizzas.action.ts | 25 +++++++++++++++++++++ src/products/store/index.ts | 0 2 files changed, 25 insertions(+) create mode 100644 src/products/store/actions/pizzas.action.ts create mode 100644 src/products/store/index.ts diff --git a/src/products/store/actions/pizzas.action.ts b/src/products/store/actions/pizzas.action.ts new file mode 100644 index 00000000..5799660c --- /dev/null +++ b/src/products/store/actions/pizzas.action.ts @@ -0,0 +1,25 @@ +import { Action } from '@ngrx/store'; + +import { Pizza } from '../../models/pizza.model'; + +// load pizzas +export const LOAD_PIZZAS = '[Products] Load Pizzas'; +export const LOAD_PIZZAS_FAIL = '[Products] Load Pizzas Fail'; +export const LOAD_PIZZAS_SUCCESS = '[Products] Load Pizzas Success'; + +export class LoadPizzas implements Action { + readonly type = LOAD_PIZZAS; +} + +export class LoadPizzasFail implements Action { + readonly type = LOAD_PIZZAS_FAIL; + constructor(public payload: any) {} +} + +export class LoadPizzasSuccess implements Action { + readonly type = LOAD_PIZZAS_SUCCESS; + constructor(public payload: Pizza[]) {} +} + +// action types +export type PizzasAction = LoadPizzas | LoadPizzasFail | LoadPizzasSuccess; diff --git a/src/products/store/index.ts b/src/products/store/index.ts new file mode 100644 index 00000000..e69de29b From 179b863474985af19350ed30e2b9ada3f7494688 Mon Sep 17 00:00:00 2001 From: Todd Motto Date: Sun, 26 Nov 2017 18:13:48 +0000 Subject: [PATCH 02/26] Step 2: Reducer setup --- src/products/products.module.ts | 5 ++ src/products/store/index.ts | 1 + src/products/store/reducers/index.ts | 11 +++++ src/products/store/reducers/pizzas.reducer.ts | 46 +++++++++++++++++++ 4 files changed, 63 insertions(+) create mode 100644 src/products/store/reducers/index.ts create mode 100644 src/products/store/reducers/pizzas.reducer.ts diff --git a/src/products/products.module.ts b/src/products/products.module.ts index 31aa1e24..95ae7f30 100755 --- a/src/products/products.module.ts +++ b/src/products/products.module.ts @@ -4,6 +4,10 @@ import { Routes, RouterModule } from '@angular/router'; import { ReactiveFormsModule } from '@angular/forms'; import { HttpClientModule } from '@angular/common/http'; +import { StoreModule } from '@ngrx/store'; + +import { reducers } from './store'; + // components import * as fromComponents from './components'; @@ -35,6 +39,7 @@ export const ROUTES: Routes = [ ReactiveFormsModule, HttpClientModule, RouterModule.forChild(ROUTES), + StoreModule.forFeature('products', reducers), ], providers: [...fromServices.services], declarations: [...fromContainers.containers, ...fromComponents.components], diff --git a/src/products/store/index.ts b/src/products/store/index.ts index e69de29b..79766fe0 100644 --- a/src/products/store/index.ts +++ b/src/products/store/index.ts @@ -0,0 +1 @@ +export * from './reducers'; diff --git a/src/products/store/reducers/index.ts b/src/products/store/reducers/index.ts new file mode 100644 index 00000000..cc0d39da --- /dev/null +++ b/src/products/store/reducers/index.ts @@ -0,0 +1,11 @@ +import { ActionReducerMap } from '@ngrx/store'; + +import * as fromPizzas from './pizzas.reducer'; + +export interface ProductsState { + pizzas: fromPizzas.PizzaState; +} + +export const reducers: ActionReducerMap = { + pizzas: fromPizzas.reducer, +}; diff --git a/src/products/store/reducers/pizzas.reducer.ts b/src/products/store/reducers/pizzas.reducer.ts new file mode 100644 index 00000000..8b2a0e7f --- /dev/null +++ b/src/products/store/reducers/pizzas.reducer.ts @@ -0,0 +1,46 @@ +import * as fromPizzas from '../actions/pizzas.action'; +import { Pizza } from '../../models/pizza.model'; + +export interface PizzaState { + data: Pizza[]; + loaded: boolean; + loading: boolean; +} + +export const initialState: PizzaState = { + data: [], + loaded: false, + loading: false, +}; + +export function reducer( + state = initialState, + action: fromPizzas.PizzasAction +): PizzaState { + switch (action.type) { + case fromPizzas.LOAD_PIZZAS: { + return { + ...state, + loading: true, + }; + } + + case fromPizzas.LOAD_PIZZAS_SUCCESS: { + return { + ...state, + loading: false, + loaded: true, + }; + } + + case fromPizzas.LOAD_PIZZAS_FAIL: { + return { + ...state, + loading: false, + loaded: false, + }; + } + } + + return state; +} From 62ae7fb8e218d02bf49b1b05b6547116eab51197 Mon Sep 17 00:00:00 2001 From: Todd Motto Date: Sun, 26 Nov 2017 20:44:10 +0000 Subject: [PATCH 03/26] Step 3: createSelector and createFeatureSelector --- .../containers/products/products.component.ts | 16 +++--- src/products/store/reducers/index.ts | 26 +++++++++- src/products/store/reducers/pizzas.reducer.ts | 49 ++++++++++++++++++- 3 files changed, 81 insertions(+), 10 deletions(-) diff --git a/src/products/containers/products/products.component.ts b/src/products/containers/products/products.component.ts index 3f2c91dc..3443e0aa 100755 --- a/src/products/containers/products/products.component.ts +++ b/src/products/containers/products/products.component.ts @@ -1,7 +1,9 @@ import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs/Observable'; +import * as fromStore from '../../store'; import { Pizza } from '../../models/pizza.model'; -import { PizzasService } from '../../services/pizzas.service'; @Component({ selector: 'products', @@ -16,11 +18,11 @@ import { PizzasService } from '../../services/pizzas.service';
-
+
No pizzas, add one to get started.
@@ -28,13 +30,11 @@ import { PizzasService } from '../../services/pizzas.service'; `, }) export class ProductsComponent implements OnInit { - pizzas: Pizza[]; + pizzas$: Observable; - constructor(private pizzaService: PizzasService) {} + constructor(private store: Store) {} ngOnInit() { - this.pizzaService.getPizzas().subscribe(pizzas => { - this.pizzas = pizzas; - }); + this.pizzas$ = this.store.select(fromStore.getAllPizzas); } } diff --git a/src/products/store/reducers/index.ts b/src/products/store/reducers/index.ts index cc0d39da..02e5c51c 100644 --- a/src/products/store/reducers/index.ts +++ b/src/products/store/reducers/index.ts @@ -1,4 +1,8 @@ -import { ActionReducerMap } from '@ngrx/store'; +import { + ActionReducerMap, + createSelector, + createFeatureSelector, +} from '@ngrx/store'; import * as fromPizzas from './pizzas.reducer'; @@ -9,3 +13,23 @@ export interface ProductsState { export const reducers: ActionReducerMap = { pizzas: fromPizzas.reducer, }; + +export const getProductsState = createFeatureSelector( + 'products' +); + +// pizzas state +export const getPizzaState = createSelector( + getProductsState, + (state: ProductsState) => state.pizzas +); + +export const getAllPizzas = createSelector(getPizzaState, fromPizzas.getPizzas); +export const getPizzasLoaded = createSelector( + getPizzaState, + fromPizzas.getPizzasLoaded +); +export const getPizzasLoading = createSelector( + getPizzaState, + fromPizzas.getPizzasLoading +); diff --git a/src/products/store/reducers/pizzas.reducer.ts b/src/products/store/reducers/pizzas.reducer.ts index 8b2a0e7f..f31a6553 100644 --- a/src/products/store/reducers/pizzas.reducer.ts +++ b/src/products/store/reducers/pizzas.reducer.ts @@ -8,7 +8,50 @@ export interface PizzaState { } export const initialState: PizzaState = { - data: [], + data: [ + { + name: "Seaside Surfin'", + toppings: [ + { + id: 6, + name: 'mushroom', + }, + { + id: 7, + name: 'olive', + }, + { + id: 2, + name: 'bacon', + }, + { + id: 3, + name: 'basil', + }, + { + id: 1, + name: 'anchovy', + }, + { + id: 8, + name: 'onion', + }, + { + id: 11, + name: 'sweetcorn', + }, + { + id: 9, + name: 'pepper', + }, + { + id: 5, + name: 'mozzarella', + }, + ], + id: 2, + }, + ], loaded: false, loading: false, }; @@ -44,3 +87,7 @@ export function reducer( return state; } + +export const getPizzasLoading = (state: PizzaState) => state.loading; +export const getPizzasLoaded = (state: PizzaState) => state.loaded; +export const getPizzas = (state: PizzaState) => state.data; From 894885240e18b3559dd74c5783accf663b54a129 Mon Sep 17 00:00:00 2001 From: Todd Motto Date: Mon, 27 Nov 2017 16:01:40 +0000 Subject: [PATCH 04/26] Step 4: NGRX Effects + Action dispatch --- .../containers/products/products.component.ts | 1 + src/products/products.module.ts | 4 +- src/products/store/actions/index.ts | 1 + src/products/store/effects/index.ts | 5 ++ src/products/store/effects/pizzas.effect.ts | 28 +++++++++++ src/products/store/index.ts | 2 + src/products/store/reducers/pizzas.reducer.ts | 47 ++----------------- 7 files changed, 43 insertions(+), 45 deletions(-) create mode 100644 src/products/store/actions/index.ts create mode 100644 src/products/store/effects/index.ts create mode 100644 src/products/store/effects/pizzas.effect.ts diff --git a/src/products/containers/products/products.component.ts b/src/products/containers/products/products.component.ts index 3443e0aa..09ea1a25 100755 --- a/src/products/containers/products/products.component.ts +++ b/src/products/containers/products/products.component.ts @@ -36,5 +36,6 @@ export class ProductsComponent implements OnInit { ngOnInit() { this.pizzas$ = this.store.select(fromStore.getAllPizzas); + this.store.dispatch(new fromStore.LoadPizzas()); } } diff --git a/src/products/products.module.ts b/src/products/products.module.ts index 95ae7f30..38b0b94a 100755 --- a/src/products/products.module.ts +++ b/src/products/products.module.ts @@ -5,8 +5,9 @@ import { ReactiveFormsModule } from '@angular/forms'; import { HttpClientModule } from '@angular/common/http'; import { StoreModule } from '@ngrx/store'; +import { EffectsModule } from '@ngrx/effects'; -import { reducers } from './store'; +import { reducers, effects } from './store'; // components import * as fromComponents from './components'; @@ -40,6 +41,7 @@ export const ROUTES: Routes = [ HttpClientModule, RouterModule.forChild(ROUTES), StoreModule.forFeature('products', reducers), + EffectsModule.forFeature(effects), ], providers: [...fromServices.services], declarations: [...fromContainers.containers, ...fromComponents.components], diff --git a/src/products/store/actions/index.ts b/src/products/store/actions/index.ts new file mode 100644 index 00000000..a5753e30 --- /dev/null +++ b/src/products/store/actions/index.ts @@ -0,0 +1 @@ +export * from './pizzas.action'; diff --git a/src/products/store/effects/index.ts b/src/products/store/effects/index.ts new file mode 100644 index 00000000..f06da68a --- /dev/null +++ b/src/products/store/effects/index.ts @@ -0,0 +1,5 @@ +import { PizzasEffects } from './pizzas.effect'; + +export const effects: any[] = [PizzasEffects]; + +export * from './pizzas.effect'; diff --git a/src/products/store/effects/pizzas.effect.ts b/src/products/store/effects/pizzas.effect.ts new file mode 100644 index 00000000..65357490 --- /dev/null +++ b/src/products/store/effects/pizzas.effect.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@angular/core'; + +import { Effect, Actions } from '@ngrx/effects'; +import { of } from 'rxjs/observable/of'; +import { map, switchMap, catchError } from 'rxjs/operators'; + +import * as pizzaActions from '../actions/pizzas.action'; +import * as fromServices from '../../services'; + +@Injectable() +export class PizzasEffects { + constructor( + private actions$: Actions, + private pizzaService: fromServices.PizzasService + ) {} + + @Effect() + loadPizzas$ = this.actions$.ofType(pizzaActions.LOAD_PIZZAS).pipe( + switchMap(() => { + return this.pizzaService + .getPizzas() + .pipe( + map(pizzas => new pizzaActions.LoadPizzasSuccess(pizzas)), + catchError(error => of(new pizzaActions.LoadPizzasFail(error))) + ); + }) + ); +} diff --git a/src/products/store/index.ts b/src/products/store/index.ts index 79766fe0..115467ce 100644 --- a/src/products/store/index.ts +++ b/src/products/store/index.ts @@ -1 +1,3 @@ export * from './reducers'; +export * from './actions'; +export * from './effects'; diff --git a/src/products/store/reducers/pizzas.reducer.ts b/src/products/store/reducers/pizzas.reducer.ts index f31a6553..d26a9f31 100644 --- a/src/products/store/reducers/pizzas.reducer.ts +++ b/src/products/store/reducers/pizzas.reducer.ts @@ -8,50 +8,7 @@ export interface PizzaState { } export const initialState: PizzaState = { - data: [ - { - name: "Seaside Surfin'", - toppings: [ - { - id: 6, - name: 'mushroom', - }, - { - id: 7, - name: 'olive', - }, - { - id: 2, - name: 'bacon', - }, - { - id: 3, - name: 'basil', - }, - { - id: 1, - name: 'anchovy', - }, - { - id: 8, - name: 'onion', - }, - { - id: 11, - name: 'sweetcorn', - }, - { - id: 9, - name: 'pepper', - }, - { - id: 5, - name: 'mozzarella', - }, - ], - id: 2, - }, - ], + data: [], loaded: false, loading: false, }; @@ -69,10 +26,12 @@ export function reducer( } case fromPizzas.LOAD_PIZZAS_SUCCESS: { + const data = action.payload; return { ...state, loading: false, loaded: true, + data, }; } From f52e5c9a587da7e7ab798e5ae63e73e5f8de63ca Mon Sep 17 00:00:00 2001 From: Todd Motto Date: Mon, 27 Nov 2017 16:47:20 +0000 Subject: [PATCH 05/26] Step 5: Optimising data structures with entities --- src/products/store/reducers/index.ts | 10 +++++++- src/products/store/reducers/pizzas.reducer.ts | 23 +++++++++++++++---- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/products/store/reducers/index.ts b/src/products/store/reducers/index.ts index 02e5c51c..4e582d65 100644 --- a/src/products/store/reducers/index.ts +++ b/src/products/store/reducers/index.ts @@ -24,7 +24,15 @@ export const getPizzaState = createSelector( (state: ProductsState) => state.pizzas ); -export const getAllPizzas = createSelector(getPizzaState, fromPizzas.getPizzas); +export const getPizzasEntities = createSelector( + getPizzaState, + fromPizzas.getPizzasEntities +); + +export const getAllPizzas = createSelector(getPizzasEntities, entities => { + return Object.keys(entities).map(id => entities[parseInt(id, 10)]); +}); + export const getPizzasLoaded = createSelector( getPizzaState, fromPizzas.getPizzasLoaded diff --git a/src/products/store/reducers/pizzas.reducer.ts b/src/products/store/reducers/pizzas.reducer.ts index d26a9f31..5af957b9 100644 --- a/src/products/store/reducers/pizzas.reducer.ts +++ b/src/products/store/reducers/pizzas.reducer.ts @@ -2,13 +2,13 @@ import * as fromPizzas from '../actions/pizzas.action'; import { Pizza } from '../../models/pizza.model'; export interface PizzaState { - data: Pizza[]; + entities: { [id: number]: Pizza }; loaded: boolean; loading: boolean; } export const initialState: PizzaState = { - data: [], + entities: {}, loaded: false, loading: false, }; @@ -26,12 +26,25 @@ export function reducer( } case fromPizzas.LOAD_PIZZAS_SUCCESS: { - const data = action.payload; + const pizzas = action.payload; + + const entities = pizzas.reduce( + (entities: { [id: number]: Pizza }, pizza: Pizza) => { + return { + ...entities, + [pizza.id]: pizza, + }; + }, + { + ...state.entities, + } + ); + return { ...state, loading: false, loaded: true, - data, + entities, }; } @@ -47,6 +60,6 @@ export function reducer( return state; } +export const getPizzasEntities = (state: PizzaState) => state.entities; export const getPizzasLoading = (state: PizzaState) => state.loading; export const getPizzasLoaded = (state: PizzaState) => state.loaded; -export const getPizzas = (state: PizzaState) => state.data; From eb1972dbbc48f8f9bee420bef5f525142f607584 Mon Sep 17 00:00:00 2001 From: Todd Motto Date: Mon, 27 Nov 2017 17:40:33 +0000 Subject: [PATCH 06/26] Step 6: Router Store setup --- src/app/app.module.ts | 4 +++- src/app/store/index.ts | 1 + src/app/store/reducers/index.ts | 22 ++++++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 src/app/store/index.ts create mode 100644 src/app/store/reducers/index.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index cbbfee0e..85e3d521 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -6,6 +6,8 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { StoreModule, MetaReducer } from '@ngrx/store'; import { EffectsModule } from '@ngrx/effects'; +import { reducers } from './store'; + // not used in production import { StoreDevtoolsModule } from '@ngrx/store-devtools'; import { storeFreeze } from 'ngrx-store-freeze'; @@ -37,7 +39,7 @@ export const ROUTES: Routes = [ BrowserModule, BrowserAnimationsModule, RouterModule.forRoot(ROUTES), - StoreModule.forRoot({}, { metaReducers }), + StoreModule.forRoot(reducers, { metaReducers }), EffectsModule.forRoot([]), environment.development ? StoreDevtoolsModule.instrument() : [], ], diff --git a/src/app/store/index.ts b/src/app/store/index.ts new file mode 100644 index 00000000..79766fe0 --- /dev/null +++ b/src/app/store/index.ts @@ -0,0 +1 @@ +export * from './reducers'; diff --git a/src/app/store/reducers/index.ts b/src/app/store/reducers/index.ts new file mode 100644 index 00000000..a4d87c8c --- /dev/null +++ b/src/app/store/reducers/index.ts @@ -0,0 +1,22 @@ +import { Params } from '@angular/router'; +import { createFeatureSelector, ActionReducerMap } from '@ngrx/store'; + +import * as fromRouter from '@ngrx/router-store'; + +export interface RouterStateUrl { + url: string; + queryParams: Params; + params: Params; +} + +export interface State { + routerReducer: fromRouter.RouterReducerState; +} + +export const reducers: ActionReducerMap = { + routerReducer: fromRouter.routerReducer, +}; + +export const getRouterState = createFeatureSelector< + fromRouter.RouterReducerState +>('routerReducer'); From b2c86b9417925b753ed8f748e71ccad66681030e Mon Sep 17 00:00:00 2001 From: Todd Motto Date: Mon, 27 Nov 2017 18:15:27 +0000 Subject: [PATCH 07/26] Step 7: Custom Router State Serializer --- src/app/app.module.ts | 8 +++++++- src/app/store/reducers/index.ts | 22 +++++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 85e3d521..3535618e 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -3,10 +3,14 @@ import { BrowserModule } from '@angular/platform-browser'; import { Routes, RouterModule } from '@angular/router'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { + StoreRouterConnectingModule, + RouterStateSerializer, +} from '@ngrx/router-store'; import { StoreModule, MetaReducer } from '@ngrx/store'; import { EffectsModule } from '@ngrx/effects'; -import { reducers } from './store'; +import { reducers, CustomSerializer } from './store'; // not used in production import { StoreDevtoolsModule } from '@ngrx/store-devtools'; @@ -41,8 +45,10 @@ export const ROUTES: Routes = [ RouterModule.forRoot(ROUTES), StoreModule.forRoot(reducers, { metaReducers }), EffectsModule.forRoot([]), + StoreRouterConnectingModule, environment.development ? StoreDevtoolsModule.instrument() : [], ], + providers: [{ provide: RouterStateSerializer, useClass: CustomSerializer }], declarations: [AppComponent], bootstrap: [AppComponent], }) diff --git a/src/app/store/reducers/index.ts b/src/app/store/reducers/index.ts index a4d87c8c..59cd70be 100644 --- a/src/app/store/reducers/index.ts +++ b/src/app/store/reducers/index.ts @@ -1,4 +1,8 @@ -import { Params } from '@angular/router'; +import { + ActivatedRouteSnapshot, + RouterStateSnapshot, + Params, +} from '@angular/router'; import { createFeatureSelector, ActionReducerMap } from '@ngrx/store'; import * as fromRouter from '@ngrx/router-store'; @@ -20,3 +24,19 @@ export const reducers: ActionReducerMap = { export const getRouterState = createFeatureSelector< fromRouter.RouterReducerState >('routerReducer'); + +export class CustomSerializer + implements fromRouter.RouterStateSerializer { + serialize(routerState: RouterStateSnapshot): RouterStateUrl { + const { url } = routerState; + const { queryParams } = routerState.root; + + let state: ActivatedRouteSnapshot = routerState.root; + while (state.firstChild) { + state = state.firstChild; + } + const { params } = state; + + return { url, queryParams, params }; + } +} From 2f697ff8100a97c41b5d022f45501043cb9d0bd4 Mon Sep 17 00:00:00 2001 From: Todd Motto Date: Mon, 27 Nov 2017 20:27:14 +0000 Subject: [PATCH 08/26] Step 8: Router State and selector composition --- .../product-item/product-item.component.ts | 61 ++++--------------- src/products/products.module.ts | 4 +- src/products/store/index.ts | 1 + src/products/store/reducers/index.ts | 30 +-------- src/products/store/selectors/index.ts | 1 + .../store/selectors/pizzas.selectors.ts | 38 ++++++++++++ 6 files changed, 54 insertions(+), 81 deletions(-) create mode 100644 src/products/store/selectors/index.ts create mode 100644 src/products/store/selectors/pizzas.selectors.ts diff --git a/src/products/containers/product-item/product-item.component.ts b/src/products/containers/product-item/product-item.component.ts index 4eb925f3..f9c63216 100755 --- a/src/products/containers/product-item/product-item.component.ts +++ b/src/products/containers/product-item/product-item.component.ts @@ -1,11 +1,11 @@ import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core'; -import { Router, ActivatedRoute } from '@angular/router'; -import { Pizza } from '../../models/pizza.model'; -import { PizzasService } from '../../services/pizzas.service'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs/Observable'; +import * as fromStore from '../../store'; +import { Pizza } from '../../models/pizza.model'; import { Topping } from '../../models/topping.model'; -import { ToppingsService } from '../../services/toppings.service'; @Component({ selector: 'product-item', @@ -14,7 +14,7 @@ import { ToppingsService } from '../../services/toppings.service';
; visualise: Pizza; toppings: Topping[]; - constructor( - private pizzaService: PizzasService, - private toppingsService: ToppingsService, - private route: ActivatedRoute, - private router: Router - ) {} + constructor(private store: Store) {} ngOnInit() { - this.pizzaService.getPizzas().subscribe(pizzas => { - const param = this.route.snapshot.params.id; - let pizza; - if (param === 'new') { - pizza = {}; - } else { - pizza = pizzas.find(pizza => pizza.id == parseInt(param, 10)); - } - this.pizza = pizza; - this.toppingsService.getToppings().subscribe(toppings => { - this.toppings = toppings; - this.onSelect(toppings.map(topping => topping.id)); - }); - }); + this.pizza$ = this.store.select(fromStore.getSelectedPizza); } - onSelect(event: number[]) { - let toppings; - if (this.toppings && this.toppings.length) { - toppings = event.map(id => - this.toppings.find(topping => topping.id === id) - ); - } else { - toppings = this.pizza.toppings; - } - this.visualise = { ...this.pizza, toppings }; - } + onSelect(event: number[]) {} - onCreate(event: Pizza) { - this.pizzaService.createPizza(event).subscribe(pizza => { - this.router.navigate([`/products/${pizza.id}`]); - }); - } + onCreate(event: Pizza) {} - onUpdate(event: Pizza) { - this.pizzaService.updatePizza(event).subscribe(() => { - this.router.navigate([`/products`]); - }); - } + onUpdate(event: Pizza) {} onRemove(event: Pizza) { const remove = window.confirm('Are you sure?'); if (remove) { - this.pizzaService.removePizza(event).subscribe(() => { - this.router.navigate([`/products`]); - }); } } } diff --git a/src/products/products.module.ts b/src/products/products.module.ts index 38b0b94a..0d592b59 100755 --- a/src/products/products.module.ts +++ b/src/products/products.module.ts @@ -25,11 +25,11 @@ export const ROUTES: Routes = [ component: fromContainers.ProductsComponent, }, { - path: ':id', + path: 'new', component: fromContainers.ProductItemComponent, }, { - path: 'new', + path: ':pizzaId', component: fromContainers.ProductItemComponent, }, ]; diff --git a/src/products/store/index.ts b/src/products/store/index.ts index 115467ce..be921ff2 100644 --- a/src/products/store/index.ts +++ b/src/products/store/index.ts @@ -1,3 +1,4 @@ export * from './reducers'; export * from './actions'; export * from './effects'; +export * from './selectors'; diff --git a/src/products/store/reducers/index.ts b/src/products/store/reducers/index.ts index 4e582d65..50ca9e6a 100644 --- a/src/products/store/reducers/index.ts +++ b/src/products/store/reducers/index.ts @@ -1,8 +1,4 @@ -import { - ActionReducerMap, - createSelector, - createFeatureSelector, -} from '@ngrx/store'; +import { ActionReducerMap, createFeatureSelector } from '@ngrx/store'; import * as fromPizzas from './pizzas.reducer'; @@ -17,27 +13,3 @@ export const reducers: ActionReducerMap = { export const getProductsState = createFeatureSelector( 'products' ); - -// pizzas state -export const getPizzaState = createSelector( - getProductsState, - (state: ProductsState) => state.pizzas -); - -export const getPizzasEntities = createSelector( - getPizzaState, - fromPizzas.getPizzasEntities -); - -export const getAllPizzas = createSelector(getPizzasEntities, entities => { - return Object.keys(entities).map(id => entities[parseInt(id, 10)]); -}); - -export const getPizzasLoaded = createSelector( - getPizzaState, - fromPizzas.getPizzasLoaded -); -export const getPizzasLoading = createSelector( - getPizzaState, - fromPizzas.getPizzasLoading -); diff --git a/src/products/store/selectors/index.ts b/src/products/store/selectors/index.ts new file mode 100644 index 00000000..b3c4a946 --- /dev/null +++ b/src/products/store/selectors/index.ts @@ -0,0 +1 @@ +export * from './pizzas.selectors'; diff --git a/src/products/store/selectors/pizzas.selectors.ts b/src/products/store/selectors/pizzas.selectors.ts new file mode 100644 index 00000000..4217459c --- /dev/null +++ b/src/products/store/selectors/pizzas.selectors.ts @@ -0,0 +1,38 @@ +import { createSelector } from '@ngrx/store'; + +import * as fromRoot from '../../../app/store'; +import * as fromFeature from '../reducers'; +import * as fromPizzas from '../reducers/pizzas.reducer'; + +import { Pizza } from '../../models/pizza.model'; + +export const getPizzaState = createSelector( + fromFeature.getProductsState, + (state: fromFeature.ProductsState) => state.pizzas +); + +export const getPizzasEntities = createSelector( + getPizzaState, + fromPizzas.getPizzasEntities +); + +export const getSelectedPizza = createSelector( + getPizzasEntities, + fromRoot.getRouterState, + (entities, router): Pizza => { + return router.state && entities[router.state.params.pizzaId]; + } +); + +export const getAllPizzas = createSelector(getPizzasEntities, entities => { + return Object.keys(entities).map(id => entities[parseInt(id, 10)]); +}); + +export const getPizzasLoaded = createSelector( + getPizzaState, + fromPizzas.getPizzasLoaded +); +export const getPizzasLoading = createSelector( + getPizzaState, + fromPizzas.getPizzasLoading +); From ca4e3891eba0b8b4e5e41b5418a052e33afcfec4 Mon Sep 17 00:00:00 2001 From: Todd Motto Date: Tue, 28 Nov 2017 15:47:00 +0000 Subject: [PATCH 09/26] Step 9: New Actions for Toppings --- .../product-item/product-item.component.ts | 1 + src/products/store/actions/index.ts | 1 + src/products/store/actions/toppings.action.ts | 27 +++++++++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 src/products/store/actions/toppings.action.ts diff --git a/src/products/containers/product-item/product-item.component.ts b/src/products/containers/product-item/product-item.component.ts index f9c63216..ed95805a 100755 --- a/src/products/containers/product-item/product-item.component.ts +++ b/src/products/containers/product-item/product-item.component.ts @@ -35,6 +35,7 @@ export class ProductItemComponent implements OnInit { constructor(private store: Store) {} ngOnInit() { + this.store.dispatch(new fromStore.LoadToppings()); this.pizza$ = this.store.select(fromStore.getSelectedPizza); } diff --git a/src/products/store/actions/index.ts b/src/products/store/actions/index.ts index a5753e30..22d481b5 100644 --- a/src/products/store/actions/index.ts +++ b/src/products/store/actions/index.ts @@ -1 +1,2 @@ export * from './pizzas.action'; +export * from './toppings.action'; diff --git a/src/products/store/actions/toppings.action.ts b/src/products/store/actions/toppings.action.ts new file mode 100644 index 00000000..317f21d6 --- /dev/null +++ b/src/products/store/actions/toppings.action.ts @@ -0,0 +1,27 @@ +import { Action } from '@ngrx/store'; + +import { Topping } from '../../models/topping.model'; + +export const LOAD_TOPPINGS = '[Products] Load Toppings'; +export const LOAD_TOPPINGS_FAIL = '[Products] Load Toppings Fail'; +export const LOAD_TOPPINGS_SUCCESS = '[Products] Load Toppings Success'; + +export class LoadToppings implements Action { + readonly type = LOAD_TOPPINGS; +} + +export class LoadToppingsFail implements Action { + readonly type = LOAD_TOPPINGS_FAIL; + constructor(public payload: any) {} +} + +export class LoadToppingsSuccess implements Action { + readonly type = LOAD_TOPPINGS_SUCCESS; + constructor(public payload: Topping[]) {} +} + +// action types +export type ToppingsAction = + | LoadToppings + | LoadToppingsFail + | LoadToppingsSuccess; From 52d2a06771ad54ae035115551f8795da5bc66a13 Mon Sep 17 00:00:00 2001 From: Todd Motto Date: Tue, 28 Nov 2017 16:19:52 +0000 Subject: [PATCH 10/26] Step 10: Toppings Reducer --- src/products/store/reducers/index.ts | 3 + .../store/reducers/toppings.reducer.ts | 65 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 src/products/store/reducers/toppings.reducer.ts diff --git a/src/products/store/reducers/index.ts b/src/products/store/reducers/index.ts index 50ca9e6a..291912ae 100644 --- a/src/products/store/reducers/index.ts +++ b/src/products/store/reducers/index.ts @@ -1,13 +1,16 @@ import { ActionReducerMap, createFeatureSelector } from '@ngrx/store'; import * as fromPizzas from './pizzas.reducer'; +import * as fromToppings from './toppings.reducer'; export interface ProductsState { pizzas: fromPizzas.PizzaState; + toppings: fromToppings.ToppingsState; } export const reducers: ActionReducerMap = { pizzas: fromPizzas.reducer, + toppings: fromToppings.reducer, }; export const getProductsState = createFeatureSelector( diff --git a/src/products/store/reducers/toppings.reducer.ts b/src/products/store/reducers/toppings.reducer.ts new file mode 100644 index 00000000..857ca72f --- /dev/null +++ b/src/products/store/reducers/toppings.reducer.ts @@ -0,0 +1,65 @@ +import * as fromToppings from '../actions/toppings.action'; +import { Topping } from '../../models/topping.model'; + +export interface ToppingsState { + entities: { [id: number]: Topping }; + loaded: boolean; + loading: boolean; +} + +export const initialState: ToppingsState = { + entities: {}, + loaded: false, + loading: false, +}; + +export function reducer( + state = initialState, + action: fromToppings.ToppingsAction +): ToppingsState { + switch (action.type) { + case fromToppings.LOAD_TOPPINGS: { + return { + ...state, + loading: true, + }; + } + + case fromToppings.LOAD_TOPPINGS_SUCCESS: { + const toppings = action.payload; + + const entities = toppings.reduce( + (entities: { [id: number]: Topping }, topping: Topping) => { + return { + ...entities, + [topping.id]: topping, + }; + }, + { + ...state.entities, + } + ); + + return { + ...state, + loaded: true, + loading: false, + entities, + }; + } + + case fromToppings.LOAD_TOPPINGS_FAIL: { + return { + ...state, + loaded: false, + loading: false, + }; + } + } + + return state; +} + +export const getToppingEntities = (state: ToppingsState) => state.entities; +export const getToppingsLoaded = (state: ToppingsState) => state.loaded; +export const getToppingsLoading = (state: ToppingsState) => state.loading; From d9ddd182a8c20bc800da7accad947cffded5e080 Mon Sep 17 00:00:00 2001 From: Todd Motto Date: Tue, 28 Nov 2017 16:59:06 +0000 Subject: [PATCH 11/26] Step 11: Toppings Effect --- src/products/store/effects/index.ts | 4 ++- src/products/store/effects/toppings.effect.ts | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 src/products/store/effects/toppings.effect.ts diff --git a/src/products/store/effects/index.ts b/src/products/store/effects/index.ts index f06da68a..13910755 100644 --- a/src/products/store/effects/index.ts +++ b/src/products/store/effects/index.ts @@ -1,5 +1,7 @@ import { PizzasEffects } from './pizzas.effect'; +import { ToppingsEffects } from './toppings.effect'; -export const effects: any[] = [PizzasEffects]; +export const effects: any[] = [PizzasEffects, ToppingsEffects]; export * from './pizzas.effect'; +export * from './toppings.effect'; diff --git a/src/products/store/effects/toppings.effect.ts b/src/products/store/effects/toppings.effect.ts new file mode 100644 index 00000000..4bd611a5 --- /dev/null +++ b/src/products/store/effects/toppings.effect.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@angular/core'; + +import { Effect, Actions } from '@ngrx/effects'; +import { of } from 'rxjs/observable/of'; +import { map, catchError, switchMap } from 'rxjs/operators'; + +import * as toppingsActions from '../actions/toppings.action'; +import * as fromServices from '../../services/toppings.service'; + +@Injectable() +export class ToppingsEffects { + constructor( + private actions$: Actions, + private toppingsService: fromServices.ToppingsService + ) {} + + @Effect() + loadToppings$ = this.actions$.ofType(toppingsActions.LOAD_TOPPINGS).pipe( + switchMap(() => { + return this.toppingsService + .getToppings() + .pipe( + map(toppings => new toppingsActions.LoadToppingsSuccess(toppings)), + catchError(error => of(new toppingsActions.LoadToppingsFail(error))) + ); + }) + ); +} From 9217fa50b40e859e4943c3a5ff53635a5ab1ab51 Mon Sep 17 00:00:00 2001 From: Todd Motto Date: Tue, 28 Nov 2017 17:19:23 +0000 Subject: [PATCH 12/26] Step 12: Toppings Selectors --- .../product-item/product-item.component.ts | 5 ++-- src/products/store/selectors/index.ts | 1 + .../store/selectors/toppings.selectors.ts | 29 +++++++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 src/products/store/selectors/toppings.selectors.ts diff --git a/src/products/containers/product-item/product-item.component.ts b/src/products/containers/product-item/product-item.component.ts index ed95805a..3f2b109d 100755 --- a/src/products/containers/product-item/product-item.component.ts +++ b/src/products/containers/product-item/product-item.component.ts @@ -15,7 +15,7 @@ import { Topping } from '../../models/topping.model'; class="product-item"> ; visualise: Pizza; - toppings: Topping[]; + toppings$: Observable; constructor(private store: Store) {} ngOnInit() { this.store.dispatch(new fromStore.LoadToppings()); this.pizza$ = this.store.select(fromStore.getSelectedPizza); + this.toppings$ = this.store.select(fromStore.getAllToppings); } onSelect(event: number[]) {} diff --git a/src/products/store/selectors/index.ts b/src/products/store/selectors/index.ts index b3c4a946..7dad7fbf 100644 --- a/src/products/store/selectors/index.ts +++ b/src/products/store/selectors/index.ts @@ -1 +1,2 @@ export * from './pizzas.selectors'; +export * from './toppings.selectors'; diff --git a/src/products/store/selectors/toppings.selectors.ts b/src/products/store/selectors/toppings.selectors.ts new file mode 100644 index 00000000..af72e9ea --- /dev/null +++ b/src/products/store/selectors/toppings.selectors.ts @@ -0,0 +1,29 @@ +import { createSelector } from '@ngrx/store'; + +import * as fromRoot from '../../../app/store'; +import * as fromFeature from '../reducers'; +import * as fromToppings from '../reducers/toppings.reducer'; + +export const getToppingsState = createSelector( + fromFeature.getProductsState, + (state: fromFeature.ProductsState) => state.toppings +); + +export const getToppingEntities = createSelector( + getToppingsState, + fromToppings.getToppingEntities +); + +export const getAllToppings = createSelector(getToppingEntities, entities => { + return Object.keys(entities).map(id => entities[parseInt(id, 10)]); +}); + +export const getToppingsLoaded = createSelector( + getToppingsState, + fromToppings.getToppingsLoaded +); + +export const getToppingsLoading = createSelector( + getToppingsState, + fromToppings.getToppingsLoading +); From 60eb3df05c6db82df1a22b4a7e9d0607810b42ac Mon Sep 17 00:00:00 2001 From: Todd Motto Date: Tue, 28 Nov 2017 18:29:13 +0000 Subject: [PATCH 13/26] Step 13: Selected state IDs --- .../product-item/product-item.component.ts | 4 +++- src/products/store/actions/toppings.action.ts | 9 ++++++++- src/products/store/reducers/toppings.reducer.ts | 13 +++++++++++++ src/products/store/selectors/pizzas.selectors.ts | 11 +++++++++++ src/products/store/selectors/toppings.selectors.ts | 5 +++++ 5 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/products/containers/product-item/product-item.component.ts b/src/products/containers/product-item/product-item.component.ts index 3f2b109d..2c49c31d 100755 --- a/src/products/containers/product-item/product-item.component.ts +++ b/src/products/containers/product-item/product-item.component.ts @@ -40,7 +40,9 @@ export class ProductItemComponent implements OnInit { this.toppings$ = this.store.select(fromStore.getAllToppings); } - onSelect(event: number[]) {} + onSelect(event: number[]) { + console.log('onSelect:::', event); + } onCreate(event: Pizza) {} diff --git a/src/products/store/actions/toppings.action.ts b/src/products/store/actions/toppings.action.ts index 317f21d6..bf83db5d 100644 --- a/src/products/store/actions/toppings.action.ts +++ b/src/products/store/actions/toppings.action.ts @@ -5,6 +5,7 @@ import { Topping } from '../../models/topping.model'; export const LOAD_TOPPINGS = '[Products] Load Toppings'; export const LOAD_TOPPINGS_FAIL = '[Products] Load Toppings Fail'; export const LOAD_TOPPINGS_SUCCESS = '[Products] Load Toppings Success'; +export const VISUALISE_TOPPINGS = '[Products] Visualise Toppings'; export class LoadToppings implements Action { readonly type = LOAD_TOPPINGS; @@ -20,8 +21,14 @@ export class LoadToppingsSuccess implements Action { constructor(public payload: Topping[]) {} } +export class VisualiseToppings implements Action { + readonly type = VISUALISE_TOPPINGS; + constructor(public payload: number[]) {} +} + // action types export type ToppingsAction = | LoadToppings | LoadToppingsFail - | LoadToppingsSuccess; + | LoadToppingsSuccess + | VisualiseToppings; diff --git a/src/products/store/reducers/toppings.reducer.ts b/src/products/store/reducers/toppings.reducer.ts index 857ca72f..a6554d69 100644 --- a/src/products/store/reducers/toppings.reducer.ts +++ b/src/products/store/reducers/toppings.reducer.ts @@ -5,12 +5,14 @@ export interface ToppingsState { entities: { [id: number]: Topping }; loaded: boolean; loading: boolean; + selectedToppings: number[]; } export const initialState: ToppingsState = { entities: {}, loaded: false, loading: false, + selectedToppings: [], }; export function reducer( @@ -18,6 +20,15 @@ export function reducer( action: fromToppings.ToppingsAction ): ToppingsState { switch (action.type) { + case fromToppings.VISUALISE_TOPPINGS: { + const selectedToppings = action.payload; + + return { + ...state, + selectedToppings, + }; + } + case fromToppings.LOAD_TOPPINGS: { return { ...state, @@ -63,3 +74,5 @@ export function reducer( export const getToppingEntities = (state: ToppingsState) => state.entities; export const getToppingsLoaded = (state: ToppingsState) => state.loaded; export const getToppingsLoading = (state: ToppingsState) => state.loading; +export const getSelectedToppings = (state: ToppingsState) => + state.selectedToppings; diff --git a/src/products/store/selectors/pizzas.selectors.ts b/src/products/store/selectors/pizzas.selectors.ts index 4217459c..cc6d63c8 100644 --- a/src/products/store/selectors/pizzas.selectors.ts +++ b/src/products/store/selectors/pizzas.selectors.ts @@ -3,6 +3,7 @@ import { createSelector } from '@ngrx/store'; import * as fromRoot from '../../../app/store'; import * as fromFeature from '../reducers'; import * as fromPizzas from '../reducers/pizzas.reducer'; +import * as fromToppings from './toppings.selectors'; import { Pizza } from '../../models/pizza.model'; @@ -24,6 +25,16 @@ export const getSelectedPizza = createSelector( } ); +export const getPizzaVisualised = createSelector( + getSelectedPizza, + fromToppings.getToppingEntities, + fromToppings.getSelectedToppings, + (pizza, toppingEntities, selectedToppings) => { + const toppings = selectedToppings.map(id => toppingEntities[id]); + return { ...pizza, toppings }; + } +); + export const getAllPizzas = createSelector(getPizzasEntities, entities => { return Object.keys(entities).map(id => entities[parseInt(id, 10)]); }); diff --git a/src/products/store/selectors/toppings.selectors.ts b/src/products/store/selectors/toppings.selectors.ts index af72e9ea..ac905bff 100644 --- a/src/products/store/selectors/toppings.selectors.ts +++ b/src/products/store/selectors/toppings.selectors.ts @@ -14,6 +14,11 @@ export const getToppingEntities = createSelector( fromToppings.getToppingEntities ); +export const getSelectedToppings = createSelector( + getToppingsState, + fromToppings.getSelectedToppings +); + export const getAllToppings = createSelector(getToppingEntities, entities => { return Object.keys(entities).map(id => entities[parseInt(id, 10)]); }); From f39259d82d40b07060210c48ad6f7aa9bef10a7c Mon Sep 17 00:00:00 2001 From: Todd Motto Date: Tue, 28 Nov 2017 19:45:59 +0000 Subject: [PATCH 14/26] Step 14: Visualise Toppings via Dispatches --- .../product-item/product-item.component.ts | 19 ++++++++++++++----- .../containers/products/products.component.ts | 1 + 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/products/containers/product-item/product-item.component.ts b/src/products/containers/product-item/product-item.component.ts index 2c49c31d..e95c82ac 100755 --- a/src/products/containers/product-item/product-item.component.ts +++ b/src/products/containers/product-item/product-item.component.ts @@ -2,6 +2,7 @@ import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core'; import { Store } from '@ngrx/store'; import { Observable } from 'rxjs/Observable'; +import { tap } from 'rxjs/operators'; import * as fromStore from '../../store'; import { Pizza } from '../../models/pizza.model'; @@ -21,7 +22,7 @@ import { Topping } from '../../models/topping.model'; (update)="onUpdate($event)" (remove)="onRemove($event)"> + [pizza]="visualise$ | async">
@@ -29,19 +30,27 @@ import { Topping } from '../../models/topping.model'; }) export class ProductItemComponent implements OnInit { pizza$: Observable; - visualise: Pizza; + visualise$: Observable; toppings$: Observable; constructor(private store: Store) {} ngOnInit() { - this.store.dispatch(new fromStore.LoadToppings()); - this.pizza$ = this.store.select(fromStore.getSelectedPizza); + this.pizza$ = this.store.select(fromStore.getSelectedPizza).pipe( + tap((pizza: Pizza = null) => { + const pizzaExists = !!(pizza && pizza.toppings); + const toppings = pizzaExists + ? pizza.toppings.map(topping => topping.id) + : []; + this.store.dispatch(new fromStore.VisualiseToppings(toppings)); + }) + ); this.toppings$ = this.store.select(fromStore.getAllToppings); + this.visualise$ = this.store.select(fromStore.getPizzaVisualised); } onSelect(event: number[]) { - console.log('onSelect:::', event); + this.store.dispatch(new fromStore.VisualiseToppings(event)); } onCreate(event: Pizza) {} diff --git a/src/products/containers/products/products.component.ts b/src/products/containers/products/products.component.ts index 09ea1a25..cf21e850 100755 --- a/src/products/containers/products/products.component.ts +++ b/src/products/containers/products/products.component.ts @@ -37,5 +37,6 @@ export class ProductsComponent implements OnInit { ngOnInit() { this.pizzas$ = this.store.select(fromStore.getAllPizzas); this.store.dispatch(new fromStore.LoadPizzas()); + this.store.dispatch(new fromStore.LoadToppings()); } } From c3c02d092c755dab8c927a6f461175689c07648c Mon Sep 17 00:00:00 2001 From: Todd Motto Date: Tue, 28 Nov 2017 20:31:53 +0000 Subject: [PATCH 15/26] Step 15: Creating a Pizza via dispatch, effects, reducers --- db.json | 18 ++++++++++++ .../product-item/product-item.component.ts | 4 ++- src/products/store/actions/pizzas.action.ts | 28 ++++++++++++++++++- src/products/store/effects/pizzas.effect.ts | 13 +++++++++ src/products/store/reducers/pizzas.reducer.ts | 13 +++++++++ 5 files changed, 74 insertions(+), 2 deletions(-) mode change 100755 => 100644 db.json diff --git a/db.json b/db.json old mode 100755 new mode 100644 index 6abe7b71..19aba8dd --- a/db.json +++ b/db.json @@ -131,6 +131,24 @@ } ], "id": 3 + }, + { + "name": "Test Pizza", + "toppings": [ + { + "id": 10, + "name": "pepperoni" + }, + { + "id": 7, + "name": "olive" + }, + { + "id": 2, + "name": "bacon" + } + ], + "id": 4 } ] } \ No newline at end of file diff --git a/src/products/containers/product-item/product-item.component.ts b/src/products/containers/product-item/product-item.component.ts index e95c82ac..60d86064 100755 --- a/src/products/containers/product-item/product-item.component.ts +++ b/src/products/containers/product-item/product-item.component.ts @@ -53,7 +53,9 @@ export class ProductItemComponent implements OnInit { this.store.dispatch(new fromStore.VisualiseToppings(event)); } - onCreate(event: Pizza) {} + onCreate(event: Pizza) { + this.store.dispatch(new fromStore.CreatePizza(event)); + } onUpdate(event: Pizza) {} diff --git a/src/products/store/actions/pizzas.action.ts b/src/products/store/actions/pizzas.action.ts index 5799660c..5603fa0d 100644 --- a/src/products/store/actions/pizzas.action.ts +++ b/src/products/store/actions/pizzas.action.ts @@ -21,5 +21,31 @@ export class LoadPizzasSuccess implements Action { constructor(public payload: Pizza[]) {} } +// create pizza +export const CREATE_PIZZA = '[Products] Create Pizza'; +export const CREATE_PIZZA_FAIL = '[Products] Create Pizza Fail'; +export const CREATE_PIZZA_SUCCESS = '[Products] Create Pizza Success'; + +export class CreatePizza implements Action { + readonly type = CREATE_PIZZA; + constructor(public payload: Pizza) {} +} + +export class CreatePizzaFail implements Action { + readonly type = CREATE_PIZZA_FAIL; + constructor(public payload: any) {} +} + +export class CreatePizzaSuccess implements Action { + readonly type = CREATE_PIZZA_SUCCESS; + constructor(public payload: Pizza) {} +} + // action types -export type PizzasAction = LoadPizzas | LoadPizzasFail | LoadPizzasSuccess; +export type PizzasAction = + | LoadPizzas + | LoadPizzasFail + | LoadPizzasSuccess + | CreatePizza + | CreatePizzaFail + | CreatePizzaSuccess; diff --git a/src/products/store/effects/pizzas.effect.ts b/src/products/store/effects/pizzas.effect.ts index 65357490..f16f68e4 100644 --- a/src/products/store/effects/pizzas.effect.ts +++ b/src/products/store/effects/pizzas.effect.ts @@ -25,4 +25,17 @@ export class PizzasEffects { ); }) ); + + @Effect() + createPizza$ = this.actions$.ofType(pizzaActions.CREATE_PIZZA).pipe( + map((action: pizzaActions.CreatePizza) => action.payload), + switchMap(pizza => { + return this.pizzaService + .createPizza(pizza) + .pipe( + map(pizza => new pizzaActions.CreatePizzaSuccess(pizza)), + catchError(error => of(new pizzaActions.CreatePizzaFail(error))) + ); + }) + ); } diff --git a/src/products/store/reducers/pizzas.reducer.ts b/src/products/store/reducers/pizzas.reducer.ts index 5af957b9..faf186b9 100644 --- a/src/products/store/reducers/pizzas.reducer.ts +++ b/src/products/store/reducers/pizzas.reducer.ts @@ -55,6 +55,19 @@ export function reducer( loaded: false, }; } + + case fromPizzas.CREATE_PIZZA_SUCCESS: { + const pizza = action.payload; + const entities = { + ...state.entities, + [pizza.id]: pizza, + }; + + return { + ...state, + entities, + }; + } } return state; From 0fcccca76746ad5e15f1fe0db1480a09af23321d Mon Sep 17 00:00:00 2001 From: Todd Motto Date: Wed, 29 Nov 2017 16:56:38 +0000 Subject: [PATCH 16/26] Step 16: Update pizza and switch fallthrough --- db.json | 32 +++++++++++++++++-- .../product-item/product-item.component.ts | 4 ++- src/products/store/actions/pizzas.action.ts | 25 ++++++++++++++- src/products/store/effects/pizzas.effect.ts | 13 ++++++++ src/products/store/reducers/pizzas.reducer.ts | 1 + 5 files changed, 71 insertions(+), 4 deletions(-) diff --git a/db.json b/db.json index 19aba8dd..52e0dc9e 100644 --- a/db.json +++ b/db.json @@ -144,8 +144,36 @@ "name": "olive" }, { - "id": 2, - "name": "bacon" + "id": 5, + "name": "mozzarella" + }, + { + "id": 6, + "name": "mushroom" + }, + { + "id": 9, + "name": "pepper" + }, + { + "id": 3, + "name": "basil" + }, + { + "id": 4, + "name": "chili" + }, + { + "id": 8, + "name": "onion" + }, + { + "id": 12, + "name": "tomato" + }, + { + "id": 11, + "name": "sweetcorn" } ], "id": 4 diff --git a/src/products/containers/product-item/product-item.component.ts b/src/products/containers/product-item/product-item.component.ts index 60d86064..1bdfdf8e 100755 --- a/src/products/containers/product-item/product-item.component.ts +++ b/src/products/containers/product-item/product-item.component.ts @@ -57,7 +57,9 @@ export class ProductItemComponent implements OnInit { this.store.dispatch(new fromStore.CreatePizza(event)); } - onUpdate(event: Pizza) {} + onUpdate(event: Pizza) { + this.store.dispatch(new fromStore.UpdatePizza(event)); + } onRemove(event: Pizza) { const remove = window.confirm('Are you sure?'); diff --git a/src/products/store/actions/pizzas.action.ts b/src/products/store/actions/pizzas.action.ts index 5603fa0d..664e31e5 100644 --- a/src/products/store/actions/pizzas.action.ts +++ b/src/products/store/actions/pizzas.action.ts @@ -41,6 +41,26 @@ export class CreatePizzaSuccess implements Action { constructor(public payload: Pizza) {} } +// update pizza +export const UPDATE_PIZZA = '[Products] Update Pizza'; +export const UPDATE_PIZZA_FAIL = '[Products] Update Pizza Fail'; +export const UPDATE_PIZZA_SUCCESS = '[Products] Update Pizza Success'; + +export class UpdatePizza implements Action { + readonly type = UPDATE_PIZZA; + constructor(public payload: Pizza) {} +} + +export class UpdatePizzaFail implements Action { + readonly type = UPDATE_PIZZA_FAIL; + constructor(public payload: any) {} +} + +export class UpdatePizzaSuccess implements Action { + readonly type = UPDATE_PIZZA_SUCCESS; + constructor(public payload: Pizza) {} +} + // action types export type PizzasAction = | LoadPizzas @@ -48,4 +68,7 @@ export type PizzasAction = | LoadPizzasSuccess | CreatePizza | CreatePizzaFail - | CreatePizzaSuccess; + | CreatePizzaSuccess + | UpdatePizza + | UpdatePizzaFail + | UpdatePizzaSuccess; diff --git a/src/products/store/effects/pizzas.effect.ts b/src/products/store/effects/pizzas.effect.ts index f16f68e4..36bd60a3 100644 --- a/src/products/store/effects/pizzas.effect.ts +++ b/src/products/store/effects/pizzas.effect.ts @@ -38,4 +38,17 @@ export class PizzasEffects { ); }) ); + + @Effect() + updatePizza$ = this.actions$.ofType(pizzaActions.UPDATE_PIZZA).pipe( + map((action: pizzaActions.UpdatePizza) => action.payload), + switchMap(pizza => { + return this.pizzaService + .updatePizza(pizza) + .pipe( + map(pizza => new pizzaActions.UpdatePizzaSuccess(pizza)), + catchError(error => of(new pizzaActions.UpdatePizzaFail(error))) + ); + }) + ); } diff --git a/src/products/store/reducers/pizzas.reducer.ts b/src/products/store/reducers/pizzas.reducer.ts index faf186b9..3b68df2e 100644 --- a/src/products/store/reducers/pizzas.reducer.ts +++ b/src/products/store/reducers/pizzas.reducer.ts @@ -56,6 +56,7 @@ export function reducer( }; } + case fromPizzas.UPDATE_PIZZA_SUCCESS: case fromPizzas.CREATE_PIZZA_SUCCESS: { const pizza = action.payload; const entities = { From c9dc8c38285fc1c581d046a017b5ce4ba4afda67 Mon Sep 17 00:00:00 2001 From: Todd Motto Date: Wed, 29 Nov 2017 17:20:49 +0000 Subject: [PATCH 17/26] Step 17: Remove pizza --- db.json | 46 ------------------- .../product-item/product-item.component.ts | 1 + src/products/store/actions/pizzas.action.ts | 25 +++++++++- src/products/store/effects/pizzas.effect.ts | 13 ++++++ src/products/store/reducers/pizzas.reducer.ts | 10 ++++ 5 files changed, 48 insertions(+), 47 deletions(-) diff --git a/db.json b/db.json index 52e0dc9e..6abe7b71 100644 --- a/db.json +++ b/db.json @@ -131,52 +131,6 @@ } ], "id": 3 - }, - { - "name": "Test Pizza", - "toppings": [ - { - "id": 10, - "name": "pepperoni" - }, - { - "id": 7, - "name": "olive" - }, - { - "id": 5, - "name": "mozzarella" - }, - { - "id": 6, - "name": "mushroom" - }, - { - "id": 9, - "name": "pepper" - }, - { - "id": 3, - "name": "basil" - }, - { - "id": 4, - "name": "chili" - }, - { - "id": 8, - "name": "onion" - }, - { - "id": 12, - "name": "tomato" - }, - { - "id": 11, - "name": "sweetcorn" - } - ], - "id": 4 } ] } \ No newline at end of file diff --git a/src/products/containers/product-item/product-item.component.ts b/src/products/containers/product-item/product-item.component.ts index 1bdfdf8e..58d3c03b 100755 --- a/src/products/containers/product-item/product-item.component.ts +++ b/src/products/containers/product-item/product-item.component.ts @@ -64,6 +64,7 @@ export class ProductItemComponent implements OnInit { onRemove(event: Pizza) { const remove = window.confirm('Are you sure?'); if (remove) { + this.store.dispatch(new fromStore.RemovePizza(event)); } } } diff --git a/src/products/store/actions/pizzas.action.ts b/src/products/store/actions/pizzas.action.ts index 664e31e5..8f1ecdb9 100644 --- a/src/products/store/actions/pizzas.action.ts +++ b/src/products/store/actions/pizzas.action.ts @@ -61,6 +61,26 @@ export class UpdatePizzaSuccess implements Action { constructor(public payload: Pizza) {} } +// remove pizza +export const REMOVE_PIZZA = '[Products] Remove Pizza'; +export const REMOVE_PIZZA_FAIL = '[Products] Remove Pizza Fail'; +export const REMOVE_PIZZA_SUCCESS = '[Products] Remove Pizza Success'; + +export class RemovePizza implements Action { + readonly type = REMOVE_PIZZA; + constructor(public payload: Pizza) {} +} + +export class RemovePizzaFail implements Action { + readonly type = REMOVE_PIZZA_FAIL; + constructor(public payload: any) {} +} + +export class RemovePizzaSuccess implements Action { + readonly type = REMOVE_PIZZA_SUCCESS; + constructor(public payload: Pizza) {} +} + // action types export type PizzasAction = | LoadPizzas @@ -71,4 +91,7 @@ export type PizzasAction = | CreatePizzaSuccess | UpdatePizza | UpdatePizzaFail - | UpdatePizzaSuccess; + | UpdatePizzaSuccess + | RemovePizza + | RemovePizzaFail + | RemovePizzaSuccess; diff --git a/src/products/store/effects/pizzas.effect.ts b/src/products/store/effects/pizzas.effect.ts index 36bd60a3..5c31590b 100644 --- a/src/products/store/effects/pizzas.effect.ts +++ b/src/products/store/effects/pizzas.effect.ts @@ -51,4 +51,17 @@ export class PizzasEffects { ); }) ); + + @Effect() + removePizza$ = this.actions$.ofType(pizzaActions.REMOVE_PIZZA).pipe( + map((action: pizzaActions.RemovePizza) => action.payload), + switchMap(pizza => { + return this.pizzaService + .removePizza(pizza) + .pipe( + map(() => new pizzaActions.RemovePizzaSuccess(pizza)), + catchError(error => of(new pizzaActions.RemovePizzaFail(error))) + ); + }) + ); } diff --git a/src/products/store/reducers/pizzas.reducer.ts b/src/products/store/reducers/pizzas.reducer.ts index 3b68df2e..b79eba53 100644 --- a/src/products/store/reducers/pizzas.reducer.ts +++ b/src/products/store/reducers/pizzas.reducer.ts @@ -69,6 +69,16 @@ export function reducer( entities, }; } + + case fromPizzas.REMOVE_PIZZA_SUCCESS: { + const pizza = action.payload; + const { [pizza.id]: removed, ...entities } = state.entities; + + return { + ...state, + entities, + }; + } } return state; From e2a3a46ea7d0d0d50c6bb20024b0d7803abf47e1 Mon Sep 17 00:00:00 2001 From: Todd Motto Date: Wed, 29 Nov 2017 17:57:56 +0000 Subject: [PATCH 18/26] Step 18: Router Actions and Effects --- src/app/app.module.ts | 4 +-- src/app/store/actions/index.ts | 1 + src/app/store/actions/router.action.ts | 27 ++++++++++++++++++++ src/app/store/effects/index.ts | 5 ++++ src/app/store/effects/router.effect.ts | 35 ++++++++++++++++++++++++++ src/app/store/index.ts | 2 ++ 6 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 src/app/store/actions/index.ts create mode 100644 src/app/store/actions/router.action.ts create mode 100644 src/app/store/effects/index.ts create mode 100644 src/app/store/effects/router.effect.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 3535618e..6f7c9ed7 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -10,7 +10,7 @@ import { import { StoreModule, MetaReducer } from '@ngrx/store'; import { EffectsModule } from '@ngrx/effects'; -import { reducers, CustomSerializer } from './store'; +import { reducers, effects, CustomSerializer } from './store'; // not used in production import { StoreDevtoolsModule } from '@ngrx/store-devtools'; @@ -44,7 +44,7 @@ export const ROUTES: Routes = [ BrowserAnimationsModule, RouterModule.forRoot(ROUTES), StoreModule.forRoot(reducers, { metaReducers }), - EffectsModule.forRoot([]), + EffectsModule.forRoot(effects), StoreRouterConnectingModule, environment.development ? StoreDevtoolsModule.instrument() : [], ], diff --git a/src/app/store/actions/index.ts b/src/app/store/actions/index.ts new file mode 100644 index 00000000..baf6af0e --- /dev/null +++ b/src/app/store/actions/index.ts @@ -0,0 +1 @@ +export * from './router.action'; diff --git a/src/app/store/actions/router.action.ts b/src/app/store/actions/router.action.ts new file mode 100644 index 00000000..f766a06e --- /dev/null +++ b/src/app/store/actions/router.action.ts @@ -0,0 +1,27 @@ +import { Action } from '@ngrx/store'; +import { NavigationExtras } from '@angular/router'; + +export const GO = '[Router] Go'; +export const BACK = '[Router] Back'; +export const FORWARD = '[Router] Forward'; + +export class Go implements Action { + readonly type = GO; + constructor( + public payload: { + path: any[]; + query?: object; + extras?: NavigationExtras; + } + ) {} +} + +export class Back implements Action { + readonly type = BACK; +} + +export class Forward implements Action { + readonly type = FORWARD; +} + +export type Actions = Go | Back | Forward; diff --git a/src/app/store/effects/index.ts b/src/app/store/effects/index.ts new file mode 100644 index 00000000..8bf11b61 --- /dev/null +++ b/src/app/store/effects/index.ts @@ -0,0 +1,5 @@ +import { RouterEffects } from './router.effect'; + +export const effects: any[] = [RouterEffects]; + +export * from './router.effect'; diff --git a/src/app/store/effects/router.effect.ts b/src/app/store/effects/router.effect.ts new file mode 100644 index 00000000..90b9f5f7 --- /dev/null +++ b/src/app/store/effects/router.effect.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; +import { Location } from '@angular/common'; + +import { Effect, Actions } from '@ngrx/effects'; +import * as RouterActions from '../actions/router.action'; + +import { tap, map } from 'rxjs/operators'; + +@Injectable() +export class RouterEffects { + constructor( + private actions$: Actions, + private router: Router, + private location: Location + ) {} + + @Effect({ dispatch: false }) + navigate$ = this.actions$.ofType(RouterActions.GO).pipe( + map((action: RouterActions.Go) => action.payload), + tap(({ path, query: queryParams, extras }) => { + this.router.navigate(path, { queryParams, ...extras }); + }) + ); + + @Effect({ dispatch: false }) + navigateBack$ = this.actions$ + .ofType(RouterActions.BACK) + .pipe(tap(() => this.location.back())); + + @Effect({ dispatch: false }) + navigateForward$ = this.actions$ + .ofType(RouterActions.FORWARD) + .pipe(tap(() => this.location.forward())); +} diff --git a/src/app/store/index.ts b/src/app/store/index.ts index 79766fe0..115467ce 100644 --- a/src/app/store/index.ts +++ b/src/app/store/index.ts @@ -1 +1,3 @@ export * from './reducers'; +export * from './actions'; +export * from './effects'; From 6ee65bc016ed54d0fa771ef5ed34f8e63516fd11 Mon Sep 17 00:00:00 2001 From: Todd Motto Date: Wed, 29 Nov 2017 18:14:40 +0000 Subject: [PATCH 19/26] Step 19: Dispatch Routing actions, combine ofType for single effect --- db.json | 42 --------------------- src/products/store/effects/pizzas.effect.ts | 27 +++++++++++++ 2 files changed, 27 insertions(+), 42 deletions(-) diff --git a/db.json b/db.json index 6abe7b71..76e5ad44 100644 --- a/db.json +++ b/db.json @@ -80,48 +80,6 @@ ], "id": 1 }, - { - "name": "Seaside Surfin'", - "toppings": [ - { - "id": 6, - "name": "mushroom" - }, - { - "id": 7, - "name": "olive" - }, - { - "id": 2, - "name": "bacon" - }, - { - "id": 3, - "name": "basil" - }, - { - "id": 1, - "name": "anchovy" - }, - { - "id": 8, - "name": "onion" - }, - { - "id": 11, - "name": "sweetcorn" - }, - { - "id": 9, - "name": "pepper" - }, - { - "id": 5, - "name": "mozzarella" - } - ], - "id": 2 - }, { "name": "Plain Ol' Pepperoni", "toppings": [ diff --git a/src/products/store/effects/pizzas.effect.ts b/src/products/store/effects/pizzas.effect.ts index 5c31590b..558e5fe7 100644 --- a/src/products/store/effects/pizzas.effect.ts +++ b/src/products/store/effects/pizzas.effect.ts @@ -4,6 +4,7 @@ import { Effect, Actions } from '@ngrx/effects'; import { of } from 'rxjs/observable/of'; import { map, switchMap, catchError } from 'rxjs/operators'; +import * as fromRoot from '../../../app/store'; import * as pizzaActions from '../actions/pizzas.action'; import * as fromServices from '../../services'; @@ -39,6 +40,18 @@ export class PizzasEffects { }) ); + @Effect() + createPizzaSuccess$ = this.actions$ + .ofType(pizzaActions.CREATE_PIZZA_SUCCESS) + .pipe( + map((action: pizzaActions.CreatePizzaSuccess) => action.payload), + map(pizza => { + return new fromRoot.Go({ + path: ['/products', pizza.id], + }); + }) + ); + @Effect() updatePizza$ = this.actions$.ofType(pizzaActions.UPDATE_PIZZA).pipe( map((action: pizzaActions.UpdatePizza) => action.payload), @@ -64,4 +77,18 @@ export class PizzasEffects { ); }) ); + + @Effect() + handlePizzaSuccess$ = this.actions$ + .ofType( + pizzaActions.UPDATE_PIZZA_SUCCESS, + pizzaActions.REMOVE_PIZZA_SUCCESS + ) + .pipe( + map(pizza => { + return new fromRoot.Go({ + path: ['/products'], + }); + }) + ); } From be7acbdfa109b53cbc64460933de32893cd8f21b Mon Sep 17 00:00:00 2001 From: Todd Motto Date: Wed, 29 Nov 2017 19:19:58 +0000 Subject: [PATCH 20/26] Step 20: LoadPizzas Route Guard --- .../containers/products/products.component.ts | 1 - src/products/guards/index.ts | 5 +++ src/products/guards/pizzas.guard.ts | 33 +++++++++++++++++++ src/products/products.module.ts | 7 +++- 4 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 src/products/guards/index.ts create mode 100644 src/products/guards/pizzas.guard.ts diff --git a/src/products/containers/products/products.component.ts b/src/products/containers/products/products.component.ts index cf21e850..29ad8249 100755 --- a/src/products/containers/products/products.component.ts +++ b/src/products/containers/products/products.component.ts @@ -36,7 +36,6 @@ export class ProductsComponent implements OnInit { ngOnInit() { this.pizzas$ = this.store.select(fromStore.getAllPizzas); - this.store.dispatch(new fromStore.LoadPizzas()); this.store.dispatch(new fromStore.LoadToppings()); } } diff --git a/src/products/guards/index.ts b/src/products/guards/index.ts new file mode 100644 index 00000000..5fad13a3 --- /dev/null +++ b/src/products/guards/index.ts @@ -0,0 +1,5 @@ +import { PizzasGuard } from './pizzas.guard'; + +export const guards: any[] = [PizzasGuard]; + +export * from './pizzas.guard'; diff --git a/src/products/guards/pizzas.guard.ts b/src/products/guards/pizzas.guard.ts new file mode 100644 index 00000000..ae3379eb --- /dev/null +++ b/src/products/guards/pizzas.guard.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@angular/core'; +import { CanActivate } from '@angular/router'; + +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs/Observable'; +import { of } from 'rxjs/observable/of'; +import { tap, filter, take, switchMap, catchError } from 'rxjs/operators'; + +import * as fromStore from '../store'; + +@Injectable() +export class PizzasGuard implements CanActivate { + constructor(private store: Store) {} + + canActivate(): Observable { + return this.checkStore().pipe( + switchMap(() => of(true)), + catchError(() => of(false)) + ); + } + + checkStore(): Observable { + return this.store.select(fromStore.getPizzasLoaded).pipe( + tap(loaded => { + if (!loaded) { + this.store.dispatch(new fromStore.LoadPizzas()); + } + }), + filter(loaded => loaded), + take(1) + ); + } +} diff --git a/src/products/products.module.ts b/src/products/products.module.ts index 0d592b59..af68d4dc 100755 --- a/src/products/products.module.ts +++ b/src/products/products.module.ts @@ -15,6 +15,9 @@ import * as fromComponents from './components'; // containers import * as fromContainers from './containers'; +// guards +import * as fromGuards from './guards'; + // services import * as fromServices from './services'; @@ -22,10 +25,12 @@ import * as fromServices from './services'; export const ROUTES: Routes = [ { path: '', + canActivate: [fromGuards.PizzasGuard], component: fromContainers.ProductsComponent, }, { path: 'new', + canActivate: [fromGuards.PizzasGuard], component: fromContainers.ProductItemComponent, }, { @@ -43,7 +48,7 @@ export const ROUTES: Routes = [ StoreModule.forFeature('products', reducers), EffectsModule.forFeature(effects), ], - providers: [...fromServices.services], + providers: [...fromServices.services, ...fromGuards.guards], declarations: [...fromContainers.containers, ...fromComponents.components], exports: [...fromContainers.containers, ...fromComponents.components], }) From b66b89e84d3897314d2e96d6e3d2d3b1d8762ad1 Mon Sep 17 00:00:00 2001 From: Todd Motto Date: Wed, 29 Nov 2017 19:40:30 +0000 Subject: [PATCH 21/26] Step 21: Pizza exists guard --- src/products/guards/index.ts | 4 +- src/products/guards/pizza-exists.guard.ts | 45 +++++++++++++++++++++++ src/products/products.module.ts | 1 + 3 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 src/products/guards/pizza-exists.guard.ts diff --git a/src/products/guards/index.ts b/src/products/guards/index.ts index 5fad13a3..e568069a 100644 --- a/src/products/guards/index.ts +++ b/src/products/guards/index.ts @@ -1,5 +1,7 @@ import { PizzasGuard } from './pizzas.guard'; +import { PizzaExistsGuards } from './pizza-exists.guard'; -export const guards: any[] = [PizzasGuard]; +export const guards: any[] = [PizzasGuard, PizzaExistsGuards]; export * from './pizzas.guard'; +export * from './pizza-exists.guard'; diff --git a/src/products/guards/pizza-exists.guard.ts b/src/products/guards/pizza-exists.guard.ts new file mode 100644 index 00000000..c29480b4 --- /dev/null +++ b/src/products/guards/pizza-exists.guard.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@angular/core'; +import { CanActivate, ActivatedRouteSnapshot } from '@angular/router'; + +import { Store } from '@ngrx/store'; + +import { Observable } from 'rxjs/Observable'; +import { tap, map, filter, take, switchMap } from 'rxjs/operators'; +import * as fromStore from '../store'; + +import { Pizza } from '../models/pizza.model'; + +@Injectable() +export class PizzaExistsGuards implements CanActivate { + constructor(private store: Store) {} + + canActivate(route: ActivatedRouteSnapshot): Observable { + return this.checkStore().pipe( + switchMap(() => { + const id = parseInt(route.params.pizzaId, 10); + return this.hasPizza(id); + }) + ); + } + + hasPizza(id: number): Observable { + return this.store + .select(fromStore.getPizzasEntities) + .pipe( + map((entities: { [key: number]: Pizza }) => !!entities[id]), + take(1) + ); + } + + checkStore(): Observable { + return this.store.select(fromStore.getPizzasLoaded).pipe( + tap(loaded => { + if (!loaded) { + this.store.dispatch(new fromStore.LoadPizzas()); + } + }), + filter(loaded => loaded), + take(1) + ); + } +} diff --git a/src/products/products.module.ts b/src/products/products.module.ts index af68d4dc..0bd4f532 100755 --- a/src/products/products.module.ts +++ b/src/products/products.module.ts @@ -35,6 +35,7 @@ export const ROUTES: Routes = [ }, { path: ':pizzaId', + canActivate: [fromGuards.PizzaExistsGuards], component: fromContainers.ProductItemComponent, }, ]; From 2e4f28b3654478ddcb285d82f1f324c2bf57cec0 Mon Sep 17 00:00:00 2001 From: Todd Motto Date: Wed, 29 Nov 2017 22:55:42 +0000 Subject: [PATCH 22/26] Step 22: Toppings Guard --- db.json | 24 ++++++++------ .../containers/products/products.component.ts | 1 - src/products/guards/index.ts | 4 ++- src/products/guards/toppings.guard.ts | 33 +++++++++++++++++++ src/products/products.module.ts | 4 +-- 5 files changed, 52 insertions(+), 14 deletions(-) create mode 100644 src/products/guards/toppings.guard.ts diff --git a/db.json b/db.json index 76e5ad44..8366061e 100644 --- a/db.json +++ b/db.json @@ -53,6 +53,14 @@ { "name": "Blazin' Inferno", "toppings": [ + { + "id": 12, + "name": "tomato" + }, + { + "id": 4, + "name": "chili" + }, { "id": 10, "name": "pepperoni" @@ -62,20 +70,16 @@ "name": "pepper" }, { - "id": 3, - "name": "basil" - }, - { - "id": 4, - "name": "chili" + "id": 5, + "name": "mozzarella" }, { - "id": 7, - "name": "olive" + "id": 3, + "name": "basil" }, { - "id": 2, - "name": "bacon" + "id": 8, + "name": "onion" } ], "id": 1 diff --git a/src/products/containers/products/products.component.ts b/src/products/containers/products/products.component.ts index 29ad8249..3443e0aa 100755 --- a/src/products/containers/products/products.component.ts +++ b/src/products/containers/products/products.component.ts @@ -36,6 +36,5 @@ export class ProductsComponent implements OnInit { ngOnInit() { this.pizzas$ = this.store.select(fromStore.getAllPizzas); - this.store.dispatch(new fromStore.LoadToppings()); } } diff --git a/src/products/guards/index.ts b/src/products/guards/index.ts index e568069a..286d45a5 100644 --- a/src/products/guards/index.ts +++ b/src/products/guards/index.ts @@ -1,7 +1,9 @@ import { PizzasGuard } from './pizzas.guard'; +import { ToppingsGuard } from './toppings.guard'; import { PizzaExistsGuards } from './pizza-exists.guard'; -export const guards: any[] = [PizzasGuard, PizzaExistsGuards]; +export const guards: any[] = [PizzasGuard, ToppingsGuard, PizzaExistsGuards]; export * from './pizzas.guard'; +export * from './toppings.guard'; export * from './pizza-exists.guard'; diff --git a/src/products/guards/toppings.guard.ts b/src/products/guards/toppings.guard.ts new file mode 100644 index 00000000..97282154 --- /dev/null +++ b/src/products/guards/toppings.guard.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@angular/core'; +import { CanActivate } from '@angular/router'; + +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs/Observable'; +import { of } from 'rxjs/observable/of'; +import { tap, filter, take, switchMap, catchError } from 'rxjs/operators'; + +import * as fromStore from '../store'; + +@Injectable() +export class ToppingsGuard implements CanActivate { + constructor(private store: Store) {} + + canActivate(): Observable { + return this.checkStore().pipe( + switchMap(() => of(true)), + catchError(() => of(false)) + ); + } + + checkStore(): Observable { + return this.store.select(fromStore.getToppingsLoaded).pipe( + tap(loaded => { + if (!loaded) { + this.store.dispatch(new fromStore.LoadToppings()); + } + }), + filter(loaded => loaded), + take(1) + ); + } +} diff --git a/src/products/products.module.ts b/src/products/products.module.ts index 0bd4f532..29918bb6 100755 --- a/src/products/products.module.ts +++ b/src/products/products.module.ts @@ -30,12 +30,12 @@ export const ROUTES: Routes = [ }, { path: 'new', - canActivate: [fromGuards.PizzasGuard], + canActivate: [fromGuards.PizzasGuard, fromGuards.ToppingsGuard], component: fromContainers.ProductItemComponent, }, { path: ':pizzaId', - canActivate: [fromGuards.PizzaExistsGuards], + canActivate: [fromGuards.PizzaExistsGuards, fromGuards.ToppingsGuard], component: fromContainers.ProductItemComponent, }, ]; From 5bcef37229a655d784f279a73e13bc9fa37512dc Mon Sep 17 00:00:00 2001 From: Todd Motto Date: Thu, 30 Nov 2017 17:00:07 +0000 Subject: [PATCH 23/26] Step 23: Change Detection OnPush --- src/products/components/pizza-form/pizza-form.component.ts | 1 + src/products/containers/product-item/product-item.component.ts | 1 + src/products/containers/products/products.component.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/src/products/components/pizza-form/pizza-form.component.ts b/src/products/components/pizza-form/pizza-form.component.ts index eca53fb4..7f0eb288 100755 --- a/src/products/components/pizza-form/pizza-form.component.ts +++ b/src/products/components/pizza-form/pizza-form.component.ts @@ -22,6 +22,7 @@ import { Topping } from '../../models/topping.model'; @Component({ selector: 'pizza-form', + changeDetection: ChangeDetectionStrategy.OnPush, styleUrls: ['pizza-form.component.scss'], template: `
diff --git a/src/products/containers/product-item/product-item.component.ts b/src/products/containers/product-item/product-item.component.ts index 58d3c03b..eb69af3c 100755 --- a/src/products/containers/product-item/product-item.component.ts +++ b/src/products/containers/product-item/product-item.component.ts @@ -10,6 +10,7 @@ import { Topping } from '../../models/topping.model'; @Component({ selector: 'product-item', + changeDetection: ChangeDetectionStrategy.OnPush, styleUrls: ['product-item.component.scss'], template: `
From 83b23fe7bb6ed3e289c4e5cad154dc571f4deb14 Mon Sep 17 00:00:00 2001 From: Todd Motto Date: Thu, 30 Nov 2017 18:01:31 +0000 Subject: [PATCH 24/26] Step 24: Testing Action Creators --- .../store/actions/pizzas.action.spec.ts | 219 ++++++++++++++++++ .../store/actions/toppings.action.spec.ts | 54 +++++ 2 files changed, 273 insertions(+) create mode 100644 src/products/store/actions/pizzas.action.spec.ts create mode 100644 src/products/store/actions/toppings.action.spec.ts diff --git a/src/products/store/actions/pizzas.action.spec.ts b/src/products/store/actions/pizzas.action.spec.ts new file mode 100644 index 00000000..21e99b43 --- /dev/null +++ b/src/products/store/actions/pizzas.action.spec.ts @@ -0,0 +1,219 @@ +import * as fromPizzas from './pizzas.action'; + +describe('Pizzas Actions', () => { + describe('LoadPizzas Actions', () => { + describe('LoadPizzas', () => { + it('should create an action', () => { + const action = new fromPizzas.LoadPizzas(); + + expect({ ...action }).toEqual({ + type: fromPizzas.LOAD_PIZZAS, + }); + }); + }); + + describe('LoadPizzasFail', () => { + it('should create an action', () => { + const payload = { message: 'Load Error' }; + const action = new fromPizzas.LoadPizzasFail(payload); + + expect({ ...action }).toEqual({ + type: fromPizzas.LOAD_PIZZAS_FAIL, + payload, + }); + }); + }); + + describe('LoadPizzasSuccess', () => { + it('should create an action', () => { + const payload = [ + { + id: 1, + name: 'Pizza #1', + toppings: [ + { id: 1, name: 'onion' }, + { id: 2, name: 'mushroom' }, + { id: 3, name: 'basil' }, + ], + }, + { + id: 2, + name: 'Pizza #2', + toppings: [ + { id: 1, name: 'onion' }, + { id: 2, name: 'mushroom' }, + { id: 3, name: 'basil' }, + ], + }, + ]; + const action = new fromPizzas.LoadPizzasSuccess(payload); + + expect({ ...action }).toEqual({ + type: fromPizzas.LOAD_PIZZAS_SUCCESS, + payload, + }); + }); + }); + }); + + describe('CreatePizza Actions', () => { + describe('CreatePizza', () => { + it('should create an action', () => { + const payload = { + name: 'Pizza #2', + toppings: [ + { id: 1, name: 'onion' }, + { id: 2, name: 'mushroom' }, + { id: 3, name: 'basil' }, + ], + }; + const action = new fromPizzas.CreatePizza(payload); + + expect({ ...action }).toEqual({ + type: fromPizzas.CREATE_PIZZA, + payload, + }); + }); + }); + + describe('CreatePizzaFail', () => { + it('should create an action', () => { + const payload = { message: 'Create Error' }; + const action = new fromPizzas.CreatePizzaFail(payload); + + expect({ ...action }).toEqual({ + type: fromPizzas.CREATE_PIZZA_FAIL, + payload, + }); + }); + }); + + describe('CreatePizzaSuccess', () => { + it('should create an action', () => { + const payload = { + id: 2, + name: 'Pizza #2', + toppings: [ + { id: 1, name: 'onion' }, + { id: 2, name: 'mushroom' }, + { id: 3, name: 'basil' }, + ], + }; + const action = new fromPizzas.CreatePizzaSuccess(payload); + + expect({ ...action }).toEqual({ + type: fromPizzas.CREATE_PIZZA_SUCCESS, + payload, + }); + }); + }); + }); + + describe('UpdatePizza Actions', () => { + describe('UpdatePizza', () => { + it('should create an action', () => { + const payload = { + id: 2, + name: 'Pizza #2', + toppings: [ + { id: 1, name: 'onion' }, + { id: 2, name: 'mushroom' }, + { id: 3, name: 'basil' }, + ], + }; + const action = new fromPizzas.UpdatePizza(payload); + + expect({ ...action }).toEqual({ + type: fromPizzas.UPDATE_PIZZA, + payload, + }); + }); + }); + + describe('UpdatePizzaFail', () => { + it('should create an action', () => { + const payload = { message: 'Update Error' }; + const action = new fromPizzas.UpdatePizzaFail(payload); + + expect({ ...action }).toEqual({ + type: fromPizzas.UPDATE_PIZZA_FAIL, + payload, + }); + }); + }); + + describe('UpdatePizzaSuccess', () => { + it('should create an action', () => { + const payload = { + id: 2, + name: 'Pizza #2', + toppings: [ + { id: 1, name: 'onion' }, + { id: 2, name: 'mushroom' }, + { id: 3, name: 'basil' }, + ], + }; + const action = new fromPizzas.UpdatePizzaSuccess(payload); + + expect({ ...action }).toEqual({ + type: fromPizzas.UPDATE_PIZZA_SUCCESS, + payload, + }); + }); + }); + }); + + describe('RemovePizza Actions', () => { + describe('RemovePizza', () => { + it('should create an action', () => { + const payload = { + id: 2, + name: 'Pizza #2', + toppings: [ + { id: 1, name: 'onion' }, + { id: 2, name: 'mushroom' }, + { id: 3, name: 'basil' }, + ], + }; + const action = new fromPizzas.RemovePizza(payload); + + expect({ ...action }).toEqual({ + type: fromPizzas.REMOVE_PIZZA, + payload, + }); + }); + }); + + describe('RemovePizzaFail', () => { + it('should create an action', () => { + const payload = { message: 'Remove Error' }; + const action = new fromPizzas.RemovePizzaFail(payload); + + expect({ ...action }).toEqual({ + type: fromPizzas.REMOVE_PIZZA_FAIL, + payload, + }); + }); + }); + + describe('RemovePizzaSuccess', () => { + it('should create an action', () => { + const payload = { + id: 2, + name: 'Pizza #2', + toppings: [ + { id: 1, name: 'onion' }, + { id: 2, name: 'mushroom' }, + { id: 3, name: 'basil' }, + ], + }; + const action = new fromPizzas.RemovePizzaSuccess(payload); + + expect({ ...action }).toEqual({ + type: fromPizzas.REMOVE_PIZZA_SUCCESS, + payload, + }); + }); + }); + }); +}); diff --git a/src/products/store/actions/toppings.action.spec.ts b/src/products/store/actions/toppings.action.spec.ts new file mode 100644 index 00000000..daeb03e3 --- /dev/null +++ b/src/products/store/actions/toppings.action.spec.ts @@ -0,0 +1,54 @@ +import * as fromToppings from './toppings.action'; + +describe('Toppings Actions', () => { + describe('LoadToppings Actions', () => { + describe('LoadToppings', () => { + it('should create an action', () => { + const action = new fromToppings.LoadToppings(); + expect({ ...action }).toEqual({ + type: fromToppings.LOAD_TOPPINGS, + }); + }); + }); + + describe('LoadToppingsFail', () => { + it('should create an action', () => { + const payload = { message: 'Load Error' }; + const action = new fromToppings.LoadToppingsFail(payload); + + expect({ ...action }).toEqual({ + type: fromToppings.LOAD_TOPPINGS_FAIL, + payload, + }); + }); + }); + + describe('LoadToppingsSuccess', () => { + it('should create an action', () => { + const payload = [ + { id: 1, name: 'onion' }, + { id: 2, name: 'mushroom' }, + { id: 3, name: 'basil' }, + ]; + const action = new fromToppings.LoadToppingsSuccess(payload); + + expect({ ...action }).toEqual({ + type: fromToppings.LOAD_TOPPINGS_SUCCESS, + payload, + }); + }); + }); + }); + + describe('VisualiseToppings Actions', () => { + describe('VisualiseToppings', () => { + it('should create an action', () => { + const action = new fromToppings.VisualiseToppings([1, 2, 3]); + expect({ ...action }).toEqual({ + type: fromToppings.VISUALISE_TOPPINGS, + payload: [1, 2, 3], + }); + }); + }); + }); +}); From d93dd7a80dd13cbdab1ef6a0f162e77ba27f70c9 Mon Sep 17 00:00:00 2001 From: Todd Motto Date: Sun, 3 Dec 2017 18:59:25 +0000 Subject: [PATCH 25/26] Step 25: Testing Reducers --- .../store/reducers/pizzas.reducer.spec.ts | 172 ++++++++++++++++++ .../store/reducers/toppings.reducer.spec.ts | 133 ++++++++++++++ 2 files changed, 305 insertions(+) create mode 100644 src/products/store/reducers/pizzas.reducer.spec.ts create mode 100644 src/products/store/reducers/toppings.reducer.spec.ts diff --git a/src/products/store/reducers/pizzas.reducer.spec.ts b/src/products/store/reducers/pizzas.reducer.spec.ts new file mode 100644 index 00000000..db3ae145 --- /dev/null +++ b/src/products/store/reducers/pizzas.reducer.spec.ts @@ -0,0 +1,172 @@ +import * as fromPizzas from './pizzas.reducer'; +import * as fromActions from '../actions/pizzas.action'; +import { Pizza } from '../../models/pizza.model'; + +describe('PizzasReducer', () => { + describe('undefined action', () => { + it('should return the default state', () => { + const { initialState } = fromPizzas; + const action = {} as any; + const state = fromPizzas.reducer(undefined, action); + + expect(state).toBe(initialState); + }); + }); + + describe('LOAD_PIZZAS action', () => { + it('should set loading to true', () => { + const { initialState } = fromPizzas; + const action = new fromActions.LoadPizzas(); + const state = fromPizzas.reducer(initialState, action); + + expect(state.loading).toEqual(true); + expect(state.loaded).toEqual(false); + expect(state.entities).toEqual({}); + }); + }); + + describe('LOAD_PIZZAS_SUCCESS action', () => { + it('should populate the toppings array', () => { + const pizzas: Pizza[] = [ + { id: 1, name: 'Pizza #1', toppings: [] }, + { id: 2, name: 'Pizza #2', toppings: [] }, + ]; + const entities = { + 1: pizzas[0], + 2: pizzas[1], + }; + const { initialState } = fromPizzas; + const action = new fromActions.LoadPizzasSuccess(pizzas); + const state = fromPizzas.reducer(initialState, action); + + expect(state.loaded).toEqual(true); + expect(state.loading).toEqual(false); + expect(state.entities).toEqual(entities); + }); + }); + + describe('LOAD_PIZZAS_FAIL action', () => { + it('should return the initial state', () => { + const { initialState } = fromPizzas; + const action = new fromActions.LoadPizzasFail({}); + const state = fromPizzas.reducer(initialState, action); + + expect(state).toEqual(initialState); + }); + + it('should return the previous state', () => { + const { initialState } = fromPizzas; + const previousState = { ...initialState, loading: true }; + const action = new fromActions.LoadPizzasFail({}); + const state = fromPizzas.reducer(previousState, action); + + expect(state).toEqual(initialState); + }); + }); + + describe('CREATE_PIZZA_SUCCESS action', () => { + it('should add the new pizza to the pizzas array', () => { + const pizzas: Pizza[] = [ + { id: 1, name: 'Pizza #1', toppings: [] }, + { id: 2, name: 'Pizza #2', toppings: [] }, + ]; + const newPizza: Pizza = { + id: 3, + name: 'Pizza #3', + toppings: [], + }; + const entities = { + 1: pizzas[0], + 2: pizzas[1], + }; + const { initialState } = fromPizzas; + const previousState = { ...initialState, entities }; + const action = new fromActions.CreatePizzaSuccess(newPizza); + const state = fromPizzas.reducer(previousState, action); + + expect(Object.keys(state.entities).length).toEqual(3); + expect(state.entities).toEqual({ ...entities, 3: newPizza }); + }); + }); + + describe('UPDATE_PIZZA_SUCCESS action', () => { + it('should update the pizza', () => { + const pizzas: Pizza[] = [ + { id: 1, name: 'Pizza #1', toppings: [] }, + { id: 2, name: 'Pizza #2', toppings: [] }, + ]; + const updatedPizza = { + id: 2, + name: 'Pizza #2', + toppings: [{ id: 1, name: 'basil' }], + }; + const entities = { + 1: pizzas[0], + 2: pizzas[1], + }; + const { initialState } = fromPizzas; + const previousState = { ...initialState, entities }; + const action = new fromActions.UpdatePizzaSuccess(updatedPizza); + const state = fromPizzas.reducer(previousState, action); + + expect(Object.keys(state.entities).length).toEqual(2); + expect(state.entities).toEqual({ ...entities, 2: updatedPizza }); + }); + }); + + describe('REMOVE_PIZZA_SUCCESS action', () => { + it('should remove the pizza', () => { + const pizzas: Pizza[] = [ + { id: 1, name: 'Pizza #1', toppings: [] }, + { id: 2, name: 'Pizza #2', toppings: [] }, + ]; + const entities = { + 1: pizzas[0], + 2: pizzas[1], + }; + const { initialState } = fromPizzas; + const previousState = { ...initialState, entities }; + const action = new fromActions.RemovePizzaSuccess(pizzas[0]); + const state = fromPizzas.reducer(previousState, action); + + expect(Object.keys(state.entities).length).toEqual(1); + expect(state.entities).toEqual({ 2: pizzas[1] }); + }); + }); +}); + +describe('PizzasReducer Selectors', () => { + describe('getPizzaEntities', () => { + it('should return .entities', () => { + const entities: { [key: number]: Pizza } = { + 1: { id: 1, name: 'Pizza #1', toppings: [] }, + 2: { id: 2, name: 'Pizza #2', toppings: [] }, + }; + const { initialState } = fromPizzas; + const previousState = { ...initialState, entities }; + const slice = fromPizzas.getPizzasEntities(previousState); + + expect(slice).toEqual(entities); + }); + }); + + describe('getPizzasLoading', () => { + it('should return .loading', () => { + const { initialState } = fromPizzas; + const previousState = { ...initialState, loading: true }; + const slice = fromPizzas.getPizzasLoading(previousState); + + expect(slice).toEqual(true); + }); + }); + + describe('getPizzasLoaded', () => { + it('should return .loaded', () => { + const { initialState } = fromPizzas; + const previousState = { ...initialState, loaded: true }; + const slice = fromPizzas.getPizzasLoaded(previousState); + + expect(slice).toEqual(true); + }); + }); +}); diff --git a/src/products/store/reducers/toppings.reducer.spec.ts b/src/products/store/reducers/toppings.reducer.spec.ts new file mode 100644 index 00000000..9538998c --- /dev/null +++ b/src/products/store/reducers/toppings.reducer.spec.ts @@ -0,0 +1,133 @@ +import * as fromToppings from './toppings.reducer'; +import * as fromActions from '../actions/toppings.action'; +import { Topping } from '../../models/topping.model'; + +describe('ToppingsReducer', () => { + describe('undefined action', () => { + it('should return the default state', () => { + const { initialState } = fromToppings; + const action = {} as any; + const state = fromToppings.reducer(undefined, action); + + expect(state).toBe(initialState); + }); + }); + + describe('LOAD_TOPPINGS action', () => { + it('should set loading to true', () => { + const { initialState } = fromToppings; + const action = new fromActions.LoadToppings(); + const state = fromToppings.reducer(initialState, action); + + expect(state.loading).toEqual(true); + expect(state.loaded).toEqual(false); + expect(state.entities).toEqual({}); + }); + }); + + describe('LOAD_TOPPINGS_SUCCESS action', () => { + it('should populate the toppings array', () => { + const toppings: Topping[] = [ + { id: 1, name: 'bacon' }, + { id: 2, name: 'pepperoni' }, + { id: 3, name: 'tomato' }, + ]; + const entities = { + 1: toppings[0], + 2: toppings[1], + 3: toppings[2], + }; + const { initialState } = fromToppings; + const action = new fromActions.LoadToppingsSuccess(toppings); + const state = fromToppings.reducer(initialState, action); + + expect(state.loaded).toEqual(true); + expect(state.loading).toEqual(false); + expect(state.entities).toEqual(entities); + }); + }); + + describe('LOAD_TOPPINGS_FAIL action', () => { + it('should return the initial state', () => { + const { initialState } = fromToppings; + const action = new fromActions.LoadToppingsFail({}); + const state = fromToppings.reducer(initialState, action); + + expect(state).toEqual(initialState); + }); + it('should return the previous state', () => { + const { initialState } = fromToppings; + const previousState = { ...initialState, loading: true }; + const action = new fromActions.LoadToppingsFail({}); + const state = fromToppings.reducer(previousState, action); + expect(state).toEqual(initialState); + }); + }); + + describe('VISUALISE_TOPPINGS action', () => { + it('should set an array of number ids', () => { + const { initialState } = fromToppings; + const action = new fromActions.VisualiseToppings([1, 5, 9]); + const state = fromToppings.reducer(initialState, action); + + expect(state.selectedToppings).toEqual([1, 5, 9]); + }); + }); +}); + +describe('PizzasReducer Selectors', () => { + describe('getToppingEntities', () => { + it('should return .entities', () => { + const entities: { [key: number]: Topping } = { + 1: { id: 1, name: 'bacon' }, + 2: { id: 2, name: 'pepperoni' }, + }; + const { initialState } = fromToppings; + const previousState = { ...initialState, entities }; + const slice = fromToppings.getToppingEntities(previousState); + + expect(slice).toEqual(entities); + }); + }); + + describe('getSelectedToppings', () => { + it('should return .selectedToppings', () => { + const selectedToppings = [1, 2, 3, 4, 5]; + const { initialState } = fromToppings; + const previousState = { ...initialState, selectedToppings }; + const slice = fromToppings.getSelectedToppings(previousState); + + expect(slice).toEqual(selectedToppings); + }); + }); + + describe('getToppingsLoading', () => { + it('should return .loading', () => { + const { initialState } = fromToppings; + const previousState = { ...initialState, loading: true }; + const slice = fromToppings.getToppingsLoading(previousState); + + expect(slice).toEqual(true); + }); + }); + + describe('getToppingsLoaded', () => { + it('should return .loaded', () => { + const { initialState } = fromToppings; + const previousState = { ...initialState, loaded: true }; + const slice = fromToppings.getToppingsLoaded(previousState); + + expect(slice).toEqual(true); + }); + }); + + describe('getSelectedToppings', () => { + it('should return .selectedToppings', () => { + const { initialState } = fromToppings; + const previousState = { ...initialState }; + const slice = fromToppings.getSelectedToppings(previousState); + + expect(slice).toEqual([]); + }); + }); +}); From 7558f2231f78c5e8fe81de9106848b810afa2dfa Mon Sep 17 00:00:00 2001 From: Todd Motto Date: Sun, 3 Dec 2017 19:34:29 +0000 Subject: [PATCH 26/26] Step 26: Testing Selectors --- .../store/selectors/pizzas.selectors.spec.ts | 233 ++++++++++++++++++ .../selectors/toppings.selectors.spec.ts | 122 +++++++++ 2 files changed, 355 insertions(+) create mode 100644 src/products/store/selectors/pizzas.selectors.spec.ts create mode 100644 src/products/store/selectors/toppings.selectors.spec.ts diff --git a/src/products/store/selectors/pizzas.selectors.spec.ts b/src/products/store/selectors/pizzas.selectors.spec.ts new file mode 100644 index 00000000..05da0227 --- /dev/null +++ b/src/products/store/selectors/pizzas.selectors.spec.ts @@ -0,0 +1,233 @@ +import { StoreModule, Store, combineReducers } from '@ngrx/store'; +import { ROUTER_NAVIGATION } from '@ngrx/router-store'; + +import { TestBed } from '@angular/core/testing'; +import { Pizza } from '../../models/pizza.model'; + +import * as fromRoot from '../../../app/store'; +import * as fromReducers from '../reducers/index'; +import * as fromActions from '../actions/index'; +import * as fromSelectors from '../selectors/pizzas.selectors'; + +describe('Pizzas Selectors', () => { + let store: Store; + + const pizza1: Pizza = { + id: 1, + name: "Fish 'n Chips", + toppings: [ + { id: 1, name: 'fish' }, + { id: 2, name: 'chips' }, + { id: 3, name: 'cheese' }, + ], + }; + + const pizza2: Pizza = { + id: 2, + name: 'Aloha', + toppings: [ + { id: 1, name: 'ham' }, + { id: 2, name: 'pineapple' }, + { id: 3, name: 'cheese' }, + ], + }; + + const pizza3: Pizza = { + id: 3, + name: 'Burrito', + toppings: [ + { id: 1, name: 'beans' }, + { id: 2, name: 'beef' }, + { id: 3, name: 'rice' }, + { id: 4, name: 'cheese' }, + { id: 5, name: 'avocado' }, + ], + }; + + const pizzas: Pizza[] = [pizza1, pizza2, pizza3]; + + const entities = { + 1: pizzas[0], + 2: pizzas[1], + 3: pizzas[2], + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ + ...fromRoot.reducers, + products: combineReducers(fromReducers.reducers), + }), + ], + }); + + store = TestBed.get(Store); + }); + + describe('getPizzaState', () => { + it('should return state of pizza store slice', () => { + let result; + + store + .select(fromSelectors.getPizzaState) + .subscribe(value => (result = value)); + + expect(result).toEqual({ + entities: {}, + loaded: false, + loading: false, + }); + + store.dispatch(new fromActions.LoadPizzasSuccess(pizzas)); + + expect(result).toEqual({ + entities, + loaded: true, + loading: false, + }); + }); + }); + + describe('getPizzaEntities', () => { + it('should return pizzas as entities', () => { + let result; + + store + .select(fromSelectors.getPizzasEntities) + .subscribe(value => (result = value)); + + expect(result).toEqual({}); + + store.dispatch(new fromActions.LoadPizzasSuccess(pizzas)); + + expect(result).toEqual(entities); + }); + }); + + describe('getSelectedPizza', () => { + it('should return selected pizza as an entity', () => { + let result; + let params; + + store.dispatch(new fromActions.LoadPizzasSuccess(pizzas)); + + store.dispatch({ + type: 'ROUTER_NAVIGATION', + payload: { + routerState: { + url: '/products', + queryParams: {}, + params: { pizzaId: '2' }, + }, + event: {}, + }, + }); + + store + .select(fromRoot.getRouterState) + .subscribe(routerState => (params = routerState.state.params)); + + expect(params).toEqual({ pizzaId: '2' }); + + store + .select(fromSelectors.getSelectedPizza) + .subscribe(selectedPizza => (result = selectedPizza)); + + expect(result).toEqual(entities[2]); + }); + }); + + describe('getPizzaVisualised', () => { + it('should return selected pizza composed with selected toppings', () => { + let result; + let params; + const toppings = [ + { + id: 6, + name: 'mushroom', + }, + { + id: 9, + name: 'pepper', + }, + { + id: 11, + name: 'sweetcorn', + }, + ]; + + store.dispatch(new fromActions.LoadPizzasSuccess(pizzas)); + store.dispatch(new fromActions.LoadToppingsSuccess(toppings)); + store.dispatch(new fromActions.VisualiseToppings([11, 9, 6])); + + store.dispatch({ + type: 'ROUTER_NAVIGATION', + payload: { + routerState: { + url: '/products', + queryParams: {}, + params: { pizzaId: '2' }, + }, + event: {}, + }, + }); + + store + .select(fromSelectors.getPizzaVisualised) + .subscribe(selectedPizza => (result = selectedPizza)); + + const expectedToppings = [toppings[2], toppings[1], toppings[0]]; + + expect(result).toEqual({ ...entities[2], toppings: expectedToppings }); + }); + }); + + describe('getAllPizzas', () => { + it('should return pizzas as an array', () => { + let result; + + store + .select(fromSelectors.getAllPizzas) + .subscribe(value => (result = value)); + + expect(result).toEqual([]); + + store.dispatch(new fromActions.LoadPizzasSuccess(pizzas)); + + expect(result).toEqual(pizzas); + }); + }); + + describe('getPizzasLoaded', () => { + it('should return the pizzas loaded state', () => { + let result; + + store + .select(fromSelectors.getPizzasLoaded) + .subscribe(value => (result = value)); + + expect(result).toEqual(false); + + store.dispatch(new fromActions.LoadPizzasSuccess([])); + + expect(result).toEqual(true); + }); + }); + + describe('getPizzasLoading', () => { + it('should return the pizzas loading state', () => { + let result; + + store + .select(fromSelectors.getPizzasLoading) + .subscribe(value => (result = value)); + + expect(result).toEqual(false); + + store.dispatch(new fromActions.LoadPizzas()); + + expect(result).toEqual(true); + }); + }); +}); diff --git a/src/products/store/selectors/toppings.selectors.spec.ts b/src/products/store/selectors/toppings.selectors.spec.ts new file mode 100644 index 00000000..d18d5d60 --- /dev/null +++ b/src/products/store/selectors/toppings.selectors.spec.ts @@ -0,0 +1,122 @@ +import { TestBed } from '@angular/core/testing'; +import { StoreModule, Store, combineReducers } from '@ngrx/store'; + +import * as fromRoot from '../../../app/store/reducers'; +import * as fromReducers from '../reducers'; +import * as fromActions from '../actions'; +import * as fromSelectors from '../selectors/toppings.selectors'; + +import { Topping } from '../../models/topping.model'; + +describe('ToppingsReducer Selectors', () => { + let store: Store; + + const toppings: Topping[] = [ + { id: 1, name: 'bacon' }, + { id: 2, name: 'pepperoni' }, + { id: 3, name: 'tomato' }, + ]; + + const entities = { + 1: toppings[0], + 2: toppings[1], + 3: toppings[2], + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ + ...fromRoot.reducers, + products: combineReducers(fromReducers.reducers), + }), + ], + }); + + store = TestBed.get(Store); + + spyOn(store, 'dispatch').and.callThrough(); + }); + + describe('getToppingEntities', () => { + it('should return toppings as entities', () => { + let result; + + store + .select(fromSelectors.getToppingEntities) + .subscribe(value => (result = value)); + + expect(result).toEqual({}); + + store.dispatch(new fromActions.LoadToppingsSuccess(toppings)); + + expect(result).toEqual(entities); + }); + }); + + describe('getSelectedToppings', () => { + it('should return selected toppings as ids', () => { + let result; + + store + .select(fromSelectors.getSelectedToppings) + .subscribe(value => (result = value)); + + store.dispatch(new fromActions.LoadToppingsSuccess(toppings)); + + expect(result).toEqual([]); + + store.dispatch(new fromActions.VisualiseToppings([1, 3])); + + expect(result).toEqual([1, 3]); + }); + }); + + describe('getAllToppings', () => { + it('should return toppings as an array', () => { + let result; + + store + .select(fromSelectors.getAllToppings) + .subscribe(value => (result = value)); + + expect(result).toEqual([]); + + store.dispatch(new fromActions.LoadToppingsSuccess(toppings)); + + expect(result).toEqual(toppings); + }); + }); + + describe('getToppingsLoaded', () => { + it('should return the toppings loaded state', () => { + let result; + + store + .select(fromSelectors.getToppingsLoaded) + .subscribe(value => (result = value)); + + expect(result).toEqual(false); + + store.dispatch(new fromActions.LoadToppingsSuccess([])); + + expect(result).toEqual(true); + }); + }); + + describe('getToppingsLoading', () => { + it('should return the toppings loading state', () => { + let result; + + store + .select(fromSelectors.getToppingsLoading) + .subscribe(value => (result = value)); + + expect(result).toEqual(false); + + store.dispatch(new fromActions.LoadToppings()); + + expect(result).toEqual(true); + }); + }); +});