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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,3 @@
// #docplaster
// #docregion // #docregion
import 'dart:async'; import 'dart:async';
@ -15,45 +14,35 @@ import 'hero_service.dart';
styleUrls: const ['heroes_component.css'], styleUrls: const ['heroes_component.css'],
directives: const [HeroDetailComponent]) directives: const [HeroDetailComponent])
class HeroesComponent implements OnInit { class HeroesComponent implements OnInit {
final Router _router;
final HeroService _heroService;
List<Hero> heroes; List<Hero> heroes;
Hero selectedHero; Hero selectedHero;
// #docregion error
String errorMessage; final HeroService _heroService;
// #enddocregion error final Router _router;
HeroesComponent(this._heroService, this._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 { Future<Null> getHeroes() async {
heroes = await _heroService.getHeroes(); 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() { void ngOnInit() {
getHeroes(); getHeroes();
} }

View File

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

View File

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

View File

@ -1,246 +1,283 @@
/// <reference path='../_protractor/e2e.d.ts' /> /// <reference path='../_protractor/e2e.d.ts' />
'use strict'; 'use strict';
describe('TOH Http Chapter', function () {
beforeEach(function () { const expectedH1 = 'Tour of Heroes';
browser.get(''); const expectedTitle = `Angular 2 ${expectedH1}`;
}); const targetHero = { id: 15, name: 'Magneta' };
const targetHeroDashboardIndex = 3;
const nameSuffix = 'X';
const newHeroName = targetHero.name + nameSuffix;
function getPageStruct() { type WPromise<T> = webdriver.promise.Promise<T>;
let hrefEles = element.all(by.css('my-app a'));
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 { return {
hrefs: hrefEles, hrefs: hrefElts,
myDashboardHref: hrefEles.get(0),
myDashboardParent: element(by.css('my-app my-dashboard')),
topHeroes: element.all(by.css('my-app my-dashboard .module.hero')),
myHeroesHref: hrefEles.get(1), myDashboardHref: hrefElts.get(0),
myHeroesParent: element(by.css('my-app my-heroes')), myDashboard: element(by.css('my-app my-dashboard')),
allHeroes: element.all(by.css('my-app my-heroes li .hero-element')), 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 > div')),
heroDetail: element(by.css('my-app my-hero-detail')),
searchBox: element(by.css('#search-box')), searchBox: element(by.css('#search-box')),
searchResults: element.all(by.css('.search-result')) searchResults: element.all(by.css('.search-result'))
}; };
} }
it('should search for hero and navigate to details view', function() { describe('Initial page', () => {
let page = getPageStruct();
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); expect(page.searchResults.count()).toBe(1);
let hero = page.searchResults.get(0); let hero = page.searchResults.get(0);
return hero.click(); expect(hero.getText()).toEqual(targetHero.name);
}) });
.then(function() {
browser.waitForAngular(); it(`navigates to ${targetHero.name} details view`, async () => {
let inputEle = page.heroDetail.element(by.css('input')); let hero = getPageElts().searchResults.get(0);
return inputEle.getAttribute('value'); expect(hero.getText()).toEqual(targetHero.name);
}) hero.click();
.then(function(value) {
expect(value).toBe('Magneta'); 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(){ function dashboardSelectTargetHero() {
let page = getPageStruct(); let targetHeroElt = getPageElts().topHeroes.get(targetHeroDashboardIndex);
let heroCount: webdriver.promise.Promise<number>; 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() { let page = getPageElts();
browser.waitForAngular(); expect(page.heroDetail.isPresent()).toBeTruthy('shows hero detail');
heroCount = page.allHeroes.count(); let hero = Hero.fromDetail(page.heroDetail);
expect(heroCount).toBe(10, 'should show 10'); expect(hero).toEqual(targetHero);
}).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();
});
} }
it('should be able to see the start screen', function () { async function updateHeroNameInDetailView() {
let page = getPageStruct(); // Assumes that the current view is the hero details view.
expect(page.hrefs.count()).toEqual(2, 'should be two dashboard choices'); addToHeroName(nameSuffix);
expect(page.myDashboardHref.getText()).toEqual('Dashboard');
expect(page.myHeroesHref.getText()).toEqual('Heroes');
});
it('should be able to see dashboard choices', function () { let hero = await Hero.fromDetail(getPageElts().heroDetail);
let page = getPageStruct(); expect(hero).toEqual({id: targetHero.id, name: newHeroName});
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();
});
} }
}); });
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 // #docregion
import { Component } from '@angular/core'; import { Component } from '@angular/core';
// #docregion rxjs-extensions
import './rxjs-extensions';
// #enddocregion rxjs-extensions
@Component({ @Component({
selector: 'my-app', selector: 'my-app',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,24 +1,26 @@
<!-- #docregion --> <!-- #docregion -->
<h2>My Heroes</h2> <h2>My Heroes</h2>
<ul class="heroes"> <!-- #docregion add -->
<li *ngFor="let hero of heroes" (click)="onSelect(hero)" [class.selected]="hero === selectedHero"> <div>
<span class="hero-element"> <label>Hero name:</label> <input #heroName />
<span class="badge">{{hero.id}}</span> {{hero.name}} <button (click)="add(heroName.value); heroName.value=''">
</span> Add
<!-- #docregion delete --> </button>
<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>
</div> </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"> <div *ngIf="selectedHero">
<h2> <h2>
{{selectedHero.name | uppercase}} is my hero {{selectedHero.name | uppercase}} is my hero

View File

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

View File

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

View File

@ -12,6 +12,7 @@ block includes
block start-server-and-watch block start-server-and-watch
:marked :marked
### Keep the app compiling and running ### Keep the app compiling and running
Open a terminal/console window. Open a terminal/console window.
Start the Dart compiler, watch for changes, and start our server by entering the command: Start the Dart compiler, watch for changes, and start our server by entering the command:
@ -25,7 +26,7 @@ block http-library
### Pubspec updates ### Pubspec updates
We need to add package dependencies for the Update package dependencies by adding the
`stream_transformers` and !{_Angular_http_library}s. `stream_transformers` and !{_Angular_http_library}s.
We also need to add a `resolved_identifiers` entry, to inform the [angular2 We also need to add a `resolved_identifiers` entry, to inform the [angular2
@ -79,30 +80,7 @@ block get-heroes-details
:marked :marked
To get the list of heroes, we first make an asynchronous call to 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 `http.get()`. Then we use the `_extractData` helper method to decode the
response payload (`body`). response 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.
block observables-section-intro block observables-section-intro
:marked :marked
@ -181,8 +159,9 @@ block file-summary
toh-6/dart/lib/hero_detail_component.html, toh-6/dart/lib/hero_detail_component.html,
toh-6/dart/lib/hero_service.dart, toh-6/dart/lib/hero_service.dart,
toh-6/dart/lib/heroes_component.css, toh-6/dart/lib/heroes_component.css,
toh-6/dart/lib/heroes_component.dart`, toh-6/dart/lib/heroes_component.dart,
null, toh-6/dart/lib/in_memory_data_service.dart`,
',,,,,,,,',
`lib/dashboard_component.dart, `lib/dashboard_component.dart,
lib/dashboard_component.html, lib/dashboard_component.html,
lib/hero.dart, lib/hero.dart,
@ -190,7 +169,8 @@ block file-summary
lib/hero_detail_component.html, lib/hero_detail_component.html,
lib/hero_service.dart, lib/hero_service.dart,
lib/heroes_component.css, lib/heroes_component.css,
lib/heroes_component.dart`) lib/heroes_component.dart,
lib/in_memory_data_service.dart`)
+makeTabs( +makeTabs(
`toh-6/dart/lib/hero_search_component.css, `toh-6/dart/lib/hero_search_component.css,

View File

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

View File

@ -8,8 +8,11 @@ block includes
- var _HttpModule = 'HttpModule' - var _HttpModule = 'HttpModule'
- var _JSON_stringify = 'JSON.stringify' - var _JSON_stringify = 'JSON.stringify'
//- Shared var definitions
- var _promise = _Promise.toLowerCase()
:marked :marked
# Getting and Saving Data with HTTP # Getting and Saving Data using HTTP
Our stakeholders appreciate our progress. Our stakeholders appreciate our progress.
Now they want to get the hero data from a server, let users add, edit, and delete heroes, 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 .l-main-section
:marked :marked
## Where We Left Off ## 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. 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. That's our starting point for this chapter.
block start-server-and-watch block start-server-and-watch
:marked :marked
### Keep the app transpiling and running ### Keep the app transpiling and running
Open a terminal/console window and enter the following command to Open a terminal/console window and enter the following command to
start the TypeScript compiler, start the server, and watch for changes: 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. Fortunately we're ready to import from `@angular/http` because `systemjs.config` configured *SystemJS* to load that library when we need it.
:marked :marked
### Register (provide) *HTTP* services ### Register (provide) HTTP services
block http-providers block http-providers
:marked :marked
@ -59,7 +64,7 @@ block http-providers
So we register them in the `imports` array of `app.module.ts` where we So we register them in the `imports` array of `app.module.ts` where we
bootstrap the application and its root `AppComponent`. 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 :marked
Notice that we supply `!{_HttpModule}` as part of the *imports* !{_array} in root NgModule `AppModule`. Notice that we supply `!{_HttpModule}` as part of the *imports* !{_array} in root NgModule `AppModule`.
@ -88,10 +93,18 @@ block http-providers
block backend block backend
:marked :marked
We're replacing the default `XHRBackend`, the service that talks to the remote server, We're importing the `InMemoryWebApiModule` and adding it to the module `imports`.
with the in-memory web API service after priming it as follows: 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. 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 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. 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 :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. 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 For *now* we get back on familiar ground by immediately by
converting that `Observable` to a `Promise` using the `toPromise` operator. converting that `Observable` to a `Promise` using the `toPromise` operator.
+makeExcerpt('app/hero.service.ts', 'to-promise', '') +makeExcerpt('app/hero.service.ts', 'to-promise', '')
:marked :marked
Unfortunately, the Angular `Observable` doesn't have a `toPromise` operator ... not out of the box. Unfortunately, the Angular `Observable` doesn't have a `toPromise` operator ...
not out of the box.
The Angular `Observable` is a bare-bones implementation. The Angular `Observable` is a bare-bones implementation.
There are scores of operators like `toPromise` that extend `Observable` with useful capabilities. 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. 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: That's as easy as importing them from the RxJS library like this:
+makeExcerpt('app/hero.service.ts', 'rxjs', '') +makeExcerpt('app/hero.service.ts', 'rxjs', '')
:marked :marked
### Extracting the data in the *then* callback ### 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. data within the response.
+makeExcerpt('app/hero.service.ts', 'to-data', '') +makeExcerpt('app/hero.service.ts', 'to-data', '')
@ -160,15 +187,14 @@ block get-heroes-details
:marked :marked
Pay close attention to the shape of the data returned by the server. 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. 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 :marked
The caller is unaware of these machinations. It receives a !{_Promise} of *heroes* just as it did before. 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 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. 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`. Such is the beauty and purpose of delegating data access to a service like this `HeroService`.
:marked
### Error Handling ### Error Handling
At the end of `getHeroes()` we `catch` server failures and pass them to an error handler: 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'; - var rejected_promise = _docsFor == 'dart' ? 'propagated exception' : 'rejected promise';
:marked :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 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. 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. 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()`. We still return a !{_Promise}. We won't have to update any of the components that call `getHeroes()`.
.l-main-section 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.
: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 to add the capability to add, edit and delete heroes. But first, let's see what happens now when we try to update a hero's details.
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')
.l-main-section .l-main-section
:marked :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. The hero detail view already allows us to edit a hero's name. Go ahead, try
In the following section we will update our components to use our new methods to add, edit and delete heroes. 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 :marked
Before we can add those methods, we need to initialize some variables with their respective imports. Updates weren't lost before, what's happening?
When the app used a list of mock heroes, changes were made directly to the
+makeExcerpt('app/hero-detail.component.ts ()', 'variables-imports') 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
block hero-detail-comp-updates the server.
: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.
:marked :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') 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
block hero-detail-comp-save-and-goback binding that invokes a new component method named `save`:
: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")
+makeExcerpt('app/hero-detail.component.html', 'save') +makeExcerpt('app/hero-detail.component.html', 'save')
:marked :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 +makeExcerpt('app/hero-detail.component.ts', 'save')
field to the `HeroesComponent` class:
+makeExcerpt('app/heroes.component.ts', 'error', '')
:marked :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 The overall structure of the `update` method is similar to that of
:marked `getHeroes`, although we'll use an HTTP _put_ to persist changes
When the user clicks the *Add New Hero* button, we display the `HeroDetailComponent`. server-side:
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.
- var _below = _docsFor == 'dart' ? 'before' : 'below'; +makeExcerpt('app/hero.service.ts', 'update')
: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.
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 :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') +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 :marked
The *delete* logic is a bit trickier. ### Hero service `delete` method
+makeExcerpt('app/heroes.component.ts', 'deleteHero')
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 :marked
Of course we delegate the persistence of hero deletion to the `HeroService`. Refresh the browser and try the new delete functionality.
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")
:marked :marked
## !{_Observable}s ## !{_Observable}s
@ -500,24 +492,26 @@ block observable-transformers
We take a different approach in this example. 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. 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 :marked
We load them all at once by importing `rxjs-extensions` in `AppComponent`. 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 :marked
### Add the search component to the dashboard ### Add the search component to the dashboard
We add the hero search HTML element to the bottom of the `DashboardComponent` template. 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 _declarations = _docsFor == 'dart' ? 'directives' : 'declarations'
- var declFile = _docsFor == 'dart' ? 'app/dashboard.component.ts' : 'app/app.module.ts' - var declFile = _docsFor == 'dart' ? 'app/dashboard.component.ts' : 'app/app.module.ts'
:marked :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') +makeExcerpt(declFile, 'search')

View File

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

View File

@ -8,8 +8,11 @@ block includes
- var _HttpModule = 'HttpModule' - var _HttpModule = 'HttpModule'
- var _JSON_stringify = 'JSON.stringify' - var _JSON_stringify = 'JSON.stringify'
//- Shared var definitions
- var _promise = _Promise.toLowerCase()
:marked :marked
# Getting and Saving Data with HTTP # Getting and Saving Data
Our stakeholders appreciate our progress. Our stakeholders appreciate our progress.
Now they want to get the hero data from a server, let users add, edit, and delete heroes, 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 .l-main-section
:marked :marked
## Where We Left Off ## 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. 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. That's our starting point for this chapter.
block start-server-and-watch block start-server-and-watch
:marked :marked
### Keep the app transpiling and running ### Keep the app transpiling and running
Open a terminal/console window and enter the following command to Open a terminal/console window and enter the following command to
start the TypeScript compiler, start the server, and watch for changes: start the TypeScript compiler, start the server, and watch for changes:
@ -41,22 +46,22 @@ block start-server-and-watch
h1 Providing HTTP Services h1 Providing HTTP Services
block http-library block http-library
:marked :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`, 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. 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. Fortunately we're ready to import from `@angular/http` because `systemjs.config` configured *SystemJS* to load that library when we need it.
:marked :marked
### Register (provide) *HTTP* services ### Register for HTTP services
block http-providers block http-providers
:marked :marked
Our app will depend upon the Angular `http` service which itself depends upon other supporting services. 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. We should be able to access these services from anywhere in the application.
So we register them in the `imports` array of `app.module.ts` where we So we register them all by adding `HttpModule` to the `imports` list of the `AppModule` where we
bootstrap the application and its root `AppComponent`. bootstrap the application and its root `AppComponent`.
+makeExample('app/app.module.ts', 'v1','app/app.module.ts (v1)') +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 `InMemoryWebApiModule` replaces the default `Http` client backend &mdash;
the supporting service that talks to the remote server &mdash; the supporting service that talks to the remote server &mdash;
with an _in-memory web API alternative service_. with an _in-memory web API alternative service_.
+makeExcerpt(_appModuleTsVsMainTs, 'in-mem-web-api', '') +makeExcerpt(_appModuleTsVsMainTs, 'in-mem-web-api', '')
:marked :marked
The `forRoot` configuration method takes an `InMemoryDataService` class The `forRoot` configuration method takes an `InMemoryDataService` class
that will prime the in-memory database as follows: that primes the in-memory database as follows:
+makeExample('app/in-memory-data.service.ts', 'init')(format='.') +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 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. 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 :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. 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 For *now* we get back on familiar ground by immediately by
converting that `Observable` to a `Promise` using the `toPromise` operator. converting that `Observable` to a `Promise` using the `toPromise` operator.
+makeExcerpt('app/hero.service.ts', 'to-promise', '') +makeExcerpt('app/hero.service.ts', 'to-promise', '')
:marked :marked
Unfortunately, the Angular `Observable` doesn't have a `toPromise` operator ... not out of the box. Unfortunately, the Angular `Observable` doesn't have a `toPromise` operator ...
not out of the box.
The Angular `Observable` is a bare-bones implementation. The Angular `Observable` is a bare-bones implementation.
There are scores of operators like `toPromise` that extend `Observable` with useful capabilities. 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. 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: That's as easy as importing them from the RxJS library like this:
+makeExcerpt('app/hero.service.ts', 'rxjs', '') +makeExcerpt('app/hero.service.ts', 'rxjs', '')
:marked :marked
### Extracting the data in the *then* callback ### 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. data within the response.
+makeExcerpt('app/hero.service.ts', 'to-data', '') +makeExcerpt('app/hero.service.ts', 'to-data', '')
@ -166,15 +187,14 @@ block get-heroes-details
:marked :marked
Pay close attention to the shape of the data returned by the server. 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. 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 :marked
The caller is unaware of these machinations. It receives a !{_Promise} of *heroes* just as it did before. 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 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. 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`. Such is the beauty and purpose of delegating data access to a service like this `HeroService`.
:marked
### Error Handling ### Error Handling
At the end of `getHeroes()` we `catch` server failures and pass them to an error handler: 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'; - var rejected_promise = _docsFor == 'dart' ? 'propagated exception' : 'rejected promise';
:marked :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 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. 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. 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()`. We still return a !{_Promise}. We won't have to update any of the components that call `getHeroes()`.
.l-main-section Our stakeholders are thrilled with the added flexibility from the API integration.
:marked Now they want the ability to create and delete heroes.
## 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 to add the capability to add, edit and delete heroes. Let's see first what happens when we try to update a hero's details.
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')
.l-main-section .l-main-section
:marked :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. We can edit a hero's name already in the hero detail view. Go ahead and try
In the following section we will update our components to use our new methods to add, edit and delete heroes. 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 :marked
Before we can add those methods, we need to initialize some variables with their respective imports. Updates weren't lost before, what's happening?
When the app used a list of mock heroes, changes were made directly to the
+makeExcerpt('app/hero-detail.component.ts ()', 'variables-imports') 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
block hero-detail-comp-updates the server.
: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.
:marked :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') 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
block hero-detail-comp-save-and-goback binding that invokes a new component method named `save`:
: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")
+makeExcerpt('app/hero-detail.component.html', 'save') +makeExcerpt('app/hero-detail.component.html', 'save')
:marked :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 +makeExcerpt('app/hero-detail.component.ts', 'save')
field to the `HeroesComponent` class:
+makeExcerpt('app/heroes.component.ts', 'error', '')
:marked :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 The overall structure of the `update` method is similar to that of
:marked `getHeroes`, although we'll use an HTTP _put_ to persist changes
When the user clicks the *Add New Hero* button, we display the `HeroDetailComponent`. server-side:
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.
- var _below = _docsFor == 'dart' ? 'before' : 'below'; +makeExcerpt('app/hero.service.ts', 'update')
: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.
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 :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') +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 :marked
The *delete* logic is a bit trickier. ### Hero service `delete` method
+makeExcerpt('app/heroes.component.ts', 'deleteHero')
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 :marked
Of course we delegate the persistence of hero deletion to the `HeroService`. Refresh the browser and try the new delete functionality.
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")
:marked :marked
## !{_Observable}s ## !{_Observable}s
block observables-section-intro block observables-section-intro
:marked :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. 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 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`. 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. 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. 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 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. and it helps that promises are widely understood by JavaScript programmers.
@ -470,21 +457,21 @@ block observable-transformers
.l-sub-section .l-sub-section
:marked :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. (formerly known as "flatMapLatest") is very clever.
Every qualifying key event can trigger an http call. 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 Even with a 300ms pause between requests, we could have multiple HTTP requests in flight
and they may not return in the order sent. and they may not return in the order sent.
`switchMap` preserves the original request order while returning `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. 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. 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. until the service supports that feature, a topic for another day.
We are content for now to discard unwanted results. We are content for now to discard unwanted results.
:marked :marked
@ -509,9 +496,9 @@ block observable-transformers
+makeExample('app/rxjs-extensions.ts')(format='.') +makeExample('app/rxjs-extensions.ts')(format='.')
:marked :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 :marked
### Add the search component to the dashboard ### Add the search component to the dashboard
@ -523,7 +510,8 @@ block observable-transformers
- var _declarations = _docsFor == 'dart' ? 'directives' : 'declarations' - var _declarations = _docsFor == 'dart' ? 'directives' : 'declarations'
- var declFile = _docsFor == 'dart' ? 'app/dashboard.component.ts' : 'app/app.module.ts' - var declFile = _docsFor == 'dart' ? 'app/dashboard.component.ts' : 'app/app.module.ts'
:marked :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}: and add it to the `!{_declarations}` !{_array}:
+makeExcerpt(declFile, 'search') +makeExcerpt(declFile, 'search')