├── .angular-cli.json ├── .editorconfig ├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── circle.yml ├── e2e ├── app.e2e-spec.ts ├── app.po.ts └── tsconfig.e2e.json ├── karma.conf.js ├── package.json ├── protractor.conf.js ├── src ├── app │ ├── actions │ │ ├── action-enum.ts │ │ ├── book.ts │ │ ├── collection.ts │ │ └── layout.ts │ ├── app.module.ts │ ├── components │ │ ├── book-authors.ts │ │ ├── book-detail.ts │ │ ├── book-preview-list.ts │ │ ├── book-preview.ts │ │ ├── book-search.ts │ │ ├── index.ts │ │ ├── layout.ts │ │ ├── nav-item.ts │ │ ├── sidenav.ts │ │ └── toolbar.ts │ ├── containers │ │ ├── app.ts │ │ ├── collection-page.ts │ │ ├── find-book-page.ts │ │ ├── not-found-page.ts │ │ ├── selected-book-page.ts │ │ └── view-book-page.ts │ ├── db.ts │ ├── effects │ │ ├── book.spec.ts │ │ ├── book.ts │ │ ├── collection.spec.ts │ │ └── collection.ts │ ├── guards │ │ └── book-exists.ts │ ├── index.ts │ ├── models │ │ └── book.ts │ ├── pipes │ │ ├── add-commas.spec.ts │ │ ├── add-commas.ts │ │ ├── ellipsis.spec.ts │ │ ├── ellipsis.ts │ │ └── index.ts │ ├── reducers │ │ ├── book.spec.ts │ │ ├── books.ts │ │ ├── collection.ts │ │ ├── index.ts │ │ ├── layout.ts │ │ ├── reducer-enum.ts │ │ └── search.ts │ ├── routes.ts │ └── services │ │ ├── google-books.spec.ts │ │ └── google-books.ts ├── assets │ ├── .gitkeep │ └── .npmignore ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── index.html ├── main.ts ├── polyfills.ts ├── styles.css ├── test.ts ├── tsconfig.app.json └── tsconfig.spec.json ├── tsconfig.json ├── tslint.json └── yarn.lock /.angular-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "project": { 4 | "name": "example-app" 5 | }, 6 | "apps": [ 7 | { 8 | "root": "src", 9 | "outDir": "dist", 10 | "assets": [ 11 | "assets", 12 | "favicon.ico" 13 | ], 14 | "index": "index.html", 15 | "main": "main.ts", 16 | "polyfills": "polyfills.ts", 17 | "test": "test.ts", 18 | "tsconfig": "tsconfig.app.json", 19 | "testTsconfig": "tsconfig.spec.json", 20 | "prefix": "bc", 21 | "styles": [ 22 | "styles.css" 23 | ], 24 | "scripts": [], 25 | "environmentSource": "environments/environment.ts", 26 | "environments": { 27 | "dev": "environments/environment.ts", 28 | "prod": "environments/environment.prod.ts" 29 | } 30 | } 31 | ], 32 | "e2e": { 33 | "protractor": { 34 | "config": "./protractor.conf.js" 35 | } 36 | }, 37 | "lint": [ 38 | { 39 | "project": "src/tsconfig.app.json" 40 | }, 41 | { 42 | "project": "src/tsconfig.spec.json" 43 | }, 44 | { 45 | "project": "e2e/tsconfig.e2e.json" 46 | } 47 | ], 48 | "test": { 49 | "karma": { 50 | "config": "./karma.conf.js" 51 | } 52 | }, 53 | "defaults": { 54 | "styleExt": "css", 55 | "component": {} 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 6 4 | } 5 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | 7 | # dependencies 8 | /node_modules 9 | 10 | # IDEs and editors 11 | /.idea 12 | .project 13 | .classpath 14 | .c9/ 15 | *.launch 16 | .settings/ 17 | 18 | # IDE - VSCode 19 | .vscode 20 | .vscode/* 21 | !.vscode/settings.json 22 | !.vscode/tasks.json 23 | !.vscode/launch.json 24 | !.vscode/extensions.json 25 | 26 | # misc 27 | /.sass-cache 28 | /connect.lock 29 | /coverage/* 30 | /libpeerconnection.log 31 | npm-debug.log 32 | testem.log 33 | /typings 34 | 35 | # e2e 36 | /e2e/*.js 37 | /e2e/*.map 38 | 39 | #System Files 40 | .DS_Store 41 | Thumbs.db 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 ngrx 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @ngrx example application, using enums 2 | 3 | [@ngrx/store](https://github.com/ngrx/store) is a very powerful utility for managing 4 | the state of Angular apps, but some developers have criticized the [example app](https://github.com/ngrx/example-app) 5 | for containing too much boilerplate (particularly in the action classes) and for having 6 | large switch statements in the reducers. This is a fork of the [example app](https://github.com/ngrx/example-app) 7 | that uses [ts-enums](https://github.com/LMFinney/ts-enums) to encapsulate the actions and 8 | reducers, thereby reducing boilerplate and hiding the switch statement from view. 9 | 10 | Built with [@angular/cli](https://github.com/angular/angular-cli). 11 | 12 | If you want to use the base action and reducer enums, you can get them from 13 | [ngrx-enums](https://github.com/LMFinney/ngrx-enums), where they have been 14 | extracted from this project with slight modifications. 15 | 16 | ### Details 17 | Because the actions are used throughout the app, there are changes from the original example 18 | in many files. However, the most important changes are in the [actions](src/app/actions) and 19 | [reducers](src/app/reducers). 20 | 21 | #### Action Example 22 | Although the enum approach adds some enum-related boilerplate, it reduces the code greatly 23 | overall by removing the action-related boilerplate. This is possible due to moving repeated 24 | logic into [action-enum.ts](src/app/actions/action-enum.ts). 25 | 26 | [Before](https://github.com/ngrx/example-app/blob/d7547f282cd3f22a1ec9e03f07e27365d5242bdb/src/app/actions/book.ts): 27 | ```typescript 28 | import { Action } from '@ngrx/store'; 29 | import { Book } from '../models/book'; 30 | 31 | export const SEARCH = '[Book] Search'; 32 | export const SEARCH_COMPLETE = '[Book] Search Complete'; 33 | export const LOAD = '[Book] Load'; 34 | export const SELECT = '[Book] Select'; 35 | 36 | 37 | /** 38 | * Every action is comprised of at least a type and an optional 39 | * payload. Expressing actions as classes enables powerful 40 | * type checking in reducer functions. 41 | * 42 | * See Discriminated Unions: https://www.typescriptlang.org/docs/handbook/advanced-types.html#discriminated-unions 43 | */ 44 | export class SearchAction implements Action { 45 | readonly type = SEARCH; 46 | 47 | constructor(public payload: string) { } 48 | } 49 | 50 | export class SearchCompleteAction implements Action { 51 | readonly type = SEARCH_COMPLETE; 52 | 53 | constructor(public payload: Book[]) { } 54 | } 55 | 56 | export class LoadAction implements Action { 57 | readonly type = LOAD; 58 | 59 | constructor(public payload: Book) { } 60 | } 61 | 62 | export class SelectAction implements Action { 63 | readonly type = SELECT; 64 | 65 | constructor(public payload: string) { } 66 | } 67 | 68 | /** 69 | * Export a type alias of all actions in this action group 70 | * so that reducers can easily compose action types 71 | */ 72 | export type Actions 73 | = SearchAction 74 | | SearchCompleteAction 75 | | LoadAction 76 | | SelectAction; 77 | ``` 78 | 79 | [After](src/app/actions/book.ts): 80 | ```typescript 81 | import {Book} from '../models/book'; 82 | import {ActionEnum, ActionEnumValue} from './action-enum'; 83 | 84 | /** 85 | * Every action is comprised of at least a type and an optional 86 | * payload. Expressing actions as classes enables powerful 87 | * type checking in reducer functions. Enums simplify generating 88 | * the classes. 89 | */ 90 | export class BookAction extends ActionEnumValue { 91 | constructor(name: string) { 92 | super(name); 93 | } 94 | } 95 | 96 | export class BookActionEnumType extends ActionEnum> { 97 | 98 | SEARCH = new BookAction('[Book] Search'); 99 | SEARCH_COMPLETE = new BookAction('[Book] Search Complete'); 100 | LOAD = new BookAction('[Book] Load'); 101 | SELECT = new BookAction('[Book] Select'); 102 | 103 | constructor() { 104 | super(); 105 | this.initEnum('bookActions'); 106 | } 107 | } 108 | 109 | export const BookActionEnum = new BookActionEnumType(); 110 | ``` 111 | 112 | #### Reducer Example 113 | The enum approach eliminates the big switch statement by storing the action instances in 114 | [reducer-enum.ts](src/app/reducers/reducer-enum.ts). 115 | 116 | [Before](https://github.com/ngrx/example-app/blob/d7547f282cd3f22a1ec9e03f07e27365d5242bdb/src/app/reducers/collection.ts): 117 | ```typescript 118 | import * as collection from '../actions/collection'; 119 | 120 | 121 | export interface State { 122 | loaded: boolean; 123 | loading: boolean; 124 | ids: string[]; 125 | }; 126 | 127 | const initialState: State = { 128 | loaded: false, 129 | loading: false, 130 | ids: [] 131 | }; 132 | 133 | export function reducer(state = initialState, action: collection.Actions): State { 134 | switch (action.type) { 135 | case collection.LOAD: { 136 | return Object.assign({}, state, { 137 | loading: true 138 | }); 139 | } 140 | 141 | case collection.LOAD_SUCCESS: { 142 | const books = action.payload; 143 | 144 | return { 145 | loaded: true, 146 | loading: false, 147 | ids: books.map(book => book.id) 148 | }; 149 | } 150 | 151 | case collection.ADD_BOOK_SUCCESS: 152 | case collection.REMOVE_BOOK_FAIL: { 153 | const book = action.payload; 154 | 155 | if (state.ids.indexOf(book.id) > -1) { 156 | return state; 157 | } 158 | 159 | return Object.assign({}, state, { 160 | ids: [ ...state.ids, book.id ] 161 | }); 162 | } 163 | 164 | case collection.REMOVE_BOOK_SUCCESS: 165 | case collection.ADD_BOOK_FAIL: { 166 | const book = action.payload; 167 | 168 | return Object.assign({}, state, { 169 | ids: state.ids.filter(id => id !== book.id) 170 | }); 171 | } 172 | 173 | default: { 174 | return state; 175 | } 176 | } 177 | } 178 | 179 | 180 | export const getLoaded = (state: State) => state.loaded; 181 | 182 | export const getLoading = (state: State) => state.loading; 183 | 184 | export const getIds = (state: State) => state.ids; 185 | ``` 186 | 187 | [After](src/app/reducers/collection.ts): 188 | ```typescript 189 | import {CollectionActionEnum} from '../actions/collection'; 190 | import {Book} from '../models/book'; 191 | import {ActionEnumValue, TypedAction} from '../actions/action-enum'; 192 | import { 193 | ReducerEnum, 194 | ReducerEnumValue, 195 | ReducerFunction 196 | } from './reducer-enum'; 197 | 198 | 199 | export interface State { 200 | loaded: boolean; 201 | loading: boolean; 202 | ids: string[]; 203 | } 204 | 205 | const initialState: State = { 206 | loaded: false, 207 | loading: false, 208 | ids: [] 209 | }; 210 | 211 | export class CollectionReducer extends ReducerEnumValue { 212 | constructor(action: ActionEnumValue | ActionEnumValue[], 213 | reduce: ReducerFunction) { 214 | super(action, reduce); 215 | } 216 | } 217 | 218 | export class CollectionReducerEnumType extends ReducerEnum, State> { 219 | 220 | LOAD: CollectionReducer = 221 | new CollectionReducer(CollectionActionEnum.LOAD, 222 | (state: State) => ({...state, loading: true})); 223 | LOAD_SUCCESS: CollectionReducer = 224 | new CollectionReducer(CollectionActionEnum.LOAD_SUCCESS, 225 | (state: State, action: TypedAction) => { 226 | return { 227 | loaded: true, 228 | loading: false, 229 | ids: action.payload.map((book: Book) => book.id) 230 | }; 231 | }); 232 | ADD_BOOK_SUCCESS: CollectionReducer = 233 | new CollectionReducer( 234 | [CollectionActionEnum.ADD_BOOK_SUCCESS, 235 | CollectionActionEnum.REMOVE_BOOK_FAIL], 236 | (state: State, action: TypedAction) => { 237 | const book = action.payload; 238 | 239 | if (state.ids.indexOf(book.id) > -1) { 240 | return state; 241 | } 242 | 243 | return Object.assign({}, state, { 244 | ids: [ ...state.ids, book.id ] 245 | }); 246 | }); 247 | REMOVE_BOOK_SUCCESS: CollectionReducer = 248 | new CollectionReducer( 249 | [CollectionActionEnum.REMOVE_BOOK_SUCCESS, 250 | CollectionActionEnum.ADD_BOOK_FAIL], 251 | (state: State, action: TypedAction) => { 252 | const book = action.payload; 253 | 254 | return Object.assign({}, state, { 255 | ids: state.ids.filter(id => id !== book.id) 256 | }); 257 | }); 258 | 259 | constructor() { 260 | super(initialState); 261 | this.initEnum('collectionReducers'); 262 | } 263 | } 264 | 265 | export const CollectionReducerEnum = new CollectionReducerEnumType(); 266 | 267 | export const getLoaded = (state: State) => state.loaded; 268 | 269 | export const getLoading = (state: State) => state.loading; 270 | 271 | export const getIds = (state: State) => state.ids; 272 | ``` 273 | 274 | ### Quick start 275 | 276 | ```bash 277 | # clone the repo 278 | git clone https://github.com/LMFinney/ngrx-example-app-enums.git 279 | 280 | 281 | # change directory to repo 282 | cd ngrx-example-app-enums 283 | 284 | # Use npm or yarn to install the dependencies: 285 | npm install 286 | 287 | # OR 288 | yarn 289 | 290 | # start the server 291 | ng serve 292 | ``` 293 | 294 | Navigate to [http://localhost:4200/](http://localhost:4200/) in your browser 295 | 296 | _NOTE:_ The above setup instructions assume you have added local npm bin folders to your path. 297 | If this is not the case you will need to install the Angular CLI globally. 298 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 6.9.5 4 | ## Customize test commands 5 | test: 6 | override: 7 | - ng lint 8 | - ng test --watch false # single test run -------------------------------------------------------------------------------- /e2e/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { ExampleAppPage } from './app.po'; 2 | 3 | describe('example-app App', function() { 4 | let page: ExampleAppPage; 5 | 6 | beforeEach(() => { 7 | page = new ExampleAppPage(); 8 | }); 9 | 10 | it('should display message saying app works', () => { 11 | page.navigateTo(); 12 | expect(page.getParagraphText()).toEqual('app works!'); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /e2e/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, element, by } from 'protractor'; 2 | 3 | export class ExampleAppPage { 4 | navigateTo() { 5 | return browser.get('/'); 6 | } 7 | 8 | getParagraphText() { 9 | return element(by.css('app-root h1')).getText(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /e2e/tsconfig.e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "declaration": false, 5 | "moduleResolution": "node", 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "lib": [ 9 | "es2016" 10 | ], 11 | "outDir": "../dist/out-tsc-e2e", 12 | "module": "commonjs", 13 | "target": "es6", 14 | "types": [ 15 | "jasmine", 16 | "node" 17 | ] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/0.13/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular/cli'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular/cli/plugins/karma') 14 | ], 15 | client: { 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | files: [ 19 | { pattern: './src/test.ts', watched: false } 20 | ], 21 | preprocessors: { 22 | './src/test.ts': ['@angular/cli'] 23 | }, 24 | mime: { 25 | 'text/x-typescript': ['ts','tsx'] 26 | }, 27 | coverageIstanbulReporter: { 28 | reports: [ 'html', 'lcovonly', 'text-summary' ], 29 | fixWebpackSourcePaths: true 30 | }, 31 | angularCli: { 32 | environment: 'dev' 33 | }, 34 | reporters: config.angularCli && config.angularCli.codeCoverage 35 | ? ['progress', 'coverage-istanbul', 'kjhtml'] 36 | : ['progress', 'kjhtml'], 37 | port: 9876, 38 | colors: true, 39 | logLevel: config.LOG_INFO, 40 | autoWatch: true, 41 | browsers: ['Chrome'], 42 | singleRun: false 43 | }); 44 | }; 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ngrx-example-app-enums", 3 | "version": "0.0.1", 4 | "description": "Example application demoing using enums with the @ngrx platform (a fork of https://github.com/ngrx/example-app)", 5 | "main": "index.ts", 6 | "scripts": { 7 | "ng": "ng", 8 | "start": "ng serve", 9 | "lint": "ng lint", 10 | "test": "ng test --code-coverage", 11 | "e2e": "ng e2e", 12 | "build": "ng build --prod" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/LMFinney/ngrx-example-app-enums.git" 17 | }, 18 | "author": "Lance Finney", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/LMFinney/ngrx-example-app-enums/issues" 22 | }, 23 | "homepage": "https://github.com/LMFinney/ngrx-example-app-enums#readme", 24 | "engines": { 25 | "node": ">= 6.9.0", 26 | "npm": ">= 3.0.0" 27 | }, 28 | "private": true, 29 | "dependencies": { 30 | "@angular/animations": "^4.0.2", 31 | "@angular/common": "^4.0.2", 32 | "@angular/compiler": "^4.0.2", 33 | "@angular/core": "^4.0.2", 34 | "@angular/forms": "^4.0.2", 35 | "@angular/http": "^4.0.2", 36 | "@angular/material": "2.0.0-beta.3", 37 | "@angular/platform-browser": "^4.0.2", 38 | "@angular/platform-browser-dynamic": "^4.0.2", 39 | "@angular/router": "^4.0.2", 40 | "@ngrx/core": "^1.0.0", 41 | "@ngrx/db": "^2.0.2", 42 | "@ngrx/effects": "^2.0.3", 43 | "@ngrx/router-store": "^1.2.6", 44 | "@ngrx/store": "^2.2.2", 45 | "@ngrx/store-devtools": "^3.2.0", 46 | "core-js": "^2.4.1", 47 | "hammerjs": "^2.0.8", 48 | "reselect": "^3.0.0", 49 | "rxjs": "^5.1.0", 50 | "ts-enums": "0.0.3", 51 | "ts-helpers": "^1.1.1", 52 | "zone.js": "^0.8.4" 53 | }, 54 | "devDependencies": { 55 | "@angular/cli": "^1.0.0", 56 | "@angular/compiler-cli": "^4.0.2", 57 | "@types/jasmine": "2.5.38", 58 | "@types/node": "~6.0.60", 59 | "codelyzer": "~2.0.0", 60 | "jasmine-core": "~2.5.2", 61 | "jasmine-spec-reporter": "~3.2.0", 62 | "karma": "~1.4.1", 63 | "karma-chrome-launcher": "~2.0.0", 64 | "karma-cli": "~1.0.1", 65 | "karma-coverage-istanbul-reporter": "^0.2.0", 66 | "karma-jasmine": "~1.1.0", 67 | "karma-jasmine-html-reporter": "^0.2.2", 68 | "ngrx-store-freeze": "^0.1.6", 69 | "protractor": "~5.1.0", 70 | "ts-node": "~2.0.0", 71 | "tslint": "~4.5.0", 72 | "typescript": "~2.2.2" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /protractor.conf.js: -------------------------------------------------------------------------------- 1 | // Protractor configuration file, see link for more information 2 | // https://github.com/angular/protractor/blob/master/lib/config.ts 3 | 4 | /*global jasmine */ 5 | const { SpecReporter } = require('jasmine-spec-reporter'); 6 | 7 | exports.config = { 8 | allScriptsTimeout: 11000, 9 | specs: [ 10 | './e2e/**/*.e2e-spec.ts' 11 | ], 12 | capabilities: { 13 | 'browserName': 'chrome' 14 | }, 15 | directConnect: true, 16 | baseUrl: 'http://localhost:4200/', 17 | framework: 'jasmine', 18 | jasmineNodeOpts: { 19 | showColors: true, 20 | defaultTimeoutInterval: 30000, 21 | print: function() {} 22 | }, 23 | beforeLaunch: function() { 24 | require('ts-node').register({ 25 | project: 'e2e' 26 | }); 27 | }, 28 | onPrepare() { 29 | jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /src/app/actions/action-enum.ts: -------------------------------------------------------------------------------- 1 | import {Enum, EnumValue} from 'ts-enums'; 2 | import {Action} from '@ngrx/store'; 3 | 4 | /** 5 | * A version of Action that uses generics to express the type of the payload. 6 | */ 7 | export class TypedAction implements Action { 8 | constructor(public type: string, public payload?: T) { 9 | } 10 | } 11 | 12 | /** 13 | * The abstract base for the action enum instances. 14 | */ 15 | export abstract class ActionEnumValue extends EnumValue { 16 | constructor(_name: string) { 17 | super(_name); 18 | } 19 | 20 | // Create the Action that contains the optional payload. 21 | toAction(payload?: T): TypedAction { 22 | return new TypedAction(this.description, payload); 23 | } 24 | 25 | get type(): string { 26 | return this.description; 27 | } 28 | 29 | get fullName(): string { 30 | return `[${this.constructor.name}] ${this.description}`; 31 | } 32 | } 33 | 34 | /** 35 | * The abstract base for the action enum types. 36 | */ 37 | export abstract class ActionEnum> extends Enum { 38 | fromAction(action: TypedAction): V { 39 | return this.byDescription(action.type); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/app/actions/book.ts: -------------------------------------------------------------------------------- 1 | import {Book} from '../models/book'; 2 | import {ActionEnum, ActionEnumValue} from './action-enum'; 3 | 4 | export class BookAction extends ActionEnumValue { 5 | constructor(name: string) { 6 | super(name); 7 | } 8 | } 9 | 10 | /** 11 | * Every action is comprised of at least a type and an optional 12 | * payload. Expressing actions as classes enables powerful 13 | * type checking in reducer functions. Enums simplify generating 14 | * the classes. 15 | */ 16 | export class BookActionEnumType extends ActionEnum> { 17 | 18 | SEARCH = new BookAction('[Book] Search'); 19 | SEARCH_COMPLETE = new BookAction('[Book] Search Complete'); 20 | LOAD = new BookAction('[Book] Load'); 21 | SELECT = new BookAction('[Book] Select'); 22 | 23 | constructor() { 24 | super(); 25 | this.initEnum('bookActions'); 26 | } 27 | } 28 | 29 | export const BookActionEnum = new BookActionEnumType(); 30 | 31 | -------------------------------------------------------------------------------- /src/app/actions/collection.ts: -------------------------------------------------------------------------------- 1 | import {Book} from '../models/book'; 2 | import {ActionEnum, ActionEnumValue} from './action-enum'; 3 | 4 | export class CollectionAction extends ActionEnumValue { 5 | constructor(name: string) { 6 | super(name); 7 | } 8 | } 9 | 10 | export class CollectionActionEnumType extends ActionEnum> { 11 | /** 12 | * Add Book to Collection Actions 13 | */ 14 | ADD_BOOK: CollectionAction = 15 | new CollectionAction('[Collection] Add Book'); 16 | ADD_BOOK_SUCCESS: CollectionAction = 17 | new CollectionAction('[Collection] Add Book Success'); 18 | ADD_BOOK_FAIL: CollectionAction = 19 | new CollectionAction('[Collection] Add Book Fail'); 20 | /** 21 | * Remove Book from Collection Actions 22 | */ 23 | REMOVE_BOOK: CollectionAction = 24 | new CollectionAction('[Collection] Remove Book'); 25 | REMOVE_BOOK_SUCCESS: CollectionAction = 26 | new CollectionAction('[Collection] Remove Book Success'); 27 | REMOVE_BOOK_FAIL: CollectionAction = 28 | new CollectionAction('[Collection] Remove Book Fail'); 29 | /** 30 | * Load Collection Actions 31 | */ 32 | LOAD: CollectionAction = 33 | new CollectionAction('[Collection] Load'); 34 | LOAD_SUCCESS: CollectionAction = 35 | new CollectionAction('[Collection] Load Success'); 36 | LOAD_FAIL: CollectionAction = 37 | new CollectionAction('[Collection] Load Fail'); 38 | 39 | constructor() { 40 | super(); 41 | this.initEnum('collectionActions'); 42 | } 43 | } 44 | 45 | export const CollectionActionEnum: CollectionActionEnumType = 46 | new CollectionActionEnumType(); 47 | -------------------------------------------------------------------------------- /src/app/actions/layout.ts: -------------------------------------------------------------------------------- 1 | import {ActionEnum, ActionEnumValue} from './action-enum'; 2 | 3 | export class LayoutAction extends ActionEnumValue { 4 | constructor(name: string) { 5 | super(name); 6 | } 7 | } 8 | 9 | export class LayoutActionEnumType extends ActionEnum> { 10 | 11 | OPEN_SIDENAV = new LayoutAction('[Layout] Open Sidenav'); 12 | CLOSE_SIDENAV = new LayoutAction('[Layout] Close Sidenav'); 13 | 14 | constructor() { 15 | super(); 16 | this.initEnum('layoutActions'); 17 | } 18 | } 19 | 20 | export const LayoutActionEnum = new LayoutActionEnumType(); 21 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule, } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { BrowserModule } from '@angular/platform-browser'; 4 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 5 | import { RouterModule } from '@angular/router'; 6 | 7 | import { StoreModule } from '@ngrx/store'; 8 | import { EffectsModule } from '@ngrx/effects'; 9 | import { DBModule } from '@ngrx/db'; 10 | import { RouterStoreModule } from '@ngrx/router-store'; 11 | import { StoreDevtoolsModule } from '@ngrx/store-devtools'; 12 | import { MaterialModule } from '@angular/material'; 13 | 14 | import { ComponentsModule } from './components'; 15 | import { BookEffects } from './effects/book'; 16 | import { CollectionEffects } from './effects/collection'; 17 | import { BookExistsGuard } from './guards/book-exists'; 18 | 19 | import { AppComponent } from './containers/app'; 20 | import { FindBookPageComponent } from './containers/find-book-page'; 21 | import { ViewBookPageComponent } from './containers/view-book-page'; 22 | import { SelectedBookPageComponent } from './containers/selected-book-page'; 23 | import { CollectionPageComponent } from './containers/collection-page'; 24 | import { NotFoundPageComponent } from './containers/not-found-page'; 25 | 26 | import { GoogleBooksService } from './services/google-books'; 27 | 28 | import { routes } from './routes'; 29 | import { reducer } from './reducers'; 30 | import { schema } from './db'; 31 | 32 | 33 | 34 | @NgModule({ 35 | imports: [ 36 | CommonModule, 37 | BrowserModule, 38 | BrowserAnimationsModule, 39 | MaterialModule, 40 | ComponentsModule, 41 | RouterModule.forRoot(routes, { useHash: true }), 42 | 43 | /** 44 | * StoreModule.provideStore is imported once in the root module, accepting a reducer 45 | * function or object map of reducer functions. If passed an object of 46 | * reducers, combineReducers will be run creating your application 47 | * meta-reducer. This returns all providers for an @ngrx/store 48 | * based application. 49 | */ 50 | StoreModule.provideStore(reducer), 51 | 52 | /** 53 | * @ngrx/router-store keeps router state up-to-date in the store and uses 54 | * the store as the single source of truth for the router's state. 55 | */ 56 | RouterStoreModule.connectRouter(), 57 | 58 | /** 59 | * Store devtools instrument the store retaining past versions of state 60 | * and recalculating new states. This enables powerful time-travel 61 | * debugging. 62 | * 63 | * To use the debugger, install the Redux Devtools extension for either 64 | * Chrome or Firefox 65 | * 66 | * See: https://github.com/zalmoxisus/redux-devtools-extension 67 | */ 68 | StoreDevtoolsModule.instrumentOnlyWithExtension(), 69 | 70 | /** 71 | * EffectsModule.run() sets up the effects class to be initialized 72 | * immediately when the application starts. 73 | * 74 | * See: https://github.com/ngrx/effects/blob/master/docs/api.md#run 75 | */ 76 | EffectsModule.run(BookEffects), 77 | EffectsModule.run(CollectionEffects), 78 | 79 | /** 80 | * `provideDB` sets up @ngrx/db with the provided schema and makes the Database 81 | * service available. 82 | */ 83 | DBModule.provideDB(schema), 84 | ], 85 | declarations: [ 86 | AppComponent, 87 | FindBookPageComponent, 88 | SelectedBookPageComponent, 89 | ViewBookPageComponent, 90 | CollectionPageComponent, 91 | NotFoundPageComponent 92 | ], 93 | providers: [ 94 | BookExistsGuard, 95 | GoogleBooksService 96 | ], 97 | bootstrap: [ 98 | AppComponent 99 | ] 100 | }) 101 | export class AppModule { } 102 | -------------------------------------------------------------------------------- /src/app/components/book-authors.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | import { Book } from '../models/book'; 4 | 5 | 6 | @Component({ 7 | selector: 'bc-book-authors', 8 | template: ` 9 |
Written By:
10 | 11 | {{ authors | bcAddCommas }} 12 | 13 | `, 14 | styles: [` 15 | h5 { 16 | margin-bottom: 5px; 17 | } 18 | `] 19 | }) 20 | export class BookAuthorsComponent { 21 | @Input() book: Book; 22 | 23 | get authors() { 24 | return this.book.volumeInfo.authors; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/app/components/book-detail.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, Output, EventEmitter } from '@angular/core'; 2 | import { Book } from '../models/book'; 3 | 4 | 5 | @Component({ 6 | selector: 'bc-book-detail', 7 | template: ` 8 | 9 | 10 | {{ title }} 11 | {{ subtitle }} 12 | 13 | 14 | 15 |

