define(["jquery", "underscore", "backbone", "models/filters/Filter"], function (
$,
_,
Backbone,
Filter,
) {
/**
* @class NumericFilter
* @classdesc A search filter whose search term is always an exact number or numbber range
* @classcategory Models/Filters
* @extends Filter
* @constructs
*/
var NumericFilter = Filter.extend(
/** @lends NumericFilter.prototype */ {
type: "NumericFilter",
/**
* Default attributes for this model
* @extends Filter#defaults
* @type {Object}
* @property {Date} min - The minimum number to use in the query for this filter
* @property {Date} max - The maximum number to use in the query for this filter
* @property {Date} rangeMin - The lowest possible number that 'min' can be
* @property {Date} rangeMax - The highest possible number that 'max' can be
* @property {string} nodeName - The XML node name to use when serializing this model into XML
* @property {boolean} range - If true, this Filter will use a numeric range as the search term instead of an exact number
* @property {number} step - The number to increase the search value by when incrementally increasing or decreasing the numeric range
*/
defaults: function () {
return _.extend(Filter.prototype.defaults(), {
nodeName: "numericFilter",
min: null,
max: null,
rangeMin: null,
rangeMax: null,
range: true,
step: 1,
});
},
initialize: function (attributes, options) {
const model = this;
Filter.prototype.initialize.call(this, attributes, options);
// Limit the range min, range max, and update step if the model switches from
// being a coordinate filter to a regular numeric filter or vice versa
model.listenTo(model, "change:fields", function () {
model.toggleCoordinateLimits();
});
model.toggleCoordinateLimits();
},
/**
* For filters that represent geographic coordinates, return the
* appropriate defaults for the NumericFilter model.
* @param {'latitude'|'longitude'} coord - The coordinate type to get
* defaults for.
* @returns {Object} The rangeMin, rangeMax, and step values for the
* given coordinate type
*/
coordDefaults: function (coord = "longitude") {
return {
rangeMin: coord === "longitude" ? -180 : -90,
rangeMax: coord === "longitude" ? 180 : 90,
step: 0.00001,
};
},
/**
* Add or remove the rangeMin, rangeMax, and step associated with
* coordinate queries. If the filter is a coordinate filter, then add
* the appropriate defaults for the rangeMin, rangeMax, and step. If
* the filter is NOT a coordinate filter, then set rangeMin, rangeMax,
* and step to the regular defaults for a numeric filter.
* @param {Boolean} [overwrite=false] - By default, the rangeMin,
* rangeMax, and step will only be reset if they are currently set to
* one of the default values (e.g. if the model has default values for
* a numeric filter, they will be set to the default values for a
* coordinate filter). To change this behaviour to always reset the
* attributes to the new defaults values, set overwrite to true.
*/
toggleCoordinateLimits: function (overwrite = false) {
try {
const model = this;
const lonDefaults = model.coordDefaults("longitude");
const latDefaults = model.coordDefaults("latitude");
const numDefaults = model.defaults();
const attrs = Object.keys(lonDefaults); // 'rangeMin', 'rangeMax', and 'step'
const isDefault = function (attr) {
const val = model.get(attr);
return (
val == numDefaults[attr] ||
val == latDefaults[attr] ||
val == lonDefaults[attr]
);
};
// When the model has changed to a numeric filter, set the range min, range max,
// and step to the default values for a numeric filter, if they are currently set
// to the default values for a coordinate filter (or when overwrite is true).
let defaultsToSet = numDefaults;
// When the model has changed to a coordinate filter, set the range min, range max,
// and step to the default values for a coordinate filter, if they are currently set
// to the default values for a numeric filter (or when overwrite is true).
if (model.isCoordinateQuery()) {
// Use longitude range (-180, 180) for longitude only queries, or queries with
// both longitude and latitude
defaultsToSet = lonDefaults;
if (model.isLatitudeQuery()) {
defaultsToSet = latDefaults;
}
}
attrs.forEach(function (attr) {
if (isDefault(attr) || overwrite) {
model.set(attr, defaultsToSet[attr]);
}
});
model.limitToRange();
model.roundToStep();
} catch (error) {
console.log(
"There was an error toggling Coordinate limits in a NumericFilter" +
". Error details: " +
error,
);
}
},
/**
* Ensures that the min, max, and value are within the rangeMin and rangeMax.
*/
limitToRange: function () {
try {
const model = this;
const min = model.get("min");
const max = model.get("max");
const rangeMin = model.get("rangeMin");
const rangeMax = model.get("rangeMax");
const values = model.get("values");
const value = values != null && values.length ? values[0] : null;
// Set MIN to min or max if it is outside the range
if (min != null) {
if (rangeMin != null && min < rangeMin) {
model.set("min", rangeMin);
}
if (rangeMax != null && min > rangeMax) {
model.set("min", rangeMax);
}
}
// Set the MAX to min or max if it is outside the range
if (max != null) {
if (rangeMax != null && max > rangeMax) {
model.set("max", rangeMax);
}
if (rangeMin != null && max < rangeMin) {
model.set("max", rangeMin);
}
}
// Set the VALUE to min or max if it is outside the range
if (value != null) {
if (rangeMax != null && value > rangeMax) {
values[0] = rangeMax;
model.set("values", values);
}
if (rangeMin != null && value < rangeMin) {
values[0] = rangeMin;
model.set("values", values);
}
}
} catch (error) {
console.log(
"There was an error limiting a NumericFilter to the range" +
". Error details: " +
error,
);
}
},
/**
* Rounds the min, max, and/or value to the same number of decimal
* places as the step.
*/
roundToStep: function () {
try {
const model = this;
const min = model.get("min");
const max = model.get("max");
const step = model.get("step");
const values = model.get("values");
const value = values != null && values.length ? values[0] : null;
// Returns the number of decimal places in a number
function countDecimals(n) {
let text = n.toString();
// verify if number 0.000005 is represented as "5e-6"
if (text.indexOf("e-") > -1) {
let [base, trail] = text.split("e-");
let deg = parseInt(trail, 10);
return deg;
}
// count decimals for number in representation like "0.123456"
if (Math.floor(n) !== n) {
return n.toString().split(".")[1].length || 0;
}
return 0;
}
// Rounds a number to the specified number of decimal places
function roundTo(n, digits) {
if (digits === undefined) {
digits = 0;
}
const multiplicator = Math.pow(10, digits);
n = parseFloat((n * multiplicator).toFixed(11));
const test = Math.round(n) / multiplicator;
return +test.toFixed(digits);
}
// Round min & max to number of decimal places in step
if (step != null) {
let digits = countDecimals(step);
if (min != null) {
model.set("min", roundTo(min, digits));
}
if (max != null) {
model.set("max", roundTo(max, digits));
}
if (value != null) {
values[0] = roundTo(value, digits);
model.set("values", values);
}
}
} catch (error) {
console.log(
"There was an error rounding values in a NumericFilter to the step" +
". Error details: " +
error,
);
}
},
/**
* Parses the numericFilter XML node into JSON
*
* @param {Element} xml - The XML Element that contains all the NumericFilter elements
* @return {JSON} - The JSON object literal to be set on the model
*/
parse: function (xml) {
try {
var modelJSON = Filter.prototype.parse.call(this, xml);
//Get the rangeMin and rangeMax nodes
var rangeMinNode = $(xml).find("rangeMin"),
rangeMaxNode = $(xml).find("rangeMax");
//Parse the range min
if (rangeMinNode.length) {
modelJSON.rangeMin = parseFloat(rangeMinNode[0].textContent);
}
//Parse the range max
if (rangeMaxNode.length) {
modelJSON.rangeMax = parseFloat(rangeMaxNode[0].textContent);
}
//If this Filter is in a filter group, don't parse the values
if (!this.get("isUIFilterType")) {
//Get the min, max, and value nodes
var minNode = $(xml).find("min"),
maxNode = $(xml).find("max"),
valueNode = $(xml).find("value");
//Parse the min value
if (minNode.length) {
modelJSON.min = parseFloat(minNode[0].textContent);
}
//Parse the max value
if (maxNode.length) {
modelJSON.max = parseFloat(maxNode[0].textContent);
}
//Parse the value
if (valueNode.length) {
modelJSON.values = [parseFloat(valueNode[0].textContent)];
}
}
//If a range min and max was given, or if a min and max value was given,
// then this NumericFilter should be presented as a numeric range (rather than
// an exact numeric value).
if (
rangeMinNode.length ||
rangeMaxNode.length ||
(minNode.length && maxNode.length)
) {
//Set the range attribute on the JSON
modelJSON.range = true;
} else {
//Set the range attribute on the JSON
modelJSON.range = false;
}
//If a range step was given, save it
if (modelJSON.range) {
var stepNode = $(xml).find("step");
if (stepNode.length) {
//Parse the text content of the node into a float
modelJSON.step = parseFloat(stepNode[0].textContent);
}
}
} catch (e) {
//If an error occurred while parsing the XML, return a blank JS object
//(i.e. this model will just have the default values).
return {};
}
return modelJSON;
},
/**
* Builds a query string that represents this filter.
*
* @return {string} The query string to send to Solr
*/
getQuery: function () {
//Start the query string
var queryString = "";
if (
// For numeric filters that are ranges, only construct the query if the min or max
// is different than the default
this.get("min") != this.get("rangeMin") ||
this.get("max") != this.get("rangeMax") ||
// Otherwise, a numeric filter could search for an exact value
(this.get("values") && this.get("values").length)
) {
//Iterate over each filter field and add to the query string
_.each(
this.get("fields"),
function (field, i, allFields) {
//Get the minimum, maximum, and value.
var max = this.get("max"),
min = this.get("min"),
value = this.get("values") ? this.get("values")[0] : null,
escapeMinus = function (val) {
return val.toString().replace("-", "\\%2D");
},
exists = function (val) {
return val !== null && val !== undefined;
};
//Construct a query string for ranges, min, or max
if (this.get("range") || max || max === 0 || min || min === 0) {
//If no min or max was set, but there is a value, construct an exact value match query
if (
!min &&
min !== 0 &&
!max &&
max !== 0 &&
(value || value === 0)
) {
// Escape the minus sign if needed
queryString += field + ":" + escapeMinus(value);
}
//If there is no min or max or value, set an empty query string
else if (
!min &&
min !== 0 &&
!max &&
max !== 0 &&
!value &&
value !== 0
) {
queryString = "";
}
//If there is at least a min or max
else {
//If there's a min but no max, set the max to a wildcard (unbounded)
if (exists(min) && !exists(max)) {
max = "*";
}
//If there's a max but no min, set the min to a wildcard (unbounded)
else if (exists(max) && !exists(min)) {
min = "*";
}
//If the max is higher than the min, set the max to a wildcard (unbounded)
else if (exists(max) && exists(min) && max < min) {
max = "*";
}
//Add the range for this field to the query string
queryString +=
field +
":[" +
escapeMinus(min) +
"%20TO%20" +
escapeMinus(max) +
"]";
}
}
//If there is a value set, construct an exact numeric match query
else if (value || value === 0) {
// If there is a value set, construct an exact numeric match query
queryString += field + ":" + escapeMinus(value);
}
//If there is another field, add an operator
if (allFields[i + 1] && queryString.length) {
queryString += "%20" + this.get("fieldsOperator") + "%20";
}
},
this,
);
//If there is more than one field, wrap the query in parentheses
if (this.get("fields").length > 1 && queryString.length) {
queryString = "(" + queryString + ")";
}
}
return queryString;
},
/**
* Updates the XML DOM with the new values from the model
* @inheritdoc
* @return {XMLElement} An updated numericFilter XML element from a portal document
*/
updateDOM: function (options) {
try {
if (typeof options == "undefined") {
var options = {};
}
var objectDOM = Filter.prototype.updateDOM.call(this);
//Numeric Filters don't use matchSubstring nodes
$(objectDOM).children("matchSubstring").remove();
//Get a clone of the original DOM
var originalDOM;
if (this.get("objectDOM")) {
originalDOM = this.get("objectDOM").cloneNode(true);
}
// Get new numeric data
var numericData = {
min: this.get("min"),
max: this.get("max"),
};
if (this.get("isUIFilterType")) {
numericData = _.extend(numericData, {
rangeMin: this.get("rangeMin"),
rangeMax: this.get("rangeMax"),
step: this.get("step"),
});
}
// Make subnodes and append to DOM
_.map(
numericData,
function (value, nodeName) {
if (value || value === 0) {
//If this value is the same as the default value, but it wasn't previously serialized,
if (
value == this.defaults()[nodeName] &&
(!$(originalDOM).children(nodeName).length ||
$(originalDOM).children(nodeName).text() !=
value + "-01-01T00:00:00Z")
) {
return;
}
var nodeSerialized =
objectDOM.ownerDocument.createElement(nodeName);
$(nodeSerialized).text(value);
$(objectDOM).append(nodeSerialized);
}
},
this,
);
//Remove filterOptions for collection definition filters
if (!this.get("isUIFilterType")) {
$(objectDOM).children("filterOptions").remove();
} else {
//Make sure the filterOptions are listed last
//Get the filterOptions element
var filterOptions = $(objectDOM).children("filterOptions");
//If the filterOptions exist
if (filterOptions.length) {
//Detach from their current position and append to the end
filterOptions.detach();
$(objectDOM).append(filterOptions);
}
}
// If there is a min or max or both, there must not be a value
if (
numericData.min ||
numericData.min === 0 ||
numericData.max ||
numericData.max === 0
) {
$(objectDOM).children("value").remove();
}
return objectDOM;
} catch (e) {
return "";
}
},
/**
* Creates a human-readable string that represents the value set on this model
* @return {string}
*/
getReadableValue: function () {
var readableValue = "";
var min = this.get("min"),
max = this.get("max"),
value = this.get("values")[0];
if (!value && value !== 0) {
//If there is a min and max
if ((min || min === 0) && (max || max === 0)) {
readableValue = min + " to " + max;
}
//If there is only a max
else if (max || max === 0) {
readableValue = "No more than " + max;
} else {
readableValue = "At least " + min;
}
} else {
readableValue = value;
}
return readableValue;
},
/**
* @inheritdoc
*/
hasChangedValues: function () {
return (
this.get("values").length > 0 ||
this.get("min") != this.defaults().min ||
this.get("max") != this.defaults().max
);
},
/**
* Checks if the values set on this model are valid and expected
* @return {object} - Returns a literal object with the invalid attributes and their corresponding error message
*/
validate: function () {
//Validate most of the NumericFilter attributes using the parent validate function
var errors = Filter.prototype.validate.call(this);
//If everything is valid so far, then we have to create a new object to store errors
if (typeof errors != "object") {
errors = {};
}
//Delete error messages for the attributes that are going to be validated specially for the NumericFilter
delete errors.values;
delete errors.min;
delete errors.max;
delete errors.rangeMin;
delete errors.rangeMax;
//If there is an exact number set as the search term
if (Array.isArray(this.get("values")) && this.get("values").length) {
//Check that all the values are numbers
if (
_.find(this.get("values"), function (n) {
return typeof n != "number";
})
) {
errors.values =
"All of the search terms for this filter need to be numbers.";
}
}
//If there is a search term set on the model that is not an array, or number,
// or undefined, or null, then it is some other invalid value like a string or date.
else if (
!Array.isArray(this.get("values")) &&
typeof values != "number" &&
typeof values != "undefined" &&
values !== null
) {
errors.values = "The search term for this filter needs to a number.";
}
//Check that the min and max values are in order, if the minimum is not the default value of 0
else if (
typeof this.get("min") == "number" &&
typeof this.get("max") == "number"
) {
if (this.get("min") > this.get("max") && this.get("min") != 0) {
errors.min =
"The minimum is after the maximum. The minimum must be a number less than the maximum, which is " +
this.get("max");
}
}
//If there is only a minimum number specified, check that it is a number
else if (this.get("min") && typeof this.get("min") != "number") {
errors.min = "The minimum needs to be a number.";
if (this.get("max") && typeof this.get("max") != "number") {
errors.max = "The maximum needs to be a number.";
}
}
//Check if the maximum is a value other than a number
else if (this.get("max") && typeof this.get("max") != "number") {
errors.max = "The maximum needs to be a number.";
}
//If there is no min, max, or value, then return an errors
else if (
!this.get("max") &&
this.get("max") !== 0 &&
!this.get("min") &&
this.get("min") !== 0 &&
((!this.get("values") && this.get("values") !== 0) ||
(Array.isArray(this.get("values")) && !this.get("values").length))
) {
errors.values =
"This search filter needs an exact number or a number range to use in the search query.";
}
//Return the error messages
if (Object.keys(errors).length) {
return errors;
} else {
return;
}
},
},
);
return NumericFilter;
});