docs(toh-6): refactoring of 'add, edit, delete heroes' (#2170)

* docs(toh-6/dart): refactoring of 'add, edit, delete heroes'

Refactoring of "add, edit, delete heroes" section of toh-6 from one big
bottom-up step into small independent feature slices, where the user
achieves a "milesone" (i.e., can run the full app) after each feature
section. The section rewrite is shorter and offers a better UX.

Other simplifications:
- Error handling is consistent: in the hero service we log to the
console, everwhere else we just let errors bubble up.
- Hero service methods renamed based on function (create, update)
rather then lower-level implementation (post, put).
- @Output properties have been eliminated (since they weren't
explained).

E2E tests now pass on both the TS and Dart sides.

* docs(toh-6/ts): refactoring of 'add, edit, delete heroes'

Refactoring of "add, edit, delete heroes" section of toh-6 from one big
bottom-up step into small independent feature slices, where the user
achieves a "milesone" (i.e., can run the full app) after each feature
section. The section rewrite is shorter and offers a better UX.

Other simplifications:
- Error handling is consistent: in the hero service we log to the
console, everwhere else we just let errors bubble up.
- Hero service methods renamed based on function (create, update)
rather then lower-level implementation (post, put).
- @Output properties have been eliminated (since they weren't
explained).

E2E tests now pass on both the TS and Dart sides.

Post-Dart-review updates included.

* docs(toh-6): ward tweaks
This commit is contained in:
Patrice Chalin 2016-08-26 14:57:45 -07:00 committed by Ward Bell
parent 2bd9946bda
commit 907f848c95
24 changed files with 708 additions and 794 deletions

View File

@ -3,59 +3,38 @@
import 'dart:async';
import 'dart:html';
// #docregion import-oninit
import 'package:angular2/core.dart';
// #enddocregion import-oninit
// #docregion import-route-params
import 'package:angular2/router.dart';
// #enddocregion import-route-params
import 'hero.dart';
// #docregion import-hero-service
import 'hero_service.dart';
// #enddocregion import-hero-service
// #docregion extract-template
@Component(
selector: 'my-hero-detail',
// #docregion template-url
templateUrl: 'hero_detail_component.html',
// #enddocregion template-url, v2
styleUrls: const ['hero_detail_component.css']
// #docregion v2
)
// #enddocregion extract-template
// #docregion implement
class HeroDetailComponent implements OnInit {
// #enddocregion implement
Hero hero;
// #docregion ctor
final HeroService _heroService;
final RouteParams _routeParams;
HeroDetailComponent(this._heroService, this._routeParams);
// #enddocregion ctor
// #docregion ng-oninit
Future<Null> ngOnInit() async {
// #docregion get-id
var idString = _routeParams.get('id');
var id = int.parse(idString, onError: (_) => null);
// #enddocregion get-id
if (id != null) hero = await (_heroService.getHero(id));
}
// #enddocregion ng-oninit
// #docregion save
Future<Null> save() async {
await _heroService.save(hero);
await _heroService.update(hero);
goBack();
}
// #enddocregion save
// #docregion go-back
void goBack() {
window.history.back();
}
// #enddocregion go-back
}

View File

@ -1,4 +1,3 @@
<!-- #docplaster -->
<!-- #docregion -->
<div *ngIf="hero != null">
<h2>{{hero.name}} details!</h2>

View File

@ -1,4 +1,5 @@
// #docregion
// #docplaster
// #docregion , imports
import 'dart:async';
import 'dart:convert';
@ -6,12 +7,13 @@ import 'package:angular2/core.dart';
import 'package:http/http.dart';
import 'hero.dart';
// #enddocregion imports
@Injectable()
class HeroService {
// #docregion post
// #docregion update
static final _headers = {'Content-Type': 'application/json'};
// #enddocregion post
// #enddocregion update
// #docregion getHeroes
static const _heroesUrl = 'app/heroes'; // URL to web API
@ -35,25 +37,20 @@ class HeroService {
// #docregion extract-data
dynamic _extractData(Response resp) => JSON.decode(resp.body)['data'];
// #enddocregion extract-data, getHeroes
Future<Hero> getHero(int id) async =>
(await getHeroes()).firstWhere((hero) => hero.id == id);
// #docregion save
Future<Hero> save(dynamic heroOrName) =>
heroOrName is Hero ? _put(heroOrName) : _post(heroOrName);
// #enddocregion save
// #enddocregion extract-data
// #docregion handleError
Exception _handleError(dynamic e) {
print(e); // for demo purposes only
return new Exception('Server error; cause: $e');
}
// #enddocregion handleError
// #enddocregion handleError, getHeroes
// #docregion post
Future<Hero> _post(String name) async {
Future<Hero> getHero(int id) async =>
(await getHeroes()).firstWhere((hero) => hero.id == id);
// #docregion create
Future<Hero> create(String name) async {
try {
final response = await _http.post(_heroesUrl,
headers: _headers, body: JSON.encode({'name': name}));
@ -62,10 +59,10 @@ class HeroService {
throw _handleError(e);
}
}
// #enddocregion post
// #enddocregion create
// #docregion update
// #docregion put
Future<Hero> _put(Hero hero) async {
Future<Hero> update(Hero hero) async {
try {
var url = '$_heroesUrl/${hero.id}';
final response =
@ -75,7 +72,7 @@ class HeroService {
throw _handleError(e);
}
}
// #enddocregion put
// #enddocregion update
// #docregion delete
Future<Null> delete(int id) async {

View File

@ -59,9 +59,10 @@ button:hover {
background-color: #cfd8dc;
}
/* #docregion additions */
.error {color:red;}
button.delete-button {
button.delete {
float:right;
margin-top: 2px;
margin-right: .8em;
background-color: gray !important;
color:white;
}

View File

@ -1,4 +1,3 @@
// #docplaster
// #docregion
import 'dart:async';
@ -15,45 +14,35 @@ import 'hero_service.dart';
styleUrls: const ['heroes_component.css'],
directives: const [HeroDetailComponent])
class HeroesComponent implements OnInit {
final Router _router;
final HeroService _heroService;
List<Hero> heroes;
Hero selectedHero;
// #docregion error
String errorMessage;
// #enddocregion error
final HeroService _heroService;
final Router _router;
HeroesComponent(this._heroService, this._router);
// #docregion addHero
Future<Null> addHero(String name) async {
name = name.trim();
if (name.isEmpty) return;
try {
heroes.add(await _heroService.save(name));
} catch (e) {
errorMessage = e.toString();
}
}
// #enddocregion addHero
// #docregion deleteHero
Future<Null> deleteHero(int id, event) async {
try {
event.stopPropagation();
await _heroService.delete(id);
heroes.removeWhere((hero) => hero.id == id);
if (selectedHero?.id == id) selectedHero = null;
} catch (e) {
errorMessage = e.toString();
}
}
// #enddocregion deleteHero
Future<Null> getHeroes() async {
heroes = await _heroService.getHeroes();
}
// #docregion add
Future<Null> add(String name) async {
name = name.trim();
if (name.isEmpty) return;
heroes.add(await _heroService.create(name));
selectedHero = null;
}
// #enddocregion add
// #docregion delete
Future<Null> delete(Hero hero) async {
await _heroService.delete(hero.id);
heroes.remove(hero);
if (selectedHero == hero) selectedHero = null;
}
// #enddocregion delete
void ngOnInit() {
getHeroes();
}

View File

@ -1,31 +1,30 @@
<!-- #docplaster -->
<!-- #docregion -->
<h2>My Heroes</h2>
<!-- #docregion add-and-error -->
<div class="error" *ngIf="errorMessage != null">{{errorMessage}}</div>
<!-- #docregion add -->
<div>
Name: <input #newHeroName />
<button (click)="addHero(newHeroName.value); newHeroName.value=''">
Add New Hero
<label>Hero name:</label> <input #heroName />
<button (click)="add(heroName.value); heroName.value=''">
Add
</button>
</div>
<!-- #enddocregion add-and-error -->
<!-- #enddocregion add -->
<ul class="heroes">
<li *ngFor="let hero of heroes"
[class.selected]="hero === selectedHero"
(click)="onSelect(hero)">
<span class="badge">{{hero.id}}</span> {{hero.name}}
<!-- #docregion li-element -->
<li *ngFor="let hero of heroes" (click)="onSelect(hero)"
[class.selected]="hero === selectedHero">
<span class="badge">{{hero.id}}</span>
<span>{{hero.name}}</span>
<!-- #docregion delete -->
<button class="delete-button" (click)="deleteHero(hero.id, $event)">x</button>
<button class="delete"
(click)="delete(hero); $event.stopPropagation()">x</button>
<!-- #enddocregion delete -->
</li>
<!-- #enddocregion li-element -->
</ul>
<!-- #docregion mini-detail -->
<div *ngIf="selectedHero != null">
<h2>
<!-- #docregion pipe -->
{{selectedHero.name | uppercase}} is my hero
<!-- #enddocregion pipe -->
</h2>
<button (click)="gotoDetail()">View Details</button>
</div>

View File

@ -1,9 +1,8 @@
// #docregion
// #docregion , init
import 'dart:async';
import 'dart:convert';
import 'dart:math';
// #docregion init
import 'package:angular2/core.dart';
import 'package:http/http.dart';
import 'package:http/testing.dart';
@ -26,7 +25,6 @@ class InMemoryDataService extends MockClient {
];
static final List<Hero> _heroesDb =
_initialHeroes.map((json) => new Hero.fromJson(json)).toList();
// #enddocregion init
static int _nextId = _heroesDb.map((hero) => hero.id).reduce(max) + 1;
static Future<Response> _handler(Request request) async {
@ -37,6 +35,7 @@ class InMemoryDataService extends MockClient {
final regExp = new RegExp(prefix, caseSensitive: false);
data = _heroesDb.where((hero) => hero.name.contains(regExp)).toList();
break;
// #enddocregion init-disabled
case 'POST':
var name = JSON.decode(request.body)['name'];
var newHero = new Hero(_nextId++, name);
@ -54,6 +53,7 @@ class InMemoryDataService extends MockClient {
_heroesDb.removeWhere((hero) => hero.id == id);
// No data, so leave it as null.
break;
// #docregion init-disabled
default:
throw 'Unimplemented HTTP method ${request.method}';
}
@ -62,5 +62,4 @@ class InMemoryDataService extends MockClient {
}
InMemoryDataService() : super(_handler);
// #docregion init
}

View File

@ -4,8 +4,8 @@ import 'package:angular2/core.dart';
import 'package:angular2/platform/browser.dart';
import 'package:angular2_tour_of_heroes/app_component.dart';
// #enddocregion v1
import 'package:http/http.dart';
import 'package:angular2_tour_of_heroes/in_memory_data_service.dart';
import 'package:http/http.dart';
void main() {
bootstrap(AppComponent,

View File

@ -1,246 +1,283 @@
/// <reference path='../_protractor/e2e.d.ts' />
'use strict';
describe('TOH Http Chapter', function () {
beforeEach(function () {
browser.get('');
});
const expectedH1 = 'Tour of Heroes';
const expectedTitle = `Angular 2 ${expectedH1}`;
const targetHero = { id: 15, name: 'Magneta' };
const targetHeroDashboardIndex = 3;
const nameSuffix = 'X';
const newHeroName = targetHero.name + nameSuffix;
function getPageStruct() {
let hrefEles = element.all(by.css('my-app a'));
type WPromise<T> = webdriver.promise.Promise<T>;
class Hero {
id: number;
name: string;
// Factory methods
// Hero from string formatted as '<id> <name>'.
static fromString(s: string): Hero {
return {
id: +s.substr(0, s.indexOf(' ')),
name: s.substr(s.indexOf(' ') + 1),
};
}
// Hero from hero list <li> element.
static async fromLi(li: protractor.ElementFinder): Promise<Hero> {
let strings = await li.all(by.xpath('span')).getText();
return { id: +strings[0], name: strings[1] };
}
// Hero id and name from the given detail element.
static async fromDetail(detail: protractor.ElementFinder): Promise<Hero> {
// Get hero id from the first <div>
let _id = await detail.all(by.css('div')).first().getText();
// Get name from the h2
let _name = await detail.element(by.css('h2')).getText();
return {
id: +_id.substr(_id.indexOf(' ') + 1),
name: _name.substr(0, _name.lastIndexOf(' '))
};
}
}
describe('Tutorial part 6', () => {
beforeAll(() => browser.get(''));
function getPageElts() {
let hrefElts = element.all(by.css('my-app a'));
return {
hrefs: hrefEles,
myDashboardHref: hrefEles.get(0),
myDashboardParent: element(by.css('my-app my-dashboard')),
topHeroes: element.all(by.css('my-app my-dashboard .module.hero')),
hrefs: hrefElts,
myHeroesHref: hrefEles.get(1),
myHeroesParent: element(by.css('my-app my-heroes')),
allHeroes: element.all(by.css('my-app my-heroes li .hero-element')),
myDashboardHref: hrefElts.get(0),
myDashboard: element(by.css('my-app my-dashboard')),
topHeroes: element.all(by.css('my-app my-dashboard > div h4')),
firstDeleteButton: element.all(by.buttonText('Delete')).get(0),
myHeroesHref: hrefElts.get(1),
myHeroes: element(by.css('my-app my-heroes')),
allHeroes: element.all(by.css('my-app my-heroes li')),
selectedHero: element(by.css('my-app li.selected')),
selectedHeroSubview: element(by.css('my-app my-heroes > div:last-child')),
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 > div')),
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();
describe('Initial page', () => {
return sendKeys(page.searchBox, 'Magneta').then(function () {
it(`has title '${expectedTitle}'`, () => {
expect(browser.getTitle()).toEqual(expectedTitle);
});
it(`has h1 '${expectedH1}'`, () => {
expectHeading(1, expectedH1);
});
const expectedViewNames = ['Dashboard', 'Heroes'];
it(`has views ${expectedViewNames}`, () => {
let viewNames = getPageElts().hrefs.map(el => el.getText());
expect(viewNames).toEqual(expectedViewNames);
});
it('has dashboard as the active view', () => {
let page = getPageElts();
expect(page.myDashboard.isPresent()).toBeTruthy();
});
});
describe('Dashboard tests', () => {
beforeAll(() => browser.get(''));
it('has top heroes', () => {
let page = getPageElts();
expect(page.topHeroes.count()).toEqual(4);
});
it(`selects and routes to ${targetHero.name} details`, dashboardSelectTargetHero);
it(`updates hero name (${newHeroName}) in details view`, updateHeroNameInDetailView);
it(`cancels and shows ${targetHero.name} in Dashboard`, () => {
element(by.buttonText('Back')).click();
browser.waitForAngular(); // seems necessary to gets tests to past for toh-6
let targetHeroElt = getPageElts().topHeroes.get(targetHeroDashboardIndex);
expect(targetHeroElt.getText()).toEqual(targetHero.name);
});
it(`selects and routes to ${targetHero.name} details`, dashboardSelectTargetHero);
it(`updates hero name (${newHeroName}) in details view`, updateHeroNameInDetailView);
it(`saves and shows ${newHeroName} in Dashboard`, () => {
element(by.buttonText('Save')).click();
browser.waitForAngular(); // seems necessary to gets tests to past for toh-6
let targetHeroElt = getPageElts().topHeroes.get(targetHeroDashboardIndex);
expect(targetHeroElt.getText()).toEqual(newHeroName);
});
});
describe('Heroes tests', () => {
beforeAll(() => browser.get(''));
it('can switch to Heroes view', () => {
getPageElts().myHeroesHref.click();
let page = getPageElts();
expect(page.myHeroes.isPresent()).toBeTruthy();
expect(page.allHeroes.count()).toEqual(10, 'number of heroes');
});
it(`selects and shows ${targetHero.name} as selected in list`, () => {
getHeroLiEltById(targetHero.id).click();
expect(Hero.fromLi(getPageElts().selectedHero)).toEqual(targetHero);
});
it('shows selected hero subview', () => {
let page = getPageElts();
let title = page.selectedHeroSubview.element(by.css('h2')).getText();
let expectedTitle = `${targetHero.name.toUpperCase()} is my hero`;
expect(title).toEqual(expectedTitle);
});
it('can route to hero details', () => {
element(by.buttonText('View Details')).click();
let page = getPageElts();
expect(page.heroDetail.isPresent()).toBeTruthy('shows hero detail');
let hero = Hero.fromDetail(page.heroDetail);
expect(hero).toEqual(targetHero);
});
it(`updates hero name (${newHeroName}) in details view`, updateHeroNameInDetailView);
it(`shows ${newHeroName} in Heroes list`, () => {
element(by.buttonText('Save')).click();
browser.waitForAngular(); // seems necessary to gets tests to past for toh-6
let expectedHero = {id: targetHero.id, name: newHeroName};
expect(Hero.fromLi(getHeroLiEltById(targetHero.id))).toEqual(expectedHero);
});
it(`deletes ${newHeroName} from Heroes list`, async () => {
const heroesBefore = await toHeroArray(getPageElts().allHeroes);
const li = getHeroLiEltById(targetHero.id);
li.element(by.buttonText('x')).click();
const page = getPageElts();
expect(page.myHeroes.isPresent()).toBeTruthy();
expect(page.allHeroes.count()).toEqual(9, 'number of heroes');
const heroesAfter = await toHeroArray(page.allHeroes);
const expectedHeroes = heroesBefore.filter(h => h.name !== newHeroName);
expect(heroesAfter).toEqual(expectedHeroes);
expect(page.selectedHeroSubview.isPresent()).toBeFalsy();
});
it(`adds back ${targetHero.name}`, async () => {
const newHeroName = 'Alice';
const heroesBefore = await toHeroArray(getPageElts().allHeroes);
const numHeroes = heroesBefore.length;
sendKeys(element(by.css('input')), newHeroName);
element(by.buttonText('Add')).click();
let page = getPageElts();
let heroesAfter = await toHeroArray(page.allHeroes);
expect(heroesAfter.length).toEqual(numHeroes + 1, 'number of heroes');
expect(heroesAfter.slice(0, numHeroes)).toEqual(heroesBefore, 'Old heroes are still there');
const maxId = heroesBefore[heroesBefore.length - 1].id;
expect(heroesAfter[numHeroes]).toEqual({id: maxId + 1, name: newHeroName});
});
});
describe('Progressive hero search', () => {
beforeAll(() => browser.get(''));
it(`searches for 'Ma'`, async () => {
sendKeys(getPageElts().searchBox, 'Ma');
browser.sleep(1000);
expect(getPageElts().searchResults.count()).toBe(4);
});
it(`continues search with 'g'`, async () => {
sendKeys(getPageElts().searchBox, 'g');
browser.sleep(1000);
expect(getPageElts().searchResults.count()).toBe(2);
});
it(`continues search with 'n' and gets ${targetHero.name}`, async () => {
sendKeys(getPageElts().searchBox, 'n');
browser.sleep(1000);
let page = getPageElts();
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');
expect(hero.getText()).toEqual(targetHero.name);
});
it(`navigates to ${targetHero.name} details view`, async () => {
let hero = getPageElts().searchResults.get(0);
expect(hero.getText()).toEqual(targetHero.name);
hero.click();
let page = getPageElts();
expect(page.heroDetail.isPresent()).toBeTruthy('shows hero detail');
expect(Hero.fromDetail(page.heroDetail)).toEqual(targetHero);
});
});
it('should be able to add a hero from the "Heroes" view', function(){
let page = getPageStruct();
let heroCount: webdriver.promise.Promise<number>;
function dashboardSelectTargetHero() {
let targetHeroElt = getPageElts().topHeroes.get(targetHeroDashboardIndex);
expect(targetHeroElt.getText()).toEqual(targetHero.name);
targetHeroElt.click();
browser.waitForAngular(); // seems necessary to gets tests to past for toh-6
page.myHeroesHref.click().then(function() {
browser.waitForAngular();
heroCount = page.allHeroes.count();
expect(heroCount).toBe(10, 'should show 10');
}).then(function() {
return page.addButton.click();
}).then(function(){
return save(page, '', 'The New Hero');
}).then(function(){
browser.waitForAngular();
heroCount = page.allHeroes.count();
expect(heroCount).toBe(11, 'should show 11');
let newHero = element(by.xpath('//span[@class="hero-element" and contains(text(),"The New Hero")]'));
expect(newHero).toBeDefined();
});
});
it('should be able to delete hero from "Heroes" view', function(){
let page = getPageStruct();
let heroCount: webdriver.promise.Promise<number>;
page.myHeroesHref.click().then(function() {
browser.waitForAngular();
heroCount = page.allHeroes.count();
expect(heroCount).toBe(10, 'should show 10');
}).then(function() {
return page.firstDeleteButton.click();
}).then(function(){
browser.waitForAngular();
heroCount = page.allHeroes.count();
expect(heroCount).toBe(9, 'should show 9');
});
});
it('should be able to save details from "Dashboard" view', function () {
let page = getPageStruct();
expect(page.myDashboardParent.isPresent()).toBe(true, 'dashboard element should be available');
let heroEle = page.topHeroes.get(2);
let heroDescrEle = heroEle.element(by.css('h4'));
let heroDescr: string;
return heroDescrEle.getText().then(function(text) {
heroDescr = text;
return heroEle.click();
}).then(function() {
return save(page, heroDescr, '-foo');
})
.then(function(){
return page.myDashboardHref.click();
})
.then(function() {
expect(page.myDashboardParent.isPresent()).toBe(true, 'dashboard element should be back');
expect(heroDescrEle.getText()).toEqual(heroDescr + '-foo');
});
});
it('should be able to save details from "Heroes" view', function () {
let page = getPageStruct();
let viewDetailsButtonEle = page.myHeroesParent.element(by.cssContainingText('button', 'View Details'));
let heroEle: protractor.ElementFinder, heroDescr: string;
page.myHeroesHref.click().then(function() {
expect(page.myDashboardParent.isPresent()).toBe(false, 'dashboard element should NOT be present');
expect(page.myHeroesParent.isPresent()).toBe(true, 'myHeroes element should be present');
expect(viewDetailsButtonEle.isPresent()).toBe(false, 'viewDetails button should not yet be present');
heroEle = page.allHeroes.get(0);
return heroEle.getText();
}).then(function(text) {
// remove leading 'id' from the element
heroDescr = text.substr(text.indexOf(' ') + 1);
return heroEle.click();
}).then(function() {
expect(viewDetailsButtonEle.isDisplayed()).toBe(true, 'viewDetails button should now be visible');
return viewDetailsButtonEle.click();
}).then(function() {
return save(page, heroDescr, '-bar');
})
.then(function(){
return page.myHeroesHref.click();
})
.then(function() {
expect(heroEle.getText()).toContain(heroDescr + '-bar');
});
});
function save(page: any, origValue: string, textToAdd: string) {
let inputEle = page.heroDetail.element(by.css('input'));
expect(inputEle.isDisplayed()).toBe(true, 'should be able to see the input box');
let saveButtonEle = page.heroDetail.element(by.buttonText('Save'));
let backButtonEle = page.heroDetail.element(by.buttonText('Back'));
expect(backButtonEle.isDisplayed()).toBe(true, 'should be able to see the back button');
let detailTextEle = page.heroDetail.element(by.css('div h2'));
expect(detailTextEle.getText()).toContain(origValue);
return sendKeys(inputEle, textToAdd).then(function () {
expect(detailTextEle.getText()).toContain(origValue + textToAdd);
return saveButtonEle.click();
});
let page = getPageElts();
expect(page.heroDetail.isPresent()).toBeTruthy('shows hero detail');
let hero = Hero.fromDetail(page.heroDetail);
expect(hero).toEqual(targetHero);
}
it('should be able to see the start screen', function () {
let page = getPageStruct();
expect(page.hrefs.count()).toEqual(2, 'should be two dashboard choices');
expect(page.myDashboardHref.getText()).toEqual('Dashboard');
expect(page.myHeroesHref.getText()).toEqual('Heroes');
});
async function updateHeroNameInDetailView() {
// Assumes that the current view is the hero details view.
addToHeroName(nameSuffix);
it('should be able to see dashboard choices', function () {
let page = getPageStruct();
expect(page.topHeroes.count()).toBe(4, 'should be 4 dashboard hero choices');
});
it('should be able to toggle the views', function () {
let page = getPageStruct();
expect(page.myDashboardParent.element(by.css('h3')).getText()).toEqual('Top Heroes');
page.myHeroesHref.click().then(function() {
expect(page.myDashboardParent.isPresent()).toBe(false, 'should no longer see dashboard element');
expect(page.allHeroes.count()).toBeGreaterThan(4, 'should be more than 4 heroes shown');
return page.myDashboardHref.click();
}).then(function() {
expect(page.myDashboardParent.isPresent()).toBe(true, 'should once again see the dashboard element');
});
});
it('should be able to edit details from "Dashboard" view', function () {
let page = getPageStruct();
expect(page.myDashboardParent.isPresent()).toBe(true, 'dashboard element should be available');
let heroEle = page.topHeroes.get(3);
let heroDescrEle = heroEle.element(by.css('h4'));
let heroDescr: string;
return heroDescrEle.getText().then(function(text) {
heroDescr = text;
return heroEle.click();
}).then(function() {
return editDetails(page, heroDescr, '-foo');
}).then(function() {
expect(page.myDashboardParent.isPresent()).toBe(true, 'dashboard element should be back');
expect(heroDescrEle.getText()).toEqual(heroDescr + '-foo');
});
});
it('should be able to edit details from "Heroes" view', function () {
let page = getPageStruct();
expect(page.myDashboardParent.isPresent()).toBe(true, 'dashboard element should be present');
let viewDetailsButtonEle = page.myHeroesParent.element(by.cssContainingText('button', 'View Details'));
let heroEle: protractor.ElementFinder, heroDescr: string;
page.myHeroesHref.click().then(function() {
expect(page.myDashboardParent.isPresent()).toBe(false, 'dashboard element should NOT be present');
expect(page.myHeroesParent.isPresent()).toBe(true, 'myHeroes element should be present');
expect(viewDetailsButtonEle.isPresent()).toBe(false, 'viewDetails button should not yet be present');
heroEle = page.allHeroes.get(2);
return heroEle.getText();
}).then(function(text) {
// remove leading 'id' from the element
heroDescr = text.substr(text.indexOf(' ') + 1);
return heroEle.click();
}).then(function() {
expect(viewDetailsButtonEle.isDisplayed()).toBe(true, 'viewDetails button should now be visible');
return viewDetailsButtonEle.click();
}).then(function() {
return editDetails(page, heroDescr, '-bar');
}).then(function() {
expect(page.myHeroesParent.isPresent()).toBe(true, 'myHeroes element should be back');
expect(heroEle.getText()).toContain(heroDescr + '-bar');
expect(viewDetailsButtonEle.isPresent()).toBe(false, 'viewDetails button should again NOT be present');
});
});
function editDetails(page: any, origValue: string, textToAdd: string) {
expect(page.myDashboardParent.isPresent()).toBe(false, 'dashboard element should NOT be present');
expect(page.myHeroesParent.isPresent()).toBe(false, 'myHeroes element should NOT be present');
expect(page.heroDetail.isDisplayed()).toBe(true, 'should be able to see hero-details');
let inputEle = page.heroDetail.element(by.css('input'));
expect(inputEle.isDisplayed()).toBe(true, 'should be able to see the input box');
let buttons = page.heroDetail.all(by.css('button'));
let backButtonEle = buttons.get(0);
let saveButtonEle = buttons.get(1);
expect(backButtonEle.isDisplayed()).toBe(true, 'should be able to see the back button');
expect(saveButtonEle.isDisplayed()).toBe(true, 'should be able to see the save button');
let detailTextEle = page.heroDetail.element(by.css('div h2'));
expect(detailTextEle.getText()).toContain(origValue);
return sendKeys(inputEle, textToAdd).then(function () {
expect(detailTextEle.getText()).toContain(origValue + textToAdd);
return saveButtonEle.click();
});
let hero = await Hero.fromDetail(getPageElts().heroDetail);
expect(hero).toEqual({id: targetHero.id, name: newHeroName});
}
});
function addToHeroName(text: string): WPromise<void> {
let input = element(by.css('input'));
return sendKeys(input, text);
}
function expectHeading(hLevel: number, expectedText: string): void {
let hTag = `h${hLevel}`;
let hText = element(by.css(hTag)).getText();
expect(hText).toEqual(expectedText, hTag);
};
function getHeroLiEltById(id: number): protractor.ElementFinder {
let spanForId = element(by.cssContainingText('li span.badge', id.toString()));
return spanForId.element(by.xpath('..'));
}
async function toHeroArray(allHeroes: protractor.ElementArrayFinder): Promise<Hero[]> {
let promisedHeroes: Array<Promise<Hero>> = await allHeroes.map(Hero.fromLi);
// The cast is necessary to get around issuing with the signature of Promise.all()
return <Promise<any>> Promise.all(promisedHeroes);
}

View File

@ -2,10 +2,6 @@
// #docregion
import { Component } from '@angular/core';
// #docregion rxjs-extensions
import './rxjs-extensions';
// #enddocregion rxjs-extensions
@Component({
selector: 'my-app',

View File

@ -1,5 +1,10 @@
// #docplaster
// #docregion , v1, v2
// #docregion
// #docregion rxjs-extensions
import './rxjs-extensions';
// #enddocregion rxjs-extensions
// #docregion v1, v2
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';

View File

@ -1,4 +1,3 @@
<!-- #docplaster -->
<!-- #docregion -->
<div *ngIf="hero">
<h2>{{hero.name}} details!</h2>

View File

@ -1,8 +1,5 @@
// #docplaster
// #docregion, variables-imports
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
// #enddocregion variables-imports
// #docregion
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Params } from '@angular/router';
import { Hero } from './hero';
@ -13,50 +10,30 @@ import { HeroService } from './hero.service';
templateUrl: 'app/hero-detail.component.html',
styleUrls: ['app/hero-detail.component.css']
})
// #docregion variables-imports
export class HeroDetailComponent implements OnInit {
@Input() hero: Hero;
@Output() close = new EventEmitter();
error: any;
navigated = false; // true if navigated here
// #enddocregion variables-imports
hero: Hero;
constructor(
private heroService: HeroService,
private route: ActivatedRoute) {
}
// #docregion ngOnInit
ngOnInit(): void {
this.route.params.forEach((params: Params) => {
if (params['id'] !== undefined) {
let id = +params['id'];
this.navigated = true;
this.heroService.getHero(id)
.then(hero => this.hero = hero);
} else {
this.navigated = false;
this.hero = new Hero();
}
let id = +params['id'];
this.heroService.getHero(id)
.then(hero => this.hero = hero);
});
}
// #enddocregion ngOnInit
// #docregion save
save(): void {
this.heroService
.save(this.hero)
.then(hero => {
this.hero = hero; // saved hero, w/ id if new
this.goBack(hero);
})
.catch(error => this.error = error); // TODO: Display error message
this.heroService.update(this.hero)
.then(this.goBack);
}
// #enddocregion save
// #docregion goBack
goBack(savedHero: Hero = null): void {
this.close.emit(savedHero);
if (this.navigated) { window.history.back(); }
goBack(): void {
window.history.back();
}
// #enddocregion goBack
}

View File

@ -1,17 +1,21 @@
// #docplaster
// #docregion
// #docregion , imports
import { Injectable } from '@angular/core';
import { Headers, Http, Response } from '@angular/http';
import { Headers, Http } from '@angular/http';
// #docregion rxjs
import 'rxjs/add/operator/toPromise';
// #enddocregion rxjs
import { Hero } from './hero';
// #enddocregion imports
@Injectable()
export class HeroService {
// #docregion update
private headers = new Headers({'Content-Type': 'application/json'});
// #enddocregion update
// #docregion getHeroes
private heroesUrl = 'app/heroes'; // URL to web api
@ -36,62 +40,40 @@ export class HeroService {
.then(heroes => heroes.find(hero => hero.id === id));
}
// #docregion save
save(hero: Hero): Promise<Hero> {
if (hero.id) {
return this.put(hero);
}
return this.post(hero);
}
// #enddocregion save
// #docregion delete
delete(hero: Hero): Promise<Response> {
let headers = new Headers();
headers.append('Content-Type', 'application/json');
let url = `${this.heroesUrl}/${hero.id}`;
return this.http
.delete(url, {headers: headers})
.toPromise()
.catch(this.handleError);
delete(id: number): Promise<void> {
let url = `${this.heroesUrl}/${id}`;
return this.http.delete(url, {headers: this.headers})
.toPromise()
.then(() => null)
.catch(this.handleError);
}
// #enddocregion delete
// #docregion post
// Add new Hero
private post(hero: Hero): Promise<Hero> {
let headers = new Headers({
'Content-Type': 'application/json'});
// #docregion create
create(name: string): Promise<Hero> {
return this.http
.post(this.heroesUrl, JSON.stringify(hero), {headers: headers})
.toPromise()
.then(res => res.json().data)
.catch(this.handleError);
.post(this.heroesUrl, JSON.stringify({name: name}), {headers: this.headers})
.toPromise()
.then(res => res.json().data)
.catch(this.handleError);
}
// #enddocregion post
// #docregion put
// Update existing Hero
private put(hero: Hero): Promise<Hero> {
let headers = new Headers();
headers.append('Content-Type', 'application/json');
let url = `${this.heroesUrl}/${hero.id}`;
// #enddocregion create
// #docregion update
update(hero: Hero): Promise<Hero> {
const url = `${this.heroesUrl}/${hero.id}`;
return this.http
.put(url, JSON.stringify(hero), {headers: headers})
.toPromise()
.then(() => hero)
.catch(this.handleError);
.put(url, JSON.stringify(hero), {headers: this.headers})
.toPromise()
.then(() => hero)
.catch(this.handleError);
}
// #enddocregion put
// #enddocregion put, update
// #docregion handleError
private handleError(error: any): Promise<any> {
console.error('An error occurred', error);
console.error('An error occurred', error); // for demo purposes only
return Promise.reject(error.message || error);
}
// #enddocregion handleError

View File

@ -59,9 +59,10 @@ button:hover {
background-color: #cfd8dc;
}
/* #docregion additions */
.error {color:red;}
button.delete-button{
button.delete {
float:right;
margin-top: 2px;
margin-right: .8em;
background-color: gray !important;
color:white;
}

View File

@ -1,24 +1,26 @@
<!-- #docregion -->
<h2>My Heroes</h2>
<ul class="heroes">
<li *ngFor="let hero of heroes" (click)="onSelect(hero)" [class.selected]="hero === selectedHero">
<span class="hero-element">
<span class="badge">{{hero.id}}</span> {{hero.name}}
</span>
<!-- #docregion delete -->
<button class="delete-button" (click)="deleteHero(hero, $event)">Delete</button>
<!-- #enddocregion delete -->
</li>
</ul>
<!-- #docregion add-and-error -->
<div class="error" *ngIf="error">{{error}}</div>
<button (click)="addHero()">Add New Hero</button>
<div *ngIf="addingHero">
<my-hero-detail (close)="close($event)"></my-hero-detail>
<!-- #docregion add -->
<div>
<label>Hero name:</label> <input #heroName />
<button (click)="add(heroName.value); heroName.value=''">
Add
</button>
</div>
<!-- #enddocregion add-and-error -->
<!-- #enddocregion add -->
<ul class="heroes">
<!-- #docregion li-element -->
<li *ngFor="let hero of heroes" (click)="onSelect(hero)"
[class.selected]="hero === selectedHero">
<span class="badge">{{hero.id}}</span>
<span>{{hero.name}}</span>
<!-- #docregion delete -->
<button class="delete"
(click)="delete(hero); $event.stopPropagation()">x</button>
<!-- #enddocregion delete -->
</li>
<!-- #enddocregion li-element -->
</ul>
<div *ngIf="selectedHero">
<h2>
{{selectedHero.name | uppercase}} is my hero

View File

@ -4,57 +4,48 @@ import { Router } from '@angular/router';
import { Hero } from './hero';
import { HeroService } from './hero.service';
// #docregion hero-detail-component
@Component({
selector: 'my-heroes',
templateUrl: 'app/heroes.component.html',
styleUrls: ['app/heroes.component.css']
})
// #enddocregion hero-detail-component
export class HeroesComponent implements OnInit {
heroes: Hero[];
selectedHero: Hero;
addingHero = false;
// #docregion error
error: any;
// #enddocregion error
constructor(
private router: Router,
private heroService: HeroService) { }
private heroService: HeroService,
private router: Router) { }
getHeroes(): void {
this.heroService
.getHeroes()
.then(heroes => this.heroes = heroes)
.catch(error => this.error = error);
.then(heroes => this.heroes = heroes);
}
// #docregion addHero
addHero(): void {
this.addingHero = true;
this.selectedHero = null;
// #docregion add
add(name: string): void {
name = name.trim();
if (!name) { return; }
this.heroService.create(name)
.then(hero => {
this.heroes.push(hero);
this.selectedHero = null;
});
}
// #enddocregion add
close(savedHero: Hero): void {
this.addingHero = false;
if (savedHero) { this.getHeroes(); }
}
// #enddocregion addHero
// #docregion deleteHero
deleteHero(hero: Hero, event: any): void {
event.stopPropagation();
// #docregion delete
delete(hero: Hero): void {
this.heroService
.delete(hero)
.then(res => {
.delete(hero.id)
.then(() => {
this.heroes = this.heroes.filter(h => h !== hero);
if (this.selectedHero === hero) { this.selectedHero = null; }
})
.catch(error => this.error = error);
});
}
// #enddocregion deleteHero
// #enddocregion delete
ngOnInit(): void {
this.getHeroes();
@ -62,7 +53,6 @@ export class HeroesComponent implements OnInit {
onSelect(hero: Hero): void {
this.selectedHero = hero;
this.addingHero = false;
}
gotoDetail(): void {

View File

@ -31,8 +31,8 @@
"nextable": true
},
"toh-pt6": {
"title": "Http",
"intro": "We convert our service and components to use Http",
"title": "HTTP",
"intro": "We convert our service and components to use Angular's HTTP service",
"nextable": true
}
}

View File

@ -12,6 +12,7 @@ block includes
block start-server-and-watch
:marked
### Keep the app compiling and running
Open a terminal/console window.
Start the Dart compiler, watch for changes, and start our server by entering the command:
@ -25,7 +26,7 @@ block http-library
### Pubspec updates
We need to add package dependencies for the
Update package dependencies by adding the
`stream_transformers` and !{_Angular_http_library}s.
We also need to add a `resolved_identifiers` entry, to inform the [angular2
@ -79,30 +80,7 @@ block get-heroes-details
:marked
To get the list of heroes, we first make an asynchronous call to
`http.get()`. Then we use the `_extractData` helper method to decode the
response payload (`body`).
block hero-detail-comp-extra-imports-and-vars
//- N/A
block hero-detail-comp-updates
:marked
### Edit in the *HeroDetailComponent*
We already have `HeroDetailComponent` for viewing details about a specific hero.
Supporting edit functionality is a natural extension of the detail view,
so we are able to reuse `HeroDetailComponent` with a few tweaks.
block hero-detail-comp-save-and-goback
//- N/A
block add-new-hero-via-detail-comp
//- N/A
block heroes-comp-add
//- N/A
block review
//- Not showing animated gif due to differences between TS and Dart implementations.
response body.
block observables-section-intro
:marked
@ -181,8 +159,9 @@ block file-summary
toh-6/dart/lib/hero_detail_component.html,
toh-6/dart/lib/hero_service.dart,
toh-6/dart/lib/heroes_component.css,
toh-6/dart/lib/heroes_component.dart`,
null,
toh-6/dart/lib/heroes_component.dart,
toh-6/dart/lib/in_memory_data_service.dart`,
',,,,,,,,',
`lib/dashboard_component.dart,
lib/dashboard_component.html,
lib/hero.dart,
@ -190,7 +169,8 @@ block file-summary
lib/hero_detail_component.html,
lib/hero_service.dart,
lib/heroes_component.css,
lib/heroes_component.dart`)
lib/heroes_component.dart,
lib/in_memory_data_service.dart`)
+makeTabs(
`toh-6/dart/lib/hero_search_component.css,

View File

@ -31,8 +31,8 @@
"nextable": true
},
"toh-pt6": {
"title": "Http",
"intro": "We convert our service and components to use Http",
"title": "HTTP",
"intro": "We convert our service and components to use Angular's HTTP service",
"nextable": true
}
}

View File

@ -8,8 +8,11 @@ block includes
- var _HttpModule = 'HttpModule'
- var _JSON_stringify = 'JSON.stringify'
//- Shared var definitions
- var _promise = _Promise.toLowerCase()
:marked
# Getting and Saving Data with HTTP
# Getting and Saving Data using HTTP
Our stakeholders appreciate our progress.
Now they want to get the hero data from a server, let users add, edit, and delete heroes,
@ -22,12 +25,14 @@ block includes
.l-main-section
:marked
## Where We Left Off
In the [previous chapter](toh-pt5.html), we learned to navigate between the dashboard and the fixed heroes list, editing a selected hero along the way.
That's our starting point for this chapter.
block start-server-and-watch
:marked
### Keep the app transpiling and running
Open a terminal/console window and enter the following command to
start the TypeScript compiler, start the server, and watch for changes:
@ -48,7 +53,7 @@ block http-library
Fortunately we're ready to import from `@angular/http` because `systemjs.config` configured *SystemJS* to load that library when we need it.
:marked
### Register (provide) *HTTP* services
### Register (provide) HTTP services
block http-providers
:marked
@ -59,7 +64,7 @@ block http-providers
So we register them in the `imports` array of `app.module.ts` where we
bootstrap the application and its root `AppComponent`.
+makeExcerpt('app/app.module.ts (v1)')
+makeExample('app/app.module.ts', 'v1','app/app.module.ts (v1)')
:marked
Notice that we supply `!{_HttpModule}` as part of the *imports* !{_array} in root NgModule `AppModule`.
@ -88,10 +93,18 @@ block http-providers
block backend
:marked
We're replacing the default `XHRBackend`, the service that talks to the remote server,
with the in-memory web API service after priming it as follows:
We're importing the `InMemoryWebApiModule` and adding it to the module `imports`.
The `InMemoryWebApiModule` replaces the default `Http` client backend &mdash;
the supporting service that talks to the remote server &mdash;
with an _in-memory web API alternative service_.
+makeExample('app/in-memory-data.service.ts', 'init')
+makeExcerpt(_appModuleTsVsMainTs, 'in-mem-web-api', '')
:marked
The `forRoot` configuration method takes an `InMemoryDataService` class
that primes the in-memory database as follows:
+makeExample('app/in-memory-data.service.ts', 'init')(format='.')
p This file replaces the #[code #[+adjExPath('mock-heroes.ts')]] which is now safe to delete.
@ -118,12 +131,21 @@ block dont-be-distracted-by-backend-subst
It may have seemed like overkill at the time, but we were anticipating the
day when we fetched heroes with an HTTP client and we knew that would have to be an asynchronous operation.
That day has arrived! Let's convert `getHeroes()` to use HTTP:
That day has arrived! Let's convert `getHeroes()` to use HTTP.
+makeExcerpt('app/hero.service.ts (new constructor and revised getHeroes)', 'getHeroes')
+makeExcerpt('app/hero.service.ts (updated getHeroes and new class members)', 'getHeroes')
:marked
### HTTP !{_Promise}
Our updated import statements are now:
+makeExcerpt('app/hero.service.ts (updated imports)', 'imports')
- var _h3id = `http-${_promise}`
:marked
Refresh the browser, and the hero data should be successfully loaded from the
mock server.
<h3 id="!{_h3id}">HTTP !{_Promise}</h3>
We're still returning a !{_Promise} but we're creating it differently.
@ -135,18 +157,23 @@ block get-heroes-details
For *now* we get back on familiar ground by immediately by
converting that `Observable` to a `Promise` using the `toPromise` operator.
+makeExcerpt('app/hero.service.ts', 'to-promise', '')
: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.
The Angular `Observable` is a bare-bones implementation.
There are scores of operators like `toPromise` that extend `Observable` with useful capabilities.
If we want those capabilities, we have to add the operators ourselves.
That's as easy as importing them from the RxJS library like this:
+makeExcerpt('app/hero.service.ts', 'rxjs', '')
:marked
### Extracting the data in the *then* callback
In the *promise*'s `then` callback we call the `json` method of the http `Response` to extract the
data within the response.
+makeExcerpt('app/hero.service.ts', 'to-data', '')
@ -160,15 +187,14 @@ block get-heroes-details
:marked
Pay close attention to the shape of the data returned by the server.
This particular *in-memory web API* example happens to return an object with a `data` property.
Your API might return something else.
Your API might return something else. Adjust the code to match *your web API*.
Adjust the code to match *your web API*.
:marked
The caller is unaware of these machinations. It receives a !{_Promise} of *heroes* just as it did before.
It has no idea that we fetched the heroes from the (mock) server.
It knows nothing of the twists and turns required to convert the HTTP response into heroes.
Such is the beauty and purpose of delegating data access to a service like this `HeroService`.
:marked
### Error Handling
At the end of `getHeroes()` we `catch` server failures and pass them to an error handler:
@ -183,173 +209,139 @@ block get-heroes-details
- var rejected_promise = _docsFor == 'dart' ? 'propagated exception' : 'rejected promise';
:marked
In this demo service we log the error to the console; we should do better in real life.
In this demo service we log the error to the console; we would do better in real life.
We've also decided to return a user friendly form of the error to
the caller in a !{rejected_promise} so that the caller can display a proper error message to the user.
### !{_Promise}s are !{_Promise}s
### Unchanged `getHeroes` API
Although we made significant *internal* changes to `getHeroes()`, the public signature did not change.
We still return a !{_Promise}. We won't have to update any of the components that call `getHeroes()`.
.l-main-section
:marked
## Add, Edit, Delete
Our stakeholders are incredibly pleased with the added flexibility from the API integration, but it doesn't stop there. Next, we want the ability to create new heroes and delete heroes.
Our stakeholders are incredibly pleased with the added flexibility from the API integration, but it doesn't stop there. Next we want to add the capability to add, edit and delete heroes.
We'll complete `HeroService` by creating `post`, `put` and `delete` methods to meet our new requirements.
:marked
### Post
We will be using `post` to add new heroes. Post requests require a little bit more setup than Get requests:
+makeExcerpt('app/hero.service.ts', 'post')
:marked
For Post requests we create a header and set the content type to `application/json`. We'll call `!{_JSON_stringify}` before we post to convert the hero object to a string.
### Put
Put will be used to update an individual hero. Its structure is very similar to Post requests. The only difference is that we have to change the URL slightly by appending the id of the hero we want to update.
+makeExcerpt('app/hero.service.ts', 'put')
:marked
### Delete
Delete will be used to delete heroes and its format is like `put` except for the function name.
+makeExcerpt('app/hero.service.ts', 'delete')
:marked
We add a `catch` to handle errors for all three methods.
:marked
### Save
We combine the call to the private `post` and `put` methods in a single `save` method. This simplifies the public API and makes the integration with `HeroDetailComponent` easier. `HeroService` determines which method to call based on the state of the `hero` object. If the hero already has an id we know it's an edit. Otherwise we know it's an add.
+makeExcerpt('app/hero.service.ts', 'save')
:marked
After these additions our `HeroService` looks like this:
+makeExample('app/hero.service.ts')
But first, let's see what happens now when we try to update a hero's details.
.l-main-section
:marked
## Updating Components
## Update hero details
Loading heroes using `Http` required no changes outside of `HeroService`, but we added a few new features as well.
In the following section we will update our components to use our new methods to add, edit and delete heroes.
The hero detail view already allows us to edit a hero's name. Go ahead, try
it now. As we type, the hero name is updated in the view heading, but
notice what happens when we hit the `Back` button: the changes are lost!
block hero-detail-comp-extra-imports-and-vars
.l-sub-section
:marked
Before we can add those methods, we need to initialize some variables with their respective imports.
+makeExcerpt('app/hero-detail.component.ts ()', 'variables-imports')
block hero-detail-comp-updates
:marked
### Add/Edit in the *HeroDetailComponent*
We already have `HeroDetailComponent` for viewing details about a specific hero.
Add and Edit are natural extensions of the detail view, so we are able to reuse `HeroDetailComponent` with a few tweaks.
The original component was created to render existing data, but to add new data we have to initialize the `hero` property to an empty `Hero` object.
+makeExcerpt('app/hero-detail.component.ts', 'ngOnInit')
:marked
In order to differentiate between add and edit we are adding a check to see if an id is passed in the URL. If the id is absent we bind `HeroDetailComponent` to an empty `Hero` object. In either case, any edits made through the UI will be bound back to the same `hero` property.
Updates weren't lost before, what's happening?
When the app used a list of mock heroes, changes were made directly to the
hero objects in the single, app-wide shared list. Now that we are fetching data
from a server, if we want changes to persist, we'll need to write them back to
the server.
:marked
Add a save method to `HeroDetailComponent` and call the corresponding save method in `HeroesService`.
### Save hero details
+makeExcerpt('app/hero-detail.component.ts', 'save')
block hero-detail-comp-save-and-goback
:marked
The same save method is used for both add and edit since `HeroService` will know when to call `post` vs `put` based on the state of the `Hero` object.
After we save a hero, we redirect the browser back to the previous page using the `goBack()` method.
+makeExcerpt('app/hero-detail.component.ts', 'goBack')
:marked
Here we call `emit` to notify that we just added or modified a hero. `HeroesComponent` is listening for this notification and will automatically refresh the list of heroes to include our recent updates.
.l-sub-section
:marked
The `emit` "handshake" between `HeroDetailComponent` and `HeroesComponent` is an example of component to component communication. This is a topic for another day, but we have detailed information in our <a href="/docs/ts/latest/cookbook/component-communication.html#!#child-to-parent">Component Interaction Cookbook</a>
:marked
Here is `HeroDetailComponent` with its new save button and the corresponding HTML.
figure.image-display
img(src='/resources/images/devguide/toh/hero-details-save-button.png' alt="Hero Details With Save Button")
Let's ensure that edits to a hero's name aren't lost. Start by adding,
to the end of the hero detail template, a save button with a `click` event
binding that invokes a new component method named `save`:
+makeExcerpt('app/hero-detail.component.html', 'save')
:marked
### Add/Delete in the *HeroesComponent*
The `save` method persists hero name changes using the hero service
`update` method and then navigates back to the previous view:
We'll be reporting propagated HTTP errors, let's start by adding the following
field to the `HeroesComponent` class:
+makeExcerpt('app/heroes.component.ts', 'error', '')
+makeExcerpt('app/hero-detail.component.ts', 'save')
:marked
The user can *add* a new hero by clicking a button and entering a name.
### Hero service `update` method
block add-new-hero-via-detail-comp
:marked
When the user clicks the *Add New Hero* button, we display the `HeroDetailComponent`.
We aren't navigating to the component so it won't receive a hero `id`;
as we noted above, that is the component's cue to create and present an empty hero.
The overall structure of the `update` method is similar to that of
`getHeroes`, although we'll use an HTTP _put_ to persist changes
server-side:
- var _below = _docsFor == 'dart' ? 'before' : 'below';
:marked
Add the following to the heroes component HTML, just !{_below} the hero list (`<ul class="heroes">...</ul>`).
+makeExcerpt('app/heroes.component.html', 'add-and-error')
:marked
The first line will display an error message if there is any. The remaining HTML is for adding heroes.
+makeExcerpt('app/hero.service.ts', 'update')
The user can *delete* an existing hero by clicking a delete button next to the hero's name.
Add the following to the heroes component HTML right after the hero name in the repeated `<li>` tag:
+makeExcerpt('app/heroes.component.html', 'delete')
:marked
Add the following to the bottom of the `HeroesComponent` CSS file:
We identify _which_ hero the server should update by encoding the hero id in
the URL. The put body is the JSON string encoding of the hero, obtained by
calling `!{_JSON_stringify}`. We identify the body content type
(`application/json`) in the request header.
Refresh the browser and give it a try. Changes to hero names should now persist.
.l-main-section
:marked
## Add a hero
To add a new hero we need to know the hero's name. Let's use an input
element for that, paired with an add button.
Insert the following into the heroes component HTML, first thing after
the heading:
+makeExcerpt('app/heroes.component.html', 'add')
:marked
In response to a click event, we call the component's click handler and then
clear the input field so that it will be ready to use for another name.
+makeExcerpt('app/heroes.component.ts', 'add')
:marked
When the given name is non-blank, the handler delegates creation of the
named hero to the hero service, and then adds the new hero to our !{_array}.
Go ahead, refresh the browser and create some new heroes!
.l-main-section
:marked
## Delete a hero
Too many heroes?
Let's add a delete button to each hero in the heroes view.
Add this button element to the heroes component HTML, right after the hero
name in the repeated `<li>` tag:
+makeExcerpt('app/heroes.component.html', 'delete', '')
:marked
The `<li>` element should now look like this:
+makeExcerpt('app/heroes.component.html', 'li-element')
:marked
In addition to calling the component's `delete` method, the delete button
click handling code stops the propagation of the click event &mdash; we
don't want the `<li>` click handler to be triggered because that would
select the hero that we are going to delete!
The logic of the `delete` handler is a bit trickier:
+makeExcerpt('app/heroes.component.ts', 'delete')
:marked
Of course, we delegate hero deletion to the hero service, but the component
is still responsible for updating the display: it removes the deleted hero
from the !{_array} and resets the selected hero if necessary.
:marked
We want our delete button to be placed at the far right of the hero entry.
This extra CSS accomplishes that:
+makeExcerpt('app/heroes.component.css', 'additions')
:marked
Now let's fix-up the `HeroesComponent` to support the *add* and *delete* actions used in the template.
Let's start with *add*.
Implement the click handler for the *Add New Hero* button.
+makeExcerpt('app/heroes.component.ts', 'addHero')
block heroes-comp-add
:marked
The `HeroDetailComponent` does most of the work. All we do is toggle an `*ngIf` flag that
swaps it into the DOM when we add a hero and removes it from the DOM when the user is done.
:marked
The *delete* logic is a bit trickier.
+makeExcerpt('app/heroes.component.ts', 'deleteHero')
### Hero service `delete` method
The hero service's `delete` method uses the _delete_ HTTP method to remove the hero from the server:
+makeExcerpt('app/hero.service.ts', 'delete')
:marked
Of course we delegate the persistence of hero deletion to the `HeroService`.
But the component is still responsible for updating the display.
So the *delete* method removes the deleted hero from the list.
block review
:marked
### Let's see it
Here are the fruits of labor in action:
figure.image-display
img(src='/resources/images/devguide/toh/toh-http.anim.gif' alt="Heroes List Editing w/ HTTP")
Refresh the browser and try the new delete functionality.
:marked
## !{_Observable}s
@ -500,24 +492,26 @@ block observable-transformers
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('app/rxjs-extensions.ts')
+makeExample('app/rxjs-extensions.ts')(format='.')
:marked
We load them all at once by importing `rxjs-extensions` in `AppComponent`.
+makeExcerpt('app/app.component.ts', 'rxjs-extensions')
+makeExcerpt('app/app.component.ts', 'rxjs-extensions')(format='.')
:marked
### Add the search component to the dashboard
We add the hero search HTML element to the bottom of the `DashboardComponent` template.
+makeExample('app/dashboard.component.html')
+makeExample('app/dashboard.component.html')(format='.')
- var _declarations = _docsFor == 'dart' ? 'directives' : 'declarations'
- var declFile = _docsFor == 'dart' ? 'app/dashboard.component.ts' : 'app/app.module.ts'
:marked
And finally, we import the `HeroSearchComponent` and add it to the `!{_declarations}` !{_array}:
And finally, we import `HeroSearchComponent` from
<span ngio-ex>hero-search.component.ts</span>
and add it to the `!{_declarations}` !{_array}:
+makeExcerpt(declFile, 'search')

View File

@ -31,8 +31,8 @@
"nextable": true
},
"toh-pt6": {
"title": "Http",
"intro": "We convert our service and components to use Http",
"title": "HTTP",
"intro": "We convert our service and components to use Angular's HTTP service",
"nextable": true
}
}
}

View File

@ -8,8 +8,11 @@ block includes
- var _HttpModule = 'HttpModule'
- var _JSON_stringify = 'JSON.stringify'
//- Shared var definitions
- var _promise = _Promise.toLowerCase()
:marked
# Getting and Saving Data with HTTP
# Getting and Saving Data
Our stakeholders appreciate our progress.
Now they want to get the hero data from a server, let users add, edit, and delete heroes,
@ -22,12 +25,14 @@ block includes
.l-main-section
:marked
## Where We Left Off
In the [previous chapter](toh-pt5.html), we learned to navigate between the dashboard and the fixed heroes list, editing a selected hero along the way.
That's our starting point for this chapter.
block start-server-and-watch
:marked
### Keep the app transpiling and running
Open a terminal/console window and enter the following command to
start the TypeScript compiler, start the server, and watch for changes:
@ -41,22 +46,22 @@ block start-server-and-watch
h1 Providing HTTP Services
block http-library
:marked
`Http` is ***not*** a core Angular module.
The `HttpModule` is ***not*** a core Angular module.
It's Angular's optional approach to web access and it exists as a separate add-on module called `@angular/http`,
shipped in a separate script file as part of the Angular npm package.
Fortunately we're ready to import from `@angular/http` because `systemjs.config` configured *SystemJS* to load that library when we need it.
:marked
### Register (provide) *HTTP* services
### Register for HTTP services
block http-providers
:marked
Our app will depend upon the Angular `http` service which itself depends upon other supporting services.
The `HttpModule` from `@angular/http` library holds providers for the complete set of `http` services.
The `HttpModule` from `@angular/http` library holds providers for a complete set of HTTP services.
We should be able to access `http` services from anywhere in the application.
So we register them in the `imports` array of `app.module.ts` where we
We should be able to access these services from anywhere in the application.
So we register them all by adding `HttpModule` to the `imports` list of the `AppModule` where we
bootstrap the application and its root `AppComponent`.
+makeExample('app/app.module.ts', 'v1','app/app.module.ts (v1)')
@ -92,10 +97,12 @@ block backend
The `InMemoryWebApiModule` replaces the default `Http` client backend &mdash;
the supporting service that talks to the remote server &mdash;
with an _in-memory web API alternative service_.
+makeExcerpt(_appModuleTsVsMainTs, 'in-mem-web-api', '')
:marked
The `forRoot` configuration method takes an `InMemoryDataService` class
that will prime the in-memory database as follows:
The `forRoot` configuration method takes an `InMemoryDataService` class
that primes the in-memory database as follows:
+makeExample('app/in-memory-data.service.ts', 'init')(format='.')
@ -124,12 +131,21 @@ block dont-be-distracted-by-backend-subst
It may have seemed like overkill at the time, but we were anticipating the
day when we fetched heroes with an HTTP client and we knew that would have to be an asynchronous operation.
That day has arrived! Let's convert `getHeroes()` to use HTTP:
That day has arrived! Let's convert `getHeroes()` to use HTTP.
+makeExcerpt('app/hero.service.ts (new constructor and revised getHeroes)', 'getHeroes')
+makeExcerpt('app/hero.service.ts (updated getHeroes and new class members)', 'getHeroes')
:marked
### HTTP !{_Promise}
Our updated import statements are now:
+makeExcerpt('app/hero.service.ts (updated imports)', 'imports')
- var _h3id = `http-${_promise}`
:marked
Refresh the browser, and the hero data should be successfully loaded from the
mock server.
<h3 id="!{_h3id}">HTTP !{_Promise}</h3>
We're still returning a !{_Promise} but we're creating it differently.
@ -141,19 +157,24 @@ block get-heroes-details
For *now* we get back on familiar ground by immediately by
converting that `Observable` to a `Promise` using the `toPromise` operator.
+makeExcerpt('app/hero.service.ts', 'to-promise', '')
: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.
The Angular `Observable` is a bare-bones implementation.
There are scores of operators like `toPromise` that extend `Observable` with useful capabilities.
If we want those capabilities, we have to add the operators ourselves.
That's as easy as importing them from the RxJS library like this:
+makeExcerpt('app/hero.service.ts', 'rxjs', '')
:marked
### Extracting the data in the *then* callback
In the *promise*'s `then` callback we call the `json` method of the http `Response` to extract the
In the *promise*'s `then` callback we call the `json` method of the HTTP `Response` to extract the
data within the response.
+makeExcerpt('app/hero.service.ts', 'to-data', '')
@ -166,15 +187,14 @@ block get-heroes-details
:marked
Pay close attention to the shape of the data returned by the server.
This particular *in-memory web API* example happens to return an object with a `data` property.
Your API might return something else.
Your API might return something else. Adjust the code to match *your web API*.
Adjust the code to match *your web API*.
:marked
The caller is unaware of these machinations. It receives a !{_Promise} of *heroes* just as it did before.
It has no idea that we fetched the heroes from the (mock) server.
It knows nothing of the twists and turns required to convert the HTTP response into heroes.
Such is the beauty and purpose of delegating data access to a service like this `HeroService`.
:marked
### Error Handling
At the end of `getHeroes()` we `catch` server failures and pass them to an error handler:
@ -189,180 +209,147 @@ block get-heroes-details
- var rejected_promise = _docsFor == 'dart' ? 'propagated exception' : 'rejected promise';
:marked
In this demo service we log the error to the console; we should do better in real life.
In this demo service we log the error to the console; we would do better in real life.
We've also decided to return a user friendly form of the error to
the caller in a !{rejected_promise} so that the caller can display a proper error message to the user.
### !{_Promise}s are !{_Promise}s
### Unchanged `getHeroes` API
Although we made significant *internal* changes to `getHeroes()`, the public signature did not change.
We still return a !{_Promise}. We won't have to update any of the components that call `getHeroes()`.
.l-main-section
:marked
## Add, Edit, Delete
Our stakeholders are thrilled with the added flexibility from the API integration.
Now they want the ability to create and delete heroes.
Our stakeholders are incredibly pleased with the added flexibility from the API integration, but it doesn't stop there. Next we want to add the capability to add, edit and delete heroes.
We'll complete `HeroService` by creating `post`, `put` and `delete` methods to meet our new requirements.
:marked
### Post
We will be using `post` to add new heroes. Post requests require a little bit more setup than Get requests:
+makeExcerpt('app/hero.service.ts', 'post')
:marked
For Post requests we create a header and set the content type to `application/json`. We'll call `!{_JSON_stringify}` before we post to convert the hero object to a string.
### Put
Put will be used to update an individual hero. Its structure is very similar to Post requests. The only difference is that we have to change the URL slightly by appending the id of the hero we want to update.
+makeExcerpt('app/hero.service.ts', 'put')
:marked
### Delete
Delete will be used to delete heroes and its format is like `put` except for the function name.
+makeExcerpt('app/hero.service.ts', 'delete')
:marked
We add a `catch` to handle errors for all three methods.
:marked
### Save
We combine the call to the private `post` and `put` methods in a single `save` method. This simplifies the public API and makes the integration with `HeroDetailComponent` easier. `HeroService` determines which method to call based on the state of the `hero` object. If the hero already has an id we know it's an edit. Otherwise we know it's an add.
+makeExcerpt('app/hero.service.ts', 'save')
:marked
After these additions our `HeroService` looks like this:
+makeExample('app/hero.service.ts')
Let's see first what happens when we try to update a hero's details.
.l-main-section
:marked
## Updating Components
## Update hero details
Loading heroes using `Http` required no changes outside of `HeroService`, but we added a few new features as well.
In the following section we will update our components to use our new methods to add, edit and delete heroes.
We can edit a hero's name already in the hero detail view. Go ahead and try
it. As we type, the hero name is updated in the view heading.
But when we hit the `Back` button, the changes are lost!
block hero-detail-comp-extra-imports-and-vars
.l-sub-section
:marked
Before we can add those methods, we need to initialize some variables with their respective imports.
+makeExcerpt('app/hero-detail.component.ts ()', 'variables-imports')
block hero-detail-comp-updates
:marked
### Add/Edit in the *HeroDetailComponent*
We already have `HeroDetailComponent` for viewing details about a specific hero.
Add and Edit are natural extensions of the detail view, so we are able to reuse `HeroDetailComponent` with a few tweaks.
The original component was created to render existing data, but to add new data we have to initialize the `hero` property to an empty `Hero` object.
+makeExcerpt('app/hero-detail.component.ts', 'ngOnInit')
:marked
In order to differentiate between add and edit we are adding a check to see if an id is passed in the URL. If the id is absent we bind `HeroDetailComponent` to an empty `Hero` object. In either case, any edits made through the UI will be bound back to the same `hero` property.
Updates weren't lost before, what's happening?
When the app used a list of mock heroes, changes were made directly to the
hero objects in the single, app-wide shared list. Now that we are fetching data
from a server, if we want changes to persist, we'll need to write them back to
the server.
:marked
Add a save method to `HeroDetailComponent` and call the corresponding save method in `HeroesService`.
### Save hero details
+makeExcerpt('app/hero-detail.component.ts', 'save')
block hero-detail-comp-save-and-goback
:marked
The same save method is used for both add and edit since `HeroService` will know when to call `post` vs `put` based on the state of the `Hero` object.
After we save a hero, we redirect the browser back to the previous page using the `goBack()` method.
+makeExcerpt('app/hero-detail.component.ts', 'goBack')
:marked
Here we call `emit` to notify that we just added or modified a hero. `HeroesComponent` is listening for this notification and will automatically refresh the list of heroes to include our recent updates.
.l-sub-section
:marked
The `emit` "handshake" between `HeroDetailComponent` and `HeroesComponent` is an example of component to component communication. This is a topic for another day, but we have detailed information in our <a href="/docs/ts/latest/cookbook/component-communication.html#!#child-to-parent">Component Interaction Cookbook</a>
:marked
Here is `HeroDetailComponent` with its new save button and the corresponding HTML.
figure.image-display
img(src='/resources/images/devguide/toh/hero-details-save-button.png' alt="Hero Details With Save Button")
Let's ensure that edits to a hero's name aren't lost. Start by adding,
to the end of the hero detail template, a save button with a `click` event
binding that invokes a new component method named `save`:
+makeExcerpt('app/hero-detail.component.html', 'save')
:marked
### Add/Delete in the *HeroesComponent*
The `save` method persists hero name changes using the hero service
`update` method and then navigates back to the previous view:
We'll be reporting propagated HTTP errors, let's start by adding the following
field to the `HeroesComponent` class:
+makeExcerpt('app/heroes.component.ts', 'error', '')
+makeExcerpt('app/hero-detail.component.ts', 'save')
:marked
The user can *add* a new hero by clicking a button and entering a name.
### Hero service `update` method
block add-new-hero-via-detail-comp
:marked
When the user clicks the *Add New Hero* button, we display the `HeroDetailComponent`.
We aren't navigating to the component so it won't receive a hero `id`;
as we noted above, that is the component's cue to create and present an empty hero.
The overall structure of the `update` method is similar to that of
`getHeroes`, although we'll use an HTTP _put_ to persist changes
server-side:
- var _below = _docsFor == 'dart' ? 'before' : 'below';
:marked
Add the following to the heroes component HTML, just !{_below} the hero list (`<ul class="heroes">...</ul>`).
+makeExcerpt('app/heroes.component.html', 'add-and-error')
:marked
The first line will display an error message if there is any. The remaining HTML is for adding heroes.
+makeExcerpt('app/hero.service.ts', 'update')
The user can *delete* an existing hero by clicking a delete button next to the hero's name.
Add the following to the heroes component HTML right after the hero name in the repeated `<li>` tag:
+makeExcerpt('app/heroes.component.html', 'delete')
:marked
Add the following to the bottom of the `HeroesComponent` CSS file:
We identify _which_ hero the server should update by encoding the hero id in
the URL. The put body is the JSON string encoding of the hero, obtained by
calling `!{_JSON_stringify}`. We identify the body content type
(`application/json`) in the request header.
Refresh the browser and give it a try. Changes to hero names should now persist.
.l-main-section
:marked
## Add a hero
To add a new hero we need to know the hero's name. Let's use an input
element for that, paired with an add button.
Insert the following into the heroes component HTML, first thing after
the heading:
+makeExcerpt('app/heroes.component.html', 'add')
:marked
In response to a click event, we call the component's click handler and then
clear the input field so that it will be ready to use for another name.
+makeExcerpt('app/heroes.component.ts', 'add')
:marked
When the given name is non-blank, the handler delegates creation of the
named hero to the hero service, and then adds the new hero to our !{_array}.
Go ahead, refresh the browser and create some new heroes!
.l-main-section
:marked
## Delete a hero
Too many heroes?
Let's add a delete button to each hero in the heroes view.
Add this button element to the heroes component HTML, right after the hero
name in the repeated `<li>` tag:
+makeExcerpt('app/heroes.component.html', 'delete', '')
:marked
The `<li>` element should now look like this:
+makeExcerpt('app/heroes.component.html', 'li-element')
:marked
In addition to calling the component's `delete` method, the delete button
click handling code stops the propagation of the click event &mdash; we
don't want the `<li>` click handler to be triggered because that would
select the hero that we are going to delete!
The logic of the `delete` handler is a bit trickier:
+makeExcerpt('app/heroes.component.ts', 'delete')
:marked
Of course, we delegate hero deletion to the hero service, but the component
is still responsible for updating the display: it removes the deleted hero
from the !{_array} and resets the selected hero if necessary.
:marked
We want our delete button to be placed at the far right of the hero entry.
This extra CSS accomplishes that:
+makeExcerpt('app/heroes.component.css', 'additions')
:marked
Now let's fix-up the `HeroesComponent` to support the *add* and *delete* actions used in the template.
Let's start with *add*.
Implement the click handler for the *Add New Hero* button.
+makeExcerpt('app/heroes.component.ts', 'addHero')
block heroes-comp-add
:marked
The `HeroDetailComponent` does most of the work. All we do is toggle an `*ngIf` flag that
swaps it into the DOM when we add a hero and removes it from the DOM when the user is done.
:marked
The *delete* logic is a bit trickier.
+makeExcerpt('app/heroes.component.ts', 'deleteHero')
### Hero service `delete` method
The hero service's `delete` method uses the _delete_ HTTP method to remove the hero from the server:
+makeExcerpt('app/hero.service.ts', 'delete')
:marked
Of course we delegate the persistence of hero deletion to the `HeroService`.
But the component is still responsible for updating the display.
So the *delete* method removes the deleted hero from the list.
block review
:marked
### Let's see it
Here are the fruits of labor in action:
figure.image-display
img(src='/resources/images/devguide/toh/toh-http.anim.gif' alt="Heroes List Editing w/ HTTP")
Refresh the browser and try the new delete functionality.
:marked
## !{_Observable}s
block observables-section-intro
:marked
Each `Http` method returns an `Observable` of HTTP `Response` objects.
Each `Http` service 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
@ -378,7 +365,7 @@ block observables-section-intro
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.
Converting to a promise is often a good choice. We typically ask `http.get` 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.
@ -470,21 +457,21 @@ block observable-transformers
.l-sub-section
:marked
The [switchMap operator](https://github.com/Reactive-Extensions/RxJS/blob/master/doc/api/core/operators/flatmaplatest.md)
The [switchMap operator](http://www.learnrxjs.io/operators/transformation/switchmap.html)
(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
Every qualifying key event can trigger an `http` method 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.
only the observable from the most recent `http` method call.
Results from prior calls are canceled and discarded.
We also short-circuit the http call and return an observable containing an empty array
We also short-circuit the `http` method call and return an observable containing an empty array
if the search text is empty.
Note that _canceling_ the `HeroSearchService` observable won't actually abort a pending http request
Note that _canceling_ the `HeroSearchService` observable won't actually abort a pending HTTP request
until the service supports that feature, a topic for another day.
We are content for now to discard unwanted results.
:marked
@ -509,9 +496,9 @@ block observable-transformers
+makeExample('app/rxjs-extensions.ts')(format='.')
:marked
We load them all at once by importing `rxjs-extensions` in `AppComponent`.
We load them all at once by importing `rxjs-extensions` at the top of `AppModule`.
+makeExcerpt('app/app.component.ts', 'rxjs-extensions')(format='.')
+makeExcerpt('app/app.module.ts', 'rxjs-extensions')(format='.')
:marked
### Add the search component to the dashboard
@ -523,7 +510,8 @@ block observable-transformers
- var _declarations = _docsFor == 'dart' ? 'directives' : 'declarations'
- var declFile = _docsFor == 'dart' ? 'app/dashboard.component.ts' : 'app/app.module.ts'
:marked
And finally, we import the `HeroSearchComponent` from `'./hero-search.component.ts'`
Finally, we import `HeroSearchComponent` from
<span ngio-ex>hero-search.component.ts</span>
and add it to the `!{_declarations}` !{_array}:
+makeExcerpt(declFile, 'search')