From 1b2754dacdd15e8fea429d56cdacb28eae76d2b1 Mon Sep 17 00:00:00 2001 From: Brian Ford Date: Fri, 17 Apr 2015 09:59:56 -0700 Subject: [PATCH] feat(router): add initial implementation --- karma-dart.conf.js | 1 + modules/angular2/router.js | 13 ++ modules/angular2/src/router/instruction.js | 73 +++++++ .../angular2/src/router/path_recognizer.js | 123 +++++++++++ modules/angular2/src/router/pipeline.js | 41 ++++ modules/angular2/src/router/route_config.js | 18 ++ .../angular2/src/router/route_recognizer.js | 64 ++++++ modules/angular2/src/router/route_registry.js | 129 ++++++++++++ modules/angular2/src/router/router.js | 198 ++++++++++++++++++ modules/angular2/src/router/router_link.js | 65 ++++++ modules/angular2/src/router/router_outlet.js | 46 ++++ modules/angular2/src/router/url.js | 13 ++ modules/angular2/test/router/outlet_spec.js | 156 ++++++++++++++ .../test/router/route_recognizer_spec.js | 61 ++++++ .../test/router/route_registry_spec.js | 41 ++++ modules/angular2/test/router/router_spec.js | 53 +++++ 16 files changed, 1095 insertions(+) create mode 100644 modules/angular2/router.js create mode 100644 modules/angular2/src/router/instruction.js create mode 100644 modules/angular2/src/router/path_recognizer.js create mode 100644 modules/angular2/src/router/pipeline.js create mode 100644 modules/angular2/src/router/route_config.js create mode 100644 modules/angular2/src/router/route_recognizer.js create mode 100644 modules/angular2/src/router/route_registry.js create mode 100644 modules/angular2/src/router/router.js create mode 100644 modules/angular2/src/router/router_link.js create mode 100644 modules/angular2/src/router/router_outlet.js create mode 100644 modules/angular2/src/router/url.js create mode 100644 modules/angular2/test/router/outlet_spec.js create mode 100644 modules/angular2/test/router/route_recognizer_spec.js create mode 100644 modules/angular2/test/router/route_registry_spec.js create mode 100644 modules/angular2/test/router/router_spec.js diff --git a/karma-dart.conf.js b/karma-dart.conf.js index 238d0bc6ac..6608bd3a6c 100644 --- a/karma-dart.conf.js +++ b/karma-dart.conf.js @@ -50,6 +50,7 @@ module.exports = function(config) { '/packages/core': 'http://localhost:9877/base/modules/core', '/packages/change_detection': 'http://localhost:9877/base/modules/change_detection', '/packages/reflection': 'http://localhost:9877/base/modules/reflection', + '/packages/router': 'http://localhost:9877/base/modules/router', '/packages/di': 'http://localhost:9877/base/modules/di', '/packages/directives': 'http://localhost:9877/base/modules/directives', '/packages/facade': 'http://localhost:9877/base/modules/facade', diff --git a/modules/angular2/router.js b/modules/angular2/router.js new file mode 100644 index 0000000000..0730734522 --- /dev/null +++ b/modules/angular2/router.js @@ -0,0 +1,13 @@ +/** + * @module + * @public + * @description + * Maps application URLs into application states, to support deep-linking and navigation. + */ + + +export {Router} from './src/router/router'; +export {RouterOutlet} from './src/router/router_outlet'; +export {RouterLink} from './src/router/router_link'; +export {RouteParams} from './src/router/instruction'; +export {RouteConfig} from './src/router/route_config'; diff --git a/modules/angular2/src/router/instruction.js b/modules/angular2/src/router/instruction.js new file mode 100644 index 0000000000..71ef0a31ca --- /dev/null +++ b/modules/angular2/src/router/instruction.js @@ -0,0 +1,73 @@ +import {Map, MapWrapper, StringMap, StringMapWrapper, List, ListWrapper} from 'angular2/src/facade/collection'; +import {Promise, PromiseWrapper} from 'angular2/src/facade/async'; +import {isPresent} from 'angular2/src/facade/lang'; + +export class RouteParams { + params:Map; + constructor(params:StringMap) { + this.params = params; + } + + get(param:string) { + return StringMapWrapper.get(this.params, param); + } +} + +export class Instruction { + component:any; + _children:Map; + router:any; + matchedUrl:string; + params:Map; + + constructor({params, component, children, matchedUrl}:{params:StringMap, component:any, children:Map, matchedUrl:string} = {}) { + this.matchedUrl = matchedUrl; + if (isPresent(children)) { + this._children = children; + var childUrl; + StringMapWrapper.forEach(this._children, (child, _) => { + childUrl = child.matchedUrl; + }); + if (isPresent(childUrl)) { + this.matchedUrl += childUrl; + } + } else { + this._children = StringMapWrapper.create(); + } + this.component = component; + this.params = params; + } + + getChildInstruction(outletName:string) { + return StringMapWrapper.get(this._children, outletName); + } + + forEachChild(fn:Function) { + StringMapWrapper.forEach(this._children, fn); + } + + mapChildrenAsync(fn):Promise { + return mapObjAsync(this._children, fn); + } + + /** + * Takes a function: + * (parent:Instruction, child:Instruction) => {} + */ + traverseSync(fn:Function) { + this.forEachChild((childInstruction, _) => fn(this, childInstruction)); + this.forEachChild((childInstruction, _) => childInstruction.traverseSync(fn)); + } +} + +function mapObjAsync(obj:StringMap, fn) { + return PromiseWrapper.all(mapObj(obj, fn)); +} + +function mapObj(obj:StringMap, fn):List { + var result = ListWrapper.create(); + StringMapWrapper.forEach(obj, (value, key) => ListWrapper.push(result, fn(value, key))); + return result; +} + +export var noopInstruction = new Instruction(); diff --git a/modules/angular2/src/router/path_recognizer.js b/modules/angular2/src/router/path_recognizer.js new file mode 100644 index 0000000000..dbd917a752 --- /dev/null +++ b/modules/angular2/src/router/path_recognizer.js @@ -0,0 +1,123 @@ +import {RegExp, RegExpWrapper, RegExpMatcherWrapper, StringWrapper, isPresent} from 'angular2/src/facade/lang'; +import {Map, MapWrapper, StringMap, StringMapWrapper, List, ListWrapper} from 'angular2/src/facade/collection'; + +import {escapeRegex} from './url'; + +class StaticSegment { + string:string; + regex:string; + name:string; + constructor(string:string) { + this.string = string; + this.name = ''; + this.regex = escapeRegex(string); + } + + generate(params) { + return this.string; + } +} + +class DynamicSegment { + name:string; + regex:string; + constructor(name:string) { + this.name = name; + this.regex = "([^/]+)"; + } + + generate(params:StringMap) { + return StringMapWrapper.get(params, this.name); + } +} + + +class StarSegment { + name:string; + regex:string; + constructor(name:string) { + this.name = name; + this.regex = "(.+)"; + } + + generate(params:StringMap) { + return StringMapWrapper.get(params, this.name); + } +} + + +var paramMatcher = RegExpWrapper.create("^:([^\/]+)$"); +var wildcardMatcher = RegExpWrapper.create("^\\*([^\/]+)$"); + +function parsePathString(route:string):List { + // normalize route as not starting with a "/". Recognition will + // also normalize. + if (route[0] === "/") { + route = StringWrapper.substring(route, 1); + } + + var segments = splitBySlash(route); + var results = ListWrapper.create(); + + for (var i=0; i 0) { + ListWrapper.push(results, new StaticSegment(segment)); + } + } + + return results; +} + +var SLASH_RE = RegExpWrapper.create('/'); +function splitBySlash (url:string):List { + return StringWrapper.split(url, SLASH_RE); +} + + +// represents something like '/foo/:bar' +export class PathRecognizer { + segments:List; + regex:RegExp; + handler:any; + + constructor(path:string, handler:any) { + this.handler = handler; + this.segments = ListWrapper.create(); + + var segments = parsePathString(path); + var regexString = '^'; + + ListWrapper.forEach(segments, (segment) => { + regexString += '/' + segment.regex; + }); + + this.regex = RegExpWrapper.create(regexString); + this.segments = segments; + } + + parseParams(url:string):StringMap { + var params = StringMapWrapper.create(); + var urlPart = url; + for(var i=0; i 0) { + StringMapWrapper.set(params, segment.name, match[1]); + } + } + + return params; + } + + generate(params:StringMap):string { + return ListWrapper.join(ListWrapper.map(this.segments, (segment) => '/' + segment.generate(params)), ''); + } +} diff --git a/modules/angular2/src/router/pipeline.js b/modules/angular2/src/router/pipeline.js new file mode 100644 index 0000000000..0245ccd343 --- /dev/null +++ b/modules/angular2/src/router/pipeline.js @@ -0,0 +1,41 @@ +import {Promise, PromiseWrapper} from 'angular2/src/facade/async'; +import {List, ListWrapper} from 'angular2/src/facade/collection'; +import {Instruction} from './instruction'; + +/** + * Responsible for performing each step of navigation. + * "Steps" are conceptually similar to "middleware" + */ +export class Pipeline { + steps:List; + constructor() { + this.steps = [ + instruction => instruction.traverseSync((parentInstruction, childInstruction) => { + childInstruction.router = parentInstruction.router.childRouter(childInstruction.component); + }), + instruction => instruction.router.traverseOutlets((outlet, name) => { + return outlet.canDeactivate(instruction.getChildInstruction(name)); + }), + instruction => instruction.router.traverseOutlets((outlet, name) => { + return outlet.canActivate(instruction.getChildInstruction(name)); + }), + instruction => instruction.router.activateOutlets(instruction) + ]; + } + + process(instruction:Instruction):Promise { + var steps = this.steps, + currentStep = 0; + + function processOne(result:any = true):Promise { + if (currentStep >= steps.length) { + return PromiseWrapper.resolve(result); + } + var step = steps[currentStep]; + currentStep += 1; + return PromiseWrapper.resolve(step(instruction)).then(processOne); + } + + return processOne(); + } +} diff --git a/modules/angular2/src/router/route_config.js b/modules/angular2/src/router/route_config.js new file mode 100644 index 0000000000..36451d39d8 --- /dev/null +++ b/modules/angular2/src/router/route_config.js @@ -0,0 +1,18 @@ +import {CONST} from 'angular2/src/facade/lang'; + +/** + * You use the RouteConfig annotation to ... + */ +export class RouteConfig { + path:string; + redirectTo:string; + component:any; + //TODO: "alias," or "as" + + @CONST() + constructor({path, component, redirectTo}:{path:string, component:any, redirectTo:string} = {}) { + this.path = path; + this.component = component; + this.redirectTo = redirectTo; + } +} diff --git a/modules/angular2/src/router/route_recognizer.js b/modules/angular2/src/router/route_recognizer.js new file mode 100644 index 0000000000..3737c383e1 --- /dev/null +++ b/modules/angular2/src/router/route_recognizer.js @@ -0,0 +1,64 @@ +import {RegExp, RegExpWrapper, StringWrapper, isPresent} from 'angular2/src/facade/lang'; +import {Map, MapWrapper, List, ListWrapper, StringMap, StringMapWrapper} from 'angular2/src/facade/collection'; + +import {PathRecognizer} from './path_recognizer'; + +export class RouteRecognizer { + names:Map; + redirects:Map; + matchers:Map; + + constructor() { + this.names = MapWrapper.create(); + this.matchers = MapWrapper.create(); + this.redirects = MapWrapper.create(); + } + + addRedirect(path:string, target:string) { + MapWrapper.set(this.redirects, path, target); + } + + addConfig(path:string, handler:any, alias:string = null) { + var recognizer = new PathRecognizer(path, handler); + MapWrapper.set(this.matchers, recognizer.regex, recognizer); + if (isPresent(alias)) { + MapWrapper.set(this.names, alias, recognizer); + } + } + + recognize(url:string):List { + var solutions = []; + MapWrapper.forEach(this.redirects, (target, path) => { + //TODO: "/" redirect case + if (StringWrapper.startsWith(url, path)) { + url = target + StringWrapper.substring(url, path.length); + } + }); + + MapWrapper.forEach(this.matchers, (pathRecognizer, regex) => { + var match; + if (isPresent(match = RegExpWrapper.firstMatch(regex, url))) { + var solution = StringMapWrapper.create(); + StringMapWrapper.set(solution, 'handler', pathRecognizer.handler); + StringMapWrapper.set(solution, 'params', pathRecognizer.parseParams(url)); + StringMapWrapper.set(solution, 'matchedUrl', match[0]); + + var unmatchedUrl = StringWrapper.substring(url, match[0].length); + StringMapWrapper.set(solution, 'unmatchedUrl', unmatchedUrl); + + ListWrapper.push(solutions, solution); + } + }); + + return solutions; + } + + hasRoute(name:string) { + return MapWrapper.contains(this.names, name); + } + + generate(name:string, params:any) { + var pathRecognizer = MapWrapper.get(this.names, name); + return pathRecognizer.generate(params); + } +} diff --git a/modules/angular2/src/router/route_registry.js b/modules/angular2/src/router/route_registry.js new file mode 100644 index 0000000000..e5e78ea0e1 --- /dev/null +++ b/modules/angular2/src/router/route_registry.js @@ -0,0 +1,129 @@ +import {RouteRecognizer} from './route_recognizer'; +import {Instruction, noopInstruction} from './instruction'; +import {List, ListWrapper, Map, MapWrapper, StringMap, StringMapWrapper} from 'angular2/src/facade/collection'; +import {isPresent, isBlank, isType, StringWrapper} from 'angular2/src/facade/lang'; +import {RouteConfig} from './route_config'; +import {reflector} from 'angular2/src/reflection/reflection'; + +export class RouteRegistry { + _rules:Map; + + constructor() { + this._rules = MapWrapper.create(); + } + + config(parentComponent, path:string, component:any, alias:string = null) { + if (parentComponent === 'app') { + parentComponent = '/'; + } + + var recognizer:RouteRecognizer; + if (MapWrapper.contains(this._rules, parentComponent)) { + recognizer = MapWrapper.get(this._rules, parentComponent); + } else { + recognizer = new RouteRecognizer(); + MapWrapper.set(this._rules, parentComponent, recognizer); + } + + this._configFromComponent(component); + + //TODO: support sibling components + var components = StringMapWrapper.create(); + StringMapWrapper.set(components, 'default', component); + + var handler = StringMapWrapper.create(); + StringMapWrapper.set(handler, 'components', components); + + recognizer.addConfig(path, handler, alias); + } + + _configFromComponent(component) { + if (!isType(component)) { + return; + } + + // Don't read the annotations from a type more than once – + // this prevents an infinite loop if a component routes recursively. + if (MapWrapper.contains(this._rules, component)) { + return; + } + var annotations = reflector.annotations(component); + if (isPresent(annotations)) { + for (var i=0; i { + if (!allMapped) { + return; + } + var childInstruction = this.recognize(candidate['unmatchedUrl'], component); + if (isPresent(childInstruction)) { + childInstruction.params = candidate['params']; + children[name] = childInstruction; + } else { + allMapped = false; + } + }); + + if (allMapped) { + return new Instruction({ + component: parentComponent, + children: children, + matchedUrl: candidate['matchedUrl'] + }); + } + } + + return null; + } + + generate(name:string, params:any) { + //TODO: implement for hierarchical routes + var componentRecognizer = MapWrapper.get(this._rules, '/'); + if (isPresent(componentRecognizer)) { + return componentRecognizer.generate(name, params); + } + } +} + +function handlerToLeafInstructions(context, parentComponent) { + var children = StringMapWrapper.create(); + StringMapWrapper.forEach(context['handler']['components'], (component, outletName) => { + children[outletName] = new Instruction({ + component: component, + params: context['params'] + }); + }); + return new Instruction({ + component: parentComponent, + children: children, + matchedUrl: context['matchedUrl'] + }); +} diff --git a/modules/angular2/src/router/router.js b/modules/angular2/src/router/router.js new file mode 100644 index 0000000000..6dfd4ac57b --- /dev/null +++ b/modules/angular2/src/router/router.js @@ -0,0 +1,198 @@ +import {Promise, PromiseWrapper, EventEmitter, ObservableWrapper} from 'angular2/src/facade/async'; +import {Map, MapWrapper, List, ListWrapper} from 'angular2/src/facade/collection'; +import {isBlank} from 'angular2/src/facade/lang'; + +import {RouteRegistry} from './route_registry'; +import {Pipeline} from './pipeline'; +import {Instruction} from './instruction'; +import {RouterOutlet} from './router_outlet'; + +/** + * # Router + * The router is responsible for mapping URLs to components. + * + * You can see the state of the router by inspecting the read-only field `router.navigating`. + * This may be useful for showing a spinner, for instance. + * + * @exportedAs angular2/router + */ +export class Router { + name; + parent:Router; + navigating:boolean; + lastNavigationAttempt: string; + previousUrl:string; + + _pipeline:Pipeline; + _registry:RouteRegistry; + _outlets:Map; + _children:Map; + _subject:EventEmitter; + + constructor(registry:RouteRegistry, pipeline:Pipeline, parent:Router = null, name = '/') { + this.name = name; + this.navigating = false; + this.parent = parent; + this.previousUrl = null; + this._outlets = MapWrapper.create(); + this._children = MapWrapper.create(); + this._registry = registry; + this._pipeline = pipeline; + this._subject = new EventEmitter(); + } + + + /** + * Constructs a child router. You probably don't need to use this unless you're writing a reusable component. + */ + childRouter(outletName = 'default') { + if (!MapWrapper.contains(this._children, outletName)) { + MapWrapper.set(this._children, outletName, new ChildRouter(this, outletName)); + } + return MapWrapper.get(this._children, outletName); + } + + + /** + * Register an object to notify of route changes. You probably don't need to use this unless you're writing a reusable component. + */ + registerOutlet(outlet:RouterOutlet, name = 'default'):Promise { + MapWrapper.set(this._outlets, name, outlet); + return this.renavigate(); + } + + + /** + * Update the routing configuration and trigger a navigation. + * + * # Usage + * + * ``` + * router.config('/', SomeCmp); + * ``` + */ + config(path:string, component, alias:string=null) { + this._registry.config(this.name, path, component, alias); + return this.renavigate(); + } + + + /** + * Navigate to a URL. Returns a promise that resolves to the canonical URL for the route. + */ + navigate(url:string):Promise { + if (this.navigating) { + return PromiseWrapper.resolve(true); + } + + this.lastNavigationAttempt = url; + + var instruction = this.recognize(url); + + if (isBlank(instruction)) { + return PromiseWrapper.resolve(false); + } + + instruction.router = this; + this._startNavigating(); + + var result = this._pipeline.process(instruction) + .then((_) => { + ObservableWrapper.callNext(this._subject, instruction.matchedUrl); + }) + .then((_) => this._finishNavigating()); + + PromiseWrapper.catchError(result, (_) => this._finishNavigating()); + + return result; + } + + _startNavigating() { + this.navigating = true; + } + + _finishNavigating() { + this.navigating = false; + } + + /** + * Subscribe to URL updates from the router + */ + subscribe(onNext) { + ObservableWrapper.subscribe(this._subject, onNext); + } + + + activateOutlets(instruction:Instruction):Promise { + return this._queryOutlets((outlet, name) => { + return outlet.activate(instruction.getChildInstruction(name)); + }) + .then((_) => instruction.mapChildrenAsync((instruction, _) => { + return instruction.router.activateOutlets(instruction); + })); + } + + traverseOutlets(fn):Promise { + return this._queryOutlets(fn) + .then((_) => mapObjAsync(this._children, (child, _) => child.traverseOutlets(fn))); + } + + _queryOutlets(fn):Promise { + return mapObjAsync(this._outlets, fn); + } + + + /** + * Given a URL, returns an instruction representing the component graph + */ + recognize(url:string) { + return this._registry.recognize(url); + } + + + /** + * Navigates to either the last URL successfully navigated to, or the last URL requested if the router has yet to successfully navigate. + */ + renavigate():Promise { + var destination = isBlank(this.previousUrl) ? this.lastNavigationAttempt : this.previousUrl; + if (this.navigating || isBlank(destination)) { + return PromiseWrapper.resolve(false); + } + return this.navigate(destination); + } + + + /** + * Generate a URL from a component name and optional map of parameters. The URL is relative to the app's base href. + */ + generate(name:string, params:any) { + return this._registry.generate(name, params); + } + + static getRoot():Router { + return new RootRouter(new Pipeline()); + } +} + +export class RootRouter extends Router { + constructor(pipeline:Pipeline) { + super(new RouteRegistry(), pipeline, null, '/'); + } +} + +class ChildRouter extends Router { + constructor(parent, name) { + super(parent._registry, parent._pipeline, parent, name); + this.parent = parent; + } +} + +function mapObjAsync(obj:Map, fn) { + return PromiseWrapper.all(mapObj(obj, fn)); +} + +function mapObj(obj:Map, fn):List { + var result = ListWrapper.create(); + MapWrapper.forEach(obj, (value, key) => ListWrapper.push(result, fn(value, key))); + return result; +} diff --git a/modules/angular2/src/router/router_link.js b/modules/angular2/src/router/router_link.js new file mode 100644 index 0000000000..27559b62eb --- /dev/null +++ b/modules/angular2/src/router/router_link.js @@ -0,0 +1,65 @@ +import {Decorator} from 'angular2/annotations'; +import {NgElement} from 'angular2/core'; + +import {isPresent} from 'angular2/src/facade/lang'; +import {DOM} from 'angular2/src/dom/dom_adapter'; + +import {Router} from './router'; + +/** + * The RouterLink directive lets you link to specific parts of your app. + * + * + * Consider the following route configuration: + + * ``` + * @RouteConfig({ + * path: '/user', component: UserCmp, alias: 'user' + * }); + * class MyComp {} + * ``` + * + * When linking to a route, you can write: + * + * ``` + * link to user component + * ``` + * + * @exportedAs angular2/router + */ +@Decorator({ + selector: '[router-link]', + properties: { + 'route': 'routerLink', + 'params': 'routerParams' + } +}) +export class RouterLink { + _domEl; + _route:string; + _params:any; + _router:Router; + //TODO: handle click events + + constructor(ngEl:NgElement, router:Router) { + this._domEl = ngEl.domElement; + this._router = router; + } + + set route(changes) { + this._route = changes; + this.updateHref(); + } + + set params(changes) { + this._params = changes; + this.updateHref(); + } + + updateHref() { + if (isPresent(this._route) && isPresent(this._params)) { + var newHref = this._router.generate(this._route, this._params); + DOM.setAttribute(this._domEl, 'href', newHref); + } + } +} diff --git a/modules/angular2/src/router/router_outlet.js b/modules/angular2/src/router/router_outlet.js new file mode 100644 index 0000000000..facfba8eed --- /dev/null +++ b/modules/angular2/src/router/router_outlet.js @@ -0,0 +1,46 @@ +import {Promise, PromiseWrapper} from 'angular2/src/facade/async'; + +import {Decorator} from 'angular2/annotations'; +import {Compiler, ViewContainerRef} from 'angular2/core'; +import {Injector, bind} from 'angular2/di'; + +import * as routerMod from './router'; +import {Instruction, RouteParams} from './instruction' + +@Decorator({ + selector: 'router-outlet' +}) +export class RouterOutlet { + _compiler:Compiler; + _injector:Injector; + _router:routerMod.Router; + _viewContainer:ViewContainerRef; + + constructor(viewContainer:ViewContainerRef, compiler:Compiler, router:routerMod.Router, injector:Injector) { + this._router = router; + this._viewContainer = viewContainer; + this._compiler = compiler; + this._injector = injector; + this._router.registerOutlet(this); + } + + activate(instruction:Instruction) { + return this._compiler.compileInHost(instruction.component).then((pv) => { + var outletInjector = this._injector.resolveAndCreateChild([ + bind(RouteParams).toValue(new RouteParams(instruction.params)), + bind(routerMod.Router).toValue(instruction.router) + ]); + + this._viewContainer.clear(); + this._viewContainer.create(0, pv, outletInjector); + }); + } + + canActivate(instruction:any) { + return PromiseWrapper.resolve(true); + } + + canDeactivate(instruction:any) { + return PromiseWrapper.resolve(true); + } +} diff --git a/modules/angular2/src/router/url.js b/modules/angular2/src/router/url.js new file mode 100644 index 0000000000..5b0085ee80 --- /dev/null +++ b/modules/angular2/src/router/url.js @@ -0,0 +1,13 @@ +import {RegExpWrapper, StringWrapper} from 'angular2/src/facade/lang'; + +var specialCharacters = [ + '/', '.', '*', '+', '?', '|', '(', ')', '[', ']', '{', '}', '\\' +]; + +var escapeRe = RegExpWrapper.create('(\\' + specialCharacters.join('|\\') + ')', 'g'); + +export function escapeRegex(string:string) { + return StringWrapper.replaceAllMapped(string, escapeRe, (match) => { + return "\\" + match; + }); +} diff --git a/modules/angular2/test/router/outlet_spec.js b/modules/angular2/test/router/outlet_spec.js new file mode 100644 index 0000000000..6cafe59b8b --- /dev/null +++ b/modules/angular2/test/router/outlet_spec.js @@ -0,0 +1,156 @@ +import { + AsyncTestCompleter, + beforeEach, + ddescribe, + xdescribe, + describe, + el, + expect, + iit, + inject, + beforeEachBindings, + it, + xit + } from 'angular2/test_lib'; + +import {TestBed} from 'angular2/test'; + +import {Injector, bind} from 'angular2/di'; +import {Component, Viewport} from 'angular2/annotations'; +import {View} from 'angular2/src/core/annotations/view'; + +import {RootRouter} from 'angular2/src/router/router'; +import {Pipeline} from 'angular2/src/router/pipeline'; +import {Router, RouterOutlet, RouterLink, RouteConfig, RouteParams} from 'angular2/router'; + +import {DOM} from 'angular2/src/dom/dom_adapter'; + +export function main() { + describe('Outlet Directive', () => { + + var ctx, tb, view, router; + + beforeEach(inject([TestBed], (testBed) => { + tb = testBed; + ctx = new MyComp(); + })); + + beforeEachBindings(() => { + router = new RootRouter(new Pipeline()); + return [ + bind(Router).toValue(router) + ]; + }); + + function compile(template:string = "") { + tb.overrideView(MyComp, new View({template: ('
' + template + '
'), directives: [RouterOutlet, RouterLink]})); + return tb.createView(MyComp, {context: ctx}).then((v) => { + view = v; + }); + } + + it('should work in a simple case', inject([AsyncTestCompleter], (async) => { + compile() + .then((_) => router.config('/test', HelloCmp)) + .then((_) => router.navigate('/test')) + .then((_) => { + view.detectChanges(); + expect(view.rootNodes).toHaveText('hello'); + async.done(); + }); + })); + + + it('should navigate between components with different parameters', inject([AsyncTestCompleter], (async) => { + compile() + .then((_) => router.config('/user/:name', UserCmp)) + .then((_) => router.navigate('/user/brian')) + .then((_) => { + view.detectChanges(); + expect(view.rootNodes).toHaveText('hello brian'); + }) + .then((_) => router.navigate('/user/igor')) + .then((_) => { + view.detectChanges(); + expect(view.rootNodes).toHaveText('hello igor'); + async.done(); + }); + })); + + + it('should work with child routers', inject([AsyncTestCompleter], (async) => { + compile('outer { }') + .then((_) => router.config('/a', ParentCmp)) + .then((_) => router.navigate('/a/b')) + .then((_) => { + view.detectChanges(); + expect(view.rootNodes).toHaveText('outer { inner { hello } }'); + async.done(); + }); + })); + + + it('should generate link hrefs', inject([AsyncTestCompleter], (async) => { + ctx.name = 'brian'; + compile('{{name}}') + .then((_) => router.config('/user/:name', UserCmp, 'user')) + .then((_) => router.navigate('/a/b')) + .then((_) => { + view.detectChanges(); + expect(view.rootNodes).toHaveText('brian'); + expect(DOM.getAttribute(view.rootNodes[0].childNodes[0], 'href')).toEqual('/user/brian'); + async.done(); + }); + })); + + }); +} + + +@Component({ + selector: 'hello-cmp' +}) +@View({ + template: "{{greeting}}" +}) +class HelloCmp { + greeting:string; + constructor() { + this.greeting = "hello"; + } +} + + +@Component({ + selector: 'user-cmp' +}) +@View({ + template: "hello {{user}}" +}) +class UserCmp { + user:string; + constructor(params:RouteParams) { + this.user = params.get('name'); + } +} + + +@Component({ + selector: 'parent-cmp' +}) +@View({ + template: "inner { }", + directives: [RouterOutlet] +}) +@RouteConfig({ + path: '/b', + component: HelloCmp +}) +class ParentCmp { + constructor() {} +} + +@Component() +class MyComp { + name; +} diff --git a/modules/angular2/test/router/route_recognizer_spec.js b/modules/angular2/test/router/route_recognizer_spec.js new file mode 100644 index 0000000000..43c4a23ee4 --- /dev/null +++ b/modules/angular2/test/router/route_recognizer_spec.js @@ -0,0 +1,61 @@ +import { + AsyncTestCompleter, + describe, + it, iit, + ddescribe, expect, + inject, beforeEach, + SpyObject} from 'angular2/test_lib'; + +import {RouteRecognizer} from 'angular2/src/router/route_recognizer'; + +export function main() { + describe('RouteRecognizer', () => { + var recognizer; + var handler = { + 'components': { 'a': 'b' } + }; + + beforeEach(() => { + recognizer = new RouteRecognizer(); + }); + + it('should work with a static segment', () => { + recognizer.addConfig('/test', handler); + + expect(recognizer.recognize('/test')[0]).toEqual({ + 'handler': { 'components': { 'a': 'b' } }, + 'params': {}, + 'matchedUrl': '/test', + 'unmatchedUrl': '' + }); + }); + + it('should work with a dynamic segment', () => { + recognizer.addConfig('/user/:name', handler); + expect(recognizer.recognize('/user/brian')[0]).toEqual({ + 'handler': handler, + 'params': { 'name': 'brian' }, + 'matchedUrl': '/user/brian', + 'unmatchedUrl': '' + }); + }); + + it('should allow redirects', () => { + recognizer.addRedirect('/a', '/b'); + recognizer.addConfig('/b', handler); + var solutions = recognizer.recognize('/a'); + expect(solutions.length).toBe(1); + expect(solutions[0]).toEqual({ + 'handler': handler, + 'params': {}, + 'matchedUrl': '/b', + 'unmatchedUrl': '' + }); + }); + + it('should generate URLs', () => { + recognizer.addConfig('/app/user/:name', handler, 'user'); + expect(recognizer.generate('user', {'name' : 'misko'})).toEqual('/app/user/misko'); + }); + }); +} diff --git a/modules/angular2/test/router/route_registry_spec.js b/modules/angular2/test/router/route_registry_spec.js new file mode 100644 index 0000000000..773dad65a0 --- /dev/null +++ b/modules/angular2/test/router/route_registry_spec.js @@ -0,0 +1,41 @@ +import { + AsyncTestCompleter, + describe, + it, iit, + ddescribe, expect, + inject, beforeEach, + SpyObject} from 'angular2/test_lib'; + +import {RouteRegistry} from 'angular2/src/router/route_registry'; + +export function main() { + describe('RouteRegistry', () => { + var registry; + var handler = {}; + var handler2 = {}; + + beforeEach(() => { + registry = new RouteRegistry(); + }); + + it('should match the full URL', () => { + registry.config('/', '/', handler); + registry.config('/', '/test', handler2); + + var instruction = registry.recognize('/test'); + + expect(instruction.getChildInstruction('default').component).toBe(handler2); + }); + + it('should match the full URL recursively', () => { + registry.config('/', '/first', handler); + registry.config(handler, '/second', handler2); + + var instruction = registry.recognize('/first/second'); + + expect(instruction.getChildInstruction('default').component).toBe(handler); + expect(instruction.getChildInstruction('default').getChildInstruction('default').component).toBe(handler2); + }); + + }); +} diff --git a/modules/angular2/test/router/router_spec.js b/modules/angular2/test/router/router_spec.js new file mode 100644 index 0000000000..a63adfee65 --- /dev/null +++ b/modules/angular2/test/router/router_spec.js @@ -0,0 +1,53 @@ +import { + AsyncTestCompleter, + describe, + proxy, + it, iit, + ddescribe, expect, + inject, beforeEach, + SpyObject} from 'angular2/test_lib'; +import {IMPLEMENTS} from 'angular2/src/facade/lang'; + +import {Promise, PromiseWrapper} from 'angular2/src/facade/async'; +import {RootRouter, Viewport} from 'angular2/src/router/router'; +import {Pipeline} from 'angular2/src/router/pipeline'; +import {RouterOutlet} from 'angular2/src/router/router_outlet'; + + +export function main() { + describe('Router', () => { + var router; + + beforeEach(() => { + router = new RootRouter(new Pipeline()); + }); + + it('should navigate after being configured', inject([AsyncTestCompleter], (async) => { + var outlet = makeDummyRef(); + + router.registerOutlet(outlet) + .then((_) => router.navigate('/a')) + .then((_) => { + expect(outlet.spy('activate')).not.toHaveBeenCalled(); + return router.config('/a', {'component': 'A' }); + }) + .then((_) => { + expect(outlet.spy('activate')).toHaveBeenCalled(); + async.done(); + }); + })); + }); +} + +@proxy +@IMPLEMENTS(RouterOutlet) +class DummyOutletRef extends SpyObject {noSuchMethod(m){return super.noSuchMethod(m)}} + +function makeDummyRef() { + var ref = new DummyOutletRef(); + ref.spy('activate').andCallFake((_) => PromiseWrapper.resolve(true)); + ref.spy('canActivate').andCallFake((_) => PromiseWrapper.resolve(true)); + ref.spy('canDeactivate').andCallFake((_) => PromiseWrapper.resolve(true)); + ref.spy('deactivate').andCallFake((_) => PromiseWrapper.resolve(true)); + return ref; +}