├── src ├── assets │ └── .gitkeep ├── setupJest.ts ├── app │ ├── books │ │ ├── components │ │ │ ├── books-total │ │ │ │ ├── books-total.component.css │ │ │ │ ├── books-total.component.html │ │ │ │ ├── books-total.component.ts │ │ │ │ └── books-total.component.spec.ts │ │ │ ├── books-page │ │ │ │ ├── books-page.component.css │ │ │ │ ├── books-page.component.html │ │ │ │ ├── books-page.component.spec.ts │ │ │ │ └── books-page.component.ts │ │ │ ├── books-list │ │ │ │ ├── books-list.component.css │ │ │ │ ├── books-list.component.spec.ts │ │ │ │ ├── books-list.component.ts │ │ │ │ └── books-list.component.html │ │ │ └── book-detail │ │ │ │ ├── book-detail.component.css │ │ │ │ ├── book-detail.component.spec.ts │ │ │ │ ├── book-detail.component.ts │ │ │ │ └── book-detail.component.html │ │ ├── actions │ │ │ ├── index.ts │ │ │ ├── books-api.actions.ts │ │ │ └── books-page.actions.ts │ │ ├── books.module.ts │ │ ├── books-api.effects.ts │ │ └── books-api.effects.spec.ts │ ├── movies │ │ ├── components │ │ │ ├── movies-total │ │ │ │ ├── movies-total.component.css │ │ │ │ ├── movies-total.component.html │ │ │ │ ├── movies-total.component.ts │ │ │ │ └── movies-total.component.spec.ts │ │ │ ├── movies-page │ │ │ │ ├── movies-page.component.css │ │ │ │ ├── movies-page.component.html │ │ │ │ ├── movies-page.component.ts │ │ │ │ └── movies-page.component.spec.ts │ │ │ ├── movies-list │ │ │ │ ├── movies-list.component.css │ │ │ │ ├── movies-list.component.spec.ts │ │ │ │ ├── movies-list.component.ts │ │ │ │ └── movies-list.component.html │ │ │ └── movie-detail │ │ │ │ ├── movie-detail.component.css │ │ │ │ ├── movie-detail.component.spec.ts │ │ │ │ ├── movie-detail.component.ts │ │ │ │ └── movie-detail.component.html │ │ ├── actions │ │ │ ├── index.ts │ │ │ ├── movies-page.actions.ts │ │ │ └── movie-api.actions.ts │ │ ├── movies.module.ts │ │ ├── movie-api.effects.spec.ts │ │ └── movie-api.effects.ts │ ├── shared │ │ ├── models │ │ │ ├── book.model.ts │ │ │ └── movie.model.ts │ │ ├── state │ │ │ ├── __snapshots__ │ │ │ │ ├── books.reducer.spec.ts.snap │ │ │ │ └── movie.reducer.spec.ts.snap │ │ │ ├── index.ts │ │ │ ├── movie.reducer.ts │ │ │ ├── books.reducer.ts │ │ │ ├── books.reducer.spec.ts │ │ │ └── movie.reducer.spec.ts │ │ └── services │ │ │ ├── book.service.ts │ │ │ └── movies.service.ts │ ├── app.component.ts │ ├── app.component.css │ ├── app.component.html │ ├── material.module.ts │ └── app.module.ts ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── tsconfig.app.json ├── tsconfig.spec.json ├── index.html ├── main.ts ├── browserslist ├── styles.css └── polyfills.ts ├── ngconf2019-workshop-slides.pdf ├── db.json ├── README.md ├── tsconfig.json ├── .gitignore ├── package.json └── angular.json /src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/setupJest.ts: -------------------------------------------------------------------------------- 1 | import "jest-preset-angular"; 2 | -------------------------------------------------------------------------------- /src/app/books/components/books-total/books-total.component.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/movies/components/movies-total/movies-total.component.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeSequence/ngconf2019-ngrx-workshop/HEAD/src/favicon.ico -------------------------------------------------------------------------------- /ngconf2019-workshop-slides.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeSequence/ngconf2019-ngrx-workshop/HEAD/ngconf2019-workshop-slides.pdf -------------------------------------------------------------------------------- /src/app/books/components/books-page/books-page.component.css: -------------------------------------------------------------------------------- 1 | :host >>> mat-list-item:hover { 2 | cursor: pointer; 3 | background: whitesmoke; 4 | } 5 | -------------------------------------------------------------------------------- /src/app/movies/components/movies-page/movies-page.component.css: -------------------------------------------------------------------------------- 1 | :host >>> mat-list-item:hover { 2 | cursor: pointer; 3 | background: whitesmoke; 4 | } 5 | -------------------------------------------------------------------------------- /src/app/books/components/books-list/books-list.component.css: -------------------------------------------------------------------------------- 1 | mat-list-item:not(:first-of-type) { 2 | border-top: 1px solid #efefef; 3 | } 4 | 5 | .symbol { 6 | color: #777; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/movies/components/movies-list/movies-list.component.css: -------------------------------------------------------------------------------- 1 | mat-list-item:not(:first-of-type) { 2 | border-top: 1px solid #efefef; 3 | } 4 | 5 | .symbol { 6 | color: #777; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/books/actions/index.ts: -------------------------------------------------------------------------------- 1 | import * as BooksPageActions from "./books-page.actions"; 2 | import * as BooksApiActions from "./books-api.actions"; 3 | 4 | export { BooksPageActions, BooksApiActions }; 5 | -------------------------------------------------------------------------------- /src/app/movies/actions/index.ts: -------------------------------------------------------------------------------- 1 | import * as MovieApiActions from "./movie-api.actions"; 2 | import * as MoviesPageActions from "./movies-page.actions"; 3 | 4 | export { MovieApiActions, MoviesPageActions }; 5 | -------------------------------------------------------------------------------- /src/app/books/components/book-detail/book-detail.component.css: -------------------------------------------------------------------------------- 1 | mat-card-actions { 2 | margin-bottom: 0; 3 | } 4 | mat-card-header { 5 | margin-bottom: 10px; 6 | } 7 | .full-width { 8 | width: 100%; 9 | } 10 | -------------------------------------------------------------------------------- /src/app/movies/components/movie-detail/movie-detail.component.css: -------------------------------------------------------------------------------- 1 | mat-card-actions { 2 | margin-bottom: 0; 3 | } 4 | mat-card-header { 5 | margin-bottom: 10px; 6 | } 7 | .full-width { 8 | width: 100%; 9 | } 10 | -------------------------------------------------------------------------------- /src/app/shared/models/book.model.ts: -------------------------------------------------------------------------------- 1 | export interface Book { 2 | id: string; 3 | name: string; 4 | earnings: number; 5 | description?: string; 6 | } 7 | 8 | export type BookRequiredProps = Pick; 9 | -------------------------------------------------------------------------------- /src/app/shared/models/movie.model.ts: -------------------------------------------------------------------------------- 1 | export interface Movie { 2 | id: string; 3 | name: string; 4 | earnings: number; 5 | description?: string; 6 | } 7 | 8 | export type MovieRequiredProps = Pick; 9 | -------------------------------------------------------------------------------- /src/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "types": [] 6 | }, 7 | "exclude": [ 8 | "test.ts", 9 | "**/*.spec.ts" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /src/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/spec", 5 | "module": "commonjs", 6 | "target": "es5", 7 | "types": ["jest", "node"], 8 | "allowJs": true 9 | }, 10 | "include": ["**/*.spec.ts", "**/*.d.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /src/app/books/components/books-total/books-total.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

Books Gross Total

5 |
6 |
7 | 8 | {{ total | currency }} 9 | 10 |
11 | -------------------------------------------------------------------------------- /src/app/movies/components/movies-total/movies-total.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

Movies Gross Total

5 |
6 |
7 | 8 | {{ total | currency }} 9 | 10 |
11 | -------------------------------------------------------------------------------- /db.json: -------------------------------------------------------------------------------- 1 | { 2 | "movies": [ 3 | { 4 | "id": "1", 5 | "name": "Interstellar", 6 | "description": "Space", 7 | "earnings": 25000000 8 | }, 9 | { 10 | "id": "2", 11 | "name": "Inception", 12 | "description": "I forgot", 13 | "earnings": 50000000 14 | } 15 | ], 16 | "books": [] 17 | } -------------------------------------------------------------------------------- /src/app/books/components/books-total/books-total.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from "@angular/core"; 2 | 3 | @Component({ 4 | selector: "app-books-total", 5 | templateUrl: "./books-total.component.html", 6 | styleUrls: ["./books-total.component.css"] 7 | }) 8 | export class BooksTotalComponent { 9 | @Input() total: number; 10 | } 11 | -------------------------------------------------------------------------------- /src/app/movies/components/movies-total/movies-total.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from "@angular/core"; 2 | 3 | @Component({ 4 | selector: "app-movies-total", 5 | templateUrl: "./movies-total.component.html", 6 | styleUrls: ["./movies-total.component.css"] 7 | }) 8 | export class MoviesTotalComponent { 9 | @Input() total: number; 10 | } 11 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NgRx Workshop 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NgRx Workshop: A Reactive State of Mind 2 | 3 | ## Setup 4 | 5 | ```sh 6 | yarn 7 | ``` 8 | 9 | or 10 | 11 | ```sh 12 | npm install 13 | ``` 14 | 15 | ## Running the app 16 | 17 | ```sh 18 | yarn start 19 | ``` 20 | 21 | or 22 | 23 | ```sh 24 | npm start 25 | ``` 26 | 27 | ## Running the tests 28 | 29 | ```sh 30 | yarn test 31 | ``` 32 | 33 | or 34 | 35 | ```sh 36 | npm run test 37 | ``` 38 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from "@angular/core"; 2 | 3 | @Component({ 4 | selector: "app-root", 5 | templateUrl: "./app.component.html", 6 | styleUrls: ["./app.component.css"] 7 | }) 8 | export class AppComponent { 9 | title = "NgRx Workshop"; 10 | links = [ 11 | { path: "/movies", icon: "movie", label: "Movies" }, 12 | { path: "/books", icon: "book", label: "Books" } 13 | ]; 14 | } 15 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from "@angular/core"; 2 | import { platformBrowserDynamic } from "@angular/platform-browser-dynamic"; 3 | 4 | import { AppModule } from "./app/app.module"; 5 | import { environment } from "./environments/environment"; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic() 12 | .bootstrapModule(AppModule) 13 | .catch(err => console.error(err)); 14 | -------------------------------------------------------------------------------- /src/app/books/components/books-list/books-list.component.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-unused-variable */ 2 | 3 | import { TestBed, async } from "@angular/core/testing"; 4 | import { BooksListComponent } from "./books-list.component"; 5 | 6 | describe("Component: BooksList", () => { 7 | it("should create an instance", () => { 8 | const component = new BooksListComponent(); 9 | expect(component).toBeTruthy(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/browserslist: -------------------------------------------------------------------------------- 1 | # This file is currently used by autoprefixer to adjust CSS to support the below specified browsers 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | # 5 | # For IE 9-11 support, please remove 'not' from the last line of the file and adjust as needed 6 | 7 | > 0.5% 8 | last 2 versions 9 | Firefox ESR 10 | not dead 11 | not IE 9-11 -------------------------------------------------------------------------------- /src/app/books/components/book-detail/book-detail.component.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-unused-variable */ 2 | 3 | import { TestBed, async } from "@angular/core/testing"; 4 | import { BookDetailComponent } from "./book-detail.component"; 5 | 6 | describe("Component: BookDetail", () => { 7 | it("should create an instance", () => { 8 | const component = new BookDetailComponent(); 9 | expect(component).toBeTruthy(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/app/movies/components/movies-list/movies-list.component.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-unused-variable */ 2 | 3 | import { TestBed, async } from "@angular/core/testing"; 4 | import { MoviesListComponent } from "./movies-list.component"; 5 | 6 | describe("Component: MoviesList", () => { 7 | it("should create an instance", () => { 8 | const component = new MoviesListComponent(); 9 | expect(component).toBeTruthy(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/app/movies/components/movie-detail/movie-detail.component.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-unused-variable */ 2 | 3 | import { TestBed, async } from "@angular/core/testing"; 4 | import { MovieDetailComponent } from "./movie-detail.component"; 5 | 6 | describe("Component: MovieDetail", () => { 7 | it("should create an instance", () => { 8 | const component = new MovieDetailComponent(); 9 | expect(component).toBeTruthy(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "sourceMap": true, 7 | "declaration": false, 8 | "module": "es2015", 9 | "moduleResolution": "node", 10 | "emitDecoratorMetadata": true, 11 | "experimentalDecorators": true, 12 | "importHelpers": true, 13 | "target": "es5", 14 | "typeRoots": [ 15 | "node_modules/@types" 16 | ], 17 | "lib": [ 18 | "es2018", 19 | "dom" 20 | ] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/app/books/components/books-list/books-list.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, Output } from "@angular/core"; 2 | import { Book } from "src/app/shared/models/book.model"; 3 | 4 | @Component({ 5 | selector: "app-books-list", 6 | templateUrl: "./books-list.component.html", 7 | styleUrls: ["./books-list.component.css"] 8 | }) 9 | export class BooksListComponent { 10 | @Input() books: Book[]; 11 | @Input() readonly = false; 12 | @Output() select = new EventEmitter(); 13 | @Output() delete = new EventEmitter(); 14 | } 15 | -------------------------------------------------------------------------------- /src/app/movies/components/movies-list/movies-list.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, Output } from "@angular/core"; 2 | import { Movie } from "src/app/shared/models/movie.model"; 3 | 4 | @Component({ 5 | selector: "app-movies-list", 6 | templateUrl: "./movies-list.component.html", 7 | styleUrls: ["./movies-list.component.css"] 8 | }) 9 | export class MoviesListComponent { 10 | @Input() movies: Movie[]; 11 | @Input() readonly = false; 12 | @Output() select = new EventEmitter(); 13 | @Output() delete = new EventEmitter(); 14 | } 15 | -------------------------------------------------------------------------------- /src/app/books/components/books-page/books-page.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 | 10 | 11 |
12 | 13 | 19 | 20 |
21 | -------------------------------------------------------------------------------- /src/app/movies/components/movies-page/movies-page.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 | 10 | 11 |
12 | 13 | 19 | 20 |
21 | -------------------------------------------------------------------------------- /src/app/app.component.css: -------------------------------------------------------------------------------- 1 | :host { 2 | display: flex; 3 | flex-direction: column; 4 | flex: 1; 5 | } 6 | 7 | .nav-link { 8 | color: rgba(0, 0, 0, 0.54); 9 | display: flex; 10 | align-items: center; 11 | padding-top: 5px; 12 | padding-bottom: 5px; 13 | } 14 | 15 | mat-toolbar { 16 | box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 17 | 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0, 0, 0, 0.12); 18 | z-index: 1; 19 | } 20 | 21 | mat-toolbar > .mat-mini-fab { 22 | margin-right: 10px; 23 | } 24 | 25 | mat-sidenav { 26 | box-shadow: 3px 0 6px rgba(0, 0, 0, 0.24); 27 | width: 200px; 28 | } 29 | 30 | .mat-sidenav-container { 31 | background: #f5f5f5; 32 | flex: 1; 33 | } 34 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false 7 | }; 8 | 9 | /* 10 | * For easier debugging in development mode, you can import the following file 11 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 12 | * 13 | * This import should be commented out in production mode because it will have a negative impact 14 | * on performance if an error is thrown. 15 | */ 16 | // import 'zone.js/dist/zone-error'; // Included with Angular CLI. 17 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | {{ title }} 7 | 8 | 9 | 10 | 11 | 12 | 24 | 25 | 26 |
27 | 28 |
29 |
30 | -------------------------------------------------------------------------------- /src/app/material.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from "@angular/core"; 2 | import { 3 | MatButtonModule, 4 | MatCardModule, 5 | MatCheckboxModule, 6 | MatIconModule, 7 | MatInputModule, 8 | MatListModule, 9 | MatSidenavModule, 10 | MatToolbarModule 11 | } from "@angular/material"; 12 | 13 | @NgModule({ 14 | imports: [ 15 | MatButtonModule, 16 | MatCardModule, 17 | MatCheckboxModule, 18 | MatIconModule, 19 | MatInputModule, 20 | MatListModule, 21 | MatSidenavModule, 22 | MatToolbarModule 23 | ], 24 | exports: [ 25 | MatButtonModule, 26 | MatCardModule, 27 | MatCheckboxModule, 28 | MatIconModule, 29 | MatInputModule, 30 | MatListModule, 31 | MatSidenavModule, 32 | MatToolbarModule 33 | ] 34 | }) 35 | export class MaterialModule {} 36 | -------------------------------------------------------------------------------- /src/app/books/actions/books-api.actions.ts: -------------------------------------------------------------------------------- 1 | import { Book } from "src/app/shared/models/book.model"; 2 | import { createAction, props } from "@ngrx/store"; 3 | 4 | export const booksLoaded = createAction( 5 | "[Books API] Books Loaded Success", 6 | props<{ books: Book[] }>() 7 | ); 8 | 9 | export const bookCreated = createAction( 10 | "[Books API] Book Created", 11 | props<{ book: Book }>() 12 | ); 13 | 14 | export const bookUpdated = createAction( 15 | "[Books API] Book Updated", 16 | props<{ book: Book }>() 17 | ); 18 | 19 | export const bookDeleted = createAction( 20 | "[Books API] Book Deleted", 21 | props<{ book: Book }>() 22 | ); 23 | 24 | export type BooksApiActions = ReturnType< 25 | | typeof booksLoaded 26 | | typeof bookCreated 27 | | typeof bookUpdated 28 | | typeof bookDeleted 29 | >; 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | # Only exists if Bazel was run 8 | /bazel-out 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # profiling files 14 | chrome-profiler-events.json 15 | speed-measure-plugin.json 16 | 17 | # IDEs and editors 18 | /.idea 19 | .project 20 | .classpath 21 | .c9/ 22 | *.launch 23 | .settings/ 24 | *.sublime-workspace 25 | 26 | # IDE - VSCode 27 | .vscode/* 28 | !.vscode/settings.json 29 | !.vscode/tasks.json 30 | !.vscode/launch.json 31 | !.vscode/extensions.json 32 | .history/* 33 | 34 | # misc 35 | /.sass-cache 36 | /connect.lock 37 | /coverage 38 | /libpeerconnection.log 39 | npm-debug.log 40 | yarn-error.log 41 | testem.log 42 | /typings 43 | 44 | # System Files 45 | .DS_Store 46 | Thumbs.db 47 | -------------------------------------------------------------------------------- /src/app/books/components/books-list/books-list.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

Books

5 |
6 |
7 | 8 | 9 | 14 |

{{ book.name }}

15 |

16 | {{ book.description }} 17 |

18 |

19 | {{ book.earnings | currency }} 20 |

21 | 28 |
29 |
30 |
31 |
32 | -------------------------------------------------------------------------------- /src/app/movies/components/movies-list/movies-list.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

Movies

5 |
6 |
7 | 8 | 9 | 14 |

{{ movie.name }}

15 |

16 | {{ movie.description }} 17 |

18 |

19 | {{ movie.earnings | currency }} 20 |

21 | 28 |
29 |
30 |
31 |
32 | -------------------------------------------------------------------------------- /src/app/books/components/books-total/books-total.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from "@angular/core/testing"; 2 | 3 | import { BooksTotalComponent } from "./books-total.component"; 4 | import { MaterialModule } from "src/app/material.module"; 5 | import { NoopAnimationsModule } from "@angular/platform-browser/animations"; 6 | 7 | describe("BooksTotalComponent", () => { 8 | let component: BooksTotalComponent; 9 | let fixture: ComponentFixture; 10 | 11 | beforeEach(async(() => { 12 | TestBed.configureTestingModule({ 13 | imports: [MaterialModule, NoopAnimationsModule], 14 | declarations: [BooksTotalComponent] 15 | }).compileComponents(); 16 | })); 17 | 18 | beforeEach(() => { 19 | fixture = TestBed.createComponent(BooksTotalComponent); 20 | component = fixture.componentInstance; 21 | fixture.detectChanges(); 22 | }); 23 | 24 | it("should create", () => { 25 | expect(component).toBeTruthy(); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/app/shared/state/__snapshots__/books.reducer.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Books Reducer should add a newly created book to the state 1`] = ` 4 | Object { 5 | "activeBookId": "1", 6 | "entities": Object { 7 | "1": Object { 8 | "earnings": 200000000, 9 | "id": "1", 10 | "name": "Forrest Gump", 11 | }, 12 | }, 13 | "ids": Array [ 14 | "1", 15 | ], 16 | } 17 | `; 18 | 19 | exports[`Books Reducer should load all books when the API loads them all successfully 1`] = ` 20 | Object { 21 | "activeBookId": null, 22 | "entities": Object { 23 | "1": Object { 24 | "earnings": 1000000, 25 | "id": "1", 26 | "name": "Castaway", 27 | }, 28 | }, 29 | "ids": Array [ 30 | "1", 31 | ], 32 | } 33 | `; 34 | 35 | exports[`Books Reducer should remove books from the state when they are deleted 1`] = ` 36 | Object { 37 | "activeBookId": null, 38 | "entities": Object {}, 39 | "ids": Array [], 40 | } 41 | `; 42 | -------------------------------------------------------------------------------- /src/app/movies/components/movies-total/movies-total.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from "@angular/core/testing"; 2 | 3 | import { MoviesTotalComponent } from "./movies-total.component"; 4 | import { MaterialModule } from "src/app/material.module"; 5 | import { NoopAnimationsModule } from "@angular/platform-browser/animations"; 6 | 7 | describe("MoviesTotalComponent", () => { 8 | let component: MoviesTotalComponent; 9 | let fixture: ComponentFixture; 10 | 11 | beforeEach(async(() => { 12 | TestBed.configureTestingModule({ 13 | imports: [MaterialModule, NoopAnimationsModule], 14 | declarations: [MoviesTotalComponent] 15 | }).compileComponents(); 16 | })); 17 | 18 | beforeEach(() => { 19 | fixture = TestBed.createComponent(MoviesTotalComponent); 20 | component = fixture.componentInstance; 21 | fixture.detectChanges(); 22 | }); 23 | 24 | it("should create", () => { 25 | expect(component).toBeTruthy(); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/app/books/actions/books-page.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from "@ngrx/store"; 2 | import { BookRequiredProps, Book } from "src/app/shared/models/book.model"; 3 | 4 | export const enter = createAction("[Books Page] Enter"); 5 | 6 | export const selectBook = createAction( 7 | "[Books Page] Select Book", 8 | props<{ bookId: string }>() 9 | ); 10 | 11 | export const clearSelectedBook = createAction( 12 | "[Books Page] Clear Selected Book" 13 | ); 14 | 15 | export const createBook = createAction( 16 | "[Books Page] Create Book", 17 | props<{ book: BookRequiredProps }>() 18 | ); 19 | 20 | export const updateBook = createAction( 21 | "[Books Page] Update Book", 22 | props<{ book: Book; changes: BookRequiredProps }>() 23 | ); 24 | 25 | export const deleteBook = createAction( 26 | "[Books Page] Delete Book", 27 | props<{ book: Book }>() 28 | ); 29 | 30 | export type BooksActions = ReturnType< 31 | | typeof enter 32 | | typeof selectBook 33 | | typeof clearSelectedBook 34 | | typeof createBook 35 | | typeof updateBook 36 | | typeof deleteBook 37 | >; 38 | -------------------------------------------------------------------------------- /src/app/movies/actions/movies-page.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from "@ngrx/store"; 2 | import { MovieRequiredProps, Movie } from "src/app/shared/models/movie.model"; 3 | 4 | export const enter = createAction("[Movies Page] Enter"); 5 | 6 | export const selectMovie = createAction( 7 | "[Movies Page] Select Movie", 8 | props<{ movieId: string }>() 9 | ); 10 | 11 | export const clearSelectedMovie = createAction( 12 | "[Movies Page] Clear Selected Movie" 13 | ); 14 | 15 | export const createMovie = createAction( 16 | "[Movies Page] Create Movie", 17 | props<{ movie: MovieRequiredProps }>() 18 | ); 19 | 20 | export const updateMovie = createAction( 21 | "[Movies Page] Update Movie", 22 | props<{ movie: Movie; changes: MovieRequiredProps }>() 23 | ); 24 | 25 | export const deleteMovie = createAction( 26 | "[Movies Page] Delete Movie", 27 | props<{ movie: Movie }>() 28 | ); 29 | 30 | export type Union = ReturnType< 31 | | typeof enter 32 | | typeof selectMovie 33 | | typeof clearSelectedMovie 34 | | typeof createMovie 35 | | typeof updateMovie 36 | | typeof deleteMovie 37 | >; 38 | -------------------------------------------------------------------------------- /src/app/books/components/book-detail/book-detail.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, Output } from "@angular/core"; 2 | import { Book } from "src/app/shared/models/book.model"; 3 | import { FormGroup, FormControl } from "@angular/forms"; 4 | 5 | @Component({ 6 | selector: "app-book-detail", 7 | templateUrl: "./book-detail.component.html", 8 | styleUrls: ["./book-detail.component.css"] 9 | }) 10 | export class BookDetailComponent { 11 | originalBook: Book | undefined; 12 | @Output() save = new EventEmitter(); 13 | @Output() cancel = new EventEmitter(); 14 | 15 | bookForm = new FormGroup({ 16 | name: new FormControl(""), 17 | earnings: new FormControl(0), 18 | description: new FormControl("") 19 | }); 20 | 21 | @Input() set book(book: Book) { 22 | this.bookForm.reset(); 23 | this.originalBook = null; 24 | 25 | if (book) { 26 | this.bookForm.setValue({ 27 | name: book.name, 28 | earnings: book.earnings, 29 | description: book.description 30 | }); 31 | 32 | this.originalBook = book; 33 | } 34 | } 35 | 36 | onSubmit(book: Book) { 37 | this.save.emit({ ...this.originalBook, ...book }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/app/books/books.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from "@angular/core"; 2 | import { CommonModule } from "@angular/common"; 3 | import { RouterModule } from "@angular/router"; 4 | import { ReactiveFormsModule } from "@angular/forms"; 5 | 6 | import { MaterialModule } from "src/app/material.module"; 7 | 8 | import { BooksPageComponent } from "./components/books-page/books-page.component"; 9 | import { BookDetailComponent } from "./components/book-detail/book-detail.component"; 10 | import { BooksListComponent } from "./components/books-list/books-list.component"; 11 | import { BooksTotalComponent } from "./components/books-total/books-total.component"; 12 | 13 | import { EffectsModule } from "@ngrx/effects"; 14 | import { BooksApiEffects } from "./books-api.effects"; 15 | 16 | @NgModule({ 17 | imports: [ 18 | CommonModule, 19 | ReactiveFormsModule, 20 | MaterialModule, 21 | RouterModule.forChild([{ path: "books", component: BooksPageComponent }]), 22 | EffectsModule.forFeature([BooksApiEffects]) 23 | ], 24 | declarations: [ 25 | BooksPageComponent, 26 | BookDetailComponent, 27 | BooksListComponent, 28 | BooksTotalComponent 29 | ] 30 | }) 31 | export class BooksModule {} 32 | -------------------------------------------------------------------------------- /src/app/movies/movies.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from "@angular/core"; 2 | import { CommonModule } from "@angular/common"; 3 | import { RouterModule } from "@angular/router"; 4 | import { ReactiveFormsModule } from "@angular/forms"; 5 | 6 | import { EffectsModule } from "@ngrx/effects"; 7 | 8 | import { MaterialModule } from "src/app/material.module"; 9 | import { MovieApiEffects } from "./movie-api.effects"; 10 | 11 | import { MoviesPageComponent } from "./components/movies-page/movies-page.component"; 12 | import { MovieDetailComponent } from "./components/movie-detail/movie-detail.component"; 13 | import { MoviesListComponent } from "./components/movies-list/movies-list.component"; 14 | import { MoviesTotalComponent } from "./components/movies-total/movies-total.component"; 15 | 16 | @NgModule({ 17 | imports: [ 18 | CommonModule, 19 | ReactiveFormsModule, 20 | MaterialModule, 21 | RouterModule.forChild([{ path: "movies", component: MoviesPageComponent }]), 22 | EffectsModule.forFeature([MovieApiEffects]) 23 | ], 24 | declarations: [ 25 | MoviesPageComponent, 26 | MovieDetailComponent, 27 | MoviesListComponent, 28 | MoviesTotalComponent 29 | ] 30 | }) 31 | export class MoviesModule {} 32 | -------------------------------------------------------------------------------- /src/app/movies/components/movie-detail/movie-detail.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, Output } from "@angular/core"; 2 | import { Movie } from "src/app/shared/models/movie.model"; 3 | import { FormGroup, FormControl } from "@angular/forms"; 4 | 5 | @Component({ 6 | selector: "app-movie-detail", 7 | templateUrl: "./movie-detail.component.html", 8 | styleUrls: ["./movie-detail.component.css"] 9 | }) 10 | export class MovieDetailComponent { 11 | originalMovie: Movie | undefined; 12 | @Output() save = new EventEmitter(); 13 | @Output() cancel = new EventEmitter(); 14 | 15 | movieForm = new FormGroup({ 16 | name: new FormControl(""), 17 | earnings: new FormControl(0), 18 | description: new FormControl("") 19 | }); 20 | 21 | @Input() set movie(movie: Movie) { 22 | this.movieForm.reset(); 23 | this.originalMovie = null; 24 | 25 | if (movie) { 26 | this.movieForm.setValue({ 27 | name: movie.name, 28 | earnings: movie.earnings, 29 | description: movie.description 30 | }); 31 | 32 | this.originalMovie = movie; 33 | } 34 | } 35 | 36 | onSubmit(movie: Movie) { 37 | this.save.emit({ ...this.originalMovie, ...movie }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/app/shared/services/book.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient, HttpHeaders } from "@angular/common/http"; 2 | import { Injectable } from "@angular/core"; 3 | import * as uuid from "uuid/v4"; 4 | import { Book, BookRequiredProps } from "../models/book.model"; 5 | 6 | const BASE_URL = "http://localhost:3000/books"; 7 | const HEADER = { 8 | headers: new HttpHeaders({ "Content-Type": "application/json" }) 9 | }; 10 | 11 | @Injectable({ 12 | providedIn: "root" 13 | }) 14 | export class BooksService { 15 | constructor(private http: HttpClient) {} 16 | 17 | all() { 18 | return this.http.get(BASE_URL); 19 | } 20 | 21 | load(id: string) { 22 | return this.http.get(`${BASE_URL}/${id}`); 23 | } 24 | 25 | create(bookProps: BookRequiredProps) { 26 | const Book: Book = { 27 | id: uuid(), 28 | ...bookProps 29 | }; 30 | 31 | return this.http.post(`${BASE_URL}`, JSON.stringify(Book), HEADER); 32 | } 33 | 34 | update(id: string, updates: BookRequiredProps) { 35 | return this.http.patch( 36 | `${BASE_URL}/${id}`, 37 | JSON.stringify(updates), 38 | HEADER 39 | ); 40 | } 41 | 42 | delete(id: string) { 43 | return this.http.delete(`${BASE_URL}/${id}`); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/app/shared/services/movies.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient, HttpHeaders } from "@angular/common/http"; 2 | import { Injectable } from "@angular/core"; 3 | import * as uuid from "uuid/v4"; 4 | import { Movie, MovieRequiredProps } from "../models/movie.model"; 5 | 6 | const BASE_URL = "http://localhost:3000/movies"; 7 | const HEADER = { 8 | headers: new HttpHeaders({ "Content-Type": "application/json" }) 9 | }; 10 | 11 | @Injectable({ 12 | providedIn: "root" 13 | }) 14 | export class MoviesService { 15 | constructor(private http: HttpClient) {} 16 | 17 | all() { 18 | return this.http.get(BASE_URL); 19 | } 20 | 21 | load(id: string) { 22 | return this.http.get(`${BASE_URL}/${id}`); 23 | } 24 | 25 | create(movieProps: MovieRequiredProps) { 26 | const movie: Movie = { 27 | id: uuid(), 28 | ...movieProps 29 | }; 30 | 31 | return this.http.post(`${BASE_URL}`, JSON.stringify(movie), HEADER); 32 | } 33 | 34 | update(id: string, updates: MovieRequiredProps) { 35 | return this.http.patch( 36 | `${BASE_URL}/${id}`, 37 | JSON.stringify(updates), 38 | HEADER 39 | ); 40 | } 41 | 42 | delete(id: string) { 43 | return this.http.delete(`${BASE_URL}/${id}`); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { BrowserModule } from "@angular/platform-browser"; 2 | import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; 3 | import { NgModule } from "@angular/core"; 4 | import { RouterModule } from "@angular/router"; 5 | import { HttpClientModule } from "@angular/common/http"; 6 | 7 | import { StoreModule } from "@ngrx/store"; 8 | import { StoreDevtoolsModule } from "@ngrx/store-devtools"; 9 | import { EffectsModule } from "@ngrx/effects"; 10 | 11 | import { MaterialModule } from "./material.module"; 12 | import { MoviesModule } from "./movies/movies.module"; 13 | 14 | import { AppComponent } from "./app.component"; 15 | 16 | import { reducers, metaReducers } from "./shared/state"; 17 | import { BooksModule } from "./books/books.module"; 18 | 19 | @NgModule({ 20 | declarations: [AppComponent], 21 | imports: [ 22 | BrowserModule, 23 | BrowserAnimationsModule, 24 | HttpClientModule, 25 | RouterModule.forRoot([ 26 | { path: "", pathMatch: "full", redirectTo: "/movies" } 27 | ]), 28 | StoreModule.forRoot(reducers, { metaReducers }), 29 | StoreDevtoolsModule.instrument(), 30 | EffectsModule.forRoot([]), 31 | MaterialModule, 32 | MoviesModule, 33 | BooksModule 34 | ], 35 | bootstrap: [AppComponent] 36 | }) 37 | export class AppModule {} 38 | -------------------------------------------------------------------------------- /src/app/books/components/book-detail/book-detail.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

5 | Editing {{ originalBook.name }} 8 | Create Book 9 |

10 |
11 |
12 |
13 | 14 | 15 | 16 | 17 | 18 | 24 | 25 | 26 | 32 | 33 | 34 | 35 | 36 | 37 | 38 |
39 |
40 | -------------------------------------------------------------------------------- /src/app/movies/components/movie-detail/movie-detail.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

5 | Editing {{ originalMovie.name }} 8 | Create Movie 9 |

10 |
11 |
12 |
13 | 14 | 15 | 16 | 17 | 18 | 24 | 25 | 26 | 32 | 33 | 34 | 35 | 36 | 37 | 38 |
39 |
40 | -------------------------------------------------------------------------------- /src/app/movies/actions/movie-api.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from "@ngrx/store"; 2 | import { Movie } from "src/app/shared/models/movie.model"; 3 | 4 | export const loadMoviesSuccess = createAction( 5 | "[Movies API] Load Movies Success", 6 | props<{ movies: Movie[] }>() 7 | ); 8 | 9 | export const loadMoviesFailure = createAction( 10 | "[Movies API] Load Movies Failure" 11 | ); 12 | 13 | export const createMovieSuccess = createAction( 14 | "[Movies API] Create Movie Success", 15 | props<{ movie: Movie }>() 16 | ); 17 | 18 | export const createMovieFailure = createAction( 19 | "[Movies API] Create Movie Failure" 20 | ); 21 | 22 | export const updateMovieSuccess = createAction( 23 | "[Movies API] Update Movie Success", 24 | props<{ movie: Movie }>() 25 | ); 26 | 27 | export const updateMovieFailure = createAction( 28 | "[Movies API] Update Movie Failure", 29 | props<{ movie: Movie }>() 30 | ); 31 | 32 | export const deleteMovieSuccess = createAction( 33 | "[Movies API] Delete Movie Success", 34 | props<{ movieId: string }>() 35 | ); 36 | 37 | export const deleteMovieFailure = createAction( 38 | "[Movies API] Delete Movie Failure", 39 | props<{ movie: Movie }>() 40 | ); 41 | 42 | export type Union = ReturnType< 43 | | typeof loadMoviesSuccess 44 | | typeof loadMoviesFailure 45 | | typeof createMovieSuccess 46 | | typeof createMovieFailure 47 | | typeof updateMovieSuccess 48 | | typeof updateMovieFailure 49 | | typeof deleteMovieSuccess 50 | | typeof deleteMovieFailure 51 | >; 52 | -------------------------------------------------------------------------------- /src/app/shared/state/index.ts: -------------------------------------------------------------------------------- 1 | import { ActionReducerMap, createSelector, MetaReducer } from "@ngrx/store"; 2 | import * as fromMovies from "./movie.reducer"; 3 | import * as fromBooks from "./books.reducer"; 4 | 5 | export interface State { 6 | movies: fromMovies.State; 7 | books: fromBooks.State; 8 | } 9 | 10 | export const reducers: ActionReducerMap = { 11 | movies: fromMovies.reducer, 12 | books: fromBooks.reducer 13 | }; 14 | 15 | export const metaReducers: MetaReducer[] = []; 16 | 17 | /** 18 | * Selectors 19 | */ 20 | export const selectMovieState = (state: State) => state.movies; 21 | 22 | export const selectMovieEntities = createSelector( 23 | selectMovieState, 24 | fromMovies.selectEntities 25 | ); 26 | 27 | export const selectMovies = createSelector( 28 | selectMovieState, 29 | fromMovies.selectAll 30 | ); 31 | 32 | export const selectActiveMovieId = createSelector( 33 | selectMovieState, 34 | fromMovies.selectActiveMovieId 35 | ); 36 | 37 | export const selectActiveMovie = createSelector( 38 | selectMovieState, 39 | fromMovies.selectActiveMovie 40 | ); 41 | 42 | export const selectMoviesEarningsTotal = createSelector( 43 | selectMovieState, 44 | fromMovies.selectEarningsTotal 45 | ); 46 | 47 | export const selectBooksState = (state: State) => state.books; 48 | 49 | export const selectAllBooks = createSelector( 50 | selectBooksState, 51 | fromBooks.selectAll 52 | ); 53 | 54 | export const selectActiveBook = createSelector( 55 | selectBooksState, 56 | fromBooks.selectActiveBook 57 | ); 58 | 59 | export const selectBookEarningsTotals = createSelector( 60 | selectBooksState, 61 | fromBooks.selectEarningsTotals 62 | ); 63 | -------------------------------------------------------------------------------- /src/app/movies/components/movies-page/movies-page.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from "@angular/core"; 2 | import { Store, select } from "@ngrx/store"; 3 | 4 | import { MoviesPageActions } from "../../actions"; 5 | import { Movie } from "src/app/shared/models/movie.model"; 6 | import * as fromRoot from "src/app/shared/state"; 7 | 8 | @Component({ 9 | selector: "app-movies", 10 | templateUrl: "./movies-page.component.html", 11 | styleUrls: ["./movies-page.component.css"] 12 | }) 13 | export class MoviesPageComponent implements OnInit { 14 | movies$ = this.store.pipe(select(fromRoot.selectMovies)); 15 | activeMovie$ = this.store.pipe(select(fromRoot.selectActiveMovie)); 16 | total$ = this.store.pipe(select(fromRoot.selectMoviesEarningsTotal)); 17 | 18 | constructor(private store: Store) {} 19 | 20 | ngOnInit() { 21 | this.store.dispatch(MoviesPageActions.enter()); 22 | } 23 | 24 | onSelect(movie: Movie) { 25 | this.store.dispatch(MoviesPageActions.selectMovie({ movieId: movie.id })); 26 | } 27 | 28 | onCancel() { 29 | this.store.dispatch(MoviesPageActions.clearSelectedMovie()); 30 | } 31 | 32 | onSave(movie: Movie) { 33 | if (!movie.id) { 34 | this.saveMovie(movie); 35 | } else { 36 | this.updateMovie(movie); 37 | } 38 | } 39 | 40 | saveMovie(movie: Movie) { 41 | this.store.dispatch(MoviesPageActions.createMovie({ movie })); 42 | } 43 | 44 | updateMovie(movie: Movie) { 45 | this.store.dispatch( 46 | MoviesPageActions.updateMovie({ movie, changes: movie }) 47 | ); 48 | } 49 | 50 | onDelete(movie: Movie) { 51 | this.store.dispatch(MoviesPageActions.deleteMovie({ movie })); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/app/books/components/books-page/books-page.component.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-unused-variable */ 2 | 3 | import { TestBed, async, ComponentFixture } from "@angular/core/testing"; 4 | import { NoopAnimationsModule } from "@angular/platform-browser/animations"; 5 | import { ReactiveFormsModule } from "@angular/forms"; 6 | 7 | import { MaterialModule } from "src/app/material.module"; 8 | 9 | import { BooksPageComponent } from "./books-page.component"; 10 | import { BooksService } from "src/app/shared/services/book.service"; 11 | import { BooksListComponent } from "../books-list/books-list.component"; 12 | import { BookDetailComponent } from "../book-detail/book-detail.component"; 13 | import { BooksTotalComponent } from "../books-total/books-total.component"; 14 | import { provideMockStore } from "@ngrx/store/testing"; 15 | 16 | class BooksServiceStub {} 17 | 18 | describe("Component: Books Page", () => { 19 | let comp: BooksPageComponent; 20 | let fixture: ComponentFixture; 21 | 22 | beforeEach(() => { 23 | TestBed.configureTestingModule({ 24 | imports: [MaterialModule, NoopAnimationsModule, ReactiveFormsModule], 25 | declarations: [ 26 | BooksPageComponent, 27 | BooksPageComponent, 28 | BooksListComponent, 29 | BookDetailComponent, 30 | BooksTotalComponent 31 | ], 32 | providers: [ 33 | { provide: BooksService, useClass: BooksServiceStub }, 34 | provideMockStore() 35 | ] 36 | }); 37 | 38 | fixture = TestBed.createComponent(BooksPageComponent); 39 | comp = fixture.componentInstance; 40 | }); 41 | 42 | it("should create an instance", () => { 43 | expect(comp).toBeTruthy(); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ngrx-workshop-example", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "concurrently \"npm run server\" \"ng serve\"", 7 | "server": "json-server db.json --watch", 8 | "build": "ng build", 9 | "test": "jest", 10 | "lint": "ng lint", 11 | "e2e": "ng e2e" 12 | }, 13 | "jest": { 14 | "preset": "jest-preset-angular", 15 | "setupFilesAfterEnv": [ 16 | "/src/setupJest.ts" 17 | ] 18 | }, 19 | "private": true, 20 | "dependencies": { 21 | "@angular/animations": "~7.2.0", 22 | "@angular/cdk": "~7.3.0", 23 | "@angular/common": "~7.2.0", 24 | "@angular/compiler": "~7.2.0", 25 | "@angular/core": "~7.2.0", 26 | "@angular/forms": "~7.2.0", 27 | "@angular/material": "~7.3.0", 28 | "@angular/platform-browser": "~7.2.0", 29 | "@angular/platform-browser-dynamic": "~7.2.0", 30 | "@angular/router": "~7.2.0", 31 | "@ngrx/effects": "^7.4.0", 32 | "@ngrx/entity": "^7.4.0", 33 | "@ngrx/router-store": "^7.4.0", 34 | "@ngrx/store": "^7.4.0", 35 | "@ngrx/store-devtools": "^7.4.0", 36 | "core-js": "^2.5.4", 37 | "rxjs": "~6.3.3", 38 | "tslib": "^1.9.0", 39 | "uuid": "^3.3.2", 40 | "zone.js": "~0.8.26" 41 | }, 42 | "devDependencies": { 43 | "@angular-devkit/build-angular": "~0.13.0", 44 | "@angular/cli": "~7.3.8", 45 | "@angular/compiler-cli": "~7.2.0", 46 | "@angular/language-service": "~7.2.0", 47 | "@types/jest": "^24.0.11", 48 | "@types/node": "~8.9.4", 49 | "@types/uuid": "^3.4.4", 50 | "concurrently": "^4.1.0", 51 | "jasmine-marbles": "^0.4.1", 52 | "jest": "^24.7.1", 53 | "jest-preset-angular": "^7.0.1", 54 | "json-server": "^0.14.2", 55 | "ts-node": "~7.0.0", 56 | "tslint": "~5.11.0", 57 | "typescript": "~3.2.2" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/app/books/components/books-page/books-page.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from "@angular/core"; 2 | 3 | import { Book } from "src/app/shared/models/book.model"; 4 | 5 | import { Observable } from "rxjs"; 6 | import { Store, select } from "@ngrx/store"; 7 | import * as fromRoot from "src/app/shared/state"; 8 | import { BooksPageActions } from "../../actions"; 9 | 10 | @Component({ 11 | selector: "app-books", 12 | templateUrl: "./books-page.component.html", 13 | styleUrls: ["./books-page.component.css"] 14 | }) 15 | export class BooksPageComponent implements OnInit { 16 | books$: Observable; 17 | activeBook$: Observable; 18 | total$: Observable; 19 | 20 | constructor(private store: Store) { 21 | this.books$ = this.store.pipe(select(fromRoot.selectAllBooks)); 22 | this.activeBook$ = this.store.pipe(select(fromRoot.selectActiveBook)); 23 | this.total$ = this.store.pipe(select(fromRoot.selectBookEarningsTotals)); 24 | } 25 | 26 | ngOnInit() { 27 | this.getBooks(); 28 | this.removeSelectedBook(); 29 | } 30 | 31 | getBooks() { 32 | this.store.dispatch(BooksPageActions.enter()); 33 | } 34 | 35 | onSelect(book: Book) { 36 | this.store.dispatch(BooksPageActions.selectBook({ bookId: book.id })); 37 | } 38 | 39 | onCancel() { 40 | this.removeSelectedBook(); 41 | } 42 | 43 | removeSelectedBook() { 44 | this.store.dispatch(BooksPageActions.clearSelectedBook()); 45 | } 46 | 47 | onSave(book: Book) { 48 | if (!book.id) { 49 | this.saveBook(book); 50 | } else { 51 | this.updateBook(book); 52 | } 53 | } 54 | 55 | saveBook(book: Book) { 56 | this.store.dispatch(BooksPageActions.createBook({ book })); 57 | } 58 | 59 | updateBook(book: Book) { 60 | this.store.dispatch(BooksPageActions.updateBook({ book, changes: book })); 61 | } 62 | 63 | onDelete(book: Book) { 64 | this.store.dispatch(BooksPageActions.deleteBook({ book })); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/app/books/books-api.effects.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@angular/core"; 2 | import { Effect, Actions, ofType } from "@ngrx/effects"; 3 | import { BooksPageActions, BooksApiActions } from "./actions"; 4 | import { BooksService } from "../shared/services/book.service"; 5 | import { 6 | mergeMap, 7 | map, 8 | catchError, 9 | exhaustMap, 10 | concatMap 11 | } from "rxjs/operators"; 12 | import { EMPTY } from "rxjs"; 13 | 14 | @Injectable() 15 | export class BooksApiEffects { 16 | @Effect() 17 | loadBooks$ = this.actions$.pipe( 18 | ofType(BooksPageActions.enter.type), 19 | exhaustMap(() => 20 | this.booksService.all().pipe( 21 | map(books => BooksApiActions.booksLoaded({ books })), 22 | catchError(() => EMPTY) 23 | ) 24 | ) 25 | ); 26 | 27 | @Effect() 28 | createBook$ = this.actions$.pipe( 29 | ofType(BooksPageActions.createBook.type), 30 | mergeMap(action => 31 | this.booksService.create(action.book).pipe( 32 | map(book => BooksApiActions.bookCreated({ book })), 33 | catchError(() => EMPTY) 34 | ) 35 | ) 36 | ); 37 | 38 | @Effect() 39 | updateBook$ = this.actions$.pipe( 40 | ofType(BooksPageActions.updateBook.type), 41 | concatMap(action => 42 | this.booksService.update(action.book.id, action.book).pipe( 43 | map(book => BooksApiActions.bookUpdated({ book })), 44 | catchError(() => EMPTY) 45 | ) 46 | ) 47 | ); 48 | 49 | @Effect() 50 | deleteBook$ = this.actions$.pipe( 51 | ofType(BooksPageActions.deleteBook.type), 52 | mergeMap(action => 53 | this.booksService.delete(action.book.id).pipe( 54 | map(() => BooksApiActions.bookDeleted({ book: action.book })), 55 | catchError(() => EMPTY) 56 | ) 57 | ) 58 | ); 59 | 60 | constructor( 61 | private booksService: BooksService, 62 | private actions$: Actions< 63 | BooksPageActions.BooksActions | BooksApiActions.BooksApiActions 64 | > 65 | ) {} 66 | } 67 | -------------------------------------------------------------------------------- /src/app/books/books-api.effects.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from "@angular/core/testing"; 2 | import { provideMockActions } from "@ngrx/effects/testing"; 3 | import { Action } from "@ngrx/store"; 4 | import { Observable } from "rxjs"; 5 | import { hot, cold } from "jasmine-marbles"; 6 | import { BooksService } from "../shared/services/book.service"; 7 | import { Book } from "../shared/models/book.model"; 8 | import { BooksPageActions, BooksApiActions } from "./actions"; 9 | import { BooksApiEffects } from "./books-api.effects"; 10 | 11 | describe("Book API Effects", () => { 12 | let effects: BooksApiEffects; 13 | let actions$: Observable; 14 | let mockBookService: { 15 | create: jest.Mock; 16 | update: jest.Mock; 17 | delete: jest.Mock; 18 | }; 19 | 20 | const mockBook: Book = { 21 | id: "test", 22 | name: "Mock Book", 23 | earnings: 25 24 | }; 25 | 26 | beforeEach(() => { 27 | TestBed.configureTestingModule({ 28 | providers: [ 29 | BooksApiEffects, 30 | provideMockActions(() => actions$), 31 | { 32 | provide: BooksService, 33 | useFactory() { 34 | mockBookService = { 35 | create: jest.fn(), 36 | update: jest.fn(), 37 | delete: jest.fn() 38 | }; 39 | 40 | return mockBookService; 41 | } 42 | } 43 | ] 44 | }); 45 | 46 | effects = TestBed.get(BooksApiEffects); 47 | }); 48 | 49 | it("should use the API to create a book", () => { 50 | const inputAction = BooksPageActions.createBook({ 51 | book: { 52 | name: mockBook.name, 53 | earnings: 25 54 | } 55 | }); 56 | const outputAction = BooksApiActions.bookCreated({ 57 | book: mockBook 58 | }); 59 | 60 | actions$ = hot("--a---", { a: inputAction }); 61 | const response$ = cold("--b|", { b: mockBook }); 62 | const expected$ = cold("----c--", { c: outputAction }); 63 | mockBookService.create.mockReturnValue(response$); 64 | 65 | expect(effects.createBook$).toBeObservable(expected$); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/app/movies/movie-api.effects.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from "@angular/core/testing"; 2 | import { provideMockActions } from "@ngrx/effects/testing"; 3 | import { Action } from "@ngrx/store"; 4 | import { Observable } from "rxjs"; 5 | import { hot, cold } from "jasmine-marbles"; 6 | import { MoviesService } from "../shared/services/movies.service"; 7 | import { Movie } from "../shared/models/movie.model"; 8 | import { MoviesPageActions, MovieApiActions } from "./actions"; 9 | import { MovieApiEffects } from "./movie-api.effects"; 10 | 11 | describe("Movie API Effects", () => { 12 | let effects: MovieApiEffects; 13 | let actions$: Observable; 14 | let mockMovieService: { 15 | create: jest.Mock; 16 | update: jest.Mock; 17 | delete: jest.Mock; 18 | }; 19 | 20 | const mockMovie: Movie = { 21 | id: "test", 22 | name: "Mock Movie", 23 | earnings: 25 24 | }; 25 | 26 | beforeEach(() => { 27 | TestBed.configureTestingModule({ 28 | providers: [ 29 | MovieApiEffects, 30 | provideMockActions(() => actions$), 31 | { 32 | provide: MoviesService, 33 | useFactory() { 34 | mockMovieService = { 35 | create: jest.fn(), 36 | update: jest.fn(), 37 | delete: jest.fn() 38 | }; 39 | 40 | return mockMovieService; 41 | } 42 | } 43 | ] 44 | }); 45 | 46 | effects = TestBed.get(MovieApiEffects); 47 | }); 48 | 49 | it("should use the API to create a movie", () => { 50 | const inputAction = MoviesPageActions.createMovie({ 51 | movie: { 52 | name: mockMovie.name, 53 | earnings: 25 54 | } 55 | }); 56 | const outputAction = MovieApiActions.createMovieSuccess({ 57 | movie: mockMovie 58 | }); 59 | 60 | actions$ = hot("--a---", { a: inputAction }); 61 | const response$ = cold("--b|", { b: mockMovie }); 62 | const expected$ = cold("----c--", { c: outputAction }); 63 | mockMovieService.create.mockReturnValue(response$); 64 | 65 | expect(effects.createMovie$).toBeObservable(expected$); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/app/movies/movie-api.effects.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@angular/core"; 2 | import { Effect, Actions, ofType } from "@ngrx/effects"; 3 | import { of } from "rxjs"; 4 | import { 5 | mergeMap, 6 | map, 7 | catchError, 8 | concatMap, 9 | exhaustMap 10 | } from "rxjs/operators"; 11 | import { MovieApiActions, MoviesPageActions } from "./actions"; 12 | import { MoviesService } from "../shared/services/movies.service"; 13 | 14 | @Injectable() 15 | export class MovieApiEffects { 16 | constructor( 17 | private actions$: Actions, 18 | private movieService: MoviesService 19 | ) {} 20 | 21 | @Effect() enterMoviesPage$ = this.actions$.pipe( 22 | ofType(MoviesPageActions.enter.type), 23 | exhaustMap(() => 24 | this.movieService.all().pipe( 25 | map(movies => MovieApiActions.loadMoviesSuccess({ movies })), 26 | catchError(() => of(MovieApiActions.loadMoviesFailure())) 27 | ) 28 | ) 29 | ); 30 | 31 | @Effect() createMovie$ = this.actions$.pipe( 32 | ofType(MoviesPageActions.createMovie.type), 33 | mergeMap(action => 34 | this.movieService.create(action.movie).pipe( 35 | map(movie => MovieApiActions.createMovieSuccess({ movie })), 36 | catchError(() => of(MovieApiActions.createMovieFailure())) 37 | ) 38 | ) 39 | ); 40 | 41 | @Effect() updateMovie$ = this.actions$.pipe( 42 | ofType(MoviesPageActions.updateMovie.type), 43 | concatMap(action => 44 | this.movieService.update(action.movie.id, action.changes).pipe( 45 | map(movie => MovieApiActions.updateMovieSuccess({ movie })), 46 | catchError(() => 47 | of(MovieApiActions.updateMovieFailure({ movie: action.movie })) 48 | ) 49 | ) 50 | ) 51 | ); 52 | 53 | @Effect() deleteMovie$ = this.actions$.pipe( 54 | ofType(MoviesPageActions.deleteMovie.type), 55 | mergeMap(action => 56 | this.movieService.delete(action.movie.id).pipe( 57 | map(() => 58 | MovieApiActions.deleteMovieSuccess({ movieId: action.movie.id }) 59 | ), 60 | catchError(() => 61 | of(MovieApiActions.deleteMovieFailure({ movie: action.movie })) 62 | ) 63 | ) 64 | ) 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /src/app/shared/state/__snapshots__/movie.reducer.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Movie Reducer should add newly created movies to the state 1`] = ` 4 | Object { 5 | "activeMovieId": "1", 6 | "entities": Object { 7 | "1": Object { 8 | "earnings": 100000, 9 | "id": "1", 10 | "name": "Arrival", 11 | }, 12 | }, 13 | "ids": Array [ 14 | "1", 15 | ], 16 | } 17 | `; 18 | 19 | exports[`Movie Reducer should apply changes to a movie when a movie is updated 1`] = ` 20 | Object { 21 | "activeMovieId": "1", 22 | "entities": Object { 23 | "1": Object { 24 | "earnings": 120000, 25 | "id": "1", 26 | "name": "Blade Runner", 27 | }, 28 | }, 29 | "ids": Array [ 30 | "1", 31 | ], 32 | } 33 | `; 34 | 35 | exports[`Movie Reducer should load all movies when the API loads them all successfully 1`] = ` 36 | Object { 37 | "activeMovieId": null, 38 | "entities": Object { 39 | "1": Object { 40 | "earnings": 0, 41 | "id": "1", 42 | "name": "Green Lantern", 43 | }, 44 | }, 45 | "ids": Array [ 46 | "1", 47 | ], 48 | } 49 | `; 50 | 51 | exports[`Movie Reducer should remove movies from the state when they are deleted 1`] = ` 52 | Object { 53 | "activeMovieId": "1", 54 | "entities": Object { 55 | "1": Object { 56 | "earnings": 1000, 57 | "id": "1", 58 | "name": "mother!", 59 | }, 60 | }, 61 | "ids": Array [ 62 | "1", 63 | ], 64 | } 65 | `; 66 | 67 | exports[`Movie Reducer should roll back a deletion if deleting a movie fails 1`] = ` 68 | Object { 69 | "activeMovieId": "1", 70 | "entities": Object { 71 | "1": Object { 72 | "earnings": 10000, 73 | "id": "1", 74 | "name": "Black Panther", 75 | }, 76 | }, 77 | "ids": Array [ 78 | "1", 79 | ], 80 | } 81 | `; 82 | 83 | exports[`Movie Reducer should rollback changes to a movie if there is an error when updating it with the API 1`] = ` 84 | Object { 85 | "activeMovieId": "1", 86 | "entities": Object { 87 | "1": Object { 88 | "earnings": 10000000000, 89 | "id": "1", 90 | "name": "Star Wars: A New Hope", 91 | }, 92 | }, 93 | "ids": Array [ 94 | "1", 95 | ], 96 | } 97 | `; 98 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | @import "https://fonts.googleapis.com/icon?family=Material+Icons"; 3 | @import "~@angular/material/prebuilt-themes/deeppurple-amber.css"; 4 | 5 | html { 6 | height: 100%; 7 | } 8 | 9 | body { 10 | margin: 0; 11 | font-family: Roboto, sans-serif; 12 | height: 100%; 13 | display: flex; 14 | } 15 | 16 | mat-toolbar-row { 17 | justify-content: space-between; 18 | } 19 | 20 | p { 21 | margin: 16px; 22 | } 23 | 24 | [mat-raised-button] { 25 | width: 100%; 26 | } 27 | 28 | mat-grid-list { 29 | max-width: 1403px; 30 | margin: 16px; 31 | } 32 | 33 | mat-sidenav-layout { 34 | height: 100vh; 35 | } 36 | 37 | mat-sidenav { 38 | width: 320px; 39 | } 40 | 41 | mat-sidenav a { 42 | box-sizing: border-box; 43 | display: block; 44 | font-size: 14px; 45 | font-weight: 400; 46 | line-height: 47px; 47 | text-decoration: none; 48 | -webkit-transition: all 0.3s; 49 | transition: all 0.3s; 50 | padding: 0 16px; 51 | position: relative; 52 | } 53 | 54 | .icon-20 { 55 | font-size: 20px; 56 | } 57 | 58 | * { 59 | -webkit-font-smoothing: antialiased; 60 | -moz-osx-font-smoothing: grayscale; 61 | } 62 | 63 | table { 64 | border-collapse: collapse; 65 | border-radius: 2px; 66 | border-spacing: 0; 67 | margin: 0 0 32px; 68 | width: 100%; 69 | box-shadow: 0 2px 2px rgba(0, 0, 0, 0.24), 0 0 2px rgba(0, 0, 0, 0.12); 70 | } 71 | 72 | th { 73 | font-size: 16px; 74 | font-weight: 400; 75 | padding: 13px 32px; 76 | text-align: left; 77 | color: rgba(0, 0, 0, 0.54); 78 | background: rgba(0, 0, 0, 0.03); 79 | } 80 | 81 | td { 82 | color: rgba(0, 0, 0, 0.54); 83 | border: 1px solid rgba(0, 0, 0, 0.03); 84 | font-weight: 400; 85 | padding: 8px 30px; 86 | } 87 | 88 | .container { 89 | display: flex; 90 | margin: 10px; 91 | flex-wrap: wrap; 92 | } 93 | 94 | .container [class*="col"] { 95 | padding: 10px; 96 | flex: 1; 97 | } 98 | 99 | mat-card-header .mat-card-header-text { 100 | margin-left: 0; 101 | border-bottom: 1px solid #ffd740; 102 | } 103 | 104 | mat-card-title h1 { 105 | display: inline; 106 | } 107 | 108 | mat-card { 109 | margin-bottom: 20px !important; 110 | } 111 | -------------------------------------------------------------------------------- /src/app/shared/state/movie.reducer.ts: -------------------------------------------------------------------------------- 1 | import { createEntityAdapter, EntityState } from "@ngrx/entity"; 2 | import { Movie } from "../models/movie.model"; 3 | import { MovieApiActions, MoviesPageActions } from "src/app/movies/actions"; 4 | import { createSelector } from "@ngrx/store"; 5 | 6 | const adapter = createEntityAdapter({ 7 | selectId: (movie: Movie) => movie.id, 8 | sortComparer: (a: Movie, b: Movie) => a.name.localeCompare(b.name) 9 | }); 10 | 11 | export interface State extends EntityState { 12 | activeMovieId: string | null; 13 | } 14 | 15 | export const initialState: State = adapter.getInitialState({ 16 | activeMovieId: null 17 | }); 18 | 19 | export function reducer( 20 | state: State = initialState, 21 | action: MovieApiActions.Union | MoviesPageActions.Union 22 | ): State { 23 | switch (action.type) { 24 | case MoviesPageActions.enter.type: { 25 | return { ...state, activeMovieId: null }; 26 | } 27 | 28 | case MoviesPageActions.selectMovie.type: { 29 | return { ...state, activeMovieId: action.movieId }; 30 | } 31 | 32 | case MoviesPageActions.clearSelectedMovie.type: { 33 | return { ...state, activeMovieId: null }; 34 | } 35 | 36 | case MovieApiActions.loadMoviesSuccess.type: { 37 | return adapter.addAll(action.movies, state); 38 | } 39 | 40 | case MovieApiActions.createMovieSuccess.type: { 41 | return adapter.addOne(action.movie, { 42 | ...state, 43 | activeMovieId: action.movie.id 44 | }); 45 | } 46 | 47 | case MovieApiActions.updateMovieSuccess.type: { 48 | return adapter.updateOne( 49 | { id: action.movie.id, changes: action.movie }, 50 | { ...state, activeMovieId: action.movie.id } 51 | ); 52 | } 53 | 54 | case MovieApiActions.deleteMovieSuccess.type: { 55 | return adapter.removeOne(action.movieId, { 56 | ...state, 57 | activeMovieId: null 58 | }); 59 | } 60 | 61 | default: { 62 | return state; 63 | } 64 | } 65 | } 66 | 67 | export const { selectEntities, selectAll } = adapter.getSelectors(); 68 | export const selectActiveMovieId = (state: State) => state.activeMovieId; 69 | export const selectActiveMovie = createSelector( 70 | selectEntities, 71 | selectActiveMovieId, 72 | (entities, activeMovieId) => entities[activeMovieId] 73 | ); 74 | export const selectEarningsTotal = createSelector( 75 | selectAll, 76 | movies => 77 | movies.reduce( 78 | (total, movie) => total + parseInt(`${movie.earnings}`, 10) || 0, 79 | 0 80 | ) 81 | ); 82 | -------------------------------------------------------------------------------- /src/app/shared/state/books.reducer.ts: -------------------------------------------------------------------------------- 1 | import { createEntityAdapter, EntityAdapter, EntityState } from "@ngrx/entity"; 2 | import { Book } from "src/app/shared/models/book.model"; 3 | import { BooksPageActions, BooksApiActions } from "src/app/books/actions"; 4 | import { createSelector } from "@ngrx/store"; 5 | 6 | export const initialBooks: Book[] = [ 7 | { 8 | id: "1", 9 | name: "Fellowship of the Ring", 10 | earnings: 100000000, 11 | description: "The start" 12 | }, 13 | { 14 | id: "2", 15 | name: "The Two Towers", 16 | earnings: 200000000, 17 | description: "The middle" 18 | }, 19 | { 20 | id: "3", 21 | name: "The Return of The King", 22 | earnings: 400000000, 23 | description: "The end" 24 | } 25 | ]; 26 | 27 | export interface State extends EntityState { 28 | activeBookId: string | null; 29 | } 30 | 31 | export const adapter = createEntityAdapter(); 32 | 33 | export const initialState = adapter.getInitialState({ 34 | activeBookId: null 35 | }); 36 | 37 | export function reducer( 38 | state = initialState, 39 | action: BooksPageActions.BooksActions | BooksApiActions.BooksApiActions 40 | ): State { 41 | switch (action.type) { 42 | case BooksApiActions.booksLoaded.type: 43 | return adapter.addAll(action.books, state); 44 | 45 | case BooksPageActions.selectBook.type: 46 | return { 47 | ...state, 48 | activeBookId: action.bookId 49 | }; 50 | 51 | case BooksPageActions.clearSelectedBook.type: 52 | return { 53 | ...state, 54 | activeBookId: null 55 | }; 56 | 57 | case BooksApiActions.bookCreated.type: 58 | return adapter.addOne(action.book, { 59 | ...state, 60 | activeBookId: action.book.id 61 | }); 62 | 63 | case BooksApiActions.bookUpdated.type: 64 | return adapter.updateOne( 65 | { id: action.book.id, changes: action.book }, 66 | { ...state, activeBookId: action.book.id } 67 | ); 68 | 69 | case BooksApiActions.bookDeleted.type: 70 | return adapter.removeOne(action.book.id, { 71 | ...state, 72 | activeBookId: null 73 | }); 74 | 75 | default: 76 | return state; 77 | } 78 | } 79 | 80 | export const { selectAll, selectEntities } = adapter.getSelectors(); 81 | export const selectActiveBookId = (state: State) => state.activeBookId; 82 | export const selectActiveBook = createSelector( 83 | selectEntities, 84 | selectActiveBookId, 85 | (entities, bookId) => (bookId ? entities[bookId] : null) 86 | ); 87 | export const selectEarningsTotals = createSelector( 88 | selectAll, 89 | books => 90 | books.reduce((total, book) => { 91 | return total + parseInt(`${book.earnings}`, 10) || 0; 92 | }, 0) 93 | ); 94 | -------------------------------------------------------------------------------- /src/app/shared/state/books.reducer.spec.ts: -------------------------------------------------------------------------------- 1 | import { BooksApiActions, BooksPageActions } from "src/app/books/actions"; 2 | import { Book } from "../models/book.model"; 3 | import { 4 | reducer, 5 | initialState, 6 | selectActiveBook, 7 | adapter, 8 | selectAll 9 | } from "./books.reducer"; 10 | 11 | describe("Books Reducer", () => { 12 | it("should return the initial state when initialized", () => { 13 | const state = reducer(undefined, { type: "@@init" } as any); 14 | 15 | expect(state).toBe(initialState); 16 | }); 17 | 18 | it("should load all books when the API loads them all successfully", () => { 19 | const books: Book[] = [{ id: "1", name: "Castaway", earnings: 1000000 }]; 20 | const action = BooksApiActions.booksLoaded({ books }); 21 | 22 | const state = reducer(initialState, action); 23 | 24 | expect(state).toMatchSnapshot(); 25 | }); 26 | 27 | it("should add a newly created book to the state", () => { 28 | const book: Book = { id: "1", name: "Forrest Gump", earnings: 200000000 }; 29 | const action = BooksApiActions.bookCreated({ book }); 30 | 31 | const state = reducer(initialState, action); 32 | 33 | expect(state).toMatchSnapshot(); 34 | }); 35 | 36 | it("should remove books from the state when they are deleted", () => { 37 | const book: Book = { id: "1", name: "Apollo 13", earnings: 1000 }; 38 | const firstAction = BooksApiActions.bookCreated({ book }); 39 | const secondAction = BooksApiActions.bookDeleted({ book }); 40 | 41 | const state = [firstAction, secondAction].reduce(reducer, initialState); 42 | 43 | expect(state).toMatchSnapshot(); 44 | }); 45 | 46 | describe("Selectors", () => { 47 | const initialState = { activeBookId: null, ids: [], entities: {} }; 48 | 49 | describe("selectActiveBook", () => { 50 | it("should return null if there is no active book", () => { 51 | const result = selectActiveBook(initialState); 52 | 53 | expect(result).toBe(null); 54 | }); 55 | 56 | it("should return the active book if there is one", () => { 57 | const book: Book = { id: "1", name: "Castaway", earnings: 1000000 }; 58 | const state = adapter.addAll([book], { 59 | ...initialState, 60 | activeBookId: "1" 61 | }); 62 | const result = selectActiveBook(state); 63 | 64 | expect(result).toBe(book); 65 | }); 66 | }); 67 | 68 | describe("selectAll", () => { 69 | it("should return all the loaded books", () => { 70 | const books: Book[] = [ 71 | { id: "1", name: "Castaway", earnings: 1000000 } 72 | ]; 73 | const state = adapter.addAll(books, initialState); 74 | const result = selectAll(state); 75 | 76 | expect(result.length).toBe(1); 77 | }); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */ 22 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 23 | 24 | /** 25 | * Web Animations `@angular/platform-browser/animations` 26 | * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. 27 | * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). 28 | */ 29 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 30 | 31 | /** 32 | * By default, zone.js will patch all possible macroTask and DomEvents 33 | * user can disable parts of macroTask/DomEvents patch by setting following flags 34 | * because those flags need to be set before `zone.js` being loaded, and webpack 35 | * will put import in the top of bundle, so user need to create a separate file 36 | * in this directory (for example: zone-flags.ts), and put the following flags 37 | * into that file, and then add the following code before importing zone.js. 38 | * import './zone-flags.ts'; 39 | * 40 | * The flags allowed in zone-flags.ts are listed here. 41 | * 42 | * The following flags will work for all browsers. 43 | * 44 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 45 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 46 | * (window as any).__zone_symbol__BLACK_LISTED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 47 | * 48 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 49 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 50 | * 51 | * (window as any).__Zone_enable_cross_context_check = true; 52 | * 53 | */ 54 | 55 | /*************************************************************************************************** 56 | * Zone JS is required by default for Angular itself. 57 | */ 58 | import "zone.js/dist/zone"; // Included with Angular CLI. 59 | 60 | /*************************************************************************************************** 61 | * APPLICATION IMPORTS 62 | */ 63 | -------------------------------------------------------------------------------- /src/app/shared/state/movie.reducer.spec.ts: -------------------------------------------------------------------------------- 1 | import { MovieApiActions, MoviesPageActions } from "src/app/movies/actions"; 2 | import { Movie } from "../models/movie.model"; 3 | import { reducer, initialState } from "./movie.reducer"; 4 | 5 | describe("Movie Reducer", () => { 6 | it("should return the initial state when initialized", () => { 7 | const state = reducer(undefined, { type: "@@init" } as any); 8 | 9 | expect(state).toBe(initialState); 10 | }); 11 | 12 | it("should load all movies when the API loads them all successfully", () => { 13 | const movies: Movie[] = [{ id: "1", name: "Green Lantern", earnings: 0 }]; 14 | const action = MovieApiActions.loadMoviesSuccess({ movies }); 15 | 16 | const state = reducer(initialState, action); 17 | 18 | expect(state).toMatchSnapshot(); 19 | }); 20 | 21 | it("should add newly created movies to the state", () => { 22 | const movie: Movie = { id: "1", name: "Arrival", earnings: 100000 }; 23 | const action = MovieApiActions.createMovieSuccess({ movie }); 24 | 25 | const state = reducer(initialState, action); 26 | 27 | expect(state).toMatchSnapshot(); 28 | }); 29 | 30 | it("should remove movies from the state when they are deleted", () => { 31 | const movie: Movie = { id: "1", name: "mother!", earnings: 1000 }; 32 | const firstAction = MovieApiActions.createMovieSuccess({ movie }); 33 | const secondAction = MoviesPageActions.deleteMovie({ movie }); 34 | 35 | const state = [firstAction, secondAction].reduce(reducer, initialState); 36 | 37 | expect(state).toMatchSnapshot(); 38 | }); 39 | 40 | it("should roll back a deletion if deleting a movie fails", () => { 41 | const movie: Movie = { id: "1", name: "Black Panther", earnings: 10000 }; 42 | const firstAction = MovieApiActions.createMovieSuccess({ movie }); 43 | const secondAction = MoviesPageActions.deleteMovie({ movie }); 44 | const thirdAction = MovieApiActions.deleteMovieFailure({ movie }); 45 | 46 | const state = [firstAction, secondAction, thirdAction].reduce( 47 | reducer, 48 | initialState 49 | ); 50 | 51 | expect(state).toMatchSnapshot(); 52 | }); 53 | 54 | it("should apply changes to a movie when a movie is updated", () => { 55 | const movie: Movie = { id: "1", name: "Blade Runner", earnings: 120000 }; 56 | const changes = { name: "Blade Runner (Final Cut)", earnings: 150000 }; 57 | const firstAction = MovieApiActions.createMovieSuccess({ movie }); 58 | const secondAction = MoviesPageActions.updateMovie({ movie, changes }); 59 | 60 | const state = [firstAction, secondAction].reduce(reducer, initialState); 61 | 62 | expect(state).toMatchSnapshot(); 63 | }); 64 | 65 | it("should rollback changes to a movie if there is an error when updating it with the API", () => { 66 | const movie: Movie = { 67 | id: "1", 68 | name: "Star Wars: A New Hope", 69 | earnings: 10000000000 70 | }; 71 | const changes = { 72 | name: "Star Wars: A New Hope (Special Edition)", 73 | earnings: 12000000000 74 | }; 75 | const firstAction = MovieApiActions.createMovieSuccess({ movie }); 76 | const secondAction = MoviesPageActions.updateMovie({ movie, changes }); 77 | const thirdAction = MovieApiActions.updateMovieFailure({ movie }); 78 | 79 | const state = [firstAction, secondAction, thirdAction].reduce( 80 | reducer, 81 | initialState 82 | ); 83 | 84 | expect(state).toMatchSnapshot(); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /src/app/movies/components/movies-page/movies-page.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { By } from "@angular/platform-browser"; 2 | import { NoopAnimationsModule } from "@angular/platform-browser/animations"; 3 | import { TestBed, ComponentFixture } from "@angular/core/testing"; 4 | import { provideMockStore, MockStore } from "@ngrx/store/testing"; 5 | import { ReactiveFormsModule } from "@angular/forms"; 6 | import { Store } from "@ngrx/store"; 7 | 8 | import { MaterialModule } from "src/app/material.module"; 9 | 10 | import { MoviesPageActions } from "../../actions"; 11 | import { Movie } from "src/app/shared/models/movie.model"; 12 | 13 | import { MoviesPageComponent } from "./movies-page.component"; 14 | import { MoviesListComponent } from "../movies-list/movies-list.component"; 15 | import { MovieDetailComponent } from "../movie-detail/movie-detail.component"; 16 | import { MoviesTotalComponent } from "../movies-total/movies-total.component"; 17 | 18 | describe("Component: Movies Page", () => { 19 | let comp: MoviesPageComponent; 20 | let fixture: ComponentFixture; 21 | let store: MockStore<{ movies: Movie[] }>; 22 | 23 | beforeEach(async () => { 24 | TestBed.configureTestingModule({ 25 | imports: [MaterialModule, NoopAnimationsModule, ReactiveFormsModule], 26 | declarations: [ 27 | MoviesPageComponent, 28 | MoviesListComponent, 29 | MovieDetailComponent, 30 | MoviesTotalComponent 31 | ], 32 | providers: [provideMockStore()] 33 | }); 34 | 35 | fixture = TestBed.createComponent(MoviesPageComponent); 36 | comp = fixture.componentInstance; 37 | store = TestBed.get(Store); 38 | 39 | spyOn(store, "dispatch").and.callThrough(); 40 | }); 41 | 42 | it("should create an instance", () => { 43 | expect(comp).toBeTruthy(); 44 | }); 45 | 46 | it("should display an Enter action on init", () => { 47 | const action = MoviesPageActions.enter(); 48 | 49 | comp.ngOnInit(); 50 | 51 | expect(store.dispatch).toHaveBeenCalledWith(action); 52 | }); 53 | 54 | it("should dispatch an select action on a select event from the movie list", () => { 55 | const movie: Movie = { id: "1", name: "Movie", earnings: 25 }; 56 | const action = MoviesPageActions.selectMovie({ movieId: movie.id }); 57 | 58 | fixture.debugElement 59 | .query(By.css("app-movies-list")) 60 | .triggerEventHandler("select", movie); 61 | 62 | expect(store.dispatch).toHaveBeenCalledWith(action); 63 | }); 64 | 65 | it("should dispatch an delete action on a delete event from the movie list", () => { 66 | const movie: Movie = { id: "1", name: "Movie", earnings: 25 }; 67 | const action = MoviesPageActions.deleteMovie({ movie }); 68 | 69 | fixture.debugElement 70 | .query(By.css("app-movies-list")) 71 | .triggerEventHandler("delete", movie); 72 | 73 | expect(store.dispatch).toHaveBeenCalledWith(action); 74 | }); 75 | 76 | it("should dispatch an save action on a save event from the movie details", () => { 77 | const movie: Movie = { id: undefined, name: "Movie", earnings: 25 }; 78 | const action = MoviesPageActions.createMovie({ movie }); 79 | 80 | fixture.debugElement 81 | .query(By.css("app-movie-detail")) 82 | .triggerEventHandler("save", movie); 83 | 84 | expect(store.dispatch).toHaveBeenCalledWith(action); 85 | }); 86 | 87 | it("should dispatch an update action on a delete event from the movie details", () => { 88 | const movie: Movie = { id: "1", name: "Movie", earnings: 25 }; 89 | const action = MoviesPageActions.updateMovie({ movie, changes: movie }); 90 | 91 | fixture.debugElement 92 | .query(By.css("app-movie-detail")) 93 | .triggerEventHandler("save", movie); 94 | 95 | expect(store.dispatch).toHaveBeenCalledWith(action); 96 | }); 97 | 98 | it("should dispatch an clear action on a cancel event from the movie details", () => { 99 | const action = MoviesPageActions.clearSelectedMovie(); 100 | 101 | fixture.debugElement 102 | .query(By.css("app-movie-detail")) 103 | .triggerEventHandler("cancel", null); 104 | 105 | expect(store.dispatch).toHaveBeenCalledWith(action); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "ngrx-workshop-example": { 7 | "root": "", 8 | "sourceRoot": "src", 9 | "projectType": "application", 10 | "prefix": "ngrx", 11 | "schematics": { 12 | "@schematics/angular:class": { 13 | "skipTests": true 14 | }, 15 | "@schematics/angular:component": { 16 | "skipTests": true 17 | }, 18 | "@schematics/angular:directive": { 19 | "skipTests": true 20 | }, 21 | "@schematics/angular:guard": { 22 | "skipTests": true 23 | }, 24 | "@schematics/angular:module": { 25 | "skipTests": true 26 | }, 27 | "@schematics/angular:pipe": { 28 | "skipTests": true 29 | }, 30 | "@schematics/angular:service": { 31 | "skipTests": true 32 | } 33 | }, 34 | "architect": { 35 | "build": { 36 | "builder": "@angular-devkit/build-angular:browser", 37 | "options": { 38 | "outputPath": "dist/ngrx-workshop-example", 39 | "index": "src/index.html", 40 | "main": "src/main.ts", 41 | "polyfills": "src/polyfills.ts", 42 | "tsConfig": "src/tsconfig.app.json", 43 | "assets": [ 44 | "src/favicon.ico", 45 | "src/assets" 46 | ], 47 | "styles": [ 48 | "src/styles.css" 49 | ], 50 | "scripts": [], 51 | "es5BrowserSupport": true 52 | }, 53 | "configurations": { 54 | "production": { 55 | "fileReplacements": [ 56 | { 57 | "replace": "src/environments/environment.ts", 58 | "with": "src/environments/environment.prod.ts" 59 | } 60 | ], 61 | "optimization": true, 62 | "outputHashing": "all", 63 | "sourceMap": false, 64 | "extractCss": true, 65 | "namedChunks": false, 66 | "aot": true, 67 | "extractLicenses": true, 68 | "vendorChunk": false, 69 | "buildOptimizer": true, 70 | "budgets": [ 71 | { 72 | "type": "initial", 73 | "maximumWarning": "2mb", 74 | "maximumError": "5mb" 75 | } 76 | ] 77 | } 78 | } 79 | }, 80 | "serve": { 81 | "builder": "@angular-devkit/build-angular:dev-server", 82 | "options": { 83 | "browserTarget": "ngrx-workshop-example:build" 84 | }, 85 | "configurations": { 86 | "production": { 87 | "browserTarget": "ngrx-workshop-example:build:production" 88 | } 89 | } 90 | }, 91 | "extract-i18n": { 92 | "builder": "@angular-devkit/build-angular:extract-i18n", 93 | "options": { 94 | "browserTarget": "ngrx-workshop-example:build" 95 | } 96 | }, 97 | "test": { 98 | "builder": "@angular-devkit/build-angular:karma", 99 | "options": { 100 | "main": "src/test.ts", 101 | "polyfills": "src/polyfills.ts", 102 | "tsConfig": "src/tsconfig.spec.json", 103 | "karmaConfig": "src/karma.conf.js", 104 | "styles": [ 105 | "src/styles.css" 106 | ], 107 | "scripts": [], 108 | "assets": [ 109 | "src/favicon.ico", 110 | "src/assets" 111 | ] 112 | } 113 | }, 114 | "lint": { 115 | "builder": "@angular-devkit/build-angular:tslint", 116 | "options": { 117 | "tsConfig": [ 118 | "src/tsconfig.app.json", 119 | "src/tsconfig.spec.json" 120 | ], 121 | "exclude": [ 122 | "**/node_modules/**" 123 | ] 124 | } 125 | } 126 | } 127 | } 128 | }, 129 | "defaultProject": "ngrx-workshop-example" 130 | } --------------------------------------------------------------------------------