├── .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
│ │ ├── 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
│ │ └── 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 | ---
2 | # This repository is for version 2.x of the example application.
3 | # [Click here for the latest version (4.x)](https://github.com/ngrx/platform)
4 | ---
5 |
6 | # @ngrx example application
7 |
8 | Example application utilizing @ngrx libraries, showcasing common patterns and best practices.
9 | Take a look at the [live app](http://ngrx.github.io/example-app/).
10 |
11 | This app is a book collection manager. Using the Google Books API, the user can search for
12 | books and add them to their collection. This application utilizes [@ngrx/db](https://github.com/ngrx/db)
13 | to persist the collection across sessions; [@ngrx/store](https://github.com/ngrx/store) to manage
14 | the state of the app and to cache requests made to the Google Books API;
15 | [@angular/router](https://github.com/angular/angular) to manage navigation between routes;
16 | [@ngrx/effects](https://github.com/ngrx/effects) to isolate side effects.
17 |
18 | Built with [@angular/cli](https://github.com/angular/angular-cli)
19 |
20 | ### Included
21 | - [ngrx/store](https://github.com/ngrx/store) - RxJS powered state management for Angular apps, inspired by Redux
22 | - [ngrx/effects](https://github.com/ngrx/effects) - Side effect model for @ngrx/store
23 | - [angular/router](https://github.com/angular/angular) - Angular Router
24 | - [ngrx/db](https://github.com/ngrx/db) - RxJS powered IndexedDB for Angular apps
25 | - [ngrx/store-devtools](https://github.com/ngrx/store-devtools) - Instrumentation for @ngrx/store enabling time-travel debugging
26 | - [codewareio/ngrx-store-freeze](https://github.com/codewareio/ngrx-store-freeze) - A @ngrx/store meta reducer that prevents state from being mutated
27 | - [reselect](https://github.com/reactjs/reselect) - Selector library for Redux
28 |
29 | ### Quick start
30 |
31 | ```bash
32 | # clone the repo
33 | git clone https://github.com/ngrx/example-app.git
34 |
35 |
36 | # change directory to repo
37 | cd example-app
38 |
39 | # Use npm or yarn to install the dependencies:
40 | npm install
41 |
42 | # OR
43 | yarn
44 |
45 | # start the server
46 | ng serve
47 | ```
48 |
49 | Navigate to [http://localhost:4200/](http://localhost:4200/) in your browser
50 |
51 | _NOTE:_ The above setup instructions assume you have added local npm bin folders to your path.
52 | If this is not the case you will need to install the Angular CLI globally.
53 |
--------------------------------------------------------------------------------
/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",
3 | "version": "1.0.0",
4 | "description": "Example application demoing the @ngrx platform",
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/ngrx/example-app.git"
17 | },
18 | "author": "Mike Ryan",
19 | "license": "MIT",
20 | "bugs": {
21 | "url": "https://github.com/ngrx/example-app/issues"
22 | },
23 | "homepage": "https://github.com/ngrx/example-app#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-helpers": "^1.1.1",
51 | "zone.js": "^0.8.4"
52 | },
53 | "devDependencies": {
54 | "@angular/cli": "^1.0.0",
55 | "@angular/compiler-cli": "^4.0.2",
56 | "@types/jasmine": "2.5.38",
57 | "@types/node": "~6.0.60",
58 | "codelyzer": "~2.0.0",
59 | "jasmine-core": "~2.5.2",
60 | "jasmine-spec-reporter": "~3.2.0",
61 | "karma": "~1.4.1",
62 | "karma-chrome-launcher": "~2.0.0",
63 | "karma-cli": "~1.0.1",
64 | "karma-coverage-istanbul-reporter": "^0.2.0",
65 | "karma-jasmine": "~1.1.0",
66 | "karma-jasmine-html-reporter": "^0.2.2",
67 | "ngrx-store-freeze": "^0.1.6",
68 | "protractor": "~5.1.0",
69 | "ts-node": "~2.0.0",
70 | "tslint": "~4.5.0",
71 | "typescript": "~2.2.2"
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/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/book.ts:
--------------------------------------------------------------------------------
1 | import { Action } from '@ngrx/store';
2 | import { Book } from '../models/book';
3 |
4 | export const SEARCH = '[Book] Search';
5 | export const SEARCH_COMPLETE = '[Book] Search Complete';
6 | export const LOAD = '[Book] Load';
7 | export const SELECT = '[Book] Select';
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.
14 | *
15 | * See Discriminated Unions: https://www.typescriptlang.org/docs/handbook/advanced-types.html#discriminated-unions
16 | */
17 | export class SearchAction implements Action {
18 | readonly type = SEARCH;
19 |
20 | constructor(public payload: string) { }
21 | }
22 |
23 | export class SearchCompleteAction implements Action {
24 | readonly type = SEARCH_COMPLETE;
25 |
26 | constructor(public payload: Book[]) { }
27 | }
28 |
29 | export class LoadAction implements Action {
30 | readonly type = LOAD;
31 |
32 | constructor(public payload: Book) { }
33 | }
34 |
35 | export class SelectAction implements Action {
36 | readonly type = SELECT;
37 |
38 | constructor(public payload: string) { }
39 | }
40 |
41 | /**
42 | * Export a type alias of all actions in this action group
43 | * so that reducers can easily compose action types
44 | */
45 | export type Actions
46 | = SearchAction
47 | | SearchCompleteAction
48 | | LoadAction
49 | | SelectAction;
50 |
--------------------------------------------------------------------------------
/src/app/actions/collection.ts:
--------------------------------------------------------------------------------
1 | import { Action } from '@ngrx/store';
2 | import { Book } from '../models/book';
3 |
4 |
5 | export const ADD_BOOK = '[Collection] Add Book';
6 | export const ADD_BOOK_SUCCESS = '[Collection] Add Book Success';
7 | export const ADD_BOOK_FAIL = '[Collection] Add Book Fail';
8 | export const REMOVE_BOOK = '[Collection] Remove Book';
9 | export const REMOVE_BOOK_SUCCESS = '[Collection] Remove Book Success';
10 | export const REMOVE_BOOK_FAIL = '[Collection] Remove Book Fail';
11 | export const LOAD = '[Collection] Load';
12 | export const LOAD_SUCCESS = '[Collection] Load Success';
13 | export const LOAD_FAIL = '[Collection] Load Fail';
14 |
15 |
16 | /**
17 | * Add Book to Collection Actions
18 | */
19 | export class AddBookAction implements Action {
20 | readonly type = ADD_BOOK;
21 |
22 | constructor(public payload: Book) { }
23 | }
24 |
25 | export class AddBookSuccessAction implements Action {
26 | readonly type = ADD_BOOK_SUCCESS;
27 |
28 | constructor(public payload: Book) { }
29 | }
30 |
31 | export class AddBookFailAction implements Action {
32 | readonly type = ADD_BOOK_FAIL;
33 |
34 | constructor(public payload: Book) { }
35 | }
36 |
37 |
38 | /**
39 | * Remove Book from Collection Actions
40 | */
41 | export class RemoveBookAction implements Action {
42 | readonly type = REMOVE_BOOK;
43 |
44 | constructor(public payload: Book) { }
45 | }
46 |
47 | export class RemoveBookSuccessAction implements Action {
48 | readonly type = REMOVE_BOOK_SUCCESS;
49 |
50 | constructor(public payload: Book) { }
51 | }
52 |
53 | export class RemoveBookFailAction implements Action {
54 | readonly type = REMOVE_BOOK_FAIL;
55 |
56 | constructor(public payload: Book) {}
57 | }
58 |
59 | /**
60 | * Load Collection Actions
61 | */
62 | export class LoadAction implements Action {
63 | readonly type = LOAD;
64 | }
65 |
66 | export class LoadSuccessAction implements Action {
67 | readonly type = LOAD_SUCCESS;
68 |
69 | constructor(public payload: Book[]) { }
70 | }
71 |
72 | export class LoadFailAction implements Action {
73 | readonly type = LOAD_FAIL;
74 |
75 | constructor(public payload: any) { }
76 | }
77 |
78 |
79 | export type Actions
80 | = AddBookAction
81 | | AddBookSuccessAction
82 | | AddBookFailAction
83 | | RemoveBookAction
84 | | RemoveBookSuccessAction
85 | | RemoveBookFailAction
86 | | LoadAction
87 | | LoadSuccessAction
88 | | LoadFailAction;
89 |
--------------------------------------------------------------------------------
/src/app/actions/layout.ts:
--------------------------------------------------------------------------------
1 | import { Action } from '@ngrx/store';
2 |
3 | export const OPEN_SIDENAV = '[Layout] Open Sidenav';
4 | export const CLOSE_SIDENAV = '[Layout] Close Sidenav';
5 |
6 |
7 | export class OpenSidenavAction implements Action {
8 | readonly type = OPEN_SIDENAV;
9 | }
10 |
11 | export class CloseSidenavAction implements Action {
12 | readonly type = CLOSE_SIDENAV;
13 | }
14 |
15 |
16 | export type Actions
17 | = OpenSidenavAction
18 | | CloseSidenavAction;
19 |
--------------------------------------------------------------------------------
/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 |
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 { Component, ChangeDetectionStrategy } from '@angular/core';
4 | import { Store } from '@ngrx/store';
5 |
6 | import * as fromRoot from '../reducers';
7 | import * as layout 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(new layout.CloseSidenavAction());
50 | }
51 |
52 | openSidenav() {
53 | this.store.dispatch(new layout.OpenSidenavAction());
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 { Component, ChangeDetectionStrategy } from '@angular/core';
4 | import { Store } from '@ngrx/store';
5 | import { Observable } from 'rxjs/Observable';
6 |
7 | import * as fromRoot from '../reducers';
8 | import * as book 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(new book.SearchAction(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 { Component, ChangeDetectionStrategy } from '@angular/core';
2 | import { Store } from '@ngrx/store';
3 | import { Observable } from 'rxjs/Observable';
4 |
5 | import * as fromRoot from '../reducers';
6 | import * as collection 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(new collection.AddBookAction(book));
33 | }
34 |
35 | removeFromCollection(book: Book) {
36 | this.store.dispatch(new collection.RemoveBookAction(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 { Component, OnDestroy, ChangeDetectionStrategy } 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 * as book 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 => new book.SelectAction(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 { EffectsTestingModule, EffectsRunner } from '@ngrx/effects/testing';
4 | import { TestBed, fakeAsync, tick } from '@angular/core/testing';
5 | import { BookEffects } from './book';
6 | import { GoogleBooksService } from '../services/google-books';
7 | import { Observable } from 'rxjs/Observable';
8 | import { SearchAction, SearchCompleteAction } from '../actions/book';
9 | import { Book } from '../models/book';
10 |
11 | describe('BookEffects', () => {
12 | beforeEach(() => TestBed.configureTestingModule({
13 | imports: [
14 | EffectsTestingModule
15 | ],
16 | providers: [
17 | BookEffects,
18 | {
19 | provide: GoogleBooksService,
20 | useValue: jasmine.createSpyObj('googleBooksService', ['searchBooks'])
21 | }
22 | ]
23 | }));
24 |
25 | function setup(params?: {searchBooksReturnValue: any}) {
26 | const googleBooksService = TestBed.get(GoogleBooksService);
27 | if (params) {
28 | googleBooksService.searchBooks.and.returnValue(params.searchBooksReturnValue);
29 | }
30 |
31 | return {
32 | runner: TestBed.get(EffectsRunner),
33 | bookEffects: TestBed.get(BookEffects)
34 | };
35 | }
36 |
37 | describe('search$', () => {
38 | it('should return a new book.SearchCompleteAction, with the books, on success, after the de-bounce', fakeAsync(() => {
39 | const book1 = {id: '111', volumeInfo: {}} as Book;
40 | const book2 = {id: '222', volumeInfo: {}} as Book;
41 | const books = [book1, book2];
42 |
43 | const {runner, bookEffects} = setup({searchBooksReturnValue: Observable.of(books)});
44 |
45 | const expectedResult = new SearchCompleteAction(books);
46 | runner.queue(new SearchAction('query'));
47 |
48 | let result = null;
49 | bookEffects.search$.subscribe(_result => result = _result);
50 | tick(299); // test de-bounce
51 | expect(result).toBe(null);
52 | tick(300);
53 | expect(result).toEqual(expectedResult);
54 | }));
55 |
56 | it('should return a new book.SearchCompleteAction, with an empty array, if the books service throws', fakeAsync(() => {
57 | const {runner, bookEffects} = setup({searchBooksReturnValue: Observable.throw(new Error())});
58 |
59 | const expectedResult = new SearchCompleteAction([]);
60 | runner.queue(new SearchAction('query'));
61 |
62 | let result = null;
63 | bookEffects.search$.subscribe(_result => result = _result);
64 | tick(299); // test de-bounce
65 | expect(result).toBe(null);
66 | tick(300);
67 | expect(result).toEqual(expectedResult);
68 | }));
69 |
70 | it(`should not do anything if the query is an empty string`, fakeAsync(() => {
71 | const {runner, bookEffects} = setup();
72 |
73 | runner.queue(new SearchAction(''));
74 | let result = null;
75 | bookEffects.search$.subscribe({
76 | next: () => result = false,
77 | complete: () => result = false,
78 | error: () => result = false
79 | });
80 |
81 | tick(300);
82 | expect(result).toBe(null);
83 | }));
84 |
85 | });
86 | });
87 |
88 |
--------------------------------------------------------------------------------
/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 * as book 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(book.SEARCH)
41 | .debounceTime(300)
42 | .map(toPayload)
43 | .switchMap(query => {
44 | if (query === '') {
45 | return empty();
46 | }
47 |
48 | const nextSearch$ = this.actions$.ofType(book.SEARCH).skip(1);
49 |
50 | return this.googleBooks.searchBooks(query)
51 | .takeUntil(nextSearch$)
52 | .map(books => new book.SearchCompleteAction(books))
53 | .catch(() => of(new book.SearchCompleteAction([])));
54 | });
55 |
56 | constructor(private actions$: Actions, private googleBooks: GoogleBooksService) { }
57 | }
58 |
--------------------------------------------------------------------------------
/src/app/effects/collection.spec.ts:
--------------------------------------------------------------------------------
1 | import 'rxjs/add/observable/of';
2 | import 'rxjs/add/observable/throw';
3 | import { EffectsTestingModule, EffectsRunner } from '@ngrx/effects/testing';
4 | import { TestBed } from '@angular/core/testing';
5 | import { CollectionEffects } from './collection';
6 | import { Database } from '@ngrx/db';
7 | import { Book } from '../models/book';
8 | import * as collection from '../actions/collection';
9 | import { Observable } from 'rxjs/Observable';
10 |
11 | describe('CollectionEffects', () => {
12 | beforeEach(() => TestBed.configureTestingModule({
13 | imports: [
14 | EffectsTestingModule
15 | ],
16 | providers: [
17 | CollectionEffects,
18 | {
19 | provide: Database,
20 | useValue: jasmine.createSpyObj('database', ['open', 'query', 'insert', 'executeWrite'])
21 | }
22 | ]
23 | }));
24 |
25 | function setup() {
26 | return {
27 | db: TestBed.get(Database),
28 | runner: TestBed.get(EffectsRunner),
29 | collectionEffects: TestBed.get(CollectionEffects)
30 | };
31 | }
32 |
33 | describe('openDB$', () => {
34 | it('should call db.open when initially subscribed to', () => {
35 | const {db, collectionEffects} = setup();
36 | collectionEffects.openDB$.subscribe();
37 | expect(db.open).toHaveBeenCalledWith('books_app');
38 | });
39 | });
40 |
41 | describe('loadCollection$', () => {
42 | it('should return a collection.LoadSuccessAction, with the books, on success', () => {
43 | const book1 = {id: '111', volumeInfo: {}} as Book;
44 | const book2 = {id: '222', volumeInfo: {}} as Book;
45 |
46 | const {db, runner, collectionEffects} = setup();
47 |
48 | const booksObservable = Observable.of(book1, book2);
49 | db.query.and.returnValue(booksObservable);
50 |
51 | const expectedResult = new collection.LoadSuccessAction([book1, book2]);
52 |
53 | runner.queue(new collection.LoadAction());
54 |
55 | collectionEffects.loadCollection$.subscribe(result => {
56 | expect(result).toEqual(expectedResult);
57 | });
58 | });
59 |
60 | it('should return a collection.LoadFailAction, if the query throws', () => {
61 | const {db, runner, collectionEffects} = setup();
62 |
63 | const error = new Error('msg');
64 | db.query.and.returnValue(Observable.throw(error));
65 |
66 | const expectedResult = new collection.LoadFailAction(error);
67 |
68 | runner.queue(new collection.LoadAction());
69 |
70 | collectionEffects.loadCollection$.subscribe(result => {
71 | expect(result).toEqual(expectedResult);
72 | });
73 | });
74 | });
75 |
76 | describe('addBookToCollection$', () => {
77 | it('should return a collection.AddBookSuccessAction, with the book, on success', () => {
78 | const book = {id: '111', volumeInfo: {}} as Book;
79 |
80 | const {db, runner, collectionEffects} = setup();
81 | db.insert.and.returnValue(Observable.of({}));
82 |
83 | const expectedResult = new collection.AddBookSuccessAction(book);
84 |
85 | runner.queue(new collection.AddBookAction(book));
86 |
87 | collectionEffects.addBookToCollection$.subscribe(result => {
88 | expect(result).toEqual(expectedResult);
89 | expect(db.insert).toHaveBeenCalledWith('books', [book]);
90 | });
91 | });
92 |
93 | it('should return a collection.AddBookFailAction, with the book, when the db insert throws', () => {
94 | const book = {id: '111', volumeInfo: {}} as Book;
95 |
96 | const {db, runner, collectionEffects} = setup();
97 | db.insert.and.returnValue(Observable.throw(new Error()));
98 |
99 | const expectedResult = new collection.AddBookFailAction(book);
100 |
101 | runner.queue(new collection.AddBookAction(book));
102 |
103 | collectionEffects.addBookToCollection$.subscribe(result => {
104 | expect(result).toEqual(expectedResult);
105 | expect(db.insert).toHaveBeenCalledWith('books', [book]);
106 | });
107 | });
108 |
109 | describe('removeBookFromCollection$', () => {
110 | it('should return a collection.RemoveBookSuccessAction, with the book, on success', () => {
111 | const book = {id: '111', volumeInfo: {}} as Book;
112 |
113 | const {db, runner, collectionEffects} = setup();
114 | db.executeWrite.and.returnValue(Observable.of({}));
115 |
116 | const expectedResult = new collection.RemoveBookSuccessAction(book);
117 |
118 | runner.queue(new collection.RemoveBookAction(book));
119 |
120 | collectionEffects.removeBookFromCollection$.subscribe(result => {
121 | expect(result).toEqual(expectedResult);
122 | expect(db.executeWrite).toHaveBeenCalledWith('books', 'delete', ['111']);
123 | });
124 | });
125 |
126 | it('should return a collection.RemoveBookFailAction, with the book, when the db insert throws', () => {
127 | const book = {id: '111', volumeInfo: {}} as Book;
128 |
129 | const {db, runner, collectionEffects} = setup();
130 | db.executeWrite.and.returnValue(Observable.throw(new Error()));
131 |
132 | const expectedResult = new collection.RemoveBookFailAction(book);
133 |
134 | runner.queue(new collection.RemoveBookAction(book));
135 |
136 | collectionEffects.removeBookFromCollection$.subscribe(result => {
137 | expect(result).toEqual(expectedResult);
138 | expect(db.executeWrite).toHaveBeenCalledWith('books', 'delete', ['111']);
139 | });
140 | });
141 | });
142 | });
143 | });
144 |
--------------------------------------------------------------------------------
/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 { Effect, Actions } 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 * as collection from '../actions/collection';
16 | import { Book } from '../models/book';
17 |
18 |
19 | @Injectable()
20 | export class CollectionEffects {
21 |
22 | /**
23 | * This effect does not yield any actions back to the store. Set
24 | * `dispatch` to false to hint to @ngrx/effects that it should
25 | * ignore any elements of this effect stream.
26 | *
27 | * The `defer` observable accepts an observable factory function
28 | * that is called when the observable is subscribed to.
29 | * Wrapping the database open call in `defer` makes
30 | * effect easier to test.
31 | */
32 | @Effect({ dispatch: false })
33 | openDB$: Observable = defer(() => {
34 | return this.db.open('books_app');
35 | });
36 |
37 | /**
38 | * This effect makes use of the `startWith` operator to trigger
39 | * the effect immediately on startup.
40 | */
41 | @Effect()
42 | loadCollection$: Observable = this.actions$
43 | .ofType(collection.LOAD)
44 | .startWith(new collection.LoadAction())
45 | .switchMap(() =>
46 | this.db.query('books')
47 | .toArray()
48 | .map((books: Book[]) => new collection.LoadSuccessAction(books))
49 | .catch(error => of(new collection.LoadFailAction(error)))
50 | );
51 |
52 | @Effect()
53 | addBookToCollection$: Observable = this.actions$
54 | .ofType(collection.ADD_BOOK)
55 | .map((action: collection.AddBookAction) => action.payload)
56 | .mergeMap(book =>
57 | this.db.insert('books', [ book ])
58 | .map(() => new collection.AddBookSuccessAction(book))
59 | .catch(() => of(new collection.AddBookFailAction(book)))
60 | );
61 |
62 |
63 | @Effect()
64 | removeBookFromCollection$: Observable = this.actions$
65 | .ofType(collection.REMOVE_BOOK)
66 | .map((action: collection.RemoveBookAction) => action.payload)
67 | .mergeMap(book =>
68 | this.db.executeWrite('books', 'delete', [ book.id ])
69 | .map(() => new collection.RemoveBookSuccessAction(book))
70 | .catch(() => of(new collection.RemoveBookFailAction(book)))
71 | );
72 |
73 | constructor(private actions$: Actions, private db: Database) { }
74 | }
75 |
--------------------------------------------------------------------------------
/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 { Router, CanActivate, ActivatedRouteSnapshot } 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 * as book from '../actions/book';
17 |
18 |
19 | /**
20 | * Guards are hooks into the route resolution process, providing an opportunity
21 | * to inform the router's navigation process whether the route should continue
22 | * to activate this route. Guards must return an observable of true or false.
23 | */
24 | @Injectable()
25 | export class BookExistsGuard implements CanActivate {
26 | constructor(
27 | private store: Store,
28 | private googleBooks: GoogleBooksService,
29 | private router: Router
30 | ) { }
31 |
32 | /**
33 | * This method creates an observable that waits for the `loaded` property
34 | * of the collection state to turn `true`, emitting one time once loading
35 | * has finished.
36 | */
37 | waitForCollectionToLoad(): Observable {
38 | return this.store.select(fromRoot.getCollectionLoaded)
39 | .filter(loaded => loaded)
40 | .take(1);
41 | }
42 |
43 | /**
44 | * This method checks if a book with the given ID is already registered
45 | * in the Store
46 | */
47 | hasBookInStore(id: string): Observable {
48 | return this.store.select(fromRoot.getBookEntities)
49 | .map(entities => !!entities[id])
50 | .take(1);
51 | }
52 |
53 | /**
54 | * This method loads a book with the given ID from the API and caches
55 | * it in the store, returning `true` or `false` if it was found.
56 | */
57 | hasBookInApi(id: string): Observable {
58 | return this.googleBooks.retrieveBook(id)
59 | .map(bookEntity => new book.LoadAction(bookEntity))
60 | .do((action: book.LoadAction) => this.store.dispatch(action))
61 | .map(book => !!book)
62 | .catch(() => {
63 | this.router.navigate(['/404']);
64 | return of(false);
65 | });
66 | }
67 |
68 | /**
69 | * `hasBook` composes `hasBookInStore` and `hasBookInApi`. It first checks
70 | * if the book is in store, and if not it then checks if it is in the
71 | * API.
72 | */
73 | hasBook(id: string): Observable {
74 | return this.hasBookInStore(id)
75 | .switchMap(inStore => {
76 | if (inStore) {
77 | return of(inStore);
78 | }
79 |
80 | return this.hasBookInApi(id);
81 | });
82 | }
83 |
84 | /**
85 | * This is the actual method the router will call when our guard is run.
86 | *
87 | * Our guard waits for the collection to load, then it checks if we need
88 | * to request a book from the API or if we already have it in our cache.
89 | * If it finds it in the cache or in the API, it returns an Observable
90 | * of `true` and the route is rendered successfully.
91 | *
92 | * If it was unable to find it in our cache or in the API, this guard
93 | * will return an Observable of `false`, causing the router to move
94 | * on to the next candidate route. In this case, it will move on
95 | * to the 404 page.
96 | */
97 | canActivate(route: ActivatedRouteSnapshot): Observable {
98 | return this.waitForCollectionToLoad()
99 | .switchMap(() => this.hasBook(route.params['id']));
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/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 { reducer } from './books';
2 | import * as fromBooks from './books';
3 | import { SearchCompleteAction, LoadAction, SelectAction } from '../actions/book';
4 | import { Book } from '../models/book';
5 | import { LoadSuccessAction } from '../actions/collection';
6 |
7 | describe('BooksReducer', () => {
8 | describe('undefined action', () => {
9 | it('should return the default state', () => {
10 | const action = {} as any;
11 |
12 | const result = reducer(undefined, action);
13 | expect(result).toEqual(fromBooks.initialState);
14 | });
15 | });
16 |
17 | describe('SEARCH_COMPLETE & LOAD_SUCCESS', () => {
18 | function noExistingBooks(action) {
19 | const book1 = {id: '111'} as Book;
20 | const book2 = {id: '222'} as Book;
21 | const createAction = new action([book1, book2]);
22 |
23 | const expectedResult = {
24 | ids: ['111', '222'],
25 | entities: {
26 | '111': book1,
27 | '222': book2
28 | },
29 | selectedBookId: null,
30 | };
31 |
32 | const result = reducer(fromBooks.initialState, createAction);
33 | expect(result).toEqual(expectedResult);
34 | }
35 |
36 | function existingBooks(action) {
37 | const book1 = {id: '111'} as Book;
38 | const book2 = {id: '222'} as Book;
39 | const initialState = {
40 | ids: ['111', '222'],
41 | entities: {
42 | '111': book1,
43 | '222': book2
44 | },
45 | selectedBookId: null,
46 | } as any;
47 | // should not replace existing books
48 | const differentBook2 = {id: '222', foo: 'bar'} as any;
49 | const book3 = {id: '333'} as Book;
50 | const createAction = new action([book3, differentBook2]);
51 |
52 | const expectedResult = {
53 | ids: ['111', '222', '333'],
54 | entities: {
55 | '111': book1,
56 | '222': book2,
57 | '333': book3
58 | },
59 | selectedBookId: null,
60 | };
61 |
62 | const result = reducer(initialState, createAction);
63 | expect(result).toEqual(expectedResult);
64 | }
65 |
66 | it('should add all books in the payload when none exist', () => {
67 | noExistingBooks(SearchCompleteAction);
68 | noExistingBooks(LoadSuccessAction);
69 | });
70 |
71 | it('should add only new books when books already exist', () => {
72 | existingBooks(SearchCompleteAction);
73 | existingBooks(LoadSuccessAction);
74 | });
75 | });
76 |
77 | describe('LOAD', () => {
78 | it('should add a single book, if the book does not exist', () => {
79 | const book = {id: '888'} as Book;
80 | const action = new LoadAction(book);
81 |
82 | const expectedResult = {
83 | ids: ['888'],
84 | entities: {
85 | '888': book
86 | },
87 | selectedBookId: null
88 | };
89 |
90 | const result = reducer(fromBooks.initialState, action);
91 | expect(result).toEqual(expectedResult);
92 | });
93 |
94 | it('should return the existing state if the book exists', () => {
95 | const initialState = {
96 | ids: ['999'],
97 | entities: {
98 | '999': {id: '999'}
99 | }
100 | } as any;
101 | const book = {id: '999', foo: 'baz'} as any;
102 | const action = new LoadAction(book);
103 |
104 | const result = reducer(initialState, action);
105 | expect(result).toEqual(initialState);
106 | });
107 | });
108 |
109 | describe('SELECT', () => {
110 | it('should set the selected book id on the state', () => {
111 | const action = new SelectAction('1');
112 |
113 | const result = reducer(fromBooks.initialState, action);
114 | expect(result.selectedBookId).toBe('1');
115 | });
116 | });
117 |
118 | describe('Selections', () => {
119 | const book1 = {id: '111'} as Book;
120 | const book2 = {id: '222'} as Book;
121 | const state: fromBooks.State = {
122 | ids: ['111', '222'],
123 | entities: {
124 | '111': book1,
125 | '222': book2,
126 | },
127 | selectedBookId: '111'
128 | };
129 |
130 | describe('getEntities', () => {
131 | it('should return entities', () => {
132 | const result = fromBooks.getEntities(state);
133 | expect(result).toBe(state.entities);
134 | });
135 | });
136 |
137 | describe('getIds', () => {
138 | it('should return ids', () => {
139 | const result = fromBooks.getIds(state);
140 | expect(result).toBe(state.ids);
141 | });
142 | });
143 |
144 | describe('getSelectedId', () => {
145 | it('should return the selected id', () => {
146 | const result = fromBooks.getSelectedId(state);
147 | expect(result).toBe('111');
148 | });
149 | });
150 |
151 | describe('getSelected', () => {
152 | it('should return the selected book', () => {
153 | const result = fromBooks.getSelected(state);
154 | expect(result).toBe(book1);
155 | });
156 | });
157 |
158 | describe('getAll', () => {
159 | it('should return all books as an array ', () => {
160 | const result = fromBooks.getAll(state);
161 | expect(result).toEqual([book1, book2]);
162 | });
163 | });
164 |
165 | });
166 | });
167 |
--------------------------------------------------------------------------------
/src/app/reducers/books.ts:
--------------------------------------------------------------------------------
1 | import { createSelector } from 'reselect';
2 | import { Book } from '../models/book';
3 | import * as book from '../actions/book';
4 | import * as collection from '../actions/collection';
5 |
6 |
7 | export interface State {
8 | ids: string[];
9 | entities: { [id: string]: Book };
10 | selectedBookId: string | null;
11 | };
12 |
13 | export const initialState: State = {
14 | ids: [],
15 | entities: {},
16 | selectedBookId: null,
17 | };
18 |
19 | export function reducer(state = initialState, action: book.Actions | collection.Actions): State {
20 | switch (action.type) {
21 | case book.SEARCH_COMPLETE:
22 | case collection.LOAD_SUCCESS: {
23 | const books = action.payload;
24 | const newBooks = books.filter(book => !state.entities[book.id]);
25 |
26 | const newBookIds = newBooks.map(book => book.id);
27 | const newBookEntities = newBooks.reduce((entities: { [id: string]: Book }, book: Book) => {
28 | return Object.assign(entities, {
29 | [book.id]: book
30 | });
31 | }, {});
32 |
33 | return {
34 | ids: [ ...state.ids, ...newBookIds ],
35 | entities: Object.assign({}, state.entities, newBookEntities),
36 | selectedBookId: state.selectedBookId
37 | };
38 | }
39 |
40 | case book.LOAD: {
41 | const book = action.payload;
42 |
43 | if (state.ids.indexOf(book.id) > -1) {
44 | return state;
45 | }
46 |
47 | return {
48 | ids: [ ...state.ids, book.id ],
49 | entities: Object.assign({}, state.entities, {
50 | [book.id]: book
51 | }),
52 | selectedBookId: state.selectedBookId
53 | };
54 | }
55 |
56 | case book.SELECT: {
57 | return {
58 | ids: state.ids,
59 | entities: state.entities,
60 | selectedBookId: action.payload
61 | };
62 | }
63 |
64 | default: {
65 | return state;
66 | }
67 | }
68 | }
69 |
70 | /**
71 | * Because the data structure is defined within the reducer it is optimal to
72 | * locate our selector functions at this level. If store is to be thought of
73 | * as a database, and reducers the tables, selectors can be considered the
74 | * queries into said database. Remember to keep your selectors small and
75 | * focused so they can be combined and composed to fit each particular
76 | * use-case.
77 | */
78 |
79 | export const getEntities = (state: State) => state.entities;
80 |
81 | export const getIds = (state: State) => state.ids;
82 |
83 | export const getSelectedId = (state: State) => state.selectedBookId;
84 |
85 | export const getSelected = createSelector(getEntities, getSelectedId, (entities, selectedId) => {
86 | return entities[selectedId];
87 | });
88 |
89 | export const getAll = createSelector(getEntities, getIds, (entities, ids) => {
90 | return ids.map(id => entities[id]);
91 | });
92 |
--------------------------------------------------------------------------------
/src/app/reducers/collection.ts:
--------------------------------------------------------------------------------
1 | import * as collection from '../actions/collection';
2 |
3 |
4 | export interface State {
5 | loaded: boolean;
6 | loading: boolean;
7 | ids: string[];
8 | };
9 |
10 | const initialState: State = {
11 | loaded: false,
12 | loading: false,
13 | ids: []
14 | };
15 |
16 | export function reducer(state = initialState, action: collection.Actions): State {
17 | switch (action.type) {
18 | case collection.LOAD: {
19 | return Object.assign({}, state, {
20 | loading: true
21 | });
22 | }
23 |
24 | case collection.LOAD_SUCCESS: {
25 | const books = action.payload;
26 |
27 | return {
28 | loaded: true,
29 | loading: false,
30 | ids: books.map(book => book.id)
31 | };
32 | }
33 |
34 | case collection.ADD_BOOK_SUCCESS:
35 | case collection.REMOVE_BOOK_FAIL: {
36 | const book = action.payload;
37 |
38 | if (state.ids.indexOf(book.id) > -1) {
39 | return state;
40 | }
41 |
42 | return Object.assign({}, state, {
43 | ids: [ ...state.ids, book.id ]
44 | });
45 | }
46 |
47 | case collection.REMOVE_BOOK_SUCCESS:
48 | case collection.ADD_BOOK_FAIL: {
49 | const book = action.payload;
50 |
51 | return Object.assign({}, state, {
52 | ids: state.ids.filter(id => id !== book.id)
53 | });
54 | }
55 |
56 | default: {
57 | return state;
58 | }
59 | }
60 | }
61 |
62 |
63 | export const getLoaded = (state: State) => state.loaded;
64 |
65 | export const getLoading = (state: State) => state.loading;
66 |
67 | export const getIds = (state: State) => state.ids;
68 |
--------------------------------------------------------------------------------
/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 | /**
7 | * The compose function is one of our most handy tools. In basic terms, you give
8 | * it any number of functions and it returns a function. This new function
9 | * takes a value and chains it through every composed function, returning
10 | * the output.
11 | *
12 | * More: https://drboolean.gitbooks.io/mostly-adequate-guide/content/ch5.html
13 | */
14 | import { compose } from '@ngrx/core/compose';
15 |
16 | /**
17 | * storeFreeze prevents state from being mutated. When mutation occurs, an
18 | * exception will be thrown. This is useful during development mode to
19 | * ensure that none of the reducers accidentally mutates the state.
20 | */
21 | import { storeFreeze } from 'ngrx-store-freeze';
22 |
23 | /**
24 | * combineReducers is another useful metareducer that takes a map of reducer
25 | * functions and creates a new reducer that gathers the values
26 | * of each reducer and stores them using the reducer's key. Think of it
27 | * almost like a database, where every reducer is a table in the db.
28 | *
29 | * More: https://egghead.io/lessons/javascript-redux-implementing-combinereducers-from-scratch
30 | */
31 | import { combineReducers } from '@ngrx/store';
32 |
33 |
34 | /**
35 | * Every reducer module's default export is the reducer function itself. In
36 | * addition, each module should export a type or interface that describes
37 | * the state of the reducer plus any selector functions. The `* as`
38 | * notation packages up all of the exports into a single object.
39 | */
40 | import * as fromSearch from './search';
41 | import * as fromBooks from './books';
42 | import * as fromCollection from './collection';
43 | import * as fromLayout from './layout';
44 |
45 |
46 | /**
47 | * As mentioned, we treat each reducer like a table in a database. This means
48 | * our top level state interface is just a map of keys to inner state types.
49 | */
50 | export interface State {
51 | search: fromSearch.State;
52 | books: fromBooks.State;
53 | collection: fromCollection.State;
54 | layout: fromLayout.State;
55 | router: fromRouter.RouterState;
56 | }
57 |
58 |
59 | /**
60 | * Because metareducers take a reducer function and return a new reducer,
61 | * we can use our compose helper to chain them together. Here we are
62 | * using combineReducers to make our top level reducer, and then
63 | * wrapping that in storeLogger. Remember that compose applies
64 | * the result from right to left.
65 | */
66 | const reducers = {
67 | search: fromSearch.reducer,
68 | books: fromBooks.reducer,
69 | collection: fromCollection.reducer,
70 | layout: fromLayout.reducer,
71 | router: fromRouter.routerReducer,
72 | };
73 |
74 | const developmentReducer: ActionReducer = compose(storeFreeze, combineReducers)(reducers);
75 | const productionReducer: ActionReducer = combineReducers(reducers);
76 |
77 | export function reducer(state: any, action: any) {
78 | if (environment.production) {
79 | return productionReducer(state, action);
80 | } else {
81 | return developmentReducer(state, action);
82 | }
83 | }
84 |
85 |
86 | /**
87 | * A selector function is a map function factory. We pass it parameters and it
88 | * returns a function that maps from the larger state tree into a smaller
89 | * piece of state. This selector simply selects the `books` state.
90 | *
91 | * Selectors are used with the `select` operator.
92 | *
93 | * ```ts
94 | * class MyComponent {
95 | * constructor(state$: Observable) {
96 | * this.booksState$ = state$.select(getBooksState);
97 | * }
98 | * }
99 | * ```
100 | */
101 | export const getBooksState = (state: State) => state.books;
102 |
103 | /**
104 | * Every reducer module exports selector functions, however child reducers
105 | * have no knowledge of the overall state tree. To make them useable, we
106 | * need to make new selectors that wrap them.
107 | *
108 | * The createSelector function from the reselect library creates
109 | * very efficient selectors that are memoized and only recompute when arguments change.
110 | * The created selectors can also be composed together to select different
111 | * pieces of state.
112 | */
113 | export const getBookEntities = createSelector(getBooksState, fromBooks.getEntities);
114 | export const getBookIds = createSelector(getBooksState, fromBooks.getIds);
115 | export const getSelectedBookId = createSelector(getBooksState, fromBooks.getSelectedId);
116 | export const getSelectedBook = createSelector(getBooksState, fromBooks.getSelected);
117 |
118 |
119 | /**
120 | * Just like with the books selectors, we also have to compose the search
121 | * reducer's and collection reducer's selectors.
122 | */
123 | export const getSearchState = (state: State) => state.search;
124 |
125 | export const getSearchBookIds = createSelector(getSearchState, fromSearch.getIds);
126 | export const getSearchQuery = createSelector(getSearchState, fromSearch.getQuery);
127 | export const getSearchLoading = createSelector(getSearchState, fromSearch.getLoading);
128 |
129 |
130 | /**
131 | * Some selector functions create joins across parts of state. This selector
132 | * composes the search result IDs to return an array of books in the store.
133 | */
134 | export const getSearchResults = createSelector(getBookEntities, getSearchBookIds, (books, searchIds) => {
135 | return searchIds.map(id => books[id]);
136 | });
137 |
138 |
139 |
140 | export const getCollectionState = (state: State) => state.collection;
141 |
142 | export const getCollectionLoaded = createSelector(getCollectionState, fromCollection.getLoaded);
143 | export const getCollectionLoading = createSelector(getCollectionState, fromCollection.getLoading);
144 | export const getCollectionBookIds = createSelector(getCollectionState, fromCollection.getIds);
145 |
146 | export const getBookCollection = createSelector(getBookEntities, getCollectionBookIds, (entities, ids) => {
147 | return ids.map(id => entities[id]);
148 | });
149 |
150 | export const isSelectedBookInCollection = createSelector(getCollectionBookIds, getSelectedBookId, (ids, selected) => {
151 | return ids.indexOf(selected) > -1;
152 | });
153 |
154 | /**
155 | * Layout Reducers
156 | */
157 | export const getLayoutState = (state: State) => state.layout;
158 |
159 | export const getShowSidenav = createSelector(getLayoutState, fromLayout.getShowSidenav);
160 |
--------------------------------------------------------------------------------
/src/app/reducers/layout.ts:
--------------------------------------------------------------------------------
1 | import * as layout from '../actions/layout';
2 |
3 |
4 | export interface State {
5 | showSidenav: boolean;
6 | }
7 |
8 | const initialState: State = {
9 | showSidenav: false,
10 | };
11 |
12 | export function reducer(state = initialState, action: layout.Actions): State {
13 | switch (action.type) {
14 | case layout.CLOSE_SIDENAV:
15 | return {
16 | showSidenav: false
17 | };
18 |
19 | case layout.OPEN_SIDENAV:
20 | return {
21 | showSidenav: true
22 | };
23 |
24 | default:
25 | return state;
26 | }
27 | }
28 |
29 | export const getShowSidenav = (state: State) => state.showSidenav;
30 |
--------------------------------------------------------------------------------
/src/app/reducers/search.ts:
--------------------------------------------------------------------------------
1 | import * as book from '../actions/book';
2 |
3 |
4 | export interface State {
5 | ids: string[];
6 | loading: boolean;
7 | query: string;
8 | };
9 |
10 | const initialState: State = {
11 | ids: [],
12 | loading: false,
13 | query: ''
14 | };
15 |
16 | export function reducer(state = initialState, action: book.Actions): State {
17 | switch (action.type) {
18 | case book.SEARCH: {
19 | const query = action.payload;
20 |
21 | if (query === '') {
22 | return {
23 | ids: [],
24 | loading: false,
25 | query
26 | };
27 | }
28 |
29 | return Object.assign({}, state, {
30 | query,
31 | loading: true
32 | });
33 | }
34 |
35 | case book.SEARCH_COMPLETE: {
36 | const books = action.payload;
37 |
38 | return {
39 | ids: books.map(book => book.id),
40 | loading: false,
41 | query: state.query
42 | };
43 | }
44 |
45 | default: {
46 | return state;
47 | }
48 | }
49 | }
50 |
51 |
52 | export const getIds = (state: State) => state.ids;
53 |
54 | export const getQuery = (state: State) => state.query;
55 |
56 | export const getLoading = (state: State) => state.loading;
57 |
--------------------------------------------------------------------------------
/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/ngrx/example-app/ee0f331bf808525e003efa264b5065964c7f942b/src/assets/.gitkeep
--------------------------------------------------------------------------------
/src/assets/.npmignore:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ngrx/example-app/ee0f331bf808525e003efa264b5065964c7f942b/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/ngrx/example-app/ee0f331bf808525e003efa264b5065964c7f942b/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 |
--------------------------------------------------------------------------------