/* global define */
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;
});