diff --git a/modules/angular1_router/karma-router.conf.js b/modules/angular1_router/karma-router.conf.js index 09fc1a408d..1bfd17d380 100644 --- a/modules/angular1_router/karma-router.conf.js +++ b/modules/angular1_router/karma-router.conf.js @@ -14,6 +14,7 @@ module.exports = function (config) { '../../node_modules/angular-mocks/angular-mocks.js', '../../dist/angular_1_router.js', + 'src/ng_route_shim.js', 'test/*.es5.js', 'test/**/*_spec.js' diff --git a/modules/angular1_router/src/ng_route_shim.js b/modules/angular1_router/src/ng_route_shim.js new file mode 100644 index 0000000000..0d3f245b28 --- /dev/null +++ b/modules/angular1_router/src/ng_route_shim.js @@ -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; + }); + } + +}()); diff --git a/modules/angular1_router/test/integration/shim_spec.js b/modules/angular1_router/test/integration/shim_spec.js new file mode 100644 index 0000000000..f074bd50fd --- /dev/null +++ b/modules/angular1_router/test/integration/shim_spec.js @@ -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(''); + + $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 {}'); + + $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(''); + + $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(''); + + $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(''); + + $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 {}'); + + $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 {}'); + + $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 {}'); + + $router.navigateByUrl('/somewhere'); + $rootScope.$digest(); + expect(elt.text()).toBe('root {welcome home!}'); + expect($location.path()).toBe('/home'); + })); + + function compile(template) { + elt = $compile('
' + template + '
')($rootScope); + $rootScope.$digest(); + return elt; + } +});