feat(angular_1_router): add ngRouteShim module

This module attempts to provide a shim for `$routeProvider` to aid in
migrating from ngRoute to Component Router.

Closes #4266
This commit is contained in:
Brian Ford 2015-09-18 15:54:35 -07:00
parent 5205a9e65f
commit aed34e1f82
3 changed files with 532 additions and 0 deletions

View File

@ -14,6 +14,7 @@ module.exports = function (config) {
'../../node_modules/angular-mocks/angular-mocks.js', '../../node_modules/angular-mocks/angular-mocks.js',
'../../dist/angular_1_router.js', '../../dist/angular_1_router.js',
'src/ng_route_shim.js',
'test/*.es5.js', 'test/*.es5.js',
'test/**/*_spec.js' 'test/**/*_spec.js'

View File

@ -0,0 +1,349 @@
/** @license Copyright 2014-2015 Google, Inc. http://github.com/angular/angular/LICENSE */
(function () {
'use strict';
// keep a reference to compileProvider so we can register new component-directives
// on-the-fly based on $routeProvider configuration
// TODO: remove this right now you can only bootstrap one Angular app with this hack
var $compileProvider, $q, $injector;
/**
* This module loads services that mimic ngRoute's configuration, and includes
* an anchor link directive that intercepts clicks to routing.
*
* This module is intended to be used as a stop-gap solution for projects upgrading from ngRoute.
* It intentionally does not implement all features of ngRoute.
*/
angular.module('ngRouteShim', [])
.provider('$route', $RouteProvider)
.config(['$compileProvider', function (compileProvider) {
$compileProvider = compileProvider;
}])
.factory('$routeParams', $routeParamsFactory)
.directive('a', anchorLinkDirective)
// Connects the legacy $routeProvider config shim to Component Router's config.
.run(['$route', '$router', function ($route, $router) {
$route.$$subscribe(function (routeDefinition) {
if (!angular.isArray(routeDefinition)) {
routeDefinition = [routeDefinition];
}
$router.config(routeDefinition);
});
}]);
/**
* A shimmed out provider that provides the same API as ngRoute's $routeProvider, but uses these calls
* to configure Component Router.
*/
function $RouteProvider() {
var routes = [];
var subscriptionFn = null;
var routeMap = {};
// Stats for which routes are skipped
var skipCount = 0;
var successCount = 0;
var allCount = 0;
function consoleMetrics() {
return '(' + skipCount + ' skipped / ' + successCount + ' success / ' + allCount + ' total)';
}
/**
* @ngdoc method
* @name $routeProvider#when
*
* @param {string} path Route path (matched against `$location.path`). If `$location.path`
* contains redundant trailing slash or is missing one, the route will still match and the
* `$location.path` will be updated to add or drop the trailing slash to exactly match the
* route definition.
*
* @param {Object} route Mapping information to be assigned to `$route.current` on route
* match.
*/
this.when = function(path, route) {
//copy original route object to preserve params inherited from proto chain
var routeCopy = angular.copy(route);
allCount++;
if (angular.isDefined(routeCopy.reloadOnSearch)) {
console.warn('Route for "' + path + '" uses "reloadOnSearch" which is not implemented.');
}
if (angular.isDefined(routeCopy.caseInsensitiveMatch)) {
console.warn('Route for "' + path + '" uses "caseInsensitiveMatch" which is not implemented.');
}
// use new wildcard format
path = reformatWildcardParams(path);
if (path[path.length - 1] == '*') {
skipCount++;
console.warn('Route for "' + path + '" ignored because it ends with *. Skipping.', consoleMetrics());
return this;
}
if (path.indexOf('?') > -1) {
skipCount++;
console.warn('Route for "' + path + '" ignored because it has optional parameters. Skipping.', consoleMetrics());
return this;
}
if (typeof route.redirectTo == 'function') {
skipCount++;
console.warn('Route for "' + path + '" ignored because lazy redirecting to a function is not yet implemented. Skipping.', consoleMetrics());
return this;
}
var routeDefinition = {
path: path,
data: routeCopy
};
routeMap[path] = routeCopy;
if (route.redirectTo) {
routeDefinition.redirectTo = route.redirectTo;
} else {
if (routeCopy.controller && !routeCopy.controllerAs) {
console.warn('Route for "' + path + '" should use "controllerAs".');
}
var directiveName = routeObjToRouteName(routeCopy, path);
if (!directiveName) {
throw new Error('Could not determine a name for route "' + path + '".');
}
routeDefinition.component = directiveName;
routeDefinition.as = upperCase(directiveName);
var directiveController = routeCopy.controller;
var directiveDefinition = {
scope: false,
controller: directiveController,
controllerAs: routeCopy.controllerAs,
templateUrl: routeCopy.templateUrl,
template: routeCopy.template
};
var directiveFactory = function () {
return directiveDefinition;
};
// if we have route resolve options, prepare a wrapper controller
if (directiveController && routeCopy.resolve) {
var originalController = directiveController;
var resolvedLocals = {};
directiveDefinition.controller = ['$injector', '$scope', function ($injector, $scope) {
var locals = angular.extend({
$scope: $scope
}, resolvedLocals);
var ctrl = $injector.instantiate(originalController, locals);
if (routeCopy.controllerAs) {
locals.$scope[routeCopy.controllerAs] = ctrl;
}
return ctrl;
}];
// we take care of controllerAs in the directive controller wrapper
delete directiveDefinition.controllerAs;
// we resolve the locals in a canActivate block
directiveFactory.$canActivate = function() {
var locals = angular.extend({}, routeCopy.resolve);
angular.forEach(locals, function(value, key) {
locals[key] = angular.isString(value) ?
$injector.get(value) : $injector.invoke(value, null, null, key);
});
return $q.all(locals).then(function (locals) {
resolvedLocals = locals;
}).then(function () {
return true;
});
};
}
// register the dynamically created directive
$compileProvider.directive(directiveName, directiveFactory);
}
if (subscriptionFn) {
subscriptionFn(routeDefinition);
} else {
routes.push(routeDefinition);
}
successCount++;
return this;
};
this.otherwise = function(params) {
if (typeof params === 'string') {
params = {redirectTo: params};
}
this.when('/*rest', params);
return this;
};
this.$get = ['$q', '$injector', function (q, injector) {
$q = q;
$injector = injector;
var $route = {
routes: routeMap,
/**
* @ngdoc method
* @name $route#reload
*
* @description
* Causes `$route` service to reload the current route even if
* {@link ng.$location $location} hasn't changed.
*/
reload: function() {
throw new Error('Not implemented: $route.reload');
},
/**
* @ngdoc method
* @name $route#updateParams
*/
updateParams: function(newParams) {
throw new Error('Not implemented: $route.updateParams');
},
/**
* Runs the given `fn` whenever new configs are added.
* Only one subscription is allowed.
* Passed `fn` is called synchronously.
*/
$$subscribe: function(fn) {
if (subscriptionFn) {
throw new Error('only one subscription allowed');
}
subscriptionFn = fn;
subscriptionFn(routes);
routes = [];
},
/**
* Runs a string with stats about many route configs were adapted, and how many were
* dropped because they are incompatible.
*/
$$getStats: consoleMetrics
};
return $route;
}];
}
function $routeParamsFactory($router, $rootScope) {
// the identity of this object cannot change
var paramsObj = {};
$rootScope.$on('$routeChangeSuccess', function () {
var newParams = $router._currentInstruction && $router._currentInstruction.component.params;
angular.forEach(paramsObj, function (val, name) {
delete paramsObj[name];
});
angular.forEach(newParams, function (val, name) {
paramsObj[name] = val;
});
});
return paramsObj;
}
/**
* Allows normal anchor links to kick off routing.
*/
function anchorLinkDirective($router) {
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;
}
// SVGAElement does not use the href attribute, but rather the 'xlinkHref' attribute.
var hrefAttrName = Object.prototype.toString.call(element.prop('href')) === '[object SVGAnimatedString]' ?
'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();
}
});
}
};
}
/**
* Given a route object, attempts to find a unique directive name.
*
* @param route route config object passed to $routeProvider.when
* @param path route configuration path
* @returns {string|name} a normalized (camelCase) directive name
*/
function routeObjToRouteName(route, path) {
var name = route.controllerAs;
var controller = route.controller;
if (!name && controller) {
if (angular.isArray(controller)) {
controller = controller[controller.length - 1];
}
name = controller.name;
}
if (!name) {
var segments = path.split('/');
name = segments[segments.length - 1];
}
if (name) {
name = name + 'AutoCmp';
}
return name;
}
function upperCase(str) {
return str.charAt(0).toUpperCase() + str.substr(1);
}
/*
* Changes "/:foo*" to "/*foo"
*/
var WILDCARD_PARAM_RE = new RegExp('\\/:([a-z0-9]+)\\*', 'gi');
function reformatWildcardParams(path) {
return path.replace(WILDCARD_PARAM_RE, function (m, m1) {
return '/*' + m1;
});
}
}());

