feat(router): auxiliary routes

Closes #2775
This commit is contained in:
Brian Ford 2015-07-17 13:36:53 -07:00
parent 96e34c1d36
commit ac6227e434
24 changed files with 1482 additions and 986 deletions

View File

@ -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';

View File

@ -1,19 +0,0 @@
import {isPresent} from 'angular2/src/facade/lang';
export function parseAndAssignParamString(splitToken: string, paramString: string,
keyValueMap: StringMap<string, string>): 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;
}
});
}

View File

@ -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<string, string>) {}
@ -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<string, Instruction>) {}
constructor(public component: any, public capturedUrl: string,
private _recognizer: PathRecognizer, public child: Instruction = null,
private _params: StringMap<string, any> = 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<string, string> {
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<Url>) {}
}
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<string>,
private _recognizer: PathRecognizer, public params: StringMap<string, any> = null) {}
get componentType() { return this._recognizer.handler.componentType; }
resolveComponentType(): Promise<Type> { return this._recognizer.handler.resolveComponentType(); }
get specificity() { return this._recognizer.specificity; }
get terminal() { return this._recognizer.terminal; }
}

View File

@ -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;
}

View File

@ -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>| boolean) => ClassDecorator =
(hook: (next: ComponentInstruction, prev: ComponentInstruction) => Promise<boolean>| boolean) => ClassDecorator =
makeDecorator(CanActivateAnnotation);

View File

