From f66ce096d8d908fcc0a643a14412136e74025c3d Mon Sep 17 00:00:00 2001 From: Brian Ford Date: Tue, 30 Jun 2015 13:18:51 -0700 Subject: [PATCH] feat(router): support deep-linking to anywhere in the app Closes #2642 --- modules/angular2/router.ts | 3 +- modules/angular2/src/facade/collection.ts | 1 - .../src/router/async_route_handler.ts | 21 +++ modules/angular2/src/router/instruction.ts | 47 +++---- .../angular2/src/router/path_recognizer.ts | 14 +- modules/angular2/src/router/route_handler.ts | 7 + .../angular2/src/router/route_recognizer.ts | 77 +++++----- modules/angular2/src/router/route_registry.ts | 133 ++++++++++++------ modules/angular2/src/router/router.ts | 4 +- modules/angular2/src/router/router_link.ts | 53 +++---- modules/angular2/src/router/router_outlet.ts | 2 +- .../angular2/src/router/sync_route_handler.ts | 13 ++ modules/angular2/test/router/outlet_spec.ts | 14 +- .../test/router/route_recognizer_spec.ts | 38 +++-- .../test/router/route_registry_spec.ts | 72 +++++++++- modules/angular2/test/router/router_spec.ts | 2 +- 16 files changed, 331 insertions(+), 170 deletions(-) create mode 100644 modules/angular2/src/router/async_route_handler.ts create mode 100644 modules/angular2/src/router/route_handler.ts create mode 100644 modules/angular2/src/router/sync_route_handler.ts diff --git a/modules/angular2/router.ts b/modules/angular2/router.ts index fecc2a7f34..9c7e605291 100644 --- a/modules/angular2/router.ts +++ b/modules/angular2/router.ts @@ -34,7 +34,8 @@ import {List} from './src/facade/collection'; export const routerDirectives: List = CONST_EXPR([RouterOutlet, RouterLink]); export var routerInjectables: List = [ - RouteRegistry, + bind(RouteRegistry) + .toFactory((appRoot) => new RouteRegistry(appRoot), [appComponentTypeToken]), Pipeline, bind(LocationStrategy).toClass(HTML5LocationStrategy), Location, diff --git a/modules/angular2/src/facade/collection.ts b/modules/angular2/src/facade/collection.ts index 29ddb57009..891f1886ec 100644 --- a/modules/angular2/src/facade/collection.ts +++ b/modules/angular2/src/facade/collection.ts @@ -239,7 +239,6 @@ export class ListWrapper { } static toString(l: List): string { return l.toString(); } static toJSON(l: List): string { return JSON.stringify(l); } - } export function isListLikeIterable(obj): boolean { diff --git a/modules/angular2/src/router/async_route_handler.ts b/modules/angular2/src/router/async_route_handler.ts new file mode 100644 index 0000000000..dbc5039038 --- /dev/null +++ b/modules/angular2/src/router/async_route_handler.ts @@ -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 = null; + componentType: Type; + + constructor(private _loader: Function) {} + + resolveComponentType(): Promise { + if (isPresent(this._resolvedComponent)) { + return this._resolvedComponent; + } + + return this._resolvedComponent = this._loader().then((componentType) => { + this.componentType = componentType; + return componentType; + }); + } +} diff --git a/modules/angular2/src/router/instruction.ts b/modules/angular2/src/router/instruction.ts index 6fa95f4028..dc174744ec 100644 --- a/modules/angular2/src/router/instruction.ts +++ b/modules/angular2/src/router/instruction.ts @@ -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) {} @@ -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; - reuse: boolean; + reuse: boolean = false; specificity: number; - constructor({params, component, child, matchedUrl, parentSpecificity}: { - params?: StringMap, - component?: any, - child?: Instruction, - matchedUrl?: string, - parentSpecificity?: number - } = {}) { - this.reuse = false; - this.capturedUrl = matchedUrl; - this.accumulatedUrl = matchedUrl; - this.specificity = parentSpecificity; + private _params: StringMap; + + 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 { + 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()); } diff --git a/modules/angular2/src/router/path_recognizer.ts b/modules/angular2/src/router/path_recognizer.ts index 5a651182f2..814014236c 100644 --- a/modules/angular2/src/router/path_recognizer.ts +++ b/modules/angular2/src/router/path_recognizer.ts @@ -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 { 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 { - return ListWrapper.join( - ListWrapper.map(this.segments, (segment) => '/' + segment.generate(params)), ''); + return ListWrapper.join(ListWrapper.map(this.segments, (segment) => segment.generate(params)), + '/'); } + + resolveComponentType(): Promise { return this.handler.resolveComponentType(); } } diff --git a/modules/angular2/src/router/route_handler.ts b/modules/angular2/src/router/route_handler.ts new file mode 100644 index 0000000000..e0b7546df6 --- /dev/null +++ b/modules/angular2/src/router/route_handler.ts @@ -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; +} diff --git a/modules/angular2/src/router/route_recognizer.ts b/modules/angular2/src/router/route_recognizer.ts index 79499b13b0..0dd55dbc41 100644 --- a/modules/angular2/src/router/route_recognizer.ts +++ b/modules/angular2/src/router/route_recognizer.ts @@ -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 { + 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; - params: StringMap; - matchedUrl: string; - unmatchedUrl: string; + constructor(public recognizer: PathRecognizer, public matchedUrl: string, + public unmatchedUrl: string) {} - constructor({specificity, handler, params, matchedUrl, unmatchedUrl}: { - specificity?: number, - handler?: StringMap, - params?: StringMap, - matchedUrl?: string, - unmatchedUrl?: string - } = {}) { - this.specificity = specificity; - this.handler = handler; - this.params = params; - this.matchedUrl = matchedUrl; - this.unmatchedUrl = unmatchedUrl; - } + params(): StringMap { 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}".`); } diff --git a/modules/angular2/src/router/route_registry.ts b/modules/angular2/src/router/route_registry.ts index a2229e558e..be66c9d6e0 100644 --- a/modules/angular2/src/router/route_registry.ts +++ b/modules/angular2/src/router/route_registry.ts @@ -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 = new Map(); + private _rules: Map = 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 { - return componentHandlerToComponentType(candidate.handler) - .then((componentType) => { - this.configFromComponent(componentType); + _completeRouteMatch(partialMatch: RouteMatch): Promise { + 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, 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, 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 { } } -function componentHandlerToComponentType(handler): Promise { - 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): List { + return ListWrapper.reduce(linkParams, (accumulation, item) => { + if (isString(item)) { + return ListWrapper.concat(accumulation, StringWrapper.split(item, SLASH)); + } + accumulation.push(item); + return accumulation; + }, []); +} diff --git a/modules/angular2/src/router/router.ts b/modules/angular2/src/router/router.ts index 7aadb775d5..8a86b600ca 100644 --- a/modules/angular2/src/router/router.ts +++ b/modules/angular2/src/router/router.ts @@ -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 { - return this._registry.generate(name, params, this.hostComponent); + generate(linkParams: List): string { + return this._registry.generate(linkParams, this.hostComponent); } } diff --git a/modules/angular2/src/router/router_link.ts b/modules/angular2/src/router/router_link.ts index edd00d0b89..82c20ac750 100644 --- a/modules/angular2/src/router/router_link.ts +++ b/modules/angular2/src/router/router_link.ts @@ -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: * * ``` - * link to user component + * link to user component * ``` * + * 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 = StringMapWrapper.create(); + private _routeParams: List; // 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) { this._params = changes; } + set routeParams(changes: List) { + 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); - } - } } diff --git a/modules/angular2/src/router/router_outlet.ts b/modules/angular2/src/router/router_outlet.ts index 21323e95ea..04ac6adf10 100644 --- a/modules/angular2/src/router/router_outlet.ts +++ b/modules/angular2/src/router/router_outlet.ts @@ -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) ]); diff --git a/modules/angular2/src/router/sync_route_handler.ts b/modules/angular2/src/router/sync_route_handler.ts new file mode 100644 index 0000000000..598601c75c --- /dev/null +++ b/modules/angular2/src/router/sync_route_handler.ts @@ -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 = null; + + constructor(public componentType: Type) { + this._resolvedComponent = PromiseWrapper.resolve(componentType); + } + + resolveComponentType(): Promise { return this._resolvedComponent; } +} diff --git a/modules/angular2/test/router/outlet_spec.ts b/modules/angular2/test/router/outlet_spec.ts index f03fbacbac..207febb72a 100644 --- a/modules/angular2/test/router/outlet_spec.ts +++ b/modules/angular2/test/router/outlet_spec.ts @@ -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('') + compile('') .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('') + compile('') .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('{{name}}') + compile('{{name}}') .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('') + compile('') .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('') + compile('') .then((_) => rtr.config({'path': '/user', 'component': UserCmp, 'as': 'user'})) .then((_) => rtr.navigate('/a/b')) .then((_) => { diff --git a/modules/angular2/test/router/route_recognizer_spec.ts b/modules/angular2/test/router/route_recognizer_spec.ts index 47925e42bc..be5399a6c6 100644 --- a/modules/angular2/test/router/route_recognizer_spec.ts +++ b/modules/angular2/test/router/route_recognizer_spec.ts @@ -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 {} diff --git a/modules/angular2/test/router/route_registry_spec.ts b/modules/angular2/test/router/route_registry_spec.ts index 77228282df..05cb22d667 100644 --- a/modules/angular2/test/router/route_registry_spec.ts +++ b/modules/angular2/test/router/route_registry_spec.ts @@ -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 { +} diff --git a/modules/angular2/test/router/router_spec.ts b/modules/angular2/test/router/router_spec.ts index 1d4c5816d4..21fb351e52 100644 --- a/modules/angular2/test/router/router_spec.ts +++ b/modules/angular2/test/router/router_spec.ts @@ -31,7 +31,7 @@ export function main() { beforeEachBindings(() => [ Pipeline, - RouteRegistry, + bind(RouteRegistry).toFactory(() => new RouteRegistry(AppCmp)), DirectiveResolver, bind(Location).toClass(SpyLocation), bind(Router)