From ac6227e4345aeffda4b24336b6e5282ed937a877 Mon Sep 17 00:00:00 2001 From: Brian Ford Date: Fri, 17 Jul 2015 13:36:53 -0700 Subject: [PATCH] feat(router): auxiliary routes Closes #2775 --- modules/angular2/router.ts | 3 +- modules/angular2/src/router/helpers.ts | 19 - modules/angular2/src/router/instruction.ts | 104 ++- modules/angular2/src/router/interfaces.ts | 12 +- .../src/router/lifecycle_annotations.ts | 4 +- .../angular2/src/router/path_recognizer.ts | 239 +++---- .../src/router/route_config_decorator.ts | 2 +- .../angular2/src/router/route_config_impl.ts | 19 +- .../src/router/route_config_nomalizer.ts | 9 +- .../angular2/src/router/route_recognizer.ts | 180 ++--- modules/angular2/src/router/route_registry.ts | 133 ++-- modules/angular2/src/router/router.ts | 145 ++-- modules/angular2/src/router/router_link.ts | 15 +- modules/angular2/src/router/router_outlet.ts | 86 ++- modules/angular2/src/router/url.ts | 9 - modules/angular2/src/router/url_parser.ts | 210 ++++++ modules/angular2/test/router/outlet_spec.ts | 636 ++++++++++-------- .../test/router/path_recognizer_spec.ts | 82 ++- .../angular2/test/router/route_config_spec.ts | 40 +- .../test/router/route_recognizer_spec.ts | 242 ++----- .../test/router/route_registry_spec.ts | 89 ++- .../angular2/test/router/router_link_spec.ts | 15 +- modules/angular2/test/router/router_spec.ts | 57 +- .../angular2/test/router/url_parser_spec.ts | 118 ++++ 24 files changed, 1482 insertions(+), 986 deletions(-) delete mode 100644 modules/angular2/src/router/helpers.ts delete mode 100644 modules/angular2/src/router/url.ts create mode 100644 modules/angular2/src/router/url_parser.ts create mode 100644 modules/angular2/test/router/url_parser_spec.ts diff --git a/modules/angular2/router.ts b/modules/angular2/router.ts index 33c29c510e..940103fd19 100644 --- a/modules/angular2/router.ts +++ b/modules/angular2/router.ts @@ -19,7 +19,8 @@ export * from './src/router/route_config_decorator'; export * from './src/router/route_definition'; export {OnActivate, OnDeactivate, OnReuse, CanDeactivate, CanReuse} from './src/router/interfaces'; export {CanActivate} from './src/router/lifecycle_annotations'; -export {Instruction} from './src/router/instruction'; +export {Instruction, ComponentInstruction} from './src/router/instruction'; +export {Url} from './src/router/url_parser'; import {LocationStrategy} from './src/router/location_strategy'; import {HTML5LocationStrategy} from './src/router/html5_location_strategy'; diff --git a/modules/angular2/src/router/helpers.ts b/modules/angular2/src/router/helpers.ts deleted file mode 100644 index 49db7319bd..0000000000 --- a/modules/angular2/src/router/helpers.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {isPresent} from 'angular2/src/facade/lang'; - -export function parseAndAssignParamString(splitToken: string, paramString: string, - keyValueMap: StringMap): void { - var first = paramString[0]; - if (first == '?' || first == ';') { - paramString = paramString.substring(1); - } - - paramString.split(splitToken) - .forEach((entry) => { - var tuple = entry.split('='); - var key = tuple[0]; - if (!isPresent(keyValueMap[key])) { - var value = tuple.length > 1 ? tuple[1] : true; - keyValueMap[key] = value; - } - }); -} diff --git a/modules/angular2/src/router/instruction.ts b/modules/angular2/src/router/instruction.ts index b8f4beb417..5baf08866a 100644 --- a/modules/angular2/src/router/instruction.ts +++ b/modules/angular2/src/router/instruction.ts @@ -6,9 +6,11 @@ import { List, ListWrapper } from 'angular2/src/facade/collection'; -import {isPresent, isBlank, normalizeBlank} from 'angular2/src/facade/lang'; +import {isPresent, isBlank, normalizeBlank, Type} from 'angular2/src/facade/lang'; +import {Promise} from 'angular2/src/facade/async'; import {PathRecognizer} from './path_recognizer'; +import {Url} from './url_parser'; export class RouteParams { constructor(public params: StringMap) {} @@ -18,34 +20,82 @@ export class RouteParams { /** - * An `Instruction` represents the component hierarchy of the application based on a given route + * `Instruction` is a tree of `ComponentInstructions`, with all the information needed + * to transition each component in the app to a given route, including all auxiliary routes. + * + * This is a public API. */ export class Instruction { - // "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; - reuse: boolean = false; - specificity: number; + constructor(public component: ComponentInstruction, public child: Instruction, + public auxInstruction: StringMap) {} - constructor(public component: any, public capturedUrl: string, - private _recognizer: PathRecognizer, public child: Instruction = null, - private _params: StringMap = null) { - this.accumulatedUrl = capturedUrl; - this.specificity = _recognizer.specificity; - if (isPresent(child)) { - this.child = child; - this.specificity += child.specificity; - var childUrl = child.accumulatedUrl; - if (isPresent(childUrl)) { - this.accumulatedUrl += childUrl; - } - } - } - - params(): StringMap { - if (isBlank(this._params)) { - this._params = this._recognizer.parseParams(this.capturedUrl); - } - return this._params; + replaceChild(child: Instruction): Instruction { + return new Instruction(this.component, child, this.auxInstruction); } } + +/** + * Represents a partially completed instruction during recognition that only has the + * primary (non-aux) route instructions matched. + * + * `PrimaryInstruction` is an internal class used by `RouteRecognizer` while it's + * figuring out where to navigate. + */ +export class PrimaryInstruction { + constructor(public component: ComponentInstruction, public child: PrimaryInstruction, + public auxUrls: List) {} +} + +export function stringifyInstruction(instruction: Instruction): string { + var params = instruction.component.urlParams.length > 0 ? + ('?' + instruction.component.urlParams.join('&')) : + ''; + + return instruction.component.urlPath + stringifyAux(instruction) + + stringifyPrimary(instruction.child) + params; +} + +function stringifyPrimary(instruction: Instruction): string { + if (isBlank(instruction)) { + return ''; + } + var params = instruction.component.urlParams.length > 0 ? + (';' + instruction.component.urlParams.join(';')) : + ''; + return '/' + instruction.component.urlPath + params + stringifyAux(instruction) + + stringifyPrimary(instruction.child); +} + +function stringifyAux(instruction: Instruction): string { + var routes = []; + StringMapWrapper.forEach(instruction.auxInstruction, (auxInstruction, _) => { + routes.push(stringifyPrimary(auxInstruction)); + }); + if (routes.length > 0) { + return '(' + routes.join('//') + ')'; + } + return ''; +} + + +/** + * A `ComponentInstruction` represents the route state for a single component. An `Instruction` is + * composed of a tree of these `ComponentInstruction`s. + * + * `ComponentInstructions` is a public API. Instances of `ComponentInstruction` are passed + * to route lifecycle hooks, like {@link CanActivate}. + */ +export class ComponentInstruction { + reuse: boolean = false; + + constructor(public urlPath: string, public urlParams: List, + private _recognizer: PathRecognizer, public params: StringMap = null) {} + + get componentType() { return this._recognizer.handler.componentType; } + + resolveComponentType(): Promise { return this._recognizer.handler.resolveComponentType(); } + + get specificity() { return this._recognizer.specificity; } + + get terminal() { return this._recognizer.terminal; } +} diff --git a/modules/angular2/src/router/interfaces.ts b/modules/angular2/src/router/interfaces.ts index afa04661d9..f39270cd14 100644 --- a/modules/angular2/src/router/interfaces.ts +++ b/modules/angular2/src/router/interfaces.ts @@ -1,4 +1,4 @@ -import {Instruction} from './instruction'; +import {ComponentInstruction} from './instruction'; import {global} from 'angular2/src/facade/lang'; // This is here only so that after TS transpilation the file is not empty. @@ -11,33 +11,33 @@ var __ignore_me = global; * Defines route lifecycle method [onActivate] */ export interface OnActivate { - onActivate(nextInstruction: Instruction, prevInstruction: Instruction): any; + onActivate(nextInstruction: ComponentInstruction, prevInstruction: ComponentInstruction): any; } /** * Defines route lifecycle method [onReuse] */ export interface OnReuse { - onReuse(nextInstruction: Instruction, prevInstruction: Instruction): any; + onReuse(nextInstruction: ComponentInstruction, prevInstruction: ComponentInstruction): any; } /** * Defines route lifecycle method [onDeactivate] */ export interface OnDeactivate { - onDeactivate(nextInstruction: Instruction, prevInstruction: Instruction): any; + onDeactivate(nextInstruction: ComponentInstruction, prevInstruction: ComponentInstruction): any; } /** * Defines route lifecycle method [canReuse] */ export interface CanReuse { - canReuse(nextInstruction: Instruction, prevInstruction: Instruction): any; + canReuse(nextInstruction: ComponentInstruction, prevInstruction: ComponentInstruction): any; } /** * Defines route lifecycle method [canDeactivate] */ export interface CanDeactivate { - canDeactivate(nextInstruction: Instruction, prevInstruction: Instruction): any; + canDeactivate(nextInstruction: ComponentInstruction, prevInstruction: ComponentInstruction): any; } diff --git a/modules/angular2/src/router/lifecycle_annotations.ts b/modules/angular2/src/router/lifecycle_annotations.ts index 8532530dfb..9c86e4a04c 100644 --- a/modules/angular2/src/router/lifecycle_annotations.ts +++ b/modules/angular2/src/router/lifecycle_annotations.ts @@ -6,7 +6,7 @@ import {makeDecorator} from 'angular2/src/util/decorators'; import {CanActivate as CanActivateAnnotation} from './lifecycle_annotations_impl'; import {Promise} from 'angular2/src/facade/async'; -import {Instruction} from 'angular2/src/router/instruction'; +import {ComponentInstruction} from 'angular2/src/router/instruction'; export { canReuse, @@ -17,5 +17,5 @@ export { } from './lifecycle_annotations_impl'; export var CanActivate: - (hook: (next: Instruction, prev: Instruction) => Promise| boolean) => ClassDecorator = + (hook: (next: ComponentInstruction, prev: ComponentInstruction) => Promise| boolean) => ClassDecorator = makeDecorator(CanActivateAnnotation); diff --git a/modules/angular2/src/router/path_recognizer.ts b/modules/angular2/src/router/path_recognizer.ts index 2d9d06abe2..d44380caa9 100644 --- a/modules/angular2/src/router/path_recognizer.ts +++ b/modules/angular2/src/router/path_recognizer.ts @@ -7,7 +7,6 @@ import { isBlank, BaseException } from 'angular2/src/facade/lang'; -import {Promise, PromiseWrapper} from 'angular2/src/facade/async'; import { Map, MapWrapper, @@ -17,21 +16,14 @@ import { ListWrapper } from 'angular2/src/facade/collection'; import {IMPLEMENTS} from 'angular2/src/facade/lang'; -import {parseAndAssignParamString} from 'angular2/src/router/helpers'; -import {escapeRegex} from './url'; + import {RouteHandler} from './route_handler'; +import {Url, RootUrl, serializeParams} from './url_parser'; +import {ComponentInstruction} from './instruction'; -// TODO(jeffbcross): implement as interface when ts2dart adds support: -// https://github.com/angular/ts2dart/issues/173 -export class Segment { - name: string; - regex: string; - generate(params: TouchMap): string { return ''; } -} - -class TouchMap { - map: StringMap = StringMapWrapper.create(); - keys: StringMap = StringMapWrapper.create(); +export class TouchMap { + map: StringMap = {}; + keys: StringMap = {}; constructor(map: StringMap) { if (isPresent(map)) { @@ -63,31 +55,28 @@ function normalizeString(obj: any): string { } } -class ContinuationSegment extends Segment {} - -class StaticSegment extends Segment { - regex: string; - name: string = ''; - - constructor(public string: string) { - super(); - this.regex = escapeRegex(string); - - // we add this property so that the route matcher still sees - // this segment as a valid path even if do not use the matrix - // parameters - this.regex += '(;[^\/]+)?'; - } - - generate(params: TouchMap): string { return this.string; } +export interface Segment { + name: string; + generate(params: TouchMap): string; + match(path: string): boolean; } -@IMPLEMENTS(Segment) -class DynamicSegment { - regex: string = "([^/]+)"; +class ContinuationSegment implements Segment { + name: string = ''; + generate(params: TouchMap): string { return ''; } + match(path: string): boolean { return true; } +} +class StaticSegment implements Segment { + name: string = ''; + constructor(public path: string) {} + match(path: string): boolean { return path == this.path; } + generate(params: TouchMap): string { return this.path; } +} + +class DynamicSegment implements Segment { constructor(public name: string) {} - + match(path: string): boolean { return true; } generate(params: TouchMap): string { if (!StringMapWrapper.contains(params.map, this.name)) { throw new BaseException( @@ -98,11 +87,9 @@ class DynamicSegment { } -class StarSegment { - regex: string = "(.+)"; - +class StarSegment implements Segment { constructor(public name: string) {} - + match(path: string): boolean { return true; } generate(params: TouchMap): string { return normalizeString(params.get(this.name)); } } @@ -150,7 +137,7 @@ function parsePathString(route: string): StringMap { throw new BaseException(`Unexpected "..." before the end of the path for "${route}".`); } results.push(new ContinuationSegment()); - } else if (segment.length > 0) { + } else { results.push(new StaticSegment(segment)); specificity += 100 * (100 - i); } @@ -161,6 +148,23 @@ function parsePathString(route: string): StringMap { return result; } +// this function is used to determine whether a route config path like `/foo/:id` collides with +// `/foo/:name` +function pathDslHash(segments: List): string { + return segments.map((segment) => { + if (segment instanceof StarSegment) { + return '*'; + } else if (segment instanceof ContinuationSegment) { + return '...'; + } else if (segment instanceof DynamicSegment) { + return ':'; + } else if (segment instanceof StaticSegment) { + return segment.path; + } + }) + .join('/'); +} + function splitBySlash(url: string): List { return url.split('/'); } @@ -178,125 +182,106 @@ function assertPath(path: string) { } } +export class PathMatch { + constructor(public instruction: ComponentInstruction, public remaining: Url, + public remainingAux: List) {} +} + // represents something like '/foo/:bar' export class PathRecognizer { - segments: List; - regex: RegExp; + private _segments: List; specificity: number; terminal: boolean = true; + hash: string; - static matrixRegex: RegExp = RegExpWrapper.create('^(.*\/[^\/]+?)(;[^\/]+)?\/?$'); - static queryRegex: RegExp = RegExpWrapper.create('^(.*\/[^\/]+?)(\\?[^\/]+)?$'); + // TODO: cache component instruction instances by params and by ParsedUrl instance - constructor(public path: string, public handler: RouteHandler, public isRoot: boolean = false) { + constructor(public path: string, public handler: RouteHandler) { assertPath(path); var parsed = parsePathString(path); - var specificity = parsed['specificity']; - var segments = parsed['segments']; - var regexString = '^'; - ListWrapper.forEach(segments, (segment) => { - if (segment instanceof ContinuationSegment) { - this.terminal = false; - } else { - regexString += '/' + segment.regex; - } - }); + this._segments = parsed['segments']; + this.specificity = parsed['specificity']; + this.hash = pathDslHash(this._segments); - if (this.terminal) { - regexString += '$'; - } - - this.regex = RegExpWrapper.create(regexString); - this.segments = segments; - this.specificity = specificity; + var lastSegment = this._segments[this._segments.length - 1]; + this.terminal = !(lastSegment instanceof ContinuationSegment); } - parseParams(url: string): StringMap { - // the last segment is always the star one since it's terminal - var segmentsLimit = this.segments.length - 1; - var containsStarSegment = - segmentsLimit >= 0 && this.segments[segmentsLimit] instanceof StarSegment; - var paramsString, useQueryString = this.isRoot && this.terminal; - if (!containsStarSegment) { - var matches = RegExpWrapper.firstMatch( - useQueryString ? PathRecognizer.queryRegex : PathRecognizer.matrixRegex, url); - if (isPresent(matches)) { - url = matches[1]; - paramsString = matches[2]; + recognize(beginningSegment: Url): PathMatch { + var nextSegment = beginningSegment; + var currentSegment: Url; + var positionalParams = {}; + var captured = []; + + for (var i = 0; i < this._segments.length; i += 1) { + if (isBlank(nextSegment)) { + return null; } + currentSegment = nextSegment; - url = StringWrapper.replaceAll(url, /(;[^\/]+)(?=(\/|$))/g, ''); - } + var segment = this._segments[i]; - var params = StringMapWrapper.create(); - var urlPart = url; - - for (var i = 0; i <= segmentsLimit; i++) { - var segment = this.segments[i]; if (segment instanceof ContinuationSegment) { - continue; + break; } - var match = RegExpWrapper.firstMatch(RegExpWrapper.create('/' + segment.regex), urlPart); - urlPart = StringWrapper.substring(urlPart, match[0].length); - if (segment.name.length > 0) { - params[segment.name] = match[1]; + captured.push(currentSegment.path); + + // the star segment consumes all of the remaining URL, including matrix params + if (segment instanceof StarSegment) { + positionalParams[segment.name] = currentSegment.toString(); + nextSegment = null; + break; } + + if (segment instanceof DynamicSegment) { + positionalParams[segment.name] = currentSegment.path; + } else if (!segment.match(currentSegment.path)) { + return null; + } + + nextSegment = currentSegment.child; } - if (isPresent(paramsString) && paramsString.length > 0) { - var expectedStartingValue = useQueryString ? '?' : ';'; - if (paramsString[0] == expectedStartingValue) { - parseAndAssignParamString(expectedStartingValue, paramsString, params); - } + if (this.terminal && isPresent(nextSegment)) { + return null; } - return params; + var urlPath = captured.join('/'); + + // If this is the root component, read query params. Otherwise, read matrix params. + var paramsSegment = beginningSegment instanceof RootUrl ? beginningSegment : currentSegment; + + + var allParams = isPresent(paramsSegment.params) ? + StringMapWrapper.merge(paramsSegment.params, positionalParams) : + positionalParams; + var urlParams = serializeParams(paramsSegment.params); + + var instruction = new ComponentInstruction(urlPath, urlParams, this, allParams); + + return new PathMatch(instruction, nextSegment, currentSegment.auxiliary); } - generate(params: StringMap): string { + + generate(params: StringMap): ComponentInstruction { var paramTokens = new TouchMap(params); - var applyLeadingSlash = false; - var useQueryString = this.isRoot && this.terminal; - var url = ''; - for (var i = 0; i < this.segments.length; i++) { - let segment = this.segments[i]; - let s = segment.generate(paramTokens); - applyLeadingSlash = applyLeadingSlash || (segment instanceof ContinuationSegment); + var path = []; - if (s.length > 0) { - url += (i > 0 ? '/' : '') + s; + for (var i = 0; i < this._segments.length; i++) { + let segment = this._segments[i]; + if (!(segment instanceof ContinuationSegment)) { + path.push(segment.generate(paramTokens)); } } + var urlPath = path.join('/'); - var unusedParams = paramTokens.getUnused(); - if (!StringMapWrapper.isEmpty(unusedParams)) { - url += useQueryString ? '?' : ';'; - var paramToken = useQueryString ? '&' : ';'; - var i = 0; - StringMapWrapper.forEach(unusedParams, (value, key) => { - if (i++ > 0) { - url += paramToken; - } - url += key; - if (!isPresent(value) && useQueryString) { - value = 'true'; - } - if (isPresent(value)) { - url += '=' + value; - } - }); - } + var nonPositionalParams = paramTokens.getUnused(); + var urlParams = serializeParams(nonPositionalParams); - if (applyLeadingSlash) { - url += '/'; - } - - return url; + return new ComponentInstruction(urlPath, urlParams, this, params); } - - resolveComponentType(): Promise { return this.handler.resolveComponentType(); } } diff --git a/modules/angular2/src/router/route_config_decorator.ts b/modules/angular2/src/router/route_config_decorator.ts index 955664de94..ba518e6b7d 100644 --- a/modules/angular2/src/router/route_config_decorator.ts +++ b/modules/angular2/src/router/route_config_decorator.ts @@ -2,6 +2,6 @@ import {RouteConfig as RouteConfigAnnotation, RouteDefinition} from './route_con import {makeDecorator} from 'angular2/src/util/decorators'; import {List} from 'angular2/src/facade/collection'; -export {Route, Redirect, AsyncRoute, RouteDefinition} from './route_config_impl'; +export {Route, Redirect, AuxRoute, AsyncRoute, RouteDefinition} from './route_config_impl'; export var RouteConfig: (configs: List) => ClassDecorator = makeDecorator(RouteConfigAnnotation); diff --git a/modules/angular2/src/router/route_config_impl.ts b/modules/angular2/src/router/route_config_impl.ts index 0a4a6cb8c9..f4e06664ff 100644 --- a/modules/angular2/src/router/route_config_impl.ts +++ b/modules/angular2/src/router/route_config_impl.ts @@ -8,7 +8,7 @@ export {RouteDefinition} from './route_definition'; * * Supported keys: * - `path` (required) - * - `component`, `redirectTo` (requires exactly one of these) + * - `component`, `loader`, `redirectTo` (requires exactly one of these) * - `as` (optional) */ @CONST() @@ -34,6 +34,21 @@ export class Route implements RouteDefinition { } } +@CONST() +export class AuxRoute implements RouteDefinition { + path: string; + component: Type; + as: string; + // added next two properties to work around https://github.com/Microsoft/TypeScript/issues/4107 + loader: Function = null; + redirectTo: string = null; + constructor({path, component, as}: {path: string, component: Type, as?: string}) { + this.path = path; + this.component = component; + this.as = as; + } +} + @CONST() export class AsyncRoute implements RouteDefinition { path: string; @@ -51,6 +66,8 @@ export class Redirect implements RouteDefinition { path: string; redirectTo: string; as: string = null; + // added next property to work around https://github.com/Microsoft/TypeScript/issues/4107 + loader: Function = null; constructor({path, redirectTo}: {path: string, redirectTo: string}) { this.path = path; this.redirectTo = redirectTo; diff --git a/modules/angular2/src/router/route_config_nomalizer.ts b/modules/angular2/src/router/route_config_nomalizer.ts index 1b401e8a92..f809987649 100644 --- a/modules/angular2/src/router/route_config_nomalizer.ts +++ b/modules/angular2/src/router/route_config_nomalizer.ts @@ -1,4 +1,4 @@ -import {AsyncRoute, Route, Redirect, RouteDefinition} from './route_config_decorator'; +import {AsyncRoute, AuxRoute, Route, Redirect, RouteDefinition} from './route_config_decorator'; import {ComponentDefinition} from './route_definition'; import {Type, BaseException} from 'angular2/src/facade/lang'; @@ -6,13 +6,14 @@ import {Type, BaseException} from 'angular2/src/facade/lang'; * Given a JS Object that represents... returns a corresponding Route, AsyncRoute, or Redirect */ export function normalizeRouteConfig(config: RouteDefinition): RouteDefinition { - if (config instanceof Route || config instanceof Redirect || config instanceof AsyncRoute) { + if (config instanceof Route || config instanceof Redirect || config instanceof AsyncRoute || + config instanceof AuxRoute) { return config; } if ((!config.component) == (!config.redirectTo)) { throw new BaseException( - `Route config should contain exactly one 'component', or 'redirectTo' property`); + `Route config should contain exactly one "component", "loader", or "redirectTo" property.`); } if (config.component) { if (typeof config.component == 'object') { @@ -28,7 +29,7 @@ export function normalizeRouteConfig(config: RouteDefinition): RouteDefinition { {path: config.path, loader: componentDefinitionObject.loader, as: config.as}); } else { throw new BaseException( - `Invalid component type '${componentDefinitionObject.type}'. Valid types are "constructor" and "loader".`); + `Invalid component type "${componentDefinitionObject.type}". Valid types are "constructor" and "loader".`); } } return new Route(<{ diff --git a/modules/angular2/src/router/route_recognizer.ts b/modules/angular2/src/router/route_recognizer.ts index aa8657c3fc..e556a0c38c 100644 --- a/modules/angular2/src/router/route_recognizer.ts +++ b/modules/angular2/src/router/route_recognizer.ts @@ -6,7 +6,8 @@ import { isPresent, isType, isStringMap, - BaseException + BaseException, + Type } from 'angular2/src/facade/lang'; import { Map, @@ -17,12 +18,13 @@ import { StringMapWrapper } from 'angular2/src/facade/collection'; -import {PathRecognizer} from './path_recognizer'; -import {RouteHandler} from './route_handler'; -import {Route, AsyncRoute, Redirect, RouteDefinition} from './route_config_impl'; +import {PathRecognizer, PathMatch} from './path_recognizer'; +import {Route, AsyncRoute, AuxRoute, Redirect, RouteDefinition} from './route_config_impl'; import {AsyncRouteHandler} from './async_route_handler'; import {SyncRouteHandler} from './sync_route_handler'; -import {parseAndAssignParamString} from 'angular2/src/router/helpers'; +import {Url} from './url_parser'; +import {ComponentInstruction} from './instruction'; + /** * `RouteRecognizer` is responsible for recognizing routes for a single component. @@ -31,30 +33,45 @@ import {parseAndAssignParamString} from 'angular2/src/router/helpers'; */ export class RouteRecognizer { names: Map = new Map(); - redirects: Map = new Map(); - matchers: Map = new Map(); - constructor(public isRoot: boolean = false) {} + auxRoutes: Map = new Map(); + + // TODO: optimize this into a trie + matchers: List = []; + + // TODO: optimize this into a trie + redirects: List = []; config(config: RouteDefinition): boolean { var handler; + + if (config instanceof AuxRoute) { + handler = new SyncRouteHandler(config.component); + let path = config.path.startsWith('/') ? config.path.substring(1) : config.path; + var recognizer = new PathRecognizer(config.path, handler); + this.auxRoutes.set(path, recognizer); + return recognizer.terminal; + } if (config instanceof Redirect) { - let path = config.path == '/' ? '' : config.path; - this.redirects.set(path, config.redirectTo); + this.redirects.push(new Redirector(config.path, config.redirectTo)); return true; - } else if (config instanceof Route) { + } + + if (config instanceof Route) { handler = new SyncRouteHandler(config.component); } else if (config instanceof AsyncRoute) { handler = new AsyncRouteHandler(config.loader); } - var recognizer = new PathRecognizer(config.path, handler, this.isRoot); - MapWrapper.forEach(this.matchers, (matcher, _) => { - if (recognizer.regex.toString() == matcher.regex.toString()) { + var recognizer = new PathRecognizer(config.path, handler); + + this.matchers.forEach((matcher) => { + if (recognizer.hash == matcher.hash) { throw new BaseException( `Configuration '${config.path}' conflicts with existing route '${matcher.path}'`); } }); - this.matchers.set(recognizer.regex, recognizer); + + this.matchers.push(recognizer); if (isPresent(config.as)) { this.names.set(config.as, recognizer); } @@ -66,102 +83,87 @@ export class RouteRecognizer { * Given a URL, returns a list of `RouteMatch`es, which are partial recognitions for some route. * */ - recognize(url: string): List { + recognize(urlParse: Url): List { var solutions = []; - if (url.length > 0 && url[url.length - 1] == '/') { - url = url.substring(0, url.length - 1); - } - MapWrapper.forEach(this.redirects, (target, path) => { - // "/" redirect case - if (path == '/' || path == '') { - if (path == url) { - url = target; - } - } else if (url.startsWith(path)) { - url = target + url.substring(path.length); - } - }); + urlParse = this._redirect(urlParse); - var queryParams = StringMapWrapper.create(); - var queryString = ''; - var queryIndex = url.indexOf('?'); - if (queryIndex >= 0) { - queryString = url.substring(queryIndex + 1); - url = url.substring(0, queryIndex); - } - if (this.isRoot && queryString.length > 0) { - parseAndAssignParamString('&', queryString, queryParams); - } + this.matchers.forEach((pathRecognizer: PathRecognizer) => { + var pathMatch = pathRecognizer.recognize(urlParse); - MapWrapper.forEach(this.matchers, (pathRecognizer, regex) => { - var match; - if (isPresent(match = RegExpWrapper.firstMatch(regex, url))) { - var matchedUrl = '/'; - var unmatchedUrl = ''; - if (url != '/') { - matchedUrl = match[0]; - unmatchedUrl = url.substring(match[0].length); - } - var params = null; - if (pathRecognizer.terminal && !StringMapWrapper.isEmpty(queryParams)) { - params = queryParams; - matchedUrl += '?' + queryString; - } - solutions.push(new RouteMatch(pathRecognizer, matchedUrl, unmatchedUrl, params)); + if (isPresent(pathMatch)) { + solutions.push(pathMatch); } }); return solutions; } + _redirect(urlParse: Url): Url { + for (var i = 0; i < this.redirects.length; i += 1) { + let redirector = this.redirects[i]; + var redirectedUrl = redirector.redirect(urlParse); + if (isPresent(redirectedUrl)) { + return redirectedUrl; + } + } + + return urlParse; + } + + recognizeAuxiliary(urlParse: Url): PathMatch { + var pathRecognizer = this.auxRoutes.get(urlParse.path); + if (isBlank(pathRecognizer)) { + return null; + } + return pathRecognizer.recognize(urlParse); + } + hasRoute(name: string): boolean { return this.names.has(name); } - generate(name: string, params: any): StringMap { + generate(name: string, params: any): ComponentInstruction { var pathRecognizer: PathRecognizer = this.names.get(name); if (isBlank(pathRecognizer)) { return null; } - var url = pathRecognizer.generate(params); - return {url, 'nextComponent': pathRecognizer.handler.componentType}; + return pathRecognizer.generate(params); } } -export class RouteMatch { - private _params: StringMap; - private _paramsParsed: boolean = false; +export class Redirector { + segments: List = []; + toSegments: List = []; - constructor(public recognizer: PathRecognizer, public matchedUrl: string, - public unmatchedUrl: string, p: StringMap = null) { - this._params = isPresent(p) ? p : StringMapWrapper.create(); + constructor(path: string, redirectTo: string) { + if (path.startsWith('/')) { + path = path.substring(1); + } + this.segments = path.split('/'); + if (redirectTo.startsWith('/')) { + redirectTo = redirectTo.substring(1); + } + this.toSegments = redirectTo.split('/'); } - params(): StringMap { - if (!this._paramsParsed) { - this._paramsParsed = true; - StringMapWrapper.forEach(this.recognizer.parseParams(this.matchedUrl), - (value, key) => { StringMapWrapper.set(this._params, key, value); }); + /** + * Returns `null` or a `ParsedUrl` representing the new path to match + */ + redirect(urlParse: Url): Url { + for (var i = 0; i < this.segments.length; i += 1) { + if (isBlank(urlParse)) { + return null; + } + let segment = this.segments[i]; + if (segment != urlParse.path) { + return null; + } + urlParse = urlParse.child; } - return this._params; + + for (var i = this.toSegments.length - 1; i >= 0; i -= 1) { + let segment = this.toSegments[i]; + urlParse = new Url(segment, urlParse); + } + return urlParse; } } - -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 ff0165fb42..e450975599 100644 --- a/modules/angular2/src/router/route_registry.ts +++ b/modules/angular2/src/router/route_registry.ts @@ -1,5 +1,6 @@ -import {RouteRecognizer, RouteMatch} from './route_recognizer'; -import {Instruction} from './instruction'; +import {PathMatch} from './path_recognizer'; +import {RouteRecognizer} from './route_recognizer'; +import {Instruction, ComponentInstruction, PrimaryInstruction} from './instruction'; import { List, ListWrapper, @@ -24,6 +25,9 @@ import {RouteConfig, AsyncRoute, Route, Redirect, RouteDefinition} from './route import {reflector} from 'angular2/src/reflection/reflection'; import {Injectable} from 'angular2/di'; import {normalizeRouteConfig} from './route_config_nomalizer'; +import {parser, Url} from './url_parser'; + +var _resolveToNull = PromiseWrapper.resolve(null); /** * The RouteRegistry holds route configurations for each component in an Angular app. @@ -37,13 +41,13 @@ export class RouteRegistry { /** * Given a component and a configuration object, add the route to this registry */ - config(parentComponent: any, config: RouteDefinition, isRootLevelRoute: boolean = false): void { + config(parentComponent: any, config: RouteDefinition): void { config = normalizeRouteConfig(config); var recognizer: RouteRecognizer = this._rules.get(parentComponent); if (isBlank(recognizer)) { - recognizer = new RouteRecognizer(isRootLevelRoute); + recognizer = new RouteRecognizer(); this._rules.set(parentComponent, recognizer); } @@ -61,7 +65,7 @@ export class RouteRegistry { /** * Reads the annotations of a component and configures the registry based on them */ - configFromComponent(component: any, isRootComponent: boolean = false): void { + configFromComponent(component: any): void { if (!isType(component)) { return; } @@ -77,8 +81,7 @@ export class RouteRegistry { var annotation = annotations[i]; if (annotation instanceof RouteConfig) { - ListWrapper.forEach(annotation.configs, - (config) => this.config(component, config, isRootComponent)); + ListWrapper.forEach(annotation.configs, (config) => this.config(component, config)); } } } @@ -90,63 +93,100 @@ export class RouteRegistry { * the application into the state specified by the url */ recognize(url: string, parentComponent: any): Promise { + var parsedUrl = parser.parse(url); + return this._recognize(parsedUrl, parentComponent); + } + + private _recognize(parsedUrl: Url, parentComponent): Promise { + return this._recognizePrimaryRoute(parsedUrl, parentComponent) + .then((instruction: PrimaryInstruction) => + this._completeAuxiliaryRouteMatches(instruction, parentComponent)); + } + + private _recognizePrimaryRoute(parsedUrl: Url, parentComponent): Promise { var componentRecognizer = this._rules.get(parentComponent); if (isBlank(componentRecognizer)) { return PromiseWrapper.resolve(null); } // Matches some beginning part of the given URL - var possibleMatches = componentRecognizer.recognize(url); + var possibleMatches = componentRecognizer.recognize(parsedUrl); + var matchPromises = - ListWrapper.map(possibleMatches, (candidate) => this._completeRouteMatch(candidate)); + ListWrapper.map(possibleMatches, (candidate) => this._completePrimaryRouteMatch(candidate)); - return PromiseWrapper.all(matchPromises) - .then((solutions: List) => { - // remove nulls - var fullSolutions = ListWrapper.filter(solutions, (solution) => isPresent(solution)); - - if (fullSolutions.length > 0) { - return mostSpecific(fullSolutions); - } - return null; - }); + return PromiseWrapper.all(matchPromises).then(mostSpecific); } - - _completeRouteMatch(partialMatch: RouteMatch): Promise { - var recognizer = partialMatch.recognizer; - var handler = recognizer.handler; - return handler.resolveComponentType().then((componentType) => { + private _completePrimaryRouteMatch(partialMatch: PathMatch): Promise { + var instruction = partialMatch.instruction; + return instruction.resolveComponentType().then((componentType) => { this.configFromComponent(componentType); - if (partialMatch.unmatchedUrl.length == 0) { - if (recognizer.terminal) { - return new Instruction(componentType, partialMatch.matchedUrl, recognizer, null, - partialMatch.params()); + if (isBlank(partialMatch.remaining)) { + if (instruction.terminal) { + return new PrimaryInstruction(instruction, null, partialMatch.remainingAux); } else { return null; } } - return this.recognize(partialMatch.unmatchedUrl, componentType) - .then(childInstruction => { + return this._recognizePrimaryRoute(partialMatch.remaining, componentType) + .then((childInstruction) => { if (isBlank(childInstruction)) { return null; } else { - return new Instruction(componentType, partialMatch.matchedUrl, recognizer, - childInstruction); + return new PrimaryInstruction(instruction, childInstruction, + partialMatch.remainingAux); } }); }); } + + private _completeAuxiliaryRouteMatches(instruction: PrimaryInstruction, + parentComponent: any): Promise { + if (isBlank(instruction)) { + return _resolveToNull; + } + + var componentRecognizer = this._rules.get(parentComponent); + var auxInstructions = {}; + + var promises = instruction.auxUrls.map((auxSegment: Url) => { + var match = componentRecognizer.recognizeAuxiliary(auxSegment); + if (isBlank(match)) { + return _resolveToNull; + } + return this._completePrimaryRouteMatch(match).then((auxInstruction: PrimaryInstruction) => { + if (isPresent(auxInstruction)) { + return this._completeAuxiliaryRouteMatches(auxInstruction, parentComponent) + .then((finishedAuxRoute: Instruction) => { + auxInstructions[auxSegment.path] = finishedAuxRoute; + }); + } + }); + }); + return PromiseWrapper.all(promises).then((_) => { + if (isBlank(instruction.child)) { + return new Instruction(instruction.component, null, auxInstructions); + } + return this._completeAuxiliaryRouteMatches(instruction.child, + instruction.component.componentType) + .then((completeChild) => { + return new Instruction(instruction.component, completeChild, auxInstructions); + }); + }); + } + /** * Given a normalized 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: any): string { - let url = ''; + generate(linkParams: List, parentComponent: any): Instruction { + let segments = []; let componentCursor = parentComponent; + for (let i = 0; i < linkParams.length; i += 1) { let segment = linkParams[i]; if (isBlank(componentCursor)) { @@ -172,15 +212,22 @@ export class RouteRegistry { `Component "${getTypeNameForDebugging(componentCursor)}" has no route config.`); } var response = componentRecognizer.generate(segment, params); + if (isBlank(response)) { throw new BaseException( `Component "${getTypeNameForDebugging(componentCursor)}" has no route named "${segment}".`); } - url += response['url']; - componentCursor = response['nextComponent']; + segments.push(response); + componentCursor = response.componentType; } - return url; + var instruction = null; + + while (segments.length > 0) { + instruction = new Instruction(segments.pop(), instruction, {}); + } + + return instruction; } } @@ -188,11 +235,17 @@ export class RouteRegistry { /* * Given a list of instructions, returns the most specific instruction */ -function mostSpecific(instructions: List): Instruction { +function mostSpecific(instructions: List): PrimaryInstruction { + if (instructions.length == 0) { + return null; + } var mostSpecificSolution = instructions[0]; for (var solutionIndex = 1; solutionIndex < instructions.length; solutionIndex++) { - var solution = instructions[solutionIndex]; - if (solution.specificity > mostSpecificSolution.specificity) { + var solution: PrimaryInstruction = instructions[solutionIndex]; + if (isBlank(solution)) { + continue; + } + if (solution.component.specificity > mostSpecificSolution.component.specificity) { mostSpecificSolution = solution; } } diff --git a/modules/angular2/src/router/router.ts b/modules/angular2/src/router/router.ts index ee6cdb1c12..1d73035556 100644 --- a/modules/angular2/src/router/router.ts +++ b/modules/angular2/src/router/router.ts @@ -12,7 +12,7 @@ import { import {RouteRegistry} from './route_registry'; import {Pipeline} from './pipeline'; -import {Instruction} from './instruction'; +import {ComponentInstruction, Instruction, stringifyInstruction} from './instruction'; import {RouterOutlet} from './router_outlet'; import {Location} from './location'; import {getCanActivateHook} from './route_lifecycle_reflector'; @@ -45,10 +45,9 @@ export class Router { private _currentInstruction: Instruction = null; private _currentNavigation: Promise = _resolveToTrue; private _outlet: RouterOutlet = null; + private _auxOutlets: Map = new Map(); private _subject: EventEmitter = new EventEmitter(); - // todo(jeffbcross): rename _registry to registry since it is accessed from subclasses - // todo(jeffbcross): rename _pipeline to pipeline since it is accessed from subclasses constructor(public registry: RouteRegistry, public _pipeline: Pipeline, public parent: Router, public hostComponent: any) {} @@ -65,8 +64,11 @@ export class Router { * you're writing a reusable component. */ registerOutlet(outlet: RouterOutlet): Promise { - // TODO: sibling routes - this._outlet = outlet; + if (isPresent(outlet.name)) { + this._auxOutlets.set(outlet.name, outlet); + } else { + this._outlet = outlet; + } if (isPresent(this._currentInstruction)) { return outlet.commit(this._currentInstruction); } @@ -87,9 +89,8 @@ export class Router { * ``` */ config(definitions: List): Promise { - definitions.forEach((routeDefinition) => { - this.registry.config(this.hostComponent, routeDefinition, this instanceof RootRouter); - }); + definitions.forEach( + (routeDefinition) => { this.registry.config(this.hostComponent, routeDefinition); }); return this.renavigate(); } @@ -104,31 +105,51 @@ export class Router { return this._currentNavigation = this._currentNavigation.then((_) => { this.lastNavigationAttempt = url; this._startNavigating(); - return this._afterPromiseFinishNavigating(this.recognize(url).then((matchedInstruction) => { - if (isBlank(matchedInstruction)) { + return this._afterPromiseFinishNavigating(this.recognize(url).then((instruction) => { + if (isBlank(instruction)) { return false; } - return this._reuse(matchedInstruction) - .then((_) => this._canActivate(matchedInstruction)) - .then((result) => { - if (!result) { - return false; - } - return this._canDeactivate(matchedInstruction) - .then((result) => { - if (result) { - return this.commit(matchedInstruction, _skipLocationChange) - .then((_) => { - this._emitNavigationFinish(matchedInstruction.accumulatedUrl); - return true; - }); - } - }); - }); + return this._navigate(instruction, _skipLocationChange); })); }); } + + /** + * Navigate via the provided instruction. Returns a promise that resolves when navigation is + * complete. + */ + navigateInstruction(instruction: Instruction, + _skipLocationChange: boolean = false): Promise { + if (isBlank(instruction)) { + return _resolveToFalse; + } + return this._currentNavigation = this._currentNavigation.then((_) => { + this._startNavigating(); + return this._afterPromiseFinishNavigating(this._navigate(instruction, _skipLocationChange)); + }); + } + + _navigate(instruction: Instruction, _skipLocationChange: boolean): Promise { + return this._reuse(instruction) + .then((_) => this._canActivate(instruction)) + .then((result) => { + if (!result) { + return false; + } + return this._canDeactivate(instruction) + .then((result) => { + if (result) { + return this.commit(instruction, _skipLocationChange) + .then((_) => { + this._emitNavigationFinish(stringifyInstruction(instruction)); + return true; + }); + } + }); + }); + } + private _emitNavigationFinish(url): void { ObservableWrapper.callNext(this._subject, url); } private _afterPromiseFinishNavigating(promise: Promise): Promise { @@ -138,21 +159,20 @@ export class Router { }); } - _reuse(instruction): Promise { + _reuse(instruction: Instruction): Promise { if (isBlank(this._outlet)) { return _resolveToFalse; } return this._outlet.canReuse(instruction) .then((result) => { - instruction.reuse = result; if (isPresent(this._outlet.childRouter) && isPresent(instruction.child)) { return this._outlet.childRouter._reuse(instruction.child); } }); } - private _canActivate(instruction: Instruction): Promise { - return canActivateOne(instruction, this._currentInstruction); + private _canActivate(nextInstruction: Instruction): Promise { + return canActivateOne(nextInstruction, this._currentInstruction); } private _canDeactivate(instruction: Instruction): Promise { @@ -160,11 +180,12 @@ export class Router { return _resolveToTrue; } var next: Promise; - if (isPresent(instruction) && instruction.reuse) { + if (isPresent(instruction) && instruction.component.reuse) { next = _resolveToTrue; } else { next = this._outlet.canDeactivate(instruction); } + // TODO: aux route lifecycle hooks return next.then((result) => { if (result == false) { return false; @@ -182,10 +203,14 @@ export class Router { */ commit(instruction: Instruction, _skipLocationChange: boolean = false): Promise { this._currentInstruction = instruction; + var next = _resolveToTrue; if (isPresent(this._outlet)) { - return this._outlet.commit(instruction); + next = this._outlet.commit(instruction); } - return _resolveToTrue; + var promises = []; + MapWrapper.forEach(this._auxOutlets, + (outlet, _) => { promises.push(outlet.commit(instruction)); }); + return next.then((_) => PromiseWrapper.all(promises)); } @@ -237,7 +262,7 @@ 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(linkParams: List): string { + generate(linkParams: List): Instruction { let normalizedLinkParams = splitAndFlattenLinkParams(linkParams); var first = ListWrapper.first(normalizedLinkParams); @@ -275,11 +300,22 @@ export class Router { throw new BaseException(msg); } - let url = ''; - if (isPresent(router.parent) && isPresent(router.parent._currentInstruction)) { - url = router.parent._currentInstruction.capturedUrl; + // TODO: structural cloning and whatnot + + var url = []; + var parent = router.parent; + while (isPresent(parent)) { + url.unshift(parent._currentInstruction); + parent = parent.parent; } - return url + '/' + this.registry.generate(rest, router.hostComponent); + + var nextInstruction = this.registry.generate(rest, router.hostComponent); + + while (url.length > 0) { + nextInstruction = url.pop().replaceChild(nextInstruction); + } + + return nextInstruction; } } @@ -291,14 +327,19 @@ export class RootRouter extends Router { super(registry, pipeline, null, hostComponent); this._location = location; this._location.subscribe((change) => this.navigate(change['url'], isPresent(change['pop']))); - this.registry.configFromComponent(hostComponent, true); + + this.registry.configFromComponent(hostComponent); this.navigate(location.path()); } commit(instruction: Instruction, _skipLocationChange: boolean = false): Promise { + var emitUrl = stringifyInstruction(instruction); + if (emitUrl.length > 0) { + emitUrl = '/' + emitUrl; + } var promise = super.commit(instruction); if (!_skipLocationChange) { - promise = promise.then((_) => { this._location.go(instruction.accumulatedUrl); }); + promise = promise.then((_) => { this._location.go(emitUrl); }); } return promise; } @@ -315,6 +356,12 @@ class ChildRouter extends Router { // Delegate navigation to the root router return this.parent.navigate(url, _skipLocationChange); } + + navigateInstruction(instruction: Instruction, + _skipLocationChange: boolean = false): Promise { + // Delegate navigation to the root router + return this.parent.navigateInstruction(instruction, _skipLocationChange); + } } /* @@ -332,22 +379,24 @@ function splitAndFlattenLinkParams(linkParams: List): List { }, []); } -function canActivateOne(nextInstruction, currentInstruction): Promise { +function canActivateOne(nextInstruction: Instruction, prevInstruction: Instruction): + Promise { var next = _resolveToTrue; if (isPresent(nextInstruction.child)) { next = canActivateOne(nextInstruction.child, - isPresent(currentInstruction) ? currentInstruction.child : null); + isPresent(prevInstruction) ? prevInstruction.child : null); } - return next.then((res) => { - if (res == false) { + return next.then((result) => { + if (result == false) { return false; } - if (nextInstruction.reuse) { + if (nextInstruction.component.reuse) { return true; } - var hook = getCanActivateHook(nextInstruction.component); + var hook = getCanActivateHook(nextInstruction.component.componentType); if (isPresent(hook)) { - return hook(nextInstruction, currentInstruction); + return hook(nextInstruction.component, + isPresent(prevInstruction) ? prevInstruction.component : null); } return true; }); diff --git a/modules/angular2/src/router/router_link.ts b/modules/angular2/src/router/router_link.ts index 53356a4d34..26d503169c 100644 --- a/modules/angular2/src/router/router_link.ts +++ b/modules/angular2/src/router/router_link.ts @@ -3,6 +3,7 @@ import {List, StringMap, StringMapWrapper} from 'angular2/src/facade/collection' import {Router} from './router'; import {Location} from './location'; +import {Instruction, stringifyInstruction} from './instruction'; /** * The RouterLink directive lets you link to specific parts of your app. @@ -43,19 +44,23 @@ export class RouterLink { // the url displayed on the anchor element. visibleHref: string; - // the url passed to the router navigation. - _navigationHref: string; + + // the instruction passed to the router to navigate + private _navigationInstruction: Instruction; constructor(private _router: Router, private _location: Location) {} set routeParams(changes: List) { this._routeParams = changes; - this._navigationHref = this._router.generate(this._routeParams); - this.visibleHref = this._location.normalizeAbsolutely(this._navigationHref); + this._navigationInstruction = this._router.generate(this._routeParams); + + // TODO: is this the right spot for this? + var navigationHref = '/' + stringifyInstruction(this._navigationInstruction); + this.visibleHref = this._location.normalizeAbsolutely(navigationHref); } onClick(): boolean { - this._router.navigate(this._navigationHref); + this._router.navigateInstruction(this._navigationInstruction); return false; } } diff --git a/modules/angular2/src/router/router_outlet.ts b/modules/angular2/src/router/router_outlet.ts index bd9037426e..39c707f495 100644 --- a/modules/angular2/src/router/router_outlet.ts +++ b/modules/angular2/src/router/router_outlet.ts @@ -7,7 +7,7 @@ import {DynamicComponentLoader, ComponentRef, ElementRef} from 'angular2/core'; import {Injector, bind, Dependency, undefinedValue} from 'angular2/di'; import * as routerMod from './router'; -import {Instruction, RouteParams} from './instruction'; +import {Instruction, ComponentInstruction, RouteParams} from './instruction'; import * as hookMod from './lifecycle_annotations'; import {hasLifecycleHook} from './route_lifecycle_reflector'; @@ -23,16 +23,16 @@ import {hasLifecycleHook} from './route_lifecycle_reflector'; @Directive({selector: 'router-outlet'}) export class RouterOutlet { childRouter: routerMod.Router = null; + name: string = null; private _componentRef: ComponentRef = null; - private _currentInstruction: Instruction = null; + private _currentInstruction: ComponentInstruction = null; constructor(private _elementRef: ElementRef, private _loader: DynamicComponentLoader, private _parentRouter: routerMod.Router, @Attribute('name') nameAttr: string) { - // TODO: reintroduce with new // sibling routes - // if (isBlank(nameAttr)) { - // nameAttr = 'default'; - //} + if (isPresent(nameAttr)) { + this.name = nameAttr; + } this._parentRouter.registerOutlet(this); } @@ -40,15 +40,28 @@ export class RouterOutlet { * Given an instruction, update the contents of this outlet. */ commit(instruction: Instruction): Promise { + instruction = this._getInstruction(instruction); + var componentInstruction = instruction.component; + if (isBlank(componentInstruction)) { + return PromiseWrapper.resolve(true); + } var next; - if (instruction.reuse) { - next = this._reuse(instruction); + if (componentInstruction.reuse) { + next = this._reuse(componentInstruction); } else { - next = this.deactivate(instruction).then((_) => this._activate(instruction)); + next = this.deactivate(instruction).then((_) => this._activate(componentInstruction)); } return next.then((_) => this._commitChild(instruction)); } + private _getInstruction(instruction: Instruction): Instruction { + if (isPresent(this.name)) { + return instruction.auxInstruction[this.name]; + } else { + return instruction; + } + } + private _commitChild(instruction: Instruction): Promise { if (isPresent(this.childRouter)) { return this.childRouter.commit(instruction.child); @@ -57,20 +70,21 @@ export class RouterOutlet { } } - private _activate(instruction: Instruction): Promise { + private _activate(instruction: ComponentInstruction): Promise { var previousInstruction = this._currentInstruction; this._currentInstruction = instruction; - this.childRouter = this._parentRouter.childRouter(instruction.component); + var componentType = instruction.componentType; + this.childRouter = this._parentRouter.childRouter(componentType); var bindings = Injector.resolve([ bind(RouteParams) - .toValue(new RouteParams(instruction.params())), + .toValue(new RouteParams(instruction.params)), bind(routerMod.Router).toValue(this.childRouter) ]); - return this._loader.loadNextToLocation(instruction.component, this._elementRef, bindings) + return this._loader.loadNextToLocation(componentType, this._elementRef, bindings) .then((componentRef) => { this._componentRef = componentRef; - if (hasLifecycleHook(hookMod.onActivate, instruction.component)) { + if (hasLifecycleHook(hookMod.onActivate, componentType)) { return this._componentRef.instance.onActivate(instruction, previousInstruction); } }); @@ -84,9 +98,11 @@ export class RouterOutlet { if (isBlank(this._currentInstruction)) { return PromiseWrapper.resolve(true); } - if (hasLifecycleHook(hookMod.canDeactivate, this._currentInstruction.component)) { - return PromiseWrapper.resolve( - this._componentRef.instance.canDeactivate(nextInstruction, this._currentInstruction)); + var outletInstruction = this._getInstruction(nextInstruction); + if (hasLifecycleHook(hookMod.canDeactivate, this._currentInstruction.componentType)) { + return PromiseWrapper.resolve(this._componentRef.instance.canDeactivate( + isPresent(outletInstruction) ? outletInstruction.component : null, + this._currentInstruction)); } return PromiseWrapper.resolve(true); } @@ -97,24 +113,34 @@ export class RouterOutlet { */ canReuse(nextInstruction: Instruction): Promise { var result; + + var outletInstruction = this._getInstruction(nextInstruction); + var componentInstruction = outletInstruction.component; + if (isBlank(this._currentInstruction) || - this._currentInstruction.component != nextInstruction.component) { + this._currentInstruction.componentType != componentInstruction.componentType) { result = false; - } else if (hasLifecycleHook(hookMod.canReuse, this._currentInstruction.component)) { - result = this._componentRef.instance.canReuse(nextInstruction, this._currentInstruction); + } else if (hasLifecycleHook(hookMod.canReuse, this._currentInstruction.componentType)) { + result = this._componentRef.instance.canReuse(componentInstruction, this._currentInstruction); } else { - result = nextInstruction == this._currentInstruction || - StringMapWrapper.equals(nextInstruction.params(), this._currentInstruction.params()); + result = + componentInstruction == this._currentInstruction || + (isPresent(componentInstruction.params) && isPresent(this._currentInstruction.params) && + StringMapWrapper.equals(componentInstruction.params, this._currentInstruction.params)); } - return PromiseWrapper.resolve(result); + return PromiseWrapper.resolve(result).then((result) => { + // TODO: this is a hack + componentInstruction.reuse = result; + return result; + }); } - private _reuse(instruction): Promise { + private _reuse(instruction: ComponentInstruction): Promise { var previousInstruction = this._currentInstruction; this._currentInstruction = instruction; return PromiseWrapper.resolve( - hasLifecycleHook(hookMod.onReuse, this._currentInstruction.component) ? + hasLifecycleHook(hookMod.onReuse, this._currentInstruction.componentType) ? this._componentRef.instance.onReuse(instruction, previousInstruction) : true); } @@ -122,14 +148,16 @@ export class RouterOutlet { deactivate(nextInstruction: Instruction): Promise { + var outletInstruction = this._getInstruction(nextInstruction); + var componentInstruction = isPresent(outletInstruction) ? outletInstruction.component : null; return (isPresent(this.childRouter) ? - this.childRouter.deactivate(isPresent(nextInstruction) ? nextInstruction.child : - null) : + this.childRouter.deactivate(isPresent(outletInstruction) ? outletInstruction.child : + null) : PromiseWrapper.resolve(true)) .then((_) => { if (isPresent(this._componentRef) && isPresent(this._currentInstruction) && - hasLifecycleHook(hookMod.onDeactivate, this._currentInstruction.component)) { - return this._componentRef.instance.onDeactivate(nextInstruction, + hasLifecycleHook(hookMod.onDeactivate, this._currentInstruction.componentType)) { + return this._componentRef.instance.onDeactivate(componentInstruction, this._currentInstruction); } }) diff --git a/modules/angular2/src/router/url.ts b/modules/angular2/src/router/url.ts deleted file mode 100644 index 90136eed2e..0000000000 --- a/modules/angular2/src/router/url.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {RegExpWrapper, StringWrapper} from 'angular2/src/facade/lang'; - -var specialCharacters = ['/', '.', '*', '+', '?', '|', '(', ')', '[', ']', '{', '}', '\\']; - -var escapeRe = RegExpWrapper.create('(\\' + specialCharacters.join('|\\') + ')', 'g'); - -export function escapeRegex(string: string): string { - return StringWrapper.replaceAllMapped(string, escapeRe, (match) => { return "\\" + match; }); -} diff --git a/modules/angular2/src/router/url_parser.ts b/modules/angular2/src/router/url_parser.ts new file mode 100644 index 0000000000..1e9e15c58d --- /dev/null +++ b/modules/angular2/src/router/url_parser.ts @@ -0,0 +1,210 @@ +import {List, StringMap, StringMapWrapper} from 'angular2/src/facade/collection'; +import { + isPresent, + isBlank, + BaseException, + RegExpWrapper, + CONST_EXPR +} from 'angular2/src/facade/lang'; + + +export class Url { + constructor(public path: string, public child: Url = null, + public auxiliary: List = CONST_EXPR([]), + public params: StringMap = null) {} + + toString(): string { + return this.path + this._matrixParamsToString() + this._auxToString() + this._childString(); + } + + segmentToString(): string { return this.path + this._matrixParamsToString(); } + + _auxToString(): string { + return this.auxiliary.length > 0 ? + ('(' + this.auxiliary.map(sibling => sibling.toString()).join('//') + ')') : + ''; + } + + private _matrixParamsToString(): string { + if (isBlank(this.params)) { + return ''; + } + + return ';' + serializeParams(this.params).join(';'); + } + + _childString(): string { return isPresent(this.child) ? ('/' + this.child.toString()) : ''; } +} + +export class RootUrl extends Url { + constructor(path: string, child: Url = null, auxiliary: List = CONST_EXPR([]), + params: StringMap = null) { + super(path, child, auxiliary, params); + } + + toString(): string { + return this.path + this._auxToString() + this._childString() + this._queryParamsToString(); + } + + segmentToString(): string { return this.path + this._queryParamsToString(); } + + private _queryParamsToString(): string { + if (isBlank(this.params)) { + return ''; + } + + return '?' + serializeParams(this.params).join('&'); + } +} + +var SEGMENT_RE = RegExpWrapper.create('^[^\\/\\(\\)\\?;=&]+'); +function matchUrlSegment(str: string): string { + var match = RegExpWrapper.firstMatch(SEGMENT_RE, str); + return isPresent(match) ? match[0] : null; +} + +export class UrlParser { + private remaining: string; + + peekStartsWith(str: string): boolean { return this.remaining.startsWith(str); } + + capture(str: string): void { + if (!this.remaining.startsWith(str)) { + throw new BaseException(`Expected "${str}".`); + } + this.remaining = this.remaining.substring(str.length); + } + + parse(url: string): Url { + this.remaining = url; + if (url == '' || url == '/') { + return new Url(''); + } + return this.parseRoot(); + } + + // segment + (aux segments) + (query params) + parseRoot(): Url { + if (this.peekStartsWith('/')) { + this.capture('/'); + } + var path = matchUrlSegment(this.remaining); + this.capture(path); + + var aux = []; + if (this.peekStartsWith('(')) { + aux = this.parseAuxiliaryRoutes(); + } + if (this.peekStartsWith(';')) { + // TODO: should these params just be dropped? + this.parseMatrixParams(); + } + var child = null; + if (this.peekStartsWith('/') && !this.peekStartsWith('//')) { + this.capture('/'); + child = this.parseSegment(); + } + var queryParams = null; + if (this.peekStartsWith('?')) { + queryParams = this.parseQueryParams(); + } + return new RootUrl(path, child, aux, queryParams); + } + + // segment + (matrix params) + (aux segments) + parseSegment(): Url { + if (this.remaining.length == 0) { + return null; + } + if (this.peekStartsWith('/')) { + this.capture('/'); + } + var path = matchUrlSegment(this.remaining); + this.capture(path); + + var matrixParams = null; + if (this.peekStartsWith(';')) { + matrixParams = this.parseMatrixParams(); + } + var aux = []; + if (this.peekStartsWith('(')) { + aux = this.parseAuxiliaryRoutes(); + } + var child = null; + if (this.peekStartsWith('/') && !this.peekStartsWith('//')) { + this.capture('/'); + child = this.parseSegment(); + } + return new Url(path, child, aux, matrixParams); + } + + parseQueryParams(): StringMap { + var params = {}; + this.capture('?'); + this.parseParam(params); + while (this.remaining.length > 0 && this.peekStartsWith('&')) { + this.capture('&'); + this.parseParam(params); + } + return params; + } + + parseMatrixParams(): StringMap { + var params = {}; + while (this.remaining.length > 0 && this.peekStartsWith(';')) { + this.capture(';'); + this.parseParam(params); + } + return params; + } + + parseParam(params: StringMap): void { + var key = matchUrlSegment(this.remaining); + if (isBlank(key)) { + return; + } + this.capture(key); + var value: any = true; + if (this.peekStartsWith('=')) { + this.capture('='); + var valueMatch = matchUrlSegment(this.remaining); + if (isPresent(valueMatch)) { + value = valueMatch; + this.capture(value); + } + } + + params[key] = value; + } + + parseAuxiliaryRoutes(): List { + var routes = []; + this.capture('('); + + while (!this.peekStartsWith(')') && this.remaining.length > 0) { + routes.push(this.parseSegment()); + if (this.peekStartsWith('//')) { + this.capture('//'); + } + } + this.capture(')'); + + return routes; + } +} + +export var parser = new UrlParser(); + +export function serializeParams(paramMap: StringMap): List { + var params = []; + if (isPresent(paramMap)) { + StringMapWrapper.forEach(paramMap, (value, key) => { + if (value == true) { + params.push(key); + } else { + params.push(key + '=' + value); + } + }); + } + return params; +} diff --git a/modules/angular2/test/router/outlet_spec.ts b/modules/angular2/test/router/outlet_spec.ts index 047ce300c8..7accf788e3 100644 --- a/modules/angular2/test/router/outlet_spec.ts +++ b/modules/angular2/test/router/outlet_spec.ts @@ -30,7 +30,13 @@ import { import {RootRouter} from 'angular2/src/router/router'; import {Pipeline} from 'angular2/src/router/pipeline'; import {Router, RouterOutlet, RouterLink, RouteParams} from 'angular2/router'; -import {RouteConfig, Route, AsyncRoute, Redirect} from 'angular2/src/router/route_config_decorator'; +import { + RouteConfig, + Route, + AuxRoute, + AsyncRoute, + Redirect +} from 'angular2/src/router/route_config_decorator'; import {DOM} from 'angular2/src/dom/dom_adapter'; @@ -45,10 +51,12 @@ import { CanReuse } from 'angular2/src/router/interfaces'; import {CanActivate} from 'angular2/src/router/lifecycle_annotations'; -import {Instruction} from 'angular2/src/router/instruction'; +import {ComponentInstruction} from 'angular2/src/router/instruction'; import {DirectiveResolver} from 'angular2/src/core/compiler/directive_resolver'; -var cmpInstanceCount, log, eventBus; +var cmpInstanceCount; +var log: List; +var eventBus: EventEmitter; var completer: PromiseCompleter; export function main() { @@ -73,7 +81,7 @@ export function main() { rtr = router; location = loc; cmpInstanceCount = 0; - log = ''; + log = []; eventBus = new EventEmitter(); })); @@ -207,7 +215,6 @@ export function main() { }); })); - it('should generate link hrefs from a child to its sibling', inject([AsyncTestCompleter], (async) => { compile() @@ -247,281 +254,299 @@ export function main() { }); })); - it('should call the onActivate hook', inject([AsyncTestCompleter], (async) => { - compile() - .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) - .then((_) => rtr.navigate('/on-activate')) - .then((_) => { - rootTC.detectChanges(); - expect(rootTC.nativeElement).toHaveText('activate cmp'); - expect(log).toEqual('activate: null -> /on-activate;'); - async.done(); - }); - })); - it('should wait for a parent component\'s onActivate hook to resolve before calling its child\'s', - inject([AsyncTestCompleter], (async) => { - compile() - .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) - .then((_) => { - ObservableWrapper.subscribe(eventBus, (ev) => { - if (ev.startsWith('parent activate')) { - completer.resolve(true); - } + describe('lifecycle hooks', () => { + it('should call the onActivate hook', inject([AsyncTestCompleter], (async) => { + compile() + .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) + .then((_) => rtr.navigate('/on-activate')) + .then((_) => { + rootTC.detectChanges(); + expect(rootTC.nativeElement).toHaveText('activate cmp'); + expect(log).toEqual(['activate: null -> /on-activate']); + async.done(); }); - rtr.navigate('/parent-activate/child-activate') - .then((_) => { + })); + + it('should wait for a parent component\'s onActivate hook to resolve before calling its child\'s', + inject([AsyncTestCompleter], (async) => { + compile() + .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) + .then((_) => { + ObservableWrapper.subscribe(eventBus, (ev) => { + if (ev.startsWith('parent activate')) { + completer.resolve(true); + } + }); + rtr.navigate('/parent-activate/child-activate') + .then((_) => { + rootTC.detectChanges(); + expect(rootTC.nativeElement).toHaveText('parent {activate cmp}'); + expect(log).toEqual([ + 'parent activate: null -> /parent-activate', + 'activate: null -> /child-activate' + ]); + async.done(); + }); + }); + })); + + it('should call the onDeactivate hook', inject([AsyncTestCompleter], (async) => { + compile() + .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) + .then((_) => rtr.navigate('/on-deactivate')) + .then((_) => rtr.navigate('/a')) + .then((_) => { + rootTC.detectChanges(); + expect(rootTC.nativeElement).toHaveText('A'); + expect(log).toEqual(['deactivate: /on-deactivate -> /a']); + async.done(); + }); + })); + + it('should wait for a child component\'s onDeactivate hook to resolve before calling its parent\'s', + inject([AsyncTestCompleter], (async) => { + compile() + .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) + .then((_) => rtr.navigate('/parent-deactivate/child-deactivate')) + .then((_) => { + ObservableWrapper.subscribe(eventBus, (ev) => { + if (ev.startsWith('deactivate')) { + completer.resolve(true); rootTC.detectChanges(); - expect(rootTC.nativeElement).toHaveText('parent {activate cmp}'); - expect(log).toEqual( - 'parent activate: null -> /parent-activate/child-activate;activate: null -> /child-activate;'); - async.done(); - }); - }); - })); - - it('should call the onDeactivate hook', inject([AsyncTestCompleter], (async) => { - compile() - .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) - .then((_) => rtr.navigate('/on-deactivate')) - .then((_) => rtr.navigate('/a')) - .then((_) => { - rootTC.detectChanges(); - expect(rootTC.nativeElement).toHaveText('A'); - expect(log).toEqual('deactivate: /on-deactivate -> /a;'); - async.done(); - }); - })); - - it('should wait for a child component\'s onDeactivate hook to resolve before calling its parent\'s', - inject([AsyncTestCompleter], (async) => { - compile() - .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) - .then((_) => rtr.navigate('/parent-deactivate/child-deactivate')) - .then((_) => { - ObservableWrapper.subscribe(eventBus, (ev) => { - if (ev.startsWith('deactivate')) { - completer.resolve(true); + expect(rootTC.nativeElement).toHaveText('parent {deactivate cmp}'); + } + }); + rtr.navigate('/a').then((_) => { rootTC.detectChanges(); - expect(rootTC.nativeElement).toHaveText('parent {deactivate cmp}'); - } + expect(rootTC.nativeElement).toHaveText('A'); + expect(log).toEqual([ + 'deactivate: /child-deactivate -> null', + 'parent deactivate: /parent-deactivate -> /a' + ]); + async.done(); + }); }); - rtr.navigate('/a').then((_) => { + })); + + it('should reuse a component when the canReuse hook returns true', + inject([AsyncTestCompleter], (async) => { + compile() + .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) + .then((_) => rtr.navigate('/on-reuse/1/a')) + .then((_) => { rootTC.detectChanges(); - expect(rootTC.nativeElement).toHaveText('A'); - expect(log).toEqual( - 'deactivate: /child-deactivate -> null;parent deactivate: /parent-deactivate/child-deactivate -> /a;'); + expect(log).toEqual([]); + expect(rootTC.nativeElement).toHaveText('reuse {A}'); + expect(cmpInstanceCount).toBe(1); + }) + .then((_) => rtr.navigate('/on-reuse/2/b')) + .then((_) => { + rootTC.detectChanges(); + expect(log).toEqual(['reuse: /on-reuse/1 -> /on-reuse/2']); + expect(rootTC.nativeElement).toHaveText('reuse {B}'); + expect(cmpInstanceCount).toBe(1); async.done(); }); - }); - })); - - it('should reuse a component when the canReuse hook returns false', - inject([AsyncTestCompleter], (async) => { - compile() - .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) - .then((_) => rtr.navigate('/on-reuse/1/a')) - .then((_) => { - rootTC.detectChanges(); - expect(log).toEqual(''); - expect(rootTC.nativeElement).toHaveText('reuse {A}'); - expect(cmpInstanceCount).toBe(1); - }) - .then((_) => rtr.navigate('/on-reuse/2/b')) - .then((_) => { - rootTC.detectChanges(); - expect(log).toEqual('reuse: /on-reuse/1/a -> /on-reuse/2/b;'); - expect(rootTC.nativeElement).toHaveText('reuse {B}'); - expect(cmpInstanceCount).toBe(1); - async.done(); - }); - })); + })); - it('should not reuse a component when the canReuse hook returns false', - inject([AsyncTestCompleter], (async) => { - compile() - .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) - .then((_) => rtr.navigate('/never-reuse/1/a')) - .then((_) => { - rootTC.detectChanges(); - expect(log).toEqual(''); - expect(rootTC.nativeElement).toHaveText('reuse {A}'); - expect(cmpInstanceCount).toBe(1); - }) - .then((_) => rtr.navigate('/never-reuse/2/b')) - .then((_) => { - rootTC.detectChanges(); - expect(log).toEqual(''); - expect(rootTC.nativeElement).toHaveText('reuse {B}'); - expect(cmpInstanceCount).toBe(2); - async.done(); - }); - })); - - it('should navigate when canActivate returns true', inject([AsyncTestCompleter], (async) => { - compile() - .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) - .then((_) => { - ObservableWrapper.subscribe(eventBus, (ev) => { - if (ev.startsWith('canActivate')) { - completer.resolve(true); - } - }); - rtr.navigate('/can-activate/a') - .then((_) => { - rootTC.detectChanges(); - expect(rootTC.nativeElement).toHaveText('canActivate {A}'); - expect(log).toEqual('canActivate: null -> /can-activate/a;'); - async.done(); - }); - }); - })); - - it('should not navigate when canActivate returns false', - inject([AsyncTestCompleter], (async) => { - compile() - .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) - .then((_) => { - ObservableWrapper.subscribe(eventBus, (ev) => { - if (ev.startsWith('canActivate')) { - completer.resolve(false); - } - }); - rtr.navigate('/can-activate/a') - .then((_) => { - rootTC.detectChanges(); - expect(rootTC.nativeElement).toHaveText(''); - expect(log).toEqual('canActivate: null -> /can-activate/a;'); - async.done(); - }); - }); - })); - - it('should navigate away when canDeactivate returns true', - inject([AsyncTestCompleter], (async) => { - compile() - .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) - .then((_) => rtr.navigate('/can-deactivate/a')) - .then((_) => { - rootTC.detectChanges(); - expect(rootTC.nativeElement).toHaveText('canDeactivate {A}'); - expect(log).toEqual(''); - - ObservableWrapper.subscribe(eventBus, (ev) => { - if (ev.startsWith('canDeactivate')) { - completer.resolve(true); - } - }); - - rtr.navigate('/a').then((_) => { + it('should not reuse a component when the canReuse hook returns false', + inject([AsyncTestCompleter], (async) => { + compile() + .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) + .then((_) => rtr.navigate('/never-reuse/1/a')) + .then((_) => { rootTC.detectChanges(); - expect(rootTC.nativeElement).toHaveText('A'); - expect(log).toEqual('canDeactivate: /can-deactivate/a -> /a;'); + expect(log).toEqual([]); + expect(rootTC.nativeElement).toHaveText('reuse {A}'); + expect(cmpInstanceCount).toBe(1); + }) + .then((_) => rtr.navigate('/never-reuse/2/b')) + .then((_) => { + rootTC.detectChanges(); + expect(log).toEqual([]); + expect(rootTC.nativeElement).toHaveText('reuse {B}'); + expect(cmpInstanceCount).toBe(2); async.done(); }); - }); - })); + })); - it('should not navigate away when canDeactivate returns false', - inject([AsyncTestCompleter], (async) => { - compile() - .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) - .then((_) => rtr.navigate('/can-deactivate/a')) - .then((_) => { - rootTC.detectChanges(); - expect(rootTC.nativeElement).toHaveText('canDeactivate {A}'); - expect(log).toEqual(''); - ObservableWrapper.subscribe(eventBus, (ev) => { - if (ev.startsWith('canDeactivate')) { - completer.resolve(false); - } + it('should navigate when canActivate returns true', inject([AsyncTestCompleter], (async) => { + compile() + .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) + .then((_) => { + ObservableWrapper.subscribe(eventBus, (ev) => { + if (ev.startsWith('canActivate')) { + completer.resolve(true); + } + }); + rtr.navigate('/can-activate/a') + .then((_) => { + rootTC.detectChanges(); + expect(rootTC.nativeElement).toHaveText('canActivate {A}'); + expect(log).toEqual(['canActivate: null -> /can-activate']); + async.done(); + }); }); + })); - rtr.navigate('/a').then((_) => { + it('should not navigate when canActivate returns false', + inject([AsyncTestCompleter], (async) => { + compile() + .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) + .then((_) => { + ObservableWrapper.subscribe(eventBus, (ev) => { + if (ev.startsWith('canActivate')) { + completer.resolve(false); + } + }); + rtr.navigate('/can-activate/a') + .then((_) => { + rootTC.detectChanges(); + expect(rootTC.nativeElement).toHaveText(''); + expect(log).toEqual(['canActivate: null -> /can-activate']); + async.done(); + }); + }); + })); + + it('should navigate away when canDeactivate returns true', + inject([AsyncTestCompleter], (async) => { + compile() + .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) + .then((_) => rtr.navigate('/can-deactivate/a')) + .then((_) => { rootTC.detectChanges(); expect(rootTC.nativeElement).toHaveText('canDeactivate {A}'); - expect(log).toEqual('canDeactivate: /can-deactivate/a -> /a;'); + expect(log).toEqual([]); + + ObservableWrapper.subscribe(eventBus, (ev) => { + if (ev.startsWith('canDeactivate')) { + completer.resolve(true); + } + }); + + rtr.navigate('/a').then((_) => { + rootTC.detectChanges(); + expect(rootTC.nativeElement).toHaveText('A'); + expect(log).toEqual(['canDeactivate: /can-deactivate -> /a']); + async.done(); + }); + }); + })); + + it('should not navigate away when canDeactivate returns false', + inject([AsyncTestCompleter], (async) => { + compile() + .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) + .then((_) => rtr.navigate('/can-deactivate/a')) + .then((_) => { + rootTC.detectChanges(); + expect(rootTC.nativeElement).toHaveText('canDeactivate {A}'); + expect(log).toEqual([]); + + ObservableWrapper.subscribe(eventBus, (ev) => { + if (ev.startsWith('canDeactivate')) { + completer.resolve(false); + } + }); + + rtr.navigate('/a').then((_) => { + rootTC.detectChanges(); + expect(rootTC.nativeElement).toHaveText('canDeactivate {A}'); + expect(log).toEqual(['canDeactivate: /can-deactivate -> /a']); + async.done(); + }); + }); + })); + + + it('should run activation and deactivation hooks in the correct order', + inject([AsyncTestCompleter], (async) => { + compile() + .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) + .then((_) => rtr.navigate('/activation-hooks/child')) + .then((_) => { + expect(log).toEqual([ + 'canActivate child: null -> /child', + 'canActivate parent: null -> /activation-hooks', + 'onActivate parent: null -> /activation-hooks', + 'onActivate child: null -> /child' + ]); + + log = []; + return rtr.navigate('/a'); + }) + .then((_) => { + expect(log).toEqual([ + 'canDeactivate parent: /activation-hooks -> /a', + 'canDeactivate child: /child -> null', + 'onDeactivate child: /child -> null', + 'onDeactivate parent: /activation-hooks -> /a' + ]); async.done(); }); - }); - })); + })); + + it('should only run reuse hooks when reusing', inject([AsyncTestCompleter], (async) => { + compile() + .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) + .then((_) => rtr.navigate('/reuse-hooks/1')) + .then((_) => { + expect(log).toEqual( + ['canActivate: null -> /reuse-hooks/1', 'onActivate: null -> /reuse-hooks/1']); + + ObservableWrapper.subscribe(eventBus, (ev) => { + if (ev.startsWith('canReuse')) { + completer.resolve(true); + } + }); - it('should run activation and deactivation hooks in the correct order', - inject([AsyncTestCompleter], (async) => { - compile() - .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) - .then((_) => rtr.navigate('/activation-hooks/child')) - .then((_) => { - expect(log).toEqual('canActivate child: null -> /child;' + - 'canActivate parent: null -> /activation-hooks/child;' + - 'onActivate parent: null -> /activation-hooks/child;' + - 'onActivate child: null -> /child;'); - - log = ''; - return rtr.navigate('/a'); - }) - .then((_) => { - expect(log).toEqual('canDeactivate parent: /activation-hooks/child -> /a;' + - 'canDeactivate child: /child -> null;' + - 'onDeactivate child: /child -> null;' + - 'onDeactivate parent: /activation-hooks/child -> /a;'); - async.done(); - }); - })); - - it('should only run reuse hooks when reusing', inject([AsyncTestCompleter], (async) => { - compile() - .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) - .then((_) => rtr.navigate('/reuse-hooks/1')) - .then((_) => { - expect(log).toEqual('canActivate: null -> /reuse-hooks/1;' + - 'onActivate: null -> /reuse-hooks/1;'); - - ObservableWrapper.subscribe(eventBus, (ev) => { - if (ev.startsWith('canReuse')) { - completer.resolve(true); - } + log = []; + return rtr.navigate('/reuse-hooks/2'); + }) + .then((_) => { + expect(log).toEqual([ + 'canReuse: /reuse-hooks/1 -> /reuse-hooks/2', + 'onReuse: /reuse-hooks/1 -> /reuse-hooks/2' + ]); + async.done(); }); + })); - log = ''; - return rtr.navigate('/reuse-hooks/2'); - }) - .then((_) => { - expect(log).toEqual('canReuse: /reuse-hooks/1 -> /reuse-hooks/2;' + - 'onReuse: /reuse-hooks/1 -> /reuse-hooks/2;'); - async.done(); - }); - })); + it('should not run reuse hooks when not reusing', inject([AsyncTestCompleter], (async) => { + compile() + .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) + .then((_) => rtr.navigate('/reuse-hooks/1')) + .then((_) => { + expect(log).toEqual( + ['canActivate: null -> /reuse-hooks/1', 'onActivate: null -> /reuse-hooks/1']); - it('should not run reuse hooks when not reusing', inject([AsyncTestCompleter], (async) => { - compile() - .then((_) => rtr.config([new Route({path: '/...', component: LifecycleCmp})])) - .then((_) => rtr.navigate('/reuse-hooks/1')) - .then((_) => { - expect(log).toEqual('canActivate: null -> /reuse-hooks/1;' + - 'onActivate: null -> /reuse-hooks/1;'); + ObservableWrapper.subscribe(eventBus, (ev) => { + if (ev.startsWith('canReuse')) { + completer.resolve(false); + } + }); - ObservableWrapper.subscribe(eventBus, (ev) => { - if (ev.startsWith('canReuse')) { - completer.resolve(false); - } + log = []; + return rtr.navigate('/reuse-hooks/2'); + }) + .then((_) => { + expect(log).toEqual([ + 'canReuse: /reuse-hooks/1 -> /reuse-hooks/2', + 'canActivate: /reuse-hooks/1 -> /reuse-hooks/2', + 'canDeactivate: /reuse-hooks/1 -> /reuse-hooks/2', + 'onDeactivate: /reuse-hooks/1 -> /reuse-hooks/2', + 'onActivate: /reuse-hooks/1 -> /reuse-hooks/2' + ]); + async.done(); }); + })); - log = ''; - return rtr.navigate('/reuse-hooks/2'); - }) - .then((_) => { - expect(log).toEqual('canReuse: /reuse-hooks/1 -> /reuse-hooks/2;' + - 'canActivate: /reuse-hooks/1 -> /reuse-hooks/2;' + - 'canDeactivate: /reuse-hooks/1 -> /reuse-hooks/2;' + - 'onDeactivate: /reuse-hooks/1 -> /reuse-hooks/2;' + - 'onActivate: /reuse-hooks/1 -> /reuse-hooks/2;'); - async.done(); - }); - })); + }); describe('when clicked', () => { @@ -572,6 +597,19 @@ export function main() { }); })); }); + + describe('auxillary routes', () => { + it('should recognize a simple case', inject([AsyncTestCompleter], (async) => { + compile() + .then((_) => rtr.config([new Route({path: '/...', component: AuxCmp})])) + .then((_) => rtr.navigate('/hello(modal)')) + .then((_) => { + rootTC.detectChanges(); + expect(rootTC.nativeElement).toHaveText('main {hello} | aux {modal}'); + async.done(); + }); + })); + }); }); } @@ -657,24 +695,26 @@ class MyComp { name; } -function logHook(name: string, next: Instruction, prev: Instruction) { - var message = name + ': ' + (isPresent(prev) ? prev.accumulatedUrl : 'null') + ' -> ' + - (isPresent(next) ? next.accumulatedUrl : 'null') + ';'; - log += message; +function logHook(name: string, next: ComponentInstruction, prev: ComponentInstruction) { + var message = name + ': ' + (isPresent(prev) ? ('/' + prev.urlPath) : 'null') + ' -> ' + + (isPresent(next) ? ('/' + next.urlPath) : 'null'); + log.push(message); ObservableWrapper.callNext(eventBus, message); } @Component({selector: 'activate-cmp'}) @View({template: 'activate cmp'}) class ActivateCmp implements OnActivate { - onActivate(next: Instruction, prev: Instruction) { logHook('activate', next, prev); } + onActivate(next: ComponentInstruction, prev: ComponentInstruction) { + logHook('activate', next, prev); + } } @Component({selector: 'parent-activate-cmp'}) @View({template: `parent {}`, directives: [RouterOutlet]}) @RouteConfig([new Route({path: '/child-activate', component: ActivateCmp})]) class ParentActivateCmp implements OnActivate { - onActivate(next: Instruction, prev: Instruction): Promise { + onActivate(next: ComponentInstruction, prev: ComponentInstruction): Promise { completer = PromiseWrapper.completer(); logHook('parent activate', next, prev); return completer.promise; @@ -684,13 +724,15 @@ class ParentActivateCmp implements OnActivate { @Component({selector: 'deactivate-cmp'}) @View({template: 'deactivate cmp'}) class DeactivateCmp implements OnDeactivate { - onDeactivate(next: Instruction, prev: Instruction) { logHook('deactivate', next, prev); } + onDeactivate(next: ComponentInstruction, prev: ComponentInstruction) { + logHook('deactivate', next, prev); + } } @Component({selector: 'deactivate-cmp'}) @View({template: 'deactivate cmp'}) class WaitDeactivateCmp implements OnDeactivate { - onDeactivate(next: Instruction, prev: Instruction): Promise { + onDeactivate(next: ComponentInstruction, prev: ComponentInstruction): Promise { completer = PromiseWrapper.completer(); logHook('deactivate', next, prev); return completer.promise; @@ -701,7 +743,9 @@ class WaitDeactivateCmp implements OnDeactivate { @View({template: `parent {}`, directives: [RouterOutlet]}) @RouteConfig([new Route({path: '/child-deactivate', component: WaitDeactivateCmp})]) class ParentDeactivateCmp implements OnDeactivate { - onDeactivate(next: Instruction, prev: Instruction) { logHook('parent deactivate', next, prev); } + onDeactivate(next: ComponentInstruction, prev: ComponentInstruction) { + logHook('parent deactivate', next, prev); + } } @Component({selector: 'reuse-cmp'}) @@ -709,8 +753,8 @@ class ParentDeactivateCmp implements OnDeactivate { @RouteConfig([new Route({path: '/a', component: A}), new Route({path: '/b', component: B})]) class ReuseCmp implements OnReuse, CanReuse { constructor() { cmpInstanceCount += 1; } - canReuse(next: Instruction, prev: Instruction) { return true; } - onReuse(next: Instruction, prev: Instruction) { logHook('reuse', next, prev); } + canReuse(next: ComponentInstruction, prev: ComponentInstruction) { return true; } + onReuse(next: ComponentInstruction, prev: ComponentInstruction) { logHook('reuse', next, prev); } } @Component({selector: 'never-reuse-cmp'}) @@ -718,8 +762,8 @@ class ReuseCmp implements OnReuse, CanReuse { @RouteConfig([new Route({path: '/a', component: A}), new Route({path: '/b', component: B})]) class NeverReuseCmp implements OnReuse, CanReuse { constructor() { cmpInstanceCount += 1; } - canReuse(next: Instruction, prev: Instruction) { return false; } - onReuse(next: Instruction, prev: Instruction) { logHook('reuse', next, prev); } + canReuse(next: ComponentInstruction, prev: ComponentInstruction) { return false; } + onReuse(next: ComponentInstruction, prev: ComponentInstruction) { logHook('reuse', next, prev); } } @Component({selector: 'can-activate-cmp'}) @@ -727,7 +771,7 @@ class NeverReuseCmp implements OnReuse, CanReuse { @RouteConfig([new Route({path: '/a', component: A}), new Route({path: '/b', component: B})]) @CanActivate(CanActivateCmp.canActivate) class CanActivateCmp { - static canActivate(next: Instruction, prev: Instruction) { + static canActivate(next: ComponentInstruction, prev: ComponentInstruction): Promise { completer = PromiseWrapper.completer(); logHook('canActivate', next, prev); return completer.promise; @@ -738,7 +782,7 @@ class CanActivateCmp { @View({template: `canDeactivate {}`, directives: [RouterOutlet]}) @RouteConfig([new Route({path: '/a', component: A}), new Route({path: '/b', component: B})]) class CanDeactivateCmp implements CanDeactivate { - canDeactivate(next: Instruction, prev: Instruction) { + canDeactivate(next: ComponentInstruction, prev: ComponentInstruction): Promise { completer = PromiseWrapper.completer(); logHook('canDeactivate', next, prev); return completer.promise; @@ -749,19 +793,23 @@ class CanDeactivateCmp implements CanDeactivate { @View({template: `child`}) @CanActivate(AllHooksChildCmp.canActivate) class AllHooksChildCmp implements CanDeactivate, OnDeactivate, OnActivate { - canDeactivate(next: Instruction, prev: Instruction) { + canDeactivate(next: ComponentInstruction, prev: ComponentInstruction) { logHook('canDeactivate child', next, prev); return true; } - onDeactivate(next: Instruction, prev: Instruction) { logHook('onDeactivate child', next, prev); } + onDeactivate(next: ComponentInstruction, prev: ComponentInstruction) { + logHook('onDeactivate child', next, prev); + } - static canActivate(next: Instruction, prev: Instruction) { + static canActivate(next: ComponentInstruction, prev: ComponentInstruction) { logHook('canActivate child', next, prev); return true; } - onActivate(next: Instruction, prev: Instruction) { logHook('onActivate child', next, prev); } + onActivate(next: ComponentInstruction, prev: ComponentInstruction) { + logHook('onActivate child', next, prev); + } } @Component({selector: 'all-hooks-parent-cmp'}) @@ -769,46 +817,56 @@ class AllHooksChildCmp implements CanDeactivate, OnDeactivate, OnActivate { @RouteConfig([new Route({path: '/child', component: AllHooksChildCmp})]) @CanActivate(AllHooksParentCmp.canActivate) class AllHooksParentCmp implements CanDeactivate, OnDeactivate, OnActivate { - canDeactivate(next: Instruction, prev: Instruction) { + canDeactivate(next: ComponentInstruction, prev: ComponentInstruction) { logHook('canDeactivate parent', next, prev); return true; } - onDeactivate(next: Instruction, prev: Instruction) { logHook('onDeactivate parent', next, prev); } + onDeactivate(next: ComponentInstruction, prev: ComponentInstruction) { + logHook('onDeactivate parent', next, prev); + } - static canActivate(next: Instruction, prev: Instruction) { + static canActivate(next: ComponentInstruction, prev: ComponentInstruction) { logHook('canActivate parent', next, prev); return true; } - onActivate(next: Instruction, prev: Instruction) { logHook('onActivate parent', next, prev); } + onActivate(next: ComponentInstruction, prev: ComponentInstruction) { + logHook('onActivate parent', next, prev); + } } @Component({selector: 'reuse-hooks-cmp'}) @View({template: 'reuse hooks cmp'}) @CanActivate(ReuseHooksCmp.canActivate) class ReuseHooksCmp implements OnActivate, OnReuse, OnDeactivate, CanReuse, CanDeactivate { - canReuse(next: Instruction, prev: Instruction): Promise { + canReuse(next: ComponentInstruction, prev: ComponentInstruction): Promise { completer = PromiseWrapper.completer(); logHook('canReuse', next, prev); return completer.promise; } - onReuse(next: Instruction, prev: Instruction) { logHook('onReuse', next, prev); } + onReuse(next: ComponentInstruction, prev: ComponentInstruction) { + logHook('onReuse', next, prev); + } - canDeactivate(next: Instruction, prev: Instruction) { + canDeactivate(next: ComponentInstruction, prev: ComponentInstruction) { logHook('canDeactivate', next, prev); return true; } - onDeactivate(next: Instruction, prev: Instruction) { logHook('onDeactivate', next, prev); } + onDeactivate(next: ComponentInstruction, prev: ComponentInstruction) { + logHook('onDeactivate', next, prev); + } - static canActivate(next: Instruction, prev: Instruction) { + static canActivate(next: ComponentInstruction, prev: ComponentInstruction) { logHook('canActivate', next, prev); return true; } - onActivate(next: Instruction, prev: Instruction) { logHook('onActivate', next, prev); } + onActivate(next: ComponentInstruction, prev: ComponentInstruction) { + logHook('onActivate', next, prev); + } } @Component({selector: 'lifecycle-cmp'}) @@ -828,3 +886,21 @@ class ReuseHooksCmp implements OnActivate, OnReuse, OnDeactivate, CanReuse, CanD ]) class LifecycleCmp { } + +@Component({selector: 'modal-cmp'}) +@View({template: "modal"}) +class ModalCmp { +} + +@Component({selector: 'aux-cmp'}) +@View({ + template: + `main {} | aux {}`, + directives: [RouterOutlet] +}) +@RouteConfig([ + new Route({path: '/hello', component: HelloCmp}), + new AuxRoute({path: '/modal', component: ModalCmp}) +]) +class AuxCmp { +} diff --git a/modules/angular2/test/router/path_recognizer_spec.ts b/modules/angular2/test/router/path_recognizer_spec.ts index d4b606fdd7..5a577dd686 100644 --- a/modules/angular2/test/router/path_recognizer_spec.ts +++ b/modules/angular2/test/router/path_recognizer_spec.ts @@ -11,6 +11,7 @@ import { } from 'angular2/test_lib'; import {PathRecognizer} from 'angular2/src/router/path_recognizer'; +import {parser, Url, RootUrl} from 'angular2/src/router/url_parser'; import {SyncRouteHandler} from 'angular2/src/router/sync_route_handler'; class DummyClass { @@ -41,65 +42,60 @@ export function main() { describe('querystring params', () => { it('should parse querystring params so long as the recognizer is a root', () => { - var rec = new PathRecognizer('/hello/there', mockRouteHandler, true); - var params = rec.parseParams('/hello/there?name=igor'); - expect(params).toEqual({'name': 'igor'}); + var rec = new PathRecognizer('/hello/there', mockRouteHandler); + var url = parser.parse('/hello/there?name=igor'); + var match = rec.recognize(url); + expect(match.instruction.params).toEqual({'name': 'igor'}); }); it('should return a combined map of parameters with the param expected in the URL path', () => { - var rec = new PathRecognizer('/hello/:name', mockRouteHandler, true); - var params = rec.parseParams('/hello/paul?topic=success'); - expect(params).toEqual({'name': 'paul', 'topic': 'success'}); + var rec = new PathRecognizer('/hello/:name', mockRouteHandler); + var url = parser.parse('/hello/paul?topic=success'); + var match = rec.recognize(url); + expect(match.instruction.params).toEqual({'name': 'paul', 'topic': 'success'}); }); }); describe('matrix params', () => { - it('should recognize a trailing matrix value on a path value and assign it to the params return value', - () => { - var rec = new PathRecognizer('/hello/:id', mockRouteHandler); - var params = rec.parseParams('/hello/matias;key=value'); - - expect(params['id']).toEqual('matias'); - expect(params['key']).toEqual('value'); - }); - - it('should recognize and parse multiple matrix params separated by a colon value', () => { - var rec = new PathRecognizer('/jello/:sid', mockRouteHandler); - var params = rec.parseParams('/jello/man;color=red;height=20'); - - expect(params['sid']).toEqual('man'); - expect(params['color']).toEqual('red'); - expect(params['height']).toEqual('20'); + it('should be parsed along with dynamic paths', () => { + var rec = new PathRecognizer('/hello/:id', mockRouteHandler); + var url = new Url('hello', new Url('matias', null, null, {'key': 'value'})); + var match = rec.recognize(url); + expect(match.instruction.params).toEqual({'id': 'matias', 'key': 'value'}); }); - it('should recognize a matrix param value on a static path value', () => { - var rec = new PathRecognizer('/static/man', mockRouteHandler); - var params = rec.parseParams('/static/man;name=dave'); - expect(params['name']).toEqual('dave'); + it('should be parsed on a static path', () => { + var rec = new PathRecognizer('/person', mockRouteHandler); + var url = new Url('person', null, null, {'name': 'dave'}); + var match = rec.recognize(url); + expect(match.instruction.params).toEqual({'name': 'dave'}); }); - it('should not parse matrix params when a wildcard segment is used', () => { + it('should be ignored on a wildcard segment', () => { var rec = new PathRecognizer('/wild/*everything', mockRouteHandler); - var params = rec.parseParams('/wild/super;variable=value'); - expect(params['everything']).toEqual('super;variable=value'); + var url = parser.parse('/wild/super;variable=value'); + var match = rec.recognize(url); + expect(match.instruction.params).toEqual({'everything': 'super;variable=value'}); }); - it('should set matrix param values to true when no value is present within the path string', - () => { - var rec = new PathRecognizer('/path', mockRouteHandler); - var params = rec.parseParams('/path;one;two;three=3'); - expect(params['one']).toEqual(true); - expect(params['two']).toEqual(true); - expect(params['three']).toEqual('3'); - }); + it('should set matrix param values to true when no value is present', () => { + var rec = new PathRecognizer('/path', mockRouteHandler); + var url = new Url('path', null, null, {'one': true, 'two': true, 'three': '3'}); + var match = rec.recognize(url); + expect(match.instruction.params).toEqual({'one': true, 'two': true, 'three': '3'}); + }); - it('should ignore earlier instances of matrix params and only consider the ones at the end of the path', - () => { - var rec = new PathRecognizer('/one/two/three', mockRouteHandler); - var params = rec.parseParams('/one;a=1/two;b=2/three;c=3'); - expect(params).toEqual({'c': '3'}); - }); + it('should be parsed on the final segment of the path', () => { + var rec = new PathRecognizer('/one/two/three', mockRouteHandler); + + var three = new Url('three', null, null, {'c': '3'}); + var two = new Url('two', three, null, {'b': '2'}); + var one = new Url('one', two, null, {'a': '1'}); + + var match = rec.recognize(one); + expect(match.instruction.params).toEqual({'c': '3'}); + }); }); }); } diff --git a/modules/angular2/test/router/route_config_spec.ts b/modules/angular2/test/router/route_config_spec.ts index 3e340c5de7..3d8fc3243b 100644 --- a/modules/angular2/test/router/route_config_spec.ts +++ b/modules/angular2/test/router/route_config_spec.ts @@ -98,7 +98,31 @@ export function main() { }); })); - // TODO: test apps with wrong configs + it('should throw if a config is missing a target', + inject( + [AsyncTestCompleter], + (async) => { + bootstrap(WrongConfigCmp, testBindings) + .catch((e) => { + expect(e.originalException) + .toContainError( + 'Route config should contain exactly one "component", "loader", or "redirectTo" property.'); + async.done(); + return null; + })})); + + it('should throw if a config has an invalid component type', + inject( + [AsyncTestCompleter], + (async) => { + bootstrap(WrongComponentTypeCmp, testBindings) + .catch((e) => { + expect(e.originalException) + .toContainError( + 'Invalid component type "intentionallyWrongComponentType". Valid types are "constructor" and "loader".'); + async.done(); + return null; + })})); }); } @@ -149,3 +173,17 @@ class ParentCmp { class HierarchyAppCmp { constructor(public router: Router, public location: LocationStrategy) {} } + +@Component({selector: 'app-cmp'}) +@View({template: `root { }`, directives: routerDirectives}) +@RouteConfig([{path: '/hello'}]) +class WrongConfigCmp { +} + +@Component({selector: 'app-cmp'}) +@View({template: `root { }`, directives: routerDirectives}) +@RouteConfig([ + {path: '/hello', component: {type: 'intentionallyWrongComponentType', constructor: HelloCmp}}, +]) +class WrongComponentTypeCmp { +} diff --git a/modules/angular2/test/router/route_recognizer_spec.ts b/modules/angular2/test/router/route_recognizer_spec.ts index 5126b2ec42..e8d4c51975 100644 --- a/modules/angular2/test/router/route_recognizer_spec.ts +++ b/modules/angular2/test/router/route_recognizer_spec.ts @@ -12,9 +12,11 @@ import { import {Map, StringMap, StringMapWrapper} from 'angular2/src/facade/collection'; -import {RouteRecognizer, RouteMatch} from 'angular2/src/router/route_recognizer'; +import {RouteRecognizer} from 'angular2/src/router/route_recognizer'; +import {ComponentInstruction} from 'angular2/src/router/instruction'; import {Route, Redirect} from 'angular2/src/router/route_config_decorator'; +import {parser} from 'angular2/src/router/url_parser'; export function main() { describe('RouteRecognizer', () => { @@ -25,31 +27,31 @@ export function main() { it('should recognize a static segment', () => { recognizer.config(new Route({path: '/test', component: DummyCmpA})); - var solution = recognizer.recognize('/test')[0]; + var solution = recognize(recognizer, '/test'); expect(getComponentType(solution)).toEqual(DummyCmpA); }); it('should recognize a single slash', () => { recognizer.config(new Route({path: '/', component: DummyCmpA})); - var solution = recognizer.recognize('/')[0]; + var solution = recognize(recognizer, '/'); expect(getComponentType(solution)).toEqual(DummyCmpA); }); it('should recognize a dynamic segment', () => { recognizer.config(new Route({path: '/user/:name', component: DummyCmpA})); - var solution = recognizer.recognize('/user/brian')[0]; + var solution = recognize(recognizer, '/user/brian'); expect(getComponentType(solution)).toEqual(DummyCmpA); - expect(solution.params()).toEqual({'name': 'brian'}); + expect(solution.params).toEqual({'name': 'brian'}); }); it('should recognize a star segment', () => { recognizer.config(new Route({path: '/first/*rest', component: DummyCmpA})); - var solution = recognizer.recognize('/first/second/third')[0]; + var solution = recognize(recognizer, '/first/second/third'); expect(getComponentType(solution)).toEqual(DummyCmpA); - expect(solution.params()).toEqual({'rest': 'second/third'}); + expect(solution.params).toEqual({'rest': 'second/third'}); }); @@ -70,235 +72,105 @@ export function main() { it('should recognize redirects', () => { - recognizer.config(new Redirect({path: '/a', redirectTo: '/b'})); recognizer.config(new Route({path: '/b', component: DummyCmpA})); - var solutions = recognizer.recognize('/a'); - expect(solutions.length).toBe(1); - - var solution = solutions[0]; + recognizer.config(new Redirect({path: '/a', redirectTo: 'b'})); + var solution = recognize(recognizer, '/a'); expect(getComponentType(solution)).toEqual(DummyCmpA); - expect(solution.matchedUrl).toEqual('/b'); + expect(solution.urlPath).toEqual('b'); }); + it('should not perform root URL redirect on a non-root route', () => { recognizer.config(new Redirect({path: '/', redirectTo: '/foo'})); recognizer.config(new Route({path: '/bar', component: DummyCmpA})); - var solutions = recognizer.recognize('/bar'); - expect(solutions.length).toBe(1); - - var solution = solutions[0]; - expect(getComponentType(solution)).toEqual(DummyCmpA); - expect(solution.matchedUrl).toEqual('/bar'); + var solution = recognize(recognizer, '/bar'); + expect(solution.componentType).toEqual(DummyCmpA); + expect(solution.urlPath).toEqual('bar'); }); - it('should perform a root URL redirect when only a slash or an empty string is being processed', - () => { - recognizer.config(new Redirect({path: '/', redirectTo: '/matias'})); - recognizer.config(new Route({path: '/matias', component: DummyCmpA})); - recognizer.config(new Route({path: '/fatias', component: DummyCmpA})); - var solutions; + it('should perform a root URL redirect only for root routes', () => { + recognizer.config(new Redirect({path: '/', redirectTo: '/matias'})); + recognizer.config(new Route({path: '/matias', component: DummyCmpA})); + recognizer.config(new Route({path: '/fatias', component: DummyCmpA})); - solutions = recognizer.recognize('/'); - expect(solutions[0].matchedUrl).toBe('/matias'); + var solution; - solutions = recognizer.recognize('/fatias'); - expect(solutions[0].matchedUrl).toBe('/fatias'); + solution = recognize(recognizer, '/'); + expect(solution.urlPath).toEqual('matias'); + + solution = recognize(recognizer, '/fatias'); + expect(solution.urlPath).toEqual('fatias'); + + solution = recognize(recognizer, ''); + expect(solution.urlPath).toEqual('matias'); + }); - solutions = recognizer.recognize(''); - expect(solutions[0].matchedUrl).toBe('/matias'); - }); it('should generate URLs with params', () => { recognizer.config(new Route({path: '/app/user/:name', component: DummyCmpA, as: 'user'})); - expect(recognizer.generate('user', {'name': 'misko'})['url']).toEqual('app/user/misko'); + var instruction = recognizer.generate('user', {'name': 'misko'}); + expect(instruction.urlPath).toEqual('app/user/misko'); }); + it('should generate URLs with numeric params', () => { recognizer.config(new Route({path: '/app/page/:number', component: DummyCmpA, as: 'page'})); - expect(recognizer.generate('page', {'number': 42})['url']).toEqual('app/page/42'); + expect(recognizer.generate('page', {'number': 42}).urlPath).toEqual('app/page/42'); }); + it('should throw in the absence of required params URLs', () => { recognizer.config(new Route({path: 'app/user/:name', component: DummyCmpA, as: 'user'})); - expect(() => recognizer.generate('user', {})['url']) + expect(() => recognizer.generate('user', {})) .toThrowError('Route generator for \'name\' was not included in parameters passed.'); }); - describe('querystring params', () => { - it('should recognize querystring parameters within the URL path', () => { - var recognizer = new RouteRecognizer(true); - recognizer.config(new Route({path: 'profile/:name', component: DummyCmpA, as: 'user'})); - var solution = recognizer.recognize('/profile/matsko?comments=all')[0]; - var params = solution.params(); - expect(params['name']).toEqual('matsko'); - expect(params['comments']).toEqual('all'); + describe('params', () => { + it('should recognize parameters within the URL path', () => { + recognizer.config(new Route({path: 'profile/:name', component: DummyCmpA, as: 'user'})); + var solution = recognize(recognizer, '/profile/matsko?comments=all'); + expect(solution.params).toEqual({'name': 'matsko', 'comments': 'all'}); }); + it('should generate and populate the given static-based route with querystring params', () => { - var recognizer = new RouteRecognizer(true); recognizer.config( new Route({path: 'forum/featured', component: DummyCmpA, as: 'forum-page'})); - var params = StringMapWrapper.create(); - params['start'] = 10; - params['end'] = 100; + var params = {'start': 10, 'end': 100}; var result = recognizer.generate('forum-page', params); - expect(result['url']).toEqual('forum/featured?start=10&end=100'); + expect(result.urlPath).toEqual('forum/featured'); + expect(result.urlParams).toEqual(['start=10', 'end=100']); }); - it('should place a higher priority on actual route params incase the same params are defined in the querystring', - () => { - var recognizer = new RouteRecognizer(true); - recognizer.config(new Route({path: 'profile/:name', component: DummyCmpA, as: 'user'})); - var solution = recognizer.recognize('/profile/yegor?name=igor')[0]; - var params = solution.params(); - expect(params['name']).toEqual('yegor'); - }); - - it('should strip out any occurences of matrix params when querystring params are allowed', - () => { - var recognizer = new RouteRecognizer(true); - recognizer.config(new Route({path: '/home', component: DummyCmpA, as: 'user'})); - - var solution = recognizer.recognize('/home;showAll=true;limit=100?showAll=false')[0]; - var params = solution.params(); - - expect(params['showAll']).toEqual('false'); - expect(params['limit']).toBeFalsy(); - }); - - it('should strip out any occurences of matrix params as input data', () => { - var recognizer = new RouteRecognizer(true); - recognizer.config(new Route({path: '/home/:subject', component: DummyCmpA, as: 'user'})); - - var solution = recognizer.recognize('/home/zero;one=1?two=2')[0]; - var params = solution.params(); - - expect(params['subject']).toEqual('zero'); - expect(params['one']).toBeFalsy(); - expect(params['two']).toEqual('2'); - }); - }); - - describe('matrix params', () => { - it('should recognize matrix parameters within the URL path', () => { - var recognizer = new RouteRecognizer(); + it('should prefer positional params over query params', () => { recognizer.config(new Route({path: 'profile/:name', component: DummyCmpA, as: 'user'})); - var solution = recognizer.recognize('/profile/matsko;comments=all')[0]; - var params = solution.params(); - expect(params['name']).toEqual('matsko'); - expect(params['comments']).toEqual('all'); + var solution = recognize(recognizer, '/profile/yegor?name=igor'); + expect(solution.params).toEqual({'name': 'yegor'}); }); - it('should recognize multiple matrix params and set parameters that contain no value to true', - () => { - var recognizer = new RouteRecognizer(); - recognizer.config(new Route({path: '/profile/hello', component: DummyCmpA, as: 'user'})); - var solution = - recognizer.recognize('/profile/hello;modal;showAll=true;hideAll=false')[0]; - var params = solution.params(); - - expect(params['modal']).toEqual(true); - expect(params['showAll']).toEqual('true'); - expect(params['hideAll']).toEqual('false'); - }); - - it('should only consider the matrix parameters at the end of the path handler', () => { - var recognizer = new RouteRecognizer(); - recognizer.config(new Route({path: '/profile/hi/:name', component: DummyCmpA, as: 'user'})); - - var solution = recognizer.recognize('/profile;a=1/hi;b=2;c=3/william;d=4')[0]; - var params = solution.params(); - - expect(params).toEqual({'name': 'william', 'd': '4'}); - }); - - it('should generate and populate the given static-based route with matrix params', () => { - var recognizer = new RouteRecognizer(); - recognizer.config( - new Route({path: 'forum/featured', component: DummyCmpA, as: 'forum-page'})); - - var params = StringMapWrapper.create(); - params['start'] = 10; - params['end'] = 100; - - var result = recognizer.generate('forum-page', params); - expect(result['url']).toEqual('forum/featured;start=10;end=100'); - }); - - it('should generate and populate the given dynamic-based route with matrix params', () => { - var recognizer = new RouteRecognizer(); - recognizer.config( - new Route({path: 'forum/:topic', component: DummyCmpA, as: 'forum-page'})); - - var params = StringMapWrapper.create(); - params['topic'] = 'crazy'; - params['total-posts'] = 100; - params['moreDetail'] = null; - - var result = recognizer.generate('forum-page', params); - expect(result['url']).toEqual('forum/crazy;total-posts=100;moreDetail'); - }); - - it('should not apply any matrix params if a dynamic route segment takes up the slot when a path is generated', - () => { - var recognizer = new RouteRecognizer(); - recognizer.config( - new Route({path: 'hello/:name', component: DummyCmpA, as: 'profile-page'})); - - var params = StringMapWrapper.create(); - params['name'] = 'matsko'; - - var result = recognizer.generate('profile-page', params); - expect(result['url']).toEqual('hello/matsko'); - }); - - it('should place a higher priority on actual route params incase the same params are defined in the matrix params string', - () => { - var recognizer = new RouteRecognizer(); - recognizer.config(new Route({path: 'profile/:name', component: DummyCmpA, as: 'user'})); - - var solution = recognizer.recognize('/profile/yegor;name=igor')[0]; - var params = solution.params(); - expect(params['name']).toEqual('yegor'); - }); - - it('should strip out any occurences of querystring params when matrix params are allowed', - () => { - var recognizer = new RouteRecognizer(); - recognizer.config(new Route({path: '/home', component: DummyCmpA, as: 'user'})); - - var solution = recognizer.recognize('/home;limit=100?limit=1000&showAll=true')[0]; - var params = solution.params(); - - expect(params['showAll']).toBeFalsy(); - expect(params['limit']).toEqual('100'); - }); - - it('should strip out any occurences of matrix params as input data', () => { - var recognizer = new RouteRecognizer(); + it('should ignore matrix params for the top-level component', () => { recognizer.config(new Route({path: '/home/:subject', component: DummyCmpA, as: 'user'})); - - var solution = recognizer.recognize('/home/zero;one=1?two=2')[0]; - var params = solution.params(); - - expect(params['subject']).toEqual('zero'); - expect(params['one']).toEqual('1'); - expect(params['two']).toBeFalsy(); + var solution = recognize(recognizer, '/home;sort=asc/zero;one=1?two=2'); + expect(solution.params).toEqual({'subject': 'zero', 'two': '2'}); }); }); }); } -function getComponentType(routeMatch: RouteMatch): any { - return routeMatch.recognizer.handler.componentType; +function recognize(recognizer: RouteRecognizer, url: string): ComponentInstruction { + return recognizer.recognize(parser.parse(url))[0].instruction; +} + +function getComponentType(routeMatch: ComponentInstruction): any { + return routeMatch.componentType; } class DummyCmpA {} diff --git a/modules/angular2/test/router/route_registry_spec.ts b/modules/angular2/test/router/route_registry_spec.ts index 832f114408..17aa6a2167 100644 --- a/modules/angular2/test/router/route_registry_spec.ts +++ b/modules/angular2/test/router/route_registry_spec.ts @@ -14,6 +14,7 @@ import {Promise, PromiseWrapper} from 'angular2/src/facade/async'; import {RouteRegistry} from 'angular2/src/router/route_registry'; import {RouteConfig, Route, AsyncRoute} from 'angular2/src/router/route_config_decorator'; +import {stringifyInstruction} from 'angular2/src/router/instruction'; export function main() { describe('RouteRegistry', () => { @@ -27,7 +28,7 @@ export function main() { registry.recognize('/test', RootHostCmp) .then((instruction) => { - expect(instruction.component).toBe(DummyCmpB); + expect(instruction.component.componentType).toBe(DummyCmpB); async.done(); }); })); @@ -36,8 +37,10 @@ export function main() { registry.config(RootHostCmp, new Route({path: '/first/...', component: DummyParentCmp, as: 'firstCmp'})); - expect(registry.generate(['firstCmp', 'secondCmp'], RootHostCmp)).toEqual('first/second'); - expect(registry.generate(['secondCmp'], DummyParentCmp)).toEqual('second'); + expect(stringifyInstruction(registry.generate(['firstCmp', 'secondCmp'], RootHostCmp))) + .toEqual('first/second'); + expect(stringifyInstruction(registry.generate(['secondCmp'], DummyParentCmp))) + .toEqual('second'); }); it('should generate URLs with params', () => { @@ -45,8 +48,8 @@ export function main() { RootHostCmp, new Route({path: '/first/:param/...', component: DummyParentParamCmp, as: 'firstCmp'})); - var url = - registry.generate(['firstCmp', {param: 'one'}, 'secondCmp', {param: 'two'}], RootHostCmp); + var url = stringifyInstruction(registry.generate( + ['firstCmp', {param: 'one'}, 'secondCmp', {param: 'two'}], RootHostCmp)); expect(url).toEqual('first/one/second/two'); }); @@ -61,7 +64,8 @@ export function main() { registry.recognize('/first/second', RootHostCmp) .then((_) => { - expect(registry.generate(['firstCmp', 'secondCmp'], RootHostCmp)) + expect( + stringifyInstruction(registry.generate(['firstCmp', 'secondCmp'], RootHostCmp))) .toEqual('first/second'); async.done(); }); @@ -73,14 +77,13 @@ export function main() { .toThrowError('Component "RootHostCmp" has no route config.'); }); - it('should prefer static segments to dynamic', inject([AsyncTestCompleter], (async) => { registry.config(RootHostCmp, new Route({path: '/:site', component: DummyCmpB})); registry.config(RootHostCmp, new Route({path: '/home', component: DummyCmpA})); registry.recognize('/home', RootHostCmp) .then((instruction) => { - expect(instruction.component).toBe(DummyCmpA); + expect(instruction.component.componentType).toBe(DummyCmpA); async.done(); }); })); @@ -91,7 +94,7 @@ export function main() { registry.recognize('/home', RootHostCmp) .then((instruction) => { - expect(instruction.component).toBe(DummyCmpA); + expect(instruction.component.componentType).toBe(DummyCmpA); async.done(); }); })); @@ -102,7 +105,7 @@ export function main() { registry.recognize('/some/path', RootHostCmp) .then((instruction) => { - expect(instruction.component).toBe(DummyCmpA); + expect(instruction.component.componentType).toBe(DummyCmpA); async.done(); }); })); @@ -113,7 +116,7 @@ export function main() { registry.recognize('/first/second', RootHostCmp) .then((instruction) => { - expect(instruction.component).toBe(DummyCmpA); + expect(instruction.component.componentType).toBe(DummyCmpA); async.done(); }); })); @@ -127,7 +130,7 @@ export function main() { registry.recognize('/first/second/third', RootHostCmp) .then((instruction) => { - expect(instruction.component).toBe(DummyCmpB); + expect(instruction.component.componentType).toBe(DummyCmpB); async.done(); }); })); @@ -137,8 +140,8 @@ export function main() { registry.recognize('/first/second', RootHostCmp) .then((instruction) => { - expect(instruction.component).toBe(DummyParentCmp); - expect(instruction.child.component).toBe(DummyCmpB); + expect(instruction.component.componentType).toBe(DummyParentCmp); + expect(instruction.child.component.componentType).toBe(DummyCmpB); async.done(); }); })); @@ -149,8 +152,8 @@ export function main() { registry.recognize('/first/second', RootHostCmp) .then((instruction) => { - expect(instruction.component).toBe(DummyAsyncCmp); - expect(instruction.child.component).toBe(DummyCmpB); + expect(instruction.component.componentType).toBe(DummyAsyncCmp); + expect(instruction.child.component.componentType).toBe(DummyCmpB); async.done(); }); })); @@ -162,28 +165,12 @@ export function main() { registry.recognize('/first/second', RootHostCmp) .then((instruction) => { - expect(instruction.component).toBe(DummyParentCmp); - expect(instruction.child.component).toBe(DummyCmpB); + expect(instruction.component.componentType).toBe(DummyParentCmp); + expect(instruction.child.component.componentType).toBe(DummyCmpB); async.done(); }); })); - // TODO: not sure what to do with these tests - // it('should throw when a config does not have a component or redirectTo property', () => { - // expect(() => registry.config(rootHostComponent, {'path': '/some/path'})) - // .toThrowError( - // 'Route config should contain exactly one \'component\', or \'redirectTo\' - // property'); - //}); - // - // it('should throw when a config has an invalid component type', () => { - // expect(() => registry.config( - // rootHostComponent, - // {'path': '/some/path', 'component': {'type': - // 'intentionallyWrongComponentType'}})) - // .toThrowError('Invalid component type \'intentionallyWrongComponentType\''); - //}); - it('should throw when a parent config is missing the `...` suffix any of its children add routes', () => { expect(() => @@ -198,6 +185,40 @@ export function main() { .toThrowError('Unexpected "..." before the end of the path for "home/.../fun/".'); }); + it('should match matrix params on child components and query params on the root component', + inject([AsyncTestCompleter], (async) => { + registry.config(RootHostCmp, new Route({path: '/first/...', component: DummyParentCmp})); + + registry.recognize('/first/second;filter=odd?comments=all', RootHostCmp) + .then((instruction) => { + expect(instruction.component.componentType).toBe(DummyParentCmp); + expect(instruction.component.params).toEqual({'comments': 'all'}); + + expect(instruction.child.component.componentType).toBe(DummyCmpB); + expect(instruction.child.component.params).toEqual({'filter': 'odd'}); + async.done(); + }); + })); + + it('should generate URLs with matrix and query params', () => { + registry.config( + RootHostCmp, + new Route({path: '/first/:param/...', component: DummyParentParamCmp, as: 'firstCmp'})); + + var url = stringifyInstruction(registry.generate( + [ + 'firstCmp', + {param: 'one', query: 'cats'}, + 'secondCmp', + { + param: 'two', + sort: 'asc', + } + ], + RootHostCmp)); + expect(url).toEqual('first/one/second/two;sort=asc?query=cats'); + }); + }); } diff --git a/modules/angular2/test/router/router_link_spec.ts b/modules/angular2/test/router/router_link_spec.ts index 3104a252ea..bc74c40f0b 100644 --- a/modules/angular2/test/router/router_link_spec.ts +++ b/modules/angular2/test/router/router_link_spec.ts @@ -23,14 +23,13 @@ import {IMPLEMENTS} from 'angular2/src/facade/lang'; import {bind, Component, View} from 'angular2/angular2'; import {Location, Router, RouterLink} from 'angular2/router'; +import {Instruction, ComponentInstruction} from 'angular2/src/router/instruction'; -import { - DOM -} from 'angular2/src/dom/dom_adapter' +import {DOM} from 'angular2/src/dom/dom_adapter'; +var dummyInstruction = new Instruction(new ComponentInstruction('detail', [], null), null, {}); - export function - main() { +export function main() { describe('router-link directive', function() { beforeEachBindings( @@ -59,7 +58,7 @@ import { testComponent.detectChanges(); // TODO: shouldn't this be just 'click' rather than '^click'? testComponent.query(By.css('a')).triggerEventHandler('^click', {}); - expect(router.spy("navigate")).toHaveBeenCalledWith('/detail'); + expect(router.spy('navigateInstruction')).toHaveBeenCalledWith(dummyInstruction); async.done(); }); })); @@ -100,7 +99,7 @@ class DummyRouter extends SpyObject { function makeDummyRouter() { var dr = new DummyRouter(); - dr.spy('generate').andCallFake((routeParams) => routeParams.join('=')); - dr.spy('navigate'); + dr.spy('generate').andCallFake((routeParams) => dummyInstruction); + dr.spy('navigateInstruction'); return dr; } diff --git a/modules/angular2/test/router/router_spec.ts b/modules/angular2/test/router/router_spec.ts index b9841ffcd2..732b8c8abe 100644 --- a/modules/angular2/test/router/router_spec.ts +++ b/modules/angular2/test/router/router_spec.ts @@ -20,6 +20,7 @@ import {Pipeline} from 'angular2/src/router/pipeline'; import {RouterOutlet} from 'angular2/src/router/router_outlet'; import {SpyLocation} from 'angular2/src/mock/location_mock'; import {Location} from 'angular2/src/router/location'; +import {stringifyInstruction} from 'angular2/src/router/instruction'; import {RouteRegistry} from 'angular2/src/router/route_registry'; import {RouteConfig, Route} from 'angular2/src/router/route_config_decorator'; @@ -125,52 +126,54 @@ export function main() { it('should generate URLs from the root component when the path starts with /', () => { router.config([new Route({path: '/first/...', component: DummyParentComp, as: 'firstCmp'})]); - expect(router.generate(['/firstCmp', 'secondCmp'])).toEqual('/first/second'); - expect(router.generate(['/firstCmp', 'secondCmp'])).toEqual('/first/second'); - expect(router.generate(['/firstCmp/secondCmp'])).toEqual('/first/second'); + var instruction = router.generate(['/firstCmp', 'secondCmp']); + expect(stringifyInstruction(instruction)).toEqual('first/second'); + + instruction = router.generate(['/firstCmp/secondCmp']); + expect(stringifyInstruction(instruction)).toEqual('first/second'); }); - describe('querstring params', () => { - it('should only apply querystring params if the given URL is on the root router and is terminal', - () => { - router.config([ - new Route({path: '/hi/how/are/you', component: DummyComponent, as: 'greeting-url'}) - ]); + describe('query string params', () => { + it('should use query string params for the root route', () => { + router.config( + [new Route({path: '/hi/how/are/you', component: DummyComponent, as: 'greeting-url'})]); - var path = router.generate(['/greeting-url', {'name': 'brad'}]); - expect(path).toEqual('/hi/how/are/you?name=brad'); - }); + var instruction = router.generate(['/greeting-url', {'name': 'brad'}]); + var path = stringifyInstruction(instruction); + expect(path).toEqual('hi/how/are/you?name=brad'); + }); - it('should use parameters that are not apart of the route definition as querystring params', + it('should serialize parameters that are not part of the route definition as query string params', () => { router.config( [new Route({path: '/one/two/:three', component: DummyComponent, as: 'number-url'})]); - var path = router.generate(['/number-url', {'three': 'three', 'four': 'four'}]); - expect(path).toEqual('/one/two/three?four=four'); + var instruction = router.generate(['/number-url', {'three': 'three', 'four': 'four'}]); + var path = stringifyInstruction(instruction); + expect(path).toEqual('one/two/three?four=four'); }); }); describe('matrix params', () => { - it('should apply inline matrix params for each router path within the generated URL', () => { + it('should generate matrix params for each non-root component', () => { router.config( [new Route({path: '/first/...', component: DummyParentComp, as: 'firstCmp'})]); - var path = + var instruction = router.generate(['/firstCmp', {'key': 'value'}, 'secondCmp', {'project': 'angular'}]); - expect(path).toEqual('/first;key=value/second;project=angular'); + var path = stringifyInstruction(instruction); + expect(path).toEqual('first/second;project=angular?key=value'); }); - it('should apply inline matrix params for each router path within the generated URL and also include named params', - () => { - router.config([ - new Route({path: '/first/:token/...', component: DummyParentComp, as: 'firstCmp'}) - ]); + it('should work with named params', () => { + router.config( + [new Route({path: '/first/:token/...', component: DummyParentComp, as: 'firstCmp'})]); - var path = - router.generate(['/firstCmp', {'token': 'min'}, 'secondCmp', {'author': 'max'}]); - expect(path).toEqual('/first/min/second;author=max'); - }); + var instruction = + router.generate(['/firstCmp', {'token': 'min'}, 'secondCmp', {'author': 'max'}]); + var path = stringifyInstruction(instruction); + expect(path).toEqual('first/min/second;author=max'); + }); }); }); } diff --git a/modules/angular2/test/router/url_parser_spec.ts b/modules/angular2/test/router/url_parser_spec.ts new file mode 100644 index 0000000000..b5ed55b408 --- /dev/null +++ b/modules/angular2/test/router/url_parser_spec.ts @@ -0,0 +1,118 @@ +import { + AsyncTestCompleter, + describe, + it, + iit, + ddescribe, + expect, + inject, + beforeEach, + SpyObject +} from 'angular2/test_lib'; + +import {UrlParser, Url} from 'angular2/src/router/url_parser'; + + +export function main() { + describe('ParsedUrl', () => { + var urlParser; + + beforeEach(() => { urlParser = new UrlParser(); }); + + it('should work in a simple case', () => { + var url = urlParser.parse('hello/there'); + expect(url.toString()).toEqual('hello/there'); + }); + + it('should remove the leading slash', () => { + var url = urlParser.parse('/hello/there'); + expect(url.toString()).toEqual('hello/there'); + }); + + it('should work with a single aux route', () => { + var url = urlParser.parse('hello/there(a)'); + expect(url.toString()).toEqual('hello/there(a)'); + }); + + it('should work with multiple aux routes', () => { + var url = urlParser.parse('hello/there(a//b)'); + expect(url.toString()).toEqual('hello/there(a//b)'); + }); + + it('should work with children after an aux route', () => { + var url = urlParser.parse('hello/there(a//b)/c/d'); + expect(url.toString()).toEqual('hello/there(a//b)/c/d'); + }); + + it('should work when aux routes have children', () => { + var url = urlParser.parse('hello(aa/bb//bb/cc)'); + expect(url.toString()).toEqual('hello(aa/bb//bb/cc)'); + }); + + it('should parse an aux route with an aux route', () => { + var url = urlParser.parse('hello(aa(bb))'); + expect(url.toString()).toEqual('hello(aa(bb))'); + }); + + it('should simplify an empty aux route definition', () => { + var url = urlParser.parse('hello()/there'); + expect(url.toString()).toEqual('hello/there'); + }); + + it('should parse a key-value matrix param', () => { + var url = urlParser.parse('hello/friend;name=bob'); + expect(url.toString()).toEqual('hello/friend;name=bob'); + }); + + it('should parse multiple key-value matrix params', () => { + var url = urlParser.parse('hello/there;greeting=hi;whats=up'); + expect(url.toString()).toEqual('hello/there;greeting=hi;whats=up'); + }); + + it('should ignore matrix params on the first segment', () => { + var url = urlParser.parse('profile;a=1/hi'); + expect(url.toString()).toEqual('profile/hi'); + }); + + it('should parse a key-only matrix param', () => { + var url = urlParser.parse('hello/there;hi'); + expect(url.toString()).toEqual('hello/there;hi'); + }); + + it('should parse a key-value query param', () => { + var url = urlParser.parse('hello/friend?name=bob'); + expect(url.toString()).toEqual('hello/friend?name=bob'); + }); + + it('should parse multiple key-value query params', () => { + var url = urlParser.parse('hello/there?greeting=hi&whats=up'); + expect(url.params).toEqual({'greeting': 'hi', 'whats': 'up'}); + expect(url.toString()).toEqual('hello/there?greeting=hi&whats=up'); + }); + + it('should parse a key-only matrix param', () => { + var url = urlParser.parse('hello/there?hi'); + expect(url.toString()).toEqual('hello/there?hi'); + }); + + it('should parse a route with matrix and query params', () => { + var url = urlParser.parse('hello/there;sort=asc;unfiltered?hi&friend=true'); + expect(url.toString()).toEqual('hello/there;sort=asc;unfiltered?hi&friend=true'); + }); + + it('should parse a route with matrix params and aux routes', () => { + var url = urlParser.parse('hello/there;sort=asc(modal)'); + expect(url.toString()).toEqual('hello/there;sort=asc(modal)'); + }); + + it('should parse an aux route with matrix params', () => { + var url = urlParser.parse('hello/there(modal;sort=asc)'); + expect(url.toString()).toEqual('hello/there(modal;sort=asc)'); + }); + + it('should parse a route with matrix params, aux routes, and query params', () => { + var url = urlParser.parse('hello/there;sort=asc(modal)?friend=true'); + expect(url.toString()).toEqual('hello/there;sort=asc(modal)?friend=true'); + }); + }); +}