diff --git a/db.json b/db.json old mode 100755 new mode 100644 index 6abe7b71..8366061e --- a/db.json +++ b/db.json @@ -54,73 +54,35 @@ "name": "Blazin' Inferno", "toppings": [ { - "id": 10, - "name": "pepperoni" - }, - { - "id": 9, - "name": "pepper" - }, - { - "id": 3, - "name": "basil" + "id": 12, + "name": "tomato" }, { "id": 4, "name": "chili" }, { - "id": 7, - "name": "olive" - }, - { - "id": 2, - "name": "bacon" - } - ], - "id": 1 - }, - { - "name": "Seaside Surfin'", - "toppings": [ - { - "id": 6, - "name": "mushroom" + "id": 10, + "name": "pepperoni" }, { - "id": 7, - "name": "olive" + "id": 9, + "name": "pepper" }, { - "id": 2, - "name": "bacon" + "id": 5, + "name": "mozzarella" }, { "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 + "id": 1 }, { "name": "Plain Ol' Pepperoni", diff --git a/src/app/app.module.ts b/src/app/app.module.ts index cbbfee0e..6f7c9ed7 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -3,9 +3,15 @@ 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, effects, CustomSerializer } from './store'; + // not used in production import { StoreDevtoolsModule } from '@ngrx/store-devtools'; import { storeFreeze } from 'ngrx-store-freeze'; @@ -37,10 +43,12 @@ export const ROUTES: Routes = [ BrowserModule, BrowserAnimationsModule, RouterModule.forRoot(ROUTES), - StoreModule.forRoot({}, { metaReducers }), - EffectsModule.forRoot([]), + StoreModule.forRoot(reducers, { metaReducers }), + EffectsModule.forRoot(effects), + StoreRouterConnectingModule, environment.development ? StoreDevtoolsModule.instrument() : [], ], + providers: [{ provide: RouterStateSerializer, useClass: CustomSerializer }], declarations: [AppComponent], bootstrap: [AppComponent], }) 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 new file mode 100644 index 00000000..115467ce --- /dev/null +++ b/src/app/store/index.ts @@ -0,0 +1,3 @@ +export * from './reducers'; +export * from './actions'; +export * from './effects'; diff --git a/src/app/store/reducers/index.ts b/src/app/store/reducers/index.ts new file mode 100644 index 00000000..59cd70be --- /dev/null +++ b/src/app/store/reducers/index.ts @@ -0,0 +1,42 @@ +import { + ActivatedRouteSnapshot, + RouterStateSnapshot, + 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'); + +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 }; + } +} 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 4eb925f3..eb69af3c 100755 --- a/src/products/containers/product-item/product-item.component.ts +++ b/src/products/containers/product-item/product-item.component.ts @@ -1,91 +1,71 @@ 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 { tap } from 'rxjs/operators'; +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', + changeDetection: ChangeDetectionStrategy.OnPush, styleUrls: ['product-item.component.scss'], template: `
+ [pizza]="visualise$ | async">
`, }) export class ProductItemComponent implements OnInit { - pizza: Pizza; - visualise: Pizza; - toppings: Topping[]; + pizza$: Observable; + visualise$: Observable; + toppings$: Observable; - 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).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[]) { - 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 }; + this.store.dispatch(new fromStore.VisualiseToppings(event)); } onCreate(event: Pizza) { - this.pizzaService.createPizza(event).subscribe(pizza => { - this.router.navigate([`/products/${pizza.id}`]); - }); + this.store.dispatch(new fromStore.CreatePizza(event)); } onUpdate(event: Pizza) { - this.pizzaService.updatePizza(event).subscribe(() => { - this.router.navigate([`/products`]); - }); + this.store.dispatch(new fromStore.UpdatePizza(event)); } onRemove(event: Pizza) { const remove = window.confirm('Are you sure?'); if (remove) { - this.pizzaService.removePizza(event).subscribe(() => { - this.router.navigate([`/products`]); - }); + this.store.dispatch(new fromStore.RemovePizza(event)); } } } diff --git a/src/products/containers/products/products.component.ts b/src/products/containers/products/products.component.ts index 3f2c91dc..749a65a7 100755 --- a/src/products/containers/products/products.component.ts +++ b/src/products/containers/products/products.component.ts @@ -1,10 +1,13 @@ 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', + changeDetection: ChangeDetectionStrategy.OnPush, styleUrls: ['products.component.scss'], template: `
@@ -16,11 +19,11 @@ import { PizzasService } from '../../services/pizzas.service';
-
+
No pizzas, add one to get started.
@@ -28,13 +31,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/guards/index.ts b/src/products/guards/index.ts new file mode 100644 index 00000000..286d45a5 --- /dev/null +++ b/src/products/guards/index.ts @@ -0,0 +1,9 @@ +import { PizzasGuard } from './pizzas.guard'; +import { ToppingsGuard } from './toppings.guard'; +import { PizzaExistsGuards } from './pizza-exists.guard'; + +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/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/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/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 31aa1e24..29918bb6 100755 --- a/src/products/products.module.ts +++ b/src/products/products.module.ts @@ -4,12 +4,20 @@ import { Routes, RouterModule } from '@angular/router'; import { ReactiveFormsModule } from '@angular/forms'; import { HttpClientModule } from '@angular/common/http'; +import { StoreModule } from '@ngrx/store'; +import { EffectsModule } from '@ngrx/effects'; + +import { reducers, effects } from './store'; + // components import * as fromComponents from './components'; // containers import * as fromContainers from './containers'; +// guards +import * as fromGuards from './guards'; + // services import * as fromServices from './services'; @@ -17,14 +25,17 @@ import * as fromServices from './services'; export const ROUTES: Routes = [ { path: '', + canActivate: [fromGuards.PizzasGuard], component: fromContainers.ProductsComponent, }, { - path: ':id', + path: 'new', + canActivate: [fromGuards.PizzasGuard, fromGuards.ToppingsGuard], component: fromContainers.ProductItemComponent, }, { - path: 'new', + path: ':pizzaId', + canActivate: [fromGuards.PizzaExistsGuards, fromGuards.ToppingsGuard], component: fromContainers.ProductItemComponent, }, ]; @@ -35,8 +46,10 @@ export const ROUTES: Routes = [ ReactiveFormsModule, HttpClientModule, RouterModule.forChild(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], }) diff --git a/src/products/store/actions/index.ts b/src/products/store/actions/index.ts new file mode 100644 index 00000000..22d481b5 --- /dev/null +++ b/src/products/store/actions/index.ts @@ -0,0 +1,2 @@ +export * from './pizzas.action'; +export * from './toppings.action'; 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/pizzas.action.ts b/src/products/store/actions/pizzas.action.ts new file mode 100644 index 00000000..8f1ecdb9 --- /dev/null +++ b/src/products/store/actions/pizzas.action.ts @@ -0,0 +1,97 @@ +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[]) {} +} + +// 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) {} +} + +// 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) {} +} + +// 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 + | LoadPizzasFail + | LoadPizzasSuccess + | CreatePizza + | CreatePizzaFail + | CreatePizzaSuccess + | UpdatePizza + | UpdatePizzaFail + | UpdatePizzaSuccess + | RemovePizza + | RemovePizzaFail + | RemovePizzaSuccess; 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], + }); + }); + }); + }); +}); diff --git a/src/products/store/actions/toppings.action.ts b/src/products/store/actions/toppings.action.ts new file mode 100644 index 00000000..bf83db5d --- /dev/null +++ b/src/products/store/actions/toppings.action.ts @@ -0,0 +1,34 @@ +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 const VISUALISE_TOPPINGS = '[Products] Visualise Toppings'; + +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[]) {} +} + +export class VisualiseToppings implements Action { + readonly type = VISUALISE_TOPPINGS; + constructor(public payload: number[]) {} +} + +// action types +export type ToppingsAction = + | LoadToppings + | LoadToppingsFail + | LoadToppingsSuccess + | VisualiseToppings; diff --git a/src/products/store/effects/index.ts b/src/products/store/effects/index.ts new file mode 100644 index 00000000..13910755 --- /dev/null +++ b/src/products/store/effects/index.ts @@ -0,0 +1,7 @@ +import { PizzasEffects } from './pizzas.effect'; +import { ToppingsEffects } from './toppings.effect'; + +export const effects: any[] = [PizzasEffects, ToppingsEffects]; + +export * from './pizzas.effect'; +export * from './toppings.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..558e5fe7 --- /dev/null +++ b/src/products/store/effects/pizzas.effect.ts @@ -0,0 +1,94 @@ +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 fromRoot from '../../../app/store'; +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))) + ); + }) + ); + + @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))) + ); + }) + ); + + @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), + switchMap(pizza => { + return this.pizzaService + .updatePizza(pizza) + .pipe( + map(pizza => new pizzaActions.UpdatePizzaSuccess(pizza)), + catchError(error => of(new pizzaActions.UpdatePizzaFail(error))) + ); + }) + ); + + @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))) + ); + }) + ); + + @Effect() + handlePizzaSuccess$ = this.actions$ + .ofType( + pizzaActions.UPDATE_PIZZA_SUCCESS, + pizzaActions.REMOVE_PIZZA_SUCCESS + ) + .pipe( + map(pizza => { + return new fromRoot.Go({ + path: ['/products'], + }); + }) + ); +} 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))) + ); + }) + ); +} diff --git a/src/products/store/index.ts b/src/products/store/index.ts new file mode 100644 index 00000000..be921ff2 --- /dev/null +++ b/src/products/store/index.ts @@ -0,0 +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 new file mode 100644 index 00000000..291912ae --- /dev/null +++ b/src/products/store/reducers/index.ts @@ -0,0 +1,18 @@ +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( + 'products' +); 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/pizzas.reducer.ts b/src/products/store/reducers/pizzas.reducer.ts new file mode 100644 index 00000000..b79eba53 --- /dev/null +++ b/src/products/store/reducers/pizzas.reducer.ts @@ -0,0 +1,89 @@ +import * as fromPizzas from '../actions/pizzas.action'; +import { Pizza } from '../../models/pizza.model'; + +export interface PizzaState { + entities: { [id: number]: Pizza }; + loaded: boolean; + loading: boolean; +} + +export const initialState: PizzaState = { + entities: {}, + 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: { + 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, + entities, + }; + } + + case fromPizzas.LOAD_PIZZAS_FAIL: { + return { + ...state, + loading: false, + loaded: false, + }; + } + + case fromPizzas.UPDATE_PIZZA_SUCCESS: + case fromPizzas.CREATE_PIZZA_SUCCESS: { + const pizza = action.payload; + const entities = { + ...state.entities, + [pizza.id]: pizza, + }; + + return { + ...state, + entities, + }; + } + + case fromPizzas.REMOVE_PIZZA_SUCCESS: { + const pizza = action.payload; + const { [pizza.id]: removed, ...entities } = state.entities; + + return { + ...state, + entities, + }; + } + } + + return state; +} + +export const getPizzasEntities = (state: PizzaState) => state.entities; +export const getPizzasLoading = (state: PizzaState) => state.loading; +export const getPizzasLoaded = (state: PizzaState) => state.loaded; 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([]); + }); + }); +}); diff --git a/src/products/store/reducers/toppings.reducer.ts b/src/products/store/reducers/toppings.reducer.ts new file mode 100644 index 00000000..a6554d69 --- /dev/null +++ b/src/products/store/reducers/toppings.reducer.ts @@ -0,0 +1,78 @@ +import * as fromToppings from '../actions/toppings.action'; +import { Topping } from '../../models/topping.model'; + +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( + state = initialState, + action: fromToppings.ToppingsAction +): ToppingsState { + switch (action.type) { + case fromToppings.VISUALISE_TOPPINGS: { + const selectedToppings = action.payload; + + return { + ...state, + selectedToppings, + }; + } + + 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; +export const getSelectedToppings = (state: ToppingsState) => + state.selectedToppings; diff --git a/src/products/store/selectors/index.ts b/src/products/store/selectors/index.ts new file mode 100644 index 00000000..7dad7fbf --- /dev/null +++ b/src/products/store/selectors/index.ts @@ -0,0 +1,2 @@ +export * from './pizzas.selectors'; +export * from './toppings.selectors'; 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/pizzas.selectors.ts b/src/products/store/selectors/pizzas.selectors.ts new file mode 100644 index 00000000..cc6d63c8 --- /dev/null +++ b/src/products/store/selectors/pizzas.selectors.ts @@ -0,0 +1,49 @@ +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'; + +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 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)]); +}); + +export const getPizzasLoaded = createSelector( + getPizzaState, + fromPizzas.getPizzasLoaded +); +export const getPizzasLoading = createSelector( + getPizzaState, + fromPizzas.getPizzasLoading +); 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); + }); + }); +}); diff --git a/src/products/store/selectors/toppings.selectors.ts b/src/products/store/selectors/toppings.selectors.ts new file mode 100644 index 00000000..ac905bff --- /dev/null +++ b/src/products/store/selectors/toppings.selectors.ts @@ -0,0 +1,34 @@ +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 getSelectedToppings = createSelector( + getToppingsState, + fromToppings.getSelectedToppings +); + +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 +);