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;
});