@ -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<string, string> = StringMapWrapper.create();
keys: StringMap<string, boolean> = StringMapWrapper.create();
export class TouchMap {
map: StringMap<string, string> = {};
keys: StringMap<string, boolean> = {};
constructor(map: StringMap<string, any>) {
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<string, any> {
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<string, any> {
return result;
}
// this function is used to determine whether a route config path like `/foo/:id` collides with
// `/foo/:name`
function pathDslHash(segments: List<Segment>): 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<string> {
return url.split('/');
}
@ -178,125 +182,106 @@ function assertPath(path: string) {
}
}
export class PathMatch {
constructor(public instruction: ComponentInstruction, public remaining: Url,
public remainingAux: List<Url>) {}
}
// represents something like '/foo/:bar'
export class PathRecognizer {
segments: List<Segment>;
regex: RegExp;
private _segments: List<Segment>;
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<string, string> {
// 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, any>): string {
generate(params: StringMap<string, any>): 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<any> { return this.handler.resolveComponentType(); }
}

View File

@ -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<RouteDefinition>) => ClassDecorator =
makeDecorator(RouteConfigAnnotation);

View File

@ -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;

View File

@ -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 <RouteDefinition>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(<{

View File

@ -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<string, PathRecognizer> = new Map();
redirects: Map<string, string> = new Map();
matchers: Map<RegExp, PathRecognizer> = new Map();
constructor(public isRoot: boolean = false) {}
auxRoutes: Map<string, PathRecognizer> = new Map();
// TODO: optimize this into a trie
matchers: List<PathRecognizer> = [];
// TODO: optimize this into a trie
redirects: List<Redirector> = [];
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<RouteMatch> {
recognize(urlParse: Url): List<PathMatch> {
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<string, any> {
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<string, any>;
private _paramsParsed: boolean = false;
export class Redirector {
segments: List<string> = [];
toSegments: List<string> = [];
constructor(public recognizer: PathRecognizer, public matchedUrl: string,
public unmatchedUrl: string, p: StringMap<string, any> = 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<string, any> {
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}".`);
}

View File

@ -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<Instruction> {
var parsedUrl = parser.parse(url);
return this._recognize(parsedUrl, parentComponent);
}
private _recognize(parsedUrl: Url, parentComponent): Promise<Instruction> {
return this._recognizePrimaryRoute(parsedUrl, parentComponent)
.then((instruction: PrimaryInstruction) =>
this._completeAuxiliaryRouteMatches(instruction, parentComponent));
}
private _recognizePrimaryRoute(parsedUrl: Url, parentComponent): Promise<PrimaryInstruction> {
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<Instruction>) => {
// 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<Instruction> {
var recognizer = partialMatch.recognizer;
var handler = recognizer.handler;
return handler.resolveComponentType().then((componentType) => {
private _completePrimaryRouteMatch(partialMatch: PathMatch): Promise<PrimaryInstruction> {
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<Instruction> {
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<any>, parentComponent: any): string {
let url = '';
generate(linkParams: List<any>, 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>): Instruction {
function mostSpecific(instructions: List<PrimaryInstruction>): 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;
}
}

View File

@ -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<any> = _resolveToTrue;
private _outlet: RouterOutlet = null;
private _auxOutlets: Map<string, RouterOutlet> = 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<boolean> {
// 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<RouteDefinition>): Promise<any> {
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<any> {
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<any> {
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<any>): Promise<any> {
@ -138,21 +159,20 @@ export class Router {
});
}
_reuse(instruction): Promise<any> {
_reuse(instruction: Instruction): Promise<any> {
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<boolean> {
return canActivateOne(instruction, this._currentInstruction);
private _canActivate(nextInstruction: Instruction): Promise<boolean> {
return canActivateOne(nextInstruction, this._currentInstruction);
}
private _canDeactivate(instruction: Instruction): Promise<boolean> {
@ -160,11 +180,12 @@ export class Router {
return _resolveToTrue;
}
var next: Promise<boolean>;
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<any> {
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<any>): string {
generate(linkParams: List<any>): 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<any> {
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<any> {
// Delegate navigation to the root router
return this.parent.navigateInstruction(instruction, _skipLocationChange);
}
}
/*
@ -332,22 +379,24 @@ function splitAndFlattenLinkParams(linkParams: List<any>): List<any> {
}, []);
}
function canActivateOne(nextInstruction, currentInstruction): Promise<boolean> {
function canActivateOne(nextInstruction: Instruction, prevInstruction: Instruction):
Promise<boolean> {
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;
});

View File

@ -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<any>) {
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;
}
}

View File

@ -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<any> {
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<any> {
if (isPresent(this.childRouter)) {
return this.childRouter.commit(instruction.child);
@ -57,20 +70,21 @@ export class RouterOutlet {
}
}
private _activate(instruction: Instruction): Promise<any> {
private _activate(instruction: ComponentInstruction): Promise<any> {
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<boolean> {
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<any> {
private _reuse(instruction: ComponentInstruction): Promise<any> {
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<any> {
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);
}
})

View File

@ -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; });
}

View File

@ -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<Url> = CONST_EXPR([]),
public params: StringMap<string, any> = 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<Url> = CONST_EXPR([]),
params: StringMap<string, any> = 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<string, any> {
var params = {};
this.capture('?');
this.parseParam(params);
while (this.remaining.length > 0 && this.peekStartsWith('&')) {
this.capture('&');
this.parseParam(params);
}
return params;
}
parseMatrixParams(): StringMap<string, any> {
var params = {};
while (this.remaining.length > 0 && this.peekStartsWith(';')) {
this.capture(';');
this.parseParam(params);
}
return params;
}
parseParam(params: StringMap<string, any>): 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<Url> {
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<string, any>): List<string> {
var params = [];
if (isPresent(paramMap)) {
StringMapWrapper.forEach(paramMap, (value, key) => {
if (value == true) {
params.push(key);
} else {
params.push(key + '=' + value);
}
});
}
return params;
}

View File

@ -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<string>;
var eventBus: EventEmitter;
var completer: PromiseCompleter<any>;
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<string>(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<string>(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<string>(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<string>(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<string>(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<string>(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<string>(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<string>(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<string>(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<string>(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<string>(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<string>(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<string>(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<string>(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<string>(eventBus, (ev) => {
if (ev.startsWith('canReuse')) {
completer.resolve(false);
}
});
ObservableWrapper.subscribe<string>(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 {<router-outlet></router-outlet>}`, directives: [RouterOutlet]})
@RouteConfig([new Route({path: '/child-activate', component: ActivateCmp})])
class ParentActivateCmp implements OnActivate {
onActivate(next: Instruction, prev: Instruction): Promise<any> {
onActivate(next: ComponentInstruction, prev: ComponentInstruction): Promise<any> {
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<any> {
onDeactivate(next: ComponentInstruction, prev: ComponentInstruction): Promise<any> {
completer = PromiseWrapper.completer();
logHook('deactivate', next, prev);
return completer.promise;
@ -701,7 +743,9 @@ class WaitDeactivateCmp implements OnDeactivate {
@View({template: `parent {<router-outlet></router-outlet>}`, 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<boolean> {
completer = PromiseWrapper.completer();
logHook('canActivate', next, prev);
return completer.promise;
@ -738,7 +782,7 @@ class CanActivateCmp {
@View({template: `canDeactivate {<router-outlet></router-outlet>}`, 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<boolean> {
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<any> {
canReuse(next: ComponentInstruction, prev: ComponentInstruction): Promise<any> {
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 {<router-outlet></router-outlet>} | aux {<router-outlet name="modal"></router-outlet>}`,
directives: [RouterOutlet]
})
@RouteConfig([
new Route({path: '/hello', component: HelloCmp}),
new AuxRoute({path: '/modal', component: ModalCmp})
])
class AuxCmp {
}

View File

@ -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'});
});
});
});
}

View File

@ -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 { <router-outlet></router-outlet> }`, directives: routerDirectives})
@RouteConfig([{path: '/hello'}])
class WrongConfigCmp {
}
@Component({selector: 'app-cmp'})
@View({template: `root { <router-outlet></router-outlet> }`, directives: routerDirectives})
@RouteConfig([
{path: '/hello', component: {type: 'intentionallyWrongComponentType', constructor: HelloCmp}},
])
class WrongComponentTypeCmp {
}

View File

@ -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 {}

View File

@ -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');
});
});
}

View File

@ -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;
}

View File

@ -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');
});
});
});
}

View File

@ -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');
});
});
}