refactor(angular_1_router): use directives for route targets

BREAKING CHANGE:

Previously, route configuration took a controller constructor function as the value of
`component` in a route definition:

```
$route.config([
  { route: '/', component: MyController }
])
```

Based on the name of the controller, we used to use a componentMapper service to
determine what template to pair with each controller, how to bind the instance to
the $scope.

To make the 1.x router more semantically alligned with Angular 2, we now route to a directive.
Thus a route configuration takes a normalized directive name:

```
$route.config([
  { route: '/', component: 'myDirective' }
])
```

BREAKING CHANGE:

In order to avoid name collisions, lifecycle hooks are now prefixed with `$`. Before:

```
MyController.prototype.onActivate = ...
```

After:

```
MyController.prototype.$onActivate = ...
```

Same for `$canActivate` (which now lives on the directive factory function),
`$canDeactivate`, `$canReuse`, and `$onDeactivate` hooks.
This commit is contained in:
Brian Ford 2015-09-18 15:53:50 -07:00
parent 6e0ca7f39a
commit 5205a9e65f
12 changed files with 547 additions and 724 deletions

View File

@ -22,12 +22,12 @@ var PRELUDE = '(function(){\n';
var POSTLUDE = '\n}());\n'; var POSTLUDE = '\n}());\n';
var FACADES = fs.readFileSync(__dirname + '/lib/facades.es5', 'utf8'); var FACADES = fs.readFileSync(__dirname + '/lib/facades.es5', 'utf8');
var DIRECTIVES = fs.readFileSync(__dirname + '/src/ng_outlet.js', 'utf8'); var DIRECTIVES = fs.readFileSync(__dirname + '/src/ng_outlet.js', 'utf8');
var moduleTemplate = fs.readFileSync(__dirname + '/src/module_template.js', 'utf8');
function main() { function main() {
var ES6_SHIM = fs.readFileSync(__dirname + '/../../node_modules/es6-shim/es6-shim.js', 'utf8'); var ES6_SHIM = fs.readFileSync(__dirname + '/../../node_modules/es6-shim/es6-shim.js', 'utf8');
var dir = __dirname + '/../angular2/src/router/'; var dir = __dirname + '/../angular2/src/router/';
var out = '';
var sharedCode = ''; var sharedCode = '';
files.forEach(function (file) { files.forEach(function (file) {
var moduleName = 'router/' + file.replace(/\.ts$/, ''); var moduleName = 'router/' + file.replace(/\.ts$/, '');
@ -35,57 +35,9 @@ function main() {
sharedCode += transform(moduleName, fs.readFileSync(dir + file, 'utf8')); sharedCode += transform(moduleName, fs.readFileSync(dir + file, 'utf8'));
}); });
out += "angular.module('ngComponentRouter')"; var out = moduleTemplate.replace('//{{FACADES}}', FACADES).replace('//{{SHARED_CODE}}', sharedCode);
out += angularFactory('$router', ['$q', '$location', '$$controllerIntrospector',
'$browser', '$rootScope', '$injector'], [
FACADES,
"var exports = {Injectable: function () {}};",
"var require = function () {return exports;};",
sharedCode,
"var RouteConfig = exports.RouteConfig;",
"angular.annotations = {RouteConfig: RouteConfig, CanActivate: exports.CanActivate};",
"angular.stringifyInstruction = exports.stringifyInstruction;",
"var RouteRegistry = exports.RouteRegistry;",
"var RootRouter = exports.RootRouter;",
//TODO: move this code into a templated JS file
"var registry = new RouteRegistry();",
"var location = new Location();",
"$$controllerIntrospector(function (name, constructor) {", return PRELUDE + DIRECTIVES + out + POSTLUDE;
"if (constructor.$canActivate) {",
"constructor.annotations = constructor.annotations || [];",
"constructor.annotations.push(new angular.annotations.CanActivate(function (instruction) {",
"return $injector.invoke(constructor.$canActivate, constructor, {",
"$routeParams: instruction.component ? instruction.component.params : instruction.params",
"});",
"}));",
"}",
"if (constructor.$routeConfig) {",
"constructor.annotations = constructor.annotations || [];",
"constructor.annotations.push(new angular.annotations.RouteConfig(constructor.$routeConfig));",
"}",
"if (constructor.annotations) {",
"constructor.annotations.forEach(function(annotation) {",
"if (annotation instanceof RouteConfig) {",
"annotation.configs.forEach(function (config) {",
"registry.config(constructor, config);",
"});",
"}",
"});",
"}",
"});",
"var router = new RootRouter(registry, location, new Object());",
"$rootScope.$watch(function () { return $location.path(); }, function (path) {",
"if (router.lastNavigationAttempt !== path) {",
"router.navigateByUrl(path);",
"}",
"});",
"return router;"
].join('\n'));
return PRELUDE + ES6_SHIM + DIRECTIVES + out + POSTLUDE;
} }

View File

@ -32,6 +32,10 @@ function isArray(obj) {
return Array.isArray(obj); return Array.isArray(obj);
} }
function getTypeNameForDebugging (fn) {
return fn.name || 'Root';
}
var PromiseWrapper = { var PromiseWrapper = {
resolve: function (reason) { resolve: function (reason) {
return $q.when(reason); return $q.when(reason);
@ -251,8 +255,8 @@ var StringWrapper = {
return s.replace(from, replace); return s.replace(from, replace);
}, },
startsWith: function(s, start) { startsWith: function(s, start) {
return s.startsWith(start); return s.substr(0, start.length) === start;
}, },
replaceAllMapped: function(s, from, cb) { replaceAllMapped: function(s, from, cb) {
@ -272,14 +276,18 @@ var StringWrapper = {
//TODO: implement? //TODO: implement?
// I think it's too heavy to ask 1.x users to bring in Rx for the router... // I think it's too heavy to ask 1.x users to bring in Rx for the router...
function EventEmitter() { function EventEmitter() {}
}
var BaseException = Error; var BaseException = Error;
var ObservableWrapper = { var ObservableWrapper = {
callNext: function(){} callNext: function(ob, val) {
ob.fn(val);
},
subscribe: function(ob, fn) {
ob.fn = fn;
}
}; };
// TODO: https://github.com/angular/angular.js/blob/master/src/ng/browser.js#L227-L265 // TODO: https://github.com/angular/angular.js/blob/master/src/ng/browser.js#L227-L265

View File

@ -0,0 +1,66 @@
angular.module('ngComponentRouter').
value('$route', null). // can be overloaded with ngRouteShim
factory('$router', ['$q', '$location', '$$directiveIntrospector', '$browser', '$rootScope', '$injector', '$route', routerFactory]);
function routerFactory($q, $location, $$directiveIntrospector, $browser, $rootScope, $injector) {
// When this file is processed, the line below is replaced with
// the contents of `../lib/facades.es5`.
//{{FACADES}}
var exports = {Injectable: function () {}};
var require = function () {return exports;};
// When this file is processed, the line below is replaced with
// the contents of the compiled TypeScript classes.
//{{SHARED_CODE}}
//TODO: this is a hack to replace the exiting implementation at run-time
exports.getCanActivateHook = function (directiveName) {
var factory = $$directiveIntrospector.getTypeByName(directiveName);
return factory && factory.$canActivate && function (next, prev) {
return $injector.invoke(factory.$canActivate, null, {
$nextInstruction: next,
$prevInstruction: prev
});
};
};
// This hack removes assertions about the type of the "component"
// property in a route config
exports.assertComponentExists = function () {};
angular.stringifyInstruction = exports.stringifyInstruction;
var RouteRegistry = exports.RouteRegistry;
var RootRouter = exports.RootRouter;
var registry = new RouteRegistry();
var location = new Location();
$$directiveIntrospector(function (name, factory) {
if (angular.isArray(factory.$routeConfig)) {
factory.$routeConfig.forEach(function (config) {
registry.config(name, config);
});
}
});
// Because Angular 1 has no notion of a root component, we use an object with unique identity
// to represent this.
var ROOT_COMPONENT_OBJECT = new Object();
var router = new RootRouter(registry, location, ROOT_COMPONENT_OBJECT);
$rootScope.$watch(function () { return $location.path(); }, function (path) {
if (router.lastNavigationAttempt !== path) {
router.navigateByUrl(path);
}
});
router.subscribe(function () {
$rootScope.$broadcast('$routeChangeSuccess', {});
});
return router;
}

View File

@ -4,69 +4,63 @@
* A module for adding new a routing system Angular 1. * A module for adding new a routing system Angular 1.
*/ */
angular.module('ngComponentRouter', []) angular.module('ngComponentRouter', [])
.factory('$componentMapper', $componentMapperFactory)
.directive('ngOutlet', ngOutletDirective) .directive('ngOutlet', ngOutletDirective)
.directive('ngOutlet', ngOutletFillContentDirective) .directive('ngOutlet', ngOutletFillContentDirective)
.directive('ngLink', ngLinkDirective) .directive('ngLink', ngLinkDirective);
.directive('a', anchorLinkDirective); // TODO: make the anchor link feature configurable
/* /*
* A module for inspecting controller constructors * A module for inspecting controller constructors
*/ */
angular.module('ng') angular.module('ng')
.provider('$$controllerIntrospector', $$controllerIntrospectorProvider) .provider('$$directiveIntrospector', $$directiveIntrospectorProvider)
.config(controllerProviderDecorator); .config(compilerProviderDecorator);
/* /*
* decorates with routing info * decorates $compileProvider so that we have access to routing metadata
*/ */
function controllerProviderDecorator($controllerProvider, $$controllerIntrospectorProvider) { function compilerProviderDecorator($compileProvider, $$directiveIntrospectorProvider) {
var register = $controllerProvider.register; var directive = $compileProvider.directive;
$controllerProvider.register = function (name, ctrl) { $compileProvider.directive = function (name, factory) {
$$controllerIntrospectorProvider.register(name, ctrl); $$directiveIntrospectorProvider.register(name, factory);
return register.apply(this, arguments); return directive.apply(this, arguments);
}; };
} }
// TODO: decorate $controller ?
/* /*
* private service that holds route mappings for each controller * private service that holds route mappings for each controller
*/ */
function $$controllerIntrospectorProvider() { function $$directiveIntrospectorProvider() {
var controllers = []; var directiveBuffer = [];
var controllersByName = {}; var directiveFactoriesByName = {};
var onControllerRegistered = null; var onDirectiveRegistered = null;
return { return {
register: function (name, constructor) { register: function (name, factory) {
if (angular.isArray(constructor)) { if (angular.isArray(factory)) {
constructor = constructor[constructor.length - 1]; factory = factory[factory.length - 1];
} }
controllersByName[name] = constructor; directiveFactoriesByName[name] = factory;
constructor.$$controllerName = name; if (onDirectiveRegistered) {
if (onControllerRegistered) { onDirectiveRegistered(name, factory);
onControllerRegistered(name, constructor);
} else { } else {
controllers.push({name: name, constructor: constructor}); directiveBuffer.push({name: name, factory: factory});
} }
}, },
$get: ['$componentMapper', function ($componentMapper) { $get: function () {
var fn = function (newOnControllerRegistered) { var fn = function (newOnControllerRegistered) {
onControllerRegistered = function (name, constructor) { onDirectiveRegistered = newOnControllerRegistered;
name = $componentMapper.component(name); while (directiveBuffer.length > 0) {
return newOnControllerRegistered(name, constructor); var directive = directiveBuffer.pop();
}; onDirectiveRegistered(directive.name, directive.factory);
while (controllers.length > 0) {
var rule = controllers.pop();
onControllerRegistered(rule.name, rule.constructor);
} }
}; };
fn.getTypeByName = function (name) { fn.getTypeByName = function (name) {
return controllersByName[name]; return directiveFactoriesByName[name];
}; };
return fn; return fn;
}] }
}; };
} }
@ -85,7 +79,7 @@ function $$controllerIntrospectorProvider() {
* *
* The value for the `ngOutlet` attribute is optional. * The value for the `ngOutlet` attribute is optional.
*/ */
function ngOutletDirective($animate, $q, $router, $componentMapper, $controller, $templateRequest) { function ngOutletDirective($animate, $q, $router) {
var rootRouter = $router; var rootRouter = $router;
return { return {
@ -105,10 +99,12 @@ function ngOutletDirective($animate, $q, $router, $componentMapper, $controller,
myCtrl = ctrls[1], myCtrl = ctrls[1],
router = (parentCtrl && parentCtrl.$$router) || rootRouter; router = (parentCtrl && parentCtrl.$$router) || rootRouter;
myCtrl.$$currentComponent = null;
var childRouter, var childRouter,
currentController,
currentInstruction, currentInstruction,
currentScope, currentScope,
currentController,
currentElement, currentElement,
previousLeaveAnimation; previousLeaveAnimation;
@ -136,8 +132,8 @@ function ngOutletDirective($animate, $q, $router, $componentMapper, $controller,
var next = $q.when(true); var next = $q.when(true);
var previousInstruction = currentInstruction; var previousInstruction = currentInstruction;
currentInstruction = instruction; currentInstruction = instruction;
if (currentController.onReuse) { if (currentController && currentController.$onReuse) {
next = $q.when(currentController.onReuse(currentInstruction, previousInstruction)); next = $q.when(currentController.$onReuse(currentInstruction, previousInstruction));
} }
return next; return next;
@ -147,8 +143,8 @@ function ngOutletDirective($animate, $q, $router, $componentMapper, $controller,
if (!currentInstruction || if (!currentInstruction ||
currentInstruction.componentType !== nextInstruction.componentType) { currentInstruction.componentType !== nextInstruction.componentType) {
result = false; result = false;
} else if (currentController.canReuse) { } else if (currentController && currentController.$canReuse) {
result = currentController.canReuse(nextInstruction, currentInstruction); result = currentController.$canReuse(nextInstruction, currentInstruction);
} else { } else {
result = nextInstruction === currentInstruction || result = nextInstruction === currentInstruction ||
angular.equals(nextInstruction.params, currentInstruction.params); angular.equals(nextInstruction.params, currentInstruction.params);
@ -156,60 +152,59 @@ function ngOutletDirective($animate, $q, $router, $componentMapper, $controller,
return $q.when(result); return $q.when(result);
}, },
canDeactivate: function (instruction) { canDeactivate: function (instruction) {
if (currentInstruction && currentController && currentController.canDeactivate) { if (currentController && currentController.$canDeactivate) {
return $q.when(currentController.canDeactivate(instruction, currentInstruction)); return $q.when(currentController.$canDeactivate(instruction, currentInstruction));
} }
return $q.when(true); return $q.when(true);
}, },
deactivate: function (instruction) { deactivate: function (instruction) {
if (currentController && currentController.onDeactivate) { if (currentController && currentController.$onDeactivate) {
return $q.when(currentController.onDeactivate(instruction, currentInstruction)); return $q.when(currentController.$onDeactivate(instruction, currentInstruction));
} }
return $q.when(); return $q.when();
}, },
activate: function (instruction) { activate: function (instruction) {
var previousInstruction = currentInstruction; var previousInstruction = currentInstruction;
currentInstruction = instruction; currentInstruction = instruction;
childRouter = router.childRouter(instruction.componentType);
var controllerConstructor, componentName; var componentName = myCtrl.$$componentName = instruction.componentType;
controllerConstructor = instruction.componentType;
componentName = $componentMapper.component(controllerConstructor.$$controllerName);
var componentTemplateUrl = $componentMapper.template(componentName); if (typeof componentName != 'string') {
return $templateRequest(componentTemplateUrl).then(function (templateHtml) { throw new Error('Component is not a string for ' + instruction.urlPath);
myCtrl.$$router = childRouter; }
myCtrl.$$template = templateHtml;
}).then(function () {
var newScope = scope.$new();
var locals = {
$scope: newScope,
$router: childRouter,
$routeParams: (instruction.params || {})
};
// todo(shahata): controllerConstructor is not minify friendly myCtrl.$$routeParams = instruction.params;
currentController = $controller(controllerConstructor, locals);
var clone = $transclude(newScope, function (clone) { myCtrl.$$template = '<div ' + dashCase(componentName) + '></div>';
$animate.enter(clone, null, currentElement || $element);
cleanupLastView();
});
var controllerAs = $componentMapper.controllerAs(componentName) || componentName; myCtrl.$$router = router.childRouter(instruction.componentType);
newScope[controllerAs] = currentController;
currentElement = clone;
currentScope = newScope;
if (currentController.onActivate) { var newScope = scope.$new();
return currentController.onActivate(instruction, previousInstruction);
} var clone = $transclude(newScope, function (clone) {
$animate.enter(clone, null, currentElement || $element);
cleanupLastView();
}); });
currentElement = clone;
currentScope = newScope;
// TODO: prefer the other directive retrieving the controller
// by debug mode
currentController = currentElement.children().eq(0).controller(componentName);
if (currentController && currentController.$onActivate) {
return currentController.$onActivate(instruction, previousInstruction);
}
return $q.when();
} }
}); });
} }
} }
/**
* This directive is responsible for compiling the contents of ng-outlet
*/
function ngOutletFillContentDirective($compile) { function ngOutletFillContentDirective($compile) {
return { return {
restrict: 'EA', restrict: 'EA',
@ -220,6 +215,15 @@ function ngOutletFillContentDirective($compile) {
$element.html(template); $element.html(template);
var link = $compile($element.contents()); var link = $compile($element.contents());
link(scope); link(scope);
// TODO: move to primary directive
var componentInstance = scope[ctrl.$$componentName];
if (componentInstance) {
ctrl.$$currentComponent = componentInstance;
componentInstance.$router = ctrl.$$router;
componentInstance.$routeParams = ctrl.$$routeParams;
}
} }
}; };
} }
@ -249,7 +253,7 @@ function ngOutletFillContentDirective($compile) {
* </div> * </div>
* ``` * ```
*/ */
function ngLinkDirective($router, $location, $parse) { function ngLinkDirective($router, $parse) {
var rootRouter = $router; var rootRouter = $router;
return { return {
@ -264,10 +268,12 @@ function ngLinkDirective($router, $location, $parse) {
return; return;
} }
var instruction = null;
var link = attrs.ngLink || ''; var link = attrs.ngLink || '';
function getLink(params) { function getLink(params) {
return './' + angular.stringifyInstruction(router.generate(params)); instruction = router.generate(params);
return './' + angular.stringifyInstruction(instruction);
} }
var routeParamsGetter = $parse(link); var routeParamsGetter = $parse(link);
@ -282,128 +288,16 @@ function ngLinkDirective($router, $location, $parse) {
elt.attr('href', getLink(params)); elt.attr('href', getLink(params));
}, true); }, true);
} }
}
}
elt.on('click', function (event) {
function anchorLinkDirective($router) { if (event.which !== 1 || !instruction) {
return {
restrict: 'E',
link: function (scope, element) {
// If the linked element is not an anchor tag anymore, do nothing
if (element[0].nodeName.toLowerCase() !== 'a') {
return; return;
} }
// SVGAElement does not use the href attribute, but rather the 'xlinkHref' attribute. $router.navigateByInstruction(instruction);
var hrefAttrName = Object.prototype.toString.call(element.prop('href')) === '[object SVGAnimatedString]' ? event.preventDefault();
'xlink:href' : 'href'; });
}
element.on('click', function (event) {
if (event.which !== 1) {
return;
}
var href = element.attr(hrefAttrName);
if (href && $router.recognize(href)) {
$router.navigateByUrl(href);
event.preventDefault();
}
});
}
};
}
/**
* @name $componentMapperFactory
* @description
*
* This lets you configure conventions for what controllers are named and where to load templates from.
*
* The default behavior is to dasherize and serve from `./components`. A component called `myWidget`
* uses a controller named `MyWidgetController` and a template loaded from `./components/my-widget/my-widget.html`.
*
* A component is:
* - a controller
* - a template
* - an optional router
*
* This service makes it easy to group all of them into a single concept.
*/
function $componentMapperFactory() {
var DEFAULT_SUFFIX = 'Controller';
var componentToCtrl = function componentToCtrlDefault(name) {
return name[0].toUpperCase() + name.substr(1) + DEFAULT_SUFFIX;
};
var componentToTemplate = function componentToTemplateDefault(name) {
var dashName = dashCase(name);
return './components/' + dashName + '/' + dashName + '.html';
};
var ctrlToComponent = function ctrlToComponentDefault(name) {
return name[0].toLowerCase() + name.substr(1, name.length - DEFAULT_SUFFIX.length - 1);
};
var componentToControllerAs = function componentToControllerAsDefault(name) {
return name;
};
return {
controllerName: function (name) {
return componentToCtrl(name);
},
controllerAs: function (name) {
return componentToControllerAs(name);
},
template: function (name) {
return componentToTemplate(name);
},
component: function (name) {
return ctrlToComponent(name);
},
/**
* @name $componentMapper#setCtrlNameMapping
* @description takes a function for mapping component names to component controller names
*/
setCtrlNameMapping: function (newFn) {
componentToCtrl = newFn;
return this;
},
/**
* @name $componentMapper#setCtrlAsMapping
* @description takes a function for mapping component names to controllerAs name in the template
*/
setCtrlAsMapping: function (newFn) {
componentToControllerAs = newFn;
return this;
},
/**
* @name $componentMapper#setComponentFromCtrlMapping
* @description takes a function for mapping component controller names to component names
*/
setComponentFromCtrlMapping: function (newFn) {
ctrlToComponent = newFn;
return this;
},
/**
* @name $componentMapper#setTemplateMapping
* @description takes a function for mapping component names to component template URLs
*/
setTemplateMapping: function (newFn) {
componentToTemplate = newFn;
return this;
}
};
} }

View File

@ -1,77 +0,0 @@
'use strict';
describe('$componentMapper', function () {
var elt,
$compile,
$rootScope,
$router,
$templateCache;
function Ctrl() {
this.message = 'howdy';
}
beforeEach(function() {
module('ng');
module('ngComponentRouter');
module(function ($controllerProvider) {
$controllerProvider.register('myComponentController', Ctrl);
});
});
it('should convert a component name to a controller name', inject(function ($componentMapper) {
expect($componentMapper.controllerName('foo')).toBe('FooController');
}));
it('should convert a controller name to a component name', inject(function ($componentMapper) {
expect($componentMapper.component('FooController')).toBe('foo');
}));
it('should convert a component name to a template URL', inject(function ($componentMapper) {
expect($componentMapper.template('foo')).toBe('./components/foo/foo.html');
}));
it('should work with a controller constructor fn and a template url', inject(function ($componentMapper) {
var routes = {};
$componentMapper.setCtrlNameMapping(function (name) {
return routes[name].controller;
});
$componentMapper.setTemplateMapping(function (name) {
return routes[name].templateUrl;
});
$componentMapper.setCtrlAsMapping(function (name) {
return 'ctrl';
});
routes.myComponent = {
controller: Ctrl,
templateUrl: '/foo'
};
inject(function(_$compile_, _$rootScope_, _$router_, _$templateCache_) {
$compile = _$compile_;
$rootScope = _$rootScope_;
$router = _$router_;
$templateCache = _$templateCache_;
});
$templateCache.put('/foo', [200, '{{ctrl.message}}', {}]);
compile('<ng-outlet></ng-outlet>');
$router.config([
{ path: '/', component: Ctrl }
]);
$router.navigateByUrl('/');
$rootScope.$digest();
expect(elt.text()).toBe('howdy');
}));
function compile(template) {
elt = $compile('<div>' + template + '</div>')($rootScope);
$rootScope.$digest();
return elt;
}
});

View File

@ -1,38 +0,0 @@
'use strict';
describe('$$controllerIntrospector', function () {
var $controllerProvider;
beforeEach(function() {
module('ng');
module('ngComponentRouter');
module(function(_$controllerProvider_) {
$controllerProvider = _$controllerProvider_;
});
});
it('should call the introspector function whenever a controller is registered', inject(function ($$controllerIntrospector) {
var spy = jasmine.createSpy();
$$controllerIntrospector(spy);
function Ctrl(){}
$controllerProvider.register('SomeController', Ctrl);
expect(spy).toHaveBeenCalledWith('some', Ctrl);
}));
it('should call the introspector function whenever a controller is registered with array annotations', inject(function ($$controllerIntrospector) {
var spy = jasmine.createSpy();
$$controllerIntrospector(spy);
function Ctrl(foo){}
$controllerProvider.register('SomeController', ['foo', Ctrl]);
expect(spy).toHaveBeenCalledWith('some', Ctrl);
}));
it('should retrieve a constructor', inject(function ($$controllerIntrospector) {
function Ctrl(foo){}
$controllerProvider.register('SomeController', ['foo', Ctrl]);
expect($$controllerIntrospector.getTypeByName('SomeController')).toBe(Ctrl);
}));
});

View File

@ -0,0 +1,38 @@
'use strict';
describe('$$directiveIntrospector', function () {
var $compileProvider;
beforeEach(function() {
module('ng');
module('ngComponentRouter');
module(function(_$compileProvider_) {
$compileProvider = _$compileProvider_;
});
});
it('should call the introspector function whenever a directive factory is registered', inject(function ($$directiveIntrospector) {
var spy = jasmine.createSpy();
$$directiveIntrospector(spy);
function myDir(){}
$compileProvider.directive('myDir', myDir);
expect(spy).toHaveBeenCalledWith('myDir', myDir);
}));
it('should call the introspector function whenever a directive factory is registered with array annotations', inject(function ($$directiveIntrospector) {
var spy = jasmine.createSpy();
$$directiveIntrospector(spy);
function myDir(){}
$compileProvider.directive('myDir', ['foo', myDir]);
expect(spy).toHaveBeenCalledWith('myDir', myDir);
}));
it('should retrieve a factory based on directive name', inject(function ($$directiveIntrospector) {
function myDir(){}
$compileProvider.directive('myDir', ['foo', myDir]);
expect($$directiveIntrospector.getTypeByName('myDir')).toBe(myDir);
}));
});

View File

@ -2,35 +2,31 @@
describe('ngOutlet animations', function () { describe('ngOutlet animations', function () {
var elt, var elt,
$animate, $animate,
$compile, $compile,
$rootScope, $rootScope,
$router, $router,
$templateCache, $compileProvider;
$controllerProvider;
function UserController($routeParams) {
this.name = $routeParams.name;
}
beforeEach(function () { beforeEach(function () {
module('ng');
module('ngAnimate'); module('ngAnimate');
module('ngAnimateMock'); module('ngAnimateMock');
module('ngComponentRouter'); module('ngComponentRouter');
module(function (_$controllerProvider_) { module(function (_$compileProvider_) {
$controllerProvider = _$controllerProvider_; $compileProvider = _$compileProvider_;
}); });
inject(function (_$animate_, _$compile_, _$rootScope_, _$router_, _$templateCache_) { inject(function (_$animate_, _$compile_, _$rootScope_, _$router_) {
$animate = _$animate_; $animate = _$animate_;
$compile = _$compile_; $compile = _$compile_;
$rootScope = _$rootScope_; $rootScope = _$rootScope_;
$router = _$router_; $router = _$router_;
$templateCache = _$templateCache_;
}); });
put('user', '<div>hello {{user.name}}</div>'); registerComponent('userCmp', {
$controllerProvider.register('UserController', UserController); template: '<div>hello {{userCmp.$routeParams.name}}</div>'
});
}); });
afterEach(function () { afterEach(function () {
@ -43,7 +39,7 @@ describe('ngOutlet animations', function () {
compile('<div ng-outlet></div>'); compile('<div ng-outlet></div>');
$router.config([ $router.config([
{ path: '/user/:name', component: UserController } { path: '/user/:name', component: 'userCmp' }
]); ]);
$router.navigateByUrl('/user/brian'); $router.navigateByUrl('/user/brian');
@ -70,8 +66,32 @@ describe('ngOutlet animations', function () {
expect(item.element.text()).toBe('hello brian'); expect(item.element.text()).toBe('hello brian');
}); });
function put(name, template) {
$templateCache.put(componentTemplatePath(name), [200, template, {}]); function registerComponent(name, options) {
var controller = options.controller || function () {};
['$onActivate', '$onDeactivate', '$onReuse', '$canReuse', '$canDeactivate'].forEach(function (hookName) {
if (options[hookName]) {
controller.prototype[hookName] = options[hookName];
}
});
function factory() {
return {
template: options.template || '',
controllerAs: name,
controller: controller
};
}
if (options.$canActivate) {
factory.$canActivate = options.$canActivate;
}
if (options.$routeConfig) {
factory.$routeConfig = options.$routeConfig;
}
$compileProvider.directive(name, factory);
} }
function compile(template) { function compile(template) {

View File

@ -1,53 +1,45 @@
'use strict'; 'use strict';
describe('ngOutlet', function () { describe('Navigation lifecycle', function () {
var elt, var elt,
$compile, $compile,
$rootScope, $rootScope,
$router, $router,
$templateCache, $compileProvider;
$controllerProvider,
$componentMapperProvider;
var OneController, TwoController, UserController;
function instructionFor(componentType) {
return jasmine.objectContaining({componentType: componentType});
}
beforeEach(function () { beforeEach(function () {
module('ng'); module('ng');
module('ngComponentRouter'); module('ngComponentRouter');
module(function (_$controllerProvider_, _$componentMapperProvider_) { module(function (_$compileProvider_) {
$controllerProvider = _$controllerProvider_; $compileProvider = _$compileProvider_;
$componentMapperProvider = _$componentMapperProvider_;
}); });
inject(function (_$compile_, _$rootScope_, _$router_, _$templateCache_) { inject(function (_$compile_, _$rootScope_, _$router_) {
$compile = _$compile_; $compile = _$compile_;
$rootScope = _$rootScope_; $rootScope = _$rootScope_;
$router = _$router_; $router = _$router_;
$templateCache = _$templateCache_;
}); });
UserController = registerComponent('user', '<div>hello {{user.name}}</div>', function ($routeParams) { registerComponent('oneCmp', {
this.name = $routeParams.name; template: '<div>{{oneCmp.number}}</div>',
controller: function () {this.number = 'one'}
});
registerComponent('twoCmp', {
template: '<div><a ng-link="[\'/Two\']">{{twoCmp.number}}</a></div>',
controller: function () {this.number = 'two'}
}); });
OneController = registerComponent('one', '<div>{{one.number}}</div>', boringController('number', 'one'));
TwoController = registerComponent('two', '<div>{{two.number}}</div>', boringController('number', 'two'));
}); });
it('should run the activate hook of controllers', function () { it('should run the activate hook of controllers', function () {
var spy = jasmine.createSpy('activate'); var spy = jasmine.createSpy('activate');
var activate = registerComponent('activate', '', { registerComponent('activateCmp', {
onActivate: spy template: '<p>hello</p>',
$onActivate: spy
}); });
$router.config([ $router.config([
{ path: '/a', component: activate } { path: '/a', component: 'activateCmp' }
]); ]);
compile('<div>outer { <div ng-outlet></div> }</div>'); compile('<div>outer { <div ng-outlet></div> }</div>');
@ -60,31 +52,32 @@ describe('ngOutlet', function () {
it('should pass instruction into the activate hook of a controller', function () { it('should pass instruction into the activate hook of a controller', function () {
var spy = jasmine.createSpy('activate'); var spy = jasmine.createSpy('activate');
var UserController = registerComponent('user', '', { registerComponent('userCmp', {
onActivate: spy $onActivate: spy
}); });
$router.config([ $router.config([
{ path: '/user/:name', component: UserController } { path: '/user/:name', component: 'userCmp' }
]); ]);
compile('<div ng-outlet></div>'); compile('<div ng-outlet></div>');
$router.navigateByUrl('/user/brian'); $router.navigateByUrl('/user/brian');
$rootScope.$digest(); $rootScope.$digest();
expect(spy).toHaveBeenCalledWith(instructionFor(UserController), undefined); expect(spy).toHaveBeenCalledWith(instructionFor('userCmp'), undefined);
}); });
it('should pass previous instruction into the activate hook of a controller', function () { it('should pass previous instruction into the activate hook of a controller', function () {
var spy = jasmine.createSpy('activate'); var spy = jasmine.createSpy('activate');
var activate = registerComponent('activate', '', { var activate = registerComponent('activateCmp', {
onActivate: spy template: 'hi',
$onActivate: spy
}); });
$router.config([ $router.config([
{ path: '/user/:name', component: OneController }, { path: '/user/:name', component: 'oneCmp' },
{ path: '/post/:id', component: activate } { path: '/post/:id', component: 'activateCmp' }
]); ]);
compile('<div ng-outlet></div>'); compile('<div ng-outlet></div>');
@ -92,19 +85,21 @@ describe('ngOutlet', function () {
$rootScope.$digest(); $rootScope.$digest();
$router.navigateByUrl('/post/123'); $router.navigateByUrl('/post/123');
$rootScope.$digest(); $rootScope.$digest();
expect(spy).toHaveBeenCalledWith(instructionFor(activate), expect(spy).toHaveBeenCalledWith(instructionFor('activateCmp'),
instructionFor(OneController)); instructionFor('oneCmp'));
}); });
it('should inject $scope into the controller constructor', function () { it('should inject $scope into the controller constructor', function () {
var injectedScope; var injectedScope;
var UserController = registerComponent('user', '', function ($scope) { registerComponent('userCmp', {
injectedScope = $scope; template: '',
controller: function ($scope) {
injectedScope = $scope;
}
}); });
$router.config([ $router.config([
{ path: '/user', component: UserController } { path: '/user', component: 'userCmp' }
]); ]);
compile('<div ng-outlet></div>'); compile('<div ng-outlet></div>');
@ -117,13 +112,13 @@ describe('ngOutlet', function () {
it('should run the deactivate hook of controllers', function () { it('should run the deactivate hook of controllers', function () {
var spy = jasmine.createSpy('deactivate'); var spy = jasmine.createSpy('deactivate');
var deactivate = registerComponent('deactivate', '', { registerComponent('deactivateCmp', {
onDeactivate: spy $onDeactivate: spy
}); });
$router.config([ $router.config([
{ path: '/a', component: deactivate }, { path: '/a', component: 'deactivateCmp' },
{ path: '/b', component: OneController } { path: '/b', component: 'oneCmp' }
]); ]);
compile('<div ng-outlet></div>'); compile('<div ng-outlet></div>');
@ -137,13 +132,13 @@ describe('ngOutlet', function () {
it('should pass instructions into the deactivate hook of controllers', function () { it('should pass instructions into the deactivate hook of controllers', function () {
var spy = jasmine.createSpy('deactivate'); var spy = jasmine.createSpy('deactivate');
var deactivate = registerComponent('deactivate', '', { registerComponent('deactivateCmp', {
onDeactivate: spy $onDeactivate: spy
}); });
$router.config([ $router.config([
{ path: '/user/:name', component: deactivate }, { path: '/user/:name', component: 'deactivateCmp' },
{ path: '/post/:id', component: OneController } { path: '/post/:id', component: 'oneCmp' }
]); ]);
compile('<div ng-outlet></div>'); compile('<div ng-outlet></div>');
@ -151,29 +146,29 @@ describe('ngOutlet', function () {
$rootScope.$digest(); $rootScope.$digest();
$router.navigateByUrl('/post/123'); $router.navigateByUrl('/post/123');
$rootScope.$digest(); $rootScope.$digest();
expect(spy).toHaveBeenCalledWith(instructionFor(OneController), expect(spy).toHaveBeenCalledWith(instructionFor('oneCmp'),
instructionFor(deactivate)); instructionFor('deactivateCmp'));
}); });
it('should run the deactivate hook before the activate hook', function () { it('should run the deactivate hook before the activate hook', function () {
var log = []; var log = [];
var activate = registerComponent('activate', '', { registerComponent('activateCmp', {
onActivate: function () { $onActivate: function () {
log.push('activate'); log.push('activate');
} }
}); });
var deactivate = registerComponent('deactivate', '', { registerComponent('deactivateCmp', {
onDeactivate: function () { $onDeactivate: function () {
log.push('deactivate'); log.push('deactivate');
} }
}); });
$router.config([ $router.config([
{ path: '/a', component: deactivate }, { path: '/a', component: 'deactivateCmp' },
{ path: '/b', component: activate } { path: '/b', component: 'activateCmp' }
]); ]);
compile('outer { <div ng-outlet></div> }'); compile('outer { <div ng-outlet></div> }');
@ -185,25 +180,32 @@ describe('ngOutlet', function () {
expect(log).toEqual(['deactivate', 'activate']); expect(log).toEqual(['deactivate', 'activate']);
}); });
it('should reuse a component when the canReuse hook returns true', function () { it('should reuse a component when the canReuse hook returns true', function () {
var log = []; var log = [];
var cmpInstanceCount = 0; var cmpInstanceCount = 0;
function ReuseCmp() { function ReuseCmp() {
cmpInstanceCount++; cmpInstanceCount++;
this.canReuse = function () {
return true;
};
this.onReuse = function (next, prev) {
log.push('reuse: ' + prev.urlPath + ' -> ' + next.urlPath);
};
} }
ReuseCmp.$routeConfig = [{path: '/a', component: OneController}, {path: '/b', component: TwoController}];
registerComponent('reuse', 'reuse {<ng-outlet></ng-outlet>}', ReuseCmp); registerComponent('reuseCmp', {
template: 'reuse {<ng-outlet></ng-outlet>}',
$routeConfig: [
{path: '/a', component: 'oneCmp'},
{path: '/b', component: 'twoCmp'}
],
controller: ReuseCmp,
$canReuse: function () {
return true;
},
$onReuse: function (next, prev) {
log.push('reuse: ' + prev.urlPath + ' -> ' + next.urlPath);
}
});
$router.config([ $router.config([
{ path: '/on-reuse/:number/...', component: ReuseCmp } { path: '/on-reuse/:number/...', component: 'reuseCmp' },
{ path: '/two', component: 'twoCmp', as: 'Two'}
]); ]);
compile('outer { <div ng-outlet></div> }'); compile('outer { <div ng-outlet></div> }');
@ -227,18 +229,25 @@ describe('ngOutlet', function () {
function NeverReuseCmp() { function NeverReuseCmp() {
cmpInstanceCount++; cmpInstanceCount++;
this.canReuse = function () {
return false;
};
this.onReuse = function (next, prev) {
log.push('reuse: ' + prev.urlPath + ' -> ' + next.urlPath);
};
} }
NeverReuseCmp.$routeConfig = [{path: '/a', component: OneController}, {path: '/b', component: TwoController}]; registerComponent('reuseCmp', {
registerComponent('reuse', 'reuse {<ng-outlet></ng-outlet>}', NeverReuseCmp); template: 'reuse {<ng-outlet></ng-outlet>}',
$routeConfig: [
{path: '/a', component: 'oneCmp'},
{path: '/b', component: 'twoCmp'}
],
controller: NeverReuseCmp,
$canReuse: function () {
return false;
},
$onReuse: function (next, prev) {
log.push('reuse: ' + prev.urlPath + ' -> ' + next.urlPath);
}
});
$router.config([ $router.config([
{ path: '/never-reuse/:number/...', component: NeverReuseCmp } { path: '/never-reuse/:number/...', component: 'reuseCmp' },
{ path: '/two', component: 'twoCmp', as: 'Two'}
]); ]);
compile('outer { <div ng-outlet></div> }'); compile('outer { <div ng-outlet></div> }');
@ -256,17 +265,17 @@ describe('ngOutlet', function () {
}); });
// TODO: need to solve getting ahold of canActivate hook
it('should not activate a component when canActivate returns false', function () { it('should not activate a component when canActivate returns false', function () {
var canActivateSpy = jasmine.createSpy('canActivate').and.returnValue(false);
var spy = jasmine.createSpy('activate'); var spy = jasmine.createSpy('activate');
var activate = registerComponent('activate', '', { registerComponent('activateCmp', {
canActivate: function () { $canActivate: canActivateSpy,
return false; $onActivate: spy
},
onActivate: spy
}); });
$router.config([ $router.config([
{ path: '/a', component: activate } { path: '/a', component: 'activateCmp' }
]); ]);
compile('outer { <div ng-outlet></div> }'); compile('outer { <div ng-outlet></div> }');
@ -279,38 +288,40 @@ describe('ngOutlet', function () {
it('should activate a component when canActivate returns true', function () { it('should activate a component when canActivate returns true', function () {
var spy = jasmine.createSpy('activate'); var activateSpy = jasmine.createSpy('activate');
var activate = registerComponent('activate', 'hi', { var canActivateSpy = jasmine.createSpy('canActivate').and.returnValue(true);
canActivate: function () { registerComponent('activateCmp', {
return true; template: 'hi',
}, $canActivate: canActivateSpy,
onActivate: spy $onActivate: activateSpy
}); });
$router.config([ $router.config([
{ path: '/a', component: activate } { path: '/a', component: 'activateCmp' }
]); ]);
compile('<div ng-outlet></div>'); compile('<div ng-outlet></div>');
$router.navigateByUrl('/a'); $router.navigateByUrl('/a');
$rootScope.$digest(); $rootScope.$digest();
expect(spy).toHaveBeenCalled(); expect(canActivateSpy).toHaveBeenCalled();
expect(activateSpy).toHaveBeenCalled();
expect(elt.text()).toBe('hi'); expect(elt.text()).toBe('hi');
}); });
it('should activate a component when canActivate returns a resolved promise', inject(function ($q) { it('should activate a component when canActivate returns a resolved promise', inject(function ($q) {
var spy = jasmine.createSpy('activate'); var spy = jasmine.createSpy('activate');
var activate = registerComponent('activate', 'hi', { registerComponent('activateCmp', {
canActivate: function () { template: 'hi',
$canActivate: function () {
return $q.when(true); return $q.when(true);
}, },
onActivate: spy $onActivate: spy
}); });
$router.config([ $router.config([
{ path: '/a', component: activate } { path: '/a', component: 'activateCmp' }
]); ]);
compile('<div ng-outlet></div>'); compile('<div ng-outlet></div>');
@ -324,33 +335,38 @@ describe('ngOutlet', function () {
it('should inject into the canActivate hook of controllers', inject(function ($http) { it('should inject into the canActivate hook of controllers', inject(function ($http) {
var spy = jasmine.createSpy('canActivate').and.returnValue(true); var spy = jasmine.createSpy('canActivate').and.returnValue(true);
var activate = registerComponent('activate', '', { registerComponent('activateCmp', {
canActivate: spy $canActivate: spy
}); });
spy.$inject = ['$routeParams', '$http']; spy.$inject = ['$nextInstruction', '$http'];
$router.config([ $router.config([
{ path: '/user/:name', component: activate } { path: '/user/:name', component: 'activateCmp' }
]); ]);
compile('<div ng-outlet></div>'); compile('<div ng-outlet></div>');
$router.navigateByUrl('/user/brian'); $router.navigateByUrl('/user/brian');
$rootScope.$digest(); $rootScope.$digest();
expect(spy).toHaveBeenCalledWith({name: 'brian'}, $http);
expect(spy).toHaveBeenCalled();
var args = spy.calls.mostRecent().args;
expect(args[0].params).toEqual({name: 'brian'});
expect(args[1]).toBe($http);
})); }));
it('should not navigate when canDeactivate returns false', function () { it('should not navigate when canDeactivate returns false', function () {
var activate = registerComponent('activate', 'hi', { registerComponent('activateCmp', {
canDeactivate: function () { template: 'hi',
$canDeactivate: function () {
return false; return false;
} }
}); });
$router.config([ $router.config([
{ path: '/a', component: activate }, { path: '/a', component: 'activateCmp' },
{ path: '/b', component: OneController } { path: '/b', component: 'oneCmp' }
]); ]);
compile('outer { <div ng-outlet></div> }'); compile('outer { <div ng-outlet></div> }');
@ -365,15 +381,16 @@ describe('ngOutlet', function () {
it('should navigate when canDeactivate returns true', function () { it('should navigate when canDeactivate returns true', function () {
var activate = registerComponent('activate', 'hi', { registerComponent('activateCmp', {
canDeactivate: function () { template: 'hi',
$canDeactivate: function () {
return true; return true;
} }
}); });
$router.config([ $router.config([
{ path: '/a', component: activate }, { path: '/a', component: 'activateCmp' },
{ path: '/b', component: OneController } { path: '/b', component: 'oneCmp' }
]); ]);
compile('outer { <div ng-outlet></div> }'); compile('outer { <div ng-outlet></div> }');
@ -389,15 +406,16 @@ describe('ngOutlet', function () {
it('should activate a component when canActivate returns true', function () { it('should activate a component when canActivate returns true', function () {
var spy = jasmine.createSpy('activate'); var spy = jasmine.createSpy('activate');
var activate = registerComponent('activate', 'hi', { registerComponent('activateCmp', {
canActivate: function () { template: 'hi',
$canActivate: function () {
return true; return true;
}, },
onActivate: spy $onActivate: spy
}); });
$router.config([ $router.config([
{ path: '/a', component: activate } { path: '/a', component: 'activateCmp' }
]); ]);
compile('<div ng-outlet></div>'); compile('<div ng-outlet></div>');
@ -411,13 +429,13 @@ describe('ngOutlet', function () {
it('should pass instructions into the canDeactivate hook of controllers', function () { it('should pass instructions into the canDeactivate hook of controllers', function () {
var spy = jasmine.createSpy('canDeactivate').and.returnValue(true); var spy = jasmine.createSpy('canDeactivate').and.returnValue(true);
var deactivate = registerComponent('deactivate', '', { registerComponent('deactivateCmp', {
canDeactivate: spy $canDeactivate: spy
}); });
$router.config([ $router.config([
{ path: '/user/:name', component: deactivate }, { path: '/user/:name', component: 'deactivateCmp' },
{ path: '/post/:id', component: OneController } { path: '/post/:id', component: 'oneCmp' }
]); ]);
compile('<div ng-outlet></div>'); compile('<div ng-outlet></div>');
@ -425,43 +443,36 @@ describe('ngOutlet', function () {
$rootScope.$digest(); $rootScope.$digest();
$router.navigateByUrl('/post/123'); $router.navigateByUrl('/post/123');
$rootScope.$digest(); $rootScope.$digest();
expect(spy).toHaveBeenCalledWith(instructionFor(OneController), expect(spy).toHaveBeenCalledWith(instructionFor('oneCmp'),
instructionFor(deactivate)); instructionFor('deactivateCmp'));
}); });
function registerComponent(name, template, config) {
var Ctrl; function registerComponent(name, options) {
if (!template) { var controller = options.controller || function () {};
template = '';
} ['$onActivate', '$onDeactivate', '$onReuse', '$canReuse', '$canDeactivate'].forEach(function (hookName) {
if (!config) { if (options[hookName]) {
Ctrl = function () {}; controller.prototype[hookName] = options[hookName];
} else if (angular.isArray(config)) {
Ctrl = function () {};
Ctrl.annotations = [new angular.annotations.RouteConfig(config)];
} else if (typeof config === 'function') {
Ctrl = config;
} else {
Ctrl = function () {};
if (config.canActivate) {
Ctrl.$canActivate = config.canActivate;
delete config.canActivate;
} }
Ctrl.prototype = config; });
function factory() {
return {
template: options.template || '',
controllerAs: name,
controller: controller
};
} }
$controllerProvider.register(componentControllerName(name), Ctrl);
put(name, template);
return Ctrl;
}
function boringController(model, value) { if (options.$canActivate) {
return function () { factory.$canActivate = options.$canActivate;
this[model] = value; }
}; if (options.$routeConfig) {
} factory.$routeConfig = options.$routeConfig;
}
function put(name, template) { $compileProvider.directive(name, factory);
$templateCache.put(componentTemplatePath(name), [200, template, {}]);
} }
function compile(template) { function compile(template) {
@ -469,4 +480,8 @@ describe('ngOutlet', function () {
$rootScope.$digest(); $rootScope.$digest();
return elt; return elt;
} }
function instructionFor(componentType) {
return jasmine.objectContaining({componentType: componentType});
}
}); });

View File

@ -6,41 +6,39 @@ describe('navigation', function () {
$compile, $compile,
$rootScope, $rootScope,
$router, $router,
$templateCache, $compileProvider;
$controllerProvider,
$componentMapperProvider;
var OneController, TwoController, UserController;
beforeEach(function () { beforeEach(function () {
module('ng'); module('ng');
module('ngComponentRouter'); module('ngComponentRouter');
module(function (_$controllerProvider_, _$componentMapperProvider_) { module(function (_$compileProvider_) {
$controllerProvider = _$controllerProvider_; $compileProvider = _$compileProvider_;
$componentMapperProvider = _$componentMapperProvider_;
}); });
inject(function (_$compile_, _$rootScope_, _$router_, _$templateCache_) { inject(function (_$compile_, _$rootScope_, _$router_) {
$compile = _$compile_; $compile = _$compile_;
$rootScope = _$rootScope_; $rootScope = _$rootScope_;
$router = _$router_; $router = _$router_;
$templateCache = _$templateCache_;
}); });
UserController = registerComponent('user', '<div>hello {{user.name}}</div>', function ($routeParams) { registerComponent('userCmp', {
this.name = $routeParams.name; template: '<div>hello {{userCmp.$routeParams.name}}</div>'
});
registerComponent('oneCmp', {
template: '<div>{{oneCmp.number}}</div>',
controller: function () {this.number = 'one'}
});
registerComponent('twoCmp', {
template: '<div>{{twoCmp.number}}</div>',
controller: function () {this.number = 'two'}
}); });
OneController = registerComponent('one', '<div>{{one.number}}</div>', boringController('number', 'one'));
TwoController = registerComponent('two', '<div>{{two.number}}</div>', boringController('number', 'two'));
}); });
it('should work in a simple case', function () { it('should work in a simple case', function () {
compile('<ng-outlet></ng-outlet>'); compile('<ng-outlet></ng-outlet>');
$router.config([ $router.config([
{ path: '/', component: OneController } { path: '/', component: 'oneCmp' }
]); ]);
$router.navigateByUrl('/'); $router.navigateByUrl('/');
@ -49,26 +47,9 @@ describe('navigation', function () {
expect(elt.text()).toBe('one'); expect(elt.text()).toBe('one');
}); });
// See https://github.com/angular/router/issues/105
xit('should warn when instantiating a component with no controller', function () {
put('noController', '<div>{{ 2 + 2 }}</div>');
$router.config([
{ path: '/', component: 'noController' }
]);
spyOn(console, 'warn');
compile('<ng-outlet></ng-outlet>');
$router.navigateByUrl('/');
expect(console.warn).toHaveBeenCalledWith('Could not find controller for', 'NoControllerController');
expect(elt.text()).toBe('4');
});
it('should navigate between components with different parameters', function () { it('should navigate between components with different parameters', function () {
$router.config([ $router.config([
{ path: '/user/:name', component: UserController } { path: '/user/:name', component: 'userCmp' }
]); ]);
compile('<ng-outlet></ng-outlet>'); compile('<ng-outlet></ng-outlet>');
@ -82,42 +63,46 @@ describe('navigation', function () {
}); });
it('should not reactivate a parent when navigating between child components with different parameters', function () { it('should reuse a parent when navigating between child components with different parameters', function () {
var spy = jasmine.createSpy('onActivate'); var instanceCount = 0;
function ParentController() {} function ParentController() {
ParentController.$routeConfig = [ instanceCount += 1;
{ path: '/user/:name', component: UserController } }
]; registerComponent('parentCmp', {
ParentController.prototype.onActivate = spy; template: 'parent { <ng-outlet></ng-outlet> }',
$routeConfig: [
registerComponent('parent', 'parent { <ng-outlet></ng-outlet> }', ParentController); { path: '/user/:name', component: 'userCmp' }
],
controller: ParentController
});
$router.config([ $router.config([
{ path: '/parent/...', component: ParentController } { path: '/parent/...', component: 'parentCmp' }
]); ]);
compile('<ng-outlet></ng-outlet>'); compile('<ng-outlet></ng-outlet>');
$router.navigateByUrl('/parent/user/brian'); $router.navigateByUrl('/parent/user/brian');
$rootScope.$digest(); $rootScope.$digest();
expect(spy).toHaveBeenCalled(); expect(instanceCount).toBe(1);
expect(elt.text()).toBe('parent { hello brian }'); expect(elt.text()).toBe('parent { hello brian }');
spy.calls.reset();
$router.navigateByUrl('/parent/user/igor'); $router.navigateByUrl('/parent/user/igor');
$rootScope.$digest(); $rootScope.$digest();
expect(spy).not.toHaveBeenCalled(); expect(instanceCount).toBe(1);
expect(elt.text()).toBe('parent { hello igor }'); expect(elt.text()).toBe('parent { hello igor }');
}); });
it('should work with nested outlets', function () { it('should work with nested outlets', function () {
var childComponent = registerComponent('childComponent', '<div>inner { <div ng-outlet></div> }</div>', [ registerComponent('childCmp', {
{ path: '/b', component: OneController } template: '<div>inner { <div ng-outlet></div> }</div>',
]); $routeConfig: [
{ path: '/b', component: 'oneCmp' }
]
});
$router.config([ $router.config([
{ path: '/a/...', component: childComponent } { path: '/a/...', component: 'childCmp' }
]); ]);
compile('<div>outer { <div ng-outlet></div> }</div>'); compile('<div>outer { <div ng-outlet></div> }</div>');
@ -128,40 +113,30 @@ describe('navigation', function () {
}); });
it('should work with recursive nested outlets', function () { // TODO: fix this
put('two', '<div>recur { <div ng-outlet></div> }</div>'); xit('should work with recursive nested outlets', function () {
registerComponent('recurCmp', {
template: '<div>recur { <div ng-outlet></div> }</div>',
$routeConfig: [
{ path: '/recur', component: 'recurCmp' },
{ path: '/end', component: 'oneCmp' }
]});
$router.config([ $router.config([
{ path: '/recur', component: TwoController }, { path: '/recur', component: 'recurCmp' },
{ path: '/', component: OneController } { path: '/', component: 'oneCmp' }
]); ]);
compile('<div>root { <div ng-outlet></div> }</div>'); compile('<div>root { <div ng-outlet></div> }</div>');
$router.navigateByUrl('/'); $router.navigateByUrl('/recur/recur/end');
$rootScope.$digest(); $rootScope.$digest();
expect(elt.text()).toBe('root { one }'); expect(elt.text()).toBe('root { one }');
}); });
it('should inject $scope into the controller constructor', function () {
var injectedScope;
var UserController = registerComponent('user', '', function ($scope) {
injectedScope = $scope;
});
$router.config([
{ path: '/user', component: UserController }
]);
compile('<div ng-outlet></div>');
$router.navigateByUrl('/user');
$rootScope.$digest();
expect(injectedScope).toBeDefined();
});
it('should change location path', inject(function ($location) { it('should change location path', inject(function ($location) {
$router.config([ $router.config([
{ path: '/user', component: UserController } { path: '/user', component: 'userCmp' }
]); ]);
compile('<div ng-outlet></div>'); compile('<div ng-outlet></div>');
@ -178,7 +153,7 @@ describe('navigation', function () {
$router.config([ $router.config([
{ path: '/', redirectTo: '/user' }, { path: '/', redirectTo: '/user' },
{ path: '/user', component: UserController } { path: '/user', component: 'userCmp' }
]); ]);
$router.navigateByUrl('/'); $router.navigateByUrl('/');
@ -189,16 +164,19 @@ describe('navigation', function () {
it('should change location to the canonical route with nested components', inject(function ($location) { it('should change location to the canonical route with nested components', inject(function ($location) {
var childRouter = registerComponent('childRouter', '<div>inner { <div ng-outlet></div> }</div>', [ registerComponent('childRouter', {
{ path: '/old-child', redirectTo: '/new-child' }, template: '<div>inner { <div ng-outlet></div> }</div>',
{ path: '/new-child', component: OneController}, $routeConfig: [
{ path: '/old-child-two', redirectTo: '/new-child-two' }, { path: '/old-child', redirectTo: '/new-child' },
{ path: '/new-child-two', component: TwoController} { path: '/new-child', component: 'oneCmp'},
]); { path: '/old-child-two', redirectTo: '/new-child-two' },
{ path: '/new-child-two', component: 'twoCmp'}
]
});
$router.config([ $router.config([
{ path: '/old-parent', redirectTo: '/new-parent' }, { path: '/old-parent', redirectTo: '/new-parent' },
{ path: '/new-parent/...', component: childRouter } { path: '/new-parent/...', component: 'childRouter' }
]); ]);
compile('<div ng-outlet></div>'); compile('<div ng-outlet></div>');
@ -219,7 +197,7 @@ describe('navigation', function () {
it('should navigate when the location path changes', inject(function ($location) { it('should navigate when the location path changes', inject(function ($location) {
$router.config([ $router.config([
{ path: '/one', component: OneController } { path: '/one', component: 'oneCmp' }
]); ]);
compile('<div ng-outlet></div>'); compile('<div ng-outlet></div>');
@ -232,18 +210,18 @@ describe('navigation', function () {
it('should expose a "navigating" property on $router', inject(function ($q) { it('should expose a "navigating" property on $router', inject(function ($q) {
var defer; var defer;
var pendingActivate = registerComponent('pendingActivate', '', { registerComponent('pendingActivate', {
onActivate: function () { $canActivate: function () {
defer = $q.defer(); defer = $q.defer();
return defer.promise; return defer.promise;
} }
}); });
$router.config([ $router.config([
{ path: '/pendingActivate', component: pendingActivate } { path: '/pending-activate', component: 'pendingActivate' }
]); ]);
compile('<div ng-outlet></div>'); compile('<div ng-outlet></div>');
$router.navigateByUrl('/pendingActivate'); $router.navigateByUrl('/pending-activate');
$rootScope.$digest(); $rootScope.$digest();
expect($router.navigating).toBe(true); expect($router.navigating).toBe(true);
defer.resolve(); defer.resolve();
@ -251,40 +229,31 @@ describe('navigation', function () {
expect($router.navigating).toBe(false); expect($router.navigating).toBe(false);
})); }));
function registerComponent(name, options) {
var controller = options.controller || function () {};
function registerComponent(name, template, config) { ['$onActivate', '$onDeactivate', '$onReuse', '$canReuse', '$canDeactivate'].forEach(function (hookName) {
var Ctrl; if (options[hookName]) {
if (!template) { controller.prototype[hookName] = options[hookName];
template = '';
}
if (!config) {
Ctrl = function () {};
} else if (angular.isArray(config)) {
Ctrl = function () {};
Ctrl.annotations = [new angular.annotations.RouteConfig(config)];
} else if (typeof config === 'function') {
Ctrl = config;
} else {
Ctrl = function () {};
if (config.canActivate) {
Ctrl.$canActivate = config.canActivate;
delete config.canActivate;
} }
Ctrl.prototype = config; });
function factory() {
return {
template: options.template || '',
controllerAs: name,
controller: controller
};
} }
$controllerProvider.register(componentControllerName(name), Ctrl);
put(name, template);
return Ctrl;
}
function boringController(model, value) { if (options.$canActivate) {
return function () { factory.$canActivate = options.$canActivate;
this[model] = value; }
}; if (options.$routeConfig) {
} factory.$routeConfig = options.$routeConfig;
}
function put(name, template) { $compileProvider.directive(name, factory);
$templateCache.put(componentTemplatePath(name), [200, template, {}]);
} }
function compile(template) { function compile(template) {

View File

@ -1,44 +1,36 @@
'use strict'; 'use strict';
describe('ngOutlet', function () { describe('ngLink', function () {
var elt, var elt,
$compile, $compile,
$rootScope, $rootScope,
$router, $router,
$templateCache, $compileProvider;
$controllerProvider;
var OneController, TwoController, UserController;
beforeEach(function () { beforeEach(function () {
module('ng'); module('ng');
module('ngComponentRouter'); module('ngComponentRouter');
module(function (_$controllerProvider_) { module(function (_$compileProvider_) {
$controllerProvider = _$controllerProvider_; $compileProvider = _$compileProvider_;
}); });
inject(function (_$compile_, _$rootScope_, _$router_, _$templateCache_) { inject(function (_$compile_, _$rootScope_, _$router_) {
$compile = _$compile_; $compile = _$compile_;
$rootScope = _$rootScope_; $rootScope = _$rootScope_;
$router = _$router_; $router = _$router_;
$templateCache = _$templateCache_;
}); });
UserController = registerComponent('user', '<div>hello {{user.name}}</div>', function ($routeParams) { registerComponent('userCmp', '<div>hello {{userCmp.$routeParams.name}}</div>', function () {});
this.name = $routeParams.name; registerComponent('oneCmp', '<div>{{oneCmp.number}}</div>', function () {this.number = 'one'});
}); registerComponent('twoCmp', '<div><a ng-link="[\'/Two\']">{{twoCmp.number}}</a></div>', function () {this.number = 'two'});
OneController = registerComponent('one', '<div>{{one.number}}</div>', boringController('number', 'one'));
TwoController = registerComponent('two', '<div>{{two.number}}</div>', boringController('number', 'two'));
}); });
it('should allow linking from the parent to the child', function () { it('should allow linking from the parent to the child', function () {
put('one', '<div>{{number}}</div>');
$router.config([ $router.config([
{ path: '/a', component: OneController }, { path: '/a', component: 'oneCmp' },
{ path: '/b', component: TwoController, as: 'Two' } { path: '/b', component: 'twoCmp', as: 'Two' }
]); ]);
compile('<a ng-link="[\'/Two\']">link</a> | outer { <div ng-outlet></div> }'); compile('<a ng-link="[\'/Two\']">link</a> | outer { <div ng-outlet></div> }');
@ -49,15 +41,13 @@ describe('ngOutlet', function () {
}); });
it('should allow linking from the child and the parent', function () { it('should allow linking from the child and the parent', function () {
put('one', '<div><a ng-link="[\'/Two\']">{{number}}</a></div>');
$router.config([ $router.config([
{ path: '/a', component: OneController }, { path: '/a', component: 'oneCmp' },
{ path: '/b', component: TwoController, as: 'Two' } { path: '/b', component: 'twoCmp', as: 'Two' }
]); ]);
compile('outer { <div ng-outlet></div> }'); compile('outer { <div ng-outlet></div> }');
$router.navigateByUrl('/a'); $router.navigateByUrl('/b');
$rootScope.$digest(); $rootScope.$digest();
expect(elt.find('a').attr('href')).toBe('./b'); expect(elt.find('a').attr('href')).toBe('./b');
@ -65,12 +55,11 @@ describe('ngOutlet', function () {
it('should allow params in routerLink directive', function () { it('should allow params in routerLink directive', function () {
put('router', '<div>outer { <div ng-outlet></div> }</div>'); registerComponent('twoLinkCmp', '<div><a ng-link="[\'/Two\', {param: \'lol\'}]">{{twoLinkCmp.number}}</a></div>', function () {this.number = 'two'});
put('one', '<div><a ng-link="[\'/Two\', {param: \'lol\'}]">{{number}}</a></div>');
$router.config([ $router.config([
{ path: '/a', component: OneController }, { path: '/a', component: 'twoLinkCmp' },
{ path: '/b/:param', component: TwoController, as: 'Two' } { path: '/b/:param', component: 'twoCmp', as: 'Two' }
]); ]);
compile('<div ng-outlet></div>'); compile('<div ng-outlet></div>');
@ -80,33 +69,34 @@ describe('ngOutlet', function () {
expect(elt.find('a').attr('href')).toBe('./b/lol'); expect(elt.find('a').attr('href')).toBe('./b/lol');
}); });
// TODO: test dynamic links
it('should update the href of links with bound params', function () {
put('router', '<div>outer { <div ng-outlet></div> }</div>');
put('one', '<div><a ng-link="[\'/Two\', {param: one.number}]">{{one.number}}</a></div>');
it('should update the href of links with bound params', function () {
registerComponent('twoLinkCmp', '<div><a ng-link="[\'/Two\', {param: twoLinkCmp.number}]">{{twoLinkCmp.number}}</a></div>', function () {this.number = 'param'});
$router.config([ $router.config([
{ path: '/a', component: OneController }, { path: '/a', component: 'twoLinkCmp' },
{ path: '/b/:param', component: TwoController, as: 'Two' } { path: '/b/:param', component: 'twoCmp', as: 'Two' }
]); ]);
compile('<div ng-outlet></div>'); compile('<div ng-outlet></div>');
$router.navigateByUrl('/a'); $router.navigateByUrl('/a');
$rootScope.$digest(); $rootScope.$digest();
expect(elt.find('a').attr('href')).toBe('./b/one'); expect(elt.find('a').attr('href')).toBe('./b/param');
}); });
it('should navigate on left-mouse click when a link url matches a route', function () { it('should navigate on left-mouse click when a link url matches a route', function () {
$router.config([ $router.config([
{ path: '/', component: OneController }, { path: '/', component: 'oneCmp' },
{ path: '/two', component: TwoController } { path: '/two', component: 'twoCmp', as: 'Two'}
]); ]);
compile('<a href="/two">link</a> | <div ng-outlet></div>'); compile('<a ng-link="[\'/Two\']">link</a> | <div ng-outlet></div>');
$rootScope.$digest(); $rootScope.$digest();
expect(elt.text()).toBe('link | one'); expect(elt.text()).toBe('link | one');
expect(elt.find('a').attr('href')).toBe('./two');
elt.find('a')[0].click(); elt.find('a')[0].click();
$rootScope.$digest(); $rootScope.$digest();
@ -116,11 +106,11 @@ describe('ngOutlet', function () {
it('should not navigate on non-left mouse click when a link url matches a route', inject(function ($router) { it('should not navigate on non-left mouse click when a link url matches a route', inject(function ($router) {
$router.config([ $router.config([
{ path: '/', component: OneController }, { path: '/', component: 'oneCmp' },
{ path: '/two', component: TwoController } { path: '/two', component: 'twoCmp', as: 'Two'}
]); ]);
compile('<a href="./two">link</a> | <div ng-outlet></div>'); compile('<a ng-link="[\'/Two\']">link</a> | <div ng-outlet></div>');
$rootScope.$digest(); $rootScope.$digest();
expect(elt.text()).toBe('link | one'); expect(elt.text()).toBe('link | one');
elt.find('a').triggerHandler({ type: 'click', which: 3 }); elt.find('a').triggerHandler({ type: 'click', which: 3 });
@ -133,8 +123,8 @@ describe('ngOutlet', function () {
// See https://github.com/angular/router/issues/206 // See https://github.com/angular/router/issues/206
it('should not navigate a link without an href', function () { it('should not navigate a link without an href', function () {
$router.config([ $router.config([
{ path: '/', component: OneController }, { path: '/', component: 'oneCmp' },
{ path: '/two', component: TwoController } { path: '/two', component: 'twoCmp', as: 'Two'}
]); ]);
expect(function () { expect(function () {
compile('<a>link</a>'); compile('<a>link</a>');
@ -147,38 +137,29 @@ describe('ngOutlet', function () {
function registerComponent(name, template, config) { function registerComponent(name, template, config) {
var Ctrl; var controller = function () {};
function factory() {
return {
template: template,
controllerAs: name,
controller: controller
};
}
if (!template) { if (!template) {
template = ''; template = '';
} }
if (!config) { if (angular.isArray(config)) {
Ctrl = function () {}; factory.annotations = [new angular.annotations.RouteConfig(config)];
} else if (angular.isArray(config)) {
Ctrl = function () {};
Ctrl.annotations = [new angular.annotations.RouteConfig(config)];
} else if (typeof config === 'function') { } else if (typeof config === 'function') {
Ctrl = config; controller = config;
} else { } else if (typeof config === 'object') {
Ctrl = function () {};
if (config.canActivate) { if (config.canActivate) {
Ctrl.$canActivate = config.canActivate; controller.$canActivate = config.canActivate;
delete config.canActivate;
} }
Ctrl.prototype = config;
} }
$controllerProvider.register(componentControllerName(name), Ctrl); $compileProvider.directive(name, factory);
put(name, template);
return Ctrl;
}
function boringController(model, value) {
return function () {
this[model] = value;
};
}
function put(name, template) {
$templateCache.put(componentTemplatePath(name), [200, template, {}]);
} }
function compile(template) { function compile(template) {

View File

@ -16,11 +16,6 @@ function dashCase(str) {
}); });
} }
function boringController (model, value) {
return function () {
this[model] = value;
};
}
function provideHelpers(fn, preInject) { function provideHelpers(fn, preInject) {
return function () { return function () {