/* global define */
define(['underscore', 'jquery', 'backbone', 'localforage',
'models/DataONEObject', 'models/metadata/eml211/EMLAttribute', 'models/metadata/eml211/EMLEntity',
'views/DataPreviewView',
'views/metadata/EMLAttributeView',
'text!templates/metadata/eml-entity.html',
'text!templates/metadata/eml-attribute-menu-item.html',
'common/Utilities'],
function(_, $, Backbone, LocalForage, DataONEObject, EMLAttribute, EMLEntity,
DataPreviewView,
EMLAttributeView,
EMLEntityTemplate,
EMLAttributeMenuItemTemplate,
Utilities){
/**
* @class EMLEntityView
* @classdesc An EMLEntityView shows the basic attributes of a DataONEObject, as described by EML
* @classcategory Views/Metadata
* @screenshot views/metadata/EMLEntityView.png
* @extends Backbone.View
*/
var EMLEntityView = Backbone.View.extend(
/** @lends EMLEntityView.prototype */{
tagName: "div",
className: "eml-entity modal hide fade",
id: null,
/* The HTML template for an entity */
template: _.template(EMLEntityTemplate),
attributeMenuItemTemplate: _.template(EMLAttributeMenuItemTemplate),
fillButtonTemplateString: '<button class="btn btn-primary fill-button"><i class="icon-magic"></i> Fill from file</button>',
/**
* A list of file formats that can be auto-filled with attribute information
* @type {string[]}
* @since 2.15.0
*/
fillableFormats: [
"text/csv"
],
/* Events this view listens to */
events: {
"change" : "saveDraft",
"change input" : "updateModel",
"change textarea" : "updateModel",
"click .entity-container > .nav-tabs a" : "showTab",
"click .attribute-menu-item" : "showAttribute",
"mouseover .attribute-menu-item .remove" : "previewAttrRemove",
"mouseout .attribute-menu-item .remove" : "previewAttrRemove",
"click .attribute-menu-item .remove" : "removeAttribute",
"click .fill-button": "handleFill"
},
initialize: function(options){
if(!options)
var options = {};
this.model = options.model || new EMLEntity();
this.DataONEObject = options.DataONEObject;
},
render: function(){
this.renderEntityTemplate();
this.renderPreview();
this.renderAttributes();
this.renderFillButton();
this.listenTo(this.model, "invalid", this.showValidation);
this.listenTo(this.model, "valid", this.showValidation);
},
renderEntityTemplate: function(){
var modelAttr = this.model.toJSON();
if(!modelAttr.entityName)
modelAttr.title = "this data";
else
modelAttr.title = modelAttr.entityName;
modelAttr.uniqueId = this.model.cid;
this.$el.html(this.template( modelAttr ));
//Initialize the modal window
this.$el.modal();
//Set the menu height
var view = this;
this.$el.on("shown", function(){
view.adjustHeight();
view.setMenuWidth();
window.addEventListener('resize', function(event){
view.adjustHeight();
view.setMenuWidth();
});
});
this.$el.on("hidden", function(){
view.showValidation();
});
},
renderPreview: function(){
//Get the DataONEObject model
if(this.DataONEObject){
var dataPreview = new DataPreviewView({
model: this.DataONEObject
});
dataPreview.render();
this.$(".preview-container").html(dataPreview.el);
if(dataPreview.$el.children().length){
this.$(".description").css("width", "calc(100% - 310px)");
}
else
dataPreview.$el.remove();
}
},
renderAttributes: function(){
//Render the attributes
var attributes = this.model.get("attributeList"),
attributeListEl = this.$(".attribute-list"),
attributeMenuEl = this.$(".attribute-menu");
_.each(attributes, function(attr){
//Create an EMLAttributeView
var view = new EMLAttributeView({
model: attr
});
//Create a link in the attribute menu
var menuItem = $(this.attributeMenuItemTemplate({
attrId: attr.cid,
attributeName: attr.get("attributeName"),
classes: ""
})).data({
model: attr,
attributeView: view
});
attributeMenuEl.append(menuItem);
menuItem.find(".tooltip-this").tooltip();
this.listenTo(attr, "change:attributeName", function(attr){
menuItem.find(".name").text(attr.get("attributeName"));
});
view.render();
attributeListEl.append(view.el);
view.$el.hide();
this.listenTo(attr, "change", this.addAttribute);
this.listenTo(attr, "invalid", this.showAttributeValidation);
this.listenTo(attr, "valid", this.hideAttributeValidation);
}, this);
//Add a new blank attribute view at the end
this.addNewAttribute();
//If there are no attributes in this EML model yet,
//then make sure we show a new add attribute button when the user starts typing
if(attributes.length == 0){
var onlyAttrView = this.$(".attribute-menu-item").first().data("attributeView"),
view = this,
keyUpCallback = function(){
//This attribute is no longer new
view.$(".attribute-menu-item.new").first().removeClass("new");
view.$(".attribute-list .eml-attribute.new").first().removeClass("new");
//Add a new attribute link and view
view.addNewAttribute();
//Don't listen to keyup anymore
onlyAttrView.$el.off("keyup", keyUpCallback);
};
onlyAttrView.$el.on("keyup", keyUpCallback);
}
//Activate the first navigation item
var firstAttr = this.$(".side-nav-item").first();
firstAttr.addClass("active");
//Show the first attribute view
firstAttr.data("attributeView").$el.show();
firstAttr.data("attributeView").postRender();
},
renderFillButton: function() {
var formatGuess = this.model.get("dataONEObject")
? this.model.get("dataONEObject").get("formatId")
: this.model.get("entityType");
if (!_.contains(this.fillableFormats, formatGuess)) {
return;
}
var target = this.$(".fill-button-container");
if (!target.length === 1) {
return;
}
var btn = $(this.fillButtonTemplateString);
$(target).html(btn);
},
updateModel: function(e){
var changedAttr = $(e.target).attr("data-category");
if(!changedAttr) return;
var emlModel = this.model.getParentEML(),
newValue = emlModel? emlModel.cleanXMLText($(e.target).val()) : $(e.target).val();
this.model.set(changedAttr, newValue);
this.model.trickleUpChange();
},
addNewAttribute: function(){
//Check if there is already a new attribute view
if( this.$(".attribute-list .eml-attribute.new").length ){
return;
}
var newAttrModel = new EMLAttribute({
parentModel: this.model,
xmlID: DataONEObject.generateId()
}),
newAttrView = new EMLAttributeView({
isNew: true,
model: newAttrModel
});
newAttrView.render();
this.$(".attribute-list").append(newAttrView.el);
newAttrView.$el.hide();
//Change the last menu item if it still says "Add attribute"
if(this.$(".attribute-menu-item").length == 1){
var firstAttrMenuItem = this.$(".attribute-menu-item").first();
if( firstAttrMenuItem.find(".name").text() == "Add attribute" ){
firstAttrMenuItem.find(".name").text("New attribute");
firstAttrMenuItem.find(".add").hide();
}
}
//Create the new menu item
var menuItem = $(this.attributeMenuItemTemplate({
attrId: newAttrModel.cid,
attributeName: "Add attribute",
classes: "new"
})).data({
model: newAttrModel,
attributeView: newAttrView
});
menuItem.find(".add").show();
this.$(".attribute-menu").append(menuItem);
menuItem.find(".tooltip-this").tooltip();
//When the attribute name is changed, update the navigation
this.listenTo(newAttrModel, "change:attributeName", function(attr){
menuItem.find(".name").text(attr.get("attributeName"));
menuItem.find(".add").hide();
});
this.listenTo(newAttrModel, "change", this.addAttribute);
this.listenTo(newAttrModel, "invalid", this.showAttributeValidation);
this.listenTo(newAttrModel, "valid", this.hideAttributeValidation);
},
addAttribute: function(emlAttribute){
//Add the attribute to the attribute list in the EMLEntity model
if( !_.contains(this.model.get("attributeList"), emlAttribute) )
this.model.addAttribute(emlAttribute);
},
removeAttribute: function(e){
var removeBtn = $(e.target);
var menuItem = removeBtn.parents(".attribute-menu-item"),
attrModel = menuItem.data("model");
if(attrModel){
//Remove the attribute from the model
this.model.removeAttribute(attrModel);
//If this menu item is active, then make the next attribute active instead
if(menuItem.is(".active")){
var nextMenuItem = menuItem.next();
if(!nextMenuItem.length || nextMenuItem.is(".new")){
nextMenuItem = menuItem.prev();
}
if(nextMenuItem.length){
nextMenuItem.addClass("active");
this.showAttribute(nextMenuItem.data("model"));
}
}
//Remove the elements for this attribute from the page
menuItem.remove();
this.$(".eml-attribute[data-attribute-id='" + attrModel.cid + "']").remove();
$(".tooltip").remove();
this.model.trickleUpChange();
}
},
adjustHeight: function(e){
var contentAreaHeight = this.$(".modal-body").height() - this.$(".entity-container .nav-tabs").height();
this.$(".attribute-menu, .attribute-list").css("height", contentAreaHeight + "px");
},
setMenuWidth: function(){
this.$(".entity-container .nav").width( this.$el.width() );
},
/**
* Shows the attribute in the attribute editor
* @param {Event} e - JS event or attribute model
*/
showAttribute: function(e){
if(e.target){
var clickedEl = $(e.target),
menuItem = clickedEl.is(".attribute-menu-item") || clickedEl.parents(".attribute-menu-item");
if(clickedEl.is(".remove"))
return;
}
else{
var menuItem = this.$(".attribute-menu-item[data-attribute-id='" + e.cid + "']");
}
if(!menuItem)
return;
//Validate the previously edited attribute
//Get the current active attribute
var activeAttrTab = this.$(".attribute-menu-item.active");
//If there is a currently-active attribute tab,
if( activeAttrTab.length ){
//Get the attribute list from this view's model
var emlAttributes = this.model.get("attributeList");
//If there is an EMLAttribute list,
if( emlAttributes && emlAttributes.length ){
//Get the active EMLAttribute
var activeEMLAttribute = _.findWhere(emlAttributes, { cid: activeAttrTab.attr("data-attribute-id") });
//If there is an active EMLAttribute model, validate it
if( activeEMLAttribute ){
activeEMLAttribute.isValid();
}
}
}
//If the user clicked on the add attribute link
if( menuItem.is(".new") && this.$(".new.attribute-menu-item").length < 2 ){
//Change the attribute menu item
menuItem.removeClass("new").find(".name").text("New attribute");
this.$(".eml-attribute.new").removeClass("new");
menuItem.find(".add").hide();
//Add a new attribute view and menu item
this.addNewAttribute();
//Scroll the attribute menu to the bottom so that the "Add New" button is always visible
var attrMenuHeight = this.$(".attribute-menu").scrollTop() + this.$(".attribute-menu").height();
this.$(".attribute-menu").scrollTop( attrMenuHeight );
}
//Get the attribute view
var attrView = menuItem.data("attributeView");
//Change the active attribute in the menu
this.$(".attribute-menu-item.active").removeClass("active");
menuItem.addClass("active");
//Hide the old attribute view
this.$(".eml-attribute").hide();
//Show the new attribute view
attrView.$el.show();
//Scroll to the top of the attribute view
this.$(".attribute-list").scrollTop(0);
attrView.postRender();
},
/**
* Show the attribute validation errors in the attribute navigation menu
* @param {EMLAttribute} attr
*/
showAttributeValidation: function(attr){
var attrLink = this.$(".attribute-menu-item[data-attribute-id='" + attr.cid + "']").find("a");
//If the validation is already displayed, then exit
if(attrLink.is(".error")) return;
var errorIcon = $(document.createElement("i")).addClass("icon icon-exclamation-sign error icon-on-left");
attrLink.addClass("error").prepend(errorIcon);
},
/**
* Hide the attribute validation errors from the attribute navigation menu
*/
hideAttributeValidation: function(attr){
this.$(".attribute-menu-item[data-attribute-id='" + attr.cid + "']")
.find("a").removeClass("error").find(".icon.error").remove();
},
/**
* Show the user what will be removed when this remove button is clicked
*/
previewAttrRemove: function(e){
var removeBtn = $(e.target);
removeBtn.parents(".attribute-menu-item").toggleClass("remove-preview");
},
/**
*
* Will display validation styling and messaging. Should be called after
* this view's model has been validated and there are error messages to display
*/
showValidation: function(){
//Reset the error messages and styling
//Only change elements inside the overview-container which contains only the
// EMLEntity metadata. The Attributes will be changed by the EMLAttributeView.
this.$(".overview-container .notification").text("");
this.$(".overview-tab .icon.error, .attributes-tab .icon.error").remove();
this.$(".overview-container, .overview-tab a, .attributes-tab a, .overview-container .error").removeClass("error");
var overviewTabErrorIcon = false,
attributeTabErrorIcon = false;
_.each( this.model.validationError, function(errorMsg, category){
if( category == "attributeList" ){
//Create an error icon for the Attributes tab
if( !attributeTabErrorIcon ){
var errorIcon = $(document.createElement("i"))
.addClass("icon icon-on-left icon-exclamation-sign error")
.attr("title", "There is missing information in this tab");
//Add the icon to the Overview tab
this.$(".attributes-tab a").prepend(errorIcon).addClass("error");
}
return;
}
//Get all the elements for this category and add the error class
this.$(".overview-container [data-category='" + category + "']").addClass("error");
//Get the notification element for this category and add the error message
this.$(".overview-container .notification[data-category='" + category + "']").text(errorMsg);
//Create an error icon for the Overview tab
if( !overviewTabErrorIcon ){
var errorIcon = $(document.createElement("i"))
.addClass("icon icon-on-left icon-exclamation-sign error")
.attr("title", "There is missing information in this tab");
//Add the icon to the Overview tab
this.$(".overview-tab a").prepend(errorIcon).addClass("error");
overviewTabErrorIcon = true;
}
}, this);
},
/**
* Show the entity overview or attributes tab
* depending on the click target
* @param {Event} e
*/
showTab: function(e){
e.preventDefault();
//Get the clicked link
var link = $(e.target);
//Remove the active class from all links and add it to the new active link
this.$(".entity-container > .nav-tabs li").removeClass("active");
link.parent("li").addClass("active");
//Hide all the panes and show the correct one
this.$(".entity-container > .tab-content > .tab-pane").hide();
this.$(link.attr("href")).show();
},
/**
* Show the entity in a modal dialog
*/
show: function(){
this.$el.modal('show');
},
/**
* Hide the entity modal dialog
*/
hide: function(){
this.$el.modal('hide');
},
/**
* Save a draft of the parent EML model
*/
saveDraft: function() {
var view = this;
try {
var model = this.model.getParentEML();
var draftModel = model.clone();
var title = model.get("title") || "No title";
LocalForage.setItem(model.get("id"),
{
id: model.get("id"),
datetime: (new Date()).toISOString(),
title: Array.isArray(title) ? title[0] : title,
draft: draftModel.serialize()
}).then(function() {
view.clearOldDrafts();
});
} catch (ex) {
console.log("Error saving draft:", ex);
}
},
/**
* Clear older drafts by iterating over the sorted list of drafts
* stored by LocalForage and removing any beyond a hardcoded limit.
*/
clearOldDrafts: function() {
var drafts = [];
try {
LocalForage.iterate(function(value, key, iterationNumber) {
// Extract each draft
drafts.push({
key: key,
value: value
});
}).then(function(){
// Sort by datetime
drafts = _.sortBy(drafts, function(draft) {
return draft.value.datetime.toString();
}).reverse();
}).then(function() {
_.each(drafts, function(draft, i) {
var age = (new Date()) - new Date(draft.value.datetime);
var isOld = (age / 2678400000) > 1; // ~31days
// Delete this draft is not in the most recent 100 or
// if older than 31 days
var shouldDelete = i > 100 || isOld;
if (!shouldDelete) {
return;
}
LocalForage.removeItem(draft.key).then(function() {
// Item should be removed
});
})
});
}
catch (ex) {
console.log("Failed to clear old drafts: ", ex);
}
},
/**
* Handle the click event on the fill button
*
* @param {Event} e - The click event
* @since 2.15.0
*/
handleFill: function(e) {
var d1Object = this.model.get("dataONEObject");
if (!d1Object) {
return;
}
var file = d1Object.get("uploadFile");
try {
if (!file) {
this.handleFillViaFetch();
} else {
this.handleFillViaFile(file);
}
} catch (error) {
console.log("Error while attempting to fill", error);
view.updateFillButton(
'<i class="icon-warning-sign"></i> Couldn\'t fill'
);
}
},
/**
* Handle the fill event using a File object
*
* @param {File} file - A File object to fill from
* @since 2.15.0
*/
handleFillViaFile: function(file) {
var view = this;
Utilities.readSlice(file, this, function (event) {
if (event.target.readyState !== FileReader.DONE) {
return;
}
view.tryParseAndFillAttributeNames.bind(view)(event.target.result);
});
},
/**
* Handle the fill event by fetching the object
* @since 2.15.0
*/
handleFillViaFetch: function() {
var view = this;
var requestSettings = {
url: MetacatUI.appModel.get("objectServiceUrl") + encodeURIComponent(this.model.get("dataONEObject").get("id")),
method: "get",
success: view.tryParseAndFillAttributeNames.bind(this),
error: function(error) {
view.updateFillButton('<i class="icon-warning-sign"></i> Couldn\'t fill');
console.error("Error fetching DataObject to parse out headers", error);
}
}
this.updateFillButton('<i class="icon-time"></i> Please wait...', true);
this.disableFillButton();
requestSettings = _.extend(requestSettings, MetacatUI.appUserModel.createAjaxSettings());
$.ajax(requestSettings);
},
/**
* Attempt to parse header and fill attributes names
*
* @param {string} content - Part of a file to attempt to parse
* @since 2.15.0
*/
tryParseAndFillAttributeNames: function(content) {
var names = Utilities.tryParseCSVHeader(content);
if (names.length === 0) {
this.updateFillButton('<i class="icon-warning-sign"></i> Couldn\'t fill');
} else {
this.updateFillButton('<i class="icon-ok"></i> Filled!');
}
//Make sure the button is enabled
this.enableFillButton();
this.updateAttributeNames(names);
},
/**
* Update attribute names from an array
*
* This will update existing attributes' names or create new
* attributes as needed. This also performs a full re-render.
*
* @param {string[]} names - A list of names to apply
* @since 2.15.0
*/
updateAttributeNames: function(names) {
if (!names) {
return;
}
var attributes = this.model.get("attributeList");
//Update the name of each attribute or create a new Attribute if one doesn't exist
for (var i = 0; i < names.length; i++) {
if (attributes.length - 1 >= i) {
attributes[i].set("attributeName", names[i]);
} else {
attributes.push(
new EMLAttribute({
parentModel: this.model,
xmlID: DataONEObject.generateId(),
attributeName: names[i],
})
);
}
}
//Update the attribute list
this.model.set("attributeList", attributes);
// Reset first
this.$(".attribute-menu.side-nav-items").empty();
this.$(".eml-attribute").remove();
// Then re-render
this.renderAttributes();
},
/**
* Update the Fill button temporarily and set it back to the default
*
* Used to show success or failure of the filling operation
*
* @param {string} messageHTML - HTML template string to set
* temporarily
* @param {boolean} disableTimeout - If true, the timeout will not be set
* @since 2.15.0
*/
updateFillButton: function(messageHTML, disableTimeout) {
var view = this;
this.$(".fill-button").html(messageHTML);
if( !disableTimeout ){
window.setTimeout(function () {
view.$(".fill-button-container").html(view.fillButtonTemplateString);
}, 3000);
}
},
/**
* Disable the Fill Attributes button
* @since 2.15.0
*/
disableFillButton: function(){
this.$(".fill-button").prop("disabled", true);
},
/**
* Enable the Fill Attributes button
* @since 2.15.0
*/
enableFillButton: function(){
this.$(".fill-button").prop("disabled", false);
}
});
return EMLEntityView;
});