Source: src/js/views/queryBuilder/QueryBuilderView.js

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;
  });