├── .editorconfig ├── .github └── FUNDING.yml ├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── angular.json ├── e2e ├── protractor.conf.js ├── src │ ├── app.e2e-spec.ts │ └── app.po.ts └── tsconfig.e2e.json ├── package.json ├── src ├── app │ ├── app-routing.module.ts │ ├── app.component.css │ ├── app.component.html │ ├── app.component.spec.ts │ ├── app.component.ts │ ├── app.container.html │ ├── app.container.ts │ ├── app.module.ts │ ├── dashboard │ │ ├── dashboard.component.css │ │ ├── dashboard.component.html │ │ ├── dashboard.component.spec.ts │ │ ├── dashboard.component.ts │ │ ├── dashboard.container.html │ │ ├── dashboard.container.spec.ts │ │ └── dashboard.container.ts │ ├── hero-detail │ │ ├── hero-detail.component.css │ │ ├── hero-detail.component.html │ │ ├── hero-detail.component.ts │ │ ├── hero-detail.container.html │ │ ├── hero-detail.container.spec.ts │ │ └── hero-detail.container.ts │ ├── hero-search │ │ ├── hero-search.component.css │ │ ├── hero-search.component.html │ │ ├── hero-search.component.spec.ts │ │ ├── hero-search.component.ts │ │ ├── hero-search.container.html │ │ ├── hero-search.container.spec.ts │ │ ├── hero-search.container.ts │ │ ├── hero-search.presenter.spec.ts │ │ └── hero-search.presenter.ts │ ├── hero.service.ts │ ├── hero.ts │ ├── heroes │ │ ├── heroes.component.css │ │ ├── heroes.component.html │ │ ├── heroes.component.spec.ts │ │ ├── heroes.component.ts │ │ ├── heroes.container.html │ │ ├── heroes.container.spec.ts │ │ ├── heroes.container.ts │ │ ├── heroes.presenter.spec.ts │ │ └── heroes.presenter.ts │ ├── in-memory-data.service.ts │ ├── message.service.spec.ts │ ├── message.service.ts │ ├── messages │ │ ├── messages.component.css │ │ ├── messages.component.html │ │ ├── messages.component.spec.ts │ │ ├── messages.component.ts │ │ ├── messages.container.html │ │ ├── messages.container.spec.ts │ │ ├── messages.container.ts │ │ ├── messages.presenter.spec.ts │ │ └── messages.presenter.ts │ └── mock-heroes.ts ├── assets │ └── .gitkeep ├── browserslist ├── environments │ ├── environment-signature.ts │ ├── environment.hmr.ts │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── hmr.ts ├── index.html ├── karma.conf.js ├── main.ts ├── polyfills.ts ├── styles.css ├── test.ts ├── test │ └── female-marvel-heroes.ts ├── tsconfig.app.json ├── tsconfig.spec.json ├── tslint.json └── wallaby-test.ts ├── tsconfig.json ├── tslint.json ├── wallaby.js └── yarn.lock /.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 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: LayZeeDK 2 | -------------------------------------------------------------------------------- /.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 | 27 | # misc 28 | /.sass-cache 29 | /connect.lock 30 | /coverage 31 | /libpeerconnection.log 32 | npm-debug.log 33 | yarn-error.log 34 | testem.log 35 | /typings 36 | package-lock.json 37 | 38 | # System Files 39 | .DS_Store 40 | Thumbs.db 41 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "ng serve", 6 | "type": "chrome", 7 | "request": "launch", 8 | "url": "http://localhost:4200/#", 9 | "webRoot": "${workspaceRoot}" 10 | }, 11 | { 12 | "name": "ng test", 13 | "type": "chrome", 14 | "request": "launch", 15 | "url": "http://localhost:9876/debug.html", 16 | "webRoot": "${workspaceRoot}" 17 | }, 18 | { 19 | "name": "ng e2e", 20 | "type": "node", 21 | "request": "launch", 22 | "program": "${workspaceRoot}/node_modules/protractor/bin/protractor", 23 | "protocol": "inspector", 24 | "args": [ 25 | "${workspaceRoot}/e2e/protractor.conf.js" 26 | ] 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "./node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Lars Gyrup Brink Nielsen 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 | # Tour of Heroes Angular app using the Model-View-Presenter pattern 2 | 3 | This project was generated with 4 | [Angular CLI](https://github.com/angular/angular-cli) version 7.0. 5 | 6 | # Model-View-Presenter 7 | Container components, presentational components and presenters are combined 8 | using the MVP (Model-View-Presenter) pattern. 9 | 10 | # Article 11 | [Model-View-Presenter with Angular](https://indepth.dev/model-view-presenter-with-angular/) 12 | 13 | # Development tools 14 | Angular CLI development server with Hot Module Replacement. Unit tests using 15 | Karma in headless Chrome browser with Wallaby.js integration. 16 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "cli": { 5 | "packageManager": "yarn" 6 | }, 7 | "defaultProject": "toh", 8 | "newProjectRoot": "projects", 9 | "projects": { 10 | "toh": { 11 | "root": "", 12 | "sourceRoot": "src", 13 | "projectType": "application", 14 | "prefix": "app", 15 | "schematics": { 16 | "@schematics/angular:component": { 17 | "styleext": "scss" 18 | } 19 | }, 20 | "architect": { 21 | "build": { 22 | "builder": "@angular-devkit/build-angular:browser", 23 | "options": { 24 | "outputPath": "dist/toh", 25 | "index": "src/index.html", 26 | "main": "src/main.ts", 27 | "polyfills": "src/polyfills.ts", 28 | "tsConfig": "src/tsconfig.app.json", 29 | "assets": [ 30 | "src/favicon.ico", 31 | "src/assets" 32 | ], 33 | "styles": [ 34 | "src/styles.css" 35 | ], 36 | "scripts": [] 37 | }, 38 | "configurations": { 39 | "hmr": { 40 | "fileReplacements": [ 41 | { 42 | "replace": "src/environments/environment.ts", 43 | "with": "src/environments/environment.hmr.ts" 44 | } 45 | ], 46 | "aot": true 47 | }, 48 | "production": { 49 | "fileReplacements": [ 50 | { 51 | "replace": "src/environments/environment.ts", 52 | "with": "src/environments/environment.prod.ts" 53 | } 54 | ], 55 | "optimization": true, 56 | "outputHashing": "all", 57 | "sourceMap": false, 58 | "extractCss": true, 59 | "namedChunks": false, 60 | "aot": true, 61 | "extractLicenses": true, 62 | "vendorChunk": false, 63 | "buildOptimizer": true 64 | } 65 | } 66 | }, 67 | "serve": { 68 | "builder": "@angular-devkit/build-angular:dev-server", 69 | "options": { 70 | "browserTarget": "toh:build" 71 | }, 72 | "configurations": { 73 | "hmr": { 74 | "browserTarget": "toh:build:hmr", 75 | "hmr": true, 76 | "hmrWarning": false 77 | }, 78 | "production": { 79 | "browserTarget": "toh:build:production" 80 | } 81 | } 82 | }, 83 | "extract-i18n": { 84 | "builder": "@angular-devkit/build-angular:extract-i18n", 85 | "options": { 86 | "browserTarget": "toh:build" 87 | } 88 | }, 89 | "test": { 90 | "builder": "@angular-devkit/build-angular:karma", 91 | "options": { 92 | "main": "src/test.ts", 93 | "polyfills": "src/polyfills.ts", 94 | "tsConfig": "src/tsconfig.spec.json", 95 | "karmaConfig": "src/karma.conf.js", 96 | "styles": [ 97 | "src/styles.css" 98 | ], 99 | "scripts": [], 100 | "assets": [ 101 | "src/favicon.ico", 102 | "src/assets" 103 | ], 104 | "watch": true 105 | } 106 | }, 107 | "lint": { 108 | "builder": "@angular-devkit/build-angular:tslint", 109 | "options": { 110 | "tsConfig": [ 111 | "src/tsconfig.app.json", 112 | "src/tsconfig.spec.json" 113 | ], 114 | "exclude": [ 115 | "**/node_modules/**" 116 | ] 117 | } 118 | } 119 | } 120 | }, 121 | "toh-e2e": { 122 | "root": "e2e/", 123 | "projectType": "application", 124 | "architect": { 125 | "e2e": { 126 | "builder": "@angular-devkit/build-angular:protractor", 127 | "options": { 128 | "protractorConfig": "e2e/protractor.conf.js", 129 | "devServerTarget": "toh:serve" 130 | }, 131 | "configurations": { 132 | "production": { 133 | "devServerTarget": "toh:serve:production" 134 | } 135 | } 136 | }, 137 | "lint": { 138 | "builder": "@angular-devkit/build-angular:tslint", 139 | "options": { 140 | "tsConfig": "e2e/tsconfig.e2e.json", 141 | "exclude": [ 142 | "**/node_modules/**" 143 | ] 144 | } 145 | } 146 | } 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /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 | const puppeteer = require('puppeteer'); 6 | 7 | exports.config = { 8 | allScriptsTimeout: 11000, 9 | specs: [ 10 | './src/**/*.e2e-spec.ts' 11 | ], 12 | capabilities: { 13 | browserName: 'chrome', 14 | chromeOptions: { 15 | args: ['--headless', '--window-size=1024,768'], 16 | binary: puppeteer.executablePath(), 17 | }, 18 | }, 19 | directConnect: true, 20 | baseUrl: 'http://localhost:4200/', 21 | framework: 'jasmine', 22 | jasmineNodeOpts: { 23 | showColors: true, 24 | defaultTimeoutInterval: 30000, 25 | print: function () { } 26 | }, 27 | onPrepare() { 28 | require('ts-node').register({ 29 | project: require('path').join(__dirname, './tsconfig.e2e.json') 30 | }); 31 | jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /e2e/src/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; // necessary for es6 output in node 2 | import { 3 | browser, 4 | by, 5 | element, 6 | ElementArrayFinder, 7 | ElementFinder, 8 | } from 'protractor'; 9 | import { promise } from 'selenium-webdriver'; 10 | 11 | 12 | const expectedH1 = 'Tour of Heroes'; 13 | const expectedTitle = `${expectedH1}`; 14 | const targetHero = { id: 15, name: 'Magneta' }; 15 | const targetHeroDashboardIndex = 3; 16 | const nameSuffix = 'X'; 17 | const newHeroName = targetHero.name + nameSuffix; 18 | 19 | class Hero { 20 | id: number; 21 | name: string; 22 | 23 | // Factory methods 24 | 25 | // Hero from string formatted as ' '. 26 | static fromString(s: string): Hero { 27 | return { 28 | id: +s.substr(0, s.indexOf(' ')), 29 | name: s.substr(s.indexOf(' ') + 1), 30 | }; 31 | } 32 | 33 | // Hero from hero list
  • element. 34 | static async fromLi(li: ElementFinder): Promise { 35 | const stringsFromA = await li.all(by.css('a')).getText(); 36 | const strings = stringsFromA[0].split(' '); 37 | return { id: +strings[0], name: strings[1] }; 38 | } 39 | 40 | // Hero id and name from the given detail element. 41 | static async fromDetail(detail: ElementFinder): Promise { 42 | // Get hero id from the first
    43 | const _id = await detail.all(by.css('div')).first().getText(); 44 | // Get name from the h2 45 | const _name = await detail.element(by.css('h2')).getText(); 46 | return { 47 | id: +_id.substr(_id.indexOf(' ') + 1), 48 | name: _name.substr(0, _name.lastIndexOf(' ')) 49 | }; 50 | } 51 | } 52 | 53 | describe('Tutorial part 6', () => { 54 | 55 | beforeAll(() => browser.get('')); 56 | 57 | function getPageElts() { 58 | const navElts = element.all(by.css('app-root nav a')); 59 | 60 | return { 61 | navElts: navElts, 62 | 63 | appDashboardHref: navElts.get(0), 64 | appDashboard: element(by.css('app-root app-dashboard')), 65 | topHeroes: element.all(by.css('app-root app-dashboard-ui > div h4')), 66 | 67 | appHeroesHref: navElts.get(1), 68 | appHeroes: element(by.css('app-root app-heroes')), 69 | allHeroes: element.all(by.css('app-root app-heroes li')), 70 | selectedHeroSubview: element(by.css('app-root app-heroes-ui > div:last-child')), 71 | 72 | heroDetail: element(by.css('app-root app-hero-detail-ui > div')), 73 | 74 | searchBox: element(by.css('#search-box')), 75 | searchResults: element.all(by.css('.search-result li')) 76 | }; 77 | } 78 | 79 | describe('Initial page', () => { 80 | 81 | it(`has title '${expectedTitle}'`, () => { 82 | expect(browser.getTitle()).toEqual(expectedTitle); 83 | }); 84 | 85 | it(`has h1 '${expectedH1}'`, () => { 86 | expectHeading(1, expectedH1); 87 | }); 88 | 89 | const expectedViewNames = ['Dashboard', 'Heroes']; 90 | it(`has views ${expectedViewNames}`, () => { 91 | const viewNames = getPageElts().navElts.map((el: ElementFinder) => el.getText()); 92 | expect(viewNames).toEqual(expectedViewNames); 93 | }); 94 | 95 | it('has dashboard as the active view', () => { 96 | const page = getPageElts(); 97 | expect(page.appDashboard.isPresent()).toBeTruthy(); 98 | }); 99 | 100 | }); 101 | 102 | describe('Dashboard tests', () => { 103 | 104 | beforeAll(() => browser.get('')); 105 | 106 | it('has top heroes', () => { 107 | const page = getPageElts(); 108 | expect(page.topHeroes.count()).toEqual(4); 109 | }); 110 | 111 | it(`selects and routes to ${targetHero.name} details`, dashboardSelectTargetHero); 112 | 113 | it(`updates hero name (${newHeroName}) in details view`, updateHeroNameInDetailView); 114 | 115 | it(`cancels and shows ${targetHero.name} in Dashboard`, () => { 116 | element(by.buttonText('go back')).click(); 117 | browser.waitForAngular(); // seems necessary to gets tests to pass for toh-pt6 118 | 119 | const targetHeroElt = getPageElts().topHeroes.get(targetHeroDashboardIndex); 120 | expect(targetHeroElt.getText()).toEqual(targetHero.name); 121 | }); 122 | 123 | it(`selects and routes to ${targetHero.name} details`, dashboardSelectTargetHero); 124 | 125 | it(`updates hero name (${newHeroName}) in details view`, updateHeroNameInDetailView); 126 | 127 | it(`saves and shows ${newHeroName} in Dashboard`, () => { 128 | element(by.buttonText('save')).click(); 129 | browser.waitForAngular(); // seems necessary to gets tests to pass for toh-pt6 130 | 131 | const targetHeroElt = getPageElts().topHeroes.get(targetHeroDashboardIndex); 132 | expect(targetHeroElt.getText()).toEqual(newHeroName); 133 | }); 134 | 135 | }); 136 | 137 | describe('Heroes tests', () => { 138 | 139 | beforeAll(() => browser.get('')); 140 | 141 | it('can switch to Heroes view', () => { 142 | getPageElts().appHeroesHref.click(); 143 | const page = getPageElts(); 144 | expect(page.appHeroes.isPresent()).toBeTruthy(); 145 | expect(page.allHeroes.count()).toEqual(10, 'number of heroes'); 146 | }); 147 | 148 | it('can route to hero details', async () => { 149 | getHeroLiEltById(targetHero.id).click(); 150 | 151 | const page = getPageElts(); 152 | expect(page.heroDetail.isPresent()).toBeTruthy('shows hero detail'); 153 | const hero = await Hero.fromDetail(page.heroDetail); 154 | expect(hero.id).toEqual(targetHero.id); 155 | expect(hero.name).toEqual(targetHero.name.toUpperCase()); 156 | }); 157 | 158 | it(`updates hero name (${newHeroName}) in details view`, updateHeroNameInDetailView); 159 | 160 | it(`shows ${newHeroName} in Heroes list`, () => { 161 | element(by.buttonText('save')).click(); 162 | browser.waitForAngular(); 163 | const expectedText = `${targetHero.id} ${newHeroName}`; 164 | expect(getHeroAEltById(targetHero.id).getText()).toEqual(expectedText); 165 | }); 166 | 167 | it(`deletes ${newHeroName} from Heroes list`, async () => { 168 | const heroesBefore = await toHeroArray(getPageElts().allHeroes); 169 | const li = getHeroLiEltById(targetHero.id); 170 | li.element(by.buttonText('x')).click(); 171 | 172 | const page = getPageElts(); 173 | expect(page.appHeroes.isPresent()).toBeTruthy(); 174 | expect(page.allHeroes.count()).toEqual(9, 'number of heroes'); 175 | const heroesAfter = await toHeroArray(page.allHeroes); 176 | // console.log(await Hero.fromLi(page.allHeroes[0])); 177 | const expectedHeroes = heroesBefore.filter(h => h.name !== newHeroName); 178 | expect(heroesAfter).toEqual(expectedHeroes); 179 | // expect(page.selectedHeroSubview.isPresent()).toBeFalsy(); 180 | }); 181 | 182 | it(`adds back ${targetHero.name}`, async () => { 183 | const newName = 'Alice'; 184 | const heroesBefore = await toHeroArray(getPageElts().allHeroes); 185 | const numHeroes = heroesBefore.length; 186 | 187 | element(by.css('input')).sendKeys(newName); 188 | element(by.buttonText('add')).click(); 189 | 190 | const page = getPageElts(); 191 | const heroesAfter = await toHeroArray(page.allHeroes); 192 | expect(heroesAfter.length).toEqual(numHeroes + 1, 'number of heroes'); 193 | 194 | expect(heroesAfter.slice(0, numHeroes)).toEqual(heroesBefore, 'Old heroes are still there'); 195 | 196 | const maxId = heroesBefore[heroesBefore.length - 1].id; 197 | expect(heroesAfter[numHeroes]).toEqual({id: maxId + 1, name: newName}); 198 | }); 199 | 200 | it('displays correctly styled buttons', async () => { 201 | element.all(by.buttonText('x')).then(buttons => { 202 | for (const button of buttons) { 203 | // Inherited styles from styles.css 204 | expect(button.getCssValue('font-family')).toBe('Arial'); 205 | expect(button.getCssValue('border')).toContain('none'); 206 | expect(button.getCssValue('padding')).toBe('5px 10px'); 207 | expect(button.getCssValue('border-radius')).toBe('4px'); 208 | // Styles defined in heroes.component.css 209 | expect(button.getCssValue('left')).toBe('194px'); 210 | expect(button.getCssValue('top')).toBe('-32px'); 211 | } 212 | }); 213 | 214 | const addButton = element(by.buttonText('add')); 215 | // Inherited styles from styles.css 216 | expect(addButton.getCssValue('font-family')).toBe('Arial'); 217 | expect(addButton.getCssValue('border')).toContain('none'); 218 | expect(addButton.getCssValue('padding')).toBe('5px 10px'); 219 | expect(addButton.getCssValue('border-radius')).toBe('4px'); 220 | }); 221 | 222 | }); 223 | 224 | describe('Progressive hero search', () => { 225 | 226 | beforeAll(() => browser.get('')); 227 | 228 | it(`searches for 'Ma'`, async () => { 229 | getPageElts().searchBox.sendKeys('Ma'); 230 | browser.sleep(1000); 231 | 232 | expect(getPageElts().searchResults.count()).toBe(4); 233 | }); 234 | 235 | it(`continues search with 'g'`, async () => { 236 | getPageElts().searchBox.sendKeys('g'); 237 | browser.sleep(1000); 238 | expect(getPageElts().searchResults.count()).toBe(2); 239 | }); 240 | 241 | it(`continues search with 'e' and gets ${targetHero.name}`, async () => { 242 | getPageElts().searchBox.sendKeys('n'); 243 | browser.sleep(1000); 244 | const page = getPageElts(); 245 | expect(page.searchResults.count()).toBe(1); 246 | const hero = page.searchResults.get(0); 247 | expect(hero.getText()).toEqual(targetHero.name); 248 | }); 249 | 250 | it(`navigates to ${targetHero.name} details view`, async () => { 251 | const hero = getPageElts().searchResults.get(0); 252 | expect(hero.getText()).toEqual(targetHero.name); 253 | hero.click(); 254 | 255 | const page = getPageElts(); 256 | expect(page.heroDetail.isPresent()).toBeTruthy('shows hero detail'); 257 | const hero2 = await Hero.fromDetail(page.heroDetail); 258 | expect(hero2.id).toEqual(targetHero.id); 259 | expect(hero2.name).toEqual(targetHero.name.toUpperCase()); 260 | }); 261 | }); 262 | 263 | async function dashboardSelectTargetHero() { 264 | const targetHeroElt = getPageElts().topHeroes.get(targetHeroDashboardIndex); 265 | expect(targetHeroElt.getText()).toEqual(targetHero.name); 266 | targetHeroElt.click(); 267 | browser.waitForAngular(); // seems necessary to gets tests to pass for toh-pt6 268 | 269 | const page = getPageElts(); 270 | expect(page.heroDetail.isPresent()).toBeTruthy('shows hero detail'); 271 | const hero = await Hero.fromDetail(page.heroDetail); 272 | expect(hero.id).toEqual(targetHero.id); 273 | expect(hero.name).toEqual(targetHero.name.toUpperCase()); 274 | } 275 | 276 | async function updateHeroNameInDetailView() { 277 | // Assumes that the current view is the hero details view. 278 | addToHeroName(nameSuffix); 279 | 280 | const page = getPageElts(); 281 | const hero = await Hero.fromDetail(page.heroDetail); 282 | expect(hero.id).toEqual(targetHero.id); 283 | expect(hero.name).toEqual(newHeroName.toUpperCase()); 284 | } 285 | 286 | }); 287 | 288 | function addToHeroName(text: string): promise.Promise { 289 | const input = element(by.css('input')); 290 | return input.sendKeys(text); 291 | } 292 | 293 | function expectHeading(hLevel: number, expectedText: string): void { 294 | const hTag = `h${hLevel}`; 295 | const hText = element(by.css(hTag)).getText(); 296 | expect(hText).toEqual(expectedText, hTag); 297 | } 298 | 299 | function getHeroAEltById(id: number): ElementFinder { 300 | const spanForId = element(by.cssContainingText('li span.badge', id.toString())); 301 | return spanForId.element(by.xpath('..')); 302 | } 303 | 304 | function getHeroLiEltById(id: number): ElementFinder { 305 | const spanForId = element(by.cssContainingText('li span.badge', id.toString())); 306 | return spanForId.element(by.xpath('../..')); 307 | } 308 | 309 | async function toHeroArray(allHeroes: ElementArrayFinder): Promise { 310 | const promisedHeroes = await allHeroes.map(Hero.fromLi); 311 | // The cast is necessary to get around issuing with the signature of Promise.all() 312 | return > Promise.all(promisedHeroes); 313 | } 314 | -------------------------------------------------------------------------------- /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 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ngx-tour-of-heroes-mvp", 3 | "description": "Tour of Heroes Angular app using the Model-View-Presenter pattern.", 4 | "version": "7.1.0", 5 | "private": true, 6 | "license": "MIT", 7 | "author": { 8 | "name": "Lars Gyrup Brink Nielsen", 9 | "email": "larsbrinknielsen@gmail.com", 10 | "url": "https://github.com/LayZeeDK" 11 | }, 12 | "homepage": "https://github.com/LayZeeDK/ngx-tour-of-heroes-mvp", 13 | "bugs": "https://github.com/LayZeeDK/ngx-tour-of-heroes-mvp/issues", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/LayZeeDK/ngx-tour-of-heroes-mvp.git" 17 | }, 18 | "scripts": { 19 | "ng": "ng", 20 | "start": "ng serve --configuration=hmr", 21 | "build": "ng build", 22 | "test": "ng test", 23 | "lint": "ng lint", 24 | "e2e": "ng e2e", 25 | "watch": "npm-watch" 26 | }, 27 | "watch": { 28 | "lint": { 29 | "patterns": [ 30 | "e2e", 31 | "src" 32 | ], 33 | "extensions": [ 34 | "ts" 35 | ] 36 | } 37 | }, 38 | "dependencies": { 39 | "@angular/animations": "^7.1.4", 40 | "@angular/common": "^7.1.4", 41 | "@angular/compiler": "^7.1.4", 42 | "@angular/core": "^7.1.4", 43 | "@angular/forms": "^7.1.4", 44 | "@angular/http": "^7.1.4", 45 | "@angular/platform-browser": "^7.1.4", 46 | "@angular/platform-browser-dynamic": "^7.1.4", 47 | "@angular/router": "^7.1.4", 48 | "angular-in-memory-web-api": "~0.8.0", 49 | "core-js": "^2.6.1", 50 | "puppeteer": "^1.13.0", 51 | "rxjs": "^6.3.3", 52 | "rxjs-multi-scan": "^1.0.2", 53 | "tslib": "^1.9.3", 54 | "zone.js": "^0.8.26" 55 | }, 56 | "devDependencies": { 57 | "@angular-devkit/build-angular": "~0.11.4", 58 | "@angular/cli": "~7.1.4", 59 | "@angular/compiler-cli": "~7.1.4", 60 | "@angular/language-service": "~7.1.4", 61 | "@angularclass/hmr": "^2.1.3", 62 | "@types/jasmine": "^3.3.5", 63 | "@types/jasminewd2": "^2.0.6", 64 | "@types/node": "~10.12.18", 65 | "codelyzer": "^4.5.0", 66 | "jasmine-core": "^3.3.0", 67 | "jasmine-spec-reporter": "~4.2.1", 68 | "karma": "^3.1.4", 69 | "karma-chrome-launcher": "^2.2.0", 70 | "karma-coverage-istanbul-reporter": "^2.0.4", 71 | "karma-jasmine": "^2.0.1", 72 | "karma-jasmine-html-reporter": "^1.4.0", 73 | "npm-watch": "^0.5.0", 74 | "protractor": "^5.4.2", 75 | "rxjs-subscription-count": "^1.0.0", 76 | "ts-node": "~7.0.1", 77 | "tslint": "~5.11.0", 78 | "typescript": "~3.1.6", 79 | "wallaby-webpack": "^3.9.13" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | 4 | import { DashboardContainerComponent } from './dashboard/dashboard.container'; 5 | import { 6 | HeroDetailContainerComponent, 7 | } from './hero-detail/hero-detail.container'; 8 | import { HeroesContainerComponent } from './heroes/heroes.container'; 9 | 10 | const routes: Routes = [ 11 | { path: '', redirectTo: '/dashboard', pathMatch: 'full' }, 12 | { path: 'dashboard', component: DashboardContainerComponent }, 13 | { path: 'detail/:id', component: HeroDetailContainerComponent }, 14 | { path: 'heroes', component: HeroesContainerComponent }, 15 | ]; 16 | 17 | @NgModule({ 18 | imports: [ RouterModule.forRoot(routes) ], 19 | exports: [ RouterModule ], 20 | }) 21 | export class AppRoutingModule {} 22 | -------------------------------------------------------------------------------- /src/app/app.component.css: -------------------------------------------------------------------------------- 1 | /* AppComponent's private CSS styles */ 2 | h1 { 3 | font-size: 1.2em; 4 | color: #999; 5 | margin-bottom: 0; 6 | } 7 | h2 { 8 | font-size: 2em; 9 | margin-top: 0; 10 | padding-top: 0; 11 | } 12 | nav a { 13 | padding: 5px 10px; 14 | text-decoration: none; 15 | margin-top: 10px; 16 | display: inline-block; 17 | background-color: #eee; 18 | border-radius: 4px; 19 | } 20 | nav a:visited, a:link { 21 | color: #607D8B; 22 | } 23 | nav a:hover { 24 | color: #039be5; 25 | background-color: #CFD8DC; 26 | } 27 | nav a.active { 28 | color: #039be5; 29 | } 30 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 |

    {{title}}

    2 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { APP_BASE_HREF } from '@angular/common'; 2 | import { Component, CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core'; 3 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 4 | import { By } from '@angular/platform-browser'; 5 | import { RouterModule } from '@angular/router'; 6 | 7 | import { AppComponent } from './app.component'; 8 | 9 | @Component({ 10 | template: ` 11 | 13 | `, 14 | }) 15 | class TestHostComponent { 16 | title = 'Tour of Heroes'; 17 | } 18 | 19 | describe(AppComponent.name, () => { 20 | let fixture: ComponentFixture; 21 | let componentDebug: DebugElement; 22 | let component: AppComponent; 23 | 24 | beforeEach(async(() => { 25 | TestBed.configureTestingModule({ 26 | declarations: [AppComponent, TestHostComponent], 27 | imports: [RouterModule.forRoot([])], 28 | providers: [{ provide: APP_BASE_HREF, useValue: '/' }], 29 | schemas: [CUSTOM_ELEMENTS_SCHEMA], 30 | }) 31 | .compileComponents(); 32 | })); 33 | 34 | beforeEach(() => { 35 | fixture = TestBed.createComponent(TestHostComponent); 36 | componentDebug = fixture.debugElement.query(By.directive(AppComponent)); 37 | component = componentDebug.componentInstance; 38 | fixture.detectChanges(); 39 | }); 40 | it('should create the app', async(() => { 41 | expect(component).toBeTruthy(); 42 | })); 43 | it(`should have a title`, async(() => { 44 | expect(component.title).toEqual('Tour of Heroes'); 45 | })); 46 | it('should render title in a h1 tag', async(() => { 47 | const compiled = componentDebug.nativeElement; 48 | 49 | expect(compiled.querySelector('h1').textContent).toContain('Tour of Heroes'); 50 | })); 51 | }); 52 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; 2 | 3 | @Component({ 4 | changeDetection: ChangeDetectionStrategy.OnPush, 5 | selector: 'app-root-ui', 6 | templateUrl: './app.component.html', 7 | styleUrls: ['./app.component.css'], 8 | }) 9 | export class AppComponent { 10 | @Input() title: string; 11 | } 12 | -------------------------------------------------------------------------------- /src/app/app.container.html: -------------------------------------------------------------------------------- 1 | 3 | -------------------------------------------------------------------------------- /src/app/app.container.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | 3 | @Component({ 4 | changeDetection: ChangeDetectionStrategy.OnPush, 5 | selector: 'app-root', 6 | templateUrl: './app.container.html', 7 | }) 8 | export class AppContainerComponent {} 9 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { HttpClientModule } from '@angular/common/http'; 2 | import { NgModule } from '@angular/core'; 3 | import { FormsModule, ReactiveFormsModule } from '@angular/forms'; 4 | import { BrowserModule } from '@angular/platform-browser'; 5 | import { HttpClientInMemoryWebApiModule } from 'angular-in-memory-web-api'; 6 | 7 | import { AppRoutingModule } from './app-routing.module'; 8 | import { AppComponent } from './app.component'; 9 | import { AppContainerComponent } from './app.container'; 10 | import { DashboardComponent } from './dashboard/dashboard.component'; 11 | import { DashboardContainerComponent } from './dashboard/dashboard.container'; 12 | import { HeroDetailComponent } from './hero-detail/hero-detail.component'; 13 | import { 14 | HeroDetailContainerComponent, 15 | } from './hero-detail/hero-detail.container'; 16 | import { HeroSearchComponent } from './hero-search/hero-search.component'; 17 | import { 18 | HeroSearchContainerComponent, 19 | } from './hero-search/hero-search.container'; 20 | import { HeroesComponent } from './heroes/heroes.component'; 21 | import { HeroesContainerComponent } from './heroes/heroes.container'; 22 | import { InMemoryDataService } from './in-memory-data.service'; 23 | import { MessagesComponent } from './messages/messages.component'; 24 | import { MessagesContainerComponent } from './messages/messages.container'; 25 | 26 | @NgModule({ 27 | imports: [ 28 | BrowserModule, 29 | FormsModule, 30 | ReactiveFormsModule, 31 | AppRoutingModule, 32 | HttpClientModule, 33 | 34 | // The HttpClientInMemoryWebApiModule module intercepts HTTP requests 35 | // and returns simulated server responses. 36 | // Remove it when a real server is ready to receive requests. 37 | HttpClientInMemoryWebApiModule.forRoot( 38 | InMemoryDataService, { dataEncapsulation: false } 39 | ) 40 | ], 41 | declarations: [ 42 | AppComponent, 43 | AppContainerComponent, 44 | DashboardComponent, 45 | DashboardContainerComponent, 46 | HeroesComponent, 47 | HeroesContainerComponent, 48 | HeroDetailComponent, 49 | HeroDetailContainerComponent, 50 | MessagesComponent, 51 | MessagesContainerComponent, 52 | HeroSearchComponent, 53 | HeroSearchContainerComponent, 54 | ], 55 | bootstrap: [ AppContainerComponent ], 56 | }) 57 | export class AppModule { } 58 | -------------------------------------------------------------------------------- /src/app/dashboard/dashboard.component.css: -------------------------------------------------------------------------------- 1 | /* DashboardComponent's private CSS styles */ 2 | [class*='col-'] { 3 | float: left; 4 | padding-right: 20px; 5 | padding-bottom: 20px; 6 | } 7 | [class*='col-']:last-of-type { 8 | padding-right: 0; 9 | } 10 | a { 11 | text-decoration: none; 12 | } 13 | *, *:after, *:before { 14 | -webkit-box-sizing: border-box; 15 | -moz-box-sizing: border-box; 16 | box-sizing: border-box; 17 | } 18 | h3 { 19 | text-align: center; margin-bottom: 0; 20 | } 21 | h4 { 22 | position: relative; 23 | } 24 | .grid { 25 | margin: 0; 26 | } 27 | .col-1-4 { 28 | width: 25%; 29 | } 30 | .module { 31 | padding: 20px; 32 | text-align: center; 33 | color: #eee; 34 | max-height: 120px; 35 | min-width: 120px; 36 | background-color: #607D8B; 37 | border-radius: 2px; 38 | } 39 | .module:hover { 40 | background-color: #EEE; 41 | cursor: pointer; 42 | color: #607d8b; 43 | } 44 | .grid-pad { 45 | padding: 10px 0; 46 | } 47 | .grid-pad > [class*='col-']:last-of-type { 48 | padding-right: 20px; 49 | } 50 | @media (max-width: 600px) { 51 | .module { 52 | font-size: 10px; 53 | max-height: 75px; } 54 | } 55 | @media (max-width: 1024px) { 56 | .grid { 57 | margin: 0; 58 | } 59 | .module { 60 | min-width: 60px; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/app/dashboard/dashboard.component.html: -------------------------------------------------------------------------------- 1 |

    {{title}}

    2 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/app/dashboard/dashboard.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; 3 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 4 | import { RouterModule } from '@angular/router'; 5 | 6 | import { DashboardComponent } from './dashboard.component'; 7 | 8 | describe('DashboardComponent', () => { 9 | let component: DashboardComponent; 10 | let fixture: ComponentFixture; 11 | 12 | beforeEach(async(() => { 13 | TestBed.configureTestingModule({ 14 | declarations: [DashboardComponent], 15 | imports: [ 16 | CommonModule, 17 | RouterModule, 18 | ], 19 | schemas: [CUSTOM_ELEMENTS_SCHEMA], 20 | }) 21 | .compileComponents(); 22 | })); 23 | 24 | beforeEach(() => { 25 | fixture = TestBed.createComponent(DashboardComponent); 26 | component = fixture.componentInstance; 27 | fixture.detectChanges(); 28 | }); 29 | 30 | it('should be created', () => { 31 | expect(component).toBeTruthy(); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/app/dashboard/dashboard.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; 2 | 3 | import { Hero } from '../hero'; 4 | 5 | @Component({ 6 | selector: 'app-dashboard-ui', 7 | templateUrl: './dashboard.component.html', 8 | styleUrls: ['./dashboard.component.css'], 9 | changeDetection: ChangeDetectionStrategy.OnPush, 10 | }) 11 | export class DashboardComponent { 12 | @Input() heroes: Hero[]; 13 | @Input() title: string; 14 | } 15 | -------------------------------------------------------------------------------- /src/app/dashboard/dashboard.container.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /src/app/dashboard/dashboard.container.spec.ts: -------------------------------------------------------------------------------- 1 | import { asapScheduler, of as observableOf } from 'rxjs'; 2 | 3 | import { femaleMarvelHeroes } from '../../test/female-marvel-heroes'; 4 | import { Hero } from '../hero'; 5 | import { HeroService } from '../hero.service'; 6 | import { DashboardContainerComponent } from './dashboard.container'; 7 | 8 | describe(DashboardContainerComponent.name, () => { 9 | describe('emits top heroes', () => { 10 | function createHeroServiceStub(): jasmine.SpyObj { 11 | const stub: jasmine.SpyObj = jasmine.createSpyObj( 12 | HeroService.name, 13 | [ 14 | 'getHeroes', 15 | ]); 16 | resetHeroServiceStub(stub); 17 | 18 | return stub; 19 | } 20 | 21 | function resetHeroServiceStub(stub: jasmine.SpyObj): void { 22 | stub.getHeroes 23 | .and.returnValue(observableOf(femaleMarvelHeroes, asapScheduler)) 24 | .calls.reset(); 25 | } 26 | 27 | let container: DashboardContainerComponent; 28 | const heroServiceStub: jasmine.SpyObj = 29 | createHeroServiceStub(); 30 | 31 | beforeEach(() => { 32 | container = new DashboardContainerComponent( 33 | heroServiceStub as unknown as HeroService); 34 | }); 35 | afterEach(() => { 36 | resetHeroServiceStub(heroServiceStub); 37 | }); 38 | 39 | it('emits the top 4 heroes', async () => { 40 | const heroes: Hero[] = await container.topHeroes$.toPromise(); 41 | 42 | expect(heroes.length).toBe(4); 43 | expect(heroes[0]).toEqual({ id: 2, name: 'Captain Marvel' }); 44 | }); 45 | 46 | it(`immediately delegates to ${HeroService.name}`, async () => { 47 | expect(heroServiceStub.getHeroes).toHaveBeenCalledTimes(1); 48 | 49 | await container.topHeroes$.toPromise(); 50 | 51 | expect(heroServiceStub.getHeroes).toHaveBeenCalledTimes(1); 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/app/dashboard/dashboard.container.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | import { map } from 'rxjs/operators'; 4 | 5 | import { Hero } from '../hero'; 6 | import { HeroService } from '../hero.service'; 7 | 8 | @Component({ 9 | changeDetection: ChangeDetectionStrategy.OnPush, 10 | selector: 'app-dashboard', 11 | templateUrl: './dashboard.container.html', 12 | }) 13 | export class DashboardContainerComponent { 14 | topHeroes$: Observable = this.heroService.getHeroes().pipe( 15 | map(heroes => heroes.slice(1, 5)), 16 | ); 17 | 18 | constructor(private heroService: HeroService) {} 19 | } 20 | -------------------------------------------------------------------------------- /src/app/hero-detail/hero-detail.component.css: -------------------------------------------------------------------------------- 1 | /* HeroDetailComponent's private CSS styles */ 2 | label { 3 | display: inline-block; 4 | width: 3em; 5 | margin: .5em 0; 6 | color: #607D8B; 7 | font-weight: bold; 8 | } 9 | input { 10 | height: 2em; 11 | font-size: 1em; 12 | padding-left: .4em; 13 | } 14 | button { 15 | margin-top: 20px; 16 | font-family: Arial; 17 | background-color: #eee; 18 | border: none; 19 | padding: 5px 10px; 20 | border-radius: 4px; 21 | cursor: pointer; cursor: hand; 22 | } 23 | button:hover { 24 | background-color: #cfd8dc; 25 | } 26 | button:disabled { 27 | background-color: #eee; 28 | color: #ccc; 29 | cursor: auto; 30 | } 31 | -------------------------------------------------------------------------------- /src/app/hero-detail/hero-detail.component.html: -------------------------------------------------------------------------------- 1 |
    2 |

    {{ hero.name | uppercase }} Details

    3 |
    id: {{hero.id}}
    4 |
    5 | 8 |
    9 | 10 | 11 |
    12 | -------------------------------------------------------------------------------- /src/app/hero-detail/hero-detail.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectionStrategy, 3 | Component, 4 | EventEmitter, 5 | Input, 6 | Output, 7 | } from '@angular/core'; 8 | 9 | import { Hero } from '../hero'; 10 | 11 | @Component({ 12 | selector: 'app-hero-detail-ui', 13 | templateUrl: './hero-detail.component.html', 14 | styleUrls: ['./hero-detail.component.css'], 15 | changeDetection: ChangeDetectionStrategy.OnPush, 16 | }) 17 | export class HeroDetailComponent { 18 | @Input() hero: Hero; 19 | @Output() cancel: EventEmitter = new EventEmitter(); 20 | @Output() heroChange: EventEmitter = new EventEmitter(); 21 | } 22 | -------------------------------------------------------------------------------- /src/app/hero-detail/hero-detail.container.html: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /src/app/hero-detail/hero-detail.container.spec.ts: -------------------------------------------------------------------------------- 1 | import { Location } from '@angular/common'; 2 | import { ActivatedRoute, ParamMap } from '@angular/router'; 3 | import { 4 | asapScheduler, 5 | BehaviorSubject, 6 | of as observableOf, 7 | Subject, 8 | } from 'rxjs'; 9 | import { subscriptionCount } from 'rxjs-subscription-count'; 10 | import { observeOn, take } from 'rxjs/operators'; 11 | 12 | import { femaleMarvelHeroes } from '../../test/female-marvel-heroes'; 13 | import { Hero } from '../hero'; 14 | import { HeroService } from '../hero.service'; 15 | import { HeroDetailContainerComponent } from './hero-detail.container'; 16 | 17 | describe(HeroDetailContainerComponent.name, () => { 18 | const blackWidow: Hero = femaleMarvelHeroes 19 | .find(x => x.name === 'Black Widow'); 20 | let container: HeroDetailContainerComponent; 21 | const heroServiceStub: jasmine.SpyObj = createHeroServiceStub(); 22 | const locationStub: jasmine.SpyObj = createLocationStub(); 23 | let routeParameters: Subject; 24 | let routeParametersSubscriptionCount: BehaviorSubject; 25 | let activatedRouteFake: ActivatedRoute; 26 | 27 | function createHeroServiceStub(): jasmine.SpyObj { 28 | const stub: jasmine.SpyObj = jasmine.createSpyObj( 29 | HeroService.name, 30 | [ 31 | 'getHero', 32 | 'updateHero', 33 | ]); 34 | resetHeroServiceStub(stub); 35 | 36 | return stub; 37 | } 38 | 39 | function createLocationStub(): jasmine.SpyObj { 40 | const stub: jasmine.SpyObj = jasmine.createSpyObj( 41 | Location.name, 42 | [ 43 | 'back', 44 | ]); 45 | 46 | resetLocationStub(stub); 47 | 48 | return stub; 49 | } 50 | 51 | function emitRouteParameters(parameters: { [key: string]: string }): void { 52 | routeParameters.next({ 53 | get keys(): string[] { 54 | return Object.keys(parameters); 55 | }, 56 | get(name: string): string | null { 57 | return parameters[name] || null; 58 | }, 59 | getAll(name: string): string[] { 60 | return this.has(name) 61 | ? [parameters[name]] 62 | : []; 63 | }, 64 | has(name: string): boolean { 65 | return Object.keys(parameters).includes(name); 66 | }, 67 | }); 68 | } 69 | 70 | function isHero(x: any): x is Hero { 71 | return x != null 72 | && typeof x === 'object' 73 | && !Array.isArray(x) 74 | && typeof x.id === 'number' 75 | && typeof x.name === 'string'; 76 | } 77 | 78 | function resetHeroServiceStub(stub: jasmine.SpyObj): void { 79 | stub.getHero 80 | .and.returnValue(observableOf(blackWidow, asapScheduler)) 81 | .calls.reset(); 82 | stub.updateHero 83 | .and.callFake((hero: Hero) => observableOf(hero, asapScheduler)) 84 | .calls.reset(); 85 | } 86 | 87 | function resetLocationStub(stub: jasmine.SpyObj): void { 88 | stub.back.calls.reset(); 89 | } 90 | 91 | beforeEach(() => { 92 | routeParameters = new Subject(); 93 | routeParametersSubscriptionCount = new BehaviorSubject(0); 94 | activatedRouteFake = { 95 | paramMap: routeParameters.asObservable().pipe( 96 | subscriptionCount(routeParametersSubscriptionCount), 97 | observeOn(asapScheduler), 98 | ), 99 | } as Partial as any; 100 | container = new HeroDetailContainerComponent( 101 | activatedRouteFake, 102 | heroServiceStub as unknown as HeroService, 103 | locationStub); 104 | }); 105 | 106 | afterEach(() => { 107 | routeParameters.complete(); 108 | routeParametersSubscriptionCount.complete(); 109 | resetHeroServiceStub(heroServiceStub); 110 | resetLocationStub(locationStub); 111 | }); 112 | 113 | it('navigates to the previous page', () => { 114 | container.goBack(); 115 | 116 | expect(locationStub.back).toHaveBeenCalledTimes(1); 117 | }); 118 | 119 | it('saves a hero', () => { 120 | container.save(blackWidow); 121 | 122 | expect(heroServiceStub.updateHero).toHaveBeenCalledTimes(1); 123 | expect(heroServiceStub.updateHero).toHaveBeenCalledWith(blackWidow); 124 | }); 125 | 126 | describe('hero observable', () => { 127 | it('emits a hero when "id" route parameter changes', async () => { 128 | const emitHero: Promise = container.hero$.pipe( 129 | take(1), 130 | ).toPromise(); 131 | 132 | emitRouteParameters({ id: '1' }); 133 | 134 | const hero: Hero = await emitHero; 135 | expect(isHero(hero)).toBe(true, 'it must be a hero'); 136 | }); 137 | 138 | it('subscribes to route parameter changes on hero subscription', () => { 139 | expect(routeParametersSubscriptionCount.value).toBe(0); 140 | container.hero$.subscribe(); 141 | 142 | expect(routeParametersSubscriptionCount.value).toBe(1); 143 | emitRouteParameters({ id: '1' }); 144 | 145 | expect(routeParametersSubscriptionCount.value).toBe(1); 146 | }); 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /src/app/hero-detail/hero-detail.container.ts: -------------------------------------------------------------------------------- 1 | import { Location } from '@angular/common'; 2 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 3 | import { ActivatedRoute } from '@angular/router'; 4 | import { Observable } from 'rxjs'; 5 | import { filter, switchMap } from 'rxjs/operators'; 6 | 7 | import { Hero } from '../hero'; 8 | import { HeroService } from '../hero.service'; 9 | 10 | @Component({ 11 | changeDetection: ChangeDetectionStrategy.OnPush, 12 | selector: 'app-hero-detail', 13 | templateUrl: './hero-detail.container.html', 14 | }) 15 | export class HeroDetailContainerComponent { 16 | hero$: Observable = this.route.paramMap.pipe( 17 | filter(params => params.has('id')), 18 | switchMap(params => this.heroService.getHero(+params.get('id'))), 19 | ); 20 | 21 | constructor( 22 | private route: ActivatedRoute, 23 | private heroService: HeroService, 24 | private location: Location, 25 | ) {} 26 | 27 | goBack(): void { 28 | this.location.back(); 29 | } 30 | 31 | save(hero: Hero): void { 32 | this.heroService.updateHero(hero) 33 | .subscribe(() => this.goBack()); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/app/hero-search/hero-search.component.css: -------------------------------------------------------------------------------- 1 | /* HeroSearch private styles */ 2 | .search-result li { 3 | border-bottom: 1px solid gray; 4 | border-left: 1px solid gray; 5 | border-right: 1px solid gray; 6 | width:195px; 7 | height: 16px; 8 | padding: 5px; 9 | background-color: white; 10 | cursor: pointer; 11 | list-style-type: none; 12 | } 13 | 14 | .search-result li:hover { 15 | background-color: #607D8B; 16 | } 17 | 18 | .search-result li a { 19 | color: #888; 20 | display: block; 21 | text-decoration: none; 22 | } 23 | 24 | .search-result li a:hover { 25 | color: white; 26 | } 27 | .search-result li a:active { 28 | color: white; 29 | } 30 | #search-box { 31 | width: 200px; 32 | height: 20px; 33 | } 34 | 35 | 36 | ul.search-result { 37 | margin-top: 0; 38 | padding-left: 0; 39 | } 40 | -------------------------------------------------------------------------------- /src/app/hero-search/hero-search.component.html: -------------------------------------------------------------------------------- 1 |
    2 |

    {{title}}

    3 | 4 | 5 | 6 | 13 |
    14 | -------------------------------------------------------------------------------- /src/app/hero-search/hero-search.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { RouterModule } from '@angular/router'; 3 | 4 | import { HeroSearchComponent } from './hero-search.component'; 5 | 6 | describe('HeroSearchComponent', () => { 7 | let component: HeroSearchComponent; 8 | let fixture: ComponentFixture; 9 | 10 | beforeEach(async(() => { 11 | TestBed.configureTestingModule({ 12 | declarations: [HeroSearchComponent], 13 | imports: [RouterModule], 14 | }) 15 | .compileComponents(); 16 | })); 17 | 18 | beforeEach(() => { 19 | fixture = TestBed.createComponent(HeroSearchComponent); 20 | component = fixture.componentInstance; 21 | fixture.detectChanges(); 22 | }); 23 | 24 | it('should create', () => { 25 | expect(component).toBeTruthy(); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/app/hero-search/hero-search.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectionStrategy, 3 | Component, 4 | EventEmitter, 5 | Input, 6 | OnDestroy, 7 | OnInit, 8 | Output, 9 | } from '@angular/core'; 10 | import { Subject } from 'rxjs'; 11 | import { takeUntil } from 'rxjs/operators'; 12 | 13 | import { Hero } from '../hero'; 14 | import { HeroSearchPresenter } from './hero-search.presenter'; 15 | 16 | @Component({ 17 | selector: 'app-hero-search-ui', 18 | templateUrl: './hero-search.component.html', 19 | styleUrls: ['./hero-search.component.css'], 20 | changeDetection: ChangeDetectionStrategy.OnPush, 21 | providers: [HeroSearchPresenter], 22 | }) 23 | export class HeroSearchComponent implements OnDestroy, OnInit { 24 | @Input() heroes: Hero[]; 25 | @Input() title: string; 26 | @Output() search: EventEmitter = new EventEmitter(); 27 | private destroy: Subject = new Subject(); 28 | 29 | constructor(private presenter: HeroSearchPresenter) {} 30 | 31 | ngOnInit(): void { 32 | this.presenter.searchTerms$.pipe( 33 | // complete when component is destroyed 34 | takeUntil(this.destroy), 35 | ).subscribe(term => this.search.emit(term)); 36 | } 37 | 38 | ngOnDestroy(): void { 39 | this.destroy.next(); 40 | this.destroy.complete(); 41 | } 42 | 43 | searchFor(term: string): void { 44 | this.presenter.search(term); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/app/hero-search/hero-search.container.html: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /src/app/hero-search/hero-search.container.spec.ts: -------------------------------------------------------------------------------- 1 | import { fakeAsync, tick } from '@angular/core/testing'; 2 | import { 3 | asapScheduler, 4 | BehaviorSubject, 5 | Observable, 6 | of as observableOf, 7 | Subject, 8 | } from 'rxjs'; 9 | import { subscriptionCount } from 'rxjs-subscription-count'; 10 | import { takeUntil } from 'rxjs/operators'; 11 | 12 | import { femaleMarvelHeroes } from '../../test/female-marvel-heroes'; 13 | import { Hero } from '../hero'; 14 | import { HeroService } from '../hero.service'; 15 | import { HeroSearchContainerComponent } from './hero-search.container'; 16 | 17 | describe(HeroSearchContainerComponent.name, () => { 18 | let container: HeroSearchContainerComponent; 19 | const destroy: Subject = new Subject(); 20 | const heroesObserver: jasmine.Spy = jasmine.createSpy('heroes observer'); 21 | let searchSubscriptionCount: BehaviorSubject; 22 | let searchResults$: Observable; 23 | let heroServiceStub: jasmine.SpyObj; 24 | // createHeroServiceStub(searchResults); 25 | 26 | function createHeroServiceStub(): jasmine.SpyObj { 27 | const stub: jasmine.SpyObj = jasmine.createSpyObj( 28 | HeroService.name, 29 | [ 30 | 'searchHeroes', 31 | ]); 32 | 33 | resetHeroServiceStub(stub); 34 | 35 | return stub; 36 | } 37 | 38 | function resetHeroServiceStub( 39 | stub: jasmine.SpyObj, 40 | ): void { 41 | stub.searchHeroes 42 | .and.returnValue(searchResults$) 43 | .calls.reset(); 44 | } 45 | 46 | beforeEach(() => { 47 | searchSubscriptionCount = new BehaviorSubject(0); 48 | searchResults$ = observableOf(femaleMarvelHeroes, asapScheduler).pipe( 49 | subscriptionCount(searchSubscriptionCount), 50 | ); 51 | heroServiceStub = createHeroServiceStub(); 52 | container = new HeroSearchContainerComponent( 53 | heroServiceStub as unknown as HeroService); 54 | container.heroes$.pipe(takeUntil(destroy)).subscribe(heroesObserver); 55 | }); 56 | 57 | afterEach(() => { 58 | destroy.next(); 59 | heroesObserver.calls.reset(); 60 | searchSubscriptionCount.complete(); 61 | }); 62 | 63 | afterAll(() => { 64 | destroy.complete(); 65 | }); 66 | 67 | describe('emits filtered heroes', () => { 68 | it('when the user searches', fakeAsync(() => { 69 | const missMarvel = 'ms. marvel'; 70 | 71 | container.search(missMarvel); 72 | tick(); 73 | 74 | expect(heroesObserver).toHaveBeenCalledTimes(1); 75 | })); 76 | }); 77 | 78 | describe('delegates search', () => { 79 | it(`delegates to ${HeroService.name}`, () => { 80 | const storm = 'storm'; 81 | const medusa = 'medusa'; 82 | 83 | container.search(storm); 84 | 85 | expect(heroServiceStub.searchHeroes).toHaveBeenCalledTimes(1); 86 | expect(heroServiceStub.searchHeroes).toHaveBeenCalledWith(storm); 87 | 88 | container.search(medusa); 89 | 90 | expect(heroServiceStub.searchHeroes).toHaveBeenCalledTimes(2); 91 | expect(heroServiceStub.searchHeroes).toHaveBeenCalledWith(medusa); 92 | }); 93 | 94 | it('switches subscription when a new search is performed', () => { 95 | const rogue = 'rogue'; 96 | const blackWidow = 'black widow'; 97 | const captainMarvel = 'captain marvel'; 98 | 99 | expect(searchSubscriptionCount.value).toBe(0); 100 | 101 | container.search(rogue); 102 | 103 | expect(searchSubscriptionCount.value).toBe(1); 104 | 105 | container.search(blackWidow); 106 | 107 | expect(searchSubscriptionCount.value).toBe(1); 108 | 109 | container.search(captainMarvel); 110 | 111 | expect(searchSubscriptionCount.value).toBe(1); 112 | }); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /src/app/hero-search/hero-search.container.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { Observable, Subject } from 'rxjs'; 3 | import { switchMap } from 'rxjs/operators'; 4 | 5 | import { Hero } from '../hero'; 6 | import { HeroService } from '../hero.service'; 7 | 8 | @Component({ 9 | changeDetection: ChangeDetectionStrategy.OnPush, 10 | selector: 'app-hero-search', 11 | templateUrl: './hero-search.container.html', 12 | }) 13 | export class HeroSearchContainerComponent { 14 | private searchTerms: Subject = new Subject(); 15 | heroes$: Observable = this.searchTerms.pipe( 16 | // switch to new search observable each time the term changes 17 | switchMap(term => this.heroService.searchHeroes(term)), 18 | ); 19 | 20 | constructor(private heroService: HeroService) {} 21 | 22 | // Push a search term into the observable stream. 23 | search(term: string): void { 24 | this.searchTerms.next(term); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/app/hero-search/hero-search.presenter.spec.ts: -------------------------------------------------------------------------------- 1 | import { fakeAsync, tick } from '@angular/core/testing'; 2 | import { Subscription } from 'rxjs'; 3 | 4 | import { HeroSearchPresenter } from './hero-search.presenter'; 5 | 6 | describe(HeroSearchPresenter.name, () => { 7 | let presenter: HeroSearchPresenter; 8 | 9 | beforeEach(() => { 10 | presenter = new HeroSearchPresenter(); 11 | }); 12 | 13 | describe('emits search terms', () => { 14 | const debounceTime = 300; 15 | let searchTermsSpy: jasmine.Spy; 16 | let searchTermsSubscription: Subscription; 17 | 18 | beforeEach(() => { 19 | searchTermsSpy = jasmine.createSpy('searchTermsSpy'); 20 | searchTermsSubscription = presenter.searchTerms$ 21 | .subscribe(searchTermsSpy); 22 | }); 23 | 24 | afterEach(() => { 25 | searchTermsSubscription.unsubscribe(); 26 | }); 27 | 28 | it('when a user searches', fakeAsync(() => { 29 | const gamora = 'gamora'; 30 | 31 | presenter.search(gamora); 32 | tick(debounceTime); 33 | 34 | expect(searchTermsSpy).toHaveBeenCalledTimes(1); 35 | expect(searchTermsSpy).toHaveBeenCalledWith(gamora); 36 | })); 37 | 38 | it(`after ${debounceTime} milliseconds of inactivity`, fakeAsync(() => { 39 | const elektra = 'elektra'; 40 | 41 | presenter.search(elektra); 42 | tick(debounceTime - 1); 43 | 44 | expect(searchTermsSpy).not.toHaveBeenCalled(); 45 | tick(1); 46 | 47 | expect(searchTermsSpy).toHaveBeenCalledTimes(1); 48 | })); 49 | 50 | it(`that is the latest preceeded by at most ${debounceTime - 1} milliseconds of inactivity`, fakeAsync(() => { 51 | const medusa = 'medusa'; 52 | const wasp = 'wasp'; 53 | 54 | presenter.search(medusa); 55 | tick(debounceTime - 1); 56 | presenter.search(wasp); 57 | 58 | expect(searchTermsSpy).not.toHaveBeenCalled(); 59 | tick(debounceTime); 60 | 61 | expect(searchTermsSpy).toHaveBeenCalledTimes(1); 62 | expect(searchTermsSpy).toHaveBeenCalledWith(wasp); 63 | })); 64 | 65 | it('and ignores duplicates', fakeAsync(() => { 66 | const sheHulk = 'she-hulk'; 67 | 68 | presenter.search(sheHulk); 69 | tick(debounceTime - 1); 70 | presenter.search(sheHulk); 71 | 72 | expect(searchTermsSpy).not.toHaveBeenCalled(); 73 | tick(300); 74 | 75 | expect(searchTermsSpy).toHaveBeenCalledTimes(1); 76 | expect(searchTermsSpy).toHaveBeenCalledWith(sheHulk); 77 | })); 78 | 79 | it('but duplicates reset the inactivity period', fakeAsync(() => { 80 | const scarletWitch = 'scarlet witch'; 81 | 82 | presenter.search(scarletWitch); 83 | tick(debounceTime - 1); 84 | presenter.search(scarletWitch); 85 | tick(1); 86 | 87 | expect(searchTermsSpy).not.toHaveBeenCalled(); 88 | tick(300); 89 | 90 | expect(searchTermsSpy).toHaveBeenCalledTimes(1); 91 | })); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /src/app/hero-search/hero-search.presenter.ts: -------------------------------------------------------------------------------- 1 | import { Observable, Subject } from 'rxjs'; 2 | import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; 3 | 4 | export class HeroSearchPresenter { 5 | private searchTerms: Subject = new Subject(); 6 | searchTerms$: Observable = this.searchTerms.pipe( 7 | // wait 300ms after each keystroke before considering the term 8 | debounceTime(300), 9 | 10 | // ignore new term if same as previous term 11 | distinctUntilChanged(), 12 | ); 13 | 14 | search(term: string): void { 15 | this.searchTerms.next(term); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/app/hero.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient, HttpHeaders } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { Observable, of } from 'rxjs'; 4 | import { catchError, map, tap } from 'rxjs/operators'; 5 | 6 | import { Hero } from './hero'; 7 | import { MessageService } from './message.service'; 8 | 9 | const httpOptions = { 10 | headers: new HttpHeaders({ 'Content-Type': 'application/json' }) 11 | }; 12 | 13 | @Injectable({ providedIn: 'root' }) 14 | export class HeroService { 15 | 16 | private heroesUrl = 'api/heroes'; // URL to web api 17 | 18 | constructor( 19 | private http: HttpClient, 20 | private messageService: MessageService) { } 21 | 22 | /** GET heroes from the server */ 23 | getHeroes (): Observable { 24 | return this.http.get(this.heroesUrl) 25 | .pipe( 26 | tap(heroes => this.log(`fetched heroes`)), 27 | catchError(this.handleError('getHeroes', [])) 28 | ); 29 | } 30 | 31 | /** GET hero by id. Return `undefined` when id not found */ 32 | getHeroNo404(id: number): Observable { 33 | const url = `${this.heroesUrl}/?id=${id}`; 34 | return this.http.get(url) 35 | .pipe( 36 | map(heroes => heroes[0]), // returns a {0|1} element array 37 | tap(h => { 38 | const outcome = h ? `fetched` : `did not find`; 39 | this.log(`${outcome} hero id=${id}`); 40 | }), 41 | catchError(this.handleError(`getHero id=${id}`)) 42 | ); 43 | } 44 | 45 | /** GET hero by id. Will 404 if id not found */ 46 | getHero(id: number): Observable { 47 | const url = `${this.heroesUrl}/${id}`; 48 | return this.http.get(url).pipe( 49 | tap(_ => this.log(`fetched hero id=${id}`)), 50 | catchError(this.handleError(`getHero id=${id}`)) 51 | ); 52 | } 53 | 54 | /* GET heroes whose name contains search term */ 55 | searchHeroes(term: string): Observable { 56 | if (!term.trim()) { 57 | // if not search term, return empty hero array. 58 | return of([]); 59 | } 60 | return this.http.get(`api/heroes/?name=${term}`).pipe( 61 | tap(_ => this.log(`found heroes matching "${term}"`)), 62 | catchError(this.handleError('searchHeroes', [])) 63 | ); 64 | } 65 | 66 | //////// Save methods ////////// 67 | 68 | /** POST: add a new hero to the server */ 69 | addHero (hero: Hero): Observable { 70 | return this.http.post(this.heroesUrl, hero, httpOptions).pipe( 71 | tap((h: Hero) => this.log(`added hero w/ id=${h.id}`)), 72 | catchError(this.handleError('addHero')) 73 | ); 74 | } 75 | 76 | /** DELETE: delete the hero from the server */ 77 | deleteHero (hero: Hero | number): Observable { 78 | const id = typeof hero === 'number' ? hero : hero.id; 79 | const url = `${this.heroesUrl}/${id}`; 80 | 81 | return this.http.delete(url, httpOptions).pipe( 82 | tap(_ => this.log(`deleted hero id=${id}`)), 83 | catchError(this.handleError('deleteHero')) 84 | ); 85 | } 86 | 87 | /** PUT: update the hero on the server */ 88 | updateHero (hero: Hero): Observable { 89 | return this.http.put(this.heroesUrl, hero, httpOptions).pipe( 90 | tap(_ => this.log(`updated hero id=${hero.id}`)), 91 | catchError(this.handleError('updateHero')) 92 | ); 93 | } 94 | 95 | /** 96 | * Handle Http operation that failed. 97 | * Let the app continue. 98 | * @param operation - name of the operation that failed 99 | * @param result - optional value to return as the observable result 100 | */ 101 | private handleError (operation = 'operation', result?: T) { 102 | return (error: any): Observable => { 103 | 104 | // TODO: send the error to remote logging infrastructure 105 | console.error(error); // log to console instead 106 | 107 | // TODO: better job of transforming error for user consumption 108 | this.log(`${operation} failed: ${error.message}`); 109 | 110 | // Let the app keep running by returning an empty result. 111 | return of(result as T); 112 | }; 113 | } 114 | 115 | /** Log a HeroService message with the MessageService */ 116 | private log(message: string) { 117 | this.messageService.add('HeroService: ' + message); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/app/hero.ts: -------------------------------------------------------------------------------- 1 | export class Hero { 2 | id: number; 3 | name: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/app/heroes/heroes.component.css: -------------------------------------------------------------------------------- 1 | /* HeroesComponent's private CSS styles */ 2 | .heroes { 3 | margin: 0 0 2em 0; 4 | list-style-type: none; 5 | padding: 0; 6 | width: 15em; 7 | } 8 | .heroes li { 9 | position: relative; 10 | cursor: pointer; 11 | background-color: #EEE; 12 | margin: .5em; 13 | padding: .3em 0; 14 | height: 1.6em; 15 | border-radius: 4px; 16 | } 17 | 18 | .heroes li:hover { 19 | color: #607D8B; 20 | background-color: #DDD; 21 | left: .1em; 22 | } 23 | 24 | .heroes a { 25 | color: #888; 26 | text-decoration: none; 27 | position: relative; 28 | display: block; 29 | width: 250px; 30 | } 31 | 32 | .heroes a:hover { 33 | color:#607D8B; 34 | } 35 | 36 | .heroes .badge { 37 | display: inline-block; 38 | font-size: small; 39 | color: white; 40 | padding: 0.8em 0.7em 0 0.7em; 41 | background-color: #607D8B; 42 | line-height: 1em; 43 | position: relative; 44 | left: -1px; 45 | top: -4px; 46 | height: 1.8em; 47 | min-width: 16px; 48 | text-align: right; 49 | margin-right: .8em; 50 | border-radius: 4px 0 0 4px; 51 | } 52 | 53 | button { 54 | background-color: #eee; 55 | border: none; 56 | padding: 5px 10px; 57 | border-radius: 4px; 58 | cursor: pointer; 59 | cursor: hand; 60 | font-family: Arial; 61 | } 62 | 63 | button:hover { 64 | background-color: #cfd8dc; 65 | } 66 | 67 | button.delete { 68 | position: relative; 69 | left: 194px; 70 | top: -32px; 71 | background-color: gray !important; 72 | color: white; 73 | } 74 | 75 | -------------------------------------------------------------------------------- /src/app/heroes/heroes.component.html: -------------------------------------------------------------------------------- 1 |

    {{title}}

    2 | 3 |
    4 | 7 | 8 | 11 |
    12 | 13 | 22 | -------------------------------------------------------------------------------- /src/app/heroes/heroes.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { ReactiveFormsModule } from '@angular/forms'; 3 | import { RouterModule } from '@angular/router'; 4 | 5 | import { HeroesComponent } from './heroes.component'; 6 | 7 | describe('HeroesComponent', () => { 8 | let component: HeroesComponent; 9 | let fixture: ComponentFixture; 10 | 11 | beforeEach(async(() => { 12 | TestBed.configureTestingModule({ 13 | declarations: [HeroesComponent], 14 | imports: [RouterModule, ReactiveFormsModule], 15 | }) 16 | .compileComponents(); 17 | })); 18 | 19 | beforeEach(() => { 20 | fixture = TestBed.createComponent(HeroesComponent); 21 | component = fixture.componentInstance; 22 | fixture.detectChanges(); 23 | }); 24 | 25 | it('should be created', () => { 26 | expect(component).toBeTruthy(); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/app/heroes/heroes.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectionStrategy, 3 | Component, 4 | EventEmitter, 5 | Input, 6 | OnInit, 7 | Output, 8 | } from '@angular/core'; 9 | import { FormControl } from '@angular/forms'; 10 | 11 | import { Hero } from '../hero'; 12 | import { HeroesPresenter } from './heroes.presenter'; 13 | 14 | @Component({ 15 | selector: 'app-heroes-ui', 16 | templateUrl: './heroes.component.html', 17 | styleUrls: ['./heroes.component.css'], 18 | changeDetection: ChangeDetectionStrategy.OnPush, 19 | providers: [HeroesPresenter], 20 | }) 21 | export class HeroesComponent implements OnInit { 22 | @Input() heroes: Hero[]; 23 | @Input() title: string; 24 | @Output() add: EventEmitter = new EventEmitter(); 25 | @Output() remove: EventEmitter = new EventEmitter(); 26 | get nameControl(): FormControl { 27 | return this.presenter.nameControl; 28 | } 29 | 30 | constructor(private presenter: HeroesPresenter) {} 31 | 32 | ngOnInit(): void { 33 | this.presenter.add$.subscribe(name => this.add.emit(name)); 34 | } 35 | 36 | addHero(): void { 37 | this.presenter.addHero(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/app/heroes/heroes.container.html: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /src/app/heroes/heroes.container.spec.ts: -------------------------------------------------------------------------------- 1 | import { fakeAsync, tick } from '@angular/core/testing'; 2 | import { asapScheduler, of as observableOf, Subject, throwError } from 'rxjs'; 3 | import { takeUntil } from 'rxjs/operators'; 4 | 5 | import { femaleMarvelHeroes } from '../../test/female-marvel-heroes'; 6 | import { Hero } from '../hero'; 7 | import { HeroService } from '../hero.service'; 8 | import { HeroesContainerComponent } from './heroes.container'; 9 | 10 | describe(HeroesContainerComponent.name, () => { 11 | function createHeroServiceStub(): jasmine.SpyObj { 12 | const stub: jasmine.SpyObj = jasmine.createSpyObj( 13 | HeroService.name, 14 | [ 15 | 'addHero', 16 | 'deleteHero', 17 | 'getHeroes', 18 | ]); 19 | resetHeroServiceStub(stub); 20 | 21 | return stub; 22 | } 23 | 24 | function resetHeroServiceStub(stub: jasmine.SpyObj): void { 25 | stub.addHero 26 | .and.callFake(({ name }: Partial) => observableOf({ 27 | id: 42, 28 | name, 29 | }, asapScheduler)) 30 | .calls.reset(); 31 | stub.deleteHero 32 | .and.callFake((hero: Hero) => observableOf(hero, asapScheduler)) 33 | .calls.reset(); 34 | stub.getHeroes 35 | .and.returnValue(observableOf(femaleMarvelHeroes, asapScheduler)) 36 | .calls.reset(); 37 | } 38 | 39 | let container: HeroesContainerComponent; 40 | const destroy: Subject = new Subject(); 41 | const heroServiceStub: jasmine.SpyObj = createHeroServiceStub(); 42 | const observer: jasmine.Spy = jasmine.createSpy('heroes observer'); 43 | 44 | beforeEach(fakeAsync(() => { 45 | container = new HeroesContainerComponent( 46 | heroServiceStub as unknown as HeroService); 47 | container.heroes$.pipe(takeUntil(destroy)).subscribe(observer); 48 | tick(); 49 | })); 50 | 51 | afterEach(() => { 52 | destroy.next(); 53 | observer.calls.reset(); 54 | resetHeroServiceStub(heroServiceStub); 55 | }); 56 | 57 | afterAll(() => { 58 | destroy.complete(); 59 | }); 60 | 61 | describe('emits all heroes', () => { 62 | it('all heroes are emitted after subscribing', () => { 63 | expect(observer).toHaveBeenCalledWith(femaleMarvelHeroes); 64 | }); 65 | 66 | it(`delegates to ${HeroService.name}`, () => { 67 | expect(heroServiceStub.getHeroes).toHaveBeenCalledTimes(1); 68 | }); 69 | }); 70 | 71 | describe('adds a hero', () => { 72 | it('emits the specified hero when server responds', fakeAsync(() => { 73 | const wonderWoman = 'Wonder Woman'; 74 | 75 | container.add(wonderWoman); 76 | tick(); 77 | 78 | expect(observer).toHaveBeenCalledWith([ 79 | ...femaleMarvelHeroes, 80 | { id: 42, name: wonderWoman }, 81 | ]); 82 | })); 83 | 84 | it(`delegates to ${HeroService.name}`, () => { 85 | const hawkeye = 'Hawkeye (Kate Bishop)'; 86 | 87 | container.add(hawkeye); 88 | 89 | expect(heroServiceStub.addHero).toHaveBeenCalledTimes(1); 90 | expect(heroServiceStub.addHero).toHaveBeenCalledWith({ name: hawkeye }); 91 | }); 92 | 93 | it('does not emit the specified hero when server fails', fakeAsync(() => { 94 | heroServiceStub.addHero.and.returnValue( 95 | throwError(new Error('server error'), asapScheduler)); 96 | const scarletWitch = 'Scarlet Witch'; 97 | 98 | container.add(scarletWitch); 99 | tick(); 100 | 101 | expect(observer).not.toHaveBeenCalledWith([ 102 | ...femaleMarvelHeroes, 103 | { id: 42, name: scarletWitch }, 104 | ]); 105 | })); 106 | }); 107 | 108 | describe('deletes a hero', () => { 109 | it(`delegates to ${HeroService.name}`, () => { 110 | const gamora: Hero = femaleMarvelHeroes.find(x => x.name === 'Gamora'); 111 | 112 | container.delete(gamora); 113 | 114 | expect(heroServiceStub.deleteHero).toHaveBeenCalledTimes(1); 115 | expect(heroServiceStub.deleteHero).toHaveBeenCalledWith(gamora); 116 | }); 117 | 118 | it('emits all other heroes immediately', fakeAsync(() => { 119 | const elektra: Hero = femaleMarvelHeroes.find(x => x.name === 'Elektra'); 120 | 121 | container.delete(elektra); 122 | tick(); 123 | 124 | expect(observer).toHaveBeenCalledWith( 125 | femaleMarvelHeroes.filter(x => x.id !== elektra.id)); 126 | })); 127 | 128 | it('emits the specified hero when server fails', fakeAsync(() => { 129 | function compareIdAscending(a: Hero, b: Hero): number { 130 | return (a.id < b.id) 131 | ? -1 132 | : (a.id > b.id) 133 | ? 1 134 | : 0; 135 | } 136 | 137 | heroServiceStub.deleteHero.and.returnValue( 138 | throwError(new Error('timeout'), asapScheduler)); 139 | const storm: Hero = femaleMarvelHeroes.find(x => x.name === 'Storm'); 140 | 141 | container.delete(storm); 142 | tick(); 143 | 144 | const emittedHeroes: Hero[] = observer.calls.mostRecent().args[0]; 145 | emittedHeroes.sort(compareIdAscending); 146 | expect(emittedHeroes).toEqual(femaleMarvelHeroes); 147 | })); 148 | }); 149 | }); 150 | -------------------------------------------------------------------------------- /src/app/heroes/heroes.container.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { noop, Observable, Subject } from 'rxjs'; 3 | import { multiScan } from 'rxjs-multi-scan'; 4 | 5 | import { Hero } from '../hero'; 6 | import { HeroService } from '../hero.service'; 7 | 8 | @Component({ 9 | changeDetection: ChangeDetectionStrategy.OnPush, 10 | selector: 'app-heroes', 11 | templateUrl: './heroes.container.html', 12 | }) 13 | export class HeroesContainerComponent { 14 | private heroAdd: Subject = new Subject(); 15 | private heroRemove: Subject = new Subject(); 16 | 17 | heroes$: Observable = multiScan( 18 | this.heroService.getHeroes(), 19 | (heroes, loadedHeroes) => [...heroes, ...loadedHeroes], 20 | this.heroAdd, 21 | (heroes, hero) => [...heroes, hero], 22 | this.heroRemove, 23 | (heroes, hero) => heroes.filter(h => h !== hero), 24 | []); 25 | 26 | constructor(private heroService: HeroService) {} 27 | 28 | add(name: string): void { 29 | this.heroService.addHero({ name } as Hero) 30 | .subscribe({ 31 | next: h => this.heroAdd.next(h), 32 | error: noop, 33 | }); 34 | } 35 | 36 | delete(hero: Hero): void { 37 | this.heroRemove.next(hero); 38 | this.heroService.deleteHero(hero) 39 | .subscribe({ 40 | error: () => this.heroAdd.next(hero), 41 | }); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/app/heroes/heroes.presenter.spec.ts: -------------------------------------------------------------------------------- 1 | import { HeroesPresenter } from './heroes.presenter'; 2 | 3 | describe(HeroesPresenter.name, () => { 4 | let presenter: HeroesPresenter; 5 | 6 | beforeEach(() => { 7 | presenter = new HeroesPresenter(); 8 | }); 9 | 10 | describe('emits a hero name', () => { 11 | let addSpy: jasmine.Spy; 12 | 13 | beforeEach(() => { 14 | addSpy = jasmine.createSpy('heroNameSpy'); 15 | presenter.add$.subscribe(addSpy); 16 | }); 17 | afterEach(() => { 18 | presenter.ngOnDestroy(); 19 | }); 20 | 21 | it('when a user enters it', () => { 22 | const captainMarvel = 'Captain Marvel'; 23 | 24 | presenter.nameControl.setValue(captainMarvel); 25 | presenter.addHero(); 26 | 27 | expect(addSpy).toHaveBeenCalledTimes(1); 28 | expect(addSpy).toHaveBeenCalledWith(captainMarvel); 29 | }); 30 | 31 | it('that is trimmed of white space', () => { 32 | const medusa = 'Medusa'; 33 | 34 | presenter.nameControl.setValue(' \t\t\t ' + medusa + ' \t\r\n \r\n '); 35 | presenter.addHero(); 36 | 37 | expect(addSpy).toHaveBeenCalledWith(medusa); 38 | }); 39 | 40 | it('but ignores blank and white space names', () => { 41 | const blank = ''; 42 | const whiteSpace = ' \r\n \t '; 43 | 44 | presenter.nameControl.setValue(blank); 45 | presenter.addHero(); 46 | presenter.nameControl.setValue(whiteSpace); 47 | presenter.addHero(); 48 | 49 | expect(addSpy).not.toHaveBeenCalled(); 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/app/heroes/heroes.presenter.ts: -------------------------------------------------------------------------------- 1 | import { OnDestroy } from '@angular/core'; 2 | import { FormControl } from '@angular/forms'; 3 | import { Observable, Subject } from 'rxjs'; 4 | 5 | export class HeroesPresenter implements OnDestroy { 6 | private add: Subject = new Subject(); 7 | add$: Observable = this.add.asObservable(); 8 | nameControl = new FormControl(''); 9 | 10 | ngOnDestroy(): void { 11 | this.add.complete(); 12 | } 13 | 14 | public addHero(): void { 15 | const name = this.nameControl.value.trim(); 16 | this.nameControl.setValue(''); 17 | 18 | if (!name) { 19 | return; 20 | } 21 | 22 | this.add.next(name); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/app/in-memory-data.service.ts: -------------------------------------------------------------------------------- 1 | import { InMemoryDbService } from 'angular-in-memory-web-api'; 2 | 3 | export class InMemoryDataService implements InMemoryDbService { 4 | createDb() { 5 | const heroes = [ 6 | { id: 11, name: 'Mr. Nice' }, 7 | { id: 12, name: 'Narco' }, 8 | { id: 13, name: 'Bombasto' }, 9 | { id: 14, name: 'Celeritas' }, 10 | { id: 15, name: 'Magneta' }, 11 | { id: 16, name: 'RubberMan' }, 12 | { id: 17, name: 'Dynama' }, 13 | { id: 18, name: 'Dr IQ' }, 14 | { id: 19, name: 'Magma' }, 15 | { id: 20, name: 'Tornado' } 16 | ]; 17 | return {heroes}; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/app/message.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, inject } from '@angular/core/testing'; 2 | 3 | import { MessageService } from './message.service'; 4 | 5 | describe('MessageService', () => { 6 | beforeEach(() => { 7 | TestBed.configureTestingModule({ 8 | providers: [MessageService] 9 | }); 10 | }); 11 | 12 | it('should be created', inject([MessageService], (service: MessageService) => { 13 | expect(service).toBeTruthy(); 14 | })); 15 | }); 16 | -------------------------------------------------------------------------------- /src/app/message.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | @Injectable({ providedIn: 'root' }) 4 | export class MessageService { 5 | messages: string[] = []; 6 | 7 | add(message: string) { 8 | this.messages = [...this.messages, message]; 9 | } 10 | 11 | clear() { 12 | this.messages = []; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/app/messages/messages.component.css: -------------------------------------------------------------------------------- 1 | /* MessagesComponent's private CSS styles */ 2 | h2 { 3 | color: red; 4 | font-family: Arial, Helvetica, sans-serif; 5 | font-weight: lighter; 6 | } 7 | body { 8 | margin: 2em; 9 | } 10 | body, input[text], button { 11 | color: crimson; 12 | font-family: Cambria, Georgia; 13 | } 14 | 15 | button.clear { 16 | font-family: Arial; 17 | background-color: #eee; 18 | border: none; 19 | padding: 5px 10px; 20 | border-radius: 4px; 21 | cursor: pointer; 22 | cursor: hand; 23 | } 24 | button:hover { 25 | background-color: #cfd8dc; 26 | } 27 | button:disabled { 28 | background-color: #eee; 29 | color: #aaa; 30 | cursor: auto; 31 | } 32 | button.clear { 33 | color: #888; 34 | margin-bottom: 12px; 35 | } 36 | -------------------------------------------------------------------------------- /src/app/messages/messages.component.html: -------------------------------------------------------------------------------- 1 |
    2 | 3 |

    {{title}}

    4 | 6 |
    {{message}}
    7 | 8 |
    9 | -------------------------------------------------------------------------------- /src/app/messages/messages.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { MessagesComponent } from './messages.component'; 4 | 5 | describe('MessagesComponent', () => { 6 | let component: MessagesComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ MessagesComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(MessagesComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should be created', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/messages/messages.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectionStrategy, 3 | Component, 4 | EventEmitter, 5 | Input, 6 | Output, 7 | } from '@angular/core'; 8 | 9 | import { MessagesPresenter } from './messages.presenter'; 10 | 11 | @Component({ 12 | selector: 'app-messages-ui', 13 | templateUrl: './messages.component.html', 14 | styleUrls: ['./messages.component.css'], 15 | changeDetection: ChangeDetectionStrategy.OnPush, 16 | providers: [MessagesPresenter], 17 | }) 18 | export class MessagesComponent { 19 | get messages(): string[] { 20 | return this.presenter.messages; 21 | } 22 | @Input() set messages(value: string[]) { 23 | this.presenter.messages = value; 24 | } 25 | @Input() title: string; 26 | @Output() clear: EventEmitter = new EventEmitter(); 27 | 28 | get hasMessages(): boolean { 29 | return this.presenter.hasMessages; 30 | } 31 | 32 | constructor(private presenter: MessagesPresenter) {} 33 | } 34 | -------------------------------------------------------------------------------- /src/app/messages/messages.container.html: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /src/app/messages/messages.container.spec.ts: -------------------------------------------------------------------------------- 1 | import { MessageService } from '../message.service'; 2 | import { MessagesContainerComponent } from './messages.container'; 3 | 4 | describe(MessagesContainerComponent.name, () => { 5 | let container: MessagesContainerComponent; 6 | let service: MessageService; 7 | 8 | beforeEach(() => { 9 | service = new MessageService(); 10 | spyOn(service, 'clear'); 11 | container = new MessagesContainerComponent(service); 12 | }); 13 | 14 | it('lists messages', () => { 15 | service.add('And a one...'); 16 | service.add('And a two...'); 17 | service.add('And a three...'); 18 | 19 | expect(container.messages.length).toEqual(3); 20 | container.messages.forEach(message => 21 | expect(message).toEqual(jasmine.any(String))); 22 | }); 23 | 24 | it('clears messages', () => { 25 | container.clearMessages(); 26 | 27 | expect(service.clear).toHaveBeenCalledTimes(1); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/app/messages/messages.container.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | import { MessageService } from '../message.service'; 4 | 5 | @Component({ 6 | selector: 'app-messages', 7 | templateUrl: './messages.container.html', 8 | }) 9 | export class MessagesContainerComponent { 10 | get messages(): string[] { 11 | return this.messageService.messages; 12 | } 13 | 14 | constructor(private messageService: MessageService) {} 15 | 16 | clearMessages(): void { 17 | this.messageService.clear(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/app/messages/messages.presenter.spec.ts: -------------------------------------------------------------------------------- 1 | import { MessagesPresenter } from './messages.presenter'; 2 | 3 | describe(MessagesPresenter.name, () => { 4 | let presenter: MessagesPresenter; 5 | 6 | beforeEach(() => { 7 | presenter = new MessagesPresenter(); 8 | }); 9 | 10 | it('lists messages', () => { 11 | expect(presenter.messages).toEqual(jasmine.any(Array)); 12 | }); 13 | 14 | it('indicates whether it has any messages', () => { 15 | presenter.messages = []; 16 | 17 | expect(presenter.hasMessages).toBe( 18 | false, 19 | 'it must not indicate that it has any messages'); 20 | presenter.messages = [ 21 | 'The city is flying, we’re fighting an army of robots and I have a bow and arrow. None of this makes sense.', 22 | 'There is nothing more reassuring than realizing the world is crazier than you are.', 23 | 'You know who I am. You don’t know where I am. And you’ll never see me coming.', 24 | ]; 25 | 26 | expect(presenter.hasMessages).toBe( 27 | true, 28 | 'it must indicate that it has messages'); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/app/messages/messages.presenter.ts: -------------------------------------------------------------------------------- 1 | export class MessagesPresenter { 2 | get hasMessages(): boolean { 3 | return this.messages.length > 0; 4 | } 5 | messages: string[] = []; 6 | } 7 | -------------------------------------------------------------------------------- /src/app/mock-heroes.ts: -------------------------------------------------------------------------------- 1 | import { Hero } from './hero'; 2 | 3 | export const HEROES: Hero[] = [ 4 | { id: 11, name: 'Mr. Nice' }, 5 | { id: 12, name: 'Narco' }, 6 | { id: 13, name: 'Bombasto' }, 7 | { id: 14, name: 'Celeritas' }, 8 | { id: 15, name: 'Magneta' }, 9 | { id: 16, name: 'RubberMan' }, 10 | { id: 17, name: 'Dynama' }, 11 | { id: 18, name: 'Dr IQ' }, 12 | { id: 19, name: 'Magma' }, 13 | { id: 20, name: 'Tornado' } 14 | ]; 15 | -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LayZeeDK/ngx-tour-of-heroes-mvp/f63739240c9998ef14680e79c3ef64cda392ba07/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/browserslist: -------------------------------------------------------------------------------- 1 | > 0.5% 2 | last 2 versions 3 | Firefox ESR 4 | not dead 5 | IE 9-11 -------------------------------------------------------------------------------- /src/environments/environment-signature.ts: -------------------------------------------------------------------------------- 1 | export abstract class Environment { 2 | public abstract readonly hmr: boolean; 3 | public abstract readonly production: boolean; 4 | } 5 | -------------------------------------------------------------------------------- /src/environments/environment.hmr.ts: -------------------------------------------------------------------------------- 1 | import { Environment } from './environment-signature'; 2 | 3 | export const environment: Environment = { 4 | hmr: true, 5 | production: false, 6 | }; 7 | 8 | /* 9 | * In development mode, to ignore zone related error stack frames such as 10 | * `zone.run`, `zoneDelegate.invokeTask` for easier debugging, you can 11 | * import the following file, but please comment it out in production mode 12 | * because it will have performance impact when throw error 13 | */ 14 | import 'zone.js/dist/zone-error'; // Included with Angular CLI. 15 | -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | import { Environment } from './environment-signature'; 2 | 3 | export const environment: Environment = { 4 | hmr: false, 5 | production: true, 6 | }; 7 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | import { Environment } from './environment-signature'; 2 | 3 | // The file contents for the current environment will overwrite these during build. 4 | // The build system defaults to the dev environment which uses `environment.ts`, but if you do 5 | // `ng build --env=prod` then `environment.prod.ts` will be used instead. 6 | // The list of which env maps to which file can be found in `.angular-cli.json`. 7 | 8 | export const environment: Environment = { 9 | hmr: false, 10 | production: false, 11 | }; 12 | 13 | /* 14 | * In development mode, to ignore zone related error stack frames such as 15 | * `zone.run`, `zoneDelegate.invokeTask` for easier debugging, you can 16 | * import the following file, but please comment it out in production mode 17 | * because it will have performance impact when throw error 18 | */ 19 | import 'zone.js/dist/zone-error'; // Included with Angular CLI. 20 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LayZeeDK/ngx-tour-of-heroes-mvp/f63739240c9998ef14680e79c3ef64cda392ba07/src/favicon.ico -------------------------------------------------------------------------------- /src/hmr.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationRef, NgModuleRef } from '@angular/core'; 2 | import { createNewHosts } from '@angularclass/hmr'; 3 | 4 | /** 5 | * Hot Module Replacement (live-reloading) 6 | * 7 | * See https://github.com/angular/angular-cli/wiki/stories-configure-hmr. 8 | */ 9 | 10 | interface NodeModule { 11 | readonly id: string; 12 | } 13 | 14 | export interface HmrNodeModule extends NodeModule { 15 | readonly hot: { 16 | readonly accept: () => void; 17 | readonly dispose: (fn: () => void) => void; 18 | }; 19 | } 20 | 21 | export function isHmrNodeModule(x: Partial): x is HmrNodeModule { 22 | return x.hot !== undefined; 23 | } 24 | 25 | export function hmrBootstrap( 26 | module: HmrNodeModule, 27 | bootstrap: () => Promise>, 28 | ): void { 29 | let ngModule: NgModuleRef; 30 | module.hot.accept(); 31 | bootstrap() 32 | .then(x => x === undefined 33 | ? Promise.reject('No NgModuleRef') 34 | : Promise.resolve(x as NgModuleRef)) 35 | .then(x => ngModule = x) 36 | .catch(error => console.error(error)); 37 | module.hot.dispose((): void => { 38 | const appRef: ApplicationRef = ngModule.injector.get(ApplicationRef); 39 | const elements: ReadonlyArray = 40 | appRef.components.map(x => x.location.nativeElement); 41 | const makeVisible: () => void = createNewHosts(elements); 42 | ngModule.destroy(); 43 | makeVisible(); 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Tour of Heroes 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /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: ['ChromeHeadless'], 29 | singleRun: false 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode, NgModuleRef } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | import { hmrBootstrap, HmrNodeModule, isHmrNodeModule } from './hmr'; 7 | 8 | 9 | declare const module: Partial; 10 | 11 | function bootstrap(): Promise> { 12 | return platformBrowserDynamic() 13 | .bootstrapModule(AppModule) 14 | .catch(error => console.log(error)); 15 | } 16 | 17 | if (environment.production) { 18 | enableProdMode(); 19 | } 20 | 21 | if (environment.hmr) { 22 | if (isHmrNodeModule(module)) { 23 | hmrBootstrap(module, bootstrap); 24 | } else { 25 | console.error('Hot Module Replacement is disabled for webpack-dev-server!'); 26 | console.log('Are you using the --hmr flag for ng serve?'); 27 | } 28 | } else { 29 | bootstrap(); 30 | } 31 | -------------------------------------------------------------------------------- /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/docs/ts/latest/guide/browser-support.html 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** IE9, IE10 and IE11 requires all of the following polyfills. **/ 22 | // import 'core-js/es6/symbol'; 23 | // import 'core-js/es6/object'; 24 | // import 'core-js/es6/function'; 25 | // import 'core-js/es6/parse-int'; 26 | // import 'core-js/es6/parse-float'; 27 | // import 'core-js/es6/number'; 28 | // import 'core-js/es6/math'; 29 | // import 'core-js/es6/string'; 30 | // import 'core-js/es6/date'; 31 | // import 'core-js/es6/array'; 32 | // import 'core-js/es6/regexp'; 33 | // import 'core-js/es6/map'; 34 | // import 'core-js/es6/weak-map'; 35 | // import 'core-js/es6/set'; 36 | 37 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */ 38 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 39 | 40 | /** IE10 and IE11 requires the following for the Reflect API. */ 41 | // import 'core-js/es6/reflect'; 42 | 43 | 44 | /** Evergreen browsers require these. **/ 45 | // Used for reflect-metadata in JIT. If you use AOT (and only Angular decorators), you can remove. 46 | // import 'core-js/es7/reflect'; 47 | 48 | 49 | /** 50 | * Required to support Web Animations `@angular/platform-browser/animations`. 51 | * Needed for: All but Chrome, Firefox and Opera. http://caniuse.com/#feat=web-animation 52 | **/ 53 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 54 | 55 | /** 56 | * By default, zone.js will patch all possible macroTask and DomEvents 57 | * user can disable parts of macroTask/DomEvents patch by setting following flags 58 | */ 59 | 60 | // (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 61 | // (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 62 | // (window as any).__zone_symbol__BLACK_LISTED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 63 | 64 | /* 65 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 66 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 67 | */ 68 | // (window as any).__Zone_enable_cross_context_check = true; 69 | 70 | /*************************************************************************************************** 71 | * Zone JS is required by default for Angular itself. 72 | */ 73 | import 'zone.js/dist/zone'; // Included with Angular CLI. 74 | 75 | 76 | 77 | /*************************************************************************************************** 78 | * APPLICATION IMPORTS 79 | */ 80 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | /* Master Styles */ 2 | h1 { 3 | color: #369; 4 | font-family: Arial, Helvetica, sans-serif; 5 | font-size: 250%; 6 | } 7 | h2, h3 { 8 | color: #444; 9 | font-family: Arial, Helvetica, sans-serif; 10 | font-weight: lighter; 11 | } 12 | body { 13 | margin: 2em; 14 | } 15 | body, input[text], button { 16 | color: #888; 17 | font-family: Cambria, Georgia; 18 | } 19 | a { 20 | cursor: pointer; 21 | cursor: hand; 22 | } 23 | button { 24 | font-family: Arial; 25 | background-color: #eee; 26 | border: none; 27 | padding: 5px 10px; 28 | border-radius: 4px; 29 | cursor: pointer; 30 | cursor: hand; 31 | } 32 | button:hover { 33 | background-color: #cfd8dc; 34 | } 35 | button:disabled { 36 | background-color: #eee; 37 | color: #aaa; 38 | cursor: auto; 39 | } 40 | 41 | /* Navigation link styles */ 42 | nav a { 43 | padding: 5px 10px; 44 | text-decoration: none; 45 | margin-right: 10px; 46 | margin-top: 10px; 47 | display: inline-block; 48 | background-color: #eee; 49 | border-radius: 4px; 50 | } 51 | nav a:visited, a:link { 52 | color: #607D8B; 53 | } 54 | nav a:hover { 55 | color: #039be5; 56 | background-color: #CFD8DC; 57 | } 58 | nav a.active { 59 | color: #039be5; 60 | } 61 | 62 | /* everywhere else */ 63 | * { 64 | font-family: Arial, Helvetica, sans-serif; 65 | } 66 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'core-js/es7/reflect'; 4 | import 'zone.js/dist/zone-testing'; 5 | import { getTestBed } from '@angular/core/testing'; 6 | import { 7 | BrowserDynamicTestingModule, 8 | platformBrowserDynamicTesting 9 | } from '@angular/platform-browser-dynamic/testing'; 10 | 11 | declare const require: any; 12 | 13 | // First, initialize the Angular testing environment. 14 | getTestBed().initTestEnvironment( 15 | BrowserDynamicTestingModule, 16 | platformBrowserDynamicTesting() 17 | ); 18 | // Then we find all the tests. 19 | const context = require.context('./', true, /\.spec\.ts$/); 20 | // And load the modules. 21 | context.keys().map(context); 22 | -------------------------------------------------------------------------------- /src/test/female-marvel-heroes.ts: -------------------------------------------------------------------------------- 1 | import { Hero } from '../app/hero'; 2 | 3 | export const femaleMarvelHeroes: Hero[] = [ 4 | { id: 1, name: 'Black Widow' }, 5 | { id: 2, name: 'Captain Marvel' }, 6 | { id: 3, name: 'Medusa' }, 7 | { id: 4, name: 'Ms. Marvel' }, 8 | { id: 5, name: 'Scarlet Witch' }, 9 | { id: 6, name: 'She-Hulk' }, 10 | { id: 7, name: 'Storm' }, 11 | { id: 8, name: 'Wasp' }, 12 | { id: 9, name: 'Rogue' }, 13 | { id: 10, name: 'Elektra' }, 14 | { id: 11, name: 'Gamora' }, 15 | { id: 12, name: 'Hawkeye (Kate Bishop)' }, 16 | ]; 17 | -------------------------------------------------------------------------------- /src/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "module": "es2015", 6 | "types": [] 7 | }, 8 | "exclude": [ 9 | "src/test.ts", 10 | "src/wallaby-test.ts", 11 | "**/*.spec.ts" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /src/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/spec", 5 | "module": "commonjs", 6 | "types": [ 7 | "jasmine", 8 | "node" 9 | ] 10 | }, 11 | "files": [ 12 | "test.ts", 13 | "polyfills.ts" 14 | ], 15 | "include": [ 16 | "**/*.spec.ts", 17 | "**/*.d.ts" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /src/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tslint.json", 3 | "rules": { 4 | "directive-selector": [ 5 | true, 6 | "attribute", 7 | "app", 8 | "camelCase" 9 | ], 10 | "component-selector": [ 11 | true, 12 | "element", 13 | "app", 14 | "kebab-case" 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/wallaby-test.ts: -------------------------------------------------------------------------------- 1 | import 'core-js/es7/reflect'; 2 | import './polyfills'; 3 | 4 | import 'zone.js/dist/zone-testing'; 5 | 6 | import { getTestBed } from '@angular/core/testing'; 7 | import { 8 | BrowserDynamicTestingModule, 9 | platformBrowserDynamicTesting 10 | } from '@angular/platform-browser-dynamic/testing'; 11 | 12 | getTestBed().initTestEnvironment( 13 | BrowserDynamicTestingModule, 14 | platformBrowserDynamicTesting() 15 | ); 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "importHelpers": true, 6 | "outDir": "./dist/out-tsc", 7 | "sourceMap": true, 8 | "declaration": false, 9 | "module": "es2015", 10 | "moduleResolution": "node", 11 | "emitDecoratorMetadata": true, 12 | "experimentalDecorators": true, 13 | "target": "es5", 14 | "typeRoots": [ 15 | "node_modules/@types" 16 | ], 17 | "lib": [ 18 | "es2018", 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-use-before-declare": true, 76 | "no-var-keyword": true, 77 | "object-literal-sort-keys": false, 78 | "one-line": [ 79 | true, 80 | "check-open-brace", 81 | "check-catch", 82 | "check-else", 83 | "check-whitespace" 84 | ], 85 | "prefer-const": true, 86 | "quotemark": [ 87 | true, 88 | "single" 89 | ], 90 | "radix": true, 91 | "semicolon": [ 92 | true, 93 | "always" 94 | ], 95 | "triple-equals": [ 96 | true, 97 | "allow-null-check" 98 | ], 99 | "typedef-whitespace": [ 100 | true, 101 | { 102 | "call-signature": "nospace", 103 | "index-signature": "nospace", 104 | "parameter": "nospace", 105 | "property-declaration": "nospace", 106 | "variable-declaration": "nospace" 107 | } 108 | ], 109 | "unified-signatures": true, 110 | "variable-name": false, 111 | "whitespace": [ 112 | true, 113 | "check-branch", 114 | "check-decl", 115 | "check-operator", 116 | "check-separator", 117 | "check-type" 118 | ], 119 | "no-output-on-prefix": true, 120 | "use-input-property-decorator": true, 121 | "use-output-property-decorator": true, 122 | "use-host-property-decorator": true, 123 | "no-input-rename": true, 124 | "no-output-rename": true, 125 | "use-life-cycle-interface": true, 126 | "use-pipe-transform-interface": true, 127 | "component-class-suffix": true, 128 | "directive-class-suffix": true 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /wallaby.js: -------------------------------------------------------------------------------- 1 | var wallabyWebpack = require('wallaby-webpack'); 2 | var path = require('path'); 3 | 4 | var compilerOptions = Object.assign( 5 | require('./tsconfig.json').compilerOptions, 6 | require('./src/tsconfig.spec.json').compilerOptions); 7 | 8 | compilerOptions.module = 'CommonJs'; 9 | 10 | module.exports = function (wallaby) { 11 | 12 | var webpackPostprocessor = wallabyWebpack({ 13 | entryPatterns: [ 14 | 'src/wallaby-test.js', 15 | 'src/**/*spec.js' 16 | ], 17 | 18 | module: { 19 | rules: [ 20 | { test: /\.css$/, loader: ['raw-loader'] }, 21 | { test: /\.html$/, loader: 'raw-loader' }, 22 | { test: /\.ts$/, loader: '@ngtools/webpack', include: /node_modules/, query: { tsConfigPath: 'tsconfig.json' } }, 23 | { test: /\.js$/, loader: 'angular2-template-loader', exclude: /node_modules/ }, 24 | { test: /\.json$/, loader: 'json-loader' }, 25 | { test: /\.styl$/, loaders: ['raw-loader', 'stylus-loader'] }, 26 | { test: /\.less$/, loaders: ['raw-loader', 'less-loader'] }, 27 | { test: /\.scss$|\.sass$/, loaders: ['raw-loader', 'sass-loader'] }, 28 | { test: /\.(jpg|png)$/, loader: 'url-loader?limit=128000' } 29 | ] 30 | }, 31 | 32 | resolve: { 33 | extensions: ['.js', '.ts'], 34 | modules: [ 35 | path.join(wallaby.projectCacheDir, 'src/app'), 36 | path.join(wallaby.projectCacheDir, 'src'), 37 | 'node_modules' 38 | ] 39 | }, 40 | node: { 41 | fs: 'empty', 42 | net: 'empty', 43 | tls: 'empty', 44 | dns: 'empty' 45 | } 46 | }); 47 | 48 | return { 49 | files: [ 50 | { pattern: 'src/**/*.+(ts|css|less|scss|sass|styl|html|json|svg)', load: false }, 51 | { pattern: 'src/**/*.d.ts', ignore: true }, 52 | { pattern: 'src/**/*spec.ts', ignore: true } 53 | ], 54 | 55 | tests: [ 56 | { pattern: 'src/**/*spec.ts', load: false }, 57 | { pattern: 'src/**/*e2e-spec.ts', ignore: true } 58 | ], 59 | 60 | testFramework: 'jasmine', 61 | 62 | compilers: { 63 | '**/*.ts': wallaby.compilers.typeScript(compilerOptions) 64 | }, 65 | 66 | middleware: function (app, express) { 67 | var path = require('path'); 68 | app.use('/favicon.ico', express.static(path.join(__dirname, 'src/favicon.ico'))); 69 | app.use('/assets', express.static(path.join(__dirname, 'src/assets'))); 70 | }, 71 | 72 | env: { 73 | kind: 'chrome' 74 | }, 75 | 76 | postprocessor: webpackPostprocessor, 77 | 78 | setup: function () { 79 | window.__moduleBundler.loadTests(); 80 | }, 81 | 82 | debug: true 83 | }; 84 | }; 85 | --------------------------------------------------------------------------------