Skip to content

Commit 8d4cab6

Browse files
fix($urlMatcherFactory): typed params in search
- Improved code structure for default parameter Type - Added and Search-Query type supporting arrays of typed params in the query-string - Consolidated path/query param codepath in UrlMatcher.format - Allow squashing default query params. - Cleaned up built-in param Types - Add toString to Type and Param - Add `name` to Type when registered - Fix typo in ParamSet.$$equals Closes #1488 fixes #1488 chore(lint): linted
1 parent cb9fd9d commit 8d4cab6

File tree

2 files changed

+136
-91
lines changed

2 files changed

+136
-91
lines changed

src/urlMatcherFactory.js

+102-91
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ function UrlMatcher(pattern, config) {
9797
switch(squashPolicy) {
9898
case "nosquash": flags = ['', '']; break;
9999
case "value": flags = ['', '?']; break;
100-
case "slash": flags = ['?', '?']; break;
100+
case "slash": flags = ['?', '?']; break;
101101
}
102102
return result + flags[0] + '(' + pattern + ')' + flags[1];
103103
}
@@ -107,13 +107,12 @@ function UrlMatcher(pattern, config) {
107107
// Split into static segments separated by path parameter placeholders.
108108
// The number of segments is always 1 more than the number of parameters.
109109
function matchDetails(m, isSearch) {
110-
var id, regexp, segment, type, typeId, cfg;
111-
var defaultTypeId = (isSearch ? "searchParam" : "pathParam");
110+
var id, regexp, segment, type, cfg;
112111
id = m[2] || m[3]; // IE[78] returns '' for unmatched groups instead of null
113112
segment = pattern.substring(last, m.index);
114113
regexp = isSearch ? m[4] : m[4] || (m[1] == '*' ? '.*' : null);
115-
typeId = regexp || defaultTypeId;
116-
type = $$UMFP.type(typeId) || extend({}, $$UMFP.type(defaultTypeId), { pattern: new RegExp(regexp) });
114+
type = $$UMFP.type(regexp || "string") || inherit($$UMFP.type("string"), { pattern: new RegExp(regexp) });
115+
type = isSearch ? type.$asSearchType() : type;
117116
cfg = config.params[id];
118117
return {
119118
id: id, regexp: regexp, segment: segment, type: type, cfg: cfg
@@ -293,47 +292,39 @@ UrlMatcher.prototype.validates = function (params) {
293292
* @returns {string} the formatted URL (path and optionally search part).
294293
*/
295294
UrlMatcher.prototype.format = function (values) {
296-
var segments = this.segments, params = this.parameters();
297-
var paramset = this.params;
298295
values = values || {};
299-
300-
var nPath = segments.length - 1, nTotal = params.length,
301-
result = segments[0], i, search, value, name, param, array, isDefaultValue;
302-
296+
var segments = this.segments, params = this.parameters(), paramset = this.params;
303297
if (!this.validates(values)) return null;
304298

305-
for (i = 0; i < nPath; i++) {
306-
name = params[i];
307-
param = paramset[name];
308-
value = param.value(values[name]);
309-
isDefaultValue = param.isOptional && param.type.equals(param.value(), value);
299+
var i, search = false, nPath = segments.length - 1, nTotal = params.length, result = segments[0];
300+
301+
for (i = 0; i < nTotal; i++) {
302+
var isPathParam = i < nPath;
303+
var name = params[i], param = paramset[name], value = param.value(values[name]);
304+
var isDefaultValue = param.isOptional && param.type.equals(param.value(), value);
310305
var squash = isDefaultValue ? param.squash : "nosquash";
311306
var encoded = param.type.encode(value);
312307

313-
var nextSegment = segments[i + 1];
314-
if (squash === "nosquash") {
315-
if (encoded != null) result += encodeURIComponent(encoded);
316-
result += nextSegment;
317-
} else if (squash === "value") {
318-
result += nextSegment;
319-
} else if (squash === "slash") {
320-
var capture = result.match(/\/$/) ? /\/?(.*)/ : /(.*)/;
321-
result += nextSegment.match(capture)[1];
308+
if (isPathParam) {
309+
var nextSegment = segments[i + 1];
310+
if (squash === "nosquash") {
311+
if (encoded != null) result += encodeURIComponent(encoded);
312+
result += nextSegment;
313+
} else if (squash === "value") {
314+
result += nextSegment;
315+
} else if (squash === "slash") {
316+
var capture = result.match(/\/$/) ? /\/?(.*)/ : /(.*)/;
317+
result += nextSegment.match(capture)[1];
318+
}
319+
} else {
320+
if (encoded == null || (isDefaultValue && squash !== "nosquash")) continue;
321+
if (!isArray(encoded)) encoded = [ encoded ];
322+
encoded = encoded.map(encodeURIComponent).join('&' + name + '=');
323+
result += (search ? '&' : '?') + (name + '=' + encoded);
324+
search = true;
322325
}
323326
}
324327

325-
for (/**/; i < nTotal; i++) {
326-
name = params[i];
327-
value = values[name];
328-
if (value == null) continue;
329-
array = isArray(value);
330-
331-
if (array) {
332-
value = value.map(encodeURIComponent).join('&' + name + '=');
333-
}
334-
result += (search ? '&' : '?') + name + '=' + (array ? value : encodeURIComponent(value));
335-
search = true;
336-
}
337328
return result;
338329
};
339330

@@ -449,6 +440,57 @@ Type.prototype.$subPattern = function() {
449440

450441
Type.prototype.pattern = /.*/;
451442

443+
Type.prototype.toString = function() { return "{Type:" + this.name + "}"; };
444+
445+
/*
446+
* Wraps an existing custom Type as a search-query aware type which adds multi-value support.
447+
* e.g.:
448+
* - urlmatcher pattern "/path?{queryParam:int}"
449+
* - url: "/path?queryParam=1&queryParam=2
450+
* - $stateParams.queryParam will be [1, 2]
451+
*/
452+
Type.prototype.$asSearchType = function() {
453+
return new SearchType(this);
454+
455+
function SearchType(type) {
456+
var self = this;
457+
if (type.$$autoSearchArray === false) return type;
458+
459+
function allTruthy(array) { // TODO: use reduce fn
460+
var result = true;
461+
forEach(array, function (val) { result = result && !!val; });
462+
return result;
463+
}
464+
465+
function map(array, callback) { // TODO: move to common.js in 1.0
466+
var result = [];
467+
forEach(array, function (val) { result.push(callback(val)); });
468+
return result;
469+
}
470+
471+
function autoHandleArray(callback, reducefn) {
472+
return function (val) {
473+
if (isArray(val)) {
474+
var result = map(val, callback);
475+
return reducefn ? reducefn(result) : result;
476+
} else {
477+
return callback(val);
478+
}
479+
};
480+
}
481+
482+
function bindTo(thisObj, callback) { return function() { return callback.apply(thisObj, arguments); }; }
483+
484+
this.encode = autoHandleArray(bindTo(this, type.encode));
485+
this.decode = autoHandleArray(bindTo(this, type.decode));
486+
this.equals = autoHandleArray(bindTo(this, type.equals), allTruthy);
487+
this.is = autoHandleArray(bindTo(this, type.is), allTruthy);
488+
this.pattern = type.pattern;
489+
}
490+
};
491+
492+
493+
452494
/**
453495
* @ngdoc object
454496
* @name ui.router.util.$urlMatcherFactory
@@ -462,75 +504,41 @@ function $UrlMatcherFactory() {
462504

463505
var isCaseInsensitive = false, isStrictMode = true, defaultSquashPolicy = "nosquash";
464506

465-
function safeString(val) { return val != null ? val.toString() : val; }
466-
function coerceEquals(left, right) { return left == right; }
507+
function valToString(val) { return val != null ? val.toString() : val; }
467508
function angularEquals(left, right) { return angular.equals(left, right); }
468-
// TODO: function regexpMatches(val) { return isDefined(val) && this.pattern.test(val); }
509+
// TODO: in 1.0, make string .is() return false if value is undefined by default.
510+
// function regexpMatches(val) { /*jshint validthis:true */ return isDefined(val) && this.pattern.test(val); }
469511
function regexpMatches(val) { /*jshint validthis:true */ return this.pattern.test(val); }
470-
function normalizeStringOrArray(val) {
471-
if (isArray(val)) {
472-
var encoded = [];
473-
forEach(val, function(item) { encoded.push(safeString(item)); });
474-
return encoded;
475-
} else {
476-
return safeString(val);
477-
}
478-
}
479512

480513
var $types = {}, enqueue = true, typeQueue = [], injector, defaultTypes = {
481-
"searchParam": {
482-
encode: normalizeStringOrArray,
483-
decode: normalizeStringOrArray,
484-
equals: angularEquals,
485-
is: regexpMatches,
486-
pattern: /[^&?]*/
487-
},
488-
"pathParam": {
489-
encode: safeString,
490-
decode: safeString,
491-
equals: coerceEquals,
514+
string: {
515+
encode: valToString,
516+
decode: valToString,
492517
is: regexpMatches,
493518
pattern: /[^/]*/
494519
},
495520
int: {
496-
decode: function(val) {
497-
return parseInt(val, 10);
498-
},
499-
is: function(val) {
500-
if (!isDefined(val)) return false;
501-
return this.decode(val.toString()) === val;
502-
},
521+
encode: valToString,
522+
decode: function(val) { return parseInt(val, 10); },
523+
is: function(val) { return isDefined(val) && this.decode(val.toString()) === val; },
503524
pattern: /\d+/
504525
},
505526
bool: {
506-
encode: function(val) {
507-
return val ? 1 : 0;
508-
},
509-
decode: function(val) {
510-
return parseInt(val, 10) !== 0;
511-
},
512-
is: function(val) {
513-
return val === true || val === false;
514-
},
527+
encode: function(val) { return val ? 1 : 0; },
528+
decode: function(val) { return parseInt(val, 10) !== 0; },
529+
is: function(val) { return val === true || val === false; },
515530
pattern: /0|1/
516531
},
517-
string: {
518-
pattern: /[^\/]*/
519-
},
520532
date: {
521-
equals: function (a, b) {
522-
return a.toISOString() === b.toISOString();
523-
},
524-
decode: function (val) {
525-
return new Date(val);
526-
},
527-
encode: function (val) {
528-
return [
533+
encode: function (val) { return [
529534
val.getFullYear(),
530535
('0' + (val.getMonth() + 1)).slice(-2),
531536
('0' + val.getDate()).slice(-2)
532537
].join("-");
533538
},
539+
decode: function (val) { return new Date(val); },
540+
is: function(val) { return val instanceof Date && !isNaN(val.valueOf()); },
541+
equals: function (a, b) { return a.toISOString() === b.toISOString(); },
534542
pattern: /[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[1-2][0-9]|3[0-1])/
535543
}
536544
};
@@ -756,7 +764,7 @@ function $UrlMatcherFactory() {
756764
if (!isDefined(definition)) return $types[name];
757765
if ($types.hasOwnProperty(name)) throw new Error("A type named '" + name + "' has already been defined.");
758766

759-
$types[name] = new Type(definition);
767+
$types[name] = new Type(extend({}, { name: name }, definition));
760768
if (definitionFn) {
761769
typeQueue.push({ name: name, def: definitionFn });
762770
if (!enqueue) flushTypeQueue();
@@ -810,7 +818,7 @@ function $UrlMatcherFactory() {
810818
function getType(config, urlType) {
811819
if (config.type && urlType) throw new Error("Param '"+id+"' has two type configurations.");
812820
if (urlType) return urlType;
813-
if (!config.type) return $types.pathParam;
821+
if (!config.type) return $types.string;
814822
return config.type instanceof Type ? config.type : new Type(config.type);
815823
}
816824

@@ -843,14 +851,17 @@ function $UrlMatcherFactory() {
843851
return isDefined(value) ? self.type.decode(value) : $$getDefaultValue();
844852
}
845853

854+
function toString() { return "{Param:" + id + " " + type + " squash: " + squash + " optional: " + isOptional + "}"; }
855+
846856
extend(this, {
847857
id: id,
848858
type: type,
849859
config: config,
850860
squash: squash,
851861
dynamic: undefined,
852862
isOptional: isOptional,
853-
value: $value
863+
value: $value,
864+
toString: toString
854865
});
855866
};
856867

@@ -870,7 +881,7 @@ function $UrlMatcherFactory() {
870881
return values;
871882
},
872883
$$equals: function(paramValues1, paramValues2) {
873-
var equal = true; self = this;
884+
var equal = true, self = this;
874885
forEach(self.$$keys(), function(key) {
875886
var left = paramValues1 && paramValues1[key], right = paramValues2 && paramValues2[key];
876887
if (!self[key].type.equals(left, right)) equal = false;

test/urlMatcherFactorySpec.js

+34
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,40 @@ describe("urlMatcherFactory", function () {
275275
expect(m.format({ id: 1138 })).toBe("/users/1138");
276276
expect(m.format({ id: "alpha" })).toBeNull();
277277
});
278+
279+
it("should automatically handle multiple search param values", inject(function($location) {
280+
var m = new UrlMatcher("/foo/{fooid:int}?{bar:int}");
281+
282+
$location.url("/foo/5?bar=1");
283+
expect(m.exec($location.path(), $location.search())).toEqual( { fooid: 5, bar: 1 } );
284+
expect(m.format({ fooid: 5, bar: 1 })).toEqual("/foo/5?bar=1");
285+
286+
$location.url("/foo/5?bar=1&bar=2&bar=3");
287+
expect(m.exec($location.path(), $location.search())).toEqual( { fooid: 5, bar: [ 1, 2, 3 ] } );
288+
expect(m.format({ fooid: 5, bar: [ 1, 2, 3 ] })).toEqual("/foo/5?bar=1&bar=2&bar=3");
289+
290+
m.format()
291+
}));
292+
293+
it("should allow custom types to handle multiple search param values manually", inject(function($location) {
294+
$umf.type("array", {
295+
encode: function(array) { return array.join("-"); },
296+
decode: function(val) { return angular.isArray(val) ? val : val.split(/-/); },
297+
equals: angular.equals,
298+
is: angular.isArray,
299+
$$autoSearchArray: false
300+
});
301+
302+
var m = new UrlMatcher("/foo?{bar:array}");
303+
304+
$location.url("/foo?bar=fox");
305+
expect(m.exec($location.path(), $location.search())).toEqual( { bar: [ 'fox' ] } );
306+
expect(m.format({ bar: [ 'fox' ] })).toEqual("/foo?bar=fox");
307+
308+
$location.url("/foo?bar=quick-brown-fox");
309+
expect(m.exec($location.path(), $location.search())).toEqual( { bar: [ 'quick', 'brown', 'fox' ] } );
310+
expect(m.format({ bar: [ 'quick', 'brown', 'fox' ] })).toEqual("/foo?bar=quick-brown-fox");
311+
}));
278312
});
279313

280314
describe("optional parameters", function() {

0 commit comments

Comments
 (0)