define([
"underscore",
"jquery",
"backbone",
"views/metadata/ScienceMetadataView",
"views/metadata/EMLGeoCoverageView",
"views/metadata/EMLPartyView",
"views/metadata/EMLMethodsView",
"views/metadata/EMLTempCoverageView",
"views/metadata/EMLTaxonView",
"models/metadata/eml211/EML211",
"models/metadata/eml211/EMLGeoCoverage",
"models/metadata/eml211/EMLKeywordSet",
"models/metadata/eml211/EMLParty",
"models/metadata/eml211/EMLProject",
"models/metadata/eml211/EMLText",
"models/metadata/eml211/EMLTemporalCoverage",
"models/metadata/eml211/EMLMethods",
"text!templates/metadata/eml.html",
"text!templates/metadata/eml-people.html",
"text!templates/metadata/EMLPartyCopyMenu.html",
"text!templates/metadata/metadataOverview.html",
"text!templates/metadata/dates.html",
"text!templates/metadata/locationsSection.html",
"text!templates/metadata/data-sensitivity.html",
], (
_,
$,
Backbone,
ScienceMetadataView,
EMLGeoCoverageView,
EMLPartyView,
EMLMethodsView,
EMLTempCoverageView,
EMLTaxonView,
EML,
EMLGeoCoverage,
EMLKeywordSet,
EMLParty,
EMLProject,
EMLText,
EMLTemporalCoverage,
EMLMethods,
Template,
PeopleTemplate,
EMLPartyCopyMenuTemplate,
OverviewTemplate,
DatesTemplate,
LocationsTemplate,
DataSensitivityTemplate,
) => {
/**
* @class EMLView
* @classdesc An EMLView renders an editable view of an EML 2.1.1 document
* @classcategory Views/Metadata
* @augments ScienceMetadataView
*/
const EMLView = ScienceMetadataView.extend(
/** @lends EMLView */ {
type: "EML211",
el: "#metadata-container",
events: {
"change .text": "updateText",
"change .basic-text": "updateBasicText",
"keyup .basic-text.new": "addBasicText",
"mouseover .basic-text-row .remove": "previewTextRemove",
"mouseout .basic-text-row .remove": "previewTextRemove",
"change .pubDate input": "updatePubDate",
"focusout .pubDate input": "showPubDateValidation",
"keyup .eml-geocoverage.new": "updateLocations",
"change .keywords": "updateKeywords",
"keyup .keyword-row.new input": "addNewKeyword",
"mouseover .keyword-row .remove": "previewKeywordRemove",
"mouseout .keyword-row .remove": "previewKeywordRemove",
"change .usage": "updateRadioButtons",
"change .funding": "updateFunding",
"keyup .funding.new": "addFunding",
"mouseover .funding-row .remove": "previewFundingRemove",
"mouseout .funding-row .remove": "previewFundingRemove",
"keyup .funding.error": "handleFundingTyping",
"click .side-nav-item": "switchSection",
"keyup .eml-party.new": "handlePersonTyping",
"change #new-party-menu": "chooseNewPersonType",
"click .eml-party .copy": "showCopyPersonMenu",
"click #copy-party-save": "copyPerson",
"click .eml-party .remove": "removePerson",
"click .eml-party .move-up": "movePersonUp",
"click .eml-party .move-down": "movePersonDown",
"click input.annotation": "addAnnotation",
"click .remove": "handleRemove",
},
/* A list of the subviews */
subviews: [],
/* The active section in the view - can only be the section name (e.g. overview, people)
* The active section is highlighted in the table of contents and is scrolled to when the page loads
*/
activeSection: "overview",
/* The visible section in the view - can either be the section name (e.g. overview, people) or "all"
* The visible section is the ONLY section that is displayed. If set to all, all sections are displayed.
*/
visibleSection: "overview",
/* Templates */
template: _.template(Template),
overviewTemplate: _.template(OverviewTemplate),
dataSensitivityTemplate: _.template(DataSensitivityTemplate),
datesTemplate: _.template(DatesTemplate),
locationsTemplate: _.template(LocationsTemplate),
copyPersonMenuTemplate: _.template(EMLPartyCopyMenuTemplate),
peopleTemplate: _.template(PeopleTemplate),
/**
* jQuery selector for the element that contains the Data Sensitivity section.
* @type {string}
*/
dataSensitivityContainerSelector: "#data-sensitivity-container",
/**
* An array of literal objects to describe each type of EML Party. This property has been moved to
* {@link EMLParty#partyTypes} as of 2.21.0 and will soon be deprecated.
* @type {object[]}
* @deprecated
* @since 2.15.0
*/
partyTypes: EMLParty.prototype.partyTypes,
initialize: function (options) {
//Set up all the options
if (typeof options == "undefined") var options = {};
//The EML Model and ID
this.model = options.model || new EML();
if (!this.model.get("id") && options.id)
this.model.set("id", options.id);
//Get the current mode
this.edit = options.edit || false;
return this;
},
/* Render the view */
render: function () {
MetacatUI.appModel.set("headerType", "default");
//Render the basic structure of the page and table of contents
this.$el.html(
this.template({
activeSection: this.activeSection,
visibleSection: this.visibleSection,
}),
);
this.$container = this.$(".metadata-container");
//Render all the EML sections when the model is synced
this.renderAllSections();
if (!this.model.get("synced"))
this.listenToOnce(this.model, "sync", this.renderAllSections);
//Listen to updates on the data package collections
_.each(
this.model.get("collections"),
function (dataPackage) {
if (dataPackage.type != "DataPackage") return;
// When the data package has been saved, render the EML again.
// This is needed because the EML model validate & serialize functions may
// automatically make changes, such as adding a contact and creator
// if none is supplied by the user.
this.listenTo(
dataPackage.packageModel,
"successSaving",
this.renderAllSections,
);
},
this,
);
return this;
},
renderAllSections: function () {
this.renderOverview();
this.renderPeople();
this.renderDates();
this.renderLocations();
this.renderTaxa();
this.renderMethods();
this.renderProject();
this.renderSharing();
//Scroll to the active section
if (this.activeSection != "overview") {
MetacatUI.appView.scrollTo(this.$(".section." + this.activeSection));
}
//When scrolling through the metadata, highlight the side navigation
var view = this;
$(document).scroll(function () {
view.highlightTOC.call(view);
});
},
/*
* Renders the Overview section of the page
*/
renderOverview: function () {
//Get the overall view mode
var edit = this.edit;
var view = this;
//Append the empty layout
var overviewEl = this.$container.find(".overview");
$(overviewEl).html(this.overviewTemplate());
//Title
this.renderTitle();
this.listenTo(this.model, "change:title", this.renderTitle);
//Data Sensitivity
this.renderDataSensitivity();
//Abstract
_.each(
this.model.get("abstract"),
function (abs) {
var abstractEl = this.createEMLText(abs, edit, "abstract");
//Add the abstract element to the view
$(overviewEl).find(".abstract").append(abstractEl);
},
this,
);
if (!this.model.get("abstract").length) {
var abstractEl = this.createEMLText(null, edit, "abstract");
//Add the abstract element to the view
$(overviewEl).find(".abstract").append(abstractEl);
}
//Keywords
//Iterate over each keyword and add a text input for the keyword value and a dropdown menu for the thesaurus
_.each(
this.model.get("keywordSets"),
function (keywordSetModel) {
_.each(
keywordSetModel.get("keywords"),
function (keyword) {
this.addKeyword(keyword, keywordSetModel.get("thesaurus"));
},
this,
);
},
this,
);
//Add a new keyword row
this.addKeyword();
//Alternate Ids
var altIdsEls = this.createBasicTextFields(
"alternateIdentifier",
"Add a new alternate identifier",
);
$(overviewEl).find(".altids").append(altIdsEls);
// Canonical Identifier
const canonicalIdEl = this.createBasicTextFields(
"canonicalDataset",
"Add a new canonical identifier",
);
$(overviewEl).find(".canonical-id").append(canonicalIdEl);
// Show canonical dataset error message on change
this.stopListening(this.model, "change:canonicalDataset");
this.listenTo(this.model, "change:canonicalDataset", () => {
const annotations = this.model.get("annotations");
const annoErrors = annotations.validate();
const canonicalError = annoErrors?.filter(
(e) => e.attr === "canonicalDataset",
);
if (canonicalError) {
const container = canonicalIdEl.parent();
const input = canonicalIdEl.find("input");
const notification = container.find(".notification");
notification.addClass("error").text(canonicalError[0].message);
input.addClass("error");
// When the user starts typing, remove the error message
input.one("keyup", () => {
notification.removeClass("error").text("");
input.removeClass("error");
});
}
});
//Usage
//Find the model value that matches a radio button and check it
// Note the replace() call removing newlines and replacing them with a single space
// character. This is a temporary hack to fix https://github.com/NCEAS/metacatui/issues/128
if (this.model.get("intellectualRights"))
this.$(
".checkbox .usage[value='" +
this.model.get("intellectualRights").replace(/\r?\n|\r/g, " ") +
"']",
).prop("checked", true);
//Funding
this.renderFunding();
// pubDate
// BDM: This isn't a createBasicText call because that helper
// assumes multiple values for the category
// TODO: Consider a re-factor of createBasicText
var pubDateInput = $(overviewEl)
.find("input.pubDate")
.val(this.model.get("pubDate"));
//Initialize all the tooltips
this.$(".tooltip-this").tooltip();
},
renderTitle: function () {
var titleEl = this.createBasicTextFields(
"title",
"Example: Greater Yellowstone Rivers from 1:126,700 U.S. Forest Service Visitor Maps (1961-1983)",
false,
);
this.$container
.find(".overview")
.find(".title-container")
.html(titleEl);
},
/**
* Renders the Data Sensitivity section of the Editor using the data-sensitivity.html template.
* @fires EML211View#editorInputsAdded
*/
renderDataSensitivity: function () {
try {
//If Data Sensitivity questions are disabled in the AppConfig, exit before rendering
if (!MetacatUI.appModel.get("enableDataSensitivityInEditor")) {
return;
}
var container = this.$(this.dataSensitivityContainerSelector),
view = this;
if (!container.length) {
container = $(`<div id="data-sensitivity-container"></div>`);
this.$(".section.overview").append(container);
}
require([
"text!../img/icons/datatags/check-tag.svg",
"text!../img/icons/datatags/alert-tag.svg",
], function (checkTagIcon, alertTagIcon) {
container.html(
view.dataSensitivityTemplate({
checkTagIcon: checkTagIcon,
alertTagIcon: alertTagIcon,
}),
);
//Initialize all the tooltips
view.$(".tooltip-this").tooltip();
//Check the radio button that is already selected, per the EML
let annotations = view.model.getDataSensitivity();
if (
annotations &&
annotations.length &&
typeof annotations[0].get == "function"
) {
let annotationValue = annotations[0].get("valueURI");
container
.find("[value='" + annotationValue + "']")
.prop("checked", true);
}
//Trigger the editorInputsAdded event which will let other parts of the app,
// such as the EditorView, know that new inputs are on the page
view.trigger("editorInputsAdded");
});
} catch (e) {
console.error("Could not render the Data Sensitivity section: ", e);
}
},
/*
* Renders the People section of the page
*/
renderPeople: function () {
var view = this,
model = view.model;
this.peopleSection = this.$(".section[data-section='people']");
// Empty the people section in case we are re-rendering people
// Insert the people template
this.peopleSection.html(this.peopleTemplate());
// Create a dropdown menu for adding new person types
this.renderPeopleDropdown();
EMLParty.prototype.partyTypes.forEach(function (partyType) {
// Make sure that there are no container elements saved
// in the partyType array, since we may need to re-create the
// containers the hold the rendered EMLParty information.
partyType.containerEl = null;
// Any party type that is listed as a role in EMLParty "roleOptions" is saved
// in the EML model as an associated party. The isAssociatedParty property
// is used for other parts of the EML211View.
if (
new EMLParty().get("roleOptions").includes(partyType.dataCategory)
) {
partyType.isAssociatedParty = true;
} else {
partyType.isAssociatedParty = false;
}
// Get the array of party members for the given partyType from the EML model
var parties = this.model.getPartiesByType(partyType.dataCategory);
// If no parties exist for the given party type, but one is required,
// (e.g. for contact and creator), then create one from the user's information.
if (!parties?.length && partyType.createFromUser) {
var newParty = new EMLParty({
type: partyType.isAssociatedParty
? "associatedParty"
: partyType.dataCategory,
roles: partyType.isAssociatedParty
? [partyType.dataCategory]
: [],
parentModel: model,
});
newParty.createFromUser();
model.addParty(newParty);
parties = [newParty];
}
// Render each party of this type
if (parties.length) {
parties.forEach(function (party) {
this.renderPerson(party, partyType.dataCategory);
}, this);
}
//If there are no parties of this type but they are required, then render a new empty person for this type
else if (
MetacatUI.appModel.get("emlEditorRequiredFields")[
partyType.dataCategory
]
) {
this.renderPerson(null, partyType.dataCategory);
}
}, this);
// Render a new blank party form at the very bottom of the people section.
// This allows the user to start entering details for a person before they've
// selected the party type.
this.renderPerson(null, "new");
// Initialize the tooltips
this.$("input.tooltip-this").tooltip({
placement: "top",
title: function () {
return $(this).attr("data-title") || $(this).attr("placeholder");
},
delay: 1000,
});
},
/**
* Creates and renders the dropdown at the bottom of the people section
* that allows the user to create a new party type category. The dropdown
* menu is saved to the view as view.partyMenu.
* @since 2.15.0
*/
renderPeopleDropdown: function () {
try {
var helpText =
"Optionally add other contributors, collaborators, and maintainers of this dataset.",
placeholderText = "Choose new person or organization role ...";
this.partyMenu = $(document.createElement("select"))
.attr("id", "new-party-menu")
.addClass("header-dropdown");
//Add the first option to the menu, which works as a label
this.partyMenu.append(
$(document.createElement("option")).text(placeholderText),
);
//Add some help text for the menu
this.partyMenu.attr("title", helpText);
//Add a container element for the new party
this.newPartyContainer = $(document.createElement("div"))
.attr("data-attribute", "new")
.addClass("row-striped");
//For each party type, add it to the menu as an option
EMLParty.prototype.partyTypes.forEach(function (partyType) {
$(this.partyMenu).append(
$(document.createElement("option"))
.val(partyType.dataCategory)
.text(partyType.label),
);
}, this);
// Add the menu and new party element to the page
this.peopleSection.append(this.partyMenu, this.newPartyContainer);
} catch (error) {
console.log(
"Error creating the menu for adding new party categories, error message: " +
error,
);
}
},
/**
* Render the information provided for a given EML party in the party section.
*
* @param {EMLParty} emlParty - the EMLParty model to render. If set to null, a new EML party will be created for the given party type.
* @param {string} partyType - The party type for which to render a new EML party. E.g. "creator", "coPrincipalInvestigator", etc.
*/
renderPerson: function (emlParty, partyType) {
// Whether or not this is a new emlParty model
var isNew = false;
//If no model is given, create a new model
if (!emlParty) {
var emlParty = new EMLParty({
parentModel: this.model,
});
//Mark this model as new
isNew = true;
// Find the party type or role based on the type given.
// Update the model.
if (partyType) {
var partyTypeProperties = _.findWhere(
EMLParty.prototype.partyTypes,
{ dataCategory: partyType },
);
if (partyTypeProperties) {
if (partyTypeProperties.isAssociatedParty) {
var newRoles = _.clone(emlParty.get("roles"));
newRoles.push(partyType);
emlParty.set("roles", newRoles);
} else {
emlParty.set("type", partyType);
}
}
}
} else {
//Get the party type, if it was not sent as a parameter
if (!partyType || !partyType.length) {
var partyType = emlParty.get("type");
if (
partyType == "associatedParty" ||
!partyType ||
!partyType.length
) {
partyType = emlParty.get("roles");
}
}
}
// partyType is a string when if it's a 'type' and an array if it's 'roles'
// If it's a string, convert to an array for the subsequent _.each() function
if (typeof partyType == "string") {
partyType = [partyType];
}
_.each(
partyType,
function (partyType) {
// The container for this specific party type
var container = null;
if (partyType === "new") {
container = this.newPartyContainer;
} else {
var partyTypeProperties = _.findWhere(
EMLParty.prototype.partyTypes,
{ dataCategory: partyType },
);
if (partyTypeProperties) {
container = partyTypeProperties.containerEl;
}
}
//See if this view already exists
if (!isNew && container && container.length && emlParty) {
var partyView;
_.each(container.find(".eml-party"), function (singlePartyEl) {
//If this EMLPartyView element is for the current model, then get the View
if ($(singlePartyEl).data("model") == emlParty)
partyView = $(singlePartyEl).data("view");
});
//If a partyView was found, just rerender it and exit
if (partyView) {
partyView.render();
return;
}
}
// If this person type is not on the page yet, add it.
// For now, this only adds the first role if person has multiple roles.
if (!container || !container.length) {
container = this.addNewPersonType(partyType);
}
//If there still is no partyView found, create a new one
var partyView = new EMLPartyView({
model: emlParty,
edit: this.edit,
isNew: isNew,
});
if (isNew) {
container.append(partyView.render().el);
} else {
if (container.find(".new").length)
container.find(".new").before(partyView.render().el);
else container.append(partyView.render().el);
}
},
this,
);
},
/*
* This function reacts to the user typing a new person in the person section (an EMLPartyView)
*/
handlePersonTyping: function (e) {
var container = $(e.target).parents(".eml-party"),
emlParty = container.length ? container.data("model") : null,
partyType =
container.length && emlParty
? emlParty.get("roles")[0] || emlParty.get("type")
: null;
(partyTypeProperties = _.findWhere(EMLParty.prototype.partyTypes, {
dataCategory: partyType,
})),
(numPartyForms = this.$(
"[data-attribute='" + partyType + "'] .eml-party",
).length),
(numNewPartyForms = this.$(
"[data-attribute='" + partyType + "'] .eml-party.new",
).length);
// If there is already a form to enter a new party for this party type, don't add another one
if (numNewPartyForms > 1) return;
// If there is a limit to how many party types can be added for this type,
// don't add more forms than is allowed
if (partyTypeProperties && partyTypeProperties.limit) {
return;
}
// Render a form to enter information for a new person
this.renderPerson(null, partyType);
},
/*
* This function is called when someone chooses a new person type from the dropdown list
*/
chooseNewPersonType: function (e) {
var partyType = $(e.target).val();
if (!partyType) return;
//Get the form and model
var partyForm = this.newPartyContainer,
partyModel = partyForm.find(".eml-party").data("model").clone(),
partyTypeProperties = _.findWhere(EMLParty.prototype.partyTypes, {
dataCategory: partyType,
});
// Remove this type from the dropdown menu
this.partyMenu.find("[value='" + partyType + "']").remove();
if (!partyModel.isEmpty()) {
//Update the model
if (partyTypeProperties.isAssociatedParty) {
var newRoles = _.clone(partyModel.get("roles"));
newRoles.push(partyType);
partyModel.set("roles", newRoles);
} else {
partyModel.set("type", partyType);
}
if (partyModel.isValid()) {
partyModel.mergeIntoParent();
// Add the person of that type (a section will be added if required)
this.renderPerson(partyModel, partyType);
// Clear and re-render the new person form
partyForm.empty();
this.renderPerson(null, "new");
} else {
partyForm.find(".eml-party").data("view").showValidation();
}
} else {
this.addNewPersonType(partyType);
}
},
/*
* addNewPersonType - Adds a header and container to the People section for the given party type/role,
* @return {JQuery} Returns the HTML element that contains each rendered EML Party for the given party type.
*/
addNewPersonType: function (partyType) {
if (!partyType) return;
var partyTypeProperties = _.findWhere(EMLParty.prototype.partyTypes, {
dataCategory: partyType,
});
if (!partyTypeProperties) {
return;
}
// If there is already a view for this person type, don't re-add it.
if (partyTypeProperties.containerEl) {
return;
}
// Container element to hold all parties of this type
var outerContainer = $(document.createElement("div")).addClass(
"party-type-container",
);
// Add a new header for the party type,
// plus an icon and spot for validation messages
var header = $(document.createElement("h4"))
.text(partyTypeProperties.label)
.append(
"<i class='required-icon hidden' data-category='" +
partyType +
"'></i>",
);
outerContainer.append(header);
// If there is a description, add that to the container as well
if (partyTypeProperties.description) {
outerContainer.append(
'<p class="subtle">' + partyTypeProperties.description + "</p>",
);
}
//Remove this type from the dropdown menu
this.partyMenu.find("[value='" + partyType + "']").remove();
//Add the new party container
partyTypeProperties.containerEl = $(document.createElement("div"))
.attr("data-attribute", partyType)
.attr("data-category", partyType)
.addClass("row-striped");
let notification = document.createElement("p");
notification.className = "notification";
notification.setAttribute("data-category", partyType);
partyTypeProperties.containerEl.append(notification);
outerContainer.append(partyTypeProperties.containerEl);
// Add in the new party type container just before the dropdown
this.partyMenu.before(outerContainer);
// Add a blank form to the new person type section, unless the max number
// for this party type has already been reached (e.g. when a new person type
// is added after copying from another type)
if (
typeof partyTypeProperties.limit !== "number" ||
this.model.getPartiesByType(partyType).length <
partyTypeProperties.limit
) {
this.renderPerson(null, partyType);
}
return partyTypeProperties.containerEl;
},
/*
* showCopyPersonMenu: Displays a modal window to the user with a list of roles that they can
* copy this person to
*/
showCopyPersonMenu: function (e) {
//Get the EMLParty to copy
var partyToCopy = $(e.target).parents(".eml-party").data("model"),
menu = this.$("#copy-person-menu");
//Check if the modal window menu has been created already
if (!menu.length) {
//Create the modal window menu from the template
menu = $(this.copyPersonMenuTemplate());
//Add to the DOM
this.$el.append(menu);
//Initialize the modal
menu.modal();
} else {
//Reset all the checkboxes
menu.find("input:checked").prop("checked", false);
menu
.find(".disabled")
.prop("disabled", false)
.removeClass("disabled")
.parent(".checkbox")
.attr("title", "");
}
//Disable the roles this person is already in
var currentRoles = partyToCopy.get("roles");
if (!currentRoles || !currentRoles.length) {
currentRoles = partyToCopy.get("type");
}
// "type" is a string and "roles" is an array.
// so that we can use _.each() on both, convert "type" to an array
if (typeof currentRoles === "string") {
currentRoles = [currentRoles];
}
_.each(
currentRoles,
function (currentRole) {
var partyTypeProperties = _.findWhere(
EMLParty.prototype.partyTypes,
{ dataCategory: currentRole },
),
label = partyTypeProperties ? partyTypeProperties.label : "";
menu
.find("input[value='" + currentRole + "']")
.prop("disabled", "disabled")
.addClass("disabled")
.parent(".checkbox")
.attr(
"title",
"This person is already in the " + label + " list.",
);
},
this,
);
// If the maximum number of parties has already been for this party type,
// then don't allow adding more.
var partiesWithLimits = _.filter(
EMLParty.prototype.partyTypes,
function (partyType) {
return typeof partyType.limit === "number";
},
);
partiesWithLimits.forEach(function (partyType) {
// See how many parties already exist for this type
var existingParties = this.model.getPartiesByType(
partyType.dataCategory,
);
if (
existingParties &&
existingParties.length &&
existingParties.length >= partyType.limit
) {
var names = _.map(existingParties, function (partyModel) {
var name = partyModel.getName();
if (name) {
return name;
} else {
return "Someone";
}
});
var sep = names.length === 2 ? " and " : ", ",
beVerbNames = names.length > 1 ? "are" : "is",
beVerbLimit = partyType.limit > 1 ? "are" : "is",
title =
names.join(sep) +
" " +
beVerbNames +
" already listed as " +
partyType.dataCategory +
". (Only " +
partyType.limit +
" " +
beVerbLimit +
" is allowed.)";
menu
.find("input[value='" + partyType.dataCategory + "']")
.prop("disabled", "disabled")
.addClass("disabled")
.parent(".checkbox")
.attr("title", title);
}
}, this);
//Attach the EMLParty to the menu DOMs
menu.data({
EMLParty: partyToCopy,
});
//Show the modal window menu now
menu.modal("show");
},
/*
* copyPerson: Gets the selected checkboxes from the copy person menu and copies the EMLParty
* to those new roles
*/
copyPerson: function () {
//Get all the checked boxes
var checkedBoxes = this.$("#copy-person-menu input:checked"),
//Get the EMLParty to copy
partyToCopy = this.$("#copy-person-menu").data("EMLParty");
//For each selected role,
_.each(
checkedBoxes,
function (checkedBox) {
//Get the roles
var role = $(checkedBox).val(),
partyTypeProperties = _.findWhere(EMLParty.prototype.partyTypes, {
dataCategory: role,
});
//Create a new EMLParty model
var newPerson = new EMLParty();
// Copy the attributes from the original person
// and set it on the new person
newPerson.set(partyToCopy.copyValues());
//If the new role is an associated party ...
if (partyTypeProperties.isAssociatedParty) {
newPerson.set("type", "associatedParty");
newPerson.set("roles", [role]);
}
//If the new role is not an associated party...
else {
newPerson.set("type", role);
newPerson.set("roles", newPerson.defaults().role);
}
//Add this new EMLParty to the EML model
this.model.addParty(newPerson);
// Add a view for the copied person
this.renderPerson(newPerson);
},
this,
);
//If there was at least one copy created, then trigger the change event
if (checkedBoxes.length) {
this.model.trickleUpChange();
}
},
removePerson: function (e) {
e.preventDefault();
//Get the party view el, view, and model
var partyEl = $(e.target).parents(".eml-party"),
partyView = partyEl.data("view"),
partyToRemove = partyEl.data("model");
//If there is no model found, we have nothing to do, so exit
if (!partyToRemove) return false;
//Call removeParty on the EML211 model to remove this EMLParty
this.model.removeParty(partyToRemove);
//Let the EMLPartyView remove itself
partyView.remove();
},
/**
* Attempt to move the current person (Party) one index backward (up).
*
* @param {EventHandler} e: The click event handler
*/
movePersonUp: function (e) {
e.preventDefault();
// Get the party view el, view, and model
var partyEl = $(e.target).parents(".eml-party"),
model = partyEl.data("model"),
next = $(partyEl).prev().not(".new");
if (next.length === 0) {
return;
}
// Remove current view, create and insert a new one for the model
$(partyEl).remove();
var newView = new EMLPartyView({
model: model,
edit: this.edit,
});
$(next).before(newView.render().el);
// Move the party down within the model too
this.model.movePartyUp(model);
this.model.trickleUpChange();
},
/**
* Attempt to move the current person (Party) one index forward (down).
*
* @param {EventHandler} e: The click event handler
*/
movePersonDown: function (e) {
e.preventDefault();
// Get the party view el, view, and model
var partyEl = $(e.target).parents(".eml-party"),
model = partyEl.data("model"),
next = $(partyEl).next().not(".new");
if (next.length === 0) {
return;
}
// Remove current view, create and insert a new one for the model
$(partyEl).remove();
var newView = new EMLPartyView({
model: model,
edit: this.edit,
});
$(next).after(newView.render().el);
// Move the party down within the model too
this.model.movePartyDown(model);
this.model.trickleUpChange();
},
/*
* Renders the Dates section of the page
*/
renderDates: function () {
//Add a header
this.$(".section.dates").html(
$(document.createElement("h2")).text("Dates"),
);
_.each(
this.model.get("temporalCoverage"),
function (model) {
var tempCovView = new EMLTempCoverageView({
model: model,
isNew: false,
edit: this.edit,
});
tempCovView.render();
this.$(".section.dates").append(tempCovView.el);
},
this,
);
if (!this.model.get("temporalCoverage").length) {
var tempCovView = new EMLTempCoverageView({
isNew: true,
edit: this.edit,
model: new EMLTemporalCoverage({ parentModel: this.model }),
});
tempCovView.render();
this.$(".section.dates").append(tempCovView.el);
}
},
/*
* Renders the Locations section of the page
*/
renderLocations: function () {
var locationsSection = this.$(".section.locations");
//Add the Locations header
locationsSection.html(this.locationsTemplate());
var locationsTable = locationsSection.find(".locations-table");
//Render an EMLGeoCoverage view for each EMLGeoCoverage model
_.each(
this.model.get("geoCoverage"),
function (geo, i) {
//Create an EMLGeoCoverageView
var geoView = new EMLGeoCoverageView({
model: geo,
edit: this.edit,
});
//Render the view
geoView.render();
geoView.$el
.find(".remove-container")
.append(
this.createRemoveButton(
null,
"geoCoverage",
".eml-geocoverage",
".locations-table",
),
);
//Add the locations section to the page
locationsTable.append(geoView.el);
//Listen to validation events
this.listenTo(geo, "valid", this.updateLocationsError);
//Save it in our subviews array
this.subviews.push(geoView);
},
this,
);
//Now add one empty row to enter a new geo coverage
if (this.edit) {
var newGeoModel = new EMLGeoCoverage({
parentModel: this.model,
isNew: true,
}),
newGeoView = new EMLGeoCoverageView({
edit: true,
model: newGeoModel,
isNew: true,
});
locationsTable.append(newGeoView.render().el);
newGeoView.$el
.find(".remove-container")
.append(
this.createRemoveButton(
null,
"geoCoverage",
".eml-geocoverage",
".locations-table",
),
);
//Listen to validation events
this.listenTo(newGeoModel, "valid", this.updateLocationsError);
}
},
/*
* Renders the Taxa section of the page
*/
renderTaxa: function () {
// const taxaSectionEl = this.$(".section.taxa");
const taxaSectionEl = document.querySelector(".section.taxa");
if (!taxaSectionEl) return;
// TODO
this.taxaView = new EMLTaxonView({
el: taxaSectionEl,
parentModel: this.model,
taxonArray: this.model.get("taxonCoverage"),
edit: this.edit,
// isNew: false, // needed?
// parentView: this, // needed?
}).render();
},
/*
* Renders the Methods section of the page
*/
renderMethods: function () {
var methodsModel = this.model.get("methods");
if (!methodsModel) {
methodsModel = new EMLMethods({
edit: this.edit,
parentModel: this.model,
});
}
this.$(".section.methods").html(
new EMLMethodsView({
model: methodsModel,
edit: this.edit,
parentEMLView: this,
}).render().el,
);
},
/*
* Renders the Projcet section of the page
*/
renderProject: function () {},
/*
* Renders the Sharing section of the page
*/
renderSharing: function () {},
/*
* Renders the funding field of the EML
*/
renderFunding: function () {
//Funding
var funding = this.model.get("project")
? this.model.get("project").get("funding")
: [];
//Clear the funding section
$(".section.overview .funding").empty();
//Create the funding input elements
_.each(
funding,
function (fundingItem, i) {
this.addFunding(fundingItem);
},
this,
);
//Add a blank funding input
this.addFunding();
},
/*
* Adds a single funding input row. Can either be called directly or used as an event callback
*/
addFunding: function (argument) {
if (this.edit) {
if (typeof argument == "string") var value = argument;
else if (!argument) var value = "";
//Don't add another new funding input if there already is one
else if (
!value &&
typeof argument == "object" &&
!$(argument.target).is(".new")
)
return;
else if (typeof argument == "object" && argument.target) {
var event = argument;
// Don't add a new funding row if the current one is empty
if ($(event.target).val().trim() === "") return;
}
var fundingInput = $(document.createElement("input"))
.attr("type", "text")
.attr("data-category", "funding")
.addClass("span12 funding hover-autocomplete-target")
.attr(
"placeholder",
"Search for NSF awards by keyword or enter custom funding information",
)
.val(value),
hiddenFundingInput = fundingInput
.clone()
.attr("type", "hidden")
.val(value)
.attr("id", "")
.addClass("hidden"),
loadingSpinner = $(document.createElement("i")).addClass(
"icon icon-spinner input-icon icon-spin subtle hidden",
);
//Append all the elements to a container
var containerEl = $(document.createElement("div"))
.addClass("ui-autocomplete-container funding-row")
.append(fundingInput, loadingSpinner, hiddenFundingInput);
if (!value) {
$(fundingInput).addClass("new");
if (event) {
$(event.target)
.parents("div.funding-row")
.append(
this.createRemoveButton(
"project",
"funding",
".funding-row",
"div.funding-container",
),
);
$(event.target).removeClass("new");
}
} else {
// Add a remove button if this is a non-new funding element
$(containerEl).append(
this.createRemoveButton(
"project",
"funding",
".funding-row",
"div.funding-container",
),
);
}
var view = this;
//Setup the autocomplete widget for the funding input
fundingInput.autocomplete({
source: function (request, response) {
var beforeRequest = function () {
loadingSpinner.show();
};
var afterRequest = function () {
loadingSpinner.hide();
};
return MetacatUI.appLookupModel.getGrantAutocomplete(
request,
response,
beforeRequest,
afterRequest,
);
},
select: function (e, ui) {
e.preventDefault();
var value =
"NSF Award " + ui.item.value + " (" + ui.item.label + ")";
hiddenFundingInput.val(value);
fundingInput.val(value);
$(".funding .ui-helper-hidden-accessible").hide();
view.updateFunding(e);
},
position: {
my: "left top",
at: "left bottom",
of: fundingInput,
collision: "fit",
},
appendTo: containerEl,
minLength: 3,
});
this.$(".funding-container").append(containerEl);
}
},
previewFundingRemove: function (e) {
$(e.target).parents(".funding-row").toggleClass("remove-preview");
},
handleFundingTyping: function (e) {
var fundingInput = $(e.target);
//If the funding value is at least one character
if (fundingInput.val().length > 0) {
//Get rid of the error styling in this row
fundingInput.parent(".funding-row").children().removeClass("error");
//If this was the only funding input with an error, we can safely remove the error message
if (!this.$("input.funding.error").length)
this.$("[data-category='funding'] .notification")
.removeClass("error")
.text("");
}
},
addKeyword: function (keyword, thesaurus) {
if (typeof keyword != "string" || !keyword) {
var keyword = "";
//Only show one new keyword row at a time
if (
this.$(".keyword.new").length == 1 &&
!this.$(".keyword.new").val()
)
return;
else if (this.$(".keyword.new").length > 1) return;
}
//Create the keyword row HTML
var row = $(document.createElement("div")).addClass(
"row-fluid keyword-row",
),
keywordInput = $(document.createElement("input"))
.attr("type", "text")
.addClass("keyword span10")
.attr("placeholder", "Add one new keyword"),
thesInput = $(document.createElement("select")).addClass(
"thesaurus span2",
),
thesOptionExists = false,
removeButton;
// Piece together the inputs
row.append(keywordInput, thesInput);
//Create the thesaurus options dropdown menu
_.each(MetacatUI.appModel.get("emlKeywordThesauri"), function (option) {
var optionEl = $(document.createElement("option"))
.val(option.thesaurus)
.text(option.label);
thesInput.append(optionEl);
if (option.thesaurus == thesaurus) {
optionEl.prop("selected", true);
thesOptionExists = true;
}
});
//Add a "None" option, which is always in the dropdown
thesInput.prepend(
$(document.createElement("option")).val("None").text("None"),
);
if (thesaurus == "None" || !thesaurus) {
thesInput.val("None");
}
//If this keyword is from a custom thesaurus that is NOT configured in this App, AND
// there is an option with the same label, then remove the option so it doesn't look like a duplicate.
else if (
!thesOptionExists &&
_.findWhere(MetacatUI.appModel.get("emlKeywordThesauri"), {
label: thesaurus,
})
) {
var duplicateOptions = thesInput.find(
"option:contains(" + thesaurus + ")",
);
duplicateOptions.each(function (i, option) {
if ($(option).text() == thesaurus && !$(option).prop("selected")) {
$(option).remove();
}
});
}
//If this keyword is from a custom thesaurus that is NOT configured in this App, then show it as a custom option
else if (!thesOptionExists) {
thesInput.append(
$(document.createElement("option"))
.val(thesaurus)
.text(thesaurus)
.prop("selected", true),
);
}
if (!keyword) row.addClass("new");
else {
//Set the keyword value on the text input
keywordInput.val(keyword);
// Add a remove button unless this is the .new keyword
row.append(
this.createRemoveButton(
null,
"keywordSets",
"div.keyword-row",
"div.keywords",
),
);
}
this.$(".keywords").append(row);
},
addNewKeyword: function (e) {
if ($(e.target).val().trim() === "") return;
$(e.target).parents(".keyword-row").first().removeClass("new");
// Add in a remove button
$(e.target)
.parents(".keyword-row")
.append(
this.createRemoveButton(
null,
"keywordSets",
"div.keyword-row",
"div.keywords",
),
);
var row = $(document.createElement("div"))
.addClass("row-fluid keyword-row new")
.data({ model: new EMLKeywordSet() }),
keywordInput = $(document.createElement("input"))
.attr("type", "text")
.addClass("keyword span10"),
thesInput = $(document.createElement("select")).addClass(
"thesaurus span2",
);
row.append(keywordInput, thesInput);
//Create the thesaurus options dropdown menu
_.each(MetacatUI.appModel.get("emlKeywordThesauri"), function (option) {
thesInput.append(
$(document.createElement("option"))
.val(option.thesaurus)
.text(option.label),
);
});
//Add a "None" option, which is always in the dropdown
thesInput.prepend(
$(document.createElement("option"))
.val("None")
.text("None")
.prop("selected", true),
);
this.$(".keywords").append(row);
},
previewKeywordRemove: function (e) {
var row = $(e.target)
.parents(".keyword-row")
.toggleClass("remove-preview");
},
/*
* Update the funding info when the form is changed
*/
updateFunding: function (e) {
if (!e) return;
var row = $(e.target).parent(".funding-row").first(),
rowNum = this.$(".funding-row").index(row),
input = $(row).find("input"),
isNew = $(row).is(".new");
var newValue = isNew
? $(e.target).siblings("input.hidden").val()
: $(e.target).val();
newValue = this.model.cleanXMLText(newValue);
if (typeof newValue == "string") {
newValue = newValue.trim();
}
//If there is no project model
if (!this.model.get("project")) {
var model = new EMLProject({ parentModel: this.model });
this.model.set("project", model);
} else var model = this.model.get("project");
var currentFundingValues = model.get("funding");
//If the new value is an empty string, then remove that index in the array
if (typeof newValue == "string" && newValue.trim().length == 0) {
currentFundingValues = currentFundingValues.splice(rowNum, 1);
} else {
currentFundingValues[rowNum] = newValue;
}
if (isNew && newValue != "") {
$(row).removeClass("new");
// Add in a remove button
$(e.target)
.parent()
.append(
this.createRemoveButton(
"project",
"funding",
".funding-row",
"div.funding-container",
),
);
this.addFunding();
}
this.model.trickleUpChange();
},
//TODO: Comma and semi-colon separate keywords
updateKeywords: function (e) {
var keywordSets = this.model.get("keywordSets"),
newKeywordSets = [];
//Get all the keywords in the view
_.each(
this.$(".keyword-row"),
function (thisRow) {
var thesaurus = this.model.cleanXMLText(
$(thisRow).find("select").val(),
),
keyword = this.model.cleanXMLText($(thisRow).find("input").val());
if (!keyword) return;
var keywordSet = _.find(newKeywordSets, function (keywordSet) {
return keywordSet.get("thesaurus") == thesaurus;
});
if (typeof keywordSet != "undefined") {
keywordSet.get("keywords").push(keyword);
} else {
newKeywordSets.push(
new EMLKeywordSet({
parentModel: this.model,
keywords: [keyword],
thesaurus: thesaurus,
}),
);
}
},
this,
);
//Update the EML model
this.model.set("keywordSets", newKeywordSets);
if (e) {
var row = $(e.target).parent(".keyword-row");
//Add a new row when the user has added a new keyword just now
if (row.is(".new")) {
row.removeClass("new");
row.append(
this.createRemoveButton(
null,
"keywordSets",
"div.keyword-row",
"div.keywords",
),
);
this.addKeyword();
}
}
},
/*
* Update the EML Geo Coverage models and views when the user interacts with the locations section
*/
updateLocations: function (e) {
if (!e) return;
e.preventDefault();
var viewEl = $(e.target).parents(".eml-geocoverage"),
geoCovModel = viewEl.data("model");
//If the EMLGeoCoverage is new
if (viewEl.is(".new")) {
if (this.$(".eml-geocoverage.new").length > 1) return;
//Render the new geo coverage view
var newGeo = new EMLGeoCoverageView({
edit: this.edit,
model: new EMLGeoCoverage({ parentModel: this.model, isNew: true }),
isNew: true,
});
this.$(".locations-table").append(newGeo.render().el);
newGeo.$el
.find(".remove-container")
.append(
this.createRemoveButton(
null,
"geoCoverage",
".eml-geocoverage",
".locations-table",
),
);
//Unmark the view as new
viewEl.data("view").notNew();
//Get the EMLGeoCoverage model attached to this EMlGeoCoverageView
var geoModel = viewEl.data("model"),
//Get the current EMLGeoCoverage models set on the parent EML model
currentCoverages = this.model.get("geoCoverage");
//Add this new geo coverage model to the parent EML model
if (Array.isArray(currentCoverages)) {
if (!_.contains(currentCoverages, geoModel)) {
currentCoverages.push(geoModel);
this.model.trigger("change:geoCoverage");
}
} else {
currentCoverages = [currentCoverages, geoModel];
this.model.set("geoCoverage", currentCoverages);
}
}
},
/*
* If all the EMLGeoCoverage models are valid, remove the error messages for the Locations section
*/
updateLocationsError: function () {
var allValid = _.every(
this.model.get("geoCoverage"),
function (geoCoverageModel) {
return geoCoverageModel.isValid();
},
);
if (allValid) {
this.$(".side-nav-item.error[data-category='geoCoverage']")
.removeClass("error")
.find(".icon.error")
.hide();
this.$(".section[data-section='locations'] .notification.error")
.removeClass("error")
.text("");
}
},
/*
* Creates the text elements
*/
createEMLText: function (textModel, edit, category) {
if (!textModel && edit) {
return $(document.createElement("textarea"))
.attr("data-category", category)
.addClass("xlarge text");
} else if (!textModel && !edit) {
return $(document.createElement("div")).attr(
"data-category",
category,
);
}
//Get the EMLText from the EML model
var finishedEl;
//Get the text attribute from the EMLText model
var paragraphs = textModel.get("text"),
paragraphsString = "";
//If the text should be editable,
if (edit) {
//Format the paragraphs with carriage returns between paragraphs
paragraphsString = paragraphs.join(String.fromCharCode(13));
//Create the textarea element
finishedEl = $(document.createElement("textarea"))
.addClass("xlarge text")
.attr("data-category", category)
.html(paragraphsString);
} else {
//Format the paragraphs with HTML
_.each(paragraphs, function (p) {
paragraphsString += "<p>" + p + "</p>";
});
//Create a div
finishedEl = $(document.createElement("div"))
.attr("data-category", category)
.append(paragraphsString);
}
$(finishedEl).data({ model: textModel });
//Return the finished DOM element
return finishedEl;
},
/*
* Updates a basic text field in the EML after the user changes the value
*/
updateText: function (e) {
if (!e) return false;
var category = $(e.target).attr("data-category"),
currentValue = this.model.get(category),
textModel = $(e.target).data("model"),
value = this.model.cleanXMLText($(e.target).val());
//We can't update anything without a category
if (!category) return false;
//Get the list of paragraphs - checking for carriage returns and line feeds
var paragraphsCR = value.split(String.fromCharCode(13));
var paragraphsLF = value.split(String.fromCharCode(10));
//Use the paragraph list that has the most
var paragraphs =
paragraphsCR > paragraphsLF ? paragraphsCR : paragraphsLF;
//If this category isn't set yet, then create a new EMLText model
if (!textModel) {
//Get the current value for this category and create a new EMLText model
var newTextModel = new EMLText({
text: paragraphs,
parentModel: this.model,
});
// Save the new model onto the underlying DOM node
$(e.target).data({ model: newTextModel });
//Set the new EMLText model on the EML model
if (Array.isArray(currentValue)) {
currentValue.push(newTextModel);
this.model.trigger("change:" + category);
this.model.trigger("change");
} else {
this.model.set(category, newTextModel);
}
}
//Update the existing EMLText model
else {
//If there are no paragraphs or all the paragraphs are empty...
if (
!paragraphs.length ||
_.every(paragraphs, function (p) {
return p.trim() == "";
})
) {
//Remove this text model from the array of text models since it is empty
var newValue = _.without(currentValue, textModel);
this.model.set(category, newValue);
} else {
textModel.set("text", paragraphs);
textModel.trigger("change:text");
//Is this text model set on the EML model?
if (
Array.isArray(currentValue) &&
!_.contains(currentValue, textModel)
) {
//Push this text model into the array of EMLText models
currentValue.push(textModel);
this.model.trigger("change:" + category);
this.model.trigger("change");
}
}
}
},
/*
* Creates and returns an array of basic text input field for editing
*/
createBasicTextFields: function (category, placeholder) {
var textContainer = $(document.createElement("div")).addClass(
"text-container",
),
modelValues = this.model.get(category),
textRow; // Holds the DOM for each field
//Format as an array
if (!Array.isArray(modelValues) && modelValues)
modelValues = [modelValues];
//For each value in this category, create an HTML element with the value inserted
_.each(
modelValues,
function (value, i, allModelValues) {
if (this.edit) {
var textRow = $(document.createElement("div")).addClass(
"basic-text-row",
),
input = $(document.createElement("input"))
.attr("type", "text")
.attr("data-category", category)
.addClass("basic-text");
textRow.append(input.clone().val(value));
if (category !== "title" && category !== "canonicalDataset")
textRow.append(
this.createRemoveButton(
null,
category,
"div.basic-text-row",
"div.text-container",
),
);
textContainer.append(textRow);
//At the end, append an empty input for the user to add a new one
if (
i + 1 == allModelValues.length &&
category !== "title" &&
category !== "canonicalDataset"
) {
var newRow = $(
$(document.createElement("div")).addClass("basic-text-row"),
);
newRow.append(
input
.clone()
.addClass("new")
.attr(
"placeholder",
placeholder || "Add a new " + category,
),
);
textContainer.append(newRow);
}
} else {
textContainer.append(
$(document.createElement("div"))
.addClass("basic-text-row")
.attr("data-category", category)
.text(value),
);
}
},
this,
);
if ((!modelValues || !modelValues.length) && this.edit) {
var input = $(document.createElement("input"))
.attr("type", "text")
.attr("data-category", category)
.addClass("basic-text new")
.attr("placeholder", placeholder || "Add a new " + category);
textContainer.append(
$(document.createElement("div"))
.addClass("basic-text-row")
.append(input),
);
}
return textContainer;
},
updateBasicText: function (e) {
if (!e) return false;
//Get the category, new value, and model
var category = $(e.target).attr("data-category"),
value = this.model.cleanXMLText($(e.target).val()),
model = $(e.target).data("model") || this.model;
//We can't update anything without a category
if (!category) return false;
//Get the current value
var currentValue = model.get(category);
//Insert the new value into the array
if (Array.isArray(currentValue)) {
//Find the position this text input is in
var position = $(e.target)
.parents("div.text-container")
.first()
.children("div")
.index($(e.target).parent());
//Set the value in that position in the array
currentValue[position] = value;
//Set the changed array on this model
model.set(category, currentValue);
model.trigger("change:" + category);
}
//Update the model if the current value is a string
else if (typeof currentValue == "string") {
model.set(category, [value]);
model.trigger("change:" + category);
} else if (!currentValue) {
model.set(category, [value]);
model.trigger("change:" + category);
}
//Add another blank text input
if (
$(e.target).is(".new") &&
value != "" &&
category != "title" &&
category !== "canonicalDataset"
) {
$(e.target).removeClass("new");
this.addBasicText(e);
}
// Trigger a change on the entire package
MetacatUI.rootDataPackage.packageModel.set("changed", true);
},
/* One-off handler for updating pubDate on the model when the form
input changes. Fairly similar but just a pared down version of
updateBasicText. */
updatePubDate: function (e) {
if (!e) return false;
this.model.set("pubDate", $(e.target).val().trim());
this.model.trigger("change");
// Trigger a change on the entire package
MetacatUI.rootDataPackage.packageModel.set("changed", true);
},
/*
* Adds a basic text input
*/
addBasicText: function (e) {
var category = $(e.target).attr("data-category"),
allBasicTexts = $(
".basic-text.new[data-category='" + category + "']",
);
//Only show one new row at a time
if (allBasicTexts.length == 1 && !allBasicTexts.val()) return;
else if (allBasicTexts.length > 1) return;
//We are only supporting one title right now
else if (category === "title" || category === "canonicalDataset")
return;
//Add another blank text input
var newRow = $(document.createElement("div")).addClass(
"basic-text-row",
);
newRow.append(
$(document.createElement("input"))
.attr("type", "text")
.attr("data-category", category)
.attr("placeholder", $(e.target).attr("placeholder"))
.addClass("new basic-text"),
);
$(e.target).parent().after(newRow);
$(e.target).after(
this.createRemoveButton(
null,
category,
".basic-text-row",
"div.text-container",
),
);
},
previewTextRemove: function (e) {
$(e.target).parents(".basic-text-row").toggleClass("remove-preview");
},
// publication date validation.
isDateFormatValid: function (dateString) {
//Date strings that are four characters should be a full year. Make sure all characters are numbers
if (dateString.length == 4) {
var digits = dateString.match(/[0-9]/g);
return digits.length == 4;
}
//Date strings that are 10 characters long should be a valid date
else {
var dateParts = dateString.split("-");
if (
dateParts.length != 3 ||
dateParts[0].length != 4 ||
dateParts[1].length != 2 ||
dateParts[2].length != 2
)
return false;
dateYear = dateParts[0];
dateMonth = dateParts[1];
dateDay = dateParts[2];
// Validating the values for the date and month if in YYYY-MM-DD format.
if (dateMonth < 1 || dateMonth > 12) return false;
else if (dateDay < 1 || dateDay > 31) return false;
else if (
(dateMonth == 4 ||
dateMonth == 6 ||
dateMonth == 9 ||
dateMonth == 11) &&
dateDay == 31
)
return false;
else if (dateMonth == 2) {
// Validation for leap year dates.
var isleap =
dateYear % 4 == 0 && (dateYear % 100 != 0 || dateYear % 400 == 0);
if (dateDay > 29 || (dateDay == 29 && !isleap)) return false;
}
var digits = _.filter(dateParts, function (part) {
return part.match(/[0-9]/g).length == part.length;
});
return digits.length == 3;
}
},
/* Event handler for showing validation messaging for the pubDate input
which has to conform to the EML yearDate type (YYYY or YYYY-MM-DD) */
showPubDateValidation: function (e) {
var container = $(e.target).parents(".pubDate").first(),
input = $(e.target),
messageEl = $(container).find(".notification"),
value = input.val(),
errors = [];
// Remove existing error borders and notifications
input.removeClass("error");
messageEl.text("");
messageEl.removeClass("error");
if (value != "" && value.length > 0) {
if (!this.isDateFormatValid(value)) {
errors.push(
"The value entered for publication date, '" +
value +
"' is not a valid value for this field. Enter either a year (e.g. 2017) or a date in the format YYYY-MM-DD.",
);
input.addClass("error");
}
}
if (errors.length > 0) {
messageEl.text(errors[0]).addClass("error");
}
},
updateRadioButtons: function (e) {
//Get the element of this radio button set that is checked
var choice = this.$(
"[name='" + $(e.target).attr("name") + "']:checked",
).val();
if (typeof choice == "undefined" || !choice)
this.model.set($(e.target).attr("data-category"), "");
else this.model.set($(e.target).attr("data-category"), choice);
this.model.trickleUpChange();
},
/*
* Switch to the given section
*/
switchSection: function (e) {
if (!e) return;
e.preventDefault();
var clickedEl = $(e.target),
section =
clickedEl.attr("data-section") ||
clickedEl.children("[data-section]").attr("data-section") ||
clickedEl.parents("[data-section]").attr("data-section");
if (this.visibleSection == "all") this.scrollToSection(section);
else {
this.$(".section." + this.activeSection).hide();
this.$(".section." + section).show();
this.highlightTOC(section);
this.activeSection = section;
this.visibleSection = section;
$("body").scrollTop(
this.$(".section." + section).offset().top - $("#Navbar").height(),
);
}
},
/*
* When a user clicks on the section names in the side tabs, jump to the section
*/
scrollToSection: function (e) {
if (!e) return false;
//Stop navigation
e.preventDefault();
var section = $(e.target).attr("data-section"),
sectionEl = this.$(".section." + section);
if (!sectionEl) return false;
//Temporarily unbind the scroll listener while we scroll to the clicked section
$(document).unbind("scroll");
var view = this;
setTimeout(function () {
$(document).scroll(view.highlightTOC.call(view));
}, 1500);
//Scroll to the section
if (sectionEl == section[0]) MetacatUI.appView.scrollToTop();
else MetacatUI.appView.scrollTo(sectionEl, $("#Navbar").outerHeight());
//Remove the active class from all the menu items
$(".side-nav-item a.active").removeClass("active");
//Set the clicked item to active
$(".side-nav-item a[data-section='" + section + "']").addClass(
"active",
);
//Set the active section on this view
this.activeSection = section;
},
/*
* Highlight the given menu item.
* The first argument is either an event object or the section name
*/
highlightTOC: function (section) {
this.resizeTOC();
//Now change sections
if (typeof section == "string") {
//Remove the active class from all the menu items
$(".side-nav-item a.active").removeClass("active");
$(".side-nav-item a[data-section='" + section + "']").addClass(
"active",
);
this.activeSection = section;
this.visibleSection = section;
return;
} else if (this.visibleSection == "all") {
//Remove the active class from all the menu items
$(".side-nav-item a.active").removeClass("active");
//Get the section
var top = $(window).scrollTop() + $("#Navbar").outerHeight() + 70,
sections = $(".metadata-container .section");
//If we're somewhere in the middle, find the right section
for (var i = 0; i < sections.length; i++) {
if (
top > $(sections[i]).offset().top &&
top < $(sections[i + 1]).offset().top
) {
$($(".side-nav-item a")[i]).addClass("active");
this.activeSection = $(sections[i]).attr("data-section");
this.visibleSection = $(sections[i]).attr("data-section");
break;
}
}
}
},
/*
* Resizes the vertical table of contents so it's always the same height as the editor body
*/
resizeTOC: function () {
var tableBottomHandle = $("#editor-body .ui-resizable-handle");
if (!tableBottomHandle.length) return;
var tableBottom = tableBottomHandle[0].getBoundingClientRect().bottom,
navTop = tableBottom;
if (tableBottom < $("#Navbar").outerHeight()) {
if ($("#Navbar").css("position") == "fixed")
navTop = $("#Navbar").outerHeight();
else navTop = 0;
}
$(".metadata-toc").css("top", navTop);
},
/*
* -- This function is for development/testing purposes only --
* Trigger a change on all the form elements
* so that when values are changed by Javascript, we make sure the change event
* is fired. This is good for capturing changes by Javascript, or
* browser plugins that fill-in forms, etc.
*/
triggerChanges: function () {
$("#metadata-container input").change();
$("#metadata-container textarea").change();
$("#metadata-container select").change();
},
/* Creates "Remove" buttons for removing non-required sections
of the EML from the DOM */
createRemoveButton: function (submodel, attribute, selector, container) {
return $(document.createElement("span"))
.addClass("icon icon-remove remove pointer")
.attr("title", "Remove")
.data({
submodel: submodel,
attribute: attribute,
selector: selector,
container: container,
});
},
/* Generic event handler for removing sections of the EML (both
the DOM and inside the EML211Model) */
handleRemove: function (e) {
var submodel = $(e.target).data("submodel"), // Optional sub-model to remove attribute from
attribute = $(e.target).data("attribute"), // Attribute on the EML211 model we're removing from
selector = $(e.target).data("selector"), // Selector to find the parent DOM elemente we'll remove
container = $(e.target).data("container"), // Selector to find the parent container so we can remove by index
parentEl, // Element we'll remove
model; // Specific sub-model we're removing
if (!attribute) return;
if (!container) return;
// Find the element we'll remove from the DOM
if (selector) {
parentEl = $(e.target).parents(selector).first();
} else {
parentEl = $(e.target).parents().first();
}
if (parentEl.length == 0) return;
// Handle remove on a EML model / sub-model
if (submodel) {
model = this.model.get(submodel);
if (!model) return;
// Get the current value of the attribute so we can remove from it
var currentValue, submodelIndex;
if (Array.isArray(this.model.get(submodel))) {
// Stop now if there's nothing to remove in the first place
if (this.model.get(submodel).length == 0) return;
// For multi-valued submodels, find *which* submodel we are removing or
// removingn from
submodelIndex = $(container).index(
$(e.target).parents(container).first(),
);
if (submodelIndex === -1) return;
currentValue = this.model
.get(submodel)
[submodelIndex].get(attribute);
} else {
currentValue = this.model.get(submodel).get(attribute);
}
//FInd the position of this field in the list of fields
var position = $(e.target)
.parents(container)
.first()
.children(selector)
.index($(e.target).parents(selector));
// Remove from the EML Model
if (position >= 0) {
if (Array.isArray(this.model.get(submodel))) {
currentValue.splice(position, 1); // Splice returns the removed members
this.model
.get(submodel)
[submodelIndex].set(attribute, currentValue);
} else {
currentValue.splice(position, 1); // Splice returns the removed members
this.model.get(submodel).set(attribute, currentValue);
}
}
} else if (selector) {
// Find the index this attribute is in the DOM
var position = $(e.target)
.parents(container)
.first()
.children(selector)
.index($(e.target).parents(selector));
//Remove this index of the array
var currentValue = this.model.get(attribute);
if (Array.isArray(currentValue)) currentValue.splice(position, 1);
//Set the array on the model so the 'set' function is executed
this.model.set(attribute, currentValue);
}
// Handle remove on a basic text field
else {
// The DOM order matches the EML model attribute order so we can remove
// by that
var position = $(e.target)
.parents(container)
.first()
.children(selector)
.index(selector);
var currentValue = this.model.get(attribute);
// Remove from the EML Model
if (position >= 0) {
currentValue.splice(position, 1);
this.model.set(attribute, currentValue);
}
}
// Trigger a change on the entire package
MetacatUI.rootDataPackage.packageModel.set("changed", true);
// Remove the DOM
$(parentEl).remove();
//updating the tablesIndex once the element has been removed
var tableNums = this.$(".editor-header-index");
for (var i = 0; i < tableNums.length; i++) {
$(tableNums[i]).text(i + 1);
}
// If this was a taxon, update the quickAdd interface
if (submodel === "taxonCoverage") {
this.taxaView.updateQuickAddTaxa();
}
},
/**
* Adds an {@link EMLAnnotation} to the {@link EML211} model currently being edited.
* Attributes for the annotation are retreived from the HTML attributes from the HTML element
* that was interacted with.
* @param {Event} e - An Event on an Element that contains {@link EMLAnnotation} data
*/
addAnnotation: function (e) {
try {
if (!e || !e.target) {
return;
}
let annotationData = _.clone(e.target.dataset);
//If this is a radio button, we only want one annotation of this type.
if (e.target.getAttribute("type") == "radio") {
annotationData.allowDuplicates = false;
}
//Set the valueURI from the input value
annotationData.valueURI = $(e.target).val();
//Reformat the propertyURI property
if (annotationData.propertyUri) {
annotationData.propertyURI = annotationData.propertyUri;
delete annotationData.propertyUri;
}
this.model.addAnnotation(annotationData);
} catch (error) {
console.error("Couldn't add annotation: ", e);
}
},
/* Close the view and its sub views */
onClose: function () {
this.remove(); // remove for the DOM, stop listening
this.off(); // remove callbacks, prevent zombies
this.model.off();
//Remove the scroll event listeners
$(document).unbind("scroll");
this.model = null;
this.subviews = [];
window.onbeforeunload = null;
},
},
);
return EMLView;
});