Skip to content

Commit

Permalink
fix($urlMatcherFactory): Pre-replace certain param values for better …
Browse files Browse the repository at this point in the history
…mapping

- Some parameter values can generate a non-bidirectional URL.  For example,
  $state.go('foo', { param: null }) can map to the url "/foo/", but that
  url would match as ('foo', {param: ""}).  Allow certain special values to
  be pre-replaced in Param.value() to support better support bi-directional
  URL mapping.
- Switch squash policy to true/false instead of "squash"/"nosquash"
- Allow squash policy to be an arbitrary string, used as a placeholder
  in the url (this uses the pre-replace feature to map that string to undefined)
  • Loading branch information
christopherthielen committed Nov 12, 2014
1 parent 9136fec commit 6374a3e
Show file tree
Hide file tree
Showing 2 changed files with 111 additions and 39 deletions.
88 changes: 49 additions & 39 deletions src/urlMatcherFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,15 +94,15 @@ function UrlMatcher(pattern, config, parentMatcher) {
return params[id];
}

function quoteRegExp(string, pattern, squashPolicy) {
var flags = ['',''], result = string.replace(/[\\\[\]\^$*+?.()|{}]/g, "\\$&");
function quoteRegExp(string, pattern, squash) {
var surroundPattern = ['',''], result = string.replace(/[\\\[\]\^$*+?.()|{}]/g, "\\$&");
if (!pattern) return result;
switch(squashPolicy) {
case "nosquash": flags = ['', '']; break;
case "value": flags = ['', '?']; break;
case "slash": flags = ['?', '?']; break;
switch(squash) {
case false: surroundPattern = ['(', ')']; break;
case true: surroundPattern = ['?(', ')?']; break;
default: surroundPattern = ['(' + squash + "|", ')?']; break;
}
return result + flags[0] + '(' + pattern + ')' + flags[1];
return result + surroundPattern[0] + pattern + surroundPattern[1];
}

this.source = pattern;
Expand Down Expand Up @@ -231,7 +231,7 @@ UrlMatcher.prototype.exec = function (path, searchParams) {

var paramNames = this.parameters(), nTotal = paramNames.length,
nPath = this.segments.length - 1,
values = {}, i, cfg, paramName;
values = {}, i, j, cfg, paramName;

if (nPath !== m.length - 1) throw new Error("Unbalanced capture group in route '" + this.source + "'");

Expand All @@ -244,8 +244,11 @@ UrlMatcher.prototype.exec = function (path, searchParams) {
for (i = 0; i < nPath; i++) {
paramName = paramNames[i];
var param = this.params[paramName];
// if the param is optional, convert an empty string to `undefined`
var paramVal = m[i+1] === "" ? param.emptyString : m[i+1];
var paramVal = m[i+1];
// if the param value matches a pre-replace pair, replace the value before decoding.
for (j = 0; j < param.replace; j++) {
if (param.replace[j].from === paramVal) paramVal = param.replace[j].to;
}
if (paramVal && param.array === true) paramVal = decodePathArray(paramVal);
values[paramName] = param.value(paramVal);
}
Expand Down Expand Up @@ -323,12 +326,12 @@ UrlMatcher.prototype.format = function (values) {
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 squash = isDefaultValue ? param.squash : false;
var encoded = param.type.encode(value);

if (isPathParam) {
var nextSegment = segments[i + 1];
if (squash === "nosquash") {
if (squash === false) {
if (encoded != null) {
if (isArray(encoded)) {
result += encoded.map(encodeDashes).join("-");
Expand All @@ -337,14 +340,14 @@ UrlMatcher.prototype.format = function (values) {
}
}
result += nextSegment;
} else if (squash === "value") {
result += nextSegment;
} else if (squash === "slash") {
} else if (squash === true) {
var capture = result.match(/\/$/) ? /\/?(.*)/ : /(.*)/;
result += nextSegment.match(capture)[1];
} else if (isString(squash)) {
result += squash + nextSegment;
}
} else {
if (encoded == null || (isDefaultValue && squash !== "nosquash")) continue;
if (encoded == null || (isDefaultValue && squash !== false)) continue;
if (!isArray(encoded)) encoded = [ encoded ];
encoded = encoded.map(encodeURIComponent).join('&' + name + '=');
result += (search ? '&' : '?') + (name + '=' + encoded);
Expand Down Expand Up @@ -525,7 +528,7 @@ Type.prototype.$asArray = function(mode, isSearch) {
function $UrlMatcherFactory() {
$$UMFP = this;

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

function valToString(val) { return val != null ? val.toString().replace("/", "%2F") : val; }
function valFromString(val) { return val != null ? val.toString().replace("%2F", "/") : val; }
Expand Down Expand Up @@ -631,14 +634,15 @@ function $UrlMatcherFactory() {
*
* @param {string} value A string that defines the default parameter URL squashing behavior.
* `nosquash`: When generating an href with a default parameter value, do not squash the parameter value from the URL
* `value`: When generating an href with a default parameter value, squash (remove) the parameter value from the URL
* `slash`: When generating an href with a default parameter value, squash (remove) the parameter value, and, if the
* parameter is surrounded by slashes, squash (remove) one slash from the URL
* any other string, e.g. "~": When generating an href with a default parameter value, squash (remove)
* the parameter value from the URL and replace it with this string.
*/
this.defaultSquashPolicy = function(value) {
if (!value) return defaultSquashPolicy;
if (value !== "nosquash" && value !== "value" && value !== "slash")
throw new Error("Invalid squash policy: " + value + ". Valid policies: 'nosquash', 'value', 'slash'");
if (!isDefined(value)) return defaultSquashPolicy;
if (value !== true && value !== false && !isString(value))
throw new Error("Invalid squash policy: " + value + ". Valid policies: false, true, arbitrary-string");
defaultSquashPolicy = value;
return value;
};
Expand Down Expand Up @@ -836,7 +840,7 @@ function $UrlMatcherFactory() {
type = arrayMode ? type.$asArray(arrayMode, isSearch) : type;
var isOptional = defaultValueConfig.value !== undefined;
var squash = getSquashPolicy(config, isOptional);
var emptyString = getEmptyStringValue(config, arrayMode, isOptional);
var replace = getReplace(config, arrayMode, isOptional, squash);

function getDefaultValueConfig(config) {
var keys = isObject(config) ? objectKeys(config) : [];
Expand Down Expand Up @@ -864,25 +868,26 @@ function $UrlMatcherFactory() {
}

/**
* returns "nosquash", "value", "slash" to indicate the "default parameter url squash policy".
* undefined aliases to urlMatcherFactory default. `false` aliases to "nosquash". `true` aliases to "slash".
* returns false, true, or the squash value to indicate the "default parameter url squash policy".
*/
function getSquashPolicy(config, isOptional) {
var squash = config.squash;
if (!isOptional || squash === false) return "nosquash";
if (!isDefined(squash)) return defaultSquashPolicy;
if (squash === true) return "slash";
if (squash === "nosquash" || squash === "value" || squash === "slash") return squash;
throw new Error("Invalid squash policy: '" + squash + "'. Valid policies: 'nosquash' (false), 'value', 'slash' (true)");
if (!isOptional || squash === false) return false;
if (!isDefined(squash) || squash == null) return defaultSquashPolicy;
if (squash === true || isString(squash)) return squash;
throw new Error("Invalid squash policy: '" + squash + "'. Valid policies: false, true, or arbitrary string");
}

/**
* Returns "" or undefined, or whatever is defined in the param's config.emptyString.
* If the parameter was matched in a URL, but was matched as an empty string, this value will be used instead.
*/
function getEmptyStringValue(config, arrayMode, isOptional) {
var defaultPolicy = { emptyString: (isOptional || arrayMode ? undefined : "") };
return extend(defaultPolicy, config).emptyString;
function getReplace(config, arrayMode, isOptional, squash) {
var replace, configuredKeys, defaultPolicy = [
{ from: "", to: (isOptional || arrayMode ? undefined : "") },
{ from: null, to: (isOptional || arrayMode ? undefined : "") }
];
replace = isArray(config.replace) ? config.replace : [];
if (isString(squash))
replace.push({ from: squash, to: undefined });
configuredKeys = replace.map(function(item) { return item.from; } );
return defaultPolicy.filter(function(item) { return configuredKeys.indexOf(item.from) === -1; }).concat(replace);
}

/**
Expand All @@ -898,19 +903,24 @@ function $UrlMatcherFactory() {
* default value, which may be the result of an injectable function.
*/
function $value(value) {
if (value === "") value = self.emptyString;
function hasReplaceVal(val) { return function(obj) { return obj.from === val; }; }
function $replace(value) {
var replacement = self.replace.filter(hasReplaceVal(value)).map(function(obj) { return obj.to; });
return replacement.length ? replacement[0] : value;
}
value = $replace(value);
return isDefined(value) ? self.type.decode(value) : $$getDefaultValue();
}

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

extend(this, {
id: id,
type: type,
array: arrayMode,
config: config,
squash: squash,
emptyString: emptyString,
replace: replace,
isOptional: isOptional,
dynamic: undefined,
value: $value,
Expand Down
62 changes: 62 additions & 0 deletions test/urlMatcherFactorySpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,68 @@ describe("urlMatcherFactory", function () {
$stateParams.user = user;
expect(m.exec('/users/').user).toBe(user);
}));

describe("squash policy", function() {
var Session = { username: "loggedinuser" };
function getMatcher(squash) {
return new UrlMatcher('/user/:userid/gallery/:galleryid/photo/:photoid', {
params: {
userid: { squash: squash, value: function () { return Session.username; } },
galleryid: { squash: squash, value: "favorites" }
}
});
}

it(": true should squash the default value and one slash", inject(function($stateParams) {
var m = getMatcher(true);

var defaultParams = { userid: 'loggedinuser', galleryid: 'favorites', photoid: '123'};
expect(m.exec('/user/gallery/photo/123')).toEqual(defaultParams);
expect(m.exec('/user//gallery//photo/123')).toEqual(defaultParams);
expect(m.format(defaultParams)).toBe('/user/gallery/photo/123');

var nonDefaultParams = { userid: 'otheruser', galleryid: 'travel', photoid: '987'};
expect(m.exec('/user/otheruser/gallery/travel/photo/987')).toEqual(nonDefaultParams);
expect(m.format(nonDefaultParams)).toBe('/user/otheruser/gallery/travel/photo/987');
}));

it(": false should not squash default values", inject(function($stateParams) {
var m = getMatcher(false);

var defaultParams = { userid: 'loggedinuser', galleryid: 'favorites', photoid: '123'};
expect(m.exec('/user/loggedinuser/gallery/favorites/photo/123')).toEqual(defaultParams);
expect(m.format(defaultParams)).toBe('/user/loggedinuser/gallery/favorites/photo/123');

var nonDefaultParams = { userid: 'otheruser', galleryid: 'travel', photoid: '987'};
expect(m.exec('/user/otheruser/gallery/travel/photo/987')).toEqual(nonDefaultParams);
expect(m.format(nonDefaultParams)).toBe('/user/otheruser/gallery/travel/photo/987');
}));

it(": '' should squash the default value to an empty string", inject(function($stateParams) {
var m = getMatcher("");

var defaultParams = { userid: 'loggedinuser', galleryid: 'favorites', photoid: '123'};
expect(m.exec('/user//gallery//photo/123')).toEqual(defaultParams);
expect(m.format(defaultParams)).toBe('/user//gallery//photo/123');

var nonDefaultParams = { userid: 'otheruser', galleryid: 'travel', photoid: '987'};
expect(m.exec('/user/otheruser/gallery/travel/photo/987')).toEqual(nonDefaultParams);
expect(m.format(nonDefaultParams)).toBe('/user/otheruser/gallery/travel/photo/987');
}));

it(": '~' should squash the default value and replace it with '~'", inject(function($stateParams) {
var m = getMatcher("~");

var defaultParams = { userid: 'loggedinuser', galleryid: 'favorites', photoid: '123'};
expect(m.exec('/user//gallery//photo/123')).toEqual(defaultParams);
expect(m.exec('/user/~/gallery/~/photo/123')).toEqual(defaultParams);
expect(m.format(defaultParams)).toBe('/user/~/gallery/~/photo/123');

var nonDefaultParams = { userid: 'otheruser', galleryid: 'travel', photoid: '987'};
expect(m.exec('/user/otheruser/gallery/travel/photo/987')).toEqual(nonDefaultParams);
expect(m.format(nonDefaultParams)).toBe('/user/otheruser/gallery/travel/photo/987');
}));
});
});
});

Expand Down

0 comments on commit 6374a3e

Please sign in to comment.