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

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