Skip to content

Commit fdd2f2c

Browse files
fix($urlMatcherFactory): allow arrays in both path and query params
- Connected Type.$$array with options.$$array. - Add $$array: true where values are always mapped to arrays (even if only one value found) - If a query param ends with [], it is considered $$array=true, else $$array='auto' - Encode array path-parameters using dashes closes #1073 closes #1045 closes #1486 closes #1394 chore(lint): linted
1 parent 8d4cab6 commit fdd2f2c

File tree

3 files changed

+218
-64
lines changed

3 files changed

+218
-64
lines changed

src/state.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) {
6868
ownParams: function(state) {
6969
var params = state.url && state.url.params || new $$UMFP.ParamSet();
7070
forEach(state.params || {}, function(config, id) {
71-
if (!params[id]) params[id] = new $$UMFP.Param(id, null, config);
71+
if (!params[id]) params[id] = new $$UMFP.Param(id, null, config, false);
7272
});
7373
return params;
7474
},

src/urlMatcherFactory.js

+95-59
Original file line numberDiff line numberDiff line change
@@ -71,23 +71,22 @@ function UrlMatcher(pattern, config) {
7171
// '{' name ':' regexp '}'
7272
// The regular expression is somewhat complicated due to the need to allow curly braces
7373
// inside the regular expression. The placeholder regexp breaks down as follows:
74-
// ([:*])(\w+) classic placeholder ($1 / $2)
75-
// ([:]?)([\w-]+) classic search placeholder (supports snake-case-params) ($1 / $2)
76-
// \{(\w+)(?:\:( ... ))?\} curly brace placeholder ($3) with optional regexp/type ... ($4)
77-
// (?: ... | ... | ... )+ the regexp consists of any number of atoms, an atom being either
78-
// [^{}\\]+ - anything other than curly braces or backslash
79-
// \\. - a backslash escape
80-
// \{(?:[^{}\\]+|\\.)*\} - a matched set of curly braces containing other atoms
81-
var placeholder = /([:*])(\w+)|\{(\w+)(?:\:((?:[^{}\\]+|\\.|\{(?:[^{}\\]+|\\.)*\})+))?\}/g,
82-
searchPlaceholder = /([:]?)([\w-]+)|\{(\w+)(?:\:((?:[^{}\\]+|\\.|\{(?:[^{}\\]+|\\.)*\})+))?\}/g,
74+
// ([:*])([\w\[\]]+) - classic placeholder ($1 / $2) (search version has - for snake-case)
75+
// \{([\w\[\]]+)(?:\:( ... ))?\} - curly brace placeholder ($3) with optional regexp/type ... ($4) (search version has - for snake-case
76+
// (?: ... | ... | ... )+ - the regexp consists of any number of atoms, an atom being either
77+
// [^{}\\]+ - anything other than curly braces or backslash
78+
// \\. - a backslash escape
79+
// \{(?:[^{}\\]+|\\.)*\} - a matched set of curly braces containing other atoms
80+
var placeholder = /([:*])([\w\[\]]+)|\{([\w\[\]]+)(?:\:((?:[^{}\\]+|\\.|\{(?:[^{}\\]+|\\.)*\})+))?\}/g,
81+
searchPlaceholder = /([:]?)([\w\[\]-]+)|\{([\w\[\]-]+)(?:\:((?:[^{}\\]+|\\.|\{(?:[^{}\\]+|\\.)*\})+))?\}/g,
8382
compiled = '^', last = 0, m,
8483
segments = this.segments = [],
8584
params = this.params = new $$UMFP.ParamSet();
8685

87-
function addParameter(id, type, config) {
88-
if (!/^\w+(-+\w+)*$/.test(id)) throw new Error("Invalid parameter name '" + id + "' in pattern '" + pattern + "'");
86+
function addParameter(id, type, config, isSearch) {
87+
if (!/^\w+(-+\w+)*(?:\[\])?$/.test(id)) throw new Error("Invalid parameter name '" + id + "' in pattern '" + pattern + "'");
8988
if (params[id]) throw new Error("Duplicate parameter name '" + id + "' in pattern '" + pattern + "'");
90-
params[id] = new $$UMFP.Param(id, type, config);
89+
params[id] = new $$UMFP.Param(id, type, config, isSearch);
9190
return params[id];
9291
}
9392

@@ -107,13 +106,12 @@ function UrlMatcher(pattern, config) {
107106
// Split into static segments separated by path parameter placeholders.
108107
// The number of segments is always 1 more than the number of parameters.
109108
function matchDetails(m, isSearch) {
110-
var id, regexp, segment, type, cfg;
111-
id = m[2] || m[3]; // IE[78] returns '' for unmatched groups instead of null
112-
segment = pattern.substring(last, m.index);
113-
regexp = isSearch ? m[4] : m[4] || (m[1] == '*' ? '.*' : null);
114-
type = $$UMFP.type(regexp || "string") || inherit($$UMFP.type("string"), { pattern: new RegExp(regexp) });
115-
type = isSearch ? type.$asSearchType() : type;
116-
cfg = config.params[id];
109+
var id, regexp, segment, type, cfg, arrayMode;
110+
id = m[2] || m[3]; // IE[78] returns '' for unmatched groups instead of null
111+
cfg = config.params[id];
112+
segment = pattern.substring(last, m.index);
113+
regexp = isSearch ? m[4] : m[4] || (m[1] == '*' ? '.*' : null);
114+
type = $$UMFP.type(regexp || "string") || inherit($$UMFP.type("string"), { pattern: new RegExp(regexp) });
117115
return {
118116
id: id, regexp: regexp, segment: segment, type: type, cfg: cfg
119117
};
@@ -124,7 +122,7 @@ function UrlMatcher(pattern, config) {
124122
p = matchDetails(m, false);
125123
if (p.segment.indexOf('?') >= 0) break; // we're into the search part
126124

127-
param = addParameter(p.id, p.type, p.cfg);
125+
param = addParameter(p.id, p.type, p.cfg, false);
128126
compiled += quoteRegExp(p.segment, param.type.pattern.source, param.squash);
129127
segments.push(p.segment);
130128
last = placeholder.lastIndex;
@@ -143,7 +141,7 @@ function UrlMatcher(pattern, config) {
143141
last = 0;
144142
while ((m = searchPlaceholder.exec(search))) {
145143
p = matchDetails(m, true);
146-
param = addParameter(p.id, p.type, p.cfg);
144+
param = addParameter(p.id, p.type, p.cfg, true);
147145
last = placeholder.lastIndex;
148146
// check if ?&
149147
}
@@ -228,9 +226,19 @@ UrlMatcher.prototype.exec = function (path, searchParams) {
228226

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

229+
function decodePathArray(string) {
230+
function reverseString(str) { return str.split("").reverse().join(""); }
231+
function unquoteDashes(str) { return str.replace(/\\-/, "-"); }
232+
return reverseString(string).split(/-(?!\\)/).map(reverseString).map(unquoteDashes).reverse();
233+
}
234+
231235
for (i = 0; i < nPath; i++) {
232236
paramName = paramNames[i];
233-
values[paramName] = this.params[paramName].value(m[i + 1]);
237+
var param = this.params[paramName];
238+
// if the param is optional, convert an empty string to `undefined`
239+
var paramVal = m[i+1] === "" ? param.emptyString : m[i+1];
240+
if (paramVal && param.array === true) paramVal = decodePathArray(paramVal);
241+
values[paramName] = param.value(paramVal);
234242
}
235243
for (/**/; i < nTotal; i++) {
236244
paramName = paramNames[i];
@@ -298,6 +306,10 @@ UrlMatcher.prototype.format = function (values) {
298306

299307
var i, search = false, nPath = segments.length - 1, nTotal = params.length, result = segments[0];
300308

309+
function encodeDashes(str) { // Replace dashes with encoded "\-"
310+
return encodeURIComponent(str).replace(/-/g, function(c) { return '%5C%' + c.charCodeAt(0).toString(16).toUpperCase(); });
311+
}
312+
301313
for (i = 0; i < nTotal; i++) {
302314
var isPathParam = i < nPath;
303315
var name = params[i], param = paramset[name], value = param.value(values[name]);
@@ -308,7 +320,13 @@ UrlMatcher.prototype.format = function (values) {
308320
if (isPathParam) {
309321
var nextSegment = segments[i + 1];
310322
if (squash === "nosquash") {
311-
if (encoded != null) result += encodeURIComponent(encoded);
323+
if (encoded != null) {
324+
if (isArray(encoded)) {
325+
result += encoded.map(encodeDashes).join("-");
326+
} else {
327+
result += encodeURIComponent(encoded);
328+
}
329+
}
312330
result += nextSegment;
313331
} else if (squash === "value") {
314332
result += nextSegment;
@@ -443,49 +461,45 @@ Type.prototype.pattern = /.*/;
443461
Type.prototype.toString = function() { return "{Type:" + this.name + "}"; };
444462

445463
/*
446-
* Wraps an existing custom Type as a search-query aware type which adds multi-value support.
464+
* Wraps an existing custom Type as an array of Type, depending on 'mode'.
447465
* e.g.:
448-
* - urlmatcher pattern "/path?{queryParam:int}"
466+
* - urlmatcher pattern "/path?{queryParam[]:int}"
449467
* - url: "/path?queryParam=1&queryParam=2
450468
* - $stateParams.queryParam will be [1, 2]
469+
* if `mode` is "auto", then
470+
* - url: "/path?queryParam=1 will create $stateParams.queryParam: 1
471+
* - url: "/path?queryParam=1&queryParam=2 will create $stateParams.queryParam: [1, 2]
451472
*/
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;
473+
Type.prototype.$asArray = function(mode, isSearch) {
474+
if (!mode) return this;
475+
if (mode === "auto" && !isSearch) throw new Error("'auto' array mode is for query parameters only");
476+
return new ArrayType(this, mode);
477+
478+
function ArrayType(type, mode) {
479+
function bindTo(thisObj, callback) {
480+
return function() {
481+
return callback.apply(thisObj, arguments);
482+
};
469483
}
470484

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-
}
485+
function arrayHandler(callback, reducefn) {
486+
// Wraps type functions to operate on each value of an array
487+
return function handleArray(val) {
488+
if (!isArray(val)) val = [ val ];
489+
var result = val.map(callback);
490+
if (reducefn)
491+
return result.reduce(reducefn, true);
492+
return (result && result.length == 1 && mode === "auto") ? result[0] : result;
479493
};
480494
}
481495

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);
496+
function alltruthy(val, memo) { return val && memo; }
497+
this.encode = arrayHandler(bindTo(this, type.encode));
498+
this.decode = arrayHandler(bindTo(this, type.decode));
499+
this.equals = arrayHandler(bindTo(this, type.equals), alltruthy);
500+
this.is = arrayHandler(bindTo(this, type.is), alltruthy);
488501
this.pattern = type.pattern;
502+
this.$arrayMode = mode;
489503
}
490504
};
491505

@@ -797,17 +811,21 @@ function $UrlMatcherFactory() {
797811
return this;
798812
}];
799813

800-
this.Param = function Param(id, type, config) {
814+
this.Param = function Param(id, type, config, isSearch) {
801815
var self = this;
802816
var defaultValueConfig = getDefaultValueConfig(config);
803817
config = config || {};
804818
type = getType(config, type);
819+
var arrayMode = getArrayMode();
820+
type = arrayMode ? type.$asArray(arrayMode, isSearch) : type;
805821
var isOptional = defaultValueConfig.value !== undefined;
806822
var squash = getSquashPolicy(config, isOptional);
823+
var emptyString = getEmptyStringValue(config, arrayMode, isOptional);
807824

808825
function getDefaultValueConfig(config) {
809826
var keys = isObject(config) ? objectKeys(config) : [];
810-
var isShorthand = keys.indexOf("value") === -1 && keys.indexOf("type") === -1 && keys.indexOf("squash") === -1;
827+
var isShorthand = keys.indexOf("value") === -1 && keys.indexOf("type") === -1 &&
828+
keys.indexOf("squash") === -1 && keys.indexOf("array") === -1;
811829
var configValue = isShorthand ? config : config.value;
812830
return {
813831
fn: isInjectable(configValue) ? configValue : function () { return configValue; },
@@ -822,6 +840,13 @@ function $UrlMatcherFactory() {
822840
return config.type instanceof Type ? config.type : new Type(config.type);
823841
}
824842

843+
// array config: param name (param[]) overrides default settings. explicit config overrides param name.
844+
function getArrayMode() {
845+
var arrayDefaults = { array: isSearch ? "auto" : false };
846+
var arrayParamNomenclature = id.match(/\[\]$/) ? { array: true } : {};
847+
return extend(arrayDefaults, arrayParamNomenclature, config).array;
848+
}
849+
825850
/**
826851
* returns "nosquash", "value", "slash" to indicate the "default parameter url squash policy".
827852
* undefined aliases to urlMatcherFactory default. `false` aliases to "nosquash". `true` aliases to "slash".
@@ -835,6 +860,15 @@ function $UrlMatcherFactory() {
835860
throw new Error("Invalid squash policy: '" + squash + "'. Valid policies: 'nosquash' (false), 'value', 'slash' (true)");
836861
}
837862

863+
/**
864+
* Returns "" or undefined, or whatever is defined in the param's config.emptyString.
865+
* If the parameter was matched in a URL, but was matched as an empty string, this value will be used instead.
866+
*/
867+
function getEmptyStringValue(config, arrayMode, isOptional) {
868+
var defaultPolicy = { emptyString: (isOptional || arrayMode ? undefined : "") };
869+
return extend(defaultPolicy, config).emptyString;
870+
}
871+
838872
/**
839873
* [Internal] Get the default value of a parameter, which may be an injectable function.
840874
*/
@@ -856,10 +890,12 @@ function $UrlMatcherFactory() {
856890
extend(this, {
857891
id: id,
858892
type: type,
893+
array: arrayMode,
859894
config: config,
860895
squash: squash,
861-
dynamic: undefined,
896+
emptyString: emptyString,
862897
isOptional: isOptional,
898+
dynamic: undefined,
863899
value: $value,
864900
toString: toString
865901
});

0 commit comments

Comments
 (0)