feat(router): support deep-linking to anywhere in the app

Closes #2642
This commit is contained in:
Brian Ford 2015-06-30 13:18:51 -07:00
parent 2335075506
commit f66ce096d8
16 changed files with 331 additions and 170 deletions

View File

@ -34,7 +34,8 @@ import {List} from './src/facade/collection';
export const routerDirectives: List<any> = CONST_EXPR([RouterOutlet, RouterLink]);
export var routerInjectables: List<any> = [
RouteRegistry,
bind(RouteRegistry)
.toFactory((appRoot) => new RouteRegistry(appRoot), [appComponentTypeToken]),
Pipeline,
bind(LocationStrategy).toClass(HTML5LocationStrategy),
Location,

View File

@ -239,7 +239,6 @@ export class ListWrapper {
}
static toString<T>(l: List<T>): string { return l.toString(); }
static toJSON<T>(l: List<T>): string { return JSON.stringify(l); }
}
export function isListLikeIterable(obj): boolean {

View File

@ -0,0 +1,21 @@
import {RouteHandler} from './route_handler';
import {Promise, PromiseWrapper} from 'angular2/src/facade/async';
import {isPresent, Type} from 'angular2/src/facade/lang';
export class AsyncRouteHandler implements RouteHandler {
_resolvedComponent: Promise<any> = null;
componentType: Type;
constructor(private _loader: Function) {}
resolveComponentType(): Promise<any> {
if (isPresent(this._resolvedComponent)) {
return this._resolvedComponent;
}
return this._resolvedComponent = this._loader().then((componentType) => {
this.componentType = componentType;
return componentType;
});
}
}

View File

@ -6,7 +6,9 @@ import {
List,
ListWrapper
} from 'angular2/src/facade/collection';
import {isPresent, normalizeBlank} from 'angular2/src/facade/lang';
import {isPresent, isBlank, normalizeBlank} from 'angular2/src/facade/lang';
import {PathRecognizer} from './path_recognizer';
export class RouteParams {
constructor(public params: StringMap<string, string>) {}
@ -14,34 +16,24 @@ export class RouteParams {
get(param: string): string { return normalizeBlank(StringMapWrapper.get(this.params, param)); }
}
/**
* An `Instruction` represents the component hierarchy of the application based on a given route
*/
export class Instruction {
component: any;
child: Instruction;
// the part of the URL captured by this instruction
capturedUrl: string;
// the part of the URL captured by this instruction and all children
// "capturedUrl" is the part of the URL captured by this instruction
// "accumulatedUrl" is the part of the URL captured by this instruction and all children
accumulatedUrl: string;
params: StringMap<string, string>;
reuse: boolean;
reuse: boolean = false;
specificity: number;
constructor({params, component, child, matchedUrl, parentSpecificity}: {
params?: StringMap<string, any>,
component?: any,
child?: Instruction,
matchedUrl?: string,
parentSpecificity?: number
} = {}) {
this.reuse = false;
this.capturedUrl = matchedUrl;
this.accumulatedUrl = matchedUrl;
this.specificity = parentSpecificity;
private _params: StringMap<string, string>;
constructor(public component: any, public capturedUrl: string,
private _recognizer: PathRecognizer, public child: Instruction = null) {
this.accumulatedUrl = capturedUrl;
this.specificity = _recognizer.specificity;
if (isPresent(child)) {
this.child = child;
this.specificity += child.specificity;
@ -49,11 +41,14 @@ export class Instruction {
if (isPresent(childUrl)) {
this.accumulatedUrl += childUrl;
}
} else {
this.child = null;
}
this.component = component;
this.params = params;
}
params(): StringMap<string, string> {
if (isBlank(this._params)) {
this._params = this._recognizer.parseParams(this.capturedUrl);
}
return this._params;
}
hasChild(): boolean { return isPresent(this.child); }
@ -73,5 +68,5 @@ export class Instruction {
function shouldReuseComponent(instr1: Instruction, instr2: Instruction): boolean {
return instr1.component == instr2.component &&
StringMapWrapper.equals(instr1.params, instr2.params);
StringMapWrapper.equals(instr1.params(), instr2.params());
}

View File

@ -8,6 +8,7 @@ import {
BaseException,
normalizeBlank
} from 'angular2/src/facade/lang';
import {Promise, PromiseWrapper} from 'angular2/src/facade/async';
import {
Map,
MapWrapper,
@ -19,6 +20,7 @@ import {
import {IMPLEMENTS} from 'angular2/src/facade/lang';
import {escapeRegex} from './url';
import {RouteHandler} from './route_handler';
// TODO(jeffbcross): implement as interface when ts2dart adds support:
// https://github.com/angular/ts2dart/issues/173
@ -27,7 +29,7 @@ export class Segment {
regex: string;
}
export class ContinuationSegment extends Segment {
class ContinuationSegment extends Segment {
generate(params): string { return ''; }
}
@ -52,7 +54,7 @@ class DynamicSegment {
generate(params: StringMap<string, string>): string {
if (!StringMapWrapper.contains(params, this.name)) {
throw new BaseException(
`Route generator for '${this.name}' was not included in parameters passed.`)
`Route generator for '${this.name}' was not included in parameters passed.`);
}
return normalizeBlank(StringMapWrapper.get(params, this.name));
}
@ -135,7 +137,7 @@ export class PathRecognizer {
specificity: number;
terminal: boolean = true;
constructor(public path: string, public handler: any) {
constructor(public path: string, public handler: RouteHandler) {
var parsed = parsePathString(path);
var specificity = parsed['specificity'];
var segments = parsed['segments'];
@ -178,7 +180,9 @@ export class PathRecognizer {
}
generate(params: StringMap<string, string>): string {
return ListWrapper.join(
ListWrapper.map(this.segments, (segment) => '/' + segment.generate(params)), '');
return ListWrapper.join(ListWrapper.map(this.segments, (segment) => segment.generate(params)),
'/');
}
resolveComponentType(): Promise<any> { return this.handler.resolveComponentType(); }
}

View File

@ -0,0 +1,7 @@
import {Promise, PromiseWrapper} from 'angular2/src/facade/async';
import {Type} from 'angular2/src/facade/lang';
export interface RouteHandler {
componentType: Type;
resolveComponentType(): Promise<any>;
}

View File

@ -2,7 +2,10 @@ import {
RegExp,
RegExpWrapper,
StringWrapper,
isBlank,
isPresent,
isType,
isStringMap,
BaseException
} from 'angular2/src/facade/lang';
import {
@ -14,7 +17,10 @@ import {
StringMapWrapper
} from 'angular2/src/facade/collection';
import {PathRecognizer, ContinuationSegment} from './path_recognizer';
import {PathRecognizer} from './path_recognizer';
import {RouteHandler} from './route_handler';
import {AsyncRouteHandler} from './async_route_handler';
import {SyncRouteHandler} from './sync_route_handler';
/**
* `RouteRecognizer` is responsible for recognizing routes for a single component.
@ -33,7 +39,8 @@ export class RouteRecognizer {
this.redirects.set(path, target);
}
addConfig(path: string, handler: any, alias: string = null): boolean {
addConfig(path: string, handlerObj: any, alias: string = null): boolean {
var handler = configObjToHandler(handlerObj['component']);
var recognizer = new PathRecognizer(path, handler);
MapWrapper.forEach(this.matchers, (matcher, _) => {
if (recognizer.regex.toString() == matcher.regex.toString()) {
@ -65,28 +72,21 @@ export class RouteRecognizer {
if (path == url) {
url = target;
}
} else if (StringWrapper.startsWith(url, path)) {
url = target + StringWrapper.substring(url, path.length);
} else if (url.startsWith(path)) {
url = target + url.substring(path.length);
}
});
MapWrapper.forEach(this.matchers, (pathRecognizer, regex) => {
var match;
if (isPresent(match = RegExpWrapper.firstMatch(regex, url))) {
// TODO(btford): determine a good generic way to deal with terminal matches
var matchedUrl = '/';
var unmatchedUrl = '';
if (url != '/') {
matchedUrl = match[0];
unmatchedUrl = StringWrapper.substring(url, match[0].length);
unmatchedUrl = url.substring(match[0].length);
}
solutions.push(new RouteMatch({
specificity: pathRecognizer.specificity,
handler: pathRecognizer.handler,
params: pathRecognizer.parseParams(url),
matchedUrl: matchedUrl,
unmatchedUrl: unmatchedUrl
}));
solutions.push(new RouteMatch(pathRecognizer, matchedUrl, unmatchedUrl));
}
});
@ -95,30 +95,39 @@ export class RouteRecognizer {
hasRoute(name: string): boolean { return this.names.has(name); }
generate(name: string, params: any): string {
var pathRecognizer = this.names.get(name);
return isPresent(pathRecognizer) ? pathRecognizer.generate(params) : null;
generate(name: string, params: any): StringMap<string, any> {
var pathRecognizer: PathRecognizer = this.names.get(name);
if (isBlank(pathRecognizer)) {
return null;
}
var url = pathRecognizer.generate(params);
return {url, 'nextComponent': pathRecognizer.handler.componentType};
}
}
export class RouteMatch {
specificity: number;
handler: StringMap<string, any>;
params: StringMap<string, string>;
matchedUrl: string;
unmatchedUrl: string;
constructor(public recognizer: PathRecognizer, public matchedUrl: string,
public unmatchedUrl: string) {}
constructor({specificity, handler, params, matchedUrl, unmatchedUrl}: {
specificity?: number,
handler?: StringMap<string, any>,
params?: StringMap<string, string>,
matchedUrl?: string,
unmatchedUrl?: string
} = {}) {
this.specificity = specificity;
this.handler = handler;
this.params = params;
this.matchedUrl = matchedUrl;
this.unmatchedUrl = unmatchedUrl;
}
params(): StringMap<string, string> { return this.recognizer.parseParams(this.matchedUrl); }
}
function configObjToHandler(config: any): RouteHandler {
if (isType(config)) {
return new SyncRouteHandler(config);
} else if (isStringMap(config)) {
if (isBlank(config['type'])) {
throw new BaseException(
`Component declaration when provided as a map should include a 'type' property`);
}
var componentType = config['type'];
if (componentType == 'constructor') {
return new SyncRouteHandler(config['constructor']);
} else if (componentType == 'loader') {
return new AsyncRouteHandler(config['loader']);
} else {
throw new BaseException(`oops`);
}
}
throw new BaseException(`Unexpected component "${config}".`);
}

View File

@ -13,6 +13,7 @@ import {
isPresent,
isBlank,
isType,
isString,
isStringMap,
isFunction,
StringWrapper,
@ -29,7 +30,9 @@ import {Injectable} from 'angular2/di';
*/
@Injectable()
export class RouteRegistry {
_rules: Map<any, RouteRecognizer> = new Map();
private _rules: Map<any, RouteRecognizer> = new Map();
constructor(private _rootHostComponent: any) {}
/**
* Given a component and a configuration object, add the route to this registry
@ -118,40 +121,80 @@ export class RouteRegistry {
}
_completeRouteMatch(candidate: RouteMatch): Promise<Instruction> {
return componentHandlerToComponentType(candidate.handler)
.then((componentType) => {
this.configFromComponent(componentType);
_completeRouteMatch(partialMatch: RouteMatch): Promise<Instruction> {
var recognizer = partialMatch.recognizer;
var handler = recognizer.handler;
return handler.resolveComponentType().then((componentType) => {
this.configFromComponent(componentType);
if (candidate.unmatchedUrl.length == 0) {
return new Instruction({
component: componentType,
params: candidate.params,
matchedUrl: candidate.matchedUrl,
parentSpecificity: candidate.specificity
});
}
if (partialMatch.unmatchedUrl.length == 0) {
return new Instruction(componentType, partialMatch.matchedUrl, recognizer);
}
return this.recognize(candidate.unmatchedUrl, componentType)
.then(childInstruction => {
if (isBlank(childInstruction)) {
return null;
}
return new Instruction({
component: componentType,
child: childInstruction,
params: candidate.params,
matchedUrl: candidate.matchedUrl,
parentSpecificity: candidate.specificity
});
});
});
return this.recognize(partialMatch.unmatchedUrl, componentType)
.then(childInstruction => {
if (isBlank(childInstruction)) {
return null;
} else {
return new Instruction(componentType, partialMatch.matchedUrl, recognizer,
childInstruction);
}
});
});
}
generate(name: string, params: StringMap<string, string>, hostComponent): string {
// TODO: implement for hierarchical routes
var componentRecognizer = this._rules.get(hostComponent);
return isPresent(componentRecognizer) ? componentRecognizer.generate(name, params) : null;
/**
* Given a list with component names and params like: `['./user', {id: 3 }]`
* generates a url with a leading slash relative to the provided `parentComponent`.
*/
generate(linkParams: List<any>, parentComponent): string {
let normalizedLinkParams = splitAndFlattenLinkParams(linkParams);
let url = '/';
let componentCursor = parentComponent;
// The first segment should be either '.' (generate from parent) or '' (generate from root).
// When we normalize above, we strip all the slashes, './' becomes '.' and '/' becomes ''.
if (normalizedLinkParams[0] == '') {
componentCursor = this._rootHostComponent;
} else if (normalizedLinkParams[0] != '.') {
throw new BaseException(
`Link "${ListWrapper.toJSON(linkParams)}" must start with "/" or "./"`);
}
if (normalizedLinkParams[normalizedLinkParams.length - 1] == '') {
ListWrapper.removeLast(normalizedLinkParams);
}
if (normalizedLinkParams.length < 2) {
throw new BaseException(
`Link "${ListWrapper.toJSON(linkParams)}" must include a route name.`);
}
for (let i = 1; i < normalizedLinkParams.length; i += 1) {
let segment = normalizedLinkParams[i];
if (!isString(segment)) {
throw new BaseException(`Unexpected segment "${segment}" in link DSL. Expected a string.`);
}
let params = null;
if (i + 1 < normalizedLinkParams.length) {
let nextSegment = normalizedLinkParams[i + 1];
if (isStringMap(nextSegment)) {
params = nextSegment;
i += 1;
}
}
var componentRecognizer = this._rules.get(componentCursor);
if (isBlank(componentRecognizer)) {
throw new BaseException(`Could not find route config for "${segment}".`);
}
var response = componentRecognizer.generate(segment, params);
url += response['url'];
componentCursor = response['nextComponent'];
}
return url;
}
}
@ -200,19 +243,6 @@ function normalizeComponentDeclaration(config: any): StringMap<string, any> {
}
}
function componentHandlerToComponentType(handler): Promise<any> {
var componentDeclaration = handler['component'], type = componentDeclaration['type'];
if (type == 'constructor') {
return PromiseWrapper.resolve(componentDeclaration['constructor']);
} else if (type == 'loader') {
var resolverFunction = componentDeclaration['loader'];
return resolverFunction();
} else {
throw new BaseException(`Cannot extract the component type from a '${type}' component`);
}
}
/*
* Given a list of instructions, returns the most specific instruction
*/
@ -244,3 +274,18 @@ function assertTerminalComponent(component, path) {
}
}
}
/*
* Given: ['/a/b', {c: 2}]
* Returns: ['', 'a', 'b', {c: 2}]
*/
var SLASH = new RegExp('/');
function splitAndFlattenLinkParams(linkParams: List<any>): List<any> {
return ListWrapper.reduce(linkParams, (accumulation, item) => {
if (isString(item)) {
return ListWrapper.concat(accumulation, StringWrapper.split(item, SLASH));
}
accumulation.push(item);
return accumulation;
}, []);
}

View File

@ -191,8 +191,8 @@ export class Router {
* Generate a URL from a component name and optional map of parameters. The URL is relative to the
* app's base href.
*/
generate(name: string, params: StringMap<string, string>): string {
return this._registry.generate(name, params, this.hostComponent);
generate(linkParams: List<any>): string {
return this._registry.generate(linkParams, this.hostComponent);
}
}

View File

@ -1,18 +1,12 @@
import {onAllChangesDone} from 'angular2/src/core/annotations/annotations';
import {Directive} from 'angular2/src/core/annotations/decorators';
import {ElementRef} from 'angular2/core';
import {StringMap, StringMapWrapper} from 'angular2/src/facade/collection';
import {isPresent} from 'angular2/src/facade/lang';
import {List, StringMap, StringMapWrapper} from 'angular2/src/facade/collection';
import {Router} from './router';
import {Location} from './location';
import {Renderer} from 'angular2/src/render/api';
/**
* The RouterLink directive lets you link to specific parts of your app.
*
*
* Consider the following route configuration:
* ```
@ -22,48 +16,47 @@ import {Renderer} from 'angular2/src/render/api';
* class MyComp {}
* ```
*
* When linking to a route, you can write:
* When linking to this `user` route, you can write:
*
* ```
* <a router-link="user">link to user component</a>
* <a [router-link]="['./user']">link to user component</a>
* ```
*
* RouterLink expects the value to be an array of route names, followed by the params
* for that level of routing. For instance `['/team', {teamId: 1}, 'user', {userId: 2}]`
* means that we want to generate a link for the `team` route with params `{teamId: 1}`,
* and with a child route `user` with params `{userId: 2}`.
*
* The first route name should be prepended with either `./` or `/`.
* If the route begins with `/`, the router will look up the route from the root of the app.
* If the route begins with `./`, the router will instead look in the current component's
* children for the route.
*
* @exportedAs angular2/router
*/
@Directive({
selector: '[router-link]',
properties: ['route: routerLink', 'params: routerParams'],
lifecycle: [onAllChangesDone],
host: {'(^click)': 'onClick()'}
properties: ['routeParams: routerLink'],
host: {'(^click)': 'onClick()', '[attr.href]': 'visibleHref'}
})
export class RouterLink {
private _route: string;
private _params: StringMap<string, string> = StringMapWrapper.create();
private _routeParams: List<any>;
// the url displayed on the anchor element.
_visibleHref: string;
visibleHref: string;
// the url passed to the router navigation.
_navigationHref: string;
constructor(private _elementRef: ElementRef, private _router: Router, private _location: Location,
private _renderer: Renderer) {}
constructor(private _router: Router, private _location: Location) {}
set route(changes: string) { this._route = changes; }
set params(changes: StringMap<string, string>) { this._params = changes; }
set routeParams(changes: List<any>) {
this._routeParams = changes;
this._navigationHref = this._router.generate(this._routeParams);
this.visibleHref = this._location.normalizeAbsolutely(this._navigationHref);
}
onClick(): boolean {
this._router.navigate(this._navigationHref);
return false;
}
onAllChangesDone(): void {
if (isPresent(this._route) && isPresent(this._params)) {
this._navigationHref = this._router.generate(this._route, this._params);
this._visibleHref = this._location.normalizeAbsolutely(this._navigationHref);
// Keeping the link on the element to support contextual menu `copy link`
// and other in-browser affordances.
this._renderer.setElementAttribute(this._elementRef, 'href', this._visibleHref);
}
}
}

View File

@ -57,7 +57,7 @@ export class RouterOutlet {
this._childRouter = this._parentRouter.childRouter(instruction.component);
var outletInjector = this._injector.resolveAndCreateChild([
bind(RouteParams)
.toValue(new RouteParams(instruction.params)),
.toValue(new RouteParams(instruction.params())),
bind(routerMod.Router).toValue(this._childRouter)
]);

View File

@ -0,0 +1,13 @@
import {RouteHandler} from './route_handler';
import {Promise, PromiseWrapper} from 'angular2/src/facade/async';
import {Type} from 'angular2/src/facade/lang';
export class SyncRouteHandler implements RouteHandler {
_resolvedComponent: Promise<any> = null;
constructor(public componentType: Type) {
this._resolvedComponent = PromiseWrapper.resolve(componentType);
}
resolveComponentType(): Promise<any> { return this._resolvedComponent; }
}

View File

@ -42,7 +42,7 @@ export function main() {
beforeEachBindings(() => [
Pipeline,
RouteRegistry,
bind(RouteRegistry).toFactory(() => new RouteRegistry(MyComp)),
DirectiveResolver,
bind(Location).toClass(SpyLocation),
bind(Router)
@ -129,7 +129,7 @@ export function main() {
it('should generate absolute hrefs that include the base href',
inject([AsyncTestCompleter], (async) => {
location.setBaseHref('/my/base');
compile('<a href="hello" router-link="user"></a>')
compile('<a href="hello" [router-link]="[\'./user\']"></a>')
.then((_) => rtr.config({'path': '/user', 'component': UserCmp, 'as': 'user'}))
.then((_) => rtr.navigate('/a/b'))
.then((_) => {
@ -141,7 +141,7 @@ export function main() {
it('should generate link hrefs without params', inject([AsyncTestCompleter], (async) => {
compile('<a href="hello" router-link="user"></a>')
compile('<a href="hello" [router-link]="[\'./user\']"></a>')
.then((_) => rtr.config({'path': '/user', 'component': UserCmp, 'as': 'user'}))
.then((_) => rtr.navigate('/a/b'))
.then((_) => {
@ -172,7 +172,7 @@ export function main() {
it('should generate link hrefs with params', inject([AsyncTestCompleter], (async) => {
compile('<a href="hello" router-link="user" [router-params]="{name: name}">{{name}}</a>')
compile('<a href="hello" [router-link]="[\'./user\', {name: name}]">{{name}}</a>')
.then((_) => rtr.config({'path': '/user/:name', 'component': UserCmp, 'as': 'user'}))
.then((_) => rtr.navigate('/a/b'))
.then((_) => {
@ -194,10 +194,8 @@ export function main() {
return dispatchedEvent;
};
it('test', inject([AsyncTestCompleter], (async) => { async.done(); }));
it('should navigate to link hrefs without params', inject([AsyncTestCompleter], (async) => {
compile('<a href="hello" router-link="user"></a>')
compile('<a href="hello" [router-link]="[\'./user\']"></a>')
.then((_) => rtr.config({'path': '/user', 'component': UserCmp, 'as': 'user'}))
.then((_) => rtr.navigate('/a/b'))
.then((_) => {
@ -218,7 +216,7 @@ export function main() {
it('should navigate to link hrefs in presence of base href',
inject([AsyncTestCompleter], (async) => {
location.setBaseHref('/base');
compile('<a href="hello" router-link="user"></a>')
compile('<a href="hello" [router-link]="[\'./user\']"></a>')
.then((_) => rtr.config({'path': '/user', 'component': UserCmp, 'as': 'user'}))
.then((_) => rtr.navigate('/a/b'))
.then((_) => {

View File

@ -10,43 +10,44 @@ import {
SpyObject
} from 'angular2/test_lib';
import {RouteRecognizer} from 'angular2/src/router/route_recognizer';
import {RouteRecognizer, RouteMatch} from 'angular2/src/router/route_recognizer';
export function main() {
describe('RouteRecognizer', () => {
var recognizer;
var handler = {'components': {'a': 'b'}};
var handler2 = {'components': {'b': 'c'}};
var handler = {'component': DummyCmpA};
var handler2 = {'component': DummyCmpB};
beforeEach(() => { recognizer = new RouteRecognizer(); });
it('should recognize a static segment', () => {
recognizer.addConfig('/test', handler);
expect(recognizer.recognize('/test')[0].handler).toEqual(handler);
var solution = recognizer.recognize('/test')[0];
expect(getComponentType(solution)).toEqual(handler['component']);
});
it('should recognize a single slash', () => {
recognizer.addConfig('/', handler);
var solution = recognizer.recognize('/')[0];
expect(solution.handler).toEqual(handler);
expect(getComponentType(solution)).toEqual(handler['component']);
});
it('should recognize a dynamic segment', () => {
recognizer.addConfig('/user/:name', handler);
var solution = recognizer.recognize('/user/brian')[0];
expect(solution.handler).toEqual(handler);
expect(solution.params).toEqual({'name': 'brian'});
expect(getComponentType(solution)).toEqual(handler['component']);
expect(solution.params()).toEqual({'name': 'brian'});
});
it('should recognize a star segment', () => {
recognizer.addConfig('/first/*rest', handler);
var solution = recognizer.recognize('/first/second/third')[0];
expect(solution.handler).toEqual(handler);
expect(solution.params).toEqual({'rest': 'second/third'});
expect(getComponentType(solution)).toEqual(handler['component']);
expect(solution.params()).toEqual({'rest': 'second/third'});
});
@ -72,7 +73,7 @@ export function main() {
expect(solutions.length).toBe(1);
var solution = solutions[0];
expect(solution.handler).toEqual(handler);
expect(getComponentType(solution)).toEqual(handler['component']);
expect(solution.matchedUrl).toEqual('/b');
});
@ -83,7 +84,7 @@ export function main() {
expect(solutions.length).toBe(1);
var solution = solutions[0];
expect(solution.handler).toEqual(handler);
expect(getComponentType(solution)).toEqual(handler['component']);
expect(solution.matchedUrl).toEqual('/bar');
});
@ -106,16 +107,23 @@ export function main() {
expect(solutions[0].matchedUrl).toBe('/matias');
});
it('should generate URLs', () => {
it('should generate URLs with params', () => {
recognizer.addConfig('/app/user/:name', handler, 'user');
expect(recognizer.generate('user', {'name': 'misko'})).toEqual('/app/user/misko');
expect(recognizer.generate('user', {'name': 'misko'})['url']).toEqual('app/user/misko');
});
it('should throw in the absence of required params URLs', () => {
recognizer.addConfig('/app/user/:name', handler, 'user');
expect(() => recognizer.generate('user', {}))
recognizer.addConfig('app/user/:name', handler, 'user');
expect(() => recognizer.generate('user', {})['url'])
.toThrowError('Route generator for \'name\' was not included in parameters passed.');
});
});
}
function getComponentType(routeMatch: RouteMatch): any {
return routeMatch.recognizer.handler.componentType;
}
class DummyCmpA {}
class DummyCmpB {}

View File

@ -11,6 +11,7 @@ import {
} from 'angular2/test_lib';
import {Promise, PromiseWrapper} from 'angular2/src/facade/async';
import {ListWrapper} from 'angular2/src/facade/collection';
import {RouteRegistry} from 'angular2/src/router/route_registry';
import {RouteConfig} from 'angular2/src/router/route_config_decorator';
@ -19,7 +20,7 @@ export function main() {
describe('RouteRegistry', () => {
var registry, rootHostComponent = new Object();
beforeEach(() => { registry = new RouteRegistry(); });
beforeEach(() => { registry = new RouteRegistry(rootHostComponent); });
it('should match the full URL', inject([AsyncTestCompleter], (async) => {
registry.config(rootHostComponent, {'path': '/', 'component': DummyCompA});
@ -32,6 +33,68 @@ export function main() {
});
}));
it('should generate URLs starting at the given component', () => {
registry.config(rootHostComponent,
{'path': '/first/...', 'component': DummyParentComp, 'as': 'firstCmp'});
expect(registry.generate(['./firstCmp/secondCmp'], rootHostComponent))
.toEqual('/first/second');
expect(registry.generate(['./secondCmp'], DummyParentComp)).toEqual('/second');
});
it('should generate URLs with params', () => {
registry.config(
rootHostComponent,
{'path': '/first/:param/...', 'component': DummyParentParamComp, 'as': 'firstCmp'});
var url = registry.generate(['./firstCmp', {param: 'one'}, 'secondCmp', {param: 'two'}],
rootHostComponent);
expect(url).toEqual('/first/one/second/two');
});
it('should generate URLs from the root component when the path starts with /', () => {
registry.config(rootHostComponent,
{'path': '/first/...', 'component': DummyParentComp, 'as': 'firstCmp'});
expect(registry.generate(['/firstCmp', 'secondCmp'], rootHostComponent))
.toEqual('/first/second');
expect(registry.generate(['/firstCmp', 'secondCmp'], DummyParentComp))
.toEqual('/first/second');
expect(registry.generate(['/firstCmp/secondCmp'], DummyParentComp)).toEqual('/first/second');
});
it('should generate URLs of loaded components after they are loaded',
inject([AsyncTestCompleter], (async) => {
registry.config(rootHostComponent, {
'path': '/first/...',
'component': {'type': 'loader', 'loader': AsyncParentLoader},
'as': 'firstCmp'
});
expect(() => registry.generate(['/firstCmp/secondCmp'], rootHostComponent))
.toThrowError('Could not find route config for "secondCmp".');
registry.recognize('/first/second', rootHostComponent)
.then((_) => {
expect(registry.generate(['/firstCmp/secondCmp'], rootHostComponent))
.toEqual('/first/second');
async.done();
});
}));
it('should throw when linkParams does not start with a "/" or "./"', () => {
expect(() => registry.generate(['firstCmp', 'secondCmp'], rootHostComponent))
.toThrowError(
`Link "${ListWrapper.toJSON(['firstCmp', 'secondCmp'])}" must start with "/" or "./"`);
});
it('should throw when linkParams does not include a route name', () => {
expect(() => registry.generate(['./'], rootHostComponent))
.toThrowError(`Link "${ListWrapper.toJSON(['./'])}" must include a route name.`);
expect(() => registry.generate(['/'], rootHostComponent))
.toThrowError(`Link "${ListWrapper.toJSON(['/'])}" must include a route name.`);
});
it('should prefer static segments to dynamic', inject([AsyncTestCompleter], (async) => {
registry.config(rootHostComponent, {'path': '/:site', 'component': DummyCompB});
registry.config(rootHostComponent, {'path': '/home', 'component': DummyCompA});
@ -172,6 +235,11 @@ class DummyAsyncComp {
class DummyCompA {}
class DummyCompB {}
@RouteConfig([{'path': '/second', 'component': DummyCompB}])
@RouteConfig([{'path': '/second', 'component': DummyCompB, 'as': 'secondCmp'}])
class DummyParentComp {
}
@RouteConfig([{'path': '/second/:param', 'component': DummyCompB, 'as': 'secondCmp'}])
class DummyParentParamComp {
}

View File

@ -31,7 +31,7 @@ export function main() {
beforeEachBindings(() => [
Pipeline,
RouteRegistry,
bind(RouteRegistry).toFactory(() => new RouteRegistry(AppCmp)),
DirectiveResolver,
bind(Location).toClass(SpyLocation),
bind(Router)