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 */ 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: "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, /** * The URL that indicates the concept where the tree should start * @type {string} */ startingRoot: "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) { this[key] = options[key]; }, this); } } 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.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 = $('
').NCBOTree({ apikey: MetacatUI.appModel.get("bioportalAPIKey"), ontology: "ECSO", 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 = $("
"); view.jumpUpButton = $(""); view.resetButton = $(""); $(view.treeContent).append(view.jumpUpButton); $(view.treeContent).append(view.resetButton); $(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({ allowMulti: true, allowAdditions: false, inputLabel: "Add one or more concepts", }) 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) } }) } 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; if(!view.multiSelectView.ready){ view.listenToOnce(view.multiSelectView, "postRender", view.updateMultiselect); return } // 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/ECSO" } }); 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($('
')); $(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("ECSO"); // 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); } }, }); });