16 |
17 | 18 | 19 | 20 | 21 | 24 | 25 | 28 | 29 |
30 | 31 | `, 32 | styles: [` 33 | :host { 34 | display: flex; 35 | justify-content: center; 36 | margin: 75px 0; 37 | } 38 | md-card { 39 | max-width: 600px; 40 | } 41 | md-card-title-group { 42 | margin-left: 0; 43 | } 44 | img { 45 | width: 60px; 46 | min-width: 60px; 47 | margin-left: 5px; 48 | } 49 | md-card-content { 50 | margin: 15px 0 50px; 51 | } 52 | md-card-actions { 53 | margin: 25px 0 0 !important; 54 | } 55 | md-card-footer { 56 | padding: 0 25px 25px; 57 | position: relative; 58 | } 59 | `] 60 | }) 61 | export class BookDetailComponent { 62 | /** 63 | * Presentational components receieve data through @Input() and communicate events 64 | * through @Output() but generally maintain no internal state of their 65 | * own. All decisions are delegated to 'container', or 'smart' 66 | * components before data updates flow back down. 67 | * 68 | * More on 'smart' and 'presentational' components: https://gist.github.com/btroncone/a6e4347326749f938510#utilizing-container-components 69 | */ 70 | @Input() book: Book; 71 | @Input() inCollection: boolean; 72 | @Output() add = new EventEmitter(); 73 | @Output() remove = new EventEmitter(); 74 | 75 | 76 | /** 77 | * Tip: Utilize getters to keep templates clean 78 | */ 79 | get id() { 80 | return this.book.id; 81 | } 82 | 83 | get title() { 84 | return this.book.volumeInfo.title; 85 | } 86 | 87 | get subtitle() { 88 | return this.book.volumeInfo.subtitle; 89 | } 90 | 91 | get description() { 92 | return this.book.volumeInfo.description; 93 | } 94 | 95 | get thumbnail() { 96 | return this.book.volumeInfo.imageLinks 97 | && this.book.volumeInfo.imageLinks.smallThumbnail; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/app/components/book-preview-list.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { Book } from '../models/book'; 3 | 4 | @Component({ 5 | selector: 'bc-book-preview-list', 6 | template: ` 7 | 8 | `, 9 | styles: [` 10 | :host { 11 | display: flex; 12 | flex-wrap: wrap; 13 | justify-content: center; 14 | } 15 | `] 16 | }) 17 | export class BookPreviewListComponent { 18 | @Input() books: Book[]; 19 | } 20 | -------------------------------------------------------------------------------- /src/app/components/book-preview.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { Book } from '../models/book'; 3 | 4 | 5 | @Component({ 6 | selector: 'bc-book-preview', 7 | template: ` 8 | 9 | 10 | 11 | 12 | {{ title | bcEllipsis:35 }} 13 | {{ subtitle | bcEllipsis:40 }} 14 | 15 | 16 |

