├── .editorconfig ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json └── tasks.json ├── README.md ├── angular.json ├── package-lock.json ├── package.json ├── src ├── app │ ├── app-routing.module.ts │ ├── app.module.ts │ ├── auth │ │ ├── actions │ │ │ ├── auth-api.actions.ts │ │ │ ├── auth.actions.ts │ │ │ ├── index.ts │ │ │ └── login-page.actions.ts │ │ ├── auth-routing.module.ts │ │ ├── auth.module.ts │ │ ├── components │ │ │ ├── __snapshots__ │ │ │ │ ├── login-form.component.spec.ts.snap │ │ │ │ └── logout-confirmation-dialog.component.spec.ts.snap │ │ │ ├── index.ts │ │ │ ├── login-form.component.spec.ts │ │ │ ├── login-form.component.ts │ │ │ ├── logout-confirmation-dialog.component.spec.ts │ │ │ └── logout-confirmation-dialog.component.ts │ │ ├── containers │ │ │ ├── __snapshots__ │ │ │ │ └── login-page.component.spec.ts.snap │ │ │ ├── index.ts │ │ │ ├── login-page.component.spec.ts │ │ │ ├── login-page.component.ts │ │ │ └── login-page.store.ts │ │ ├── effects │ │ │ ├── auth.effects.spec.ts │ │ │ ├── auth.effects.ts │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── models │ │ │ ├── index.ts │ │ │ └── user.ts │ │ ├── reducers │ │ │ ├── __snapshots__ │ │ │ │ ├── auth.reducer.spec.ts.snap │ │ │ │ └── login-page.reducer.spec.ts.snap │ │ │ ├── auth.reducer.spec.ts │ │ │ ├── auth.reducer.ts │ │ │ ├── index.ts │ │ │ ├── login-page.reducer.spec.ts │ │ │ └── login-page.reducer.ts │ │ └── services │ │ │ ├── auth-guard.service.spec.ts │ │ │ ├── auth-guard.service.ts │ │ │ ├── auth.service.ts │ │ │ └── index.ts │ ├── books │ │ ├── actions │ │ │ ├── book.actions.ts │ │ │ ├── books-api.actions.ts │ │ │ ├── collection-api.actions.ts │ │ │ ├── collection-page.actions.ts │ │ │ ├── find-book-page.actions.ts │ │ │ ├── index.ts │ │ │ ├── selected-book-page.actions.ts │ │ │ └── view-book-page.actions.ts │ │ ├── books-routing.module.ts │ │ ├── books.module.ts │ │ ├── components │ │ │ ├── book-authors.component.ts │ │ │ ├── book-detail.component.ts │ │ │ ├── book-preview-list.component.ts │ │ │ ├── book-preview.component.ts │ │ │ ├── book-search.component.ts │ │ │ └── index.ts │ │ ├── containers │ │ │ ├── __snapshots__ │ │ │ │ ├── collection-page.component.spec.ts.snap │ │ │ │ ├── find-book-page.component.spec.ts.snap │ │ │ │ ├── selected-book-page.component.spec.ts.snap │ │ │ │ └── view-book-page.component.spec.ts.snap │ │ │ ├── collection-page.component.spec.ts │ │ │ ├── collection-page.component.ts │ │ │ ├── find-book-page.component.spec.ts │ │ │ ├── find-book-page.component.ts │ │ │ ├── index.ts │ │ │ ├── selected-book-page.component.spec.ts │ │ │ ├── selected-book-page.component.ts │ │ │ ├── view-book-page.component.spec.ts │ │ │ └── view-book-page.component.ts │ │ ├── effects │ │ │ ├── book.effects.spec.ts │ │ │ ├── book.effects.ts │ │ │ ├── collection.effects.spec.ts │ │ │ ├── collection.effects.ts │ │ │ └── index.ts │ │ ├── guards │ │ │ ├── book-exists.guard.ts │ │ │ └── index.ts │ │ ├── models │ │ │ ├── book.ts │ │ │ └── index.ts │ │ └── reducers │ │ │ ├── __snapshots__ │ │ │ └── books.reducer.spec.ts.snap │ │ │ ├── books.reducer.spec.ts │ │ │ ├── books.reducer.ts │ │ │ ├── collection.reducer.ts │ │ │ ├── index.ts │ │ │ └── search.reducer.ts │ ├── core │ │ ├── actions │ │ │ ├── index.ts │ │ │ ├── layout.actions.ts │ │ │ └── user.actions.ts │ │ ├── components │ │ │ ├── index.ts │ │ │ ├── layout.component.ts │ │ │ ├── nav-item.component.ts │ │ │ ├── sidenav.component.ts │ │ │ └── toolbar.component.ts │ │ ├── containers │ │ │ ├── app.component.ts │ │ │ ├── index.ts │ │ │ └── not-found-page.component.ts │ │ ├── core.module.ts │ │ ├── effects │ │ │ ├── index.ts │ │ │ ├── router.effects.spec.ts │ │ │ ├── router.effects.ts │ │ │ ├── user.effects.spec.ts │ │ │ └── user.effects.ts │ │ ├── index.ts │ │ ├── reducers │ │ │ └── layout.reducer.ts │ │ └── services │ │ │ ├── book-storage.service.spec.ts │ │ │ ├── book-storage.service.ts │ │ │ ├── google-books.service.spec.ts │ │ │ ├── google-books.service.ts │ │ │ └── index.ts │ ├── material │ │ ├── index.ts │ │ └── material.module.ts │ ├── reducers │ │ └── index.ts │ └── shared │ │ └── pipes │ │ ├── add-commas.pipe.spec.ts │ │ ├── add-commas.pipe.ts │ │ ├── ellipsis.pipe.spec.ts │ │ ├── ellipsis.pipe.ts │ │ └── index.ts ├── assets │ ├── .gitkeep │ └── .npmignore ├── favicon.ico ├── index.html ├── main.ts ├── polyfills.ts ├── styles.css └── test-setup.ts ├── tsconfig.app.json ├── tsconfig.json └── tsconfig.spec.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://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 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.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 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | 40 | # System files 41 | .DS_Store 42 | Thumbs.db 43 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 3 | "recommendations": ["angular.ng-template"] 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "name": "ng serve", 7 | "type": "chrome", 8 | "request": "launch", 9 | "preLaunchTask": "npm: start", 10 | "url": "http://localhost:4200/" 11 | }, 12 | { 13 | "name": "ng test", 14 | "type": "chrome", 15 | "request": "launch", 16 | "preLaunchTask": "npm: test", 17 | "url": "http://localhost:9876/debug.html" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 3 | "version": "2.0.0", 4 | "tasks": [ 5 | { 6 | "type": "npm", 7 | "script": "start", 8 | "isBackground": true, 9 | "problemMatcher": { 10 | "owner": "typescript", 11 | "pattern": "$tsc", 12 | "background": { 13 | "activeOnStart": true, 14 | "beginsPattern": { 15 | "regexp": "(.*?)" 16 | }, 17 | "endsPattern": { 18 | "regexp": "bundle generation complete" 19 | } 20 | } 21 | } 22 | }, 23 | { 24 | "type": "npm", 25 | "script": "test", 26 | "isBackground": true, 27 | "problemMatcher": { 28 | "owner": "typescript", 29 | "pattern": "$tsc", 30 | "background": { 31 | "activeOnStart": true, 32 | "beginsPattern": { 33 | "regexp": "(.*?)" 34 | }, 35 | "endsPattern": { 36 | "regexp": "bundle generation complete" 37 | } 38 | } 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NgRx 🤝 Signals 2 | 3 | YouTube Video: https://youtube.com/live/MsbPkJYrv68 4 | 5 | ## Setup 6 | 7 | ```sh 8 | npm install 9 | ``` 10 | 11 | ## Development 12 | 13 | ```sh 14 | npm start 15 | ``` 16 | 17 | ## Extras 18 | 19 | Check out [Analog](https://analogjs.org), the fullstack meta-framework for Angular. 20 | 21 | Sponsor me on [GitHub](https://github.com/sponsors/brandonroberts) 22 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "ngrx-signals": { 7 | "projectType": "application", 8 | "schematics": {}, 9 | "root": "", 10 | "sourceRoot": "src", 11 | "prefix": "app", 12 | "architect": { 13 | "build": { 14 | "builder": "@angular-devkit/build-angular:browser", 15 | "options": { 16 | "outputPath": "dist/ngrx-signals", 17 | "index": "src/index.html", 18 | "main": "src/main.ts", 19 | "polyfills": [ 20 | "zone.js" 21 | ], 22 | "tsConfig": "tsconfig.app.json", 23 | "assets": [ 24 | "src/favicon.ico", 25 | "src/assets" 26 | ], 27 | "styles": [ 28 | "src/styles.css" 29 | ], 30 | "scripts": [] 31 | }, 32 | "configurations": { 33 | "production": { 34 | "budgets": [ 35 | { 36 | "type": "initial", 37 | "maximumWarning": "500kb", 38 | "maximumError": "1mb" 39 | }, 40 | { 41 | "type": "anyComponentStyle", 42 | "maximumWarning": "2kb", 43 | "maximumError": "4kb" 44 | } 45 | ], 46 | "outputHashing": "all" 47 | }, 48 | "development": { 49 | "buildOptimizer": false, 50 | "optimization": false, 51 | "vendorChunk": true, 52 | "extractLicenses": false, 53 | "sourceMap": true, 54 | "namedChunks": true 55 | } 56 | }, 57 | "defaultConfiguration": "production" 58 | }, 59 | "serve": { 60 | "builder": "@angular-devkit/build-angular:dev-server", 61 | "configurations": { 62 | "production": { 63 | "browserTarget": "ngrx-signals:build:production" 64 | }, 65 | "development": { 66 | "browserTarget": "ngrx-signals:build:development" 67 | } 68 | }, 69 | "defaultConfiguration": "development" 70 | }, 71 | "extract-i18n": { 72 | "builder": "@angular-devkit/build-angular:extract-i18n", 73 | "options": { 74 | "browserTarget": "ngrx-signals:build" 75 | } 76 | }, 77 | "test": { 78 | "builder": "@angular-devkit/build-angular:karma", 79 | "options": { 80 | "polyfills": [ 81 | "zone.js", 82 | "zone.js/testing" 83 | ], 84 | "tsConfig": "tsconfig.spec.json", 85 | "assets": [ 86 | "src/favicon.ico", 87 | "src/assets" 88 | ], 89 | "styles": [ 90 | "src/styles.css" 91 | ], 92 | "scripts": [] 93 | } 94 | } 95 | } 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ngrx-signals", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "watch": "ng build --watch --configuration development", 9 | "test": "ng test" 10 | }, 11 | "private": true, 12 | "dependencies": { 13 | "@angular/animations": "^16.0.0-next.0", 14 | "@angular/cdk": "^16.0.0-next.0", 15 | "@angular/common": "^16.0.0-next.0", 16 | "@angular/compiler": "^16.0.0-next.0", 17 | "@angular/core": "^16.0.0-next.0", 18 | "@angular/forms": "^16.0.0-next.0", 19 | "@angular/material": "^16.0.0-next.0", 20 | "@angular/platform-browser": "^16.0.0-next.0", 21 | "@angular/platform-browser-dynamic": "^16.0.0-next.0", 22 | "@angular/router": "^16.0.0-next.0", 23 | "@ngrx/component-store": "^16.0.0-beta.0", 24 | "@ngrx/effects": "16.0.0-beta.0", 25 | "@ngrx/entity": "16.0.0-beta.0", 26 | "@ngrx/router-store": "16.0.0-beta.0", 27 | "@ngrx/store": "16.0.0-beta.0", 28 | "rxjs": "~7.8.0", 29 | "tslib": "^2.3.0", 30 | "zone.js": "~0.13.0" 31 | }, 32 | "devDependencies": { 33 | "@angular-devkit/build-angular": "^16.0.0-rc.2", 34 | "@angular/cli": "~16.0.0-rc.2", 35 | "@angular/compiler-cli": "^16.0.0-next.0", 36 | "@ngrx/store-devtools": "16.0.0-beta.0", 37 | "@types/jasmine": "~4.3.0", 38 | "jasmine-core": "~4.6.0", 39 | "karma": "~6.4.0", 40 | "karma-chrome-launcher": "~3.2.0", 41 | "karma-coverage": "~2.2.0", 42 | "karma-jasmine": "~5.1.0", 43 | "karma-jasmine-html-reporter": "~2.0.0", 44 | "typescript": "~5.0.2" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { 3 | Routes, 4 | RouterModule, 5 | withComponentInputBinding, 6 | provideRouter, 7 | withHashLocation, 8 | } from '@angular/router'; 9 | 10 | import { authGuard } from '@example-app/auth/services'; 11 | import { NotFoundPageComponent } from '@example-app/core/containers'; 12 | 13 | export const routes: Routes = [ 14 | { path: '', redirectTo: '/books', pathMatch: 'full' }, 15 | { 16 | path: 'books', 17 | loadChildren: () => 18 | import('@example-app/books/books.module').then((m) => m.BooksModule), 19 | canActivate: [authGuard], 20 | }, 21 | { 22 | path: '**', 23 | component: NotFoundPageComponent, 24 | data: { title: 'Not found' }, 25 | }, 26 | ]; 27 | 28 | @NgModule({ 29 | exports: [RouterModule], 30 | providers: [ 31 | provideRouter(routes, withComponentInputBinding(), withHashLocation()), 32 | ], 33 | }) 34 | export class AppRoutingModule {} 35 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { HttpClientModule } from '@angular/common/http'; 4 | import { BrowserModule } from '@angular/platform-browser'; 5 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 6 | 7 | import { StoreModule } from '@ngrx/store'; 8 | import { EffectsModule } from '@ngrx/effects'; 9 | import { StoreRouterConnectingModule } from '@ngrx/router-store'; 10 | import { StoreDevtoolsModule } from '@ngrx/store-devtools'; 11 | 12 | import { AuthModule } from '@example-app/auth'; 13 | 14 | import { ROOT_REDUCERS, metaReducers } from '@example-app/reducers'; 15 | 16 | import { CoreModule } from '@example-app/core'; 17 | import { AppRoutingModule } from '@example-app/app-routing.module'; 18 | import { UserEffects, RouterEffects } from '@example-app/core/effects'; 19 | import { AppComponent } from '@example-app/core/containers'; 20 | 21 | @NgModule({ 22 | imports: [ 23 | CommonModule, 24 | BrowserModule, 25 | BrowserAnimationsModule, 26 | HttpClientModule, 27 | AuthModule, 28 | AppRoutingModule, 29 | 30 | /** 31 | * StoreModule.forRoot is imported once in the root module, accepting a reducer 32 | * function or object map of reducer functions. If passed an object of 33 | * reducers, combineReducers will be run creating your application 34 | * meta-reducer. This returns all providers for an @ngrx/store 35 | * based application. 36 | */ 37 | StoreModule.forRoot(ROOT_REDUCERS, { 38 | metaReducers, 39 | runtimeChecks: { 40 | // strictStateImmutability and strictActionImmutability are enabled by default 41 | strictStateSerializability: true, 42 | strictActionSerializability: true, 43 | strictActionWithinNgZone: true, 44 | strictActionTypeUniqueness: true, 45 | }, 46 | }), 47 | 48 | /** 49 | * @ngrx/router-store keeps router state up-to-date in the store. 50 | */ 51 | StoreRouterConnectingModule.forRoot(), 52 | 53 | /** 54 | * Store devtools instrument the store retaining past versions of state 55 | * and recalculating new states. This enables powerful time-travel 56 | * debugging. 57 | * 58 | * To use the debugger, install the Redux Devtools extension for either 59 | * Chrome or Firefox 60 | * 61 | * See: https://github.com/zalmoxisus/redux-devtools-extension 62 | */ 63 | StoreDevtoolsModule.instrument({ 64 | name: 'NgRx Book Store App', 65 | // In a production build you would want to disable the Store Devtools 66 | // logOnly: !isDevMode(), 67 | }), 68 | 69 | /** 70 | * EffectsModule.forRoot() is imported once in the root module and 71 | * sets up the effects class to be initialized immediately when the 72 | * application starts. 73 | * 74 | * See: https://ngrx.io/guide/effects#registering-root-effects 75 | */ 76 | EffectsModule.forRoot(UserEffects, RouterEffects), 77 | CoreModule, 78 | ], 79 | bootstrap: [AppComponent], 80 | providers: [ 81 | 82 | ] 83 | }) 84 | export class AppModule {} 85 | -------------------------------------------------------------------------------- /src/app/auth/actions/auth-api.actions.ts: -------------------------------------------------------------------------------- 1 | import { props, createAction } from '@ngrx/store'; 2 | import { User } from '@example-app/auth/models'; 3 | 4 | export const loginSuccess = createAction( 5 | '[Auth/API] Login Success', 6 | props<{ user: User }>() 7 | ); 8 | 9 | export const loginFailure = createAction( 10 | '[Auth/API] Login Failure', 11 | props<{ error: any }>() 12 | ); 13 | 14 | export const loginRedirect = createAction('[Auth/API] Login Redirect'); 15 | -------------------------------------------------------------------------------- /src/app/auth/actions/auth.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction } from '@ngrx/store'; 2 | 3 | export const logout = createAction('[Auth] Logout'); 4 | export const logoutConfirmation = createAction('[Auth] Logout Confirmation'); 5 | export const logoutConfirmationDismiss = createAction( 6 | '[Auth] Logout Confirmation Dismiss' 7 | ); 8 | -------------------------------------------------------------------------------- /src/app/auth/actions/index.ts: -------------------------------------------------------------------------------- 1 | import * as AuthActions from './auth.actions'; 2 | import * as AuthApiActions from './auth-api.actions'; 3 | import * as LoginPageActions from './login-page.actions'; 4 | 5 | export { AuthActions, AuthApiActions, LoginPageActions }; 6 | -------------------------------------------------------------------------------- /src/app/auth/actions/login-page.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from '@ngrx/store'; 2 | import { Credentials } from '@example-app/auth/models'; 3 | 4 | export const login = createAction( 5 | '[Login Page] Login', 6 | props<{ credentials: Credentials }>() 7 | ); 8 | -------------------------------------------------------------------------------- /src/app/auth/auth-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { Routes, RouterModule } from '@angular/router'; 3 | import { LoginPageComponent } from '@example-app/auth/containers'; 4 | 5 | const routes: Routes = [ 6 | { path: 'login', component: LoginPageComponent, data: { title: 'Login' } }, 7 | ]; 8 | 9 | @NgModule({ 10 | imports: [RouterModule.forChild(routes)], 11 | exports: [RouterModule], 12 | }) 13 | export class AuthRoutingModule {} 14 | -------------------------------------------------------------------------------- /src/app/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { ReactiveFormsModule } from '@angular/forms'; 4 | import { StoreModule } from '@ngrx/store'; 5 | import { EffectsModule } from '@ngrx/effects'; 6 | import { LoginPageComponent } from '@example-app/auth/containers'; 7 | import { 8 | LoginFormComponent, 9 | LogoutConfirmationDialogComponent, 10 | } from '@example-app/auth/components'; 11 | 12 | import { AuthEffects } from '@example-app/auth/effects'; 13 | import * as fromAuth from '@example-app/auth/reducers'; 14 | import { MaterialModule } from '@example-app/material'; 15 | import { AuthRoutingModule } from './auth-routing.module'; 16 | 17 | export const COMPONENTS = [ 18 | LoginPageComponent, 19 | LoginFormComponent, 20 | LogoutConfirmationDialogComponent, 21 | ]; 22 | 23 | @NgModule({ 24 | imports: [ 25 | CommonModule, 26 | ReactiveFormsModule, 27 | MaterialModule, 28 | AuthRoutingModule, 29 | StoreModule.forFeature({ 30 | name: fromAuth.authFeatureKey, 31 | reducer: fromAuth.reducers, 32 | }), 33 | EffectsModule.forFeature(AuthEffects), 34 | ], 35 | declarations: COMPONENTS, 36 | }) 37 | export class AuthModule {} 38 | -------------------------------------------------------------------------------- /src/app/auth/components/__snapshots__/login-form.component.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Login Page should compile 1`] = ` 4 | 8 | 9 | 10 | Login 11 | 12 | 13 |
17 |

18 | 19 | 26 | 27 |

28 |

29 | 30 | 37 | 38 |

39 | 49 |
50 |
51 |
52 |
53 | `; 54 | 55 | exports[`Login Page should disable the form if pending 1`] = ` 56 | 60 | 61 | 62 | Login 63 | 64 | 65 |
69 |

70 | 71 | 79 | 80 |

81 |

82 | 83 | 91 | 92 |

93 | 103 |
104 |
105 |
106 |
107 | `; 108 | 109 | exports[`Login Page should display an error message if provided 1`] = ` 110 | 115 | 116 | 117 | Login 118 | 119 | 120 |
124 |

125 | 126 | 133 | 134 |

