Skip to content

Commit 13a468a

Browse files
fix($urlMatcherFactory): default to parameter string coersion.
feat($urlMatcherFactory): unify params handling code for path/search feat($urlMatcherFactory): add a defaultType that does string coersion and supports arrays (for params) feat($urlMatcherFactory): separate default Type(s) for path/query params Closes #1414
1 parent 838b747 commit 13a468a

File tree

4 files changed

+118
-42
lines changed

4 files changed

+118
-42
lines changed

src/state.js

+2-3
Original file line numberDiff line numberDiff line change
@@ -784,8 +784,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) {
784784
if (options.inherit) toParams = inheritParams($stateParams, toParams || {}, $state.$current, toState);
785785
if (!toState.params.$$validates(toParams)) return TransitionFailed;
786786

787-
var defaultParams = toState.params.$$values();
788-
toParams = extend(defaultParams, toParams);
787+
toParams = toState.params.$$values(toParams);
789788
to = toState;
790789

791790
var toPath = to.path;
@@ -794,7 +793,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) {
794793
var keep = 0, state = toPath[keep], locals = root.locals, toLocals = [];
795794

796795
if (!options.reload) {
797-
while (state && state === fromPath[keep] && equalForKeys(toParams, fromParams, state.ownParams.$$keys())) {
796+
while (state && state === fromPath[keep] && state.ownParams.$$equals(toParams, fromParams)) {
798797
locals = toLocals[keep] = state.locals;
799798
keep++;
800799
state = toPath[keep];

src/urlMatcherFactory.js

+64-26
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,14 @@ function UrlMatcher(pattern, config) {
7070
// The regular expression is somewhat complicated due to the need to allow curly braces
7171
// inside the regular expression. The placeholder regexp breaks down as follows:
7272
// ([:*])(\w+) classic placeholder ($1 / $2)
73-
// \{(\w+)(?:\:( ... ))?\} curly brace placeholder ($3) with optional regexp ... ($4)
73+
// ([:]?)([\w-]+) classic search placeholder (supports snake-case-params) ($1 / $2)
74+
// \{(\w+)(?:\:( ... ))?\} curly brace placeholder ($3) with optional regexp/type ... ($4)
7475
// (?: ... | ... | ... )+ the regexp consists of any number of atoms, an atom being either
7576
// [^{}\\]+ - anything other than curly braces or backslash
7677
// \\. - a backslash escape
7778
// \{(?:[^{}\\]+|\\.)*\} - a matched set of curly braces containing other atoms
7879
var placeholder = /([:*])(\w+)|\{(\w+)(?:\:((?:[^{}\\]+|\\.|\{(?:[^{}\\]+|\\.)*\})+))?\}/g,
80+
searchPlaceholder = /([:]?)([\w-]+)|\{(\w+)(?:\:((?:[^{}\\]+|\\.|\{(?:[^{}\\]+|\\.)*\})+))?\}/g,
7981
compiled = '^', last = 0, m,
8082
segments = this.segments = [],
8183
params = this.params = new $$UrlMatcherFactoryProvider.ParamSet();
@@ -94,32 +96,33 @@ function UrlMatcher(pattern, config) {
9496
return result + flag + '(' + pattern + ')' + flag;
9597
}
9698

97-
function regexpType(regexp) {
98-
var type = new Type({
99-
pattern: new RegExp(regexp),
100-
is: function(value) { return type.pattern.exec(type.encode(value)); }
101-
});
102-
return type;
103-
}
104-
10599
this.source = pattern;
106100

107101
// Split into static segments separated by path parameter placeholders.
108102
// The number of segments is always 1 more than the number of parameters.
109-
var id, regexp, segment, type, cfg;
110-
111-
while ((m = placeholder.exec(pattern))) {
103+
function matchDetails(m, isSearch) {
104+
var id, regexp, segment, type, typeId, cfg;
105+
var $types = UrlMatcher.prototype.$types;
106+
var defaultTypeId = (isSearch ? "searchParam" : "pathParam");
112107
id = m[2] || m[3]; // IE[78] returns '' for unmatched groups instead of null
113-
regexp = m[4] || (m[1] == '*' ? '.*' : '[^/]*');
114108
segment = pattern.substring(last, m.index);
115-
type = this.$types[regexp] || regexpType(regexp);
109+
regexp = isSearch ? m[4] : m[4] || (m[1] == '*' ? '.*' : null);
110+
typeId = regexp || defaultTypeId;
111+
type = $types[typeId] || extend({}, $types[defaultTypeId], { pattern: new RegExp(regexp) });
116112
cfg = config.params[id];
113+
return {
114+
id: id, regexp: regexp, segment: segment, type: type, cfg: cfg
115+
};
116+
}
117117

118-
if (segment.indexOf('?') >= 0) break; // we're into the search part
118+
var p, param, segment;
119+
while ((m = placeholder.exec(pattern))) {
120+
p = matchDetails(m, false);
121+
if (p.segment.indexOf('?') >= 0) break; // we're into the search part
119122

120-
var param = addParameter(id, type, cfg);
121-
compiled += quoteRegExp(segment, type.$subPattern(), param.isOptional);
122-
segments.push(segment);
123+
param = addParameter(p.id, p.type, p.cfg);
124+
compiled += quoteRegExp(p.segment, param.type.pattern.source, param.isOptional);
125+
segments.push(p.segment);
123126
last = placeholder.lastIndex;
124127
}
125128
segment = pattern.substring(last);
@@ -132,10 +135,15 @@ function UrlMatcher(pattern, config) {
132135
segment = segment.substring(0, i);
133136
this.sourcePath = pattern.substring(0, last + i);
134137

135-
// Allow parameters to be separated by '?' as well as '&' to make concat() easier
136-
forEach(search.substring(1).split(/[&?]/), function(key) {
137-
addParameter(key, null, config.params[key]);
138-
});
138+
if (search.length > 0) {
139+
last = 0;
140+
while ((m = searchPlaceholder.exec(search))) {
141+
p = matchDetails(m, true);
142+
param = addParameter(p.id, p.type, p.cfg);
143+
last = placeholder.lastIndex;
144+
// check if ?&
145+
}
146+
}
139147
} else {
140148
this.sourcePath = pattern;
141149
this.sourceSearch = '';
@@ -385,7 +393,7 @@ Type.prototype.encode = function(val, key) {
385393
* @methodOf ui.router.util.type:Type
386394
*
387395
* @description
388-
* Converts a string URL parameter value to a custom/native value.
396+
* Converts a parameter value (from URL string or transition param) to a custom/native value.
389397
*
390398
* @param {string} val The URL parameter value to decode.
391399
* @param {string} key The name of the parameter in which `val` is stored. Can be used for
@@ -433,7 +441,36 @@ function $UrlMatcherFactory() {
433441

434442
var isCaseInsensitive = false, isStrictMode = true;
435443

444+
function safeString(val) { return isDefined(val) ? val.toString() : val; }
445+
function coerceEquals(left, right) { return left == right; }
446+
function angularEquals(left, right) { return angular.equals(left, right); }
447+
// TODO: function regexpMatches(val) { return isDefined(val) && this.pattern.test(val); }
448+
function regexpMatches(val) { /*jshint validthis:true */ return this.pattern.test(val); }
449+
function normalizeStringOrArray(val) {
450+
if (isArray(val)) {
451+
var encoded = [];
452+
forEach(val, function(item) { encoded.push(safeString(item)); });
453+
return encoded;
454+
} else {
455+
return safeString(val);
456+
}
457+
}
458+
436459
var enqueue = true, typeQueue = [], injector, defaultTypes = {
460+
"searchParam": {
461+
encode: normalizeStringOrArray,
462+
decode: normalizeStringOrArray,
463+
equals: angularEquals,
464+
is: regexpMatches,
465+
pattern: /[^&?]*/
466+
},
467+
"pathParam": {
468+
encode: safeString,
469+
decode: safeString,
470+
equals: coerceEquals,
471+
is: regexpMatches,
472+
pattern: /[^/]*/
473+
},
437474
int: {
438475
decode: function(val) {
439476
return parseInt(val, 10);
@@ -449,7 +486,7 @@ function $UrlMatcherFactory() {
449486
return val ? 1 : 0;
450487
},
451488
decode: function(val) {
452-
return parseInt(val, 10) === 0 ? false : true;
489+
return parseInt(val, 10) !== 0;
453490
},
454491
is: function(val) {
455492
return val === true || val === false;
@@ -720,8 +757,9 @@ function $UrlMatcherFactory() {
720757

721758
function getType(config, urlType) {
722759
if (config.type && urlType) throw new Error("Param '"+id+"' has two type configurations.");
723-
if (urlType && !config.type) return urlType;
724-
return config.type instanceof Type ? config.type : new Type(config.type || {});
760+
if (urlType) return urlType;
761+
if (!config.type) return UrlMatcher.prototype.$types.pathParam;
762+
return config.type instanceof Type ? config.type : new Type(config.type);
725763
}
726764

727765
/**

test/stateDirectivesSpec.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ describe('uiStateRef', function() {
130130
$q.flush();
131131

132132
expect($state.current.name).toEqual('contacts.item.detail');
133-
expect($stateParams).toEqual({ id: 5 });
133+
expect($stateParams).toEqual({ id: "5" });
134134
}));
135135

136136
it('should transition when given a click that contains no data (fake-click)', inject(function($state, $stateParams, $q) {
@@ -147,7 +147,7 @@ describe('uiStateRef', function() {
147147
$q.flush();
148148

149149
expect($state.current.name).toEqual('contacts.item.detail');
150-
expect($stateParams).toEqual({ id: 5 });
150+
expect($stateParams).toEqual({ id: "5" });
151151
}));
152152

153153
it('should not transition states when ctrl-clicked', inject(function($state, $stateParams, $q) {
@@ -328,7 +328,7 @@ describe('uiStateRef', function() {
328328
$q.flush();
329329

330330
expect($state.$current.name).toBe("contacts.item.detail");
331-
expect($state.params).toEqual({ id: 5 });
331+
expect($state.params).toEqual({ id: "5" });
332332
}));
333333

334334
it('should resolve states from parent uiView', inject(function ($state, $stateParams, $q, $timeout) {

test/stateSpec.js

+49-10
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ describe('state', function () {
2727
HH = { parent: H },
2828
HHH = {parent: HH, data: {propA: 'overriddenA', propC: 'propC'} },
2929
RS = { url: '^/search?term', reloadOnSearch: false },
30-
OPT = { url: '/opt/:param', params: { param: 100 } },
30+
OPT = { url: '/opt/:param', params: { param: "100" } },
31+
OPT2 = { url: '/opt2/:param2/:param3', params: { param3: "300", param4: "400" } },
3132
AppInjectable = {};
3233

3334
beforeEach(module(function ($stateProvider, $provide) {
@@ -48,6 +49,7 @@ describe('state', function () {
4849
.state('HH', HH)
4950
.state('HHH', HHH)
5051
.state('OPT', OPT)
52+
.state('OPT.OPT2', OPT2)
5153
.state('RS', RS)
5254

5355
.state('home', { url: "/" })
@@ -273,7 +275,7 @@ describe('state', function () {
273275
$q.flush();
274276
expect(called).toBeTruthy();
275277
expect($state.current.name).toEqual('DDD');
276-
expect($state.params).toEqual({ x: 1, y: 2, z: 3, w: 4 });
278+
expect($state.params).toEqual({ x: "1", y: "2", z: "3", w: "4" });
277279
}));
278280

279281
it('can defer a state transition in $stateNotFound', inject(function ($state, $q, $rootScope) {
@@ -290,7 +292,7 @@ describe('state', function () {
290292
$q.flush();
291293
expect(called).toBeTruthy();
292294
expect($state.current.name).toEqual('AA');
293-
expect($state.params).toEqual({ a: 1 });
295+
expect($state.params).toEqual({ a: "1" });
294296
}));
295297

296298
it('can defer and supersede a state transition in $stateNotFound', inject(function ($state, $q, $rootScope) {
@@ -479,11 +481,11 @@ describe('state', function () {
479481
$state.transitionTo('about.person', { person: 'bob' });
480482
$q.flush();
481483

482-
$state.go('.item', { id: 5 });
484+
$state.go('.item', { id: "5" });
483485
$q.flush();
484486

485487
expect($state.$current.name).toBe('about.person.item');
486-
expect($stateParams).toEqual({ person: 'bob', id: 5 });
488+
expect($stateParams).toEqual({ person: 'bob', id: "5" });
487489

488490
$state.go('^.^.sidebar');
489491
$q.flush();
@@ -751,6 +753,7 @@ describe('state', function () {
751753
'HH',
752754
'HHH',
753755
'OPT',
756+
'OPT.OPT2',
754757
'RS',
755758
'about',
756759
'about.person',
@@ -799,16 +802,52 @@ describe('state', function () {
799802
$state.get("OPT").onEnter = function($stateParams) { stateParams = $stateParams; };
800803
$state.go("OPT"); $q.flush();
801804
expect($state.current.name).toBe("OPT");
802-
expect($state.params).toEqual({ param: 100 });
803-
expect(stateParams).toEqual({ param: 100 });
805+
expect($state.params).toEqual({ param: "100" });
806+
expect(stateParams).toEqual({ param: "100" });
804807
}));
805808

806809
it("should be populated during primary transition, if unspecified", inject(function($state, $q) {
807810
var count = 0;
808811
$state.get("OPT").onEnter = function($stateParams) { count++; };
809812
$state.go("OPT"); $q.flush();
810813
expect($state.current.name).toBe("OPT");
811-
expect($state.params).toEqual({ param: 100 });
814+
expect($state.params).toEqual({ param: "100" });
815+
expect(count).toEqual(1);
816+
}));
817+
818+
it("should allow mixed URL and config params", inject(function($state, $q) {
819+
var count = 0;
820+
$state.get("OPT").onEnter = function($stateParams) { count++; };
821+
$state.get("OPT.OPT2").onEnter = function($stateParams) { count++; };
822+
$state.go("OPT"); $q.flush();
823+
expect($state.current.name).toBe("OPT");
824+
expect($state.params).toEqual({ param: "100" });
825+
expect(count).toEqual(1);
826+
827+
$state.go("OPT.OPT2", { param2: 200 }); $q.flush();
828+
expect($state.current.name).toBe("OPT.OPT2");
829+
expect($state.params).toEqual({ param: "100", param2: "200", param3: "300", param4: "400" });
830+
expect(count).toEqual(2);
831+
}));
832+
});
833+
834+
// TODO: Enforce by default in next major release (1.0.0)
835+
xdescribe('non-optional parameters', function() {
836+
it("should cause transition failure, when unspecified.", inject(function($state, $q) {
837+
var count = 0;
838+
$state.get("OPT").onEnter = function() { count++; };
839+
$state.get("OPT.OPT2").onEnter = function() { count++; };
840+
$state.go("OPT"); $q.flush();
841+
expect($state.current.name).toBe("OPT");
842+
expect($state.params).toEqual({ param: "100" });
843+
expect(count).toEqual(1);
844+
845+
var result;
846+
$state.go("OPT.OPT2").then(function(data) { result = data; });
847+
$q.flush();
848+
expect($state.current.name).toBe("OPT");
849+
expect($state.params).toEqual({ param: "100" });
850+
expect(result).toEqual("asdfasdf");
812851
expect(count).toEqual(1);
813852
}));
814853
});
@@ -996,7 +1035,7 @@ describe('state', function () {
9961035
$state.go('root.sub1', { param2: 2 });
9971036
$q.flush();
9981037
expect($state.current.name).toEqual('root.sub1');
999-
expect($stateParams).toEqual({ param1: 1, param2: 2 });
1038+
expect($stateParams).toEqual({ param1: "1", param2: "2" });
10001039
}));
10011040

10021041
it('should not inherit siblings\' states', inject(function ($state, $stateParams, $q) {
@@ -1009,7 +1048,7 @@ describe('state', function () {
10091048
$q.flush();
10101049
expect($state.current.name).toEqual('root.sub2');
10111050

1012-
expect($stateParams).toEqual({ param1: 1, param2: undefined });
1051+
expect($stateParams).toEqual({ param1: "1", param2: undefined });
10131052
}));
10141053
});
10151054

0 commit comments

Comments
 (0)