Source: src/js/views/AnnotationView.js

/*global define */
define(['jquery',
    'underscore',
    'backbone',
    'text!templates/bioportalAnnotationTemplate.html',],
    function($, _, Backbone, AnnotationPopoverTemplate) {
    'use strict';

    /**
    * @class AnnotationView
    * @classdesc A view of a single semantic annotation for a metadata field. It is usually displayed on the {@link MetadataView}.
    * @classcategory Views
    * @extends Backbone.View
    * @screenshot views/AnnotationView.png
    * @constructor
    */
    var AnnotationView = Backbone.View.extend(
      /** @lends AnnotationView.prototype */{
        className: 'annotation-view',
        annotationPopoverTemplate: _.template(AnnotationPopoverTemplate),

        el: null,

        events: {
            "click" : "handleClick",
            "click .annotation-popover-findmore" : "findMore",
        },

        /**
         * Context string is a human-readable bit of text that comes out of the
         * Metacat view service and describes the context of the annotation
         * i.e., what entity, or which attribute within which entity the
         * annotation is on
         */
        context: null,

        // State. See initialize(), we store a bunch of info in these
        property: null,
        value: null,

        initialize: function () {
            // Detect legacy pill DOM structure with the old arrow,
            // ┌───────────┬───────┬───┐
            // │ property  │ value │ ↗ │
            // └───────────┴───────┴───┘
            // clean up, and disable ourselves. This can be removed at some
            // point in the future
            if (this.$el.find(".annotation-findmore").length > 0) {
                this.$el.find(".annotation-findmore").remove();
                this.$el.find(".annotation-value").attr("style", "color: white");

                return;
            }

            this.property = {
                type: "property",
                el: null,
                popover: null,
                label: null,
                uri: null,
                definition: null,
                ontology: null,
                ontologyName: null,
                resolved: false
            };

            this.value = {
                type: "value",
                el: null,
                popover: null,
                label: null,
                uri: null,
                definition: null,
                ontology: null,
                ontologyName: null,
                resolved: false
            };

            this.property.el = this.$el.children(".annotation-property");
            this.value.el = this.$el.children(".annotation-value");

            // Bail now if things aren't set up right
            if (!this.property.el || !this.value.el) {
                return;
            }

            this.context = this.$el.data("context");
            this.property.label = this.property.el.data("label");
            this.property.uri = this.property.el.data("uri");
            this.value.label = this.value.el.data("label");
            this.value.uri = this.value.el.data("uri");

            // Decode HTML tags in the context string, which is passed in as
            // an HTML attribute from the XSLT so it needs encoding of some sort
            // Note: Only supports < and > at this point
            if (this.context) {
              this.context = this.context.replace("&lt;", "<").replace("&gt;", ">");
            }
        },

        /**
         * Click handler for when the user clicks either the property or the
         * value portion of the pill.
         *
         * If the popover hasn't yet been created for either, we create the
         * popover and query BioPortal for more information. Otherwise, we do
         * nothing and Bootstrap's default popover handling is triggered,
         * showing the popover.
         *
         * @param {Event} e - Click event
         */
        handleClick: function (e) {
            if (!this.property || !this.value) {
                    return;
            }

            if (e.target.className === "annotation-property") {
                if (this.property.popover) {
                    return;
                }

                this.createPopover(this.property);
                this.property.popover.popover("show");
                this.queryAndUpdate(this.property);
            } else if (e.target.className === "annotation-value" ||
                e.target.className === "annotation-value-text") {
                if (this.value.popover) {
                    return;
                }

                this.createPopover(this.value);
                this.value.popover.popover("show");
                this.queryAndUpdate(this.value);
            }
        },

        /**
         * Update the value popover with the current state
         *
         * @param {Object} which - Which popover to create. Either this.property
         * or this.value.
         */
        createPopover: function (which) {
            var new_content = this.annotationPopoverTemplate({
                context: this.context,
                label: which.label,
                uri: which.uri,
                definition: which.definition,
                ontology: which.ontology,
                ontologyName: which.ontologyName,
                resolved: which.resolved,
                propertyURI: this.property.uri,
                propertyLabel: this.property.label,
                valueURI: this.value.uri,
                valueLabel: this.value.label
            });

            which.el.data("content", new_content);

            which.popover = which.el.popover({
                container: which.el,
                delay: 500,
                trigger: "click"
            });
        },

        /**
         * Find a definition for the value URI either from cache or from
         * Bioportal. Updates the popover if necessary.
         *
         * @param {Object} which - Which popover to create. Either this.property
         * or this.value.
         */
        queryAndUpdate: function (which) {
            if (which.resolved) {
                return;
            }

            var viewRef = this,
                cache = MetacatUI.appModel.get("bioportalLookupCache"),
                token = MetacatUI.appModel.get("bioportalAPIKey");

            // Attempt to grab from cache first
            if (cache && cache[which.uri]) {
                which.definition = cache[which.uri].definition;
                which.ontology = cache[which.uri].links.ontology;

                // Try to get a simpler name for the ontology, rather than just
                // using the ontology URI, which is all Bioportal gives back
                which.ontologyName = this.getFriendlyOntologyName(cache[which.uri].links.ontology);
                which.resolved = true;
                viewRef.updatePopover(which);

                return;
            }

            // Verify token before moving on
            if (typeof token !== "string" || token.length === 0) {
                which.resolved = true;

                return;
            }

            // Query the API and handle the response
            // TODO: Looks like we should proxy this so the token doesn't leak
            var url = MetacatUI.appModel.get("bioportalSearchUrl") +
                "?q=" + encodeURIComponent(which.uri) +
                "&apikey=" +
                token;

            $.get(url, function (data) {
                var match = null;

                // Verify response structure before trusting it
                if (!data.collection ||
                    !data.collection.length ||
                    !data.collection.length > 0) {
                    return;
                }

                // Find the first match by URI
                match = _.find(data.collection, function(result) {
                    return result["@id"] && result["@id"] === which.uri;
                });

                // Verify structure of response looks right and bail out if it
                // doesn't
                if (!match ||
                    !match.definition ||
                    !match.definition.length ||
                    !match.definition.length > 0) {
                    which.resolved = true;

                    return;
                }

                which.definition = match.definition[0];
                which.ontology = match.links.ontology;

                // Try to get a simpler name for the ontology, rather than just
                // using the ontology URI, which is all Bioportal gives back
                which.ontologyName = viewRef.getFriendlyOntologyName(match.links.ontology);

                which.resolved = true;
                viewRef.updateCache(which.uri, match);
                viewRef.updatePopover(which);
            });
        },

        /**
         * Update the popover data and raw HTML. This is necessary because
         * we want to create the popover before we fetch the data to populate
         * it from BioPortal and Bootstrap Popovers are designed to be static.
         *
         * The main trick I had to figure out here was that I could access
         * the underlying content member of the popover with
         * popover_data.options.content which wasn't documented in the API.
         *
         * @param {Object} which - Which popover to create. Either this.property
         * or this.value.
         */
        updatePopover: function(which) {
            var popover_content = $(which.popover).find(".popover-content")

            var new_content = this.annotationPopoverTemplate({
                context: this.context,
                label: which.label,
                uri: which.uri,
                definition: which.definition,
                ontology: which.ontology,
                ontologyName: which.ontologyName,
                resolved: which.resolved,
                propertyURI: which.uri,
                propertyLabel: which.label,
                valueURI: this.value.uri,
                valueLabel: this.value.label
            });

            // Update both the existing DOM and the underlying data
            // attribute in order to persist the updated content between
            // displays of the popover

            // Update the Popover first
            //
            // This is a hack to work around the fact that we're updating the
            // content of the popover after it is created. I read the source
            // for Bootstrap's Popover and it showed the popover is generated
            // from the data-popover attribute's content which has an
            // options.content member we can modify directly
            var popover_data = $(which.el).data('popover');

            if (popover_data && popover_data.options && popover_data.options) {
                popover_data.options.content = new_content;
            }

            $(which.el).data('popover', popover_data);

            // Then update the DOM on the open popover
            $(popover_content).html(new_content);
        },

        /**
         * Update the cache for a given term.
         *
         * @param {string} term - The term URI
         * @param {Object} match - The BioPortal match object for the term
        */
        updateCache: function(term, match) {
            var cache = MetacatUI.appModel.get("bioportalLookupCache");

            if (cache &&
                typeof term === "string" &&
                typeof match === "string") {
                cache[term] = match;
            }
        },

        /**
         * Send the user to a pre-canned search for a term.
         *
         * @param {Event} e - Click event
         */
        findMore: function(e) {
            e.preventDefault();

            // Find the URI we need to filter on. Try the value first
            var parent = $(e.target).parents(".annotation-value");

            // Fall back to finding the URI from the property
            if (parent.length <= 0) {
                parent = $(e.target).parents(".annotation-property");
            }

            // Bail if we found neither
            if (parent.length <= 0) {
                return;
            }

            // Now grab the label and URI and filter
            var label = $(parent).data("label"),
                uri = $(parent).data("uri");

            if (!label || !uri) {
                return;
            }

            // Direct the user towards a search for the annotation
            MetacatUI.appSearchModel.clear();
            MetacatUI.appSearchModel.set('annotation', [{
                label: label,
                value: uri
            }]);
            MetacatUI.uiRouter.navigate('data', {trigger: true});
        },

        /**
         * Get a friendly name (ie ECSO) from a long BioPortal URI
         *
         * @param {string} uri - A URI returned from the BioPortal API
         * @return {string}
         */
        getFriendlyOntologyName: function(uri) {
            if ((typeof uri === "string")) {
                return uri;
            }

            return uri.replace("http://data.bioontology.org/ontologies/", "");
        }
    });

    return AnnotationView;
  });