From 450b1f0e8e03c738174ff967f688b9a6373290f4 Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Wed, 26 Mar 2014 09:59:04 -0400 Subject: [PATCH 01/25] feat($urlMatcherFactory): implement type support --- src/urlMatcherFactory.js | 171 +++++++++++++++++++++------- test/urlMatcherFactorySpec.js | 206 ++++++++++++++++++++-------------- 2 files changed, 249 insertions(+), 128 deletions(-) diff --git a/src/urlMatcherFactory.js b/src/urlMatcherFactory.js index 542c75a03..0a272e501 100644 --- a/src/urlMatcherFactory.js +++ b/src/urlMatcherFactory.js @@ -72,15 +72,14 @@ function UrlMatcher(pattern, caseInsensitiveMatch) { // \\. - a backslash escape // \{(?:[^{}\\]+|\\.)*\} - a matched set of curly braces containing other atoms var placeholder = /([:*])(\w+)|\{(\w+)(?:\:((?:[^{}\\]+|\\.|\{(?:[^{}\\]+|\\.)*\})+))?\}/g, - names = {}, compiled = '^', last = 0, m, + compiled = '^', last = 0, m, segments = this.segments = [], - params = this.params = []; + params = this.params = {}; - function addParameter(id) { + function addParameter(id, type) { if (!/^\w+(-+\w+)*$/.test(id)) throw new Error("Invalid parameter name '" + id + "' in pattern '" + pattern + "'"); - if (names[id]) throw new Error("Duplicate parameter name '" + id + "' in pattern '" + pattern + "'"); - names[id] = true; - params.push(id); + if (params[id]) throw new Error("Duplicate parameter name '" + id + "' in pattern '" + pattern + "'"); + params[id] = type; } function quoteRegExp(string) { @@ -91,14 +90,18 @@ function UrlMatcher(pattern, caseInsensitiveMatch) { // Split into static segments separated by path parameter placeholders. // The number of segments is always 1 more than the number of parameters. - var id, regexp, segment; + var id, regexp, segment, type; + while ((m = placeholder.exec(pattern))) { - id = m[2] || m[3]; // IE[78] returns '' for unmatched groups instead of null - regexp = m[4] || (m[1] == '*' ? '.*' : '[^/]*'); + id = m[2] || m[3]; // IE[78] returns '' for unmatched groups instead of null + regexp = m[4] || (m[1] == '*' ? '.*' : '[^/]*'); segment = pattern.substring(last, m.index); + type = this.$types[regexp] || new Type({ pattern: new RegExp(regexp) }); + if (segment.indexOf('?') >= 0) break; // we're into the search part - compiled += quoteRegExp(segment) + '(' + regexp + ')'; - addParameter(id); + + compiled += quoteRegExp(segment) + '(' + type.$subPattern() + ')'; + addParameter(id, type); segments.push(segment); last = placeholder.lastIndex; } @@ -106,10 +109,11 @@ function UrlMatcher(pattern, caseInsensitiveMatch) { // Find any search parameter names and remove them from the last segment var i = segment.indexOf('?'); + if (i >= 0) { var search = this.sourceSearch = segment.substring(i); segment = segment.substring(0, i); - this.sourcePath = pattern.substring(0, last+i); + this.sourcePath = pattern.substring(0, last + i); // Allow parameters to be separated by '?' as well as '&' to make concat() easier forEach(search.substring(1).split(/[&?]/), addParameter); @@ -120,12 +124,8 @@ function UrlMatcher(pattern, caseInsensitiveMatch) { compiled += quoteRegExp(segment) + '$'; segments.push(segment); - if(caseInsensitiveMatch){ - this.regexp = new RegExp(compiled, 'i'); - }else{ - this.regexp = new RegExp(compiled); - } - + + this.regexp = (caseInsensitiveMatch) ? new RegExp(compiled, 'i') : new RegExp(compiled); this.prefix = segments[0]; } @@ -187,14 +187,18 @@ UrlMatcher.prototype.exec = function (path, searchParams) { var m = this.regexp.exec(path); if (!m) return null; - var params = this.params, nTotal = params.length, - nPath = this.segments.length-1, - values = {}, i; + var params = this.parameters(), nTotal = params.length, + nPath = this.segments.length - 1, + values = {}, i, type, param; if (nPath !== m.length - 1) throw new Error("Unbalanced capture group in route '" + this.source + "'"); - for (i=0; i Date: Wed, 26 Mar 2014 10:06:40 -0400 Subject: [PATCH 02/25] fix($urlMatcherFactory): syntax --- src/urlMatcherFactory.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/urlMatcherFactory.js b/src/urlMatcherFactory.js index 0a272e501..b22cf6b41 100644 --- a/src/urlMatcherFactory.js +++ b/src/urlMatcherFactory.js @@ -255,7 +255,7 @@ UrlMatcher.prototype.format = function (values) { } for (/**/; i < nTotal; i++) { - param = params[i] + param = params[i]; if (values[param] == null) continue; result += (search ? '&' : '?') + param + '=' + encodeURIComponent(values[param]); search = true; @@ -279,7 +279,7 @@ Type.prototype.encode = function(val, key) { Type.prototype.decode = function(val, key) { return val; -} +}; Type.prototype.equals = function(a, b) { return a == b; From aeb6d9b9d1eba6361d8b0723d45976a9a6f54a3a Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Wed, 26 Mar 2014 17:01:47 -0400 Subject: [PATCH 03/25] chore($urlMatcherFactory): clean up test defs --- test/urlMatcherFactorySpec.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/urlMatcherFactorySpec.js b/test/urlMatcherFactorySpec.js index 68c75fc96..d70fc86c4 100644 --- a/test/urlMatcherFactorySpec.js +++ b/test/urlMatcherFactorySpec.js @@ -160,18 +160,18 @@ describe("urlMatcherFactory", function () { }); describe("typed parameters", function() { - it("should accept object definitions", function() { + it("should accept object definitions", function () { var type = { encode: function() {}, decode: function() {} }; $umf.type("myType", type); expect($umf.type("myType").encode).toBe(type.encode); }); - it("should reject duplicate definitions", function() { - $umf.type("myType", { encode: function() {}, decode: function() {} }); + it("should reject duplicate definitions", function () { + $umf.type("myType", { encode: function () {}, decode: function () {} }); expect(function() { $umf.type("myType", {}); }).toThrow("A type named 'myType' has already been defined."); }); - it("should accept injected function definitions", inject(function($stateParams) { + it("should accept injected function definitions", inject(function ($stateParams) { $umf.type("myType", function($stateParams) { return { decode: function() { @@ -182,7 +182,7 @@ describe("urlMatcherFactory", function () { expect($umf.type("myType").decode()).toBe($stateParams); })); - it("should match built-in types", function() { + it("should match built-in types", function () { var m = new UrlMatcher("/{foo:int}/{flag:bool}"); expect(m.exec("/1138/1")).toEqual({ foo: 1138, flag: true }); expect(m.format({ foo: 5, flag: true })).toBe("/5/1"); From b7f074ff65ca150a3cdbda4d5ad6cb17107300eb Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Wed, 26 Mar 2014 17:03:20 -0400 Subject: [PATCH 04/25] feat($urlMatcherFactory): date type support --- src/urlMatcherFactory.js | 18 +++++++++++++++++- test/urlMatcherFactorySpec.js | 9 +++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/urlMatcherFactory.js b/src/urlMatcherFactory.js index b22cf6b41..4dc9db43f 100644 --- a/src/urlMatcherFactory.js +++ b/src/urlMatcherFactory.js @@ -327,7 +327,23 @@ function $UrlMatcherFactory() { pattern: /0|1/ }, string: { - pattern: /.*/ + pattern: /[^\/]*/ + }, + date: { + equals: function (a, b) { + return a.toISOString() === b.toISOString(); + }, + decode: function (val) { + return new Date(val); + }, + encode: function (val) { + return [ + val.getFullYear(), + ('0' + (val.getMonth() + 1)).slice(-2), + ('0' + val.getDate()).slice(-2) + ].join("-"); + }, + pattern: /[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[1-2][0-9]|3[0-1])/ } }; diff --git a/test/urlMatcherFactorySpec.js b/test/urlMatcherFactorySpec.js index d70fc86c4..fdec89661 100644 --- a/test/urlMatcherFactorySpec.js +++ b/test/urlMatcherFactorySpec.js @@ -187,5 +187,14 @@ describe("urlMatcherFactory", function () { expect(m.exec("/1138/1")).toEqual({ foo: 1138, flag: true }); expect(m.format({ foo: 5, flag: true })).toBe("/5/1"); }); + + it("should encode/decode dates", function () { + var m = new UrlMatcher("/calendar/{date:date}"), + result = m.exec("/calendar/2014-03-26"); + + expect(result.date instanceof Date).toBe(true); + expect(result.date.toUTCString()).toEqual('Wed, 26 Mar 2014 00:00:00 GMT'); + expect(m.format({ date: new Date(2014, 2, 26) })).toBe("/calendar/2014-03-26"); + }); }); }); From 8939d0572ab1316e458ef016317ecff53131a822 Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Wed, 26 Mar 2014 17:05:01 -0400 Subject: [PATCH 05/25] feat($state): allow parameters to pass unharmed [BREAK] This is a breaking change: state parameters are no longer automatically coerced to strings, and unspecified parameter values are now set to undefined rather than null. --- src/common.js | 17 ----------------- src/state.js | 6 +++--- test/stateDirectivesSpec.js | 18 +++++++++--------- test/stateSpec.js | 20 ++++++++++---------- 4 files changed, 22 insertions(+), 39 deletions(-) diff --git a/src/common.js b/src/common.js index 9f73bacda..7e2209cb8 100644 --- a/src/common.js +++ b/src/common.js @@ -108,23 +108,6 @@ function inheritParams(currentParams, newParams, $current, $to) { return extend({}, inherited, newParams); } -/** - * Normalizes a set of values to string or `null`, filtering them by a list of keys. - * - * @param {Array} keys The list of keys to normalize/return. - * @param {Object} values An object hash of values to normalize. - * @return {Object} Returns an object hash of normalized string values. - */ -function normalize(keys, values) { - var normalized = {}; - - forEach(keys, function (name) { - var value = values[name]; - normalized[name] = (value != null) ? String(value) : null; - }); - return normalized; -} - /** * Performs a non-strict comparison of the subset of two objects, defined by a list of keys. * diff --git a/src/state.js b/src/state.js index 905b89e8f..abf794439 100644 --- a/src/state.js +++ b/src/state.js @@ -814,8 +814,8 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { return $q.when($state.current); } - // Normalize/filter parameters before we pass them to event handlers etc. - toParams = normalize(to.params, toParams || {}); + // Filter parameters before we pass them to event handlers etc. + toParams = filterByKeys(to.params, toParams || {}); // Broadcast start event and cancel the transition if requested if (options.notify) { @@ -1102,7 +1102,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { if (!nav || !nav.url) { return null; } - return $urlRouter.href(nav.url, normalize(state.params, params || {}), { absolute: options.absolute }); + return $urlRouter.href(nav.url, filterByKeys(state.params, params || {}), { absolute: options.absolute }); }; /** diff --git a/test/stateDirectivesSpec.js b/test/stateDirectivesSpec.js index 244f0a060..68c2973d8 100644 --- a/test/stateDirectivesSpec.js +++ b/test/stateDirectivesSpec.js @@ -125,7 +125,7 @@ describe('uiStateRef', function() { $q.flush(); expect($state.current.name).toEqual('contacts.item.detail'); - expect($stateParams).toEqual({ id: "5" }); + expect($stateParams).toEqual({ id: 5 }); })); it('should transition when given a click that contains no data (fake-click)', inject(function($state, $stateParams, $document, $q) { @@ -142,7 +142,7 @@ describe('uiStateRef', function() { $q.flush(); expect($state.current.name).toEqual('contacts.item.detail'); - expect($stateParams).toEqual({ id: "5" }); + expect($stateParams).toEqual({ id: 5 }); })); it('should not transition states when ctrl-clicked', inject(function($state, $stateParams, $document, $q) { @@ -153,7 +153,7 @@ describe('uiStateRef', function() { $q.flush(); expect($state.current.name).toEqual(''); - expect($stateParams).toEqual({ id: "5" }); + expect($stateParams).toEqual({ id: 5 }); })); it('should not transition states when meta-clicked', inject(function($state, $stateParams, $document, $q) { @@ -164,7 +164,7 @@ describe('uiStateRef', function() { $q.flush(); expect($state.current.name).toEqual(''); - expect($stateParams).toEqual({ id: "5" }); + expect($stateParams).toEqual({ id: 5 }); })); it('should not transition states when shift-clicked', inject(function($state, $stateParams, $document, $q) { @@ -175,7 +175,7 @@ describe('uiStateRef', function() { $q.flush(); expect($state.current.name).toEqual(''); - expect($stateParams).toEqual({ id: "5" }); + expect($stateParams).toEqual({ id: 5 }); })); it('should not transition states when middle-clicked', inject(function($state, $stateParams, $document, $q) { @@ -186,7 +186,7 @@ describe('uiStateRef', function() { $q.flush(); expect($state.current.name).toEqual(''); - expect($stateParams).toEqual({ id: "5" }); + expect($stateParams).toEqual({ id: 5 }); })); it('should not transition states when element has target specified', inject(function($state, $stateParams, $document, $q) { @@ -198,7 +198,7 @@ describe('uiStateRef', function() { $q.flush(); expect($state.current.name).toEqual(''); - expect($stateParams).toEqual({ id: "5" }); + expect($stateParams).toEqual({ id: 5 }); })); it('should not transition states if preventDefault() is called in click handler', inject(function($state, $stateParams, $document, $q) { @@ -212,7 +212,7 @@ describe('uiStateRef', function() { $q.flush(); expect($state.current.name).toEqual(''); - expect($stateParams).toEqual({ id: "5" }); + expect($stateParams).toEqual({ id: 5 }); })); it('should allow passing params to current state', inject(function($compile, $rootScope, $state) { @@ -277,7 +277,7 @@ describe('uiStateRef', function() { $q.flush(); expect($state.$current.name).toBe("contacts.item.detail"); - expect($state.params).toEqual({ id: '5' }); + expect($state.params).toEqual({ id: 5 }); })); it('should resolve states from parent uiView', inject(function ($state, $stateParams, $q, $timeout) { diff --git a/test/stateSpec.js b/test/stateSpec.js index 7dcae9f3f..e9036f835 100644 --- a/test/stateSpec.js +++ b/test/stateSpec.js @@ -265,7 +265,7 @@ describe('state', function () { $q.flush(); expect(called).toBeTruthy(); expect($state.current.name).toEqual('DDD'); - expect($state.params).toEqual({ x: '1', y: '2', z: '3', w: '4' }); + expect($state.params).toEqual({ x: 1, y: 2, z: 3, w: 4 }); })); it('can defer a state transition in $stateNotFound', inject(function ($state, $q, $rootScope) { @@ -282,7 +282,7 @@ describe('state', function () { $q.flush(); expect(called).toBeTruthy(); expect($state.current.name).toEqual('AA'); - expect($state.params).toEqual({ a: '1' }); + expect($state.params).toEqual({ a: 1 }); })); it('can defer and supersede a state transition in $stateNotFound', inject(function ($state, $q, $rootScope) { @@ -475,7 +475,7 @@ describe('state', function () { $q.flush(); expect($state.$current.name).toBe('about.person.item'); - expect($stateParams).toEqual({ person: 'bob', id: '5' }); + expect($stateParams).toEqual({ person: 'bob', id: 5 }); $state.go('^.^.sidebar'); $q.flush(); @@ -603,7 +603,7 @@ describe('state', function () { it('contains the parameter values for the current state', inject(function ($state, $q) { initStateTo(D, { x: 'x value', z: 'invalid value' }); - expect($state.params).toEqual({ x: 'x value', y: null }); + expect($state.params).toEqual({ x: 'x value', y: undefined }); })); }); @@ -878,16 +878,16 @@ describe('state', function () { describe('substate and stateParams inheritance', function() { it('should inherit the parent param', inject(function ($state, $stateParams, $q) { - initStateTo($state.get('root'), {param1: 1}); - $state.go('root.sub1', {param2: 2}); + initStateTo($state.get('root'), { param1: 1 }); + $state.go('root.sub1', { param2: 2 }); $q.flush(); expect($state.current.name).toEqual('root.sub1'); - expect($stateParams).toEqual({param1: '1', param2: '2'}); + expect($stateParams).toEqual({ param1: 1, param2: 2 }); })); it('should not inherit siblings\' states', inject(function ($state, $stateParams, $q) { - initStateTo($state.get('root'), {param1: 1}); - $state.go('root.sub1', {param2: 2}); + initStateTo($state.get('root'), { param1: 1 }); + $state.go('root.sub1', { param2: 2 }); $q.flush(); expect($state.current.name).toEqual('root.sub1'); @@ -895,7 +895,7 @@ describe('state', function () { $q.flush(); expect($state.current.name).toEqual('root.sub2'); - expect($stateParams).toEqual({param1: '1', param2: null}); + expect($stateParams).toEqual({ param1: 1, param2: undefined }); })); }); From d8f124c10d00c7e5dde88c602d966db261aea221 Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Wed, 26 Mar 2014 17:34:25 -0400 Subject: [PATCH 06/25] feat($urlMatcherFactory): fail on bad parameters --- src/urlMatcherFactory.js | 8 ++++---- test/urlMatcherFactorySpec.js | 10 ++++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/urlMatcherFactory.js b/src/urlMatcherFactory.js index 4dc9db43f..b02622c0b 100644 --- a/src/urlMatcherFactory.js +++ b/src/urlMatcherFactory.js @@ -248,8 +248,8 @@ UrlMatcher.prototype.format = function (values) { param = params[i]; value = values[param]; type = this.params[param]; - // TODO: Maybe we should throw on null here? It's not really good style - // to use '' and null interchangeabley + + if (!type.is(value)) return null; if (value != null) result += encodeURIComponent(type.encode(value)); result += segments[i + 1]; } @@ -270,11 +270,11 @@ function Type(options) { } Type.prototype.is = function(val, key) { - return angular.toJson(this.decode(this.encode(val))) === angular.toJson(val); + return true; }; Type.prototype.encode = function(val, key) { - return String(val); + return val; }; Type.prototype.decode = function(val, key) { diff --git a/test/urlMatcherFactorySpec.js b/test/urlMatcherFactorySpec.js index fdec89661..b5c04efbf 100644 --- a/test/urlMatcherFactorySpec.js +++ b/test/urlMatcherFactorySpec.js @@ -196,5 +196,15 @@ describe("urlMatcherFactory", function () { expect(result.date.toUTCString()).toEqual('Wed, 26 Mar 2014 00:00:00 GMT'); expect(m.format({ date: new Date(2014, 2, 26) })).toBe("/calendar/2014-03-26"); }); + + it("should not match invalid typed parameter values", function () { + var m = new UrlMatcher('/users/{id:int}'); + + expect(m.exec('/users/1138').id).toBe(1138); + expect(m.exec('/users/alpha')).toBeNull(); + + expect(m.format({ id: 1138 })).toBe("/users/1138"); + expect(m.format({ id: "alpha" })).toBeNull(); + }); }); }); From d48505cd328d83e39d5706e085ba319715f999a6 Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Wed, 26 Mar 2014 17:56:47 -0400 Subject: [PATCH 07/25] feat($urlRouter): force URLs to have valid params --- src/urlMatcherFactory.js | 22 ++++++++++++++++++++++ src/urlRouter.js | 3 +++ test/urlRouterSpec.js | 14 ++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/src/urlMatcherFactory.js b/src/urlMatcherFactory.js index b02622c0b..1870d2341 100644 --- a/src/urlMatcherFactory.js +++ b/src/urlMatcherFactory.js @@ -218,6 +218,28 @@ UrlMatcher.prototype.parameters = function () { return keys(this.params); }; +/** + * @ngdoc function + * @name ui.router.util.type:UrlMatcher#validate + * @methodOf ui.router.util.type:UrlMatcher + * + * @description + * Checks an object hash of parameters to validate their correctness according to the parameter + * types of this `UrlMatcher`. + * + * @param {Object} params The object hash of parameters to validate. + * @returns {Boolean} Returns `true` if `params` validates, otherwise `false`. + */ +UrlMatcher.prototype.validates = function (params) { + var result = true, self = this; + + forEach(params, function(val, key) { + if (!self.params[key]) return; + result = result && self.params[key].is(val); + }); + return result; +} + /** * @ngdoc function * @name ui.router.util.type:UrlMatcher#format diff --git a/src/urlRouter.js b/src/urlRouter.js index 0ad472e6f..d77c96fbd 100644 --- a/src/urlRouter.js +++ b/src/urlRouter.js @@ -292,8 +292,11 @@ function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { }, href: function(urlMatcher, params, options) { + if (!urlMatcher.validates(params)) return null; + var isHtml5 = $locationProvider.html5Mode(); var url = urlMatcher.format(params); + options = options || {}; if (!isHtml5 && url) { url = "#" + $locationProvider.hashPrefix() + url; diff --git a/test/urlRouterSpec.js b/test/urlRouterSpec.js index 9cd7de370..c296aa75b 100644 --- a/test/urlRouterSpec.js +++ b/test/urlRouterSpec.js @@ -146,6 +146,20 @@ describe("UrlRouter", function () { expect($location.url()).toBe('/old'); })); }); + + describe("URL generation", function() { + it("should return null when UrlMatcher rejects parameters", inject(function($urlRouter, $urlMatcherFactory) { + $urlMatcherFactory.type("custom", { + is: function(val) { + return val === 1138; + } + }); + var matcher = new UrlMatcher("/foo/{param:custom}"); + + expect($urlRouter.href(matcher, { param: 1138 })).toBe('#/foo/1138'); + expect($urlRouter.href(matcher, { param: 5 })).toBeNull(); + })); + }); }); }); \ No newline at end of file From c27a0ee4b5f03947f4a9d3e6fe9620fafaf4348d Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Wed, 26 Mar 2014 20:26:45 -0400 Subject: [PATCH 08/25] chore($state): validate rejection of bad params --- src/urlMatcherFactory.js | 4 ++-- test/stateSpec.js | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/urlMatcherFactory.js b/src/urlMatcherFactory.js index 1870d2341..da17fd2c8 100644 --- a/src/urlMatcherFactory.js +++ b/src/urlMatcherFactory.js @@ -79,7 +79,7 @@ function UrlMatcher(pattern, caseInsensitiveMatch) { function addParameter(id, type) { if (!/^\w+(-+\w+)*$/.test(id)) throw new Error("Invalid parameter name '" + id + "' in pattern '" + pattern + "'"); if (params[id]) throw new Error("Duplicate parameter name '" + id + "' in pattern '" + pattern + "'"); - params[id] = type; + params[id] = angular.isNumber(type) ? new Type() : type; } function quoteRegExp(string) { @@ -238,7 +238,7 @@ UrlMatcher.prototype.validates = function (params) { result = result && self.params[key].is(val); }); return result; -} +}; /** * @ngdoc function diff --git a/test/stateSpec.js b/test/stateSpec.js index e9036f835..9fa432c9c 100644 --- a/test/stateSpec.js +++ b/test/stateSpec.js @@ -91,6 +91,9 @@ describe('state', function () { } } }) + .state('badParam', { + url: "/bad/{param:int}" + }) .state('first', { url: '^/first/subpath' }) .state('second', { url: '^/second' }) @@ -712,6 +715,7 @@ describe('state', function () { 'about.person.item', 'about.sidebar', 'about.sidebar.item', + 'badParam', 'dynamicController', 'first', 'home', @@ -779,6 +783,29 @@ describe('state', function () { expect($state.current.name).toBe(''); })); + describe("typed parameter handling", function() { + + it('should initialize parameters without a hacky empty test', inject(function ($urlMatcherFactory, $state) { + new UrlMatcher(""); + })); + + it('should ignore bad url parameters', inject(function ($state, $rootScope, $location, $urlMatcherFactory) { + $location.path("/bad/5"); + $rootScope.$broadcast("$locationChangeSuccess"); + $rootScope.$apply(); + expect($state.current.name).toBe("badParam"); + + $state.transitionTo("about"); + $rootScope.$apply(); + expect($state.current.name).toBe('about'); + + $location.path("/bad/foo"); + $rootScope.$broadcast("$locationChangeSuccess"); + $rootScope.$apply(); + expect($state.current.name).toBe("about"); + })); + }); + it('should revert to last known working url on state change failure', inject(function ($state, $rootScope, $location, $q) { $state.transitionTo("about"); $q.flush(); From 07b3029f4d409cf955780113df92e36401b47580 Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Tue, 15 Apr 2014 22:29:39 -0400 Subject: [PATCH 09/25] feat(UrlMatcher): add per-param config support Implements optional parameters and default parameter values. [BC-BREAK]: the `params` option in state configurations must now be an object keyed by parameter name. --- src/common.js | 5 +- src/state.js | 42 ++++++++-------- src/urlMatcherFactory.js | 90 ++++++++++++++++++++++------------- test/stateSpec.js | 14 +++--- test/urlMatcherFactorySpec.js | 66 +++++++++++++++++++++++-- 5 files changed, 149 insertions(+), 68 deletions(-) diff --git a/src/common.js b/src/common.js index 7e2209cb8..bd2b97a5e 100644 --- a/src/common.js +++ b/src/common.js @@ -96,8 +96,9 @@ function inheritParams(currentParams, newParams, $current, $to) { var parents = ancestors($current, $to), parentParams, inherited = {}, inheritList = []; for (var i in parents) { - if (!parents[i].params || !parents[i].params.length) continue; - parentParams = parents[i].params; + if (!parents[i].params) continue; + parentParams = keys(parents[i].params); + if (!parentParams.length) continue; for (var j in parentParams) { if (arraySearch(inheritList, parentParams[j]) >= 0) continue; diff --git a/src/state.js b/src/state.js index abf794439..88d066824 100644 --- a/src/state.js +++ b/src/state.js @@ -48,18 +48,14 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { // Build a URLMatcher if necessary, either via a relative or absolute URL url: function(state) { - var url = state.url; + var url = state.url, config = { params: state.params || {} }; if (isString(url)) { - if (url.charAt(0) == '^') { - return $urlMatcherFactory.compile(url.substring(1)); - } - return (state.parent.navigable || root).url.concat(url); + if (url.charAt(0) == '^') return $urlMatcherFactory.compile(url.substring(1), config); + return (state.parent.navigable || root).url.concat(url, config); } - if ($urlMatcherFactory.isMatcher(url) || url == null) { - return url; - } + if (!url || $urlMatcherFactory.isMatcher(url)) return url; throw new Error("Invalid url '" + url + "' in state '" + state + "'"); }, @@ -71,10 +67,8 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { // Derive parameters for this state and ensure they're a super-set of parent's parameters params: function(state) { if (!state.params) { - return state.url ? state.url.parameters() : state.parent.params; + return state.url ? state.url.params : state.parent.params; } - if (!isArray(state.params)) throw new Error("Invalid params in state '" + state + "'"); - if (state.url) throw new Error("Both params and url specicified in state '" + state + "'"); return state.params; }, @@ -94,16 +88,18 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { }, ownParams: function(state) { + state.params = state.params || {}; + if (!state.parent) { - return state.params; + return keys(state.params); } - var paramNames = {}; forEach(state.params, function (p) { paramNames[p] = true; }); + var paramNames = {}; forEach(state.params, function (v, k) { paramNames[k] = true; }); - forEach(state.parent.params, function (p) { - if (!paramNames[p]) { - throw new Error("Missing required parameter '" + p + "' in state '" + state.name + "'"); + forEach(state.parent.params, function (v, k) { + if (!paramNames[k]) { + throw new Error("Missing required parameter '" + k + "' in state '" + state.name + "'"); } - paramNames[p] = false; + paramNames[k] = false; }); var ownParams = []; @@ -782,8 +778,8 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { toState = findState(to, options.relative); if (!isDefined(toState)) { - if (options.relative) throw new Error("Could not resolve '" + to + "' from state '" + options.relative + "'"); - throw new Error("No such state '" + to + "'"); + if (!options.relative) throw new Error("No such state '" + to + "'"); + throw new Error("Could not resolve '" + to + "' from state '" + options.relative + "'"); } } if (toState[abstractKey]) throw new Error("Cannot transition to abstract state '" + to + "'"); @@ -808,14 +804,14 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { // TODO: We may not want to bump 'transition' if we're called from a location change // that we've initiated ourselves, because we might accidentally abort a legitimate // transition initiated from code? - if (shouldTriggerReload(to, from, locals, options) ) { + if (shouldTriggerReload(to, from, locals, options)) { if (to.self.reloadOnSearch !== false) $urlRouter.update(); $state.transition = null; return $q.when($state.current); } // Filter parameters before we pass them to event handlers etc. - toParams = filterByKeys(to.params, toParams || {}); + toParams = filterByKeys(keys(to.params), toParams || {}); // Broadcast start event and cancel the transition if requested if (options.notify) { @@ -1102,7 +1098,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { if (!nav || !nav.url) { return null; } - return $urlRouter.href(nav.url, filterByKeys(state.params, params || {}), { absolute: options.absolute }); + return $urlRouter.href(nav.url, filterByKeys(keys(state.params), params || {}), { absolute: options.absolute }); }; /** @@ -1132,7 +1128,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { // necessary. In addition to being available to the controller and onEnter/onExit callbacks, // we also need $stateParams to be available for any $injector calls we make during the // dependency resolution process. - var $stateParams = (paramsAreFiltered) ? params : filterByKeys(state.params, params); + var $stateParams = (paramsAreFiltered) ? params : filterByKeys(keys(state.params), params); var locals = { $stateParams: $stateParams }; // Resolve 'global' dependencies for the state, i.e. those not specific to a view. diff --git a/src/urlMatcherFactory.js b/src/urlMatcherFactory.js index da17fd2c8..d02fe506b 100644 --- a/src/urlMatcherFactory.js +++ b/src/urlMatcherFactory.js @@ -38,8 +38,11 @@ * path into the parameter 'path'. * * `'/files/*path'` - ditto. * - * @param {string} pattern the pattern to compile into a matcher. - * @param {bool} caseInsensitiveMatch true if url matching should be case insensitive, otherwise false, the default value (for backward compatibility) is false. + * @param {string} pattern The pattern to compile into a matcher. + * @param {config} config A configuration object hash: + * + * * `caseInsensitive` - `true` if URL matching should be case insensitive, otherwise `false`, the default value (for backward compatibility) is `false`. + * * `strict` - `false` if matching against a URL with a trailing slash should be treated as equivalent to a URL without a trailing slash, the default value is `true`. * * @property {string} prefix A static prefix of this pattern. The matcher guarantees that any * URL matching this matcher (i.e. any string for which {@link ui.router.util.type:UrlMatcher#methods_exec exec()} returns @@ -54,9 +57,10 @@ * @property {string} regex The constructed regex that will be used to match against the url when * it is time to determine which url will match. * - * @returns {Object} New UrlMatcher object + * @returns {Object} New `UrlMatcher` object */ -function UrlMatcher(pattern, caseInsensitiveMatch) { +function UrlMatcher(pattern, config) { + config = angular.isObject(config) ? config : {}; // Find all placeholders and create a compiled pattern, using either classic or curly syntax: // '*' name @@ -76,32 +80,41 @@ function UrlMatcher(pattern, caseInsensitiveMatch) { segments = this.segments = [], params = this.params = {}; - function addParameter(id, type) { + function addParameter(id, type, config) { if (!/^\w+(-+\w+)*$/.test(id)) throw new Error("Invalid parameter name '" + id + "' in pattern '" + pattern + "'"); if (params[id]) throw new Error("Duplicate parameter name '" + id + "' in pattern '" + pattern + "'"); - params[id] = angular.isNumber(type) ? new Type() : type; + params[id] = extend({ type: type || new Type() }, config); + } + + function quoteRegExp(string, pattern, isOptional) { + var result = string.replace(/[\\\[\]\^$*+?.()|{}]/g, "\\$&"); + if (!pattern) return result; + var flag = isOptional ? '?' : ''; + return result + flag + '(' + pattern + ')' + flag; } - function quoteRegExp(string) { - return string.replace(/[\\\[\]\^$*+?.()|{}]/g, "\\$&"); + function paramConfig(param) { + if (!config.params || !config.params[param]) return {}; + return config.params[param]; } this.source = pattern; // Split into static segments separated by path parameter placeholders. // The number of segments is always 1 more than the number of parameters. - var id, regexp, segment, type; + var id, regexp, segment, type, cfg; while ((m = placeholder.exec(pattern))) { id = m[2] || m[3]; // IE[78] returns '' for unmatched groups instead of null regexp = m[4] || (m[1] == '*' ? '.*' : '[^/]*'); segment = pattern.substring(last, m.index); type = this.$types[regexp] || new Type({ pattern: new RegExp(regexp) }); + cfg = paramConfig(id); if (segment.indexOf('?') >= 0) break; // we're into the search part - compiled += quoteRegExp(segment) + '(' + type.$subPattern() + ')'; - addParameter(id, type); + compiled += quoteRegExp(segment, type.$subPattern(), isDefined(cfg.value)); + addParameter(id, type, cfg); segments.push(segment); last = placeholder.lastIndex; } @@ -116,7 +129,9 @@ function UrlMatcher(pattern, caseInsensitiveMatch) { this.sourcePath = pattern.substring(0, last + i); // Allow parameters to be separated by '?' as well as '&' to make concat() easier - forEach(search.substring(1).split(/[&?]/), addParameter); + forEach(search.substring(1).split(/[&?]/), function(key) { + addParameter(key, null, paramConfig(key)); + }); } else { this.sourcePath = pattern; this.sourceSearch = ''; @@ -125,7 +140,7 @@ function UrlMatcher(pattern, caseInsensitiveMatch) { compiled += quoteRegExp(segment) + '$'; segments.push(segment); - this.regexp = (caseInsensitiveMatch) ? new RegExp(compiled, 'i') : new RegExp(compiled); + this.regexp = RegExp(compiled, config.caseInsensitive ? 'i' : undefined); this.prefix = segments[0]; } @@ -148,13 +163,14 @@ function UrlMatcher(pattern, caseInsensitiveMatch) { * ``` * * @param {string} pattern The pattern to append. + * @param {object} config An object hash of the configuration for the matcher. * @returns {ui.router.util.type:UrlMatcher} A matcher for the concatenated pattern. */ -UrlMatcher.prototype.concat = function (pattern) { +UrlMatcher.prototype.concat = function (pattern, config) { // Because order of search parameters is irrelevant, we can add our own search // parameters to the end of the new pattern. Parse the new pattern by itself // and then join the bits together, but it's much easier to do this on a string level. - return new UrlMatcher(this.sourcePath + pattern + this.sourceSearch); + return new UrlMatcher(this.sourcePath + pattern + this.sourceSearch, config); }; UrlMatcher.prototype.toString = function () { @@ -189,14 +205,14 @@ UrlMatcher.prototype.exec = function (path, searchParams) { var params = this.parameters(), nTotal = params.length, nPath = this.segments.length - 1, - values = {}, i, type, param; + values = {}, i, cfg, param; if (nPath !== m.length - 1) throw new Error("Unbalanced capture group in route '" + this.source + "'"); for (i = 0; i < nPath; i++) { param = params[i]; - type = this.params[param]; - values[param] = type.decode(m[i + 1]); + cfg = this.params[param]; + values[param] = cfg.type.decode(isDefined(m[i + 1]) ? m[i + 1] : cfg.value); } for (/**/; i < nTotal; i++) values[params[i]] = searchParams[params[i]]; @@ -214,8 +230,9 @@ UrlMatcher.prototype.exec = function (path, searchParams) { * @returns {Array.} An array of parameter names. Must be treated as read-only. If the * pattern has no parameters, an empty array is returned. */ -UrlMatcher.prototype.parameters = function () { - return keys(this.params); +UrlMatcher.prototype.parameters = function (param) { + if (!isDefined(param)) return keys(this.params); + return this.params[param] || null; }; /** @@ -231,11 +248,13 @@ UrlMatcher.prototype.parameters = function () { * @returns {Boolean} Returns `true` if `params` validates, otherwise `false`. */ UrlMatcher.prototype.validates = function (params) { - var result = true, self = this; + var result = true, isOptional, cfg, self = this; forEach(params, function(val, key) { if (!self.params[key]) return; - result = result && self.params[key].is(val); + cfg = self.params[key]; + isOptional = !val && isDefined(cfg.value); + result = result && (isOptional || cfg.type.is(val)); }); return result; }; @@ -261,18 +280,21 @@ UrlMatcher.prototype.validates = function (params) { */ UrlMatcher.prototype.format = function (values) { var segments = this.segments, params = this.parameters(); - if (!values) return segments.join(''); + + if (!values) return segments.join('').replace('//', '/'); var nPath = segments.length - 1, nTotal = params.length, - result = segments[0], i, search, value, param, type; + result = segments[0], i, search, value, param, cfg; + + if (!this.validates(values)) return null; for (i = 0; i < nPath; i++) { param = params[i]; value = values[param]; - type = this.params[param]; + cfg = this.params[param]; - if (!type.is(value)) return null; - if (value != null) result += encodeURIComponent(type.encode(value)); + if (!isDefined(value) && (segments[i] === '/' || segments[i + 1] === '/')) continue; + if (value != null) result += encodeURIComponent(cfg.type.encode(value)); result += segments[i + 1]; } @@ -324,7 +346,7 @@ Type.prototype.pattern = /.*/; */ function $UrlMatcherFactory() { - var useCaseInsensitiveMatch = false; + var isCaseInsensitive = false; var enqueue = true, typeQueue = [], injector, defaultTypes = { int: { @@ -332,6 +354,7 @@ function $UrlMatcherFactory() { return parseInt(val, 10); }, is: function(val) { + if (!isDefined(val)) return false; return this.decode(val.toString()) === val; }, pattern: /\d+/ @@ -371,7 +394,7 @@ function $UrlMatcherFactory() { /** * @ngdoc function - * @name ui.router.util.$urlMatcherFactory#caseInsensitiveMatch + * @name ui.router.util.$urlMatcherFactory#caseInsensitive * @methodOf ui.router.util.$urlMatcherFactory * * @description @@ -379,8 +402,8 @@ function $UrlMatcherFactory() { * * @param {bool} value false to match URL in a case sensitive manner; otherwise true; */ - this.caseInsensitiveMatch = function(value) { - useCaseInsensitiveMatch = value; + this.caseInsensitive = function(value) { + isCaseInsensitive = value; }; /** @@ -392,10 +415,11 @@ function $UrlMatcherFactory() { * Creates a {@link ui.router.util.type:UrlMatcher} for the specified pattern. * * @param {string} pattern The URL pattern. + * @param {object} config The config object hash. * @returns {ui.router.util.type:UrlMatcher} The UrlMatcher. */ - this.compile = function (pattern) { - return new UrlMatcher(pattern, useCaseInsensitiveMatch); + this.compile = function (pattern, config) { + return new UrlMatcher(pattern, extend({ caseInsensitive: isCaseInsensitive }, config)); }; /** diff --git a/test/stateSpec.js b/test/stateSpec.js index 9fa432c9c..3d504f538 100644 --- a/test/stateSpec.js +++ b/test/stateSpec.js @@ -20,9 +20,9 @@ describe('state', function () { var A = { data: {} }, B = {}, C = {}, - D = { params: [ 'x', 'y' ] }, - DD = { parent: D, params: [ 'x', 'y', 'z' ] }, - E = { params: [ 'i' ] }, + D = { params: { x: {}, y: {} } }, + DD = { parent: D, params: { x: {}, y: {}, z: {} } }, + E = { params: { i: {} } }, H = { data: {propA: 'propA', propB: 'propB'} }, HH = { parent: H }, HHH = {parent: HH, data: {propA: 'overriddenA', propC: 'propC'} }, @@ -261,7 +261,7 @@ describe('state', function () { initStateTo(DD, { x: 1, y: 2, z: 3 }); var called; $rootScope.$on('$stateNotFound', function (ev, redirect) { - stateProvider.state(redirect.to, { parent: DD, params: [ 'x', 'y', 'z', 'w' ]}); + stateProvider.state(redirect.to, { parent: DD, params: { x: {}, y: {}, z: {}, w: {} }}); called = true; }); var promise = $state.go('DDD', { w: 4 }); @@ -280,7 +280,7 @@ describe('state', function () { called = true; }); var promise = $state.go('AA', { a: 1 }); - stateProvider.state('AA', { parent: A, params: [ 'a' ]}); + stateProvider.state('AA', { parent: A, params: { a: {} }}); deferred.resolve(); $q.flush(); expect(called).toBeTruthy(); @@ -298,7 +298,7 @@ describe('state', function () { }); var promise = $state.go('AA', { a: 1 }); $state.go(B); - stateProvider.state('AA', { parent: A, params: [ 'a' ]}); + stateProvider.state('AA', { parent: A, params: { a: {} }}); deferred.resolve(); $q.flush(); expect(called).toBeTruthy(); @@ -439,7 +439,7 @@ describe('state', function () { describe('.go()', function () { it('transitions to a relative state', inject(function ($state, $q) { - $state.transitionTo('about.person.item', { id: 5 }); $q.flush(); + $state.transitionTo('about.person.item', { person: "bob", id: 5 }); $q.flush(); $state.go('^.^.sidebar'); $q.flush(); expect($state.$current.name).toBe('about.sidebar'); diff --git a/test/urlMatcherFactorySpec.js b/test/urlMatcherFactorySpec.js index b5c04efbf..0654d98fc 100644 --- a/test/urlMatcherFactorySpec.js +++ b/test/urlMatcherFactorySpec.js @@ -5,7 +5,7 @@ describe("UrlMatcher", function () { }); it("should match static case insensitive URLs", function () { - expect(new UrlMatcher('/hello/world', true).exec('/heLLo/World')).toEqual({}); + expect(new UrlMatcher('/hello/world', { caseInsensitive: true }).exec('/heLLo/World')).toEqual({}); }); it("should match against the entire path", function () { @@ -155,7 +155,7 @@ describe("urlMatcherFactory", function () { }); it("should handle case insensistive URL", function () { - $umf.caseInsensitiveMatch(true); + $umf.caseInsensitive(true); expect($umf.compile('/hello/world').exec('/heLLo/WORLD')).toEqual({}); }); @@ -197,7 +197,7 @@ describe("urlMatcherFactory", function () { expect(m.format({ date: new Date(2014, 2, 26) })).toBe("/calendar/2014-03-26"); }); - it("should not match invalid typed parameter values", function () { + it("should not match invalid typed parameter values", function() { var m = new UrlMatcher('/users/{id:int}'); expect(m.exec('/users/1138').id).toBe(1138); @@ -207,4 +207,64 @@ describe("urlMatcherFactory", function () { expect(m.format({ id: "alpha" })).toBeNull(); }); }); + + describe("optional parameters", function() { + it("should match with or without values", function () { + var m = new UrlMatcher('/users/{id:int}', { + params: { id: { value: null } } + }); + expect(m.exec('/users/1138')).toEqual({ id: 1138 }); + expect(m.exec('/users/').id.toString()).toBe("NaN"); + expect(m.exec('/users').id.toString()).toBe("NaN"); + }); + + it("should correctly match multiple", function() { + var m = new UrlMatcher('/users/{id:int}/{state:[A-Z]+}', { + params: { id: { value: null }, state: { value: null } } + }); + expect(m.exec('/users/1138')).toEqual({ id: 1138, state: null }); + expect(m.exec('/users/1138/NY')).toEqual({ id: 1138, state: "NY" }); + + expect(m.exec('/users/').id.toString()).toBe("NaN"); + expect(m.exec('/users/').state).toBeNull(); + + expect(m.exec('/users').id.toString()).toBe("NaN"); + expect(m.exec('/users').state).toBeNull(); + + expect(m.exec('/users/NY').state).toBe("NY"); + expect(m.exec('/users/NY').id.toString()).toBe("NaN"); + }); + + it("should correctly format with or without values", function() { + var m = new UrlMatcher('/users/{id:int}', { + params: { id: { value: null } } + }); + expect(m.format()).toBe('/users/'); + expect(m.format({ id: 1138 })).toBe('/users/1138'); + }); + + it("should correctly format multiple", function() { + var m = new UrlMatcher('/users/{id:int}/{state:[A-Z]+}', { + params: { id: { value: null }, state: { value: null } } + }); + + expect(m.format()).toBe("/users/"); + expect(m.format({ id: 1138 })).toBe("/users/1138/"); + expect(m.format({ state: "NY" })).toBe("/users/NY"); + expect(m.format({ id: 1138, state: "NY" })).toBe("/users/1138/NY"); + }); + + describe("default values", function() { + it("should populate if not supplied in URL", function() { + var m = new UrlMatcher('/users/{id:int}/{test}', { + params: { id: { value: 0 }, test: { value: "foo" } } + }); + expect(m.exec('/users')).toEqual({ id: 0, test: "foo" }); + expect(m.exec('/users/2')).toEqual({ id: 2, test: "foo" }); + expect(m.exec('/users/bar')).toEqual({ id: 0, test: "bar" }); + expect(m.exec('/users/2/bar')).toEqual({ id: 2, test: "bar" }); + expect(m.exec('/users/bar/2')).toBeNull(); + }); + }); + }); }); From 27d382cebf05aa8342949885505485603697b82e Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Tue, 15 Apr 2014 22:31:20 -0400 Subject: [PATCH 10/25] test(uiSref): remove unused dependencies --- test/stateDirectivesSpec.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/stateDirectivesSpec.js b/test/stateDirectivesSpec.js index 68c2973d8..da21806b4 100644 --- a/test/stateDirectivesSpec.js +++ b/test/stateDirectivesSpec.js @@ -117,7 +117,7 @@ describe('uiStateRef', function() { expect(el.attr('href')).toBe('#/contacts/3'); })); - it('should transition states when left-clicked', inject(function($state, $stateParams, $document, $q) { + it('should transition states when left-clicked', inject(function($state, $stateParams, $q) { expect($state.$current.name).toEqual(''); triggerClick(el); @@ -128,7 +128,7 @@ describe('uiStateRef', function() { expect($stateParams).toEqual({ id: 5 }); })); - it('should transition when given a click that contains no data (fake-click)', inject(function($state, $stateParams, $document, $q) { + it('should transition when given a click that contains no data (fake-click)', inject(function($state, $stateParams, $q) { expect($state.current.name).toEqual(''); triggerClick(el, { @@ -145,7 +145,7 @@ describe('uiStateRef', function() { expect($stateParams).toEqual({ id: 5 }); })); - it('should not transition states when ctrl-clicked', inject(function($state, $stateParams, $document, $q) { + it('should not transition states when ctrl-clicked', inject(function($state, $stateParams, $q) { expect($state.$current.name).toEqual(''); triggerClick(el, { ctrlKey: true }); @@ -156,7 +156,7 @@ describe('uiStateRef', function() { expect($stateParams).toEqual({ id: 5 }); })); - it('should not transition states when meta-clicked', inject(function($state, $stateParams, $document, $q) { + it('should not transition states when meta-clicked', inject(function($state, $stateParams, $q) { expect($state.$current.name).toEqual(''); triggerClick(el, { metaKey: true }); @@ -167,7 +167,7 @@ describe('uiStateRef', function() { expect($stateParams).toEqual({ id: 5 }); })); - it('should not transition states when shift-clicked', inject(function($state, $stateParams, $document, $q) { + it('should not transition states when shift-clicked', inject(function($state, $stateParams, $q) { expect($state.$current.name).toEqual(''); triggerClick(el, { shiftKey: true }); @@ -178,7 +178,7 @@ describe('uiStateRef', function() { expect($stateParams).toEqual({ id: 5 }); })); - it('should not transition states when middle-clicked', inject(function($state, $stateParams, $document, $q) { + it('should not transition states when middle-clicked', inject(function($state, $stateParams, $q) { expect($state.$current.name).toEqual(''); triggerClick(el, { button: 1 }); @@ -189,7 +189,7 @@ describe('uiStateRef', function() { expect($stateParams).toEqual({ id: 5 }); })); - it('should not transition states when element has target specified', inject(function($state, $stateParams, $document, $q) { + it('should not transition states when element has target specified', inject(function($state, $stateParams, $q) { el.attr('target', '_blank'); expect($state.$current.name).toEqual(''); @@ -201,7 +201,7 @@ describe('uiStateRef', function() { expect($stateParams).toEqual({ id: 5 }); })); - it('should not transition states if preventDefault() is called in click handler', inject(function($state, $stateParams, $document, $q) { + it('should not transition states if preventDefault() is called in click handler', inject(function($state, $stateParams, $q) { expect($state.$current.name).toEqual(''); el.bind('click', function(e) { e.preventDefault(); From c72d8ce11916d0ac22c81b409c9e61d7048554d7 Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Tue, 15 Apr 2014 22:47:54 -0400 Subject: [PATCH 11/25] feat($urlRouter): defer URL change interception $urlRouter's listener can now be made to take lower priority than custom listeners by calling `$urlRouterProvider.deferIntercept()` to detach the listener, and `$urlRouter.listen()` to reattach it. --- src/urlRouter.js | 79 ++++++++++++++++++++++++++++++++++++++----- test/urlRouterSpec.js | 73 ++++++++++++++++++++++++++++----------- 2 files changed, 123 insertions(+), 29 deletions(-) diff --git a/src/urlRouter.js b/src/urlRouter.js index d77c96fbd..bfa22fe0e 100644 --- a/src/urlRouter.js +++ b/src/urlRouter.js @@ -16,8 +16,7 @@ */ $UrlRouterProvider.$inject = ['$locationProvider', '$urlMatcherFactoryProvider']; function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { - var rules = [], - otherwise = null; + var rules = [], otherwise = null, interceptDeferred = false, listener; // Returns a string that is a prefix of all strings matching the RegExp function regExpPrefix(re) { @@ -38,7 +37,7 @@ function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { * @methodOf ui.router.router.$urlRouterProvider * * @description - * Defines rules that are used by `$urlRouterProvider to find matches for + * Defines rules that are used by `$urlRouterProvider` to find matches for * specific URLs. * * @example @@ -61,7 +60,7 @@ function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { * @param {object} rule Handler function that takes `$injector` and `$location` * services as arguments. You can use them to return a valid path as a string. * - * @return {object} $urlRouterProvider - $urlRouterProvider instance + * @return {object} `$urlRouterProvider` - `$urlRouterProvider` instance */ this.rule = function (rule) { if (!isFunction(rule)) throw new Error("'rule' must be a function"); @@ -75,7 +74,7 @@ function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { * @methodOf ui.router.router.$urlRouterProvider * * @description - * Defines a path that is used when an invalied route is requested. + * Defines a path that is used when an invalid route is requested. * * @example *
@@ -98,7 +97,7 @@ function $UrlRouterProvider(   $locationProvider,   $urlMatcherFactory) {
    * rule that returns the url path. The function version is passed two params: 
    * `$injector` and `$location` services.
    *
-   * @return {object} $urlRouterProvider - $urlRouterProvider instance
+   * @return {object} `$urlRouterProvider` - `$urlRouterProvider` instance
    */
   this.otherwise = function (rule) {
     if (isString(rule)) {
@@ -124,8 +123,8 @@ function $UrlRouterProvider(   $locationProvider,   $urlMatcherFactory) {
    *
    * @description
    * Registers a handler for a given url matching. if handle is a string, it is
-   * treated as a redirect, and is interpolated according to the syyntax of match
-   * (i.e. like String.replace() for RegExp, or like a UrlMatcher pattern otherwise).
+   * treated as a redirect, and is interpolated according to the syntax of match
+   * (i.e. like `String.replace()` for `RegExp`, or like a `UrlMatcher` pattern otherwise).
    *
    * If the handler is a function, it is injectable. It gets invoked if `$location`
    * matches. You have the option of inject the match object as `$match`.
@@ -197,6 +196,59 @@ function $UrlRouterProvider(   $locationProvider,   $urlMatcherFactory) {
     throw new Error("invalid 'what' in when()");
   };
 
+  /**
+   * @ngdoc function
+   * @name ui.router.router.$urlRouterProvider#deferIntercept
+   * @methodOf ui.router.router.$urlRouterProvider
+   *
+   * @description
+   * Disables (or enables) deferring location change interception.
+   *
+   * If you wish to customize the behavior of syncing the URL (for example, if you wish to
+   * defer a transition but maintain the current URL), call this method at configuration time.
+   * Then, at run time, call `$urlRouter.listen()` after you have configured your own
+   * `$locationChangeSuccess` event handler.
+   *
+   * @example
+   * 
+   * var app = angular.module('app', ['ui.router.router']);
+   *
+   * app.config(function ($urlRouterProvider) {
+   *
+   *   // Prevent $urlRouter from automatically intercepting URL changes;
+   *   // this allows you to configure custom behavior in between
+   *   // location changes and route synchronization:
+   *   $urlRouterProvider.deferIntercept();
+   *
+   * }).run(function ($rootScope, $urlRouter, UserService) {
+   *
+   *   $rootScope.$on('$locationChangeSuccess', function(e) {
+   *     // UserService is an example service for managing user state
+   *     if (UserService.isLoggedIn()) return;
+   *
+   *     // Prevent $urlRouter's default handler from firing
+   *     e.preventDefault();
+   *
+   *     UserService.handleLogin().then(function() {
+   *       // Once the user has logged in, sync the current URL
+   *       // to the router:
+   *       $urlRouter.sync();
+   *     });
+   *   });
+   *
+   *   // Configures $urlRouter's listener *after* your custom listener
+   *   $urlRouter.listen();
+   * });
+   * 
+ * + * @param {boolean} defer Indicates whether to defer location change interception. Passing + no parameter is equivalent to `true`. + */ + this.deferIntercept = function (defer) { + if (defer === undefined) defer = true; + interceptDeferred = defer; + } + /** * @ngdoc object * @name ui.router.router.$urlRouter @@ -242,7 +294,12 @@ function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { if (otherwise) check(otherwise); } - $rootScope.$on('$locationChangeSuccess', update); + function listen() { + listener = listener || $rootScope.$on('$locationChangeSuccess', update); + return listener; + } + + if (!interceptDeferred) listen(); return { /** @@ -275,6 +332,10 @@ function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { update(); }, + listen: function() { + return listen(); + }, + update: function(read) { if (read) { location = $location.url(); diff --git a/test/urlRouterSpec.js b/test/urlRouterSpec.js index c296aa75b..e4183d8b2 100644 --- a/test/urlRouterSpec.js +++ b/test/urlRouterSpec.js @@ -2,40 +2,73 @@ describe("UrlRouter", function () { var $urp, $ur, location, match, scope; - beforeEach(function() { - angular.module('ui.router.router.test', function() {}).config(function ($urlRouterProvider) { - $urp = $urlRouterProvider; + describe("provider", function () { - $urp.rule(function ($injector, $location) { - var path = $location.path(); - if (!/baz/.test(path)) return false; - return path.replace('baz', 'b4z'); - }).when('/foo/:param', function($match) { - match = ['/foo/:param', $match]; - }).when('/bar', function($match) { - match = ['/bar', $match]; + beforeEach(function() { + angular.module('ui.router.router.test', function() {}).config(function ($urlRouterProvider) { + $urlRouterProvider.deferIntercept(); + $urp = $urlRouterProvider; }); - }); - module('ui.router.router', 'ui.router.router.test'); + module('ui.router.router', 'ui.router.router.test'); - inject(function($rootScope, $location, $injector) { - scope = $rootScope.$new(); - location = $location; - $ur = $injector.invoke($urp.$get); + inject(function($rootScope, $location, $injector) { + scope = $rootScope.$new(); + location = $location; + $ur = $injector.invoke($urp.$get); + }); }); - }); - - describe("provider", function () { it("should throw on non-function rules", function () { expect(function() { $urp.rule(null); }).toThrow("'rule' must be a function") expect(function() { $urp.otherwise(null); }).toThrow("'rule' must be a function") }); + it("should allow location changes to be deferred", inject(function ($urlRouter, $location, $rootScope) { + var log = []; + + $urp.rule(function ($injector, $location) { + log.push($location.path()); + }); + + $location.path("/foo"); + $rootScope.$broadcast("$locationChangeSuccess"); + + expect(log).toEqual([]); + + $urlRouter.listen(); + $rootScope.$broadcast("$locationChangeSuccess"); + + expect(log).toEqual(["/foo"]); + })); }); describe("service", function() { + + beforeEach(function() { + angular.module('ui.router.router.test', function() {}).config(function ($urlRouterProvider) { + $urp = $urlRouterProvider; + + $urp.rule(function ($injector, $location) { + var path = $location.path(); + if (!/baz/.test(path)) return false; + return path.replace('baz', 'b4z'); + }).when('/foo/:param', function($match) { + match = ['/foo/:param', $match]; + }).when('/bar', function($match) { + match = ['/bar', $match]; + }); + }); + + module('ui.router.router', 'ui.router.router.test'); + + inject(function($rootScope, $location, $injector) { + scope = $rootScope.$new(); + location = $location; + $ur = $injector.invoke($urp.$get); + }); + }); + it("should execute rewrite rules", function () { location.path("/foo"); scope.$emit("$locationChangeSuccess"); From ad07a8d0ff185572c05e596f5e3a11880c583ba2 Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Tue, 15 Apr 2014 23:06:00 -0400 Subject: [PATCH 12/25] style(UrlMatcher): semantic and formatting fixes --- src/urlMatcherFactory.js | 2 +- src/urlRouter.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/urlMatcherFactory.js b/src/urlMatcherFactory.js index d02fe506b..0521fd531 100644 --- a/src/urlMatcherFactory.js +++ b/src/urlMatcherFactory.js @@ -140,7 +140,7 @@ function UrlMatcher(pattern, config) { compiled += quoteRegExp(segment) + '$'; segments.push(segment); - this.regexp = RegExp(compiled, config.caseInsensitive ? 'i' : undefined); + this.regexp = new RegExp(compiled, config.caseInsensitive ? 'i' : undefined); this.prefix = segments[0]; } diff --git a/src/urlRouter.js b/src/urlRouter.js index bfa22fe0e..3b7fb8d13 100644 --- a/src/urlRouter.js +++ b/src/urlRouter.js @@ -247,7 +247,7 @@ function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { this.deferIntercept = function (defer) { if (defer === undefined) defer = true; interceptDeferred = defer; - } + }; /** * @ngdoc object From a3e21366bee0475c9795a1ec76f70eec41c5b4e3 Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Wed, 16 Apr 2014 07:26:24 -0400 Subject: [PATCH 13/25] feat(UrlMatcher): implement non-strict matching Implements strict (and non-strict) matching, for configuring whether UrlMatchers should treat URLs with and without trailing slashes identically. --- src/urlMatcherFactory.js | 33 ++++++++++++++++---- test/urlMatcherFactorySpec.js | 58 +++++++++++++++++++++++++++++++++-- 2 files changed, 83 insertions(+), 8 deletions(-) diff --git a/src/urlMatcherFactory.js b/src/urlMatcherFactory.js index 0521fd531..4e49e5708 100644 --- a/src/urlMatcherFactory.js +++ b/src/urlMatcherFactory.js @@ -137,7 +137,7 @@ function UrlMatcher(pattern, config) { this.sourceSearch = ''; } - compiled += quoteRegExp(segment) + '$'; + compiled += quoteRegExp(segment) + (config.strict === false ? '\/?' : '') + '$'; segments.push(segment); this.regexp = new RegExp(compiled, config.caseInsensitive ? 'i' : undefined); @@ -346,7 +346,7 @@ Type.prototype.pattern = /.*/; */ function $UrlMatcherFactory() { - var isCaseInsensitive = false; + var isCaseInsensitive = false, isStrictMode = true; var enqueue = true, typeQueue = [], injector, defaultTypes = { int: { @@ -392,20 +392,41 @@ function $UrlMatcherFactory() { } }; + function getDefaultConfig() { + return { + strict: isStrictMode, + caseInsensitive: isCaseInsensitive + }; + } + /** * @ngdoc function * @name ui.router.util.$urlMatcherFactory#caseInsensitive * @methodOf ui.router.util.$urlMatcherFactory * * @description - * Define if url matching should be case sensistive, the default behavior, or not. - * - * @param {bool} value false to match URL in a case sensitive manner; otherwise true; + * Defines whether URL matching should be case sensitive (the default behavior), or not. + * + * @param {bool} value `false` to match URL in a case sensitive manner; otherwise `true`; */ this.caseInsensitive = function(value) { isCaseInsensitive = value; }; + /** + * @ngdoc function + * @name ui.router.util.$urlMatcherFactory#strictMode + * @methodOf ui.router.util.$urlMatcherFactory + * + * @description + * Defines whether URLs should match trailing slashes, or not (the default behavior). + * + * @param {bool} value `false` to match trailing slashes in URLs, otherwise `true`. + */ + this.strictMode = function(value) { + isStrictMode = value; + }; + /** * @ngdoc function * @name ui.router.util.$urlMatcherFactory#compile @@ -419,7 +440,7 @@ function $UrlMatcherFactory() { * @returns {ui.router.util.type:UrlMatcher} The UrlMatcher. */ this.compile = function (pattern, config) { - return new UrlMatcher(pattern, extend({ caseInsensitive: isCaseInsensitive }, config)); + return new UrlMatcher(pattern, extend(getDefaultConfig(), config)); }; /** diff --git a/test/urlMatcherFactorySpec.js b/test/urlMatcherFactorySpec.js index 0654d98fc..12fc4b33f 100644 --- a/test/urlMatcherFactorySpec.js +++ b/test/urlMatcherFactorySpec.js @@ -1,6 +1,37 @@ describe("UrlMatcher", function () { - it("shoudl match static URLs", function () { + describe("provider", function () { + + var provider; + + beforeEach(function() { + angular.module('ui.router.router.test', function() {}).config(function ($urlMatcherFactoryProvider) { + provider = $urlMatcherFactoryProvider; + }); + + module('ui.router.router', 'ui.router.router.test'); + + inject(function($injector) { + $injector.invoke(provider.$get); + }); + }); + + it("should factory matchers with correct configuration", function () { + provider.caseInsensitive(false); + expect(provider.compile('/hello').exec('/HELLO')).toBeNull(); + + provider.caseInsensitive(true); + expect(provider.compile('/hello').exec('/HELLO')).toEqual({}); + + provider.strictMode(true); + expect(provider.compile('/hello').exec('/hello/')).toBeNull(); + + provider.strictMode(false); + expect(provider.compile('/hello').exec('/hello/')).toEqual({}); + }); + }); + + it("should match static URLs", function () { expect(new UrlMatcher('/hello/world').exec('/hello/world')).toEqual({}); }); @@ -14,7 +45,7 @@ describe("UrlMatcher", function () { expect(matcher.exec('/hello/world/suffix')).toBeNull(); }); - it("shoudl parse parameter placeholders", function () { + it("should parse parameter placeholders", function () { var matcher = new UrlMatcher('/users/:id/details/{type}/{repeat:[0-9]+}?from&to'); var params = matcher.parameters(); expect(params.length).toBe(5); @@ -267,4 +298,27 @@ describe("urlMatcherFactory", function () { }); }); }); + + describe("strict matching", function() { + it("should match with or without trailing slash", function() { + var m = new UrlMatcher('/users', { strict: false }); + expect(m.exec('/users')).toEqual({}); + expect(m.exec('/users/')).toEqual({}); + }); + + it("should not match multiple trailing slashes", function() { + var m = new UrlMatcher('/users', { strict: false }); + expect(m.exec('/users//')).toBeNull(); + }); + + it("should match when defined with parameters", function() { + var m = new UrlMatcher('/users/{name}', { strict: false, params: { + name: { value: null } + }}); + expect(m.exec('/users/')).toEqual({ name: null }); + expect(m.exec('/users/bob')).toEqual({ name: "bob" }); + expect(m.exec('/users/bob/')).toEqual({ name: "bob" }); + expect(m.exec('/users/bob//')).toBeNull(); + }); + }); }); From 32b27db173722e9194ef1d5c0ea7d93f25a98d11 Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Wed, 16 Apr 2014 07:45:30 -0400 Subject: [PATCH 14/25] feat(UrlMatcher): validates whole interface urlMatcherFactoryProvider.isMatcher() now validates the entire UrlMatcher interface. --- src/urlMatcherFactory.js | 15 ++++++++++++--- test/urlMatcherFactorySpec.js | 16 +++++++++++++++- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/urlMatcherFactory.js b/src/urlMatcherFactory.js index 4e49e5708..0bb9878f5 100644 --- a/src/urlMatcherFactory.js +++ b/src/urlMatcherFactory.js @@ -449,13 +449,22 @@ function $UrlMatcherFactory() { * @methodOf ui.router.util.$urlMatcherFactory * * @description - * Returns true if the specified object is a UrlMatcher, or false otherwise. + * Returns true if the specified object is a `UrlMatcher`, or false otherwise. * * @param {Object} object The object to perform the type check against. - * @returns {Boolean} Returns `true` if the object has the following functions: `exec`, `format`, and `concat`. + * @returns {Boolean} Returns `true` if the object matches the `UrlMatcher` interface, by + * implementing all the same methods. */ this.isMatcher = function (o) { - return isObject(o) && isFunction(o.exec) && isFunction(o.format) && isFunction(o.concat); + if (!isObject(o)) return false; + var result = true; + + forEach(UrlMatcher.prototype, function(val, name) { + if (isFunction(val)) { + result = result && (isDefined(o[name]) && isFunction(o[name])); + } + }); + return result; }; this.type = function (name, def) { diff --git a/test/urlMatcherFactorySpec.js b/test/urlMatcherFactorySpec.js index 12fc4b33f..5be15d9b2 100644 --- a/test/urlMatcherFactorySpec.js +++ b/test/urlMatcherFactorySpec.js @@ -29,6 +29,14 @@ describe("UrlMatcher", function () { provider.strictMode(false); expect(provider.compile('/hello').exec('/hello/')).toEqual({}); }); + + it("should correctly validate UrlMatcher interface", function () { + var m = new UrlMatcher("/"); + expect(provider.isMatcher(m)).toBe(true); + + m.validates = null; + expect(provider.isMatcher(m)).toBe(false); + }); }); it("should match static URLs", function () { @@ -177,7 +185,13 @@ describe("urlMatcherFactory", function () { it("recognizes matchers", function () { expect($umf.isMatcher(new UrlMatcher('/'))).toBe(true); - var custom = { format: angular.noop, exec: angular.noop, concat: angular.noop }; + var custom = { + format: angular.noop, + exec: angular.noop, + concat: angular.noop, + validates: angular.noop, + parameters: angular.noop + }; expect($umf.isMatcher(custom)).toBe(true); }); From 7057de4e3487fa5621b17d4ae113e145732a55a5 Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Wed, 16 Apr 2014 08:13:21 -0400 Subject: [PATCH 15/25] test($urlRouter): update to UrlMatcher interface --- test/urlRouterSpec.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/urlRouterSpec.js b/test/urlRouterSpec.js index e4183d8b2..8cc41698f 100644 --- a/test/urlRouterSpec.js +++ b/test/urlRouterSpec.js @@ -97,7 +97,13 @@ describe("UrlRouter", function () { it("should allow custom URL matchers", function () { var custom = { - url: { exec: function() {}, format: function() {}, concat: function() {} }, + url: { + exec: function() {}, + format: function() {}, + concat: function() {}, + validates: function() {}, + parameters: function() {} + }, handler: function() {} }; From bba61cdaa9dc0836b38d31ee64f8dc28f8a0f954 Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Thu, 17 Apr 2014 06:57:48 -0400 Subject: [PATCH 16/25] doc($urlRouter): document href() method --- src/urlRouter.js | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/urlRouter.js b/src/urlRouter.js index 3b7fb8d13..750470242 100644 --- a/src/urlRouter.js +++ b/src/urlRouter.js @@ -352,6 +352,31 @@ function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { if (options && options.replace) $location.replace(); }, + /** + * @ngdoc function + * @name ui.router.router.$urlRouter#href + * @methodOf ui.router.router.$urlRouter + * + * @description + * A URL generation method that returns the compiled URL for a given + * {@link ui.router.util.type:UrlMatcher `UrlMatcher`}, populated with the provided parameters. + * + * @example + *
+       * $bob = $urlRouter.href(new UrlMatcher("/about/:person"), {
+       *   person: "bob"
+       * });
+       * // $bob == "/about/bob";
+       * 
+ * + * @param {UrlMatcher} urlMatcher The `UrlMatcher` object which is used as the template of the URL to generate. + * @param {object=} params An object of parameter values to fill the matcher's required parameters. + * @param {object=} options Options object. The options are: + * + * - **`absolute`** - {boolean=false}, If true will generate an absolute url, e.g. "http://www.example.com/fullurl". + * + * @returns {string} Returns the fully compiled URL, or `null` if `params` fail validation against `urlMatcher` + */ href: function(urlMatcher, params, options) { if (!urlMatcher.validates(params)) return null; From 101012e6c3765a428c8989ab441af1d7c02dc1ea Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Thu, 17 Apr 2014 06:58:14 -0400 Subject: [PATCH 17/25] perf($urlRouter): only call $location.port() once --- src/urlRouter.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/urlRouter.js b/src/urlRouter.js index 750470242..e4a76524f 100644 --- a/src/urlRouter.js +++ b/src/urlRouter.js @@ -393,8 +393,8 @@ function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { return url; } - var slash = (!isHtml5 && url ? '/' : ''), - port = $location.port() == 80 || $location.port() == 443 ? '' : ':' + $location.port(); + var slash = (!isHtml5 && url ? '/' : ''), port = $location.port(); + port = (port === 80 || port === 443 ? '' : ':' + port); return [$location.protocol(), '://', $location.host(), port, slash, url].join(''); } From 5b7243049793505e44b6608ea09878c37c95b1f5 Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Thu, 17 Apr 2014 08:51:38 -0400 Subject: [PATCH 18/25] feat(UrlMatcher): allow shorthand definitions When defining parameter configurations, `{ param: "default" }` is now equivalent to `{ param: { value: "default" }}`. --- src/urlMatcherFactory.js | 3 ++- test/urlMatcherFactorySpec.js | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/urlMatcherFactory.js b/src/urlMatcherFactory.js index 0bb9878f5..0a7c6aa3f 100644 --- a/src/urlMatcherFactory.js +++ b/src/urlMatcherFactory.js @@ -95,7 +95,8 @@ function UrlMatcher(pattern, config) { function paramConfig(param) { if (!config.params || !config.params[param]) return {}; - return config.params[param]; + var cfg = config.params[param]; + return isObject(cfg) ? cfg : { value: cfg }; } this.source = pattern; diff --git a/test/urlMatcherFactorySpec.js b/test/urlMatcherFactorySpec.js index 5be15d9b2..85d3209a6 100644 --- a/test/urlMatcherFactorySpec.js +++ b/test/urlMatcherFactorySpec.js @@ -310,6 +310,13 @@ describe("urlMatcherFactory", function () { expect(m.exec('/users/2/bar')).toEqual({ id: 2, test: "bar" }); expect(m.exec('/users/bar/2')).toBeNull(); }); + + it("should allow shorthand definitions", function() { + var m = new UrlMatcher('/foo/:foo', { + params: { foo: "bar" } + }); + expect(m.exec("/foo")).toEqual({ foo: "bar" }); + }); }); }); From a472b301389fbe84d1c1fa9f24852b492a569d11 Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Thu, 17 Apr 2014 08:59:04 -0400 Subject: [PATCH 19/25] feat(UrlMatcher): default values & type decoding for query params --- src/urlMatcherFactory.js | 7 ++++++- test/urlMatcherFactorySpec.js | 8 ++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/urlMatcherFactory.js b/src/urlMatcherFactory.js index 0a7c6aa3f..c8bc59014 100644 --- a/src/urlMatcherFactory.js +++ b/src/urlMatcherFactory.js @@ -203,6 +203,7 @@ UrlMatcher.prototype.toString = function () { UrlMatcher.prototype.exec = function (path, searchParams) { var m = this.regexp.exec(path); if (!m) return null; + searchParams = searchParams || {}; var params = this.parameters(), nTotal = params.length, nPath = this.segments.length - 1, @@ -215,7 +216,11 @@ UrlMatcher.prototype.exec = function (path, searchParams) { cfg = this.params[param]; values[param] = cfg.type.decode(isDefined(m[i + 1]) ? m[i + 1] : cfg.value); } - for (/**/; i < nTotal; i++) values[params[i]] = searchParams[params[i]]; + for (/**/; i < nTotal; i++) { + param = params[i]; + cfg = this.params[param]; + values[param] = cfg.type.decode(isDefined(searchParams[param]) ? searchParams[param] : cfg.value); + } return values; }; diff --git a/test/urlMatcherFactorySpec.js b/test/urlMatcherFactorySpec.js index 85d3209a6..5844a76ad 100644 --- a/test/urlMatcherFactorySpec.js +++ b/test/urlMatcherFactorySpec.js @@ -317,6 +317,14 @@ describe("urlMatcherFactory", function () { }); expect(m.exec("/foo")).toEqual({ foo: "bar" }); }); + + it("should populate default values for query params", function() { + var defaults = { order: "name", limit: 25, page: 1 }; + var m = new UrlMatcher('/foo?order&limit&page', { + params: defaults + }); + expect(m.exec("/foo")).toEqual(defaults); + }); }); }); From 67be0bdc54683dfe26d4d3c647d39a03c29eb98a Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Thu, 17 Apr 2014 14:32:27 -0400 Subject: [PATCH 20/25] docs($urlMatcherFactory): Type object & formatting Adds documentation for the Type object, and misc. cleanup and improvement of UrlMatcher and $urlMatcherFactory docs. --- src/urlMatcherFactory.js | 224 +++++++++++++++++++++++++++++++++++---- 1 file changed, 203 insertions(+), 21 deletions(-) diff --git a/src/urlMatcherFactory.js b/src/urlMatcherFactory.js index c8bc59014..f1ed0986c 100644 --- a/src/urlMatcherFactory.js +++ b/src/urlMatcherFactory.js @@ -39,7 +39,7 @@ * * `'/files/*path'` - ditto. * * @param {string} pattern The pattern to compile into a matcher. - * @param {config} config A configuration object hash: + * @param {Object} config A configuration object hash: * * * `caseInsensitive` - `true` if URL matching should be case insensitive, otherwise `false`, the default value (for backward compatibility) is `false`. * * `strict` - `false` if matching against a URL with a trailing slash should be treated as equivalent to a URL without a trailing slash, the default value is `true`. @@ -158,14 +158,14 @@ function UrlMatcher(pattern, config) { * * @example * The following two matchers are equivalent: - * ``` + *
  * new UrlMatcher('/user/{id}?q').concat('/details?date');
  * new UrlMatcher('/user/{id}/details?q&date');
- * ```
+ * 
* * @param {string} pattern The pattern to append. - * @param {object} config An object hash of the configuration for the matcher. - * @returns {ui.router.util.type:UrlMatcher} A matcher for the concatenated pattern. + * @param {Object} config An object hash of the configuration for the matcher. + * @returns {UrlMatcher} A matcher for the concatenated pattern. */ UrlMatcher.prototype.concat = function (pattern, config) { // Because order of search parameters is irrelevant, we can add our own search @@ -191,10 +191,12 @@ UrlMatcher.prototype.toString = function () { * as optional. * * @example - * ``` - * new UrlMatcher('/user/{id}?q&r').exec('/user/bob', { x:'1', q:'hello' }); - * // returns { id:'bob', q:'hello', r:null } - * ``` + *
+ * new UrlMatcher('/user/{id}?q&r').exec('/user/bob', {
+ *   x: '1', q: 'hello'
+ * });
+ * // returns { id: 'bob', q: 'hello', r: null }
+ * 
* * @param {string} path The URL path to match, e.g. `$location.path()`. * @param {Object} searchParams URL search parameters, e.g. `$location.search()`. @@ -251,7 +253,7 @@ UrlMatcher.prototype.parameters = function (param) { * types of this `UrlMatcher`. * * @param {Object} params The object hash of parameters to validate. - * @returns {Boolean} Returns `true` if `params` validates, otherwise `false`. + * @returns {boolean} Returns `true` if `params` validates, otherwise `false`. */ UrlMatcher.prototype.validates = function (params) { var result = true, isOptional, cfg, self = this; @@ -276,10 +278,10 @@ UrlMatcher.prototype.validates = function (params) { * treated as empty strings. * * @example - * ``` + *
  * new UrlMatcher('/user/{id}?q').format({ id:'bob', q:'yes' });
  * // returns '/user/bob?q=yes'
- * ```
+ * 
* * @param {Object} values the values to substitute for the parameters in this pattern. * @returns {string} the formatted URL (path and optionally search part). @@ -315,22 +317,98 @@ UrlMatcher.prototype.format = function (values) { UrlMatcher.prototype.$types = {}; -function Type(options) { - extend(this, options); +/** + * @ngdoc object + * @name ui.router.util.type:Type + * + * @description + * Implements an interface to define custom parameter types that can be decoded from and encoded to + * string parameters matched in a URL. Used by {@link ui.router.util.type:UrlMatcher `UrlMatcher`} + * objects when matching or formatting URLs, or comparing or validating parameter values. + * + * See {@link ui.router.util.$urlMatcherFactory#methods_type `$urlMatcherFactory#type()`} for more + * information on registering custom types. + * + * @param {Object} config A configuration object hash that includes any method in `Type`'s public + * interface, and/or `pattern`, which should contain a custom regular expression used to match + * string parameters originating from a URL. + * + * @property {RegExp} pattern The regular expression pattern used to match values of this type when + * coming from a substring of a URL. + * + * @returns {Object} Returns a new `Type` object. + */ +function Type(config) { + extend(this, config); } +/** + * @ngdoc function + * @name ui.router.util.type:Type#is + * @methodOf ui.router.util.type:Type + * + * @description + * Detects whether a value is of a particular type. Accepts a native (decoded) value + * and determines whether it matches the current `Type` object. + * + * @param {*} val The value to check. + * @param {string} key Optional. If the type check is happening in the context of a specific + * {@link ui.router.util.type:UrlMatcher `UrlMatcher`} object, this is the name of the + * parameter in which `val` is stored. Can be used for meta-programming of `Type` objects. + * @returns {Boolean} Returns `true` if the value matches the type, otherwise `false`. + */ Type.prototype.is = function(val, key) { return true; }; +/** + * @ngdoc function + * @name ui.router.util.type:Type#encode + * @methodOf ui.router.util.type:Type + * + * @description + * Encodes a custom/native type value to a string that can be embedded in a URL. Note that the + * return value does *not* need to be URL-safe (i.e. passed through `encodeURIComponent()`), it + * only needs to be a representation of `val` that has been coerced to a string. + * + * @param {*} val The value to encode. + * @param {string} key The name of the parameter in which `val` is stored. Can be used for + * meta-programming of `Type` objects. + * @returns {string} Returns a string representation of `val` that can be encoded in a URL. + */ Type.prototype.encode = function(val, key) { return val; }; +/** + * @ngdoc function + * @name ui.router.util.type:Type#decode + * @methodOf ui.router.util.type:Type + * + * @description + * Converts a string URL parameter value to a custom/native value. + * + * @param {string} val The URL parameter value to decode. + * @param {string} key The name of the parameter in which `val` is stored. Can be used for + * meta-programming of `Type` objects. + * @returns {*} Returns a custom representation of the URL parameter value. + */ Type.prototype.decode = function(val, key) { return val; }; +/** + * @ngdoc function + * @name ui.router.util.type:Type#equals + * @methodOf ui.router.util.type:Type + * + * @description + * Determines whether two decoded values are equivalent. + * + * @param {*} a A value to compare against. + * @param {*} b A value to compare against. + * @returns {Boolean} Returns `true` if the values are equivalent/equal, otherwise `false`. + */ Type.prototype.equals = function(a, b) { return a == b; }; @@ -347,8 +425,8 @@ Type.prototype.pattern = /.*/; * @name ui.router.util.$urlMatcherFactory * * @description - * Factory for {@link ui.router.util.type:UrlMatcher} instances. The factory is also available to providers - * under the name `$urlMatcherFactoryProvider`. + * Factory for {@link ui.router.util.type:UrlMatcher `UrlMatcher`} instances. The factory + * is also available to providers under the name `$urlMatcherFactoryProvider`. */ function $UrlMatcherFactory() { @@ -413,7 +491,7 @@ function $UrlMatcherFactory() { * @description * Defines whether URL matching should be case sensitive (the default behavior), or not. * - * @param {bool} value `false` to match URL in a case sensitive manner; otherwise `true`; + * @param {boolean} value `false` to match URL in a case sensitive manner; otherwise `true`; */ this.caseInsensitive = function(value) { isCaseInsensitive = value; @@ -427,7 +505,7 @@ function $UrlMatcherFactory() { * @description * Defines whether URLs should match trailing slashes, or not (the default behavior). * - * @param {bool} value `false` to match trailing slashes in URLs, otherwise `true`. + * @param {boolean} value `false` to match trailing slashes in URLs, otherwise `true`. */ this.strictMode = function(value) { isStrictMode = value; @@ -439,11 +517,11 @@ function $UrlMatcherFactory() { * @methodOf ui.router.util.$urlMatcherFactory * * @description - * Creates a {@link ui.router.util.type:UrlMatcher} for the specified pattern. + * Creates a {@link ui.router.util.type:UrlMatcher `UrlMatcher`} for the specified pattern. * * @param {string} pattern The URL pattern. - * @param {object} config The config object hash. - * @returns {ui.router.util.type:UrlMatcher} The UrlMatcher. + * @param {Object} config The config object hash. + * @returns {UrlMatcher} The UrlMatcher. */ this.compile = function (pattern, config) { return new UrlMatcher(pattern, extend(getDefaultConfig(), config)); @@ -473,6 +551,110 @@ function $UrlMatcherFactory() { return result; }; + /** + * @ngdoc function + * @name ui.router.util.$urlMatcherFactory#type + * @methodOf ui.router.util.$urlMatcherFactory + * + * @description + * Registers a custom {@link ui.router.util.type:Type `Type`} object that can be used to + * generate URLs with typed parameters. + * + * @param {string} name The type name. + * @param {Object|Function} def The type definition. See + * {@link ui.router.util.type:Type `Type`} for information on the values accepted. + * + * @returns {Object} Returns `$urlMatcherFactoryProvider`. + * + * @example + * This is a simple example of a custom type that encodes and decodes items from an + * array, using the array index as the URL-encoded value: + * + *
+   * var list = ['John', 'Paul', 'George', 'Ringo'];
+   *
+   * $urlMatcherFactoryProvider.type('listItem', {
+   *   encode: function(item) {
+   *     // Represent the list item in the URL using its corresponding index
+   *     return list.indexOf(item);
+   *   },
+   *   decode: function(item) {
+   *     // Look up the list item by index
+   *     return list[parseInt(item, 10)];
+   *   },
+   *   is: function(item) {
+   *     // Ensure the item is valid by checking to see that it appears
+   *     // in the list
+   *     return list.indexOf(item) > -1;
+   *   }
+   * });
+   *
+   * $stateProvider.state('list', {
+   *   url: "/list/{item:listItem}",
+   *   controller: function($scope, $stateParams) {
+   *     console.log($stateParams.item);
+   *   }
+   * });
+   *
+   * // ...
+   *
+   * // Changes URL to '/list/3', logs "Ringo" to the console
+   * $state.go('list', { item: "Ringo" });
+   * 
+ * + * This is a more complex example of a type that relies on dependency injection to + * interact with services, and uses the parameter name from the URL to infer how to + * handle encoding and decoding parameter values: + * + *
+   * // Defines a custom type that gets a value from a service,
+   * // where each service gets different types of values from
+   * // a backend API:
+   * $urlMatcherFactoryProvider.type('dbObject', function(Users, Posts) {
+   *
+   *   // Matches up services to URL parameter names
+   *   var services = {
+   *     user: Users,
+   *     post: Posts
+   *   };
+   *
+   *   return {
+   *     encode: function(object) {
+   *       // Represent the object in the URL using its unique ID
+   *       return object.id;
+   *     },
+   *     decode: function(value, key) {
+   *       // Look up the object by ID, using the parameter
+   *       // name (key) to call the correct service
+   *       return services[key].findById(value);
+   *     },
+   *     is: function(object, key) {
+   *       // Check that object is a valid dbObject
+   *       return angular.isObject(object) && object.id && services[key];
+   *     }
+   *     equals: function(a, b) {
+   *       // Check the equality of decoded objects by comparing
+   *       // their unique IDs
+   *       return a.id === b.id;
+   *     }
+   *   };
+   * });
+   *
+   * // In a config() block, you can then attach URLs with
+   * // type-annotated parameters:
+   * $stateProvider.state('users', {
+   *   url: "/users",
+   *   // ...
+   * }).state('users.item', {
+   *   url: "/{user:dbObject}",
+   *   controller: function($scope, $stateParams) {
+   *     // $stateParams.user will now be an object returned from
+   *     // the Users service
+   *   },
+   *   // ...
+   * });
+   * 
+ */ this.type = function (name, def) { if (!isDefined(def)) return UrlMatcher.prototype.$types[name]; typeQueue.push({ name: name, def: def }); From 91f75ae66c4d129f6f69e53bd547594e9661f5d5 Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Thu, 17 Apr 2014 14:38:33 -0400 Subject: [PATCH 21/25] fix($urlMatcherFactory): detect injected functions Correctly detects injectable functions annotated as arrays when registering new type definitions. --- src/urlMatcherFactory.js | 6 +++++- test/urlMatcherFactorySpec.js | 11 +++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/urlMatcherFactory.js b/src/urlMatcherFactory.js index f1ed0986c..04a21ea85 100644 --- a/src/urlMatcherFactory.js +++ b/src/urlMatcherFactory.js @@ -675,12 +675,16 @@ function $UrlMatcherFactory() { return this; }]; + // To ensure proper order of operations in object configuration, and to allow internal + // types to be overridden, `flushTypeQueue()` waits until `$urlMatcherFactory` is injected + // before actually wiring up and assigning type definitions function flushTypeQueue() { forEach(typeQueue, function(type) { if (UrlMatcher.prototype.$types[type.name]) { throw new Error("A type named '" + type.name + "' has already been defined."); } - var def = new Type(isFunction(type.def) ? injector.invoke(type.def) : type.def); + var isAnnotated = isFunction(type.def) || isArray(type.def); + var def = new Type(isAnnotated ? injector.invoke(type.def) : type.def); UrlMatcher.prototype.$types[type.name] = def; }); } diff --git a/test/urlMatcherFactorySpec.js b/test/urlMatcherFactorySpec.js index 5844a76ad..433157bcf 100644 --- a/test/urlMatcherFactorySpec.js +++ b/test/urlMatcherFactorySpec.js @@ -227,6 +227,17 @@ describe("urlMatcherFactory", function () { expect($umf.type("myType").decode()).toBe($stateParams); })); + it("should accept annotated function definitions", inject(function ($stateParams) { + $umf.type("myAnnotatedType", ['$stateParams', function(s) { + return { + decode: function() { + return s; + } + }; + }]); + expect($umf.type("myAnnotatedType").decode()).toBe($stateParams); + })); + it("should match built-in types", function () { var m = new UrlMatcher("/{foo:int}/{flag:bool}"); expect(m.exec("/1138/1")).toEqual({ foo: 1138, flag: true }); From b881c72289fc8f53d0264686dcd194c48f5f06ed Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Thu, 17 Apr 2014 19:09:52 -0400 Subject: [PATCH 22/25] chore(common): unique name for internal function --- src/common.js | 4 ++-- src/state.js | 17 ++++++++++++----- src/urlMatcherFactory.js | 2 +- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/common.js b/src/common.js index bd2b97a5e..1dc0172ee 100644 --- a/src/common.js +++ b/src/common.js @@ -49,7 +49,7 @@ function ancestors(first, second) { * @param {Object} object A JavaScript object. * @return {Array} Returns the keys of the object as an array. */ -function keys(object) { +function objectKeys(object) { if (Object.keys) { return Object.keys(object); } @@ -97,7 +97,7 @@ function inheritParams(currentParams, newParams, $current, $to) { for (var i in parents) { if (!parents[i].params) continue; - parentParams = keys(parents[i].params); + parentParams = objectKeys(parents[i].params); if (!parentParams.length) continue; for (var j in parentParams) { diff --git a/src/state.js b/src/state.js index 88d066824..cf32681e3 100644 --- a/src/state.js +++ b/src/state.js @@ -91,7 +91,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { state.params = state.params || {}; if (!state.parent) { - return keys(state.params); + return objectKeys(state.params); } var paramNames = {}; forEach(state.params, function (v, k) { paramNames[k] = true; }); @@ -811,7 +811,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { } // Filter parameters before we pass them to event handlers etc. - toParams = filterByKeys(keys(to.params), toParams || {}); + toParams = filterByKeys(objectKeys(to.params), toParams || {}); // Broadcast start event and cancel the transition if requested if (options.notify) { @@ -1086,7 +1086,12 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { * @returns {string} compiled state url */ $state.href = function href(stateOrName, params, options) { - options = extend({ lossy: true, inherit: false, absolute: false, relative: $state.$current }, options || {}); + options = extend({ + lossy: true, + inherit: false, + absolute: false, + relative: $state.$current + }, options || {}); var state = findState(stateOrName, options.relative); @@ -1098,7 +1103,9 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { if (!nav || !nav.url) { return null; } - return $urlRouter.href(nav.url, filterByKeys(keys(state.params), params || {}), { absolute: options.absolute }); + return $urlRouter.href(nav.url, filterByKeys(objectKeys(state.params), params || {}), { + absolute: options.absolute + }); }; /** @@ -1128,7 +1135,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { // necessary. In addition to being available to the controller and onEnter/onExit callbacks, // we also need $stateParams to be available for any $injector calls we make during the // dependency resolution process. - var $stateParams = (paramsAreFiltered) ? params : filterByKeys(keys(state.params), params); + var $stateParams = (paramsAreFiltered) ? params : filterByKeys(objectKeys(state.params), params); var locals = { $stateParams: $stateParams }; // Resolve 'global' dependencies for the state, i.e. those not specific to a view. diff --git a/src/urlMatcherFactory.js b/src/urlMatcherFactory.js index 04a21ea85..8d2607ead 100644 --- a/src/urlMatcherFactory.js +++ b/src/urlMatcherFactory.js @@ -239,7 +239,7 @@ UrlMatcher.prototype.exec = function (path, searchParams) { * pattern has no parameters, an empty array is returned. */ UrlMatcher.prototype.parameters = function (param) { - if (!isDefined(param)) return keys(this.params); + if (!isDefined(param)) return objectKeys(this.params); return this.params[param] || null; }; From 63607bdbbcb432d3fb37856a1cb3da0cd496804e Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Sat, 19 Apr 2014 17:49:45 -0400 Subject: [PATCH 23/25] fix(UrlMatcher): don't decode default values --- src/urlMatcherFactory.js | 4 ++-- test/urlMatcherFactorySpec.js | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/urlMatcherFactory.js b/src/urlMatcherFactory.js index 8d2607ead..7ae6410f4 100644 --- a/src/urlMatcherFactory.js +++ b/src/urlMatcherFactory.js @@ -216,12 +216,12 @@ UrlMatcher.prototype.exec = function (path, searchParams) { for (i = 0; i < nPath; i++) { param = params[i]; cfg = this.params[param]; - values[param] = cfg.type.decode(isDefined(m[i + 1]) ? m[i + 1] : cfg.value); + values[param] = isDefined(m[i + 1]) ? cfg.type.decode(m[i + 1]) : cfg.value; } for (/**/; i < nTotal; i++) { param = params[i]; cfg = this.params[param]; - values[param] = cfg.type.decode(isDefined(searchParams[param]) ? searchParams[param] : cfg.value); + values[param] = isDefined(searchParams[param]) ? cfg.type.decode(searchParams[param]) : cfg.value; } return values; diff --git a/test/urlMatcherFactorySpec.js b/test/urlMatcherFactorySpec.js index 433157bcf..b377b0157 100644 --- a/test/urlMatcherFactorySpec.js +++ b/test/urlMatcherFactorySpec.js @@ -270,8 +270,8 @@ describe("urlMatcherFactory", function () { params: { id: { value: null } } }); expect(m.exec('/users/1138')).toEqual({ id: 1138 }); - expect(m.exec('/users/').id.toString()).toBe("NaN"); - expect(m.exec('/users').id.toString()).toBe("NaN"); + expect(m.exec('/users/').id).toBeNull(); + expect(m.exec('/users').id).toBeNull(); }); it("should correctly match multiple", function() { @@ -281,14 +281,14 @@ describe("urlMatcherFactory", function () { expect(m.exec('/users/1138')).toEqual({ id: 1138, state: null }); expect(m.exec('/users/1138/NY')).toEqual({ id: 1138, state: "NY" }); - expect(m.exec('/users/').id.toString()).toBe("NaN"); + expect(m.exec('/users/').id).toBeNull(); expect(m.exec('/users/').state).toBeNull(); - expect(m.exec('/users').id.toString()).toBe("NaN"); + expect(m.exec('/users').id).toBeNull(); expect(m.exec('/users').state).toBeNull(); expect(m.exec('/users/NY').state).toBe("NY"); - expect(m.exec('/users/NY').id.toString()).toBe("NaN"); + expect(m.exec('/users/NY').id).toBeNull(); }); it("should correctly format with or without values", function() { From 00966ecd91fb745846039160cab707bfca8b3bec Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Sat, 19 Apr 2014 19:12:04 -0400 Subject: [PATCH 24/25] feat(UrlMatcher): injectable functions as defaults Default parameter values may now be specified as injectable functions. --- src/urlMatcherFactory.js | 29 +++++++++++++++++++++++++---- test/urlMatcherFactorySpec.js | 27 ++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/urlMatcherFactory.js b/src/urlMatcherFactory.js index 7ae6410f4..83044bf28 100644 --- a/src/urlMatcherFactory.js +++ b/src/urlMatcherFactory.js @@ -80,10 +80,19 @@ function UrlMatcher(pattern, config) { segments = this.segments = [], params = this.params = {}; + /** + * [Internal] Gets the decoded representation of a value if the value is defined, otherwise, returns the + * default value, which may be the result of an injectable function. + */ + function $value(value) { + /*jshint validthis: true */ + return isDefined(value) ? this.type.decode(value) : $UrlMatcherFactory.$$getDefaultValue(this); + } + function addParameter(id, type, config) { if (!/^\w+(-+\w+)*$/.test(id)) throw new Error("Invalid parameter name '" + id + "' in pattern '" + pattern + "'"); if (params[id]) throw new Error("Duplicate parameter name '" + id + "' in pattern '" + pattern + "'"); - params[id] = extend({ type: type || new Type() }, config); + params[id] = extend({ type: type || new Type(), $value: $value }, config); } function quoteRegExp(string, pattern, isOptional) { @@ -216,7 +225,7 @@ UrlMatcher.prototype.exec = function (path, searchParams) { for (i = 0; i < nPath; i++) { param = params[i]; cfg = this.params[param]; - values[param] = isDefined(m[i + 1]) ? cfg.type.decode(m[i + 1]) : cfg.value; + values[param] = cfg.$value(m[i + 1]); } for (/**/; i < nTotal; i++) { param = params[i]; @@ -483,6 +492,19 @@ function $UrlMatcherFactory() { }; } + function isInjectable(value) { + return (isFunction(value) || (isArray(value) && isFunction(value[value.length - 1]))); + } + + /** + * [Internal] Get the default value of a parameter, which may be an injectable function. + */ + $UrlMatcherFactory.$$getDefaultValue = function(config) { + if (!isInjectable(config.value)) return config.value; + if (!injector) throw new Error("Injectable functions cannot be called at configuration time"); + return injector.invoke(config.value); + }; + /** * @ngdoc function * @name ui.router.util.$urlMatcherFactory#caseInsensitive @@ -683,8 +705,7 @@ function $UrlMatcherFactory() { if (UrlMatcher.prototype.$types[type.name]) { throw new Error("A type named '" + type.name + "' has already been defined."); } - var isAnnotated = isFunction(type.def) || isArray(type.def); - var def = new Type(isAnnotated ? injector.invoke(type.def) : type.def); + var def = new Type(isInjectable(type.def) ? injector.invoke(type.def) : type.def); UrlMatcher.prototype.$types[type.name] = def; }); } diff --git a/test/urlMatcherFactorySpec.js b/test/urlMatcherFactorySpec.js index b377b0157..02ea7ec95 100644 --- a/test/urlMatcherFactorySpec.js +++ b/test/urlMatcherFactorySpec.js @@ -329,13 +329,38 @@ describe("urlMatcherFactory", function () { expect(m.exec("/foo")).toEqual({ foo: "bar" }); }); - it("should populate default values for query params", function() { + it("should populate query params", function() { var defaults = { order: "name", limit: 25, page: 1 }; var m = new UrlMatcher('/foo?order&limit&page', { params: defaults }); expect(m.exec("/foo")).toEqual(defaults); }); + + it("should allow function-calculated values", function() { + var m = new UrlMatcher('/foo/:bar', { + params: { + bar: function() { + return "Value from bar()"; + } + } + }); + expect(m.exec('/foo').bar).toBe("Value from bar()"); + }); + + it("should allow injectable functions", inject(function($stateParams) { + var m = new UrlMatcher('/users/:user', { + params: { + user: function($stateParams) { + return $stateParams.user; + } + } + }); + var user = { name: "Bob" }; + + $stateParams.user = user; + expect(m.exec('/users').user).toBe(user); + })); }); }); From 7ef8f3cdee8b4b059aa54b6261d9b22423c63d6d Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Sat, 19 Apr 2014 19:12:50 -0400 Subject: [PATCH 25/25] test(UrlMatcher): optional params between statics Ensure that optional parameters that exist between static segments can be matched (or not matched) correctly. --- test/urlMatcherFactorySpec.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/urlMatcherFactorySpec.js b/test/urlMatcherFactorySpec.js index 02ea7ec95..80b500c23 100644 --- a/test/urlMatcherFactorySpec.js +++ b/test/urlMatcherFactorySpec.js @@ -310,6 +310,16 @@ describe("urlMatcherFactory", function () { expect(m.format({ id: 1138, state: "NY" })).toBe("/users/1138/NY"); }); + it("should match in between static segments", function() { + var m = new UrlMatcher('/users/{user:int}/photos', { + params: { user: 5 } + }); + expect(m.exec('/users/photos').user).toBe(5); + expect(m.exec('/users/6/photos').user).toBe(6); + expect(m.format()).toBe("/users/photos"); + expect(m.format({ user: 1138 })).toBe("/users/1138/photos"); + }); + describe("default values", function() { it("should populate if not supplied in URL", function() { var m = new UrlMatcher('/users/{id:int}/{test}', {