View File

@ -0,0 +1,182 @@
'use strict';
describe('ngRoute shim', function () {
var elt,
$compile,
$rootScope,
$router,
$compileProvider,
$routeProvider;
beforeEach(function () {
module('ng');
module('ngComponentRouter');
module('ngRouteShim');
module(function (_$compileProvider_, _$routeProvider_) {
$compileProvider = _$compileProvider_;
$routeProvider = _$routeProvider_;
});
inject(function (_$compile_, _$rootScope_, _$router_) {
$compile = _$compile_;
$rootScope = _$rootScope_;
$router = _$router_;
});
});
it('should work in a simple case', function () {
$routeProvider.when('/', {
controller: function OneController() {
this.number = 'one';
},
controllerAs: 'oneCmp',
template: '{{oneCmp.number}}'
});
compile('<ng-outlet></ng-outlet>');
$router.navigateByUrl('/');
$rootScope.$digest();
expect(elt.text()).toBe('one');
});
it('should adapt routes with templateUrl', inject(function ($templateCache) {
$routeProvider.when('/', {
controller: function OneController() {
this.number = 'one';
},
controllerAs: 'oneCmp',
templateUrl: '/foo'
});
$templateCache.put('/foo', [200, '{{oneCmp.number}}', {}]);
compile('root {<ng-outlet></ng-outlet>}');
$router.navigateByUrl('/');
$rootScope.$digest();
expect(elt.text()).toBe('root {one}');
}));
it('should adapt routes using the "resolve" option', inject(function ($q) {
$routeProvider.when('/', {
controller: function TestController(resolvedService) {
this.resolvedValue = resolvedService;
},
controllerAs: 'testCmp',
resolve: {
resolvedService: function () {
return $q.when(42);
}
},
template: 'value: {{testCmp.resolvedValue}}'
});
compile('<ng-outlet></ng-outlet>');
$router.navigateByUrl('/');
$rootScope.$digest();
expect(elt.text()).toBe('value: 42');
}));
it('should adapt routes with params', function () {
$routeProvider.when('/user/:name', {
controller: function UserController($routeParams) {
this.$routeParams = $routeParams;
},
controllerAs: 'userCmp',
template: 'hello {{userCmp.$routeParams.name}}'
});
$rootScope.$digest();
compile('<ng-outlet></ng-outlet>');
$router.navigateByUrl('/user/brian');
$rootScope.$digest();
expect(elt.text()).toBe('hello brian');
$router.navigateByUrl('/user/igor');
$rootScope.$digest();
expect(elt.text()).toBe('hello igor');
});
it('should adapt routes with wildcard params', function () {
$routeProvider.when('/home/:params*', {
controller: function UserController($routeParams) {
this.$routeParams = $routeParams;
},
controllerAs: 'homeCmp',
template: 'rest: {{homeCmp.$routeParams.params}}'
});
$rootScope.$digest();
compile('<ng-outlet></ng-outlet>');
$router.navigateByUrl('/home/foo/bar/123');
$rootScope.$digest();
expect(elt.text()).toBe('rest: foo/bar/123');
});
it('should warn about and ignore routes with optional params', function () {
spyOn(console, 'warn');
$routeProvider.when('/home/:params?', {
template: 'home'
});
$rootScope.$digest();
compile('root {<ng-outlet></ng-outlet>}');
$router.navigateByUrl('/home/test');
$rootScope.$digest();
expect(elt.text()).toBe('root {}');
expect(console.warn)
.toHaveBeenCalledWith('Route for "/home/:params?" ignored because it has optional parameters. Skipping.',
'(1 skipped / 0 success / 1 total)');
});
it('should adapt routes with redirects', inject(function ($location) {
$routeProvider
.when('/', {
redirectTo: '/home'
})
.when('/home', {
template: 'welcome home!'
});
$rootScope.$digest();
compile('root {<ng-outlet></ng-outlet>}');
$router.navigateByUrl('/');
$rootScope.$digest();
expect(elt.text()).toBe('root {welcome home!}');
expect($location.path()).toBe('/home');
}));
//TODO: this is broken in recognition. un-xit this when https://github.com/angular/angular/issues/4133 is fixed
xit('should adapt "otherwise" routes', inject(function ($location) {
$routeProvider
.when('/home', {
template: 'welcome home!'
})
.otherwise({
redirectTo: '/home'
});
$rootScope.$digest();
compile('root {<ng-outlet></ng-outlet>}');
$router.navigateByUrl('/somewhere');
$rootScope.$digest();
expect(elt.text()).toBe('root {welcome home!}');
expect($location.path()).toBe('/home');
}));
function compile(template) {
elt = $compile('<div>' + template + '</div>')($rootScope);
$rootScope.$digest();
return elt;
}
});