diff --git a/public/docs/_examples/toh-6/e2e-spec.ts b/public/docs/_examples/toh-6/e2e-spec.ts index 96277a910a..e2ab2602dc 100644 --- a/public/docs/_examples/toh-6/e2e-spec.ts +++ b/public/docs/_examples/toh-6/e2e-spec.ts @@ -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; diff --git a/public/docs/_examples/toh-6/ts/app/app.component.ts b/public/docs/_examples/toh-6/ts/app/app.component.ts index 2a1ff50ba3..d49c87ccbf 100644 --- a/public/docs/_examples/toh-6/ts/app/app.component.ts +++ b/public/docs/_examples/toh-6/ts/app/app.component.ts @@ -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', diff --git a/public/docs/_examples/toh-6/ts/app/dashboard.component.html b/public/docs/_examples/toh-6/ts/app/dashboard.component.html index 028eab6eb3..e22a2a5ebb 100644 --- a/public/docs/_examples/toh-6/ts/app/dashboard.component.html +++ b/public/docs/_examples/toh-6/ts/app/dashboard.component.html @@ -9,3 +9,5 @@

{{hero.name}}

+ + diff --git a/public/docs/_examples/toh-6/ts/app/dashboard.component.ts b/public/docs/_examples/toh-6/ts/app/dashboard.component.ts index 08ffecc0ea..f7b4100cee 100644 --- a/public/docs/_examples/toh-6/ts/app/dashboard.component.ts +++ b/public/docs/_examples/toh-6/ts/app/dashboard.component.ts @@ -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 { diff --git a/public/docs/_examples/toh-6/ts/app/hero-search.component.html b/public/docs/_examples/toh-6/ts/app/hero-search.component.html new file mode 100644 index 0000000000..47c853746b --- /dev/null +++ b/public/docs/_examples/toh-6/ts/app/hero-search.component.html @@ -0,0 +1,11 @@ + +
+

Hero Search

+ +
+
+ {{hero.name}} +
+
+
diff --git a/public/docs/_examples/toh-6/ts/app/hero-search.component.ts b/public/docs/_examples/toh-6/ts/app/hero-search.component.ts new file mode 100644 index 0000000000..2b4d155046 --- /dev/null +++ b/public/docs/_examples/toh-6/ts/app/hero-search.component.ts @@ -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(); + // #enddocregion subject + // #docregion search + heroes: Observable; + // #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([])) + + .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); + } +} diff --git a/public/docs/_examples/toh-6/ts/app/hero-search.service.ts b/public/docs/_examples/toh-6/ts/app/hero-search.service.ts new file mode 100644 index 0000000000..42018e3526 --- /dev/null +++ b/public/docs/_examples/toh-6/ts/app/hero-search.service.ts @@ -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 +} diff --git a/public/docs/_examples/toh-6/ts/app/hero.service.ts b/public/docs/_examples/toh-6/ts/app/hero.service.ts index 8abbcc2778..04012768be 100644 --- a/public/docs/_examples/toh-6/ts/app/hero.service.ts +++ b/public/docs/_examples/toh-6/ts/app/hero.service.ts @@ -17,13 +17,13 @@ export class HeroService { constructor(private http: Http) { } - getHeroes(): Promise { + 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); diff --git a/public/docs/_examples/toh-6/ts/app/rxjs-extensions.ts b/public/docs/_examples/toh-6/ts/app/rxjs-extensions.ts new file mode 100644 index 0000000000..a0facfe03e --- /dev/null +++ b/public/docs/_examples/toh-6/ts/app/rxjs-extensions.ts @@ -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'; diff --git a/public/docs/_examples/toh-6/ts/sample.css b/public/docs/_examples/toh-6/ts/sample.css index 0c99008d2d..a5ac5b4d70 100644 --- a/public/docs/_examples/toh-6/ts/sample.css +++ b/public/docs/_examples/toh-6/ts/sample.css @@ -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; +} + diff --git a/public/docs/ts/latest/tutorial/toh-pt6.jade b/public/docs/ts/latest/tutorial/toh-pt6.jade index 5f65c5a421..0b00284f5c 100644 --- a/public/docs/ts/latest/tutorial/toh-pt6.jade +++ b/public/docs/ts/latest/tutorial/toh-pt6.jade @@ -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` +) diff --git a/public/resources/images/devguide/toh/toh-hero-search.png b/public/resources/images/devguide/toh/toh-hero-search.png new file mode 100644 index 0000000000..f09fd45d7e Binary files /dev/null and b/public/resources/images/devguide/toh/toh-hero-search.png differ