14 January, 2022

Angular

Install the Angular CLI

npm i -g @angular/cli
ng new Teach
# Options: add Angular routing, use CSS

cd Teach
ng serve

Open http://localhost:4200 with a browser.

Use the CLI to generate a new component:

ng g module courses
ng g component courselist -m courses

Angular Material

ng add @angular/material

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);
  }
}

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);
}

Localization

ng add @angular/localize
npm i --save-dev @angular-devkit/build-angular

Add i18n directives to templates.

Update angular.json:

{
  "projects": {
    "architect": {
      "build": {
        "options": {
            "localize": true,
        },
        "configurations": {
            "dev-de": {
              "index": {
                "input": "app/index.de-CH.html",
                "output": "index.html"
              },
              "localize": ["de"]
            },
            "dev-fr": {
              "index": {
                "input": "app/index.fr-CH.html",
                "output": "index.html"
              },
              "localize": ["fr"]
            }
        }
      },
      "serve": {
        "configurations": {
          "prod-de": {
            "browserTarget": "bbtcare:build:prod-de"
          },
          "prod-fr": {
            "browserTarget": "bbtcare:build:prod-fr"
          },
          "dev-en": {
            "browserTarget": "i18nTest:build:dev-en"
          },
          "dev-de": {
            "browserTarget": "i18nTest:build:dev-de"
          },
          "dev-fr": {
            "browserTarget": "bbtcare:build:dev-fr"
          },
          "defaultConfiguration": "dev-de"
        },
      },
      "i18n": {
        "sourceLocale": "en-US",
        "locales": {
          "de": {
            "baseHref": "",
            "translation": "./app/locale/messages.de-CH.xlf"
          },
          "fr": {
            "baseHref": "",
            "translation": "./app/locale/messages.fr-CH.xlf"
          }
        }
      }
    }
  }
}

Extract templates messages to app/locale/messages.xlf

ng extract-i18n --format=xlf2 --output-path app/locale

Optional, change to a different file with: ng extract-i18n --format=xlf2 --outFile=message.de-CH.xlf

Start with a specific configuration

ng serve --configuration=dev-en

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

ng build

To build prod dist-files with specific language-configuration:

ng build --configuration=prod-en
ng build --configuration=prod-de --delete-output-path=false
ng build --configuration=prod-fr --delete-output-path=false

To build and store output as logfile:

ng build 2>  ng.log

Upgrade

List version:

npm view @angular/core versions --json