parent
f056a2d5d2
commit
f50dff818a
|
@ -23,10 +23,31 @@ describe('TOH Http Chapter', function () {
|
||||||
|
|
||||||
addButton: element.all(by.buttonText('Add New Hero')).get(0),
|
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(){
|
it('should be able to add a hero from the "Heroes" view', function(){
|
||||||
let page = getPageStruct();
|
let page = getPageStruct();
|
||||||
let heroCount: webdriver.promise.Promise<number>;
|
let heroCount: webdriver.promise.Promise<number>;
|
||||||
|
|
|
@ -4,6 +4,9 @@ import { Component } from '@angular/core';
|
||||||
import { ROUTER_DIRECTIVES } from '@angular/router';
|
import { ROUTER_DIRECTIVES } from '@angular/router';
|
||||||
|
|
||||||
import { HeroService } from './hero.service';
|
import { HeroService } from './hero.service';
|
||||||
|
// #docregion rxjs-extensions
|
||||||
|
import './rxjs-extensions';
|
||||||
|
// #enddocregion rxjs-extensions
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-app',
|
selector: 'my-app',
|
||||||
|
|
|
@ -9,3 +9,5 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<hero-search></hero-search>
|
||||||
|
|
||||||
|
|
|
@ -5,11 +5,13 @@ import { Router } from '@angular/router';
|
||||||
|
|
||||||
import { Hero } from './hero';
|
import { Hero } from './hero';
|
||||||
import { HeroService } from './hero.service';
|
import { HeroService } from './hero.service';
|
||||||
|
import { HeroSearchComponent } from './hero-search.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-dashboard',
|
selector: 'my-dashboard',
|
||||||
templateUrl: 'app/dashboard.component.html',
|
templateUrl: 'app/dashboard.component.html',
|
||||||
styleUrls: ['app/dashboard.component.css']
|
styleUrls: ['app/dashboard.component.css'],
|
||||||
|
directives: [HeroSearchComponent]
|
||||||
})
|
})
|
||||||
export class DashboardComponent implements OnInit {
|
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) { }
|
constructor(private http: Http) { }
|
||||||
|
|
||||||
getHeroes(): Promise<Hero[]> {
|
getHeroes() {
|
||||||
return this.http.get(this.heroesUrl)
|
return this.http.get(this.heroesUrl)
|
||||||
// #docregion to-promise
|
// #docregion to-promise
|
||||||
.toPromise()
|
.toPromise()
|
||||||
// #enddocregion to-promise
|
// #enddocregion to-promise
|
||||||
// #docregion to-data
|
// #docregion to-data
|
||||||
.then(response => response.json().data)
|
.then(response => response.json().data as Hero[])
|
||||||
// #enddocregion to-data
|
// #enddocregion to-data
|
||||||
// #docregion catch
|
// #docregion catch
|
||||||
.catch(this.handleError);
|
.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;
|
background-color: gray !important;
|
||||||
color:white;
|
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
|
:marked
|
||||||
The Angular `http.get` returns an RxJS `Observable`.
|
The Angular `http.get` returns an RxJS `Observable`.
|
||||||
*Observables* are a powerful way to manage asynchronous data flows.
|
*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=".")
|
+makeExample('toh-6/ts/app/hero.service.ts', 'to-promise')(format=".")
|
||||||
:marked
|
:marked
|
||||||
Unfortunately, the Angular `Observable` doesn't have a `toPromise` operator ... not out of the box.
|
Unfortunately, the Angular `Observable` doesn't have a `toPromise` operator ... not out of the box.
|
||||||
|
@ -358,6 +359,146 @@ block review
|
||||||
figure.image-display
|
figure.image-display
|
||||||
img(src='/resources/images/devguide/toh/toh-http.anim.gif' alt="Heroes List Editting w/ HTTP")
|
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
|
.l-main-section
|
||||||
:marked
|
:marked
|
||||||
## Application structure and code
|
## Application structure and code
|
||||||
|
@ -381,6 +522,10 @@ block filetree
|
||||||
.file hero-detail.component.css
|
.file hero-detail.component.css
|
||||||
.file hero-detail.component.html
|
.file hero-detail.component.html
|
||||||
.file hero-detail.component.ts
|
.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 hero.service.ts
|
||||||
.file heroes.component.css
|
.file heroes.component.css
|
||||||
.file heroes.component.html
|
.file heroes.component.html
|
||||||
|
@ -407,6 +552,7 @@ block filetree
|
||||||
- We extended HeroService to support post, put and delete calls.
|
- We extended HeroService to support post, put and delete calls.
|
||||||
- We updated our components to allow adding, editing and deleting of heroes.
|
- We updated our components to allow adding, editing and deleting of heroes.
|
||||||
- We configured an in-memory web API.
|
- We configured an in-memory web API.
|
||||||
|
- We learned how to use Observables.
|
||||||
|
|
||||||
Below is a summary of the files we changed and added.
|
Below is a summary of the files we changed and added.
|
||||||
|
|
||||||
|
@ -430,3 +576,15 @@ block file-summary
|
||||||
in-memory-data.service.ts,
|
in-memory-data.service.ts,
|
||||||
sample.css`
|
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