Source: src/js/views/filters/FilterView.js

/*global define */
define(['jquery', 'underscore', 'backbone',
        'models/filters/Filter',
        'text!templates/filters/filter.html',
        'text!templates/filters/filterLabel.html'],
  function($, _, Backbone, Filter, Template, LabelTemplate) {
  'use strict';

  /**
  * @class FilterView
  * @classdesc Render a view of a single FilterModel
  * @classcategory Views/Filters
  * @extends Backbone.View
  */
  var FilterView = Backbone.View.extend(
    /** @lends FilterView.prototype */{

    /**
    * A Filter model to be rendered in this view
    * @type {Filter} */
    model: null,

    /**
     * The Filter model that this View renders. This is used to create a new
     * instance of the model if one is not provided to the view.
     * @type {Backbone.Model}
     * @since 2.17.0
     */
    modelClass: Filter,

    tagName: "div",

    className: "filter",

    /**
    * Reference to template for this view. HTML files are converted to Underscore.js
    * templates
    * @type {Underscore.Template}
    */
    template: _.template(Template),

    /**
    * The template that renders the icon and label of a filter
    * @type {Underscore.Template}
    * @since 2.17.0
    */
    labelTemplate: _.template(LabelTemplate),

    /**
     * One of "normal", "edit", or "uiBuilder". "normal" renders a regular filter used to
     * update a search model in a DataCatalogViewWithFilters. "edit" creates a filter that
     * cannot update a search model, but which has an "EDIT" button that opens a modal
     * with an interface for editing the filter model's properties (e.g. fields, model
     * type, etc.). "uiBuilder" is the view of the filter within this editing modal; it
     * has inputs that are overlaid above the filter elements where a user can edit the
     * placeholder text, label, etc. in a WYSIWYG fashion.
     * @type {string}
     * @since 2.17.0
     */
    mode: "normal",

    /**
     * The class to add to the filter when it is in "uiBuilder" mode
     * @type {string}
     * @since 2.17.0
     */
      uiBuilderClass: "ui-build",
    
    /**
     * Whether the filter is collapsible. If true, the filter will have a button that
     * toggles the collapsed state.
     * @type {boolean}
     * @since 2.25.0
     */
    collapsible: false,

    /**
     * The class to add to the filter when it is collapsed.
     * @type {string}
     * @since 2.25.0
     * @default "collapsed"
     */
    collapsedClass: "collapsed",

    /**
     * The class used for the button that toggles the collapsed state of the filter.
     * @type {string}
     * @since 2.25.0
     * @default "collapse-toggle"
     */
    collapseToggleClass: "collapse-toggle",
    
    /**
     * The current state of the filter, if it is {@link FilterView#collapsible}.
     * Whatever this value is set to at initialization, will be how the filter is
     * initially rendered.
     * @type {boolean}
     * @since 2.25.0
     * @default true
     */
    collapsed: true,

    /**
     * The class used for input elements where the user can change UI attributes when this
     * view is in "uiBuilder" mode. For example, the input for the placeholder text should
     * have this class. Elements with this class also need to have a data-category
     * attribute with the name of the model attribute they correspond to.
     * @type {string}
     * @since 2.17.0
     */
    uiInputClass: "ui-build-input",

    /**
     * A function that creates and returns the Backbone events object.
     * @return {Object} Returns a Backbone events object
     */
    events: function(){
      try {
        var events = {
          "click .btn": "handleChange",
          "keydown input": "handleTyping"
        }
        events["change ." + this.uiInputClass] = "updateUIAttribute";
        events[`click .${this.collapseToggleClass}`] = "toggleCollapse";
        return events
      }
      catch (error) {
        console.log( 'There was an error setting the events object in a FilterView' +
          ' Error details: ' + error );
      }
      },

    /**
     * Function executed whenever a new FilterView is created.
     * @param {Object} [options] - A literal object of options to set on this View
     */
      initialize: function (options) {

      try {
        if (!options || typeof options != "object") {
          var options = {};
        }

        this.editorView = options.editorView || null;

        if (options.mode && ["edit", "uiBuilder", "normal"].includes(options.mode)) {
          this.mode = options.mode
        }

        // When this view is being rendered in an editable mode (e.g. in the custom search
        // filter editor), then overwrite the functions that update the search model. This
        // way the user can interact with the filter without causing the
        // dataCatalogViewWithFilters to update the search results. For simplicity, and
        // because extended Filter Views call this function, update functions from other
        // types of Filter views are included here.
        if (["edit", "uiBuilder"].includes(this.mode)) {
          var functionsToOverwrite = [
            "updateModel", "handleChange", "handleTyping",
            "updateChoices", "updateToggle", "updateYearRange"
          ]
          functionsToOverwrite.forEach(function (fnName) {
            if (typeof this[fnName] === "function") {
              this[fnName] = function () { return }
            }
          }, this)
        }

        this.model = options.model || new this.modelClass();

        if (options.collapsible && typeof options.collapsible === "boolean") {
          this.collapsible = options.collapsible;
        }

      }

      catch (error) {
        console.log( 'There was an error initializing a FilterView' +
          ' Error details: ' + error );
      }

    },

    /**
     * Render an instance of a Filter View. All of the extended Filter Views also call
     * this render function.
     * @param {Object} templateVars - The variables to use in the HTML template. If not
     * provided, defaults to the model in JSON
     */
    render: function (templateVars) {
      try {
        var view = this;
      
        if(!templateVars){
          var templateVars = this.model.toJSON()
        }

        // Pass the mode (e.g. "edit", "uiBuilder") to the template, as well
        // as the variables related to collapsibility.
        const viewVars = {
          mode: this.mode,
          collapsible: this.collapsible,
          collapseToggleClass: this.collapseToggleClass
        }
        templateVars = _.extend(templateVars, viewVars)

        // Render the filter HTML (without label or icon)
        this.$el.html( this.template( templateVars ) );
        // Add the filter label & icon (common between most filters)
        this.$el.prepend( this.labelTemplate( templateVars ) );

        // a FilterEditorView adds an "EDIT" button, which opens a modal allowing the user
        // to change the UI options of the filter - e.g., label, icon, placeholder text,
        // etc.
        if(this.mode === "edit"){
          require(['views/filters/FilterEditorView'], function(FilterEditor){
            var filterEditor = new FilterEditor({
              model: view.model,
              editorView: view.editorView
            });
            filterEditor.render();
            view.$el.prepend(filterEditor.el);
          });
        }
        if(this.mode === "uiBuilder"){
          this.$el.addClass(this.uiBuilderClass);
        }
        // Don't show the editor footer with save button when a user types text into
        // a filter in edit or build mode.
        if(["edit", "uiBuilder"].includes(this.mode)){
          this.$el.find("input").addClass("ignore-changes")
        }

        // If the filter is collapsible, set the initial collapsed state
        if(this.collapsible && typeof this.collapsed === "boolean"){
          this.toggleCollapse(this.collapsed)
        }
        
      }
      catch (error) {
        console.log( 'There was an error rendering a FilterView' +
          ' Error details: ' + error );
      }
    },

    /**
    * When the user presses Enter in the input element, update the view and model
    *
    * @param {Event} - The DOM Event that occurred on the filter view input element
    */
      handleTyping: function (e) {
      
        if (["edit", "uiBuilder"].includes(this.mode)) {
          return
        }

        if (e.key == "Enter"){
          this.handleChange();
          return;
        }
        else{
          /** @todo Get search suggestions when the user is typing. See {@link DataCatalogView#getAutoCompletes }*/
          
        }

    },

    /**
    * Updates the view when the filter input is updated
    *
    * @param {Event} - The DOM Event that occurred on the filter view input element
    */
    handleChange: function () {

      if (["edit", "uiBuilder"].includes(this.mode)) {
        return
      }

      this.updateModel();

      //Clear the value of the text input
      this.$("input").val("");

    },

    /**
    * Updates the value set on the Filter Model associated with this view.
    * The filter value is grabbed from the input element in this view.
    */
    updateModel: function () {
      
      if (["edit", "uiBuilder"].includes(this.mode)) {
        return
      }

      //Get the new value from the text input
      var newValue = this.$("input").val();

      if( newValue == "" )
        return;

      //Get the current values array from the model
      var currentValue = this.model.get("values");

      //Create a copy of the array
      var newValuesArray = _.flatten(new Array(currentValue, newValue));

      //Trigger the change event manually since it is an array
      this.model.set("values", newValuesArray);

    },

    /**
     * Updates the corresponding model attribute when an input for one of the UI options
     * changes (in "uiBuilder" mode).
     * @param {Object} e The change event
     * @since 2.17.0
     */
    updateUIAttribute: function(e){
      try {
        if (this.mode != "uiBuilder") {
          return
        }
        var inputEl = e.target;
        if (!inputEl) {
          return
        }
        if (!inputEl.dataset || !inputEl.dataset.category) {
          return
        }
        var modelAttribute = inputEl.dataset.category,
          newValue = inputEl.value;
        if (inputEl.type === "number") {
          newValue = parseInt(newValue)
        }
        this.model.set(modelAttribute, newValue)
      }
      catch (error) {
        console.log( 'There was an error updating a UI attribute in a FilterView' +
          ' Error details: ' + error );
      }
      },
    
      /**
       * Show validation errors. This is used for filters that are in "UIBuilder" mode.
       * @param {Object} errors The error messages associated with each attribute that has
       * an error, passed from the Filter model validation function.
       */
      showValidationErrors: function (errors) {
        try {
          var view = this;
          var uiInputClass = this.uiInputClass;

          for (const [category, message] of Object.entries(errors)) {

            const input = view.el.querySelector("." + uiInputClass + "[data-category='" + category + "']");
            const messageContainer = view.el.querySelector(".notification[data-category='" + category + "']");

            view.showInputError(input, messageContainer, message);

            if (input) {
              input.addEventListener('input', function () {
                view.hideInputError(input, messageContainer)
              }, { once: true })
            }
          }
        }
        catch (error) {
          console.log(
            'There was an error showing validation errors in a FilterView' +
            '. Error details: ' + error
          );
        }
      },

      /**
       * This function indicates that there is an error with an input in this filter. It
       * displays an error message and adds the error CSS class to the problematic input.
       * @param {HTMLElement} input The input that has an error associated with its value
       * @param {HTMLElement} messageContainer The element in which to insert the error
       * message
       * @param {string} message The error message to show
       */
      showInputError: function (input, messageContainer, message) {
        try {
          if (messageContainer && message) {
            messageContainer.innerText = message;
            messageContainer.style.display = "block";
          }
          if (input) {
            input.classList.add("error");
          }
        }
        catch (error) {
          console.log(
            'Failed to show an error message for an input in a FilterView' +
            '. Error details: ' + error
          );
        }
      },

      /**
       * This function hides the error message and error class added to inputs with the
       * FilterView#showInputError function.
       * @param {HTMLElement} input The input that had an error associated with its value
       * @param {HTMLElement} messageContainer The element in which the error message was
       * inserted
       */
      hideInputError: function (input, messageContainer) {
        try {
          if (messageContainer) {
            messageContainer.innerText = "";
            messageContainer.style.display = "none";
          }
          if (input) {
            input.classList.remove("error");
          }
        }
        catch (error) {
          console.log(
            'Failed to hide the error message for an input in a FilterView' +
            '. Error details: ' + error
          );
        }
      },

      /**
       * Toggle the collapsed state of the filter. If collapse is a boolean, then set the
       * collapsed state to that value. Otherwise, set it to the opposite of whichever
       * state is currently set.
       * @param {boolean} [collapse] Whether to collapse the filter. If not provided, the
       * filter will be collapsed if it is currently expanded, and vice versa.
       * @since 2.25.0
       */
      toggleCollapse: function (collapse) {
        try {
          // If collapse is a boolean, then set the collapsed state to that value.
          // Otherwise, set it to the opposite of whichever state is currently set.
          if (typeof collapse !== "boolean") {
            collapse = !this.collapsed
          }
          if (collapse) {
            this.el.classList.add(this.collapsedClass)
            this.collapsed = true
          } else {
            this.el.classList.remove(this.collapsedClass)
            this.collapsed = false
          }
        }
        catch (e) {
          console.log("Could not un/collapse filter.", e);
        }
      },
    
    

  });
  return FilterView;
});