Skip to content

Commit

Permalink
fix($urlMatcherFactory): typed params in search
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
christopherthielen committed Nov 8, 2014
1 parent cb9fd9d commit 8d4cab6
Show file tree
Hide file tree
Showing 2 changed files with 136 additions and 91 deletions.
193 changes: 102 additions & 91 deletions src/urlMatcherFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ function UrlMatcher(pattern, config) {
switch(squashPolicy) {
case "nosquash": flags = ['', '']; break;
case "value": flags = ['', '?']; break;
case "slash": flags = ['?', '?']; break;
case "slash": flags = ['?', '?']; break;
}
return result + flags[0] + '(' + pattern + ')' + flags[1];
}
Expand All @@ -107,13 +107,12 @@ function UrlMatcher(pattern, config) {
// Split into static segments separated by path parameter placeholders.
// The number of segments is always 1 more than the number of parameters.
function matchDetails(m, isSearch) {
var id, regexp, segment, type, typeId, cfg;
var defaultTypeId = (isSearch ? "searchParam" : "pathParam");
var id, regexp, segment, type, cfg;
id = m[2] || m[3]; // IE[78] returns '' for unmatched groups instead of null
segment = pattern.substring(last, m.index);
regexp = isSearch ? m[4] : m[4] || (m[1] == '*' ? '.*' : null);
typeId = regexp || defaultTypeId;
type = $$UMFP.type(typeId) || extend({}, $$UMFP.type(defaultTypeId), { pattern: new RegExp(regexp) });
type = $$UMFP.type(regexp || "string") || inherit($$UMFP.type("string"), { pattern: new RegExp(regexp) });
type = isSearch ? type.$asSearchType() : type;
cfg = config.params[id];
return {
id: id, regexp: regexp, segment: segment, type: type, cfg: cfg
Expand Down Expand Up @@ -293,47 +292,39 @@ UrlMatcher.prototype.validates = function (params) {
* @returns {string} the formatted URL (path and optionally search part).
*/
UrlMatcher.prototype.format = function (values) {
var segments = this.segments, params = this.parameters();
var paramset = this.params;
values = values || {};

var nPath = segments.length - 1, nTotal = params.length,
result = segments[0], i, search, value, name, param, array, isDefaultValue;

var segments = this.segments, params = this.parameters(), paramset = this.params;
if (!this.validates(values)) return null;

for (i = 0; i < nPath; i++) {
name = params[i];
param = paramset[name];
value = param.value(values[name]);
isDefaultValue = param.isOptional && param.type.equals(param.value(), value);
var i, search = false, nPath = segments.length - 1, nTotal = params.length, result = segments[0];

for (i = 0; i < nTotal; i++) {
var isPathParam = i < nPath;
var name = params[i], param = paramset[name], value = param.value(values[name]);
var isDefaultValue = param.isOptional && param.type.equals(param.value(), value);
var squash = isDefaultValue ? param.squash : "nosquash";
var encoded = param.type.encode(value);

var nextSegment = segments[i + 1];
if (squash === "nosquash") {
if (encoded != null) result += encodeURIComponent(encoded);
result += nextSegment;
} else if (squash === "value") {
result += nextSegment;
} else if (squash === "slash") {
var capture = result.match(/\/$/) ? /\/?(.*)/ : /(.*)/;
result += nextSegment.match(capture)[1];
if (isPathParam) {
var nextSegment = segments[i + 1];
if (squash === "nosquash") {
if (encoded != null) result += encodeURIComponent(encoded);
result += nextSegment;
} else if (squash === "value") {
result += nextSegment;
} else if (squash === "slash") {
var capture = result.match(/\/$/) ? /\/?(.*)/ : /(.*)/;
result += nextSegment.match(capture)[1];
}
} else {
if (encoded == null || (isDefaultValue && squash !== "nosquash")) continue;
if (!isArray(encoded)) encoded = [ encoded ];
encoded = encoded.map(encodeURIComponent).join('&' + name + '=');
result += (search ? '&' : '?') + (name + '=' + encoded);
search = true;
}
}

for (/**/; i < nTotal; i++) {
name = params[i];
value = values[name];
if (value == null) continue;
array = isArray(value);

if (array) {
value = value.map(encodeURIComponent).join('&' + name + '=');
}
result += (search ? '&' : '?') + name + '=' + (array ? value : encodeURIComponent(value));
search = true;
}
return result;
};

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

Type.prototype.pattern = /.*/;

Type.prototype.toString = function() { return "{Type:" + this.name + "}"; };

/*
* Wraps an existing custom Type as a search-query aware type which adds multi-value support.
* e.g.:
* - urlmatcher pattern "/path?{queryParam:int}"
* - url: "/path?queryParam=1&queryParam=2
* - $stateParams.queryParam will be [1, 2]
*/
Type.prototype.$asSearchType = function() {
return new SearchType(this);

function SearchType(type) {
var self = this;
if (type.$$autoSearchArray === false) return type;

function allTruthy(array) { // TODO: use reduce fn
var result = true;
forEach(array, function (val) { result = result && !!val; });
return result;
}

function map(array, callback) { // TODO: move to common.js in 1.0
var result = [];
forEach(array, function (val) { result.push(callback(val)); });
return result;
}

function autoHandleArray(callback, reducefn) {
return function (val) {
if (isArray(val)) {
var result = map(val, callback);
return reducefn ? reducefn(result) : result;
} else {
return callback(val);
}
};
}

function bindTo(thisObj, callback) { return function() { return callback.apply(thisObj, arguments); }; }

this.encode = autoHandleArray(bindTo(this, type.encode));
this.decode = autoHandleArray(bindTo(this, type.decode));
this.equals = autoHandleArray(bindTo(this, type.equals), allTruthy);
this.is = autoHandleArray(bindTo(this, type.is), allTruthy);
this.pattern = type.pattern;
}
};



/**
* @ngdoc object
* @name ui.router.util.$urlMatcherFactory
Expand All @@ -462,75 +504,41 @@ function $UrlMatcherFactory() {

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

function safeString(val) { return val != null ? val.toString() : val; }
function coerceEquals(left, right) { return left == right; }
function valToString(val) { return val != null ? val.toString() : val; }
function angularEquals(left, right) { return angular.equals(left, right); }
// TODO: function regexpMatches(val) { return isDefined(val) && this.pattern.test(val); }
// TODO: in 1.0, make string .is() return false if value is undefined by default.
// function regexpMatches(val) { /*jshint validthis:true */ return isDefined(val) && this.pattern.test(val); }
function regexpMatches(val) { /*jshint validthis:true */ return this.pattern.test(val); }
function normalizeStringOrArray(val) {
if (isArray(val)) {
var encoded = [];
forEach(val, function(item) { encoded.push(safeString(item)); });
return encoded;
} else {
return safeString(val);
}
}

var $types = {}, enqueue = true, typeQueue = [], injector, defaultTypes = {
"searchParam": {
encode: normalizeStringOrArray,
decode: normalizeStringOrArray,
equals: angularEquals,
is: regexpMatches,
pattern: /[^&?]*/
},
"pathParam": {
encode: safeString,
decode: safeString,
equals: coerceEquals,
string: {
encode: valToString,
decode: valToString,
is: regexpMatches,
pattern: /[^/]*/
},
int: {
decode: function(val) {
return parseInt(val, 10);
},
is: function(val) {
if (!isDefined(val)) return false;
return this.decode(val.toString()) === val;
},
encode: valToString,
decode: function(val) { return parseInt(val, 10); },
is: function(val) { return isDefined(val) && this.decode(val.toString()) === val; },
pattern: /\d+/
},
bool: {
encode: function(val) {
return val ? 1 : 0;
},
decode: function(val) {
return parseInt(val, 10) !== 0;
},
is: function(val) {
return val === true || val === false;
},
encode: function(val) { return val ? 1 : 0; },
decode: function(val) { return parseInt(val, 10) !== 0; },
is: function(val) { return val === true || val === false; },
pattern: /0|1/
},
string: {
pattern: /[^\/]*/
},
date: {
equals: function (a, b) {
return a.toISOString() === b.toISOString();
},
decode: function (val) {
return new Date(val);
},
encode: function (val) {
return [
encode: function (val) { return [
val.getFullYear(),
('0' + (val.getMonth() + 1)).slice(-2),
('0' + val.getDate()).slice(-2)
].join("-");
},
decode: function (val) { return new Date(val); },
is: function(val) { return val instanceof Date && !isNaN(val.valueOf()); },
equals: function (a, b) { return a.toISOString() === b.toISOString(); },
pattern: /[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[1-2][0-9]|3[0-1])/
}
};
Expand Down Expand Up @@ -756,7 +764,7 @@ function $UrlMatcherFactory() {
if (!isDefined(definition)) return $types[name];
if ($types.hasOwnProperty(name)) throw new Error("A type named '" + name + "' has already been defined.");

$types[name] = new Type(definition);
$types[name] = new Type(extend({}, { name: name }, definition));
if (definitionFn) {
typeQueue.push({ name: name, def: definitionFn });
if (!enqueue) flushTypeQueue();
Expand Down Expand Up @@ -810,7 +818,7 @@ function $UrlMatcherFactory() {
function getType(config, urlType) {
if (config.type && urlType) throw new Error("Param '"+id+"' has two type configurations.");
if (urlType) return urlType;
if (!config.type) return $types.pathParam;
if (!config.type) return $types.string;
return config.type instanceof Type ? config.type : new Type(config.type);
}

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

function toString() { return "{Param:" + id + " " + type + " squash: " + squash + " optional: " + isOptional + "}"; }

extend(this, {
id: id,
type: type,
config: config,
squash: squash,
dynamic: undefined,
isOptional: isOptional,
value: $value
value: $value,
toString: toString
});
};

Expand All @@ -870,7 +881,7 @@ function $UrlMatcherFactory() {
return values;
},
$$equals: function(paramValues1, paramValues2) {
var equal = true; self = this;
var equal = true, self = this;
forEach(self.$$keys(), function(key) {
var left = paramValues1 && paramValues1[key], right = paramValues2 && paramValues2[key];
if (!self[key].type.equals(left, right)) equal = false;
Expand Down
34 changes: 34 additions & 0 deletions test/urlMatcherFactorySpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,40 @@ describe("urlMatcherFactory", function () {
expect(m.format({ id: 1138 })).toBe("/users/1138");
expect(m.format({ id: "alpha" })).toBeNull();
});

it("should automatically handle multiple search param values", inject(function($location) {
var m = new UrlMatcher("/foo/{fooid:int}?{bar:int}");

$location.url("/foo/5?bar=1");
expect(m.exec($location.path(), $location.search())).toEqual( { fooid: 5, bar: 1 } );
expect(m.format({ fooid: 5, bar: 1 })).toEqual("/foo/5?bar=1");

$location.url("/foo/5?bar=1&bar=2&bar=3");
expect(m.exec($location.path(), $location.search())).toEqual( { fooid: 5, bar: [ 1, 2, 3 ] } );
expect(m.format({ fooid: 5, bar: [ 1, 2, 3 ] })).toEqual("/foo/5?bar=1&bar=2&bar=3");

m.format()
}));

it("should allow custom types to handle multiple search param values manually", inject(function($location) {
$umf.type("array", {
encode: function(array) { return array.join("-"); },
decode: function(val) { return angular.isArray(val) ? val : val.split(/-/); },
equals: angular.equals,
is: angular.isArray,
$$autoSearchArray: false
});

var m = new UrlMatcher("/foo?{bar:array}");

$location.url("/foo?bar=fox");
expect(m.exec($location.path(), $location.search())).toEqual( { bar: [ 'fox' ] } );
expect(m.format({ bar: [ 'fox' ] })).toEqual("/foo?bar=fox");

$location.url("/foo?bar=quick-brown-fox");
expect(m.exec($location.path(), $location.search())).toEqual( { bar: [ 'quick', 'brown', 'fox' ] } );
expect(m.format({ bar: [ 'quick', 'brown', 'fox' ] })).toEqual("/foo?bar=quick-brown-fox");
}));
});

describe("optional parameters", function() {
Expand Down

0 comments on commit 8d4cab6

Please sign in to comment.