Tutorial Walk Through: Tour of Heroes in VSCode Editor – Part 2

This is  a continuation of the walk though of the application.

  1. Services
  2. Routing
  3. HTTP

5   Services

Multiple components will need access to hero data and we don’t want to copy and paste the same code over and over. Instead, we’ll create a single reusable data service and learn to inject it in the components that need it.

Refactoring data access to a separate service keeps the component lean and focused on supporting the view. It also makes it easier to unit test the component with a mock service.

Because data services are invariably asynchronous, we’ll finish the section with a Promise-based version of the data service.

We already can select a hero from a list. Soon we’ll add a dashboard with the top performing heroes and create a separate view for editing hero details. All three views need hero data.

Create the Hero Service

Add the file: hero.service.ts contain the following code. We decorate with @Injectable() function imported from ‘@angular/core’.  This enables the class to use DI (dependency injection) into the consuming classes. See link for more details on DI.

import { Injectable } from '@angular/core';

@Injectable()
export class HeroService {
}

Add a getHeroes() method stub.

@Injectable()
export class HeroService {
  getHeroes(): void {} // stub
}

We will for now you mock data. Add file: src/app/mock-heroes.ts with the following code

import { Hero } from './hero';
export const HEROES: Hero[] = [
 {id: 11, name: 'Mr. Nice'},
 {id: 12, name: 'Narco'},
 {id: 13, name: 'Bombasto'},
 {id: 14, name: 'Celeritas'},
 {id: 15, name: 'Magneta'},
 {id: 16, name: 'RubberMan'},
 {id: 17, name: 'Dynama'},
 {id: 18, name: 'Dr IQ'},
 {id: 19, name: 'Magma'},
 {id: 20, name: 'Tornado'}
];

Remove in AppComponent we remove the data HERO array and replace the heroes variable with the empty array

heroes: Hero[];

Removed from AppComponent:

const HEROES: Hero[] = [
  { id: 11, name: 'Mr. Nice' },
  { id: 12, name: 'Narco' },
  { id: 13, name: 'Bombasto' },
  { id: 14, name: 'Celeritas' },
  { id: 15, name: 'Magneta' },
  { id: 16, name: 'RubberMan' },
  { id: 17, name: 'Dynama' },
  { id: 18, name: 'Dr IQ' },
  { id: 19, name: 'Magma' },
  { id: 20, name: 'Tornado' }
];

The app is currently broken, we fix this by pointing to the mock data.

Heroservice is updatad by importing Hero and HERO for the mock-heros

import { Injectable } from '@angular/core';

import { Hero } from './hero';
import { HEROES } from './mock-heroes';

@Injectable()
export class HeroService {
  getHeroes(): Hero[] {
    return HEROES;
  }
}

Now let’s use the service by adding this import to the AppComponent

import { HeroService } from './hero.service';

We will use DI. please don’t use new

heroService = new HeroService(); // don't do this
constructor(private heroService: HeroService) { } //DI yes!

Add the @Component() variable (still in AppComponent)

providers: [HeroService]

This in stub form in AppComponent is now

@Component({
 selector: 'app-root',
 template: `
 ...
 ...
 `,
 styles: [`
  ...
  ...
 `],
 providers: [HeroService]
})

Now use this in a getHeros() method

  getHeroes(): void {
    this.heroes = this.heroService.getHeroes();
  }

NOTE: It is bad practice to call getHeros() in the constructor! Instead use one of the built in life cycle events/hooks ngOnInit(). See here link for more Details on angular events/Lifecycle hooks.

import { OnInit } from '@angular/core';

export class AppComponent implements OnInit {
  ngOnInit(): void {
      this.getHeroes();
  }
}

This is now working! But wait, in real life the the data is often delivered from a remote server via  a Web API call. This should be call as an asynchronous service. We do this next using “Promise”. We will eventually will be calling the service as an  “observable” stream, but one step at a time. See link for details.

Calling the hero service with promises
Source: Updated getHeroes() in HeroService
getHeroes(): Promise<Hero[]> {
  return Promise.resolve(HEROES);
}
Subscriber: Updated getHeroes() in AppComponent

We have to change our implementation to act on the Promise when it resolves. When the Promise resolves successfully, then we will have heroes to display.

We pass our callback function as an argument to the Promise’s then method:

getHeroes(): void {
  this.heroService.getHeroes().then(heroes => this.heroes = heroes);
}

Summarizing the changes to convert into a promise service

Subscriber:

Source:

Check it out in the browser!


6   Routing

