define([
"jquery",
"underscore",
"backbone",
"models/filters/Filter",
"models/filters/BooleanFilter",
"models/filters/ChoiceFilter",
"models/filters/DateFilter",
"models/filters/NumericFilter",
"models/filters/ToggleFilter",
"models/filters/SpatialFilter",
], function (
$,
_,
Backbone,
Filter,
BooleanFilter,
ChoiceFilter,
DateFilter,
NumericFilter,
ToggleFilter,
SpatialFilter,
) {
"use strict";
/**
* @class Filters
* @classdesc A collection of Filter models that represents a full search
* @classcategory Collections
* @name Filters
* @extends Backbone.Collection
* @constructor
*/
var Filters = Backbone.Collection.extend(
/** @lends Filters.prototype */ {
/**
* The name of this type of collection
* @type {string}
* @since 2.25.0
* @default "Filters"
*/
type: "Filters",
/**
* 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.
* @type {boolean}
*/
mustMatchIds: false,
/**
* Function executed whenever a new Filters collection is created.
* @param
* {Filter|BooleanFilter|ChoiceFilter|DateFilter|NumericFilter|ToggleFilter|FilterGroup[]}
* models - Array of filter or filter group models to add to this creation
* @param {Object} [options] -
* @property {boolean} isUIFilterType - Set to true to indicate that these
* filters or filterGroups are part of a UIFilterGroup (aka custom Portal
* search filter). Otherwise, it's assumed that this model is in a
* Collection model definition.
* @property {XMLElement} objectDOM - A FilterGroupType or
* UIFilterGroupType XML element from a portal or collection document. If
* provided, the XML will be parsed and the Filters models extracted
* @property {boolean} catalogSearch - If set to true, a catalog search
* phrase will be appended to the search query that limits the results to
* un-obsoleted metadata.
*/
initialize: function (models, options) {
try {
if (typeof options === "undefined") {
var options = {};
}
if (options && options.objectDOM) {
// Models are automatically added to the collection by the parse
// function.
var isUIFilterType = options.isUIFilterType == true ? true : false;
this.parse(options.objectDOM, isUIFilterType);
}
if (options.catalogSearch) {
this.catalogSearchQuery = this.createCatalogSearchQuery();
}
} catch (error) {
console.log(
"Error initializing a Filters collection. Error details: " + error,
);
}
},
/**
* Creates the type of Filter Model based on the given filter type. This
* function is typically not called directly. It is used by Backbone.js
* when adding a new model to the collection.
* @param {object} attrs - A literal object that contains the attributes
* to pass to the model
* @property {string} attrs.filterType - The type of Filter to create
* @property {XMLElement} attrs.objectDOM - The Filter XML
* @param {object} options - A literal object of additional options to
* pass to the model
* @returns
* {Filter|BooleanFilter|ChoiceFilter|DateFilter|NumericFilter|ToggleFilter|FilterGroup}
*/
model: function (attrs, options) {
// Get the model type
var type = "";
// If no filterType was specified, but an objectDOM exists (from parsing
// a Collection or Portal document), get the filter type from the
// objectDOM node name
if (!attrs.filterType && attrs.objectDOM) {
type = attrs.objectDOM.nodeName;
} else if (attrs.filterType) {
type = attrs.filterType;
} else if (attrs.nodeName) {
type = attrs.nodeName;
}
// Ignoring the case of the type allows using either the filter type
// (e.g. BooleanFilter) or the nodeName value (e.g. "booleanFilter")
type = type.toLowerCase();
switch (type) {
case "booleanfilter":
return new BooleanFilter(attrs, options);
case "datefilter":
return new DateFilter(attrs, options);
case "numericfilter":
return new NumericFilter(attrs, options);
case "filtergroup":
// We must initialize a Filter Group using the inline require syntax
// to avoid the problem of circular dependencies. Filters requires
// Filter Groups, and Filter Groups require Filters. For more info,
// see https://requirejs.org/docs/api.html#circular
var FilterGroup = require("models/filters/FilterGroup");
var newFilterGroup = new FilterGroup(attrs, options);
return newFilterGroup;
case "choicefilter":
return new ChoiceFilter(attrs, options);
case "togglefilter":
return new ToggleFilter(attrs, options);
case "spatialfilter":
return new SpatialFilter(attrs, options);
default:
return new Filter(attrs, options);
}
},
/**
* Parses a <filterGroup> or <definition> element from a collection or
* portal document and sets the resulting models on this collection.
*
* @param {XMLElement} objectDOM - A FilterGroupType or UIFilterGroupType
* XML element from a portal or collection document
* @param {boolean} isUIFilterType - Set to true to indicate that these
* filters or filterGroups are part of a UIFilterGroup (aka custom Portal
* search filter). Otherwise, it's assumed that the filters are part of a
* Collection model definition.
* @return {JSON} The result of the parsed XML, in JSON.
*/
parse: function (objectDOM, isUIFilterType) {
var filters = this;
$(objectDOM)
.children()
.each(function (i, filterNode) {
filters.add({
objectDOM: filterNode,
isUIFilterType: isUIFilterType == true ? true : false,
});
});
return filters.toJSON();
},
/**
* Builds the query string to send to the query engine. Iterates over each
* filter in the collection and adds to the query string.
*
* @param {string} [operator=AND] The operator to use to combine multiple
* filters in this filter group. Must be AND or OR.
* @return {string} The query string to send to Solr
*/
getQuery: function (operator = "AND") {
// The complete query string that eventually gets returned
var completeQuery = "";
// Ensure that the operator is AND or OR so that the query string will
// be valid. Default to AND.
if (typeof operator !== "string") {
var operator = "AND";
}
operator = operator.toUpperCase();
if (!["AND", "OR"].includes(operator)) {
operator = "AND";
}
// Adds URI encoded spaces to either side of a string
var padString = function (string) {
return "%20" + string + "%20";
};
// Get the list of filters that use id fields since these are used
// differently.
var idFilters = this.getIdFilters();
// Get the remaining filters that don't contain any ID fields
var mainFilters = this.getNonIdFilters();
// Create the grouped query for the id filters
var idFilterQuery = this.getGroupQuery(idFilters, "OR");
// Make a query for all of the filters that do not contain ID fields
var mainQuery = this.getGroupQuery(mainFilters, operator);
// First add the query string built from the non-ID filters
completeQuery += mainQuery;
// Then add the Data Catalog filters if Filters was initialized with the
// catalogSearch = true option. Filters that are used in the data
// catalog are treated specially
if (this.catalogSearchQuery && this.catalogSearchQuery.length) {
// If there are other filters besides the catalog filters, AND the
// catalog filters to the end of the query for the other filters,
// regardless of which operator this function uses to combine other
// filters.
if (completeQuery && completeQuery.trim().length) {
completeQuery += padString("AND");
}
completeQuery += this.catalogSearchQuery;
}
// Finally, add the ID filters to the very end of the query. This is
// done so that the query string is constructed with these filters
// "OR"ed into the query. For example, a query might be to look for
// datasets by a certain scientist OR with the given id. If those
// filters were ANDed together, the search would essentially ignore the
// creator filter and only return the dataset with the matching id.
if (idFilterQuery && idFilterQuery.trim().length) {
if (completeQuery && completeQuery.trim().length) {
// If the search results must always match one of the ids in the id
// filters, then add the id filters to the query with the AND
// operator. This flag is set on this Collection. Otherwise, use the
// OR operator
var idOperator = this.mustMatchIds
? padString("AND")
: padString("OR");
completeQuery =
"(" + completeQuery + ")" + idOperator + idFilterQuery;
} else {
// If the query is ONLY made of id filters, then the id filter query
// is the complete query
completeQuery += idFilterQuery;
}
}
// Return the completed query
return completeQuery;
},
/**
* Searches the Filter models in this collection and returns any that have
* at least one field that matches any of the ID query fields, such as by
* id, seriesId, or the isPartOf relationship.
* @returns
* {Filter|BooleanFilter|ChoiceFilter|DateFilter|NumericFilter|ToggleFilter|FilterGroup[]}
* Returns an array of filter models that include at least one ID field
*/
getIdFilters: function () {
try {
return this.filter(function (filterModel) {
if (typeof filterModel.isIdFilter == "undefined") {
return false;
}
return filterModel.isIdFilter();
});
} catch (error) {
console.log(
"Error trying to find ID Filters, error details: " + error,
);
}
},
/**
* Searches the Filter models in this collection and returns all have no
* fields matching any of the ID query fields.
* @returns
* {Filter|BooleanFilter|ChoiceFilter|DateFilter|NumericFilter|ToggleFilter|FilterGroup[]}
* Returns an array of filter models that do not include any ID fields
*/
getNonIdFilters: function () {
try {
return this.difference(this.getIdFilters());
} catch (error) {
console.log(
"Error trying to find non-ID Filters, error details: " + error,
);
}
},
/**
* Get a query string for a group of Filters. The Filters will be ANDed
* together, unless a different operator is given.
* @param
* {Filter|BooleanFilter|ChoiceFilter|DateFilter|NumericFilter|ToggleFilter|FilterGroup[]}
* filterModels - The Filters to turn into a query string
* @param {string} [operator="AND"] - The operator to use between filter
* models
* @return {string} The query string
*/
getGroupQuery: function (filterModels, operator = "AND") {
try {
if (
!filterModels ||
!filterModels.length ||
!this.getNonEmptyFilters(filterModels)
) {
return "";
}
//Start an array to contain the query fragments
var groupQueryFragments = [];
//For each Filter in this group, get the query string
_.each(
filterModels,
function (filterModel) {
// Get the Solr query string from this model. Pass on the group
// operator so that we can detect whether this filter query needs
// a positive clause in case it has exclude set to true.
var filterQuery = filterModel.getQuery(operator);
//Add the filter query string to the overall array
if (filterQuery && filterQuery.length > 0) {
groupQueryFragments.push(filterQuery);
}
},
this,
);
//Join this group's query fragments with an OR operator
if (groupQueryFragments.length) {
var queryString = groupQueryFragments.join(
"%20" + operator + "%20",
);
if (groupQueryFragments.length > 1) {
queryString = "(" + queryString + ")";
}
return queryString;
}
//Otherwise, return an empty string
else {
return "";
}
} catch (e) {
console.log(
"Error creating a group query, returning a blank string. ",
e,
);
return "";
}
},
/**
* Given a Solr field name, determines if that field is set as a filter
* option
*/
filterIsAvailable: function (field) {
var matchingFilter = this.find(function (filterModel) {
return _.contains(filterModel.fields, field);
});
if (matchingFilter) {
return true;
} else {
return false;
}
},
/*
* Returns an array of filter models in this collection that have a value
* set
*
* @return {Array} - an array of filter models in this collection that
* have a value set
*/
getCurrentFilters: function () {
var currentFilters = new Array();
this.each(function (filterModel) {
//If the filter model has values set differently than the default AND
// it is not an invisible filter, then add it to the current filters
// array
if (
!filterModel.get("isInvisible") &&
((Array.isArray(filterModel.get("values")) &&
filterModel.get("values").length &&
_.difference(
filterModel.get("values"),
filterModel.defaults().values,
).length) ||
(!Array.isArray(filterModel.get("values")) &&
filterModel.get("values") !== filterModel.defaults().values))
) {
currentFilters.push(filterModel);
}
});
return currentFilters;
},
/**
* Get all filters in this collection that are of a given filter type.
* @param {string} type - The filter type to get, e.g. "BooleanFilter". If
* not set, all filters will be returned.
* @returns {Filter[]} An array of filter models
*/
getAllOfType: function (type) {
if (!type) {
return this.models;
}
return this.filter(function (filterModel) {
return filterModel.get("filterType") == type;
});
},
/**
* Returns the geohash levels that are set on any SpatialFilter models in
* this collection. If no SpatialFilter models are found, or no geohash
* levels are set, an empty array is returned.
* @returns {string[]} An array of geohash levels in the format
* ["geohash_1", "geohash_2", ...]
*/
getGeohashLevels: function () {
const spFilters = this.getAllOfType("SpatialFilter");
if (!spFilters.length) {
return [];
}
return _.uniq(
_.flatten(
_.map(spFilters, function (spFilter) {
const fields = spFilter.get("fields");
if (fields && fields.length) {
return _.filter(fields, function (field) {
return field.indexOf("geohash") > -1;
});
}
}),
),
);
},
/*
* Clear the values of all geohash-related models in the collection
*/
resetGeohash: function () {
//Find all the filters in this collection that are related to geohashes
this.each(function (filterModel) {
if (
!filterModel.get("isInvisible") &&
(filterModel.type == "SpatialFilter" ||
_.intersection(filterModel.fields, [
"geohashes",
"geohashLevel",
"geohashGroups",
]).length)
) {
filterModel.resetValue();
}
});
},
/**
* Create a partial query string that's required for catalog searches
* @returns {string} - Returns the query string fragment for a catalog
* search
*/
createCatalogSearchQuery: function () {
var catalogFilters = new Filters([
{
fields: ["obsoletedBy"],
values: ["*"],
exclude: true,
},
{
fields: ["formatType"],
values: ["METADATA"],
matchSubstring: false,
},
{
fields: ["formatId"],
values: ["dataone.org/collections", "dataone.org/portals"],
exclude: true,
matchSubstring: true,
operator: "OR",
},
]);
var query = catalogFilters.getGroupQuery(catalogFilters.models, "AND");
return query;
},
/**
* Creates and adds a Filter to this collection that filters datasets to
* only those that the logged-in user has permission to change permission
* of.
*/
addOwnershipFilter: function () {
if (MetacatUI.appUserModel.get("loggedIn")) {
//Filter datasets by their ownership
this.add({
fields: ["rightsHolder", "changePermission"],
values: MetacatUI.appUserModel.get("allIdentitiesAndGroups"),
operator: "OR",
fieldsOperator: "OR",
matchSubstring: false,
exclude: false,
});
}
},
/**
* Creates and adds a Filter to this collection that filters datasets to
* only those that the logged-in user has permission to write to.
*/
addWritePermissionFilter: function () {
if (MetacatUI.appUserModel.get("loggedIn")) {
//Filter datasets by their ownership
this.add({
fields: ["rightsHolder", "writePermission", "changePermission"],
values: MetacatUI.appUserModel.get("allIdentitiesAndGroups"),
operator: "OR",
fieldsOperator: "OR",
matchSubstring: false,
exclude: false,
});
}
},
/**
* Removes Filter models from this collection if they match the given
* field
* @param {string} field - The field whose matching filters that should be
* removed from this collection
*/
removeFiltersByField: function (field) {
var toRemove = [];
this.each(function (filter) {
if (filter.get && filter.get("fields").includes(field)) {
toRemove.push(filter);
}
});
this.remove(toRemove);
},
/**
* Remove filters from the collection that are lacking fields, values, and
* in the case of a numeric filter, a min and max value.
* @param {boolean} [recursive=false] - Set to true to also remove empty
* filters from within any and all nested filterGroups.
*/
removeEmptyFilters: function (recursive = false) {
try {
var toRemove = this.difference(this.getNonEmptyFilters());
this.remove(toRemove);
if (recursive) {
var nestedGroups = this.filter(function (filterModel) {
return filterModel.type == "FilterGroup";
});
if (nestedGroups) {
nestedGroups.forEach(function (filterGroupModel) {
filterGroupModel.get("filters").removeEmptyFilters(true);
});
}
}
} catch (e) {
console.log(
"Failed to remove empty Filter models from the Filters collection, error message: " +
e,
);
}
},
/**
* getNonEmptyFilters - Returns the array of filters that are not empty
* @return
* {Filter|BooleanFilter|ChoiceFilter|DateFilter|NumericFilter|ToggleFilter|FilterGroup[]}
* returns an array of Filter or FilterGroup models that are not empty
*/
getNonEmptyFilters: function () {
try {
return this.filter(function (filterModel) {
return !filterModel.isEmpty();
});
} catch (e) {
console.log(
"Failed to remove empty Filter models from the Filters collection, error message: " +
e,
);
}
},
/**
* Remove a Filter from the Filters collection silently, and replace it
* with a new model.
*
* @param {Filter} model The model to replace
* @param {object} newAttrs Attributes for the replacement model. Use the
* filterType attribute to replace with a different type of Filter.
* @return {Filter} Returns the replacement Filter model, which
* is already part of the Filters collection.
*/
replaceModel: function (model, newAttrs) {
try {
var index = this.indexOf(model),
oldModelId = model.cid;
this.remove(oldModelId, { silent: true });
var newModel = this.add(newAttrs, { at: index });
return newModel;
} catch (e) {
console.log(
"Failed to replace a Filter model in a Filters collection, " + e,
);
}
},
/**
* visibleIndexOf - Get the index of a given model, excluding any filters
* that are marked as invisible.
*
* @param {Filter|BooleanFilter|NumericFilter|DateFilter} model The
* filter model for which to get the visible index
* @return {number} An integer representing the filter model's position in
* the list of visible filters.
*/
visibleIndexOf: function (model) {
try {
// Don't count invisible filters in the index we display to the user
var visibleFilters = this.filter(function (filterModel) {
var isInvisible = filterModel.get("isInvisible");
return typeof isInvisible == "undefined" || isInvisible === false;
});
return _.indexOf(visibleFilters, model);
} catch (e) {
console.log(
"Failed to get the index of a Filter within the collection of visible Filters, error message: " +
e,
);
}
},
},
);
return Filters;
});