You need Node.js to install and build Angular.
Install the Angular CLI with the following commands:
npm i -g @angular/cli
Create the example SPA Teach
:
ng new Teach
Start it:
cd Teach
ng serve
Open http://localhost:4200 with a browser.
Material Design
Add Angular Material Design:
ng add @angular/material
Replace the content of app.component.html
with:
<mat-slide-toggle checked="true">Hello world!</mat-slide-toggle>
Edit the app.module.ts
to include the component. Visit the Angular Material homepage to learn more about it.
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
@NgModule({
...
imports: [
MatSlideToggleModule
]
...
})
export class AppModule { }
Localization
Internationalization or i18n in short.
ng add @angular/localize
Add i18n
directives to templates.
Add i18n-
attributes to mark attributes to be transtlated. e.g.
<input i18n-placeholder placeholder="Hello"></input>
For templates without html tag:
<ng-container i18n>Hello</ng-container>
Add $localze in *.ts file:
var hello = $localize `Hello`;
It's different for the index.html
file. As it's not a template, it is not tranlated.
We are adding new locales German and French.
The index.html
is not translated by Angular, use different index.html
pages for each language.
Update angular.json
.
{
"projects": {
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"localize": true,
},
"configurations": {
"de": {
"index": {
"input": "app/index.de.html",
"output": "index.html"
},
"localize": ["de"]
},
"fr": {
"index": {
"input": "app/index.fr.html",
"output": "index.html"
},
"localize": ["fr"]
}
}
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"dev-de": {
"browserTarget": "Teach:build:development,de"
},
"dev-fr": {
"browserTarget": "Teach:build:development,fr"
},
"defaultConfiguration": "dev-de"
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "Teach:build"
}
}
},
"i18n": {
"sourceLocale": "en",
"locales": {
"de": {
"baseHref": "",
"translation": "./src/locale/messages.de.xlf"
},
"fr": {
"baseHref": "",
"translation": "./src/locale/messages.fr.xlf"
}
}
}
}
}
Extract templates messages to src/locale/messages.xlf
ng extract-i18n --format=xlf2 --output-path src/locale
Optional, change to a different file with:
ng extract-i18n --format=xlf2 --output-path src/locale --out-file message.en.xlf
Make a copy of the src/locale/messages.xlf
as src/locale/messages.de.xlf
.
Edit src/locale/messages.de.xlf
and add new line <target></target>
below each <source>...</source>
lines.
Make a copy of src/locale/messages.de.xlf
as src/locale/messages.fr.xlf
.
Start with a specific configuration
ng serve --configuration=dev-de
Angular CLI
Use the CLI to generate a new component:
ng g module courses
ng g component courselist -m courses
ng g service core/class
ng g service core/topic
ng g guard teacher/teacher
ng g component chats
ng g guard chats/chat
ng g component messages
ng g guard messages/message
ng g component users -m core
ng g service core/user
Angular Material
ng add @angular/material
In app.module.ts
add imports of all used material component modules:
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
imports: [imports: [
MatSlideToggleModule
]
Use Sass
Use @use
instead of @import
. Unused CSS can be better determined.
Links
Service
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders, HttpErrorResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { catchError, map, tap } from 'rxjs/operators';
import { Hero } from './hero';
const httpOptions = {
headers: new HttpHeaders({ 'Content-Type': 'application/json' })
};
@Injectable()
export class HeroService {
readonly heroesUrl = 'api/heroes'; // URL to web api
constructor(private http: HttpClient) { }
/** GET heroes from the server */
getHeroes(): Observable<Hero[]> {
return this.http.get<Hero[]>(this.heroesUrl)
.pipe(
tap(heroes => this.log(`fetched heroes`)),
catchError(this.handleError('getHeroes'))
) as Observable<Hero[]>;
}
/** GET hero by id. Return `undefined` when id not found */
getHero<Data>(id: number | string): Observable<Hero> {
if (typeof id === 'string') {
id = parseInt(id, 10);
}
const url = `${this.heroesUrl}/?id=${id}`;
return this.http.get<Hero[]>(url)
.pipe(
map(heroes => heroes[0]), // returns a {0|1} element array
tap(h => {
const outcome = h ? `fetched` : `did not find`;
this.log(`${outcome} hero id=${id}`);
}),
catchError(this.handleError<Hero>(`getHero id=${id}`))
);
}
//////// Save methods //////////
/** POST: add a new hero to the server */
addHero(hero: Hero): Observable<Hero> {
return this.http.post<Hero>(this.heroesUrl, hero, httpOptions).pipe(
tap((addedHero) => this.log(`added hero w/ id=${addedHero.id}`)),
catchError(this.handleError<Hero>('addHero'))
);
}
/** DELETE: delete the hero from the server */
deleteHero(hero: Hero | number): Observable<Hero> {
const id = typeof hero === 'number' ? hero : hero.id;
const url = `${this.heroesUrl}/${id}`;
return this.http.delete<Hero>(url, httpOptions).pipe(
tap(_ => this.log(`deleted hero id=${id}`)),
catchError(this.handleError<Hero>('deleteHero'))
);
}
/** PUT: update the hero on the server */
updateHero(hero: Hero): Observable<any> {
return this.http.put(this.heroesUrl, hero, httpOptions).pipe(
tap(_ => this.log(`updated hero id=${hero.id}`)),
catchError(this.handleError<any>('updateHero'))
);
}
/**
* Returns a function that handles Http operation failures.
* This error handler lets the app continue to run as if no error occurred.
* @param operation - name of the operation that failed
*/
private handleError<T>(operation = 'operation') {
return (error: HttpErrorResponse): Observable<T> => {
// TODO: send the error to remote logging infrastructure
console.error(error); // log to console instead
// If a native error is caught, do not transform it. We only want to
// transform response errors that are not wrapped in an `Error`.
if (error.error instanceof Event) {
throw error.error;
}
const message = `server returned code ${error.status} with body "${error.error}"`;
// TODO: better job of transforming error for user consumption
throw new Error(`${operation} failed: ${message}`);
};
}
private log(message: string) {
console.log('HeroService: ' + message);
}
}
XSRF or CSRF
Angular has a feature to retrieve a cookie used for XSRF and add the value in the header of some requests.
Response Header containing:
set-cookie: XSRF-TOKEN=CfDJ8LJyEjnZE3tJ; path=/
Request Header containing:
X-XSRF-TOKEN: CfDJ8LJyEjnZE3tJ
The Angular HttpXsrfInterceptor does not add the token to the header for GET and HEAD verbs and absolute urls.
- No GET or HEAD verb
- Not absolut path, e.g. starting with 'http://' or 'https://'.
- When you add the cookie in the server yourself, don't forget to make it
httpOnly = false
or else it cannot be read by JavaScript.
import { HttpClientModule, HttpClientXsrfModule } from '@angular/common/http';
If the server does not use the keyword XSRF-TOKEN
:
// ...
HttpClientXsrfModule.withOptions({
cookieName: 'CSRF-TOKEN',
headerName: 'X-CSRF-TOKEN'
})
// ...
Child Component
Child components are referenced by:
@ViewChild('child') child: ChildComponent;
It cannot be referenced in ngOnInit()
because it is still undefined
. Even after initialization in ngAfterViewInit
the child may not change its view there. Delay doing that with setTimeout
like this:
ngAfterViewInit(): void {
setTimeout(() => {
this.child.filterValue = 'something';
this.child.loadData();
}, 0);
}
Standalone Component
ng new Teach --inline-style --inline-template
ng g componet courselist --flat --standalone
Using Standalone Component
In NgModule-based applications, add it to imports: [ ]
Convert to Standalone Component
Add standalone: true
Service Worker
Add Service Worker to a project:
ng add @angular/pwa --project Teach
Test
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
// Other imports
import { TestBed } from '@angular/core/testing';
import { HttpClient, HttpResponse, HttpErrorResponse } from '@angular/common/http';
import { asyncData, asyncError } from '../../testing/async-observable-helpers';
import { Hero } from './hero';
import { HeroService } from './hero.service';
describe ('HeroesService (with spies)', () => {
// #docregion test-with-spies
let httpClientSpy: jasmine.SpyObj<HttpClient>;
let heroService: HeroService;
beforeEach(() => {
// TODO: spy on other methods too
httpClientSpy = jasmine.createSpyObj('HttpClient', ['get']);
heroService = new HeroService(httpClientSpy);
});
it('should return expected heroes (HttpClient called once)', (done: DoneFn) => {
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');
done();
},
done.fail
);
expect(httpClientSpy.get.calls.count()).toBe(1, 'one call');
});
it('should return an error when the server returns a 404', (done: DoneFn) => {
const errorResponse = new HttpErrorResponse({
error: 'test 404 error',
status: 404, statusText: 'Not Found'
});
httpClientSpy.get.and.returnValue(asyncError(errorResponse));
heroService.getHeroes().subscribe(
heroes => done.fail('expected an error, not heroes'),
error => {
expect(error.message).toContain('test 404 error');
done();
}
);
});
// #enddocregion test-with-spies
});
describe('HeroesService (with mocks)', () => {
let httpClient: HttpClient;
let httpTestingController: HttpTestingController;
let heroService: HeroService;
beforeEach(() => {
TestBed.configureTestingModule({
// Import the HttpClient mocking services
imports: [ HttpClientTestingModule ],
// Provide the service-under-test
providers: [ HeroService ]
});
// Inject the http, test controller, and service-under-test
// as they will be referenced by each test.
httpClient = TestBed.inject(HttpClient);
httpTestingController = TestBed.inject(HttpTestingController);
heroService = TestBed.inject(HeroService);
});
afterEach(() => {
// After every test, assert that there are no more pending requests.
httpTestingController.verify();
});
/// HeroService method tests begin ///
describe('#getHeroes', () => {
let expectedHeroes: Hero[];
beforeEach(() => {
heroService = TestBed.inject(HeroService);
expectedHeroes = [
{ id: 1, name: 'A' },
{ id: 2, name: 'B' },
] as Hero[];
});
it('should return expected heroes (called once)', () => {
heroService.getHeroes().subscribe(
heroes => expect(heroes).toEqual(expectedHeroes, 'should return expected heroes'),
fail
);
// HeroService should have made one request to GET heroes from expected URL
const req = httpTestingController.expectOne(heroService.heroesUrl);
expect(req.request.method).toEqual('GET');
// Respond with the mock heroes
req.flush(expectedHeroes);
});
it('should be OK returning no heroes', () => {
heroService.getHeroes().subscribe(
heroes => expect(heroes.length).toEqual(0, 'should have empty heroes array'),
fail
);
const req = httpTestingController.expectOne(heroService.heroesUrl);
req.flush([]); // Respond with no heroes
});
it('should turn 404 into a user-friendly error', () => {
const msg = 'Deliberate 404';
heroService.getHeroes().subscribe(
heroes => fail('expected to fail'),
error => expect(error.message).toContain(msg)
);
const req = httpTestingController.expectOne(heroService.heroesUrl);
// respond with a 404 and the error message in the body
req.flush(msg, {status: 404, statusText: 'Not Found'});
});
it('should return expected heroes (called multiple times)', () => {
heroService.getHeroes().subscribe();
heroService.getHeroes().subscribe();
heroService.getHeroes().subscribe(
heroes => expect(heroes).toEqual(expectedHeroes, 'should return expected heroes'),
fail
);
const requests = httpTestingController.match(heroService.heroesUrl);
expect(requests.length).toEqual(3, 'calls to getHeroes()');
// Respond to each request with different mock hero results
requests[0].flush([]);
requests[1].flush([{id: 1, name: 'bob'}]);
requests[2].flush(expectedHeroes);
});
});
describe('#updateHero', () => {
// Expecting the query form of URL so should not 404 when id not found
const makeUrl = (id: number) => `${heroService.heroesUrl}/?id=${id}`;
it('should update a hero and return it', () => {
const updateHero: Hero = { id: 1, name: 'A' };
heroService.updateHero(updateHero).subscribe(
data => expect(data).toEqual(updateHero, 'should return the hero'),
fail
);
// HeroService should have made one request to PUT hero
const req = httpTestingController.expectOne(heroService.heroesUrl);
expect(req.request.method).toEqual('PUT');
expect(req.request.body).toEqual(updateHero);
// Expect server to return the hero after PUT
const expectedResponse = new HttpResponse(
{ status: 200, statusText: 'OK', body: updateHero });
req.event(expectedResponse);
});
it('should turn 404 error into user-facing error', () => {
const msg = 'Deliberate 404';
const updateHero: Hero = { id: 1, name: 'A' };
heroService.updateHero(updateHero).subscribe(
heroes => fail('expected to fail'),
error => expect(error.message).toContain(msg)
);
const req = httpTestingController.expectOne(heroService.heroesUrl);
// respond with a 404 and the error message in the body
req.flush(msg, {status: 404, statusText: 'Not Found'});
});
it('should turn network error into user-facing error', done => {
// Create mock ProgressEvent with type `error`, raised when something goes wrong at
// the network level. Connection timeout, DNS error, offline, etc.
const errorEvent = new ProgressEvent('error');
const updateHero: Hero = { id: 1, name: 'A' };
heroService.updateHero(updateHero).subscribe(
heroes => fail('expected to fail'),
error => {
expect(error).toBe(errorEvent);
done();
}
);
const req = httpTestingController.expectOne(heroService.heroesUrl);
// Respond with mock error
req.error(errorEvent);
});
});
// TODO: test other HeroService methods
});
Start
ng serve
starts the default configuration.
ng serve --configuration=dev-fr
Build
The build stores the resulting files in dist
folder. Containing the SPA in each language.
ng build
To build and store output as logfile:
ng build 2> ng.log
The files contains a hash-code in the name to mitigate caching problem. It doesn't help us when we use rewrite-function of the web-server as the hash-code is the same for each language.
Use a custom webpack-config to generate different hashes:
{
"projects": {
"architect": {
"build": {
"builder": "@angular-builders/custom-webpack:browser",
"options": {
"customWebpackConfig": {
"path": "./webpack.config.js",
"replaceDuplicatePlugins": true
}
},
"configurations": {
"en": {
"localize": [ "en" ]
},
"de": {
"localize": [ "de" ]
}
}
}
}
}
}
Add webpack.config.js
file:
module.exports = {
optimization: {
relContentHash: false
}
}
Currently, the custom-webpack
for Angular 16 is still in beta:
npm i --save-dev @angular-builders/custom-webpack@16.0.0-beta.0
ng build --configuration="production,en"
ng build --configuration="production,de" --delete-output-path=false
Upgrade
List version:
npm view @angular/core versions --json
Subscriptions
foo$: Observable<Foo> = this.bar();
subscriptions = new Subscription();
constructor() {
this.subscriptions.add(this.foo$.subscribe());
}
ngOnDestroy(): void {
this.subs.unsubscribe();
}
takeUntilDestroyed
import { Component } from '@angular/core';
import { takeUntilDestroyed } '@angular/core/rxjs-interop';
import { Observable } from 'rxjs';
@Component({/* ... */})
export class OneComponent {
foo$: Observable<Foo> = this.bar();
constructor() {
this.foo$.pipe(takeUntilDestroyed()).subscribe();
data$ = http.get('...').pipe(takeUntilDestroyed());
}
}