*** New requirements for our Tour of Heroes application: ***  😉

  • Add a Dashboard view.
  • Navigate between the Heroes and Dashboard views.
  • Clicking on a hero in either view navigates to a detail view of the selected hero.
  • Clicking a deep link in an email opens the detail view for a particular hero.

Lets use routing and navigation…

Action Plan
  • Turn AppComponent into an application shell that only handles navigation
  • Relocate the Heroes concerns within the current AppComponent to a separate HeroesComponent
  • Add routing
  • Create a new DashboardComponent
  • Tie the Dashboard into the navigation structure

Our revised app should present a shell with a choice of views (Dashboard and Heroes) and then default to one of them.

Change file: src/app/app.component.ts to scr/app/heroes.component.ts (showing renaming only)

@Component({
 selector: 'my-heroes',
})
export class HeroesComponent implements OnInit {
}

In file index.html, change <app-root></app-root> to <my-hero></my-hero>

<my-heroes>Loading...</my-heroes>

House keeping! change AppModule…

import { FormsModule } from '@angular/forms';
import { HttpModule } from '@angular/http';

import { HeroesComponent } from './heroes.component';
import { HeroDetailComponent } from './hero-detail.component';

@NgModule({
 declarations: [
 HeroesComponent,
 HeroDetailComponent
 ],
 imports: [
 BrowserModule,
 FormsModule,
 HttpModule
 ],
 providers: [],
 bootstrap: [HeroesComponent]
})
export class AppModule { }

Create a new src/app/app.component.ts (first draft)

import { Component } from '@angular/core';
@Component({
 selector: 'my-app',
 template: `
 <h1>{{title}}</h1>
 <my-heroes></my-heroes>
 `
})
export class AppComponent {
 title = 'Tour of Heroes';
}

and update AppModule add…

