Setting up the environment:
Requirements:
- Node.js https://nodejs.org/en/download/current/
- A JavaScript editor of your choice:
- WebStorm https://www.jetbrains.com/webstorm/
- VS Code https://code.visualstudio.com/
- Atom https://atom.io/
Angular CLI is a simple way of generating Angular projects. Open a terminal and run:
npm install -g @angular/cli
ng new angular-tour-of-heroes
? Would you like to add Angular routing? No
? Which stylesheet format would you like to use? CSS
cd angular-tour-of-heroes
ng serve --open
Open the project in an editor or IDE and navigate to the src/app
folder.
The base app is implemented in the following files:
- app.component.ts - The component class code
- app.component.html - The component template
- app.component.css - The component private styles
In the component class change the title
value:
title = 'Tour of Heroes';
In the template file replace the generated template:
<h1>{{title}}</h1>
And in the global styles.css
add some styles:
/* Application-wide 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[type="text"], button {
color: #888;
font-family: Cambria, Georgia;
}
/* everywhere else */
* {
font-family: Arial, Helvetica, sans-serif;
}
ng generate component heroes
What changed?
- A new folder called
heroes
was created in the projectsrc/app
folder. - Inside that folder a new component called
HeroesComponent
was created. - The
HeroesComponent
was declared in theAppModule
metadatadeclarations
property.
Open the heroes.component.ts
file and add a new hero
property:
hero = 'Windstorm';
Show the hero in the component heroes.component.html
template:
{{hero}}
And include the component in the app component template:
<h1>{{title}}</h1>
<app-heroes></app-heroes>
Check the changes on your browser.
Create a hero.ts
file and declare a new type:
export class Hero {
id: number;
name: string;
}
Replace the initial hero name in the hero component with and hero object:
hero: Hero = {
id: 1,
name: 'Windstorm'
};
Edit the template to display the hero object:
<h2>{{hero.name}} Details</h2>
<div><span>id: </span>{{hero.id}}</div>
<div><span>name: </span>{{hero.name}}</div>
Then format the hero to be upper case using the uppercase pipe:
<h2>{{hero.name | uppercase}} Details</h2>
<div>
<label>name:
<input [(ngModel)]="hero.name" placeholder="name">
</label>
</div>
Replace the name div with the above code. Check your browser.
You'll notice the app has stopped working since the [(ngModel)]
is declared in the FormsModule
which is not imported into the application.
Open the AppModule
and import the required module:
import { FormsModule } from '@angular/forms';
...
imports: [
BrowserModule,
FormsModule
],
Save and check the browser again. Edit the value in the input to see the details changing.
First we need a list of heroes to display.
Add a file called mock-heroes.ts
and add some heroes:
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' }
];
Import the heroes list in the heroes component:
import { HEROES } from '../mock-heroes';
Declare a heroes property in the heroes component:
heroes = HEROES;
Display the heroes using the *ngFor
directive in the template above the hero details:
<h2>My Heroes</h2>
<ul class="heroes">
<li *ngFor="let hero of heroes">
<span class="badge">{{hero.id}}</span> {{hero.name}}
</li>
</ul>
And style the heroes list:
/* HeroesComponent's private CSS styles */
.selected {
background-color: #CFD8DC !important;
color: white;
}
.heroes {
margin: 0 0 2em 0;
list-style-type: none;
padding: 0;
width: 15em;
}
.heroes li {
cursor: pointer;
position: relative;
left: 0;
background-color: #EEE;
margin: .5em;
padding: .3em 0;
height: 1.6em;
border-radius: 4px;
}
.heroes li.selected:hover {
background-color: #BBD8DC !important;
color: white;
}
.heroes li:hover {
color: #607D8B;
background-color: #DDD;
left: .1em;
}
.heroes .text {
position: relative;
top: -3px;
}
.heroes .badge {
display: inline-block;
font-size: small;
color: white;
padding: 0.8em 0.7em 0 0.7em;
background-color: #607D8B;
line-height: 1em;
position: relative;
left: -1px;
top: -4px;
height: 1.8em;
margin-right: .8em;
border-radius: 4px 0 0 4px;
}
On the heroes template add an event binding for the hero click:
<li *ngFor="let hero of heroes" (click)="onSelect(hero)">
On the heroes component rename the hero property to selectedHero
and add an onSelect
method to handle the click event:
selectedHero: Hero;
onSelect(hero: Hero): void {
this.selectedHero = hero;
}
And update the hero details template t use the new property:
<h2>{{selectedHero.name | uppercase}} Details</h2>
<div><span>id: </span>{{selectedHero.id}}</div>
<div>
<label>name:
<input [(ngModel)]="selectedHero.name" placeholder="name">
</label>
</div>
Check your browser, you'll get an error since the selectedHero
is initially undefined. Clicking in an hero on the list will make the app work again.
To fix it let's make sure the hero details only shows if an hero is selected:
<div *ngIf="selectedHero"> <!-- new line -->
<h2>{{selectedHero.name | uppercase}} Details</h2>
<div><span>id: </span>{{selectedHero.id}}</div>
<div>
<label>name:
<input [(ngModel)]="selectedHero.name"
placeholder="name">
</label>
</div>
</div> <!-- new line -->
On the hero list <li>
use style binding to set a style:
<li *ngFor="let hero of heroes"
[class.selected]="hero === selectedHero"
(click)="onSelect(hero)">
<span class="badge">{{hero.id}}</span> {{hero.name}}
</li>
Browse through the heroes list and check how the application is working right now.
You should be able the select and edit the existing heroes.
Until now we have used a single component to implement the application logic. In a real application this is not maintainable.
Let's split the app into multiple components.
Add a new component to handle the hero details:
ng generate component hero-detail
Move the the hero details part from the master component template into the details and change the selectedHero
reference to hero
.
<div *ngIf="hero">
<h2>{{hero.name | uppercase}} Details</h2>
<div><span>id: </span>{{hero.id}}</div>
<div>
<label>name:
<input [(ngModel)]="hero.name" placeholder="name"/>
</label>
</div>
</div>
And style the hero details component:
/*
HeroDetailComponent's private CSS styles
*/
label {
display: inline-block;
width: 3em;
margin: .5em 0;
color: #607D8B;
font-weight: bold;
}
input {
height: 2em;
font-size: 1em;
padding-left: .4em;
background: white;
color: #607D8B;
border: 1px solid #607D8B;
border-radius: 4px;
}
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;
}
Import the hero object:
import { Hero } from '../hero';
Import the Input
decorator:
import { Component, OnInit, Input } from '@angular/core';
Add an hero property to the hero details component class:
@Input() hero: Hero;
In the master heroes component, where the hero details where, add a reference to the created hero details component:
<app-hero-detail [hero]="selectedHero"></app-hero-detail>
The hero list template should look like this:
<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>
<app-hero-detail [hero]="selectedHero"></app-hero-detail>
Check the app. You'll see that the app behaves in the same way as before.
Components shouldn't fetch or save data directly and they certainly shouldn't knowingly present fake data. They should focus on presenting data and delegate data access to a service.
Use the CLI to create a service
ng generate service hero
A skeleton service is created in the project src/app
folder, open it:
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class HeroService {
constructor() { }
}
On the AppModule
you can check that the services is not registered in the providers. That's because of the providedIn
in the @Injectable
metadata. This will cause Angular to create a single shared instance os the service to be created.
Delete the HEROES
import and import the HeroesService
:
import { HeroService } from '../hero.service';
Replace the heroes
property definition with a simple declaration:
heroes: Hero[];
Inject the HeroesService
:
constructor(private heroService: HeroService) { }
Add a method to call the service:
getHeroes(): void {
this.heroes = this.heroService.getHeroes();
}
And call it on the component initialization callback:
ngOnInit() {
this.getHeroes();
}
The way we are calling the service will only handle synchronous calls. Since most of the applications get data from some Wb API we need to handle the call asynchronously.
On the service import the required RxJS operators and classes:
import { Observable, of } from 'rxjs';
And return the HEROES
data has an observable:
getHeroes(): Observable<Hero[]> {
return of(HEROES);
}
Edit the heroes component to subscribe to the observable:
getHeroes(): void {
this.heroService.getHeroes()
.subscribe(heroes => this.heroes = heroes);
}
Let's add a service to display a message at the bottom os the screen.
ng generate component messages
<h1>{{title}}</h1>
<app-heroes></app-heroes>
<app-messages></app-messages>
ng generate service messages
Add a messages property:
messages: string[] = [];
Add a add
and a clear
method:
add(message: string) {
this.messages.push(message);
}
clear() {
this.messages = [];
}
Import it in the heroes service:
import { MessageService } from './message.service';
Inject it into the heroes service:
constructor(private messageService: MessageService) { }
And send a message from the service
getHeroes(): Observable<Hero[]> {
this.messageService.add('HeroService: fetched heroes');
return of(HEROES);
}
In the messages component import the messages service:
import { MessageService } from './message.service';
Inject it into the messages component:
constructor(public messageService: MessageService) { }
Edit the messages component template and bind it to the messages service:
<div *ngIf="messageService.messages.length">
<h2>Messages</h2>
<button class="clear" (click)="messageService.clear()">
Clear
</button>
<div *ngFor='let message of messageService.messages'>
{{message}}
</div>
</div>
And style the messages component;
/* MessagesComponent's private CSS styles */
h2 {
color: red;
font-family: Arial, Helvetica, sans-serif;
font-weight: lighter;
}
body {
margin: 2em;
}
body, input[text], button {
color: crimson;
font-family: Cambria, Georgia;
}
button.clear {
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: #aaa;
cursor: auto;
}
button.clear {
color: #888;
margin-bottom: 12px;
}
Navigation in Angular applications is done by routing.
In the command line execute the CLI:
ng generate module app-routing --flat --module=app
This will generate a base routing module:
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
@NgModule({
imports: [
CommonModule
],
declarations: []
})
export class AppRoutingModule { }
Edit the routing module to remove the imported modules and exports. Then import the required routing dependencies:
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
@NgModule({
exports: [ RouterModule ]
})
export class AppRoutingModule {}
Add a route to the heroes component:
import { HeroesComponent } from './heroes/heroes.component';
const routes: Routes = [
{ path: 'heroes', component: HeroesComponent }
];
Import the routing module and configure it with the routes:
@NgModule({
imports: [ RouterModule.forRoot(routes) ],
exports: [ RouterModule ]
})
Replace the heroes component in the app template with a router outlet:
<h1>{{title}}</h1>
<router-outlet></router-outlet>
<app-messages></app-messages>
Try the app. You'll no longer see the heroes list because the list is the the /heroes
path and not the root.
Add a navigation bar to the app component template:
<h1>{{title}}</h1>
<nav>
<a routerLink="/heroes">Heroes</a>
</nav>
<router-outlet></router-outlet>
<app-messages></app-messages>
Try the app and click the link, the heroes list will be displayed and you'll notice that the application path has changed to 'http://localhost:4200/heroes'.
Add a new dashboard component
ng generate component dashboard
Replace the dashboard template content:
<h3>Top Heroes</h3>
<div class="grid grid-pad">
<a *ngFor="let hero of heroes" class="col-1-4">
<div class="module hero">
<h4>{{hero.name}}</h4>
</div>
</a>
</div>
Add the component properties:
export class DashboardComponent implements OnInit {
heroes: Hero[] = [];
constructor(private heroService: HeroService) {
}
ngOnInit() {
this.getHeroes();
}
getHeroes(): void {
this.heroService.getHeroes()
.subscribe(heroes => this.heroes = heroes.slice(1, 5));
}
}
And style the component:
/* DashboardComponent's private CSS styles */
[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;
}
}
Edit the app routing module and import the dashboard component:
import {
DashboardComponent
} from './dashboard/dashboard.component';
Add a new route:
const routes: Routes = [
{ path: 'dashboard', component: DashboardComponent },
{ path: 'heroes', component: HeroesComponent }
];
Add a default route that redirects the root path to the dashboard:
const routes: Routes = [
{ path: 'dashboard', component: DashboardComponent },
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
{ path: 'heroes', component: HeroesComponent }
];
And add a dashboard link to the app component:
<h1>{{title}}</h1>
<nav>
<a routerLink="/dashboard">Dashboard</a>
<a routerLink="/heroes">Heroes</a>
</nav>
<router-outlet></router-outlet>
<app-messages></app-messages>
Try the app.
Start by deleting the hero details from the hero component.
Edit the app routing module and import the hero details component:
import {
HeroDetailComponent
} from './hero-detail/hero-detail.component';
Add a new route:
{ path: 'detail/:id', component: HeroDetailComponent },
On the dashboard component template add routes to the hero details:
<a *ngFor="let hero of heroes" class="col-1-4"
routerLink="/detail/{{hero.id}}">
<div class="module hero">
<h4>{{hero.name}}</h4>
</div>
</a>
Edit the hero component details template to use a link:
<ul class="heroes">
<li *ngFor="let hero of heroes">
<a routerLink="/detail/{{hero.id}}">
<span class="badge">{{hero.id}}</span> {{hero.name}}
</a>
</li>
</ul>
Remove dead code from the heroes component. The remaining component should look like this:
export class HeroesComponent implements OnInit {
heroes: Hero[];
constructor(private heroService: HeroService) { }
ngOnInit() {
this.getHeroes();
}
getHeroes(): void {
this.heroService.getHeroes()
.subscribe(heroes => this.heroes = heroes);
}
}
In the hero details component we need to get the selected hero ID. So import the router required dependencies:
import { ActivatedRoute } from '@angular/router';
import { Location } from '@angular/common';
import { HeroService } from '../hero.service';
Inject the required services in the hero details component constructor:
constructor(
private route: ActivatedRoute,
private heroService: HeroService,
private location: Location
) {}
Extract the ID from the route and call the heroes service with the ID:
ngOnInit(): void {
this.getHero();
}
getHero(): void {
const id = +this.route.snapshot.paramMap.get('id');
this.heroService.getHero(id)
.subscribe(hero => this.hero = hero);
}
Add the method to the heroes service:
getHero(id: number): Observable<Hero> {
this.messageService.add(`HeroService: fetched hero id=${id}`);
return of(HEROES.find(hero => hero.id === id));
}
Add an action to return from the hero details to the source path:
<button (click)="goBack()">go back</button>
And in the hero details handle the action:
goBack(): void {
this.location.back();
}
Clean up by removing the @Input
decorator from the hero
property.
To do this we will be using the Angular HttpClient
.
In the app module import the HttpClient
:
import { HttpClientModule } from '@angular/common/http';
And add it to the modules imports:
imports: [
BrowserModule,
FormsModule,
HttpClientModule,
AppRoutingModule
],
For simplicity let's simulate a server using the Angular In-memory We API.
Add the npm package:
npm install angular-in-memory-web-api --save
In the app module import the required dependencies:
import {
HttpClientInMemoryWebApiModule
} from 'angular-in-memory-web-api';
import { InMemoryDataService } from './in-memory-data.service';
Initialize the in-memory module:
imports: [
BrowserModule,
FormsModule,
HttpClientModule,
HttpClientInMemoryWebApiModule.forRoot(InMemoryDataService, {
dataEncapsulation: false
}),
AppRoutingModule
],
Generate a in-memory support service:
ng generate service InMemoryData
Add in-memory logic:
import { InMemoryDbService } from 'angular-in-memory-web-api';
import { Injectable } from '@angular/core';
import { Hero } from "./heroes/hero";
@Injectable({
providedIn: 'root',
})
export class InMemoryDataService implements InMemoryDbService {
createDb() {
const heroes = [
{ 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' }
];
return { heroes };
}
genId(heroes: Hero[]): number {
return heroes.length > 0 ?
Math.max(...heroes.map(hero => hero.id)) + 1 : 11;
}
}
Import the HttpClient
:
import { HttpClient, HttpHeaders } from '@angular/common/http';
And inject it:
constructor(private http: HttpClient,
private messageService: MessageService) { }
Add a logger service:
private log(message: string) {
this.messageService.add(`HeroService: ${message}`);
}
Add an API URL:
private heroesUrl = 'api/heroes';
And change the heroes method to use the client:
getHeroes (): Observable<Hero[]> {
return this.http.get<Hero[]>(this.heroesUrl);
}
Import the RxJS operators for error handling:
import { catchError, map, tap } from 'rxjs/operators';
And catch the errors:
getHeroes (): Observable<Hero[]> {
return this.http.get<Hero[]>(this.heroesUrl)
.pipe(
catchError(this.handleError('getHeroes', []))
);
}
Add the method to handle the errors:
private handleError<T> (operation = 'operation', result?: T) {
return (error: any): Observable<T> => {
console.error(error);
this.log(`${operation} failed: ${error.message}`);
return of(result as T);
};
}
Tap into the observable to log the messages after getting the heroes:
getHeroes (): Observable<Hero[]> {
return this.http.get<Hero[]>(this.heroesUrl)
.pipe(
tap(_ => this.log('fetched heroes')),
catchError(this.handleError('getHeroes', []))
);
}
And change the get hero details method to use the same logic:
getHero(id: number): Observable<Hero> {
const url = `${this.heroesUrl}/${id}`;
return this.http.get<Hero>(url)
.pipe(
tap(_ => this.log(`fetched hero id=${id}`)),
catchError(this.handleError<Hero>(`getHero id=${id}`))
);
}
Browse the app. You'll notice some delay since the HttpClientInMemoryWebApiModule
will delay the response simulating a real Web request.
Right now, since we are using a mock server, when you make changes to a hero and go back to the list changes are lost. Let's save teh changes to the service.
Edit the hero details component template and add a save component:
<button (click)="save()">save</button>
Handle the button click on the component:
save(): void {
this.heroService.updateHero(this.hero)
.subscribe(() => this.goBack());
}
Edit the heroes service and add a update method:
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'))
);
}
Declare the httpOptions
with headers information to set the content type as JSON:
const httpOptions = {
headers: new HttpHeaders({ 'Content-Type': 'application/json' })
};
Now if you browse the application, edit an hero and save before pressing back the hero will also be updated in the list.
Edit the heroes component template and add an input after the header:
<div>
<label>Hero name:
<input #heroName />
</label>
<!-- (click) passes input value to add()
and then clears the input -->
<button (click)="add(heroName.value); heroName.value=''">
add
</button>
</div>
In the component code handle the click event:
add(name: string): void {
name = name.trim();
if (!name) { return; }
this.heroService.addHero({ name } as Hero)
.subscribe(hero => {
this.heroes.push(hero);
});
}
Add some styles for the input in the component private styles:
label {
display: inline-block;
margin: .5em 0;
color: #607D8B;
font-weight: bold;
}
input {
height: 2em;
font-size: 1em;
padding-left: .4em;
background: white;
color: #607D8B;
border: 1px solid #607D8B;
border-radius: 4px;
display: block;
}
button {
background-color: #eee;
border: none;
padding: 5px 10px;
border-radius: 4px;
cursor: pointer;
cursor: hand;
font-family: Arial;
}
button:hover {
background-color: #cfd8dc;
}
Edit the heroes service and add an add
method:
addHero (hero: Hero): Observable<Hero> {
return this.http.post<Hero>(this.heroesUrl, hero, httpOptions).pipe(
tap((hero: Hero) => this.log(`added hero w/ id=${hero.id}`)),
catchError(this.handleError<Hero>('addHero'))
);
}
Finally we should be able to delete heroes too, so let's add a delete button to each hero on the list:
<button class="delete" title="delete hero"
(click)="delete(hero)">x</button>
The heroes component template list should look like this:
<ul class="heroes">
<li *ngFor="let hero of heroes">
<a routerLink="/detail/{{hero.id}}">
<span class="badge">{{hero.id}}</span> {{hero.name}}
</a>
<button class="delete" title="delete hero"
(click)="delete(hero)">x</button>
</li>
</ul>
Handle the click on the heroes component:
delete(hero: Hero): void {
this.heroes = this.heroes.filter(h => h !== hero);
this.heroService.deleteHero(hero).subscribe();
}
Note about the above code. The subscribe method does nothing. This is not an error. An observable by rule does nothing until something subscribes it, so you must always call subscribe.
Style the delete button:
li {
display: flex;
}
li a {
flex-grow: 1;
}
button.delete {
position: relative;
background-color: gray !important;
color: white;
}
Add a delete method to the heroes service:
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'))
);
}
As a final exercise we will use observable operators to optimize and minimize the number of similar HTTP requests.
To do this we will include a hero search feature in the application dashboard.
Generate a new search component:
ng generate component hero-search
Edit the search component template:
<div id="search-component">
<h4>Hero Search</h4>
<input #searchBox id="search-box"
(input)="search(searchBox.value)" />
<ul class="search-result">
<li *ngFor="let hero of heroes$ | async" >
<a routerLink="/detail/{{hero.id}}">
{{hero.name}}
</a>
</li>
</ul>
</div>
Style the search component:
/* HeroSearch private styles */
.search-result li {
border-bottom: 1px solid gray;
border-left: 1px solid gray;
border-right: 1px solid gray;
width: 195px;
height: 16px;
padding: 5px;
background-color: white;
cursor: pointer;
list-style-type: none;
}
.search-result li:hover {
background-color: #607D8B;
}
.search-result li a {
color: #888;
display: block;
text-decoration: none;
}
.search-result li a:hover {
color: white;
}
.search-result li a:active {
color: white;
}
#search-box {
width: 200px;
height: 20px;
}
ul.search-result {
margin-top: 0;
padding-left: 0;
}
input {
background: white;
color: #607D8B;
border: 1px solid #607D8B;
border-radius: 4px;
}
Edit the search component code:
import { Component, OnInit } from '@angular/core';
import { Observable, Subject } from 'rxjs';
import {
debounceTime, distinctUntilChanged, switchMap
} from 'rxjs/operators';
import { Hero } from '../heroes/hero';
import { HeroService } from '../hero.service';
@Component({
selector: 'app-hero-search',
templateUrl: './hero-search.component.html',
styleUrls: ['./hero-search.component.css']
})
export class HeroSearchComponent implements OnInit {
heroes$: Observable<Hero[]>;
private searchTerms = new Subject<string>();
constructor(private heroService: HeroService) {
}
search(term: string): void {
this.searchTerms.next(term);
}
ngOnInit(): void {
this.heroes$ = this.searchTerms.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((term: string) => this.heroService.searchHeroes(term))
);
}
}
Notice the declaration of the heroes array:
heroes$: Observable<Hero[]>;
This is an observable that is not subscribed in the code. Why will this work? The answer is in the template async
pipe that handles the subscription in the template:
<li *ngFor="let hero of heroes$ | async" >
We also use a Subject
that when called will invoke the search method in the service:
private searchTerms = new Subject<string>();
search(term: string): void {
this.searchTerms.next(term);
}
It will be called when the search input value changes:
<input #searchBox id="search-box"
(input)="search(searchBox.value)" />
The Subject
has some chained operators to minimize the amount of calls to the service:
this.heroes$ = this.searchTerms.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((term: string) => this.heroService.searchHeroes(term))
);
debounceTime(300)
waits until the flow of new string events pauses for 300 milliseconds before passing along the latest string. You'll never make requests more frequently than 300ms.distinctUntilChanged()
ensures that a request is sent only if the filter text changed.switchMap()
calls the search service for each search term that makes it through debounce and distinctUntilChanged. It cancels and discards previous search observables, returning only the latest search service observable.
Add a search method to the heroes service:
searchHeroes(term: string): Observable<Hero[]> {
if (!term.trim()) {
// if not search term, return empty hero array.
return of([]);
}
return this.http.get<Hero[]>(`${this.heroesUrl}/?name=${term}`)
.pipe(
tap(_ => this.log(`found heroes matching "${term}"`)),
catchError(this.handleError<Hero[]>('searchHeroes', []))
);
}
And edit the dashboard template to add the search component:
<h3>Top Heroes</h3>
<div class="grid grid-pad">
<a *ngFor="let hero of heroes" class="col-1-4"
routerLink="/detail/{{hero.id}}">
<div class="module hero">
<h4>{{hero.name}}</h4>
</div>
</a>
</div>
<app-hero-search></app-hero-search>
And try it!
Done!
For full documentations check the Angular guide at https://angular.io/guide/quickstart
There's a lot more to know about what was discussed where discussed here and other. I recommend you check:
- Forms https://angular.io/guide/forms-overview
- Observables https://angular.io/guide/observables
- Routing & Navigation https://angular.io/guide/router
- Animations https://angular.io/guide/animations
And explore all the other documentation which includes Security, Service Workers, Internationalization, Server-side Rendering, etc.