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("<", "<").replace(">", ">");
}
},
/**
* 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;
});