define([
"jquery",
"underscore",
"backbone",
"collections/Filters",
"collections/queryFields/QueryFields",
"views/searchSelect/SearchableSelectView",
"views/queryBuilder/QueryRuleView",
"text!templates/queryBuilder/queryBuilder.html",
], function (
$,
_,
Backbone,
Filters,
QueryFields,
SearchableSelect,
QueryRule,
Template,
) {
/**
* @class QueryBuilderView
* @classdesc A view that provides a UI for users to construct a complex search
* through the DataONE Solr index
* @classcategory Views/QueryBuilder
* @screenshot views/QueryBuilderView.png
* @extends Backbone.View
* @constructor
* @since 2.14.0
*/
var QueryBuilderView = Backbone.View.extend(
/** @lends QueryBuilderView.prototype */
{
/**
* The type of View this is
* @type {string}
*/
type: "QueryBuilderView",
/**
* The HTML class names for this view element
* @type {string}
*/
className: "query-builder",
/**
* A JQuery selector for the element in the template that will contain the query
* rules
* @type {string}
*/
rulesContainerSelector: ".rules-container",
/**
* An ID for the element in the template that a user should click to add a new
* rule. A unique ID will be appended to this ID, and the ID will be added to the
* template.
* @type {string}
* @since 2.17.0
*/
addRuleButtonID: "add-rule-",
/**
* An ID for the element in the template that a user should click to add a new
* rule group. A unique ID will be appended to this ID, and the ID will be added
* to the template.
* @type {string}
* @since 2.17.0
*/
addRuleGroupButtonID: "add-rule-group-",
/**
* A JQuery selector for the element in the template that will contain the input
* allowing a user to switch the exclude attribute from "include" to "exclude"
* (i.e. to switch between exclude:false and exclude:true in the filterGroup
* model.)
* @type {string}
* @since 2.17.0
*/
excludeInputSelector: ".exclude-input",
/**
* A JQuery selector for the element in the template that will contain the input
* allowing a user to switch the operator from "all" to "any" (i.e. to switch
* between operator:"AND" and exclude:"OR" in the filterGroup model.)
* @type {string}
* @since 2.17.0
*/
operatorInputSelector: ".operator-input",
/**
* The maximum number of levels nested Rule Groups (i.e. nested FilterGroup
* models) that a user is permitted to *build* in the Query Builder. If a
* Portal/Collection document is loaded into the Query Builder that has more than
* the maximum allowable nested levels, those levels will still be displayed. This
* only prevents the "Add Rule Group" button from being shown.
* @type {number}
* @since 2.17.0
*/
nestedLevelsAllowed: 1,
/**
* An array of hex color codes used to help distinguish between different rules
* @type {string[]}
*/
ruleColorPalette: [
"#44AA99",
"#137733",
"#c9a538",
"#CC6677",
"#882355",
"#AA4499",
"#332288",
],
/**
* Query fields to exclude in the metadata field selector of each Query Rule. This
* is a list of field names that exist in the query service index (i.e. Solr), but
* which should be hidden in the Query Builder
* @type {string[]}
*/
excludeFields: [],
/**
* Query fields to exclude in the metadata field selector for any Query Rules that
* are in nested Query Builders (i.e. in nested Filter Groups). This is a list of
* field names that exist in the query service index (i.e. Solr), but which should
* be hidden in nested Query Builders
* @type {string[]}
*/
nestedExcludeFields: [],
/**
* Query fields that do not exist in the query service index, but which we would
* like to show as options in the Query Builder field input.
*
* @type {SpecialField[]}
*
* @since 2.15.0
*/
specialFields: [],
/**
* A Filters collection that stores filters to be edited with this Query Builder,
* e.g. the definitionFilters in a Collection or Portal model. If a filterGroup is
* set, then collection doesn't necessarily need to be set, as the Filters
* collection from within the FilterGroup model will automatically be set on view.
* @type {Filters}
*/
collection: null,
/**
* The FilterGroup model that stores the filters, the exclude attribute, and the
* group operator to be edited with this Query Builder. This does not need to be
* set; just a Filters collection can be set on the view instead, but then there
* will be no input to switch between the include & exclude and any & all, since
* these are the exclude and operator attributes on the filterGroup model.
* @type {FilterGroup}
* @since 2.17.0
*/
filterGroup: null,
/**
* The primary HTML template for this view
* @type {Underscore.template}
*/
template: _.template(Template),
/**
* events - A function that specifies a set of DOM events that will be bound to
* methods on your View through Backbone.delegateEvents.
* @see {@link https://backbonejs.org/#View-events}
*
* @return {Object} The events hash
*/
events: function () {
try {
var events = {};
var addRuleAction = "click #" + this.addRuleButtonID + this.cid;
events[addRuleAction] = "addQueryRule";
var addRuleGroupAction =
"click #" + this.addRuleGroupButtonID + this.cid;
events[addRuleGroupAction] = "addQueryRuleGroup";
return events;
} catch (e) {
console.error(
"Failed to specify events for the Query Builder View," +
" error message: " +
e,
);
}
},
/**
* The list of QueryRuleViews that are contained within this queryBuilder
* @type {QueryRuleView[]}
*/
rules: [],
/**
* Creates a new QueryBuilderView
* @param {Object} options - A literal object with options to pass to the view
*/
initialize: function (options) {
try {
// Get all the options and apply them to this view
if (typeof options == "object") {
var optionKeys = Object.keys(options);
_.each(
optionKeys,
function (key, i) {
this[key] = options[key];
},
this,
);
}
// If neither a Filters collection nor a FilterGroup model is provided in the
// options for this view, then create a new FilterGroup model and set it on
// the view.
if (!this.collection && !this.filterGroup) {
this.filterGroup = new FilterGroup();
}
// If there is a FilterGroup model set, but no Filters collection, then use
// the Filters from within the FilterGroup model as the Filters collection.
if (!this.collection && this.filterGroup) {
this.collection = this.filterGroup.get("filters");
}
} catch (e) {
console.error(
"Failed to initialize the Query Builder view, error message:",
e,
);
}
},
/**
* render - Render the view
*
* @return {QueryBuilder} Returns the view
*/
render: function () {
try {
// Ensure the query fields are cached for the Query Field Select View and the
// Query Rule View
if (
typeof MetacatUI.queryFields === "undefined" ||
MetacatUI.queryFields.length === 0
) {
MetacatUI.queryFields = new QueryFields();
this.listenToOnce(MetacatUI.queryFields, "sync", this.render);
MetacatUI.queryFields.fetch();
return;
}
// Insert the template into the view
this.$el.html(
this.template({
addRuleButtonID: this.addRuleButtonID + this.cid,
addRuleGroupButtonID: this.addRuleGroupButtonID + this.cid,
}),
);
// Nested Query Builders are used to display nested filterGroup models.
// They need to be styled slightly different from the parent Query Builder.
if (this.parentRule) {
this.$el.addClass("nested");
}
// Remove the rule group button ID if no more nested Query Builders are
// allowed.
if (
typeof this.nestedLevelsAllowed == "number" &&
this.nestedLevelsAllowed < 1
) {
this.$el.find("#" + this.addRuleGroupButtonID + this.cid).remove();
}
// Save the rules container element to the view before we add any nested
// QueryBuilders (nested FilterGroups), since their rules container uses the
// same selector.
this.rulesContainer = this.$el.find(this.rulesContainerSelector);
// If there is a FilterGroup model set on this view (not just a Filters
// collection) then render the inputs that allow a user to edit the "exclude"
// and "operator" attributes
if (this.filterGroup) {
this.renderExcludeOperatorInputs();
}
// Add a row for each rule that exists already in the model
if (
this.collection &&
this.collection.models &&
this.collection.models.length
) {
this.collection.models.forEach(function (model) {
this.addQueryRule(model);
}, this);
}
// Render a new Query Rule at the end
this.addQueryRule();
return this;
} catch (e) {
console.error(
"Failed to render a Query Builder view, error message: ",
e,
);
}
},
/**
* Insert two inputs: one that allows the user to edit the "exclude" attribute in
* the FilterGroup model by selecting either "include" or "exclude"; and a second
* that allows the user to edit the "operator" attribute in the FilterGroup model
* by selecting between "all" and "any".
* @since 2.17.0
*/
renderExcludeOperatorInputs: function () {
try {
if (!this.filterGroup) {
console.log(
"A filterGroup model is required to edit the exclude and " +
"operator attributes in a Query Builder View.",
);
return;
}
// Select the elements in the template where the two inputs should be inserted
var excludeContainer = this.$el.find(this.excludeInputSelector);
var operatorContainer = this.$el.find(this.operatorInputSelector);
// Create the exclude input
var excludeInput = new SearchableSelect({
options: [
{
label: "Include",
value: "false",
description:
"Include all datasets with metadata that matches the rules" +
" that are set below.",
},
{
label: "Exclude",
value: "true",
description:
"Match any dataset except those with metadata that match" +
" the rules that are set below",
},
],
allowMulti: false,
allowAdditions: false,
inputLabel: "",
selected: [this.filterGroup.get("exclude").toString()],
clearable: false,
});
// Create the operator input
var operatorInput = new SearchableSelect({
options: [
{
label: "all",
value: "AND",
description:
"For a dataset to match, it must have metadata that " +
"matches every rule set below.",
},
{
label: "any",
value: "OR",
description:
"For a dataset to match, its metadata only needs to " +
"match one of the rules set below.",
},
],
allowMulti: false,
allowAdditions: false,
inputLabel: "",
selected: [this.filterGroup.get("operator")],
clearable: false,
});
// Update the FilterGroup model when the user changes the operator or exclude
// options. newValues will always be an Array, but since these inputs don't
// allow multiple selections (allowMulti: false), then there will only ever be
// one value.
this.stopListening(excludeInput);
this.listenTo(excludeInput, "changeSelection", function (newValues) {
// Convert the string (necessary to be used as a value in SearchableSelect)
// to a boolean. It should be "true" or "false".
var newExclude = newValues[0] == "true";
this.filterGroup.set("exclude", newExclude);
});
this.stopListening(operatorInput);
this.listenTo(operatorInput, "changeSelection", function (newValues) {
this.filterGroup.set("operator", newValues[0]);
});
// Render the inputs and insert them into the view. Replace the default text
// within the containers otherwise.
excludeContainer.html(excludeInput.render().el);
operatorContainer.html(operatorInput.render().el);
} catch (error) {
console.log(
"There was a problem rendering the exclude and operator " +
"inputs in a QueryBuilderView, error details: " +
error,
);
}
},
/**
* Appends a new row (Query Rule View) to the end of the Query Builder
*
* @param {Filter|FilterGroup} filterModel The Filter model or FilterGroup model
* for which to create a rule. If none is provided, then a Filter group model
* will be created and added to the collection.
*/
addQueryRule: function (filterModel) {
try {
// Ensure that the object passed to this function is a filter. When the "add
// rule" button is clicked, the Event object is passed to this function
// instead. If no filter model is provided, assume that this is a new rule
if (
!filterModel ||
(filterModel && !/filter/i.test(filterModel.type))
) {
filterModel = this.collection.add({
nodeName: "filter",
operator: "OR",
fieldsOperator: "OR",
});
}
// Don't show invisible rules
if (filterModel.get("isInvisible")) {
return;
}
// insert QueryRuleView
var rule = new QueryRule({
model: filterModel,
ruleColorPalette: this.ruleColorPalette,
excludeFields: this.excludeFields,
nestedExcludeFields: this.nestedExcludeFields,
specialFields: this.specialFields,
parentRule: this.parentRule,
nestedLevelsAllowed: this.nestedLevelsAllowed,
});
// Insert and render the rule
this.rulesContainer.append(rule.el);
rule.render();
// Add the rule to the list of rule sub-views
// TODO: is this really needed? are they removed when rule removed?
this.rules.push(rule);
} catch (e) {
console.error("Error adding a Query Rule, error message:", e);
}
},
/**
* Exactly the same as {@link QueryBuilderView#addQueryRule}, except that if no
* model is provided to this function, then a FilterGroup model will be created
* instead of a Filter model.
* @param {FilterGroup} filterGroupModel
*/
addQueryRuleGroup: function (filterGroupModel) {
try {
// Ensure that the object passed to this function is a filter. When the "add
// rule" button is clicked, the Event object is passed to this function
// instead. If no filter model is provided, assume that this is a new rule
if (
!filterGroupModel ||
(filterGroupModel && filterGroupModel.type != "FilterGroup")
) {
filterGroupModel = this.collection.add({
filterType: "FilterGroup",
});
}
this.addQueryRule(filterGroupModel);
} catch (error) {
console.log(
"Error adding a Query Rule Group in a Query Builder View. " +
"Error details: " +
error,
);
}
},
},
);
return QueryBuilderView;
});