Source: src/js/models/filters/NumericFilter.js

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;
});