Source: src/js/views/searchSelect/AnnotationFilterView.js

define(
  [
    "jquery",
    "underscore",
    "backbone",
    "bioportal",
  ],
  function(
    $, _, Backbone, Bioportal,
  ) {

    /**
     * @class AnnotationFilter
     * @classdesc A view that renders an annotation filter interface, which uses
     * the bioportal tree search to select ontology terms.
     * @classcategory Views/SearchSelect
     * @extends Backbone.View
     * @constructor
     * @since 2.14.0
     * @screenshot views/searchSelect/AnnotationFilterView.png
     */
    return Backbone.View.extend(
      /** @lends AnnotationFilterView.prototype */
      {
        /**
         * The type of View this is
         * @type {string}
         */
        type: "AnnotationFilter",

        /**
         * The HTML class names for this view element
         * @type {string}
         */
        className: "filter annotation-filter",

        /**
         * The selector for the element that will show/hide the annotation
         * popover interface when clicked. Searches within body.
         * @type {string}
         */
        popoverTriggerSelector: "",

        /**
         * If set to true, instead of showing the annotation tree interface in
         * a popover, show it in a multi-select input interface, which allows
         * the user to select multiple annotations.
         * @type {boolean}
         */
        multiselect: false,

        /**
         * If true, this filter will be added to the query but will
         * act in the "background", like a default filter
         * @type {boolean}
         * @since 2.22.0
         */
        isInvisible: true,

        /**
         * If set to true, instead of showing the annotation tree interface in
         * a popover, show it on the custom search filter interface, which allows
         * the user to filter search based on the annotations.
         * @type {boolean}
         * @since 2.22.0
         */
        useSearchableSelect: false,

        /**
         * The acronym of the ontology or ontologies to render a tree from.
         *
         * Must be an ontology that's present on BioPortal.
         *
         * TODO: Test out comma-separated lists. How does that render?
         * @type {string}
         * @since 2.22.0
         */
        defaultOntology: "ECSO",

        /**
         * The URL that indicates the concept where the tree should start
         * @type {string}
         */
        defaultStartingRoot: "http://ecoinformatics.org/oboe/oboe.1.2/oboe-core.owl#MeasurementType",

        /**
         * Creates a new AnnotationFilterView
         * @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) {
                // Only override non-null values so we can pass in nulls and
                // still trigger default behavior
                if (typeof options[key] === "undefined") {
                  return;
                }

                this[key] = options[key];
              }, this);
            }

            // Mix in defaults if needed
            if (!this.ontology) {
              this.ontology = this.defaultOntology;
              this.startingRoot = this.defaultStartingRoot;
            }

          } catch (e) {
            console.log("Failed to initialize an Annotation Filter View, error message:", e);
          }
        },

        /**
         * render - Render the view
         *
         * @return {AnnotationFilter}  Returns the view
         */
        render: function() {
          try {

            if(!MetacatUI.appModel.get("bioportalAPIKey")){
              console.log("A bioportal key is required for the Annotation Filter View. Please set a key in the MetacatUI config. The view will not render.");
              return
            }

            var view = this;

            if(view.multiselect || view.useSearchableSelect){
              view.createMultiselect()
            } else {
              view.setUpTree()
              view.createPopoverHTML()
              view.setListeners()
            }

            return this

          } catch (e) {
            console.log("Failed to render an Annotation Filter View, error message: " + e);
          }
        },

        /**
         * setUpTree - Create the HTML for the annotation tree
         */
        setUpTree: function() {

          try {

            var view = this;

            view.treeEl = $('<div id="bioportal-tree"></div>').NCBOTree({
              apikey: MetacatUI.appModel.get("bioportalAPIKey"),
              ontology: view.ontology,
              width: "400",
              startingRoot: view.startingRoot
            });

            // Make an element that contains the tree and reset/jumpUp buttons
            var buttonProps = "data-trigger='hover' data-placement='top' data-container='body' style='margin-right: 3px'"
            view.treeContent = $("<div></div>");
            view.buttonContainer = $('<div class="ncbo-tree-buttons-container"></div>');
            view.jumpUpButton = $("<button class='icon icon-level-up tooltip-this btn' id='jumpUp' data-title='Go up to parent' " + buttonProps + " ></button>");
            view.resetButton = $("<button class='icon icon-undo tooltip-this btn' id='resetTree' data-title='Reset tree' " + buttonProps + " ></button>");
            $(view.buttonContainer).append(view.jumpUpButton);
            $(view.buttonContainer).append(view.resetButton);
            $(view.treeContent).append(view.buttonContainer);
            $(view.treeContent).append(view.treeEl);

          } catch (e) {
            console.log("Failed to set up an annotation tree, error message: " + e);
          }

        },

        /**
         * createMultiselect - Create a searchable multi-select interface
         * that includes an annotation filter tree.
         */
        createMultiselect: function(){

          try {
            var view = this;

            require(["views/searchSelect/SearchableSelectView"], function(SearchableSelect){

              view.multiSelectView = new SearchableSelect({
                placeholderText: view.placeholderText ? view.placeholderText : "Search for or select a value",
                icon: view.icon,
                separatorText: view.separatorText,
                inputLabel: view.inputLabel
              })
              view.$el.append(view.multiSelectView.el);
              view.multiSelectView.render();
              // If there are pre-selected values, get the user-facing labels
              // and then update the multiselect
              if(view.selected && view.selected.length){
                view.getClassLabels.call(view, view.updateMultiselect);
              } else {
                // Otherwise, update the multi-select right away with tree element
                view.updateMultiselect.call(view)
              }

              //Forward the separatorChanged event from the SearchableSelectView to this AnnotationFilterView
              //(perhaps this view should have been a subclass?)
              view.multiSelectView.on("separatorChanged", (separatorText) => {
                view.trigger("separatorChanged", separatorText)
              })
            })
          } catch (e) {
            console.log("Failed to create the multi-select interface for an Annotation Filter View, error message: " + e);
          }
        },

        /**
         * updateMultiselect - Functions to run once a SearchableSelect view has
         * been rendered and inserted into this view, and the labels for any
         * pre-selected annotation values have been fetched. Updates the
         * hidden menu of items and the selected items.
         */
        updateMultiselect: function(){

          try {
            var view = this;

            // Check if this is the first time we are updating this multiselect.
            // If it is, then don't trigger the event that updates the model,
            // because nothing has changed.
            if(view.updateMultiselectTimes === undefined){
              view.updateMultiselectTimes = 0
            } else {
              view.updateMultiselectTimes++
            }

            // Re-init the tree
            view.setUpTree();

            // Re-render the multiselect menu with the new options. These options
            // will be hidden from view, but they must be present in the DOM for
            // the multi-select interface to function correctly.
            // Add an empty item to the list of selected values, so that
            // the dropdown menu is always expandable.
            if(view.options === undefined){
              view.options = []
            }
            view.options.push({value:""});
            view.multiSelectView.options = view.options;
            view.multiSelectView.updateMenu();
            // Make sure the new menu is attached before updating list of selected
            // annotations
            setTimeout(function () {
              var silent = view.updateMultiselectTimes === 0;
              var newValues = _.reject(view.selected, function(val){ return val === "" });
              view.multiSelectView.changeSelection(newValues, silent);
            }, 25);

            // Add the annotation tree to the menu content
            view.multiSelectView.$el.find(".menu").append(view.treeContent);
            view.searchInput = view.multiSelectView.$selectUI.find("input");

            // Simulate a search in the annotation tree when the user
            // searches in the multiSelect interface
            view.searchInput.on("keyup", function(e){
              var treeInput = view.treeContent.find("input.ncboAutocomplete");
              treeInput.val(e.target.value).keydown();
            });

            view.setListeners();

          } catch (e) {
            console.log("Failed to update an annotation filter with selected values, error message: " + e);
          }

        },

        /**
         * getClassLabels - Given an array of bioontology IDs set in
         * view.selected, query the bioontology API to find the user-friendly
         * labels (prefLabels)
         *
         * @param  {function} callback A function to call once the labels have
         * been found (or not). The function will be called with the formatted
         * response: an array with an object for each ID with the properties
         * value (the original ID) and label (the user-friendly label, or the
         * value again if no label was found)
         */
        getClassLabels: function(callback){

          try {
            var view = this;

            if(!view.selected || !view.selected.length){
              return
            }

            const ontologyCollection = _.map(view.selected, function(id){
              return {
                "class" : id,
                "ontology": "http://data.bioontology.org/ontologies/" + view.ontology
              }
            });

            const bioData = JSON.stringify({
              "http://www.w3.org/2002/07/owl#Class": {
                "collection": ontologyCollection,
                "display": "prefLabel"
              }
            });

            const formatResponse = function(response, success){
              if(view.options === undefined){
                view.options = []
              }
              view.selected.forEach(function(item,index){
                if(success){
                  var match = _.findWhere(response[Object.keys(response)[0]], { "@id": item });
                } else {
                  var match = null;
                }
                view.options[index] = {
                  value: item,
                  label: match ? match.prefLabel : item
                }
              })
            }

            // Get the pre-selected values
            $.ajax({
              type: "POST",
              url: "http://data.bioontology.org/batch?display_context=false",
              headers: {
                "Authorization" : "apikey token=" + MetacatUI.appModel.get("bioportalAPIKey"),
                "Accept" : "application/json",
                "Content-Type" : "application/json"
              },
              processData: false,
              data: bioData,
              crossDomain: true,
              timeout: 5000,
              success: function(response) {
                formatResponse(response, true)
                callback.call(view)
              },
              error: function(response) {
                console.log("Error finding class labels for the Annotation Filter, error response:", response);
                formatResponse(response, false)
                callback.call(view)
              }
            });
          } catch (e) {
            console.log("Failed to fetch labels for bioontology IDs, error message: " + e);
          }

        },

        /**
         * createPopoverHTML - Create the HTML for annotation filters that are
         * displayed as a popup (e.g. in the search catalog)
         *
         * @return {type}  description
         */
        createPopoverHTML: function(){
          try {
            var view = this;
            $("body").append($('<div id="bioportal-popover" data-category="annotation"></div>'));
            $(view.popoverTriggerSelector).popover({
              html: true,
              placement: "bottom",
              trigger: "manual",
              content: view.treeContent,
              container: "#bioportal-popover"
            }).on("click", function() {
              if ($($(this).data().popover.options.content).is(":visible")) {
                // Detach the tree from the popover so it doesn't get removed by Bootstrap
                $(this).data().popover.options.content.detach();
                // Hide the popover
                $(this).popover("hide");
              } else {
                // Get the popover content
                var content = $(this).data().popoverContent ||
                  $(this).data().popover.options.content.detach();
                // Cache it
                $(this).data({
                  popoverContent: content
                });
                // Show the popover
                $(this).popover("show");
                // Insert the tree into the popover content
                $(this).data().popover.options.content = content;

                // Ensure tooltips are activated
                $(".tooltip-this").tooltip();
              }
            });
          } catch (e) {
            console.log("Failed to create popover HTML for an annotation filter, error message: " + e);
          }
        },

        /**
         * setListeners - Sets listeners on the tree elements. Must be run
         * after the tree HTML is created.
         */
        setListeners: function(){
          try {
            var view = this;
            view.treeEl.off();
            view.jumpUpButton.off();
            view.resetButton.off();
            view.treeEl.on("afterSelect", function(event, classId, prefLabel, selectedNode) {
              view.selectConcept.call(view, event, classId, prefLabel, selectedNode)
            });
            view.treeEl.on("afterJumpToClass", function(event, classId) {
              view.afterJumpToClass.call(view, event, classId);
            });
            view.treeEl.on("afterExpand", function() {
              view.afterExpand.call(view)
            });
            view.jumpUpButton.on("click", function(){
              view.jumpUp.call(view);
            });
            view.resetButton.on("click", function(){
              view.resetTree.call(view);
            });
            if(view.multiselect){
              view.treeEl.off("searchItemSelected");
              view.treeEl.on("searchItemSelected", function(){
                view.searchInput.val("")
              });
              view.stopListening(view.multiSelectView, "changeSelection");
              view.listenTo(view.multiSelectView, "changeSelection", function(newValues){
                // When values are removed, update the interface
                if(newValues != view.selected){
                  view.selected = newValues;
                  // So that the function doesn't trigger an endless loop
                  delete view.updateMultiselectTimes;
                  view.updateMultiselect()
                }
                view.trigger("changeSelection", newValues);
              })
            }
          } catch (e) {
            console.log("Failed to set listeners in an Annotation Filter View, error message: " + e);
          }
        },

        /**
         * selectConcept - Actions that are performed after the user selects
         * a concept from the annotation tree interface. Triggers an event for
         * any parent views, hides and resets the annotation popup.
         *
         * @param  {object} event        The "afterSelect" event
         * @param  {string} classId      The ID for the selected concept (a URL)
         * @param  {string} prefLabel    The label for the selected concept
         * @param  {jQuery} selectedNode The element that was clicked
         */
        selectConcept: function(event, classId, prefLabel, selectedNode) {

          try {

            var view = this;

            // Get the concept info
            var item = {
              value: classId,
              label: prefLabel,
              filterLabel: prefLabel,
              desc: ""
            }

            // Trigger an event so that the parent view can update filters, etc.
            view.trigger("annotationSelected", event, item);

            // Hide the popover
            if(!view.multiselect){
              var annotationFilterEl = $(view.popoverTriggerSelector);
              annotationFilterEl.trigger("click");
              $(selectedNode).trigger("mouseout");
              view.resetTree();

            // Update the multi-select with the new options
            } else {
              view.options.push(item);
              view.selected.push(item.value);
              view.updateMultiselect();
            }

            // Ensure tooltips are removed
            $("body > .tooltip").remove();

            // Prevent default action
            return false;

          } catch (e) {
            console.log("Failed to select an annotation concept, error message: " + e);
          }

        },

        /**
         * afterExpand - Actions to perform when the user expands a concept in
         * the tree
         */
        afterExpand: function() {
          try {
            // Ensure tooltips are activated
            $(".tooltip-this").tooltip();
          } catch (e) {
            console.log("Failed to initialize tooltips in the annotation filter, error message: " + e);
          }
        },

        /**
         * afterJumpToClass - Called when a user searches for and selects a
         * concept from the search results
         *
         * @param  {type} event   The jump to class event
         * @param  {type} classId The ID for the selected concept (a URL)
         */
        afterJumpToClass: function(event, classId) {

          try {
            var view = this;
            // Re-root the tree at this concept
            var tree = view.treeEl.data("NCBOTree");
            var options = tree.options();
            $.extend(options, {
              startingRoot: classId
            });

            // Force a re-render
            tree.init();

            // Ensure the tooltips are activated
            $(".tooltip-this").tooltip();

          } catch (e) {
            console.log("Failed to re-render the annotation filter after jump to class, error message: " + e);
          }

        },

        /**
         * jumpUp -  Jumps up to the parent concept in the UI
         *
         * @return {boolean}  Returns false
         */
        jumpUp: function() {

          try {
            // Re-root the tree at the parent concept of the root
            var view = this,
                tree = view.treeEl.data("NCBOTree"),
                options = tree.options(),
                startingRoot = options.startingRoot;

            if (startingRoot == view.startingRoot) {
              return false;
            }

            var parentId = $("a[data-id='" + encodeURIComponent(startingRoot) + "'").attr("data-subclassof");

            // Re-root
            $.extend(options, {
              startingRoot: parentId
            });

            // Force a re-render
            tree.init();

            // Ensure the tooltips are activated
            $(".tooltip-this").tooltip();

            return false;

          } catch (e) {
            console.log("Failed to jump to parent concept in the annotation filter, error message: " + e);
          }

        },

        /**
         * resetTree - Collapse all expanded concepts
         *
         * @return {boolean}  Returns false
         */
        resetTree: function() {

          try {

            var view = this;

            // Re-root the tree at the original concept
            var tree = view.treeEl.data("NCBOTree");

            var options = tree.options();

            // Re-root
            $.extend(options, {
              startingRoot: view.startingRoot
            });

            tree.changeOntology(view.ontology);

            // Force a re-render
            tree.init();

            // Ensure the tooltips are activated
            $(".tooltip-this").tooltip();

            return false;
          } catch (e) {
            console.log("Failed to reset the annotation filter tree, error message: " + e);
          }

        },

      });
  });