import { AppComponent } from './app.component';
...
...
 declarations: [
 AppComponent,
...
...
bootstrap: [ AppComponent ]

The app with the new HeroesComponent still work and is ready for adding routing.

Add Routing

The hero list needs to be displayed via buttons

Add <base href> in index.html

<head>
  <base href="/">

NOTE this is essential, see link for details

Configure routes

In file: src/app/app.module.ts (heroes route)

import { RouterModule } from '@angular/router';
...
...
RouterModule.forRoot([
  {
    path: 'heroes',
    component: HeroesComponent
   }])
...

Note the new syntax used. See link for more details.

....
@NgModule({
  declarations: [
    AppComponent,
    HeroesComponent,
    HeroDetailComponent
  ],
  imports: [
    BrowserModule,
    FormsModule,
    HttpModule,
    RouterModule.forRoot([
      {
        path: 'heroes',
        component: HeroesComponent
      }])
  ],
...

Router Outlet

The element <router-outlet></router-outlet> is placed in the temple where HeroesComponent meta data will appear when the user navigates to the “/heroes” address.

file: src/app/app.component.ts (template-v2)

template: `
 <h1>{{title}}</h1>
 <a routerLink = "/heroes">Heroes</a>
 <router-outlet></router-outlet>
 `

Explanation: “routerLink = ‘/heroes’ ” is the Angular replacement for “href=’/heroes’ “. It’s a one way binding to the route path. See link for more details.

Summing up src/app/app.component.ts (v2)

import { Component } from '@angular/core';
@Component({
 selector: 'my-app',
 template: `
 <h1>{{title}}</h1>
 <a routerLink="/heroes">Heroes</a>
 <router-outlet></router-outlet>
 `
})
export class AppComponent {
 title = 'Tour of Heroes';
}

Check in Browser:

Add file: src/app/dashboard.component.ts (v1)

import { Component } from '@angular/core';

@Component({
  selector: 'my-dashboard',
  template: '<h3>My Dashboard</h3>'
})
export class DashboardComponent { }

Add dashboard route. In file: src/app/app.module.ts (Dashboard route)

{
 path: 'dashboard',
 component: DashboardComponent
},

And again “house keeping” in file: src/app/app.module.ts (dashboard)

 

...
import { DashboardComponent } from './dashboard.component';
...
declarations: [
 AppComponent,
 DashboardComponent,
 HeroDetailComponent,
 HeroesComponent
],

Redirect to /dashboard (First page)

Part1: Add to file: src/app/app.module.ts (redirect)

{
 path: '',
 redirectTo: '/dashboard',
 pathMatch: 'full'
},

Add navigation to the template

Part2: Add to file: src/app/app.component.ts (template-v3)

...
template: `
 <h1>{{title}}</h1>
 <nav>
 <a routerLink="/dashboard">Dashboard</a>
 <a routerLink="/heroes">Heroes</a>
 </nav>
 <router-outlet></router-outlet>
 `
...

Check in browser (default redirect to localhost:4200/dashboard)

Dashboard Top Heroes

We will now build out the dashboard

Updated file: src/app/dashboard.component.ts (metadata)

@Component({
 moduleId: module.id,
 selector: 'my-dashboard',
 templateUrl: './dashboard.component.html',
})

Set the moduleId property to module.id for module-relative loading of the templateUrl

Add the file:   src/app/dashboard.component.html

<h3>Top Heroes</h3>
<div class="grid grid-pad">
  <div *ngFor="let hero of heroes" class="col-1-4">
     <div class="module hero">
        <h4>{{hero.name}}</h4>
    </div>
  </div>
</div>

Update the file: src/app/dashboard.component.ts (imports)

import { Component, OnInit } from '@angular/core';

import { Hero } from './hero';
import { HeroService } from './hero.service';

Update the file:   src/app/dashboard.component.ts (class)

export class DashboardComponent implements OnInit {

 heroes: Hero[] = [];

 constructor(private heroService: HeroService) { }

 ngOnInit(): void {
 this.heroService.getHeroes()
 .then(heroes => this.heroes = heroes.slice(1, 5));
 }
}

Check in browser

Navigate to Hero Details

Configure a Route with a Parameter

Update file:  src/app/app.module.ts (hero detail)

{
 path: 'detail/:id',
 component: HeroDetailComponent
},
Revise the HeroDetailComponent

Current file: src/app/hero-detail.component.ts (current)

import { Component, Input } from '@angular/core';
import { Hero } from './hero';
@Component({
 selector: 'my-hero-detail',
 template: `
 <div *ngIf="hero">
 <h2>{{hero.name}} details!</h2>
 <div>
 <label>id: </label>{{hero.id}}
 </div>
 <div>
 <label>name: </label>
 <input [(ngModel)]="hero.name" placeholder="name"/>
 </div>
 </div>
 `
})
export class HeroDetailComponent {
 @Input() hero: Hero;
}

Update the file imports

// Keep the Input import for now, we'll remove it later:
import { Component, Input, OnInit } from '@angular/core';
import { ActivatedRoute, Params } from '@angular/router';
import { Location } from '@angular/common';

import { HeroService } from './hero.service';

src/app/hero-detail.component.ts (constructor)

constructor(
 private heroService: HeroService,
 private route: ActivatedRoute,
 private location: Location
) {}

src/app/hero-detail.component.ts (switchMap import)

import 'rxjs/add/operator/switchMap';

We tell the class that we want to implement the OnInit interface.

export class HeroDetailComponent implements OnInit {

src/app/hero-detail.component.ts (ngOnInit)

ngOnInit(): void {
 this.route.params
 .switchMap((params: Params) => this.heroService.getHero(+params['id']))
 .subscribe(hero => this.hero = hero);
}
Add HeroService.getHero

src/app/hero.service.ts (getHero)

getHero(id: number): Promise<Hero> {
 return this.getHeroes()
 .then(heroes => heroes.find(hero => hero.id === id));
}
Find our way back

src/app/hero-detail.component.ts (goBack)

goBack(): void {
 this.location.back();
}

Wire this method with an event binding to a Back button

<button (click)="goBack()">Back</button>

Now we can move this template out into it’s own file
src/app/hero-detail.component.html

<div *ngIf="hero">
 <h2>{{hero.name}} details!</h2>
 <div>
 <label>id: </label>{{hero.id}}</div>
 <div>
 <label>name: </label>
 <input [(ngModel)]="hero.name" placeholder="name" />
 </div>
 <button (click)="goBack()">Back</button>
</div>

src/app/hero-detail.component.ts (metadata)

@Component({
 moduleId: module.id,
 selector: 'my-hero-detail',
 templateUrl: './hero-detail.component.html',
})

Check in browser

Select a Dashboard Hero

When a user selects a hero in the dashboard, the app should navigate to the HeroDetailComponent to view and edit the selected hero.

Although the dashboard heroes are presented as button-like blocks, they should behave like anchor tags. When hovering over a hero block, the target URL should display in the browser status bar and the user should be able to copy the link or open the hero detail view in a new tab.

To achieve this effect, reopen the dashboard.component.html and replace the repeated <div *ngFor…> tags with <a> tags. The opening <a> tag looks like this:

<a *ngFor="let hero of heroes" [routerLink]="['/detail', hero.id]" class="col-1-4">

Notice the [routerLink] binding.

src/app/app.module.ts (hero detail)

{
 path: 'detail/:id',
 component: HeroDetailComponent
},

Recap: file: hero-detail.component.ts

<h3>Top Heroes</h3>
<div class="grid grid-pad">
 <a *ngFor="let hero of heroes" [routerLink]="['/detail', hero.id]" class="col-1-4">
 <div class="module hero">
 <h4>{{hero.name}}</h4>
 </div>
 </a>
</div>

Check in browser

Refactor routes to a Routing Module

Add file: src/app/app-routing.module.ts

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { DashboardComponent } from './dashboard.component';
import { HeroesComponent } from './heroes.component';
import { HeroDetailComponent } from './hero-detail.component';
const routes: Routes = [
 { path: '', redirectTo: '/dashboard', pathMatch: 'full' },
 { path: 'dashboard', component: DashboardComponent },
 { path: 'detail/:id', component: HeroDetailComponent },
 { path: 'heroes', component: HeroesComponent }
];
@NgModule({
 imports: [ RouterModule.forRoot(routes) ],
 exports: [ RouterModule ]
})
export class AppRoutingModule {}

Noteworthy points, typical of Routing Modules:

  • Pulls the routes into a variable. You might export it in future and it clarifies the Routing Module pattern.
  • Adds RouterModule.forRoot(routes) to imports.
  • Adds RouterModule to exports so that the components in the companion module have access to Router declarables such as RouterLink and RouterOutlet.
  • No declarations! Declarations are the responsibility of the companion module.
  • Adds module providers for guard services if you have them; there are none in this example.

Update AppModule: src/app/app.module.ts

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
import { AppComponent } from './app.component';
import { DashboardComponent } from './dashboard.component';
import { HeroDetailComponent } from './hero-detail.component';
import { HeroesComponent } from './heroes.component';
import { HeroService } from './hero.service';
import { AppRoutingModule } from './app-routing.module';
@NgModule({
 imports: [
 BrowserModule,
 FormsModule,
 AppRoutingModule
 ],
 declarations: [
 AppComponent,
 DashboardComponent,
 HeroDetailComponent,
 HeroesComponent
 ],
 providers: [ HeroService ],
 bootstrap: [ AppComponent ]
})
export class AppModule { }
Select a Hero in the HeroesComponent

src/app/heroes.component.ts ( current template )

template: `
  <h1>{{title}}</h1>
  <h2>My Heroes</h2>
  <ul class="heroes">
    <li *ngFor="let hero of heroes"
      [class.selected]="hero === selectedHero"
      (click)="onSelect(hero)">
      <span class="badge">{{hero.id}}</span> {{hero.name}}
    </li>
  </ul>
  <my-hero-detail [hero]="selectedHero"></my-hero-detail>
`,

Delete the <h1> at the top (we forgot about it during the AppComponent-to-HeroesComponent conversion).

Delete the last line of the template with the <my-hero-detail> tags.

We are keeping the “master/detail” style but shrinking the detail to a “mini”, read-only version.

Add the mini-detail

Add the following HTML fragment at the bottom of the template where the <my-hero-detail> used to be:

<div *ngIf="selectedHero">
 <h2>
 {{selectedHero.name | uppercase}} is my hero
 </h2>
 <button (click)="gotoDetail()">View Details</button>
</div>

After clicking a hero, the user should see something like this below the hero list:

Format with the uppercase pipe
{{selectedHero.name | uppercase}} is my hero
Move content out of the component file

Let’s migrate the template and the styles to their own files before we make any more changes:

  • Cut-and-paste the template contents into a new heroes.component.html file.
  • Cut-and-paste the styles contents into a new heroes.component.css file.
  • Set the component metadata’s templateUrl and styleUrls properties to refer to both files.
  • Set the moduleId property to module.id so that templateUrl and styleUrls are relative to the component.

File: src/app/heroes.component.ts (revised metadata)

@Component({
  moduleId: module.id,
  selector: 'my-heroes',
  templateUrl: './heroes.component.html',
  styleUrls: [ './heroes.component.css' ]
})
Update the HeroesComponent class.
  • Import the router from the Angular router library
  • Inject the router in the constructor (along with the HeroService)
  • Implement gotoDetail by calling the router.navigate method
gotoDetail(): void {
 this.router.navigate(['/detail', this.selectedHero.id]);
}

For reference, our heroes.component.ts class now looks:

export class HeroesComponent implements OnInit {
 heroes: Hero[];
 selectedHero: Hero;

 constructor(
 private router: Router,
 private heroService: HeroService) { }

 getHeroes(): void {
 this.heroService.getHeroes().then(heroes => this.heroes = heroes);
 }

 ngOnInit(): void {
 this.getHeroes();
 }

 onSelect(hero: Hero): void {
 this.selectedHero = hero;
 }

 gotoDetail(): void {
 this.router.navigate(['/detail', this.selectedHero.id]);
 }
}

Styling the App

Add a dashboard.component.css (src/app/dashboard.component.ts

styleUrls: [ './dashboard.component.css' ]

and (src/app/dashboard.component.css)

[class*='col-'] {
 float: left;
 padding-right: 20px;
 padding-bottom: 20px;
}
[class*='col-']:last-of-type {
 padding-right: 0;
}
a {
 text-decoration: none;
}
*, *:after, *:before {
 -webkit-box-sizing: border-box;
 -moz-box-sizing: border-box;
 box-sizing: border-box;
}
h3 {
 text-align: center; margin-bottom: 0;
}
h4 {
 position: relative;
}
.grid {
 margin: 0;
}
.col-1-4 {
 width: 25%;
}
.module {
 padding: 20px;
 text-align: center;
 color: #eee;
 max-height: 120px;
 min-width: 120px;
 background-color: #607D8B;
 border-radius: 2px;
}
.module:hover {
 background-color: #EEE;
 cursor: pointer;
 color: #607d8b;
}
.grid-pad {
 padding: 10px 0;
}
.grid-pad > [class*='col-']:last-of-type {
 padding-right: 20px;
}
@media (max-width: 600px) {
 .module {
 font-size: 10px;
 max-height: 75px; }
}
@media (max-width: 1024px) {
 .grid {
 margin: 0;
 }
 .module {
 min-width: 60px;
 }
}

now file: src/app/hero.component.css

label {
 display: inline-block;
 width: 3em;
 margin: .5em 0;
 color: #607D8B;
 font-weight: bold;
}
input {
 height: 2em;
 font-size: 1em;
 padding-left: .4em;
}
button {
 margin-top: 20px;
 font-family: Arial;
 background-color: #eee;
 border: none;
 padding: 5px 10px;
 border-radius: 4px;
 cursor: pointer; cursor: hand;
}
button:hover {
 background-color: #cfd8dc;
}
button:disabled {
 background-color: #eee;
 color: #ccc; 
 cursor: auto;
}
Style the Navigation Links

Add a app.component.css file to the app folder with the following content.

file: crs/app/app.component.css

h1 {
 font-size: 1.2em;
 color: #999;
 margin-bottom: 0;
}
h2 {
 font-size: 2em;
 margin-top: 0;
 padding-top: 0;
}
nav a {
 padding: 5px 10px;
 text-decoration: none;
 margin-top: 10px;
 display: inline-block;
 background-color: #eee;
 border-radius: 4px;
}
nav a:visited, a:link {
 color: #607D8B;
}
nav a:hover {
 color: #039be5;
 background-color: #CFD8DC;
}
nav a.active {
 color: #039be5;
}

Check in browser

The routerLinkActive directive

First add moduleId: module.id to the @Component metadata of the AppComponent to enable module-relative file URLs. Then add a styleUrls property that points to this CSS file as follows.

styleUrls: ['./app.component.css'],

Create the file styles.css, if it doesn’t exist already. Ensure that it contains the master styles given here.

Global application styles

When we add styles to a component, we’re keeping everything a component needs — HTML, the CSS, the code — together in one convenient place. It’s pretty easy to package it all up and re-use the component somewhere else.

We can also create styles at the application level outside of any component.

Our designers provided some basic styles to apply to elements across the entire app. These correspond to the full set of master styles that we installed earlier during setup. Here is an excerpt:

styles.css(excerpt)

/* Master Styles */
h1 {
 color: #369;
 font-family: Arial, Helvetica, sans-serif;
 font-size: 250%;
}
h2, h3 {
 color: #444;
 font-family: Arial, Helvetica, sans-serif;
 font-weight: lighter;
}
body {
 margin: 2em;
}
body, input[text], button {
 color: #888;
 font-family: Cambria, Georgia;
}
/* . . . */
/* everywhere else */
* {
 font-family: Arial, Helvetica, sans-serif;
}

If necessary, also edit index.html to refer to this stylesheet.

<link rel="stylesheet" href="styles.css">
Recap
  • We added the Angular Router to navigate among different components.
  • We learned how to create router links to represent navigation menu items.
  • We used router link parameters to navigate to the details of user selected hero.
  • We shared the HeroService among multiple components.
  • We moved HTML and CSS out of the component file and into their own files.
  • We added the uppercase pipe to format data.
  • We refactored routes into a Routing Module that we import.

 

Leave a Reply

Your email address will not be published. Required fields are marked *