9 January, 2024

Angular Notes

You need Node.js to install and build Angular.

Check Angular CLI version:

ng version

Angular CLI: 17.0.3
Node: 18.16.0
Package Manager: npm 9.5.1
OS: win32 x64

Angular:
...

Package                      Version
------------------------------------------------------
@angular-devkit/architect    0.1700.3 (cli-only)
@angular-devkit/core         17.0.3 (cli-only)
@angular-devkit/schematics   17.0.3 (cli-only)
@schematics/angular          17.0.3 (cli-only)

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.xlfas 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());
    }
}

Link: https://github.com/angular/angular/pull/50042/files#diff-956acddee849d61cffeefd1cc53f148873e8771d46d86fd6f5e945df6092e7c1R102

Angular Source Code at GitHub