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

define(["jquery", "underscore", "backbone"], function ($, _, Backbone) {
  /**
   * @class Filter
   * @classdesc A single search filter that is used in queries sent to the DataONE search service.
   * @classcategory Models/Filters
   * @extends Backbone.Model
   * @constructs
   */
  var FilterModel = Backbone.Model.extend(
    /** @lends Filter.prototype */
    {
      /**
       * The name of this Model
       * @name Filter#type
       * @type {string}
       * @readonly
       */
      type: "Filter",

      /**
       * Default attributes for this model
       * @type {object}
       * @returns {object}
       * @property {Element} objectDOM - The XML DOM for this filter
       * @property {string} nodeName - The XML node name for this filter's XML DOM
       * @property {string[]} fields - The search index fields to search
       * @property {string[]} values - The values to search for in the given search fields
       * @property {object} valueLabels - Optional human-readable labels for the elements of
       * values. Keys are the value and the human-readable label is the value at that key.
       * @property {string} operator - The operator to use between values set on this model.
       * "AND" or "OR"
       * @property {string} fieldsOperator - The operator to use between fields set on this
       * model. "AND" or "OR"
       * @property {string} queryGroup - Deprecated: Add this filter along with other the
       * other associated query group filters to a FilterGroup model instead. Old definition:
       * The name of the group this Filter is a part of, which is primarily used when
       * creating a query string from multiple Filter models. Filters in the same group will
       * be wrapped in parenthesis in the query.
       * @property {boolean} exclude - If true, search index docs matching this filter will
       * be excluded from the search results
       * @property {boolean} matchSubstring - If true, the search values will be wrapped in
       * wildcard characters to match substrings
       * @property {string} label - A human-readable short label for this Filter
       * @property {string} placeholder - A short example or description of this Filter
       * @property {string} icon - A term that identifies a single icon in a supported icon
       * library
       * @property {string} description - A longer description of this Filter's function
       * @property {boolean} isInvisible - If true, this filter will be added to the query
       * but will act in the "background", like a default filter
       * @property {boolean} inFilterGroup - Deprecated: use isUIFilterType instead.
       * @property {boolean} isUIFilterType - If true, this filter is one of the
       * UIFilterTypes, belongs to a UIFilterGroupType model, and is used to create a custom
       * Portal search filters. This changes how the XML is parsed and how the model is
       * validated and serialized.
       */
      defaults: function () {
        return {
          objectDOM: null,
          nodeName: "filter",
          fields: [],
          values: [],
          valueLabels: {},
          operator: "AND",
          fieldsOperator: "AND",
          exclude: false,
          matchSubstring: false,
          label: null,
          placeholder: null,
          icon: null,
          description: null,
          isInvisible: false,
          isUIFilterType: false,
        };
      },

      /**
       * Creates a new Filter model
       */
      initialize: function (attributes) {
        if (this.get("objectDOM")) {
          this.set(this.parse(this.get("objectDOM")));
        }

        if (attributes && attributes.isUIFilterType) {
          this.set("isUIFilterType", true);
        }

        //If this is an isPartOf filter, then add a label and description. Make it invisible
        //depending on how MetacatUI is configured.
        if (
          this.get("fields").length == 1 &&
          this.get("fields").includes("isPartOf")
        ) {
          this.set({
            label: "Datasets added manually",
            description:
              "Datasets added to this collection manually by dataset owners",
            isInvisible:
              MetacatUI.appModel.get("hideIsPartOfFilter") === true
                ? true
                : false,
          });
        }

        // Operator must be AND or OR
        ["fieldsOperator", "operator"].forEach(function (op) {
          if (!["AND", "OR"].includes(this.get(op))) {
            // Set the value to the default
            this.set(op, this.defaults()[op]);
          }
        }, this);
      },

      /**
       * Parses the given XML node into a JSON object to be set on the model
       *
       * @param {Element} xml - The XML element that contains all the filter elements
       * @return {JSON} - The JSON object of all the filter attributes
       */
      parse: function (xml) {
        //If an XML element wasn't sent as a parameter, get it from the model
        if (!xml) {
          var xml = this.get("objectDOM");

          //Return an empty JSON object if there is no objectDOM saved in the model
          if (!xml) return {};
        }

        var modelJSON = {};

        if ($(xml).children("field").length) {
          //Parse the field(s)
          modelJSON.fields = this.parseTextNode(xml, "field", true);
        }

        if ($(xml).children("label").length) {
          //Parse the label
          modelJSON.label = this.parseTextNode(xml, "label");
        }

        // Check if this filter contains one of the Id fields - we use OR by default for the
        // operator for these fields.
        var idFields = MetacatUI.appModel.get("queryIdentifierFields");
        var isIdFilter = false;
        if (modelJSON.fields) {
          isIdFilter = _.some(idFields, function (idField) {
            return modelJSON.fields.includes(idField);
          });
        }

        //Parse the operators, if they exist
        if ($(xml).find("operator").length) {
          modelJSON.operator = this.parseTextNode(xml, "operator");
        } else {
          if (isIdFilter) {
            modelJSON.operator = "OR";
          }
        }

        if ($(xml).find("fieldsOperator").length) {
          modelJSON.fieldsOperator = this.parseTextNode(xml, "fieldsOperator");
        } else {
          if (isIdFilter) {
            modelJSON.fieldsOperator = "OR";
          }
        }

        //Parse the exclude, if it exists
        if ($(xml).find("exclude").length) {
          modelJSON.exclude =
            this.parseTextNode(xml, "exclude") === "true" ? true : false;
        }

        //Parse the matchSubstring
        if ($(xml).find("matchSubstring").length) {
          modelJSON.matchSubstring =
            this.parseTextNode(xml, "matchSubstring") === "true" ? true : false;
        }

        var filterOptionsNode = $(xml).children("filterOptions");
        if (filterOptionsNode.length) {
          //Parse the filterOptions XML node
          modelJSON = _.extend(
            this.parseFilterOptions(filterOptionsNode),
            modelJSON,
          );
        }

        //If this Filter is in a filter group, don't parse the values
        if (!this.get("isUIFilterType")) {
          if ($(xml).children("value").length) {
            //Parse the value(s)
            modelJSON.values = this.parseTextNode(xml, "value", true);
          }
        }

        return modelJSON;
      },

      /**
       * Gets the text content of the XML node matching the given node name
       *
       * @param {Element} parentNode - The parent node to select from
       * @param {string} nodeName - The name of the XML node to parse
       * @param {boolean} isMultiple - If true, parses the nodes into an array
       * @return {(string|Array)} - Returns a string or array of strings of the text content
       */
      parseTextNode: function (parentNode, nodeName, isMultiple) {
        var node = $(parentNode).children(nodeName);

        //If no matching nodes were found, return falsey values
        if (!node || !node.length) {
          //Return an empty array if the isMultiple flag is true
          if (isMultiple) return [];
          //Return null if the isMultiple flag is false
          else return null;
        }
        //If exactly one node is found and we are only expecting one, return the text content
        else if (node.length == 1 && !isMultiple) {
          if (!node[0].textContent) return null;
          else return node[0].textContent;
        }
        //If more than one node is found, parse into an array
        else {
          var allContents = [];

          _.each(node, function (node) {
            if (node.textContent || node.textContent === 0)
              allContents.push(node.textContent);
          });

          return allContents;
        }
      },

      /**
       * Parses the filterOptions XML node into a literal object
       * @param {Element} filterOptionsNode - The filterOptions XML element to parse
       * @return {Object} - The parsed filter options, in literal object form
       */
      parseFilterOptions: function (filterOptionsNode) {
        if (typeof filterOptionsNode == "undefined") {
          return {};
        }

        var modelJSON = {};

        try {
          //The list of options to parse
          var options = ["placeholder", "icon", "description"];

          //Parse the text nodes for each filter option
          _.each(
            options,
            function (option) {
              if ($(filterOptionsNode).children(option).length) {
                modelJSON[option] = this.parseTextNode(
                  filterOptionsNode,
                  option,
                  false,
                );
              }
            },
            this,
          );

          //Parse the generic option name and value pairs and set on the model JSON
          $(filterOptionsNode)
            .children("option")
            .each(function (i, optionNode) {
              var optName = $(optionNode).children("optionName").text();
              var optValue = $(optionNode).children("optionValue").text();

              modelJSON[optName] = optValue;
            });

          //Return the JSON to be set on this model
          return modelJSON;
        } catch (e) {
          return {};
        }
      },

      /**
       * Builds a query string that represents this filter.
       *
       * @return {string} The query string to send to Solr
       * @param {string} [groupLevelOperator] - "AND" or "OR". The operator used in the
       * parent Filters collection to combine the filter query fragments together. If the
       * group level operator is "OR" and this filter has exclude set to TRUE, then a
       * positive clause is added.
       */
      getQuery: function (groupLevelOperator) {
        //Get the values of this filter in Array format
        var values = this.get("values");
        if (!Array.isArray(values)) {
          values = [values];
        }

        //Check that there are actually values to serialize
        if (!values.length) {
          return "";
        }

        //Filter out any invalid values (can't use _.compact() because we want to keep 'false' values)
        values = _.reject(values, function (value) {
          return (
            value === null ||
            typeof value == "undefined" ||
            value === NaN ||
            value === "" ||
            (Array.isArray(value) && !value.length)
          );
        });

        if (!values.length) {
          return "";
        }

        //Start a query string for this model and get the fields
        var queryString = "",
          fields = this.get("fields");

        //If the fields are not an array, convert it to an array
        if (!Array.isArray(fields)) {
          fields = [fields];
        }

        //Iterate over each field
        _.each(
          fields,
          function (field, i) {
            //Add the query string for this field to the overall model query string
            queryString += field + ":" + this.getValueQuerySubstring(values);

            //Add the OR operator between field names
            if (fields.length > i + 1 && queryString.length) {
              queryString += "%20" + this.get("fieldsOperator") + "%20";
            }
          },
          this,
        );

        //If there is more than one field, wrap the multiple fields in parenthesis
        if (fields.length > 1) {
          queryString = "(" + queryString + ")";
        }

        //If this filter should be excluding matches from the results,
        // then add a hyphen in front
        if (queryString && this.get("exclude")) {
          queryString = "-" + queryString;
          if (this.requiresPositiveClause(groupLevelOperator)) {
            queryString = queryString + "%20AND%20*:*";
            if (groupLevelOperator && groupLevelOperator === "OR") {
              queryString = "(" + queryString + ")";
            }
          }
        }

        return queryString;
      },

      /**
       * For "negative" Filters (filter models where exclude is set to true), detects
       * whether the query requires an additional "positive" query phrase in order to avoid
       * the problem of pure negative queries returning zero results. If this Filter is not
       * part of a collection of Filters, assume it needs the positive clause. If this
       * Filter is part of a collection of Filters, detect whether there are other,
       * "positive" filters in the same query (i.e. filter models where exclude is set to
       * false). If there are other positive queries, then an additional clause is not
       * required. If the Filter is part of a pure negative query, but it is not the last
       * filter, then don't add a clause since it will be added to the last, and only one
       * is required. When looking for other positive and negative filters, exclude empty
       * filters and filters that use any of the identifier fields, as these are appended to
       * the end of the query.
       * @see {@link https://github.com/NCEAS/metacatui/issues/1600}
       * @see {@link https://cwiki.apache.org/confluence/display/SOLR/NegativeQueryProblems}
       * @param {string} [groupLevelOperator] - "AND" or "OR". The operator used in the
       * parent Filters collection to combine the filter query fragments together. If the
       * group level operator is "OR" and this filter has exclude set to TRUE, then a
       * positive clause is required.
       * @return {boolean} returns true of this Filter needs a positive clause, false
       * otherwise
       */
      requiresPositiveClause: function (groupLevelOperator) {
        try {
          // Only negative queries require the additional clause
          if (this.get("exclude") == false) {
            return false;
          }
          // If this Filter is not part of a collection of Filters, assume it needs the
          // positive clause.
          if (!this.collection) {
            return true;
          }
          // If this Filter is the only one in the group, assume it needs a positive clause
          if (this.collection.length === 1) {
            return true;
          }
          // If this filter is being "OR"'ed together with other filters, then assume it
          // needs the additional clause.
          if (groupLevelOperator && groupLevelOperator === "OR") {
            return true;
          }
          // Get all of the other filters in the same collection that are not ID filters.
          // These filters are always appended to the end of the query as a separated group.
          var nonIDFilters = this.collection.getNonIdFilters();
          // Exclude filters that would give an empty query string (e.g. because value is
          // missing)
          var filters = _.reject(nonIDFilters, function (filterModel) {
            if (filterModel === this) {
              return false;
            }
            return !filterModel.isValid();
          });

          // If at least one filter in the collection is positive (exclude = false), then we
          // don't need to add anything
          var positiveFilters = _.find(filters, function (filterModel) {
            return filterModel.get("exclude") != true;
          });
          if (positiveFilters) {
            return false;
          }
          // Assuming that all the non-ID filters are negative, check if this is the first
          // last the list. Since we only need one additional positive query phrase to avoid
          // the pure negative query problem, by convention, only add the positive phrase at
          // the end of the filter group
          if (this === _.last(filters)) {
            return true;
          } else {
            return false;
          }
        } catch (error) {
          console.log(
            "There was a problem detecting whether a Filter required a positive" +
              " clause. Assuming that it needs one. Error details: " +
              error,
          );
          return true;
        }
      },

      /**
       * Constructs a query substring for each of the values set on this model
       *
       * @example
       *   values: ["walker", "jones"]
       *   Returns: "(walker%20OR%20jones)"
       *
       * @param {string[]} [values] - The values to use in this query substring.
       * If not provided, the values set on the model are used.
       * @return {string} The query substring
       */
      getValueQuerySubstring: function (values) {
        //Start a query string for this field and get the values
        var valuesQueryString = "",
          values = values || this.get("values");

        //If the values are not an array, convert it to an array
        if (!Array.isArray(values)) {
          values = [values];
        }

        //Iterate over each value set on the model
        _.each(
          values,
          function (value, i) {
            //If the value is not a string, then convert it to a string
            if (typeof value != "string") {
              value = value.toString();
            }

            //Trim off whitespace
            value = value.trim();

            var dateRangeRegEx =
                /^\[((\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d*Z)|\*)( |%20)TO( |%20)((\d{4}-[01]\d-[0-3]\dT[0-2]\d(:|\\:)[0-5]\d(:|\\:)[0-5]\d\.\d*Z)|\*)\]/,
              isDateRange = dateRangeRegEx.test(value),
              isSearchPhrase = value.indexOf(" ") > -1,
              isIdFilter = this.isIdFilter(),
              //Test for ORCIDs and group subjects
              isSubject =
                /^(?:https?:\/\/orcid\.org\/)?(?:\w{4}-){3}\w{4}$|^(?:CN=.{1,},DC=.{1,},DC=.{1,})$/i.test(
                  value,
                );

            // Escape special characters
            value = this.escapeSpecialChar(value);

            //URL encode the search value
            value = encodeURIComponent(value);

            // If the value is a search phrase (more than one word), is part of an ID filter,
            // and not a date range string, wrap in quotes
            if ((isSearchPhrase || isIdFilter || isSubject) && !isDateRange) {
              value = '"' + value + '"';
            }

            if (this.get("matchSubstring") && !isDateRange) {
              // Look for existing wildcard characters at the end of the value string, wrap
              // the value string in wildcard characters if there aren't any yet.
              if (!value.match(/^\*|\*$/)) {
                value = "*" + value + "*";
              }
            }

            // Add the value to the query string
            valuesQueryString += value;

            //Add the operator between values
            if (values.length > i + 1 && valuesQueryString.length) {
              valuesQueryString += "%20" + this.get("operator") + "%20";
            }
          },
          this,
        );

        if (values.length > 1) {
          valuesQueryString = "(" + valuesQueryString + ")";
        }

        return valuesQueryString;
      },

      /**
       * Checks if any of the fields in this Filter match one of the
       * {@link AppConfig#queryIdentifierFields}
       * @since 2.17.0
       */
      isIdFilter: function () {
        try {
          let fields = this.get("fields");
          let values = this.get("values");

          if (!fields) {
            return false;
          }
          let idFields = MetacatUI.appModel.get("queryIdentifierFields");
          let match = _.some(idFields, (idField) => fields.includes(idField));

          //Check if the values are all identifiers by checking for uuids and dois
          if (!match && values.length) {
            match = values.every((v) =>
              /^urn:uuid:\w{8,}-\w{4,}-\w{4,}-\w{4,}-\w{12,}$|^.{0,}doi.{1,}10.\d{4,9}\/[-._;()\/:A-Z0-9]+$/i.test(
                v,
              ),
            );
          }

          return match;
        } catch (error) {
          console.log(
            "Error checking if a Filter model is an ID filter. " +
              "Assuming it is not. Error details:" +
              error,
          );
          return false;
        }
      },

      /**
       * Resets the values attribute on this filter
       */
      resetValue: function () {
        this.set("values", this.defaults().values);
      },

      /**
       * Checks if this Filter has values different than the default values.
       * @return {boolean} - Returns true if this Filter has values set on it, otherwise will return false
       */
      hasChangedValues: function () {
        return this.get("values").length > 0;
      },

      /**
       * isEmpty - Checks whether this Filter has any values or fields set
       *
       * @return {boolean}  returns true if the Filter's values and fields are empty
       */
      isEmpty: function () {
        try {
          var fields = this.get("fields"),
            values = this.get("values"),
            noFields = !fields || fields.length == 0,
            fieldsEmpty = _.every(fields, function (item) {
              return item == "";
            }),
            noValues = !values || values.length == 0,
            valuesEmpty = _.every(values, function (item) {
              return item == "";
            });

          var noMinNoMax = _.every(
            [this.get("min"), this.get("max")],
            function (num) {
              return typeof num === "undefined" || (!num && num !== 0);
            },
          );

          // Values aren't required for UI filter types. Labels, icons, and descriptions are
          // available.
          if (this.get("isUIFilterType")) {
            noUIVals = _.every(
              ["label", "icon", "description"],
              function (attrName) {
                var setValue = this.get(attrName);
                var defaultValue = this.defaults()[attrName];
                return !setValue || setValue === defaultValue;
              },
              this,
            );
            return noUIVals && noFields && fieldsEmpty && noMinNoMax;
          }

          // For regular search filters, just a field and some sort of search term/value is
          // required
          return (
            noFields && fieldsEmpty && noValues && valuesEmpty && noMinNoMax
          );
        } catch (e) {
          console.log(
            "Failed to check if a Filter is empty, error message: " + e,
          );
        }
      },

      /**
       * Escapes Solr query reserved characters so that search terms can include
       *  those characters without throwing an error.
       *
       * @param {string} term - The search term or phrase to escape
       * @return {string} - The search term or phrase, after special characters are escaped
       */
      escapeSpecialChar: function (term) {
        if (!term || typeof term != "string") {
          return "";
        }

        // Removes all the ampersands since Metacat cannot handle escaped ampersands for some reason
        // See https://github.com/NCEAS/metacat/issues/1576
        term = term.replaceAll("&", "");

        return term.replace(
          /\+|-|&|\||!|\(|\)|\{|\}|\[|\]|\^|\\|\"|~|\?|:|\//g,
          "\\$&",
        );
      },

      /**
       * Updates XML DOM with the new values from the model
       *
       *  @param {object} [options] A literal object with options for this serialization
       *  @return {Element} A new XML element with the updated values
       */
      updateDOM: function (options) {
        try {
          if (typeof options == "undefined") {
            var options = {};
          }

          var objectDOM = this.get("objectDOM"),
            filterOptionsNode;

          if (
            typeof objectDOM == "undefined" ||
            !objectDOM ||
            !$(objectDOM).length
          ) {
            // Node name differs for different filters, all of which use this function
            var nodeName = this.get("nodeName") || "filter";
            // Create an XML filter element from scratch
            var objectDOM = new DOMParser().parseFromString(
              "<" + nodeName + "></" + nodeName + ">",
              "text/xml",
            );
            var $objectDOM = $(objectDOM).find(nodeName);
          } else {
            objectDOM = objectDOM.cloneNode(true);
            var $objectDOM = $(objectDOM);

            //Detach the filterOptions so they are saved
            filterOptionsNode = $objectDOM.children("filterOptions");
            filterOptionsNode.detach();

            //Empty the DOM
            $objectDOM.empty();
          }

          var xmlDocument = $objectDOM[0].ownerDocument;

          // Get new values. Must store in an array because the order that we add each
          // element to the DOM matters
          var filterData = [
            {
              nodeName: "label",
              value: this.get("label"),
            },
            {
              nodeName: "field",
              value: this.get("fields"),
            },
            {
              nodeName: "operator",
              value: this.get("operator"),
            },
            {
              nodeName: "exclude",
              value: this.get("exclude"),
            },
            {
              nodeName: "fieldsOperator",
              value: this.get("fieldsOperator"),
            },
            {
              nodeName: "matchSubstring",
              value: this.get("matchSubstring"),
            },
            {
              nodeName: "value",
              value: this.get("values"),
            },
          ];

          filterData.forEach(function (element) {
            var values = element.value;
            var nodeName = element.nodeName;

            // Serialize the nodes with multiple occurrences
            if (Array.isArray(values)) {
              _.each(
                values,
                function (value) {
                  // Don't serialize empty, null, or undefined values
                  if (value || value === false || value === 0) {
                    var nodeSerialized = xmlDocument.createElement(nodeName);
                    $(nodeSerialized).text(value);
                    $objectDOM.append(nodeSerialized);
                  }
                },
                this,
              );
            }
            // Serialize the single occurrence nodes. Don't serialize falsey or default values
            else if (
              (values || values === false) &&
              values != this.defaults()[nodeName]
            ) {
              var nodeSerialized = xmlDocument.createElement(nodeName);
              $(nodeSerialized).text(values);
              $objectDOM.append(nodeSerialized);
            }
          }, this);

          // If this is a UIFilterType that won't be serialized into a Collection definition,
          // then add extra XML nodes
          if (this.get("isUIFilterType")) {
            //Update the filterOptions XML DOM
            filterOptionsNode = this.updateFilterOptionsDOM(filterOptionsNode);

            //Add the filterOptions to the filter DOM
            if (
              typeof filterOptionsNode != "undefined" &&
              $(filterOptionsNode).children().length
            ) {
              $objectDOM.append(filterOptionsNode);
            }
          }

          return $objectDOM[0];
        } catch (e) {
          console.error("Unable to serialize a Filter.", e);
          return this.get("objectDOM") || "";
        }
      },

      /**
       * Serializes the filter options into an XML DOM and returns it
       * @param {Element} [filterOptionsNode] - The XML filterOptions node to update
       * @return {Element} - The updated DOM
       */
      updateFilterOptionsDOM: function (filterOptionsNode) {
        try {
          if (
            typeof filterOptionsNode == "undefined" ||
            !filterOptionsNode.length
          ) {
            var filterOptionsNode = new DOMParser().parseFromString(
              "<filterOptions></filterOptions>",
              "text/xml",
            );
            var filterOptionsNode =
              $(filterOptionsNode).find("filterOptions")[0];
          }
          //Convert the XML node into a jQuery object
          var $filterOptionsNode = $(filterOptionsNode);

          //Get the first option element
          var firstOptionNode = $filterOptionsNode.children("option").first();

          var xmlDocument;
          if (filterOptionsNode.length && filterOptionsNode[0]) {
            xmlDocument = filterOptionsNode[0].ownerDocument;
          }
          if (!xmlDocument) {
            xmlDocument = filterOptionsNode.ownerDocument;
          }
          if (!xmlDocument) {
            xmlDocument = filterOptionsNode;
          }

          // Update the text value of UI nodes. The following values are for
          // UIFilterOptionsType
          ["placeholder", "icon", "description"].forEach(function (nodeName) {
            //Remove the existing node, if it exists
            $filterOptionsNode.children(nodeName).remove();

            // If there is a value set on the model for this attribute, then create an XML
            // node for this attribute and set the text value
            var value = this.get(nodeName);
            if (value) {
              var newNode = $(xmlDocument.createElement(nodeName)).text(value);

              if (firstOptionNode.length) firstOptionNode.before(newNode);
              else $filterOptionsNode.append(newNode);
            }
          }, this);

          //If no options were serialized, then return an empty string
          if (!$filterOptionsNode.children().length) {
            return "";
          } else {
            return filterOptionsNode;
          }
        } catch (e) {
          console.log(
            "Error updating the FilterOptions DOM in a Filter model, " +
              "error details: ",
            e,
          );
          return "";
        }
      },

      /**
       * Returns true if the given value or value set on this filter is a date range query
       * @param {string} value - The string to test
       * @return {boolean}
       */
      isDateQuery: function (value) {
        if (typeof value == "undefined" && this.get("values").length == 1) {
          var value = this.get("values")[0];
        }

        if (value) {
          return /[\d|\-|:|T]*Z TO [\d|\-|:|T]*Z/.test(value);
        } else {
          return false;
        }
      },

      /**
       * Check whether a set of query fields contain only fields that specify latitude and/or
       * longitude
       * @param {string[]} [fields] A list of fields to check for coordinate fields. If not
       * provided, the fields set on the model will be used.
       * @returns {Boolean} Returns true if every field is a field that specifies latitude or
       * longitude
       * @since 2.21.0
       */
      isCoordinateQuery: function (fields) {
        try {
          if (!fields) {
            fields = this.get("fields");
          }
          const latitudeFields = MetacatUI.appModel.get("queryLatitudeFields");
          const longitudeFields = MetacatUI.appModel.get(
            "queryLongitudeFields",
          );
          const coordinateFields = latitudeFields.concat(longitudeFields);
          return _.every(fields, function (field) {
            return _.contains(coordinateFields, field);
          });
        } catch (e) {
          console.log(
            "Error checking if filter is a coordinate filter. Returning false. ",
            e,
          );
          return false;
        }
      },

      /**
       * Check whether a set of query fields contain only fields that specify latitude
       * @param {string[]} [fields] A list of fields to check for coordinate fields. If not
       * provided, the fields set on the model will be used.
       * @returns {Boolean} Returns true if every field is a field that specifies latitude
       * @since 2.21.0
       */
      isLatitudeQuery: function (fields) {
        try {
          if (!fields) {
            fields = this.get("fields");
          }
          const latitudeFields = MetacatUI.appModel.get("queryLatitudeFields");
          return _.every(fields, function (field) {
            return _.contains(latitudeFields, field);
          });
        } catch (e) {
          console.log(
            "Error checking if filter is a latitude filter. Returning false. ",
            e,
          );
          return false;
        }
      },

      /**
       * Check whether a set of query fields contain only fields that specify longitude
       * @param {string[]} [fields] A list of fields to check for longitude fields. If not
       * provided, the fields set on the model will be used.
       * @returns {Boolean} Returns true if every field is a field that specifies longitude
       * @since 2.21.0
       */
      isLongitudeQuery: function (fields) {
        try {
          if (!fields) {
            fields = this.get("fields");
          }
          const longitudeFields = MetacatUI.appModel.get(
            "queryLongitudeFields",
          );
          return _.every(fields, function (field) {
            return _.contains(longitudeFields, field);
          });
        } catch (error) {
          console.log(
            "Error checking if filter is a longitude filter. Returning false. ",
            e,
          );
          return false;
        }
      },

      /**
       * Checks if the values set on this model are valid.
       * Some of the attributes are changed during this process if they are found to be invalid,
       * since there aren't any easy ways for users to fix these issues themselves in the UI.
       * @return {object} - Returns a literal object with the invalid attributes and their corresponding error message
       */
      validate: function () {
        try {
          var errors = {};
          // UI filter types have
          var isUIFilterType = this.get("isUIFilterType");

          //---Validate fields----
          var fields = this.get("fields");
          //All fields should be strings
          var nonStrings = _.filter(fields, function (field) {
            return typeof field != "string" || !field.trim().length;
          });

          if (nonStrings.length) {
            //Remove the nonstrings from the model, rather than returning an error
            this.set("fields", _.without(fields, nonStrings));
          }
          //If there are no fields, set an error message
          if (!this.get("fields").length) {
            errors.fields = "Filters should have at least one search field.";
          }

          //---Validate values----
          var values = this.get("values");
          // All values should be strings, booleans, numbers, or dates
          var invalidValues = _.filter(values, function (value) {
            //Empty strings are invalid
            if (typeof value == "string" && !value.trim().length) {
              return true;
            }
            //Non-empty strings, booleans, numbers, or dates are valid
            else if (
              typeof value == "string" ||
              typeof value == "boolean" ||
              typeof value == "number" ||
              Date.prototype.isPrototypeOf(value)
            ) {
              return false;
            }
          });

          if (invalidValues.length) {
            //Remove the invalid values from the model, rather than returning an error
            this.set("values", _.without(values, invalidValues));
          }

          //If there are no values, and this isn't a custom search filter, set an error
          //message.
          if (!isUIFilterType && !this.get("values").length) {
            errors.values = "Filters should include at least one search term.";
          }

          //---Validate operators ----
          //The operator must be either AND or OR
          ["operator", "fieldsOperator"].forEach(function (op) {
            if (!["AND", "OR"].includes(this.get(op))) {
              //Reset the value to the default rather than return an error
              this.set(op, this.defaults()[op]);
            }
          }, this);

          //---Validate exclude and matchSubstring----
          //Exclude should always be a boolean
          if (typeof this.get("exclude") != "boolean") {
            //Reset the value to the default rather than return an error
            this.set("exclude", this.defaults().exclude);
          }
          //matchSubstring should always be a boolean
          if (typeof this.get("matchSubstring") != "boolean") {
            //Reset the value to the default rather than return an error
            this.set("matchSubstring", this.defaults().matchSubstring);
          }

          //---Validate label, placeholder, icon, and description----
          var textAttributes = ["label", "placeholder", "icon", "description"];
          //These fields should be strings
          _.each(
            textAttributes,
            function (attr) {
              if (typeof this.get(attr) != "string") {
                //Reset the value to the default rather than return an error
                this.set(attr, this.defaults()[attr]);
              }
            },
            this,
          );

          if (Object.keys(errors).length) return errors;
          else {
            return;
          }
        } catch (e) {
          console.error(e);
        }
      },
    },
  );

  return FilterModel;
});