docs(toh-pt6): add observables to HTTP tutorial chapter

closes #1528
This commit is contained in:
Torgeir Helgevold 2016-05-23 22:24:15 -04:00 committed by Ward Bell
parent f056a2d5d2
commit f50dff818a
12 changed files with 306 additions and 7 deletions

View File

@ -23,10 +23,31 @@ describe('TOH Http Chapter', function () {
addButton: element.all(by.buttonText('Add New Hero')).get(0),
heroDetail: element(by.css('my-app my-hero-detail'))
heroDetail: element(by.css('my-app my-hero-detail')),
searchBox: element(by.css('#search-box')),
searchResults: element.all(by.css('.search-result'))
};
}
it('should search for hero and navigate to details view', function() {
let page = getPageStruct();
return sendKeys(page.searchBox, 'Magneta').then(function () {
expect(page.searchResults.count()).toBe(1);
let hero = page.searchResults.get(0);
return hero.click();
})
.then(function() {
browser.waitForAngular();
let inputEle = page.heroDetail.element(by.css('input'));
return inputEle.getAttribute('value');
})
.then(function(value) {
expect(value).toBe('Magneta');
});
});
it('should be able to add a hero from the "Heroes" view', function(){
let page = getPageStruct();
let heroCount: webdriver.promise.Promise<number>;

View File

@ -4,6 +4,9 @@ import { Component } from '@angular/core';
import { ROUTER_DIRECTIVES } from '@angular/router';
import { HeroService } from './hero.service';
// #docregion rxjs-extensions
import './rxjs-extensions';
// #enddocregion rxjs-extensions
@Component({
selector: 'my-app',

View File

@ -9,3 +9,5 @@
</div>
</div>
</div>
<hero-search></hero-search>

View File

@ -5,11 +5,13 @@ import { Router } from '@angular/router';
import { Hero } from './hero';
import { HeroService } from './hero.service';
import { HeroSearchComponent } from './hero-search.component';
@Component({
selector: 'my-dashboard',
templateUrl: 'app/dashboard.component.html',
styleUrls: ['app/dashboard.component.css']
styleUrls: ['app/dashboard.component.css'],
directives: [HeroSearchComponent]
})
export class DashboardComponent implements OnInit {

View File

@ -0,0 +1,11 @@
<!-- #docregion -->
<div id="search-component">
<h4>Hero Search</h4>
<input #searchBox id="search-box" (keyup)="search.next(searchBox.value)" />
<div>
<div *ngFor="let hero of heroes | async"
(click)="gotoDetail(hero)" class="search-result" >
{{hero.name}}
</div>
</div>
</div>

View File

@ -0,0 +1,53 @@
// #docplaster
// #docregion
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { Observable } from 'rxjs/Observable';
import { Subject } from 'rxjs/Subject';
import { HeroSearchService } from './hero-search.service';
import { Hero } from './hero';
@Component({
selector: 'hero-search',
templateUrl: 'app/hero-search.component.html',
providers: [HeroSearchService]
})
export class HeroSearchComponent implements OnInit {
// #docregion subject
search = new Subject<string>();
// #enddocregion subject
// #docregion search
heroes: Observable<Hero>;
// #enddocregion search
constructor(
private heroSearchService: HeroSearchService,
private router: Router) {}
// #docregion search
ngOnInit() {
this.heroes = this.search
.asObservable() // "cast" as Observable
.debounceTime(300) // wait for 300ms pause in events
.distinctUntilChanged() // ignore if next search term is same as previous
.switchMap(term => term // switch to new observable each time
// return the http search observable
? this.heroSearchService.search(term)
// or the observable of empty heroes if no search term
: Observable.of<Hero[]>([]))
.catch(error => {
// Todo: real error handling
console.log(error);
return Observable.throw(error);
});
}
// #enddocregion search
gotoDetail(hero: Hero) {
let link = ['/detail', hero.id];
this.router.navigate(link);
}
}

View File

@ -0,0 +1,19 @@
// #docregion
import { Injectable } from '@angular/core';
import { Http, Response } from '@angular/http';
import { Hero } from './hero';
@Injectable()
export class HeroSearchService {
constructor(private http: Http) {}
// #docregion observable-search
search(term: string) {
return this.http
.get(`app/heroes/?name=${term}+`)
.map((r: Response) => r.json().data as Hero[]);
}
// #enddocregion observable-search
}

View File

@ -17,13 +17,13 @@ export class HeroService {
constructor(private http: Http) { }
getHeroes(): Promise<Hero[]> {
getHeroes() {
return this.http.get(this.heroesUrl)
// #docregion to-promise
.toPromise()
// #enddocregion to-promise
// #docregion to-data
.then(response => response.json().data)
.then(response => response.json().data as Hero[])
// #enddocregion to-data
// #docregion catch
.catch(this.handleError);

View File

@ -0,0 +1,13 @@
// #docregion
// Observable class extensions
import 'rxjs/add/observable/of';
import 'rxjs/add/observable/throw';
// Observable operators
import 'rxjs/add/operator/catch';
import 'rxjs/add/operator/debounceTime';
import 'rxjs/add/operator/distinctUntilChanged';
import 'rxjs/add/operator/do';
import 'rxjs/add/operator/filter';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/switchMap';

View File

@ -5,3 +5,20 @@ button.delete-button{
background-color: gray !important;
color:white;
}
.search-result{
border-bottom: 1px solid gray;
border-left: 1px solid gray;
border-right: 1px solid gray;
width:195px;
height: 20px;
padding: 5px;
background-color: white;
cursor: pointer;
}
#search-box{
width: 200px;
height: 20px;
}

View File

@ -131,9 +131,10 @@ block get-heroes-details
:marked
The Angular `http.get` returns an RxJS `Observable`.
*Observables* are a powerful way to manage asynchronous data flows.
We'll learn about `Observables` *later*.
We'll learn about [Observables](#observables) later in this chapter.
For *now* we get back on familiar ground by immediately converting that `Observable` to a `Promise` using the `toPromise` operator.
For *now* we get back on familiar ground by immediately by
converting that `Observable` to a `Promise` using the `toPromise` operator.
+makeExample('toh-6/ts/app/hero.service.ts', 'to-promise')(format=".")
:marked
Unfortunately, the Angular `Observable` doesn't have a `toPromise` operator ... not out of the box.
@ -358,6 +359,146 @@ block review
figure.image-display
img(src='/resources/images/devguide/toh/toh-http.anim.gif' alt="Heroes List Editting w/ HTTP")
:marked
## Observables
Each `Http` method returns an `Observable` of HTTP `Response` objects.
Our `HeroService` converts that `Observable` into a `Promise` and returns the promise to the caller.
In this section we learn to return the `Observable` directly and discuss when and why that might be
a good thing to do.
### Background
An *observable* is a stream of events that we can process with array-like operators.
Angular core has basic support for observables. We developers augment that support with
operators and extensions from the [RxJS Observables](http://reactivex.io/rxjs/) library.
We'll see how shortly.
Recall that our `HeroService` quickly chained the `toPromise` operator to the `Observable` result of `http.get`.
That operator converted the `Observable` into a `Promise` and we passed that promise back to the caller.
Converting to a promise is often a good choice. We typically ask `http` to fetch a single chunk of data.
When we receive the data, we're done.
A single result in the form of a promise is easy for the calling component to consume
and it helps that promises are widely understood by JavaScript programmers.
But requests aren't always "one and done". We may start one request,
then cancel it, and make a different request ... before the server has responded to the first request.
Such a _request-cancel-new-request_ sequence is difficult to implement with *promises*.
It's easy with *observables* as we'll see.
### Search-by-name
We're going to add a *hero search* feature to the Tour of Heroes.
As the user types a name into a search box, we'll make repeated http requests for heroes filtered by that name.
We start by creating `HeroSearchService` that sends search queries to our server's web api.
+makeExample('toh-6/ts/app/hero-search.service.ts', null, 'app/hero-search.service.ts')(format=".")
:marked
The `http.get` call in `HeroSearchService` is similar to the `http.get` call in the `HeroService`.
The notable difference: we no longer call `toPromise`.
We simply return the *observable* instead.
### HeroSearchComponent
Let's create a new `HeroSearchComponent` that calls this new `HeroSearchService`.
The component template is simple - just a textbox and a list of matching search results.
+makeExample('toh-6/ts/app/hero-search.component.html', null,'hero-search.component.html')
:marked
As the user types in the search box, a *keyup* event binding calls `search.next` with the new search box value.
The component's data bound `search` property returns a `Subject`.
A `Subject` is a producer of an _observable_ event stream.
Each call to `search.next` puts a new string into this subject's _observable_ stream.
The `*ngFor` repeats *hero* objects from the component's `heroes` property. No surprise there.
But `heroes` is an `Observable` of heroes, not an array of heroes.
The `*ngFor` can't do anything with that until we flow it through the `AsyncPipe` (`heroes | async`).
The `AsyncPipe` subscribes to the observable and produces the array of heroes to `*ngFor`.
Time to create the `HeroSearchComponent` class and metadata.
+makeExample('toh-6/ts/app/hero-search.component.ts', null,'hero-search.component.ts')
:marked
Scroll down to where we create the `search` subject.
+makeExample('toh-6/ts/app/hero-search.component.ts', 'subject')
:marked
We're binding to that `search` subject in our template.
The user is sending it a stream of strings, the filter criteria for the name search.
A `Subject` is also an `Observable`.
We're going to access that `Observable` and append operators to it that turn the stream
of strings into a stream of `Hero[]` arrays.
Each user keystroke could result in a new http request returning a new Observable array of heroes.
This could be a very chatty, taxing our server resources and burning up our cellular network data plan.
Fortunately we can chain `Observable` operators to reduce the request flow
and still get timely results. Here's how:
+makeExample('toh-6/ts/app/hero-search.component.ts', 'search')(format=".")
:marked
* The `asObservable` operator casts the `Subject` as an `Observable` of filter strings.
* `debounceTime(300)` waits until the flow of new string events pauses for 300 milliseconds
before passing along the latest string. We'll never make requests more frequently than 300ms.
* `distinctUntilChanged` ensures that we only send a request if the filter text changed.
There's no point in repeating a request for the same search term.
* `switchMap` calls our search service for each search term that makes it through the `debounce` and `distinctUntilChanged` gauntlet.
It discards previous search observables, returning only the latest search service observable.
.l-sub-section
:marked
The [switchMap operator](https://github.com/Reactive-Extensions/RxJS/blob/master/doc/api/core/operators/flatmaplatest.md)
(formerly known as "flatMapLatest") is very clever.
Every qualifying key event can trigger an http call.
Even with a 300ms pause between requests, we could have multiple http requests in flight
and they may not return in the order sent.
`switchMap` preserves the original request order while returning
only the observable from the most recent http call.
Results from prior calls will be discarded.
We also short-circuit the http call and return an observable containing an empty array
if the search text is empty.
:marked
* `catch` intercepts a failed observable.
Our simple example prints the error to the console; a real life application should do better.
Then it re-throws the failed observable so that downstream processes know it failed.
The `AsyncPipe` in the template is downstream. It sees the failure and ignores it.
### Import RxJS operators
The RxJS operators are not available in Angular's base `Observable` implementation.
We have to extend `Observable` by *importing* them.
We could extend `Observable` with just the operators we need here by
including the pertinent `import` statements at the top of this file.
.l-sub-section
:marked
Many authorities say we should do just that.
:marked
We take a different approach in this example.
We combine all of the RxJS `Observable` extensions that _our entire app_ requires into a single RxJS imports file.
+makeExample('toh-6/ts/app/rxjs-extensions.ts', null, 'app/rxjs-extensions.ts')(format=".")
:marked
We load them all at once by importing `rxjs-extensions` in `AppComponent`.
+makeExample('toh-6/ts/app/app.component.ts', 'rxjs-extensions', 'app/app/app.component.ts')(format=".")
:marked
Finally, we add the `HeroSearchComponent` to the bottom of the `DashboardComponent`.
Run the app again, go to the *Dashboard*, and enter some text in the search box below the hero tiles.
At some point it might look like this.
figure.image-display
img(src='/resources/images/devguide/toh/toh-hero-search.png' alt="Hero Search Component")
.l-main-section
:marked
## Application structure and code
@ -381,6 +522,10 @@ block filetree
.file hero-detail.component.css
.file hero-detail.component.html
.file hero-detail.component.ts
.file hero-search.component.html
.file hero-search.component.ts
.file hero-search.service.ts
.file rxjs-operators.ts
.file hero.service.ts
.file heroes.component.css
.file heroes.component.html
@ -407,7 +552,8 @@ block filetree
- We extended HeroService to support post, put and delete calls.
- We updated our components to allow adding, editing and deleting of heroes.
- We configured an in-memory web API.
- We learned how to use Observables.
Below is a summary of the files we changed and added.
block file-summary
@ -430,3 +576,15 @@ block file-summary
in-memory-data.service.ts,
sample.css`
)
+makeTabs(
`toh-6/ts/app/hero-search.service.ts,
toh-6/ts/app/hero-search.component.ts,
toh-6/ts/app/hero-search.component.html,
toh-6/ts/app/rxjs-operators.ts`,
null,
`hero-search.service.ts,
hero-search.component.ts,
hero-search.service.html,
rxjs-operators.ts`
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB