parent
f056a2d5d2
commit
f50dff818a
|
@ -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>;
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -9,3 +9,5 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hero-search></hero-search>
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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';
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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 |
Loading…
Reference in New Issue