├── .browserslistrc ├── .commitlintrc.json ├── .editorconfig ├── .gitignore ├── .huskyrc.json ├── .lintstagedrc.json ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── angular.json ├── assets └── screenshot.png ├── e2e ├── protractor.conf.js ├── src │ ├── app.e2e-spec.ts │ └── app.po.ts └── tsconfig.e2e.json ├── jest.config.js ├── package.json ├── src ├── app │ ├── app-routing.module.ts │ ├── app.module.ts │ ├── auth │ │ ├── auth-routing.module.ts │ │ ├── auth.module.ts │ │ ├── components │ │ │ ├── __snapshots__ │ │ │ │ └── login-form.component.spec.ts.snap │ │ │ ├── login-form.component.spec.ts │ │ │ └── login-form.component.ts │ │ ├── containers │ │ │ ├── __snapshots__ │ │ │ │ └── login-page.component.spec.ts.snap │ │ │ ├── login-page.component.spec.ts │ │ │ └── login-page.component.ts │ │ ├── models │ │ │ └── user.ts │ │ ├── services │ │ │ ├── auth-guard.service.spec.ts │ │ │ ├── auth-guard.service.ts │ │ │ └── auth.service.ts │ │ └── store │ │ │ ├── actions │ │ │ └── auth.actions.ts │ │ │ ├── index.ts │ │ │ └── states │ │ │ ├── auth-status.state.ts │ │ │ ├── auth.state.ts │ │ │ └── login-page.state.ts │ ├── books │ │ ├── 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 │ │ │ ├── selected-book-page.component.spec.ts │ │ │ ├── selected-book-page.component.ts │ │ │ ├── view-book-page.component.spec.ts │ │ │ └── view-book-page.component.ts │ │ ├── guards │ │ │ └── book-exists.guard.ts │ │ ├── models │ │ │ └── book.ts │ │ └── store │ │ │ ├── actions │ │ │ ├── book.actions.ts │ │ │ ├── collection.actions.ts │ │ │ └── search.actions.ts │ │ │ ├── index.ts │ │ │ ├── states │ │ │ ├── books.state.ts │ │ │ ├── collection.state.ts │ │ │ └── search.state.ts │ │ │ └── tests │ │ │ ├── books.state.spec.ts │ │ │ ├── collection.state.spec.ts │ │ │ └── search.state.spec.ts │ ├── core │ │ ├── components │ │ │ ├── layout.component.ts │ │ │ ├── nav-item.component.ts │ │ │ ├── sidenav.component.ts │ │ │ └── toolbar.component.ts │ │ ├── containers │ │ │ ├── app.component.ts │ │ │ └── not-found-page.component.ts │ │ ├── core.module.ts │ │ ├── services │ │ │ ├── google-books.service.spec.ts │ │ │ └── google-books.service.ts │ │ └── store │ │ │ ├── actions │ │ │ └── layout.actions.ts │ │ │ ├── index.ts │ │ │ └── states │ │ │ └── layout.state.ts │ ├── index.ts │ ├── material │ │ ├── index.ts │ │ └── material.module.ts │ └── shared │ │ └── pipes │ │ ├── add-commas.pipe.spec.ts │ │ ├── add-commas.pipe.ts │ │ ├── ellipsis.pipe.spec.ts │ │ ├── ellipsis.pipe.ts │ │ └── index.ts ├── assets │ ├── .gitkeep │ └── .npmignore ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── index.html ├── jest-global-mocks.ts ├── karma.conf.js ├── main.ts ├── polyfills.ts ├── setup-jest.ts ├── styles.css ├── tsconfig.app.json ├── tsconfig.spec.json └── tslint.json ├── tsconfig.json ├── tslint.json └── yarn.lock /.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is currently used by autoprefixer to adjust CSS to support the below specified browsers 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | # For IE 9-11 support, please uncomment the last line of the file and adjust as needed 5 | > 0.5% 6 | last 2 versions 7 | Firefox ESR 8 | not dead 9 | # IE 9-11 -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.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 | 8 | # dependencies 9 | /node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | .angulardoc.json 27 | 28 | # misc 29 | /.sass-cache 30 | /connect.lock 31 | /coverage 32 | /libpeerconnection.log 33 | npm-debug.log 34 | yarn-error.log 35 | testem.log 36 | /typings 37 | 38 | # System Files 39 | .DS_Store 40 | Thumbs.db 41 | -------------------------------------------------------------------------------- /.huskyrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "lint-staged", 4 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.{ts,js}": ["prettier --write", "git add"] 3 | } 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /tmp 3 | package-lock.json 4 | package.json 5 | yarn.lock -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5", 4 | "tabWidth": 2 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 eranshmil 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @ngxs example application 2 | 3 | 4 | **A port of [ngrx-example-app](https://github.com/ngrx/platform/tree/master/example-app).** 5 | 6 | This app is a book collection manager. The user can authenticate, use the Google Books API to search for books and add them to their collection. 7 | 8 | To log in, the username and password is `test`. 9 | 10 | Live demo could be found [here](https://ngxs-example-app.netlify.app). (ReduxDevTools enabled) 11 | 12 | Try on [StackBlitz](https://stackblitz.com/github/eranshmil/ngxs-example-app). 13 | 14 | ![Screenshot](assets/screenshot.png) 15 | 16 | ## Included 17 | 18 | - [ngxs](https://ngxs.gitbook.io/ngxs/) - including storage, router, devtools and logger plugins. 19 | - [jest](https://facebook.github.io/jest/) 20 | 21 | ## Quickstart 22 | 23 | ```bash 24 | # Clone the repo 25 | git clone https://github.com/eranshmil/ngxs-example-app.git 26 | 27 | # Use yarn to install the dependencies 28 | yarn 29 | 30 | # Start the server 31 | yarn serve 32 | 33 | # Running test suites 34 | yarn test 35 | 36 | # Build the app 37 | yarn build:prod 38 | ``` 39 | 40 | Navigate to [http://localhost:4200/](http://localhost:4200/) in your browser 41 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "ngxs-example-app": { 7 | "root": "", 8 | "sourceRoot": "src", 9 | "projectType": "application", 10 | "prefix": "app", 11 | "schematics": {}, 12 | "architect": { 13 | "build": { 14 | "builder": "@angular-devkit/build-angular:browser", 15 | "options": { 16 | "aot": true, 17 | "outputPath": "dist", 18 | "index": "src/index.html", 19 | "main": "src/main.ts", 20 | "polyfills": "src/polyfills.ts", 21 | "tsConfig": "src/tsconfig.app.json", 22 | "assets": ["src/favicon.ico", "src/assets"], 23 | "styles": ["src/styles.css"], 24 | "scripts": [] 25 | }, 26 | "configurations": { 27 | "production": { 28 | "budgets": [ 29 | { 30 | "type": "anyComponentStyle", 31 | "maximumWarning": "6kb" 32 | } 33 | ], 34 | "fileReplacements": [ 35 | { 36 | "replace": "src/environments/environment.ts", 37 | "with": "src/environments/environment.prod.ts" 38 | } 39 | ], 40 | "optimization": true, 41 | "outputHashing": "all", 42 | "sourceMap": false, 43 | "extractCss": true, 44 | "namedChunks": false, 45 | "aot": true, 46 | "extractLicenses": true, 47 | "vendorChunk": false, 48 | "buildOptimizer": true 49 | } 50 | } 51 | }, 52 | "serve": { 53 | "builder": "@angular-devkit/build-angular:dev-server", 54 | "options": { 55 | "browserTarget": "ngxs-example-app:build" 56 | }, 57 | "configurations": { 58 | "production": { 59 | "browserTarget": "ngxs-example-app:build:production" 60 | } 61 | } 62 | }, 63 | "extract-i18n": { 64 | "builder": "@angular-devkit/build-angular:extract-i18n", 65 | "options": { 66 | "browserTarget": "ngxs-example-app:build" 67 | } 68 | }, 69 | "test": { 70 | "builder": "@angular-devkit/build-angular:karma", 71 | "options": { 72 | "main": "src/test.ts", 73 | "polyfills": "src/polyfills.ts", 74 | "tsConfig": "src/tsconfig.spec.json", 75 | "karmaConfig": "src/karma.conf.js", 76 | "styles": ["src/styles.css"], 77 | "scripts": [], 78 | "assets": ["src/favicon.ico", "src/assets"] 79 | } 80 | }, 81 | "lint": { 82 | "builder": "@angular-devkit/build-angular:tslint", 83 | "options": { 84 | "tsConfig": ["src/tsconfig.app.json", "src/tsconfig.spec.json"], 85 | "exclude": ["**/node_modules/**"] 86 | } 87 | } 88 | } 89 | }, 90 | "ngxs-example-app-e2e": { 91 | "root": "e2e/", 92 | "projectType": "application", 93 | "architect": { 94 | "e2e": { 95 | "builder": "@angular-devkit/build-angular:protractor", 96 | "options": { 97 | "protractorConfig": "e2e/protractor.conf.js", 98 | "devServerTarget": "ngxs-example-app:serve" 99 | }, 100 | "configurations": { 101 | "production": { 102 | "devServerTarget": "ngxs-example-app:serve:production" 103 | } 104 | } 105 | }, 106 | "lint": { 107 | "builder": "@angular-devkit/build-angular:tslint", 108 | "options": { 109 | "tsConfig": "e2e/tsconfig.e2e.json", 110 | "exclude": ["**/node_modules/**"] 111 | } 112 | } 113 | } 114 | } 115 | }, 116 | "defaultProject": "ngxs-example-app" 117 | } 118 | -------------------------------------------------------------------------------- /assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eranshmil/ngxs-example-app/fd2a64606ca9dd1bf3be14a3f2983624e05fb1c2/assets/screenshot.png -------------------------------------------------------------------------------- /e2e/protractor.conf.js: -------------------------------------------------------------------------------- 1 | // Protractor configuration file, see link for more information 2 | // https://github.com/angular/protractor/blob/master/lib/config.ts 3 | 4 | const { SpecReporter } = require('jasmine-spec-reporter'); 5 | 6 | exports.config = { 7 | allScriptsTimeout: 11000, 8 | specs: ['./src/**/*.e2e-spec.ts'], 9 | capabilities: { 10 | browserName: 'chrome', 11 | }, 12 | directConnect: true, 13 | baseUrl: 'http://localhost:4200/', 14 | framework: 'jasmine', 15 | jasmineNodeOpts: { 16 | showColors: true, 17 | defaultTimeoutInterval: 30000, 18 | print: function () {}, 19 | }, 20 | onPrepare() { 21 | require('ts-node').register({ 22 | project: require('path').join(__dirname, './tsconfig.e2e.json'), 23 | }); 24 | jasmine 25 | .getEnv() 26 | .addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /e2e/src/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { AppPage } from './app.po'; 2 | 3 | describe('workspace-project App', () => { 4 | let page: AppPage; 5 | 6 | beforeEach(() => { 7 | page = new AppPage(); 8 | }); 9 | 10 | it('should display welcome message', () => { 11 | page.navigateTo(); 12 | expect(page.getParagraphText()).toEqual('Welcome to ngxs-example-app!'); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /e2e/src/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor'; 2 | 3 | export class AppPage { 4 | navigateTo() { 5 | return browser.get('/'); 6 | } 7 | 8 | getParagraphText() { 9 | return element(by.css('app-root h1')).getText(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /e2e/tsconfig.e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "module": "commonjs", 6 | "target": "es5", 7 | "types": [ 8 | "jasmine", 9 | "jasminewd2", 10 | "node" 11 | ] 12 | } 13 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@jest/types').Config.InitialOptions} */ 2 | module.exports = { 3 | globals: { 4 | 'ts-jest': { 5 | tsconfig: '/src/tsconfig.spec.json', 6 | stringifyContentPathRegex: '\\.html$', 7 | astTransformers: { 8 | before: [ 9 | 'jest-preset-angular/build/InlineFilesTransformer', 10 | 'jest-preset-angular/build/StripStylesTransformer', 11 | ], 12 | }, 13 | }, 14 | }, 15 | transform: { 16 | '^.+\\.(ts|js|html)$': 'ts-jest', 17 | }, 18 | moduleFileExtensions: ['ts', 'html', 'js', 'json'], 19 | moduleNameMapper: { 20 | '^src/(.*)$': '/src/$1', 21 | '^app/(.*)$': '/src/app/$1', 22 | '^assets/(.*)$': '/src/assets/$1', 23 | '^environments/(.*)$': '/src/environments/$1', 24 | }, 25 | transformIgnorePatterns: ['node_modules/(?!@ngrx)'], 26 | snapshotSerializers: [ 27 | 'jest-preset-angular/build/AngularSnapshotSerializer.js', 28 | 'jest-preset-angular/build/HTMLCommentSerializer.js', 29 | ], 30 | setupFilesAfterEnv: ['/src/setup-jest.ts'], 31 | }; 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ngxs-example-app", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "cli": "ng", 6 | "precommit": "lint-staged", 7 | "format": "prettier --write \"**/*.{ts,js}\"", 8 | "start": "node server.js", 9 | "serve": "yarn run cli serve", 10 | "serve:aot": "yarn run cli serve --prod", 11 | "test": "jest -c jest.config.js --watch", 12 | "build:prod": "yarn cli build --prod" 13 | }, 14 | "private": true, 15 | "dependencies": { 16 | "@angular/animations": "^10.1.6", 17 | "@angular/cdk": "^10.2.5", 18 | "@angular/cli": "^10.1.7", 19 | "@angular/common": "^10.1.6", 20 | "@angular/compiler": "^10.1.6", 21 | "@angular/compiler-cli": "^10.1.6", 22 | "@angular/core": "^10.1.6", 23 | "@angular/forms": "^10.1.6", 24 | "@angular/material": "^10.2.5", 25 | "@angular/platform-browser": "^10.1.6", 26 | "@angular/platform-browser-dynamic": "^10.1.6", 27 | "@angular/router": "^10.1.6", 28 | "@ngxs/devtools-plugin": "^3.7.0", 29 | "@ngxs/logger-plugin": "^3.7.0", 30 | "@ngxs/router-plugin": "^3.7.0", 31 | "@ngxs/storage-plugin": "^3.7.0", 32 | "@ngxs/store": "^3.7.0", 33 | "core-js": "^3.6.5", 34 | "rxjs": "^6.6.3", 35 | "tslib": "^2.0.0", 36 | "typescript": "~4.0.3", 37 | "zone.js": "~0.10.3" 38 | }, 39 | "devDependencies": { 40 | "@angular-devkit/build-angular": "~0.1001.7", 41 | "@angular/language-service": "^10.1.6", 42 | "@babel/core": "^7.12.3", 43 | "@commitlint/cli": "^11.0.0", 44 | "@commitlint/config-conventional": "^11.0.0", 45 | "@types/jasmine": "^3.5.14", 46 | "@types/jasminewd2": "^2.0.8", 47 | "@types/jest": "^26.0.14", 48 | "@types/node": "^14.11.10", 49 | "babel-core": "^7.0.0-bridge.0", 50 | "babel-jest": "^26.5.2", 51 | "codelyzer": "^6.0.1", 52 | "husky": "^4.3.0", 53 | "jasmine-core": "~3.6.0", 54 | "jasmine-marbles": "^0.6.0", 55 | "jasmine-spec-reporter": "~6.0.0", 56 | "jest": "^26.5.3", 57 | "jest-preset-angular": "^8.3.1", 58 | "karma": "~5.2.3", 59 | "karma-chrome-launcher": "~3.1.0", 60 | "karma-coverage-istanbul-reporter": "~3.0.2", 61 | "karma-jasmine": "~4.0.0", 62 | "karma-jasmine-html-reporter": "^1.5.0", 63 | "lint-staged": "^10.4.2", 64 | "ngx-mock-provider": "^1.0.6", 65 | "prettier": "^2.1.2", 66 | "protractor": "~7.0.0", 67 | "ts-node": "^9.0.0", 68 | "tslint": "~6.1.0" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { Routes, RouterModule } from '@angular/router'; 3 | import { AuthGuard } from './auth/services/auth-guard.service'; 4 | import { NotFoundPageComponent } from './core/containers/not-found-page.component'; 5 | 6 | export const routes: Routes = [ 7 | { path: '', redirectTo: '/books', pathMatch: 'full' }, 8 | { 9 | path: 'books', 10 | loadChildren: () => 11 | import('./books/books.module').then((m) => m.BooksModule), 12 | canActivate: [AuthGuard], 13 | }, 14 | { path: '**', component: NotFoundPageComponent }, 15 | ]; 16 | 17 | @NgModule({ 18 | imports: [RouterModule.forRoot(routes, { useHash: true })], 19 | exports: [RouterModule], 20 | }) 21 | export class AppRoutingModule {} 22 | -------------------------------------------------------------------------------- /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 { NgxsModule } from '@ngxs/store'; 8 | import { NgxsReduxDevtoolsPluginModule } from '@ngxs/devtools-plugin'; 9 | import { NgxsRouterPluginModule } from '@ngxs/router-plugin'; 10 | import { NgxsLoggerPluginModule } from '@ngxs/logger-plugin'; 11 | 12 | import { CoreModule } from './core/core.module'; 13 | import { AuthModule } from './auth/auth.module'; 14 | 15 | import { AppStates } from './core/store'; 16 | import { AppComponent } from './core/containers/app.component'; 17 | import { environment } from '../environments/environment'; 18 | import { AppRoutingModule } from './app-routing.module'; 19 | import { NgxsStoragePluginModule } from '@ngxs/storage-plugin'; 20 | 21 | @NgModule({ 22 | imports: [ 23 | CommonModule, 24 | BrowserModule, 25 | BrowserAnimationsModule, 26 | HttpClientModule, 27 | AuthModule.forRoot(), 28 | AppRoutingModule, 29 | 30 | NgxsModule.forRoot(AppStates, { 31 | developmentMode: !environment.production, 32 | }), 33 | NgxsStoragePluginModule.forRoot({ 34 | key: ['auth.status', 'books'], 35 | }), 36 | NgxsRouterPluginModule.forRoot(), 37 | NgxsReduxDevtoolsPluginModule.forRoot({ 38 | name: 'Ngxs Book Store DevTools', 39 | }), 40 | NgxsLoggerPluginModule.forRoot({ 41 | disabled: environment.production, 42 | }), 43 | 44 | CoreModule.forRoot(), 45 | ], 46 | bootstrap: [AppComponent], 47 | }) 48 | export class AppModule {} 49 | -------------------------------------------------------------------------------- /src/app/auth/auth-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { Routes, RouterModule } from '@angular/router'; 3 | import { LoginPageComponent } from './containers/login-page.component'; 4 | 5 | const routes: Routes = [{ path: 'login', component: LoginPageComponent }]; 6 | 7 | @NgModule({ 8 | imports: [RouterModule.forChild(routes)], 9 | exports: [RouterModule], 10 | }) 11 | export class AuthRoutingModule {} 12 | -------------------------------------------------------------------------------- /src/app/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule, ModuleWithProviders } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { ReactiveFormsModule } from '@angular/forms'; 4 | 5 | import { NgxsModule } from '@ngxs/store'; 6 | 7 | import { LoginPageComponent } from './containers/login-page.component'; 8 | import { LoginFormComponent } from './components/login-form.component'; 9 | 10 | import { AuthService } from './services/auth.service'; 11 | import { AuthGuard } from './services/auth-guard.service'; 12 | import { MaterialModule } from '../material'; 13 | import { AuthRoutingModule } from './auth-routing.module'; 14 | import { AuthStates } from './store'; 15 | 16 | export const COMPONENTS = [LoginPageComponent, LoginFormComponent]; 17 | 18 | @NgModule({ 19 | imports: [CommonModule, ReactiveFormsModule, MaterialModule], 20 | declarations: COMPONENTS, 21 | exports: COMPONENTS, 22 | }) 23 | export class AuthModule { 24 | static forRoot(): ModuleWithProviders { 25 | return { 26 | ngModule: RootAuthModule, 27 | providers: [AuthService, AuthGuard], 28 | }; 29 | } 30 | } 31 | 32 | @NgModule({ 33 | imports: [AuthModule, AuthRoutingModule, NgxsModule.forFeature(AuthStates)], 34 | }) 35 | export class RootAuthModule {} 36 | -------------------------------------------------------------------------------- /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 |
18 |

19 | 20 | 28 | 29 |

30 |

31 | 32 | 40 | 41 |

42 | 43 |

46 | 52 |

53 |
54 |
55 |
56 |
57 | `; 58 | 59 | exports[`Login Page should disable the form if pending 1`] = ` 60 | 64 | 65 | 66 | Login 67 | 68 | 69 |
74 |

75 | 76 | 85 | 86 |

87 |

88 | 89 | 98 | 99 |

100 | 101 |

104 | 110 |

111 |
112 |
113 |
114 |
115 | `; 116 | 117 | exports[`Login Page should display an error message if provided 1`] = ` 118 | 123 | 124 | 125 | Login 126 | 127 | 128 |
133 |

134 | 135 | 143 | 144 |

145 |

146 | 147 | 155 | 156 |

157 | 158 |

161 | Invalid credentials 162 |

163 |

166 | 172 |

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

{{ errorMessage }}

35 | 36 |

37 | 38 |

39 |
40 |
41 |
42 | `, 43 | styles: [ 44 | ` 45 | :host { 46 | display: flex; 47 | justify-content: center; 48 | margin: 72px 0; 49 | } 50 | 51 | .mat-form-field { 52 | width: 100%; 53 | min-width: 300px; 54 | } 55 | 56 | mat-card-title, 57 | mat-card-content { 58 | display: flex; 59 | justify-content: center; 60 | } 61 | 62 | .loginError { 63 | padding: 16px; 64 | width: 300px; 65 | color: white; 66 | background-color: red; 67 | } 68 | 69 | .loginButtons { 70 | display: flex; 71 | flex-direction: row; 72 | justify-content: flex-end; 73 | } 74 | `, 75 | ], 76 | }) 77 | export class LoginFormComponent implements OnInit { 78 | @Input() 79 | set pending(isPending: boolean) { 80 | if (isPending) { 81 | this.form.disable(); 82 | } else { 83 | this.form.enable(); 84 | } 85 | } 86 | 87 | @Input() errorMessage: string | null; 88 | 89 | @Output() submitted = new EventEmitter(); 90 | 91 | form: FormGroup = new FormGroup({ 92 | username: new FormControl(''), 93 | password: new FormControl(''), 94 | }); 95 | 96 | constructor() {} 97 | 98 | ngOnInit() {} 99 | 100 | submit() { 101 | if (this.form.valid) { 102 | this.submitted.emit(this.form.value); 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /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 | 12 | 15 | 18 | Login 19 | 20 | 23 |
28 |

29 | 32 |

35 |
38 | 39 | 40 |
43 | 56 | 59 | 60 | 78 | 79 |
80 | 81 |
82 | 83 |
86 | 89 |
90 |
94 | 95 | 96 |
100 | 101 |
104 |
105 |
106 |
107 | 108 |

109 |

110 | 113 |

116 |
119 | 120 | 121 |
124 | 137 | 140 | 141 | 159 | 160 |
161 | 162 |
163 | 164 |
167 | 170 |
171 |
175 | 176 | 177 |
181 | 182 |
185 |
186 |
187 |
188 | 189 |

190 | 191 |

194 | 200 |

201 | 202 | 203 | 204 | 205 | 206 | `; 207 | -------------------------------------------------------------------------------- /src/app/auth/containers/login-page.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, ComponentFixture } from '@angular/core/testing'; 2 | import { MatCardModule } from '@angular/material/card'; 3 | import { MatInputModule } from '@angular/material/input'; 4 | import { ReactiveFormsModule } from '@angular/forms'; 5 | import { NoopAnimationsModule } from '@angular/platform-browser/animations'; 6 | 7 | import { NgxsModule, Store } from '@ngxs/store'; 8 | 9 | import { LoginPageComponent } from './login-page.component'; 10 | import { LoginFormComponent } from '../components/login-form.component'; 11 | import { AuthStates, Login } from '../store'; 12 | import { AuthService } from '../services/auth.service'; 13 | 14 | describe('Login Page', () => { 15 | let fixture: ComponentFixture; 16 | let store: Store; 17 | let instance: LoginPageComponent; 18 | 19 | beforeEach(() => { 20 | TestBed.configureTestingModule({ 21 | imports: [ 22 | NoopAnimationsModule, 23 | NgxsModule.forRoot(AuthStates), 24 | MatInputModule, 25 | MatCardModule, 26 | ReactiveFormsModule, 27 | ], 28 | declarations: [LoginPageComponent, LoginFormComponent], 29 | providers: [AuthService], 30 | }); 31 | 32 | fixture = TestBed.createComponent(LoginPageComponent); 33 | instance = fixture.componentInstance; 34 | store = TestBed.inject(Store); 35 | 36 | spyOn(store, 'dispatch').and.callThrough(); 37 | }); 38 | 39 | /** 40 | * Container components are used as integration points for connecting 41 | * the store to presentational components and dispatching 42 | * actions to the store. 43 | * 44 | * Container methods that dispatch events are like a component's output observables. 45 | * Container properties that select state from store are like a component's input properties. 46 | * If pure components are functions of their inputs, containers are functions of state 47 | * 48 | * Traditionally you would query the components rendered template 49 | * to validate its state. Since the components are analogous to 50 | * pure functions, we take snapshots of these components for a given state 51 | * to validate the rendered output and verify the component's output 52 | * against changes in state. 53 | */ 54 | it('should compile', () => { 55 | fixture.detectChanges(); 56 | 57 | expect(fixture).toMatchSnapshot(); 58 | }); 59 | 60 | it('should dispatch a login event on submit', () => { 61 | const $event: any = {}; 62 | const action = new Login($event); 63 | 64 | instance.onSubmit($event); 65 | 66 | expect(store.dispatch).toHaveBeenCalledWith(action); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/app/auth/containers/login-page.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | 4 | import { Store, Select } from '@ngxs/store'; 5 | 6 | import { Authenticate } from '../models/user'; 7 | import { Login, LoginPageState } from '../store'; 8 | 9 | @Component({ 10 | selector: 'bc-login-page', 11 | template: ` 12 | 17 | 18 | `, 19 | styles: [], 20 | }) 21 | export class LoginPageComponent implements OnInit { 22 | @Select(LoginPageState.getPending) pending$: Observable; 23 | @Select(LoginPageState.getError) error$: Observable; 24 | 25 | constructor(private store: Store) {} 26 | 27 | ngOnInit() {} 28 | 29 | onSubmit($event: Authenticate) { 30 | this.store.dispatch(new Login($event)); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/app/auth/models/user.ts: -------------------------------------------------------------------------------- 1 | export interface Authenticate { 2 | username: string; 3 | password: string; 4 | } 5 | 6 | export interface User { 7 | name: string; 8 | } 9 | -------------------------------------------------------------------------------- /src/app/auth/services/auth-guard.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { cold } from 'jasmine-marbles'; 4 | 5 | import { NgxsModule, Store } from '@ngxs/store'; 6 | 7 | import { AuthGuard } from './auth-guard.service'; 8 | import { AuthStates, LoginSuccess } from '../store'; 9 | import { AuthService } from './auth.service'; 10 | 11 | describe('Auth Guard', () => { 12 | let guard: AuthGuard; 13 | let store: Store; 14 | 15 | beforeEach(() => { 16 | TestBed.configureTestingModule({ 17 | imports: [NgxsModule.forRoot(AuthStates)], 18 | providers: [AuthGuard, AuthService], 19 | }); 20 | 21 | store = TestBed.inject(Store); 22 | spyOn(store, 'dispatch').and.callThrough(); 23 | guard = TestBed.inject(AuthGuard); 24 | }); 25 | 26 | it('should return false if the user state is not logged in', () => { 27 | const expected = cold('(a|)', { a: false }); 28 | 29 | expect(guard.canActivate()).toBeObservable(expected); 30 | }); 31 | 32 | it('should return true if the user state is logged in', () => { 33 | const user: any = {}; 34 | const action = new LoginSuccess({ user }); 35 | store.dispatch(action); 36 | 37 | const expected = cold('(a|)', { a: true }); 38 | 39 | expect(guard.canActivate()).toBeObservable(expected); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/app/auth/services/auth-guard.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { CanActivate } from '@angular/router'; 3 | import { Observable } from 'rxjs'; 4 | import { map, take } from 'rxjs/operators'; 5 | 6 | import { Store } from '@ngxs/store'; 7 | 8 | import { LoginRedirect, AuthStatusState } from '../store'; 9 | 10 | @Injectable() 11 | export class AuthGuard implements CanActivate { 12 | constructor(private store: Store) {} 13 | 14 | canActivate(): Observable { 15 | return this.store.select(AuthStatusState.getLoggedIn).pipe( 16 | map((authed) => { 17 | if (!authed) { 18 | this.store.dispatch(new LoginRedirect()); 19 | return false; 20 | } 21 | 22 | return true; 23 | }), 24 | take(1) 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/app/auth/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Observable, of, throwError } from 'rxjs'; 3 | 4 | import { Authenticate, User } from '../models/user'; 5 | 6 | @Injectable() 7 | export class AuthService { 8 | constructor() {} 9 | 10 | login({ username, password }: Authenticate): Observable { 11 | /** 12 | * Simulate a failed login to display the error 13 | * message for the login form. 14 | */ 15 | if (username !== 'test') { 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/store/actions/auth.actions.ts: -------------------------------------------------------------------------------- 1 | import { Authenticate, User } from '../../models/user'; 2 | 3 | export class Login { 4 | static readonly type = '[Auth] Login'; 5 | 6 | constructor(public payload: Authenticate) {} 7 | } 8 | 9 | export class LoginSuccess { 10 | static readonly type = '[Auth] Login Success'; 11 | 12 | constructor(public payload: { user: User }) {} 13 | } 14 | 15 | export class LoginFailure { 16 | static readonly type = '[Auth] Login Failure'; 17 | 18 | constructor(public payload: any) {} 19 | } 20 | 21 | export class LoginRedirect { 22 | static readonly type = '[Auth] Login Redirect'; 23 | } 24 | 25 | export class Logout { 26 | static readonly type = '[Auth] Logout'; 27 | } 28 | -------------------------------------------------------------------------------- /src/app/auth/store/index.ts: -------------------------------------------------------------------------------- 1 | import { AuthState } from './states/auth.state'; 2 | import { LoginPageState } from './states/login-page.state'; 3 | import { AuthStatusState } from './states/auth-status.state'; 4 | 5 | export const AuthStates = [AuthState, AuthStatusState, LoginPageState]; 6 | 7 | export * from './actions/auth.actions'; 8 | export * from './states/auth-status.state'; 9 | export * from './states/login-page.state'; 10 | -------------------------------------------------------------------------------- /src/app/auth/store/states/auth-status.state.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { State, Action, Selector, StateContext } from '@ngxs/store'; 4 | import { Navigate } from '@ngxs/router-plugin'; 5 | 6 | import { User } from '../../models/user'; 7 | import { LoginRedirect, LoginSuccess, Logout } from '../actions/auth.actions'; 8 | 9 | export interface AuthStatusStateModel { 10 | loggedIn: boolean; 11 | user: User | null; 12 | } 13 | 14 | const authStatusStateDefaults: AuthStatusStateModel = { 15 | loggedIn: false, 16 | user: null, 17 | }; 18 | 19 | @State({ 20 | name: 'status', 21 | defaults: authStatusStateDefaults, 22 | }) 23 | @Injectable() 24 | export class AuthStatusState { 25 | @Selector() 26 | static getLoggedIn(state: AuthStatusStateModel) { 27 | return state.loggedIn; 28 | } 29 | 30 | @Selector() 31 | static getUser(state: AuthStatusStateModel) { 32 | return state.user; 33 | } 34 | 35 | @Action(LoginSuccess) 36 | loginSuccess( 37 | { patchState }: StateContext, 38 | action: LoginSuccess 39 | ) { 40 | patchState({ 41 | loggedIn: true, 42 | user: action.payload.user, 43 | }); 44 | } 45 | 46 | @Action([Logout, LoginRedirect]) 47 | logout({ dispatch, setState }: StateContext) { 48 | setState(authStatusStateDefaults); 49 | 50 | dispatch(new Navigate(['/login'])); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/app/auth/store/states/auth.state.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { State } from '@ngxs/store'; 4 | 5 | import { LoginPageState } from './login-page.state'; 6 | import { AuthStatusState } from './auth-status.state'; 7 | 8 | @State({ 9 | name: 'auth', 10 | children: [AuthStatusState, LoginPageState], 11 | }) 12 | @Injectable() 13 | export class AuthState {} 14 | -------------------------------------------------------------------------------- /src/app/auth/store/states/login-page.state.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { catchError, map } from 'rxjs/operators'; 3 | 4 | import { State, Selector, Action, StateContext } from '@ngxs/store'; 5 | import { Navigate } from '@ngxs/router-plugin'; 6 | 7 | import { Login, LoginFailure, LoginSuccess } from '../actions/auth.actions'; 8 | import { AuthService } from '../../services/auth.service'; 9 | 10 | export interface LoginPageStateModel { 11 | error: string | null; 12 | pending: boolean; 13 | } 14 | 15 | @State({ 16 | name: 'loginPage', 17 | defaults: { 18 | error: null, 19 | pending: false, 20 | }, 21 | }) 22 | @Injectable() 23 | export class LoginPageState { 24 | constructor(private authService: AuthService) {} 25 | 26 | @Selector() 27 | static getError(state: LoginPageStateModel) { 28 | return state.error; 29 | } 30 | 31 | @Selector() 32 | static getPending(state: LoginPageStateModel) { 33 | return state.pending; 34 | } 35 | 36 | @Action(Login) 37 | login( 38 | { dispatch, patchState }: StateContext, 39 | action: Login 40 | ) { 41 | patchState({ 42 | error: null, 43 | pending: true, 44 | }); 45 | return this.authService.login(action.payload).pipe( 46 | map((user) => dispatch(new LoginSuccess({ user }))), 47 | catchError((error) => { 48 | return dispatch(new LoginFailure(error)); 49 | }) 50 | ); 51 | } 52 | 53 | @Action(LoginSuccess) 54 | loginSuccess({ dispatch, patchState }: StateContext) { 55 | patchState({ 56 | error: null, 57 | pending: false, 58 | }); 59 | 60 | dispatch(new Navigate(['/'])); 61 | } 62 | 63 | @Action(LoginFailure) 64 | loginFailure( 65 | { patchState }: StateContext, 66 | action: LoginFailure 67 | ) { 68 | patchState({ 69 | error: action.payload, 70 | pending: false, 71 | }); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/app/books/books-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { Routes, RouterModule } from '@angular/router'; 3 | import { FindBookPageComponent } from './containers/find-book-page.component'; 4 | import { ViewBookPageComponent } from './containers/view-book-page.component'; 5 | import { CollectionPageComponent } from './containers/collection-page.component'; 6 | import { BookExistsGuard } from './guards/book-exists.guard'; 7 | 8 | export const routes: Routes = [ 9 | { path: 'find', component: FindBookPageComponent }, 10 | { 11 | path: ':id', 12 | component: ViewBookPageComponent, 13 | canActivate: [BookExistsGuard], 14 | }, 15 | { path: '', component: CollectionPageComponent }, 16 | ]; 17 | 18 | @NgModule({ 19 | imports: [RouterModule.forChild(routes)], 20 | exports: [RouterModule], 21 | }) 22 | export class BooksRoutingModule {} 23 | -------------------------------------------------------------------------------- /src/app/books/books.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | 4 | import { NgxsModule } from '@ngxs/store'; 5 | 6 | import { ComponentsModule } from './components'; 7 | import { BookExistsGuard } from './guards/book-exists.guard'; 8 | 9 | import { FindBookPageComponent } from './containers/find-book-page.component'; 10 | import { ViewBookPageComponent } from './containers/view-book-page.component'; 11 | import { SelectedBookPageComponent } from './containers/selected-book-page.component'; 12 | import { CollectionPageComponent } from './containers/collection-page.component'; 13 | import { MaterialModule } from '../material'; 14 | 15 | import { BooksRoutingModule } from './books-routing.module'; 16 | import { BooksStates } from './store'; 17 | 18 | @NgModule({ 19 | imports: [ 20 | CommonModule, 21 | MaterialModule, 22 | ComponentsModule, 23 | BooksRoutingModule, 24 | 25 | NgxsModule.forFeature(BooksStates), 26 | ], 27 | declarations: [ 28 | FindBookPageComponent, 29 | ViewBookPageComponent, 30 | SelectedBookPageComponent, 31 | CollectionPageComponent, 32 | ], 33 | providers: [BookExistsGuard], 34 | }) 35 | export class BooksModule {} 36 | -------------------------------------------------------------------------------- /src/app/books/components/book-authors.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | import { Book } from '../models/book'; 4 | 5 | @Component({ 6 | selector: 'bc-book-authors', 7 | template: ` 8 |
Written By:
9 | {{ authors | bcAddCommas }} 10 | `, 11 | styles: [ 12 | ` 13 | h5 { 14 | margin-bottom: 5px; 15 | } 16 | `, 17 | ], 18 | }) 19 | export class BookAuthorsComponent { 20 | @Input() book: Book; 21 | 22 | get authors() { 23 | return this.book.volumeInfo.authors; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/books/components/book-detail.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, Output, EventEmitter } from '@angular/core'; 2 | import { Book } from '../models/book'; 3 | 4 | @Component({ 5 | selector: 'bc-book-detail', 6 | template: ` 7 | 8 | 9 | {{ title }} 10 | {{ subtitle }} 11 | 12 | 13 |

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

{{ description | bcEllipsis }}

18 |
19 | 20 | 21 | 22 |
23 |
24 | `, 25 | styles: [ 26 | ` 27 | :host { 28 | display: flex; 29 | } 30 | 31 | :host a { 32 | display: flex; 33 | } 34 | 35 | mat-card { 36 | width: 400px; 37 | margin: 15px; 38 | display: flex; 39 | flex-flow: column; 40 | justify-content: space-between; 41 | } 42 | 43 | @media only screen and (max-width: 768px) { 44 | mat-card { 45 | margin: 15px 0 !important; 46 | } 47 | } 48 | mat-card:hover { 49 | box-shadow: 3px 3px 16px -2px rgba(0, 0, 0, 0.5); 50 | } 51 | mat-card-title { 52 | margin-right: 10px; 53 | } 54 | mat-card-title-group { 55 | margin: 0; 56 | } 57 | a { 58 | color: inherit; 59 | text-decoration: none; 60 | } 61 | img { 62 | width: 60px; 63 | min-width: 60px; 64 | margin-left: 5px; 65 | } 66 | mat-card-content { 67 | margin-top: 15px; 68 | margin: 15px 0 0; 69 | } 70 | span { 71 | display: inline-block; 72 | font-size: 13px; 73 | } 74 | mat-card-footer { 75 | padding: 0 25px 25px; 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 | 18 | 19 | 20 | 21 | {{ error }} 22 | 23 | 24 | `, 25 | styles: [ 26 | ` 27 | mat-card-title, 28 | mat-card-content, 29 | mat-card-footer { 30 | display: flex; 31 | justify-content: center; 32 | } 33 | 34 | mat-card-footer { 35 | color: #ff0000; 36 | padding: 5px 0; 37 | } 38 | 39 | .mat-form-field { 40 | min-width: 300px; 41 | margin-right: 10px; // Make room for the spinner 42 | } 43 | 44 | .mat-spinner { 45 | position: relative; 46 | top: 10px; 47 | left: 10px; 48 | visibility: hidden; 49 | } 50 | 51 | .mat-spinner.show { 52 | visibility: visible; 53 | } 54 | `, 55 | ], 56 | }) 57 | export class BookSearchComponent { 58 | @Input() query = ''; 59 | @Input() searching = false; 60 | @Input() error = ''; 61 | @Output() search = new EventEmitter(); 62 | } 63 | -------------------------------------------------------------------------------- /src/app/books/components/index.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { ReactiveFormsModule } from '@angular/forms'; 4 | import { RouterModule } from '@angular/router'; 5 | 6 | import { BookAuthorsComponent } from './book-authors.component'; 7 | import { BookDetailComponent } from './book-detail.component'; 8 | import { BookPreviewComponent } from './book-preview.component'; 9 | import { BookPreviewListComponent } from './book-preview-list.component'; 10 | import { BookSearchComponent } from './book-search.component'; 11 | 12 | import { PipesModule } from '../../shared/pipes'; 13 | import { MaterialModule } from '../../material'; 14 | 15 | export const COMPONENTS = [ 16 | BookAuthorsComponent, 17 | BookDetailComponent, 18 | BookPreviewComponent, 19 | BookPreviewListComponent, 20 | BookSearchComponent, 21 | ]; 22 | 23 | @NgModule({ 24 | imports: [ 25 | CommonModule, 26 | ReactiveFormsModule, 27 | MaterialModule, 28 | RouterModule, 29 | PipesModule, 30 | ], 31 | declarations: COMPONENTS, 32 | exports: COMPONENTS, 33 | }) 34 | export class ComponentsModule {} 35 | -------------------------------------------------------------------------------- /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 | 7 | 10 | 13 | My Collection 14 | 15 | 18 | 19 | 20 | 21 | `; 22 | -------------------------------------------------------------------------------- /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 | 16 | 19 | 22 | Find a Book 23 | 24 | 27 | 30 |
33 |
36 | 37 | 38 |
41 | 51 | 54 | 55 | 73 | 74 |
75 | 76 |
77 | 78 |
81 | 84 |
85 |
89 | 90 | 91 |
95 | 96 |
99 |
100 |
101 |
102 | 103 | 111 | 118 | 119 | 126 | 127 | 128 | 129 | 130 | 133 | 134 | 135 | 136 | 139 | 140 | 141 | 142 | `; 143 | -------------------------------------------------------------------------------- /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 | 12 | 13 | 14 | 15 | `; 16 | -------------------------------------------------------------------------------- /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 | 11 | 12 | 13 | 14 | 15 | `; 16 | -------------------------------------------------------------------------------- /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 | import { MatCardModule } from '@angular/material/card'; 5 | import { MatInputModule } from '@angular/material/input'; 6 | import { HttpClientModule } from '@angular/common/http'; 7 | 8 | import { NgxsModule, Store } from '@ngxs/store'; 9 | 10 | import { CoreModule } from '../../core/core.module'; 11 | import { CollectionPageComponent } from './collection-page.component'; 12 | import { BookPreviewListComponent } from '../components/book-preview-list.component'; 13 | import { BookPreviewComponent } from '../components/book-preview.component'; 14 | import { EllipsisPipe } from '../../shared/pipes/ellipsis.pipe'; 15 | import { AddCommasPipe } from '../../shared/pipes/add-commas.pipe'; 16 | import { BookAuthorsComponent } from '../components/book-authors.component'; 17 | import { BooksStates } from '../store'; 18 | 19 | describe('Collection Page', () => { 20 | let fixture: ComponentFixture; 21 | let store: Store; 22 | let instance: CollectionPageComponent; 23 | 24 | beforeEach(() => { 25 | TestBed.configureTestingModule({ 26 | imports: [ 27 | CoreModule.forRoot(), 28 | HttpClientModule, 29 | NoopAnimationsModule, 30 | NgxsModule.forRoot(BooksStates), 31 | MatCardModule, 32 | MatInputModule, 33 | RouterTestingModule, 34 | ], 35 | declarations: [ 36 | CollectionPageComponent, 37 | BookPreviewListComponent, 38 | BookPreviewComponent, 39 | BookAuthorsComponent, 40 | AddCommasPipe, 41 | EllipsisPipe, 42 | ], 43 | }); 44 | 45 | fixture = TestBed.createComponent(CollectionPageComponent); 46 | instance = fixture.componentInstance; 47 | store = TestBed.inject(Store); 48 | 49 | spyOn(store, 'dispatch').and.callThrough(); 50 | }); 51 | 52 | it('should compile', () => { 53 | fixture.detectChanges(); 54 | 55 | expect(fixture).toMatchSnapshot(); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/app/books/containers/collection-page.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | 4 | import { Select } from '@ngxs/store'; 5 | 6 | import { Book } from '../models/book'; 7 | import { BooksState } from '../store'; 8 | 9 | @Component({ 10 | selector: 'bc-collection-page', 11 | changeDetection: ChangeDetectionStrategy.OnPush, 12 | template: ` 13 | My Collection 14 | 15 | 16 | `, 17 | /** 18 | * Container components are permitted to have just enough styles 19 | * to bring the view together. If the number of styles grow, 20 | * consider breaking them out into presentational 21 | * components. 22 | */ 23 | styles: [ 24 | ` 25 | mat-card-title { 26 | display: flex; 27 | justify-content: center; 28 | } 29 | `, 30 | ], 31 | }) 32 | export class CollectionPageComponent { 33 | @Select(BooksState.getBookCollection) books$: Observable; 34 | } 35 | -------------------------------------------------------------------------------- /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 { HttpClientModule } from '@angular/common/http'; 4 | import { MatCardModule } from '@angular/material/card'; 5 | import { MatInputModule } from '@angular/material/input'; 6 | import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; 7 | import { NoopAnimationsModule } from '@angular/platform-browser/animations'; 8 | import { RouterTestingModule } from '@angular/router/testing'; 9 | 10 | import { cold } from 'jasmine-marbles'; 11 | import { NgxsModule, Store } from '@ngxs/store'; 12 | 13 | import { BookSearchComponent } from '../components/book-search.component'; 14 | import { BookPreviewComponent } from '../components/book-preview.component'; 15 | import { BookPreviewListComponent } from '../components/book-preview-list.component'; 16 | import { EllipsisPipe } from '../../shared/pipes/ellipsis.pipe'; 17 | import { BookAuthorsComponent } from '../components/book-authors.component'; 18 | import { AddCommasPipe } from '../../shared/pipes/add-commas.pipe'; 19 | import { FindBookPageComponent } from './find-book-page.component'; 20 | import { BooksStates, Search } from '../store'; 21 | import { GoogleBooksService } from '../../core/services/google-books.service'; 22 | 23 | describe('Find Book Page', () => { 24 | let fixture: ComponentFixture; 25 | let store: Store; 26 | let googleBooksService: any; 27 | let instance: FindBookPageComponent; 28 | 29 | beforeEach(() => { 30 | TestBed.configureTestingModule({ 31 | imports: [ 32 | HttpClientModule, 33 | NoopAnimationsModule, 34 | NgxsModule.forRoot(BooksStates), 35 | RouterTestingModule, 36 | MatInputModule, 37 | MatCardModule, 38 | MatProgressSpinnerModule, 39 | ReactiveFormsModule, 40 | ], 41 | declarations: [ 42 | FindBookPageComponent, 43 | BookSearchComponent, 44 | BookPreviewComponent, 45 | BookPreviewListComponent, 46 | BookAuthorsComponent, 47 | AddCommasPipe, 48 | EllipsisPipe, 49 | ], 50 | providers: [ 51 | { 52 | provide: GoogleBooksService, 53 | useValue: { searchBooks: jest.fn() }, 54 | }, 55 | ], 56 | }); 57 | 58 | fixture = TestBed.createComponent(FindBookPageComponent); 59 | instance = fixture.componentInstance; 60 | store = TestBed.inject(Store); 61 | googleBooksService = TestBed.inject(GoogleBooksService); 62 | 63 | spyOn(store, 'dispatch').and.callThrough(); 64 | }); 65 | 66 | it('should compile', () => { 67 | fixture.detectChanges(); 68 | 69 | expect(fixture).toMatchSnapshot(); 70 | }); 71 | 72 | it('should dispatch a Search action on search', () => { 73 | const $event = 'book name'; 74 | const action = new Search($event); 75 | const response = cold('-a|', { a: [] }); 76 | googleBooksService.searchBooks = jest.fn(() => response); 77 | 78 | instance.search($event); 79 | 80 | expect(store.dispatch).toHaveBeenCalledWith(action); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /src/app/books/containers/find-book-page.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | 4 | import { Select, Store } from '@ngxs/store'; 5 | 6 | import { Book } from '../models/book'; 7 | import { BooksState, SearchState, Search } from '../store'; 8 | 9 | @Component({ 10 | selector: 'bc-find-book-page', 11 | changeDetection: ChangeDetectionStrategy.OnPush, 12 | template: ` 13 | 19 | 20 | `, 21 | }) 22 | export class FindBookPageComponent { 23 | @Select(SearchState.getQuery) searchQuery$: Observable; 24 | @Select(BooksState.getSearchResults) books$: Observable; 25 | @Select(SearchState.getLoading) loading$: Observable; 26 | @Select(SearchState.getError) error$: Observable; 27 | 28 | constructor(private store: Store) {} 29 | 30 | search(query: string) { 31 | this.store.dispatch(new Search(query)); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/app/books/containers/selected-book-page.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { HttpClientModule } from '@angular/common/http'; 3 | import { NoopAnimationsModule } from '@angular/platform-browser/animations'; 4 | import { MatCardModule } from '@angular/material/card'; 5 | 6 | import { NgxsModule, Store } from '@ngxs/store'; 7 | 8 | import { BookDetailComponent } from '../components/book-detail.component'; 9 | import { Book, generateMockBook } from '../models/book'; 10 | import { BookAuthorsComponent } from '../components/book-authors.component'; 11 | import { AddCommasPipe } from '../../shared/pipes/add-commas.pipe'; 12 | import { AddBook, BooksStates, RemoveBook } from '../store'; 13 | import { CoreModule } from '../../core/core.module'; 14 | import { SelectedBookPageComponent } from './selected-book-page.component'; 15 | 16 | describe('Selected Book Page', () => { 17 | let fixture: ComponentFixture; 18 | let store: Store; 19 | let instance: SelectedBookPageComponent; 20 | 21 | beforeEach(() => { 22 | TestBed.configureTestingModule({ 23 | imports: [ 24 | CoreModule.forRoot(), 25 | HttpClientModule, 26 | NoopAnimationsModule, 27 | NgxsModule.forRoot(BooksStates), 28 | MatCardModule, 29 | ], 30 | declarations: [ 31 | SelectedBookPageComponent, 32 | BookDetailComponent, 33 | BookAuthorsComponent, 34 | AddCommasPipe, 35 | ], 36 | }); 37 | 38 | fixture = TestBed.createComponent(SelectedBookPageComponent); 39 | instance = fixture.componentInstance; 40 | store = TestBed.inject(Store); 41 | 42 | spyOn(store, 'dispatch').and.callThrough(); 43 | }); 44 | 45 | it('should compile', () => { 46 | fixture.detectChanges(); 47 | 48 | expect(fixture).toMatchSnapshot(); 49 | }); 50 | 51 | it('should dispatch a AddBook action when addToCollection is called', () => { 52 | const $event: Book = generateMockBook(); 53 | const action = new AddBook($event); 54 | 55 | instance.addToCollection($event); 56 | 57 | expect(store.dispatch).toHaveBeenLastCalledWith(action); 58 | }); 59 | 60 | it('should dispatch a RemoveBook action on removeFromCollection', () => { 61 | const $event: Book = generateMockBook(); 62 | const action = new RemoveBook($event); 63 | 64 | instance.removeFromCollection($event); 65 | 66 | expect(store.dispatch).toHaveBeenLastCalledWith(action); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/app/books/containers/selected-book-page.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ChangeDetectionStrategy } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | 4 | import { Store, Select } from '@ngxs/store'; 5 | 6 | import { Book } from '../models/book'; 7 | import { AddBook, BooksState, RemoveBook } from '../store'; 8 | 9 | @Component({ 10 | selector: 'bc-selected-book-page', 11 | changeDetection: ChangeDetectionStrategy.OnPush, 12 | template: ` 13 | 19 | 20 | `, 21 | }) 22 | export class SelectedBookPageComponent { 23 | @Select(BooksState.getSelectedBook) book$: Observable; 24 | @Select(BooksState.isSelectedBookInCollection) 25 | isSelectedBookInCollection$: Observable; 26 | 27 | constructor(private store: Store) {} 28 | 29 | addToCollection(book: Book) { 30 | this.store.dispatch(new AddBook(book)); 31 | } 32 | 33 | removeFromCollection(book: Book) { 34 | this.store.dispatch(new RemoveBook(book)); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /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 | import { MatCardModule } from '@angular/material/card'; 4 | import { HttpClient } from '@angular/common/http'; 5 | import { BehaviorSubject } from 'rxjs'; 6 | 7 | import { NgxsModule, Store } from '@ngxs/store'; 8 | 9 | import { ViewBookPageComponent } from './view-book-page.component'; 10 | import { SelectedBookPageComponent } from './selected-book-page.component'; 11 | import { BookDetailComponent } from '../components/book-detail.component'; 12 | import { BookAuthorsComponent } from '../components/book-authors.component'; 13 | import { AddCommasPipe } from '../../shared/pipes/add-commas.pipe'; 14 | import { BooksStates, Select } from '../store'; 15 | import { GoogleBooksService } from '../../core/services/google-books.service'; 16 | 17 | describe('View Book Page', () => { 18 | const params = new BehaviorSubject({}); 19 | let fixture: ComponentFixture; 20 | let store: Store; 21 | let instance: ViewBookPageComponent; 22 | 23 | beforeEach(() => { 24 | TestBed.configureTestingModule({ 25 | imports: [MatCardModule, NgxsModule.forRoot(BooksStates)], 26 | providers: [ 27 | { 28 | provide: ActivatedRoute, 29 | useValue: { params }, 30 | }, 31 | HttpClient, 32 | { 33 | provide: GoogleBooksService, 34 | useValue: { searchBooks: () => {} }, 35 | }, 36 | ], 37 | declarations: [ 38 | ViewBookPageComponent, 39 | SelectedBookPageComponent, 40 | BookDetailComponent, 41 | BookAuthorsComponent, 42 | AddCommasPipe, 43 | ], 44 | }); 45 | 46 | fixture = TestBed.createComponent(ViewBookPageComponent); 47 | instance = fixture.componentInstance; 48 | store = TestBed.inject(Store); 49 | 50 | spyOn(store, 'dispatch').and.callThrough(); 51 | }); 52 | 53 | it('should compile', () => { 54 | fixture.detectChanges(); 55 | 56 | expect(fixture).toMatchSnapshot(); 57 | }); 58 | 59 | it('should dispatch a Select action on init', () => { 60 | const action = new Select('2'); 61 | params.next({ id: '2' }); 62 | 63 | fixture.detectChanges(); 64 | 65 | expect(store.dispatch).toHaveBeenCalledWith(action); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/app/books/containers/view-book-page.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnDestroy, ChangeDetectionStrategy } from '@angular/core'; 2 | import { ActivatedRoute } from '@angular/router'; 3 | import { Subscription } from 'rxjs'; 4 | import { map, tap } from 'rxjs/operators'; 5 | 6 | import { Store } from '@ngxs/store'; 7 | 8 | import { Select } from '../store'; 9 | 10 | /** 11 | * Note: Container components are also reusable. Whether or not 12 | * a component is a presentation component or a container 13 | * component is an implementation detail. 14 | * 15 | * The View Book Page's responsibility is to map router params 16 | * to a 'Select' book action. Actually showing the selected 17 | * book remains a responsibility of the 18 | * SelectedBookPageComponent 19 | */ 20 | @Component({ 21 | selector: 'bc-view-book-page', 22 | changeDetection: ChangeDetectionStrategy.OnPush, 23 | template: ` `, 24 | }) 25 | export class ViewBookPageComponent implements OnDestroy { 26 | actionsSubscription: Subscription; 27 | 28 | constructor(store: Store, route: ActivatedRoute) { 29 | this.actionsSubscription = route.params 30 | .pipe( 31 | map((params) => new Select(params.id)), 32 | tap((action: Select) => store.dispatch(action)) 33 | ) 34 | .subscribe(); 35 | } 36 | 37 | ngOnDestroy() { 38 | this.actionsSubscription.unsubscribe(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/app/books/guards/book-exists.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ActivatedRouteSnapshot, CanActivate, Router } from '@angular/router'; 3 | import { Observable, of } from 'rxjs'; 4 | import { catchError, map, switchMap, take, tap } from 'rxjs/operators'; 5 | 6 | import { Store } from '@ngxs/store'; 7 | 8 | import { GoogleBooksService } from '../../core/services/google-books.service'; 9 | import { BooksState, CollectionState, Load } from '../store'; 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 | @Injectable() 17 | export class BookExistsGuard implements CanActivate { 18 | constructor( 19 | private store: Store, 20 | private googleBooks: GoogleBooksService, 21 | private router: Router 22 | ) {} 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 | waitForCollectionToLoad(): Observable { 30 | return this.store.select(CollectionState.getLoaded).pipe(take(1)); 31 | } 32 | 33 | /** 34 | * This method checks if a book with the given ID is already registered 35 | * in the Store 36 | */ 37 | hasBookInStore(id: string): Observable { 38 | return this.store.select(BooksState.getEntities).pipe( 39 | map((entities) => entities && !!entities[id]), 40 | take(1) 41 | ); 42 | } 43 | 44 | /** 45 | * This method loads a book with the given ID from the API and caches 46 | * it in the store, returning `true` or `false` if it was found. 47 | */ 48 | hasBookInApi(id: string): Observable { 49 | return this.googleBooks.retrieveBook(id).pipe( 50 | map((bookEntity) => new Load(bookEntity)), 51 | tap((action: Load) => this.store.dispatch(action)), 52 | map((book) => !!book), 53 | catchError(() => { 54 | this.router.navigate(['/404']); 55 | return of(false); 56 | }) 57 | ); 58 | } 59 | 60 | /** 61 | * `hasBook` composes `hasBookInStore` and `hasBookInApi`. It first checks 62 | * if the book is in store, and if not it then checks if it is in the 63 | * API. 64 | */ 65 | hasBook(id: string): Observable { 66 | return this.hasBookInStore(id).pipe( 67 | switchMap((inStore) => { 68 | if (inStore) { 69 | return of(inStore); 70 | } 71 | 72 | return this.hasBookInApi(id); 73 | }) 74 | ); 75 | } 76 | 77 | /** 78 | * This is the actual method the router will call when our guard is run. 79 | * 80 | * Our guard waits for the collection to load, then it checks if we need 81 | * to request a book from the API or if we already have it in our cache. 82 | * If it finds it in the cache or in the API, it returns an Observable 83 | * of `true` and the route is rendered successfully. 84 | * 85 | * If it was unable to find it in our cache or in the API, this guard 86 | * will return an Observable of `false`, causing the router to move 87 | * on to the next candidate route. In this case, it will move on 88 | * to the 404 page. 89 | */ 90 | canActivate(route: ActivatedRouteSnapshot): Observable { 91 | return this.waitForCollectionToLoad().pipe( 92 | switchMap(() => this.hasBook(route.params['id'])) 93 | ); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /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/store/actions/book.actions.ts: -------------------------------------------------------------------------------- 1 | import { Book } from '../../models/book'; 2 | 3 | export class Load { 4 | static readonly type = '[Book] Load'; 5 | 6 | constructor(public payload: Book) {} 7 | } 8 | 9 | export class Select { 10 | static readonly type = '[Book] Select'; 11 | 12 | constructor(public payload: string) {} 13 | } 14 | -------------------------------------------------------------------------------- /src/app/books/store/actions/collection.actions.ts: -------------------------------------------------------------------------------- 1 | import { Book } from '../../models/book'; 2 | 3 | export class AddBook { 4 | static readonly type = '[Collection] Add Book'; 5 | 6 | constructor(public payload: Book) {} 7 | } 8 | 9 | export class RemoveBook { 10 | static readonly type = '[Collection] Remove Book'; 11 | 12 | constructor(public payload: Book) {} 13 | } 14 | -------------------------------------------------------------------------------- /src/app/books/store/actions/search.actions.ts: -------------------------------------------------------------------------------- 1 | import { Book } from '../../models/book'; 2 | 3 | export class Search { 4 | static readonly type = '[Book] Search'; 5 | 6 | constructor(public payload: string) {} 7 | } 8 | 9 | export class SearchComplete { 10 | static readonly type = '[Book] Search Complete'; 11 | 12 | constructor(public payload: Book[]) {} 13 | } 14 | 15 | export class SearchError { 16 | static readonly type = '[Book] Search Error'; 17 | 18 | constructor(public payload: string) {} 19 | } 20 | -------------------------------------------------------------------------------- /src/app/books/store/index.ts: -------------------------------------------------------------------------------- 1 | import { BooksState } from './states/books.state'; 2 | import { SearchState } from './states/search.state'; 3 | import { CollectionState } from './states/collection.state'; 4 | 5 | export const BooksStates = [BooksState, SearchState, CollectionState]; 6 | 7 | export * from './states/books.state'; 8 | export * from './states/search.state'; 9 | export * from './states/collection.state'; 10 | export * from './actions/book.actions'; 11 | export * from './actions/collection.actions'; 12 | export * from './actions/search.actions'; 13 | -------------------------------------------------------------------------------- /src/app/books/store/states/books.state.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { Action, Selector, State, StateContext } from '@ngxs/store'; 4 | 5 | import { Book } from '../../models/book'; 6 | import { SearchState, SearchStateModel } from './search.state'; 7 | import { CollectionState, CollectionStateModel } from './collection.state'; 8 | import { Load, Select } from '../actions/book.actions'; 9 | import { SearchComplete } from '../actions/search.actions'; 10 | 11 | export const arrayToObject = (entities) => { 12 | return entities.reduce((obj, book: Book) => { 13 | return { ...obj, [book.id]: book }; 14 | }, {}); 15 | }; 16 | 17 | export interface BooksStateModel { 18 | ids: string[]; 19 | entities: { 20 | [id: string]: Book; 21 | }; 22 | selectedBookId: string | null; 23 | } 24 | 25 | export const booksStateDefaults: BooksStateModel = { 26 | ids: [], 27 | entities: {}, 28 | selectedBookId: null, 29 | }; 30 | 31 | @State({ 32 | name: 'books', 33 | defaults: booksStateDefaults, 34 | children: [SearchState, CollectionState], 35 | }) 36 | @Injectable() 37 | export class BooksState { 38 | @Selector() 39 | static getEntities(state: BooksStateModel) { 40 | return state.entities; 41 | } 42 | 43 | @Selector() 44 | static getSelectedId(state: BooksStateModel) { 45 | return state.selectedBookId; 46 | } 47 | 48 | @Selector() 49 | static getSelectedBook(state: BooksStateModel) { 50 | return state.selectedBookId && state.entities[state.selectedBookId]; 51 | } 52 | 53 | @Selector([CollectionState]) 54 | static isSelectedBookInCollection( 55 | state: BooksStateModel, 56 | collectionState: CollectionStateModel 57 | ) { 58 | return collectionState.ids.indexOf(state.selectedBookId) > -1; 59 | } 60 | 61 | @Selector([CollectionState]) 62 | static getBookCollection( 63 | state: BooksStateModel, 64 | collectionState: CollectionStateModel 65 | ): Book[] { 66 | const entities = state.entities; 67 | const ids = [...collectionState.ids]; 68 | 69 | return ids.map((id) => entities[id]); 70 | } 71 | 72 | @Selector([SearchState]) 73 | static getSearchResults( 74 | state: BooksStateModel, 75 | searchState: SearchStateModel 76 | ) { 77 | const searchIds = [...searchState.ids]; 78 | const books = state.entities; 79 | 80 | return searchIds.map((id) => books[id]); 81 | } 82 | 83 | @Action(Load) 84 | load({ getState, patchState }: StateContext, action: Load) { 85 | const state = getState(); 86 | const book = action.payload; 87 | 88 | patchState({ 89 | ids: [...state.ids, book.id], 90 | entities: { ...state.entities, [book.id]: book }, 91 | }); 92 | } 93 | 94 | @Action(Select) 95 | select({ patchState }: StateContext, action: Select) { 96 | patchState({ selectedBookId: action.payload }); 97 | } 98 | 99 | @Action(SearchComplete) 100 | searchComplete( 101 | { getState, patchState }: StateContext, 102 | action: SearchComplete 103 | ) { 104 | const state = getState(); 105 | const ids = action.payload.map((book) => book.id); 106 | const entities = arrayToObject(action.payload); 107 | 108 | patchState({ 109 | ids: [...state.ids, ...ids], 110 | entities: { ...state.entities, ...entities }, 111 | }); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/app/books/store/states/collection.state.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { Action, Selector, State, StateContext } from '@ngxs/store'; 4 | 5 | import { AddBook, RemoveBook } from '../actions/collection.actions'; 6 | 7 | export interface CollectionStateModel { 8 | loaded: boolean; 9 | loading: boolean; 10 | ids: string[]; 11 | } 12 | 13 | export const collectionStateDefaults: CollectionStateModel = { 14 | loaded: false, 15 | loading: false, 16 | ids: [], 17 | }; 18 | 19 | @State({ 20 | name: 'collection', 21 | defaults: collectionStateDefaults, 22 | }) 23 | @Injectable() 24 | export class CollectionState { 25 | @Selector() 26 | static getLoaded(state: CollectionStateModel) { 27 | return state.loaded; 28 | } 29 | 30 | @Selector() 31 | static getLoading(state: CollectionStateModel) { 32 | return state.loading; 33 | } 34 | 35 | @Selector() 36 | static getIds(state: CollectionStateModel) { 37 | return state.ids; 38 | } 39 | 40 | @Action(AddBook) 41 | addBook( 42 | { getState, patchState }: StateContext, 43 | action: AddBook 44 | ) { 45 | const state = getState(); 46 | const bookId = action.payload.id; 47 | 48 | if (state.ids.indexOf(action.payload.id) === -1) { 49 | patchState({ ids: [...state.ids, bookId] }); 50 | } 51 | } 52 | 53 | @Action(RemoveBook) 54 | removeBook( 55 | { getState, patchState }: StateContext, 56 | action: RemoveBook 57 | ) { 58 | const state = getState(); 59 | const bookId = action.payload.id; 60 | 61 | patchState({ ids: state.ids.filter((id) => id !== bookId) }); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/app/books/store/states/search.state.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { catchError, map } from 'rxjs/operators'; 3 | import { of } from 'rxjs'; 4 | 5 | import { Action, Selector, State, StateContext } from '@ngxs/store'; 6 | 7 | import { Search, SearchComplete, SearchError } from '../actions/search.actions'; 8 | import { Book } from '../../models/book'; 9 | import { GoogleBooksService } from '../../../core/services/google-books.service'; 10 | 11 | export interface SearchStateModel { 12 | ids: string[]; 13 | loading: boolean; 14 | error: string; 15 | query: string; 16 | } 17 | 18 | export const searchStateDefaults: SearchStateModel = { 19 | ids: [], 20 | loading: false, 21 | error: '', 22 | query: '', 23 | }; 24 | 25 | @State({ 26 | name: 'search', 27 | defaults: searchStateDefaults, 28 | }) 29 | @Injectable() 30 | export class SearchState { 31 | constructor(private googleBooks: GoogleBooksService) {} 32 | 33 | @Selector() 34 | static getIds(state: SearchStateModel) { 35 | return state.ids; 36 | } 37 | 38 | @Selector() 39 | static getQuery(state: SearchStateModel) { 40 | return state.query; 41 | } 42 | 43 | @Selector() 44 | static getLoading(state: SearchStateModel) { 45 | return state.loading; 46 | } 47 | 48 | @Selector() 49 | static getError(state: SearchStateModel) { 50 | return state.error; 51 | } 52 | 53 | @Action(Search, { cancelUncompleted: true }) 54 | search( 55 | { dispatch, patchState }: StateContext, 56 | action: Search 57 | ) { 58 | const query = action.payload; 59 | 60 | if (query === '') { 61 | patchState({ 62 | ids: [], 63 | loading: false, 64 | error: '', 65 | query, 66 | }); 67 | } 68 | 69 | patchState({ 70 | loading: true, 71 | error: '', 72 | query, 73 | }); 74 | 75 | return this.googleBooks.searchBooks(action.payload).pipe( 76 | map((books: Book[]) => dispatch(new SearchComplete(books))), 77 | catchError((err) => { 78 | dispatch(new SearchError(err.error.error.message)); 79 | 80 | return of(new SearchError(err)); 81 | }) 82 | ); 83 | } 84 | 85 | @Action(SearchComplete) 86 | searchComplete( 87 | { patchState }: StateContext, 88 | action: SearchComplete 89 | ) { 90 | patchState({ 91 | ids: action.payload.map((book) => book.id), 92 | loading: false, 93 | error: '', 94 | }); 95 | } 96 | 97 | @Action(SearchError) 98 | searchError( 99 | { patchState }: StateContext, 100 | action: SearchError 101 | ) { 102 | patchState({ 103 | loading: false, 104 | error: action.payload, 105 | }); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/app/books/store/tests/books.state.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, waitForAsync } from '@angular/core/testing'; 2 | 3 | import { NgxsModule, Store } from '@ngxs/store'; 4 | import { MockProvider } from 'ngx-mock-provider'; 5 | 6 | import { Book, generateMockBook } from '../../models/book'; 7 | import { GoogleBooksService } from '../../../core/services/google-books.service'; 8 | 9 | import { 10 | Load, 11 | Select, 12 | BooksState, 13 | booksStateDefaults, 14 | BooksStates, 15 | collectionStateDefaults, 16 | CollectionStateModel, 17 | SearchComplete, 18 | searchStateDefaults, 19 | } from '../'; 20 | 21 | describe('Books State', () => { 22 | let store: Store; 23 | 24 | const book1: Book = generateMockBook(); 25 | const book2: Book = { ...generateMockBook(), id: '2' }; 26 | 27 | const ids: string[] = [book1.id, book2.id]; 28 | const entities: { [id: string]: Book } = { 29 | [book1.id]: book1, 30 | [book2.id]: book2, 31 | }; 32 | 33 | const collection: CollectionStateModel = { 34 | ...collectionStateDefaults, 35 | ids: ['1'], 36 | }; 37 | 38 | beforeEach( 39 | waitForAsync(() => { 40 | TestBed.configureTestingModule({ 41 | imports: [NgxsModule.forRoot(BooksStates)], 42 | providers: [ 43 | MockProvider({ 44 | provider: GoogleBooksService, 45 | }), 46 | ], 47 | }).compileComponents(); 48 | 49 | store = TestBed.inject(Store); 50 | store.reset({ books: booksStateDefaults }); 51 | }) 52 | ); 53 | 54 | it( 55 | '[action] it should load a book', 56 | waitForAsync(() => { 57 | store.dispatch(new Load(book1)); 58 | 59 | const actualEntities = store.selectSnapshot( 60 | (state) => state.books.entities 61 | ); 62 | 63 | expect(actualEntities).toEqual({ 64 | [book1.id]: book1, 65 | }); 66 | }) 67 | ); 68 | 69 | it( 70 | '[action] it should select a book', 71 | waitForAsync(() => { 72 | store.dispatch(new Select(book2.id)); 73 | 74 | const actualSelectedBookId = store.selectSnapshot( 75 | (state) => state.books.selectedBookId 76 | ); 77 | expect(actualSelectedBookId).toEqual(book2.id); 78 | }) 79 | ); 80 | 81 | it( 82 | '[action] it should fill entities on search complete', 83 | waitForAsync(() => { 84 | store.dispatch(new SearchComplete([book1, book2])); 85 | 86 | const actualState = store.selectSnapshot((state) => state.books); 87 | expect(actualState.ids).toEqual(ids); 88 | expect(actualState.entities).toEqual(entities); 89 | }) 90 | ); 91 | 92 | it('[selector] it should get selected book', () => { 93 | const selector = BooksState.getSelectedBook({ 94 | ids, 95 | entities, 96 | selectedBookId: '2', 97 | }); 98 | 99 | expect(selector).toEqual(book2); 100 | }); 101 | 102 | it('[selector] it should check if selected book in collection', () => { 103 | const selector1 = BooksState.isSelectedBookInCollection( 104 | { ids, entities, selectedBookId: '1' }, 105 | collection 106 | ); 107 | const selector2 = BooksState.isSelectedBookInCollection( 108 | { ids, entities, selectedBookId: '2' }, 109 | collection 110 | ); 111 | 112 | expect(selector1).toBe(true); 113 | expect(selector2).toBe(false); 114 | }); 115 | 116 | it('[selector] it should get book collection', () => { 117 | const selector = BooksState.getBookCollection( 118 | { ...booksStateDefaults, ids, entities }, 119 | collection 120 | ); 121 | const expected = [book1]; 122 | 123 | expect(selector).toEqual(expected); 124 | }); 125 | 126 | it('[selector] it should get search results', () => { 127 | const selector = BooksState.getSearchResults( 128 | { ...booksStateDefaults, ids, entities }, 129 | { ...searchStateDefaults, ids: ['2'] } 130 | ); 131 | const expected = [book2]; 132 | 133 | expect(selector).toEqual(expected); 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /src/app/books/store/tests/collection.state.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, waitForAsync } from '@angular/core/testing'; 2 | 3 | import { NgxsModule, Store } from '@ngxs/store'; 4 | 5 | import { Book, generateMockBook } from '../../models/book'; 6 | 7 | import { 8 | collectionStateDefaults, 9 | CollectionState, 10 | RemoveBook, 11 | AddBook, 12 | } from '../'; 13 | 14 | describe('Collection State', () => { 15 | let store: Store; 16 | 17 | const book: Book = generateMockBook(); 18 | 19 | beforeEach( 20 | waitForAsync(() => { 21 | TestBed.configureTestingModule({ 22 | imports: [NgxsModule.forRoot([CollectionState])], 23 | }).compileComponents(); 24 | 25 | store = TestBed.inject(Store); 26 | }) 27 | ); 28 | 29 | it( 30 | '[action] it should add book', 31 | waitForAsync(() => { 32 | store.reset({ collection: collectionStateDefaults }); 33 | 34 | store.dispatch(new AddBook(book)); 35 | 36 | const actualIds = store.selectSnapshot((state) => state.collection.ids); 37 | expect(actualIds).toEqual([book.id]); 38 | }) 39 | ); 40 | 41 | it( 42 | '[action] it should remove book', 43 | waitForAsync(() => { 44 | store.reset({ 45 | collection: { ...collectionStateDefaults, ids: [book.id] }, 46 | }); 47 | 48 | store.dispatch(new RemoveBook(book)); 49 | 50 | const actualIds = store.selectSnapshot((state) => state.collection.ids); 51 | expect(actualIds).toEqual([]); 52 | }) 53 | ); 54 | }); 55 | -------------------------------------------------------------------------------- /src/app/books/store/tests/search.state.spec.ts: -------------------------------------------------------------------------------- 1 | import { waitForAsync, TestBed } from '@angular/core/testing'; 2 | import { of, throwError } from 'rxjs'; 3 | 4 | import { NgxsModule, Store } from '@ngxs/store'; 5 | 6 | import { Book, generateMockBook } from '../../models/book'; 7 | import { GoogleBooksService } from '../../../core/services/google-books.service'; 8 | 9 | import { Search } from '../actions/search.actions'; 10 | import { SearchState, searchStateDefaults } from '../states/search.state'; 11 | 12 | describe('Books State', () => { 13 | let store: Store; 14 | let googleBooksService: GoogleBooksService; 15 | 16 | const book1: Book = generateMockBook(); 17 | 18 | beforeEach( 19 | waitForAsync(() => { 20 | TestBed.configureTestingModule({ 21 | imports: [NgxsModule.forRoot([SearchState])], 22 | providers: [ 23 | { 24 | provide: GoogleBooksService, 25 | useValue: { searchBooks: jest.fn() }, 26 | }, 27 | ], 28 | }).compileComponents(); 29 | 30 | store = TestBed.inject(Store); 31 | store.reset({ search: searchStateDefaults }); 32 | googleBooksService = TestBed.inject(GoogleBooksService); 33 | }) 34 | ); 35 | 36 | it( 37 | '[action] it should search a book', 38 | waitForAsync(() => { 39 | const response = of([book1]); 40 | googleBooksService.searchBooks = jest.fn(() => response); 41 | 42 | store.dispatch(new Search('title')); 43 | 44 | const actualIds = store.selectSnapshot((state) => state.search.ids); 45 | expect(actualIds).toEqual([book1.id]); 46 | }) 47 | ); 48 | 49 | it( 50 | '[action] it should search and throw error', 51 | waitForAsync(() => { 52 | const expectedError = 'error message'; 53 | const response = throwError({ 54 | error: { error: { message: expectedError } }, 55 | }); 56 | googleBooksService.searchBooks = jest.fn(() => response); 57 | 58 | store.dispatch(new Search('title')); 59 | 60 | const actualError = store.selectSnapshot((state) => state.search.error); 61 | expect(actualError).toEqual(expectedError); 62 | }) 63 | ); 64 | }); 65 | -------------------------------------------------------------------------------- /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 | ::ng-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 | .secondary { 15 | color: rgba(0, 0, 0, 0.54); 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, Input } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'bc-sidenav', 5 | template: ` 6 | 7 | 8 | 9 | `, 10 | styles: [ 11 | ` 12 | mat-sidenav { 13 | width: 300px; 14 | } 15 | `, 16 | ], 17 | }) 18 | export class SidenavComponent { 19 | @Input() open = false; 20 | } 21 | -------------------------------------------------------------------------------- /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 { Observable } from 'rxjs'; 3 | 4 | import { Store, Select } from '@ngxs/store'; 5 | 6 | import { CloseSidenav, OpenSidenav, LayoutState } from '../store'; 7 | import { Logout, AuthStatusState } from '../../auth/store'; 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 | 34 | Sign In 35 | 36 | 37 | Sign Out 38 | 39 | 40 | Book Collection 41 | 42 | 43 | 44 | `, 45 | }) 46 | export class AppComponent { 47 | @Select(LayoutState.getShowSidenav) showSidenav$: Observable; 48 | @Select(AuthStatusState.getLoggedIn) loggedIn$: Observable; 49 | 50 | constructor(private store: Store) {} 51 | 52 | closeSidenav() { 53 | this.store.dispatch(new CloseSidenav()); 54 | } 55 | 56 | openSidenav() { 57 | this.store.dispatch(new OpenSidenav()); 58 | } 59 | 60 | logout() { 61 | this.closeSidenav(); 62 | 63 | this.store.dispatch(new Logout()); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /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 | ], 26 | }) 27 | export class NotFoundPageComponent {} 28 | -------------------------------------------------------------------------------- /src/app/core/core.module.ts: -------------------------------------------------------------------------------- 1 | import { ModuleWithProviders, NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { RouterModule } from '@angular/router'; 4 | 5 | import { AppComponent } from './containers/app.component'; 6 | import { NotFoundPageComponent } from './containers/not-found-page.component'; 7 | import { LayoutComponent } from './components/layout.component'; 8 | import { NavItemComponent } from './components/nav-item.component'; 9 | import { SidenavComponent } from './components/sidenav.component'; 10 | import { ToolbarComponent } from './components/toolbar.component'; 11 | import { MaterialModule } from '../material'; 12 | 13 | import { GoogleBooksService } from './services/google-books.service'; 14 | 15 | export const COMPONENTS = [ 16 | AppComponent, 17 | NotFoundPageComponent, 18 | LayoutComponent, 19 | NavItemComponent, 20 | SidenavComponent, 21 | ToolbarComponent, 22 | ]; 23 | 24 | @NgModule({ 25 | imports: [CommonModule, RouterModule, MaterialModule], 26 | declarations: COMPONENTS, 27 | exports: COMPONENTS, 28 | }) 29 | export class CoreModule { 30 | static forRoot(): ModuleWithProviders { 31 | return { 32 | ngModule: CoreModule, 33 | providers: [GoogleBooksService], 34 | }; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/app/core/services/google-books.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { cold } from 'jasmine-marbles'; 4 | import { GoogleBooksService } from './google-books.service'; 5 | 6 | describe('Service: GoogleBooks', () => { 7 | let service: GoogleBooksService; 8 | let http: HttpClient; 9 | 10 | beforeEach(() => { 11 | TestBed.configureTestingModule({ 12 | providers: [ 13 | { provide: HttpClient, useValue: { get: jest.fn() } }, 14 | GoogleBooksService, 15 | ], 16 | }); 17 | 18 | service = TestBed.inject(GoogleBooksService); 19 | http = TestBed.inject(HttpClient); 20 | }); 21 | 22 | const data = { 23 | title: 'Book Title', 24 | author: 'John Smith', 25 | volumeId: '12345', 26 | }; 27 | 28 | const books = { 29 | items: [ 30 | { id: '12345', volumeInfo: { title: 'Title' } }, 31 | { id: '67890', volumeInfo: { title: 'Another Title' } }, 32 | ], 33 | }; 34 | 35 | const queryTitle = 'Book Title'; 36 | 37 | it('should call the search api and return the search results', () => { 38 | const response = cold('-a|', { a: books }); 39 | const expected = cold('-b|', { b: books.items }); 40 | http.get = jest.fn(() => response); 41 | 42 | expect(service.searchBooks(queryTitle)).toBeObservable(expected); 43 | expect(http.get).toHaveBeenCalledWith( 44 | `https://www.googleapis.com/books/v1/volumes?q=${queryTitle}` 45 | ); 46 | }); 47 | 48 | it('should retrieve the book from the volumeId', () => { 49 | const response = cold('-a|', { a: data }); 50 | const expected = cold('-b|', { b: data }); 51 | http.get = jest.fn(() => response); 52 | 53 | expect(service.retrieveBook(data.volumeId)).toBeObservable(expected); 54 | expect(http.get).toHaveBeenCalledWith( 55 | `https://www.googleapis.com/books/v1/volumes/${data.volumeId}` 56 | ); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/app/core/services/google-books.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { Observable } from 'rxjs'; 4 | import { map } from 'rxjs/operators'; 5 | 6 | import { Book } from '../../books/models/book'; 7 | 8 | @Injectable() 9 | export class GoogleBooksService { 10 | private API_PATH = 'https://www.googleapis.com/books/v1/volumes'; 11 | 12 | constructor(private http: HttpClient) {} 13 | 14 | searchBooks(queryTitle: string): Observable { 15 | return this.http 16 | .get<{ items: Book[] }>(`${this.API_PATH}?q=${queryTitle}`) 17 | .pipe(map((books) => books.items || [])); 18 | } 19 | 20 | retrieveBook(volumeId: string): Observable { 21 | return this.http.get(`${this.API_PATH}/${volumeId}`); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/app/core/store/actions/layout.actions.ts: -------------------------------------------------------------------------------- 1 | export class OpenSidenav { 2 | static readonly type = '[Layout] Open Sidenav'; 3 | } 4 | 5 | export class CloseSidenav { 6 | static readonly type = '[Layout] Close Sidenav'; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/core/store/index.ts: -------------------------------------------------------------------------------- 1 | import { LayoutState } from './states/layout.state'; 2 | 3 | export const AppStates = [LayoutState]; 4 | 5 | export * from './states/layout.state'; 6 | export * from './actions/layout.actions'; 7 | -------------------------------------------------------------------------------- /src/app/core/store/states/layout.state.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { Action, Selector, State, StateContext } from '@ngxs/store'; 4 | 5 | import { CloseSidenav, OpenSidenav } from '../actions/layout.actions'; 6 | 7 | export interface LayoutStateModel { 8 | showSidenav: boolean; 9 | } 10 | 11 | @State({ 12 | name: 'layout', 13 | defaults: { 14 | showSidenav: false, 15 | }, 16 | }) 17 | @Injectable() 18 | export class LayoutState { 19 | @Selector() 20 | static getShowSidenav(state: LayoutStateModel) { 21 | return state.showSidenav; 22 | } 23 | 24 | @Action(OpenSidenav) 25 | openSidenav({ patchState }: StateContext) { 26 | patchState({ 27 | showSidenav: true, 28 | }); 29 | } 30 | 31 | @Action(CloseSidenav) 32 | closeSidenav({ patchState }: StateContext) { 33 | patchState({ 34 | showSidenav: false, 35 | }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/app/index.ts: -------------------------------------------------------------------------------- 1 | export * from './app.module'; 2 | -------------------------------------------------------------------------------- /src/app/material/index.ts: -------------------------------------------------------------------------------- 1 | export * from './material.module'; 2 | -------------------------------------------------------------------------------- /src/app/material/material.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { MatButtonModule } from '@angular/material/button'; 4 | import { MatCardModule } from '@angular/material/card'; 5 | import { MatIconModule } from '@angular/material/icon'; 6 | import { MatInputModule } from '@angular/material/input'; 7 | import { MatListModule } from '@angular/material/list'; 8 | import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; 9 | import { MatSidenavModule } from '@angular/material/sidenav'; 10 | import { MatToolbarModule } from '@angular/material/toolbar'; 11 | 12 | @NgModule({ 13 | imports: [ 14 | MatInputModule, 15 | MatCardModule, 16 | MatButtonModule, 17 | MatSidenavModule, 18 | MatListModule, 19 | MatIconModule, 20 | MatToolbarModule, 21 | MatProgressSpinnerModule, 22 | ], 23 | exports: [ 24 | MatInputModule, 25 | MatCardModule, 26 | MatButtonModule, 27 | MatSidenavModule, 28 | MatListModule, 29 | MatIconModule, 30 | MatToolbarModule, 31 | MatProgressSpinnerModule, 32 | ], 33 | }) 34 | export class MaterialModule {} 35 | -------------------------------------------------------------------------------- /src/app/shared/pipes/add-commas.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { AddCommasPipe } from './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(undefined)).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 './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.substr(0, 250)}...`); 48 | }); 49 | 50 | it('should return only 20 characters followed by an ellipsis', () => { 51 | expect(pipe.transform(longStr, 20)).toEqual(`${longStr.substr(0, 20)}...`); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/app/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 './add-commas.pipe'; 4 | import { EllipsisPipe } from './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/eranshmil/ngxs-example-app/fd2a64606ca9dd1bf3be14a3f2983624e05fb1c2/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/assets/.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eranshmil/ngxs-example-app/fd2a64606ca9dd1bf3be14a3f2983624e05fb1c2/src/assets/.npmignore -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | }; 4 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // The file contents for the current environment will overwrite these during build. 2 | // The build system defaults to the dev environment which uses `environment.ts`, but if you do 3 | // `ng build --env=prod` then `environment.prod.ts` will be used instead. 4 | // The list of which env maps to which file can be found in `angular-cli.json`. 5 | 6 | export const environment = { 7 | production: false, 8 | }; 9 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eranshmil/ngxs-example-app/fd2a64606ca9dd1bf3be14a3f2983624e05fb1c2/src/favicon.ico -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Book Collection 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | Loading... 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/jest-global-mocks.ts: -------------------------------------------------------------------------------- 1 | Object.defineProperty(window, 'CSS', { value: null }); 2 | Object.defineProperty(document, 'doctype', { 3 | value: '', 4 | }); 5 | Object.defineProperty(window, 'getComputedStyle', { 6 | value: () => { 7 | return { 8 | display: 'none', 9 | appearance: ['-webkit-appearance'], 10 | }; 11 | }, 12 | }); 13 | 14 | /** 15 | * ISSUE: https://github.com/angular/material2/issues/7101 16 | * Workaround for JSDOM missing transform property 17 | */ 18 | Object.defineProperty(document.body.style, 'transform', { 19 | value: () => { 20 | return { 21 | enumerable: true, 22 | configurable: true, 23 | }; 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /src/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma'), 14 | ], 15 | client: { 16 | clearContext: false, // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, '../coverage'), 20 | reports: ['html', 'lcovonly'], 21 | fixWebpackSourcePaths: true, 22 | }, 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['Chrome'], 29 | singleRun: false, 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import './polyfills.ts'; 2 | 3 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 4 | import { enableProdMode } from '@angular/core'; 5 | import { environment } from './environments/environment'; 6 | import { AppModule } from './app/app.module'; 7 | 8 | if (environment.production) { 9 | enableProdMode(); 10 | } 11 | 12 | platformBrowserDynamic().bootstrapModule(AppModule); 13 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */ 22 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 23 | 24 | /** 25 | * Web Animations `@angular/platform-browser/animations` 26 | * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. 27 | * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). 28 | */ 29 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 30 | 31 | /** 32 | * By default, zone.js will patch all possible macroTask and DomEvents 33 | * user can disable parts of macroTask/DomEvents patch by setting following flags 34 | * because those flags need to be set before `zone.js` being loaded, and webpack 35 | * will put import in the top of bundle, so user need to create a separate file 36 | * in this directory (for example: zone-flags.ts), and put the following flags 37 | * into that file, and then add the following code before importing zone.js. 38 | * import './zone-flags'; 39 | * 40 | * The flags allowed in zone-flags.ts are listed here. 41 | * 42 | * The following flags will work for all browsers. 43 | * 44 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 45 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 46 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 47 | * 48 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 49 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 50 | * 51 | * (window as any).__Zone_enable_cross_context_check = true; 52 | * 53 | */ 54 | 55 | /*************************************************************************************************** 56 | * Zone JS is required by default for Angular itself. 57 | */ 58 | import 'zone.js/dist/zone'; // Included with Angular CLI. 59 | 60 | /*************************************************************************************************** 61 | * APPLICATION IMPORTS 62 | */ 63 | -------------------------------------------------------------------------------- /src/setup-jest.ts: -------------------------------------------------------------------------------- 1 | import 'jest-preset-angular'; 2 | import './jest-global-mocks'; 3 | -------------------------------------------------------------------------------- /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-progress-spinner svg { 15 | width: 30px !important; 16 | height: 30px !important; 17 | } 18 | -------------------------------------------------------------------------------- /src/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "types": [] 6 | }, 7 | "files": [ 8 | "main.ts", 9 | "polyfills.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /src/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "downlevelIteration": true, 4 | "sourceMap": true, 5 | "declaration": false, 6 | "moduleResolution": "node", 7 | "emitDecoratorMetadata": true, 8 | "esModuleInterop": true, 9 | "experimentalDecorators": true, 10 | "lib": ["es2017", "dom"], 11 | "outDir": "../out-tsc/spec", 12 | "module": "es2020", 13 | "target": "es2015", 14 | "types": ["jest", "node"], 15 | "baseUrl": ".", 16 | "rootDir": "../" 17 | }, 18 | "include": [ 19 | "src/**/*.spec.ts", 20 | "src/**/*.d.ts" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /src/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tslint.json", 3 | "rules": { 4 | "directive-selector": [ 5 | true, 6 | "attribute", 7 | "bc", 8 | "camelCase" 9 | ], 10 | "component-selector": [ 11 | true, 12 | "element", 13 | "bc", 14 | "kebab-case" 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "downlevelIteration": true, 6 | "outDir": "./dist/out-tsc", 7 | "sourceMap": true, 8 | "declaration": false, 9 | "module": "es2020", 10 | "moduleResolution": "node", 11 | "emitDecoratorMetadata": true, 12 | "experimentalDecorators": true, 13 | "target": "es2015", 14 | "typeRoots": [ 15 | "node_modules/@types" 16 | ], 17 | "lib": [ 18 | "es2017", 19 | "dom" 20 | ] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rulesDirectory": [ 3 | "node_modules/codelyzer" 4 | ], 5 | "rules": { 6 | "arrow-return-shorthand": true, 7 | "callable-types": true, 8 | "class-name": true, 9 | "comment-format": [ 10 | true, 11 | "check-space" 12 | ], 13 | "curly": true, 14 | "deprecation": { 15 | "severity": "warn" 16 | }, 17 | "eofline": true, 18 | "forin": true, 19 | "import-blacklist": [ 20 | true, 21 | "rxjs/Rx" 22 | ], 23 | "import-spacing": true, 24 | "indent": [ 25 | true, 26 | "spaces" 27 | ], 28 | "interface-over-type-literal": true, 29 | "label-position": true, 30 | "max-line-length": [ 31 | true, 32 | 140 33 | ], 34 | "member-access": false, 35 | "member-ordering": [ 36 | true, 37 | { 38 | "order": [ 39 | "static-field", 40 | "instance-field", 41 | "static-method", 42 | "instance-method" 43 | ] 44 | } 45 | ], 46 | "no-arg": true, 47 | "no-bitwise": true, 48 | "no-console": [ 49 | true, 50 | "debug", 51 | "info", 52 | "time", 53 | "timeEnd", 54 | "trace" 55 | ], 56 | "no-construct": true, 57 | "no-debugger": true, 58 | "no-duplicate-super": true, 59 | "no-empty": false, 60 | "no-empty-interface": true, 61 | "no-eval": true, 62 | "no-inferrable-types": [ 63 | true, 64 | "ignore-params" 65 | ], 66 | "no-misused-new": true, 67 | "no-non-null-assertion": true, 68 | "no-shadowed-variable": true, 69 | "no-string-literal": false, 70 | "no-string-throw": true, 71 | "no-switch-case-fall-through": true, 72 | "no-trailing-whitespace": true, 73 | "no-unnecessary-initializer": true, 74 | "no-unused-expression": true, 75 | "no-var-keyword": true, 76 | "object-literal-sort-keys": false, 77 | "one-line": [ 78 | true, 79 | "check-open-brace", 80 | "check-catch", 81 | "check-else", 82 | "check-whitespace" 83 | ], 84 | "prefer-const": true, 85 | "radix": true, 86 | "semicolon": [ 87 | true, 88 | "always" 89 | ], 90 | "triple-equals": [ 91 | true, 92 | "allow-null-check" 93 | ], 94 | "typedef-whitespace": [ 95 | true, 96 | { 97 | "call-signature": "nospace", 98 | "index-signature": "nospace", 99 | "parameter": "nospace", 100 | "property-declaration": "nospace", 101 | "variable-declaration": "nospace" 102 | } 103 | ], 104 | "unified-signatures": true, 105 | "variable-name": false, 106 | "whitespace": [ 107 | true, 108 | "check-branch", 109 | "check-decl", 110 | "check-operator", 111 | "check-separator", 112 | "check-type" 113 | ], 114 | "no-output-on-prefix": true, 115 | "no-inputs-metadata-property": true, 116 | "no-outputs-metadata-property": true, 117 | "no-host-metadata-property": true, 118 | "no-input-rename": true, 119 | "no-output-rename": true, 120 | "use-lifecycle-interface": true, 121 | "use-pipe-transform-interface": true, 122 | "component-class-suffix": true, 123 | "directive-class-suffix": true 124 | } 125 | } 126 | --------------------------------------------------------------------------------