135 |

136 | 137 | 144 | 145 |

146 | 151 | 161 |
162 |
163 |
164 |
165 | `; 166 | -------------------------------------------------------------------------------- /src/app/auth/components/__snapshots__/logout-confirmation-dialog.component.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Logout Confirmation Dialog should compile 1`] = ` 4 | 5 |

9 | Logout 10 |

13 | Are you sure you want to logout? 14 | 17 | 39 | 61 | 62 |
63 | `; 64 | -------------------------------------------------------------------------------- /src/app/auth/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './login-form.component'; 2 | export * from './logout-confirmation-dialog.component'; 3 | -------------------------------------------------------------------------------- /src/app/auth/components/login-form.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, ComponentFixture } from '@angular/core/testing'; 2 | import { NO_ERRORS_SCHEMA } from '@angular/core'; 3 | import { LoginFormComponent } from '@example-app/auth/components'; 4 | import { ReactiveFormsModule } from '@angular/forms'; 5 | 6 | describe('Login Page', () => { 7 | let fixture: ComponentFixture; 8 | let instance: LoginFormComponent; 9 | 10 | beforeEach(() => { 11 | TestBed.configureTestingModule({ 12 | imports: [ReactiveFormsModule], 13 | declarations: [LoginFormComponent], 14 | schemas: [NO_ERRORS_SCHEMA], 15 | }); 16 | 17 | fixture = TestBed.createComponent(LoginFormComponent); 18 | instance = fixture.componentInstance; 19 | }); 20 | 21 | it('should compile', () => { 22 | fixture.detectChanges(); 23 | 24 | /** 25 | * The login form is a presentational component, as it 26 | * only derives its state from inputs and communicates 27 | * externally through outputs. We can use snapshot 28 | * tests to validate the presentation state of this component 29 | * by changing its inputs and snapshotting the generated 30 | * HTML. 31 | * 32 | * We can also use this as a validation tool against changes 33 | * to the component's template against the currently stored 34 | * snapshot. 35 | */ 36 | expect(fixture).toMatchSnapshot(); 37 | }); 38 | 39 | it('should disable the form if pending', () => { 40 | instance.pending = true; 41 | 42 | fixture.detectChanges(); 43 | 44 | expect(fixture).toMatchSnapshot(); 45 | }); 46 | 47 | it('should display an error message if provided', () => { 48 | instance.errorMessage = 'Invalid credentials'; 49 | 50 | fixture.detectChanges(); 51 | 52 | expect(fixture).toMatchSnapshot(); 53 | }); 54 | 55 | it('should emit an event if the form is valid when submitted', () => { 56 | const credentials = { 57 | username: 'user', 58 | password: 'pass', 59 | }; 60 | instance.form.setValue(credentials); 61 | 62 | jest.spyOn(instance.submitted, 'emit'); 63 | instance.submit(); 64 | 65 | expect(instance.submitted.emit).toHaveBeenCalledWith(credentials); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/app/auth/components/login-form.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, Output, EventEmitter } from '@angular/core'; 2 | import { FormGroup, FormControl } from '@angular/forms'; 3 | import { Credentials } from '@example-app/auth/models'; 4 | 5 | @Component({ 6 | selector: 'bc-login-form', 7 | template: ` 8 | 9 | Login 10 | 11 |
12 |

13 | 14 | 20 | 21 |

22 | 23 |

24 | 25 | 31 | 32 |

33 | 34 | 37 | 38 | 41 |
42 |
43 |
44 | `, 45 | styles: [ 46 | ` 47 | :host { 48 | display: flex; 49 | justify-content: center; 50 | margin: 4.5rem 0; 51 | } 52 | 53 | .mat-mdc-form-field { 54 | width: 100%; 55 | min-width: 300px; 56 | } 57 | 58 | mat-card-title { 59 | text-align: center; 60 | margin: 1rem 0; 61 | } 62 | 63 | mat-card-content { 64 | justify-content: center; 65 | } 66 | 67 | .login-error { 68 | padding: 1rem; 69 | width: 300px; 70 | color: white; 71 | background-color: red; 72 | } 73 | 74 | .login-buttons { 75 | display: flex; 76 | flex-direction: row; 77 | justify-content: flex-end; 78 | } 79 | `, 80 | ], 81 | }) 82 | export class LoginFormComponent { 83 | @Input() 84 | set pending(isPending: boolean) { 85 | if (isPending) { 86 | this.form.disable(); 87 | } else { 88 | this.form.enable(); 89 | } 90 | } 91 | 92 | @Input() errorMessage!: string | null; 93 | 94 | @Output() submitted = new EventEmitter(); 95 | 96 | form: FormGroup = new FormGroup({ 97 | username: new FormControl('ngrx'), 98 | password: new FormControl(''), 99 | }); 100 | 101 | submit() { 102 | if (this.form.valid) { 103 | this.submitted.emit(this.form.value); 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/app/auth/components/logout-confirmation-dialog.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { LogoutConfirmationDialogComponent } from '@example-app/auth/components'; 3 | import { MaterialModule } from '@example-app/material'; 4 | 5 | describe('Logout Confirmation Dialog', () => { 6 | let fixture: ComponentFixture; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({ 10 | imports: [MaterialModule], 11 | declarations: [LogoutConfirmationDialogComponent], 12 | }); 13 | 14 | fixture = TestBed.createComponent(LogoutConfirmationDialogComponent); 15 | }); 16 | 17 | it('should compile', () => { 18 | fixture.detectChanges(); 19 | 20 | expect(fixture).toMatchSnapshot(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/app/auth/components/logout-confirmation-dialog.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | /** 4 | * The dialog will close with true if user clicks the ok button, 5 | * otherwise it will close with undefined. 6 | */ 7 | @Component({ 8 | template: ` 9 |

Logout

10 | Are you sure you want to logout? 11 | 12 | 13 | 14 | 15 | `, 16 | styles: [ 17 | ` 18 | :host { 19 | display: block; 20 | width: 100%; 21 | max-width: 300px; 22 | } 23 | 24 | mat-dialog-actions { 25 | display: flex; 26 | justify-content: flex-end; 27 | } 28 | 29 | [mat-button] { 30 | padding: 0; 31 | } 32 | `, 33 | ], 34 | }) 35 | export class LogoutConfirmationDialogComponent {} 36 | -------------------------------------------------------------------------------- /src/app/auth/containers/__snapshots__/login-page.component.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Login Page should compile 1`] = ` 4 | 9 | 10 | 13 | 16 | Login 17 | 18 | 21 |
25 |

26 | 29 |

32 |
35 |
38 |
41 | 50 |
51 |
52 |
56 |
57 |
60 |
64 |
67 |
68 |
69 | 70 |

71 |

72 | 75 |

78 |
81 |
84 |
87 | 96 |
97 |
98 |
102 |
103 |
106 |
110 |
113 |
114 |
115 | 116 |

117 | 143 | 144 | 145 | 146 | 147 | 148 | `; 149 | -------------------------------------------------------------------------------- /src/app/auth/containers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './login-page.component'; 2 | -------------------------------------------------------------------------------- /src/app/auth/containers/login-page.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, ComponentFixture } from '@angular/core/testing'; 2 | import { ReactiveFormsModule } from '@angular/forms'; 3 | import { NoopAnimationsModule } from '@angular/platform-browser/animations'; 4 | import { LoginPageComponent } from '@example-app/auth/containers'; 5 | import { LoginFormComponent } from '@example-app/auth/components'; 6 | import * as fromAuth from '@example-app/auth/reducers'; 7 | import { LoginPageActions } from '@example-app/auth/actions'; 8 | import { provideMockStore, MockStore } from '@ngrx/store/testing'; 9 | import { MaterialModule } from '@example-app/material'; 10 | 11 | describe('Login Page', () => { 12 | let fixture: ComponentFixture; 13 | let store: MockStore; 14 | let instance: LoginPageComponent; 15 | 16 | beforeEach(() => { 17 | TestBed.configureTestingModule({ 18 | imports: [NoopAnimationsModule, MaterialModule, ReactiveFormsModule], 19 | declarations: [LoginPageComponent, LoginFormComponent], 20 | providers: [ 21 | provideMockStore({ 22 | selectors: [ 23 | { selector: fromAuth.selectLoginPagePending, value: false }, 24 | ], 25 | }), 26 | ], 27 | }); 28 | 29 | fixture = TestBed.createComponent(LoginPageComponent); 30 | instance = fixture.componentInstance; 31 | store = TestBed.inject(MockStore); 32 | 33 | jest.spyOn(store, 'dispatch'); 34 | }); 35 | 36 | /** 37 | * Container components are used as integration points for connecting 38 | * the store to presentational components and dispatching 39 | * actions to the store. 40 | * 41 | * Container methods that dispatch events are like a component's output observables. 42 | * Container properties that select state from store are like a component's input properties. 43 | * If pure components are functions of their inputs, containers are functions of state 44 | * 45 | * Traditionally you would query the components rendered template 46 | * to validate its state. Since the components are analogous to 47 | * pure functions, we take snapshots of these components for a given state 48 | * to validate the rendered output and verify the component's output 49 | * against changes in state. 50 | */ 51 | it('should compile', () => { 52 | fixture.detectChanges(); 53 | 54 | expect(fixture).toMatchSnapshot(); 55 | }); 56 | 57 | it('should dispatch a login event on submit', () => { 58 | const credentials: any = {}; 59 | const action = LoginPageActions.login({ credentials }); 60 | 61 | instance.onSubmit(credentials); 62 | 63 | expect(store.dispatch).toHaveBeenCalledWith(action); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/app/auth/containers/login-page.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { Credentials } from '@example-app/auth/models'; 3 | 4 | import { LoginPageStore } from './login-page.store'; 5 | 6 | @Component({ 7 | selector: 'bc-login-page', 8 | template: ` 9 | 14 | 15 | `, 16 | styles: [], 17 | providers: [ 18 | LoginPageStore 19 | ] 20 | }) 21 | export class LoginPageComponent { 22 | pending = this.store.pending; 23 | error = this.store.error; 24 | 25 | constructor(private store: LoginPageStore) {} 26 | 27 | onSubmit(credentials: Credentials) { 28 | this.store.login(credentials); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/app/auth/containers/login-page.store.ts: -------------------------------------------------------------------------------- 1 | import { computed, Injectable } from '@angular/core'; 2 | import { ComponentStore, tapResponse } from '@ngrx/component-store'; 3 | import { Store } from '@ngrx/store'; 4 | import { Observable, exhaustMap } from 'rxjs'; 5 | import { Router } from '@angular/router'; 6 | 7 | import { Credentials } from '@example-app/auth/models'; 8 | import { AuthApiActions } from '@example-app/auth/actions'; 9 | import { AuthService } from '../services/auth.service'; 10 | 11 | interface LoginPageState { 12 | pending: boolean; 13 | error: string | null; 14 | } 15 | 16 | const initialState: LoginPageState = { 17 | pending: false, 18 | error: null, 19 | }; 20 | 21 | @Injectable() 22 | export class LoginPageStore extends ComponentStore { 23 | constructor( 24 | private authService: AuthService, 25 | private store: Store, 26 | private router: Router 27 | ) { 28 | super(initialState); 29 | } 30 | 31 | readonly pending = this.selectSignal((s) => s.pending); 32 | readonly error = computed(() => this.state().error); 33 | 34 | login = this.effect((credentials$: Observable) => { 35 | return credentials$.pipe( 36 | exhaustMap((auth: Credentials) => { 37 | this.setState({ 38 | pending: true, 39 | error: null 40 | }); 41 | return this.authService.login(auth).pipe( 42 | tapResponse( 43 | (user) => { 44 | this.setState({ 45 | error: null, 46 | pending: false, 47 | }); 48 | this.store.dispatch(AuthApiActions.loginSuccess({ user })); 49 | this.router.navigate(['/']); 50 | }, 51 | (error: string) => { 52 | this.setState({ 53 | error, 54 | pending: false, 55 | }); 56 | } 57 | ) 58 | ); 59 | }) 60 | ); 61 | }); 62 | } 63 | -------------------------------------------------------------------------------- /src/app/auth/effects/auth.effects.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { MatDialog } from '@angular/material/dialog'; 3 | import { Router } from '@angular/router'; 4 | import { Actions } from '@ngrx/effects'; 5 | import { provideMockActions } from '@ngrx/effects/testing'; 6 | import { cold, hot } from 'jasmine-marbles'; 7 | import { Observable, of } from 'rxjs'; 8 | import { 9 | LoginPageActions, 10 | AuthActions, 11 | AuthApiActions, 12 | } from '@example-app/auth/actions'; 13 | 14 | import { Credentials, User } from '@example-app/auth/models'; 15 | import { AuthService } from '@example-app/auth/services'; 16 | import { AuthEffects } from '@example-app/auth/effects'; 17 | 18 | describe('AuthEffects', () => { 19 | let effects: AuthEffects; 20 | let authService: any; 21 | let actions$: Observable; 22 | let routerService: any; 23 | let dialog: any; 24 | 25 | beforeEach(() => { 26 | TestBed.configureTestingModule({ 27 | providers: [ 28 | AuthEffects, 29 | { 30 | provide: AuthService, 31 | useValue: { login: jest.fn() }, 32 | }, 33 | provideMockActions(() => actions$), 34 | { 35 | provide: Router, 36 | useValue: { navigate: jest.fn() }, 37 | }, 38 | { 39 | provide: MatDialog, 40 | useValue: { 41 | open: jest.fn(), 42 | }, 43 | }, 44 | ], 45 | }); 46 | 47 | effects = TestBed.inject(AuthEffects); 48 | authService = TestBed.inject(AuthService); 49 | actions$ = TestBed.inject(Actions); 50 | routerService = TestBed.inject(Router); 51 | dialog = TestBed.inject(MatDialog); 52 | 53 | jest.spyOn(routerService, 'navigate'); 54 | }); 55 | 56 | describe('login$', () => { 57 | it('should return an auth.loginSuccess action, with user information if login succeeds', () => { 58 | const credentials: Credentials = { username: 'test', password: '' }; 59 | const user = { name: 'User' } as User; 60 | const action = LoginPageActions.login({ credentials }); 61 | const completion = AuthApiActions.loginSuccess({ user }); 62 | 63 | actions$ = hot('-a---', { a: action }); 64 | const response = cold('-a|', { a: user }); 65 | const expected = cold('--b', { b: completion }); 66 | authService.login = jest.fn(() => response); 67 | 68 | expect(effects.login$).toBeObservable(expected); 69 | }); 70 | 71 | it('should return a new auth.loginFailure if the login service throws', () => { 72 | const credentials: Credentials = { username: 'someOne', password: '' }; 73 | const action = LoginPageActions.login({ credentials }); 74 | const completion = AuthApiActions.loginFailure({ 75 | error: 'Invalid username or password', 76 | }); 77 | const error = 'Invalid username or password'; 78 | 79 | actions$ = hot('-a---', { a: action }); 80 | const response = cold('-#', {}, error); 81 | const expected = cold('--b', { b: completion }); 82 | authService.login = jest.fn(() => response); 83 | 84 | expect(effects.login$).toBeObservable(expected); 85 | }); 86 | }); 87 | 88 | describe('loginSuccess$', () => { 89 | it('should dispatch a RouterNavigation action', (done: any) => { 90 | const user = { name: 'User' } as User; 91 | const action = AuthApiActions.loginSuccess({ user }); 92 | 93 | actions$ = of(action); 94 | 95 | effects.loginSuccess$.subscribe(() => { 96 | expect(routerService.navigate).toHaveBeenCalledWith(['/']); 97 | done(); 98 | }); 99 | }); 100 | }); 101 | 102 | describe('loginRedirect$', () => { 103 | it('should dispatch a RouterNavigation action when auth.loginRedirect is dispatched', (done: any) => { 104 | const action = AuthApiActions.loginRedirect(); 105 | 106 | actions$ = of(action); 107 | 108 | effects.loginRedirect$.subscribe(() => { 109 | expect(routerService.navigate).toHaveBeenCalledWith(['/login']); 110 | done(); 111 | }); 112 | }); 113 | 114 | it('should dispatch a RouterNavigation action when auth.logout is dispatched', (done: any) => { 115 | const action = AuthActions.logout(); 116 | 117 | actions$ = of(action); 118 | 119 | effects.loginRedirect$.subscribe(() => { 120 | expect(routerService.navigate).toHaveBeenCalledWith(['/login']); 121 | done(); 122 | }); 123 | }); 124 | }); 125 | 126 | describe('logoutConfirmation$', () => { 127 | it('should dispatch a Logout action if dialog closes with true result', () => { 128 | const action = AuthActions.logoutConfirmation(); 129 | const completion = AuthActions.logout(); 130 | 131 | actions$ = hot('-a', { a: action }); 132 | const expected = cold('-b', { b: completion }); 133 | 134 | dialog.open = () => ({ 135 | afterClosed: jest.fn(() => of(true)), 136 | }); 137 | 138 | expect(effects.logoutConfirmation$).toBeObservable(expected); 139 | }); 140 | 141 | it('should dispatch a LogoutConfirmationDismiss action if dialog closes with falsy result', () => { 142 | const action = AuthActions.logoutConfirmation(); 143 | const completion = AuthActions.logoutConfirmationDismiss(); 144 | 145 | actions$ = hot('-a', { a: action }); 146 | const expected = cold('-b', { b: completion }); 147 | 148 | dialog.open = () => ({ 149 | afterClosed: jest.fn(() => of(false)), 150 | }); 151 | 152 | expect(effects.logoutConfirmation$).toBeObservable(expected); 153 | }); 154 | }); 155 | }); 156 | -------------------------------------------------------------------------------- /src/app/auth/effects/auth.effects.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { MatDialog } from '@angular/material/dialog'; 3 | import { Router } from '@angular/router'; 4 | import { Actions, ofType, createEffect } from '@ngrx/effects'; 5 | import { of } from 'rxjs'; 6 | import { catchError, exhaustMap, map, tap } from 'rxjs/operators'; 7 | import { 8 | LoginPageActions, 9 | AuthActions, 10 | AuthApiActions, 11 | } from '@example-app/auth/actions'; 12 | import { Credentials } from '@example-app/auth/models'; 13 | import { AuthService } from '@example-app/auth/services'; 14 | import { LogoutConfirmationDialogComponent } from '@example-app/auth/components'; 15 | import { UserActions } from '@example-app/core/actions'; 16 | 17 | @Injectable() 18 | export class AuthEffects { 19 | login$ = createEffect(() => 20 | this.actions$.pipe( 21 | ofType(LoginPageActions.login), 22 | map((action) => action.credentials), 23 | exhaustMap((auth: Credentials) => 24 | this.authService.login(auth).pipe( 25 | map((user) => AuthApiActions.loginSuccess({ user })), 26 | catchError((error) => of(AuthApiActions.loginFailure({ error }))) 27 | ) 28 | ) 29 | ) 30 | ); 31 | 32 | loginSuccess$ = createEffect( 33 | () => 34 | this.actions$.pipe( 35 | ofType(AuthApiActions.loginSuccess), 36 | tap(() => this.router.navigate(['/'])) 37 | ), 38 | { dispatch: false } 39 | ); 40 | 41 | loginRedirect$ = createEffect( 42 | () => 43 | this.actions$.pipe( 44 | ofType(AuthApiActions.loginRedirect, AuthActions.logout), 45 | tap(() => { 46 | this.router.navigate(['/login']); 47 | }) 48 | ), 49 | { dispatch: false } 50 | ); 51 | 52 | logoutConfirmation$ = createEffect(() => 53 | this.actions$.pipe( 54 | ofType(AuthActions.logoutConfirmation), 55 | exhaustMap(() => { 56 | const dialogRef = this.dialog.open< 57 | LogoutConfirmationDialogComponent, 58 | undefined, 59 | boolean 60 | >(LogoutConfirmationDialogComponent); 61 | 62 | return dialogRef.afterClosed(); 63 | }), 64 | map((result) => 65 | result ? AuthActions.logout() : AuthActions.logoutConfirmationDismiss() 66 | ) 67 | ) 68 | ); 69 | 70 | logoutIdleUser$ = createEffect(() => 71 | this.actions$.pipe( 72 | ofType(UserActions.idleTimeout), 73 | map(() => AuthActions.logout()) 74 | ) 75 | ); 76 | 77 | constructor( 78 | private actions$: Actions, 79 | private authService: AuthService, 80 | private router: Router, 81 | private dialog: MatDialog 82 | ) {} 83 | } 84 | -------------------------------------------------------------------------------- /src/app/auth/effects/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth.effects'; 2 | -------------------------------------------------------------------------------- /src/app/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth.module'; 2 | -------------------------------------------------------------------------------- /src/app/auth/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user'; 2 | -------------------------------------------------------------------------------- /src/app/auth/models/user.ts: -------------------------------------------------------------------------------- 1 | export interface Credentials { 2 | username: string; 3 | password: string; 4 | } 5 | 6 | export interface User { 7 | name: string; 8 | } 9 | -------------------------------------------------------------------------------- /src/app/auth/reducers/__snapshots__/auth.reducer.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`AuthReducer LOGIN_SUCCESS should add a user set loggedIn to true in auth state 1`] = ` 4 | { 5 | "user": { 6 | "name": "test", 7 | }, 8 | } 9 | `; 10 | 11 | exports[`AuthReducer LOGOUT should logout a user 1`] = ` 12 | { 13 | "user": null, 14 | } 15 | `; 16 | 17 | exports[`AuthReducer undefined action should return the default state 1`] = ` 18 | { 19 | "user": null, 20 | } 21 | `; 22 | -------------------------------------------------------------------------------- /src/app/auth/reducers/__snapshots__/login-page.reducer.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`LoginPageReducer LOGIN should make pending to true 1`] = ` 4 | { 5 | "error": null, 6 | "pending": true, 7 | } 8 | `; 9 | 10 | exports[`LoginPageReducer LOGIN_FAILURE should have an error and no pending state 1`] = ` 11 | { 12 | "error": "login failed", 13 | "pending": false, 14 | } 15 | `; 16 | 17 | exports[`LoginPageReducer LOGIN_SUCCESS should have no error and no pending state 1`] = ` 18 | { 19 | "error": null, 20 | "pending": false, 21 | } 22 | `; 23 | 24 | exports[`LoginPageReducer undefined action should return the default state 1`] = ` 25 | { 26 | "error": null, 27 | "pending": false, 28 | } 29 | `; 30 | -------------------------------------------------------------------------------- /src/app/auth/reducers/auth.reducer.spec.ts: -------------------------------------------------------------------------------- 1 | import { reducer } from '@example-app/auth/reducers/auth.reducer'; 2 | import * as fromAuth from '@example-app/auth/reducers/auth.reducer'; 3 | import { AuthApiActions, AuthActions } from '@example-app/auth/actions'; 4 | 5 | import { User } from '@example-app/auth/models'; 6 | 7 | describe('AuthReducer', () => { 8 | describe('undefined action', () => { 9 | it('should return the default state', () => { 10 | const action = {} as any; 11 | 12 | const result = reducer(undefined, action); 13 | 14 | /** 15 | * Snapshot tests are a quick way to validate 16 | * the state produced by a reducer since 17 | * its plain JavaScript object. These snapshots 18 | * are used to validate against the current state 19 | * if the functionality of the reducer ever changes. 20 | */ 21 | expect(result).toMatchSnapshot(); 22 | }); 23 | }); 24 | 25 | describe('LOGIN_SUCCESS', () => { 26 | it('should add a user set loggedIn to true in auth state', () => { 27 | const user = { name: 'test' } as User; 28 | const createAction = AuthApiActions.loginSuccess({ user }); 29 | 30 | const result = reducer(fromAuth.initialState, createAction); 31 | 32 | expect(result).toMatchSnapshot(); 33 | }); 34 | }); 35 | 36 | describe('LOGOUT', () => { 37 | it('should logout a user', () => { 38 | const initialState = { 39 | user: { name: 'test' }, 40 | } as fromAuth.State; 41 | const createAction = AuthActions.logout(); 42 | 43 | const result = reducer(initialState, createAction); 44 | 45 | expect(result).toMatchSnapshot(); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/app/auth/reducers/auth.reducer.ts: -------------------------------------------------------------------------------- 1 | import { createReducer, on } from '@ngrx/store'; 2 | import { AuthApiActions, AuthActions } from '@example-app/auth/actions'; 3 | import { User } from '@example-app/auth/models'; 4 | 5 | export const statusFeatureKey = 'status'; 6 | 7 | export interface State { 8 | user: User | null; 9 | } 10 | 11 | export const initialState: State = { 12 | user: null, 13 | }; 14 | 15 | export const reducer = createReducer( 16 | initialState, 17 | on(AuthApiActions.loginSuccess, (state, { user }) => ({ ...state, user })), 18 | on(AuthActions.logout, () => initialState) 19 | ); 20 | 21 | export const getUser = (state: State) => state.user; 22 | -------------------------------------------------------------------------------- /src/app/auth/reducers/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createSelector, 3 | createFeatureSelector, 4 | Action, 5 | combineReducers, 6 | } from '@ngrx/store'; 7 | import * as fromRoot from '@example-app/reducers'; 8 | import * as fromAuth from '@example-app/auth/reducers/auth.reducer'; 9 | import * as fromLoginPage from '@example-app/auth/reducers/login-page.reducer'; 10 | 11 | export const authFeatureKey = 'auth'; 12 | 13 | export interface AuthState { 14 | [fromAuth.statusFeatureKey]: fromAuth.State; 15 | [fromLoginPage.loginPageFeatureKey]: fromLoginPage.State; 16 | } 17 | 18 | export interface State extends fromRoot.State { 19 | [authFeatureKey]: AuthState; 20 | } 21 | 22 | export function reducers(state: AuthState | undefined, action: Action) { 23 | return combineReducers({ 24 | [fromAuth.statusFeatureKey]: fromAuth.reducer, 25 | [fromLoginPage.loginPageFeatureKey]: fromLoginPage.reducer, 26 | })(state, action); 27 | } 28 | 29 | export const selectAuthState = createFeatureSelector(authFeatureKey); 30 | 31 | export const selectAuthStatusState = createSelector( 32 | selectAuthState, 33 | (state) => state.status 34 | ); 35 | export const selectUser = createSelector( 36 | selectAuthStatusState, 37 | fromAuth.getUser 38 | ); 39 | export const selectLoggedIn = createSelector(selectUser, (user) => !!user); 40 | 41 | export const selectLoginPageState = createSelector( 42 | selectAuthState, 43 | (state) => state.loginPage 44 | ); 45 | export const selectLoginPageError = createSelector( 46 | selectLoginPageState, 47 | fromLoginPage.getError 48 | ); 49 | export const selectLoginPagePending = createSelector( 50 | selectLoginPageState, 51 | fromLoginPage.getPending 52 | ); 53 | -------------------------------------------------------------------------------- /src/app/auth/reducers/login-page.reducer.spec.ts: -------------------------------------------------------------------------------- 1 | import { reducer } from '@example-app/auth/reducers/login-page.reducer'; 2 | import * as fromLoginPage from '@example-app/auth/reducers/login-page.reducer'; 3 | 4 | import { AuthApiActions, LoginPageActions } from '@example-app/auth/actions'; 5 | 6 | import { Credentials, User } from '@example-app/auth/models'; 7 | 8 | describe('LoginPageReducer', () => { 9 | describe('undefined action', () => { 10 | it('should return the default state', () => { 11 | const action = {} as any; 12 | 13 | const result = reducer(undefined, action); 14 | 15 | expect(result).toMatchSnapshot(); 16 | }); 17 | }); 18 | 19 | describe('LOGIN', () => { 20 | it('should make pending to true', () => { 21 | const user = { username: 'test' } as Credentials; 22 | const createAction = LoginPageActions.login({ credentials: user }); 23 | 24 | const result = reducer(fromLoginPage.initialState, createAction); 25 | 26 | expect(result).toMatchSnapshot(); 27 | }); 28 | }); 29 | 30 | describe('LOGIN_SUCCESS', () => { 31 | it('should have no error and no pending state', () => { 32 | const user = { name: 'test' } as User; 33 | const createAction = AuthApiActions.loginSuccess({ user }); 34 | 35 | const result = reducer(fromLoginPage.initialState, createAction); 36 | 37 | expect(result).toMatchSnapshot(); 38 | }); 39 | }); 40 | 41 | describe('LOGIN_FAILURE', () => { 42 | it('should have an error and no pending state', () => { 43 | const error = 'login failed'; 44 | const createAction = AuthApiActions.loginFailure({ error }); 45 | 46 | const result = reducer(fromLoginPage.initialState, createAction); 47 | 48 | expect(result).toMatchSnapshot(); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/app/auth/reducers/login-page.reducer.ts: -------------------------------------------------------------------------------- 1 | import { AuthApiActions, LoginPageActions } from '@example-app/auth/actions'; 2 | import { createReducer, on } from '@ngrx/store'; 3 | 4 | export const loginPageFeatureKey = 'loginPage'; 5 | 6 | export interface State { 7 | error: string | null; 8 | pending: boolean; 9 | } 10 | 11 | export const initialState: State = { 12 | error: null, 13 | pending: false, 14 | }; 15 | 16 | export const reducer = createReducer( 17 | initialState, 18 | on(LoginPageActions.login, (state) => ({ 19 | ...state, 20 | error: null, 21 | pending: true, 22 | })), 23 | 24 | on(AuthApiActions.loginSuccess, (state) => ({ 25 | ...state, 26 | error: null, 27 | pending: false, 28 | })), 29 | on(AuthApiActions.loginFailure, (state, { error }) => ({ 30 | ...state, 31 | error, 32 | pending: false, 33 | })) 34 | ); 35 | 36 | export const getError = (state: State) => state.error; 37 | export const getPending = (state: State) => state.pending; 38 | -------------------------------------------------------------------------------- /src/app/auth/services/auth-guard.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { MemoizedSelector } from '@ngrx/store'; 3 | import { cold } from 'jasmine-marbles'; 4 | import { authGuard } from '@example-app/auth/services'; 5 | import * as fromAuth from '@example-app/auth/reducers'; 6 | import { provideMockStore, MockStore } from '@ngrx/store/testing'; 7 | import { Observable } from 'rxjs'; 8 | 9 | describe('Auth Guard', () => { 10 | let guard: Observable; 11 | let store: MockStore; 12 | let loggedIn: MemoizedSelector; 13 | 14 | beforeEach(() => { 15 | TestBed.configureTestingModule({ 16 | providers: [provideMockStore()], 17 | }); 18 | 19 | store = TestBed.inject(MockStore); 20 | guard = TestBed.runInInjectionContext(authGuard); 21 | loggedIn = store.overrideSelector(fromAuth.selectLoggedIn, false); 22 | }); 23 | 24 | it('should return false if the user state is not logged in', () => { 25 | const expected = cold('(a|)', { a: false }); 26 | 27 | expect(guard).toBeObservable(expected); 28 | }); 29 | 30 | it('should return true if the user state is logged in', () => { 31 | const expected = cold('(a|)', { a: true }); 32 | 33 | loggedIn.setResult(true); 34 | 35 | expect(guard).toBeObservable(expected); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/app/auth/services/auth-guard.service.ts: -------------------------------------------------------------------------------- 1 | import { inject } from '@angular/core'; 2 | import { Store } from '@ngrx/store'; 3 | import { Observable } from 'rxjs'; 4 | import { map, take } from 'rxjs/operators'; 5 | import { AuthApiActions } from '@example-app/auth/actions'; 6 | import * as fromAuth from '@example-app/auth/reducers'; 7 | 8 | export const authGuard = (): Observable => { 9 | const store = inject(Store); 10 | 11 | return store.select(fromAuth.selectLoggedIn).pipe( 12 | map((authed) => { 13 | if (!authed) { 14 | store.dispatch(AuthApiActions.loginRedirect()); 15 | return false; 16 | } 17 | 18 | return true; 19 | }), 20 | take(1) 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /src/app/auth/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Observable, of, throwError } from 'rxjs'; 3 | 4 | import { Credentials, User } from '@example-app/auth/models'; 5 | 6 | @Injectable({ 7 | providedIn: 'root', 8 | }) 9 | export class AuthService { 10 | login({ username, password }: Credentials): Observable { 11 | /** 12 | * Simulate a failed login to display the error 13 | * message for the login form. 14 | */ 15 | if (username !== 'test' && username !== 'ngrx') { 16 | return throwError(() => 'Invalid username or password'); 17 | } 18 | 19 | return of({ name: 'User' }); 20 | } 21 | 22 | logout() { 23 | return of(true); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/auth/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth.service'; 2 | export * from './auth-guard.service'; 3 | -------------------------------------------------------------------------------- /src/app/books/actions/book.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from '@ngrx/store'; 2 | 3 | import { Book } from '@example-app/books/models'; 4 | 5 | export const loadBook = createAction( 6 | '[Book Exists Guard] Load Book', 7 | props<{ book: Book }>() 8 | ); 9 | -------------------------------------------------------------------------------- /src/app/books/actions/books-api.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from '@ngrx/store'; 2 | 3 | import { Book } from '@example-app/books/models'; 4 | 5 | export const searchSuccess = createAction( 6 | '[Books/API] Search Success', 7 | props<{ books: Book[] }>() 8 | ); 9 | 10 | export const searchFailure = createAction( 11 | '[Books/API] Search Failure', 12 | props<{ errorMsg: string }>() 13 | ); 14 | -------------------------------------------------------------------------------- /src/app/books/actions/collection-api.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from '@ngrx/store'; 2 | 3 | import { Book } from '@example-app/books/models'; 4 | 5 | /** 6 | * Add Book to Collection Actions 7 | */ 8 | export const addBookSuccess = createAction( 9 | '[Collection/API] Add Book Success', 10 | props<{ book: Book }>() 11 | ); 12 | 13 | export const addBookFailure = createAction( 14 | '[Collection/API] Add Book Failure', 15 | props<{ book: Book }>() 16 | ); 17 | 18 | /** 19 | * Remove Book from Collection Actions 20 | */ 21 | export const removeBookSuccess = createAction( 22 | '[Collection/API] Remove Book Success', 23 | props<{ book: Book }>() 24 | ); 25 | 26 | export const removeBookFailure = createAction( 27 | '[Collection/API] Remove Book Failure', 28 | props<{ book: Book }>() 29 | ); 30 | 31 | /** 32 | * Load Collection Actions 33 | */ 34 | export const loadBooksSuccess = createAction( 35 | '[Collection/API] Load Books Success', 36 | props<{ books: Book[] }>() 37 | ); 38 | 39 | export const loadBooksFailure = createAction( 40 | '[Collection/API] Load Books Failure', 41 | props<{ error: any }>() 42 | ); 43 | -------------------------------------------------------------------------------- /src/app/books/actions/collection-page.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction } from '@ngrx/store'; 2 | 3 | /** 4 | * Load Collection Action 5 | */ 6 | export const enter = createAction('[Collection Page] Enter'); 7 | -------------------------------------------------------------------------------- /src/app/books/actions/find-book-page.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from '@ngrx/store'; 2 | 3 | export const searchBooks = createAction( 4 | '[Find Book Page] Search Books', 5 | props<{ query: string }>() 6 | ); 7 | -------------------------------------------------------------------------------- /src/app/books/actions/index.ts: -------------------------------------------------------------------------------- 1 | import * as BookActions from './book.actions'; 2 | import * as BooksApiActions from './books-api.actions'; 3 | import * as CollectionApiActions from './collection-api.actions'; 4 | import * as CollectionPageActions from './collection-page.actions'; 5 | import * as FindBookPageActions from './find-book-page.actions'; 6 | import * as SelectedBookPageActions from './selected-book-page.actions'; 7 | import * as ViewBookPageActions from './view-book-page.actions'; 8 | 9 | export { 10 | BookActions, 11 | BooksApiActions, 12 | CollectionApiActions, 13 | CollectionPageActions, 14 | FindBookPageActions, 15 | SelectedBookPageActions, 16 | ViewBookPageActions, 17 | }; 18 | -------------------------------------------------------------------------------- /src/app/books/actions/selected-book-page.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from '@ngrx/store'; 2 | 3 | import { Book } from '@example-app/books/models'; 4 | 5 | /** 6 | * Add Book to Collection Action 7 | */ 8 | export const addBook = createAction( 9 | '[Selected Book Page] Add Book', 10 | props<{ book: Book }>() 11 | ); 12 | 13 | /** 14 | * Remove Book from Collection Action 15 | */ 16 | export const removeBook = createAction( 17 | '[Selected Book Page] Remove Book', 18 | props<{ book: Book }>() 19 | ); 20 | -------------------------------------------------------------------------------- /src/app/books/actions/view-book-page.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from '@ngrx/store'; 2 | 3 | export const selectBook = createAction( 4 | '[View Book Page] Select Book', 5 | props<{ id: string }>() 6 | ); 7 | -------------------------------------------------------------------------------- /src/app/books/books-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | 4 | import { 5 | CollectionPageComponent, 6 | FindBookPageComponent, 7 | ViewBookPageComponent, 8 | } from '@example-app/books/containers'; 9 | import { bookExistsGuard } from '@example-app/books/guards'; 10 | 11 | export const routes: Routes = [ 12 | { 13 | path: 'find', 14 | component: FindBookPageComponent, 15 | data: { title: 'Find book' }, 16 | }, 17 | { 18 | path: ':id', 19 | component: ViewBookPageComponent, 20 | canActivate: [bookExistsGuard], 21 | data: { title: 'Book details' }, 22 | }, 23 | { 24 | path: '', 25 | component: CollectionPageComponent, 26 | data: { title: 'Collection' }, 27 | }, 28 | ]; 29 | 30 | @NgModule({ 31 | imports: [RouterModule.forChild(routes)], 32 | exports: [RouterModule], 33 | }) 34 | export class BooksRoutingModule {} 35 | -------------------------------------------------------------------------------- /src/app/books/books.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { NgModule } from '@angular/core'; 3 | 4 | import { EffectsModule } from '@ngrx/effects'; 5 | import { StoreModule } from '@ngrx/store'; 6 | 7 | import { BooksRoutingModule } from '@example-app/books/books-routing.module'; 8 | import { 9 | BookAuthorsComponent, 10 | BookDetailComponent, 11 | BookPreviewComponent, 12 | BookPreviewListComponent, 13 | BookSearchComponent, 14 | } from '@example-app/books/components'; 15 | import { 16 | CollectionPageComponent, 17 | FindBookPageComponent, 18 | SelectedBookPageComponent, 19 | ViewBookPageComponent, 20 | } from '@example-app/books/containers'; 21 | import { BookEffects, CollectionEffects } from '@example-app/books/effects'; 22 | 23 | import * as fromBooks from '@example-app/books/reducers'; 24 | import { MaterialModule } from '@example-app/material'; 25 | import { PipesModule } from '@example-app/shared/pipes'; 26 | 27 | export const COMPONENTS = [ 28 | BookAuthorsComponent, 29 | BookDetailComponent, 30 | BookPreviewComponent, 31 | BookPreviewListComponent, 32 | BookSearchComponent, 33 | ]; 34 | 35 | export const CONTAINERS = [ 36 | FindBookPageComponent, 37 | ViewBookPageComponent, 38 | SelectedBookPageComponent, 39 | CollectionPageComponent, 40 | ]; 41 | 42 | @NgModule({ 43 | imports: [ 44 | CommonModule, 45 | MaterialModule, 46 | BooksRoutingModule, 47 | 48 | /** 49 | * StoreModule.forFeature is used for composing state 50 | * from feature modules. These modules can be loaded 51 | * eagerly or lazily and will be dynamically added to 52 | * the existing state. 53 | */ 54 | StoreModule.forFeature(fromBooks.booksFeatureKey, fromBooks.reducers), 55 | 56 | /** 57 | * Effects.forFeature is used to register effects 58 | * from feature modules. Effects can be loaded 59 | * eagerly or lazily and will be started immediately. 60 | * 61 | * All Effects will only be instantiated once regardless of 62 | * whether they are registered once or multiple times. 63 | */ 64 | EffectsModule.forFeature(BookEffects, CollectionEffects), 65 | PipesModule, 66 | ], 67 | declarations: [COMPONENTS, CONTAINERS], 68 | }) 69 | export class BooksModule {} 70 | -------------------------------------------------------------------------------- /src/app/books/components/book-authors.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | import { Book } from '@example-app/books/models'; 4 | 5 | @Component({ 6 | selector: 'bc-book-authors', 7 | template: ` 8 |
Written By:
9 | 10 | {{ authors | bcAddCommas }} 11 | 12 | `, 13 | styles: [ 14 | ` 15 | h5 { 16 | margin-bottom: 5px; 17 | } 18 | `, 19 | ], 20 | }) 21 | export class BookAuthorsComponent { 22 | @Input() book!: Book; 23 | 24 | get authors() { 25 | return this.book.volumeInfo.authors; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/app/books/components/book-detail.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, Output } from '@angular/core'; 2 | 3 | import { Book } from '@example-app/books/models'; 4 | 5 | @Component({ 6 | selector: 'bc-book-detail', 7 | template: ` 8 | 9 | 10 | {{ title }} 11 | {{ subtitle }} 12 | 13 | 14 | 15 |

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

{{ description | bcEllipsis }}

24 |
25 | 26 | 27 | 28 |
29 |
30 | `, 31 | styles: [ 32 | ` 33 | :host, 34 | a { 35 | display: flex; 36 | } 37 | 38 | mat-card { 39 | width: 400px; 40 | margin: 1rem; 41 | padding: 1rem; 42 | display: flex; 43 | flex-direction: column; 44 | justify-content: space-between; 45 | } 46 | 47 | @media only screen and (max-width: 768px) { 48 | mat-card { 49 | margin: 1rem 0 !important; 50 | } 51 | } 52 | 53 | mat-card:hover { 54 | box-shadow: 3px 3px 16px -2px rgba(0, 0, 0, 0.5); 55 | } 56 | 57 | a { 58 | color: inherit; 59 | text-decoration: none; 60 | } 61 | 62 | img { 63 | width: 60px; 64 | min-width: 60px; 65 | margin-left: 5px; 66 | } 67 | 68 | span { 69 | display: inline-block; 70 | font-size: 13px; 71 | } 72 | 73 | mat-card-content { 74 | padding: 0; 75 | margin: 1rem 0; 76 | } 77 | `, 78 | ], 79 | }) 80 | export class BookPreviewComponent { 81 | @Input() book!: Book; 82 | 83 | get id() { 84 | return this.book.id; 85 | } 86 | 87 | get title() { 88 | return this.book.volumeInfo.title; 89 | } 90 | 91 | get subtitle() { 92 | return this.book.volumeInfo.subtitle; 93 | } 94 | 95 | get description() { 96 | return this.book.volumeInfo.description; 97 | } 98 | 99 | get thumbnail(): string | boolean { 100 | if (this.book.volumeInfo.imageLinks) { 101 | return this.book.volumeInfo.imageLinks.smallThumbnail.replace( 102 | 'http:', 103 | '' 104 | ); 105 | } 106 | 107 | return false; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/app/books/components/book-search.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Output, Input, EventEmitter } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'bc-book-search', 5 | template: ` 6 | 7 | Find a Book 8 | 9 | 10 | 16 | 17 | 22 | 23 | 24 | {{ error }} 25 | 26 | 27 | `, 28 | styles: [ 29 | ` 30 | mat-card-title, 31 | mat-card-content, 32 | mat-card-footer { 33 | display: flex; 34 | justify-content: center; 35 | } 36 | 37 | mat-card-title { 38 | padding: 1rem; 39 | } 40 | 41 | mat-card-footer { 42 | color: #ff0000; 43 | padding: 5px 0; 44 | } 45 | 46 | .mat-mdc-form-field { 47 | min-width: 300px; 48 | margin-right: 10px; 49 | } 50 | 51 | .mat-mdc-progress-spinner { 52 | position: relative; 53 | top: 10px; 54 | left: 10px; 55 | visibility: hidden; 56 | } 57 | 58 | .mat-mdc-progress-spinner.show { 59 | visibility: visible; 60 | } 61 | `, 62 | ], 63 | }) 64 | export class BookSearchComponent { 65 | @Input() query = ''; 66 | @Input() searching = false; 67 | @Input() error = ''; 68 | @Output() search = new EventEmitter(); 69 | 70 | onSearch(event: KeyboardEvent): void { 71 | this.search.emit((event.target as HTMLInputElement).value); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/app/books/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './book-authors.component'; 2 | export * from './book-detail.component'; 3 | export * from './book-preview.component'; 4 | export * from './book-preview-list.component'; 5 | export * from './book-search.component'; 6 | -------------------------------------------------------------------------------- /src/app/books/containers/__snapshots__/collection-page.component.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Collection Page should compile 1`] = ` 4 | 8 | 11 | 14 | My Collection 15 | 16 | 17 | 18 | 19 | `; 20 | -------------------------------------------------------------------------------- /src/app/books/containers/__snapshots__/find-book-page.component.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Find Book Page should compile 1`] = ` 4 | 11 | 12 | 15 | 18 | Find a Book 19 | 20 | 23 | 26 |
29 |
32 |
35 |
38 | 45 |
46 |
47 |
51 |
52 |
55 |
59 |
62 |
63 |
64 | 65 | 74 | 93 | 153 | 154 | 155 | 158 | 159 | 160 | 161 | 162 | 163 | `; 164 | -------------------------------------------------------------------------------- /src/app/books/containers/__snapshots__/selected-book-page.component.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Selected Book Page should compile 1`] = ` 4 | 9 | 10 | 11 | 12 | `; 13 | -------------------------------------------------------------------------------- /src/app/books/containers/__snapshots__/view-book-page.component.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`View Book Page should compile 1`] = ` 4 | 7 | 8 | 9 | 10 | 11 | 12 | `; 13 | -------------------------------------------------------------------------------- /src/app/books/containers/collection-page.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { NoopAnimationsModule } from '@angular/platform-browser/animations'; 3 | import { RouterTestingModule } from '@angular/router/testing'; 4 | 5 | import { MockStore, provideMockStore } from '@ngrx/store/testing'; 6 | 7 | import { CollectionPageActions } from '@example-app/books/actions'; 8 | import { 9 | BookAuthorsComponent, 10 | BookPreviewComponent, 11 | BookPreviewListComponent, 12 | } from '@example-app/books/components'; 13 | import { CollectionPageComponent } from '@example-app/books/containers'; 14 | import * as fromBooks from '@example-app/books/reducers'; 15 | import { AddCommasPipe } from '@example-app/shared/pipes/add-commas.pipe'; 16 | import { EllipsisPipe } from '@example-app/shared/pipes/ellipsis.pipe'; 17 | import { MaterialModule } from '@example-app/material'; 18 | 19 | describe('Collection Page', () => { 20 | let fixture: ComponentFixture; 21 | let store: MockStore; 22 | 23 | beforeEach(() => { 24 | TestBed.configureTestingModule({ 25 | imports: [NoopAnimationsModule, MaterialModule, RouterTestingModule], 26 | declarations: [ 27 | CollectionPageComponent, 28 | BookPreviewListComponent, 29 | BookPreviewComponent, 30 | BookAuthorsComponent, 31 | AddCommasPipe, 32 | EllipsisPipe, 33 | ], 34 | providers: [ 35 | provideMockStore({ 36 | selectors: [{ selector: fromBooks.selectBookCollection, value: [] }], 37 | }), 38 | ], 39 | }); 40 | 41 | fixture = TestBed.createComponent(CollectionPageComponent); 42 | store = TestBed.inject(MockStore); 43 | 44 | jest.spyOn(store, 'dispatch'); 45 | }); 46 | 47 | it('should compile', () => { 48 | fixture.detectChanges(); 49 | 50 | expect(fixture).toMatchSnapshot(); 51 | }); 52 | 53 | it('should dispatch a collection.Load on init', () => { 54 | const action = CollectionPageActions.enter(); 55 | 56 | fixture.detectChanges(); 57 | 58 | expect(store.dispatch).toHaveBeenCalledWith(action); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/app/books/containers/collection-page.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; 2 | 3 | import { Store } from '@ngrx/store'; 4 | 5 | import { CollectionPageActions } from '@example-app/books/actions'; 6 | import * as fromBooks from '@example-app/books/reducers'; 7 | 8 | @Component({ 9 | selector: 'bc-collection-page', 10 | changeDetection: ChangeDetectionStrategy.OnPush, 11 | template: ` 12 | 13 | My Collection 14 | 15 | 16 | 17 | `, 18 | /** 19 | * Container components are permitted to have just enough styles 20 | * to bring the view together. If the number of styles grow, 21 | * consider breaking them out into presentational 22 | * components. 23 | */ 24 | styles: [ 25 | ` 26 | mat-card-title { 27 | display: flex; 28 | justify-content: center; 29 | padding: 1rem; 30 | } 31 | `, 32 | ], 33 | }) 34 | export class CollectionPageComponent implements OnInit { 35 | books = this.store.selectSignal(fromBooks.selectBookCollection); 36 | 37 | constructor(private store: Store) {} 38 | 39 | ngOnInit() { 40 | this.store.dispatch(CollectionPageActions.enter()); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/app/books/containers/find-book-page.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { ReactiveFormsModule } from '@angular/forms'; 3 | import { NoopAnimationsModule } from '@angular/platform-browser/animations'; 4 | import { RouterTestingModule } from '@angular/router/testing'; 5 | 6 | import { MockStore, provideMockStore } from '@ngrx/store/testing'; 7 | 8 | import { FindBookPageActions } from '@example-app/books/actions'; 9 | import { 10 | BookAuthorsComponent, 11 | BookPreviewComponent, 12 | BookPreviewListComponent, 13 | BookSearchComponent, 14 | } from '@example-app/books/components'; 15 | import { FindBookPageComponent } from '@example-app/books/containers'; 16 | import * as fromBooks from '@example-app/books/reducers'; 17 | import { AddCommasPipe } from '@example-app/shared/pipes/add-commas.pipe'; 18 | import { EllipsisPipe } from '@example-app/shared/pipes/ellipsis.pipe'; 19 | import { MaterialModule } from '@example-app/material'; 20 | 21 | describe('Find Book Page', () => { 22 | let fixture: ComponentFixture; 23 | let store: MockStore; 24 | let instance: FindBookPageComponent; 25 | 26 | beforeEach(() => { 27 | TestBed.configureTestingModule({ 28 | imports: [ 29 | NoopAnimationsModule, 30 | RouterTestingModule, 31 | MaterialModule, 32 | ReactiveFormsModule, 33 | ], 34 | declarations: [ 35 | FindBookPageComponent, 36 | BookSearchComponent, 37 | BookPreviewComponent, 38 | BookPreviewListComponent, 39 | BookAuthorsComponent, 40 | AddCommasPipe, 41 | EllipsisPipe, 42 | ], 43 | providers: [ 44 | provideMockStore({ 45 | selectors: [ 46 | { selector: fromBooks.selectSearchQuery, value: '' }, 47 | { selector: fromBooks.selectSearchResults, value: [] }, 48 | { selector: fromBooks.selectSearchLoading, value: false }, 49 | { selector: fromBooks.selectSearchError, value: '' }, 50 | ], 51 | }), 52 | ], 53 | }); 54 | 55 | fixture = TestBed.createComponent(FindBookPageComponent); 56 | instance = fixture.componentInstance; 57 | store = TestBed.inject(MockStore); 58 | 59 | jest.spyOn(store, 'dispatch'); 60 | }); 61 | 62 | it('should compile', () => { 63 | fixture.detectChanges(); 64 | 65 | expect(fixture).toMatchSnapshot(); 66 | }); 67 | 68 | it('should dispatch a book.Search action on search', () => { 69 | const $event = 'book name'; 70 | const action = FindBookPageActions.searchBooks({ query: $event }); 71 | 72 | instance.search($event); 73 | 74 | expect(store.dispatch).toHaveBeenCalledWith(action); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /src/app/books/containers/find-book-page.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, computed } from '@angular/core'; 2 | 3 | import { Store } from '@ngrx/store'; 4 | import { Observable } from 'rxjs'; 5 | import { take } from 'rxjs/operators'; 6 | 7 | import { FindBookPageActions } from '@example-app/books/actions'; 8 | import * as fromBooks from '@example-app/books/reducers'; 9 | 10 | @Component({ 11 | selector: 'bc-find-book-page', 12 | changeDetection: ChangeDetectionStrategy.OnPush, 13 | template: ` 14 | 20 | 21 | 22 | 23 | `, 24 | }) 25 | export class FindBookPageComponent { 26 | searchQuery$: Observable; 27 | 28 | vm = computed(() => { 29 | const books = this.store.selectSignal(fromBooks.selectSearchResults); 30 | const loading = this.store.selectSignal(fromBooks.selectSearchLoading); 31 | const error = this.store.selectSignal(fromBooks.selectSearchError); 32 | 33 | return { 34 | books: books(), 35 | loading: loading(), 36 | error: error() 37 | }; 38 | }); 39 | 40 | constructor(private store: Store) { 41 | this.searchQuery$ = store.select(fromBooks.selectSearchQuery).pipe(take(1)); 42 | } 43 | 44 | search(query: string) { 45 | this.store.dispatch(FindBookPageActions.searchBooks({ query })); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/app/books/containers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './collection-page.component'; 2 | export * from './find-book-page.component'; 3 | export * from './selected-book-page.component'; 4 | export * from './view-book-page.component'; 5 | -------------------------------------------------------------------------------- /src/app/books/containers/selected-book-page.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { NoopAnimationsModule } from '@angular/platform-browser/animations'; 3 | 4 | import { MockStore, provideMockStore } from '@ngrx/store/testing'; 5 | 6 | import { SelectedBookPageActions } from '@example-app/books/actions'; 7 | import { 8 | BookAuthorsComponent, 9 | BookDetailComponent, 10 | } from '@example-app/books/components'; 11 | import { SelectedBookPageComponent } from '@example-app/books/containers'; 12 | import { Book, generateMockBook } from '@example-app/books/models'; 13 | import { AddCommasPipe } from '@example-app/shared/pipes/add-commas.pipe'; 14 | import { MaterialModule } from '@example-app/material'; 15 | 16 | describe('Selected Book Page', () => { 17 | let fixture: ComponentFixture; 18 | let store: MockStore; 19 | let instance: SelectedBookPageComponent; 20 | 21 | beforeEach(() => { 22 | TestBed.configureTestingModule({ 23 | imports: [NoopAnimationsModule, MaterialModule], 24 | declarations: [ 25 | SelectedBookPageComponent, 26 | BookDetailComponent, 27 | BookAuthorsComponent, 28 | AddCommasPipe, 29 | ], 30 | providers: [provideMockStore()], 31 | }); 32 | 33 | fixture = TestBed.createComponent(SelectedBookPageComponent); 34 | instance = fixture.componentInstance; 35 | store = TestBed.inject(MockStore); 36 | 37 | jest.spyOn(store, 'dispatch'); 38 | }); 39 | 40 | it('should compile', () => { 41 | fixture.detectChanges(); 42 | 43 | expect(fixture).toMatchSnapshot(); 44 | }); 45 | 46 | it('should dispatch a collection.AddBook action when addToCollection is called', () => { 47 | const $event: Book = generateMockBook(); 48 | const action = SelectedBookPageActions.addBook({ book: $event }); 49 | 50 | instance.addToCollection($event); 51 | 52 | expect(store.dispatch).toHaveBeenLastCalledWith(action); 53 | }); 54 | 55 | it('should dispatch a collection.RemoveBook action on removeFromCollection', () => { 56 | const $event: Book = generateMockBook(); 57 | const action = SelectedBookPageActions.removeBook({ book: $event }); 58 | 59 | instance.removeFromCollection($event); 60 | 61 | expect(store.dispatch).toHaveBeenLastCalledWith(action); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/app/books/containers/selected-book-page.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, Signal } from '@angular/core'; 2 | 3 | import { Store } from '@ngrx/store'; 4 | 5 | import { SelectedBookPageActions } from '@example-app/books/actions'; 6 | import { Book } from '@example-app/books/models'; 7 | import * as fromBooks from '@example-app/books/reducers'; 8 | 9 | @Component({ 10 | selector: 'bc-selected-book-page', 11 | changeDetection: ChangeDetectionStrategy.OnPush, 12 | template: ` 13 | 19 | 20 | `, 21 | }) 22 | export class SelectedBookPageComponent { 23 | book: Signal; 24 | isSelectedBookInCollection: Signal; 25 | 26 | constructor(private store: Store) { 27 | this.book = store.selectSignal(fromBooks.selectSelectedBook) as Signal; 28 | this.isSelectedBookInCollection = store.selectSignal( 29 | fromBooks.isSelectedBookInCollection 30 | ); 31 | } 32 | 33 | addToCollection(book: Book) { 34 | this.store.dispatch(SelectedBookPageActions.addBook({ book })); 35 | } 36 | 37 | removeFromCollection(book: Book) { 38 | this.store.dispatch(SelectedBookPageActions.removeBook({ book })); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/app/books/containers/view-book-page.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { ActivatedRoute } from '@angular/router'; 3 | 4 | import { provideMockStore, MockStore } from '@ngrx/store/testing'; 5 | import { BehaviorSubject } from 'rxjs'; 6 | 7 | import { 8 | BookAuthorsComponent, 9 | BookDetailComponent, 10 | } from '@example-app/books/components'; 11 | import { SelectedBookPageComponent } from '@example-app/books/containers'; 12 | import { ViewBookPageComponent } from '@example-app/books/containers'; 13 | import { ViewBookPageActions } from '@example-app/books/actions'; 14 | import { AddCommasPipe } from '@example-app/shared/pipes/add-commas.pipe'; 15 | import { MaterialModule } from '@example-app/material'; 16 | 17 | describe('View Book Page', () => { 18 | let fixture: ComponentFixture; 19 | let store: MockStore; 20 | let route: ActivatedRoute; 21 | 22 | beforeEach(() => { 23 | TestBed.configureTestingModule({ 24 | imports: [MaterialModule], 25 | providers: [ 26 | { 27 | provide: ActivatedRoute, 28 | useValue: { params: new BehaviorSubject({}) }, 29 | }, 30 | provideMockStore(), 31 | ], 32 | declarations: [ 33 | ViewBookPageComponent, 34 | SelectedBookPageComponent, 35 | BookDetailComponent, 36 | BookAuthorsComponent, 37 | AddCommasPipe, 38 | ], 39 | }); 40 | 41 | fixture = TestBed.createComponent(ViewBookPageComponent); 42 | store = TestBed.inject(MockStore); 43 | route = TestBed.inject(ActivatedRoute); 44 | 45 | jest.spyOn(store, 'dispatch'); 46 | }); 47 | 48 | it('should compile', () => { 49 | fixture.detectChanges(); 50 | 51 | expect(fixture).toMatchSnapshot(); 52 | }); 53 | 54 | it('should dispatch a book.Select action on init', () => { 55 | const action = ViewBookPageActions.selectBook({ id: '2' }); 56 | 57 | (route.params as BehaviorSubject).next({ id: '2' }); 58 | 59 | expect(store.dispatch).toHaveBeenLastCalledWith(action); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/app/books/containers/view-book-page.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | ChangeDetectionStrategy, 4 | effect, 5 | Signal, 6 | Input as RouteInput 7 | } from '@angular/core'; 8 | import { toSignal } from '@angular/core/rxjs-interop'; 9 | import { ActivatedRoute } from '@angular/router'; 10 | import { Store } from '@ngrx/store'; 11 | import { map } from 'rxjs/operators'; 12 | 13 | import { ViewBookPageActions } from '@example-app/books/actions'; 14 | 15 | /** 16 | * Note: Container components are also reusable. Whether or not 17 | * a component is a presentation component or a container 18 | * component is an implementation detail. 19 | * 20 | * The View Book Page's responsibility is to map router params 21 | * to a 'Select' book action. Actually showing the selected 22 | * book remains a responsibility of the 23 | * SelectedBookPageComponent 24 | */ 25 | @Component({ 26 | selector: 'bc-view-book-page', 27 | changeDetection: ChangeDetectionStrategy.OnPush, 28 | template: ` `, 29 | }) 30 | export class ViewBookPageComponent { 31 | // id: Signal = toSignal( 32 | // this.route.paramMap.pipe(map((params) => params.get('id'))) 33 | // ); 34 | @RouteInput('id') set bookId(id: string) { 35 | const action = ViewBookPageActions.selectBook({ id }); 36 | this.store.dispatch(action); 37 | } 38 | 39 | constructor(private store: Store, private route: ActivatedRoute) { 40 | // effect(() => { 41 | // const id = this.id() as string; 42 | // const action = ViewBookPageActions.selectBook({ id }); 43 | // this.store.dispatch(action); 44 | // }, { allowSignalWrites: true }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/app/books/effects/book.effects.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { Actions } from '@ngrx/effects'; 4 | import { provideMockActions } from '@ngrx/effects/testing'; 5 | import { cold, getTestScheduler, hot } from 'jasmine-marbles'; 6 | import { Observable } from 'rxjs'; 7 | 8 | import { 9 | BooksApiActions, 10 | FindBookPageActions, 11 | } from '@example-app/books/actions'; 12 | import { BookEffects } from '@example-app/books/effects'; 13 | import { Book } from '@example-app/books/models'; 14 | import { GoogleBooksService } from '@example-app/core/services'; 15 | 16 | describe('BookEffects', () => { 17 | let effects: BookEffects; 18 | let googleBooksService: any; 19 | let actions$: Observable; 20 | 21 | beforeEach(() => { 22 | TestBed.configureTestingModule({ 23 | providers: [ 24 | BookEffects, 25 | { 26 | provide: GoogleBooksService, 27 | useValue: { searchBooks: jest.fn() }, 28 | }, 29 | provideMockActions(() => actions$), 30 | ], 31 | }); 32 | 33 | effects = TestBed.inject(BookEffects); 34 | googleBooksService = TestBed.inject(GoogleBooksService); 35 | actions$ = TestBed.inject(Actions); 36 | }); 37 | 38 | describe('search$', () => { 39 | it('should return a book.SearchComplete, with the books, on success, after the de-bounce', () => { 40 | const book1 = { id: '111', volumeInfo: {} } as Book; 41 | const book2 = { id: '222', volumeInfo: {} } as Book; 42 | const books = [book1, book2]; 43 | const action = FindBookPageActions.searchBooks({ query: 'query' }); 44 | const completion = BooksApiActions.searchSuccess({ books }); 45 | 46 | actions$ = hot('-a---', { a: action }); 47 | const response = cold('-a|', { a: books }); 48 | const expected = cold('-----b', { b: completion }); 49 | googleBooksService.searchBooks = jest.fn(() => response); 50 | 51 | expect( 52 | effects.search$({ 53 | debounce: 30, 54 | scheduler: getTestScheduler(), 55 | }) 56 | ).toBeObservable(expected); 57 | }); 58 | 59 | it('should return a book.SearchError if the books service throws', () => { 60 | const action = FindBookPageActions.searchBooks({ query: 'query' }); 61 | const completion = BooksApiActions.searchFailure({ 62 | errorMsg: 'Unexpected Error. Try again later.', 63 | }); 64 | const error = { message: 'Unexpected Error. Try again later.' }; 65 | 66 | actions$ = hot('-a---', { a: action }); 67 | const response = cold('-#|', {}, error); 68 | const expected = cold('-----b', { b: completion }); 69 | googleBooksService.searchBooks = jest.fn(() => response); 70 | 71 | expect( 72 | effects.search$({ 73 | debounce: 30, 74 | scheduler: getTestScheduler(), 75 | }) 76 | ).toBeObservable(expected); 77 | }); 78 | 79 | it(`should not do anything if the query is an empty string`, () => { 80 | const action = FindBookPageActions.searchBooks({ query: '' }); 81 | 82 | actions$ = hot('-a---', { a: action }); 83 | const expected = cold('---'); 84 | 85 | expect( 86 | effects.search$({ 87 | debounce: 30, 88 | scheduler: getTestScheduler(), 89 | }) 90 | ).toBeObservable(expected); 91 | }); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /src/app/books/effects/book.effects.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { Actions, createEffect, ofType } from '@ngrx/effects'; 4 | import { asyncScheduler, EMPTY as empty, of } from 'rxjs'; 5 | import { 6 | catchError, 7 | debounceTime, 8 | map, 9 | skip, 10 | switchMap, 11 | takeUntil, 12 | } from 'rxjs/operators'; 13 | 14 | import { Book } from '@example-app/books/models'; 15 | import { 16 | BooksApiActions, 17 | FindBookPageActions, 18 | } from '@example-app/books/actions'; 19 | import { GoogleBooksService } from '@example-app/core/services'; 20 | 21 | /** 22 | * Effects offer a way to isolate and easily test side-effects within your 23 | * application. 24 | * 25 | * If you are unfamiliar with the operators being used in these examples, please 26 | * check out the sources below: 27 | * 28 | * Official Docs: http://reactivex.io/rxjs/manual/overview.html#categories-of-operators 29 | * RxJS 5 Operators By Example: https://gist.github.com/btroncone/d6cf141d6f2c00dc6b35 30 | */ 31 | 32 | @Injectable() 33 | export class BookEffects { 34 | search$ = createEffect( 35 | () => 36 | ({ debounce = 300, scheduler = asyncScheduler } = {}) => 37 | this.actions$.pipe( 38 | ofType(FindBookPageActions.searchBooks), 39 | debounceTime(debounce, scheduler), 40 | switchMap(({ query }) => { 41 | if (query === '') { 42 | return empty; 43 | } 44 | 45 | const nextSearch$ = this.actions$.pipe( 46 | ofType(FindBookPageActions.searchBooks), 47 | skip(1) 48 | ); 49 | 50 | return this.googleBooks.searchBooks(query).pipe( 51 | takeUntil(nextSearch$), 52 | map((books: Book[]) => BooksApiActions.searchSuccess({ books })), 53 | catchError((err) => 54 | of(BooksApiActions.searchFailure({ errorMsg: err.message })) 55 | ) 56 | ); 57 | }) 58 | ) 59 | ); 60 | 61 | constructor( 62 | private actions$: Actions, 63 | private googleBooks: GoogleBooksService 64 | ) {} 65 | } 66 | -------------------------------------------------------------------------------- /src/app/books/effects/collection.effects.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { 4 | CollectionApiActions, 5 | CollectionPageActions, 6 | SelectedBookPageActions, 7 | } from '@example-app/books/actions'; 8 | import { CollectionEffects } from '@example-app/books/effects'; 9 | import { Book } from '@example-app/books/models'; 10 | import { 11 | BookStorageService, 12 | LOCAL_STORAGE_TOKEN, 13 | } from '@example-app/core/services'; 14 | import { Actions } from '@ngrx/effects'; 15 | import { provideMockActions } from '@ngrx/effects/testing'; 16 | import { cold, hot } from 'jasmine-marbles'; 17 | import { Observable } from 'rxjs'; 18 | 19 | describe('CollectionEffects', () => { 20 | let db: any; 21 | let effects: CollectionEffects; 22 | let actions$: Observable; 23 | 24 | const book1 = { id: '111', volumeInfo: {} } as Book; 25 | const book2 = { id: '222', volumeInfo: {} } as Book; 26 | 27 | beforeEach(() => { 28 | TestBed.configureTestingModule({ 29 | providers: [ 30 | CollectionEffects, 31 | { 32 | provide: BookStorageService, 33 | useValue: { 34 | supported: jest.fn(), 35 | deleteStoredCollection: jest.fn(), 36 | addToCollection: jest.fn(), 37 | getCollection: jest.fn(), 38 | removeFromCollection: jest.fn(), 39 | }, 40 | }, 41 | { 42 | provide: LOCAL_STORAGE_TOKEN, 43 | useValue: { 44 | removeItem: jest.fn(), 45 | setItem: jest.fn(), 46 | getItem: jest.fn((_) => JSON.stringify([])), 47 | }, 48 | }, 49 | provideMockActions(() => actions$), 50 | ], 51 | }); 52 | 53 | db = TestBed.inject(BookStorageService); 54 | effects = TestBed.inject(CollectionEffects); 55 | actions$ = TestBed.inject(Actions); 56 | }); 57 | describe('checkStorageSupport$', () => { 58 | it('should call db.checkStorageSupport when initially subscribed to', () => { 59 | effects.checkStorageSupport$.subscribe(); 60 | expect(db.supported).toHaveBeenCalled(); 61 | }); 62 | }); 63 | describe('loadCollection$', () => { 64 | it('should return a collection.LoadSuccess, with the books, on success', () => { 65 | const action = CollectionPageActions.enter(); 66 | const completion = CollectionApiActions.loadBooksSuccess({ 67 | books: [book1, book2], 68 | }); 69 | 70 | actions$ = hot('-a', { a: action }); 71 | const response = cold('-a|', { a: [book1, book2] }); 72 | const expected = cold('--c', { c: completion }); 73 | db.getCollection = jest.fn(() => response); 74 | 75 | expect(effects.loadCollection$).toBeObservable(expected); 76 | }); 77 | 78 | it('should return a collection.LoadFail, if the query throws', () => { 79 | const action = CollectionPageActions.enter(); 80 | const error = 'Error!'; 81 | const completion = CollectionApiActions.loadBooksFailure({ error }); 82 | 83 | actions$ = hot('-a', { a: action }); 84 | const response = cold('-#', {}, error); 85 | const expected = cold('--c', { c: completion }); 86 | db.getCollection = jest.fn(() => response); 87 | 88 | expect(effects.loadCollection$).toBeObservable(expected); 89 | }); 90 | }); 91 | 92 | describe('addBookToCollection$', () => { 93 | it('should return a collection.AddBookSuccess, with the book, on success', () => { 94 | const action = SelectedBookPageActions.addBook({ book: book1 }); 95 | const completion = CollectionApiActions.addBookSuccess({ book: book1 }); 96 | 97 | actions$ = hot('-a', { a: action }); 98 | const response = cold('-b', { b: true }); 99 | const expected = cold('--c', { c: completion }); 100 | db.addToCollection = jest.fn(() => response); 101 | 102 | expect(effects.addBookToCollection$).toBeObservable(expected); 103 | expect(db.addToCollection).toHaveBeenCalledWith([book1]); 104 | }); 105 | 106 | it('should return a collection.AddBookFail, with the book, when the db insert throws', () => { 107 | const action = SelectedBookPageActions.addBook({ book: book1 }); 108 | const completion = CollectionApiActions.addBookFailure({ book: book1 }); 109 | const error = 'Error!'; 110 | 111 | actions$ = hot('-a', { a: action }); 112 | const response = cold('-#', {}, error); 113 | const expected = cold('--c', { c: completion }); 114 | db.addToCollection = jest.fn(() => response); 115 | 116 | expect(effects.addBookToCollection$).toBeObservable(expected); 117 | }); 118 | 119 | describe('removeBookFromCollection$', () => { 120 | it('should return a collection.RemoveBookSuccess, with the book, on success', () => { 121 | const action = SelectedBookPageActions.removeBook({ book: book1 }); 122 | const completion = CollectionApiActions.removeBookSuccess({ 123 | book: book1, 124 | }); 125 | 126 | actions$ = hot('-a', { a: action }); 127 | const response = cold('-b', { b: true }); 128 | const expected = cold('--c', { c: completion }); 129 | db.removeFromCollection = jest.fn(() => response); 130 | 131 | expect(effects.removeBookFromCollection$).toBeObservable(expected); 132 | expect(db.removeFromCollection).toHaveBeenCalledWith([book1.id]); 133 | }); 134 | 135 | it('should return a collection.RemoveBookFail, with the book, when the db insert throws', () => { 136 | const action = SelectedBookPageActions.removeBook({ book: book1 }); 137 | const completion = CollectionApiActions.removeBookFailure({ 138 | book: book1, 139 | }); 140 | const error = 'Error!'; 141 | 142 | actions$ = hot('-a', { a: action }); 143 | const response = cold('-#', {}, error); 144 | const expected = cold('--c', { c: completion }); 145 | db.removeFromCollection = jest.fn(() => response); 146 | 147 | expect(effects.removeBookFromCollection$).toBeObservable(expected); 148 | expect(db.removeFromCollection).toHaveBeenCalledWith([book1.id]); 149 | }); 150 | }); 151 | }); 152 | }); 153 | -------------------------------------------------------------------------------- /src/app/books/effects/collection.effects.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { Actions, createEffect, ofType } from '@ngrx/effects'; 4 | import { defer, of } from 'rxjs'; 5 | import { catchError, map, mergeMap, switchMap } from 'rxjs/operators'; 6 | 7 | import { 8 | CollectionApiActions, 9 | CollectionPageActions, 10 | SelectedBookPageActions, 11 | } from '@example-app/books/actions'; 12 | import { Book } from '@example-app/books/models'; 13 | import { BookStorageService } from '@example-app/core/services'; 14 | 15 | @Injectable() 16 | export class CollectionEffects { 17 | /** 18 | * This effect does not yield any actions back to the store. Set 19 | * `dispatch` to false to hint to @ngrx/effects that it should 20 | * ignore any elements of this effect stream. 21 | * 22 | * The `defer` observable accepts an observable factory function 23 | * that is called when the observable is subscribed to. 24 | * Wrapping the supported call in `defer` makes 25 | * effect easier to test. 26 | */ 27 | checkStorageSupport$ = createEffect( 28 | () => defer(() => this.storageService.supported()), 29 | { dispatch: false } 30 | ); 31 | 32 | loadCollection$ = createEffect(() => 33 | this.actions$.pipe( 34 | ofType(CollectionPageActions.enter), 35 | switchMap(() => 36 | this.storageService.getCollection().pipe( 37 | map((books: Book[]) => 38 | CollectionApiActions.loadBooksSuccess({ books }) 39 | ), 40 | catchError((error) => 41 | of(CollectionApiActions.loadBooksFailure({ error })) 42 | ) 43 | ) 44 | ) 45 | ) 46 | ); 47 | 48 | addBookToCollection$ = createEffect(() => 49 | this.actions$.pipe( 50 | ofType(SelectedBookPageActions.addBook), 51 | mergeMap(({ book }) => 52 | this.storageService.addToCollection([book]).pipe( 53 | map(() => CollectionApiActions.addBookSuccess({ book })), 54 | catchError(() => of(CollectionApiActions.addBookFailure({ book }))) 55 | ) 56 | ) 57 | ) 58 | ); 59 | 60 | removeBookFromCollection$ = createEffect(() => 61 | this.actions$.pipe( 62 | ofType(SelectedBookPageActions.removeBook), 63 | mergeMap(({ book }) => 64 | this.storageService.removeFromCollection([book.id]).pipe( 65 | map(() => CollectionApiActions.removeBookSuccess({ book })), 66 | catchError(() => of(CollectionApiActions.removeBookFailure({ book }))) 67 | ) 68 | ) 69 | ) 70 | ); 71 | 72 | constructor( 73 | private actions$: Actions, 74 | private storageService: BookStorageService 75 | ) {} 76 | } 77 | -------------------------------------------------------------------------------- /src/app/books/effects/index.ts: -------------------------------------------------------------------------------- 1 | export * from './book.effects'; 2 | export * from './collection.effects'; 3 | -------------------------------------------------------------------------------- /src/app/books/guards/book-exists.guard.ts: -------------------------------------------------------------------------------- 1 | import { inject } from '@angular/core'; 2 | import { Router, ActivatedRouteSnapshot } from '@angular/router'; 3 | import { Store } from '@ngrx/store'; 4 | import { Observable, of } from 'rxjs'; 5 | import { catchError, filter, map, switchMap, take, tap } from 'rxjs/operators'; 6 | 7 | import { GoogleBooksService } from '@example-app/core/services'; 8 | import { BookActions } from '@example-app/books/actions'; 9 | import * as fromBooks from '@example-app/books/reducers'; 10 | 11 | /** 12 | * Guards are hooks into the route resolution process, providing an opportunity 13 | * to inform the router's navigation process whether the route should continue 14 | * to activate this route. Guards must return an observable of true or false. 15 | */ 16 | 17 | export const bookExistsGuard = ( 18 | route: ActivatedRouteSnapshot 19 | ): Observable => { 20 | const store = inject(Store); 21 | const googleBooks = inject(GoogleBooksService); 22 | const router = inject(Router); 23 | 24 | /** 25 | * This method creates an observable that waits for the `loaded` property 26 | * of the collection state to turn `true`, emitting one time once loading 27 | * has finished. 28 | */ 29 | function waitForCollectionToLoad(): Observable { 30 | return store.select(fromBooks.selectCollectionLoaded).pipe( 31 | filter((loaded) => loaded), 32 | take(1) 33 | ); 34 | } 35 | 36 | /** 37 | * This method checks if a book with the given ID is already registered 38 | * in the Store 39 | */ 40 | function hasBookInStore(id: string): Observable { 41 | return store.select(fromBooks.selectBookEntities).pipe( 42 | map((entities) => !!entities[id]), 43 | take(1) 44 | ); 45 | } 46 | 47 | /** 48 | * This method loads a book with the given ID from the API and caches 49 | * it in the store, returning `true` or `false` if it was found. 50 | */ 51 | function hasBookInApi(id: string): Observable { 52 | return googleBooks.retrieveBook(id).pipe( 53 | map((bookEntity) => BookActions.loadBook({ book: bookEntity })), 54 | tap((action) => store.dispatch(action)), 55 | map((book) => !!book), 56 | catchError(() => { 57 | router.navigate(['/404']); 58 | return of(false); 59 | }) 60 | ); 61 | } 62 | 63 | /** 64 | * `hasBook` composes `hasBookInStore` and `hasBookInApi`. It first checks 65 | * if the book is in store, and if not it then checks if it is in the 66 | * API. 67 | */ 68 | function hasBook(id: string): Observable { 69 | return hasBookInStore(id).pipe( 70 | switchMap((inStore) => { 71 | if (inStore) { 72 | return of(inStore); 73 | } 74 | 75 | return hasBookInApi(id); 76 | }) 77 | ); 78 | } 79 | 80 | /** 81 | * This is the actual method the router will call when our guard is run. 82 | * 83 | * Our guard waits for the collection to load, then it checks if we need 84 | * to request a book from the API or if we already have it in our cache. 85 | * If it finds it in the cache or in the API, it returns an Observable 86 | * of `true` and the route is rendered successfully. 87 | * 88 | * If it was unable to find it in our cache or in the API, this guard 89 | * will return an Observable of `false`, causing the router to move 90 | * on to the next candidate route. In this case, it will move on 91 | * to the 404 page. 92 | */ 93 | return waitForCollectionToLoad().pipe( 94 | switchMap(() => hasBook(route.params['id'])) 95 | ); 96 | }; 97 | -------------------------------------------------------------------------------- /src/app/books/guards/index.ts: -------------------------------------------------------------------------------- 1 | export * from './book-exists.guard'; 2 | -------------------------------------------------------------------------------- /src/app/books/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 | 19 | export function generateMockBook(): Book { 20 | return { 21 | id: '1', 22 | volumeInfo: { 23 | title: 'title', 24 | subtitle: 'subtitle', 25 | authors: ['author'], 26 | publisher: 'publisher', 27 | publishDate: '', 28 | description: 'description', 29 | averageRating: 3, 30 | ratingsCount: 5, 31 | imageLinks: { 32 | thumbnail: 'string', 33 | smallThumbnail: 'string', 34 | }, 35 | }, 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /src/app/books/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './book'; 2 | -------------------------------------------------------------------------------- /src/app/books/reducers/__snapshots__/books.reducer.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`BooksReducer LOAD should add a single book, if the book does not exist 1`] = ` 4 | { 5 | "entities": { 6 | "1": { 7 | "id": "1", 8 | "volumeInfo": { 9 | "authors": [ 10 | "author", 11 | ], 12 | "averageRating": 3, 13 | "description": "description", 14 | "imageLinks": { 15 | "smallThumbnail": "string", 16 | "thumbnail": "string", 17 | }, 18 | "publishDate": "", 19 | "publisher": "publisher", 20 | "ratingsCount": 5, 21 | "subtitle": "subtitle", 22 | "title": "title", 23 | }, 24 | }, 25 | }, 26 | "ids": [ 27 | "1", 28 | ], 29 | "selectedBookId": null, 30 | } 31 | `; 32 | 33 | exports[`BooksReducer LOAD should return the existing state if the book exists 1`] = ` 34 | { 35 | "entities": { 36 | "1": { 37 | "id": "1", 38 | "volumeInfo": { 39 | "authors": [ 40 | "author", 41 | ], 42 | "averageRating": 3, 43 | "description": "description", 44 | "imageLinks": { 45 | "smallThumbnail": "string", 46 | "thumbnail": "string", 47 | }, 48 | "publishDate": "", 49 | "publisher": "publisher", 50 | "ratingsCount": 5, 51 | "subtitle": "subtitle", 52 | "title": "title", 53 | }, 54 | }, 55 | }, 56 | "ids": [ 57 | "1", 58 | ], 59 | "selectedBookId": null, 60 | } 61 | `; 62 | 63 | exports[`BooksReducer SEARCH_COMPLETE & LOAD_SUCCESS should add all books in the payload when none exist 1`] = ` 64 | { 65 | "entities": { 66 | "1": { 67 | "id": "1", 68 | "volumeInfo": { 69 | "authors": [ 70 | "author", 71 | ], 72 | "averageRating": 3, 73 | "description": "description", 74 | "imageLinks": { 75 | "smallThumbnail": "string", 76 | "thumbnail": "string", 77 | }, 78 | "publishDate": "", 79 | "publisher": "publisher", 80 | "ratingsCount": 5, 81 | "subtitle": "subtitle", 82 | "title": "title", 83 | }, 84 | }, 85 | "222": { 86 | "id": "222", 87 | "volumeInfo": { 88 | "authors": [ 89 | "author", 90 | ], 91 | "averageRating": 3, 92 | "description": "description", 93 | "imageLinks": { 94 | "smallThumbnail": "string", 95 | "thumbnail": "string", 96 | }, 97 | "publishDate": "", 98 | "publisher": "publisher", 99 | "ratingsCount": 5, 100 | "subtitle": "subtitle", 101 | "title": "title", 102 | }, 103 | }, 104 | }, 105 | "ids": [ 106 | "1", 107 | "222", 108 | ], 109 | "selectedBookId": null, 110 | } 111 | `; 112 | 113 | exports[`BooksReducer SEARCH_COMPLETE & LOAD_SUCCESS should add all books in the payload when none exist 2`] = ` 114 | { 115 | "entities": { 116 | "1": { 117 | "id": "1", 118 | "volumeInfo": { 119 | "authors": [ 120 | "author", 121 | ], 122 | "averageRating": 3, 123 | "description": "description", 124 | "imageLinks": { 125 | "smallThumbnail": "string", 126 | "thumbnail": "string", 127 | }, 128 | "publishDate": "", 129 | "publisher": "publisher", 130 | "ratingsCount": 5, 131 | "subtitle": "subtitle", 132 | "title": "title", 133 | }, 134 | }, 135 | "222": { 136 | "id": "222", 137 | "volumeInfo": { 138 | "authors": [ 139 | "author", 140 | ], 141 | "averageRating": 3, 142 | "description": "description", 143 | "imageLinks": { 144 | "smallThumbnail": "string", 145 | "thumbnail": "string", 146 | }, 147 | "publishDate": "", 148 | "publisher": "publisher", 149 | "ratingsCount": 5, 150 | "subtitle": "subtitle", 151 | "title": "title", 152 | }, 153 | }, 154 | }, 155 | "ids": [ 156 | "1", 157 | "222", 158 | ], 159 | "selectedBookId": null, 160 | } 161 | `; 162 | 163 | exports[`BooksReducer SEARCH_COMPLETE & LOAD_SUCCESS should add only books when books already exist 1`] = ` 164 | { 165 | "entities": { 166 | "1": { 167 | "id": "1", 168 | "volumeInfo": { 169 | "authors": [ 170 | "author", 171 | ], 172 | "averageRating": 3, 173 | "description": "description", 174 | "imageLinks": { 175 | "smallThumbnail": "string", 176 | "thumbnail": "string", 177 | }, 178 | "publishDate": "", 179 | "publisher": "publisher", 180 | "ratingsCount": 5, 181 | "subtitle": "subtitle", 182 | "title": "title", 183 | }, 184 | }, 185 | "222": { 186 | "id": "222", 187 | "volumeInfo": { 188 | "authors": [ 189 | "author", 190 | ], 191 | "averageRating": 3, 192 | "description": "description", 193 | "imageLinks": { 194 | "smallThumbnail": "string", 195 | "thumbnail": "string", 196 | }, 197 | "publishDate": "", 198 | "publisher": "publisher", 199 | "ratingsCount": 5, 200 | "subtitle": "subtitle", 201 | "title": "title", 202 | }, 203 | }, 204 | "333": { 205 | "id": "333", 206 | "volumeInfo": { 207 | "authors": [ 208 | "author", 209 | ], 210 | "averageRating": 3, 211 | "description": "description", 212 | "imageLinks": { 213 | "smallThumbnail": "string", 214 | "thumbnail": "string", 215 | }, 216 | "publishDate": "", 217 | "publisher": "publisher", 218 | "ratingsCount": 5, 219 | "subtitle": "subtitle", 220 | "title": "title", 221 | }, 222 | }, 223 | }, 224 | "ids": [ 225 | "1", 226 | "222", 227 | "333", 228 | ], 229 | "selectedBookId": null, 230 | } 231 | `; 232 | 233 | exports[`BooksReducer SEARCH_COMPLETE & LOAD_SUCCESS should add only books when books already exist 2`] = ` 234 | { 235 | "entities": { 236 | "1": { 237 | "id": "1", 238 | "volumeInfo": { 239 | "authors": [ 240 | "author", 241 | ], 242 | "averageRating": 3, 243 | "description": "description", 244 | "imageLinks": { 245 | "smallThumbnail": "string", 246 | "thumbnail": "string", 247 | }, 248 | "publishDate": "", 249 | "publisher": "publisher", 250 | "ratingsCount": 5, 251 | "subtitle": "subtitle", 252 | "title": "title", 253 | }, 254 | }, 255 | "222": { 256 | "id": "222", 257 | "volumeInfo": { 258 | "authors": [ 259 | "author", 260 | ], 261 | "averageRating": 3, 262 | "description": "description", 263 | "imageLinks": { 264 | "smallThumbnail": "string", 265 | "thumbnail": "string", 266 | }, 267 | "publishDate": "", 268 | "publisher": "publisher", 269 | "ratingsCount": 5, 270 | "subtitle": "subtitle", 271 | "title": "title", 272 | }, 273 | }, 274 | "333": { 275 | "id": "333", 276 | "volumeInfo": { 277 | "authors": [ 278 | "author", 279 | ], 280 | "averageRating": 3, 281 | "description": "description", 282 | "imageLinks": { 283 | "smallThumbnail": "string", 284 | "thumbnail": "string", 285 | }, 286 | "publishDate": "", 287 | "publisher": "publisher", 288 | "ratingsCount": 5, 289 | "subtitle": "subtitle", 290 | "title": "title", 291 | }, 292 | }, 293 | }, 294 | "ids": [ 295 | "1", 296 | "222", 297 | "333", 298 | ], 299 | "selectedBookId": null, 300 | } 301 | `; 302 | 303 | exports[`BooksReducer SELECT should set the selected book id on the state 1`] = ` 304 | { 305 | "entities": { 306 | "1": { 307 | "id": "1", 308 | "volumeInfo": { 309 | "authors": [ 310 | "author", 311 | ], 312 | "averageRating": 3, 313 | "description": "description", 314 | "imageLinks": { 315 | "smallThumbnail": "string", 316 | "thumbnail": "string", 317 | }, 318 | "publishDate": "", 319 | "publisher": "publisher", 320 | "ratingsCount": 5, 321 | "subtitle": "subtitle", 322 | "title": "title", 323 | }, 324 | }, 325 | "222": { 326 | "id": "222", 327 | "volumeInfo": { 328 | "authors": [ 329 | "author", 330 | ], 331 | "averageRating": 3, 332 | "description": "description", 333 | "imageLinks": { 334 | "smallThumbnail": "string", 335 | "thumbnail": "string", 336 | }, 337 | "publishDate": "", 338 | "publisher": "publisher", 339 | "ratingsCount": 5, 340 | "subtitle": "subtitle", 341 | "title": "title", 342 | }, 343 | }, 344 | }, 345 | "ids": [ 346 | "1", 347 | "222", 348 | ], 349 | "selectedBookId": "1", 350 | } 351 | `; 352 | 353 | exports[`BooksReducer Selectors selectId should return the selected id 1`] = `"1"`; 354 | 355 | exports[`BooksReducer undefined action should return the default state 1`] = ` 356 | { 357 | "entities": {}, 358 | "ids": [], 359 | "selectedBookId": null, 360 | } 361 | `; 362 | -------------------------------------------------------------------------------- /src/app/books/reducers/books.reducer.spec.ts: -------------------------------------------------------------------------------- 1 | import { reducer } from '@example-app/books/reducers/books.reducer'; 2 | import * as fromBooks from '@example-app/books/reducers/books.reducer'; 3 | import { 4 | BooksApiActions, 5 | BookActions, 6 | ViewBookPageActions, 7 | CollectionApiActions, 8 | } from '@example-app/books/actions'; 9 | import { Book, generateMockBook } from '@example-app/books/models'; 10 | 11 | describe('BooksReducer', () => { 12 | const book1 = generateMockBook(); 13 | const book2 = { ...book1, id: '222' }; 14 | const book3 = { ...book1, id: '333' }; 15 | const initialState: fromBooks.State = { 16 | ids: [book1.id, book2.id], 17 | entities: { 18 | [book1.id]: book1, 19 | [book2.id]: book2, 20 | }, 21 | selectedBookId: null, 22 | }; 23 | 24 | describe('undefined action', () => { 25 | it('should return the default state', () => { 26 | const result = reducer(undefined, {} as any); 27 | 28 | expect(result).toMatchSnapshot(); 29 | }); 30 | }); 31 | 32 | describe('SEARCH_COMPLETE & LOAD_SUCCESS', () => { 33 | type BooksActions = 34 | | typeof BooksApiActions.searchSuccess 35 | | typeof CollectionApiActions.loadBooksSuccess; 36 | function noExistingBooks( 37 | action: BooksActions, 38 | booksInitialState: any, 39 | books: Book[] 40 | ) { 41 | const createAction = action({ books }); 42 | 43 | const result = reducer(booksInitialState, createAction); 44 | 45 | expect(result).toMatchSnapshot(); 46 | } 47 | 48 | function existingBooks( 49 | action: BooksActions, 50 | booksInitialState: any, 51 | books: Book[] 52 | ) { 53 | // should not replace existing books 54 | const differentBook2 = { ...books[0], foo: 'bar' }; 55 | const createAction = action({ books: [books[1], differentBook2] }); 56 | 57 | const result = reducer(booksInitialState, createAction); 58 | 59 | expect(result).toMatchSnapshot(); 60 | } 61 | 62 | it('should add all books in the payload when none exist', () => { 63 | noExistingBooks(BooksApiActions.searchSuccess, initialState, [ 64 | book1, 65 | book2, 66 | ]); 67 | 68 | noExistingBooks(CollectionApiActions.loadBooksSuccess, initialState, [ 69 | book1, 70 | book2, 71 | ]); 72 | }); 73 | 74 | it('should add only books when books already exist', () => { 75 | existingBooks(BooksApiActions.searchSuccess, initialState, [ 76 | book2, 77 | book3, 78 | ]); 79 | 80 | existingBooks(CollectionApiActions.loadBooksSuccess, initialState, [ 81 | book2, 82 | book3, 83 | ]); 84 | }); 85 | }); 86 | 87 | describe('LOAD', () => { 88 | const expectedResult = { 89 | ids: [book1.id], 90 | entities: { 91 | [book1.id]: book1, 92 | }, 93 | selectedBookId: null, 94 | }; 95 | 96 | it('should add a single book, if the book does not exist', () => { 97 | const action = BookActions.loadBook({ book: book1 }); 98 | 99 | const result = reducer(fromBooks.initialState, action); 100 | 101 | expect(result).toMatchSnapshot(); 102 | }); 103 | 104 | it('should return the existing state if the book exists', () => { 105 | const action = BookActions.loadBook({ book: book1 }); 106 | 107 | const result = reducer(expectedResult, action); 108 | 109 | expect(result).toMatchSnapshot(); 110 | }); 111 | }); 112 | 113 | describe('SELECT', () => { 114 | it('should set the selected book id on the state', () => { 115 | const action = ViewBookPageActions.selectBook({ id: book1.id }); 116 | 117 | const result = reducer(initialState, action); 118 | 119 | expect(result).toMatchSnapshot(); 120 | }); 121 | }); 122 | 123 | describe('Selectors', () => { 124 | describe('selectId', () => { 125 | it('should return the selected id', () => { 126 | const result = fromBooks.selectId({ 127 | ...initialState, 128 | selectedBookId: book1.id, 129 | }); 130 | 131 | expect(result).toMatchSnapshot(); 132 | }); 133 | }); 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /src/app/books/reducers/books.reducer.ts: -------------------------------------------------------------------------------- 1 | import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity'; 2 | import { createReducer, on } from '@ngrx/store'; 3 | 4 | import { 5 | BookActions, 6 | BooksApiActions, 7 | CollectionApiActions, 8 | ViewBookPageActions, 9 | } from '@example-app/books/actions'; 10 | import { Book } from '@example-app/books/models'; 11 | 12 | export const booksFeatureKey = 'books'; 13 | 14 | /** 15 | * @ngrx/entity provides a predefined interface for handling 16 | * a structured dictionary of records. This interface 17 | * includes an array of ids, and a dictionary of the provided 18 | * model type by id. This interface is extended to include 19 | * any additional interface properties. 20 | */ 21 | export interface State extends EntityState { 22 | selectedBookId: string | null; 23 | } 24 | 25 | /** 26 | * createEntityAdapter creates an object of many helper 27 | * functions for single or multiple operations 28 | * against the dictionary of records. The configuration 29 | * object takes a record id selector function and 30 | * a sortComparer option which is set to a compare 31 | * function if the records are to be sorted. 32 | */ 33 | export const adapter: EntityAdapter = createEntityAdapter({ 34 | selectId: (book: Book) => book.id, 35 | sortComparer: false, 36 | }); 37 | 38 | /** 39 | * getInitialState returns the default initial state 40 | * for the generated entity state. Initial state 41 | * additional properties can also be defined. 42 | */ 43 | export const initialState: State = adapter.getInitialState({ 44 | selectedBookId: null, 45 | }); 46 | 47 | export const reducer = createReducer( 48 | initialState, 49 | /** 50 | * The addMany function provided by the created adapter 51 | * adds many records to the entity dictionary 52 | * and returns a new state including those records. If 53 | * the collection is to be sorted, the adapter will 54 | * sort each record upon entry into the sorted array. 55 | */ 56 | on( 57 | BooksApiActions.searchSuccess, 58 | CollectionApiActions.loadBooksSuccess, 59 | (state, { books }) => adapter.addMany(books, state) 60 | ), 61 | /** 62 | * The addOne function provided by the created adapter 63 | * adds one record to the entity dictionary 64 | * and returns a new state including that records if it doesn't 65 | * exist already. If the collection is to be sorted, the adapter will 66 | * insert the new record into the sorted array. 67 | */ 68 | on(BookActions.loadBook, (state, { book }) => adapter.addOne(book, state)), 69 | on(ViewBookPageActions.selectBook, (state, { id }) => ({ 70 | ...state, 71 | selectedBookId: id, 72 | })) 73 | ); 74 | 75 | /** 76 | * Because the data structure is defined within the reducer it is optimal to 77 | * locate our selector functions at this level. If store is to be thought of 78 | * as a database, and reducers the tables, selectors can be considered the 79 | * queries into said database. Remember to keep your selectors small and 80 | * focused so they can be combined and composed to fit each particular 81 | * use-case. 82 | */ 83 | 84 | export const selectId = (state: State) => state.selectedBookId; 85 | -------------------------------------------------------------------------------- /src/app/books/reducers/collection.reducer.ts: -------------------------------------------------------------------------------- 1 | import { createReducer, on } from '@ngrx/store'; 2 | 3 | import { 4 | CollectionApiActions, 5 | CollectionPageActions, 6 | SelectedBookPageActions, 7 | } from '@example-app/books/actions'; 8 | 9 | export const collectionFeatureKey = 'collection'; 10 | 11 | export interface State { 12 | loaded: boolean; 13 | loading: boolean; 14 | ids: string[]; 15 | } 16 | 17 | const initialState: State = { 18 | loaded: false, 19 | loading: false, 20 | ids: [], 21 | }; 22 | 23 | export const reducer = createReducer( 24 | initialState, 25 | on(CollectionPageActions.enter, (state) => ({ 26 | ...state, 27 | loading: true, 28 | })), 29 | on(CollectionApiActions.loadBooksSuccess, (_state, { books }) => ({ 30 | loaded: true, 31 | loading: false, 32 | ids: books.map((book) => book.id), 33 | })), 34 | /** 35 | * Optimistically add book to collection. 36 | * If this succeeds there's nothing to do. 37 | * If this fails we revert state by removing the book. 38 | * 39 | * `on` supports handling multiple types of actions 40 | */ 41 | on( 42 | SelectedBookPageActions.addBook, 43 | CollectionApiActions.removeBookFailure, 44 | (state, { book }) => { 45 | if (state.ids.indexOf(book.id) > -1) { 46 | return state; 47 | } 48 | return { 49 | ...state, 50 | ids: [...state.ids, book.id], 51 | }; 52 | } 53 | ), 54 | /** 55 | * Optimistically remove book from collection. 56 | * If addBook fails, we "undo" adding the book. 57 | */ 58 | on( 59 | SelectedBookPageActions.removeBook, 60 | CollectionApiActions.addBookFailure, 61 | (state, { book }) => ({ 62 | ...state, 63 | ids: state.ids.filter((id) => id !== book.id), 64 | }) 65 | ) 66 | ); 67 | 68 | export const getLoaded = (state: State) => state.loaded; 69 | 70 | export const getLoading = (state: State) => state.loading; 71 | 72 | export const getIds = (state: State) => state.ids; 73 | -------------------------------------------------------------------------------- /src/app/books/reducers/index.ts: -------------------------------------------------------------------------------- 1 | import { Book } from '@example-app/books/models'; 2 | import { 3 | createSelector, 4 | createFeatureSelector, 5 | combineReducers, 6 | Action, 7 | } from '@ngrx/store'; 8 | import * as fromSearch from '@example-app/books/reducers/search.reducer'; 9 | import * as fromBooks from '@example-app/books/reducers/books.reducer'; 10 | import * as fromCollection from '@example-app/books/reducers/collection.reducer'; 11 | import * as fromRoot from '@example-app/reducers'; 12 | 13 | export const booksFeatureKey = 'books'; 14 | 15 | export interface BooksState { 16 | [fromSearch.searchFeatureKey]: fromSearch.State; 17 | [fromBooks.booksFeatureKey]: fromBooks.State; 18 | [fromCollection.collectionFeatureKey]: fromCollection.State; 19 | } 20 | 21 | export interface State extends fromRoot.State { 22 | [booksFeatureKey]: BooksState; 23 | } 24 | 25 | /** Provide reducer in AoT-compilation happy way */ 26 | export function reducers(state: BooksState | undefined, action: Action) { 27 | return combineReducers({ 28 | [fromSearch.searchFeatureKey]: fromSearch.reducer, 29 | [fromBooks.booksFeatureKey]: fromBooks.reducer, 30 | [fromCollection.collectionFeatureKey]: fromCollection.reducer, 31 | })(state, action); 32 | } 33 | 34 | /** 35 | * A selector function is a map function factory. We pass it parameters and it 36 | * returns a function that maps from the larger state tree into a smaller 37 | * piece of state. This selector simply selects the `books` state. 38 | * 39 | * Selectors are used with the `select` operator. 40 | * 41 | * ```ts 42 | * class MyComponent { 43 | * constructor(state$: Observable) { 44 | * this.booksState$ = state$.pipe(select(getBooksState)); 45 | * } 46 | * } 47 | * ``` 48 | */ 49 | 50 | /** 51 | * The createFeatureSelector function selects a piece of state from the root of the state object. 52 | * This is used for selecting feature states that are loaded eagerly or lazily. 53 | */ 54 | export const selectBooksState = 55 | createFeatureSelector(booksFeatureKey); 56 | 57 | /** 58 | * Every reducer module exports selector functions, however child reducers 59 | * have no knowledge of the overall state tree. To make them usable, we 60 | * need to make new selectors that wrap them. 61 | * 62 | * The createSelector function creates very efficient selectors that are memoized and 63 | * only recompute when arguments change. The created selectors can also be composed 64 | * together to select different pieces of state. 65 | */ 66 | export const selectBookEntitiesState = createSelector( 67 | selectBooksState, 68 | (state) => state.books 69 | ); 70 | 71 | export const selectSelectedBookId = createSelector( 72 | selectBookEntitiesState, 73 | fromBooks.selectId 74 | ); 75 | 76 | /** 77 | * Adapters created with @ngrx/entity generate 78 | * commonly used selector functions including 79 | * getting all ids in the record set, a dictionary 80 | * of the records by id, an array of records and 81 | * the total number of records. This reduces boilerplate 82 | * in selecting records from the entity state. 83 | */ 84 | export const { 85 | selectIds: selectBookIds, 86 | selectEntities: selectBookEntities, 87 | selectAll: selectAllBooks, 88 | selectTotal: selectTotalBooks, 89 | } = fromBooks.adapter.getSelectors(selectBookEntitiesState); 90 | 91 | export const selectSelectedBook = createSelector( 92 | selectBookEntities, 93 | selectSelectedBookId, 94 | (entities, selectedId) => { 95 | return selectedId && entities[selectedId]; 96 | } 97 | ); 98 | 99 | /** 100 | * Just like with the books selectors, we also have to compose the search 101 | * reducer's and collection reducer's selectors. 102 | */ 103 | export const selectSearchState = createSelector( 104 | selectBooksState, 105 | (state) => state.search 106 | ); 107 | 108 | export const selectSearchBookIds = createSelector( 109 | selectSearchState, 110 | fromSearch.getIds 111 | ); 112 | export const selectSearchQuery = createSelector( 113 | selectSearchState, 114 | fromSearch.getQuery 115 | ); 116 | export const selectSearchLoading = createSelector( 117 | selectSearchState, 118 | fromSearch.getLoading 119 | ); 120 | export const selectSearchError = createSelector( 121 | selectSearchState, 122 | fromSearch.getError 123 | ); 124 | 125 | /** 126 | * Some selector functions create joins across parts of state. This selector 127 | * composes the search result IDs to return an array of books in the store. 128 | */ 129 | export const selectSearchResults = createSelector( 130 | selectBookEntities, 131 | selectSearchBookIds, 132 | (books, searchIds) => { 133 | return searchIds 134 | .map((id) => books[id]) 135 | .filter((book): book is Book => book != null); 136 | } 137 | ); 138 | 139 | export const selectCollectionState = createSelector( 140 | selectBooksState, 141 | (state) => state.collection 142 | ); 143 | 144 | export const selectCollectionLoaded = createSelector( 145 | selectCollectionState, 146 | fromCollection.getLoaded 147 | ); 148 | export const getCollectionLoading = createSelector( 149 | selectCollectionState, 150 | fromCollection.getLoading 151 | ); 152 | export const selectCollectionBookIds = createSelector( 153 | selectCollectionState, 154 | fromCollection.getIds 155 | ); 156 | 157 | export const selectBookCollection = createSelector( 158 | selectBookEntities, 159 | selectCollectionBookIds, 160 | (entities, ids) => { 161 | return ids 162 | .map((id) => entities[id]) 163 | .filter((book): book is Book => book != null); 164 | } 165 | ); 166 | 167 | export const isSelectedBookInCollection = createSelector( 168 | selectCollectionBookIds, 169 | selectSelectedBookId, 170 | (ids, selected) => { 171 | return !!selected && ids.indexOf(selected) > -1; 172 | } 173 | ); 174 | -------------------------------------------------------------------------------- /src/app/books/reducers/search.reducer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BooksApiActions, 3 | FindBookPageActions, 4 | } from '@example-app/books/actions'; 5 | import { createReducer, on } from '@ngrx/store'; 6 | 7 | export const searchFeatureKey = 'search'; 8 | 9 | export interface State { 10 | ids: string[]; 11 | loading: boolean; 12 | error: string; 13 | query: string; 14 | } 15 | 16 | const initialState: State = { 17 | ids: [], 18 | loading: false, 19 | error: '', 20 | query: '', 21 | }; 22 | 23 | export const reducer = createReducer( 24 | initialState, 25 | on(FindBookPageActions.searchBooks, (state, { query }) => { 26 | return query === '' 27 | ? { 28 | ids: [], 29 | loading: false, 30 | error: '', 31 | query, 32 | } 33 | : { 34 | ...state, 35 | loading: true, 36 | error: '', 37 | query, 38 | }; 39 | }), 40 | on(BooksApiActions.searchSuccess, (state, { books }) => ({ 41 | ids: books.map((book) => book.id), 42 | loading: false, 43 | error: '', 44 | query: state.query, 45 | })), 46 | on(BooksApiActions.searchFailure, (state, { errorMsg }) => ({ 47 | ...state, 48 | loading: false, 49 | error: errorMsg, 50 | })) 51 | ); 52 | 53 | export const getIds = (state: State) => state.ids; 54 | 55 | export const getQuery = (state: State) => state.query; 56 | 57 | export const getLoading = (state: State) => state.loading; 58 | 59 | export const getError = (state: State) => state.error; 60 | -------------------------------------------------------------------------------- /src/app/core/actions/index.ts: -------------------------------------------------------------------------------- 1 | import * as LayoutActions from './layout.actions'; 2 | import * as UserActions from './user.actions'; 3 | 4 | export { LayoutActions, UserActions }; 5 | -------------------------------------------------------------------------------- /src/app/core/actions/layout.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction } from '@ngrx/store'; 2 | 3 | export const openSidenav = createAction('[Layout] Open Sidenav'); 4 | export const closeSidenav = createAction('[Layout] Close Sidenav'); 5 | -------------------------------------------------------------------------------- /src/app/core/actions/user.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction } from '@ngrx/store'; 2 | 3 | export const idleTimeout = createAction('[User] Idle Timeout'); 4 | -------------------------------------------------------------------------------- /src/app/core/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './layout.component'; 2 | export * from './nav-item.component'; 3 | export * from './sidenav.component'; 4 | export * from './toolbar.component'; 5 | -------------------------------------------------------------------------------- /src/app/core/components/layout.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'bc-layout', 5 | template: ` 6 | 7 | 8 | 9 | `, 10 | styles: [ 11 | ` 12 | mat-sidenav-container { 13 | background: rgba(0, 0, 0, 0.03); 14 | } 15 | 16 | *, 17 | /deep/ * { 18 | -webkit-font-smoothing: antialiased; 19 | -moz-osx-font-smoothing: grayscale; 20 | } 21 | `, 22 | ], 23 | }) 24 | export class LayoutComponent {} 25 | -------------------------------------------------------------------------------- /src/app/core/components/nav-item.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, Output, EventEmitter } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'bc-nav-item', 5 | template: ` 6 | 7 | {{ icon }} 8 |
9 |
{{ hint }}
10 |
11 | `, 12 | styles: [ 13 | ` 14 | a:hover { 15 | cursor: pointer; 16 | } 17 | `, 18 | ], 19 | }) 20 | export class NavItemComponent { 21 | @Input() icon = ''; 22 | @Input() hint = ''; 23 | @Input() routerLink: string | any[] = '/'; 24 | @Output() navigate = new EventEmitter(); 25 | } 26 | -------------------------------------------------------------------------------- /src/app/core/components/sidenav.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, Output } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'bc-sidenav', 5 | template: ` 6 | 13 | 14 | 15 | 16 | 17 | `, 18 | styles: [ 19 | ` 20 | mat-sidenav { 21 | width: 300px; 22 | } 23 | `, 24 | ], 25 | }) 26 | export class SidenavComponent { 27 | @Input() open = false; 28 | @Output() closeMenu = new EventEmitter(); 29 | } 30 | -------------------------------------------------------------------------------- /src/app/core/components/toolbar.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Output, EventEmitter } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'bc-toolbar', 5 | template: ` 6 | 7 | 10 | 11 | 12 | `, 13 | }) 14 | export class ToolbarComponent { 15 | @Output() openMenu = new EventEmitter(); 16 | } 17 | -------------------------------------------------------------------------------- /src/app/core/containers/app.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { Store } from '@ngrx/store'; 3 | 4 | import { AuthActions } from '@example-app/auth/actions'; 5 | import * as fromAuth from '@example-app/auth/reducers'; 6 | import * as fromRoot from '@example-app/reducers'; 7 | import { LayoutActions } from '@example-app/core/actions'; 8 | 9 | @Component({ 10 | selector: 'bc-app', 11 | changeDetection: ChangeDetectionStrategy.OnPush, 12 | template: ` 13 | 14 | 15 | 22 | My Collection 23 | 24 | 31 | Browse Books 32 | 33 | 37 | Sign In 38 | 39 | 40 | Sign Out 41 | 42 | 43 | Book Collection 44 | 45 | 46 | 47 | `, 48 | }) 49 | export class AppComponent { 50 | showSidenav = this.store.selectSignal(fromRoot.selectShowSidenav); 51 | loggedIn = this.store.selectSignal(fromAuth.selectLoggedIn); 52 | 53 | constructor(private store: Store) {} 54 | 55 | closeSidenav() { 56 | /** 57 | * All state updates are handled through dispatched actions in 'container' 58 | * components. This provides a clear, reproducible history of state 59 | * updates and user interaction through the life of our 60 | * application. 61 | */ 62 | this.store.dispatch(LayoutActions.closeSidenav()); 63 | } 64 | 65 | openSidenav() { 66 | this.store.dispatch(LayoutActions.openSidenav()); 67 | } 68 | 69 | logout() { 70 | this.store.dispatch(AuthActions.logoutConfirmation()); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/app/core/containers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './app.component'; 2 | export * from './not-found-page.component'; 3 | -------------------------------------------------------------------------------- /src/app/core/containers/not-found-page.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ChangeDetectionStrategy } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'bc-not-found-page', 5 | changeDetection: ChangeDetectionStrategy.OnPush, 6 | template: ` 7 | 8 | 404: Not Found 9 | 10 |

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

11 |
12 | 13 | 16 | 17 |
18 | `, 19 | styles: [ 20 | ` 21 | :host { 22 | text-align: center; 23 | } 24 | 25 | mat-card-title, 26 | mat-card-content { 27 | margin-top: 1rem; 28 | } 29 | 30 | mat-card-actions { 31 | justify-content: center; 32 | margin-top: 1rem; 33 | } 34 | `, 35 | ], 36 | }) 37 | export class NotFoundPageComponent {} 38 | -------------------------------------------------------------------------------- /src/app/core/core.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { RouterModule } from '@angular/router'; 4 | 5 | import { MaterialModule } from '@example-app/material'; 6 | import { 7 | LayoutComponent, 8 | NavItemComponent, 9 | SidenavComponent, 10 | ToolbarComponent, 11 | } from '@example-app/core/components'; 12 | import { 13 | AppComponent, 14 | NotFoundPageComponent, 15 | } from '@example-app/core/containers'; 16 | 17 | export const COMPONENTS = [ 18 | AppComponent, 19 | NotFoundPageComponent, 20 | LayoutComponent, 21 | NavItemComponent, 22 | SidenavComponent, 23 | ToolbarComponent, 24 | ]; 25 | 26 | @NgModule({ 27 | imports: [CommonModule, RouterModule, MaterialModule], 28 | declarations: COMPONENTS, 29 | exports: COMPONENTS, 30 | }) 31 | export class CoreModule {} 32 | -------------------------------------------------------------------------------- /src/app/core/effects/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user.effects'; 2 | export * from './router.effects'; 3 | -------------------------------------------------------------------------------- /src/app/core/effects/router.effects.spec.ts: -------------------------------------------------------------------------------- 1 | import { Title } from '@angular/platform-browser'; 2 | import { TestBed } from '@angular/core/testing'; 3 | 4 | import { of } from 'rxjs'; 5 | 6 | import { Actions } from '@ngrx/effects'; 7 | import { routerNavigatedAction } from '@ngrx/router-store'; 8 | import { provideMockStore } from '@ngrx/store/testing'; 9 | 10 | import { RouterEffects } from '@example-app/core/effects'; 11 | import * as fromRoot from '@example-app/reducers'; 12 | 13 | describe('RouterEffects', () => { 14 | let effects: RouterEffects; 15 | let titleService: Title; 16 | 17 | beforeEach(() => { 18 | TestBed.configureTestingModule({ 19 | providers: [ 20 | RouterEffects, 21 | { 22 | provide: Actions, 23 | useValue: of(routerNavigatedAction), 24 | }, 25 | provideMockStore({ 26 | selectors: [ 27 | { selector: fromRoot.selectRouteData, value: { title: 'Search' } }, 28 | ], 29 | }), 30 | { provide: Title, useValue: { setTitle: jest.fn() } }, 31 | ], 32 | }); 33 | 34 | effects = TestBed.inject(RouterEffects); 35 | titleService = TestBed.inject(Title); 36 | }); 37 | 38 | describe('updateTitle$', () => { 39 | it('should update the title on router navigation', () => { 40 | effects.updateTitle$.subscribe(); 41 | expect(titleService.setTitle).toHaveBeenCalledWith( 42 | 'Book Collection - Search' 43 | ); 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/app/core/effects/router.effects.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Title } from '@angular/platform-browser'; 3 | 4 | import { map, tap } from 'rxjs/operators'; 5 | 6 | import { Actions, concatLatestFrom, createEffect, ofType } from '@ngrx/effects'; 7 | import { Store } from '@ngrx/store'; 8 | import { routerNavigatedAction } from '@ngrx/router-store'; 9 | 10 | import * as fromRoot from '@example-app/reducers'; 11 | 12 | @Injectable() 13 | export class RouterEffects { 14 | updateTitle$ = createEffect( 15 | () => 16 | this.actions$.pipe( 17 | ofType(routerNavigatedAction), 18 | concatLatestFrom(() => this.store.select(fromRoot.selectRouteData)), 19 | map(([, data]) => `Book Collection - ${data['title']}`), 20 | tap((title) => this.titleService.setTitle(title)) 21 | ), 22 | { 23 | dispatch: false, 24 | } 25 | ); 26 | 27 | constructor( 28 | private actions$: Actions, 29 | private store: Store, 30 | private titleService: Title 31 | ) {} 32 | } 33 | -------------------------------------------------------------------------------- /src/app/core/effects/user.effects.spec.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '@ngrx/store'; 2 | import { TestBed, fakeAsync, tick } from '@angular/core/testing'; 3 | 4 | import { UserEffects } from '@example-app/core/effects'; 5 | import { UserActions } from '@example-app/core/actions'; 6 | 7 | describe('UserEffects', () => { 8 | let effects: UserEffects; 9 | const eventsMap: { [key: string]: any } = {}; 10 | 11 | beforeAll(() => { 12 | document.addEventListener = jest.fn((event, cb) => { 13 | eventsMap[event] = cb; 14 | }); 15 | }); 16 | 17 | beforeEach(() => { 18 | TestBed.configureTestingModule({ 19 | providers: [UserEffects], 20 | }); 21 | 22 | effects = TestBed.inject(UserEffects); 23 | }); 24 | 25 | describe('idle$', () => { 26 | it('should trigger idleTimeout action after 5 minutes', fakeAsync(() => { 27 | let action: Action | undefined; 28 | effects.idle$.subscribe((res) => (action = res)); 29 | 30 | // Initial action to trigger the effect 31 | eventsMap['click'](); 32 | 33 | tick(2 * 60 * 1000); 34 | expect(action).toBeUndefined(); 35 | 36 | tick(3 * 60 * 1000); 37 | expect(action).toBeDefined(); 38 | expect(action?.type).toBe(UserActions.idleTimeout.type); 39 | })); 40 | 41 | it('should reset timeout on user activity', fakeAsync(() => { 42 | let action: Action | undefined; 43 | effects.idle$.subscribe((res) => (action = res)); 44 | 45 | // Initial action to trigger the effect 46 | eventsMap['keydown'](); 47 | 48 | tick(4 * 60 * 1000); 49 | eventsMap['mousemove'](); 50 | 51 | tick(4 * 60 * 1000); 52 | expect(action).toBeUndefined(); 53 | 54 | tick(1 * 60 * 1000); 55 | expect(action).toBeDefined(); 56 | expect(action?.type).toBe(UserActions.idleTimeout.type); 57 | })); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/app/core/effects/user.effects.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { fromEvent, merge, timer } from 'rxjs'; 4 | import { map, switchMap } from 'rxjs/operators'; 5 | 6 | import { createEffect } from '@ngrx/effects'; 7 | import { UserActions } from '@example-app/core/actions'; 8 | 9 | @Injectable() 10 | export class UserEffects { 11 | clicks$ = fromEvent(document, 'click'); 12 | keys$ = fromEvent(document, 'keydown'); 13 | mouse$ = fromEvent(document, 'mousemove'); 14 | 15 | idle$ = createEffect(() => 16 | merge(this.clicks$, this.keys$, this.mouse$).pipe( 17 | // 5 minute inactivity timeout 18 | switchMap(() => timer(5 * 60 * 1000)), 19 | map(() => UserActions.idleTimeout()) 20 | ) 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/app/core/index.ts: -------------------------------------------------------------------------------- 1 | export * from './core.module'; 2 | -------------------------------------------------------------------------------- /src/app/core/reducers/layout.reducer.ts: -------------------------------------------------------------------------------- 1 | import { createReducer, on } from '@ngrx/store'; 2 | 3 | import { LayoutActions } from '@example-app/core/actions'; 4 | import { AuthActions } from '@example-app/auth/actions'; 5 | 6 | export const layoutFeatureKey = 'layout'; 7 | 8 | export interface State { 9 | showSidenav: boolean; 10 | } 11 | 12 | const initialState: State = { 13 | showSidenav: false, 14 | }; 15 | 16 | export const reducer = createReducer( 17 | initialState, 18 | on(LayoutActions.closeSidenav, () => ({ showSidenav: false })), 19 | on(LayoutActions.openSidenav, () => ({ showSidenav: true })), 20 | on(AuthActions.logoutConfirmation, () => ({ showSidenav: false })) 21 | ); 22 | 23 | export const selectShowSidenav = (state: State) => state.showSidenav; 24 | -------------------------------------------------------------------------------- /src/app/core/services/book-storage.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { cold } from 'jasmine-marbles'; 4 | 5 | import { Book } from '@example-app/books/models'; 6 | import { 7 | BookStorageService, 8 | LOCAL_STORAGE_TOKEN, 9 | } from '@example-app/core/services/book-storage.service'; 10 | 11 | describe('BookStorageService', () => { 12 | let fixture: any; 13 | 14 | const localStorageFake: Storage & any = { 15 | removeItem: jest.fn(), 16 | setItem: jest.fn(), 17 | getItem: jest.fn((_) => JSON.stringify(persistedCollection)), 18 | }; 19 | 20 | const book1 = { id: '111', volumeInfo: {} } as Book; 21 | const book2 = { id: '222', volumeInfo: {} } as Book; 22 | const book3 = { id: '333', volumeInfo: {} } as Book; 23 | const book4 = { id: '444', volumeInfo: {} } as Book; 24 | 25 | const persistedStorageKey = 'books-app'; 26 | const persistedCollection = [book2, book4]; 27 | 28 | beforeEach(() => { 29 | TestBed.configureTestingModule({ 30 | providers: [ 31 | { 32 | provide: LOCAL_STORAGE_TOKEN, 33 | useValue: localStorageFake, 34 | }, 35 | ], 36 | }); 37 | fixture = TestBed.inject(BookStorageService); 38 | }); 39 | 40 | describe('supported', () => { 41 | it('should have truthy value if localStorage provider set', () => { 42 | const expected = cold('(-a|)', { a: true }); 43 | expect(fixture.supported()).toBeObservable(expected); 44 | }); 45 | 46 | it('should throw error if localStorage provider not available', () => { 47 | TestBed.resetTestingModule().configureTestingModule({ 48 | providers: [ 49 | { 50 | provide: LOCAL_STORAGE_TOKEN, 51 | useValue: null, 52 | }, 53 | ], 54 | }); 55 | 56 | fixture = TestBed.inject(BookStorageService); 57 | const expected = cold('#', {}, 'Local Storage Not Supported'); 58 | expect(fixture.supported()).toBeObservable(expected); 59 | }); 60 | }); 61 | 62 | describe('getCollection', () => { 63 | it('should call get collection', () => { 64 | const expected = cold('(-a|)', { a: persistedCollection }); 65 | expect(fixture.getCollection()).toBeObservable(expected); 66 | expect(localStorageFake.getItem).toHaveBeenCalledWith( 67 | persistedStorageKey 68 | ); 69 | localStorageFake.getItem.mockClear(); 70 | }); 71 | }); 72 | 73 | describe('addToCollection', () => { 74 | it('should add single item', () => { 75 | const result = [...persistedCollection, book1]; 76 | const expected = cold('(-a|)', { a: result }); 77 | expect(fixture.addToCollection([book1])).toBeObservable(expected); 78 | expect(localStorageFake.setItem).toHaveBeenCalledWith( 79 | persistedStorageKey, 80 | JSON.stringify(result) 81 | ); 82 | 83 | localStorageFake.setItem.mockClear(); 84 | }); 85 | 86 | it('should add multiple items', () => { 87 | const result = [...persistedCollection, book1, book3]; 88 | const expected = cold('(-a|)', { a: result }); 89 | expect(fixture.addToCollection([book1, book3])).toBeObservable(expected); 90 | expect(localStorageFake.setItem).toHaveBeenCalledWith( 91 | persistedStorageKey, 92 | JSON.stringify(result) 93 | ); 94 | localStorageFake.setItem.mockClear(); 95 | }); 96 | }); 97 | 98 | describe('removeFromCollection', () => { 99 | it('should remove item from collection', () => { 100 | const filterCollection = persistedCollection.filter( 101 | (f) => f.id !== book2.id 102 | ); 103 | const expected = cold('(-a|)', { a: filterCollection }); 104 | expect(fixture.removeFromCollection([book2.id])).toBeObservable(expected); 105 | expect(localStorageFake.getItem).toHaveBeenCalledWith( 106 | persistedStorageKey 107 | ); 108 | expect(localStorageFake.setItem).toHaveBeenCalledWith( 109 | persistedStorageKey, 110 | JSON.stringify(filterCollection) 111 | ); 112 | localStorageFake.getItem.mockClear(); 113 | }); 114 | 115 | it('should remove multiple items from collection', () => { 116 | const filterCollection = persistedCollection.filter( 117 | (f) => f.id !== book4.id 118 | ); 119 | const expected = cold('(-a|)', { a: filterCollection }); 120 | expect(fixture.removeFromCollection([book4.id])).toBeObservable(expected); 121 | expect(localStorageFake.getItem).toHaveBeenCalledWith( 122 | persistedStorageKey 123 | ); 124 | expect(localStorageFake.setItem).toHaveBeenCalledWith( 125 | persistedStorageKey, 126 | JSON.stringify(filterCollection) 127 | ); 128 | localStorageFake.getItem.mockClear(); 129 | }); 130 | 131 | it('should ignore items not present in collection', () => { 132 | const filterCollection = persistedCollection; 133 | const expected = cold('(-a|)', { a: filterCollection }); 134 | expect(fixture.removeFromCollection([book1.id])).toBeObservable(expected); 135 | }); 136 | }); 137 | 138 | describe('deleteCollection', () => { 139 | it('should delete storage key and collection', () => { 140 | const expected = cold('(-a|)', { a: true }); 141 | expect(fixture.deleteCollection()).toBeObservable(expected); 142 | expect(localStorageFake.removeItem).toHaveBeenCalledWith( 143 | persistedStorageKey 144 | ); 145 | localStorageFake.removeItem.mockClear(); 146 | }); 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /src/app/core/services/book-storage.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, InjectionToken } from '@angular/core'; 2 | 3 | import { Observable, of, throwError } from 'rxjs'; 4 | import { map, tap } from 'rxjs/operators'; 5 | 6 | import { Book } from '@example-app/books/models'; 7 | 8 | export function storageFactory() { 9 | return typeof window === undefined || typeof localStorage === undefined 10 | ? null 11 | : localStorage; 12 | } 13 | 14 | export const LOCAL_STORAGE_TOKEN = new InjectionToken( 15 | 'example-app-local-storage', 16 | { factory: storageFactory } 17 | ); 18 | 19 | @Injectable({ providedIn: 'root' }) 20 | export class BookStorageService { 21 | private collectionKey = 'books-app'; 22 | 23 | supported(): Observable { 24 | return this.storage !== null 25 | ? of(true) 26 | : throwError(() => 'Local Storage Not Supported'); 27 | } 28 | 29 | getCollection(): Observable { 30 | return this.supported().pipe( 31 | map((_) => this.storage.getItem(this.collectionKey)), 32 | map((value: string | null) => (value ? JSON.parse(value) : [])) 33 | ); 34 | } 35 | 36 | addToCollection(records: Book[]): Observable { 37 | return this.getCollection().pipe( 38 | map((value: Book[]) => [...value, ...records]), 39 | tap((value: Book[]) => 40 | this.storage.setItem(this.collectionKey, JSON.stringify(value)) 41 | ) 42 | ); 43 | } 44 | 45 | removeFromCollection(ids: Array): Observable { 46 | return this.getCollection().pipe( 47 | map((value: Book[]) => value.filter((item) => !ids.includes(item.id))), 48 | tap((value: Book[]) => 49 | this.storage.setItem(this.collectionKey, JSON.stringify(value)) 50 | ) 51 | ); 52 | } 53 | 54 | deleteCollection(): Observable { 55 | return this.supported().pipe( 56 | tap(() => this.storage.removeItem(this.collectionKey)) 57 | ); 58 | } 59 | 60 | constructor(@Inject(LOCAL_STORAGE_TOKEN) private storage: Storage) {} 61 | } 62 | -------------------------------------------------------------------------------- /src/app/core/services/google-books.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { HttpClient } from '@angular/common/http'; 3 | 4 | import { cold } from 'jasmine-marbles'; 5 | 6 | import { GoogleBooksService } from './google-books.service'; 7 | 8 | describe('Service: GoogleBooks', () => { 9 | let service: GoogleBooksService; 10 | let http: HttpClient; 11 | 12 | beforeEach(() => { 13 | TestBed.configureTestingModule({ 14 | providers: [{ provide: HttpClient, useValue: { get: jest.fn() } }], 15 | }); 16 | 17 | service = TestBed.inject(GoogleBooksService); 18 | http = TestBed.inject(HttpClient); 19 | }); 20 | 21 | const data = { 22 | title: 'Book Title', 23 | author: 'John Smith', 24 | volumeId: '12345', 25 | }; 26 | 27 | const books = { 28 | items: [ 29 | { id: '12345', volumeInfo: { title: 'Title' } }, 30 | { id: '67890', volumeInfo: { title: 'Another Title' } }, 31 | ], 32 | }; 33 | 34 | const queryTitle = 'Book Title'; 35 | 36 | it('should call the search api and return the search results', () => { 37 | const response = cold('-a|', { a: books }); 38 | const expected = cold('-b|', { b: books.items }); 39 | http.get = jest.fn(() => response); 40 | 41 | expect(service.searchBooks(queryTitle)).toBeObservable(expected); 42 | expect(http.get).toHaveBeenCalledWith( 43 | `https://www.googleapis.com/books/v1/volumes?orderBy=newest&q=${queryTitle}` 44 | ); 45 | }); 46 | 47 | it('should retrieve the book from the volumeId', () => { 48 | const response = cold('-a|', { a: data }); 49 | const expected = cold('-b|', { b: data }); 50 | http.get = jest.fn(() => response); 51 | 52 | expect(service.retrieveBook(data.volumeId)).toBeObservable(expected); 53 | expect(http.get).toHaveBeenCalledWith( 54 | `https://www.googleapis.com/books/v1/volumes/${data.volumeId}` 55 | ); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/app/core/services/google-books.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | 4 | import { Observable } from 'rxjs'; 5 | import { map } from 'rxjs/operators'; 6 | 7 | import { Book } from '@example-app/books/models'; 8 | 9 | @Injectable({ 10 | providedIn: 'root', 11 | }) 12 | export class GoogleBooksService { 13 | private API_PATH = 'https://www.googleapis.com/books/v1/volumes'; 14 | 15 | constructor(private http: HttpClient) {} 16 | 17 | searchBooks(queryTitle: string): Observable { 18 | return this.http 19 | .get<{ items: Book[] }>(`${this.API_PATH}?orderBy=newest&q=${queryTitle}`) 20 | .pipe(map((books) => books.items || [])); 21 | } 22 | 23 | retrieveBook(volumeId: string): Observable { 24 | return this.http.get(`${this.API_PATH}/${volumeId}`); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/app/core/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './book-storage.service'; 2 | export * from './google-books.service'; 3 | -------------------------------------------------------------------------------- /src/app/material/index.ts: -------------------------------------------------------------------------------- 1 | export * from '@example-app/material/material.module'; 2 | -------------------------------------------------------------------------------- /src/app/material/material.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { MatInputModule } from '@angular/material/input'; 4 | import { MatCardModule } from '@angular/material/card'; 5 | import { MatButtonModule } from '@angular/material/button'; 6 | import { MatSidenavModule } from '@angular/material/sidenav'; 7 | import { MatListModule } from '@angular/material/list'; 8 | import { MatIconModule } from '@angular/material/icon'; 9 | import { MatToolbarModule } from '@angular/material/toolbar'; 10 | import { MatDialogModule } from '@angular/material/dialog'; 11 | import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; 12 | 13 | @NgModule({ 14 | imports: [ 15 | MatInputModule, 16 | MatCardModule, 17 | MatButtonModule, 18 | MatSidenavModule, 19 | MatListModule, 20 | MatIconModule, 21 | MatToolbarModule, 22 | MatProgressSpinnerModule, 23 | MatDialogModule, 24 | ], 25 | exports: [ 26 | MatInputModule, 27 | MatCardModule, 28 | MatButtonModule, 29 | MatSidenavModule, 30 | MatListModule, 31 | MatIconModule, 32 | MatToolbarModule, 33 | MatProgressSpinnerModule, 34 | MatDialogModule, 35 | ], 36 | }) 37 | export class MaterialModule {} 38 | -------------------------------------------------------------------------------- /src/app/reducers/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createSelector, 3 | createFeatureSelector, 4 | ActionReducer, 5 | Action, 6 | ActionReducerMap, 7 | MetaReducer, 8 | } from '@ngrx/store'; 9 | import { 10 | getRouterSelectors, 11 | routerReducer, 12 | RouterReducerState, 13 | } from '@ngrx/router-store'; 14 | 15 | /** 16 | * Every reducer module's default export is the reducer function itself. In 17 | * addition, each module should export a type or interface that describes 18 | * the state of the reducer plus any selector functions. The `* as` 19 | * notation packages up all of the exports into a single object. 20 | */ 21 | 22 | import * as fromLayout from '@example-app/core/reducers/layout.reducer'; 23 | import { InjectionToken, isDevMode } from '@angular/core'; 24 | 25 | /** 26 | * As mentioned, we treat each reducer like a table in a database. This means 27 | * our top level state interface is just a map of keys to inner state types. 28 | */ 29 | export interface State { 30 | [fromLayout.layoutFeatureKey]: fromLayout.State; 31 | router: RouterReducerState; 32 | } 33 | 34 | /** 35 | * Our state is composed of a map of action reducer functions. 36 | * These reducer functions are called with each dispatched action 37 | * and the current or initial state and return a new immutable state. 38 | */ 39 | export const ROOT_REDUCERS = new InjectionToken< 40 | ActionReducerMap 41 | >('Root reducers token', { 42 | factory: () => ({ 43 | [fromLayout.layoutFeatureKey]: fromLayout.reducer, 44 | router: routerReducer, 45 | }), 46 | }); 47 | 48 | // console.log all actions 49 | export function logger(reducer: ActionReducer): ActionReducer { 50 | return (state, action) => { 51 | const result = reducer(state, action); 52 | console.groupCollapsed(action.type); 53 | console.log('prev state', state); 54 | console.log('action', action); 55 | console.log('next state', result); 56 | console.groupEnd(); 57 | 58 | return result; 59 | }; 60 | } 61 | 62 | /** 63 | * By default, @ngrx/store uses combineReducers with the reducer map to compose 64 | * the root meta-reducer. To add more meta-reducers, provide an array of meta-reducers 65 | * that will be composed to form the root meta-reducer. 66 | */ 67 | export const metaReducers: MetaReducer[] = isDevMode() ? [logger] : []; 68 | 69 | /** 70 | * Layout Selectors 71 | */ 72 | export const selectLayoutState = createFeatureSelector( 73 | fromLayout.layoutFeatureKey 74 | ); 75 | 76 | export const selectShowSidenav = createSelector( 77 | selectLayoutState, 78 | fromLayout.selectShowSidenav 79 | ); 80 | 81 | /** 82 | * Router Selectors 83 | */ 84 | export const { selectRouteData } = getRouterSelectors(); 85 | -------------------------------------------------------------------------------- /src/app/shared/pipes/add-commas.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { AddCommasPipe } from '@example-app/shared/pipes/add-commas.pipe'; 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( 20 | 'Kim, Ryan, and Amanda' 21 | ); 22 | }); 23 | 24 | it('transforms undefined to "Author Unknown"', () => { 25 | expect(pipe.transform(null)).toEqual('Author Unknown'); 26 | }); 27 | 28 | it('transforms [] to "Author Unknown"', () => { 29 | expect(pipe.transform([])).toEqual('Author Unknown'); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/app/shared/pipes/add-commas.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | @Pipe({ name: 'bcAddCommas' }) 4 | export class AddCommasPipe implements PipeTransform { 5 | transform(authors: null | string[]) { 6 | if (!authors) { 7 | return 'Author Unknown'; 8 | } 9 | 10 | switch (authors.length) { 11 | case 0: 12 | return 'Author Unknown'; 13 | case 1: 14 | return authors[0]; 15 | case 2: 16 | return authors.join(' and '); 17 | default: 18 | const last = authors[authors.length - 1]; 19 | const remaining = authors.slice(0, -1); 20 | return `${remaining.join(', ')}, and ${last}`; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/app/shared/pipes/ellipsis.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { EllipsisPipe } from '@example-app/shared/pipes/ellipsis.pipe'; 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.substring(0, 250)}...`); 48 | }); 49 | 50 | it('should return only 20 characters followed by an ellipsis', () => { 51 | expect(pipe.transform(longStr, 20)).toEqual( 52 | `${longStr.substring(0, 20)}...` 53 | ); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/app/shared/pipes/ellipsis.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | @Pipe({ name: 'bcEllipsis' }) 4 | export class EllipsisPipe implements PipeTransform { 5 | transform(str: string, strLength: number = 250) { 6 | const withoutHtml = str.replace(/(<([^>]+)>)/gi, ''); 7 | 8 | if (str.length >= strLength) { 9 | return `${withoutHtml.slice(0, strLength)}...`; 10 | } 11 | 12 | return withoutHtml; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/app/shared/pipes/index.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { AddCommasPipe } from '@example-app/shared/pipes/add-commas.pipe'; 4 | import { EllipsisPipe } from '@example-app/shared/pipes/ellipsis.pipe'; 5 | 6 | export const PIPES = [AddCommasPipe, EllipsisPipe]; 7 | 8 | @NgModule({ 9 | declarations: PIPES, 10 | exports: PIPES, 11 | }) 12 | export class PipesModule {} 13 | -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brandonroberts/ngrx-example-app-signals/9c14d6d75d9b5b58d9c00e7ee74a66bc4a4986f3/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/assets/.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brandonroberts/ngrx-example-app-signals/9c14d6d75d9b5b58d9c00e7ee74a66bc4a4986f3/src/assets/.npmignore -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- 1 |  h& ��(  @������������������71�U61��0-��2.�;������������������������������71�971��71��71��0-��0-��1-��3/�#������������������71�!71�71��71��71��71��0-��0-��0-��0-��1-��3/����������71�U71��OI��VQ��82��71��71��0-��0-��0-��UR��KH��0-��2.�=������71�71����������mh��71��71��0-��0-��[X����������0-��1.�{������71�71��C=����������71��71��0-��0-����������85��0-��0-��������71��71��71����������������������������������0-��0-��1-��������71��71��71��QL��������������������������?<��0-��0-��0-��������71��71��71��71����������;5��:7����������0-��0-��0-��0-�����71�#71��71��71��71��d_������}y����������IG��0-��0-��0-��0-��4/�71�M71��71��71��71��71������������������0-��0-��0-��0-��0-��3/�A71�k71��71��71��71��71��zv����������VS��0-��0-��0-��0-��0-��2.�e71�}71��71��71��71��71��93����������0-��0-��0-��0-��0-��0-��1.�{71�A71��71��71��71��71��71������db��0-��0-��0-��0-��0-��1-��0-�E������71�)71�71��71��71��=7��0-��0-��0-��0-��1-��3/�/���������������������71�A71�61��0-��0-��3.�9�����������������������������������������������( @ �������������������������������������������71�61��2.��3/�?������������������������������������������������������������������������������71�_71��71��71��0-��0-��2.��3/�������������������������������������������������������������������71�;71��71��71��71��71��0-��0-��0-��0-��3.ŭ4/� ������������������������������������������������������71�71��71��71��71��71��71��71��0-��0-��0-��0-��0-��1.��3/lj���������������������������������������������71� 71�71��71��71��71��71��71��71��71��0-��0-��0-��0-��0-��0-��0-��1.��3/�_������������������������������������71�71��71��71��71��71��71��71��71��71��71��0-��0-��0-��0-��0-��0-��0-��0-��0-��2.��3/�;������������������������71�Y71��71��71��71��71��71��71��71��71��71��71��71��0-��0-��0-��0-��0-��0-��0-��0-��0-��0-��0-��2.��3/�������������������71��71��71��XS��wr��vr��vr��<7��71��71��71��71��71��0-��0-��0-��0-��0-��0-��wu��~|��~|��NK��0-��0-��2.��������������������71��71��71��UP��������������{w��71��71��71��71��71��0-��0-��0-��0-��0-��YW��������������DB��0-��0-��1.�����������������71�71��71��71��71������������������71��71��71��71��71��0-��0-��0-��0-��0-������������������0-��0-��0-��1.�����������������71�O71��71��71��71��hc��������������SN��71��71��71��71��0-��0-��0-��0-��B?��������������PM��0-��0-��0-��0-��50�������������71�}71��71��71��71��71������������������71��71��71��71��0-��0-��0-��0-������������������0-��0-��0-��0-��0-��3/�3������������71�71��71��71��71��71��~{����������������������������������������������������������][��0-��0-��0-��0-��0-��3/�i������������71��71��71��71��71��71��;5����������������������������������������������������������0-��0-��0-��0-��0-��0-��3/Ǘ������������71��71��71��71��71��71��71������������������������������������������������������mk��0-��0-��0-��0-��0-��0-��2.Ž������������71��71��71��71��71��71��71��D>������������������ie��ie��db��db������������������1.��0-��0-��0-��0-��0-��0-��2.��������������71��71��71��71��71��71��71��71������������������71��71��0-��0-��������������|z��0-��0-��0-��0-��0-��0-��0-��2.��������������71��71��71��71��71��71��71��71��RL��������������JD��71��0-��XU��������������52��0-��0-��0-��0-��0-��0-��0-��1.�����������71�+71��71��71��71��71��71��71��71��71������������������71��0-������������������0-��0-��0-��0-��0-��0-��0-��0-��0-��50�������71�]71��71��71��71��71��71��71��71��71��ea��������������:4��QO��������������:7��0-��0-��0-��0-��0-��0-��0-��0-��0-��4/�5������71�71��71��71��71��71��71��71��71��71��71��������������wr������������������0-��0-��0-��0-��0-��0-��0-��0-��0-��0-��3/�k������71�71��71��71��71��71��71��71��71��71��71��zv��������������������������C@��0-��0-��0-��0-��0-��0-��0-��0-��0-��0-��3/Ǘ������71��71��71��71��71��71��71��71��71��71��71��:4��������������������������0-��0-��0-��0-��0-��0-��0-��0-��0-��0-��0-��2.Ž������71��71��71��71��71��71��71��71��71��71��71��71����������������������MK��0-��0-��0-��0-��0-��0-��0-��0-��0-��0-��0-��2.��������71��71��71��71��71��71��71��71��71��71��71��71��B<������������������0-��0-��0-��0-��0-��0-��0-��0-��0-��0-��0-��0-��2.��������71��71��71��71��71��71��71��71��71��71��71��71��71��������������[Y��0-��0-��0-��0-��0-��0-��0-��0-��0-��0-��0-��0-��1.��������71��71��71��71��71��71��71��71��71��71��71��71��71��OJ����������0-��0-��0-��0-��0-��0-��0-��0-��0-��0-��0-��0-��0-��0-��������71�71�y71��71��71��71��71��71��71��71��71��71��71��71������jg��0-��0-��0-��0-��0-��0-��0-��0-��0-��0-��0-��2.��3/Ɠ4/����������������71�71�71��71��71��71��71��71��71��71��71��PK��1.��0-��0-��0-��0-��0-��0-��0-��0-��2.��3/Ǜ3/����������������������������������71�)71�71��71��71��71��71��71��71��0-��0-��0-��0-��0-��0-��1.��3/ƥ4/�'���������������������������������������������������71�C71�71��71��71��71��0-��0-��0-��1.��3.ů3/�1���������������������������������������������������������������������71�a71��61��1.��2.ŷ3/�=��������������������������������������� -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Book Collection 6 | 7 | 8 | 9 | 10 | 11 | 12 | 16 | 17 | 18 | Loading... 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import './polyfills'; 2 | 3 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 4 | import { AppModule } from './app/app.module'; 5 | 6 | platformBrowserDynamic().bootstrapModule(AppModule); 7 | -------------------------------------------------------------------------------- /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 | /** 22 | * By default, zone.js will patch all possible macroTask and DomEvents 23 | * user can disable parts of macroTask/DomEvents patch by setting following flags 24 | * because those flags need to be set before `zone.js` being loaded, and webpack 25 | * will put import in the top of bundle, so user need to create a separate file 26 | * in this directory (for example: zone-flags.ts), and put the following flags 27 | * into that file, and then add the following code before importing zone.js. 28 | * import './zone-flags'; 29 | * 30 | * The flags allowed in zone-flags.ts are listed here. 31 | * 32 | * The following flags will work for all browsers. 33 | * 34 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 35 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 36 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 37 | * 38 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 39 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 40 | * 41 | * (window as any).__Zone_enable_cross_context_check = true; 42 | * 43 | */ 44 | 45 | /*************************************************************************************************** 46 | * Zone JS is required by default for Angular itself. 47 | */ 48 | import 'zone.js'; // Included with Angular CLI. 49 | 50 | /*************************************************************************************************** 51 | * APPLICATION IMPORTS 52 | */ 53 | -------------------------------------------------------------------------------- /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 | 14 | .mat-mdc-progress-spinner svg { 15 | width: 30px !important; 16 | height: 30px !important; 17 | } 18 | -------------------------------------------------------------------------------- /src/test-setup.ts: -------------------------------------------------------------------------------- 1 | import 'jest-preset-angular/setup-jest'; 2 | (global as any)['CSS'] = null; 3 | 4 | /** 5 | * ISSUE: https://github.com/angular/material2/issues/7101 6 | * Workaround for JSDOM missing transform property 7 | */ 8 | Object.defineProperty(document.body.style, 'transform', { 9 | value: () => { 10 | return { 11 | enumerable: true, 12 | configurable: true, 13 | }; 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "src/main.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "noImplicitOverride": true, 10 | "noPropertyAccessFromIndexSignature": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "sourceMap": true, 14 | "declaration": false, 15 | "downlevelIteration": true, 16 | "experimentalDecorators": true, 17 | "moduleResolution": "node", 18 | "importHelpers": true, 19 | "target": "ES2022", 20 | "module": "ES2022", 21 | "useDefineForClassFields": false, 22 | "lib": [ 23 | "ES2022", 24 | "dom" 25 | ], 26 | "paths": { 27 | "@example-app/*": ["./src/app/*"] 28 | } 29 | }, 30 | "angularCompilerOptions": { 31 | "enableI18nLegacyMessageIdFormat": false, 32 | "strictInjectionParameters": true, 33 | "strictInputAccessModifiers": true, 34 | "strictTemplates": true 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": [ 7 | "jasmine" 8 | ] 9 | }, 10 | "include": [ 11 | "src/**/*.spec.ts", 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | --------------------------------------------------------------------------------