define([
"jquery",
"underscore",
"backbone",
"collections/Filters",
"models/filters/Filter",
], function ($, _, Backbone, Filters, Filter) {
/**
* @class FilterGroup
* @classdesc A group of multiple Filters, and optionally nested Filter Groups, which
* may be combined to create a complex query. A FilterGroup may be a Collection
* FilterGroupType or a Portal UIFilterGroupType.
* @classcategory Models/Filters
* @extends Backbone.Model
* @constructs
*/
var FilterGroup = Backbone.Model.extend(
/** @lends FilterGroup.prototype */ {
/**
* The name of this Model
* @type {string}
* @readonly
*/
type: "FilterGroup",
/**
* Default attributes for FilterGroup models
* @type {Object}
* @property {string} label - For UIFilterGroupType filter groups, a
* human-readable short label for this Filter Group
* @property {string} description - For UIFilterGroupType filter groups, a
* description of the Filter Group's function
* @property {string} icon - For UIFilterGroupType filter groups, a term that
* identifies a single icon in a supported icon library.
* @property {Filters} filters - A collection of Filter models that represent a
* full or partial query
* @property {XMLElement} objectDOM - FilterGroup XML
* @property {string} operator - The operator to use between filters (including
* filter groups) set on this model. Must be set to "AND" or "OR".
* @property {boolean} exclude - If true, search index docs matching the filters
* within this group will be excluded from the search results
* @property {boolean} isUIFilterType - Set to true if this group is
* UIFilterGroupType (aka custom Portal search filter). Otherwise, it's assumed
* that this model is FilterGroupType (e.g. a Collection FilterGroupType)
* @property {string} nodeName - the XML node name to use when serializing this
* model. For example, may be "filterGroup" or "definition".
* @property {boolean} isInvisible - If true, this filter will be added to the
* query but will act in the "background", like a default filter. It will not
* appear in the Query Builder or other UIs. If this is invisible, then the
* "isInvisible" property on sub-filters will be ignored.
* @property {boolean} mustMatchIds - If the search results must always match one
* of the ids in the id filters, then the id filters will be added to the query
* with an AND operator.
*/
defaults: function () {
return {
label: null,
description: null,
icon: null,
filters: null,
objectDOM: null,
operator: "AND",
exclude: false,
isUIFilterType: false,
nodeName: "filterGroup",
isInvisible: false,
mustMatchIds: false,
// TODO: support options for UIFilterGroupType 1.1.0
// options: [],
};
},
/**
* This function is executed whenever a new FilterGroup model is created. Model
* attributes are set either by parsing attributes.objectDOM or ny extracting the
* properties from attributes (e.g. attributes.nodeName, attributes.operator, etc)
*/
initialize: function (attributes) {
if (!attributes) {
attributes = {};
}
if (attributes.isUIFilterType) {
this.set("isUIFilterType", true);
}
// When a Filter model within this Filter group changes, or when the Filters
// collection is updated, trigger a change event in this filterGroup model.
// Updates and Changes in the Filters collection won't trigger an event from
// this model otherwise. This helps when other models, collections, views are
// listening to this filterGroup, e.g. when the collections model updates the
// searchModel whenever the definition changes.
this.off("change:filters");
this.on(
"change:filters",
function () {
this.stopListening(this.get("filters"), "update change");
this.listenTo(
this.get("filters"),
"update change",
function (model, record) {
this.trigger("update", model, record);
},
);
},
this,
);
var newFiltersOptions = {};
var catalogSearch = false;
if (attributes.catalogSearch) {
this.set("catalogSearch", true);
}
// Set the attributes on this model by parsing XML if some was provided,
// or by using any attributes provided to this model
if (attributes.objectDOM) {
var groupAttrs = this.parse(attributes.objectDOM, catalogSearch);
this.set(groupAttrs);
} else {
[
"label",
"description",
"icon",
"operator",
"exclude",
"nodeName",
"isInvisible",
].forEach(function (modelAttribute) {
if (
attributes[modelAttribute] ||
attributes[modelAttribute] === false
) {
this.set(modelAttribute, attributes[modelAttribute]);
}
}, this);
}
if (attributes.filters) {
var filtersCollection = new Filters(null, newFiltersOptions);
filtersCollection.add(attributes.filters);
this.set("filters", filtersCollection);
}
// Start a new Filters collection if no filters were provided
if (!this.get("filters")) {
this.set("filters", new Filters(null, newFiltersOptions));
}
if (attributes.mustMatchIds) {
this.set("mustMatchIds", true);
this.get("filters").mustMatchIds = true;
}
// The operator must be AND or OR
if (!["AND", "OR"].includes(this.get("operator"))) {
// Set the value to the default
this.set("operator", this.defaults()["operator"]);
}
},
/**
* Overrides the default Backbone.Model.parse() function to parse the filterGroup
* XML snippet
*
* @param {Element} xml - The XML Element that contains all the FilterGroup elements
* @param {boolean} catalogSearch [false] - Set to true to append a catalog search phrase
* to the search query created from Filters that limits the results to un-obsoleted
* metadata.
* @return {JSON} The result of the parsed XML, in JSON. To be set directly on the model.
*/
parse: function (xml, catalogSearch = false) {
var modelJSON = {};
if (!xml) {
return modelJSON;
}
// FilterGroups can be either <definition> or <filterGroup>
this.set("nodeName", xml.nodeName);
// Parse all the text nodes. Node names and model attributes always match
// in this case.
["label", "description", "icon", "operator"].forEach(function (
nodeName,
) {
if ($(xml).find(nodeName).length) {
modelJSON[nodeName] = this.parseTextNode(xml, nodeName);
}
}, this);
// Parse the exclude field node (true or false)
if ($(xml).find("exclude").length) {
modelJSON.exclude =
this.parseTextNode(xml, "exclude") === "true" ? true : false;
}
// Remove any nodes that aren't filters or filter groups from the XML
var filterNodeNames = [
"filter",
"booleanFilter",
"dateFilter",
"numericFilter",
"filterGroup",
"choiceFilter",
"toggleFilter",
];
filterXML = xml.cloneNode(true);
$(filterXML).children().not(filterNodeNames.join(", ")).remove();
// Add the filters and nested filter groups to this filters model
// TODO: Add isNested property for filterGroups that are within filterGroups?
var filtersOptions = {
objectDOM: filterXML,
isUIFilterType: this.get("isUIFilterType"),
};
if (catalogSearch) {
filtersOptions.catalogSearch = true;
}
modelJSON.filters = new Filters(null, filtersOptions);
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) {
return node[0].textContent.trim();
}
//If more than one node is found, parse into an array
else {
return _.map(node, function (node) {
return node.textContent.trim() || null;
});
}
},
/**
* Builds the query string to send to the query engine. Iterates over each filter
* in the filter group and adds to the query string.
*
* @return {string} The query string to send to Solr
*/
getQuery: function () {
try {
// Although the logic used in this function is very similar to the getQuery()
// function in the Filters collection, we can't just use
// this.get("filters").getQuery(operator), because there are some subtle
// differences with how queries are built using the information from
// filterGroups, especially when the exclude attribute is set to true.
var queryString = "";
if (this.isEmpty()) {
return queryString;
}
// The operator to use between queries from filters/sub-filterGroups
var operator = this.get("operator");
// Helper function that adds URI encoded spaces to either side of a string
var padString = function (string) {
return "%20" + string + "%20";
};
// Helper function that appends a new part to a query fragment, using an
// operator if the initial fragment is not empty. Returns the string as-is if
// the newFragment is empty.
var addQueryFragment = function (string, newFragment, operator) {
if (
!newFragment ||
(newFragment && newFragment.trim().length == 0)
) {
return string;
}
if (string && string.trim().length) {
string += padString(operator);
}
string += newFragment;
return string;
};
// Helper function that wraps a string in parentheses
var wrapInParentheses = function (string) {
if (!string || (string && string.trim().length == 0)) {
return string;
}
// TODO: We still want to wrap in parentheses in cases like "(a) OR (b)" and
// "a OR (b) or c" but not in cases like (a OR b)
// var alreadyWrapped = /^\(.*\)$/.test(string);
// if (alreadyWrapped) {
// return string
// }
return "(" + string + ")";
};
// Get the list of filters that use id fields since these are used differently.
var idFilters = this.get("filters").getIdFilters();
// Get the remaining filters that don't contain any ID fields
var mainFilters = this.get("filters").getNonIdFilters();
// If the filterGroup should be excluded from the results, then don't include
// the isPartOf filter in the part of the query that gets excluded. The
// isPartOf filter is only meant to *include* additional results, never
// exclude any.
if (this.get("exclude")) {
var isPartOfFilter = null;
idFilters.forEach(function (filterModel, index) {
if (filterModel.get("fields")[0] == "isPartOf") {
idFilters.splice(index, 1);
isPartOfFilter = filterModel;
}
}, this);
}
// Create the grouped query for the id filters (this will have the isPartOf
// filter query if exclude is false, and will not have it if exclude is true)
var idFilterQuery = this.get("filters")
.getGroupQuery(idFilters, "OR")
.trim();
// Make the query fragment for all of the filters that do not contain ID fields
var mainQuery = this.get("filters")
.getGroupQuery(mainFilters, operator)
.trim();
// Make the query string that should be added to all catalog searches
var categoryQuery = "";
if (this.get("catalogSearch")) {
categoryQuery = this.get("filters")
.createCatalogSearchQuery()
.trim();
}
// Make the query string for the isPartOf filter when the filter group should
// be excluded
var isPartOfQuery = "";
if (isPartOfFilter) {
isPartOfQuery = isPartOfFilter.getQuery().trim();
}
if (this.get("exclude")) {
// The query is constructed like so for filter groups with exclude set to true:
// ( ( -( mainQuery OR idFilterQuery ) AND *:* ) OR isPartOfQuery ) AND categoryQuery
// Build the query string piece by piece:
// 1. mainQuery
queryString += mainQuery;
queryString = wrapInParentheses(queryString);
// 2. ( mainQuery OR idFilterQuery )
if (idFilterQuery.trim().length) {
idOperator = this.get("mustMatchIds") ? "AND" : "OR";
queryString = addQueryFragment(
queryString,
idFilterQuery,
idOperator,
);
queryString = wrapInParentheses(queryString);
}
// 3. -( mainQuery OR idFilterQuery )
if (queryString.trim().length) {
queryString = "-" + queryString;
}
// 4. ( -( mainQuery OR idFilterQuery ) AND *:* ) - see Filter model
// requiresPositiveClause for details on why positive clause is
// needed here
if (queryString.trim().length) {
queryString = addQueryFragment(queryString, "*:*", "AND");
queryString = wrapInParentheses(queryString);
}
// 5. ( ( -( mainQuery OR idFilterQuery ) AND *:* ) OR isPartOfQuery)
if (isPartOfQuery) {
queryString = addQueryFragment(queryString, isPartOfQuery, "OR");
queryString = wrapInParentheses(queryString);
}
// 6. (-( mainQuery OR idFilterQuery ) AND *:* OR isPartOfQuery) AND
// categoryQuery
queryString = addQueryFragment(queryString, categoryQuery, "AND");
} else {
// The query is constructed like so for filter groups with exclude set to
// false: ( mainQuery OR idFilterQuery ) AND catalogQuery where
// idFilterQuery includes the isPartOfQuery
// 1. mainQuery
queryString += mainQuery;
queryString = wrapInParentheses(queryString);
// 2. ( mainQuery OR idFilterQuery )
if (idFilterQuery.trim().length) {
queryString = addQueryFragment(queryString, idFilterQuery, "OR");
queryString = wrapInParentheses(queryString);
}
// 3. ( mainQuery OR idFilterQuery ) AND catalogQuery
queryString = addQueryFragment(queryString, categoryQuery, "AND");
}
return queryString;
} catch (error) {
console.log(
"Error creating a query for a Filter Group, error details:" + error,
);
}
},
/**
* Overrides the default Backbone.Model.validate.function() to check if this
* FilterGroup model has all the required values.
*
* @param {Object} [attrs] - A literal object of model attributes to validate.
* @param {Object} [options] - A literal object of options for this validation
* process
* @return {Object} If there are errors, an object comprising error messages. If
* no errors, returns nothing.
*/
validate: function () {
try {
var errors = {};
// The operator must be AND or OR
if (!["AND", "OR"].includes(this.get("operator"))) {
//Reset the value to the default rather than return an error
this.set("operator", this.defaults()["operator"]);
}
//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);
}
// Validate label, icon, and description for UI Filter Groups
if (this.get("isUIFilterType")) {
var textAttributes = ["label", "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 this filter group is not empty, and it's a UI Filter Group, then
// the group needs a label to be valid.
if (!this.isEmpty() && !this.get("label")) {
// Set a generic label instead of returning an error
this.set("label", "Search");
}
}
// There must be at least one filter or filter group within each group,
// and each filter must be valid.
if (this.get("filters").length == 0) {
errors.noFilters = "At least one filter is required.";
} else {
this.get("filters").each(function (filter) {
if (!filter.isValid()) {
errors.filter = "At least one filter is invalid.";
}
});
}
if (Object.keys(errors).length) {
return errors;
} else {
return;
}
} catch (error) {
console.log(
"Error validating a FilterGroup. Error details: " + error,
);
}
},
/**
* isEmpty - Checks whether this Filter Group has any filter models that are not
* empty.
*
* @return {boolean} returns true if the Filter Group has Filter models that are
* not empty
*/
isEmpty: function () {
try {
var filters = this.get("filters");
if (!filters || !filters.length) {
return true;
}
var subFilters = filters.getNonEmptyFilters();
if (!subFilters || !subFilters.length) {
return true;
} else {
return false;
}
} catch (error) {
console.log(
"Error checking if a Filter Group is empty. Assuming it is not." +
" Error details: " +
error,
);
return false;
}
},
/**
* Updates the XML DOM with the new values from the model
* @param {object} [options] A literal object with options for this serialization
* @return {XMLElement} An updated filterGroup XML element
*/
updateDOM: function (options) {
try {
// Don't serialize an empty filter group
if (this.isEmpty()) {
return null;
}
// Clone the DOM if it exists
var objectDOM = this.get("objectDOM");
if (objectDOM) {
objectDOM = objectDOM.cloneNode(true);
} else {
// Create an XML filterGroup or definition element from scratch
if (!objectDOM) {
var name = this.get("nodeName");
objectDOM = new DOMParser().parseFromString(
"<" + name + "></" + name + ">",
"text/xml",
);
objectDOM = $(objectDOM).find(name)[0];
}
}
$(objectDOM).empty();
// label, description, and icon are elements that are used in Portal
// UIFilterGroupType filterGroups only. Collection FilterGroupType filterGroups
// do not use these elements.
if (this.get("isUIFilterType")) {
// Get the new values for the simple text elements
var filterGroupData = {
label: this.get("label"),
description: this.get("description"),
icon: this.get("icon"),
};
// Serialize the simple text elements
_.map(filterGroupData, function (value, nodeName) {
// Don't serialize falsey values
if (value) {
// Make new sub-node
var nodeSerialized =
objectDOM.ownerDocument.createElement(nodeName);
$(nodeSerialized).text(value);
// Append new sub-node to objectDOM
$(objectDOM).append(nodeSerialized);
}
});
}
// Serialize the filters
var filterModels = this.get("filters").models;
// TODO: Remove filter types depending on isUIFilterType attribute?
// toggleFilter and choiceFilter are only allowed in Portal UIFilterGroupType.
// nested filterGroups are only allowed in Collection FilterGroupType.
// Don't serialize falsey values
if (filterModels && filterModels.length) {
// Update each filter and append it to the DOM
_.each(filterModels, function (filterModel) {
if (filterModel) {
var filterModelSerialized = filterModel.updateDOM();
}
$(objectDOM).append(filterModelSerialized);
});
}
// exclude and operator are elements used only in Collection FilterGroupType
// filterGroups. Portal UIFilterGroupType filterGroups do not use either of
// these elements.
if (!this.get("isUIFilterType")) {
// The nodeName and model attribute are the same in these cases.
["operator", "exclude"].forEach(function (nodeName) {
// Don't serialize empty, null, undefined, or default values
var value = this.get(nodeName);
if (
(value || value === false) &&
value !== this.defaults()[nodeName]
) {
// Make new sub-node
var nodeSerialized =
objectDOM.ownerDocument.createElement(nodeName);
$(nodeSerialized).text(value);
// Append new sub-node to objectDOM
$(objectDOM).append(nodeSerialized);
}
}, this);
}
// TODO: serialize the new <option> elements supported for Portal
// UIFilterGroupType 1.1.0
// if(this.get("isUIFilterType")){
// ... serialize options ...
// }
return objectDOM;
} catch (error) {
console.error("Unable to serialize a Filter Group.", error);
return this.get("objectDOM") || "";
}
},
},
);
return FilterGroup;
});