refactor(router): refactor BrowserLocation into LocationStrategy

This makes it easy to mock browser location and paves the way to
implementing hash routing.
This commit is contained in:
Brian Ford 2015-06-22 12:14:19 -07:00
parent b48f000657
commit e5de1f771a
8 changed files with 86 additions and 62 deletions

View File

@ -11,12 +11,14 @@ export {RouterOutlet} from './src/router/router_outlet';
export {RouterLink} from './src/router/router_link';
export {RouteParams} from './src/router/instruction';
export {RouteRegistry} from './src/router/route_registry';
export {BrowserLocation} from './src/router/browser_location';
export {LocationStrategy} from './src/router/location_strategy';
export {HTML5LocationStrategy} from './src/router/html5_location_strategy';
export {Location, appBaseHrefToken} from './src/router/location';
export {Pipeline} from './src/router/pipeline';
export * from './src/router/route_config_decorator';
import {BrowserLocation} from './src/router/browser_location';
import {LocationStrategy} from './src/router/location_strategy';
import {HTML5LocationStrategy} from './src/router/html5_location_strategy';
import {Router, RootRouter} from './src/router/router';
import {RouterOutlet} from './src/router/router_outlet';
import {RouterLink} from './src/router/router_link';
@ -33,7 +35,7 @@ export const routerDirectives: List<any> = CONST_EXPR([RouterOutlet, RouterLink]
export var routerInjectables: List<any> = [
RouteRegistry,
Pipeline,
BrowserLocation,
bind(LocationStrategy).toClass(HTML5LocationStrategy),
Location,
bind(Router)
.toFactory((registry, pipeline, location,

View File

@ -1,12 +1,9 @@
import {proxy, SpyObject} from 'angular2/test_lib';
import {IMPLEMENTS, BaseException} from 'angular2/src/facade/lang';
import {EventEmitter, ObservableWrapper} from 'angular2/src/facade/async';
import {List, ListWrapper} from 'angular2/src/facade/collection';
import {BrowserLocation} from 'angular2/src/router/browser_location';
import {List} from 'angular2/src/facade/collection';
import {LocationStrategy} from 'angular2/src/router/location_strategy';
@proxy
@IMPLEMENTS(BrowserLocation)
export class DummyBrowserLocation extends SpyObject {
export class MockLocationStrategy extends LocationStrategy {
internalBaseHref: string = '/';
internalPath: string = '/';
internalTitle: string = '';
@ -31,13 +28,7 @@ export class DummyBrowserLocation extends SpyObject {
this.urlChanges.push(url);
}
forward(): void { throw new BaseException('Not implemented yet!'); }
back(): void { throw new BaseException('Not implemented yet!'); }
onPopState(fn): void { ObservableWrapper.subscribe(this._subject, fn); }
getBaseHref(): string { return this.internalBaseHref; }
noSuchMethod(m) { return super.noSuchMethod(m); }
}

View File

@ -1,14 +1,16 @@
import {DOM} from 'angular2/src/dom/dom_adapter';
import {Injectable} from 'angular2/di';
import {EventListener, History, Location} from 'angular2/src/facade/browser';
import {LocationStrategy} from './location_strategy';
@Injectable()
export class BrowserLocation {
export class HTML5LocationStrategy extends LocationStrategy {
private _location: Location;
private _history: History;
private _baseHref: string;
constructor() {
super();
this._location = DOM.getLocation();
this._history = DOM.getHistory();
this._baseHref = DOM.getBaseHref();

View File

@ -1,44 +1,57 @@
import {BrowserLocation} from './browser_location';
import {LocationStrategy} from './location_strategy';
import {StringWrapper, isPresent, CONST_EXPR} from 'angular2/src/facade/lang';
import {EventEmitter, ObservableWrapper} from 'angular2/src/facade/async';
import {OpaqueToken, Injectable, Optional, Inject} from 'angular2/di';
export const appBaseHrefToken: OpaqueToken = CONST_EXPR(new OpaqueToken('locationHrefToken'));
/**
* This is the service that an application developer will directly interact with.
*
* Responsible for normalizing the URL against the application's base href.
* A normalized URL is absolute from the URL host, includes the application's base href, and has no
* trailing slash:
* - `/my/app/user/123` is normalized
* - `my/app/user/123` **is not** normalized
* - `/my/app/user/123/` **is not** normalized
*/
@Injectable()
export class Location {
private _subject: EventEmitter;
private _baseHref: string;
constructor(public _browserLocation: BrowserLocation,
constructor(public _platformStrategy: LocationStrategy,
@Optional() @Inject(appBaseHrefToken) href?: string) {
this._subject = new EventEmitter();
this._baseHref = stripIndexHtml(isPresent(href) ? href : this._browserLocation.getBaseHref());
this._browserLocation.onPopState((_) => this._onPopState(_));
this._baseHref = stripTrailingSlash(
stripIndexHtml(isPresent(href) ? href : this._platformStrategy.getBaseHref()));
this._platformStrategy.onPopState((_) => this._onPopState(_));
}
_onPopState(_): void { ObservableWrapper.callNext(this._subject, {'url': this.path()}); }
path(): string { return this.normalize(this._browserLocation.path()); }
path(): string { return this.normalize(this._platformStrategy.path()); }
normalize(url: string): string { return this._stripBaseHref(stripIndexHtml(url)); }
normalize(url: string): string {
return stripTrailingSlash(this._stripBaseHref(stripIndexHtml(url)));
}
normalizeAbsolutely(url: string): string {
if (url.length > 0 && url[0] != '/') {
if (!url.startsWith('/')) {
url = '/' + url;
}
return this._addBaseHref(url);
return stripTrailingSlash(this._addBaseHref(url));
}
_stripBaseHref(url: string): string {
if (this._baseHref.length > 0 && StringWrapper.startsWith(url, this._baseHref)) {
return StringWrapper.substring(url, this._baseHref.length);
if (this._baseHref.length > 0 && url.startsWith(this._baseHref)) {
return url.substring(this._baseHref.length);
}
return url;
}
_addBaseHref(url: string): string {
if (!StringWrapper.startsWith(url, this._baseHref)) {
if (!url.startsWith(this._baseHref)) {
return this._baseHref + url;
}
return url;
@ -46,12 +59,12 @@ export class Location {
go(url: string): void {
var finalUrl = this.normalizeAbsolutely(url);
this._browserLocation.pushState(null, '', finalUrl);
this._platformStrategy.pushState(null, '', finalUrl);
}
forward(): void { this._browserLocation.forward(); }
forward(): void { this._platformStrategy.forward(); }
back(): void { this._browserLocation.back(); }
back(): void { this._platformStrategy.back(); }
subscribe(onNext, onThrow = null, onReturn = null): void {
ObservableWrapper.subscribe(this._subject, onNext, onThrow, onReturn);
@ -61,12 +74,16 @@ export class Location {
function stripIndexHtml(url: string): string {
// '/index.html'.length == 11
if (url.length > 10 && StringWrapper.substring(url, url.length - 11) == '/index.html') {
return StringWrapper.substring(url, 0, url.length - 11);
}
if (url.length > 1 && url[url.length - 1] == '/') {
url = StringWrapper.substring(url, 0, url.length - 1);
if (/\/index.html$/g.test(url)) {
// '/index.html'.length == 11
return url.substring(0, url.length - 11);
}
return url;
}
function stripTrailingSlash(url: string): string {
if (/\/$/g.test(url)) {
url = url.substring(0, url.length - 1);
}
return url;
}

View File

@ -0,0 +1,14 @@
import {BaseException} from 'angular2/src/facade/lang';
function _abstract() {
return new BaseException('This method is abstract');
}
export class LocationStrategy {
path(): string { throw _abstract(); }
pushState(ctx: any, title: string, url: string): void { throw _abstract(); }
forward(): void { throw _abstract(); }
back(): void { throw _abstract(); }
onPopState(fn): void { throw _abstract(); }
getBaseHref(): string { throw _abstract(); }
}

View File

@ -32,6 +32,8 @@ import {EventManager, DomEventsPlugin} from 'angular2/src/render/dom/events/even
import {MockTemplateResolver} from 'angular2/src/mock/template_resolver_mock';
import {MockXHR} from 'angular2/src/render/xhr_mock';
import {MockLocationStrategy} from 'angular2/src/mock/mock_location_strategy';
import {LocationStrategy} from 'angular2/src/router/location_strategy';
import {MockNgZone} from 'angular2/src/mock/ng_zone_mock';
import {TestBed} from './test_bed';
@ -109,6 +111,7 @@ function _getAppBindings() {
Parser,
Lexer,
ExceptionHandler,
bind(LocationStrategy).toClass(MockLocationStrategy),
bind(XHR).toClass(MockXHR),
ComponentUrlMapper,
UrlResolver,

View File

@ -15,19 +15,19 @@ import {
import {Injector, bind} from 'angular2/di';
import {CONST_EXPR} from 'angular2/src/facade/lang';
import {Location, appBaseHrefToken} from 'angular2/src/router/location';
import {BrowserLocation} from 'angular2/src/router/browser_location';
import {DummyBrowserLocation} from 'angular2/src/mock/browser_location_mock';
import {LocationStrategy} from 'angular2/src/router/location_strategy';
import {MockLocationStrategy} from 'angular2/src/mock/mock_location_strategy';
export function main() {
describe('Location', () => {
var browserLocation, location;
var locationStrategy, location;
function makeLocation(baseHref: string = '/my/app', binding: any = CONST_EXPR([])): Location {
browserLocation = new DummyBrowserLocation();
browserLocation.internalBaseHref = baseHref;
locationStrategy = new MockLocationStrategy();
locationStrategy.internalBaseHref = baseHref;
let injector = Injector.resolveAndCreate(
[Location, bind(BrowserLocation).toValue(browserLocation), binding]);
[Location, bind(LocationStrategy).toValue(locationStrategy), binding]);
return location = injector.get(Location);
}
@ -35,11 +35,11 @@ export function main() {
it('should normalize relative urls on navigate', () => {
location.go('user/btford');
expect(browserLocation.path()).toEqual('/my/app/user/btford');
expect(locationStrategy.path()).toEqual('/my/app/user/btford');
});
it('should not prepend urls with starting slash when an empty URL is provided',
() => { expect(location.normalizeAbsolutely('')).toEqual(browserLocation.getBaseHref()); });
() => { expect(location.normalizeAbsolutely('')).toEqual(locationStrategy.getBaseHref()); });
it('should not prepend path with an extra slash when a baseHref has a trailing slash', () => {
let location = makeLocation('/my/slashed/app/');
@ -48,17 +48,17 @@ export function main() {
it('should not append urls with leading slash on navigate', () => {
location.go('/my/app/user/btford');
expect(browserLocation.path()).toEqual('/my/app/user/btford');
expect(locationStrategy.path()).toEqual('/my/app/user/btford');
});
it('should remove index.html from base href', () => {
let location = makeLocation('/my/app/index.html');
location.go('user/btford');
expect(browserLocation.path()).toEqual('/my/app/user/btford');
expect(locationStrategy.path()).toEqual('/my/app/user/btford');
});
it('should normalize urls on popstate', inject([AsyncTestCompleter], (async) => {
browserLocation.simulatePopState('/my/app/user/btford');
locationStrategy.simulatePopState('/my/app/user/btford');
location.subscribe((ev) => {
expect(ev['url']).toEqual('/user/btford');
async.done();
@ -66,14 +66,14 @@ export function main() {
}));
it('should normalize location path', () => {
browserLocation.internalPath = '/my/app/user/btford';
locationStrategy.internalPath = '/my/app/user/btford';
expect(location.path()).toEqual('/user/btford');
});
it('should use optional base href param', () => {
let location = makeLocation('/', bind(appBaseHrefToken).toValue('/my/custom/href'));
location.go('user/btford');
expect(browserLocation.path()).toEqual('/my/custom/href/user/btford');
expect(locationStrategy.path()).toEqual('/my/custom/href/user/btford');
});
});
}

View File

@ -20,8 +20,8 @@ import {RouteConfig} from 'angular2/src/router/route_config_decorator';
import {PromiseWrapper} from 'angular2/src/facade/async';
import {BaseException} from 'angular2/src/facade/lang';
import {routerInjectables, Router, appBaseHrefToken, routerDirectives} from 'angular2/router';
import {BrowserLocation} from 'angular2/src/router/browser_location';
import {DummyBrowserLocation} from 'angular2/src/mock/browser_location_mock';
import {LocationStrategy} from 'angular2/src/router/location_strategy';
import {MockLocationStrategy} from 'angular2/src/mock/mock_location_strategy';
export function main() {
describe('router injectables', () => {
@ -32,12 +32,7 @@ export function main() {
DOM.appendChild(fakeDoc.body, el);
testBindings = [
routerInjectables,
bind(BrowserLocation)
.toFactory(() => {
var browserLocation = new DummyBrowserLocation();
browserLocation.spy('pushState');
return browserLocation;
}),
bind(LocationStrategy).toClass(MockLocationStrategy),
bind(DOCUMENT_TOKEN).toValue(fakeDoc)
];
});
@ -48,7 +43,7 @@ export function main() {
var router = applicationRef.hostComponent.router;
router.subscribe((_) => {
expect(el).toHaveText('outer { hello }');
expect(applicationRef.hostComponent.location.path()).toEqual('/');
expect(applicationRef.hostComponent.location.path()).toEqual('');
async.done();
});
});
@ -109,7 +104,7 @@ class HelloCmp {
@View({template: "outer { <router-outlet></router-outlet> }", directives: routerDirectives})
@RouteConfig([{path: '/', component: HelloCmp}])
class AppCmp {
constructor(public router: Router, public location: BrowserLocation) {}
constructor(public router: Router, public location: LocationStrategy) {}
}
@ -124,7 +119,7 @@ class ParentCmp {
@View({template: `root { <router-outlet></router-outlet> }`, directives: routerDirectives})
@RouteConfig([{path: '/parent/...', component: ParentCmp}])
class HierarchyAppCmp {
constructor(public router: Router, public location: BrowserLocation) {}
constructor(public router: Router, public location: LocationStrategy) {}
}
@Component({selector: 'oops-cmp'})
@ -137,5 +132,5 @@ class BrokenCmp {
@View({template: "outer { <router-outlet></router-outlet> }", directives: routerDirectives})
@RouteConfig([{path: '/cause-error', component: BrokenCmp}])
class BrokenAppCmp {
constructor(public router: Router, public location: BrowserLocation) {}
constructor(public router: Router, public location: LocationStrategy) {}
}