{{ description | bcEllipsis }}

17 |
18 | 19 | 20 | 21 |
22 |
23 | `, 24 | styles: [` 25 | md-card { 26 | width: 400px; 27 | height: 300px; 28 | margin: 15px; 29 | } 30 | @media only screen and (max-width: 768px) { 31 | md-card { 32 | margin: 15px 0 !important; 33 | } 34 | } 35 | md-card:hover { 36 | box-shadow: 3px 3px 16px -2px rgba(0, 0, 0, .5); 37 | } 38 | md-card-title { 39 | margin-right: 10px; 40 | } 41 | md-card-title-group { 42 | margin: 0; 43 | } 44 | a { 45 | color: inherit; 46 | text-decoration: none; 47 | } 48 | img { 49 | width: 60px; 50 | min-width: 60px; 51 | margin-left: 5px; 52 | } 53 | md-card-content { 54 | margin-top: 15px; 55 | margin: 15px 0 0; 56 | } 57 | span { 58 | display: inline-block; 59 | font-size: 13px; 60 | } 61 | md-card-footer { 62 | padding: 0 25px 25px; 63 | } 64 | `] 65 | }) 66 | export class BookPreviewComponent { 67 | @Input() book: Book; 68 | 69 | get id() { 70 | return this.book.id; 71 | } 72 | 73 | get title() { 74 | return this.book.volumeInfo.title; 75 | } 76 | 77 | get subtitle() { 78 | return this.book.volumeInfo.subtitle; 79 | } 80 | 81 | get description() { 82 | return this.book.volumeInfo.description; 83 | } 84 | 85 | get thumbnail(): string | boolean { 86 | if (this.book.volumeInfo.imageLinks) { 87 | return this.book.volumeInfo.imageLinks.smallThumbnail; 88 | } 89 | 90 | return false; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/app/components/book-search.ts: -------------------------------------------------------------------------------- 1 | import 'rxjs/add/operator/debounceTime'; 2 | import 'rxjs/add/operator/map'; 3 | import 'rxjs/add/operator/distinctUntilChanged'; 4 | import { Component, Output, Input, EventEmitter } from '@angular/core'; 5 | 6 | 7 | @Component({ 8 | selector: 'bc-book-search', 9 | template: ` 10 | 11 | Find a Book 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | `, 20 | styles: [` 21 | md-card-title, 22 | md-card-content { 23 | display: flex; 24 | justify-content: center; 25 | } 26 | 27 | input { 28 | width: 300px; 29 | } 30 | 31 | md-card-spinner { 32 | padding-left: 60px; // Make room for the spinner 33 | } 34 | 35 | md-spinner { 36 | width: 30px; 37 | height: 30px; 38 | position: relative; 39 | top: 10px; 40 | left: 10px; 41 | opacity: 0.0; 42 | } 43 | 44 | md-spinner.show { 45 | opacity: 1.0; 46 | } 47 | `] 48 | }) 49 | export class BookSearchComponent { 50 | @Input() query = ''; 51 | @Input() searching = false; 52 | @Output() search = new EventEmitter(); 53 | } 54 | -------------------------------------------------------------------------------- /src/app/components/index.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { MaterialModule } from '@angular/material'; 4 | import { ReactiveFormsModule } from '@angular/forms'; 5 | import { RouterModule } from '@angular/router'; 6 | 7 | import { BookAuthorsComponent } from './book-authors'; 8 | import { BookDetailComponent } from './book-detail'; 9 | import { BookPreviewComponent } from './book-preview'; 10 | import { BookPreviewListComponent } from './book-preview-list'; 11 | import { BookSearchComponent } from './book-search'; 12 | import { LayoutComponent } from './layout'; 13 | import { NavItemComponent } from './nav-item'; 14 | import { SidenavComponent } from './sidenav'; 15 | import { ToolbarComponent } from './toolbar'; 16 | 17 | import { PipesModule } from '../pipes'; 18 | 19 | 20 | export const COMPONENTS = [ 21 | BookAuthorsComponent, 22 | BookDetailComponent, 23 | BookPreviewComponent, 24 | BookPreviewListComponent, 25 | BookSearchComponent, 26 | LayoutComponent, 27 | NavItemComponent, 28 | SidenavComponent, 29 | ToolbarComponent, 30 | ]; 31 | 32 | 33 | @NgModule({ 34 | imports: [ 35 | CommonModule, 36 | ReactiveFormsModule, 37 | MaterialModule, 38 | RouterModule, 39 | PipesModule, 40 | ], 41 | declarations: COMPONENTS, 42 | exports: COMPONENTS 43 | }) 44 | export class ComponentsModule { } 45 | -------------------------------------------------------------------------------- /src/app/components/layout.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | 4 | @Component({ 5 | selector: 'bc-layout', 6 | template: ` 7 | 8 | 9 | 10 | 11 | 12 | `, 13 | styles: [` 14 | md-sidenav-container { 15 | background: rgba(0, 0, 0, 0.03); 16 | } 17 | 18 | *, /deep/ * { 19 | -webkit-font-smoothing: antialiased; 20 | -moz-osx-font-smoothing: grayscale; 21 | } 22 | `] 23 | }) 24 | export class LayoutComponent { } 25 | -------------------------------------------------------------------------------- /src/app/components/nav-item.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, Output, EventEmitter } from '@angular/core'; 2 | 3 | 4 | @Component({ 5 | selector: 'bc-nav-item', 6 | template: ` 7 | 8 | {{ icon }} 9 | 10 | {{ hint }} 11 | 12 | `, 13 | styles: [` 14 | .secondary { 15 | color: rgba(0, 0, 0, 0.54); 16 | } 17 | `] 18 | }) 19 | export class NavItemComponent { 20 | @Input() icon = ''; 21 | @Input() hint = ''; 22 | @Input() routerLink: string | any[] = '/'; 23 | @Output() activate = new EventEmitter(); 24 | } 25 | -------------------------------------------------------------------------------- /src/app/components/sidenav.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'bc-sidenav', 5 | template: ` 6 | 7 | 8 | 9 | 10 | 11 | `, 12 | styles: [` 13 | md-sidenav { 14 | width: 300px; 15 | } 16 | `] 17 | }) 18 | export class SidenavComponent { 19 | @Input() open = false; 20 | } 21 | -------------------------------------------------------------------------------- /src/app/components/toolbar.ts: -------------------------------------------------------------------------------- 1 | import { Component, Output, EventEmitter } from '@angular/core'; 2 | 3 | 4 | @Component({ 5 | selector: 'bc-toolbar', 6 | template: ` 7 | 8 | 11 | 12 | 13 | ` 14 | }) 15 | export class ToolbarComponent { 16 | @Output() openMenu = new EventEmitter(); 17 | } 18 | -------------------------------------------------------------------------------- /src/app/containers/app.ts: -------------------------------------------------------------------------------- 1 | import 'rxjs/add/operator/let'; 2 | import {Observable} from 'rxjs/Observable'; 3 | import {ChangeDetectionStrategy, Component} from '@angular/core'; 4 | import {Store} from '@ngrx/store'; 5 | 6 | import * as fromRoot from '../reducers'; 7 | import {LayoutActionEnum} from '../actions/layout'; 8 | 9 | 10 | @Component({ 11 | selector: 'bc-app', 12 | changeDetection: ChangeDetectionStrategy.OnPush, 13 | template: ` 14 | 15 | 16 | 17 | My Collection 18 | 19 | 20 | Browse Books 21 | 22 | 23 | 24 | Book Collection 25 | 26 | 27 | 28 | 29 | ` 30 | }) 31 | export class AppComponent { 32 | showSidenav$: Observable; 33 | 34 | constructor(private store: Store) { 35 | /** 36 | * Selectors can be applied with the `select` operator which passes the state 37 | * tree to the provided selector 38 | */ 39 | this.showSidenav$ = this.store.select(fromRoot.getShowSidenav); 40 | } 41 | 42 | closeSidenav() { 43 | /** 44 | * All state updates are handled through dispatched actions in 'container' 45 | * components. This provides a clear, reproducible history of state 46 | * updates and user interaction through the life of our 47 | * application. 48 | */ 49 | this.store.dispatch(LayoutActionEnum.CLOSE_SIDENAV.toAction()); 50 | } 51 | 52 | openSidenav() { 53 | this.store.dispatch(LayoutActionEnum.OPEN_SIDENAV.toAction()); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/app/containers/collection-page.ts: -------------------------------------------------------------------------------- 1 | import 'rxjs/add/operator/let'; 2 | import { Component, ChangeDetectionStrategy } from '@angular/core'; 3 | import { Store } from '@ngrx/store'; 4 | import { Observable } from 'rxjs/Observable'; 5 | 6 | import * as fromRoot from '../reducers'; 7 | import { Book } from '../models/book'; 8 | 9 | 10 | @Component({ 11 | selector: 'bc-collection-page', 12 | changeDetection: ChangeDetectionStrategy.OnPush, 13 | template: ` 14 | 15 | My Collection 16 | 17 | 18 | 19 | `, 20 | /** 21 | * Container components are permitted to have just enough styles 22 | * to bring the view together. If the number of styles grow, 23 | * consider breaking them out into presentational 24 | * components. 25 | */ 26 | styles: [` 27 | md-card-title { 28 | display: flex; 29 | justify-content: center; 30 | } 31 | `] 32 | }) 33 | export class CollectionPageComponent { 34 | books$: Observable; 35 | 36 | constructor(store: Store) { 37 | this.books$ = store.select(fromRoot.getBookCollection); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/app/containers/find-book-page.ts: -------------------------------------------------------------------------------- 1 | import 'rxjs/add/operator/let'; 2 | import 'rxjs/add/operator/take'; 3 | import {ChangeDetectionStrategy, Component} from '@angular/core'; 4 | import {Store} from '@ngrx/store'; 5 | import {Observable} from 'rxjs/Observable'; 6 | 7 | import * as fromRoot from '../reducers'; 8 | import {BookActionEnum} from '../actions/book'; 9 | import {Book} from '../models/book'; 10 | 11 | 12 | @Component({ 13 | selector: 'bc-find-book-page', 14 | changeDetection: ChangeDetectionStrategy.OnPush, 15 | template: ` 16 | 17 | 18 | ` 19 | }) 20 | export class FindBookPageComponent { 21 | searchQuery$: Observable; 22 | books$: Observable; 23 | loading$: Observable; 24 | 25 | constructor(private store: Store) { 26 | this.searchQuery$ = store.select(fromRoot.getSearchQuery).take(1); 27 | this.books$ = store.select(fromRoot.getSearchResults); 28 | this.loading$ = store.select(fromRoot.getSearchLoading); 29 | } 30 | 31 | search(query: string) { 32 | this.store.dispatch(BookActionEnum.SEARCH.toAction(query)); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/app/containers/not-found-page.ts: -------------------------------------------------------------------------------- 1 | import { Component, ChangeDetectionStrategy } from '@angular/core'; 2 | 3 | 4 | @Component({ 5 | selector: 'bc-not-found-page', 6 | changeDetection: ChangeDetectionStrategy.OnPush, 7 | template: ` 8 | 9 | 404: Not Found 10 | 11 |

Hey! It looks like this page doesn't exist yet.

12 |
13 | 14 | 15 | 16 |
17 | `, 18 | styles: [` 19 | :host { 20 | text-align: center; 21 | } 22 | `] 23 | }) 24 | export class NotFoundPageComponent { } 25 | -------------------------------------------------------------------------------- /src/app/containers/selected-book-page.ts: -------------------------------------------------------------------------------- 1 | import {ChangeDetectionStrategy, Component} from '@angular/core'; 2 | import {Store} from '@ngrx/store'; 3 | import {Observable} from 'rxjs/Observable'; 4 | 5 | import * as fromRoot from '../reducers'; 6 | import {CollectionActionEnum} from '../actions/collection'; 7 | import {Book} from '../models/book'; 8 | 9 | 10 | @Component({ 11 | selector: 'bc-selected-book-page', 12 | changeDetection: ChangeDetectionStrategy.OnPush, 13 | template: ` 14 | 19 | 20 | ` 21 | }) 22 | export class SelectedBookPageComponent { 23 | book$: Observable; 24 | isSelectedBookInCollection$: Observable; 25 | 26 | constructor(private store: Store) { 27 | this.book$ = store.select(fromRoot.getSelectedBook); 28 | this.isSelectedBookInCollection$ = store.select(fromRoot.isSelectedBookInCollection); 29 | } 30 | 31 | addToCollection(book: Book) { 32 | this.store.dispatch(CollectionActionEnum.ADD_BOOK.toAction(book)); 33 | } 34 | 35 | removeFromCollection(book: Book) { 36 | this.store.dispatch(CollectionActionEnum.REMOVE_BOOK.toAction(book)); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/app/containers/view-book-page.ts: -------------------------------------------------------------------------------- 1 | import '@ngrx/core/add/operator/select'; 2 | import 'rxjs/add/operator/map'; 3 | import {ChangeDetectionStrategy, Component, OnDestroy} from '@angular/core'; 4 | import {ActivatedRoute} from '@angular/router'; 5 | import {Store} from '@ngrx/store'; 6 | import {Subscription} from 'rxjs/Subscription'; 7 | 8 | import * as fromRoot from '../reducers'; 9 | import {BookActionEnum} from '../actions/book'; 10 | 11 | /** 12 | * Note: Container components are also reusable. Whether or not 13 | * a component is a presentation component or a container 14 | * component is an implementation detail. 15 | * 16 | * The View Book Page's responsibility is to map router params 17 | * to a 'Select' book action. Actually showing the selected 18 | * book remains a responsibility of the 19 | * SelectedBookPageComponent 20 | */ 21 | @Component({ 22 | selector: 'bc-view-book-page', 23 | changeDetection: ChangeDetectionStrategy.OnPush, 24 | template: ` 25 | 26 | ` 27 | }) 28 | export class ViewBookPageComponent implements OnDestroy { 29 | actionsSubscription: Subscription; 30 | 31 | constructor(store: Store, route: ActivatedRoute) { 32 | this.actionsSubscription = route.params 33 | .select('id') 34 | .map(id => BookActionEnum.SELECT.toAction(id)) 35 | .subscribe(store); 36 | } 37 | 38 | ngOnDestroy() { 39 | this.actionsSubscription.unsubscribe(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/app/db.ts: -------------------------------------------------------------------------------- 1 | import { DBSchema } from '@ngrx/db'; 2 | 3 | 4 | /** 5 | * ngrx/db uses a simple schema config object to initialize stores in IndexedDB. 6 | */ 7 | export const schema: DBSchema = { 8 | version: 1, 9 | name: 'books_app', 10 | stores: { 11 | books: { 12 | autoIncrement: true, 13 | primaryKey: 'id' 14 | } 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /src/app/effects/book.spec.ts: -------------------------------------------------------------------------------- 1 | import 'rxjs/add/observable/of'; 2 | import 'rxjs/add/observable/throw'; 3 | import {Action} from '@ngrx/store'; 4 | import {EffectsRunner, EffectsTestingModule} from '@ngrx/effects/testing'; 5 | import {fakeAsync, TestBed, tick} from '@angular/core/testing'; 6 | import {BookEffects} from './book'; 7 | import {GoogleBooksService} from '../services/google-books'; 8 | import {Observable} from 'rxjs/Observable'; 9 | import {BookActionEnum} from '../actions/book'; 10 | import {Book} from '../models/book'; 11 | 12 | describe('BookEffects', () => { 13 | beforeEach(() => TestBed.configureTestingModule({ 14 | imports: [ 15 | EffectsTestingModule 16 | ], 17 | providers: [ 18 | BookEffects, 19 | { 20 | provide: GoogleBooksService, 21 | useValue: jasmine.createSpyObj('googleBooksService', ['searchBooks']) 22 | } 23 | ] 24 | })); 25 | 26 | function setup(params?: {searchBooksReturnValue: any}) { 27 | const googleBooksService = TestBed.get(GoogleBooksService); 28 | if (params) { 29 | googleBooksService.searchBooks.and.returnValue(params.searchBooksReturnValue); 30 | } 31 | 32 | return { 33 | runner: TestBed.get(EffectsRunner), 34 | bookEffects: TestBed.get(BookEffects) 35 | }; 36 | } 37 | 38 | describe('search$', () => { 39 | it('should return a new book.SearchCompleteAction, with the books, on success, after the de-bounce', fakeAsync(() => { 40 | const book1 = {id: '111', volumeInfo: {}} as Book; 41 | const book2 = {id: '222', volumeInfo: {}} as Book; 42 | const books = [book1, book2]; 43 | 44 | const {runner, bookEffects} = setup({searchBooksReturnValue: Observable.of(books)}); 45 | 46 | const expectedResult = BookActionEnum.SEARCH_COMPLETE.toAction(books); 47 | runner.queue(BookActionEnum.SEARCH.toAction('query')); 48 | 49 | let result = null; 50 | bookEffects.search$.subscribe((_result: Action) => result = _result); 51 | tick(299); // test de-bounce 52 | expect(result).toBe(null); 53 | tick(300); 54 | expect(result).toEqual(expectedResult); 55 | })); 56 | 57 | it('should return a new book.SearchCompleteAction, with an empty array, if the books service throws', fakeAsync(() => { 58 | const {runner, bookEffects} = setup({searchBooksReturnValue: Observable.throw(new Error())}); 59 | 60 | const expectedResult = BookActionEnum.SEARCH_COMPLETE.toAction([]); 61 | runner.queue(BookActionEnum.SEARCH.toAction('query')); 62 | 63 | let result = null; 64 | bookEffects.search$.subscribe((_result: Action) => result = _result); 65 | tick(299); // test de-bounce 66 | expect(result).toBe(null); 67 | tick(300); 68 | expect(result).toEqual(expectedResult); 69 | })); 70 | 71 | it(`should not do anything if the query is an empty string`, fakeAsync(() => { 72 | const {runner, bookEffects} = setup(); 73 | 74 | runner.queue(BookActionEnum.SEARCH.toAction('')); 75 | let result = null; 76 | bookEffects.search$.subscribe({ 77 | next: () => result = false, 78 | complete: () => result = false, 79 | error: () => result = false 80 | }); 81 | 82 | tick(300); 83 | expect(result).toBe(null); 84 | })); 85 | 86 | }); 87 | }); 88 | 89 | -------------------------------------------------------------------------------- /src/app/effects/book.ts: -------------------------------------------------------------------------------- 1 | import 'rxjs/add/operator/catch'; 2 | import 'rxjs/add/operator/map'; 3 | import 'rxjs/add/operator/switchMap'; 4 | import 'rxjs/add/operator/debounceTime'; 5 | import 'rxjs/add/operator/skip'; 6 | import 'rxjs/add/operator/takeUntil'; 7 | import {Injectable} from '@angular/core'; 8 | import {Effect, Actions, toPayload} from '@ngrx/effects'; 9 | import {Action} from '@ngrx/store'; 10 | import {Observable} from 'rxjs/Observable'; 11 | import {empty} from 'rxjs/observable/empty'; 12 | import {of} from 'rxjs/observable/of'; 13 | 14 | import {GoogleBooksService} from '../services/google-books'; 15 | import {BookActionEnum} from '../actions/book'; 16 | 17 | 18 | /** 19 | * Effects offer a way to isolate and easily test side-effects within your 20 | * application. 21 | * The `toPayload` helper function returns just 22 | * the payload of the currently dispatched action, useful in 23 | * instances where the current state is not necessary. 24 | * 25 | * Documentation on `toPayload` can be found here: 26 | * https://github.com/ngrx/effects/blob/master/docs/api.md#topayload 27 | * 28 | * If you are unfamiliar with the operators being used in these examples, please 29 | * check out the sources below: 30 | * 31 | * Official Docs: http://reactivex.io/rxjs/manual/overview.html#categories-of-operators 32 | * RxJS 5 Operators By Example: https://gist.github.com/btroncone/d6cf141d6f2c00dc6b35 33 | */ 34 | 35 | @Injectable() 36 | export class BookEffects { 37 | 38 | @Effect() 39 | search$: Observable = this.actions$ 40 | .ofType(BookActionEnum.SEARCH.type) 41 | .debounceTime(300) 42 | .map(toPayload) 43 | .switchMap(query => { 44 | if (query === '') { 45 | return empty(); 46 | } 47 | 48 | const nextSearch$ = this.actions$.ofType(BookActionEnum.SEARCH.type).skip(1); 49 | 50 | return this.googleBooks.searchBooks(query) 51 | .takeUntil(nextSearch$) 52 | .map(books => BookActionEnum.SEARCH_COMPLETE.toAction(books)) 53 | .catch(() => of(BookActionEnum.SEARCH_COMPLETE.toAction([]))); 54 | }); 55 | 56 | constructor(private actions$: Actions, private googleBooks: GoogleBooksService) { 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/app/effects/collection.spec.ts: -------------------------------------------------------------------------------- 1 | import 'rxjs/add/observable/of'; 2 | import 'rxjs/add/observable/throw'; 3 | import {Action} from '@ngrx/store'; 4 | import {EffectsRunner, EffectsTestingModule} from '@ngrx/effects/testing'; 5 | import {TestBed} from '@angular/core/testing'; 6 | import {CollectionEffects} from './collection'; 7 | import {Database} from '@ngrx/db'; 8 | import {Book} from '../models/book'; 9 | import {CollectionActionEnum} from '../actions/collection'; 10 | import {Observable} from 'rxjs/Observable'; 11 | 12 | describe('CollectionEffects', () => { 13 | beforeEach(() => TestBed.configureTestingModule({ 14 | imports: [ 15 | EffectsTestingModule 16 | ], 17 | providers: [ 18 | CollectionEffects, 19 | { 20 | provide: Database, 21 | useValue: jasmine.createSpyObj('database', ['open', 'query', 'insert', 'executeWrite']) 22 | } 23 | ] 24 | })); 25 | 26 | function setup() { 27 | return { 28 | db: TestBed.get(Database), 29 | runner: TestBed.get(EffectsRunner), 30 | collectionEffects: TestBed.get(CollectionEffects) 31 | }; 32 | } 33 | 34 | describe('openDB$', () => { 35 | it('should call db.open when initially subscribed to', () => { 36 | const {db, collectionEffects} = setup(); 37 | collectionEffects.openDB$.subscribe(); 38 | expect(db.open).toHaveBeenCalledWith('books_app'); 39 | }); 40 | }); 41 | 42 | describe('loadCollection$', () => { 43 | it('should return a collection.LoadSuccessAction, with the books, on success', () => { 44 | const book1 = {id: '111', volumeInfo: {}} as Book; 45 | const book2 = {id: '222', volumeInfo: {}} as Book; 46 | 47 | const {db, runner, collectionEffects} = setup(); 48 | 49 | const booksObservable = Observable.of(book1, book2); 50 | db.query.and.returnValue(booksObservable); 51 | 52 | const expectedResult = CollectionActionEnum.LOAD_SUCCESS.toAction([book1, book2]); 53 | 54 | runner.queue(CollectionActionEnum.LOAD.toAction()); 55 | 56 | collectionEffects.loadCollection$.subscribe((result: Action) => { 57 | expect(result).toEqual(expectedResult); 58 | }); 59 | }); 60 | 61 | it('should return a collection.LoadFailAction, if the query throws', () => { 62 | const {db, runner, collectionEffects} = setup(); 63 | 64 | const error = new Error('msg'); 65 | db.query.and.returnValue(Observable.throw(error)); 66 | 67 | const expectedResult = CollectionActionEnum.LOAD_FAIL.toAction(error); 68 | 69 | runner.queue(CollectionActionEnum.LOAD.toAction()); 70 | 71 | collectionEffects.loadCollection$.subscribe((result: Action) => { 72 | expect(result).toEqual(expectedResult); 73 | }); 74 | }); 75 | }); 76 | 77 | describe('addBookToCollection$', () => { 78 | it('should return a collection.AddBookSuccessAction, with the book, on success', () => { 79 | const book = {id: '111', volumeInfo: {}} as Book; 80 | 81 | const {db, runner, collectionEffects} = setup(); 82 | db.insert.and.returnValue(Observable.of({})); 83 | 84 | const expectedResult = CollectionActionEnum.ADD_BOOK_SUCCESS.toAction(book); 85 | 86 | runner.queue(CollectionActionEnum.ADD_BOOK.toAction(book)); 87 | 88 | collectionEffects.addBookToCollection$.subscribe((result: Action) => { 89 | expect(result).toEqual(expectedResult); 90 | expect(db.insert).toHaveBeenCalledWith('books', [book]); 91 | }); 92 | }); 93 | 94 | it('should return a collection.AddBookFailAction, with the book, when the db insert throws', () => { 95 | const book = {id: '111', volumeInfo: {}} as Book; 96 | 97 | const {db, runner, collectionEffects} = setup(); 98 | db.insert.and.returnValue(Observable.throw(new Error())); 99 | 100 | const expectedResult = CollectionActionEnum.ADD_BOOK_FAIL.toAction(book); 101 | 102 | runner.queue(CollectionActionEnum.ADD_BOOK.toAction(book)); 103 | 104 | collectionEffects.addBookToCollection$.subscribe((result: Action) => { 105 | expect(result).toEqual(expectedResult); 106 | expect(db.insert).toHaveBeenCalledWith('books', [book]); 107 | }); 108 | }); 109 | 110 | describe('removeBookFromCollection$', () => { 111 | it('should return a collection.RemoveBookSuccessAction, with the book, on success', () => { 112 | const book = {id: '111', volumeInfo: {}} as Book; 113 | 114 | const {db, runner, collectionEffects} = setup(); 115 | db.executeWrite.and.returnValue(Observable.of({})); 116 | 117 | const expectedResult = CollectionActionEnum.REMOVE_BOOK_SUCCESS.toAction(book); 118 | 119 | runner.queue(CollectionActionEnum.REMOVE_BOOK.toAction(book)); 120 | 121 | collectionEffects.removeBookFromCollection$.subscribe((result: Action) => { 122 | expect(result).toEqual(expectedResult); 123 | expect(db.executeWrite).toHaveBeenCalledWith('books', 'delete', ['111']); 124 | }); 125 | }); 126 | 127 | it('should return a collection.RemoveBookFailAction, with the book, when the db insert throws', () => { 128 | const book = {id: '111', volumeInfo: {}} as Book; 129 | 130 | const {db, runner, collectionEffects} = setup(); 131 | db.executeWrite.and.returnValue(Observable.throw(new Error())); 132 | 133 | const expectedResult = CollectionActionEnum.REMOVE_BOOK_FAIL.toAction(book); 134 | 135 | runner.queue(CollectionActionEnum.REMOVE_BOOK.toAction(book)); 136 | 137 | collectionEffects.removeBookFromCollection$.subscribe((result: Action) => { 138 | expect(result).toEqual(expectedResult); 139 | expect(db.executeWrite).toHaveBeenCalledWith('books', 'delete', ['111']); 140 | }); 141 | }); 142 | }); 143 | }); 144 | }); 145 | -------------------------------------------------------------------------------- /src/app/effects/collection.ts: -------------------------------------------------------------------------------- 1 | import 'rxjs/add/operator/map'; 2 | import 'rxjs/add/operator/catch'; 3 | import 'rxjs/add/operator/startWith'; 4 | import 'rxjs/add/operator/switchMap'; 5 | import 'rxjs/add/operator/mergeMap'; 6 | import 'rxjs/add/operator/toArray'; 7 | import {Injectable} from '@angular/core'; 8 | import {Action} from '@ngrx/store'; 9 | import {Actions, Effect} from '@ngrx/effects'; 10 | import {Database} from '@ngrx/db'; 11 | import {Observable} from 'rxjs/Observable'; 12 | import {defer} from 'rxjs/observable/defer'; 13 | import {of} from 'rxjs/observable/of'; 14 | 15 | import {CollectionActionEnum} from '../actions/collection'; 16 | import {TypedAction} from '../actions/action-enum'; 17 | import {Book} from '../models/book'; 18 | 19 | 20 | @Injectable() 21 | export class CollectionEffects { 22 | 23 | /** 24 | * This effect does not yield any actions back to the store. Set 25 | * `dispatch` to false to hint to @ngrx/effects that it should 26 | * ignore any elements of this effect stream. 27 | * 28 | * The `defer` observable accepts an observable factory function 29 | * that is called when the observable is subscribed to. 30 | * Wrapping the database open call in `defer` makes 31 | * effect easier to test. 32 | */ 33 | @Effect({ dispatch: false }) 34 | openDB$: Observable = defer(() => { 35 | return this.db.open('books_app'); 36 | }); 37 | 38 | /** 39 | * This effect makes use of the `startWith` operator to trigger 40 | * the effect immediately on startup. 41 | */ 42 | @Effect() 43 | loadCollection$: Observable = this.actions$ 44 | .ofType(CollectionActionEnum.LOAD.type) 45 | .startWith(CollectionActionEnum.LOAD.toAction()) 46 | .switchMap(() => 47 | this.db.query('books') 48 | .toArray() 49 | .map((books: Book[]) => CollectionActionEnum.LOAD_SUCCESS.toAction(books)) 50 | .catch(error => of(CollectionActionEnum.LOAD_FAIL.toAction(error))) 51 | ); 52 | 53 | @Effect() 54 | addBookToCollection$: Observable = this.actions$ 55 | .ofType(CollectionActionEnum.ADD_BOOK.type) 56 | .map((action: TypedAction) => action.payload) 57 | .mergeMap(book => 58 | this.db.insert('books', [ book ]) 59 | .map(() => CollectionActionEnum.ADD_BOOK_SUCCESS.toAction(book)) 60 | .catch(() => of(CollectionActionEnum.ADD_BOOK_FAIL.toAction(book))) 61 | ); 62 | 63 | 64 | @Effect() 65 | removeBookFromCollection$: Observable = this.actions$ 66 | .ofType(CollectionActionEnum.REMOVE_BOOK.type) 67 | .map((action: TypedAction) => action.payload) 68 | .mergeMap(book => 69 | this.db.executeWrite('books', 'delete', [ book.id ]) 70 | .map(() => CollectionActionEnum.REMOVE_BOOK_SUCCESS.toAction(book)) 71 | .catch(() => of(CollectionActionEnum.REMOVE_BOOK_FAIL.toAction(book))) 72 | ); 73 | 74 | constructor(private actions$: Actions, private db: Database) { } 75 | } 76 | -------------------------------------------------------------------------------- /src/app/guards/book-exists.ts: -------------------------------------------------------------------------------- 1 | import 'rxjs/add/operator/take'; 2 | import 'rxjs/add/operator/filter'; 3 | import 'rxjs/add/operator/do'; 4 | import 'rxjs/add/operator/map'; 5 | import 'rxjs/add/operator/switchMap'; 6 | import 'rxjs/add/operator/catch'; 7 | import 'rxjs/add/operator/let'; 8 | import {Injectable} from '@angular/core'; 9 | import {Store} from '@ngrx/store'; 10 | import {ActivatedRouteSnapshot, CanActivate, Router} from '@angular/router'; 11 | import {Observable} from 'rxjs/Observable'; 12 | import {of} from 'rxjs/observable/of'; 13 | 14 | import {GoogleBooksService} from '../services/google-books'; 15 | import * as fromRoot from '../reducers'; 16 | import {BookActionEnum} from '../actions/book'; 17 | import {Book} from '../models/book'; 18 | import {TypedAction} from '../actions/action-enum'; 19 | 20 | 21 | /** 22 | * Guards are hooks into the route resolution process, providing an opportunity 23 | * to inform the router's navigation process whether the route should continue 24 | * to activate this route. Guards must return an observable of true or false. 25 | */ 26 | @Injectable() 27 | export class BookExistsGuard implements CanActivate { 28 | constructor( 29 | private store: Store, 30 | private googleBooks: GoogleBooksService, 31 | private router: Router 32 | ) { } 33 | 34 | /** 35 | * This method creates an observable that waits for the `loaded` property 36 | * of the collection state to turn `true`, emitting one time once loading 37 | * has finished. 38 | */ 39 | waitForCollectionToLoad(): Observable { 40 | return this.store.select(fromRoot.getCollectionLoaded) 41 | .filter(loaded => loaded) 42 | .take(1); 43 | } 44 | 45 | /** 46 | * This method checks if a book with the given ID is already registered 47 | * in the Store 48 | */ 49 | hasBookInStore(id: string): Observable { 50 | return this.store.select(fromRoot.getBookEntities) 51 | .map(entities => !!entities[id]) 52 | .take(1); 53 | } 54 | 55 | /** 56 | * This method loads a book with the given ID from the API and caches 57 | * it in the store, returning `true` or `false` if it was found. 58 | */ 59 | hasBookInApi(id: string): Observable { 60 | return this.googleBooks.retrieveBook(id) 61 | .map(bookEntity => BookActionEnum.LOAD.toAction(bookEntity)) 62 | .do((action: TypedAction) => this.store.dispatch(action)) 63 | .map(book => !!book) 64 | .catch(() => { 65 | this.router.navigate(['/404']); 66 | return of(false); 67 | }); 68 | } 69 | 70 | /** 71 | * `hasBook` composes `hasBookInStore` and `hasBookInApi`. It first checks 72 | * if the book is in store, and if not it then checks if it is in the 73 | * API. 74 | */ 75 | hasBook(id: string): Observable { 76 | return this.hasBookInStore(id) 77 | .switchMap(inStore => { 78 | if (inStore) { 79 | return of(inStore); 80 | } 81 | 82 | return this.hasBookInApi(id); 83 | }); 84 | } 85 | 86 | /** 87 | * This is the actual method the router will call when our guard is run. 88 | * 89 | * Our guard waits for the collection to load, then it checks if we need 90 | * to request a book from the API or if we already have it in our cache. 91 | * If it finds it in the cache or in the API, it returns an Observable 92 | * of `true` and the route is rendered successfully. 93 | * 94 | * If it was unable to find it in our cache or in the API, this guard 95 | * will return an Observable of `false`, causing the router to move 96 | * on to the next candidate route. In this case, it will move on 97 | * to the 404 page. 98 | */ 99 | canActivate(route: ActivatedRouteSnapshot): Observable { 100 | return this.waitForCollectionToLoad() 101 | .switchMap(() => this.hasBook(route.params['id'])); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/app/index.ts: -------------------------------------------------------------------------------- 1 | export * from './app.module'; 2 | -------------------------------------------------------------------------------- /src/app/models/book.ts: -------------------------------------------------------------------------------- 1 | export interface Book { 2 | id: string; 3 | volumeInfo: { 4 | title: string; 5 | subtitle: string; 6 | authors: string[]; 7 | publisher: string; 8 | publishDate: string; 9 | description: string; 10 | averageRating: number; 11 | ratingsCount: number; 12 | imageLinks: { 13 | thumbnail: string; 14 | smallThumbnail: string; 15 | }; 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /src/app/pipes/add-commas.spec.ts: -------------------------------------------------------------------------------- 1 | import { AddCommasPipe } from './add-commas'; 2 | 3 | describe('Pipe: Add Commas', () => { 4 | let pipe: AddCommasPipe; 5 | 6 | beforeEach(() => { 7 | pipe = new AddCommasPipe(); 8 | }); 9 | 10 | it('should transform ["Rick"] to "Rick"', () => { 11 | expect(pipe.transform(['Rick'])).toEqual('Rick'); 12 | }); 13 | 14 | it('should transform ["Jeremy", "Andrew"] to "Jeremy and Andrew"', () => { 15 | expect(pipe.transform(['Jeremy', 'Andrew'])).toEqual('Jeremy and Andrew'); 16 | }); 17 | 18 | it('should transform ["Kim", "Ryan", "Amanda"] to "Kim, Ryan, and Amanda"', () => { 19 | expect(pipe.transform(['Kim', 'Ryan', 'Amanda'])).toEqual('Kim, Ryan, and Amanda'); 20 | }); 21 | 22 | it('transforms undefined to "Author Unknown"', () => { 23 | expect(pipe.transform(undefined)).toEqual('Author Unknown'); 24 | }); 25 | 26 | it('transforms [] to "Author Unknown"', () => { 27 | expect(pipe.transform([])).toEqual('Author Unknown'); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/app/pipes/add-commas.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | 4 | @Pipe({ name: 'bcAddCommas' }) 5 | export class AddCommasPipe implements PipeTransform { 6 | transform(authors: null | string[]) { 7 | if (!authors) { 8 | return 'Author Unknown'; 9 | } 10 | 11 | switch (authors.length) { 12 | case 0: 13 | return 'Author Unknown'; 14 | case 1: 15 | return authors[0]; 16 | case 2: 17 | return authors.join(' and '); 18 | default: 19 | const last = authors[authors.length - 1]; 20 | const remaining = authors.slice(0, -1); 21 | return `${remaining.join(', ')}, and ${last}`; 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/app/pipes/ellipsis.spec.ts: -------------------------------------------------------------------------------- 1 | import { EllipsisPipe } from './ellipsis'; 2 | 3 | describe('Pipe: Ellipsis', () => { 4 | let pipe: EllipsisPipe; 5 | const longStr = `Lorem ipsum dolor sit amet, 6 | consectetur adipisicing elit. Quibusdam ab similique, odio sit 7 | harum laborum rem, nesciunt atque iure a pariatur nam nihil dolore necessitatibus quos ea autem accusantium dolor 8 | voluptates voluptatibus. Doloribus libero, facilis ea nam 9 | quibusdam aut labore itaque aliquid, optio. Rerum, dolorum! 10 | Error ratione tempore nesciunt magnam reprehenderit earum 11 | tempora aliquam laborum consectetur repellendus, nam hic 12 | maiores, qui corrupti saepe possimus, velit impedit eveniet 13 | totam. Aliquid qui corrupti facere. Alias itaque pariatur 14 | aliquam, nemo praesentium. Iure delectus, nemo natus! Libero 15 | ducimus aspernatur laborum voluptatibus officiis eaque enim 16 | minus accusamus, harum facilis sed eum! Sit vero vitae 17 | voluptatibus deleniti, corporis deserunt? Optio reprehenderit 18 | quae nesciunt minus at, sint fuga impedit, laborum praesentium 19 | illo nisi natus quia illum obcaecati id error suscipit eaque! 20 | Sed quam, ab dolorum qui sit dolorem fuga laudantium est, 21 | voluptas sequi consequuntur dolores animi veritatis doloremque 22 | at placeat maxime suscipit provident? Mollitia deserunt 23 | repudiandae illo. Similique voluptatem repudiandae possimus 24 | veritatis amet incidunt alias, debitis eveniet voluptate 25 | magnam consequatur eum molestiae provident est dicta. A autem 26 | praesentium voluptas, quis itaque doloremque quidem debitis? 27 | Ex qui, corporis voluptatibus assumenda necessitatibus 28 | accusamus earum rem cum quidem quasi! Porro assumenda, modi. 29 | Voluptatibus enim dignissimos fugit voluptas hic ducimus ullam, 30 | minus. Soluta architecto ratione, accusamus vitae eligendi 31 | explicabo beatae reprehenderit. Officiis voluptatibus 32 | dignissimos cum magni! Deleniti fuga reiciendis, ab dicta 33 | quasi impedit voluptatibus earum ratione inventore cum 34 | voluptas eligendi vel ut tenetur numquam, alias praesentium 35 | iusto asperiores, ipsa. Odit a ea, quaerat culpa dolore 36 | veritatis mollitia veniam quidem, velit, natus sint at.`; 37 | 38 | beforeEach(() => { 39 | pipe = new EllipsisPipe(); 40 | }); 41 | 42 | it('should return the string if it\'s length is less than 250', () => { 43 | expect(pipe.transform('string')).toEqual('string'); 44 | }); 45 | 46 | it('should return up to 250 characters followed by an ellipsis', () => { 47 | expect(pipe.transform(longStr)).toEqual(`${longStr.substr(0, 250)}...`); 48 | }); 49 | 50 | it('should return only 20 characters followed by an ellipsis', () => { 51 | expect(pipe.transform(longStr, 20)).toEqual(`${longStr.substr(0, 20)}...`); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/app/pipes/ellipsis.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | 4 | @Pipe({ name: 'bcEllipsis' }) 5 | export class EllipsisPipe implements PipeTransform { 6 | transform(str: string, strLength: number = 250) { 7 | const withoutHtml = str.replace(/(<([^>]+)>)/ig, ''); 8 | 9 | if (str.length >= strLength) { 10 | return `${withoutHtml.slice(0, strLength)}...`; 11 | } 12 | 13 | return withoutHtml; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/app/pipes/index.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { AddCommasPipe } from './add-commas'; 4 | import { EllipsisPipe } from './ellipsis'; 5 | 6 | 7 | export const PIPES = [ 8 | AddCommasPipe, 9 | EllipsisPipe, 10 | ]; 11 | 12 | @NgModule({ 13 | declarations: PIPES, 14 | exports: PIPES 15 | }) 16 | export class PipesModule { } 17 | -------------------------------------------------------------------------------- /src/app/reducers/book.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fromBooks from './books'; 2 | import {BooksReducerEnum} from './books'; 3 | import {Book} from '../models/book'; 4 | import {BookAction, BookActionEnum} from '../actions/book'; 5 | 6 | describe('BooksReducer', () => { 7 | describe('undefined action', () => { 8 | it('should return the default state', () => { 9 | const action = {} as any; 10 | 11 | const result = BooksReducerEnum.reducer()(undefined, action); 12 | expect(result).toEqual(fromBooks.initialState); 13 | }); 14 | }); 15 | 16 | describe('SEARCH_COMPLETE & LOAD_SUCCESS', () => { 17 | function noExistingBooks(actionEnum: BookAction) { 18 | const book1 = {id: '111'} as Book; 19 | const book2 = {id: '222'} as Book; 20 | const createAction = actionEnum.toAction([book1, book2]); 21 | 22 | const expectedResult = { 23 | ids: ['111', '222'], 24 | entities: { 25 | '111': book1, 26 | '222': book2 27 | }, 28 | selectedBookId: null, 29 | } as fromBooks.State; 30 | 31 | const result = BooksReducerEnum.reducer()(fromBooks.initialState, createAction); 32 | expect(result).toEqual(expectedResult); 33 | } 34 | 35 | function existingBooks(actionEnum: BookAction) { 36 | const book1 = {id: '111'} as Book; 37 | const book2 = {id: '222'} as Book; 38 | const initialState = { 39 | ids: ['111', '222'], 40 | entities: { 41 | '111': book1, 42 | '222': book2 43 | }, 44 | selectedBookId: null, 45 | } as any; 46 | // should not replace existing books 47 | const differentBook2 = {id: '222', foo: 'bar'} as any; 48 | const book3 = {id: '333'} as Book; 49 | const createAction = actionEnum.toAction([book3, differentBook2]); 50 | 51 | const expectedResult = { 52 | ids: ['111', '222', '333'], 53 | entities: { 54 | '111': book1, 55 | '222': book2, 56 | '333': book3 57 | }, 58 | selectedBookId: null, 59 | } as fromBooks.State; 60 | 61 | const result = BooksReducerEnum.reducer()(initialState, createAction); 62 | expect(result).toEqual(expectedResult); 63 | } 64 | 65 | it('should add all books in the payload when none exist', () => { 66 | noExistingBooks(BookActionEnum.SEARCH_COMPLETE); 67 | noExistingBooks(BookActionEnum.LOAD); 68 | }); 69 | 70 | it('should add only new books when books already exist', () => { 71 | existingBooks(BookActionEnum.SEARCH_COMPLETE); 72 | existingBooks(BookActionEnum.LOAD); 73 | }); 74 | }); 75 | 76 | describe('LOAD', () => { 77 | it('should add a single book, if the book does not exist', () => { 78 | const book = {id: '888'} as Book; 79 | const action = BookActionEnum.LOAD.toAction(book); 80 | 81 | const expectedResult = { 82 | ids: ['888'], 83 | entities: { 84 | '888': book 85 | }, 86 | selectedBookId: null 87 | } as fromBooks.State; 88 | 89 | const result = BooksReducerEnum.reducer()(fromBooks.initialState, action); 90 | expect(result).toEqual(expectedResult); 91 | }); 92 | 93 | it('should return the existing state if the book exists', () => { 94 | const initialState = { 95 | ids: ['999'], 96 | entities: { 97 | '999': {id: '999'} 98 | } 99 | } as any; 100 | const book = {id: '999', foo: 'baz'} as any; 101 | const action = BookActionEnum.LOAD.toAction(book); 102 | 103 | const result = BooksReducerEnum.reducer()(initialState, action); 104 | expect(result).toEqual(initialState); 105 | }); 106 | }); 107 | 108 | describe('SELECT', () => { 109 | it('should set the selected book id on the state', () => { 110 | const action = BookActionEnum.SELECT.toAction('1'); 111 | 112 | const result = BooksReducerEnum.reducer()(fromBooks.initialState, action); 113 | expect(result.selectedBookId).toBe('1'); 114 | }); 115 | }); 116 | 117 | describe('Selections', () => { 118 | const book1 = {id: '111'} as Book; 119 | const book2 = {id: '222'} as Book; 120 | const state: fromBooks.State = { 121 | ids: ['111', '222'], 122 | entities: { 123 | '111': book1, 124 | '222': book2, 125 | }, 126 | selectedBookId: '111' 127 | }; 128 | 129 | describe('getEntities', () => { 130 | it('should return entities', () => { 131 | const result = fromBooks.getEntities(state); 132 | expect(result).toBe(state.entities); 133 | }); 134 | }); 135 | 136 | describe('getIds', () => { 137 | it('should return ids', () => { 138 | const result = fromBooks.getIds(state); 139 | expect(result).toBe(state.ids); 140 | }); 141 | }); 142 | 143 | describe('getSelectedId', () => { 144 | it('should return the selected id', () => { 145 | const result = fromBooks.getSelectedId(state); 146 | expect(result).toBe('111'); 147 | }); 148 | }); 149 | 150 | describe('getSelected', () => { 151 | it('should return the selected book', () => { 152 | const result = fromBooks.getSelected(state); 153 | expect(result).toBe(book1); 154 | }); 155 | }); 156 | 157 | describe('getAll', () => { 158 | it('should return all books as an array ', () => { 159 | const result = fromBooks.getAll(state); 160 | expect(result).toEqual([book1, book2]); 161 | }); 162 | }); 163 | 164 | }); 165 | }); 166 | -------------------------------------------------------------------------------- /src/app/reducers/books.ts: -------------------------------------------------------------------------------- 1 | import {createSelector} from 'reselect'; 2 | import {Book} from '../models/book'; 3 | import {BookActionEnum} from '../actions/book'; 4 | import {CollectionActionEnum} from '../actions/collection'; 5 | import {ActionEnumValue, TypedAction} from '../actions/action-enum'; 6 | import { 7 | ReducerEnum, 8 | ReducerEnumValue, 9 | ReducerFunction 10 | } from './reducer-enum'; 11 | import {ActionReducer} from '@ngrx/store'; 12 | 13 | 14 | export interface State { 15 | ids: string[]; 16 | entities: { [id: string]: Book }; 17 | selectedBookId: string | null; 18 | } 19 | 20 | export const initialState: State = { 21 | ids: [], 22 | entities: {}, 23 | selectedBookId: null, 24 | }; 25 | 26 | export class BooksReducer extends ReducerEnumValue { 27 | constructor(action: ActionEnumValue | ActionEnumValue[], 28 | reduce: ReducerFunction) { 29 | super(action, reduce); 30 | } 31 | } 32 | 33 | export class BooksReducerEnumType extends ReducerEnum, State> { 34 | 35 | SEARCH_COMPLETE = new BooksReducer( 36 | [BookActionEnum.SEARCH_COMPLETE, CollectionActionEnum.LOAD_SUCCESS], 37 | (state: State, action: TypedAction) => { 38 | const books = action.payload; 39 | const newBooks = books.filter((book: Book) => !state.entities[book.id]); 40 | 41 | const newBookIds = newBooks.map((book: Book) => book.id); 42 | const newBookEntities = newBooks.reduce((entities: { [id: string]: Book }, book: Book) => { 43 | return Object.assign(entities, { 44 | [book.id]: book 45 | }); 46 | }, {}); 47 | 48 | return { 49 | ids: [...state.ids, ...newBookIds], 50 | entities: Object.assign({}, state.entities, newBookEntities), 51 | selectedBookId: state.selectedBookId 52 | }; 53 | }); 54 | LOAD = new BooksReducer(BookActionEnum.LOAD, 55 | (state: State, action: TypedAction) => { 56 | const book = action.payload; 57 | 58 | if (state.ids.indexOf(book.id) > -1) { 59 | return state; 60 | } 61 | 62 | return { 63 | ids: [...state.ids, book.id], 64 | entities: Object.assign({}, state.entities, { 65 | [book.id]: book 66 | }), 67 | selectedBookId: state.selectedBookId 68 | }; 69 | }); 70 | SELECT = new BooksReducer(BookActionEnum.SELECT, 71 | (state: State, action: TypedAction) => { 72 | return { 73 | ids: state.ids, 74 | entities: state.entities, 75 | selectedBookId: action.payload 76 | }; 77 | }); 78 | 79 | constructor() { 80 | super(initialState); 81 | this.initEnum('booksReducers'); 82 | } 83 | } 84 | 85 | export const BooksReducerEnum = new BooksReducerEnumType(); 86 | const reducer: ActionReducer = BooksReducerEnum.reducer(); 87 | 88 | export function booksReducer(state: State, action: TypedAction): State { 89 | return reducer(state, action); 90 | } 91 | 92 | /** 93 | * Because the data structure is defined within the reducer it is optimal to 94 | * locate our selector functions at this level. If store is to be thought of 95 | * as a database, and reducers the tables, selectors can be considered the 96 | * queries into said database. Remember to keep your selectors small and 97 | * focused so they can be combined and composed to fit each particular 98 | * use-case. 99 | */ 100 | 101 | export const getEntities = (state: State) => state.entities; 102 | 103 | export const getIds = (state: State) => state.ids; 104 | 105 | export const getSelectedId = (state: State) => state.selectedBookId; 106 | 107 | export const getSelected = createSelector(getEntities, getSelectedId, (entities, selectedId) => { 108 | return entities[selectedId]; 109 | }); 110 | 111 | export const getAll = createSelector(getEntities, getIds, (entities, ids) => { 112 | return ids.map(id => entities[id]); 113 | }); 114 | -------------------------------------------------------------------------------- /src/app/reducers/collection.ts: -------------------------------------------------------------------------------- 1 | import {CollectionActionEnum} from '../actions/collection'; 2 | import {Book} from '../models/book'; 3 | import {ActionEnumValue, TypedAction} from '../actions/action-enum'; 4 | import { 5 | ReducerEnum, 6 | ReducerEnumValue, 7 | ReducerFunction 8 | } from './reducer-enum'; 9 | import {ActionReducer} from '@ngrx/store'; 10 | 11 | 12 | export interface State { 13 | loaded: boolean; 14 | loading: boolean; 15 | ids: string[]; 16 | } 17 | 18 | const initialState: State = { 19 | loaded: false, 20 | loading: false, 21 | ids: [] 22 | }; 23 | 24 | export class CollectionReducer extends ReducerEnumValue { 25 | constructor(action: ActionEnumValue | ActionEnumValue[], 26 | reduce: ReducerFunction) { 27 | super(action, reduce); 28 | } 29 | } 30 | 31 | export class CollectionReducerEnumType extends ReducerEnum, State> { 32 | 33 | LOAD = new CollectionReducer(CollectionActionEnum.LOAD, 34 | (state: State) => ({...state, loading: true})); 35 | LOAD_SUCCESS = new CollectionReducer(CollectionActionEnum.LOAD_SUCCESS, 36 | (state: State, action: TypedAction) => { 37 | return { 38 | loaded: true, 39 | loading: false, 40 | ids: action.payload.map((book: Book) => book.id) 41 | }; 42 | }); 43 | ADD_BOOK_SUCCESS = new CollectionReducer( 44 | [CollectionActionEnum.ADD_BOOK_SUCCESS, 45 | CollectionActionEnum.REMOVE_BOOK_FAIL], 46 | (state: State, action: TypedAction) => { 47 | const book = action.payload; 48 | 49 | if (state.ids.indexOf(book.id) > -1) { 50 | return state; 51 | } 52 | 53 | return Object.assign({}, state, { 54 | ids: [ ...state.ids, book.id ] 55 | }); 56 | }); 57 | REMOVE_BOOK_SUCCESS = new CollectionReducer( 58 | [CollectionActionEnum.REMOVE_BOOK_SUCCESS, 59 | CollectionActionEnum.ADD_BOOK_FAIL], 60 | (state: State, action: TypedAction) => { 61 | const book = action.payload; 62 | 63 | return Object.assign({}, state, { 64 | ids: state.ids.filter(id => id !== book.id) 65 | }); 66 | }); 67 | 68 | constructor() { 69 | super(initialState); 70 | this.initEnum('collectionReducers'); 71 | } 72 | } 73 | 74 | export const CollectionReducerEnum = new CollectionReducerEnumType(); 75 | const reducer: ActionReducer = CollectionReducerEnum.reducer(); 76 | 77 | export function collectionReducer(state: State, action: TypedAction): State { 78 | return reducer(state, action); 79 | } 80 | 81 | export const getLoaded = (state: State) => state.loaded; 82 | 83 | export const getLoading = (state: State) => state.loading; 84 | 85 | export const getIds = (state: State) => state.ids; 86 | -------------------------------------------------------------------------------- /src/app/reducers/index.ts: -------------------------------------------------------------------------------- 1 | import {createSelector} from 'reselect'; 2 | import { ActionReducer } from '@ngrx/store'; 3 | import * as fromRouter from '@ngrx/router-store'; 4 | import {environment} from '../../environments/environment'; 5 | /** 6 | * The compose function is one of our most handy tools. In basic terms, you give 7 | * it any number of functions and it returns a function. This new function 8 | * takes a value and chains it through every composed function, returning 9 | * the output. 10 | * 11 | * More: https://drboolean.gitbooks.io/mostly-adequate-guide/content/ch5.html 12 | */ 13 | import {compose} from '@ngrx/core/compose'; 14 | /** 15 | * storeFreeze prevents state from being mutated. When mutation occurs, an 16 | * exception will be thrown. This is useful during development mode to 17 | * ensure that none of the reducers accidentally mutates the state. 18 | */ 19 | import {storeFreeze} from 'ngrx-store-freeze'; 20 | 21 | /** 22 | * combineReducers is another useful metareducer that takes a map of reducer 23 | * functions and creates a new reducer that gathers the values 24 | * of each reducer and stores them using the reducer's key. Think of it 25 | * almost like a database, where every reducer is a table in the db. 26 | * 27 | * More: https://egghead.io/lessons/javascript-redux-implementing-combinereducers-from-scratch 28 | */ 29 | import { combineReducers } from '@ngrx/store'; 30 | 31 | 32 | /** 33 | * Every reducer module's default export is the reducer function itself. In 34 | * addition, each module should export a type or interface that describes 35 | * the state of the reducer plus any selector functions. The `* as` 36 | * notation packages up all of the exports into a single object. 37 | */ 38 | import * as fromSearch from './search'; 39 | import * as fromBooks from './books'; 40 | import * as fromCollection from './collection'; 41 | import * as fromLayout from './layout'; 42 | 43 | 44 | /** 45 | * As mentioned, we treat each reducer like a table in a database. This means 46 | * our top level state interface is just a map of keys to inner state types. 47 | */ 48 | export interface State { 49 | search: fromSearch.State; 50 | books: fromBooks.State; 51 | collection: fromCollection.State; 52 | layout: fromLayout.State; 53 | router: fromRouter.RouterState; 54 | } 55 | 56 | 57 | /** 58 | * Because metareducers take a reducer function and return a new reducer, 59 | * we can use our compose helper to chain them together. Here we are 60 | * using combineReducers to make our top level reducer, and then 61 | * wrapping that in storeLogger. Remember that compose applies 62 | * the result from right to left. 63 | */ 64 | const reducers = { 65 | search: fromSearch.searchReducer, 66 | books: fromBooks.booksReducer, 67 | collection: fromCollection.collectionReducer, 68 | layout: fromLayout.layoutReducer, 69 | router: fromRouter.routerReducer, 70 | }; 71 | 72 | const developmentReducer: ActionReducer = compose(storeFreeze, combineReducers)(reducers); 73 | const productionReducer: ActionReducer = combineReducers(reducers); 74 | 75 | export function reducer(state: any, action: any) { 76 | if (environment.production) { 77 | return productionReducer(state, action); 78 | } else { 79 | return developmentReducer(state, action); 80 | } 81 | } 82 | 83 | 84 | /** 85 | * A selector function is a map function factory. We pass it parameters and it 86 | * returns a function that maps from the larger state tree into a smaller 87 | * piece of state. This selector simply selects the `books` state. 88 | * 89 | * Selectors are used with the `select` operator. 90 | * 91 | * ```ts 92 | * class MyComponent { 93 | * constructor(state$: Observable) { 94 | * this.booksState$ = state$.select(getBooksState); 95 | * } 96 | * } 97 | * ``` 98 | */ 99 | export const getBooksState = (state: State) => state.books; 100 | 101 | /** 102 | * Every reducer module exports selector functions, however child reducers 103 | * have no knowledge of the overall state tree. To make them useable, we 104 | * need to make new selectors that wrap them. 105 | * 106 | * The createSelector function from the reselect library creates 107 | * very efficient selectors that are memoized and only recompute when arguments change. 108 | * The created selectors can also be composed together to select different 109 | * pieces of state. 110 | */ 111 | export const getBookEntities = createSelector(getBooksState, fromBooks.getEntities); 112 | export const getBookIds = createSelector(getBooksState, fromBooks.getIds); 113 | export const getSelectedBookId = createSelector(getBooksState, fromBooks.getSelectedId); 114 | export const getSelectedBook = createSelector(getBooksState, fromBooks.getSelected); 115 | 116 | 117 | /** 118 | * Just like with the books selectors, we also have to compose the search 119 | * reducer's and collection reducer's selectors. 120 | */ 121 | export const getSearchState = (state: State) => state.search; 122 | 123 | export const getSearchBookIds = createSelector(getSearchState, fromSearch.getIds); 124 | export const getSearchQuery = createSelector(getSearchState, fromSearch.getQuery); 125 | export const getSearchLoading = createSelector(getSearchState, fromSearch.getLoading); 126 | 127 | 128 | /** 129 | * Some selector functions create joins across parts of state. This selector 130 | * composes the search result IDs to return an array of books in the store. 131 | */ 132 | export const getSearchResults = createSelector(getBookEntities, getSearchBookIds, (books, searchIds) => { 133 | return searchIds.map(id => books[id]); 134 | }); 135 | 136 | 137 | 138 | export const getCollectionState = (state: State) => state.collection; 139 | 140 | export const getCollectionLoaded = createSelector(getCollectionState, fromCollection.getLoaded); 141 | export const getCollectionLoading = createSelector(getCollectionState, fromCollection.getLoading); 142 | export const getCollectionBookIds = createSelector(getCollectionState, fromCollection.getIds); 143 | 144 | export const getBookCollection = createSelector(getBookEntities, getCollectionBookIds, (entities, ids) => { 145 | return ids.map(id => entities[id]); 146 | }); 147 | 148 | export const isSelectedBookInCollection = createSelector(getCollectionBookIds, getSelectedBookId, (ids, selected) => { 149 | return ids.indexOf(selected) > -1; 150 | }); 151 | 152 | /** 153 | * Layout Reducers 154 | */ 155 | export const getLayoutState = (state: State) => state.layout; 156 | 157 | export const getShowSidenav = createSelector(getLayoutState, fromLayout.getShowSidenav); 158 | -------------------------------------------------------------------------------- /src/app/reducers/layout.ts: -------------------------------------------------------------------------------- 1 | import {LayoutActionEnum} from '../actions/layout'; 2 | import {ActionEnumValue, TypedAction} from '../actions/action-enum'; 3 | import { 4 | ReducerEnum, 5 | ReducerEnumValue, 6 | ReducerFunction 7 | } from './reducer-enum'; 8 | import {ActionReducer} from '@ngrx/store'; 9 | 10 | 11 | export interface State { 12 | showSidenav: boolean; 13 | } 14 | 15 | const initialState: State = { 16 | showSidenav: false, 17 | }; 18 | 19 | export class LayoutReducer extends ReducerEnumValue { 20 | constructor(action: ActionEnumValue, reduce: ReducerFunction) { 21 | super(action, reduce); 22 | } 23 | } 24 | 25 | export class LayoutReducerEnumType extends ReducerEnum, State> { 26 | 27 | CLOSE_SIDENAV = new LayoutReducer(LayoutActionEnum.CLOSE_SIDENAV, 28 | (state: State) => ({showSidenav: false})); 29 | OPEN_SIDENAV = new LayoutReducer(LayoutActionEnum.OPEN_SIDENAV, 30 | (state: State) => ({showSidenav: true})); 31 | 32 | constructor() { 33 | super(initialState); 34 | this.initEnum('layoutReducers'); 35 | } 36 | } 37 | 38 | export const LayoutReducerEnum = new LayoutReducerEnumType(); 39 | const reducer: ActionReducer = LayoutReducerEnum.reducer(); 40 | 41 | export function layoutReducer(state: State, action: TypedAction): State { 42 | return reducer(state, action); 43 | } 44 | 45 | export const getShowSidenav = (state: State) => state.showSidenav; 46 | -------------------------------------------------------------------------------- /src/app/reducers/reducer-enum.ts: -------------------------------------------------------------------------------- 1 | import {Enum, EnumValue} from 'ts-enums'; 2 | import {ActionEnumValue, TypedAction} from '../actions/action-enum'; 3 | import {isArray} from 'rxjs/util/isArray'; 4 | import {ActionReducer} from '@ngrx/store'; 5 | 6 | export type ReducerFunction = (state: S, action: TypedAction) => S; 7 | 8 | function extractDescriptions(action: ActionEnumValue | ActionEnumValue[]): string { 9 | if (isArray(action)) { 10 | return action.map((value: ActionEnumValue) => value.fullName).join(';'); 11 | } else { 12 | return action.fullName; 13 | } 14 | } 15 | 16 | export abstract class ReducerEnumValue extends EnumValue { 17 | readonly actions: ActionEnumValue[]; 18 | 19 | constructor(action: ActionEnumValue | ActionEnumValue[], 20 | private _reduce: ReducerFunction) { 21 | // if there's only one action value, use its fullName. Otherwise, concat 22 | // the fullName of all the action values. 23 | super(extractDescriptions(action)); 24 | // accumulate the action types to check against when reducing 25 | this.actions = isArray(action) ? action : [action]; 26 | } 27 | 28 | get reduce(): ReducerFunction { 29 | return this._reduce; 30 | } 31 | } 32 | 33 | export abstract class ReducerEnum, S> extends Enum { 34 | constructor(private initialState: S) { 35 | super(); 36 | } 37 | 38 | protected initEnum(name: string): void { 39 | super.initEnum(name); 40 | 41 | // ensure that each enum is used at most once per reducer 42 | const allActions: Set> = new Set(); 43 | this.values.forEach((value: V) => { 44 | value.actions.forEach((action: ActionEnumValue) => { 45 | if (allActions.has(action)) { 46 | const message = 47 | `Action ${action.fullName} is used multiple times in ${name} - this is not allowed`; 48 | throw new Error(message); 49 | } else { 50 | allActions.add(action); 51 | } 52 | }); 53 | }); 54 | } 55 | 56 | reducer(): ActionReducer { 57 | // Find the appropriate enum instance for the action, if any, and return 58 | // its reducer. 59 | return (state: S = this.initialState, action: TypedAction): S => { 60 | const reducerInstance: V = this.fromAction(action); 61 | return reducerInstance ? reducerInstance.reduce(state, action) : state; 62 | }; 63 | } 64 | 65 | private fromAction(action: TypedAction): V { 66 | // look through all of the reducer enum instances to find one that has the 67 | // current action in its array of actions 68 | return this.values.find((value: V) => { 69 | return value.actions.some((type: ActionEnumValue) => 70 | type.description === action.type); 71 | }); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/app/reducers/search.ts: -------------------------------------------------------------------------------- 1 | import {BookActionEnum} from '../actions/book'; 2 | import {Book} from '../models/book'; 3 | import {ActionEnumValue, TypedAction} from '../actions/action-enum'; 4 | import { 5 | ReducerEnum, 6 | ReducerEnumValue, 7 | ReducerFunction 8 | } from './reducer-enum'; 9 | import {ActionReducer} from '@ngrx/store'; 10 | 11 | 12 | export interface State { 13 | ids: string[]; 14 | loading: boolean; 15 | query: string; 16 | } 17 | 18 | const initialState: State = { 19 | ids: [], 20 | loading: false, 21 | query: '' 22 | }; 23 | 24 | export class SearchReducer extends ReducerEnumValue { 25 | constructor(action: ActionEnumValue, reduce: ReducerFunction) { 26 | super(action, reduce); 27 | } 28 | } 29 | 30 | export class SearchReducerEnumType extends ReducerEnum, State> { 31 | 32 | SEARCH = new SearchReducer(BookActionEnum.SEARCH, 33 | (state: State, action: TypedAction): State => { 34 | const query = action.payload; 35 | 36 | if (query === '') { 37 | return { 38 | ids: [], 39 | loading: false, 40 | query 41 | }; 42 | } 43 | 44 | return Object.assign({}, state, { 45 | query, 46 | loading: true 47 | }); 48 | }); 49 | SEARCH_COMPLETE = new SearchReducer(BookActionEnum.SEARCH_COMPLETE, 50 | (state: State, action: TypedAction): State => { 51 | return { 52 | ids: action.payload.map((book: Book) => book.id), 53 | loading: false, 54 | query: state.query 55 | }; 56 | }); 57 | 58 | constructor() { 59 | super(initialState); 60 | this.initEnum('searchReducers'); 61 | } 62 | } 63 | 64 | export const SearchReducerEnum = new SearchReducerEnumType(); 65 | const reducer: ActionReducer = SearchReducerEnum.reducer(); 66 | 67 | export function searchReducer(state: State, action: TypedAction): State { 68 | return reducer(state, action); 69 | } 70 | 71 | export const getIds = (state: State) => state.ids; 72 | 73 | export const getQuery = (state: State) => state.query; 74 | 75 | export const getLoading = (state: State) => state.loading; 76 | -------------------------------------------------------------------------------- /src/app/routes.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '@angular/router'; 2 | 3 | import { BookExistsGuard } from './guards/book-exists'; 4 | import { FindBookPageComponent } from './containers/find-book-page'; 5 | import { ViewBookPageComponent } from './containers/view-book-page'; 6 | import { CollectionPageComponent } from './containers/collection-page'; 7 | import { NotFoundPageComponent } from './containers/not-found-page'; 8 | 9 | export const routes: Routes = [ 10 | { 11 | path: '', 12 | component: CollectionPageComponent 13 | }, 14 | { 15 | path: 'book/find', 16 | component: FindBookPageComponent 17 | }, 18 | { 19 | path: 'book/:id', 20 | canActivate: [ BookExistsGuard ], 21 | component: ViewBookPageComponent 22 | }, 23 | { 24 | path: '**', 25 | component: NotFoundPageComponent 26 | } 27 | ]; 28 | -------------------------------------------------------------------------------- /src/app/services/google-books.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, inject } from '@angular/core/testing'; 2 | import { Http, BaseRequestOptions, Response, ResponseOptions, RequestMethod } from '@angular/http'; 3 | import { MockBackend, MockConnection } from '@angular/http/testing'; 4 | import { GoogleBooksService } from './google-books'; 5 | 6 | describe('Service: GoogleBooks', () => { 7 | let service: GoogleBooksService = null; 8 | let backend: MockBackend = null; 9 | 10 | beforeEach(() => { 11 | TestBed.configureTestingModule({ 12 | providers: [ 13 | MockBackend, 14 | BaseRequestOptions, 15 | { 16 | provide: Http, 17 | useFactory: (backendInstance: MockBackend, defaultOptions: BaseRequestOptions) => { 18 | return new Http(backendInstance, defaultOptions); 19 | }, 20 | deps: [ MockBackend, BaseRequestOptions ] 21 | }, 22 | GoogleBooksService 23 | ] 24 | }); 25 | }); 26 | 27 | beforeEach(inject([GoogleBooksService, MockBackend], (googleBooksService: GoogleBooksService, mockBackend: MockBackend) => { 28 | service = googleBooksService; 29 | backend = mockBackend; 30 | })); 31 | 32 | const data = { 33 | 'title': 'Book Title', 34 | 'author': 'John Smith', 35 | 'volumeId': '12345' 36 | }; 37 | 38 | const books = { 39 | items: [ 40 | {id: '12345', volumeInfo: {title: 'Title'}}, 41 | {id: '67890', volumeInfo: {title: 'Another Title'}} 42 | ] 43 | }; 44 | 45 | const queryTitle = 'Book Title'; 46 | 47 | it('should call the search api and return the search results', (done) => { 48 | backend.connections.subscribe((connection: MockConnection) => { 49 | const options = new ResponseOptions({ 50 | body: JSON.stringify(books) 51 | }); 52 | connection.mockRespond(new Response(options)); 53 | expect(connection.request.method).toEqual(RequestMethod.Get); 54 | expect(connection.request.url).toEqual(`https://www.googleapis.com/books/v1/volumes?q=${queryTitle}`); 55 | }); 56 | 57 | service 58 | .searchBooks(queryTitle) 59 | .subscribe((res) => { 60 | expect(res).toEqual(books.items); 61 | done(); 62 | }); 63 | }); 64 | 65 | it('should retrieve the book from the volumeId', (done) => { 66 | backend.connections.subscribe((connection: MockConnection) => { 67 | const options = new ResponseOptions({ 68 | body: JSON.stringify(data) 69 | }); 70 | connection.mockRespond(new Response(options)); 71 | expect(connection.request.method).toEqual(RequestMethod.Get); 72 | expect(connection.request.url).toEqual(`https://www.googleapis.com/books/v1/volumes/${queryTitle}`); 73 | }); 74 | service 75 | .retrieveBook(queryTitle) 76 | .subscribe((response) => { 77 | expect(response).toEqual(data); 78 | done(); 79 | }); 80 | }); 81 | 82 | }); 83 | -------------------------------------------------------------------------------- /src/app/services/google-books.ts: -------------------------------------------------------------------------------- 1 | import 'rxjs/add/operator/map'; 2 | import { Injectable } from '@angular/core'; 3 | import { Http } from '@angular/http'; 4 | import { Observable } from 'rxjs/Observable'; 5 | import { Book } from '../models/book'; 6 | 7 | 8 | @Injectable() 9 | export class GoogleBooksService { 10 | private API_PATH = 'https://www.googleapis.com/books/v1/volumes'; 11 | 12 | constructor(private http: Http) {} 13 | 14 | searchBooks(queryTitle: string): Observable { 15 | return this.http.get(`${this.API_PATH}?q=${queryTitle}`) 16 | .map(res => res.json().items || []); 17 | } 18 | 19 | retrieveBook(volumeId: string): Observable { 20 | return this.http.get(`${this.API_PATH}/${volumeId}`) 21 | .map(res => res.json()); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LMFinney/ngrx-example-app-enums/a7b460991614f9325f6aa0a2ba7567ef3db610bf/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/assets/.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LMFinney/ngrx-example-app-enums/a7b460991614f9325f6aa0a2ba7567ef3db610bf/src/assets/.npmignore -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // The file contents for the current environment will overwrite these during build. 2 | // The build system defaults to the dev environment which uses `environment.ts`, but if you do 3 | // `ng build --env=prod` then `environment.prod.ts` will be used instead. 4 | // The list of which env maps to which file can be found in `angular-cli.json`. 5 | 6 | export const environment = { 7 | production: false 8 | }; 9 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LMFinney/ngrx-example-app-enums/a7b460991614f9325f6aa0a2ba7567ef3db610bf/src/favicon.ico -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Book Collection 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | Loading... 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import './polyfills.ts'; 2 | 3 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 4 | import { enableProdMode } from '@angular/core'; 5 | import { environment } from './environments/environment'; 6 | import { AppModule } from './app/app.module'; 7 | 8 | if (environment.production) { 9 | enableProdMode(); 10 | } 11 | 12 | platformBrowserDynamic().bootstrapModule(AppModule); 13 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | // This file includes polyfills needed by Angular and is loaded before 2 | // the app. You can add your own extra polyfills to this file. 3 | import 'core-js/es6/symbol'; 4 | import 'core-js/es6/object'; 5 | import 'core-js/es6/function'; 6 | import 'core-js/es6/parse-int'; 7 | import 'core-js/es6/parse-float'; 8 | import 'core-js/es6/number'; 9 | import 'core-js/es6/math'; 10 | import 'core-js/es6/string'; 11 | import 'core-js/es6/date'; 12 | import 'core-js/es6/array'; 13 | import 'core-js/es6/regexp'; 14 | import 'core-js/es6/map'; 15 | import 'core-js/es6/set'; 16 | import 'core-js/es6/reflect'; 17 | 18 | import 'core-js/es7/reflect'; 19 | import 'zone.js/dist/zone'; 20 | 21 | import 'hammerjs'; 22 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | @import "~@angular/material/prebuilt-themes/deeppurple-amber.css"; 3 | 4 | * { 5 | box-sizing: border-box; 6 | } 7 | 8 | html { 9 | -webkit-font-smoothing: antialiased; 10 | -ms-overflow-style: none; 11 | overflow: auto; 12 | } 13 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/dist/long-stack-trace-zone'; 4 | import 'zone.js/dist/proxy.js'; 5 | import 'zone.js/dist/sync-test'; 6 | import 'zone.js/dist/jasmine-patch'; 7 | import 'zone.js/dist/async-test'; 8 | import 'zone.js/dist/fake-async-test'; 9 | import { getTestBed } from '@angular/core/testing'; 10 | import { 11 | BrowserDynamicTestingModule, 12 | platformBrowserDynamicTesting 13 | } from '@angular/platform-browser-dynamic/testing'; 14 | 15 | // fixes typing errors in Atom editor 16 | import {} from 'jasmine'; 17 | 18 | // Unfortunately there's no typing for the `__karma__` variable. Just declare it as any. 19 | declare var __karma__: any; 20 | declare var require: any; 21 | 22 | // Prevent Karma from running prematurely. 23 | __karma__.loaded = function () {}; 24 | 25 | // First, initialize the Angular testing environment. 26 | getTestBed().initTestEnvironment( 27 | BrowserDynamicTestingModule, 28 | platformBrowserDynamicTesting() 29 | ); 30 | // Then we find all the tests. 31 | const context = require.context('./', true, /\.spec\.ts$/); 32 | // And load the modules. 33 | context.keys().map(context); 34 | // Finally, start Karma to run the tests. 35 | __karma__.start(); 36 | -------------------------------------------------------------------------------- /src/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "declaration": false, 5 | "moduleResolution": "node", 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "lib": [ 9 | "es2016", 10 | "dom" 11 | ], 12 | "outDir": "../out-tsc/app", 13 | "target": "es5", 14 | "module": "es2015", 15 | "baseUrl": "", 16 | "types": [] 17 | }, 18 | "exclude": [ 19 | "test.ts", 20 | "**/*.spec.ts" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /src/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "declaration": false, 5 | "moduleResolution": "node", 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "lib": [ 9 | "es2016" 10 | ], 11 | "outDir": "../out-tsc/spec", 12 | "module": "commonjs", 13 | "target": "es6", 14 | "baseUrl": "", 15 | "types": [ 16 | "jasmine", 17 | "node" 18 | ] 19 | }, 20 | "files": [ 21 | "test.ts" 22 | ], 23 | "include": [ 24 | "**/*.spec.ts" 25 | ] 26 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "declaration": true, 5 | "stripInternal": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "module": "es2015", 9 | "moduleResolution": "node", 10 | "noEmitOnError": false, 11 | "noImplicitAny": true, 12 | "noImplicitReturns": true, 13 | "outDir": "./dist", 14 | "rootDir": ".", 15 | "sourceMap": true, 16 | "inlineSources": true, 17 | "lib": ["es2015", "dom"], 18 | "target": "es5", 19 | "skipLibCheck": true, 20 | "types": [ 21 | "hammerjs" 22 | ] 23 | }, 24 | "exclude": [ 25 | "node_modules" 26 | ], 27 | "compileOnSave": false, 28 | "buildOnSave": false, 29 | "atom": { "rewriteTsconfig": false } 30 | } 31 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rulesDirectory": [ 3 | "node_modules/codelyzer" 4 | ], 5 | "rules": { 6 | "callable-types": true, 7 | "class-name": true, 8 | "comment-format": [ 9 | true, 10 | "check-space" 11 | ], 12 | "curly": true, 13 | "eofline": true, 14 | "forin": true, 15 | "import-blacklist": [true, "rxjs"], 16 | "import-spacing": true, 17 | "indent": [ 18 | true, 19 | "spaces" 20 | ], 21 | "interface-over-type-literal": true, 22 | "label-position": true, 23 | "max-line-length": [ 24 | true, 25 | 140 26 | ], 27 | "member-access": false, 28 | "member-ordering": [ 29 | true, 30 | "static-before-instance", 31 | "variables-before-functions" 32 | ], 33 | "no-arg": true, 34 | "no-bitwise": true, 35 | "no-console": [ 36 | true, 37 | "debug", 38 | "info", 39 | "time", 40 | "timeEnd", 41 | "trace" 42 | ], 43 | "no-construct": true, 44 | "no-debugger": true, 45 | "no-duplicate-variable": true, 46 | "no-empty": false, 47 | "no-empty-interface": true, 48 | "no-eval": true, 49 | "no-inferrable-types": [true, "ignore-params"], 50 | "no-shadowed-variable": true, 51 | "no-string-literal": false, 52 | "no-string-throw": true, 53 | "no-switch-case-fall-through": false, 54 | "no-trailing-whitespace": true, 55 | "no-unused-expression": true, 56 | "no-use-before-declare": true, 57 | "no-var-keyword": true, 58 | "one-line": [ 59 | true, 60 | "check-open-brace", 61 | "check-catch", 62 | "check-else", 63 | "check-whitespace" 64 | ], 65 | "prefer-const": true, 66 | "quotemark": [ 67 | true, 68 | "single" 69 | ], 70 | "semicolon": true, 71 | "triple-equals": [ 72 | true, 73 | "allow-null-check" 74 | ], 75 | "typedef-whitespace": [ 76 | true, 77 | { 78 | "call-signature": "nospace", 79 | "index-signature": "nospace", 80 | "parameter": "nospace", 81 | "property-declaration": "nospace", 82 | "variable-declaration": "nospace" 83 | } 84 | ], 85 | "typeof-compare": true, 86 | "unified-signatures": true, 87 | "variable-name": false, 88 | "whitespace": [ 89 | true, 90 | "check-branch", 91 | "check-decl", 92 | "check-operator", 93 | "check-separator", 94 | "check-type" 95 | ], 96 | "directive-selector": [true, "attribute", "bc", "camelCase"], 97 | "component-selector": [true, "element", "bc", "kebab-case"], 98 | "use-input-property-decorator": true, 99 | "use-output-property-decorator": true, 100 | "use-host-property-decorator": true, 101 | "no-input-rename": true, 102 | "no-output-rename": true, 103 | "use-life-cycle-interface": true, 104 | "use-pipe-transform-interface": true, 105 | "component-class-suffix": true, 106 | "directive-class-suffix": true, 107 | "no-access-missing-member": true, 108 | "templates-use-public": true, 109 | "invoke-injectable": true 110 | } 111 | } 112 | --------------------------------------------------------------------------------