Security
- to block XSS (cross-site scripting) attacks, you must prevent malicious code from entering the DOM, Angular treats all values as untrusted by default, sanitizes and escapes untrusted values inserted into the DOM from a template, via property, attribute, style, class binding, or interpolation
- Angular templates are the same as executable code: HTML, attributes, and binding expressions (but not the values bound) in templates are trusted to be safe
- never generate template source code by concatenating user input and templates, use offline template compiler (template injection), Angular trusts template code
- sanitization - inspection of an untrusted value, turning it into a value that is safe to insert into the DOM, security contexts:
- HTML - when interpreting a value as HTML (binding to innerHtml,...)
- Style - when binding CSS into the style property
- URL - for URL properties, such as <a href>
- Resource URL - URL that will be loaded and executed as code, for example, in <script src>
- in development mode, Angular prints a console warning when it has to change a value during sanitization
- pending an ID to a URL is safe, always make sure to construct SafeValue objects as close as possible to the input data so that is easier to check if the value is safe
import { Component } from '@angular/core';
import {
DomSanitizer, SafeResourceUrl, SafeUrl
} from '@angular/platform-browser';
@Component({
selector: 'app-security',
templateUrl: './security.component.html',
})
export class SecurityComponent {
dangerousUrl: string;
trustedUrl: SafeUrl;
dangerousVideoUrl: string;
videoUrl: SafeResourceUrl;
constructor(private sanitizer: DomSanitizer) {
// explicitly tell Angular to trust this value:
this.dangerousUrl = 'javascript:alert("Hi there")';
this.trustedUrl = sanitizer.bypassSecurityTrustUrl(this.dangerousUrl);
this.updateVideoUrl('PUBnlbjZFAI');
}
updateVideoUrl(id: string) {
this.dangerousVideoUrl = 'https://www.youtube.com/embed/' + id;
this.videoUrl =
this.sanitizer.bypassSecurityTrustResourceUrl(this.dangerousVideoUrl);
}
// sanitize script tag out of content automatically
htmlSnippet = 'Template <script>alert("0wned")</script> <b>Syntax</b>';
}
<h3>Bypass Security Component</h3>
<h4>An untrusted URL:</h4>
<p><a [href]="dangerousUrl">Click me</a></p>
<h4>A trusted URL:</h4>
<p><a [href]="trustedUrl">Click me</a></p>
<h4>Resource URL:</h4>
<p>Showing: {{dangerousVideoUrl}}</p>
<p>Trusted:</p>
<iframe width="640" height="390" [src]="videoUrl"></iframe>
<p>Untrusted:</p>
<iframe width="640" height="390" [src]="dangerousVideoUrl"></iframe>
<h3>Binding innerHTML</h3>
<p>Bound value:</p>
<p>{{htmlSnippet}}</p>
<p>Result of binding to innerHTML:</p>
<p [innerHTML]="htmlSnippet"></p>
abstract class DomSanitizer implements Sanitizer {
abstract sanitize(
// enum SecurityContext {
// NONE: 0
// HTML: 1
// STYLE: 2
// SCRIPT: 3
// URL: 4
// RESOURCE_URL: 5
// }
context: SecurityContext,
value: string | SafeValue
): string | null
// calling any of the bypassSecurityTrust... APIs
// disables Angular built-in sanitization for the value passed in
abstract bypassSecurityTrustHtml(value: string): SafeHtml
abstract bypassSecurityTrustStyle(value: string): SafeStyle
abstract bypassSecurityTrustScript(value: string): SafeScript
abstract bypassSecurityTrustUrl(value: string): SafeUrl
abstract bypassSecurityTrustResourceUrl(value: string): SafeResourceUrl
}
Internationalization (i18n)
- making applications available and user-friendly to a worldwide audience
- designing and preparing your app to be usable in different languages
- displaying dates, number, percentages, and currencies in a local format
- preparing text in component templates for translation
- handling plural forms of words
- handling alternative text
- after preparing your app for an international audience, use the CLI to localize app
- extract marked text to a source language file
- make a copy of this file for each language, and send these translation files to a translator or service
- merge the finished translation files when building your app for one or more locales
- steps to localize your app
- add the localize package:
ng add @angular/localize
- refer to locales by ID (en-US by default): change app sourceLocale for the build in angular.json, to automatically set the LOCALE_ID token and load the locale data
- format data based on locale: use DatePipe, CurrencyPipe, DecimalPipe and PercentPipe, override LOCALE_ID with locale parameter: {{amount | currency : 'en-US'}}
- prepare templates for translations
- mark text for translations: use i18n attribute on every element tag with fixed text to be translated
- add helpful descriptions and meanings to help the translator with additional information or context: <meaning>|<description>
<h1 i18n="site header|An introduction header for this sample">Hello i18n!</h1>
- extraction tool generates a translation unit entry for each i18n attribute in a template, assigns each translation unit a unique ID based on the meaning and description
- same text elements with different meanings are extracted with separate IDs: if the word "right" appears with the meaning "correct" ("You are right") in one place, and with the meaning direction ("Turn right") in another place, word is translated differently and merged back into the app as different translation entries
- if same text elements have different descriptions but same meaning, they are extracted only once, with only one ID, that one translation entry is merged back into the app wherever the same text elements appear
- translate text not for display (non-displayed HTML): avoid creating a new DOM element by wrapping the text in an <ng-container>
- mark element attributes for translations such as images "title" attribute
- mark plurals and alternates for translation in order to comply with the pluralization rules and grammatical constructions of different languages:
plural
and select
clauses
- work with translation files: use
ng extract-i18n
> command to extract the marked text in the template into a source language file
- extract the source language file, Angular uses the source locale configured in angular.json, optionally change the location, format, and name
ng extract-i18n --output-path src/locale
- change the location
--format=xlf
|xlf2|xmb|json|arb - change the format
--out-file source.xlf
- change the file name
- source language file named messages.xlf is generated in the project src
- create a translation file for each language by copying the source language file: organize files by locale in a dedicated locale folder under src/, use a filename extension that matches the associated locale, such as messages.fr.xlf
- translate each translation file
- translate plurals and alternate expressions separately
- merge translations into the app (AOT by default), build a copy of the app distributable files for each locale, then serve each distributable copy using server-side language detection or different subdirectories
- build a separate distributable copy of the app for each locale
- define the locales in angular.json, shortens the build process by removing the requirement to perform a full app build for each locale
- generate app versions for each locale using the "localize" option in angular.json
- sourceLocale - locale within app source code (en-US by default)
- locales - map of locale identifiers to translation files
- you can set the "localize" property as a shared configuration that all the configurations effectively inherit (or can override)
- set "localize" to true for all the locales previously defined in the build configuration
- set "localize" to an array of a subset of the previously-defined locale identifiers to build only those locale versions
- set "localize" to false to disable localization and not generate any locale-specific versions
- development server only supports localizing a single locale at a time, setting the "localize" option to true will cause an error when using ng serve if more than one locale is defined, setting the option to a specific locale, such as "localize": ["fr"], can work if you want to develop against a specific locale (such as fr)
- CLI loads and registers the locale data, places each generated version in a locale-specific directory to keep it separate from other locale versions, and puts the directories within the configured outputPath for the project. For each application variant the lang attribute of the html element is set to the locale, CLI also adjusts the HTML base HREF for each version of the app by adding the locale to the configured baseHref
- build from the command:
ng build --localize
with existing production configuration
- deploy multiple locales
- build for multiple locales ...
<h1 i18n>Hello i18n!</h1>
<!-- description of the text message -->
<h1 i18n="An introduction header for this sample">Hello i18n!</h1>
<!-- <meaning>|<description> -->
<h1 i18n="site header|An introduction header for this sample">Hello i18n!</h1>
<!-- custom id is persistent -->
<!-- <trans-unit id="introductionHeader" datatype="html"> -->
<h1 i18n="@@introductionHeader">Hello i18n!</h1>
<!-- custom id with a description -->
<h1
i18n="An introduction header for this sample@@introductionHeader"
>Hello i18n!</h1>
<!-- ... with meaning -->
<h1
i18n="site header|An introduction header for this sample@@introductionHeader"
>Hello i18n!</h1>
<!-- non-displayed HTML -->
<ng-container i18n>I don't output any element</ng-container>
<!-- mark an attribute for translation -->
<!-- i18n-x="<meaning>|<description>@@<id> -->
<img [src]="logo" i18n-title title="Angular logo" />
<!-- plural -->
<span i18n>
Updated {
minutes, <!-- key, component property (minutes) -->
plural, <!-- translation type -->
<!-- pluralization pattern consisting of -->
<!-- pluralization categories and their matching values -->
=0 {just now} =1 {one minute ago} other {{{minutes}} minutes ago}
}
</span>
<!-- select -->
<span i18nn>
The author is {
gender, <!-- key, component property (minutes) -->
select, <!-- translation type -->
male {male} female {female} other {other}
}
</span>
<!-- nesting plural and select -->
<span i18nn>
Updated: {
minutes,
plural,
=0 {just now}
=1 {one minute ago}
other {
{{minutes}} minutes ago by {
gender, select, male {male} female {female} other {other}
}
}
}
</span>
// --- specific build options for just one locale
// and use "ng serve --configuration=fr" for:
"build": {
...
"configurations": {
...
"fr": {
"localize": ["fr"],
"main": "src/main.fr.ts",
...
}
}
},
"serve": {
...
"configurations": {
...
"fr": {
"browserTarget": "*project-name*:build:fr"
}
}
}
// OR "ng build --configuration=production,fr", to execute both configurations for:
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": { ... },
"configurations": {
"fr": {
"localize": ["fr"],
}
}
},
...
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "my-project:build"
},
"configurations": {
"production": {
"browserTarget": "my-project:build:production"
},
"fr": {
"browserTarget": "my-project:build:fr"
}
}
}
}
// --- specify missing translations warning level
angular.json
content_copy
"options": {
...
"i18nMissingTranslation": "error" // warning | ignore
}
EXAMPLE APP
angular.json
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"angular.io-example": {
"projectType": "application",
"schematics": {
"@schematics/angular:application": {
"strict": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"i18n": {
"sourceLocale": "en-US",
"locales": {
"fr": "src/locale/messages.fr.xlf"
}
},
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"localize": true,
"outputPath": "dist",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.app.json",
"aot": true,
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"src/styles.css"
],
"scripts": []
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
]
},
"fr": {
"localize": [
"fr"
]
}
}
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "angular.io-example:build"
},
"configurations": {
"production": {
"browserTarget": "angular.io-example:build:production"
},
"fr": {
"browserTarget": "angular.io-example:build:fr"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "angular.io-example:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js",
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"src/styles.css"
],
"scripts": []
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"tsconfig.app.json",
"tsconfig.spec.json",
"e2e/tsconfig.json"
],
"exclude": [
"**/node_modules/**"
]
}
},
"e2e": {
"builder": "@angular-devkit/build-angular:protractor",
"options": {
"protractorConfig": "e2e/protractor.conf.js",
"devServerTarget": "angular.io-example:serve:fr"
},
"configurations": {
"production": {
"devServerTarget": "angular.io-example:serve:production"
}
}
}
}
}
},
"defaultProject": "angular.io-example"
}
e2e
+---- protractor.conf.js
// @ts-check
// Protractor configuration file, see link for more information
// https://github.com/angular/protractor/blob/master/lib/config.ts
const { SpecReporter, StacktraceOption } = require('jasmine-spec-reporter');
/**
* @type { import("protractor").Config }
*/
exports.config = {
allScriptsTimeout: 11000,
specs: [
'./src/**/*.e2e-spec.ts'
],
capabilities: {
browserName: 'chrome'
},
directConnect: true,
SELENIUM_PROMISE_MANAGER: false,
baseUrl: 'http://localhost:4200/',
framework: 'jasmine',
jasmineNodeOpts: {
showColors: true,
defaultTimeoutInterval: 30000,
print: function() {}
},
onPrepare() {
require('ts-node').register({
project: require('path').join(__dirname, './tsconfig.json')
});
jasmine.getEnv().addReporter(new SpecReporter({
spec: {
displayStacktrace: StacktraceOption.PRETTY
}
}));
}
};
+---- src
| +---- app.e2e-spec.ts
import { browser, element, by } from 'protractor';
describe('i18n E2E Tests', () => {
beforeEach(() => browser.get(''));
it('should display i18n translated welcome: Bonjour !', async () => {
expect(await element(by.css('h1')).getText()).toEqual('Bonjour i18n !');
});
it('should display the node texts without elements', async () => {
expect(await element(by.css('app-root')).getText()).toContain(`Je n'affiche aucun élément`);
});
it('should display the translated title attribute', async () => {
const title = await element(by.css('img')).getAttribute('title');
expect(title).toBe(`Logo d'Angular`);
});
it('should display the ICU plural expression', async () => {
expect(await element.all(by.css('span')).get(0).getText()).toBe(`Mis à jour à l'instant`);
});
it('should display the ICU select expression', async () => {
const selectIcuExp = element.all(by.css('span')).get(1);
expect(await selectIcuExp.getText()).toBe(`L'auteur est une femme`);
await element.all(by.css('button')).get(2).click();
expect(await selectIcuExp.getText()).toBe(`L'auteur est un homme`);
});
it('should display the nested expression', async () => {
const nestedExp = element.all(by.css('span')).get(2);
const incBtn = element.all(by.css('button')).get(0);
expect(await nestedExp.getText()).toBe(`Mis à jour: à l'instant`);
await incBtn.click();
expect(await nestedExp.getText()).toBe(`Mis à jour: il y a une minute`);
await incBtn.click();
await incBtn.click();
await element.all(by.css('button')).get(4).click();
expect(await nestedExp.getText()).toBe(`Mis à jour: il y a 3 minutes par autre`);
});
});
| +---- app.po.ts
import { browser, by, element } from 'protractor';
export class AppPage {
async navigateTo(): Promise<unknown> {
return browser.get(browser.baseUrl);
}
async getTitleText(): Promise<string> {
return element(by.css('app-root h1')).getText();
}
}
+---- tsconfig.json
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/e2e",
"module": "commonjs",
"target": "es2018",
"types": [
"jasmine",
"node"
]
}
}
karma.conf.js
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
jasmine: {
// you can add configuration options for Jasmine here
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
// for example, you can disable the random execution with `random: false`
// or set a specific seed with `seed: 4321`
},
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
jasmineHtmlReporter: {
suppressAll: true // removes the duplicated traces
},
coverageReporter: {
dir: require('path').join(__dirname, './coverage/angular.io-example'),
subdir: '.',
reporters: [
{ type: 'html' },
{ type: 'text-summary' }
]
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false,
restartOnFileChange: true
});
};
package.json
{
"name": "angular.io-example",
"version": "0.0.0",
"description": "Example project from an angular.io guide.",
"license": "MIT",
"scripts": {
"ng": "ng",
"start": "ng serve",
"start:fr": "ng serve --configuration=fr",
"build": "ng build",
"build:fr": "ng build --configuration=production-fr",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e",
"extract": "ng extract-i18n --output-path=locale"
},
"private": true,
"dependencies": {
"@angular/animations": "~12.0.0",
"@angular/common": "~12.0.0",
"@angular/compiler": "~12.0.0",
"@angular/core": "~12.0.0",
"@angular/forms": "~12.0.0",
"@angular/localize": "~12.0.0",
"@angular/platform-browser": "~12.0.0",
"@angular/platform-browser-dynamic": "~12.0.0",
"@angular/router": "~12.0.0",
"angular-in-memory-web-api": "~0.11.0",
"rxjs": "~6.6.0",
"tslib": "^2.0.0",
"zone.js": "~0.11.4"
},
"devDependencies": {
"@angular-devkit/build-angular": "~12.0.0",
"@angular/cli": "~12.0.0",
"@angular/compiler-cli": "~12.0.0",
"@types/jasmine": "~3.6.0",
"@types/node": "^12.11.1",
"codelyzer": "^6.0.0",
"jasmine-core": "~3.7.0",
"jasmine-marbles": "~0.6.0",
"jasmine-spec-reporter": "~5.0.0",
"karma": "~6.3.0",
"karma-chrome-launcher": "~3.1.0",
"karma-coverage": "~2.0.3",
"karma-jasmine": "~4.0.0",
"karma-jasmine-html-reporter": "^1.5.0",
"protractor": "~7.0.0",
"ts-node": "~8.3.0",
"tslint": "~6.1.0",
"typescript": "~4.2.3"
}
}
src
+---- app
| +---- app.component.html
<h1 i18n="User welcome|An introduction header for this sample@@introductionHeader">
Hello i18n!
</h1>
<ng-container i18n>I don't output any element</ng-container>
<br />
<img [src]="logo" i18n-title title="Angular logo" />
<br>
<button (click)="inc(1)">+</button> <button (click)="inc(-1)">-</button>
<span i18n>Updated {minutes, plural, =0 {just now} =1 {one minute ago} other {{{minutes}} minutes ago}}</span>
({{minutes}})
<br><br>
<button (click)="male()">♂</button> <button (click)="female()">♀</button> <button (click)="other()">⚧</button>
<span i18n>The author is {gender, select, male {male} female {female} other {other}}</span>
<br><br>
<span i18n>Updated: {minutes, plural,
=0 {just now}
=1 {one minute ago}
other {{{minutes}} minutes ago by {gender, select, male {male} female {female} other {other}}}}
</span>
| +---- app.component.ts
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: './app.component.html'
})
export class AppComponent {
minutes = 0;
gender = 'female';
fly = true;
logo = 'https://angular.io/assets/images/logos/angular/angular.png';
inc(i: number) {
this.minutes = Math.min(5, Math.max(0, this.minutes + i));
}
male() { this.gender = 'male'; }
female() { this.gender = 'female'; }
other() { this.gender = 'other'; }
}
| +---- app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
@NgModule({
imports: [ BrowserModule ],
declarations: [ AppComponent ],
bootstrap: [ AppComponent ]
})
export class AppModule { }
+---- environments
| +---- environment.prod.ts
export const environment = {
production: true
};
| +---- environment.ts
// This file can be replaced during build by using the `fileReplacements` array.
// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`.
// The list of file replacements can be found in `angular.json`.
export const environment = {
production: false
};
/*
* For easier debugging in development mode, you can import the following file
* to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
*
* This import should be commented out in production mode because it will have a negative impact
* on performance if an error is thrown.
*/
// import 'zone.js/plugins/zone-error'; // Included with Angular CLI.
+---- index.html
<!DOCTYPE html>
<html>
<head>
<base href="/">
<title>Angular i18n example</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<app-root>Loading...</app-root>
</body>
</html>
+---- locale
| +---- messages.fr.xlf
<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="introductionHeader" datatype="html">
<source>
Hello i18n!
</source>
<target>
Bonjour i18n !
</target>
<context-group purpose="location">
<context context-type="sourcefile">app\app.component.ts</context>
<context context-type="linenumber">4</context>
</context-group>
<note priority="1" from="description">An introduction header for this sample</note>
<note priority="1" from="meaning">User welcome</note>
</trans-unit>
<trans-unit id="5206857922697139278" datatype="html">
<source>I don't output any element</source>
<target>Je n'affiche aucun élément</target>
<context-group purpose="location">
<context context-type="sourcefile">app\app.component.ts</context>
<context context-type="linenumber">10</context>
</context-group>
</trans-unit>
<trans-unit id="392942015236586892" datatype="html">
<source>Angular logo</source>
<target>Logo d'Angular</target>
<context-group purpose="location">
<context context-type="sourcefile">app\app.component.ts</context>
<context context-type="linenumber">16</context>
</context-group>
</trans-unit>
<trans-unit id="4606963464835766483" datatype="html">
<source>Updated <x id="ICU" equiv-text="{minutes, plural, =0 {...} =1 {...} other {...}}"/></source>
<target>Mis à jour <x id="ICU" equiv-text="{minutes, plural, =0 {...} =1 {...} other {...}}"/></target>
<context-group purpose="location">
<context context-type="sourcefile">app\app.component.ts</context>
<context context-type="linenumber">21</context>
</context-group>
</trans-unit>
<trans-unit id="2002272803511843863" datatype="html">
<source>{VAR_PLURAL, plural, =0 {just now} =1 {one minute ago} other {<x id="INTERPOLATION" equiv-text="{{minutes}}"/> minutes ago} }</source>
<target>{VAR_PLURAL, plural, =0 {à l'instant} =1 {il y a une minute} other {il y a <x id="INTERPOLATION" equiv-text="{{minutes}}"/> minutes} }</target>
<context-group purpose="location">
<context context-type="sourcefile">app\app.component.ts</context>
<context context-type="linenumber">21</context>
</context-group>
</trans-unit>
<trans-unit id="3560311772637911677" datatype="html">
<source>The author is <x id="ICU" equiv-text="{gender, select, male {...} female {...} other {...}}"/></source>
<target>L'auteur est <x id="ICU" equiv-text="{gender, select, male {...} female {...} other {...}}"/></target>
<context-group purpose="location">
<context context-type="sourcefile">app\app.component.ts</context>
<context context-type="linenumber">27</context>
</context-group>
</trans-unit>
<trans-unit id="7670372064920373295" datatype="html">
<source>{VAR_SELECT, select, male {male} female {female} other {other} }</source>
<target>{VAR_SELECT, select, male {un homme} female {une femme} other {autre} }</target>
<context-group purpose="location">
<context context-type="sourcefile">app\app.component.ts</context>
<context context-type="linenumber">27</context>
</context-group>
</trans-unit>
<trans-unit id="3967965900462880190" datatype="html">
<source>Updated: <x id="ICU" equiv-text="{minutes, plural, =0 {...} =1 {...} other {...}}"/>
</source>
<target>Mis à jour: <x id="ICU" equiv-text="{minutes, plural, =0 {...} =1 {...} other {...}}"/>
</target>
<context-group purpose="location">
<context context-type="sourcefile">app\app.component.ts</context>
<context context-type="linenumber">31</context>
</context-group>
</trans-unit>
<trans-unit id="2508975984005233379" datatype="html">
<source>{VAR_PLURAL, plural, =0 {just now} =1 {one minute ago} other {<x id="INTERPOLATION" equiv-text="{{minutes}}"/> minutes ago by {VAR_SELECT, select, male {male} female {female} other {other} }} }</source>
<target>{VAR_PLURAL, plural, =0 {à l'instant} =1 {il y a une minute} other {il y a <x id="INTERPOLATION" equiv-text="{{minutes}}"/> minutes par {VAR_SELECT, select, male {un homme} female {une femme} other {autre} }} }</target>
<context-group purpose="location">
<context context-type="sourcefile">app\app.component.ts</context>
<context context-type="linenumber">31</context>
</context-group>
</trans-unit>
</body>
</file>
</xliff>
| +---- messages.xlf
<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="introductionHeader" datatype="html">
<source>
Hello i18n!
</source>
<context-group purpose="location">
<context context-type="sourcefile">app/app.component.ts</context>
<context context-type="linenumber">3</context>
</context-group>
<note priority="1" from="description">An introduction header for this sample</note>
<note priority="1" from="meaning">User welcome</note>
</trans-unit>
<trans-unit id="5206857922697139278" datatype="html">
<source>I don't output any element</source>
<context-group purpose="location">
<context context-type="sourcefile">app/app.component.ts</context>
<context context-type="linenumber">9</context>
</context-group>
</trans-unit>
<trans-unit id="392942015236586892" datatype="html">
<source>Angular logo</source>
<context-group purpose="location">
<context context-type="sourcefile">app/app.component.ts</context>
<context context-type="linenumber">15</context>
</context-group>
</trans-unit>
<trans-unit id="4606963464835766483" datatype="html">
<source>Updated <x id="ICU" equiv-text="{minutes, plural, =0 {...} =1 {...} other {...}}"/></source>
<context-group purpose="location">
<context context-type="sourcefile">app/app.component.ts</context>
<context context-type="linenumber">20</context>
</context-group>
</trans-unit>
<trans-unit id="2002272803511843863" datatype="html">
<source>{VAR_PLURAL, plural, =0 {just now} =1 {one minute ago} other {<x id="INTERPOLATION" equiv-text="{{minutes}}"/> minutes ago} }</source>
<context-group purpose="location">
<context context-type="sourcefile">app/app.component.ts</context>
<context context-type="linenumber">20</context>
</context-group>
</trans-unit>
<trans-unit id="3560311772637911677" datatype="html">
<source>The author is <x id="ICU" equiv-text="{gender, select, male {...} female {...} other {...}}"/></source>
<context-group purpose="location">
<context context-type="sourcefile">app/app.component.ts</context>
<context context-type="linenumber">26</context>
</context-group>
</trans-unit>
<trans-unit id="7670372064920373295" datatype="html">
<source>{VAR_SELECT, select, male {male} female {female} other {other} }</source>
<context-group purpose="location">
<context context-type="sourcefile">app/app.component.ts</context>
<context context-type="linenumber">26</context>
</context-group>
</trans-unit>
<trans-unit id="3967965900462880190" datatype="html">
<source>Updated: <x id="ICU" equiv-text="{minutes, plural, =0 {...} =1 {...} other {...}}"/>
</source>
<context-group purpose="location">
<context context-type="sourcefile">app/app.component.ts</context>
<context context-type="linenumber">30</context>
</context-group>
</trans-unit>
<trans-unit id="2508975984005233379" datatype="html">
<source>{VAR_PLURAL, plural, =0 {just now} =1 {one minute ago} other {<x id="INTERPOLATION" equiv-text="{{minutes}}"/> minutes ago by {VAR_SELECT, select, male {male} female {female} other {other} }} }</source>
<context-group purpose="location">
<context context-type="sourcefile">app/app.component.ts</context>
<context context-type="linenumber">30</context>
</context-group>
</trans-unit>
</body>
</file>
</xliff>
+---- main.ts
import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
if (environment.production) {
enableProdMode();
}
platformBrowserDynamic().bootstrapModule(AppModule);
+---- styles.css
/* Global Styles */
* {
font-family: Arial, Helvetica, sans-serif;
}
h1 {
color: #264D73;
font-size: 2.5rem;
}
h2, h3 {
color: #444;
font-weight: lighter;
}
h3 {
font-size: 1.3rem;
}
body {
padding: .5rem;
max-width: 1000px;
margin: auto;
}
@media (min-width: 600px) {
body {
padding: 2rem;
}
}
body, input[text] {
color: #333;
font-family: Cambria, Georgia, serif;
}
a {
cursor: pointer;
}
button {
background-color: #eee;
border: none;
border-radius: 4px;
cursor: pointer;
color: black;
font-size: 1.2rem;
padding: 1rem;
margin-right: 1rem;
margin-bottom: 1rem;
margin-top: 1rem;
}
button:hover {
background-color: black;
color: white;
}
button:disabled {
background-color: #eee;
color: #aaa;
cursor: auto;
}
/* Navigation link styles */
nav a {
padding: 5px 10px;
text-decoration: none;
margin-right: 10px;
margin-top: 10px;
display: inline-block;
background-color: #e8e8e8;
color: #3d3d3d;
border-radius: 4px;
}
nav a:hover {
color: white;
background-color: #42545C;
}
nav a.active {
background-color: black;
color: white;
}
hr {
margin: 1.5rem 0;
}
input[type="text"] {
box-sizing: border-box;
width: 100%;
padding: .5rem;
}
+---- test.ts
// This file is required by karma.conf.js and loads recursively all the .spec and framework files
import 'zone.js/testing';
import { getTestBed } from '@angular/core/testing';
import {
BrowserDynamicTestingModule,
platformBrowserDynamicTesting
} from '@angular/platform-browser-dynamic/testing';
declare const require: {
context(path: string, deep?: boolean, filter?: RegExp): {
<T>(id: string): T;
keys(): string[];
};
};
// First, initialize the Angular testing environment.
getTestBed().initTestEnvironment(
BrowserDynamicTestingModule,
platformBrowserDynamicTesting()
);
// Then we find all the tests.
const context = require.context('./', true, /\.spec\.ts$/);
// And load the modules.
context.keys().map(context);
tsconfig.app.json
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts",
"src/polyfills.ts"
],
"include": [
"src/**/*.d.ts"
],
"exclude": [
"src/test.ts",
"src/**/*.spec.ts",
"src/**/*-specs.ts",
"src/**/*.avoid.ts",
"src/**/*.0.ts",
"src/**/*.1.ts",
"src/**/*.1b.ts",
"src/**/*.2.ts",
"src/**/*.3.ts",
"src/**/*.4.ts",
"src/**/*.5.ts",
"src/**/*.6.ts",
"src/**/*.7.ts",
"src/**/testing"
]
}
tsconfig.json
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"compileOnSave": false,
"compilerOptions": {
"baseUrl": "./",
"outDir": "./dist/out-tsc",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"sourceMap": true,
"declaration": false,
"downlevelIteration": true,
"experimentalDecorators": true,
"moduleResolution": "node",
"importHelpers": true,
"target": "es2015",
"module": "es2020",
"lib": [
"es2018",
"dom"
]
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}
tslint.json
{
"extends": "tslint:recommended",
"rulesDirectory": [
"codelyzer"
],
"rules": {
"align": {
"options": [
"parameters",
"statements"
]
},
"array-type": false,
"arrow-return-shorthand": true,
"curly": true,
"deprecation": {
"severity": "warning"
},
"eofline": true,
"import-blacklist": [
true,
"rxjs/Rx"
],
"import-spacing": true,
"indent": {
"options": [
"spaces"
]
},
"max-classes-per-file": false,
"max-line-length": [
true,
140
],
"member-ordering": [
true,
{
"order": [
"static-field",
"instance-field",
"static-method",
"instance-method"
]
}
],
"no-console": [
true,
"debug",
"info",
"time",
"timeEnd",
"trace"
],
"no-empty": false,
"no-inferrable-types": [
true,
"ignore-params"
],
"no-non-null-assertion": false,
"no-redundant-jsdoc": true,
"no-switch-case-fall-through": true,
"no-var-requires": false,
"object-literal-key-quotes": [
true,
"as-needed"
],
"quotemark": [
true,
"single"
],
"semicolon": {
"options": [
"always"
]
},
"space-before-function-paren": {
"options": {
"anonymous": "never",
"asyncArrow": "always",
"constructor": "never",
"method": "never",
"named": "never"
}
},
// TODO(gkalpak): Fix the code and enable this.
// "typedef": [
// true,
// "call-signature"
// ],
"typedef-whitespace": {
"options": [
{
"call-signature": "nospace",
"index-signature": "nospace",
"parameter": "nospace",
"property-declaration": "nospace",
"variable-declaration": "nospace"
},
{
"call-signature": "onespace",
"index-signature": "onespace",
"parameter": "onespace",
"property-declaration": "onespace",
"variable-declaration": "onespace"
}
]
},
"variable-name": {
"options": [
"ban-keywords",
"check-format",
"allow-pascal-case"
]
},
"whitespace": {
"options": [
"check-branch",
"check-decl",
"check-operator",
"check-separator",
"check-type",
"check-typecast"
]
},
"component-class-suffix": true,
"contextual-lifecycle": true,
"directive-class-suffix": true,
"no-conflicting-lifecycle": true,
"no-host-metadata-property": true,
"no-input-rename": true,
"no-inputs-metadata-property": true,
"no-output-native": true,
"no-output-on-prefix": true,
"no-output-rename": true,
"no-outputs-metadata-property": true,
"template-banana-in-box": true,
"template-no-negated-async": true,
"use-lifecycle-interface": true,
"use-pipe-transform-interface": true,
"directive-selector": [
true,
"attribute",
["app", "toh"],
"camelCase"
],
"component-selector": [
true,
"element",
// TODO: Fix the code and change the prefix to `"app"` (or whatever makes sense).
"",
"kebab-case"
]
}
}
ServiceWorker
- Angular service worker (SW) follows design goal
- application is cached as one unit, and all files update together
- unning application continues to run with the same version of all files
- when users refresh the application, they see the latest fully cached version, new tabs load the latest cached code
- updates happen in the background, relatively quickly after changes are published, previous version of the application is served until an update is installed and ready
- SW conserves bandwidth when possible, resources are only downloaded when changed
- loads a manifest file from the server which describes the resources to cache and includes hashes of every file contents, when an update to the application is deployed, the contents of the manifest change, informing the SW that a new version of the application should be downloaded and cached, manifest is generated from a CLI-generated configuration file called ngsw-config.json
- this also makes a few services available for injection which interact with the SW and can be used to control it
- src/ngsw-config.json configuration file specifies which files and data URLs the Angular service worker should cache and how it should update the cached files and data
- CLI processes the configuration file during ng build --prod, manually, you can process it with the ngsw-config tool
- all file paths must begin with /, which is the deployment directory—usually dist in CLI projects
- use a limited glob format:
- ** - 0 or more path segments
- * - 0 or more characters excluding /
- ? - exactly one character excluding /
- ! prefix - marks the pattern as being negative, meaning that only files that dont match the pattern will be included
- /**/*.html - all HTML files
- /*.html - only HTML files in the root
- !/**/*.map - exclude all sourcemaps
- section of the configuration file:
- appData - pass any data you want that describes this particular version of the app, SwUpdate service includes that data in the update notifications, many apps use this section to provide additional information for the display of UI popups, notifying users of the available update
- index - specifies the file that serves as the index page to satisfy navigation requests, usually this is /index.html
- assetGroups - assets are resources that are part of the app version that update along with the app, can include resources loaded from the page origin as well as third-party resources loaded from CDNs and other external URLs, as not all such external URLs may be known at build time, URL patterns can be matched
- dataGroups - unlike asset resources, data requests are not versioned along with the app, cached according to manually-configured policies that are more useful for situations such as API requests and other data dependencies
- navigationUrls - optional section, enables you to specify a custom list of URLs that will be redirected to the index file, for example, you may want to ignore specific routes (that are not part of the Angular app) and pass them through to the server, defaults to:
- /** - include all URLs
- !/**/*.* - exclude URLs to files
- !/**/*__* - exclude URLs containing "__" in the last segment
- !/**/*__*/** - exclude URLs containing "__" in any other segment
- if resourcesOutputPath or assets paths are modified after the generation of configuration file, you need to change the paths manually in ngsw-config.json
- change base meta-tag for assets lookup on local domain
- SwUpdate service - access to events that indicate when the SW has discovered an available update for your app or when it has activated such an update - meaning it is now serving content from that update to your app, supports four separate operations:
- getting notified of available updates - new versions of the app to be loaded if the page is refreshed
- getting notified of update activation - SW starts serving a new version of the app immediately
- asking the SW to check the server for new updates
- asking the SW to activate the latest version of the app for the current tab
- notify the user of a pending update or to refresh their pages when the code they are running is out of date
- version - collection of resources that represent a specific build of the Angular app, determined by the contents of the ngsw.json file, which includes hashes for all known content
- to ensure resource integrity, hashes of all resources for which it has a hash are validated, for an app created with the CLI, this is everything in the dist directory covered by the user src/ngsw-config.json configuration
- Angular SW provides a guarantee: a running app will continue to run the same version of the app, if another instance of the app is opened in a new web browser tab, then the most current version of the app is served, as a result, that new tab can be running a different version of the app than the original tab
- guarantee is stronger than that provided by the normal web deployment model, there is no guarantee that code lazily loaded later in a running app is from the same version as the initial code for the app
- SW exposes debugging information under the ngsw/ virtual directory, the single exposed URL is ngsw/state
- to deactivate SW - remove or rename the ngsw.json file, when the SW request for ngsw.json returns a 404, then the SW removes all of its caches and de-registers itself, essentially self-destructing
- small script safety-worker.js, loaded will unregister itself from the browser, comes in @angular/service-worker NPM package
- https://blog.angular-university.io/angular-service-worker/
# add @angular/service-worker package
# enables SW build support in the CLI
# imports and registers the SW in the app module
# updates the index.html file:
# includes a link to add the manifest.json file
# adds meta tags for theme-color
# installs icon files to support the installed PWA
# creates the SW configuration file called ngsw-config.json
ng add @angular/pwa --project my-project-name
# now, build the project:
ng build --prod
# serve local files with npm http-server
http-server -p 8080 -c-1 dist/angular-tour
# try target http-server from node_modules/http-server/bin/http-server
# and run current folder
../../node_modules/.bin/http-server .
# or serve just index to serve correctly routes !
../../node_modules/.bin/http-server index.html
SW
import { ApplicationRef, Injectable } from '@angular/core';
import { SwUpdate } from '@angular/service-worker';
import { concat, interval } from 'rxjs';
import { first } from 'rxjs/operators';
@Injectable()
export class LogUpdateService {
constructor(
appRef: ApplicationRef,
updates: SwUpdate
) {
// --- available and activated updates
updates.available.subscribe(event => {
console.log('current version is', event.current);
console.log('available version is', event.available);
});
updates.activated.subscribe(event => {
console.log('old version was', event.previous);
console.log('new version is', event.current);
});
// --- checking for updates
// allow the app to stabilize first,
// before starting polling for updates with interval().
// Promise which indicates the update check has completed successfully
// does not indicate whether an update was discovered as a result of the check
const appIsStable$ = appRef.isStable.pipe(
first(isStable => isStable === true)
);
const everySixHours$ = interval(6 * 60 * 60 * 1000);
const everySixHoursOnceAppIsStable$ =
concat(appIsStable$, everySixHours$);
everySixHoursOnceAppIsStable$.subscribe(
() => updates.checkForUpdate()
);
// --- forcing update activation immediately
// doing this could break lazy-loading into currently running apps
updates.available.subscribe(event => {
if (promptUser(event)) {
updates.activateUpdate().then(
() => document.location.reload()
);
}
});
}
}
// deactivate the service worker:
// npm i safety-worker
navigator.serviceWorker.register("/safety-worker.js")
// sconst skipWaiting = () => self.skipWaiting();
// sconst unregister = (event) => {
// event.waitUntil(self.clients.claim());
// self.registration.unregister()
// .then(() => console.log('Unregistered old service worker'));
// };
// sself.addEventListener('install', skipWaiting);
// self.addEventListener('activate', unregister);
class SwUpdate {
// true - if the SW is enabled
// (supported by the browser and enabled via ServiceWorkerModule)
isEnabled: boolean
checkForUpdate(): Promise<void>
// whenever the app has been updated to a new version
activateUpdate(): Promise<void>
// whenever a new app version is available
SwUpdate#versionUpdates
}
ngsw-config.json
{
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.html",
"/*.css",
"/*.js"
]
}
}, {
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/assets/**",
"/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)"
]
}
}
]
}
AssetGroup {
// identifies group of assets between versions of the configuration
name: string;
// how these resources are initially cached
// prefetch (default) - fetch every single listed resource
// lazy - on-demand caching mode
installMode?: 'prefetch' | 'lazy';
// caching behavior when a new version of the app is discovered
// prefetch - download and cache the changed resources immediately
// lazy - dont cache resources,
// treats them as unrequested and waits until they
// are requested again before updating them
// updateMode of lazy is only valid if the installMode is also lazy
updateMode?: 'prefetch' | 'lazy';
// resources to cache
resources: {
files?: string[];
// URLs and URL patterns that will be matched at runtime
// will be cached according to their HTTP headers
// negative glob patterns are not supported and ? will be matched literally
// will not match any character other than ?
urls?: string[];
};
}
DataGroup {
// group name which uniquely identifies
name: string;
// URL patterns, will be cached according to this data group policy
urls: string[];
// mechanism to indicate that the resources being cached
// have been updated in a backwards-incompatible way,
// and that the old version cache entries should be discarded
// integer field, defaults to 1
version?: number;
// policy by which matching requests will be cached
cacheConfig: {
// maximum number of entries, or responses, in the cache
maxSize: number;
// how long responses are allowed to remain in the cache
// before being considered invalid and evicted
// duration string, using the following unit suffixes:
// d: days
// h: hours
// m: minutes
// s: seconds
// u: milliseconds
// 3d12h will cache content for up to three and a half days
maxAge: string;
// how long the Angular service worker will wait for the network
// to respond before using a cached response, if configured to do so
// uses same sufixes
timeout?: string;
// strategies for data resources
// performance (default) - optimizes for fast responses
// if resource exists in the cache, cached version is used
// suitable for resources that dont change often
// freshness - optimizes for currency of data,
// preferentially fetching requested data from the network
// only if the network times out, according to timeout,
// does the request fall back to the cache
// useful for resources that change frequently
strategy?: 'freshness' | 'performance';
};
}
SSR
- Angular Universal - technology that renders Angular applications on the server
- here, CLI compiles and bundles the Universal version of the app with the Ahead-of-Time (AoT) compiler, Node Express web server compiles HTML pages with Universal based on client requests
- 1 - create the server-side app module (app.server.module.ts) and other server side files:
ng add @nguniversal/express-engine --clientProject project-name
- 2 - start rendering your app with Universal on your local system:
npm run build:ssr && npm run serve:ssr
- 3 - navigate to http://localhost:4000/
- user events other than routerLink clicks arent supported, wait for the full client app to bootstrap and run, or buffer the events using libraries like preboot, which allow you to replay these events once the client-side scripts load
- Universal applications use the Angular platform-server package (as opposed to platform-browser), which provides server implementations of the DOM, XMLHttpRequest, and other low-level features that dont rely on a browser
- any web server technology can serve a Universal app as long as it can call Universal renderModule() function, which:
- takes as inputs a template HTML page (usually index.html)
- an Angular module containing components
- a route (comes from the client request to the server) that determines which components to display
- each request results in the appropriate view for the requested route
- rendered within the app-tag of the template, creating a finished HTML page for the client
- some of the browser APIs and capabilities may be missing on the server, such as window, document, navigator, or location. Angular provides some injectable abstractions over these objects, such as Location or DOCUMENT; it may substitute adequately for these APIs, if Angular doesnt provide it, its possible to write new abstractions that delegate to the browser APIs while in the browser and to an alternative implementation while on the server (aka shimming), similarly, without mouse or keyboard events, a server-side app cant rely on a user clicking a button to show a component, app must determine what to render based solely on the incoming client request
- you need to change your services to make requests with absolute URLs when running on the server and with relative URLs when running in the browser, one solution is to provide the full URL to your application on the server, and write an interceptor that can retrieve this value and prepend it to the request URL
http interceptor for server requests
// interceptor will fire and replace the
// request URL with the absolute URL provided in the Express Request object
import {Injectable, Inject, Optional} from '@angular/core';
import {HttpInterceptor, HttpHandler, HttpRequest, HttpHeaders} from '@angular/common/http';
import {Request} from 'express';
import {REQUEST} from '@nguniversal/express-engine/tokens';
@Injectable()
export class UniversalInterceptor implements HttpInterceptor {
constructor(@Optional() @Inject(REQUEST) protected request: Request) {}
intercept(req: HttpRequest, next: HttpHandler) {
let serverReq: HttpRequest = req;
if (this.request) {
let newUrl = `${this.request.protocol}://${this.request.get('host')}`;
if (!req.url.startsWith('/')) {
newUrl += '/';
}
newUrl += req.url;
serverReq = req.clone({url: newUrl});
}
return next.handle(serverReq);
}
}
// --- provide interceptor in app.server.module.ts
import {HTTP_INTERCEPTORS} from '@angular/common/http';
import {UniversalInterceptor} from './universal-interceptor';
@NgModule({
...
providers: [{
provide: HTTP_INTERCEPTORS,
useClass: UniversalInterceptor,
multi: true
}],
})
export class AppServerModule {}
Libraries
- to add, use the CLI ng add lib_name command
- library packages often include typings in .d.ts files, if not, install the library associated @types/lib_name package
- types defined in a @types/ package for a library installed into the workspace are automatically added to the TS configuration for the project that uses that library, TS looks for types in the node_modules/@types folder by default
- legacy JavaScript libraries that are not imported into an app can be added to the runtime global scope and loaded as if they were in a script tag, configure the CLI to do this at build time using the "scripts" and "styles" options of the build target in the CLI configuration file, angular.json
- creating libraries
- ng generate library my-lib - new library skeleton, projects/my-lib folder in your workspace
- build, test, and lint the project with CLI: ng build my-lib && ng test my-lib && ng lint my-lib
- public-api.ts - public API file, defines what is available to consumers of your library: NgModules, service providers and general utility functions through a single import path, use an NgModule to expose services and components
- supply documentation (typically a README file)
- declarations such as components and pipes should be designed as stateless, meaning they dont rely on or alter external variables, if you do rely on state, you need to evaluate every case and decide whether it is application state or state that the library would manage
- observables that the components subscribe to internally should be cleaned up and disposed of during the lifecycle of those components
- components should expose their interactions through inputs for providing context, and outputs for communicating events to other components
- to leave the service out of the bundle if it never gets injected into the application that imports the library, services should declare their own providers (rather than declaring providers in the NgModule or a component), so that they are tree-shakable
- if you register global service providers or share providers across multiple NgModules, use the forRoot() and forChild()
- check all internal dependencies: for custom classes or interfaces used in components or service, check whether they depend on additional classes or interfaces or services orother libraries (such a Angular Material, for instance) that also need to be migrated or configured dependencies
- library is packaged into an npm package for publishing and sharing, and this package can also include schematics that provide instructions for generating or transforming code directly in your project or to integrate with the Angular CLI
- include an installation schematic so that ng add can add your library to a project
- include generation schematics in your library so that ng generate can scaffold your defined artifacts (components, services, tests, and so on) in a project
- include an update schematic so that ng update can update your library dependencies and provide migrations for breaking changes in new releases
- publish (with npm account): ng build my-lib, cd dist/my-lib, npm publish
- use npm link to avoid reinstalling the library on every build and make sure that the build step runs in watch mode, and that the library package.json configuration points at the correct entry points (main should point at a JavaScript file, not a TypeScript file,...)
- Angular libraries should list all @angular/* dependencies as peer dependencies (peerDependencies)
- while developing, install all peer dependencies through devDependencies to ensure that the library compiles properly, list all the peer dependencies that your library uses in the TypeScript configuration file ./tsconfig.json, and point them at the local copy in the app node_modules folder
- importing own library after build: import { my-export } from 'my-lib';
- generating a library with the CLI automatically adds its path to the tsconfig file, CLI uses the tsconfig paths to tell the build system where to find the library
- ng build my-lib --watch - incremental builds as a backround process for dev environment while library is not published yet but used
- https://update.angular.io/
# install library and its types
npm install d3 --save
npm install @types/d3 --save-dev
# update individual library versions
ng update lib_name
manually adding typings
// 1 - create a typings.d.ts file in your src/ folder
// is automatically included as global type definition
// 2
declare module 'host' {
export interface Host {
protocol?: string;
hostname?: string;
pathname?: string;
}
export function parse(url: string, queryString?: string): Host;
}
// 3 - in the component or file that uses the library
import * as host from 'host';
const parsedUrl = host.parse('https://angular.io');
console.log(parsedUrl.hostname);
adding a library to the runtime global scope, Bootstrap 4
npm install jquery --save
npm install popper.js --save
npm install bootstrap --save
// angular.json
"scripts": [
"node_modules/jquery/dist/jquery.slim.js",
"node_modules/popper.js/dist/umd/popper.js",
"node_modules/bootstrap/dist/js/bootstrap.js"
],
...
"styles": [
"node_modules/bootstrap/dist/css/bootstrap.css",
"src/styles.css"
],
// once you import a library using the "scripts" array,
// you should not import it using an import statement
// in your TS code (such as import * as $ from 'jquery';)
// instead, download typings for your library
// npm install @types/jquery and others
// ... run or restart ng serve
// if the global library you need to use
// does not have global typings,
// declare them manually as any in src/typings.d.ts
declare var libraryName: any;
// some scripts extend other libraries;
// for instance with JQuery plugins
$('.test').myPlugin();
// installed @types/jquery doesn't include myPlugin,
// so you need to add an interface in src/typings.d.ts
interface JQuery {
myPlugin(options?: any): any;
}
creating libraries
// list all the peer dependencies that your library uses
// in the TS configuration file ./tsconfig.json
// this mapping ensures that your library
// always loads the local copies of the modules it needs.
{
"compilerOptions": {
// ...
// paths are relative to `baseUrl` path.
"paths": {
"@angular/*": [
"../node_modules/@angular/*"
]
}
}
}
Workspace/Project
- apps are developed in the context of an Angular workspace
- workspace contains the files for one or more projects
- project is the set of files that comprise a standalone app, a library, or a set of end-to-end (e2e) tests
- angular.json at the root level of an Angular workspace provides workspace-wide and project-specific configuration defaults for build and development tools provided by the CLI, path values given in the configuration are relative to the root workspace folder
WORKSPACE CONFIG FILES
.editorconfig |
configuration for code editors |
.gitignore |
untracked files that Git should ignore |
angular.json |
CLI configuration defaults for all projects in the workspace, including configuration options for build, serve, and test tools that the CLI uses, such as TSLint, Karma, and Protractor |
node_modules |
provides npm packages to the entire workspace |
package.json |
configures npm package dependencies that are available to all projects in the workspace |
package-lock.json | yarn.lock |
provides version information for all packages installed into node_modules by the npm client |
tsconfig.json |
default TS configuration for apps in the workspace, including TypeScript and Angular template compiler options |
tslint.json |
default TSLint configuration for apps in the workspace |
README.md |
introductory documentation |
WORKSPACE CONFIGURATION (angular.json)
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
// configuration-file version
"version": 1,
// path where new projects are created, bsolute or relative to the workspace folder
"newProjectRoot": "projects",
// subsection for each app, e2e app, and library individual configuration options
"projects": {
"angular-tour": {
// root folder for files, relative to the workspace folder
"root": "",
// root folder for source files
"sourceRoot": "src",
// application | library
// application can run independently in a browser, library cannot
// both an app and its e2e test app are of type "application"
"projectType": "application",
// string that Angular prepends to generated selectors
// can be customized to identify an app or feature area
"prefix": "app",
// object containing schematics that customize CLI commands
"schematics": {},
// configuration defaults for Architect builder targets
// tool that the CLI uses to perform complex tasks
// can be run using the "ng run" command, and define your own targets
"architect": {
// target, which is the npm package for the tool that Architect runs
// defaults for options of the "ng build" command
"build": {
// npm package for the build tool used to create this target
// default is @angular-devkit/build-angular:browser
// which uses the webpack package bundler
"builder": "@angular-devkit/build-angular:browser",
// default options for the target
// used when no named alternative configuration is specified
"options": {
"outputPath": "dist/browser",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.app.json",
// object containing paths to static assets to add to the global context
// of the project, default paths point to the project icon file and its assets folder
"assets": [ "src/favicon.ico", "src/assets", "src/manifest.webmanifest" ],
"styles": [ "src/styles.css" ],
// option-value pairs to pass to style preprocessors
stylePreprocessorOptions: { },
// JS files to add to the global context of the project
// loaded exactly as if you had added them in a script-tag inside index.html
"scripts": [
{
"input": "node_modules/document-register-element/build/document-register-element.js"
}
],
// default size-budget type and threshholds for all or parts of your app
"budgets": [ ],
},
// section with named configuration
// sets the default options for that intended environment
"configurations": {
"production": {
// files and their compile-time replacements
// default environment with production version
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"extractCss": true,
"namedChunks": false,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
}
],
"serviceWorker": true
},
// locale-specific configuration
"ru": {
"outputPath": "dist/angular-tour-ru/",
"i18nFile": "src/locale/messages.ru.xlf",
"i18nFormat": "xlf",
"i18nLocale": "ru",
"i18nMissingTranslation": "error"
}
}
},
// override build defaults and supplies additional
// serve defaults for the "ng serve" command
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": { "browserTarget": "angular-tour:build" },
"configurations": {
"production": {
"browserTarget": "angular-tour:build:production"
},
"ru": { "browserTarget": "angular-tour:build:ru" }
}
},
// override build-option defaults
// for building end-to-end testing apps using the "ng e2e" command
"e2e" : { },
// overrides build-option defaults for test builds and supplies
// additional test-running defaults for the "ng test" command
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.spec.json",
"karmaConfig": "src/karma.conf.js",
"styles": [ "src/styles.css" ],
"scripts": [],
"assets": [ "src/favicon.ico", "src/assets", "src/manifest.webmanifest" ]
}
},
// configure defaults for options of the ng-xi18n tool used by the "ng xi18n" command
// which extracts marked message strings from source code and outputs translation files
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "angular-tour:build"
}
},
// configure defaults for options of the "ng lint" command
// which performs code analysis on project source files
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [ "src/tsconfig.app.json", "src/tsconfig.spec.json" ],
"exclude": [ "**/node_modules/**" ]
}
},
// defaults for creating a Universal app with server-side rendering
// using the "ng run project-name:server" command
"server": {
"builder": "@angular-devkit/build-angular:server",
"options": {
"outputPath": "dist/server",
"main": "src/main.server.ts",
"tsConfig": "src/tsconfig.server.json"
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
]
}
}
},
// defaults for creating an app shell for a PWA
// using the "ng run project-name:app-shell" command
"app-shell": { }
} },
"angular-tour-e2e": {
"root": "e2e/",
"projectType": "application",
"prefix": "",
"architect": {
"e2e": {
"builder": "@angular-devkit/build-angular:protractor",
"options": {
"protractorConfig": "e2e/protractor.conf.js",
"devServerTarget": "angular-tour:serve"
},
"configurations": {
"production": {
"devServerTarget": "angular-tour:serve:production"
}
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": "e2e/tsconfig.e2e.json",
"exclude": [ "**/node_modules/**" ]
} } } } },
// default project name to use in commands
"defaultProject": "angular-tour"
}
APP SOURCE (/src) & CONFIG FILES
app/ |
contains the component files in which your app logic and data are defined |
assets/ |
contains image files and other asset files to be copied as-is when you build your application |
environments/ |
contains build configuration options for particular target environments, by default there is an unnamed standard development environment and a production ("prod") environment, you can define additional target environment configurations |
browserslist |
configures sharing of target browsers and Node.js versions among various front-end tools |
favicon.ico |
icon to use for this app in the bookmark bar |
index.html |
main HTML page that is served when someone visits your site, CLI automatically adds all JavaScript and CSS files when building your app, so you typically dont need to add any <script> or<link> tags here manually |
main.ts |
main entry point for your app, compiles the application with the JIT compiler and bootstraps the application root module (AppModule) to run in the browser |
polyfills.ts |
polyfill scripts for browser support |
styles.sass |
CSS files that supply styles for a project, extension reflects the style preprocessor you have configured for the project |
test.ts |
main entry point for your unit tests, with some Angular-specific configuration, you dont typically need to edit this file |
tsconfig.app.json |
inherits from the workspace-wide tsconfig.json file |
tsconfig.spec.json |
inherits from the workspace-wide tsconfig.json file |
tslint.json |
inherits from the workspace-wide tslint.json file |
APP SOURCE FILES
app/app.component.ts |
defines the logic for the app root component, named AppComponent ,view associated with this root component becomes the root of the view hierarchy as you add components and services to your app |
app/app.component.html |
defines the HTML template associated with the root AppComponent |
app/app.component.css |
defines the base CSS stylesheet for the root AppComponent |
app/app.component.spec.ts |
defines a unit test for the root AppComponent |
app/app.module.ts |
defines the root module, named AppModule , that tells Angular how to assemble the application. Initially declares only the AppComponent . As you add more components to the app, they must be declared here |
assets/* |
contains image files and other asset files to be copied as-is when you build your application |
# project folders for additional apps and libraries
# newly generated libraries are also added under projects/.
my-app/
...
projects/ (additional apps and libs)
my-other-app/ (a second app)
src/
(config files)
my-other-app-e2e/ (corresponding test app)
src/
(config files)
my-lib/ (a generated library)
(config files)
# default app project end-to-end test files
# workspace-wide node_modules dependencies are visible to this project
my-app/
e2e/ (end-to-end test app for my-app)
src/ (app source files)
protractor.conf.js (test-tool config)
tsconfig.e2e.json (TS config inherits from workspace tsconfig.json)
npm/packages
- Angular Framework, Angular CLI, and components used by Angular applications are packaged as npm packages and distributed via the npm registry
- npm and yarn install the packages that are identified in a package.json created with "ng new", used by all projects in the workspace, organized into two groups of packages:
- dependencies - essential to running applications
- Angular packages - core and optional modules, names begin @angular/
- @angular/animations - Angular animations library makes it easy to define and apply animation effects such as page and list transitions
- @angular/common - commonly-needed services, pipes, and directives provided by the Angular team, HttpClientModule is also here, in the @angular/common/http subfolder
- @angular/compiler - template compiler, understands templates and can convert them to code that makes the application run and render, typically you dont interact with the compiler directly; rather, you use it indirectly via platform-browser-dynamic when JIT compiling in the browser
- @angular/core - critical runtime parts of the framework that are needed by every application, includes all metadata decorators, Component, Directive, dependency injection, and the component lifecycle hooks
- @angular/forms - support for both template-driven and reactive forms
- @angular/platform-browser - everything DOM and browser related, especially the pieces that help render into the DOM, also includes the bootstrapModuleFactory() method for bootstrapping applications for production builds that pre-compile with AOT
- @angular/platform-browser-dynamic - includes providers and methods to compile and run the app on the client using the JIT compiler
- @angular/router - router module navigates among your app pages when the browser URL changes
- Support packages - 3rd party libraries that must be present for Angular apps to run
- rxjs - implementation of the proposed Observables specification currently before the TC39 committee, which determines standards for the JS language
- zone.js - Angular relies on zone.js to run change detection processes when native JavaScript operations raise events, implementation of a specification currently before the TC39 committee that determines standards for the JavaScript language
- Polyfill packages - polyfills plug gaps in a browser JavaScript implementation, emulate the missing browser features
- devDependencies - are only necessary to develop applications, defaults:
- @angular-devkit/build-angular - Angular build tools
- @angular/cli - CLI tools
- @angular/compiler-cli - Angular compiler, which is invoked by the Angular CLI "ng build" and "ng serve" commands
- @angular/language-service - language service analyzes component templates and provides type and error information that TS-aware editors can use to improve the developer experience
- @types/... - TS definition files for 3rd party libraries such as Jasmine and Node.js
- codelyzer - linter for Angular apps whose rules conform to the Angular style guide
- jasmine/... - packages to support the Jasmine test library
- karma/... - packages to support the karma test runner
- protractor - end-to-end (e2e) framework for Angular apps, built on top of WebDriverJS
- ts-node -TS execution environment and REPL for Node.js
- tslint - static analysis tool that checks TS code for readability, maintainability, and functionality errors
- typescript - TS language server, including the tsc TypeScript compiler
- to add a new dependency, use the "ng add" command
- to add a new devDependency, use the "npm install --dev package-name" command
Compilation
- CLI apps compile in AOT mode by default
- Ahead-of-Time (AOT) - compiles app at build time and CLI commands (default)
- Just-in-Time (JIT)
ng build --prod
compiles with AOT by default
- AOT
- faster rendering - browser downloads a pre-compiled version of the application
- fewer asynchronous requests - compiler inlines external HTML templates and CSS style sheets within the application JS
- detect template errors earlier - compiler detects and reports template binding errors during the build step before users can see them
- better security - AOT compiles HTML templates and components into JS files long before they are served to the client, fewer opportunities for injection attacks with no templates to read and no risky client-side HTML or JS evaluation
- AOT compilation control
- 1 - template compiler options in the tsconfig.json
- 2 - specifying Angular metadata, tells how to construct instances of application classes and interact with them at runtime, AOT compiler extracts metadata (once and generates a factory) to interpret the parts of the application: @Component(), @Input(), ...
- how AOT works
- 1 - analysis (AOT collector)
- AOT collector analyzes the metadata recorded in the Angular decorators and outputs metadata information in .metadata.json files, one per .d.ts file
- emits the .d.ts type definition files with type information for application code generation
- .metadata.json - diagram of the overall structure of a decorator metadata, represented as an abstract syntax tree (AST)
- collector only understands a subset of JavaScript:
- literal object - {cherry: true, apple: true, mincemeat: false}
- literal array - ['cherries', 'flour', 'sugar']
- spread in literal array - ['apples', 'flour', ...the_rest]
- calls - bake(ingredients)
- new - new Oven()
- property access - pie.slice
- array index - ingredients[0]
- identity reference - Component
- template string - `pie is ${multiplier} times better than cake`
- literal string - pi
- literal number - 3.14153265
- literal boolean - true
- literal null - null
- supported prefix operator - !cake
- supported binary operator - a+b
- conditional operator - a ? b : c
- parentheses - (a+b)
- if an expression uses unsupported syntax collector writes an error node to the .metadata.json file and later reports the error if it needs that piece of metadata to generate the application code, set the strictMetadataEmit:true in tsconfig to report syntax errors immediately
- AOT compiler does not support function expressions and arrow functions, also called lambda functions, to turn the arrow function into an exported function
- folding - gathering/evaluating multiple values to generate one, collector can evaluate references to module-local const declarations and initialized var and let declarations, removing them from the .metadata.json file, foldable:
- literal object|array|string|number|boolean|null - YES
- spread in literal array | calls | new - NO
- property access - YES, if target is foldable
- array index - YES, if target and index are foldable
- identity reference - YES, if it is a reference to a local
- template with no substitutions - YES
- template with substitutions - YES, if the substitutions are foldable
- supported prefix operator - YES, if operand is foldable
- supported binary operator - YES, if both left and right are foldable
- conditional operator - YES, if condition is foldable
- parentheses - YES, if the expression is foldable
- if an expression is not foldable collector writes it to .metadata.json as an AST for the compiler to resolve
- 2 - code generation (AOT compiler)
- compiler job is to interpret the .metadata.json in the code generation phase
- may reject syntactically correct metadata if the semantics violate compiler rules
- compiler can only reference exported symbols
- decorated component class members must be public
- data bound properties must also be public
- compiler only generates code to create instances of certain classes, support certain decorators, and call certain functions from the following lists, from @angular/core: Attribute, Component, ContentChild, ContentChildren, Directive, Host, HostBinding, HostListner, Inject, Injectable, Input, NgModule, Optional, Output, Pipe, Self, SkipSelf, ViewChild
- compiler also supports macros in the form of functions or static methods that return an expression
- 3 - template type checking
- use TS compiler to validate the binding expressions in templates
- disable type checking using $any(): {{$any(person).addresss.street}}
- enable this phase explicitly by adding the compiler option "fullTemplateTypeCheck"|"strictTemplates" in the "angularCompilerOptions" of the project tsconfig.json
- BASIC MODE fullTemplateTypeCheck: false - validates only top-level expressions in a template, ignores *ngIf, *ngFor, other ng-template embedded view, doesn't figure out the types of #refs, results of pipes, or type of $event in event bindings
- FULL MODE, fullTemplateTypeCheck: true - more aggressive type-checking within templates
- STRICT MODE, strictTemplates: true - superset of full mode
- similar to TS Compiler, Angular Compiler also supports extends in the tsconfig.json on angularCompilerOptions, configuration from the base file are loaded first, then overridden by those in the inheriting config file, extends is a top level property parallel to compilerOptions and angularCompilerOptions:
{
"extends": "../tsconfig.json",
"compilerOptions": {
"experimentalDecorators": true,
...
},
"angularCompilerOptions": {
"fullTemplateTypeCheck": true,
"preserveWhitespaces": true,
...
}
}
- Angular template compiler options, members of the "angularCompilerOptions" object in the tsconfig.json
- compilationMode - 'full' - generates fully AOT-compiled code (default), 'partial' - generates code in a stable, but intermediate form suitable for a published library
- fullTemplateTypeCheck - when true (recommended), enables the binding expression validation phase of the template compiler, which uses TS to validate binding expressions, default is false, but when you use the CLI command
ng new --strict
, it is set to true in the generated project configuration
- strictTemplates - enables strict template type checking, only available when using Ivy, additional strictness flags allow you to enable and disable specific types of strict template type checking, when you use the CLI command
ng new --strict
, it is set to true in the generated project configuration
- enableResourceInlining - replace the templateUrl and styleUrls property in all @Component decorators with inlined contents in template and styles properties, .js output of ngc will have no lazy-loaded templateUrl or styleUrls
- skipMetadataEmit - tells the compiler not to produce .metadata.json files (false by default), set to true if you are using TS --outFile option, because the metadata files are not valid for this style of TS output, it is not recommended to use --outFile with Angular, use a bundler, such as webpack, instead
- strictMetadataEmit - report an error to the .metadata.json, should only be used when "skipMetadataEmit" is false and "skipTemplateCodeGen" is true
- skipTemplateCodegen - suppress emitting .ngfactory.js and .ngstyle.js files, turns off most of the template compiler and disables reporting template diagnostics, can be used to instruct the template compiler to produce .metadata.json files for distribution with an npm package while avoiding the production of .ngfactory.js and .ngstyle.js files that cannot be distributed to npm
- strictInjectionParameters - when true, tells the compiler to report an error for a parameter supplied whose injection type cannot be determined
- flatModuleOutFile - when true, tells the template compiler to generate a flat module index of the given file name and the corresponding flat module metadata, use when creating flat modules that are packaged similarly to @angular/core and @angular/common, the package.json for the library should refer to the generated flat module index instead of the library index file, only one .metadata.json file is produced, which contains all the metadata necessary for symbols exported from the library index. In the generated .ngfactory.js files, the flat module index is used to import symbols that includes both the public API from the library index as well as shrowded internal symbols. By default the .ts file supplied in the files field is assumed to be the library index, if more than one .ts file is specified, libraryIndex is used to select the file to use, if more than one .ts file is supplied without a libraryIndex, an error is produced, flat module index .d.ts and .js will be created with the given flatModuleOutFile name in the same location as the library index .d.ts file. For example, if a library uses the public_api.ts file as the library index of the module, the tsconfig.json files field would be ["public_api.ts"]. The flatModuleOutFile options could then be set to, for example "index.js", which produces index.d.ts and index.metadata.json files, library package.json module field would be "index.js" and the typings field would be "index.d.ts"
- flatModuleId - specifies the preferred module id to use for importing a flat module, only meaningful when flatModuleOutFile is also supplied
- generateCodeForLibraries - tells the compiler to enable the binding expression validation phase of the template compiler
- annotateForClosureCompiler - use Tsickle to annotate the emitted JavaScript with JSDoc comments needed by the Closure Compiler
- annotationsAs - modify how the Angular specific annotations are emitted to improve tree-shaking: decorators (leave the decorators in place, makes compilation faster) | static fields (default, replace decorators with a static field in the class)
- trace - print extra information while compiling templates
- enableLegacyTemplate - enables the use of the deprecated template-element
- disableExpressionLowering - disables metadata rewriting, requiring the rewriting to be done manually
- disableTypeScriptVersionCheck - tells the compiler not to check the TS version
- preserveWhitespaces - whether to remove blank text nodes from compiled templates
- allowEmptyCodegenFiles - generate all the possible generated files even if they are empty, used by the Bazel build rules and is needed to simplify how Bazel rules track file dependencies, not recommended to use this option outside of the Bazel rules
- enableI18nLegacyMessageIdFormat - instructs the template compiler to generate legacy ids for messages that are tagged in templates by the i18n attribute, set this option to false unless your project relies upon translations that were previously generated using legacy ids, default - true
- Unless otherwise noted, each option below is set to the value for strictTemplates (true when strictTemplates is true and vice versa)
- fall back to full mode by disabling strictTemplates, if that does not work, an option of last resort is to turn off full mode entirely with fullTemplateTypeCheck: false
- strictInputTypes - whether the assignability of a binding expression to the @Input() field is checked, affects the inference of directive generic types
- strictInputAccessModifiers - whether access modifiers such as private/protected/readonly are honored when assigning a binding expression to an @Input(), if disabled, the access modifiers of the @Input are ignored; only the type is checked, is false by default, even with strictTemplates set to true
- strictNullInputTypes - whether strictNullChecks is honored when checking @Input() bindings (per strictInputTypes), when off, can be useful when using a library that was not built with strictNullChecks in mind
- strictAttributeTypes - whether to check @Input() bindings that are made using text attributes (for example, <mat-tab label="Step 1"> vs <mat-tab [label]="'Step 1'">)
- strictSafeNavigationTypes - whether the return type of safe navigation operations (for example, user?.name) will be correctly inferred based on the type of user)., if disabled, user?.name will be of type any
- strictDomLocalRefTypes - whether local references to DOM elements will have the correct type, if disabled ref will be of type any for <input #ref>
- strictOutputEventTypes - whether $event will have the correct type for event bindings to component/directive an @Output(), or to animation events, if disabled, it will be any
- strictDomEventTypes - whether $event will have the correct type for event bindings to DOM events, if disabled, it will be any
- strictContextGenerics - whether the type parameters of generic components will be inferred correctly (including any generic bounds), if disabled, any type parameters will be any
- strictLiteralTypes - whether object and array literals declared in the template will have their type inferred, if disabled, the type of such literals will be any. This flag is true when either fullTemplateTypeCheck or strictTemplates is set to true
// --- compiler does not support function expressions and arrow functions
// use exported value
export function serverFactory() {
return new Server();
}
@Component({
...
providers: [{provide: server, useFactory: serverFactory}]
})
// --- folding
const template = '<div>{{hero.name}}</div>';
@Component({
selector: 'app-hero',
// template: template // leads to error !
template: template + '<div>{{hero.title}}</div>' // ok
})
export class HeroComponent {
@Input() hero: Hero;
}
// --- metadata rewriting
// compiler converts the expression initializing
// one of the useClass|useValue|useFactory
// into an exported variable, which replaces the expression.
class TypicalServer { }
export const ɵ0 = () => new TypicalServer();
@NgModule({
providers: [{provide: SERVER, useFactory: ɵ0}]
})
export class TypicalModule {}
// --- BAD CODE - title is private
@Component({
selector: 'app-root',
template: '<h1>{{title}}</h1>'
})
export class AppComponent {
private title = 'My App'; // Bad
}
// --- TS compiler infers that binding expression will never be undefined
@Component({
selector: 'my-component',
template: '<span *ngIf="person"> {{person.addresss.street}} </span>'
})
class MyComponent {
person?: Person;
}
// --- disabling type checking using $any()
@Component({
selector: 'my-component',
template: '{{$any(person).addresss.street}}'
})
class MyComponent {
person?: Person;
}
metadata errors
// --- EXPRESSION FORM NOT SUPPORTED
// language features outside of the compiler restricted expression syntax
export class Fooish { ... }
...
const prop = typeof Fooish; // typeof is not valid in metadata
...
// bracket notation is not valid in metadata
{ provide: 'token', useValue: { [prop]: 'value' } };
...
// you can use typeof and bracket notation in normal application code
// --- REFERENCE TO A LOCAL (NON-EXPORTED) SYMBOL
// defined symbol that either wasnt exported or wasnt initialized
let foo: number; // neither exported nor initialized
// let foo = 42; // initialized !
// export let foo: number; // exported !
export let someTemplate: string; // exported but not initialized
@Component({
selector: 'my-component',
template: someTemplate,
providers: [
{ provide: Foo, useValue: foo }
]
})
export class MyComponent {}
// --- ONLY INITIALIZED VARIABLES AND CONSTANTS
// reference to an exported variable or static field that wasnt initialized
// --- REFERENCE TO A NON-EXPORTED CLASS
// --- REFERENCE TO A NON-EXPORTED FUNCTION
// metadata referenced a class/function that wasnt exported
// --- FUNCTION CALLS ARE NOT SUPPORTED
// export a function from the module and refer to the function
// --- DESTRUCTURED VARIABLE OR CONSTANT NOT SUPPORTED
import { configuration } from './configuration';
// destructured assignment to foo and bar
const {foo, bar} = configuration;
...
// wrong
providers: [
{provide: Foo, useValue: foo},
{provide: Bar, useValue: bar},
]
// right
// providers: [
// {provide: Foo, useValue: configuration.foo},
// {provide: Bar, useValue: configuration.bar},
// ]
...
// --- COULD NOT RESOLVE TYPE
// cant determine which module exports type.
// do not refer to ambient types in metadata expressions
@Component({ })
export class MyComponent {
constructor (private win: Window) { ... }
}
// inject an instance of an ambient type:
// create an injection token for an instance of the ambient type
// create a factory function that returns that instance
// add a useFactory provider with that factory function
// use @Inject to inject the instance
import { Inject } from '@angular/core';
import { DOCUMENT } from '@angular/platform-browser';
export const WINDOW = new InjectionToken('Window');
export function _window() { return window; }
@Component({
...
providers: [ { provide: WINDOW, useFactory: _window } ]
})
export class MyComponent {
constructor (
@Inject(WINDOW) private win: Window,
@Inject(DOCUMENT) private doc: Document
) { ... }
}
// --- NAME EXPECTED
// compiler expected a name in an expression it was evaluating
// happens if you use a number as a property name
provider: [{ provide: Foo, useValue: { 0: 'test' } }]
// provider: [{ provide: Foo, useValue: { '0': 'test' } }]
// --- UNSUPPORTED ENUM MEMBER NAME
enum Colors {
Red = 1,
White,
Blue = "Blue".length // computed = complex
}
...
providers: [
{ provide: BaseColor, useValue: Colors.White } // ok
{ provide: DangerColor, useValue: Colors.Red } // ok
{ provide: StrongColor, useValue: Colors.Blue } // bad
]
...
// --- TAGGED TEMPLATE EXPRESSIONS ARE NOT SUPPORTED
// compiler encountered a ES6 tagged template expression
const expression = 'funky';
const raw = String.raw`A tagged template ${expression} string`;
...
template: '<div>' + raw + '</div>'
...
// --- SYMBOL REFERENCE EXPECTED
// dont use expression in the "extends" clause of a class
build/serve
- you can define different named build configurations for your project, such as stage and production, each can have defaults for any of the options that apply to the various build targets, such as build, serve, and test
- src/environments/ - contains the base configuration file, environment.ts, which provides a default environment when no environment is specified
- environment.prod.ts - default values for the production build, replace prod to any other environment name
- use the environment configurations you have defined, your components must import the original environments file: import { environment } from './../environments/environment'; - ensures that the build and serve commands can find the configurations for specific build targets
- angular.json contains a fileReplacements used to replace default environment file with specific one, then, ng build --prod AND ng build --configuration=production will match same config
- extend angular.json serve:configurations section for targeted build configuration project-name:build:production
- configure size budgets in budgets section of angular.json for each configured environment to ensure that parts of your application stay within size boundaries that you define, properties:
- type
- bundle - size of a specific bundle
- initial - initial size of the app
- allScript - size of all scripts
- all - size of the entire app
- anyScript - size of any one script
- any - size of any file
- name - name of the bundle (for `type=bundle`)
- baseline - baseline size for comparison
- maximumWarning - maximum threshold for warning relative to the baseline
- maximumError - maximum threshold for error relative to the baseline
- minimumWarning - minimum threshold for warning relative to the baseline
- minimumError - minimum threshold for error relative to the baseline
- warning - threshold for warning relative to the baseline (min & max)
- error - threshold for error relative to the baseline (min & max)
- size values formats: 23|123b, 123kb, 123mb, 12%
- browser compatibility
- CLI uses Autoprefixer to ensure compatibility with different browser and browser versions to target or exclude certain browser versions from your build
- adding a
browserslist
property to the package.json
- alternatively, add a new file, .browserslistrc, to the project directory, that specifies browsers you want to support
- enable or add polyfills through the src/polyfills.ts by installing the npm package (
npm install --save web-animations-js
) and importing package file (import 'web-animations-js';
), add your polyfill scripts directly to the host web page (index.html) if you are not using the CLI
- use Angular Language Service for autocompletion, error checking, navigation
- in a project - "npm install --save-dev @angular/language-service" and dd the following to the "compilerOptions" section of your project tsconfig.json: "plugins": [ {"name": "@angular/language-service"} ]
- Visual Studio Code - via extensions or store
- WebStorm - install the language service as a dev dependency in package.json: devDependencies { "@angular/language-service": "^6.0.0" }, and "npm install"or "yarn" or "yarn install"
- Sublime Text - "npm install --save-dev typescript" then "npm install --save-dev @angular/language-service", next in your user preferences (Cmd+, or Ctrl+,), add: "typescript-tsdk": "path/to/your/folder/node_modules/typescript/lib"
- proxying to a backend server - use the proxying support in the webpack dev server to divert certain URLs to a backend server, by passing a file to the --proxy-config build option
- for example, to divert all calls for http://localhost:4200/api to a server running on http://localhost:3000/api
- 1 - create a file proxy.conf.json in the projects src/ folder, next to package.json
- 2 - add the content to the new proxy file:
{ "/api": { "target": "http://localhost:3000", "secure": false } }
- 3 - add the
proxyConfig
option to the serve
target in angular.json
- 4 - to run the dev server with this proxy configuration, call ng serve
- if you edit the proxy configuration file, you must relaunch the ng serve process
// environment.ts
export const environment = {
production: false,
apiUrl: 'http://my-api-url'
};
// environment.prod.ts
export const environment = {
production: true,
apiUrl: 'http://my-prod-url'
};
import { Component } from '@angular/core';
import { environment } from './../environments/environment';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
constructor() {
console.log(environment.production); // Logs false for default environment
}
title = 'app works!';
}
// package.json - PWA browsers list
{
"name": "angular-tour",
"version": "0.0.0",
"browserslist": [
"last 2 versions",
"not ie <= 10",
"not ie_mob <= 10"
],
...
proxying to a backend server
// --- proxy.conf.json
{
"/api": {
"target": "http://localhost:3000",
"secure": false,
// remove "api" from the end of a path
"pathRewrite": {
"^/api": ""
},
// access a backend that is not on localhost
"changeOrigin": true,
// determine whether your proxy is working as intended
"logLevel": "debug" // info (the default) | debug | warn | error | silent
}
}
// --- proxy.conf.js
// proxy multiple entries to the same target
const PROXY_CONFIG = [
{
context: [
"/my",
"/many",
"/endpoints",
"/i",
"/need",
"/to",
"/proxy"
],
target: "http://localhost:3000",
secure: false
}
]
module.exports = PROXY_CONFIG;
// bypass the proxy, or dynamically change the request before it is sent
const PROXY_CONFIG = {
"/api/proxy": {
"target": "http://localhost:3000",
"secure": false,
"bypass": function (req, res, proxyOptions) {
if (req.headers.accept.indexOf("html") !== -1) {
console.log("Skipping proxy for browser request.");
return "/index.html";
}
req.headers["X-Custom-Header"] = "yes";
}
}
}
module.exports = PROXY_CONFIG;
// using corporate proxy
// configure the backend proxy to redirect calls through your corporate proxy using an agent:
// npm install --save-dev https-proxy-agent
// when you define an environment variable http_proxy or HTTP_PROXY,
// an agent is automatically added to pass calls through your corporate proxy
// when running npm start
var HttpsProxyAgent = require('https-proxy-agent');
var proxyConfig = [{
context: '/api',
target: 'http://your-remote-server.com:3000',
secure: false
}];
function setupForCorporateProxy(proxyConfig) {
var proxyServer = process.env.http_proxy || process.env.HTTP_PROXY;
if (proxyServer) {
var agent = new HttpsProxyAgent(proxyServer);
console.log('Using corporate proxy server: ' + proxyServer);
proxyConfig.forEach(function(entry) {
entry.agent = agent;
});
}
return proxyConfig;
}
module.exports = setupForCorporateProxy(proxyConfig);
// --- angular.json
...
"architect": {
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "your-application-name:build",
"proxyConfig": "src/proxy.conf.json"
// "proxyConfig": "src/proxy.conf.js"
},
...
// --- call "ng serve"
Testing
- CLI constructs the full runtime configuration in memory, based on application structure specified in the angular.json file, supplemented by karma.conf.js
- .spec.ts - extension which identifies as a file with tests, set in test.ts as target to identify test files
- project created with CLI is immediately ready to test, run the
ng test
command which builds the app in watch mode (watching for changes while running), and launches the Karma test runner
- click on a test row to re-run just that test or click on a description to re-run the tests in the selected test group ("test suite")
- jasmine - tests framework
- karma - task runner, uses a configuration file in order to set the startup file, the reporters, the testing framework, the browser among other things
- karma.conf.js - config file
- test.ts - entry point
- TestBed - unit testing tool provided by angular, creates a dynamically-constructed Angular test module that emulates an Angular @NgModule, any module, component or service that your tested component needs have to be included in the test bed
- TestBed.configureTestingModule() method takes a metadata object that can have most of the properties of an @NgModule
- TestBed.createComponent() - creates an instance of the component, adds a corresponding element to the test-runner DOM, and returns a ComponentFixture for interacting with the created component and its corresponding element, CLI creates an initial test for created component, do not re-configure TestBed after calling createComponent, createComponent method freezes the current TestBed definition, closing it to further configuration
- TestBed.compileComponents() - for tests in a non-CLI environment on component with external files, method asynchronously compiles all components configured in the testing module, calls XHR to read external template and css files during "just-in-time" compilation, only call when necessary, use waitForAsync() utility and divide the setup logic into two separate beforeEach() functions:
- async beforeEach() that compiles the components
- synchronous beforeEach() that performs the remaining setup
- TestBed.configureTestingModule() - returns the TestBed class so you can chain calls to other TestBed static methods such as compileComponents()
- make compileComponents() the last step before calling TestBed.createComponent()
- TestBed.inject(UserService) - only works when Angular injects the component with the service instance in the test root injector
- TestBed.overrideComponent(component, {set|add|remove:{...}}}) - replace the component providers with easy-to-manage test doubles or fake services features when they have a parent one, takes the component type to override (HeroDetailComponent) and an override metadata object:
TestBed.overrideComponent( HeroDetailComponent,
{ set:{providers: [{ provide:HeroDetailService, useClass:HeroDetailServiceSpy }]}})
- override other parts with overrideDirective, overrideModule, and overridePipe
- ComponentFixture.nativeElement has the "any" type, you can use the standard HTML querySelector to dive deeper into the element tree, element may not exist if app is running on a non-browser platform
- ComponentFixture.debugElement - abstraction to work safely across all supported platforms, Angular creates a DebugElement tree that wraps the native elements for the runtime platform, nativeElement property unwraps the DebugElement and returns the platform-specific element object, import debugElement symbol from @angular/core
- fixture.debugElement.nativeElement == fixture.nativeElement
- By class - adds predicate function that returns true when a node in the DebugElement tree matches the selection criteria, imported from @angular/platform-browser
- debugElement.query(By.all()) - all elements
- debugElement.query(By.css('[attribute]')) - elements by the given CSS selector
- debugElement.query(By.directive(MyDirective)) - elements that have the given directive present
- By.css approach may be overkill, often is easier and more clear to filter with a standard HTMLElement method such as querySelector() or querySelectorAll()
- ComponentFixture.detectChanges() - tell the TestBed to perform data binding, only then elements will have binded value, gives an opportunity to inspect and change the state of the component before Angular initiates data binding and calls lifecycle hooks
- configure TestBed with the ComponentFixtureAutoDetect provider to run change detection automatically, import from @angular/core/testing
- Angular doesnt know that you set the input element value property and wont read that property until you raise the element input event by calling dispatchEvent(), then you call detectChanges()
- DebugElement.triggerEventHandler('event_name'[,event_obj]) - raise any data-bound event by its event name
- beforeEach() - set the preconditions for each it() test and rely on the TestBed to create classes and inject services
- you can create classes explicitly rather than use the TestBed and call beforeEach()
- afterEach()
- Service testing :
- for dependent service, mock the dependency, use a dummy value, or create a spy on the pertinent service method (stubs any function and tracks calls to it and all arguments)
- subscribe to services which returns Observables, subscribe() method takes a success (next) and fail (error) callback
- to begin testing calls to HttpClient, import the HttpClientTestingModule and the mocking controller HttpTestingController, along with the other symbols your tests require
- Component testing :
- 1 - test a component class behavior on its own
- 2 - component DOM testing, how is going to render properly, respond to user input and gestures, or integrate with its parent and child components
- create test doubles for services (stubs, fakes, spies, or mocks) while testing component
- ComponentFixture.debugElement.injector.get(UserService) - safest way to get the injected service, from the injector of the component-under-test
- tests themselves should not make calls to remote servers, they should emulate such calls
- waitForAsync() - runs the body of a test (it) or setup (beforeEach) function within a test function in an asynchronous test zone, test will automatically complete when all asynchronous calls within this zone are done, can be used to wrap an inject call
- you dont need to pass Jasmine done() into the test and call done() because it is undefined in promise or observable callbacks
- asynchronous nature is revealed by the call to ComponentFixture.whenStable() returns a promise that resolves when the JavaScript engine task queue becomes empty
- fakeAsync - runs the body of a test (it) within a special fakeAsync test zone, enabling a linear control flow coding style, test body appears to be synchronous and there is no nested syntax (like a Promise.then()) to disrupt the flow of control
- microtasks are manually executed by calling flushMicrotasks()
- timers are synchronous, tick([ms]) simulates the asynchronous passage of time
- supports the following macroTasks: setTimeout, setInterval, requestAnimationFrame, webkitRequestAnimationFrame, mozRequestAnimationFrame, I/O tasks
- if you want to support other macroTasks (like HTMLCanvasElement.toBlob()) you need to define them in beforeEach()
- fakeAsync() utility function has a few limitations, in particular, it wont work if the test body makes an XHR call, use waitForAsync()
- inject() - allows injecting dependencies in beforeEach() and it()
- done() - alternative for wrappinf with waitForAsync() and fakeAsync(), used as command to fall back to the traditional technique
- when a fakeAsync() test ends with pending micro-tasks such as unresolved promises, the test fails with a clear error message:
- discardPeriodicTasks() - call when pending timer tasks are expected to flush the task queue and avoid the error
- flushMicrotasks() - to flush the micro-task queue and avoid the error
- RxJS marble testing - alternative way to test, useful with overlapping sequences of values and errors:
- Jasmine test is synchronous, no fakeAsync(), uses a test scheduler to simulate the passage of time in a synchronous test
- has a visual definition of the observable streams (ASCII marble diagrams): cold('---x|',{x: testQuote}) - cold observable that waits three frames (---), emits a value (x), and completes (|), second argument you map the value marker (x) to the emitted value (testQuote)
- you cant directly test RxJS code that consumes Promises or uses any of the other schedulers
- marble-testing.md on github
- test debugging
- reveal the Karma browser window (hidden earlier)
- click the DEBUG button; it opens a new browser tab and re-runs the tests
- pen the browser's “Developer Tools” (Ctrl-Shift-I on windows; Command-Option-I in OSX)
- pick the "sources" section
- open the 1st.spec.ts test file (Control/Command-P, then start typing the name of the file)
- set a breakpoint in the test
- refresh the browser, and it stops at the breakpoint
- code coverage reports show you any parts of code base that may not be properly tested by your unit tests, run in root folder:
ng test --no-watch --code-coverage
, command creates a new /coverage folder in the project, index.html file contains a report with your source code and code coverage values
- to create code-coverage reports every time you test, add in angular.json:
"test": { "options": { "codeCoverage": true } }
- to code coverage minimum of 80% of code base, karma.conf.js:
coverageIstanbulReporter: { reports: [ 'html', 'lcovonly' ],
fixWebpackSourcePaths: true, thresholds: {
statements: 80, lines: 80, branches: 80, functions: 80 } }
- Continuous integration (CI) servers (like Circle CI and Travis CI, or created with Jenkins) let you set up your project repository so that your tests run on every commit and pull request
- place spec file next to the component(etc...) file and test of interactions of multiple parts spread across folders and modules into tests directory, specs that test the test helpers belong in the test folder, next to their corresponding helper files
interfaces/classes/functions
interface TestBed {
platform: PlatformRef
ngModule: Type<any> | Type<any>[]
// initialize the environment for testing with a compiler factory
// if need to change the providers, first use resetTestEnvironment
// test modules and platforms for individual platforms
// are available from '@angular/platform_name/testing'
initTestEnvironment(
ngModule: Type<any> | Type<any>[],
platform: PlatformRef,
): void
// reset the providers for the test injector
resetTestEnvironment(): void
// reset the initial test environment, including default testing module
resetTestingModule(): void
// retrieve a service from the current TestBed injector
// with optional second parameter data as unfound provider replacement
get(
token: any, notFoundValue?: any ): any
execute(
tokens: any[], fn: Function, context?: any ): any
configureCompiler(
config: { providers?: any[]; } ): void
// refine the testing module configuration for a particular set of tests
// by adding and removing imports,
// declarations (of components, directives, and pipes), and providers
configureTestingModule(
moduleDef: TestModuleMetadata ): void
// after callings, configuration is frozen for the duration of the current spec
compileComponents(): Promise<any>
createComponent<T>(
component: Type<T>): ComponentFixture<T>
// replacing metadata
// can reach deeply into the current testing module
overrideModule(
ngModule: Type<any>,
override: MetadataOverride<NgModule> ): void
overrideComponent(
component: Type<any>,
override: MetadataOverride<Component> ): void
overrideDirective(
directive: Type<any>,
override: MetadataOverride<Directive> ): void
overridePipe(
pipe: Type<any>,
override: MetadataOverride<Pipe> ): void
// overwrites all providers for the given token
// with the given provider definition
overrideProvider(
token: any,
provider: { useFactory: Function; deps: any[]; } ): void
overrideTemplateUsingTestingModule(
component: Type<any>, template: string ): void
}
// fixture for debugging and testing a component
class ComponentFixture<T> {
constructor(
componentRef: ComponentRef<T>,
ngZone: NgZone,
_autoDetect: boolean
)
debugElement: DebugElement
componentInstance: T
nativeElement: any
elementRef: ElementRef
// change detection functionality
// add and remove views from the tree,
// initiate change-detection, and explicitly mark views as dirty (changed and need to be rerendered)
changeDetectorRef: ChangeDetectorRef
componentRef: ComponentRef<T>
ngZone: NgZone | null
// trigger a change detection cycle for the component
detectChanges(checkNoChanges: boolean = true): void
// do a change detection run to make sure there were no changes
checkNoChanges(): void
// set whether the fixture should autodetect changes
autoDetectChanges(autoDetect: boolean = true)
// whether the fixture is currently stable
// or has async tasks that have not been completed yet
isStable(): boolean
//get a promise that resolves when the fixture is stable
// can be used to resume testing after events have triggered
// asynchronous activity or asynchronous change detection
whenStable(): Promise<any>
// get a promise that resolves when the ui state is stable following animations
whenRenderingDone(): Promise<any>
// trigger component destruction
destroy(): void
}
interface DebugElement extends DebugNode {
// element tag name, if it is an element
name: string
properties: {...}
attributes: {...}
classes: {...}
styles: {...}
// immediate DebugElement children
children: DebugElement[]
childNodes: DebugNode[]
// corresponding DOM element in the browser (null for WebWorkers)
nativeElement: any
// first DebugElement that matches the predicate
query(predicate: Predicate<DebugElement>): DebugElement
// all DebugElements that matches the predicate
queryAll(predicate: Predicate<DebugElement>): DebugElement[]
queryAllNodes(predicate: Predicate<DebugNode>): DebugNode[]
// triggers the event by its name
// if there is a corresponding listener in the element listeners collection
// second parameter is the event object expected by the handler
// if the event lacks a listener or there some other problem,
// consider calling nativeElement.dispatchEvent(eventObject)
triggerEventHandler(eventName: string, eventObj: any): void
// callbacks attached to the component @Output properties
// and/or the element event properties
listeners: EventListener[]
// DebugElement parent
parent: DebugElement | null
nativeNode: any
// host dependency injector
injector: Injector
// element own component instance, if it has one
componentInstance: any
// provides parent context for this element
// often an ancestor component instance that governs this element.
// when an element is repeated within *ngFor, the context is an NgForRow
// whose $implicit property is the value of the row instance value
// for example, the hero in *ngFor="let hero of heroes"
context: any
// dictionary of objects associated with template local variables
// (e.g. #foo), keyed by the local variable name
references: {...}
// this component injector lookup tokens
// includes the component itself
// plus the tokens that the component lists in its providers metadata
providerTokens: any[]
}
// sync()
it('...', waitForAsync(inject([AClass], (object) => {
object.doSomething.then(() => {
expect(...);
})
});
// fakeAsync()
describe('this test', () => {
it('looks async but is synchronous', <any>fakeAsync((): void => {
let flag = false;
setTimeout(() => { flag = true; }, 100);
expect(flag).toBe(false);
tick(50);
expect(flag).toBe(false);
tick(50);
expect(flag).toBe(true);
}));
});
// tick()
describe('this test', () => {
it('looks async but is synchronous', <any>fakeAsync((): void => {
let flag = false;
setTimeout(() => { flag = true; }, 100);
expect(flag).toBe(false);
tick(50);
expect(flag).toBe(false);
tick(50);
expect(flag).toBe(true);
}));
});
component class test
// --- --- COMPONENT
@Component({
selector: 'lightswitch-comp',
template: `
<button (click)="clicked()">Click me!</button>
<span>{{message}}</span>`
})
export class LightswitchComponent {
// --- 1 - switch value
isOn = false;
clicked() { this.isOn = !this.isOn; }
get message() { return `The light is ${this.isOn ? 'On' : 'Off'}`; }
// --- 2 - binding
@Input() hero: Hero;
@Output() selected = new EventEmitter<Hero>();
click() { this.selected.emit(this.hero); }
// --- 3 - service dependencies
welcome: string;
constructor(private userService: UserService) { }
ngOnInit(): void {
this.welcome = this.userService.isLoggedIn ?
'Welcome, ' + this.userService.user.name : 'Please log in.';
}
}
// --- service
class MockUserService {
isLoggedIn = true;
user = { name: 'Test User'};
};
// --- --- TEST
// --- 1 - switch value
it('#clicked() should toggle #isOn', () => {
const comp = new LightswitchComponent();
expect(comp.isOn).toBe(false, 'off at first');
comp.clicked();
expect(comp.isOn).toBe(true, 'on after click');
comp.clicked();
expect(comp.isOn).toBe(false, 'off after second click');
});
it('#clicked() should set #message to "is on"', () => {
const comp = new LightswitchComponent();
expect(comp.message).toMatch(/is off/i, 'off at first');
comp.clicked();
expect(comp.message).toMatch(/is on/i, 'on after clicked');
});
// --- 2 - binding
it('raises the selected event when clicked', () => {
const comp = new DashboardHeroComponent();
const hero: Hero = { id: 42, name: 'Test' };
comp.hero = hero;
comp.selected.subscribe(selectedHero => expect(selectedHero).toBe(hero));
comp.click();
});
// --- 3 - service dependencies
beforeEach(() => {
TestBed.configureTestingModule({
// provide the component-under-test and dependent service
providers: [
WelcomeComponent,
{ provide: UserService, useClass: MockUserService }
]
});
// inject both the component and the dependent service.
comp = TestBed.inject(WelcomeComponent);
userService = TestBed.inject(UserService);
});
it('should not have welcome message after construction', () => {
expect(comp.welcome).toBeUndefined();
});
it('should welcome logged in user after Angular calls ngOnInit', () => {
comp.ngOnInit();
expect(comp.welcome).toContain(userService.user.name);
});
it('should ask user to log in if not logged in after ngOnInit', () => {
userService.isLoggedIn = false;
comp.ngOnInit();
expect(comp.welcome).not.toContain(userService.user.name);
expect(comp.welcome).toContain('log in');
});
binding, DOM test, external urls and compileComponents()
// --- 1 - initial
import { Component } from '@angular/core';
@Component({
selector: 'app-banner',
template: `<p>banner works!</p>`,
styles: []
})
export class BannerComponent { }
// --- 2 - with external urls
import { Component } from '@angular/core';
@Component({
selector: 'app-banner',
templateUrl: './banner-external.component.html',
styleUrls: ['./banner-external.component.css']
})
export class BannerComponent { title = 'Test Tour of Heroes'; }
// --- 3
import { Component } from '@angular/core';
@Component({
selector: 'app-banner',
template: '<h1>{{title}}</h1>',
styles: ['h1 { color: green; font-size: 350%}']
})
export class BannerComponent { title = 'Test Tour of Heroes'; }
--- --- TEST
// --- 1 - initial
import { By } from '@angular/platform-browser';
import { DebugElement } from '@angular/core';
import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing';
import { BannerComponent } from './banner-initial.component';
describe('BannerComponent (initial CLI generated)', () => {
let component: BannerComponent;
let fixture: ComponentFixture<BannerComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ BannerComponent ]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(BannerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeDefined();
});
});
describe('BannerComponent (minimal)', () => {
it('should create', () => {
TestBed.configureTestingModule({
declarations: [ BannerComponent ]
});
const fixture = TestBed.createComponent(BannerComponent);
const component = fixture.componentInstance;
expect(component).toBeDefined();
});
});
describe('BannerComponent (with beforeEach)', () => {
let component: BannerComponent;
let fixture: ComponentFixture<BannerComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [ BannerComponent ]
});
fixture = TestBed.createComponent(BannerComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeDefined();
});
it('should contain "banner works!"', () => {
const bannerElement: HTMLElement = fixture.nativeElement;
expect(bannerElement.textContent).toContain('banner works!');
});
it('should have <p> with "banner works!"', () => {
const bannerElement: HTMLElement = fixture.nativeElement;
const p = bannerElement.querySelector('p');
expect(p.textContent).toEqual('banner works!');
});
it('should find the <p> with fixture.debugElement.nativeElement)', () => {
const bannerDe: DebugElement = fixture.debugElement;
const bannerEl: HTMLElement = bannerDe.nativeElement;
const p = bannerEl.querySelector('p');
expect(p.textContent).toEqual('banner works!');
});
it('should find the <p> with fixture.debugElement.query(By.css)', () => {
const bannerDe: DebugElement = fixture.debugElement;
const paragraphDe = bannerDe.query(By.css('p'));
const p: HTMLElement = paragraphDe.nativeElement;
expect(p.textContent).toEqual('banner works!');
});
});
// --- 2 - with external urls
import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { DebugElement } from '@angular/core';
import { BannerComponent } from './banner-external.component';
describe('BannerComponent (external files)', () => {
let component: BannerComponent;
let fixture: ComponentFixture<BannerComponent>;
let h1: HTMLElement;
describe('Two beforeEach', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ BannerComponent ],
})
.compileComponents(); // compile template and css
}));
// synchronous beforeEach
beforeEach(() => {
fixture = TestBed.createComponent(BannerComponent);
component = fixture.componentInstance; // BannerComponent test instance
h1 = fixture.nativeElement.querySelector('h1');
});
tests();
});
describe('One beforeEach', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ BannerComponent ],
})
.compileComponents()
.then(() => {
fixture = TestBed.createComponent(BannerComponent);
component = fixture.componentInstance;
h1 = fixture.nativeElement.querySelector('h1');
});
}));
tests();
});
function tests() {
it('no title in the DOM until manually call `detectChanges`', () => {
expect(h1.textContent).toEqual('');
});
it('should display original title', () => {
fixture.detectChanges();
expect(h1.textContent).toContain(component.title);
});
it('should display a different test title', () => {
component.title = 'Test Title';
fixture.detectChanges();
expect(h1.textContent).toContain('Test Title');
});
}
});
// --- 3
import { BannerComponent } from './banner.component';
describe('BannerComponent (inline template)', () => {
let component: BannerComponent;
let fixture: ComponentFixture<BannerComponent>;
let h1: HTMLElement;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [ BannerComponent ],
});
fixture = TestBed.createComponent(BannerComponent);
component = fixture.componentInstance; // BannerComponent test instance
h1 = fixture.nativeElement.querySelector('h1');
});
it('no title in the DOM after createComponent()', () => {
expect(h1.textContent).toEqual('');
});
it('should display original title', () => {
fixture.detectChanges();
expect(h1.textContent).toContain(component.title);
});
it('should display original title after detectChanges()', () => {
fixture.detectChanges();
expect(h1.textContent).toContain(component.title);
});
it('should display a different test title', () => {
component.title = 'Test Title';
fixture.detectChanges();
expect(h1.textContent).toContain('Test Title');
});
});
// --- banner.component.detect-changes.spec.ts
describe('BannerComponent (AutoChangeDetect)', () => {
let comp: BannerComponent;
let fixture: ComponentFixture<BannerComponent>;
let h1: HTMLElement;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [ BannerComponent ],
providers: [
{ provide: ComponentFixtureAutoDetect, useValue: true }
]
});
fixture = TestBed.createComponent(BannerComponent);
comp = fixture.componentInstance;
h1 = fixture.nativeElement.querySelector('h1');
});
it('should display original title', () => {
// Hooray! No `fixture.detectChanges()` needed
expect(h1.textContent).toContain(comp.title);
});
it('should still see original title after comp.title change', () => {
const oldTitle = comp.title;
comp.title = 'Test Title';
// Displayed title is old because Angular didn't hear the change :(
expect(h1.textContent).toContain(oldTitle);
});
it('should display updated title after detectChanges', () => {
comp.title = 'Test Title';
fixture.detectChanges(); // detect changes explicitly
expect(h1.textContent).toContain(comp.title);
});
});
component with a dependency
// --- --- COMPONENT
import { Component, OnInit } from '@angular/core';
import { UserService } from '../model/user.service';
@Component({
selector: 'app-welcome',
template: '<h3 class="welcome"><i>{{welcome}}</i></h3>'
})
export class WelcomeComponent implements OnInit {
welcome: string;
constructor(private userService: UserService) { }
ngOnInit(): void {
this.welcome = this.userService.isLoggedIn ?
'Welcome, ' + this.userService.user.name : 'Please log in.';
}
}
// --- --- TEST
let userServiceStub: Partial<UserService>;
beforeEach(() => {
// stub UserService for test purposes
userServiceStub = {
isLoggedIn: true,
user: { name: 'Test User'}
};
TestBed.configureTestingModule({
declarations: [ WelcomeComponent ],
providers: [ {provide: UserService, useValue: userServiceStub } ]
});
fixture = TestBed.createComponent(WelcomeComponent);
comp = fixture.componentInstance;
// UserService from the root injector
userService = TestBed.inject(UserService);
// get the "welcome" element by CSS selector (e.g., by class name)
el = fixture.nativeElement.querySelector('.welcome');
});
it('should welcome the user', () => {
fixture.detectChanges();
const content = el.textContent;
expect(content).toContain('Welcome', '"Welcome ..."');
expect(content).toContain('Test User', 'expected name');
});
it('should welcome "Bubba"', () => {
userService.user.name = 'Bubba'; // welcome message hasn't been shown yet
fixture.detectChanges();
expect(el.textContent).toContain('Bubba');
});
it('should request login if not logged in', () => {
userService.isLoggedIn = false; // welcome message hasn't been shown yet
fixture.detectChanges();
const content = el.textContent;
expect(content).not.toContain('Welcome', 'not welcomed');
expect(content).toMatch(/log in/i, '"log in"');
});
component with sync and async service
// --- --- COMPONENT
import { TwainService } from './twain.service';
@Component({
selector: 'twain-quote',
template: `
<p class="twain"><i>{{quote | async}}</i></p>
<button (click)="getQuote()">Next quote</button>
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>`,
styles: [ `.twain { font-style: italic; } .error { color: red; }` ]
})
export class TwainComponent implements OnInit {
errorMessage: string;
quote: Observable<string>;
constructor(private twainService: TwainService) {}
ngOnInit(): void { this.getQuote(); }
getQuote() {
this.errorMessage = '';
this.quote = this.twainService.getQuote().pipe(
startWith('...'),
catchError( (err: any) => { // wait a turn, errorMessage already set once
setTimeout(() => this.errorMessage = err.message || err.toString());
return of('...'); // reset message to placeholder
})
);
}
}
// Mark Twain Quote service gets quotes from server
@Injectable()
export class TwainService {
constructor(private http: HttpClient) { }
private nextId = 1;
getQuote(): Observable<string> {
return Observable.create(observer => observer.next(this.nextId++)).pipe(
// tap((id: number) => console.log(id)),
// tap((id: number) => { throw new Error('Simulated server error'); }),
switchMap((id: number) => this.http.get<Quote>(`api/quotes/${id}`)),
// tap((q : Quote) => console.log(q)),
map((q: Quote) => q.quote),
// `errors` is observable of http.get errors
retryWhen(errors => errors.pipe(
switchMap((error: HttpErrorResponse) => {
if (error.status === 404) {
// Queried for quote that doesn't exist.
this.nextId = 1; // retry with quote id:1
return of(null); // signal OK to retry
}
// Some other HTTP error.
console.error(error);
return throwError('Cannot get Twain quotes from the server');
}),
take(2),
// If a second retry value, then didn't find id:1 and triggers the following error
concat(throwError('There are no Twain quotes')) // didn't find id:1
))
);
}
}
// --- --- TEST
// --- async observable helpers, to return an asynchronous observable
import { defer } from 'rxjs';
// async observable that emits-once and completes after a JS engine turn
export function asyncData<T>(data: T) {
return defer(() => Promise.resolve(data));
}
// async observable error that errors after a JS engine turn
export function asyncError<T>(errorObject: any) {
return defer(() => Promise.reject(errorObject));
}
// ---
import {
waitForAsync, fakeAsync, ComponentFixture, TestBed, tick
} from '@angular/core/testing';
import { asyncData, asyncError } from '../../testing';
import { of, throwError, interval } from 'rxjs';
import { last, delay, take } from 'rxjs/operators';
import { TwainService } from './twain.service';
import { TwainComponent } from './twain.component';
import { cold, getTestScheduler } from 'jasmine-marbles';
describe('TwainComponent', () => {
let component: TwainComponent;
let fixture: ComponentFixture<TwainComponent>;
let getQuoteSpy: jasmine.Spy;
let quoteEl: HTMLElement;
let testQuote: string;
// Helper function to get the error message element value
// An *ngIf keeps it out of the DOM until there is an error
const errorMessage = () => {
const el = fixture.nativeElement.querySelector('.error');
return el ? el.textContent : null;
};
beforeEach(() => {
testQuote = 'Test Quote';
// Create a fake TwainService object with a `getQuote()` spy
const twainService = jasmine.createSpyObj('TwainService', ['getQuote']);
// Make the spy return a synchronous Observable with the test data
getQuoteSpy = twainService.getQuote.and.returnValue( of(testQuote) );
TestBed.configureTestingModule({
declarations: [ TwainComponent ],
providers: [
{ provide: TwainService, useValue: twainService }
]
});
fixture = TestBed.createComponent(TwainComponent);
component = fixture.componentInstance;
quoteEl = fixture.nativeElement.querySelector('.twain');
});
describe('when test with synchronous observable', () => {
it('should not show quote before OnInit', () => {
expect(quoteEl.textContent).toBe('', 'nothing displayed');
expect(errorMessage()).toBeNull('should not show error element');
expect(getQuoteSpy.calls.any()).toBe(false, 'getQuote not yet called');
});
// The quote would not be immediately available if the service were truly async
it('should show quote after component initialized', () => {
fixture.detectChanges(); // onInit()
// sync spy result shows testQuote immediately after init
expect(quoteEl.textContent).toBe(testQuote);
expect(getQuoteSpy.calls.any()).toBe(true, 'getQuote called');
});
// The error would not be immediately available if the service were truly async
// Use `fakeAsync` because the component error calls `setTimeout`
it('should display error when TwainService fails',
fakeAsync(() => {
// tell spy to return an error observable
getQuoteSpy.and.returnValue(
throwError('TwainService test failure'));
fixture.detectChanges(); // onInit()
// sync spy errors immediately after init
tick(); // flush the component's setTimeout()
fixture.detectChanges(); // update errorMessage within setTimeout()
expect(errorMessage()).toMatch(/test failure/, 'should display error');
expect(quoteEl.textContent).toBe('...', 'should show placeholder');
}));
});
describe('when test with asynchronous observable', () => {
beforeEach(() => {
// Simulate delayed observable values with the `asyncData()` helper
getQuoteSpy.and.returnValue(asyncData(testQuote));
});
it('should not show quote before OnInit',
() => {
expect(quoteEl.textContent).toBe('', 'nothing displayed');
expect(errorMessage()).toBeNull('should not show error element');
expect(getQuoteSpy.calls.any()).toBe(false, 'getQuote not yet called');
});
it('should still not show quote after component initialized',
() => {
fixture.detectChanges();
// getQuote service is async => still has not returned with quote
// so should show the start value, '...'
expect(quoteEl.textContent).toBe('...', 'should show placeholder');
expect(errorMessage()).toBeNull('should not show error');
expect(getQuoteSpy.calls.any()).toBe(true, 'getQuote called');
});
it('should show quote after getQuote (fakeAsync)',
fakeAsync(() => {
fixture.detectChanges(); // ngOnInit()
expect(quoteEl.textContent).toBe('...', 'should show placeholder');
tick(); // flush the observable to get the quote
fixture.detectChanges(); // update view
expect(quoteEl.textContent).toBe(testQuote, 'should show quote');
expect(errorMessage()).toBeNull('should not show error');
}));
it('should show quote after getQuote (async)',
waitForAsync(() => {
fixture.detectChanges(); // ngOnInit()
expect(quoteEl.textContent).toBe('...', 'should show placeholder');
fixture.whenStable().then(() => { // wait for async getQuote
fixture.detectChanges(); // update view with quote
expect(quoteEl.textContent).toBe(testQuote);
expect(errorMessage()).toBeNull('should not show error');
});
}));
it('should show last quote (quote done)',
(done: DoneFn) => {
fixture.detectChanges();
component.quote.pipe( last() ).subscribe(() => {
fixture.detectChanges(); // update view with quote
expect(quoteEl.textContent).toBe(testQuote);
expect(errorMessage()).toBeNull('should not show error');
done();
});
});
it('should show quote after getQuote (spy done)',
(done: DoneFn) => {
fixture.detectChanges();
// the spy's most recent call returns the observable with the test quote
getQuoteSpy.calls.mostRecent().returnValue.subscribe(() => {
fixture.detectChanges(); // update view with quote
expect(quoteEl.textContent).toBe(testQuote);
expect(errorMessage()).toBeNull('should not show error');
done();
});
});
it('should display error when TwainService fails',
fakeAsync(() => {
// tell spy to return an async error observable
getQuoteSpy.and.returnValue(asyncError<string>('TwainService test failure'));
fixture.detectChanges();
tick(); // component shows error after a setTimeout()
fixture.detectChanges(); // update error message
expect(errorMessage()).toMatch(/test failure/, 'should display error');
expect(quoteEl.textContent).toBe('...', 'should show placeholder');
}));
});
// --- marbles
// A synchronous test that simulates async behavior
it('should show quote after getQuote (marbles)', () => {
// observable test quote value and complete(), after delay:
// cold observable that waits three frames (---),
// emits a value (x), and completes (|)
// second argument (x) is to the emitted value (testQuote)
const q$ = cold('---x|', { x: testQuote });
getQuoteSpy.and.returnValue( q$ );
fixture.detectChanges(); // ngOnInit()
expect(quoteEl.textContent).toBe('...', 'should show placeholder');
getTestScheduler().flush(); // flush the observables
fixture.detectChanges(); // update view
expect(quoteEl.textContent).toBe(testQuote, 'should show quote');
expect(errorMessage()).toBeNull('should not show error');
});
// Still need fakeAsync() because of component setTimeout()
it('should display error when TwainService fails', fakeAsync(() => {
// observable error after delay
const q$ = cold('---#|', null, new Error('TwainService test failure'));
getQuoteSpy.and.returnValue( q$ );
fixture.detectChanges(); // ngOnInit()
expect(quoteEl.textContent).toBe('...', 'should show placeholder');
getTestScheduler().flush(); // flush the observables
tick(); // component shows error after a setTimeout()
fixture.detectChanges(); // update error message
expect(errorMessage()).toMatch(/test failure/, 'should display error');
expect(quoteEl.textContent).toBe('...', 'should show placeholder');
}));
});
// --- --- fakeAsync sample
describe('this test', () => {
it('looks async but is synchronous',
<any>fakeAsync((): void => {
let flag = false;
setTimeout(() => { flag = true; }, 100);
expect(flag).toBe(false);
tick(50);
expect(flag).toBe(false);
tick(50);
expect(flag).toBe(true);
}));
});
it('should get Date diff correctly in fakeAsync',
fakeAsync(() => {
const start = Date.now();
tick(100);
const end = Date.now();
expect(end - start).toBe(100);
}));
it('should get Date diff correctly in fakeAsync with rxjs scheduler',
fakeAsync(() => {
// need to add `import 'zone.js/dist/zone-patch-rxjs-fake-async'
// to patch rxjs scheduler
let result = null;
of ('hello').pipe(delay(1000)).subscribe(v => { result = v; });
expect(result).toBeNull();
tick(1000);
expect(result).toBe('hello');
const start = new Date().getTime();
let dateDiff = 0;
interval(1000).pipe(take(2)).subscribe(() => dateDiff = (new Date().getTime() - start));
tick(1000);
expect(dateDiff).toBe(1000);
tick(1000);
expect(dateDiff).toBe(2000);
}));
// --- --- jasmine.clock() - auto enter fakeAsync
// --- src/test.ts
(window as any)['__zone_symbol__fakeAsyncPatchLock'] = true;
import 'zone.js/dist/zone-testing';
// ---
describe('use jasmine.clock()', () => {
// need to config __zone_symbol__fakeAsyncPatchLock flag
// before loading zone.js/dist/zone-testing
beforeEach(() => { jasmine.clock().install(); });
afterEach(() => { jasmine.clock().uninstall(); });
it('should auto enter fakeAsync', () => {
// is in fakeAsync now, don't need to call fakeAsync(testFn)
let called = false;
setTimeout(() => { called = true; }, 100);
jasmine.clock().tick(100);
expect(called).toBe(true);
});
});
macroTasks in fakeAsync
// --- --- COMPONENT
import { Component, AfterViewInit, ViewChild } from '@angular/core';
@Component({
selector: 'sample-canvas',
template: '<canvas #sampleCanvas width="200" height="200"></canvas>'
})
export class CanvasComponent implements AfterViewInit {
blobSize: number;
@ViewChild('sampleCanvas') sampleCanvas;
constructor() { }
ngAfterViewInit() {
const canvas = this.sampleCanvas.nativeElement;
const context = canvas.getContext('2d');
if (context) {
context.clearRect(0, 0, 200, 200);
context.fillStyle = '#FF1122';
context.fillRect(0, 0, 200, 200);
canvas.toBlob((blob: any) => {
this.blobSize = blob.size;
});
}
}
}
// --- --- TEST
import { TestBed, waitForAsync, tick, fakeAsync } from '@angular/core/testing';
import { CanvasComponent } from './canvas.component';
describe('CanvasComponent', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [
CanvasComponent
],
}).compileComponents();
}));
beforeEach(() => {
window['__zone_symbol__FakeAsyncTestMacroTask'] = [
{
source: 'HTMLCanvasElement.toBlob',
callbackArgs: [{ size: 200 }]
}
];
});
it('should be able to generate blob data from canvas', fakeAsync(() => {
const fixture = TestBed.createComponent(CanvasComponent);
fixture.detectChanges();
tick();
const app = fixture.debugElement.componentInstance;
expect(app.blobSize).toBeGreaterThan(0);
}));
});
inputs/outputs, host component, click (events), routing/routed component
// --- --- HOST COMPONENT
// set each component hero input property to the looping value
// and listens for the component "selected" event:
@Component({
selector: 'app-dashboard',
template: `
<div class="grid grid-pad">
<dashboard-hero *ngFor="let hero of heroes" class="col-1-4"
[hero]=hero (selected)="gotoDetail($event)" >
</dashboard-hero>
</div>`,
styleUrls: [ './dashboard.component.css' ]
})
export class DashboardComponent implements OnInit {
heroes: Hero[] = [];
constructor(
private router: Router,
private heroService: HeroService
) { }
ngOnInit() {
this.heroService.getHeroes()
.subscribe(heroes => this.heroes = heroes.slice(1, 5));
}
gotoDetail(hero: Hero) {
let url = `/heroes/${hero.id}`;
this.router.navigateByUrl(url);
}
get title() {
let cnt = this.heroes.length;
return cnt === 0 ? 'No Heroes' :
cnt === 1 ? 'Top Hero' : `Top ${cnt} Heroes`;
}
}
// --- --- COMPONENT
@Component({
selector: 'dashboard-hero',
template: `
<div (click)="click()" class="hero">
{{hero.name | uppercase}}
</div>`,
styleUrls: [ './dashboard-hero.component.css' ]
})
export class DashboardHeroComponent {
@Input() hero: Hero;
@Output() selected = new EventEmitter<Hero>();
click() { this.selected.emit(this.hero); }
}
// --- --- TEST
--- dashboard-hero.component.spec.ts
// --- click helper from '../../testing'
// button events to pass to "DebugElement.triggerEventHandler"
// for RouterLink event handler
export const ButtonClickEvents = {
left: { button: 0 },
right: { button: 2 }
};
// Simulate element click. Defaults to mouse left-button click event
export function click(
el: DebugElement | HTMLElement, eventObj: any = ButtonClickEvents.left
): void {
if (el instanceof HTMLElement) {
el.click();
} else {
el.triggerEventHandler('click', eventObj);
}
}
// ---
import {
waitForAsync, ComponentFixture, TestBed
} from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { DebugElement } from '@angular/core';
import { addMatchers, click } from '../../testing';
import { Hero } from '../model/hero';
import { DashboardHeroComponent } from './dashboard-hero.component';
beforeEach( addMatchers );
describe('DashboardHeroComponent class only', () => {
it('raises the selected event when clicked', () => {
const comp = new DashboardHeroComponent();
const hero: Hero = { id: 42, name: 'Test' };
comp.hero = hero;
comp.selected.subscribe(
selectedHero => expect(selectedHero).toBe(hero)
);
comp.click();
});
});
describe('DashboardHeroComponent when tested stand-alone (directly)', () => {
let comp: DashboardHeroComponent;
let expectedHero: Hero;
let fixture: ComponentFixture<DashboardHeroComponent>;
let heroDe: DebugElement;
let heroEl: HTMLElement;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ DashboardHeroComponent ]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(DashboardHeroComponent);
comp = fixture.componentInstance;
// find the hero's DebugElement and element
heroDe = fixture.debugElement.query(By.css('.hero'));
heroEl = heroDe.nativeElement;
// mock the hero supplied by the parent component
expectedHero = { id: 42, name: 'Test Name' };
// simulate the parent setting the input property with that hero
comp.hero = expectedHero;
// trigger initial data binding
fixture.detectChanges();
});
it('should display hero name in uppercase', () => {
const expectedPipedName = expectedHero.name.toUpperCase();
expect(heroEl.textContent).toContain(expectedPipedName);
});
it('should raise selected event when clicked (triggerEventHandler)', () => {
let selectedHero: Hero;
comp.selected.subscribe((hero: Hero) => selectedHero = hero);
heroDe.triggerEventHandler('click', null);
expect(selectedHero).toBe(expectedHero);
});
it('should raise selected event when clicked (element.click)', () => {
let selectedHero: Hero;
comp.selected.subscribe((hero: Hero) => selectedHero = hero);
heroEl.click();
expect(selectedHero).toBe(expectedHero);
});
it('should raise selected event when clicked (click helper)', () => {
let selectedHero: Hero;
comp.selected.subscribe(hero => selectedHero = hero);
click(heroDe); // click helper with DebugElement
click(heroEl); // click helper with native element
expect(selectedHero).toBe(expectedHero);
});
});
describe('DashboardHeroComponent when inside a TestHostComponent', () => {
let testHost: TestHostComponent;
let fixture: ComponentFixture<TestHostComponent>;
let heroEl: HTMLElement;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ DashboardHeroComponent, TestHostComponent ]
}).compileComponents();
}));
beforeEach(() => {
// create TestHostComponent instead of DashboardHeroComponent
fixture = TestBed.createComponent(TestHostComponent);
testHost = fixture.componentInstance;
heroEl = fixture.nativeElement.querySelector('.hero');
fixture.detectChanges(); // trigger initial data binding
});
it('should display hero name', () => {
const expectedPipedName = testHost.hero.name.toUpperCase();
expect(heroEl.textContent).toContain(expectedPipedName);
});
it('should raise selected event when clicked', () => {
click(heroEl);
// selected hero should be the same data bound hero
expect(testHost.selectedHero).toBe(testHost.hero);
});
});
// TestHostComponent
import { Component } from '@angular/core';
@Component({
template: `
<dashboard-hero
[hero]="hero" (selected)="onSelected($event)">
</dashboard-hero>`
})
class TestHostComponent {
hero: Hero = { id: 42, name: 'Test Name' };
selectedHero: Hero;
onSelected(hero: Hero) { this.selectedHero = hero; }
}
--- dashboard.component.spec.ts
import {
waitForAsync, inject, ComponentFixture, TestBed
} from '@angular/core/testing';
import { addMatchers, asyncData, click } from '../../testing';
import { HeroService } from '../model/hero.service';
import { getTestHeroes } from '../model/testing/test-heroes';
import { By } from '@angular/platform-browser';
import { Router } from '@angular/router';
import { DashboardComponent } from './dashboard.component';
import { DashboardModule } from './dashboard.module';
beforeEach ( addMatchers );
let comp: DashboardComponent;
let fixture: ComponentFixture<DashboardComponent>;
// Deep
describe('DashboardComponent (deep)', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ DashboardModule ]
});
});
compileAndCreate();
tests(clickForDeep);
function clickForDeep() {
// get first <div class="hero">
const heroEl: HTMLElement = fixture.nativeElement.querySelector('.hero');
click(heroEl);
}
});
// Shallow
import { NO_ERRORS_SCHEMA } from '@angular/core';
describe('DashboardComponent (shallow)', () => {
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [ DashboardComponent ],
schemas: [NO_ERRORS_SCHEMA]
});
});
compileAndCreate();
tests(clickForShallow);
function clickForShallow() {
// get first <dashboard-hero> DebugElement
const heroDe = fixture.debugElement.query(By.css('dashboard-hero'));
heroDe.triggerEventHandler('selected', comp.heroes[0]);
}
});
// Add TestBed providers, compile, and create DashboardComponent
function compileAndCreate() {
beforeEach(waitForAsync(() => {
const routerSpy = jasmine.createSpyObj('Router', ['navigateByUrl']);
const heroServiceSpy = jasmine.createSpyObj('HeroService', ['getHeroes']);
TestBed.configureTestingModule({
providers: [
{ provide: HeroService, useValue: heroServiceSpy },
{ provide: Router, useValue: routerSpy }
]
})
.compileComponents().then(() => {
fixture = TestBed.createComponent(DashboardComponent);
comp = fixture.componentInstance;
// getHeroes spy returns observable of test heroes
heroServiceSpy.getHeroes.and.returnValue(asyncData(getTestHeroes()));
});
}));
}
// (almost) same tests for both
// only change: the way that the first hero is clicked
function tests(heroClick: Function) {
it('should NOT have heroes before ngOnInit', () => {
expect(comp.heroes.length).toBe(0,
'should not have heroes before ngOnInit');
});
it('should NOT have heroes immediately after ngOnInit', () => {
fixture.detectChanges(); // runs initial lifecycle hooks
expect(comp.heroes.length).toBe(0,
'should not have heroes until service promise resolves');
});
describe('after get dashboard heroes', () => {
let router: Router;
// Trigger component so it gets heroes and binds to them
beforeEach(waitForAsync(() => {
router = fixture.debugElement.injector.get(Router);
fixture.detectChanges(); // runs ngOnInit -> getHeroes
fixture.whenStable() // No need for the `lastPromise` hack!
.then(() => fixture.detectChanges()); // bind to heroes
}));
it('should HAVE heroes', () => {
expect(comp.heroes.length).toBeGreaterThan(0,
'should have heroes after service promise resolves');
});
it('should DISPLAY heroes', () => {
// Find and examine the displayed heroes
// Look for them in the DOM by css class
const heroes = fixture.nativeElement.querySelectorAll('dashboard-hero');
expect(heroes.length).toBe(4, 'should display 4 heroes');
});
it('should tell ROUTER to navigate when hero clicked', () => {
heroClick(); // trigger click on first inner <div class="hero">
// args passed to router.navigateByUrl() spy
const spy = router.navigateByUrl as jasmine.Spy;
const navArgs = spy.calls.first().args[0];
// expecting to navigate to id of the component's first hero
const id = comp.heroes[0].id;
expect(navArgs).toBe('/heroes/' + id,
'should nav to HeroDetail for first hero');
});
});
}
--- dashboard.component.no-testbed.spec.ts
import { Router } from '@angular/router';
import { DashboardComponent } from './dashboard.component';
import { Hero } from '../model/hero';
import { addMatchers } from '../../testing';
import { TestHeroService, HeroService } from '../model/testing/test-hero.service';
class FakeRouter { navigateByUrl(url: string) { return url; } }
describe('DashboardComponent class only', () => {
let comp: DashboardComponent;
let heroService: TestHeroService;
let router: Router;
beforeEach(() => {
addMatchers();
router = new FakeRouter() as any as Router;
heroService = new TestHeroService();
comp = new DashboardComponent(router, heroService);
});
it('should NOT have heroes before calling OnInit', () => {
expect(comp.heroes.length).toBe(0,
'should not have heroes before OnInit');
});
it('should NOT have heroes immediately after OnInit', () => {
comp.ngOnInit(); // ngOnInit -> getHeroes
expect(comp.heroes.length).toBe(0,
'should not have heroes until service promise resolves');
});
it('should HAVE heroes after HeroService gets them', (done: DoneFn) => {
comp.ngOnInit(); // ngOnInit -> getHeroes
heroService.lastResult // the one from getHeroes
.subscribe(
() => {
// throw new Error('deliberate error'); // see it fail gracefully
expect(comp.heroes.length).toBeGreaterThan(0,
'should have heroes after service promise resolves');
done();
},
done.fail);
});
it('should tell ROUTER to navigate by hero id', () => {
const hero: Hero = {id: 42, name: 'Abbracadabra' };
const spy = spyOn(router, 'navigateByUrl');
comp.gotoDetail(hero);
const navArgs = spy.calls.mostRecent().args[0];
expect(navArgs).toBe('/heroes/42', 'should nav to HeroDetail for Hero 42');
});
});
module imports setups, ActivatedRouteStub, page object as component stub
// --- hero-detail.component.ts
import { HeroDetailService } from './hero-detail.service';
@Component({
selector: 'app-hero-detail',
template: `
<div *ngIf="hero">
<h2><span>{{hero.name | titlecase}}</span> Details</h2>
<div>
<label>id: </label>{{hero.id}}</div>
<div>
<label for="name">name: </label>
<input id="name" [(ngModel)]="hero.name" placeholder="name" />
</div>
<button (click)="save()">Save</button>
<button (click)="cancel()">Cancel</button>
</div>`,
providers: [ HeroDetailService ]
})
export class HeroDetailComponent implements OnInit {
constructor(
private heroDetailService: HeroDetailService,
private route: ActivatedRoute,
private router: Router) {
}
@Input() hero: Hero;
ngOnInit(): void {
// get hero when `id` param changes
this.route.paramMap.subscribe(pmap => this.getHero(pmap.get('id')));
}
private getHero(id: string): void {
// when no id or id===0, create new blank hero
if (!id) {
this.hero = { id: 0, name: '' } as Hero;
return;
}
this.heroDetailService.getHero(id).subscribe(hero => {
if (hero) {
this.hero = hero;
} else {
this.gotoList(); // id not found; navigate to list
}
});
}
save(): void {
this.heroDetailService.saveHero(this.hero).subscribe(() => this.gotoList());
}
cancel() { this.gotoList(); }
gotoList() {
this.router.navigate(['../'], {relativeTo: this.route});
}
}
// --- hero-list.component.ts
import { HeroService } from '../model/hero.service';
@Component({
selector: 'app-heroes',
templateUrl: `
<ul class="heroes">
<li *ngFor="let hero of heroes | async "
[class.selected]="hero === selectedHero"
(click)="onSelect(hero)">
<span class="badge">{{hero.id}}</span> {{hero.name}}
</li>
</ul>`
})
export class HeroListComponent implements OnInit {
heroes: Observable<Hero[]>;
selectedHero: Hero;
constructor(
private router: Router,
private heroService: HeroService
) { }
ngOnInit() {
this.heroes = this.heroService.getHeroes();
}
onSelect(hero: Hero) {
this.selectedHero = hero;
this.router.navigate(['../heroes', this.selectedHero.id ]);
}
}
// --- --- TEST
--- hero-detail.component.spec.ts
import {
waitForAsync, ComponentFixture, fakeAsync, inject, TestBed, tick
} from '@angular/core/testing';
import { Router } from '@angular/router';
import {
ActivatedRoute, ActivatedRouteStub, asyncData, click, newEvent
} from '../../testing';
import { Hero } from '../model/hero';
import { HeroDetailComponent } from './hero-detail.component';
import { HeroDetailService } from './hero-detail.service';
import { HeroModule } from './hero.module';
// ---
let activatedRoute: ActivatedRouteStub;
let component: HeroDetailComponent;
let fixture: ComponentFixture<HeroDetailComponent>;
let page: Page;
// --- TESTS CALLS
describe('HeroDetailComponent', () => {
beforeEach(() => {
activatedRoute = new ActivatedRouteStub();
});
// --- import own feature module, when many mutual dependencies within the module
describe('with HeroModule setup', heroModuleSetup);
// --- fake all necessary features of a nested services with stub spy
describe('when override its provided HeroDetailService', overrideSetup);
// --- configure testing module from individual pieces
describe('with FormsModule setup', formsModuleSetup);
// --- combine frequently requested parts
describe('with SharedModule setup', sharedModuleSetup);
});
// --- HELPERS
// create the HeroDetailComponent, initialize it, set test variables
function createComponent() {
fixture = TestBed.createComponent(HeroDetailComponent);
component = fixture.componentInstance;
page = new Page(fixture);
// 1st change detection triggers ngOnInit which gets a hero
fixture.detectChanges();
return fixture.whenStable().then(() => {
// 2nd change detection displays the async-fetched hero
fixture.detectChanges();
});
}
class Page {
// getter properties wait to query the DOM until called.
get buttons() {
return this.queryAll<HTMLButtonElement>('button'); }
get saveBtn() {
return this.buttons[0]; }
get cancelBtn() {
return this.buttons[1]; }
get nameDisplay() {
return this.query<HTMLElement>('span'); }
get nameInput() {
return this.query<HTMLInputElement>('input'); }
gotoListSpy: jasmine.Spy;
navigateSpy: jasmine.Spy;
constructor(fixture: ComponentFixture<HeroDetailComponent>) {
// get the navigate spy from the injected router spy object
const routerSpy = <any> fixture.debugElement.injector.get(Router);
this.navigateSpy = routerSpy.navigate;
// spy on component `gotoList()` method
const component = fixture.componentInstance;
this.gotoListSpy = spyOn(component, 'gotoList').and.callThrough();
}
// query helpers
private query<T>(selector: string): T {
return fixture.nativeElement.querySelector(selector);
}
private queryAll<T>(selector: string): T[] {
return fixture.nativeElement.querySelectorAll(selector);
}
}
function createRouterSpy() {
return jasmine.createSpyObj('Router', ['navigate']);
}
// ActivatedRouteStub - ActivateRoute test double with a `paramMap` observable
// use the `setParamMap()` method to add the next `paramMap` value
export { ActivatedRoute } from '@angular/router';
import { convertToParamMap, ParamMap, Params } from '@angular/router';
import { ReplaySubject } from 'rxjs';
export class ActivatedRouteStub {
// Use a ReplaySubject to share previous values with subscribers
// and pump new values into the `paramMap` observable
private subject = new ReplaySubject<ParamMap>();
constructor(initialParams?: Params) {
this.setParamMap(initialParams);
}
// The mock paramMap observable
readonly paramMap = this.subject.asObservable();
// Set the paramMap observables's next value
setParamMap(params?: Params) {
this.subject.next(convertToParamMap(params));
};
}
--- --- heroModuleSetup() - import own feature module
import {
getTestHeroes, TestHeroService, HeroService
} from '../model/testing/test-hero.service';
const firstHero = getTestHeroes()[0];
function heroModuleSetup() {
beforeEach(waitForAsync(() => {
const routerSpy = createRouterSpy();
TestBed.configureTestingModule({
imports: [ HeroModule ],
// declarations: [ HeroDetailComponent ], // NO! DOUBLE DECLARATION
providers: [
{ provide: ActivatedRoute, useValue: activatedRoute },
{ provide: HeroService, useClass: TestHeroService },
{ provide: Router, useValue: routerSpy},
]
}).compileComponents();
}));
describe('when navigate to existing hero', () => {
let expectedHero: Hero;
beforeEach(waitForAsync(() => {
expectedHero = firstHero;
activatedRoute.setParamMap({ id: expectedHero.id });
createComponent();
}));
it('should display that hero name', () => {
expect(page.nameDisplay.textContent).toBe(expectedHero.name);
});
it('should navigate when click cancel', () => {
click(page.cancelBtn);
expect(page.navigateSpy.calls.any())
.toBe(true, 'router.navigate called');
});
it('should save when click save but not navigate immediately', () => {
// Get service injected into component and spy on its`saveHero` method.
// It delegates to fake `HeroService.updateHero` which delivers a safe test result.
const hds = fixture.debugElement.injector.get(HeroDetailService);
const saveSpy = spyOn(hds, 'saveHero').and.callThrough();
click(page.saveBtn);
expect(saveSpy.calls.any())
.toBe(true, 'HeroDetailService.save called');
expect(page.navigateSpy.calls.any())
.toBe(false, 'router.navigate not called');
});
it('should navigate when click save and save resolves', fakeAsync(() => {
click(page.saveBtn);
tick(); // wait for async save to complete
expect(page.navigateSpy.calls.any())
.toBe(true, 'router.navigate called');
}));
it('should convert hero name to Title Case', () => {
// get the name's input and display elements from the DOM
const hostElement = fixture.nativeElement;
const nameInput: HTMLInputElement = hostElement.querySelector('input');
const nameDisplay: HTMLElement = hostElement.querySelector('span');
// simulate user entering a new name into the input box
nameInput.value = 'quick BROWN fOx';
// dispatch a DOM event so that Angular learns of input value change.
nameInput.dispatchEvent(newEvent('input'));
// Tell Angular to update the display binding through the title pipe
fixture.detectChanges();
expect(nameDisplay.textContent).toBe('Quick Brown Fox');
});
});
describe('when navigate with no hero id', () => {
beforeEach(waitForAsync( createComponent ));
it('should have hero.id === 0', () => {
expect(component.hero.id).toBe(0);
});
it('should display empty hero name', () => {
expect(page.nameDisplay.textContent).toBe('');
});
});
describe('when navigate to non-existent hero id', () => {
beforeEach(waitForAsync(() => {
activatedRoute.setParamMap({ id: 99999 });
createComponent();
}));
it('should try to navigate back to hero list', () => {
expect(page.gotoListSpy.calls.any())
.toBe(true, 'comp.gotoList called');
expect(page.navigateSpy.calls.any())
.toBe(true, 'router.navigate called');
});
});
// Why we must use `fixture.debugElement.injector` in `Page()`
it('cannot use `inject` to get component provided HeroDetailService', () => {
let service: HeroDetailService;
fixture = TestBed.createComponent(HeroDetailComponent);
expect(
// Throws because `inject` only has access to TestBed's injector
// which is an ancestor of the component's injector
inject([HeroDetailService], (hds: HeroDetailService) => service = hds )
).toThrowError(/No provider for HeroDetailService/);
// get `HeroDetailService` with component's own injector
service = fixture.debugElement.injector.get(HeroDetailService);
expect(service).toBeDefined('debugElement.injector');
});
}
--- --- overrideSetup() - fake all necessary features of a nested services with stub spy
function overrideSetup() {
class HeroDetailServiceSpy {
testHero: Hero = {id: 42, name: 'Test Hero' };
// emit cloned test hero
getHero = jasmine.createSpy('getHero').and.callFake(
() => asyncData(Object.assign({}, this.testHero))
);
// emit clone of test hero, with changes merged in
saveHero = jasmine.createSpy('saveHero').and.callFake(
(hero: Hero) => asyncData(Object.assign(this.testHero, hero))
);
}
// the "id" value is irrelevant because ignored by service stub
beforeEach(() => activatedRoute.setParamMap({ id: 99999 }));
beforeEach(waitForAsync(() => {
const routerSpy = createRouterSpy();
TestBed.configureTestingModule({
imports: [ HeroModule ],
providers: [
{ provide: ActivatedRoute, useValue: activatedRoute },
{ provide: Router, useValue: routerSpy},
// HeroDetailService at this level is IRRELEVANT !
{ provide: HeroDetailService, useValue: {} }
]
})
// Override component own provider
.overrideComponent(HeroDetailComponent, {
set: {
providers: [
{ provide: HeroDetailService, useClass: HeroDetailServiceSpy }
]
}
}).compileComponents();
}));
let hdsSpy: HeroDetailServiceSpy;
beforeEach(waitForAsync(() => {
createComponent();
// get the component's injected HeroDetailServiceSpy
hdsSpy = fixture.debugElement.injector.get(HeroDetailService) as any;
}));
it('should have called `getHero`', () => {
expect(hdsSpy.getHero.calls.count())
.toBe(1, 'getHero called once');
});
it('should display stub hero name', () => {
expect(page.nameDisplay.textContent)
.toBe(hdsSpy.testHero.name);
});
it('should save stub hero change', fakeAsync(() => {
const origName = hdsSpy.testHero.name;
const newName = 'New Name';
page.nameInput.value = newName;
page.nameInput.dispatchEvent(newEvent('input')); // tell Angular
expect(component.hero.name)
.toBe(newName, 'component hero has new name');
expect(hdsSpy.testHero.name)
.toBe(origName, 'service hero unchanged before save');
click(page.saveBtn);
expect(hdsSpy.saveHero.calls.count())
.toBe(1, 'saveHero called once');
tick(); // wait for async save to complete
expect(hdsSpy.testHero.name)
.toBe(newName, 'service hero has new name after save');
expect(page.navigateSpy.calls.any())
.toBe(true, 'router.navigate called');
}));
it('fixture injected service is not the component injected service',
// inject gets the service from the fixture
inject([HeroDetailService], (fixtureService: HeroDetailService) => {
// use `fixture.debugElement.injector` to get service from component
const componentService = fixture.debugElement.injector.get(HeroDetailService);
expect(fixtureService)
.not.toBe(componentService, 'service injected from fixture');
}));
}
--- --- formsModuleSetup() - configure testing module from individual pieces
import { FormsModule } from '@angular/forms';
import { TitleCasePipe } from '../shared/title-case.pipe';
function formsModuleSetup() {
beforeEach(waitForAsync(() => {
const routerSpy = createRouterSpy();
TestBed.configureTestingModule({
imports: [ FormsModule ],
declarations: [ HeroDetailComponent, TitleCasePipe ],
providers: [
{ provide: ActivatedRoute, useValue: activatedRoute },
{ provide: HeroService, useClass: TestHeroService },
{ provide: Router, useValue: routerSpy},
]
}).compileComponents();
}));
it('should display 1st hero name', waitForAsync(() => {
const expectedHero = firstHero;
activatedRoute.setParamMap({ id: expectedHero.id });
createComponent().then(() => {
expect(page.nameDisplay.textContent).toBe(expectedHero.name);
});
}));
}
--- --- sharedModuleSetup() - combine frequently requested parts
import { SharedModule } from '../shared/shared.module';
function sharedModuleSetup() {
beforeEach(waitForAsync(() => {
const routerSpy = createRouterSpy();
TestBed.configureTestingModule({
imports: [ SharedModule ],
declarations: [ HeroDetailComponent ],
providers: [
{ provide: ActivatedRoute, useValue: activatedRoute },
{ provide: HeroService, useClass: TestHeroService },
{ provide: Router, useValue: routerSpy},
]
})
.compileComponents();
}));
it('should display 1st hero name', waitForAsync(() => {
const expectedHero = firstHero;
activatedRoute.setParamMap({ id: expectedHero.id });
createComponent().then(() => {
expect(page.nameDisplay.textContent).toBe(expectedHero.name);
});
}));
}
// --- shared.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HighlightDirective } from './highlight.directive';
import { TitleCasePipe } from './title-case.pipe';
@NgModule({
imports: [ CommonModule ],
exports: [
CommonModule,
// SharedModule importers won't have to import FormsModule too
FormsModule,
HighlightDirective,
TitleCasePipe
],
declarations: [ HighlightDirective, TitleCasePipe ]
})
export class SharedModule { }
--- hero-detail.component.no-testbed.spec.ts
import { asyncData, ActivatedRouteStub } from '../../testing';
import { HeroDetailComponent } from './hero-detail.component';
import { Hero } from '../model/hero';
describe('HeroDetailComponent - no TestBed', () => {
let activatedRoute: ActivatedRouteStub;
let comp: HeroDetailComponent;
let expectedHero: Hero;
let hds: any;
let router: any;
beforeEach((done: DoneFn) => {
expectedHero = {id: 42, name: 'Bubba' };
const activatedRoute = new ActivatedRouteStub({ id: expectedHero.id });
router = jasmine.createSpyObj('router', ['navigate']);
hds = jasmine.createSpyObj('HeroDetailService', ['getHero', 'saveHero']);
hds.getHero.and.returnValue(asyncData(expectedHero));
hds.saveHero.and.returnValue(asyncData(expectedHero));
comp = new HeroDetailComponent(hds, <any> activatedRoute, router);
comp.ngOnInit();
// OnInit calls HDS.getHero; wait for it to get the fake hero
hds.getHero.calls.first().returnValue.subscribe(done);
});
it('should expose the hero retrieved from the service', () => {
expect(comp.hero).toBe(expectedHero);
});
it('should navigate when click cancel', () => {
comp.cancel();
expect(router.navigate.calls.any())
.toBe(true, 'router.navigate called');
});
it('should save when click save', () => {
comp.save();
expect(hds.saveHero.calls.any())
.toBe(true, 'HeroDetailService.save called');
expect(router.navigate.calls.any())
.toBe(false, 'router.navigate not called yet');
});
it('should navigate when click save resolves',
(done: DoneFn) => {
comp.save();
// waits for async save to complete before navigating
hds.saveHero.calls.first().returnValue
.subscribe(() => {
expect(router.navigate.calls.any())
.toBe(true, 'router.navigate called');
done();
});
});
});
--- hero-list.component.spec.ts
import {
waitForAsync, ComponentFixture, fakeAsync, TestBed, tick
} from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { DebugElement } from '@angular/core';
import { Router } from '@angular/router';
import { addMatchers, newEvent } from '../../testing';
import { getTestHeroes, TestHeroService } from '../model/testing/test-hero.service';
import { HeroModule } from './hero.module';
import { HeroListComponent } from './hero-list.component';
import { HighlightDirective } from '../shared/highlight.directive';
import { HeroService } from '../model/hero.service';
const HEROES = getTestHeroes();
let comp: HeroListComponent;
let fixture: ComponentFixture<HeroListComponent>;
let page: Page;
describe('HeroListComponent', () => {
beforeEach(waitForAsync(() => {
addMatchers();
const routerSpy = jasmine.createSpyObj('Router', ['navigate']);
TestBed.configureTestingModule({
imports: [HeroModule],
providers: [
{ provide: HeroService, useClass: TestHeroService },
{ provide: Router, useValue: routerSpy}
]
})
.compileComponents()
.then(createComponent);
}));
it('should display heroes', () => {
expect(page.heroRows.length).toBeGreaterThan(0);
});
it('1st hero should match 1st test hero', () => {
const expectedHero = HEROES[0];
const actualHero = page.heroRows[0].textContent;
expect(actualHero).toContain(expectedHero.id.toString(), 'hero.id');
expect(actualHero).toContain(expectedHero.name, 'hero.name');
});
it('should select hero on click', fakeAsync(() => {
const expectedHero = HEROES[1];
const li = page.heroRows[1];
li.dispatchEvent(newEvent('click'));
tick();
// `.toEqual` because selectedHero is clone of expectedHero; see FakeHeroService
expect(comp.selectedHero).toEqual(expectedHero);
}));
it('should navigate to selected hero detail on click', fakeAsync(() => {
const expectedHero = HEROES[1];
const li = page.heroRows[1];
li.dispatchEvent(newEvent('click'));
tick();
// should have navigated
expect(page.navSpy.calls.any()).toBe(true, 'navigate called');
// composed hero detail will be URL like 'heroes/42'
// expect link array with the route path and hero id
// first argument to router.navigate is link array
const navArgs = page.navSpy.calls.first().args[0];
expect(navArgs[0]).toContain('heroes', 'nav to heroes detail URL');
expect(navArgs[1]).toBe(expectedHero.id, 'expected hero.id');
}));
it('should find `HighlightDirective` with `By.directive', () => {
// Can find DebugElement either by css selector or by directive
const h2 = fixture.debugElement.query(By.css('h2'));
const directive = fixture.debugElement.query(By.directive(HighlightDirective));
expect(h2).toBe(directive);
});
it('should color header with `HighlightDirective`', () => {
const h2 = page.highlightDe.nativeElement as HTMLElement;
const bgColor = h2.style.backgroundColor;
// different browsers report color values differently
const isExpectedColor = bgColor === 'gold' || bgColor === 'rgb(255, 215, 0)';
expect(isExpectedColor).toBe(true, 'backgroundColor');
});
it('the `HighlightDirective` is among the element providers', () => {
expect(page.highlightDe.providerTokens).toContain(HighlightDirective, 'HighlightDirective');
});
});
// --- helpers
// Create the component and set the `page` test variables
function createComponent() {
fixture = TestBed.createComponent(HeroListComponent);
comp = fixture.componentInstance;
// change detection triggers ngOnInit which gets a hero
fixture.detectChanges();
return fixture.whenStable().then(() => {
// got the heroes and updated component
// change detection updates the view
fixture.detectChanges();
page = new Page();
});
}
class Page {
heroRows: HTMLLIElement[];
highlightDe: DebugElement;
navSpy: jasmine.Spy;
constructor() {
const heroRowNodes = fixture.nativeElement.querySelectorAll('li');
this.heroRows = Array.from(heroRowNodes);
// Find the first element with an attached HighlightDirective
this.highlightDe = fixture.debugElement.query(By.directive(HighlightDirective));
// Get the component injected router navigation spy
const routerSpy = fixture.debugElement.injector.get(Router);
this.navSpy = routerSpy.navigate as jasmine.Spy;
};
}
router-outlet, RouterLink in nested or AppComponent (using stubs)
// --- --- COMPONENT
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
template: `
<app-banner></app-banner>
<app-welcome></app-welcome>
<nav>
<a routerLink="/dashboard">Dashboard</a>
<a routerLink="/heroes">Heroes</a>
<a routerLink="/about">About</a>
</nav>
<router-outlet></router-outlet>`
})
export class AppComponent { }
// --- --- TEST
--- app.component.spec.ts
import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { By } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { RouterLinkDirectiveStub } from '../testing';
// stubs
@Component({selector: 'app-banner', template: ''})
class BannerStubComponent {}
@Component({selector: 'router-outlet', template: ''})
class RouterOutletStubComponent { }
@Component({selector: 'app-welcome', template: ''})
class WelcomeStubComponent {}
let comp: AppComponent;
let fixture: ComponentFixture<AppComponent>;
// with STUBS
describe('AppComponent & TestModule', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [
AppComponent,
RouterLinkDirectiveStub,
BannerStubComponent,
RouterOutletStubComponent,
WelcomeStubComponent
]
})
.compileComponents().then(() => {
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance;
});
}));
tests();
});
// with NO_ERRORS_SCHEMA
describe('AppComponent & NO_ERRORS_SCHEMA', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [
AppComponent,
BannerStubComponent,
RouterLinkDirectiveStub
],
// tell Angular compiler to ignore unrecognized elements and attributes
// prevents the compiler from telling you about
// the missing components and attributes that you omitted inadvertently or misspelled
schemas: [ NO_ERRORS_SCHEMA ]
})
.compileComponents().then(() => {
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance;
});
}));
tests();
});
// with real root module
// Tricky because we are disabling the router and its configuration
// Better to use RouterTestingModule
describe('AppComponent & AppModule', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [ AppModule ]
})
// Get rid of app Router configuration otherwise many failures.
// Doing so removes Router declarations; add the Router stubs
.overrideModule(AppModule, {
remove: { imports: [ AppRoutingModule ] },
add: {
declarations: [ RouterLinkDirectiveStub, RouterOutletStubComponent ]
}
}).compileComponents()
.then(() => {
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance;
});
}));
tests();
});
function tests() {
let routerLinks: RouterLinkDirectiveStub[];
let linkDes: DebugElement[];
beforeEach(() => {
fixture.detectChanges(); // trigger initial data binding
// find DebugElements with an attached RouterLinkStubDirective
linkDes = fixture.debugElement
.queryAll(By.directive(RouterLinkDirectiveStub));
// get attached link directive instances
// using each DebugElement injector
routerLinks = linkDes.map(de => de.injector.get(RouterLinkDirectiveStub));
});
it('can instantiate the component', () => {
expect(comp).not.toBeNull();
});
it('can get RouterLinks from template', () => {
expect(routerLinks.length).toBe(3, 'should have 3 routerLinks');
expect(routerLinks[0].linkParams).toBe('/dashboard');
expect(routerLinks[1].linkParams).toBe('/heroes');
expect(routerLinks[2].linkParams).toBe('/about');
});
it('can click Heroes link in template', () => {
const heroesLinkDe = linkDes[1]; // heroes link DebugElement
const heroesLink = routerLinks[1]; // heroes link directive
expect(heroesLink.navigatedTo).toBeNull('should not have navigated yet');
heroesLinkDe.triggerEventHandler('click', null);
fixture.detectChanges();
expect(heroesLink.navigatedTo).toBe('/heroes');
});
}
// --- helpers
import { Directive, Input, HostListener } from '@angular/core';
// export for convenience
export { RouterLink } from '@angular/router';
// tslint:disable:directive-class-suffix
// emulate RouterLink
@Directive({ selector: '[routerLink]' })
export class RouterLinkDirectiveStub {
@Input('routerLink') linkParams: any;
navigatedTo: any = null;
@HostListener('click')
onClick() { this.navigatedTo = this.linkParams; }
}
/// Dummy module to satisfy Angular Language service. Never used.
import { NgModule } from '@angular/core';
@NgModule({ declarations: [ RouterLinkDirectiveStub ] })
export class RouterStubsModule {}
--- app.component.router.spec.ts
import { HeroService, TestHeroService } from './model/testing/test-hero.service';
let comp: AppComponent;
let fixture: ComponentFixture<AppComponent>;
let page: Page;
let router: Router;
let location: SpyLocation;
describe('AppComponent & RouterTestingModule', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [ AppModule, RouterTestingModule ],
providers: [
{ provide: HeroService, useClass: TestHeroService }
]
})
.compileComponents();
}));
it('should navigate to "Dashboard" immediately', fakeAsync(() => {
createComponent();
tick(); // wait for async data to arrive
expect(location.path()).toEqual('/dashboard', 'after initialNavigation()');
expectElementOf(DashboardComponent);
}));
it('should navigate to "About" on click', fakeAsync(() => {
createComponent();
click(page.aboutLinkDe);
// page.aboutLinkDe.nativeElement.click(); // ok but fails in phantom
advance();
expectPathToBe('/about');
expectElementOf(AboutComponent);
}));
it('should navigate to "About" w/ browser location URL change', fakeAsync(() => {
createComponent();
location.simulateHashChange('/about');
// location.go('/about'); // also works ... except, perhaps, in Stackblitz
advance();
expectPathToBe('/about');
expectElementOf(AboutComponent);
}));
// Can't navigate to lazy loaded modules with this technique
xit('should navigate to "Heroes" on click (not working yet)', fakeAsync(() => {
createComponent();
page.heroesLinkDe.nativeElement.click();
advance();
expectPathToBe('/heroes');
}));
});
// should be lazy loaded
import { HeroModule } from './hero/hero.module';
import { HeroListComponent } from './hero/hero-list.component';
// cant get lazy loaded Heroes to work yet
xdescribe('AppComponent & Lazy Loading (not working yet)', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [ AppModule, RouterTestingModule ]
})
.compileComponents();
}));
beforeEach(fakeAsync(() => {
createComponent();
router.resetConfig([{path: 'heroes', loadChildren: () => HeroModule}]);
}));
it('should navigate to "Heroes" on click', waitForAsync(() => {
page.heroesLinkDe.nativeElement.click();
advance();
expectPathToBe('/heroes');
expectElementOf(HeroListComponent);
}));
it('can navigate to "Heroes" w/ browser location URL change', fakeAsync(() => {
location.go('/heroes');
advance();
expectPathToBe('/heroes');
expectElementOf(HeroListComponent);
}));
});
// --- helpers
// advance to the routed page
// wait a tick, then detect changes, and tick again
function advance(): void {
tick(); // wait while navigating
fixture.detectChanges(); // update view
tick(); // wait for async data to arrive
}
function createComponent() {
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance;
const injector = fixture.debugElement.injector;
location = injector.get(Location) as SpyLocation;
router = injector.get(Router);
router.initialNavigation();
spyOn(injector.get(TwainService), 'getQuote')
// fake fast async observable
.and.returnValue(asyncData('Test Quote'));
advance();
page = new Page();
}
class Page {
aboutLinkDe: DebugElement;
dashboardLinkDe: DebugElement;
heroesLinkDe: DebugElement;
// for debugging
comp: AppComponent;
location: SpyLocation;
router: Router;
fixture: ComponentFixture<AppComponent>;
constructor() {
const links = fixture.debugElement.queryAll(By.directive(RouterLink));
this.aboutLinkDe = links[2];
this.dashboardLinkDe = links[0];
this.heroesLinkDe = links[1];
// for debugging
this.comp = comp;
this.fixture = fixture;
this.router = router;
}
}
function expectPathToBe(path: string, expectationFailOutput?: any) {
expect(location.path()).toEqual(path, expectationFailOutput || 'location.path()');
}
function expectElementOf(type: Type<any>): any {
const el = fixture.debugElement.query(By.directive(type));
expect(el).toBeTruthy('expected an element for ' + type.name);
return el;
}
import { defer } from 'rxjs';
// async observable that emits-once and completes after a JS engine turn
export function asyncData<T>(data: T) {
return defer(() => Promise.resolve(data));
}
// async observable error that errors after a JS engine turn
export function asyncError<T>(errorObject: any) {
return defer(() => Promise.reject(errorObject));
}
// button events to pass to `DebugElement.triggerEventHandler`
// for RouterLink event handler
export const ButtonClickEvents = {
left: { button: 0 },
right: { button: 2 }
};
// simulate element click, defaults to mouse left-button click event
export function click(
el: DebugElement | HTMLElement,
eventObj: any = ButtonClickEvents.left
): void {
if (el instanceof HTMLElement) {
el.click();
} else {
el.triggerEventHandler('click', eventObj);
}
}
Service testing
describe('testing', () => {
let service: ValueService;
// --- inject service inside test
it('should use ValueService', () => {
service = TestBed.inject(ValueService);
expect(service.getValue()).toBe('real value');
});
// --- OR inject the service as part of your setup
// set the preconditions for each it() test
// and rely on the TestBed to create classes and inject services
beforeEach(() => {
TestBed.configureTestingModule({ providers: [ValueService] });
service = TestBed.inject(ValueService);
});
// --- OR, while testing a service with a dependency
// provide the mock in the providers array, here mock is a spy object
let masterService: MasterService;
let valueServiceSpy: jasmine.SpyObj<ValueService>;
beforeEach(() => {
const spy = jasmine.createSpyObj('ValueService', ['getValue']);
TestBed.configureTestingModule({
providers: [
MasterService, // service to test
{ provide: ValueService, useValue: spy } // its (spy) dependency
]
});
masterService = TestBed.inject(MasterService); // injext service and
valueServiceSpy = TestBed.inject(ValueService); // its (spy) dependency
});
// test consumes that spy in the same way it did earlier
it('#getValue should return stubbed value from a spy', () => {
const stubValue = 'stub value';
valueServiceSpy.getValue.and.returnValue(stubValue);
expect(masterService.getValue())
.toBe(stubValue, 'service returned stub value');
expect(valueServiceSpy.getValue.calls.count())
.toBe(1, 'spy method was called once');
expect(valueServiceSpy.getValue.calls.mostRecent().returnValue)
.toBe(stubValue);
});
// --- create classes explicitly, MasterService (no beforeEach)
it('#getValue should return stubbed value from a spy', () => {
const { masterService, stubValue, valueServiceSpy } = setup();
expect(masterService.getValue())
.toBe(stubValue, 'service returned stub value');
expect(valueServiceSpy.getValue.calls.count())
.toBe(1, 'spy method was called once');
expect(valueServiceSpy.getValue.calls.mostRecent().returnValue)
.toBe(stubValue);
});
function setup() {
const valueServiceSpy =
jasmine.createSpyObj('ValueService', ['getValue']);
const stubValue = 'stub value';
const masterService = new MasterService(valueServiceSpy);
valueServiceSpy.getValue.and.returnValue(stubValue);
return { masterService, stubValue, valueServiceSpy };
}
// --- http services
let httpClientSpy: { get: jasmine.Spy };
let heroService: HeroService;
beforeEach(() => {
// TODO: spy on other methods too
httpClientSpy = jasmine.createSpyObj('HttpClient', ['get']);
heroService = new HeroService(<any> httpClientSpy);
});
it('should return expected heroes (HttpClient called once)', () => {
const expectedHeroes: Hero[] =
[{ id: 1, name: 'A' }, { id: 2, name: 'B' }];
httpClientSpy.get.and.returnValue(asyncData(expectedHeroes));
heroService.getHeroes().subscribe(
heroes => expect(heroes).toEqual(expectedHeroes, 'expected heroes'),
fail
);
expect(httpClientSpy.get.calls.count()).toBe(1, 'one call');
});
it('should return an error when the server returns a 404', () => {
const errorResponse = new HttpErrorResponse({
error: 'test 404 error',
status: 404, statusText: 'Not Found'
});
httpClientSpy.get.and.returnValue(asyncError(errorResponse));
heroService.getHeroes().subscribe(
heroes => fail('expected an error, not heroes'),
error => expect(error.message).toContain('test 404 error')
);
});
});
Directive testing
// --- --- DIRECTIVE
import { Directive, ElementRef, Input, OnChanges } from '@angular/core';
@Directive({ selector: '[highlight]' })
// set backgroundColor for the attached element to highlight color
// and set the element customProperty to true
export class HighlightDirective implements OnChanges {
defaultColor = 'rgb(211, 211, 211)'; // lightgray
@Input('highlight') bgColor: string;
constructor(private el: ElementRef) {
el.nativeElement.style.customProperty = true;
}
ngOnChanges() {
this.el.nativeElement.style.backgroundColor = this.bgColor || this.defaultColor;
}
}
// --- --- TEST
import { Component, DebugElement } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { HighlightDirective } from './highlight.directive';
import { newEvent } from '../../testing';
// artificial test component that demonstrates all ways to apply the directive
@Component({
template: `
<h2 highlight="yellow">Something Yellow</h2>
<h2 highlight>The Default (Gray)</h2>
<h2>No Highlight</h2>
<input #box [highlight]="box.value" value="cyan"/>`
})
class TestComponent { }
describe('HighlightDirective', () => {
let fixture: ComponentFixture<TestComponent>;
let des: DebugElement[]; // the three elements w/ the directive
let bareH2: DebugElement; // the <h2> w/o the directive
beforeEach(() => {
fixture = TestBed.configureTestingModule({
declarations: [ HighlightDirective, TestComponent ]
})
.createComponent(TestComponent);
fixture.detectChanges(); // initial binding
// all elements with an attached HighlightDirective
des = fixture.debugElement.queryAll(By.directive(HighlightDirective));
// the h2 without the HighlightDirective
bareH2 = fixture.debugElement.query(By.css('h2:not([highlight])'));
});
// color tests
it('should have three highlighted elements', () => {
expect(des.length).toBe(3);
});
it('should color 1st <h2> background "yellow"', () => {
const bgColor = des[0].nativeElement.style.backgroundColor;
expect(bgColor).toBe('yellow');
});
it('should color 2nd <h2> background w/ default color', () => {
const dir = des[1].injector.get(HighlightDirective) as HighlightDirective;
const bgColor = des[1].nativeElement.style.backgroundColor;
expect(bgColor).toBe(dir.defaultColor);
});
it('should bind <input> background to value color', () => {
// easier to work with nativeElement
const input = des[2].nativeElement as HTMLInputElement;
expect(input.style.backgroundColor).toBe('cyan', 'initial backgroundColor');
// dispatch a DOM event so that Angular responds to the input value change.
input.value = 'green';
input.dispatchEvent(newEvent('input'));
fixture.detectChanges();
expect(input.style.backgroundColor).toBe('green', 'changed backgroundColor');
});
it('bare <h2> should not have a customProperty', () => {
expect(bareH2.properties['customProperty']).toBeUndefined();
});
// injected directive
// attached HighlightDirective can be injected
it('can inject `HighlightDirective` in 1st <h2>', () => {
const dir = des[0].injector.get(HighlightDirective);
expect(dir).toBeTruthy();
});
it('cannot inject `HighlightDirective` in 3rd <h2>', () => {
const dir = bareH2.injector.get(HighlightDirective, null);
expect(dir).toBe(null);
});
// DebugElement.providerTokens
// attached HighlightDirective should be listed in the providerTokens
it('should have `HighlightDirective` in 1st <h2> providerTokens', () => {
expect(des[0].providerTokens).toContain(HighlightDirective);
});
it('should not have `HighlightDirective` in 3rd <h2> providerTokens', () => {
expect(bareH2.providerTokens).not.toContain(HighlightDirective);
});
});
// --- --- TEST COMPONENT
import { Component } from '@angular/core';
@Component({
template: `
<h2 highlight="skyblue">About</h2>
<h3>Quote of the day:</h3>
<twain-quote></twain-quote>`
})
export class AboutComponent { }
// --- --- TEST
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AboutComponent } from './about.component';
import { HighlightDirective } from '../shared/highlight.directive';
let fixture: ComponentFixture<AboutComponent>;
describe('AboutComponent (highlightDirective)', () => {
beforeEach(() => {
fixture = TestBed.configureTestingModule({
declarations: [ AboutComponent, HighlightDirective],
schemas: [ NO_ERRORS_SCHEMA ]
})
.createComponent(AboutComponent);
fixture.detectChanges(); // initial binding
});
it('should have skyblue <h2>', () => {
const h2: HTMLElement = fixture.nativeElement.querySelector('h2');
const bgColor = h2.style.backgroundColor;
expect(bgColor).toBe('skyblue');
});
});
Pipe testing
// --- --- PIPE
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({name: 'titlecase', pure: true})
// uppercase the first letter of the words in a string
export class TitleCasePipe implements PipeTransform {
transform(input: string): string {
return input.length === 0 ? '' :
input.replace(/\w\S*/g, (txt => txt[0].toUpperCase() + txt.substr(1).toLowerCase() ));
}
}
// --- --- TEST
import { TitleCasePipe } from './title-case.pipe';
describe('TitleCasePipe', () => {
// This pipe is a pure, stateless function so no need for BeforeEach
let pipe = new TitleCasePipe();
it('transforms "abc" to "Abc"', () => {
expect(pipe.transform('abc')).toBe('Abc');
});
it('transforms "abc def" to "Abc Def"', () => {
expect(pipe.transform('abc def')).toBe('Abc Def');
});
// ... more tests ...
it('leaves "Abc Def" unchanged', () => {
expect(pipe.transform('Abc Def')).toBe('Abc Def');
});
it('transforms "abc-def" to "Abc-def"', () => {
expect(pipe.transform('abc-def')).toBe('Abc-def');
});
it('transforms " abc def" to " Abc Def" (preserves spaces) ', () => {
expect(pipe.transform(' abc def')).toBe(' Abc Def');
});
});
// --- --- COMPONENT TEST
it('should convert hero name to Title Case', () => {
// get the name's input and display elements from the DOM
const hostElement = fixture.nativeElement;
const nameInput: HTMLInputElement = hostElement.querySelector('input');
const nameDisplay: HTMLElement = hostElement.querySelector('span');
// simulate user entering a new name into the input box
nameInput.value = 'quick BROWN fOx';
// dispatch a DOM event so that Angular learns of input value change.
nameInput.dispatchEvent(newEvent('input'));
// Tell Angular to update the display binding through the title pipe
fixture.detectChanges();
expect(nameDisplay.textContent).toBe('Quick Brown Fox');
});
HttpClientTestingModule
// --- Http testing module and mocking controller
import {
HttpClientTestingModule, HttpTestingController
} from '@angular/common/http/testing';
// Other imports
import { TestBed } from '@angular/core/testing';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { HttpHeaders } from '@angular/common/http';
interface Data {
name: string;
}
const testUrl = '/data';
describe('HttpClient testing', () => {
let httpClient: HttpClient;
let httpTestingController: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({ imports: [ HttpClientTestingModule ] });
// Inject the http service and test controller for each test
httpClient = TestBed.inject(HttpClient);
httpTestingController = TestBed.inject(HttpTestingController);
});
afterEach(() => {
// After every test, assert that there are no more pending requests
httpTestingController.verify();
});
/// Tests begin ///
it('can test HttpClient.get', () => {
const testData: Data = {name: 'Test Data'};
// Make an HTTP GET request
httpClient.get<Data>(testUrl)
.subscribe(data =>
// When observable resolves, result should match test data
expect(data).toEqual(testData)
);
// The following `expectOne()` will match the request's URL.
// If no requests or multiple requests matched that URL
// `expectOne()` would throw.
const req = httpTestingController.expectOne('/data');
// Assert that the request is a GET.
expect(req.request.method).toEqual('GET');
// Respond with mock data, causing Observable to resolve.
// Subscribe callback asserts that correct data was returned.
req.flush(testData);
// Finally, assert that there are no outstanding requests.
httpTestingController.verify();
});
it('can test HttpClient.get with matching header', () => {
const testData: Data = {name: 'Test Data'};
// Make an HTTP GET request with specific header
httpClient.get<Data>(testUrl, {
headers: new HttpHeaders({'Authorization': 'my-auth-token'})
})
.subscribe(data =>
expect(data).toEqual(testData)
);
// Find request with a predicate function.
// Expect one request with an authorization header
const req = httpTestingController.expectOne(
req => req.headers.has('Authorization')
);
req.flush(testData);
});
it('can test multiple requests', () => {
let testData: Data[] = [
{ name: 'bob' }, { name: 'carol' },
{ name: 'ted' }, { name: 'alice' }
];
// Make three requests in a row
httpClient.get<Data[]>(testUrl)
.subscribe(d => expect(d.length).toEqual(0, 'should have no data'));
httpClient.get<Data[]>(testUrl)
.subscribe(d => expect(d).toEqual([testData[0]], 'should be one element array'));
httpClient.get<Data[]>(testUrl)
.subscribe(d => expect(d).toEqual(testData, 'should be expected data'));
// get all pending requests that match the given URL
const requests = httpTestingController.match(testUrl);
expect(requests.length).toEqual(3);
// Respond to each request with different results
requests[0].flush([]);
requests[1].flush([testData[0]]);
requests[2].flush(testData);
});
it('can test for 404 error', () => {
const emsg = 'deliberate 404 error';
httpClient.get<Data[]>(testUrl).subscribe(
data => fail('should have failed with the 404 error'),
(error: HttpErrorResponse) => {
expect(error.status).toEqual(404, 'status');
expect(error.error).toEqual(emsg, 'message');
}
);
const req = httpTestingController.expectOne(testUrl);
// Respond with mock error
req.flush(emsg, { status: 404, statusText: 'Not Found' });
});
it('can test for network error', () => {
const emsg = 'simulated network error';
httpClient.get<Data[]>(testUrl).subscribe(
data => fail('should have failed with the network error'),
(error: HttpErrorResponse) => {
expect(error.error.message).toEqual(emsg, 'message');
}
);
const req = httpTestingController.expectOne(testUrl);
// Create mock ErrorEvent, raised when something goes wrong at the network level.
// Connection timeout, DNS error, offline, etc
const mockError = new ErrorEvent('Network error', {
message: emsg,
// The rest of this is optional and not used.
// Just showing that you could provide this too.
filename: 'HeroService.ts',
lineno: 42,
colno: 21
});
// Respond with mock error
req.error(mockError);
});
it('httpTestingController.verify should fail if HTTP response not simulated', () => {
// Sends request
httpClient.get('some/api').subscribe();
// verify() should fail because haven't handled the pending request.
expect(() => httpTestingController.verify()).toThrow();
// Now get and flush the request so that afterEach() doesn't fail
const req = httpTestingController.expectOne('some/api');
req.flush(null);
});
// Proves that verify in afterEach() really would catch error
// if test doesnt simulate the HTTP response
//
// Must disable this test because can't catch an error in an afterEach()
// Uncomment if you want to confirm that afterEach() does the job
// it('afterEach() should fail when HTTP response not simulated',() => {
// // Sends request which is never handled by this test
// httpClient.get('some/api').subscribe();
// });
});
straight service Jasmine testing without Angular testing support
describe('ValueService', () => {
let service: ValueService;
beforeEach(() => { service = new ValueService(); });
it('#getValue should return real value', () => {
expect(service.getValue()).toBe('real value');
});
it('#getObservableValue should return value from observable',
(done: DoneFn) => {
service.getObservableValue().subscribe(value => {
expect(value).toBe('observable value');
done();
});
});
it('#getPromiseValue should return value from a promise',
(done: DoneFn) => {
service.getPromiseValue().then(value => {
expect(value).toBe('promise value');
done();
});
});
});
// --- testing a dependent service
describe('MasterService without Angular testing support', () => {
let masterService: MasterService;
it('#getValue should return real value from the real service', () => {
masterService = new MasterService(new ValueService());
expect(masterService.getValue()).toBe('real value');
});
it('#getValue should return faked value from a fakeService', () => {
masterService = new MasterService(new FakeValueService());
expect(masterService.getValue()).toBe('faked service value');
});
it('#getValue should return faked value from a fake object', () => {
const fake = { getValue: () => 'fake value' };
masterService = new MasterService(fake as ValueService);
expect(masterService.getValue()).toBe('fake value');
});
it('#getValue should return stubbed value from a spy', () => {
// create `getValue` spy on an object representing the ValueService
const valueServiceSpy =
jasmine.createSpyObj('ValueService', ['getValue']);
// set the value to return when the `getValue` spy is called.
const stubValue = 'stub value';
valueServiceSpy.getValue.and.returnValue(stubValue);
masterService = new MasterService(valueServiceSpy);
expect(masterService.getValue())
.toBe(stubValue, 'service returned stub value');
expect(valueServiceSpy.getValue.calls.count())
.toBe(1, 'spy method was called once');
expect(valueServiceSpy.getValue.calls.mostRecent().returnValue)
.toBe(stubValue);
});
});
configure CLI for CI testing in Chrome (Headless Chrome)
// karma.conf.js
browsers: ['Chrome'],
customLaunchers: {
ChromeHeadlessCI: {
base: 'ChromeHeadless',
flags: ['--no-sandbox']
}
},
// protractor-ci.conf.js inside e2e tests project
// extends the original protractor.conf.js
const config = require('./protractor.conf').config;
config.capabilities = {
browserName: 'chrome',
chromeOptions: {
args: ['--headless', '--no-sandbox']
}
};
exports.config = config;
// run the following commands to use the --no-sandbox flag
ng test -- --no-watch --no-progress --browsers=ChromeHeadlessCI
ng e2e -- --protractor-config=e2e/protractor-ci.conf.js
configure project for Circle CI
# 1 - create a folder called .circleci at the project root
# 2 - create a file called config.yml
version: 2
jobs:
build:
working_directory: ~/my-project
docker:
- image: circleci/node:8-browsers
steps:
- checkout
- restore_cache:
key: my-project-{{ .Branch }}-{{ checksum "package-lock.json" }}
- run: npm install
- save_cache:
key: my-project-{{ .Branch }}-{{ checksum "package-lock.json" }}
paths:
- "node_modules"
- run: npm run test -- --no-watch --no-progress --browsers=ChromeHeadlessCI
- run: npm run e2e -- --protractor-config=e2e/protractor-ci.conf.js
# 3 - commit your changes and push them to your repository
# 4 - sign up for Circle CI and add your project
configure project for Travis CI
# 1 - create a file called .travis.yml at the project root
dist: trusty
sudo: false
language: node_js
node_js:
- "8"
addons:
apt:
sources:
- google-chrome
packages:
- google-chrome-stable
cache:
directories:
- ./node_modules
install:
- npm install
script:
- npm run test -- --no-watch --no-progress --browsers=ChromeHeadlessCI
- npm run e2e -- --protractor-config=e2e/protractor-ci.conf.js
# 2 - commit your changes and push them to your repository
# 3 - sign up for Travis CI and add your project, push a commit to trigger build
Deployment
- 1 -
ng build --prod
- create production build inside output folder (dist/ by default)
- 2 - copy to server and configure the server to redirect requests for missing files to index.html
- deploy to GitHub pages
- create a GitHub account and a repository for project
-
build your project using Github project name, with the CLI:
ng build --prod --output-path docs --base-href /project_name/
- when the build is complete, make a copy of docs/index.html and name it docs/404.html
- commit your changes and push
- on the GitHub project page, configure it to publish from the docs folder
- see your deployed page at https://user_name.github.io/project_name/
- source-map-explorer - view bundle size
npm install source-map-explorer --save-dev
- install
ng build --prod --source-map
- production build with source maps
node_modules/.bin/source-map-explorer dist/file.js
- generate a graphical representation of one of the bundles or lazy loaded module
- Angular router uses the base href as the base path to component, template, and module files, when the URL to load the app is something like http://www.mysite.com/my/app/, the subfolder is my/app/ and you should add <base href="/my/app/"> to the server version of the index.html, app fails to load and the browser console displays 404-NotFound errors for the missing files, look at where it tried to find those files and adjust the base tag appropriately
- analyze bundle for third party or unremoved package
npm install -g webpack-bundle-analyzer
- get an additional file stats.json:
run ng build --stats-json
, in app
webpack-bundle-analyzer path/to/your/stats.json
- browser will pop up the page at localhost:8888
- remove some packages not used anymore and/or larger than expected and could be replaced with another one and/or improperly imported (for example, 80% of moment.js is just locale data which is probably not needed)
Base href
import {Component, NgModule} from '@angular/core';
import {APP_BASE_HREF} from '@angular/common';
@NgModule({
providers: [{provide: APP_BASE_HREF, useValue: '/my/app'}]
})
class AppModule {}
fallback server configuration - routed apps must fallback to index.htm
# --- Apache: .htaccess rewrite rule
RewriteEngine On
# If an existing asset or directory is requested go to it as it is
RewriteCond %{DOCUMENT_ROOT}%{REQUEST_URI} -f [OR]
RewriteCond %{DOCUMENT_ROOT}%{REQUEST_URI} -d
RewriteRule ^ - [L]
# If the requested resource doesn't exist, use index.html
RewriteRule ^ /index.html
# --- Nginx: use try_files, modified to serve index.html
try_files $uri $uri/ /index.html;
enable CORS
// ExpressJS
app.use(function(req, res, next) {
res.header("Access-Control-Allow-Origin", "*");
res.header(
"Access-Control-Allow-Headers",
"Origin,
X-Requested-With,
Content-Type,
Accept"
);
next();
});
app.get('/', function(req, res, next) {
// Handle the get for this route
});
app.post('/', function(req, res, next) {
// Handle the post for this route
});
// PHP
<?php
header("Access-Control-Allow-Origin: *");
# NGINX
# Wide-open CORS config for nginx
location / {
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
# Custom headers and headers various browsers *should* be OK with but arent
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
# Tell client that this pre-flight info is valid for 20 days
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
if ($request_method = 'POST') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
}
if ($request_method = 'GET') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
}
}
gzip static assets in a NodeJS + expressJS app
// check first "Response Headers" , "Content-Encoding: gzip"
const compression = require('compression')
const express = require('express')
const app = express()
app.use(compression())
RxJS
- RxJS (Reactive Extensions for JavaScript) - library for reactive programming using observables that makes it easier to compose asynchronous or callback-based code
- supports conversions for ReadableStreams e.g. from(readableStream)
- AsyncIterables such as those defined by IxJS or by async generators (async function*), may be passed to any API that accepts an observable, and can be converted to an Observable directly using "from"
- rxjs.dev/operator-decision-tree
Observable
// Observable - representation of any set of values over any amount of time
class Observable<T> implements Subscribable {
static create: (...args: any[]) => any
constructor(subscribe?: (this: Observable<T>, subscriber: Subscriber<T>) => TeardownLogic)
source: Observable<any> | undefined
operator: Operator<any, T> | undefined
lift<R>(operator?: Operator<T, R>): Observable<R>
subscribe(observerOrNext?: Partial<Observer<T>> | ((value: T) => void), error?: (error: any) => void, complete?: () => void): Subscription
forEach(next: (value: T) => void, promiseCtor?: PromiseConstructorLike): Promise<void>
pipe(...operations: OperatorFunction<any, any>[]): Observable<any>
toPromise(promiseCtor?: PromiseConstructorLike): Promise<T | undefined>
}
// subscribe with an Observer
const sumObserver = {
sum: 0,
next(value) {
console.log('Adding: ' + value);
this.sum = this.sum + value;
},
error() { // We could just remove this method,
}, // since we do not really care about errors right now
complete() { console.log('Sum equals: ' + this.sum); }
};
Rx.Observable.of(1, 2, 3) // Synchronously emits 1, 2, 3 and then completes
.subscribe(sumObserver);
// Logs: "Adding: 1" "Adding: 2" "Adding: 3" "Sum equals: 6"
// subscribe with functions
let sum = 0;
Rx.Observable.of(1, 2, 3)
.subscribe(
function(value) {
console.log('Adding: ' + value);
sum = sum + value;
},
undefined,
function() { console.log('Sum equals: ' + sum); }
);
// Logs: "Adding: 1" "Adding: 2" "Adding: 3" "Sum equals: 6"
// cancel a subscription
const subscription = Rx.Observable.interval(1000).subscribe(
num => console.log(num),
undefined,
() => console.log('completed!') // Will not be called, even
); // when cancelling subscription
setTimeout(() => {
subscription.unsubscribe();
console.log('unsubscribed!');
}, 2500);
// Logs: 0 after 1s , 1 after 2s , "unsubscribed!" after 2.5s
Subject
// --- Subject
// special type of Observable, allows values to be multicasted to many Observers
// is like EventEmitters
// every Subject is an Observable and an Observer
class Subject<T> extends Observable implements SubscriptionLike {
static create: (...args: any[]) => any
constructor()
closed: false
observers: Observer<T>[]
isStopped: false
hasError: false
thrownError: any
get observed
lift<R>(operator: Operator<T, R>): Observable<R>
next(value: T)
error(err: any)
complete()
unsubscribe()
asObservable(): Observable<T>
// inherited from index/Observable
static create: (...args: any[]) => any
constructor(subscribe?: (this: Observable<T>, subscriber: Subscriber<T>) => TeardownLogic)
source: Observable<any> | undefined
operator: Operator<any, T> | undefined
lift<R>(operator?: Operator<T, R>): Observable<R>
subscribe(observerOrNext?: Partial<Observer<T>> | ((value: T) => void), error?: (error: any) => void, complete?: () => void): Subscription
forEach(next: (value: T) => void, promiseCtor?: PromiseConstructorLike): Promise<void>
pipe(...operations: OperatorFunction<any, any>[]): Observable<any>
toPromise(promiseCtor?: PromiseConstructorLike): Promise<T | undefined>
}
// --- BehaviorSubject - get last message
// similar to a Subject except it requires an initial value as an argument
// to mark the starting point of the data stream,
// when we subscribe, returns the last message
// --- ReplaySubject - time travel
// once subscribed, broadcasts all messages, despite if we subscribed late or not
// access all the values that were broadcast
// --- AsyncSubject - once completed, get last message
// similar to BehaviorSubject in terms of emitting the last value once subscribed
// only difference, requires a complete() method to mark the stream as completed
// once that is done, the last value is emitted
Unsubscription
// --- take(n) - receive data amount of times before navigating
// --- takeUntil
done$ = new AsyncSubject();
myService$ = interval(1000)
.pipe(
tap(curr => console.log(curr)),
takeUntil(this.done$)
);
counter;
constructor() { }
ngOnInit() {
this.myService$
.subscribe(data => this.counter = data)
}
ngOnDestroy() {
this.done$.next('');
this.done$.complete();
}
// --- with async pipe, unsubscribe is automatic
// template: Counter: {{ myService$ | async }}
myService$ = interval(1000)
.pipe(tap(curr => console.log(curr)));
// counter
constructor() { }
ngOnInit() {
// this.myService$.subscribe(data => this.counter = data)
}
CREATION
// --- from - convert Promise, array-like, or an iterable object into an Observable
import { from } from 'rxjs';
const data = from(fetch('/api/endpoint'));
data.subscribe({
next(response) { console.log(response); },
error(err) { console.error('Error: ' + err); },
complete() { console.log('Completed'); }
});
// --- fromEvent - Observable from DOM events, or Node.js EventEmitter events or others
import { fromEvent } from 'rxjs';
// function fromEvent(target, eventName) { // is the same
// return new Observable((observer) => {
// const handler = (e) => observer.next(e);
// target.addEventListener(eventName, handler);
// return () => { target.removeEventListener(eventName, handler); };
// });
// }
const el = document.getElementById('my-element');
const mouseMoves = fromEvent(el, 'mousemove');
const subscription = mouseMoves.subscribe((evt: MouseEvent) => {
console.log(`Coords: ${evt.clientX} X ${evt.clientY}`);
});
// --- fromFetch - Observable from Fetch API, to make an HTTP request
import { of } from 'rxjs';
import { fromFetch } from 'rxjs/fetch';
import { switchMap, catchError } from 'rxjs/operators';
const data$ = fromFetch('https://api.github.com/users?per_page=5').pipe(
switchMap(response => {
if (response.ok) { return response.json(); }
else { return of({ error: true, message: `Error ${response.status}` }); }
}),
catchError(err => {
// Network or other error, handle appropriately
console.error(err);
return of({ error: true, message: err.message })
})
);
data$.subscribe({
next: result => console.log(result),
complete: () => console.log('done')
});
// with Chunked Transfer Encoding
// promise will resolve as soon as the response headers are received
import { of } from 'rxjs';
import { fromFetch } from 'rxjs/fetch';
const data$ = fromFetch('https://api.github.com/users?per_page=5', {
selector: response => response.json()
});
data$.subscribe({
next: result => console.log(result),
complete: () => console.log('done')
});
// --- of - converts the arguments to an observable sequence
import { of } from 'rxjs';
of(10, 20, 30)
.subscribe(
next => console.log('next:', next),
err => console.log('error:', err),
() => console.log('the end'),
);
// result: 'next: 10' , 'next: 20' , 'next: 30'
// --- observeOn
// re-emits all notifications from source Observable with specified scheduler.
// ensure values in subscribe are called just before browser repaint:
import { interval } from 'rxjs';
import { observeOn } from 'rxjs/operators';
// Intervals are scheduled with async scheduler by default...
const intervals = interval(10);
intervals.pipe(
// ...but we will observe on animationFrame scheduler to ensure smooth animation
observeOn(animationFrameScheduler),
).subscribe(val => {
someDiv.style.height = val + 'px';
});
// --- repeatWhen
// returns an Observable that mirrors the source Observable
// with the exception of a complete
// if the source Observable calls complete,
// this method will emit to the Observable returned from notifier
// if that Observable calls complete or error,
// then this method will call complete or error on the child subscription
// otherwise this method will resubscribe to the source Observable
repeatWhen<T>(
notifier: (notifications: Observable<any>) => Observable<any>
): MonoTypeOperatorFunction<T>
// --- repeat
// returns an Observable that repeats the stream of items
// emitted by the source Observable at most count times
repeat<T>(count: number = -1): MonoTypeOperatorFunction<T>
// --- publish
// makes a cold Observable hot.
// make source$ hot by applying publish operator,
// then merge each inner observable into a single one and subscribe:
import { of, zipWith, interval, merge } from "rxjs";
import { map, publish } from "rxjs/operators";
const source$ = zipWith(
interval(2000),
of(1, 2, 3, 4, 5, 6, 7, 8, 9),
).pipe(
map(values => values[1])
);
source$.pipe(
publish(multicasted$ => {
return merge(
multicasted$.pipe(tap(x => console.log('Stream 1:', x))),
multicasted$.pipe(tap(x => console.log('Stream 2:', x))),
multicasted$.pipe(tap(x => console.log('Stream 3:', x))),
);
})).subscribe();
/* results every two seconds:
Stream 1: 1
Stream 2: 1
Stream 3: 1
...
Stream 1: 9
Stream 2: 9
Stream 3: 9 */
// --- raceWith
// mirror first source Observable to emit a next, error or complete notification
// from the combination of the Observable to which the operator is applied and supplied Observables
import { interval } from 'rxjs';
import { mapTo, raceWith } from 'rxjs/operators';
const obs1 = interval(1000).pipe(mapTo('fast one'));
const obs2 = interval(3000).pipe(mapTo('medium one'));
const obs3 = interval(5000).pipe(mapTo('slow one'));
obs2.pipe(
raceWith(obs3, obs1)
).subscribe(
winner => console.log(winner)
); // outputs a series of 'fast one'
// --- animationFrames
// emits the the amount of time elapsed since subscription and timestamp on each animation frame
// defaults to milliseconds provided to the requestAnimationFrame callback
// does not end on its own.
// uuseful for setting up animations with RxJS.
// tweening a div to move it on the screen:
import { animationFrames } from 'rxjs';
import { map, takeWhile, endWith } from 'rxjs/operators';
function tween(start: number, end: number, duration: number) {
const diff = end - start;
return animationFrames().pipe(
map(({elapsed}) => elapsed / duration), // figure out what percentage of time has passed
takeWhile(v => v < 1), // take the vector while less than 100%
endWith(1), // finish with 100%
map(v => v * diff + start) // calculate the distance traveled between start and end
);
}
// setup a div to move around
const div = document.createElement('div');
document.body.appendChild(div);
div.style.position = 'absolute';
div.style.width = '40px';
div.style.height = '40px';
div.style.backgroundColor = 'lime';
div.style.transform = 'translate3d(10px, 0, 0)';
tween(10, 200, 4000).subscribe(x => {
div.style.transform = `translate3d(${x}px, 0, 0)`;
});
// providing a custom timestamp provider:
import { animationFrames, TimestampProvider } from 'rxjs';
let now = 0; // custom timestamp provider
const customTSProvider: TimestampProvider = { now() { return now++; } };
const source$ = animationFrames(customTSProvider);
source$.subscribe(({ elapsed }) => console.log(elapsed)); // 0...1...2... on every animation frame
COMBINATION
// --- combineLatest
// combine multiple Observables to create an Observable whose values
// are calculated from the latest values of each of its input Observables.
// - combine an array of Observables:
import { combineLatest, of } from 'rxjs';
import { delay, startWith } from 'rxjs/operators';
const observables = [1, 5, 10].map(
n => of(n).pipe(
delay(n * 1000), // emit 0 and then emit n after n seconds
startWith(0),
)
);
const combined = combineLatest(observables);
combined.subscribe(value => console.log(value)); // [0, 0, 0] (immediately) , [1, 0, 0] (in 1s) , [1, 5, 0] (in 5s) , [1, 5, 10] (in 10s)
// - combine two timer Observables:
import { combineLatest, timer } from 'rxjs'; // 0, 1, 2... after every second, starting from now
const firstTimer = timer(0, 1000); // 0, 1, 2... after every second, starting 0,5s from now
const secondTimer = timer(500, 1000);
const combinedTimers = combineLatest(firstTimer, secondTimer);
combinedTimers.subscribe(value => console.log(value)); // [0, 0] (in 0.5s) , [1, 0] (in 1s) , [1, 1] (in 1.5s) , [2, 1] (in 2s)
// - combine a dictionary of Observables:
import { combineLatest, of } from 'rxjs';
import { delay, startWith } from 'rxjs/operators';
const observables = {
a: of(1).pipe(delay(1000), startWith(0)),
b: of(5).pipe(delay(5000), startWith(0)),
c: of(10).pipe(delay(10000), startWith(0))
};
const combined = combineLatest(observables);
combined.subscribe(value => console.log(value));
// {a: 0, b: 0, c: 0} immediately
// {a: 1, b: 0, c: 0} after 1s
// {a: 1, b: 5, c: 0} after 5s
// {a: 1, b: 5, c: 10} after 10s
// - use map operator to dynamically calculate the Body-Mass Index:
import { combineLatest, of } from 'rxjs';
import { map } from 'rxjs/operators';
const weight = of(70, 72, 76, 79, 75);
const height = of(1.76, 1.77, 1.78);
const bmi = combineLatest([weight, height]).pipe(
map(([w, h]) => w / (h * h)),
);
bmi.subscribe(x => console.log('BMI is ' + x));
// BMI is 24.212293388429753
// BMI is 23.93948099205209
// BMI is 23.671253629592222
// --- combineLatestWith
// create an observable that combines the latest values
// from all passed observables and the source into arrays and emits them.
/ calculation from two inputs:
const input1 = document.createElement('input');
document.body.appendChild(input1);
const input2 = document.createElement('input');
document.body.appendChild(input2);
// get streams of changes
const input1Changes$ = fromEvent(input1, 'change');
const input2Changes$ = fromEvent(input2, 'change');
// combine the changes by adding them together
input1Changes$.pipe(
combineLatestWith(input2Changes$),
map(([e1, e2]) => Number(e1.target.value) + Number(e2.target.value)),
)
.subscribe(x => console.log(x));
// --- combineLatestAll
// takes an Observable of Observables, and collects all Observables from it
// once the outer Observable completes, it subscribes to all collected Observables
// and combines their values using the combineLatest strategy, such that:
// - every time an inner Observable emits, the output Observable emits
// when the returned observable emits, it emits all of the latest values by:
// - if a project function is provided, it is called with each recent value
// from each inner Observable in whatever order they arrived,
// and the result of the project function is what is emitted by the output Observable
// - if there is no project function,
// an array of all the most recent values is emitted by the output Observable
// map two click events to a finite interval Observable, then apply combineLatestAll
import { map, combineLatestAll, take } from 'rxjs/operators';
import { fromEvent } from 'rxjs/observable/fromEvent';
const clicks = fromEvent(document, 'click');
const higherOrder = clicks.pipe(
map(ev =>
interval(Math.random() * 2000).pipe(take(3))
),
take(2)
);
const result = higherOrder.pipe(
combineLatestAll()
);
result.subscribe(x => console.log(x));
// --- concat
// concatenates multiple Observables together
// by sequentially emitting their values, one Observable after the other
import { concat, interval, range } from 'rxjs';
import { take } from 'rxjs/operators';
// timer counting from 0 to 3 with a synchronous sequence from 1 to 10
const timer = interval(1000).pipe(take(4));
const sequence = range(1, 10);
const result = concat(timer, sequence);
result.subscribe(x => console.log(x));
// results in: 0 -1000ms-> 1 -1000ms-> 2 -1000ms-> 3 -immediate-> 1 ... 10
// --- concatWith
// emits all of the values from the source observable, then, once it completes,
// subscribes to each observable source provided, one at a time, emitting all of their values,
// and not subscribing to the next one until it completes.
// Listen for one mouse click, then listen for all mouse moves:
import { fromEvent } from 'rxjs';
import { concatWith } from 'rxjs/operators';
const clicks$ = fromEvent(document, 'click');
const moves$ = fromEvent(document, 'mousemove');
clicks$.pipe(
map(() => 'click'),
take(1),
concatWith(
moves$.pipe(
map(() => 'move')
)
)
)
.subscribe(x => console.log(x)); // 'click' 'move' 'move' 'move' ...
// --- mergeWith
// merge the values from all observables to an single observable result
import { fromEvent } from 'rxjs';
import { map, mergeWith } from 'rxjs/operators';
const clicks$ = fromEvent(document, 'click').pipe(map(() => 'click'));
const mousemoves$ = fromEvent(document, 'mousemove').pipe(map(() => 'mousemove'));
const dblclicks$ = fromEvent(document, 'dblclick').pipe(map(() => 'dblclick'));
mousemoves$.pipe( mergeWith(clicks$, dblclicks$) )
.subscribe(x => console.log(x));
// result (assuming user interactions)
// "mousemove"
// "mousemove"
// "mousemove"
// "click"
// "click"
// "dblclick"
// --- forkJoin
// accepts an Array of ObservableInput or a dictionary Object of ObservableInput
// returns an Observable that emits either an array of values in the exact same order as the passed array,
// or a dictionary of values in the same shape as the passed dictionary.
// dictionary of observable inputs:
import { forkJoin, of, timer } from 'rxjs';
const observable = forkJoin({
foo: of(1, 2, 3, 4),
bar: Promise.resolve(8),
baz: timer(4000),
});
observable.subscribe({
next: value => console.log(value),
complete: () => console.log('This is how it ends!'),
});
// { foo: 4, bar: 8, baz: 0 } after 4 seconds
// "This is how it ends!" immediately after
// array of observable inputs:
import { forkJoin, of, timer } from 'rxjs';
const observable = forkJoin([
of(1, 2, 3, 4),
Promise.resolve(8),
timer(4000),
]);
observable.subscribe({
next: value => console.log(value),
complete: () => console.log('This is how it ends!'),
});
// [4, 8, 0] after 4 seconds
// "This is how it ends!" immediately after
// --- startWith
// return an Observable that emits the items you specify as arguments
// before it begins to emit items emitted by the source Observable
import { of } from 'rxjs';
import { startWith } from 'rxjs/operators';
of("from source")
.pipe(startWith("first", "second"))
.subscribe(x => console.log(x));
// results: "first" "second" "from source"
// --- withLatestFrom
// combine the source Observable with other Observables
// to create an Observable whose values are calculated
// from the latest values of each, only when the source emits
import { fromEvent, interval } from 'rxjs';
import { withLatestFrom } from 'rxjs/operators';
const clicks = fromEvent(document, 'click');
const timer = interval(1000);
const result = clicks.pipe(withLatestFrom(timer));
result.subscribe(x => console.log(x));
// on every click event,
// emit an array with the latest timer event plus the click event
// --- zipWith , zipAll
// combines multiple Observables to create an Observable
// whose values are calculated from the values, in order,
// of each of its input Observables
import { zipWith, of } from 'rxjs';
import { map } from 'rxjs/operators';
let age$ = of<number>(27, 25, 29);
let name$ = of<string>('Foo', 'Bar', 'Beer');
let d$ = of<boolean>(true, true, false);
zipWith(age$, name$, d$).pipe(
map(([age, name, d]) => ({ age, name, d })),
).subscribe(x => console.log(x));
// outputs
// { age: 27, name: 'Foo', d: true }
// { age: 25, name: 'Bar', d: true }
// { age: 29, name: 'Beer', d: false }
// --- reduce
// combines together all values emitted on the source,
// using an accumulator function that knows how to join a new source value
// into the accumulation from the past
import { fromEvent, interval } from 'rxjs';
import { reduce, takeUntil, mapTo } from 'rxjs/operators';
const clicksInFiveSeconds = fromEvent(document, 'click').pipe(
takeUntil(interval(5000)),
);
const ones = clicksInFiveSeconds.pipe(mapTo(1));
const seed = 0;
const count = ones.pipe(reduce((acc, one) => acc + one, seed));
count.subscribe(x => console.log(x));
FILTERING
// --- debounceTime
// emits a value from the source Observable
// only after a particular time span has passed without another source emission
import { fromEvent } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
const clicks = fromEvent(document, 'click');
const result = clicks.pipe(debounceTime(1000));
result.subscribe(x => console.log(x));
// --- distinctUntilChanged
// returns an Observable that emits all items emitted by the source Observable
// that are distinct by comparison from the previous item
import { of } from 'rxjs';
import { distinctUntilChanged } from 'rxjs/operators';
interface Person { age: number, name: string }
of<Person>(
{ age: 4, name: 'Foo'},
{ age: 7, name: 'Bar'},
{ age: 5, name: 'Foo'},
{ age: 6, name: 'Foo'},
).pipe( // using a compare function
distinctUntilChanged((p: Person, q: Person) => p.name === q.name),
// distinctUntilKeyChanged('name'),
)
.subscribe(x => console.log(x));
// displays:
// { age: 4, name: 'Foo' }
// { age: 7, name: 'Bar' }
// { age: 5, name: 'Foo' }
// --- filter
// filter items emitted by the source Observable
// by only emitting those that satisfy a specified predicate
import { fromEvent } from 'rxjs';
import { filter } from 'rxjs/operators';
const clicks = fromEvent(document, 'click');
const clicksOnDivs = clicks.pipe(filter(ev => ev.target.tagName === 'DIV'));
clicksOnDivs.subscribe(x => console.log(x));
// --- partition
// like filter, but returns two Observables: one like the output of filter,
// and other with values that did not pass the condition.
// partition a set of numbers into odds and evens observables
import { of, partition } from 'rxjs';
const observableValues = of(1, 2, 3, 4, 5, 6);
const [evens$, odds$] = partition(observableValues, (value, index) => value % 2 === 0);
odds$.subscribe(x => console.log('odds', x));
evens$.subscribe(x => console.log('evens', x));
// odds 1 odds 3 odds 5 evens 2 evens 4 evens 6
// --- take
// emits only the first count values emitted by the source Observable
// take the first 5 seconds of an infinite 1-second interval Observable:
import { interval } from 'rxjs';
import { take } from 'rxjs/operators';
const intervalCount = interval(1000);
const takeFive = intervalCount.pipe(take(5));
takeFive.subscribe(x => console.log(x));
// Logs: 0 1 2 3 4
// --- takeUntil
// emits the values emitted by the source Observable
// until a notifier Observable emits a value
// tick every second until the first click happens:
import { fromEvent, interval } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
const source = interval(1000);
const clicks = fromEvent(document, 'click');
const result = source.pipe(takeUntil(clicks));
result.subscribe(x => console.log(x));
// --- takeWhile
// emits values emitted by the source Observable
// so long as each value satisfies the given predicate,
// and then completes as soon as this predicate is not satisfied
import { fromEvent } from 'rxjs';
import { takeWhile } from 'rxjs/operators';
const clicks = fromEvent(document, 'click');
const result = clicks.pipe(takeWhile(ev => ev.clientX > 200));
result.subscribe(x => console.log(x));
// --- firstValueFrom
// converts an observable to a promise by subscribing to the observable,
// returning a promise that will resolve as soon as the first value arrives from the observable
// will then be closed.
// wait for the first value from a stream and emit it from a promise in an async function:
import { interval, firstValueFrom } from 'rxjs';
async function execute() {
const source$ = interval(2000);
const firstNumber = await firstValueFrom(source$);
console.log(`The first number is ${firstNumber}`);
}
execute(); // "The first number is 0"
// --- lastValueFrom
// converts an observable to a promise by subscribing to the observable,
// waiting for it to complete, and resolving the returned promise with the last value from the observed stream.
// wait for the last value from a stream and emit it from a promise in an async function:
import { interval, lastValueFrom } from 'rxjs';
import { take } from 'rxjs/operators';
async function execute() {
const source$ = interval(2000).pipe(take(10));
const finalNumber = await lastValueFrom(source$);
console.log(`The final number is ${finalNumber}`);
}
execute(); // "The final number is 9"
// --- skip
// returns an Observable that skips the first count items emitted by the source Observable
skip<T>(count: number): MonoTypeOperatorFunction<T>
// --- skipLast
// skip the last count values emitted by the source Observable
import { range } from 'rxjs';
import { skipLast } from 'rxjs/operators';
const many = range(1, 5);
const skipLastTwo = many.pipe(skipLast(2));
skipLastTwo.subscribe(x => console.log(x)); // Results in: 1 2 3
// --- skipUntil
// returns an Observable that skips items emitted by the source Observable
// until a second Observable emits an item
skipUntil<T>(notifier: Observable<any>): MonoTypeOperatorFunction<T>
// --- skipWhile
// returns an Observable that skips all items emitted by the source Observable
// as long as a specified condition holds true,
// but emits all further source items as soon as the condition becomes false
skipWhile<T>(
predicate: (value: T, index: number) => boolean
): MonoTypeOperatorFunction<T>
// --- auditTime
// when it sees a source values, it ignores that plus the next ones
// for duration milliseconds, and then it emits the most recent value from the source.
// --- audit
// like auditTime, but the silencing duration is determined by a second Observable.
// emit clicks at a rate of at most one click per second:
import { fromEvent } from 'rxjs';
import { auditTime } from 'rxjs/operators';
const clicks = fromEvent(document, 'click');
const result = clicks.pipe(auditTime(1000));
// const result = clicks.pipe(audit(ev => interval(1000)));
result.subscribe(x => console.log(x));
// --- exhaustAll
// flattens an Observable-of-Observables by dropping the next
// inner Observables while the current inner is still executing.
// run a finite timer for each click, only if there is no currently active timer:
import { fromEvent, interval } from 'rxjs';
import { exhaustAll, map, take } from 'rxjs/operators';
const clicks = fromEvent(document, 'click');
const higherOrder = clicks.pipe(
map((ev) => interval(1000).pipe(take(5))),
);
const result = higherOrder.pipe(exhaustAll());
result.subscribe(x => console.log(x));
// --- exhaustMap
// maps each value to an Observable,
// then flattens all of these inner Observables using exhaustAll.
// run a finite timer for each click, only if there is no currently active timer:
import { fromEvent, } from 'rxjs';
import { exhaustMap, take } from 'rxjs/operators';
const clicks = fromEvent(document, 'click');
const result = clicks.pipe(
exhaustMap((ev) => interval(1000).pipe(take(5))),
);
result.subscribe(x => console.log(x));
TRANSFORMATION
// --- map
// applies a given project function to each value emitted by the source Observable,
// and emits the resulting values as an Observable
import { fromEvent } from 'rxjs';
import { map } from 'rxjs/operators';
const clicks = fromEvent(document, 'click');
const positions = clicks.pipe(map(ev => ev.clientX));
positions.subscribe(x => console.log(x));
// --- mergeMap
// maps each value to an Observable,
// then flattens all of these inner Observables using mergeAll
import { of, interval } from 'rxjs';
import { mergeMap, map } from 'rxjs/operators';
const letters = of('a', 'b', 'c');
const result = letters.pipe(
mergeMap(x => interval(1000).pipe(map(i => x+i))),
);
result.subscribe(x => console.log(x));
// Results in the following: a0 b0 c0 a1 b1 c1
// continues to list a,b,c with respective ascending integers
// --- mergeMapTo
// like mergeMap, but maps each value always to the same inner Observable
// --- mergeAll
// flattens an Observable-of-Observables.
// spawn a new interval Observable for each click event,
// and blend their outputs as one Observable:
import { fromEvent, interval } from 'rxjs';
import { map, mergeAll } from 'rxjs/operators';
const clicks = fromEvent(document, 'click');
const higherOrder = clicks.pipe(map((ev) => interval(1000)));
const firstOrder = higherOrder.pipe(mergeAll());
firstOrder.subscribe(x => console.log(x));
// count from 0 to 9 every second for each click,
// but only allow 2 concurrent timers:
import { fromEvent, interval } from 'rxjs';
import { take, map, mergeAll } from 'rxjs/operators';
const clicks = fromEvent(document, 'click');
const higherOrder = clicks.pipe(
map((ev) => interval(1000).pipe(take(10))),
);
const firstOrder = higherOrder.pipe(mergeAll(2));
firstOrder.subscribe(x => console.log(x));
// --- concatMap
// project each source value to an Observable
// which is merged in the output Observable, in a serialized fashion
// waiting for each one to complete before merging the next
import { fromEvent, interval } from 'rxjs';
import { concatMap, take } from 'rxjs/operators';
const clicks = fromEvent(document, 'click');
const result = clicks.pipe(
concatMap(ev => interval(1000).pipe(take(4)),
);
result.subscribe(x => console.log(x));
// Results in the following: (results are not concurrent)
// For every click on the "document" it will emit values 0 to 3 spaced
// on a 1000ms interval
// one click = 1000ms-> 0 -1000ms-> 1 -1000ms-> 2 -1000ms-> 3
// --- concatMapTo
// projects each source value to the same Observable
// which is merged multiple times in a serialized fashion on the output Observable
// like concatMap, but maps each value always to the same inner Observable
// for each click event, tick every second from 0 to 3, with no concurrency:
import { fromEvent, interval } from 'rxjs';
import { concatMapTo, take } from 'rxjs/operators';
const clicks = fromEvent(document, 'click');
const result = clicks.pipe(
concatMapTo(interval(1000).pipe(take(4))),
);
result.subscribe(x => console.log(x));
// for every click on the "document" it will emit values 0 to 3 spaced
// on a 1000ms interval one click = 1000ms-> 0 -1000ms-> 1 -1000ms-> 2 -1000ms-> 3
// --- switchMap
// projects each source value to an Observable which is merged in the output Observable,
// emitting values only from the most recently projected Observable
import { fromEvent, interval } from 'rxjs';
import { switchMap } from 'rxjs/operators';
const clicks = fromEvent(document, 'click');
const result = clicks.pipe(switchMap((ev) => interval(1000)));
result.subscribe(x => console.log(x));
// --- pluck
// like map, but picking one of the nested properties
// of every emitted object.
// map every click to the tagName of the clicked target element:
import { fromEvent } from 'rxjs';
import { pluck } from 'rxjs/operators';
const clicks = fromEvent(document, 'click');
const tagNames = clicks.pipe(pluck('target', 'tagName'));
tagNames.subscribe(x => console.log(x));
// --- scan
// applies an accumulator function over the source Observable,
// and returns each intermediate result, with an optional seed value
// count the number of click events:
import { fromEvent } from 'rxjs';
import { scan, mapTo } from 'rxjs/operators';
const clicks = fromEvent(document, 'click');
const ones = clicks.pipe(mapTo(1));
const seed = 0;
const count = ones.pipe(scan((acc, one) => acc + one, seed));
count.subscribe(x => console.log(x));
// --- mergeScan
// applies an accumulator function over the source Observable
// where the accumulator function itself returns an Observable,
//then each intermediate Observable returned is merged into the output Observable
import { fromEvent, of } from 'rxjs';
import { mapTo, mergeScan } from 'rxjs/operators';
const click$ = fromEvent(document, 'click');
const one$ = click$.pipe(mapTo(1));
const seed = 0;
const count$ = one$.pipe(
mergeScan((acc, one) => of(acc + one), seed),
);
count$.subscribe(x => console.log(x)); // 1 2 3 4 ...and so on for each click
// --- switchScan
// applies an accumulator function over the source Observable
// where the accumulator function itself returns an Observable,
// emitting values only from the most recently returned Observable.
// like scan, but only the most recent Observable returned by the accumulator is merged into the outer Observable
UTILITY
// --- tap
// perform a side effect for every emission on the source Observable,
// but return an Observable that is identical to the source
import { fromEvent } from 'rxjs';
import { tap, map } from 'rxjs/operators';
const clicks = fromEvent(document, 'click');
const positions = clicks.pipe(
tap(ev => console.log(ev)),
map(ev => ev.clientX),
);
positions.subscribe(x => console.log(x));
// --- count
// counts the number of emissions on the source and emits that number when the source completes
// how many seconds have passed before the first click happened:
import { fromEvent, interval } from 'rxjs';
import { count, takeUntil } from 'rxjs/operators';
const seconds = interval(1000);
const clicks = fromEvent(document, 'click');
const secondsBeforeClick = seconds.pipe(takeUntil(clicks));
const result = secondsBeforeClick.pipe(count());
result.subscribe(x => console.log(x));
// counts how many odd numbers are there between 1 and 7:
import { range } from 'rxjs';
import { count } from 'rxjs/operators';
const numbers = range(1, 7);
const result = numbers.pipe(count(i => i % 2 === 1));
result.subscribe(x => console.log(x));
// Results in: 4
// --- min , max
// operates on an Observable that emits numbers
// (or items that can be compared with a provided function),
// and when source Observable completes it emits
// a single item: the item with the smallest/largest value
// minimal value of a series of numbers
import { of } from 'rxjs';
import { min, max } from 'rxjs/operators';
of(5, 4, 7, 2, 8).pipe(
min(),
// max(),
).subscribe(x => console.log(x)); // -> 2
// use a comparer function to get the minimal item
import { of } from 'rxjs';
import { min } from 'rxjs/operators';
interface Person {
age: number,
name: string
}
of<Person>(
{age: 7, name: 'Foo'},
{age: 5, name: 'Bar'},
{age: 9, name: 'Beer'},
).pipe(
min<Person>( (a: Person, b: Person) => a.age < b.age ? -1 : 1),
// max<Person>( (a: Person, b: Person) => a.age < b.age ? -1 : 1),
).subscribe((x: Person) => console.log(x.name)); // -> 'Bar'
BUFFER , WINDOW
// --- buffer
// buffers the source Observable values until closingNotifier emits.
// --- window* - like buffer, but emit a nested Observable instead of an array.
// emit array of most recent interval events on every click:
import { fromEvent, interval } from 'rxjs';
import { buffer } from 'rxjs/operators';
// window
// import { window, mergeAll, map take } from 'rxjs/operators';
const clicks = fromEvent(document, 'click');
const interval = interval(1000);
const result = interval.pipe(buffer(clicks));
// every window of 1 second each, emit at most 2 click events:
// const result = clicks.pipe(
// window(sec),
// map(win => win.pipe(take(2))), // each window has at most 2 emissions
// mergeAll(), // flatten the Observable-of-Observables
// );
result.subscribe(x => console.log(x));
// --- bufferCount
// buffers the source Observable values
// until the size hits the maximum bufferSize given.
// emit the last two click events as an array:
import { fromEvent } from 'rxjs';
import { bufferCount } from 'rxjs/operators';
// for windowCount:
import { windowCount, map, mergeAll } from 'rxjs/operators';
const clicks = fromEvent(document, 'click');
const result = clicks.pipe(bufferCount(2));
// ignore every 3rd click event, starting from the first one:
// const result = clicks.pipe(
// windowCount(3)),
// map(win => win.skip(1)), // skip first of every 3 clicks
// mergeAll(), // flatten the Observable-of-Observables
// );
// ignore every 3rd click event, starting from the third one:
// windowCount(2, 3),
// mergeAll(), // flatten the Observable-of-Observables
// );
result.subscribe(x => console.log(x));
// --- bufferTime
// buffers the source Observable values for a specific time period
// every second, emit an array of the recent click events:
import { fromEvent } from 'rxjs';
import { bufferTime } from 'rxjs/operators';
const clicks = fromEvent(document, 'click');
const buffered = clicks.pipe(bufferTime(1000));
buffered.subscribe(x => console.log(x));
// every 5 seconds, emit the click events from the next 2 seconds:
import { fromEvent } from 'rxjs';
import { bufferTime } from 'rxjs/operators';
const clicks = fromEvent(document, 'click');
const buffered = clicks.pipe(bufferTime(2000, 5000));
buffered.subscribe(x => console.log(x));
// --- windowTime
// in every window of 1 second each, emit at most 2 click events
import { fromEvent } from 'rxjs';
import { windowTime, map, mergeAll } from 'rxjs/operators';
const clicks = fromEvent(document, 'click');
const result = clicks.pipe(
windowTime(1000),
map(win => win.take(2)), // each window has at most 2 emissions
mergeAll(), // flatten the Observable-of-Observables
);
result.subscribe(x => console.log(x));
// every 5 seconds start a window 1 second long,
// and emit at most 2 click events per window
const clicks = fromEvent(document, 'click');
const result = clicks.pipe(
windowTime(1000, 5000),
map(win => win.take(2)), // each window has at most 2 emissions
mergeAll(), // flatten the Observable-of-Observables
);
result.subscribe(x => console.log(x));
// same as example above but with maxWindowCount instead of take
const clicks = fromEvent(document, 'click');
const result = clicks.pipe(
windowTime(1000, 5000, 2), // each window has still at most 2 emissions
mergeAll(), // flatten the Observable-of-Observables
);
result.subscribe(x => console.log(x));
// --- bufferToggle
// buffers the source Observable values
// starting from an emission from openings
// and ending when the output of closingSelector emits
import { fromEvent, interval, empty } from 'rxjs';
import { bufferToggle } from 'rxjs/operators';
// windowToggle
// import { windowToggle, mergeAll } from 'rxjs/operators';
const clicks = fromEvent(document, 'click');
const openings = interval(1000);
const buffered = clicks.pipe(
bufferToggle(
openings, i => i % 2 ? interval(500) : empty()
)
// windowToggle(
// openings, i => i % 2 ? interval(500) : empty()
// ),
// mergeAll(),
);
buffered.subscribe(x => console.log(x));
// --- bufferWhen
// buffers the source Observable values,
// using a factory function of closing Observables to determine
// when to close, emit, and reset the buffer
import { fromEvent, interval } from 'rxjs';
import { bufferWhen } from 'rxjs/operators';
// windowWhen
// import { windowWhen, map, mergeAll } from 'rxjs/operators';
const clicks = fromEvent(document, 'click');
const buffered = clicks.pipe(
bufferWhen(
() => interval(1000 + Math.random() * 4000)
)
// windowWhen(
// () => interval(1000 + Math.random() * 4000)
// ),
// map(win => win.pipe(take(2))), // each window has at most 2 emissions
// mergeAll(), // flatten the Observable-of-Observables
);
buffered.subscribe(x => console.log(x));
// --- withLatestFrom
// whenever the source Observable emits a value,
// it computes a formula using that value
// plus the latest values from other input Observables,
// then emits the output of that formula
import { fromEvent, interval } from 'rxjs';
import { withLatestFrom } from 'rxjs/operators';
const clicks = fromEvent(document, 'click');
const timer = interval(1000);
const result = clicks.pipe(withLatestFrom(timer));
result.subscribe(x => console.log(x));
TIMING
// --- delay
// time shifts each item by some specified amount of milliseconds.
// delay each click by one second:
import { fromEvent } from 'rxjs';
import { delay } from 'rxjs/operators';
const clicks = fromEvent(document, 'click');
// each click emitted after 1 second
const delayedClicks = clicks.pipe(delay(1000));
delayedClicks.subscribe(x => console.log(x));
// delay all clicks until a future date happens:
import { fromEvent } from 'rxjs';
import { delay } from 'rxjs/operators';
const clicks = fromEvent(document, 'click');
const date = new Date('March 15, 2050 12:00:00'); // in the future
const delayedClicks = clicks.pipe(delay(date)); // click emitted only after that date
delayedClicks.subscribe(x => console.log(x));
// --- timeout
// errors if Observable does not emit a value in given time span.
// check if ticks are emitted within certain timespan:
import { interval } from 'rxjs';
import { timeout } from 'rxjs/operators';
const seconds = interval(1000);
// Lets use bigger timespan to be safe,
// since "interval" might fire a bit later then scheduled
seconds.pipe(timeout(1100)).subscribe(
// will emit numbers just as regular "interval" would
value => console.log(value),
err => console.log(err), // Will never be called
);
seconds.pipe(timeout(900)).subscribe(
value => console.log(value), // Will never be called
// will emit error before even first value is emitted,
// since it did not arrive within 900ms period
err => console.log(err)
);
// use Date to check if Observable completed:
import { interval } from 'rxjs';
import { timeout } from 'rxjs/operators';
const seconds = interval(1000);
seconds.pipe(
timeout(new Date("December 17, 2020 03:24:00")),
).subscribe(
// will emit values as regular "interval" would
// until December 17, 2020 at 03:24:00
value => console.log(value),
// on December 17, 2020 at 03:24:00 it will emit an error,
// since Observable did not complete by then
err => console.log(err)
);
// --- timeoutWith
// a version of timeout, lets you specify fallback Observable
// add fallback observable
import { intrerval } from 'rxjs';
import { timeoutWith } from 'rxjs/operators';
const seconds = interval(1000);
const minutes = interval(60 * 1000);
seconds.pipe(timeoutWith(900, minutes))
.subscribe(
// After 900ms, will start emitting `minutes`,
// since first value of `seconds` will not arrive fast enough.
value => console.log(value),
// Would be called after 900ms in case of `timeout`,
// but here will never be called.
err => console.log(err),
);
// --- timeInterval
// emits an object containing the current value,
// and the time that has passed
// between emitting the current value and the previous value,
// which is calculated by using the provided scheduler now() method
// to retrieve the current time at each emission,
// then calculating the difference
// scheduler defaults to async, so by default, the interval will be in milliseconds
// emit inteval between current value with the last value:
const seconds = interval(1000);
seconds.pipe(timeinterval()).subscribe(
value => console.log(value),
err => console.log(err),
);
seconds.pipe(timeout(900)).subscribe(
value => console.log(value),
err => console.log(err),
);
// NOTE: The values will never be this precise,
// intervals created with `interval` or `setInterval`
// are non-deterministic.
// {value: 0, interval: 1000}
// {value: 1, interval: 1000}
// {value: 2, interval: 1000}
MULTICASTING
// --- share(options: ShareConfig<T> = {}): Observable
// return a new Observable that multicasts (shares) the original Observable
// as long as there is at least one Subscriber
// this Observable will be subscribed and emitting data
// when all subscribers have unsubscribed
// it will unsubscribe from the source Observable
// because the Observable is multicasting it makes the stream hot
// this is an alias for multicast(() => new Subject()), refCount().
// - generate new multicast Observable from the source Observable value:
import { interval } from 'rxjs';
import { share, map } from 'rxjs/operators';
const source = interval(1000)
.pipe(
map((x: number) => {
console.log('Processing: ', x);
return x*x;
}),
share()
);
source.subscribe(x => console.log('subscription 1: ', x));
source.subscribe(x => console.log('subscription 1: ', x));
// Logs:
// Processing: 0
// subscription 1: 0
// subscription 1: 0
// Processing: 1
// subscription 1: 1
// subscription 1: 1
// Processing: 2
// subscription 1: 4
// subscription 1: 4
// Processing: 3
// subscription 1: 9
// subscription 1: 9
// ... and so on
// - with notifier factory, delayed reset:
import { interval } from 'rxjs';
import { share, take, timer } from 'rxjs/operators';
const source = interval(1000).pipe(take(3), share({ resetOnRefCountZero: () => timer(1000) }));
const subscriptionOne = source.subscribe(x => console.log('subscription 1: ', x));
setTimeout(() => subscriptionOne.unsubscribe(), 1300);
setTimeout(() => source.subscribe(x => console.log('subscription 2: ', x)), 1700);
setTimeout(() => source.subscribe(x => console.log('subscription 3: ', x)), 5000);
// Logs:
// subscription 1: 0
// (subscription 1 unsubscribes here)
// (subscription 2 subscribes here ~400ms later, source was not reset)
// subscription 2: 1
// subscription 2: 2
// (subscription 2 unsubscribes here)
// (subscription 3 subscribes here ~2000ms later, source did reset before)
// subscription 3: 0
// subscription 3: 1
// subscription 3: 2
// --- connect
// creates an observable by multicasting the source within a function
// that allows to define the usage of the multicast prior to connection.
// sharing a totally synchronous observable:
import { defer, of } from 'rxjs';
import { tap, connect } from 'rxjs/operators';
const source$ = defer(() => {
console.log('subscription started');
return of(1, 2, 3, 4, 5).pipe(
tap(n => console.log(`source emitted ${n}`))
);
});
source$.pipe(
// merging 3 subscriptions to shared$
connect((shared$) => merge(
shared$.pipe(map(n => `all ${n}`)),
shared$.pipe(filter(n => n % 2 === 0), map(n => `even ${n}`)),
shared$.pipe(filter(n => n % 2 === 1), map(n => `odd ${n}`)),
))
)
.subscribe(console.log); // expected output: (notice only one subscription)
// "subscription started" "source emitted 1" "all 1" "odd 1"
// "source emitted 2" "all 2" "even 2"
// "source emitted 3" "all 3" "odd 3"
// "source emitted 4" "all 4" "even 4"
// "source emitted 5" "all 5" "odd 5"
// --- connectable
// creates an observable that multicasts once connect() is called on it
// connectable<T>(source: ObservableInput<T>, config: ConnectableConfig<T> = DEFAULT_CONFIG): ConnectableObservableLike<T>
// --- shareReplay - share source and replay specified number of emissions on subscription
// replay values on subscription differentiates share and shareReplay
// for side-effects or taxing computations
// that you do not wish to be executed amongst multiple subscriber,
// also where late subscribers to a stream that need access to previously emitted values
// multiple subscribers sharing source:
import { Subject } from 'rxjs/Subject';
import { ReplaySubject } from 'rxjs/ReplaySubject';
import { pluck, share, shareReplay, tap } from 'rxjs/operators';
const routeEnd = new Subject<{data: any, url: string}>(); // simulate url change with subject
const lastUrl = routeEnd.pipe( // grab url and share with subscribers
tap(_ => console.log('executed')),
pluck('url'),
// defaults to all values so we set it to just keep and replay last one
shareReplay(1)
);
const initialSubscriber = lastUrl.subscribe(console.log); // requires initial subscription
routeEnd.next({data: {}, url: 'my-path'}); // simulate route change, logged: 'executed', 'my-path'
const lateSubscriber = lastUrl.subscribe(console.log); // logged: 'my-path'
AJAX , ERROR HANDLING
// --- ajax
// - fetch the response object that is being returned from API:
import { ajax } from 'rxjs/ajax';
import { map, catchError } from 'rxjs/operators';
import { of } from 'rxjs';
const obs$ = ajax(`https://api.github.com/users?per_page=5`).pipe(
map(userResponse => console.log('users: ', userResponse)),
catchError(error => {
console.log('error: ', error);
return of(error);
})
);
// - using ajax.getJSON() to fetch data from API:
content_copyopen_in_new
import { ajax } from 'rxjs/ajax';
import { map, catchError } from 'rxjs/operators';
import { of } from 'rxjs';
const obs$ = ajax.getJSON(`https://api.github.com/users?per_page=5`).pipe(
map(userResponse => console.log('users: ', userResponse)),
catchError(error => {
console.log('error: ', error);
return of(error);
})
);
// - using ajax() with object as argument and method POST with a two seconds delay:
content_copyopen_in_new
import { ajax } from 'rxjs/ajax';
import { of } from 'rxjs';
const users = ajax({
url: 'https://httpbin.org/delay/2',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'rxjs-custom-header': 'Rxjs'
},
body: {
rxjs: 'Hello World!'
}
}).pipe(
map(response => console.log('response: ', response)),
catchError(error => {
console.log('error: ', error);
return of(error);
})
);
// - using ajax() to fetch. An error object that is being returned from the request:
content_copyopen_in_new
import { ajax } from 'rxjs/ajax';
import { map, catchError } from 'rxjs/operators';
import { of } from 'rxjs';
const obs$ = ajax(`https://api.github.com/404`).pipe(
map(userResponse => console.log('users: ', userResponse)),
catchError(error => {
console.log('error: ', error);
return of(error);
})
);
// --- catchError
// catches errors on the observable to be handled
// by returning a new observable or throwing an error.
// continue with a different Observable when there an error:
import { of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
of(1, 2, 3, 4, 5).pipe(
map(n => {
if (n == 4) { throw 'four!'; }
return n;
}), catchError(err => of('I', 'II', 'III', 'IV', 'V')),
).subscribe(x => console.log(x)); // 1, 2, 3, I, II, III, IV, V
// retries the caught source Observable again in case of error,
// similar to retry() operator
import { of } from 'rxjs';
import { map, catchError, take } from 'rxjs/operators';
of(1, 2, 3, 4, 5).pipe(
map(n => {
if (n === 4) { throw 'four!'; }
return n;
}),
catchError((err, caught) => caught),
take(30),
).subscribe(x => console.log(x)); // 1, 2, 3, 1, 2, 3, ...
// throws a new error when the source Observable throws an error
import { of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
of(1, 2, 3, 4, 5).pipe(
map(n => {
if (n == 4) { throw 'four!'; }
return n;
}),
catchError(err => {
throw 'error in source. Details: ' + err;
}),
) .subscribe(
x => console.log(x),
err => console.log(err)
); // 1, 2, 3, error in source. Details: four!
// --- throwIfEmpty
// if the source observable completes without emitting a value, it will emit an error
// error will be created at that time by the optional errorFactory argument,
// otherwise, the error will be EmptyError.
// click on dcument or error will be thrown:
fromEvent(document, 'click').pipe(
takeUntil(timer(1000)),
throwIfEmpty(
() => new Error('the button was not clicked within 1 second')
),
)
.subscribe({
next() { console.log('The button was clicked'); },
error(err) { console.error(err); },
});
// --- throttleTime
// lets a value pass,
// then ignores source values for the next duration milliseconds.
// --- throttle
// like throttleTime, but the silencing duration is determined by a second Observable.
// emit clicks at a rate of at most one click per second:
import { fromEvent } from 'rxjs';
import { throttleTime, throttle } from 'rxjs/operators';
const clicks = fromEvent(document, 'click');
const result = clicks.pipe(throttleTime(1000));
// const result = clicks.pipe(throttle(ev => interval(1000)));
result.subscribe(x => console.log(x));
// --- finalize - returns an Observable that mirrors the source Observable,
// but will call a specified function
// when the source terminates on complete or error.
// extend server response observable with logging:
return next.handle(req).pipe(
tap(
// Succeeds when there is a response; ignore other events
event => ok = event instanceof HttpResponse ? 'succeeded' : '',
// Operation failed; error is an HttpErrorResponse
error => ok = 'failed'
),
// Log when response observable either completes or errors
finalize(() => {
const elapsed = Date.now() - started;
const msg = `${req.method} "${req.urlWithParams}"
${ok} in ${elapsed} ms.`;
this.messenger.add(msg);
})
);
Back to Main Page