-
-
Notifications
You must be signed in to change notification settings - Fork 27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Allow adding, removing, and changing the "canonical dataset" in the Editor #2551
base: develop
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remaining comments which cannot be posted as a review comment to avoid GitHub Rate Limit
eslint
🚫 [eslint] <prefer-arrow-callback> reported by reviewdog 🐶
Unexpected function expression.
metacatui/src/js/views/metadata/EML211View.js
Lines 29 to 3151 in 43c3062
], function ( | |
_, | |
$, | |
Backbone, | |
ScienceMetadataView, | |
EMLGeoCoverageView, | |
EMLPartyView, | |
EMLMethodsView, | |
EMLTempCoverageView, | |
EML, | |
EMLGeoCoverage, | |
EMLKeywordSet, | |
EMLParty, | |
EMLProject, | |
EMLText, | |
EMLTaxonCoverage, | |
EMLTemporalCoverage, | |
EMLMethods, | |
Template, | |
PeopleTemplate, | |
EMLPartyCopyMenuTemplate, | |
OverviewTemplate, | |
DatesTemplate, | |
LocationsTemplate, | |
TaxonomicCoverageTemplate, | |
TaxonomicClassificationTable, | |
TaxonomicClassificationRow, | |
DataSensitivityTemplate, | |
) { | |
/** | |
* @class EMLView | |
* @classdesc An EMLView renders an editable view of an EML 2.1.1 document | |
* @classcategory Views/Metadata | |
* @extends ScienceMetadataView | |
*/ | |
var 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 .taxonomic-coverage": "updateTaxonCoverage", | |
"keyup .taxonomic-coverage .new input": "addNewTaxon", | |
"keyup .taxonomic-coverage .new select": "addNewTaxon", | |
"focusout .taxonomic-coverage tr": "showTaxonValidation", | |
"click .taxonomic-coverage-row .remove": "removeTaxonRank", | |
"mouseover .taxonomic-coverage .remove": "previewTaxonRemove", | |
"mouseout .taxonomic-coverage .remove": "previewTaxonRemove", | |
"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), | |
taxonomicCoverageTemplate: _.template(TaxonomicCoverageTemplate), | |
taxonomicClassificationTableTemplate: _.template( | |
TaxonomicClassificationTable, | |
), | |
taxonomicClassificationRowTemplate: _.template( | |
TaxonomicClassificationRow, | |
), | |
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); | |
//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 view = this; | |
const taxaSectionEl = this.$(".section.taxa"); | |
if (!taxaSectionEl) return; | |
taxaSectionEl.html($(document.createElement("h2")).text("Taxa")); | |
var taxonomy = this.model.get("taxonCoverage"); | |
// Render a set of tables for each taxonomicCoverage | |
if ( | |
typeof taxonomy !== "undefined" && | |
Array.isArray(taxonomy) && | |
taxonomy.length | |
) { | |
for (var i = 0; i < taxonomy.length; i++) { | |
taxaSectionEl.append(this.createTaxonomicCoverage(taxonomy[i])); | |
} | |
} else { | |
// Create a new one | |
var taxonCov = new EMLTaxonCoverage({ | |
parentModel: this.model, | |
}); | |
this.model.set("taxonCoverage", [taxonCov], { silent: true }); | |
taxaSectionEl.append(this.createTaxonomicCoverage(taxonCov)); | |
} | |
// updating the indexes of taxa-tables before rendering the information on page(view). | |
var taxaNums = this.$(".editor-header-index"); | |
for (var i = 0; i < taxaNums.length; i++) { | |
$(taxaNums[i]).text(i + 1); | |
} | |
// Insert the quick-add taxon options, if any are configured for this | |
// theme. See {@link AppModel#quickAddTaxa} | |
view.renderTaxaQuickAdd(); | |
// If duplicates are removed while saving, make sure to re-render the taxa | |
view.model.get("taxonCoverage").forEach(function (taxonCov) { | |
view.model.stopListening(taxonCov); | |
view.model.listenTo( | |
taxonCov, | |
"duplicateClassificationsRemoved", | |
function () { | |
view.renderTaxa(); | |
}, | |
); | |
}, view); | |
}, | |
/* | |
* 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"); | |
} | |
}, | |
// Creates a table to hold a single EMLTaxonCoverage element (table) for | |
// each root-level taxonomicClassification | |
createTaxonomicCoverage: function (coverage) { | |
var finishedEls = $( | |
this.taxonomicCoverageTemplate({ | |
generalTaxonomicCoverage: | |
coverage.get("generalTaxonomicCoverage") || "", | |
}), | |
), | |
coverageEl = finishedEls.filter(".taxonomic-coverage"); | |
coverageEl.data({ model: coverage }); | |
var classifications = coverage.get("taxonomicClassification"); | |
// Makes a table... for the root level | |
for (var i = 0; i < classifications.length; i++) { | |
coverageEl.append( | |
this.createTaxonomicClassificationTable(classifications[i]), | |
); | |
} | |
// Create a new, blank table for another taxonomicClassification | |
var newTableEl = this.createTaxonomicClassificationTable(); | |
coverageEl.append(newTableEl); | |
return finishedEls; | |
}, | |
createTaxonomicClassificationTable: function (classification) { | |
// updating the taxonomic table indexes before adding a new table to the page. | |
var taxaNums = this.$(".editor-header-index"); | |
for (var i = 0; i < taxaNums.length; i++) { | |
$(taxaNums[i]).text(i + 1); | |
} | |
// Adding the taxoSpeciesCounter to the table header for enhancement of the view | |
var finishedEl = $( | |
'<div class="row-striped root-taxonomic-classification-container"></div>', | |
); | |
$(finishedEl).append( | |
'<h6>Species <span class="editor-header-index">' + | |
(taxaNums.length + 1) + | |
"</span> </h6>", | |
); | |
// Add a remove button if this is not a new table | |
if (!(typeof classification === "undefined")) { | |
$(finishedEl).append( | |
this.createRemoveButton( | |
"taxonCoverage", | |
"taxonomicClassification", | |
".root-taxonomic-classification-container", | |
".taxonomic-coverage", | |
), | |
); | |
} | |
var tableEl = $(this.taxonomicClassificationTableTemplate()); | |
var tableBodyEl = $(document.createElement("tbody")); | |
var queue = [classification], | |
rows = [], | |
cur; | |
while (queue.length > 0) { | |
cur = queue.pop(); | |
// I threw this in here so I can this function without an | |
// argument to generate a new table from scratch | |
if (typeof cur === "undefined") { | |
continue; | |
} | |
cur.taxonRankName = cur.taxonRankName?.toLowerCase(); | |
rows.push(cur); | |
if (cur.taxonomicClassification) { | |
for (var i = 0; i < cur.taxonomicClassification.length; i++) { | |
queue.push(cur.taxonomicClassification[i]); | |
} | |
} | |
} | |
for (var j = 0; j < rows.length; j++) { | |
tableBodyEl.append(this.makeTaxonomicClassificationRow(rows[j])); | |
} | |
var newRowEl = this.makeNewTaxonomicClassificationRow(); | |
$(tableBodyEl).append(newRowEl); | |
$(tableEl).append(tableBodyEl); | |
// Add the new class to the entire table if it's a new one | |
if (typeof classification === "undefined") { | |
$(tableEl).addClass("new"); | |
} | |
$(finishedEl).append(tableEl); | |
return finishedEl; | |
}, | |
/** | |
* Create the HTML for a single row in a taxonomicClassification table | |
* @param {EMLTaxonCoverage#taxonomicClassification} classification A | |
* classification object from an EMLTaxonCoverage model, may include | |
* a taxonRank, taxonValue, taxonId, commonName, and nested | |
* taxonomicClassification objects | |
* @returns {jQuery} A jQuery object containing the HTML for a single | |
* row in a taxonomicClassification table | |
* @since 2.24.0 | |
*/ | |
makeTaxonomicClassificationRow: function (classification) { | |
try { | |
if (!classification) classification = {}; | |
var finishedEl = $( | |
this.taxonomicClassificationRowTemplate({ | |
taxonRankName: classification.taxonRankName || "", | |
taxonRankValue: classification.taxonRankValue || "", | |
}), | |
); | |
// Save a reference to other taxon attributes that we need to keep | |
// when serializing the model | |
if (classification.taxonId) { | |
$(finishedEl).data("taxonId", classification.taxonId); | |
} | |
if (classification.commonName) { | |
$(finishedEl).data("commonName", classification.commonName); | |
} | |
return finishedEl; | |
} catch (e) { | |
console.log("Error making taxonomic classification row: ", e); | |
} | |
}, | |
/** | |
* Create the HTML for a new row in a taxonomicClassification table | |
* @returns {jQuery} A jQuery object containing the HTML for a new row | |
* in a taxonomicClassification table | |
* @since 2.24.0 | |
*/ | |
makeNewTaxonomicClassificationRow: function () { | |
const row = this.makeTaxonomicClassificationRow({}); | |
$(row).addClass("new"); | |
return row; | |
}, | |
/* Update the underlying model and DOM for an EML TaxonomicCoverage | |
section. This method handles updating the underlying TaxonomicCoverage | |
models when the user changes form fields as well as inserting new | |
form fields automatically when the user needs them. | |
Since a dataset has multiple TaxonomicCoverage elements at the dataset | |
level, each Taxonomic Coverage is represented by a table element and | |
all taxonomicClassifications within are rows in that table. | |
TODO: Finish this function | |
TODO: Link this function into the DOM | |
*/ | |
updateTaxonCoverage: function (options) { | |
if (options.target) { | |
// Ignore the event if the target is a quick add taxon UI element. | |
const quickAddEl = $(this.taxonQuickAddEl); | |
if (quickAddEl && quickAddEl.has(options.target).length) { | |
return; | |
} | |
var e = options; | |
/* Getting `model` here is different than in other places because | |
the thing being updated is an `input` or `select` element which | |
is part of a `taxonomicClassification`. The model is | |
`TaxonCoverage` which has one or more | |
`taxonomicClassifications`. So we have to walk up to the | |
hierarchy from input < td < tr < tbody < table < div to get at | |
the underlying TaxonCoverage model. | |
*/ | |
var coverage = $(e.target).parents(".taxonomic-coverage"), | |
classificationEl = $(e.target).parents( | |
".root-taxonomic-classification", | |
), | |
model = $(coverage).data("model") || this.model, | |
category = $(e.target).attr("data-category"), | |
value = this.model.cleanXMLText($(e.target).val()); | |
//We can't update anything without a coverage, or | |
//classification | |
if (!coverage) return false; | |
if (!classificationEl) return false; | |
// Use `category` to determine if we're updating the generalTaxonomicCoverage or | |
// the taxonomicClassification | |
if (category && category === "generalTaxonomicCoverage") { | |
model.set("generalTaxonomicCoverage", value); | |
return; | |
} | |
} else { | |
var coverage = options.coverage, | |
model = $(coverage).data("model"); | |
} | |
// Find all of the root-level taxonomicClassifications | |
var classificationTables = $(coverage).find( | |
".root-taxonomic-classification", | |
); | |
if (!classificationTables) return false; | |
//TODO :This should probably (at least) be in its own View and | |
//definitely refactored into tidy functions.*/ | |
var rows, | |
collectedClassifications = []; | |
for (var i = 0; i < classificationTables.length; i++) { | |
rows = $(classificationTables[i]).find("tbody tr"); | |
if (!rows) continue; | |
var topLevelClassification = {}, | |
classification = topLevelClassification, | |
currentRank, | |
currentValue; | |
for (var j = 0; j < rows.length; j++) { | |
const thisRow = rows[j]; | |
currentRank = | |
this.model.cleanXMLText($(thisRow).find("select").val()) || ""; | |
currentValue = | |
this.model.cleanXMLText($(thisRow).find("input").val()) || ""; | |
// Maintain classification attributes that exist in the EML but are not visible in the editor | |
const taxonId = $(thisRow).data("taxonId"); | |
const commonName = $(thisRow).data("commonName"); | |
// Skip over rows with empty Rank or Value | |
if (!currentRank.length || !currentValue.length) { | |
continue; | |
} | |
//After the first row, start nesting taxonomicClassification objects | |
if (j > 0) { | |
classification.taxonomicClassification = [{}]; | |
classification = classification.taxonomicClassification[0]; | |
} | |
// Add it to the classification object | |
classification.taxonRankName = currentRank; | |
classification.taxonRankValue = currentValue; | |
classification.taxonId = taxonId; | |
classification.commonName = commonName; | |
} | |
//Add the top level classification to the array | |
if (Object.keys(topLevelClassification).length) | |
collectedClassifications.push(topLevelClassification); | |
} | |
if ( | |
!_.isEqual( | |
collectedClassifications, | |
model.get("taxonomicClassification"), | |
) | |
) { | |
model.set("taxonomicClassification", collectedClassifications); | |
this.model.trigger("change"); | |
} | |
// Handle adding new tables and rows | |
// Do nothing if the value isn't set | |
if (value) { | |
// Add a new row if this is itself a new row | |
if ($(e.target).parents("tr").first().is(".new")) { | |
var newRowEl = this.makeNewTaxonomicClassificationRow(); | |
$(e.target).parents("tbody").first().append(newRowEl); | |
$(e.target).parents("tr").first().removeClass("new"); | |
} | |
// Add a new classification table if this is itself a new table | |
if ($(classificationEl).is(".new")) { | |
$(classificationEl).removeClass("new"); | |
$(classificationEl).append( | |
this.createRemoveButton( | |
"taxonCoverage", | |
"taxonomicClassification", | |
".root-taxonomic-classification-container", | |
".taxonomic-coverage", | |
), | |
); | |
$(coverage).append(this.createTaxonomicClassificationTable()); | |
} | |
} | |
// update the quick add interface | |
this.updateQuickAddTaxa(); | |
}, | |
/** | |
* Update the options for the quick add taxon select interface. This | |
* ensures that only taxonomic classifications that are not already | |
* included in the taxonomic coverage are available for selection. | |
* @since 2.24.0 | |
*/ | |
updateQuickAddTaxa: function () { | |
const selects = this.taxonSelects; | |
if (!selects || !selects.length) return; | |
const taxa = this.getTaxonQuickAddOptions(); | |
if (!taxa || !taxa.length) return; | |
selects.forEach((select, i) => { | |
select.updateOptions(taxa[i].options); | |
}); | |
}, | |
/* | |
* Adds a new row and/or table to the taxonomic coverage section | |
*/ | |
addNewTaxon: function (e) { | |
// Don't do anything if the current classification doesn't have new content | |
if ($(e.target).val().trim() === "") return; | |
// If the row is new, add a new row to the table | |
if ($(e.target).parents("tr").is(".new")) { | |
var newRow = this.makeNewTaxonomicClassificationRow(); | |
//Append the new row and remove the new class from the old row | |
$(e.target).parents("tr").removeClass("new").after(newRow); | |
} | |
}, | |
/** | |
* Insert the "quick add" interface for adding common taxa to the | |
* taxonomic coverage section. Only renders if there is a list of taxa | |
* configured in the appModel. | |
*/ | |
renderTaxaQuickAdd: function () { | |
try { | |
const view = this; | |
// To render the taxon select, the view must be in editor mode and we | |
// need a list of taxa configured for the theme | |
if (!view.edit) return; | |
// remove any existing quick add interface: | |
if (view.taxonQuickAddEl) view.taxonQuickAddEl.remove(); | |
const quickAddTaxa = view.getTaxonQuickAddOptions(); | |
if (!quickAddTaxa || !quickAddTaxa.length) { | |
// If the taxa are configured as SID for a dataObject, then wait | |
// for the dataObject to be loaded | |
this.listenToOnce( | |
MetacatUI.appModel, | |
"change:quickAddTaxa", | |
this.renderTaxaQuickAdd, | |
); | |
return; | |
} | |
// Create & insert the basic HTML for the taxon select interface | |
const template = `<div class="taxa-quick-add"> | |
<p class="taxa-quick-add__text"> | |
<b>⭐️ Quick Add Taxa:</b> Select one or more common taxa. Click "Add" to add them to the list. | |
</p> | |
<div class="taxa-quick-add__controls"> | |
<div class="taxa-quick-add__selects"></div> | |
<button class="btn btn-primary taxa-quick-add__button">Add Taxa</button> | |
</div> | |
</div>`; | |
const parser = new DOMParser(); | |
const doc = parser.parseFromString(template, "text/html"); | |
const quickAddEl = doc.body.firstChild; | |
const button = quickAddEl.querySelector("button"); | |
const container = quickAddEl.querySelector( | |
".taxa-quick-add__selects", | |
); | |
const rowSelector = ".root-taxonomic-classification-container"; | |
const firstRow = document.querySelector(rowSelector); | |
firstRow.parentNode.insertBefore(quickAddEl, firstRow); | |
view.taxonQuickAddEl = quickAddEl; | |
// Update the taxon coverage when the button is clicked | |
const onButtonClick = () => { | |
const taxonSelects = view.taxonSelects; | |
if (!taxonSelects || !taxonSelects.length) return; | |
const selectedItems = taxonSelects | |
.map((select) => select.model.get("selected")) | |
.flat(); | |
if (!selectedItems || !selectedItems.length) return; | |
const selectedItemObjs = selectedItems.map((item) => { | |
try { | |
// It will be encoded JSON if it's a pre-defined taxon | |
return JSON.parse(decodeURIComponent(item)); | |
} catch (e) { | |
// Otherwise it will be a string a user typed in | |
return { | |
taxonRankName: "", | |
taxonRankValue: item, | |
}; | |
} | |
}); | |
view.addTaxa(selectedItemObjs); | |
taxonSelects.forEach((select) => | |
select.model.setSelected([], { silent: true }), | |
); | |
}; | |
button.removeEventListener("click", onButtonClick); | |
button.addEventListener("click", onButtonClick); | |
// Create the search selects | |
view.taxonSelects = []; | |
const componentPath = "views/searchSelect/SearchSelectView"; | |
require([componentPath], function (SearchSelect) { | |
quickAddTaxa.forEach((taxaList, i) => { | |
try { | |
const taxaInput = new SearchSelect({ | |
options: taxaList.options, | |
placeholderText: taxaList.placeholder, | |
inputLabel: taxaList.label, | |
allowMulti: true, | |
allowAdditions: true, | |
separatorTextOptions: false, | |
selected: [], | |
}); | |
container.appendChild(taxaInput.el); | |
taxaInput.render(); | |
view.taxonSelects.push(taxaInput); | |
} catch (e) { | |
console.log("Failed to create taxon select: ", e); | |
} | |
}); | |
}); | |
} catch (e) { | |
console.log("Failed to render taxon select: ", e); | |
} | |
}, | |
/** | |
* Get the list of options for the taxon quick add interface. Filter | |
* out any that have already been added to the taxonomic coverage. | |
* @returns {Object[]} An array of search select options | |
* @since 2.24.0 | |
*/ | |
getTaxonQuickAddOptions: function () { | |
const quickAddTaxa = MetacatUI.appModel.getQuickAddTaxa(); | |
if (!quickAddTaxa || !quickAddTaxa.length) return; | |
const coverages = this.model.get("taxonCoverage"); | |
for (const taxaList of quickAddTaxa) { | |
const opts = []; | |
for (const taxon of taxaList.taxa) { | |
// check that it is not a duplicate in any coverages | |
let isDuplicate = false; | |
for (cov of coverages) { | |
if (cov.isDuplicate(taxon)) { | |
isDuplicate = true; | |
break; | |
} | |
} | |
if (!isDuplicate) { | |
opts.push(this.taxonOptionToSearchSelectItem(taxon)); | |
} | |
} | |
taxaList.options = opts; | |
} | |
return quickAddTaxa; | |
}, | |
/** | |
* Reformats a taxon option, as provided in the appModel | |
* {@link AppModel#quickAddTaxa}, as a search select item. | |
* @param {Object} option A single taxon classification with at least a | |
* taxonRankValue and taxonRankName. It may also have a taxonId (object | |
* with provider and value) and a commonName. | |
* @returns {Object} A search select item with label, value, and | |
* description properties. | |
*/ | |
taxonOptionToSearchSelectItem: function (option) { | |
try { | |
// option must have a taxonRankValue and taxonRankName or it is invalid | |
if (!option.taxonRankValue || !option.taxonRankName) { | |
console.log("Invalid taxon option: ", option); | |
return null; | |
} | |
// Create a description | |
let description = option.taxonRankName + ": " + option.taxonRankValue; | |
if (option.taxonId) { | |
description += | |
" (" + | |
option.taxonId.provider + | |
": " + | |
option.taxonId.value + | |
")"; | |
} | |
// search select doesn't work with some of the json characters | |
const val = encodeURIComponent(JSON.stringify(option)); | |
return { | |
label: option.commonName || option.taxonRankValue, | |
value: val, | |
description: description, | |
}; | |
} catch (e) { | |
console.log( | |
"Failed to reformat taxon option as search select item: ", | |
e, | |
); | |
return null; | |
} | |
}, | |
/** | |
* Add new taxa to the EML model and re-render the taxa section. The new | |
* taxa will be added to the first <taxonomicCoverage> element in the EML | |
* model. If there is no <taxonomicCoverage> element, one will be created. | |
* @param {Object[]} newClassifications - An array of objects with any of | |
* the following properties: | |
* - taxonRankName: (sting) The name of the taxonomic rank, e.g. | |
* "Kingdom" | |
* - taxonRankValue: (string) The value of the taxonomic rank, e.g. | |
* "Animalia" | |
* - commonName: (string) The common name of the taxon, e.g. "Animals" | |
* - taxonId: (object) The official ID of the taxon, including "provider" | |
* and "value". | |
* - taxonomicClassification: (array) An array of nested taxonomic | |
* classifications | |
* @since 2.24.0 | |
* @example | |
* this.addTaxon([{ | |
* taxonRankName: "Kingdom", | |
* taxonRankValue: "Animalia", | |
* commonName: "Animals", | |
* taxonId: { | |
* provider: "https://www.itis.gov/", | |
* value: "202423" | |
* }]); | |
*/ | |
addTaxa: function (newClassifications) { | |
try { | |
// TODO: validate the new taxon before adding it to the model? | |
const taxonCoverages = this.model.get("taxonCoverage"); | |
// We expect that there is already a taxonCoverage array on the model. | |
// If the EML was made in the editor, there can only be one | |
// <taxonomicCoverage> element. Add the new taxon to its | |
// <taxonomicClassification> array. If there is more than one, then the | |
// new taxon will be added to the first <taxonomicCoverage> element. | |
if (taxonCoverages && taxonCoverages.length >= 1) { | |
const taxonCoverage = taxonCoverages[0]; | |
const classifications = taxonCoverage.get( | |
"taxonomicClassification", | |
); | |
const allClass = classifications.concat(newClassifications); | |
taxonCoverage.set("taxonomicClassification", allClass); | |
} else { | |
// If there is no <taxonomicCoverage> element for some reason, | |
// create one and add the new taxon to its <taxonomicClassification> | |
// array. | |
const newCov = new EMLTaxonCoverage({ | |
taxonomicClassification: newClassifications, | |
parentModel: this.model, | |
}); | |
this.model.set("taxonCoverage", [newCov]); | |
} | |
// Re-render the taxa section | |
this.renderTaxa(); | |
} catch (e) { | |
console.log("Error adding taxon to EML model: ", e); | |
} | |
}, | |
removeTaxonRank: function (e) { | |
var row = $(e.target).parents(".taxonomic-coverage-row"), | |
coverageEl = $(row).parents(".taxonomic-coverage"), | |
view = this; | |
//Animate the row away and then remove it | |
row.slideUp("fast", function () { | |
row.remove(); | |
view.updateTaxonCoverage({ coverage: coverageEl }); | |
}); | |
}, | |
/* | |
* After the user focuses out, show validation help, if needed | |
*/ | |
showTaxonValidation: function (e) { | |
//Get the text inputs and select menus | |
var row = $(e.target).parents("tr"), | |
allInputs = row.find("input, select"), | |
tableContainer = $(e.target).parents("table"), | |
errorInputs = []; | |
//If none of the inputs have a value and this is a new row, then do nothing | |
if ( | |
_.every(allInputs, function (i) { | |
return !i.value; | |
}) && | |
row.is(".new") | |
) | |
return; | |
//Add the error styling to any input with no value | |
_.each(allInputs, function (input) { | |
// Keep track of the number of clicks of each input element so we only show the | |
// error message after the user has focused on both input elements | |
if (!input.value) errorInputs.push(input); | |
}); | |
if (errorInputs.length) { | |
//Show the error message after a brief delay | |
setTimeout(function () { | |
//If the user focused on another element in the same row, don't do anything | |
if (_.contains(allInputs, document.activeElement)) return; | |
//Add the error styling | |
$(errorInputs).addClass("error"); | |
//Add the error message | |
if (!tableContainer.prev(".notification").length) { | |
tableContainer.before( | |
$(document.createElement("p")) | |
.addClass("error notification") | |
.text("Enter a rank name AND value in each row."), | |
); | |
} | |
}, 200); | |
} else { | |
allInputs.removeClass("error"); | |
if (!tableContainer.find(".error").length) | |
tableContainer.prev(".notification").remove(); | |
} | |
}, | |
previewTaxonRemove: function (e) { | |
var removeBtn = $(e.target); | |
if (removeBtn.parent().is(".root-taxonomic-classification")) { | |
removeBtn.parent().toggleClass("remove-preview"); | |
} else { | |
removeBtn | |
.parents(".taxonomic-coverage-row") | |
.toggleClass("remove-preview"); | |
} | |
}, | |
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.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; | |
}); |
🚫 [eslint] <no-var> reported by reviewdog 🐶
Unexpected var, use let or const instead.
metacatui/src/js/views/metadata/EML211View.js
Lines 64 to 3149 in 43c3062
var 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 .taxonomic-coverage": "updateTaxonCoverage", | |
"keyup .taxonomic-coverage .new input": "addNewTaxon", | |
"keyup .taxonomic-coverage .new select": "addNewTaxon", | |
"focusout .taxonomic-coverage tr": "showTaxonValidation", | |
"click .taxonomic-coverage-row .remove": "removeTaxonRank", | |
"mouseover .taxonomic-coverage .remove": "previewTaxonRemove", | |
"mouseout .taxonomic-coverage .remove": "previewTaxonRemove", | |
"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), | |
taxonomicCoverageTemplate: _.template(TaxonomicCoverageTemplate), | |
taxonomicClassificationTableTemplate: _.template( | |
TaxonomicClassificationTable, | |
), | |
taxonomicClassificationRowTemplate: _.template( | |
TaxonomicClassificationRow, | |
), | |
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); | |
//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 view = this; | |
const taxaSectionEl = this.$(".section.taxa"); | |
if (!taxaSectionEl) return; | |
taxaSectionEl.html($(document.createElement("h2")).text("Taxa")); | |
var taxonomy = this.model.get("taxonCoverage"); | |
// Render a set of tables for each taxonomicCoverage | |
if ( | |
typeof taxonomy !== "undefined" && | |
Array.isArray(taxonomy) && | |
taxonomy.length | |
) { | |
for (var i = 0; i < taxonomy.length; i++) { | |
taxaSectionEl.append(this.createTaxonomicCoverage(taxonomy[i])); | |
} | |
} else { | |
// Create a new one | |
var taxonCov = new EMLTaxonCoverage({ | |
parentModel: this.model, | |
}); | |
this.model.set("taxonCoverage", [taxonCov], { silent: true }); | |
taxaSectionEl.append(this.createTaxonomicCoverage(taxonCov)); | |
} | |
// updating the indexes of taxa-tables before rendering the information on page(view). | |
var taxaNums = this.$(".editor-header-index"); | |
for (var i = 0; i < taxaNums.length; i++) { | |
$(taxaNums[i]).text(i + 1); | |
} | |
// Insert the quick-add taxon options, if any are configured for this | |
// theme. See {@link AppModel#quickAddTaxa} | |
view.renderTaxaQuickAdd(); | |
// If duplicates are removed while saving, make sure to re-render the taxa | |
view.model.get("taxonCoverage").forEach(function (taxonCov) { | |
view.model.stopListening(taxonCov); | |
view.model.listenTo( | |
taxonCov, | |
"duplicateClassificationsRemoved", | |
function () { | |
view.renderTaxa(); | |
}, | |
); | |
}, view); | |
}, | |
/* | |
* 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"); | |
} | |
}, | |
// Creates a table to hold a single EMLTaxonCoverage element (table) for | |
// each root-level taxonomicClassification | |
createTaxonomicCoverage: function (coverage) { | |
var finishedEls = $( | |
this.taxonomicCoverageTemplate({ | |
generalTaxonomicCoverage: | |
coverage.get("generalTaxonomicCoverage") || "", | |
}), | |
), | |
coverageEl = finishedEls.filter(".taxonomic-coverage"); | |
coverageEl.data({ model: coverage }); | |
var classifications = coverage.get("taxonomicClassification"); | |
// Makes a table... for the root level | |
for (var i = 0; i < classifications.length; i++) { | |
coverageEl.append( | |
this.createTaxonomicClassificationTable(classifications[i]), | |
); | |
} | |
// Create a new, blank table for another taxonomicClassification | |
var newTableEl = this.createTaxonomicClassificationTable(); | |
coverageEl.append(newTableEl); | |
return finishedEls; | |
}, | |
createTaxonomicClassificationTable: function (classification) { | |
// updating the taxonomic table indexes before adding a new table to the page. | |
var taxaNums = this.$(".editor-header-index"); | |
for (var i = 0; i < taxaNums.length; i++) { | |
$(taxaNums[i]).text(i + 1); | |
} | |
// Adding the taxoSpeciesCounter to the table header for enhancement of the view | |
var finishedEl = $( | |
'<div class="row-striped root-taxonomic-classification-container"></div>', | |
); | |
$(finishedEl).append( | |
'<h6>Species <span class="editor-header-index">' + | |
(taxaNums.length + 1) + | |
"</span> </h6>", | |
); | |
// Add a remove button if this is not a new table | |
if (!(typeof classification === "undefined")) { | |
$(finishedEl).append( | |
this.createRemoveButton( | |
"taxonCoverage", | |
"taxonomicClassification", | |
".root-taxonomic-classification-container", | |
".taxonomic-coverage", | |
), | |
); | |
} | |
var tableEl = $(this.taxonomicClassificationTableTemplate()); | |
var tableBodyEl = $(document.createElement("tbody")); | |
var queue = [classification], | |
rows = [], | |
cur; | |
while (queue.length > 0) { | |
cur = queue.pop(); | |
// I threw this in here so I can this function without an | |
// argument to generate a new table from scratch | |
if (typeof cur === "undefined") { | |
continue; | |
} | |
cur.taxonRankName = cur.taxonRankName?.toLowerCase(); | |
rows.push(cur); | |
if (cur.taxonomicClassification) { | |
for (var i = 0; i < cur.taxonomicClassification.length; i++) { | |
queue.push(cur.taxonomicClassification[i]); | |
} | |
} | |
} | |
for (var j = 0; j < rows.length; j++) { | |
tableBodyEl.append(this.makeTaxonomicClassificationRow(rows[j])); | |
} | |
var newRowEl = this.makeNewTaxonomicClassificationRow(); | |
$(tableBodyEl).append(newRowEl); | |
$(tableEl).append(tableBodyEl); | |
// Add the new class to the entire table if it's a new one | |
if (typeof classification === "undefined") { | |
$(tableEl).addClass("new"); | |
} | |
$(finishedEl).append(tableEl); | |
return finishedEl; | |
}, | |
/** | |
* Create the HTML for a single row in a taxonomicClassification table | |
* @param {EMLTaxonCoverage#taxonomicClassification} classification A | |
* classification object from an EMLTaxonCoverage model, may include | |
* a taxonRank, taxonValue, taxonId, commonName, and nested | |
* taxonomicClassification objects | |
* @returns {jQuery} A jQuery object containing the HTML for a single | |
* row in a taxonomicClassification table | |
* @since 2.24.0 | |
*/ | |
makeTaxonomicClassificationRow: function (classification) { | |
try { | |
if (!classification) classification = {}; | |
var finishedEl = $( | |
this.taxonomicClassificationRowTemplate({ | |
taxonRankName: classification.taxonRankName || "", | |
taxonRankValue: classification.taxonRankValue || "", | |
}), | |
); | |
// Save a reference to other taxon attributes that we need to keep | |
// when serializing the model | |
if (classification.taxonId) { | |
$(finishedEl).data("taxonId", classification.taxonId); | |
} | |
if (classification.commonName) { | |
$(finishedEl).data("commonName", classification.commonName); | |
} | |
return finishedEl; | |
} catch (e) { | |
console.log("Error making taxonomic classification row: ", e); | |
} | |
}, | |
/** | |
* Create the HTML for a new row in a taxonomicClassification table | |
* @returns {jQuery} A jQuery object containing the HTML for a new row | |
* in a taxonomicClassification table | |
* @since 2.24.0 | |
*/ | |
makeNewTaxonomicClassificationRow: function () { | |
const row = this.makeTaxonomicClassificationRow({}); | |
$(row).addClass("new"); | |
return row; | |
}, | |
/* Update the underlying model and DOM for an EML TaxonomicCoverage | |
section. This method handles updating the underlying TaxonomicCoverage | |
models when the user changes form fields as well as inserting new | |
form fields automatically when the user needs them. | |
Since a dataset has multiple TaxonomicCoverage elements at the dataset | |
level, each Taxonomic Coverage is represented by a table element and | |
all taxonomicClassifications within are rows in that table. | |
TODO: Finish this function | |
TODO: Link this function into the DOM | |
*/ | |
updateTaxonCoverage: function (options) { | |
if (options.target) { | |
// Ignore the event if the target is a quick add taxon UI element. | |
const quickAddEl = $(this.taxonQuickAddEl); | |
if (quickAddEl && quickAddEl.has(options.target).length) { | |
return; | |
} | |
var e = options; | |
/* Getting `model` here is different than in other places because | |
the thing being updated is an `input` or `select` element which | |
is part of a `taxonomicClassification`. The model is | |
`TaxonCoverage` which has one or more | |
`taxonomicClassifications`. So we have to walk up to the | |
hierarchy from input < td < tr < tbody < table < div to get at | |
the underlying TaxonCoverage model. | |
*/ | |
var coverage = $(e.target).parents(".taxonomic-coverage"), | |
classificationEl = $(e.target).parents( | |
".root-taxonomic-classification", | |
), | |
model = $(coverage).data("model") || this.model, | |
category = $(e.target).attr("data-category"), | |
value = this.model.cleanXMLText($(e.target).val()); | |
//We can't update anything without a coverage, or | |
//classification | |
if (!coverage) return false; | |
if (!classificationEl) return false; | |
// Use `category` to determine if we're updating the generalTaxonomicCoverage or | |
// the taxonomicClassification | |
if (category && category === "generalTaxonomicCoverage") { | |
model.set("generalTaxonomicCoverage", value); | |
return; | |
} | |
} else { | |
var coverage = options.coverage, | |
model = $(coverage).data("model"); | |
} | |
// Find all of the root-level taxonomicClassifications | |
var classificationTables = $(coverage).find( | |
".root-taxonomic-classification", | |
); | |
if (!classificationTables) return false; | |
//TODO :This should probably (at least) be in its own View and | |
//definitely refactored into tidy functions.*/ | |
var rows, | |
collectedClassifications = []; | |
for (var i = 0; i < classificationTables.length; i++) { | |
rows = $(classificationTables[i]).find("tbody tr"); | |
if (!rows) continue; | |
var topLevelClassification = {}, | |
classification = topLevelClassification, | |
currentRank, | |
currentValue; | |
for (var j = 0; j < rows.length; j++) { | |
const thisRow = rows[j]; | |
currentRank = | |
this.model.cleanXMLText($(thisRow).find("select").val()) || ""; | |
currentValue = | |
this.model.cleanXMLText($(thisRow).find("input").val()) || ""; | |
// Maintain classification attributes that exist in the EML but are not visible in the editor | |
const taxonId = $(thisRow).data("taxonId"); | |
const commonName = $(thisRow).data("commonName"); | |
// Skip over rows with empty Rank or Value | |
if (!currentRank.length || !currentValue.length) { | |
continue; | |
} | |
//After the first row, start nesting taxonomicClassification objects | |
if (j > 0) { | |
classification.taxonomicClassification = [{}]; | |
classification = classification.taxonomicClassification[0]; | |
} | |
// Add it to the classification object | |
classification.taxonRankName = currentRank; | |
classification.taxonRankValue = currentValue; | |
classification.taxonId = taxonId; | |
classification.commonName = commonName; | |
} | |
//Add the top level classification to the array | |
if (Object.keys(topLevelClassification).length) | |
collectedClassifications.push(topLevelClassification); | |
} | |
if ( | |
!_.isEqual( | |
collectedClassifications, | |
model.get("taxonomicClassification"), | |
) | |
) { | |
model.set("taxonomicClassification", collectedClassifications); | |
this.model.trigger("change"); | |
} | |
// Handle adding new tables and rows | |
// Do nothing if the value isn't set | |
if (value) { | |
// Add a new row if this is itself a new row | |
if ($(e.target).parents("tr").first().is(".new")) { | |
var newRowEl = this.makeNewTaxonomicClassificationRow(); | |
$(e.target).parents("tbody").first().append(newRowEl); | |
$(e.target).parents("tr").first().removeClass("new"); | |
} | |
// Add a new classification table if this is itself a new table | |
if ($(classificationEl).is(".new")) { | |
$(classificationEl).removeClass("new"); | |
$(classificationEl).append( | |
this.createRemoveButton( | |
"taxonCoverage", | |
"taxonomicClassification", | |
".root-taxonomic-classification-container", | |
".taxonomic-coverage", | |
), | |
); | |
$(coverage).append(this.createTaxonomicClassificationTable()); | |
} | |
} | |
// update the quick add interface | |
this.updateQuickAddTaxa(); | |
}, | |
/** | |
* Update the options for the quick add taxon select interface. This | |
* ensures that only taxonomic classifications that are not already | |
* included in the taxonomic coverage are available for selection. | |
* @since 2.24.0 | |
*/ | |
updateQuickAddTaxa: function () { | |
const selects = this.taxonSelects; | |
if (!selects || !selects.length) return; | |
const taxa = this.getTaxonQuickAddOptions(); | |
if (!taxa || !taxa.length) return; | |
selects.forEach((select, i) => { | |
select.updateOptions(taxa[i].options); | |
}); | |
}, | |
/* | |
* Adds a new row and/or table to the taxonomic coverage section | |
*/ | |
addNewTaxon: function (e) { | |
// Don't do anything if the current classification doesn't have new content | |
if ($(e.target).val().trim() === "") return; | |
// If the row is new, add a new row to the table | |
if ($(e.target).parents("tr").is(".new")) { | |
var newRow = this.makeNewTaxonomicClassificationRow(); | |
//Append the new row and remove the new class from the old row | |
$(e.target).parents("tr").removeClass("new").after(newRow); | |
} | |
}, | |
/** | |
* Insert the "quick add" interface for adding common taxa to the | |
* taxonomic coverage section. Only renders if there is a list of taxa | |
* configured in the appModel. | |
*/ | |
renderTaxaQuickAdd: function () { | |
try { | |
const view = this; | |
// To render the taxon select, the view must be in editor mode and we | |
// need a list of taxa configured for the theme | |
if (!view.edit) return; | |
// remove any existing quick add interface: | |
if (view.taxonQuickAddEl) view.taxonQuickAddEl.remove(); | |
const quickAddTaxa = view.getTaxonQuickAddOptions(); | |
if (!quickAddTaxa || !quickAddTaxa.length) { | |
// If the taxa are configured as SID for a dataObject, then wait | |
// for the dataObject to be loaded | |
this.listenToOnce( | |
MetacatUI.appModel, | |
"change:quickAddTaxa", | |
this.renderTaxaQuickAdd, | |
); | |
return; | |
} | |
// Create & insert the basic HTML for the taxon select interface | |
const template = `<div class="taxa-quick-add"> | |
<p class="taxa-quick-add__text"> | |
<b>⭐️ Quick Add Taxa:</b> Select one or more common taxa. Click "Add" to add them to the list. | |
</p> | |
<div class="taxa-quick-add__controls"> | |
<div class="taxa-quick-add__selects"></div> | |
<button class="btn btn-primary taxa-quick-add__button">Add Taxa</button> | |
</div> | |
</div>`; | |
const parser = new DOMParser(); | |
const doc = parser.parseFromString(template, "text/html"); | |
const quickAddEl = doc.body.firstChild; | |
const button = quickAddEl.querySelector("button"); | |
const container = quickAddEl.querySelector( | |
".taxa-quick-add__selects", | |
); | |
const rowSelector = ".root-taxonomic-classification-container"; | |
const firstRow = document.querySelector(rowSelector); | |
firstRow.parentNode.insertBefore(quickAddEl, firstRow); | |
view.taxonQuickAddEl = quickAddEl; | |
// Update the taxon coverage when the button is clicked | |
const onButtonClick = () => { | |
const taxonSelects = view.taxonSelects; | |
if (!taxonSelects || !taxonSelects.length) return; | |
const selectedItems = taxonSelects | |
.map((select) => select.model.get("selected")) | |
.flat(); | |
if (!selectedItems || !selectedItems.length) return; | |
const selectedItemObjs = selectedItems.map((item) => { | |
try { | |
// It will be encoded JSON if it's a pre-defined taxon | |
return JSON.parse(decodeURIComponent(item)); | |
} catch (e) { | |
// Otherwise it will be a string a user typed in | |
return { | |
taxonRankName: "", | |
taxonRankValue: item, | |
}; | |
} | |
}); | |
view.addTaxa(selectedItemObjs); | |
taxonSelects.forEach((select) => | |
select.model.setSelected([], { silent: true }), | |
); | |
}; | |
button.removeEventListener("click", onButtonClick); | |
button.addEventListener("click", onButtonClick); | |
// Create the search selects | |
view.taxonSelects = []; | |
const componentPath = "views/searchSelect/SearchSelectView"; | |
require([componentPath], function (SearchSelect) { | |
quickAddTaxa.forEach((taxaList, i) => { | |
try { | |
const taxaInput = new SearchSelect({ | |
options: taxaList.options, | |
placeholderText: taxaList.placeholder, | |
inputLabel: taxaList.label, | |
allowMulti: true, | |
allowAdditions: true, | |
separatorTextOptions: false, | |
selected: [], | |
}); | |
container.appendChild(taxaInput.el); | |
taxaInput.render(); | |
view.taxonSelects.push(taxaInput); | |
} catch (e) { | |
console.log("Failed to create taxon select: ", e); | |
} | |
}); | |
}); | |
} catch (e) { | |
console.log("Failed to render taxon select: ", e); | |
} | |
}, | |
/** | |
* Get the list of options for the taxon quick add interface. Filter | |
* out any that have already been added to the taxonomic coverage. | |
* @returns {Object[]} An array of search select options | |
* @since 2.24.0 | |
*/ | |
getTaxonQuickAddOptions: function () { | |
const quickAddTaxa = MetacatUI.appModel.getQuickAddTaxa(); | |
if (!quickAddTaxa || !quickAddTaxa.length) return; | |
const coverages = this.model.get("taxonCoverage"); | |
for (const taxaList of quickAddTaxa) { | |
const opts = []; | |
for (const taxon of taxaList.taxa) { | |
// check that it is not a duplicate in any coverages | |
let isDuplicate = false; | |
for (cov of coverages) { | |
if (cov.isDuplicate(taxon)) { | |
isDuplicate = true; | |
break; | |
} | |
} | |
if (!isDuplicate) { | |
opts.push(this.taxonOptionToSearchSelectItem(taxon)); | |
} | |
} | |
taxaList.options = opts; | |
} | |
return quickAddTaxa; | |
}, | |
/** | |
* Reformats a taxon option, as provided in the appModel | |
* {@link AppModel#quickAddTaxa}, as a search select item. | |
* @param {Object} option A single taxon classification with at least a | |
* taxonRankValue and taxonRankName. It may also have a taxonId (object | |
* with provider and value) and a commonName. | |
* @returns {Object} A search select item with label, value, and | |
* description properties. | |
*/ | |
taxonOptionToSearchSelectItem: function (option) { | |
try { | |
// option must have a taxonRankValue and taxonRankName or it is invalid | |
if (!option.taxonRankValue || !option.taxonRankName) { | |
console.log("Invalid taxon option: ", option); | |
return null; | |
} | |
// Create a description | |
let description = option.taxonRankName + ": " + option.taxonRankValue; | |
if (option.taxonId) { | |
description += | |
" (" + | |
option.taxonId.provider + | |
": " + | |
option.taxonId.value + | |
")"; | |
} | |
// search select doesn't work with some of the json characters | |
const val = encodeURIComponent(JSON.stringify(option)); | |
return { | |
label: option.commonName || option.taxonRankValue, | |
value: val, | |
description: description, | |
}; | |
} catch (e) { | |
console.log( | |
"Failed to reformat taxon option as search select item: ", | |
e, | |
); | |
return null; | |
} | |
}, | |
/** | |
* Add new taxa to the EML model and re-render the taxa section. The new | |
* taxa will be added to the first <taxonomicCoverage> element in the EML | |
* model. If there is no <taxonomicCoverage> element, one will be created. | |
* @param {Object[]} newClassifications - An array of objects with any of | |
* the following properties: | |
* - taxonRankName: (sting) The name of the taxonomic rank, e.g. | |
* "Kingdom" | |
* - taxonRankValue: (string) The value of the taxonomic rank, e.g. | |
* "Animalia" | |
* - commonName: (string) The common name of the taxon, e.g. "Animals" | |
* - taxonId: (object) The official ID of the taxon, including "provider" | |
* and "value". | |
* - taxonomicClassification: (array) An array of nested taxonomic | |
* classifications | |
* @since 2.24.0 | |
* @example | |
* this.addTaxon([{ | |
* taxonRankName: "Kingdom", | |
* taxonRankValue: "Animalia", | |
* commonName: "Animals", | |
* taxonId: { | |
* provider: "https://www.itis.gov/", | |
* value: "202423" | |
* }]); | |
*/ | |
addTaxa: function (newClassifications) { | |
try { | |
// TODO: validate the new taxon before adding it to the model? | |
const taxonCoverages = this.model.get("taxonCoverage"); | |
// We expect that there is already a taxonCoverage array on the model. | |
// If the EML was made in the editor, there can only be one | |
// <taxonomicCoverage> element. Add the new taxon to its | |
// <taxonomicClassification> array. If there is more than one, then the | |
// new taxon will be added to the first <taxonomicCoverage> element. | |
if (taxonCoverages && taxonCoverages.length >= 1) { | |
const taxonCoverage = taxonCoverages[0]; | |
const classifications = taxonCoverage.get( | |
"taxonomicClassification", | |
); | |
const allClass = classifications.concat(newClassifications); | |
taxonCoverage.set("taxonomicClassification", allClass); | |
} else { | |
// If there is no <taxonomicCoverage> element for some reason, | |
// create one and add the new taxon to its <taxonomicClassification> | |
// array. | |
const newCov = new EMLTaxonCoverage({ | |
taxonomicClassification: newClassifications, | |
parentModel: this.model, | |
}); | |
this.model.set("taxonCoverage", [newCov]); | |
} | |
// Re-render the taxa section | |
this.renderTaxa(); | |
} catch (e) { | |
console.log("Error adding taxon to EML model: ", e); | |
} | |
}, | |
removeTaxonRank: function (e) { | |
var row = $(e.target).parents(".taxonomic-coverage-row"), | |
coverageEl = $(row).parents(".taxonomic-coverage"), | |
view = this; | |
//Animate the row away and then remove it | |
row.slideUp("fast", function () { | |
row.remove(); | |
view.updateTaxonCoverage({ coverage: coverageEl }); | |
}); | |
}, | |
/* | |
* After the user focuses out, show validation help, if needed | |
*/ | |
showTaxonValidation: function (e) { | |
//Get the text inputs and select menus | |
var row = $(e.target).parents("tr"), | |
allInputs = row.find("input, select"), | |
tableContainer = $(e.target).parents("table"), | |
errorInputs = []; | |
//If none of the inputs have a value and this is a new row, then do nothing | |
if ( | |
_.every(allInputs, function (i) { | |
return !i.value; | |
}) && | |
row.is(".new") | |
) | |
return; | |
//Add the error styling to any input with no value | |
_.each(allInputs, function (input) { | |
// Keep track of the number of clicks of each input element so we only show the | |
// error message after the user has focused on both input elements | |
if (!input.value) errorInputs.push(input); | |
}); | |
if (errorInputs.length) { | |
//Show the error message after a brief delay | |
setTimeout(function () { | |
//If the user focused on another element in the same row, don't do anything | |
if (_.contains(allInputs, document.activeElement)) return; | |
//Add the error styling | |
$(errorInputs).addClass("error"); | |
//Add the error message | |
if (!tableContainer.prev(".notification").length) { | |
tableContainer.before( | |
$(document.createElement("p")) | |
.addClass("error notification") | |
.text("Enter a rank name AND value in each row."), | |
); | |
} | |
}, 200); | |
} else { | |
allInputs.removeClass("error"); | |
if (!tableContainer.find(".error").length) | |
tableContainer.prev(".notification").remove(); | |
} | |
}, | |
previewTaxonRemove: function (e) { | |
var removeBtn = $(e.target); | |
if (removeBtn.parent().is(".root-taxonomic-classification")) { | |
removeBtn.parent().toggleClass("remove-preview"); | |
} else { | |
removeBtn | |
.parents(".taxonomic-coverage-row") | |
.toggleClass("remove-preview"); | |
} | |
}, | |
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.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; | |
}, | |
}, | |
); |
🚫 [eslint] <object-shorthand> reported by reviewdog 🐶
Expected method shorthand.
metacatui/src/js/views/metadata/EML211View.js
Lines 243 to 336 in 43c3062
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); | |
//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(); | |
}, |
🚫 [eslint] <vars-on-top> reported by reviewdog 🐶
All 'var' declarations must be at the top of the function scope.
metacatui/src/js/views/metadata/EML211View.js
Lines 299 to 302 in 43c3062
var altIdsEls = this.createBasicTextFields( | |
"alternateIdentifier", | |
"Add a new alternate identifier", | |
); |
🚫 [eslint] <no-var> reported by reviewdog 🐶
Unexpected var, use let or const instead.
metacatui/src/js/views/metadata/EML211View.js
Lines 299 to 302 in 43c3062
var altIdsEls = this.createBasicTextFields( | |
"alternateIdentifier", | |
"Add a new alternate identifier", | |
); |
🚫 [eslint] <object-shorthand> reported by reviewdog 🐶
Expected method shorthand.
metacatui/src/js/views/metadata/EML211View.js
Lines 1894 to 1978 in 43c3062
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; | |
}, |
🚫 [eslint] <one-var> reported by reviewdog 🐶
Split 'var' declarations into multiple statements.
metacatui/src/js/views/metadata/EML211View.js
Lines 1910 to 1916 in 43c3062
var textRow = $(document.createElement("div")).addClass( | |
"basic-text-row", | |
), | |
input = $(document.createElement("input")) | |
.attr("type", "text") | |
.attr("data-category", category) | |
.addClass("basic-text"); |
🚫 [eslint] <vars-on-top> reported by reviewdog 🐶
All 'var' declarations must be at the top of the function scope.
metacatui/src/js/views/metadata/EML211View.js
Lines 1910 to 1916 in 43c3062
var textRow = $(document.createElement("div")).addClass( | |
"basic-text-row", | |
), | |
input = $(document.createElement("input")) | |
.attr("type", "text") | |
.attr("data-category", category) | |
.addClass("basic-text"); |
🚫 [eslint] <no-var> reported by reviewdog 🐶
Unexpected var, use let or const instead.
metacatui/src/js/views/metadata/EML211View.js
Lines 1910 to 1916 in 43c3062
var textRow = $(document.createElement("div")).addClass( | |
"basic-text-row", | |
), | |
input = $(document.createElement("input")) | |
.attr("type", "text") | |
.attr("data-category", category) | |
.addClass("basic-text"); |
🚫 [eslint] <object-shorthand> reported by reviewdog 🐶
Expected method shorthand.
metacatui/src/js/views/metadata/EML211View.js
Lines 1980 to 2032 in 43c3062
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); | |
}, |
🚫 [eslint] <object-shorthand> reported by reviewdog 🐶
Expected method shorthand.
metacatui/src/js/views/metadata/EML211View.js
Lines 2050 to 2085 in 43c3062
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", | |
), | |
); | |
}, |
🚫 [eslint] <one-var> reported by reviewdog 🐶
Split 'var' declarations into multiple statements.
metacatui/src/js/views/metadata/EML211View.js
Lines 2051 to 2054 in 43c3062
var category = $(e.target).attr("data-category"), | |
allBasicTexts = $( | |
".basic-text.new[data-category='" + category + "']", | |
); |
🚫 [eslint] <no-var> reported by reviewdog 🐶
Unexpected var, use let or const instead.
metacatui/src/js/views/metadata/EML211View.js
Lines 2051 to 2054 in 43c3062
var category = $(e.target).attr("data-category"), | |
allBasicTexts = $( | |
".basic-text.new[data-category='" + category + "']", | |
); |
🚫 [eslint] <vars-on-top> reported by reviewdog 🐶
All 'var' declarations must be at the top of the function scope.
metacatui/src/js/views/metadata/EML211View.js
Lines 2063 to 2065 in 43c3062
var newRow = $(document.createElement("div")).addClass( | |
"basic-text-row", | |
); |
🚫 [eslint] <no-var> reported by reviewdog 🐶
Unexpected var, use let or const instead.
metacatui/src/js/views/metadata/EML211View.js
Lines 2063 to 2065 in 43c3062
var newRow = $(document.createElement("div")).addClass( | |
"basic-text-row", | |
); |
"change:canonicalDataset", | ||
this.updateCanonicalDataset, | ||
); | ||
|
||
//Create a Unit collection |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚫 [eslint] <spaced-comment> reported by reviewdog 🐶
Expected exception block, space or tab after '//' in comment.
//Create a Unit collection | |
// Create a Unit collection |
@@ -734,6 +753,16 @@ define([ | |||
} | |||
} | |||
|
|||
// Once all the nodes have been parsed, check if any of the annotations | |||
// make up a canonical dataset reference | |||
const annotations = modelJSON["annotations"]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚫 [eslint] <prefer-destructuring> reported by reviewdog 🐶
Use object destructuring.
@@ -734,6 +753,16 @@ define([ | |||
} | |||
} | |||
|
|||
// Once all the nodes have been parsed, check if any of the annotations | |||
// make up a canonical dataset reference | |||
const annotations = modelJSON["annotations"]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚫 [eslint] <dot-notation> reported by reviewdog 🐶
["annotations"] is better written in dot notation.
const annotations = modelJSON["annotations"]; | |
const annotations = modelJSON.annotations; |
if (annotations) { | ||
const canonicalDataset = annotations.getCanonicalURI(); | ||
if (canonicalDataset) { | ||
modelJSON["canonicalDataset"] = canonicalDataset; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚫 [eslint] <dot-notation> reported by reviewdog 🐶
["canonicalDataset"] is better written in dot notation.
modelJSON["canonicalDataset"] = canonicalDataset; | |
modelJSON.canonicalDataset = canonicalDataset; |
@@ -23,7 +23,8 @@ define(["jquery", "underscore", "backbone"], function ($, _, Backbone) { | |||
}, | |||
|
|||
initialize: function (attributes, opions) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unexpected unnamed method 'initialize'.
@@ -2036,12 +2052,12 @@ define([ | |||
allBasicTexts = $( | |||
".basic-text.new[data-category='" + category + "']", | |||
); | |||
|
|||
//Only show one new row at a time |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚫 [eslint] <spaced-comment> reported by reviewdog 🐶
Expected exception block, space or tab after '//' in comment.
//Only show one new row at a time | |
// Only show one new row at a time |
@@ -2036,12 +2052,12 @@ define([ | |||
allBasicTexts = $( | |||
".basic-text.new[data-category='" + category + "']", | |||
); | |||
|
|||
//Only show one new row at a time | |||
if (allBasicTexts.length == 1 && !allBasicTexts.val()) return; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
else if (allBasicTexts.length > 1) return; | ||
//We are only supporting one title right now | ||
else if (category == "title") return; | ||
else if (category === "title" || category === "canonicalDataset") | ||
return; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚫 [eslint] <no-else-return> reported by reviewdog 🐶
Unnecessary 'else' after 'return'.
reviewdog suggestion error
GitHub comment range and suggestion line range must be same. L2057-L2060 v.s. L2050-L2057@@ -2036,12 +2052,12 @@ define([ | |||
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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚫 [eslint] <spaced-comment> reported by reviewdog 🐶
Expected exception block, space or tab after '//' in comment.
//We are only supporting one title right now | |
// We are only supporting one title right now |
//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") return; | ||
else if (category === "title" || category === "canonicalDataset") | ||
return; | ||
|
||
//Add another blank text input |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚫 [eslint] <spaced-comment> reported by reviewdog 🐶
Expected exception block, space or tab after '//' in comment.
//Add another blank text input | |
// Add another blank text input |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚫 [eslint] <prefer-arrow-callback> reported by reviewdog 🐶
Unexpected function expression.
metacatui/src/js/models/metadata/eml211/EML211.js
Lines 23 to 2666 in 43c3062
], function ( | |
$, | |
_, | |
Backbone, | |
uuid, | |
Units, | |
ScienceMetadata, | |
DataONEObject, | |
EMLGeoCoverage, | |
EMLKeywordSet, | |
EMLTaxonCoverage, | |
EMLTemporalCoverage, | |
EMLDistribution, | |
EMLEntity, | |
EMLDataTable, | |
EMLOtherEntity, | |
EMLParty, | |
EMLProject, | |
EMLText, | |
EMLMethods, | |
EMLAnnotations, | |
EMLAnnotation, | |
) { | |
/** | |
* @class EML211 | |
* @classdesc An EML211 object represents an Ecological Metadata Language | |
* document, version 2.1.1 | |
* @classcategory Models/Metadata/EML211 | |
* @extends ScienceMetadata | |
*/ | |
var EML211 = ScienceMetadata.extend( | |
/** @lends EML211.prototype */ { | |
type: "EML", | |
defaults: function () { | |
return _.extend(ScienceMetadata.prototype.defaults(), { | |
id: "urn:uuid:" + uuid.v4(), | |
formatId: "https://eml.ecoinformatics.org/eml-2.2.0", | |
objectXML: null, | |
isEditable: false, | |
alternateIdentifier: [], | |
shortName: null, | |
title: [], | |
creator: [], // array of EMLParty objects | |
metadataProvider: [], // array of EMLParty objects | |
associatedParty: [], // array of EMLParty objects | |
contact: [], // array of EMLParty objects | |
publisher: [], // array of EMLParty objects | |
pubDate: null, | |
language: null, | |
series: null, | |
abstract: [], //array of EMLText objects | |
keywordSets: [], //array of EMLKeywordSet objects | |
additionalInfo: [], | |
intellectualRights: | |
"This work is dedicated to the public domain under the Creative Commons Universal 1.0 Public Domain Dedication. To view a copy of this dedication, visit https://creativecommons.org/publicdomain/zero/1.0/.", | |
distribution: [], // array of EMLDistribution objects | |
geoCoverage: [], //an array for EMLGeoCoverages | |
temporalCoverage: [], //an array of EMLTempCoverage models | |
taxonCoverage: [], //an array of EMLTaxonCoverages | |
purpose: [], | |
entities: [], //An array of EMLEntities | |
pubplace: null, | |
methods: new EMLMethods(), // An EMLMethods objects | |
project: null, // An EMLProject object, | |
annotations: null, // Dataset-level annotations | |
canonicalDataset: null, | |
dataSensitivityPropertyURI: | |
"http://purl.dataone.org/odo/SENSO_00000005", | |
nodeOrder: [ | |
"alternateidentifier", | |
"shortname", | |
"title", | |
"creator", | |
"metadataprovider", | |
"associatedparty", | |
"pubdate", | |
"language", | |
"series", | |
"abstract", | |
"keywordset", | |
"additionalinfo", | |
"intellectualrights", | |
"licensed", | |
"distribution", | |
"coverage", | |
"annotation", | |
"purpose", | |
"introduction", | |
"gettingstarted", | |
"acknowledgements", | |
"maintenance", | |
"contact", | |
"publisher", | |
"pubplace", | |
"methods", | |
"project", | |
"datatable", | |
"spatialraster", | |
"spatialvector", | |
"storedprocedure", | |
"view", | |
"otherentity", | |
"referencepublications", | |
"usagecitations", | |
"literaturecited", | |
], | |
}); | |
}, | |
units: new Units(), | |
initialize: function (attributes) { | |
// Call initialize for the super class | |
ScienceMetadata.prototype.initialize.call(this, attributes); | |
// EML211-specific init goes here | |
// this.set("objectXML", this.createXML()); | |
this.parse(this.createXML()); | |
this.on("sync", function () { | |
this.set("synced", true); | |
}); | |
this.stopListening(this, "change:canonicalDataset"); | |
this.listenTo( | |
this, | |
"change:canonicalDataset", | |
this.updateCanonicalDataset, | |
); | |
//Create a Unit collection | |
if (!this.units.length) this.createUnits(); | |
}, | |
url: function (options) { | |
var identifier; | |
if (options && options.update) { | |
identifier = this.get("oldPid") || this.get("seriesid"); | |
} else { | |
identifier = this.get("id") || this.get("seriesid"); | |
} | |
return ( | |
MetacatUI.appModel.get("objectServiceUrl") + | |
encodeURIComponent(identifier) | |
); | |
}, | |
updateCanonicalDataset() { | |
let uri = this.get("canonicalDataset"); | |
uri = uri?.length ? uri[0] : null; | |
let annotations = this.get("annotations"); | |
if (!annotations) { | |
annotations = new EMLAnnotations(); | |
this.set("annotations", annotations); | |
} | |
annotations.updateCanonicalDataset(uri); | |
}, | |
/* | |
* Maps the lower-case EML node names (valid in HTML DOM) to the camel-cased EML node names (valid in EML). | |
* Used during parse() and serialize() | |
*/ | |
nodeNameMap: function () { | |
return _.extend( | |
this.constructor.__super__.nodeNameMap(), | |
EMLDistribution.prototype.nodeNameMap(), | |
EMLGeoCoverage.prototype.nodeNameMap(), | |
EMLKeywordSet.prototype.nodeNameMap(), | |
EMLParty.prototype.nodeNameMap(), | |
EMLProject.prototype.nodeNameMap(), | |
EMLTaxonCoverage.prototype.nodeNameMap(), | |
EMLTemporalCoverage.prototype.nodeNameMap(), | |
EMLMethods.prototype.nodeNameMap(), | |
{ | |
accuracyreport: "accuracyReport", | |
actionlist: "actionList", | |
additionalclassifications: "additionalClassifications", | |
additionalinfo: "additionalInfo", | |
additionallinks: "additionalLinks", | |
additionalmetadata: "additionalMetadata", | |
allowfirst: "allowFirst", | |
alternateidentifier: "alternateIdentifier", | |
altitudedatumname: "altitudeDatumName", | |
altitudedistanceunits: "altitudeDistanceUnits", | |
altituderesolution: "altitudeResolution", | |
altitudeencodingmethod: "altitudeEncodingMethod", | |
altitudesysdef: "altitudeSysDef", | |
asneeded: "asNeeded", | |
associatedparty: "associatedParty", | |
attributeaccuracyexplanation: "attributeAccuracyExplanation", | |
attributeaccuracyreport: "attributeAccuracyReport", | |
attributeaccuracyvalue: "attributeAccuracyValue", | |
attributedefinition: "attributeDefinition", | |
attributelabel: "attributeLabel", | |
attributelist: "attributeList", | |
attributename: "attributeName", | |
attributeorientation: "attributeOrientation", | |
attributereference: "attributeReference", | |
awardnumber: "awardNumber", | |
awardurl: "awardUrl", | |
audiovisual: "audioVisual", | |
authsystem: "authSystem", | |
banddescription: "bandDescription", | |
bilinearfit: "bilinearFit", | |
binaryrasterformat: "binaryRasterFormat", | |
blockedmembernode: "blockedMemberNode", | |
booktitle: "bookTitle", | |
cameracalibrationinformationavailability: | |
"cameraCalibrationInformationAvailability", | |
casesensitive: "caseSensitive", | |
cellgeometry: "cellGeometry", | |
cellsizexdirection: "cellSizeXDirection", | |
cellsizeydirection: "cellSizeYDirection", | |
changehistory: "changeHistory", | |
changedate: "changeDate", | |
changescope: "changeScope", | |
chapternumber: "chapterNumber", | |
characterencoding: "characterEncoding", | |
checkcondition: "checkCondition", | |
checkconstraint: "checkConstraint", | |
childoccurences: "childOccurences", | |
citableclassificationsystem: "citableClassificationSystem", | |
cloudcoverpercentage: "cloudCoverPercentage", | |
codedefinition: "codeDefinition", | |
codeexplanation: "codeExplanation", | |
codesetname: "codesetName", | |
codeseturl: "codesetURL", | |
collapsedelimiters: "collapseDelimiters", | |
communicationtype: "communicationType", | |
compressiongenerationquality: "compressionGenerationQuality", | |
compressionmethod: "compressionMethod", | |
conferencedate: "conferenceDate", | |
conferencelocation: "conferenceLocation", | |
conferencename: "conferenceName", | |
conferenceproceedings: "conferenceProceedings", | |
constraintdescription: "constraintDescription", | |
constraintname: "constraintName", | |
constanttosi: "constantToSI", | |
controlpoint: "controlPoint", | |
cornerpoint: "cornerPoint", | |
customunit: "customUnit", | |
dataformat: "dataFormat", | |
datasetgpolygon: "datasetGPolygon", | |
datasetgpolygonoutergring: "datasetGPolygonOuterGRing", | |
datasetgpolygonexclusiongring: "datasetGPolygonExclusionGRing", | |
datatable: "dataTable", | |
datatype: "dataType", | |
datetime: "dateTime", | |
datetimedomain: "dateTimeDomain", | |
datetimeprecision: "dateTimePrecision", | |
defaultvalue: "defaultValue", | |
definitionattributereference: "definitionAttributeReference", | |
denomflatratio: "denomFlatRatio", | |
depthsysdef: "depthSysDef", | |
depthdatumname: "depthDatumName", | |
depthdistanceunits: "depthDistanceUnits", | |
depthencodingmethod: "depthEncodingMethod", | |
depthresolution: "depthResolution", | |
descriptorvalue: "descriptorValue", | |
dictref: "dictRef", | |
diskusage: "diskUsage", | |
domainDescription: "domainDescription", | |
editedbook: "editedBook", | |
encodingmethod: "encodingMethod", | |
endcondition: "endCondition", | |
entitycodelist: "entityCodeList", | |
entitydescription: "entityDescription", | |
entityname: "entityName", | |
entityreference: "entityReference", | |
entitytype: "entityType", | |
enumerateddomain: "enumeratedDomain", | |
errorbasis: "errorBasis", | |
errorvalues: "errorValues", | |
externalcodeset: "externalCodeSet", | |
externallydefinedformat: "externallyDefinedFormat", | |
fielddelimiter: "fieldDelimiter", | |
fieldstartcolumn: "fieldStartColumn", | |
fieldwidth: "fieldWidth", | |
filmdistortioninformationavailability: | |
"filmDistortionInformationAvailability", | |
foreignkey: "foreignKey", | |
formatname: "formatName", | |
formatstring: "formatString", | |
formatversion: "formatVersion", | |
fractiondigits: "fractionDigits", | |
fundername: "funderName", | |
funderidentifier: "funderIdentifier", | |
gettingstarted: "gettingStarted", | |
gring: "gRing", | |
gringpoint: "gRingPoint", | |
gringlatitude: "gRingLatitude", | |
gringlongitude: "gRingLongitude", | |
geogcoordsys: "geogCoordSys", | |
geometricobjectcount: "geometricObjectCount", | |
georeferenceinfo: "georeferenceInfo", | |
highwavelength: "highWavelength", | |
horizontalaccuracy: "horizontalAccuracy", | |
horizcoordsysdef: "horizCoordSysDef", | |
horizcoordsysname: "horizCoordSysName", | |
identifiername: "identifierName", | |
illuminationazimuthangle: "illuminationAzimuthAngle", | |
illuminationelevationangle: "illuminationElevationAngle", | |
imagingcondition: "imagingCondition", | |
imagequalitycode: "imageQualityCode", | |
imageorientationangle: "imageOrientationAngle", | |
intellectualrights: "intellectualRights", | |
imagedescription: "imageDescription", | |
isbn: "ISBN", | |
issn: "ISSN", | |
joincondition: "joinCondition", | |
keywordtype: "keywordType", | |
languagevalue: "LanguageValue", | |
languagecodestandard: "LanguageCodeStandard", | |
lensdistortioninformationavailability: | |
"lensDistortionInformationAvailability", | |
licensename: "licenseName", | |
licenseurl: "licenseURL", | |
linenumber: "lineNumber", | |
literalcharacter: "literalCharacter", | |
literallayout: "literalLayout", | |
literaturecited: "literatureCited", | |
lowwavelength: "lowWaveLength", | |
machineprocessor: "machineProcessor", | |
maintenanceupdatefrequency: "maintenanceUpdateFrequency", | |
matrixtype: "matrixType", | |
maxexclusive: "maxExclusive", | |
maxinclusive: "maxInclusive", | |
maxlength: "maxLength", | |
maxrecordlength: "maxRecordLength", | |
maxvalues: "maxValues", | |
measurementscale: "measurementScale", | |
metadatalist: "metadataList", | |
methodstep: "methodStep", | |
minexclusive: "minExclusive", | |
mininclusive: "minInclusive", | |
minlength: "minLength", | |
minvalues: "minValues", | |
missingvaluecode: "missingValueCode", | |
moduledocs: "moduleDocs", | |
modulename: "moduleName", | |
moduledescription: "moduleDescription", | |
multiband: "multiBand", | |
multipliertosi: "multiplierToSI", | |
nonnumericdomain: "nonNumericDomain", | |
notnullconstraint: "notNullConstraint", | |
notplanned: "notPlanned", | |
numberofbands: "numberOfBands", | |
numbertype: "numberType", | |
numericdomain: "numericDomain", | |
numfooterlines: "numFooterLines", | |
numheaderlines: "numHeaderLines", | |
numberofrecords: "numberOfRecords", | |
numberofvolumes: "numberOfVolumes", | |
numphysicallinesperrecord: "numPhysicalLinesPerRecord", | |
objectname: "objectName", | |
oldvalue: "oldValue", | |
operatingsystem: "operatingSystem", | |
orderattributereference: "orderAttributeReference", | |
originalpublication: "originalPublication", | |
otherentity: "otherEntity", | |
othermaintenanceperiod: "otherMaintenancePeriod", | |
parameterdefinition: "parameterDefinition", | |
packageid: "packageId", | |
pagerange: "pageRange", | |
parentoccurences: "parentOccurences", | |
parentsi: "parentSI", | |
peakresponse: "peakResponse", | |
personalcommunication: "personalCommunication", | |
physicallinedelimiter: "physicalLineDelimiter", | |
pointinpixel: "pointInPixel", | |
preferredmembernode: "preferredMemberNode", | |
preprocessingtypecode: "preProcessingTypeCode", | |
primarykey: "primaryKey", | |
primemeridian: "primeMeridian", | |
proceduralstep: "proceduralStep", | |
programminglanguage: "programmingLanguage", | |
projcoordsys: "projCoordSys", | |
projectionlist: "projectionList", | |
propertyuri: "propertyURI", | |
pubdate: "pubDate", | |
pubplace: "pubPlace", | |
publicationplace: "publicationPlace", | |
quantitativeaccuracyreport: "quantitativeAccuracyReport", | |
quantitativeaccuracyvalue: "quantitativeAccuracyValue", | |
quantitativeaccuracymethod: "quantitativeAccuracyMethod", | |
quantitativeattributeaccuracyassessment: | |
"quantitativeAttributeAccuracyAssessment", | |
querystatement: "queryStatement", | |
quotecharacter: "quoteCharacter", | |
radiometricdataavailability: "radiometricDataAvailability", | |
rasterorigin: "rasterOrigin", | |
recommendedunits: "recommendedUnits", | |
recommendedusage: "recommendedUsage", | |
referencedkey: "referencedKey", | |
referencetype: "referenceType", | |
relatedentry: "relatedEntry", | |
relationshiptype: "relationshipType", | |
reportnumber: "reportNumber", | |
reprintedition: "reprintEdition", | |
researchproject: "researchProject", | |
researchtopic: "researchTopic", | |
recorddelimiter: "recordDelimiter", | |
referencepublication: "referencePublication", | |
revieweditem: "reviewedItem", | |
rowcolumnorientation: "rowColumnOrientation", | |
runtimememoryusage: "runtimeMemoryUsage", | |
samplingdescription: "samplingDescription", | |
scalefactor: "scaleFactor", | |
sequenceidentifier: "sequenceIdentifier", | |
semiaxismajor: "semiAxisMajor", | |
shortname: "shortName", | |
simpledelimited: "simpleDelimited", | |
spatialraster: "spatialRaster", | |
spatialreference: "spatialReference", | |
spatialvector: "spatialVector", | |
standalone: "standAlone", | |
standardunit: "standardUnit", | |
startcondition: "startCondition", | |
studyareadescription: "studyAreaDescription", | |
storagetype: "storageType", | |
studyextent: "studyExtent", | |
studytype: "studyType", | |
textdelimited: "textDelimited", | |
textdomain: "textDomain", | |
textfixed: "textFixed", | |
textformat: "textFormat", | |
topologylevel: "topologyLevel", | |
tonegradation: "toneGradation", | |
totaldigits: "totalDigits", | |
totalfigures: "totalFigures", | |
totalpages: "totalPages", | |
totaltables: "totalTables", | |
triangulationindicator: "triangulationIndicator", | |
typesystem: "typeSystem", | |
uniquekey: "uniqueKey", | |
unittype: "unitType", | |
unitlist: "unitList", | |
usagecitation: "usageCitation", | |
valueuri: "valueURI", | |
valueattributereference: "valueAttributeReference", | |
verticalaccuracy: "verticalAccuracy", | |
vertcoordsys: "vertCoordSys", | |
virtualmachine: "virtualMachine", | |
wavelengthunits: "waveLengthUnits", | |
whitespace: "whiteSpace", | |
xintercept: "xIntercept", | |
xcoordinate: "xCoordinate", | |
"xsi:schemalocation": "xsi:schemaLocation", | |
xslope: "xSlope", | |
ycoordinate: "yCoordinate", | |
yintercept: "yIntercept", | |
yslope: "ySlope", | |
}, | |
); | |
}, | |
/** | |
* Fetch the EML from the MN object service | |
* @param {object} [options] - A set of options for this fetch() | |
* @property {boolean} [options.systemMetadataOnly=false] - If true, only the system metadata will be fetched. | |
* If false, the system metadata AND EML document will be fetched. | |
*/ | |
fetch: function (options) { | |
if (!options) var options = {}; | |
//Add the authorization header and other AJAX settings | |
_.extend(options, MetacatUI.appUserModel.createAjaxSettings(), { | |
dataType: "text", | |
}); | |
// Merge the system metadata into the object first | |
_.extend(options, { merge: true }); | |
this.fetchSystemMetadata(options); | |
//If we are retrieving system metadata only, then exit now | |
if (options.systemMetadataOnly) return; | |
//Call Backbone.Model.fetch to retrieve the info | |
return Backbone.Model.prototype.fetch.call(this, options); | |
}, | |
/* | |
Deserialize an EML 2.1.1 XML document | |
*/ | |
parse: function (response) { | |
// Save a reference to this model for use in setting the | |
// parentModel inside anonymous functions | |
var model = this; | |
//If the response is XML | |
if (typeof response == "string" && response.indexOf("<") == 0) { | |
//Look for a system metadata tag and call DataONEObject parse instead | |
if (response.indexOf("systemMetadata>") > -1) | |
return DataONEObject.prototype.parse.call(this, response); | |
response = this.cleanUpXML(response); | |
response = this.dereference(response); | |
this.set("objectXML", response); | |
var emlElement = $($.parseHTML(response)).filter("eml\\:eml"); | |
} | |
var datasetEl; | |
if (emlElement[0]) datasetEl = $(emlElement[0]).find("dataset"); | |
if (!datasetEl || !datasetEl.length) return {}; | |
var emlParties = [ | |
"metadataprovider", | |
"associatedparty", | |
"creator", | |
"contact", | |
"publisher", | |
], | |
emlDistribution = ["distribution"], | |
emlEntities = [ | |
"datatable", | |
"otherentity", | |
"spatialvector", | |
"spatialraster", | |
"storedprocedure", | |
"view", | |
], | |
emlText = ["abstract", "additionalinfo"], | |
emlMethods = ["methods"]; | |
var nodes = datasetEl.children(), | |
modelJSON = {}; | |
for (var i = 0; i < nodes.length; i++) { | |
var thisNode = nodes[i]; | |
var convertedName = | |
this.nodeNameMap()[thisNode.localName] || thisNode.localName; | |
//EML Party modules are stored in EMLParty models | |
if (_.contains(emlParties, thisNode.localName)) { | |
if (thisNode.localName == "metadataprovider") | |
var attributeName = "metadataProvider"; | |
else if (thisNode.localName == "associatedparty") | |
var attributeName = "associatedParty"; | |
else var attributeName = thisNode.localName; | |
if (typeof modelJSON[attributeName] == "undefined") | |
modelJSON[attributeName] = []; | |
modelJSON[attributeName].push( | |
new EMLParty({ | |
objectDOM: thisNode, | |
parentModel: model, | |
type: attributeName, | |
}), | |
); | |
} | |
//EML Distribution modules are stored in EMLDistribution models | |
else if (_.contains(emlDistribution, thisNode.localName)) { | |
if (typeof modelJSON[thisNode.localName] == "undefined") | |
modelJSON[thisNode.localName] = []; | |
modelJSON[thisNode.localName].push( | |
new EMLDistribution( | |
{ | |
objectDOM: thisNode, | |
parentModel: model, | |
}, | |
{ parse: true }, | |
), | |
); | |
} | |
//The EML Project is stored in the EMLProject model | |
else if (thisNode.localName == "project") { | |
modelJSON.project = new EMLProject({ | |
objectDOM: thisNode, | |
parentModel: model, | |
}); | |
} | |
//EML Temporal, Taxonomic, and Geographic Coverage modules are stored in their own models | |
else if (thisNode.localName == "coverage") { | |
var temporal = $(thisNode).children("temporalcoverage"), | |
geo = $(thisNode).children("geographiccoverage"), | |
taxon = $(thisNode).children("taxonomiccoverage"); | |
if (temporal.length) { | |
modelJSON.temporalCoverage = []; | |
_.each(temporal, function (t) { | |
modelJSON.temporalCoverage.push( | |
new EMLTemporalCoverage({ | |
objectDOM: t, | |
parentModel: model, | |
}), | |
); | |
}); | |
} | |
if (geo.length) { | |
modelJSON.geoCoverage = []; | |
_.each(geo, function (g) { | |
modelJSON.geoCoverage.push( | |
new EMLGeoCoverage({ | |
objectDOM: g, | |
parentModel: model, | |
}), | |
); | |
}); | |
} | |
if (taxon.length) { | |
modelJSON.taxonCoverage = []; | |
_.each(taxon, function (t) { | |
modelJSON.taxonCoverage.push( | |
new EMLTaxonCoverage({ | |
objectDOM: t, | |
parentModel: model, | |
}), | |
); | |
}); | |
} | |
} | |
//Parse EMLText modules | |
else if (_.contains(emlText, thisNode.localName)) { | |
if (typeof modelJSON[convertedName] == "undefined") | |
modelJSON[convertedName] = []; | |
modelJSON[convertedName].push( | |
new EMLText({ | |
objectDOM: thisNode, | |
parentModel: model, | |
}), | |
); | |
} else if (_.contains(emlMethods, thisNode.localName)) { | |
if (typeof modelJSON[thisNode.localName] === "undefined") | |
modelJSON[thisNode.localName] = []; | |
modelJSON[thisNode.localName] = new EMLMethods({ | |
objectDOM: thisNode, | |
parentModel: model, | |
}); | |
} | |
//Parse keywords | |
else if (thisNode.localName == "keywordset") { | |
//Start an array of keyword sets | |
if (typeof modelJSON["keywordSets"] == "undefined") | |
modelJSON["keywordSets"] = []; | |
modelJSON["keywordSets"].push( | |
new EMLKeywordSet({ | |
objectDOM: thisNode, | |
parentModel: model, | |
}), | |
); | |
} | |
//Parse intellectual rights | |
else if (thisNode.localName == "intellectualrights") { | |
var value = ""; | |
if ($(thisNode).children("para").length == 1) | |
value = $(thisNode).children("para").first().text().trim(); | |
else $(thisNode).text().trim(); | |
//If the value is one of our pre-defined options, then add it to the model | |
//if(_.contains(this.get("intellRightsOptions"), value)) | |
modelJSON["intellectualRights"] = value; | |
} | |
//Parse Entities | |
else if (_.contains(emlEntities, thisNode.localName)) { | |
//Start an array of Entities | |
if (typeof modelJSON["entities"] == "undefined") | |
modelJSON["entities"] = []; | |
//Create the model | |
var entityModel; | |
if (thisNode.localName == "otherentity") { | |
entityModel = new EMLOtherEntity( | |
{ | |
objectDOM: thisNode, | |
parentModel: model, | |
}, | |
{ | |
parse: true, | |
}, | |
); | |
} else if (thisNode.localName == "datatable") { | |
entityModel = new EMLDataTable( | |
{ | |
objectDOM: thisNode, | |
parentModel: model, | |
}, | |
{ | |
parse: true, | |
}, | |
); | |
} else { | |
entityModel = new EMLEntity( | |
{ | |
objectDOM: thisNode, | |
parentModel: model, | |
entityType: "application/octet-stream", | |
type: thisNode.localName, | |
}, | |
{ | |
parse: true, | |
}, | |
); | |
} | |
modelJSON["entities"].push(entityModel); | |
} | |
//Parse dataset-level annotations | |
else if (thisNode.localName === "annotation") { | |
if (!modelJSON["annotations"]) { | |
modelJSON["annotations"] = new EMLAnnotations(); | |
} | |
var annotationModel = new EMLAnnotation( | |
{ | |
objectDOM: thisNode, | |
}, | |
{ parse: true }, | |
); | |
modelJSON["annotations"].add(annotationModel); | |
} else { | |
//Is this a multi-valued field in EML? | |
if (Array.isArray(this.get(convertedName))) { | |
//If we already have a value for this field, then add this value to the array | |
if (Array.isArray(modelJSON[convertedName])) | |
modelJSON[convertedName].push(this.toJson(thisNode)); | |
//If it's the first value for this field, then create a new array | |
else modelJSON[convertedName] = [this.toJson(thisNode)]; | |
} else modelJSON[convertedName] = this.toJson(thisNode); | |
} | |
} | |
// Once all the nodes have been parsed, check if any of the annotations | |
// make up a canonical dataset reference | |
const annotations = modelJSON["annotations"]; | |
if (annotations) { | |
const canonicalDataset = annotations.getCanonicalURI(); | |
if (canonicalDataset) { | |
modelJSON["canonicalDataset"] = canonicalDataset; | |
} | |
} | |
return modelJSON; | |
}, | |
/* | |
* Retireves the model attributes and serializes into EML XML, to produce the new or modified EML document. | |
* Returns the EML XML as a string. | |
*/ | |
serialize: function () { | |
//Get the EML document | |
var xmlString = this.get("objectXML"), | |
html = $.parseHTML(xmlString), | |
eml = $(html).filter("eml\\:eml"), | |
datasetNode = $(eml).find("dataset"); | |
//Update the packageId on the eml node with the EML id | |
$(eml).attr("packageId", this.get("id")); | |
// Set id attribute on dataset node if needed | |
if (this.get("xmlID")) { | |
$(datasetNode).attr("id", this.get("xmlID")); | |
} | |
// Set schema version | |
$(eml).attr( | |
"xmlns:eml", | |
MetacatUI.appModel.get("editorSerializationFormat") || | |
"https://eml.ecoinformatics.org/eml-2.2.0", | |
); | |
// Set formatID | |
this.set( | |
"formatId", | |
MetacatUI.appModel.get("editorSerializationFormat") || | |
"https://eml.ecoinformatics.org/eml-2.2.0", | |
); | |
// Ensure xsi:schemaLocation has a value for the current format | |
eml = this.setSchemaLocation(eml); | |
var nodeNameMap = this.nodeNameMap(); | |
//Serialize the basic text fields | |
var basicText = ["alternateIdentifier", "title"]; | |
_.each( | |
basicText, | |
function (fieldName) { | |
var basicTextValues = this.get(fieldName); | |
if (!Array.isArray(basicTextValues)) | |
basicTextValues = [basicTextValues]; | |
// Remove existing nodes | |
datasetNode.children(fieldName.toLowerCase()).remove(); | |
// Create new nodes | |
var nodes = _.map(basicTextValues, function (value) { | |
if (value) { | |
var node = document.createElement(fieldName.toLowerCase()); | |
$(node).text(value); | |
return node; | |
} else { | |
return ""; | |
} | |
}); | |
var insertAfter = this.getEMLPosition(eml, fieldName.toLowerCase()); | |
if (insertAfter) { | |
insertAfter.after(nodes); | |
} else { | |
datasetNode.prepend(nodes); | |
} | |
}, | |
this, | |
); | |
// Serialize pubDate | |
// This one is special because it has a default behavior, unlike | |
// the others: When no pubDate is set, it should be set to | |
// the current year | |
var pubDate = this.get("pubDate"); | |
datasetNode.find("pubdate").remove(); | |
if (pubDate != null && pubDate.length > 0) { | |
var pubDateEl = document.createElement("pubdate"); | |
$(pubDateEl).text(pubDate); | |
this.getEMLPosition(eml, "pubdate").after(pubDateEl); | |
} | |
// Serialize the parts of EML that are eml-text modules | |
var textFields = ["abstract", "additionalInfo"]; | |
_.each( | |
textFields, | |
function (field) { | |
var fieldName = this.nodeNameMap()[field] || field; | |
// Get the EMLText model | |
var emlTextModels = Array.isArray(this.get(field)) | |
? this.get(field) | |
: [this.get(field)]; | |
if (!emlTextModels.length) return; | |
// Get the node from the EML doc | |
var nodes = datasetNode.find(fieldName); | |
// Update the DOMs for each model | |
_.each( | |
emlTextModels, | |
function (thisTextModel, i) { | |
//Don't serialize falsey values | |
if (!thisTextModel) return; | |
var node; | |
//Get the existing node or create a new one | |
if (nodes.length < i + 1) { | |
node = document.createElement(fieldName); | |
this.getEMLPosition(eml, fieldName).after(node); | |
} else { | |
node = nodes[i]; | |
} | |
$(node).html($(thisTextModel.updateDOM()).html()); | |
}, | |
this, | |
); | |
// Remove the extra nodes | |
this.removeExtraNodes(nodes, emlTextModels); | |
}, | |
this, | |
); | |
//Create a <coverage> XML node if there isn't one | |
if (datasetNode.children("coverage").length === 0) { | |
var coverageNode = $(document.createElement("coverage")), | |
coveragePosition = this.getEMLPosition(eml, "coverage"); | |
if (coveragePosition) coveragePosition.after(coverageNode); | |
else datasetNode.append(coverageNode); | |
} else { | |
var coverageNode = datasetNode.children("coverage").first(); | |
} | |
//Serialize the geographic coverage | |
if ( | |
typeof this.get("geoCoverage") !== "undefined" && | |
this.get("geoCoverage").length > 0 | |
) { | |
// Don't serialize if geoCoverage is invalid | |
var validCoverages = _.filter( | |
this.get("geoCoverage"), | |
function (cov) { | |
return cov.isValid(); | |
}, | |
); | |
//Get the existing geo coverage nodes from the EML | |
var existingGeoCov = datasetNode.find("geographiccoverage"); | |
//Update the DOM of each model | |
_.each( | |
validCoverages, | |
function (cov, position) { | |
//Update the existing node if it exists | |
if (existingGeoCov.length - 1 >= position) { | |
$(existingGeoCov[position]).replaceWith(cov.updateDOM()); | |
} | |
//Or, append new nodes | |
else { | |
var insertAfter = existingGeoCov.length | |
? datasetNode.find("geographiccoverage").last() | |
: null; | |
if (insertAfter) insertAfter.after(cov.updateDOM()); | |
else coverageNode.append(cov.updateDOM()); | |
} | |
}, | |
this, | |
); | |
//Remove existing taxon coverage nodes that don't have an accompanying model | |
this.removeExtraNodes( | |
datasetNode.find("geographiccoverage"), | |
validCoverages, | |
); | |
} else { | |
//If there are no geographic coverages, remove the nodes | |
coverageNode.children("geographiccoverage").remove(); | |
} | |
//Serialize the taxonomic coverage | |
if ( | |
typeof this.get("taxonCoverage") !== "undefined" && | |
this.get("taxonCoverage").length > 0 | |
) { | |
// Group the taxonomic coverage models into empty and non-empty | |
var sortedTaxonModels = _.groupBy( | |
this.get("taxonCoverage"), | |
function (t) { | |
if (_.flatten(t.get("taxonomicClassification")).length > 0) { | |
return "notEmpty"; | |
} else { | |
return "empty"; | |
} | |
}, | |
); | |
//Get the existing taxon coverage nodes from the EML | |
var existingTaxonCov = coverageNode.children("taxonomiccoverage"); | |
//Iterate over each taxon coverage and update it's DOM | |
if ( | |
sortedTaxonModels["notEmpty"] && | |
sortedTaxonModels["notEmpty"].length > 0 | |
) { | |
//Update the DOM of each model | |
_.each( | |
sortedTaxonModels["notEmpty"], | |
function (taxonCoverage, position) { | |
//Update the existing taxonCoverage node if it exists | |
if (existingTaxonCov.length - 1 >= position) { | |
$(existingTaxonCov[position]).replaceWith( | |
taxonCoverage.updateDOM(), | |
); | |
} | |
//Or, append new nodes | |
else { | |
coverageNode.append(taxonCoverage.updateDOM()); | |
} | |
}, | |
); | |
//Remove existing taxon coverage nodes that don't have an accompanying model | |
this.removeExtraNodes(existingTaxonCov, this.get("taxonCoverage")); | |
} | |
//If all the taxon coverages are empty, remove the parent taxonomicCoverage node | |
else if ( | |
!sortedTaxonModels["notEmpty"] || | |
sortedTaxonModels["notEmpty"].length == 0 | |
) { | |
existingTaxonCov.remove(); | |
} | |
} | |
//Serialize the temporal coverage | |
var existingTemporalCoverages = datasetNode.find("temporalcoverage"); | |
//Update the DOM of each model | |
_.each( | |
this.get("temporalCoverage"), | |
function (temporalCoverage, position) { | |
//Update the existing temporalCoverage node if it exists | |
if (existingTemporalCoverages.length - 1 >= position) { | |
$(existingTemporalCoverages[position]).replaceWith( | |
temporalCoverage.updateDOM(), | |
); | |
} | |
//Or, append new nodes | |
else { | |
coverageNode.append(temporalCoverage.updateDOM()); | |
} | |
}, | |
); | |
//Remove existing taxon coverage nodes that don't have an accompanying model | |
this.removeExtraNodes( | |
existingTemporalCoverages, | |
this.get("temporalCoverage"), | |
); | |
//Remove the temporal coverage if it is empty | |
if (!coverageNode.children("temporalcoverage").children().length) { | |
coverageNode.children("temporalcoverage").remove(); | |
} | |
//Remove the <coverage> node if it's empty | |
if (coverageNode.children().length == 0) { | |
coverageNode.remove(); | |
} | |
// Dataset-level annotations | |
datasetNode.children("annotation").remove(); | |
if (this.get("annotations")) { | |
this.get("annotations").each(function (annotation) { | |
if (annotation.isEmpty()) { | |
return; | |
} | |
var after = this.getEMLPosition(eml, "annotation"); | |
$(after).after(annotation.updateDOM()); | |
}, this); | |
//Since there is at least one annotation, the dataset node needs to have an id attribute. | |
datasetNode.attr("id", this.getUniqueEntityId(this)); | |
} | |
//If there is no creator, create one from the user | |
if (!this.get("creator").length) { | |
var party = new EMLParty({ parentModel: this, type: "creator" }); | |
party.createFromUser(); | |
this.set("creator", [party]); | |
} | |
//Serialize the creators | |
this.serializeParties(eml, "creator"); | |
//Serialize the metadata providers | |
this.serializeParties(eml, "metadataProvider"); | |
//Serialize the associated parties | |
this.serializeParties(eml, "associatedParty"); | |
//Serialize the contacts | |
this.serializeParties(eml, "contact"); | |
//Serialize the publishers | |
this.serializeParties(eml, "publisher"); | |
// Serialize methods | |
if (this.get("methods")) { | |
//If the methods model is empty, remove it from the EML | |
if (this.get("methods").isEmpty()) | |
datasetNode.find("methods").remove(); | |
else { | |
//Serialize the methods model | |
var methodsEl = this.get("methods").updateDOM(); | |
//If the methodsEl is an empty string or other falsey value, then remove the methods node | |
if (!methodsEl || !$(methodsEl).children().length) { | |
datasetNode.find("methods").remove(); | |
} else { | |
//Add the <methods> node to the EML | |
datasetNode.find("methods").detach(); | |
var insertAfter = this.getEMLPosition(eml, "methods"); | |
if (insertAfter) insertAfter.after(methodsEl); | |
else datasetNode.append(methodsEl); | |
} | |
} | |
} | |
//If there are no methods, then remove the methods nodes | |
else { | |
if (datasetNode.find("methods").length > 0) { | |
datasetNode.find("methods").remove(); | |
} | |
} | |
//Serialize the keywords | |
this.serializeKeywords(eml, "keywordSets"); | |
//Serialize the intellectual rights | |
if (this.get("intellectualRights")) { | |
if (datasetNode.find("intellectualRights").length) | |
datasetNode | |
.find("intellectualRights") | |
.html("<para>" + this.get("intellectualRights") + "</para>"); | |
else { | |
this.getEMLPosition(eml, "intellectualrights").after( | |
$(document.createElement("intellectualRights")).html( | |
"<para>" + this.get("intellectualRights") + "</para>", | |
), | |
); | |
} | |
} | |
// Serialize the distribution | |
const distributions = this.get("distribution"); | |
if (distributions && distributions.length > 0) { | |
// Remove existing nodes | |
datasetNode.children("distribution").remove(); | |
// Get the updated DOMs | |
const distributionDOMs = distributions.map((d) => d.updateDOM()); | |
// Insert the updated DOMs in their correct positions | |
distributionDOMs.forEach((dom, i) => { | |
const insertAfter = this.getEMLPosition(eml, "distribution"); | |
if (insertAfter) { | |
insertAfter.after(dom); | |
} else { | |
datasetNode.append(dom); | |
} | |
}); | |
} | |
//Detach the project elements from the DOM | |
if (datasetNode.find("project").length) { | |
datasetNode.find("project").detach(); | |
} | |
//If there is an EMLProject, update its DOM | |
if (this.get("project")) { | |
this.getEMLPosition(eml, "project").after( | |
this.get("project").updateDOM(), | |
); | |
} | |
//Get the existing taxon coverage nodes from the EML | |
var existingEntities = datasetNode.find( | |
"otherEntity, dataTable, spatialRaster, spatialVector, storedProcedure, view", | |
); | |
//Serialize the entities | |
_.each( | |
this.get("entities"), | |
function (entity, position) { | |
//Update the existing node if it exists | |
if (existingEntities.length - 1 >= position) { | |
//Remove the entity from the EML | |
$(existingEntities[position]).detach(); | |
//Insert it into the correct position | |
this.getEMLPosition(eml, entity.get("type").toLowerCase()).after( | |
entity.updateDOM(), | |
); | |
} | |
//Or, append new nodes | |
else { | |
//Inser the entity into the correct position | |
this.getEMLPosition(eml, entity.get("type").toLowerCase()).after( | |
entity.updateDOM(), | |
); | |
} | |
}, | |
this, | |
); | |
//Remove extra entities that have been removed | |
var numExtraEntities = | |
existingEntities.length - this.get("entities").length; | |
for ( | |
var i = existingEntities.length - numExtraEntities; | |
i < existingEntities.length; | |
i++ | |
) { | |
$(existingEntities)[i].remove(); | |
} | |
//Do a final check to make sure there are no duplicate ids in the EML | |
var elementsWithIDs = $(eml).find("[id]"), | |
//Get an array of all the ids in this EML doc | |
allIDs = _.map(elementsWithIDs, function (el) { | |
return $(el).attr("id"); | |
}); | |
//If there is at least one id in the EML... | |
if (allIDs && allIDs.length) { | |
//Boil the array down to just the unique values | |
var uniqueIDs = _.uniq(allIDs); | |
//If the unique array is shorter than the array of all ids, | |
// then there is a duplicate somewhere | |
if (uniqueIDs.length < allIDs.length) { | |
//For each element in the EML that has an id, | |
_.each(elementsWithIDs, function (el) { | |
//Get the id for this element | |
var id = $(el).attr("id"); | |
//If there is more than one element in the EML with this id, | |
if ($(eml).find("[id='" + id + "']").length > 1) { | |
//And if it is not a unit node, which we don't want to change, | |
if (!$(el).is("unit")) | |
//Then change the id attribute to a random uuid | |
$(el).attr("id", "urn-uuid-" + uuid.v4()); | |
} | |
}); | |
} | |
} | |
//Camel-case the XML | |
var emlString = ""; | |
_.each( | |
html, | |
function (rootEMLNode) { | |
emlString += this.formatXML(rootEMLNode); | |
}, | |
this, | |
); | |
return emlString; | |
}, | |
/* | |
* Given an EML DOM and party type, this function updated and/or adds the EMLParties to the EML | |
*/ | |
serializeParties: function (eml, type) { | |
//Remove the nodes from the EML for this party type | |
$(eml).children("dataset").children(type.toLowerCase()).remove(); | |
//Serialize each party of this type | |
_.each( | |
this.get(type), | |
function (party, i) { | |
//Get the last node of this type to insert after | |
var insertAfter = $(eml) | |
.children("dataset") | |
.children(type.toLowerCase()) | |
.last(); | |
//If there isn't a node found, find the EML position to insert after | |
if (!insertAfter.length) { | |
insertAfter = this.getEMLPosition(eml, type); | |
} | |
//Update the DOM of the EMLParty | |
var emlPartyDOM = party.updateDOM(); | |
//Make sure we don't insert empty EMLParty nodes into the EML | |
if ($(emlPartyDOM).children().length) { | |
//Insert the party DOM at the insert position | |
if (insertAfter && insertAfter.length) | |
insertAfter.after(emlPartyDOM); | |
//If an insert position still hasn't been found, then just append to the dataset node | |
else $(eml).find("dataset").append(emlPartyDOM); | |
} | |
}, | |
this, | |
); | |
//Create a certain parties from the current app user if none is given | |
if (type == "contact" && !this.get("contact").length) { | |
//Get the creators | |
var creators = this.get("creator"), | |
contacts = []; | |
_.each( | |
creators, | |
function (creator) { | |
//Clone the creator model and add it to the contacts array | |
var newModel = new EMLParty({ parentModel: this }); | |
newModel.set(creator.toJSON()); | |
newModel.set("type", type); | |
contacts.push(newModel); | |
}, | |
this, | |
); | |
this.set(type, contacts); | |
//Call this function again to serialize the new models | |
this.serializeParties(eml, type); | |
} | |
}, | |
serializeKeywords: function (eml) { | |
// Remove all existing keywordSets before appending | |
$(eml).find("dataset").find("keywordset").remove(); | |
if (this.get("keywordSets").length == 0) return; | |
// Create the new keywordSets nodes | |
var nodes = _.map(this.get("keywordSets"), function (kwd) { | |
return kwd.updateDOM(); | |
}); | |
this.getEMLPosition(eml, "keywordset").after(nodes); | |
}, | |
/* | |
* Remoes nodes from the EML that do not have an accompanying model | |
* (Were probably removed from the EML by the user during editing) | |
*/ | |
removeExtraNodes: function (nodes, models) { | |
// Remove the extra nodes | |
var extraNodes = nodes.length - models.length; | |
if (extraNodes > 0) { | |
for (var i = models.length; i < nodes.length; i++) { | |
$(nodes[i]).remove(); | |
} | |
} | |
}, | |
/* | |
* Saves the EML document to the server using the DataONE API | |
*/ | |
save: function (attributes, options) { | |
//Validate before we try anything else | |
if (!this.isValid()) { | |
this.trigger("invalid"); | |
this.trigger("cancelSave"); | |
return false; | |
} else { | |
this.trigger("valid"); | |
} | |
this.setFileName(); | |
//Set the upload transfer as in progress | |
this.set("uploadStatus", "p"); | |
//Reset the draftSaved attribute | |
this.set("draftSaved", false); | |
//Create the creator from the current user if none is provided | |
if (!this.get("creator").length) { | |
var party = new EMLParty({ parentModel: this, type: "creator" }); | |
party.createFromUser(); | |
this.set("creator", [party]); | |
} | |
//Create the contact from the current user if none is provided | |
if (!this.get("contact").length) { | |
var party = new EMLParty({ parentModel: this, type: "contact" }); | |
party.createFromUser(); | |
this.set("contact", [party]); | |
} | |
//If this is an existing object and there is no system metadata, retrieve it | |
if (!this.isNew() && !this.get("sysMetaXML")) { | |
var model = this; | |
//When the system metadata is fetched, try saving again | |
var fetchOptions = { | |
success: function (response) { | |
model.set(DataONEObject.prototype.parse.call(model, response)); | |
model.save(attributes, options); | |
}, | |
}; | |
//Fetch the system metadata now | |
this.fetchSystemMetadata(fetchOptions); | |
return; | |
} | |
//Create a FormData object to send data with our XHR | |
var formData = new FormData(); | |
try { | |
//Add the identifier to the XHR data | |
if (this.isNew()) { | |
formData.append("pid", this.get("id")); | |
} else { | |
//Create a new ID | |
this.updateID(); | |
//Add the ids to the form data | |
formData.append("newPid", this.get("id")); | |
formData.append("pid", this.get("oldPid")); | |
} | |
//Serialize the EML XML | |
var xml = this.serialize(); | |
var xmlBlob = new Blob([xml], { type: "application/xml" }); | |
//Get the size of the new EML XML | |
this.set("size", xmlBlob.size); | |
//Get the new checksum of the EML XML | |
var checksum = md5(xml); | |
this.set("checksum", checksum); | |
this.set("checksumAlgorithm", "MD5"); | |
//Create the system metadata XML | |
var sysMetaXML = this.serializeSysMeta(); | |
//Send the system metadata as a Blob | |
var sysMetaXMLBlob = new Blob([sysMetaXML], { | |
type: "application/xml", | |
}); | |
//Add the object XML and System Metadata XML to the form data | |
//Append the system metadata first, so we can take advantage of Metacat's streaming multipart handler | |
formData.append("sysmeta", sysMetaXMLBlob, "sysmeta"); | |
formData.append("object", xmlBlob); | |
} catch (error) { | |
//Reset the identifier since we didn't actually update the object | |
this.resetID(); | |
this.set("uploadStatus", "e"); | |
this.trigger("error"); | |
this.trigger("cancelSave"); | |
return false; | |
} | |
var model = this; | |
var saveOptions = options || {}; | |
_.extend( | |
saveOptions, | |
{ | |
data: formData, | |
cache: false, | |
contentType: false, | |
dataType: "text", | |
processData: false, | |
parse: false, | |
//Use the URL function to determine the URL | |
url: this.isNew() ? this.url() : this.url({ update: true }), | |
xhr: function () { | |
var xhr = new window.XMLHttpRequest(); | |
//Upload progress | |
xhr.upload.addEventListener( | |
"progress", | |
function (evt) { | |
if (evt.lengthComputable) { | |
var percentComplete = (evt.loaded / evt.total) * 100; | |
model.set("uploadProgress", percentComplete); | |
} | |
}, | |
false, | |
); | |
return xhr; | |
}, | |
success: function (model, response, xhr) { | |
model.set("numSaveAttempts", 0); | |
model.set("uploadStatus", "c"); | |
model.set("sysMetaXML", model.serializeSysMeta()); | |
model.set("oldPid", null); | |
model.fetch({ merge: true, systemMetadataOnly: true }); | |
model.trigger("successSaving", model); | |
}, | |
error: function (model, response, xhr) { | |
model.set("numSaveAttempts", model.get("numSaveAttempts") + 1); | |
var numSaveAttempts = model.get("numSaveAttempts"); | |
//Reset the identifier changes | |
model.resetID(); | |
if ( | |
numSaveAttempts < 3 && | |
(response.status == 408 || response.status == 0) | |
) { | |
//Try saving again in 10, 40, and 90 seconds | |
setTimeout( | |
function () { | |
model.save.call(model); | |
}, | |
numSaveAttempts * numSaveAttempts * 10000, | |
); | |
} else { | |
model.set("numSaveAttempts", 0); | |
//Get the error error information | |
var errorDOM = $($.parseHTML(response.responseText)), | |
errorContainer = errorDOM.filter("error"), | |
msgContainer = errorContainer.length | |
? errorContainer.find("description") | |
: errorDOM.not("style, title"), | |
errorMsg = msgContainer.length | |
? msgContainer.text() | |
: errorDOM; | |
//When there is no network connection (status == 0), there will be no response text | |
if (!errorMsg || response.status == 408 || response.status == 0) | |
errorMsg = | |
"There was a network issue that prevented your metadata from uploading. " + | |
"Make sure you are connected to a reliable internet connection."; | |
//Save the error message in the model | |
model.set("errorMessage", errorMsg); | |
//Set the model status as e for error | |
model.set("uploadStatus", "e"); | |
//Save the EML as a plain text file, until drafts are a supported feature | |
var copy = model.createTextCopy(); | |
//If the EML copy successfully saved, let the user know that there is a copy saved behind the scenes | |
model.listenToOnce(copy, "successSaving", function () { | |
model.set("draftSaved", true); | |
//Trigger the errorSaving event so other parts of the app know that the model failed to save | |
//And send the error message with it | |
model.trigger("errorSaving", errorMsg); | |
}); | |
//If the EML copy fails to save too, then just display the usual error message | |
model.listenToOnce(copy, "errorSaving", function () { | |
//Trigger the errorSaving event so other parts of the app know that the model failed to save | |
//And send the error message with it | |
model.trigger("errorSaving", errorMsg); | |
}); | |
//Save the EML plain text copy | |
copy.save(); | |
// Track the error | |
MetacatUI.analytics?.trackException( | |
`EML save error: ${errorMsg}, EML draft: ${copy.get("id")}`, | |
model.get("id"), | |
true, | |
); | |
} | |
}, | |
}, | |
MetacatUI.appUserModel.createAjaxSettings(), | |
); | |
return Backbone.Model.prototype.save.call( | |
this, | |
attributes, | |
saveOptions, | |
); | |
}, | |
/* | |
* Checks if this EML model has all the required values necessary to save to the server | |
*/ | |
validate: function () { | |
let errors = {}; | |
//A title is always required by EML | |
if (!this.get("title").length || !this.get("title")[0]) { | |
errors.title = "A title is required"; | |
} | |
// Validate the publication date | |
if (this.get("pubDate") != null) { | |
if (!this.isValidYearDate(this.get("pubDate"))) { | |
errors["pubDate"] = [ | |
"The value entered for publication date, '" + | |
this.get("pubDate") + | |
"' is not a valid value for this field. Enter with a year (e.g. 2017) or a date in the format YYYY-MM-DD.", | |
]; | |
} | |
} | |
// Validate the temporal coverage | |
errors.temporalCoverage = []; | |
//If temporal coverage is required and there aren't any, return an error | |
if ( | |
MetacatUI.appModel.get("emlEditorRequiredFields").temporalCoverage && | |
!this.get("temporalCoverage").length | |
) { | |
errors.temporalCoverage = [{ beginDate: "Provide a begin date." }]; | |
} | |
//If temporal coverage is required and they are all empty, return an error | |
else if ( | |
MetacatUI.appModel.get("emlEditorRequiredFields").temporalCoverage && | |
_.every(this.get("temporalCoverage"), function (tc) { | |
return tc.isEmpty(); | |
}) | |
) { | |
errors.temporalCoverage = [{ beginDate: "Provide a begin date." }]; | |
} | |
//If temporal coverage is not required, validate each one | |
else if ( | |
this.get("temporalCoverage").length || | |
(MetacatUI.appModel.get("emlEditorRequiredFields").temporalCoverage && | |
_.every(this.get("temporalCoverage"), function (tc) { | |
return tc.isEmpty(); | |
})) | |
) { | |
//Iterate over each temporal coverage and add it's validation errors | |
_.each(this.get("temporalCoverage"), function (temporalCoverage) { | |
if (!temporalCoverage.isValid() && !temporalCoverage.isEmpty()) { | |
errors.temporalCoverage.push(temporalCoverage.validationError); | |
} | |
}); | |
} | |
//Remove the temporalCoverage attribute if no errors were found | |
if (errors.temporalCoverage.length == 0) { | |
delete errors.temporalCoverage; | |
} | |
//Validate the EMLParty models | |
var partyTypes = [ | |
"associatedParty", | |
"contact", | |
"creator", | |
"metadataProvider", | |
"publisher", | |
]; | |
_.each( | |
partyTypes, | |
function (type) { | |
var people = this.get(type); | |
_.each( | |
people, | |
function (person, i) { | |
if (!person.isValid()) { | |
if (!errors[type]) errors[type] = [person.validationError]; | |
else errors[type].push(person.validationError); | |
} | |
}, | |
this, | |
); | |
}, | |
this, | |
); | |
//Validate the EMLGeoCoverage models | |
_.each( | |
this.get("geoCoverage"), | |
function (geoCoverageModel, i) { | |
if (!geoCoverageModel.isValid()) { | |
if (!errors.geoCoverage) | |
errors.geoCoverage = [geoCoverageModel.validationError]; | |
else errors.geoCoverage.push(geoCoverageModel.validationError); | |
} | |
}, | |
this, | |
); | |
//Validate the EMLTaxonCoverage model | |
var taxonModel = this.get("taxonCoverage")[0]; | |
if (!taxonModel.isEmpty() && !taxonModel.isValid()) { | |
errors = _.extend(errors, taxonModel.validationError); | |
} else if ( | |
taxonModel.isEmpty() && | |
this.get("taxonCoverage").length == 1 && | |
MetacatUI.appModel.get("emlEditorRequiredFields").taxonCoverage | |
) { | |
taxonModel.isValid(); | |
errors = _.extend(errors, taxonModel.validationError); | |
} | |
//Validate each EMLEntity model | |
_.each(this.get("entities"), function (entityModel) { | |
if (!entityModel.isValid()) { | |
if (!errors.entities) | |
errors.entities = [entityModel.validationError]; | |
else errors.entities.push(entityModel.validationError); | |
} | |
}); | |
//Validate the EML Methods | |
let emlMethods = this.get("methods"); | |
if (emlMethods) { | |
if (!emlMethods.isValid()) { | |
errors.methods = emlMethods.validationError; | |
} | |
} | |
// Validate each EMLAnnotation model | |
if (this.get("annotations")) { | |
this.get("annotations").each(function (model) { | |
if (model.isValid()) { | |
return; | |
} | |
if (!errors.annotations) { | |
errors.annotations = []; | |
} | |
errors.annotations.push(model.validationError); | |
}, this); | |
} | |
//Check the required fields for this MetacatUI configuration | |
for ([field, isRequired] of Object.entries( | |
MetacatUI.appModel.get("emlEditorRequiredFields"), | |
)) { | |
//If it's not required, then go to the next field | |
if (!isRequired) continue; | |
if (field == "alternateIdentifier") { | |
if ( | |
!this.get("alternateIdentifier").length || | |
_.every(this.get("alternateIdentifier"), function (altId) { | |
return altId.trim() == ""; | |
}) | |
) | |
errors.alternateIdentifier = | |
"At least one alternate identifier is required."; | |
} else if (field == "generalTaxonomicCoverage") { | |
if ( | |
!this.get("taxonCoverage").length || | |
!this.get("taxonCoverage")[0].get("generalTaxonomicCoverage") | |
) | |
errors.generalTaxonomicCoverage = | |
"Provide a description of the general taxonomic coverage of this data set."; | |
} else if (field == "geoCoverage") { | |
if (!this.get("geoCoverage").length) | |
errors.geoCoverage = "At least one location is required."; | |
} else if (field == "intellectualRights") { | |
if (!this.get("intellectualRights")) | |
errors.intellectualRights = | |
"Select usage rights for this data set."; | |
} else if (field == "studyExtentDescription") { | |
if ( | |
!this.get("methods") || | |
!this.get("methods").get("studyExtentDescription") | |
) | |
errors.studyExtentDescription = | |
"Provide a study extent description."; | |
} else if (field == "samplingDescription") { | |
if ( | |
!this.get("methods") || | |
!this.get("methods").get("samplingDescription") | |
) | |
errors.samplingDescription = "Provide a sampling description."; | |
} else if (field == "temporalCoverage") { | |
if (!this.get("temporalCoverage").length) | |
errors.temporalCoverage = | |
"Provide the date(s) for this data set."; | |
} else if (field == "taxonCoverage") { | |
if (!this.get("taxonCoverage").length) | |
errors.taxonCoverage = | |
"At least one taxa rank and value is required."; | |
} else if (field == "keywordSets") { | |
if (!this.get("keywordSets").length) | |
errors.keywordSets = "Provide at least one keyword."; | |
} | |
//The EMLMethods model will validate itself for required fields, but | |
// this is a rudimentary check to make sure the EMLMethods model was created | |
// in the first place | |
else if (field == "methods") { | |
if (!this.get("methods")) | |
errors.methods = "At least one method step is required."; | |
} else if (field == "funding") { | |
// Note: Checks for either the funding or award element. award | |
// element is checked by the project's objectDOM for now until | |
// EMLProject fully supports the award element | |
if ( | |
!this.get("project") || | |
!( | |
this.get("project").get("funding").length || | |
(this.get("project").get("objectDOM") && | |
this.get("project").get("objectDOM").querySelectorAll && | |
this.get("project").get("objectDOM").querySelectorAll("award") | |
.length > 0) | |
) | |
) | |
errors.funding = | |
"Provide at least one project funding number or name."; | |
} else if (field == "abstract") { | |
if (!this.get("abstract").length) | |
errors["abstract"] = "Provide an abstract."; | |
} else if (field == "dataSensitivity") { | |
if (!this.getDataSensitivity()) { | |
errors["dataSensitivity"] = | |
"Pick the category that best describes the level of sensitivity or restriction of the data."; | |
} | |
} | |
//If this is an EMLParty type, check that there is a party of this type in the model | |
else if ( | |
EMLParty.prototype.partyTypes | |
.map((t) => t.dataCategory) | |
.includes(field) | |
) { | |
//If this is an associatedParty role | |
if (EMLParty.prototype.defaults().roleOptions?.includes(field)) { | |
if ( | |
!this.get("associatedParty") | |
?.map((p) => p.get("roles")) | |
.flat() | |
.includes(field) | |
) { | |
errors[field] = | |
"Provide information about the people or organization(s) in the role: " + | |
EMLParty.prototype.partyTypes.find( | |
(t) => t.dataCategory == field, | |
)?.label; | |
} | |
} else if (!this.get(field)?.length) { | |
errors[field] = | |
"Provide information about the people or organization(s) in the role: " + | |
EMLParty.prototype.partyTypes.find( | |
(t) => t.dataCategory == field, | |
)?.label; | |
} | |
} else if (!this.get(field) || !this.get(field)?.length) { | |
errors[field] = "Provide a " + field + "."; | |
} | |
} | |
if (Object.keys(errors).length) return errors; | |
else { | |
return; | |
} | |
}, | |
/* Returns a boolean for whether the argument 'value' is a valid | |
value for EML's yearDate type which is used in a few places. | |
Note that this method considers a zero-length String to be valid | |
because the EML211.serialize() method will properly handle a null | |
or zero-length String by serializing out the current year. */ | |
isValidYearDate: function (value) { | |
return ( | |
value === "" || | |
/^\d{4}$/.test(value) || | |
/^\d{4}-\d{2}-\d{2}$/.test(value) | |
); | |
}, | |
/* | |
* Sends an AJAX request to fetch the system metadata for this EML object. | |
* Will not trigger a sync event since it does not use Backbone.Model.fetch | |
*/ | |
fetchSystemMetadata: function (options) { | |
if (!options) var options = {}; | |
else options = _.clone(options); | |
var model = this, | |
fetchOptions = _.extend( | |
{ | |
url: | |
MetacatUI.appModel.get("metaServiceUrl") + | |
encodeURIComponent(this.get("id")), | |
dataType: "text", | |
success: function (response) { | |
model.set(DataONEObject.prototype.parse.call(model, response)); | |
//Trigger a custom event that the sys meta was updated | |
model.trigger("sysMetaUpdated"); | |
}, | |
error: function () { | |
model.trigger("error"); | |
}, | |
}, | |
options, | |
); | |
//Add the authorization header and other AJAX settings | |
_.extend(fetchOptions, MetacatUI.appUserModel.createAjaxSettings()); | |
$.ajax(fetchOptions); | |
}, | |
/* | |
* Returns the nofde in the given EML document that the given node type | |
* should be inserted after | |
* | |
* Returns false if either the node is not found in the and this should | |
* be handled by the caller. | |
*/ | |
getEMLPosition: function (eml, nodeName) { | |
var nodeOrder = this.get("nodeOrder"); | |
var position = _.indexOf(nodeOrder, nodeName.toLowerCase()); | |
if (position == -1) { | |
return false; | |
} | |
// Go through each node in the node list and find the position where this | |
// node will be inserted after | |
for (var i = position - 1; i >= 0; i--) { | |
if ($(eml).find("dataset").children(nodeOrder[i]).length) { | |
return $(eml).find("dataset").children(nodeOrder[i]).last(); | |
} | |
} | |
return false; | |
}, | |
/* | |
* Checks if this model has updates that need to be synced with the server. | |
*/ | |
hasUpdates: function () { | |
if (this.constructor.__super__.hasUpdates.call(this)) return true; | |
//If nothing else has been changed, then this object hasn't had any updates | |
return false; | |
}, | |
/* | |
Add an entity into the EML 2.1.1 object | |
*/ | |
addEntity: function (emlEntity, position) { | |
//Get the current list of entities | |
var currentEntities = this.get("entities"); | |
if (typeof position == "undefined" || position == -1) | |
currentEntities.push(emlEntity); | |
//Add the entity model to the entity array | |
else currentEntities.splice(position, 0, emlEntity); | |
this.trigger("change:entities"); | |
this.trickleUpChange(); | |
return this; | |
}, | |
/* | |
Remove an entity from the EML 2.1.1 object | |
*/ | |
removeEntity: function (emlEntity) { | |
if (!emlEntity || typeof emlEntity != "object") return; | |
//Get the current list of entities | |
var entities = this.get("entities"); | |
entities = _.without(entities, emlEntity); | |
this.set("entities", entities); | |
}, | |
/* | |
* Find the entity model for a given DataONEObject | |
*/ | |
getEntity: function (dataONEObj) { | |
//If an EMLEntity model has been found for this object before, then return it | |
if (dataONEObj.get("metadataEntity")) { | |
dataONEObj.get("metadataEntity").set("dataONEObject", dataONEObj); | |
return dataONEObj.get("metadataEntity"); | |
} | |
var entity = _.find( | |
this.get("entities"), | |
function (e) { | |
//Matches of the checksum or identifier are definite matches | |
if (e.get("xmlID") == dataONEObj.getXMLSafeID()) return true; | |
else if ( | |
e.get("physicalMD5Checksum") && | |
e.get("physicalMD5Checksum") == dataONEObj.get("checksum") && | |
dataONEObj.get("checksumAlgorithm").toUpperCase() == "MD5" | |
) | |
return true; | |
else if ( | |
e.get("downloadID") && | |
e.get("downloadID") == dataONEObj.get("id") | |
) | |
return true; | |
// Get the file name from the EML for this entity | |
var fileNameFromEML = | |
e.get("physicalObjectName") || e.get("entityName"); | |
// If the EML file name matches the DataONEObject file name | |
if ( | |
fileNameFromEML && | |
dataONEObj.get("fileName") && | |
(fileNameFromEML.toLowerCase() == | |
dataONEObj.get("fileName").toLowerCase() || | |
fileNameFromEML.replace(/ /g, "_").toLowerCase() == | |
dataONEObj.get("fileName").toLowerCase()) | |
) { | |
//Get an array of all the other entities in this EML | |
var otherEntities = _.without(this.get("entities"), e); | |
// If this entity name matches the dataone object file name, AND no other dataone object file name | |
// matches, then we can assume this is the entity element for this file. | |
var otherMatchingEntity = _.find( | |
otherEntities, | |
function (otherE) { | |
// Get the file name from the EML for the other entities | |
var otherFileNameFromEML = | |
otherE.get("physicalObjectName") || | |
otherE.get("entityName"); | |
// If the file names match, return true | |
if ( | |
otherFileNameFromEML == dataONEObj.get("fileName") || | |
otherFileNameFromEML.replace(/ /g, "_") == | |
dataONEObj.get("fileName") | |
) | |
return true; | |
}, | |
); | |
// If this entity's file name didn't match any other file names in the EML, | |
// then this entity is a match for the given dataONEObject | |
if (!otherMatchingEntity) return true; | |
} | |
}, | |
this, | |
); | |
//If we found an entity, give it an ID and return it | |
if (entity) { | |
//If this entity has been matched to another DataONEObject already, then don't match it again | |
if (entity.get("dataONEObject") == dataONEObj) { | |
return entity; | |
} | |
//If this entity has been matched to a different DataONEObject already, then don't match it again. | |
//i.e. We will not override existing entity<->DataONEObject pairings | |
else if (entity.get("dataONEObject")) { | |
return; | |
} else { | |
entity.set("dataONEObject", dataONEObj); | |
} | |
//Create an XML-safe ID and set it on the Entity model | |
var entityID = this.getUniqueEntityId(dataONEObj); | |
entity.set("xmlID", entityID); | |
//Save a reference to this entity so we don't have to refind it later | |
dataONEObj.set("metadataEntity", entity); | |
return entity; | |
} | |
//See if one data object is of this type in the package | |
var matchingTypes = _.filter(this.get("entities"), function (e) { | |
return ( | |
e.get("formatName") == | |
(dataONEObj.get("formatId") || dataONEObj.get("mediaType")) | |
); | |
}); | |
if (matchingTypes.length == 1) { | |
//Create an XML-safe ID and set it on the Entity model | |
matchingTypes[0].set("xmlID", dataONEObj.getXMLSafeID()); | |
return matchingTypes[0]; | |
} | |
//If this EML is in a DataPackage with only one other DataONEObject, | |
// and there is only one entity in the EML, then we can assume they are the same entity | |
if (this.get("entities").length == 1) { | |
if ( | |
this.get("collections")[0] && | |
this.get("collections")[0].type == "DataPackage" && | |
this.get("collections")[0].length == 2 && | |
_.contains(this.get("collections")[0].models, dataONEObj) | |
) { | |
return this.get("entities")[0]; | |
} | |
} | |
return false; | |
}, | |
createEntity: function (dataONEObject) { | |
// Add or append an entity to the parent's entity list | |
var entityModel = new EMLOtherEntity({ | |
entityName: dataONEObject.get("fileName"), | |
entityType: | |
dataONEObject.get("formatId") || | |
dataONEObject.get("mediaType") || | |
"application/octet-stream", | |
dataONEObject: dataONEObject, | |
parentModel: this, | |
xmlID: dataONEObject.getXMLSafeID(), | |
}); | |
this.addEntity(entityModel); | |
//If this DataONEObject fails to upload, remove the EML entity | |
this.listenTo(dataONEObject, "errorSaving", function () { | |
this.removeEntity(dataONEObject.get("metadataEntity")); | |
//Listen for a successful save so the entity can be added back | |
this.listenToOnce(dataONEObject, "successSaving", function () { | |
this.addEntity(dataONEObject.get("metadataEntity")); | |
}); | |
}); | |
}, | |
/* | |
* Creates an XML-safe identifier that is unique to this EML document, | |
* based on the given DataONEObject model. It is intended for EML entity nodes in particular. | |
* | |
* @param {DataONEObject} - a DataONEObject model that this EML documents | |
* @return {string} - an identifier string unique to this EML document | |
*/ | |
getUniqueEntityId: function (dataONEObject) { | |
var uniqueId = ""; | |
uniqueId = dataONEObject.getXMLSafeID(); | |
//Get the EML string, if there is one, to check if this id already exists | |
var emlString = this.get("objectXML"); | |
//If this id already exists in the EML... | |
if (emlString && emlString.indexOf(' id="' + uniqueId + '"')) { | |
//Create a random uuid to use instead | |
uniqueId = "urn-uuid-" + uuid.v4(); | |
} | |
return uniqueId; | |
}, | |
/* | |
* removeParty - removes the given EMLParty model from this EML211 model's attributes | |
*/ | |
removeParty: function (partyModel) { | |
//The list of attributes this EMLParty might be stored in | |
var possibleAttr = [ | |
"creator", | |
"contact", | |
"metadataProvider", | |
"publisher", | |
"associatedParty", | |
]; | |
// Iterate over each possible attribute | |
_.each( | |
possibleAttr, | |
function (attr) { | |
if (_.contains(this.get(attr), partyModel)) { | |
this.set(attr, _.without(this.get(attr), partyModel)); | |
} | |
}, | |
this, | |
); | |
}, | |
/** | |
* Attempt to move a party one index forward within its sibling models | |
* | |
* @param {EMLParty} partyModel: The EMLParty model we're moving | |
*/ | |
movePartyUp: function (partyModel) { | |
var possibleAttr = [ | |
"creator", | |
"contact", | |
"metadataProvider", | |
"publisher", | |
"associatedParty", | |
]; | |
// Iterate over each possible attribute | |
_.each( | |
possibleAttr, | |
function (attr) { | |
if (!_.contains(this.get(attr), partyModel)) { | |
return; | |
} | |
// Make a clone because we're going to use splice | |
var models = _.clone(this.get(attr)); | |
// Find the index of the model we're moving | |
var index = _.findIndex(models, function (m) { | |
return m === partyModel; | |
}); | |
if (index === 0) { | |
// Already first | |
return; | |
} | |
if (index === -1) { | |
// Couldn't find the model | |
return; | |
} | |
// Do the move using splice and update the model | |
models.splice(index - 1, 0, models.splice(index, 1)[0]); | |
this.set(attr, models); | |
this.trigger("change:" + attr); | |
}, | |
this, | |
); | |
}, | |
/** | |
* Attempt to move a party one index forward within its sibling models | |
* | |
* @param {EMLParty} partyModel: The EMLParty model we're moving | |
*/ | |
movePartyDown: function (partyModel) { | |
var possibleAttr = [ | |
"creator", | |
"contact", | |
"metadataProvider", | |
"publisher", | |
"associatedParty", | |
]; | |
// Iterate over each possible attribute | |
_.each( | |
possibleAttr, | |
function (attr) { | |
if (!_.contains(this.get(attr), partyModel)) { | |
return; | |
} | |
// Make a clone because we're going to use splice | |
var models = _.clone(this.get(attr)); | |
// Find the index of the model we're moving | |
var index = _.findIndex(models, function (m) { | |
return m === partyModel; | |
}); | |
if (index === -1) { | |
// Couldn't find the model | |
return; | |
} | |
// Figure out where to put the new model | |
// Leave it in the same place if the next index doesn't exist | |
// Move one forward if it does | |
var newIndex = models.length <= index + 1 ? index : index + 1; | |
// Do the move using splice and update the model | |
models.splice(newIndex, 0, models.splice(index, 1)[0]); | |
this.set(attr, models); | |
this.trigger("change:" + attr); | |
}, | |
this, | |
); | |
}, | |
/* | |
* Adds the given EMLParty model to this EML211 model in the | |
* appropriate role array in the given position | |
* | |
* @param {EMLParty} - The EMLParty model to add | |
* @param {number} - The position in the role array in which to insert this EMLParty | |
* @return {boolean} - Returns true if the EMLParty was successfully added, false if it was cancelled | |
*/ | |
addParty: function (partyModel, position) { | |
//If the EMLParty model is empty, don't add it to the EML211 model | |
if (partyModel.isEmpty()) return false; | |
//Get the role of this EMLParty | |
var role = partyModel.get("type") || "associatedParty"; | |
//If this model already contains this EMLParty, then exit | |
if (_.contains(this.get(role), partyModel)) return false; | |
if (typeof position == "undefined") { | |
this.get(role).push(partyModel); | |
} else { | |
this.get(role).splice(position, 0, partyModel); | |
} | |
this.trigger("change:" + role); | |
return true; | |
}, | |
/** | |
* getPartiesByType - Gets an array of EMLParty members that have a particular party type or role. | |
* @param {string} partyType - A string that represents either the role or the party type. For example, "contact", "creator", "principalInvestigator", etc. | |
* @since 2.15.0 | |
*/ | |
getPartiesByType: function (partyType) { | |
try { | |
if (!partyType) { | |
return false; | |
} | |
var associatedPartyTypes = new EMLParty().get("roleOptions"), | |
isAssociatedParty = associatedPartyTypes.includes(partyType), | |
parties = []; | |
// For "contact", "creator", "metadataProvider", "publisher", each party type has it's own | |
// array in the EML model | |
if (!isAssociatedParty) { | |
parties = this.get(partyType); | |
// For "custodianSteward", "principalInvestigator", "collaboratingPrincipalInvestigator", etc., | |
// party members are listed in the EML model's associated parties array. Each associated party's | |
// party type is indicated in the role attribute. | |
} else { | |
parties = _.filter( | |
this.get("associatedParty"), | |
function (associatedParty) { | |
return associatedParty.get("roles").includes(partyType); | |
}, | |
); | |
} | |
return parties; | |
} catch (error) { | |
console.log( | |
"Error trying to find a list of party members in an EML model by type. Error details: " + | |
error, | |
); | |
} | |
}, | |
createUnits: function () { | |
this.units.fetch(); | |
}, | |
/* Initialize the object XML for brand spankin' new EML objects */ | |
createXML: function () { | |
let emlSystem = MetacatUI.appModel.get("emlSystem"); | |
emlSystem = | |
!emlSystem || typeof emlSystem != "string" ? "knb" : emlSystem; | |
var xml = | |
'<eml:eml xmlns:eml="https://eml.ecoinformatics.org/eml-2.2.0"></eml:eml>', | |
eml = $($.parseHTML(xml)); | |
// Set base attributes | |
eml.attr("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"); | |
eml.attr("xmlns:stmml", "http://www.xml-cml.org/schema/stmml-1.1"); | |
eml.attr( | |
"xsi:schemaLocation", | |
"https://eml.ecoinformatics.org/eml-2.2.0 https://eml.ecoinformatics.org/eml-2.2.0/eml.xsd", | |
); | |
eml.attr("packageId", this.get("id")); | |
eml.attr("system", emlSystem); | |
// Add the dataset | |
eml.append(document.createElement("dataset")); | |
eml.find("dataset").append(document.createElement("title")); | |
var emlString = $(document.createElement("div")) | |
.append(eml.clone()) | |
.html(); | |
return emlString; | |
}, | |
/* | |
Replace elements named "source" with "sourced" due to limitations | |
with using $.parseHTML() rather than $.parseXML() | |
@param xmlString The XML string to make the replacement in | |
*/ | |
cleanUpXML: function (xmlString) { | |
xmlString.replace("<source>", "<sourced>"); | |
xmlString.replace("</source>", "</sourced>"); | |
return xmlString; | |
}, | |
createTextCopy: function () { | |
var emlDraftText = | |
"EML draft for " + | |
this.get("id") + | |
"(" + | |
this.get("title") + | |
") by " + | |
MetacatUI.appUserModel.get("firstName") + | |
" " + | |
MetacatUI.appUserModel.get("lastName"); | |
if (this.get("uploadStatus") == "e" && this.get("errorMessage")) { | |
emlDraftText += | |
". This EML had the following save error: `" + | |
this.get("errorMessage") + | |
"` "; | |
} else { | |
emlDraftText += ": "; | |
} | |
emlDraftText += this.serialize(); | |
var plainTextEML = new DataONEObject({ | |
formatId: "text/plain", | |
fileName: | |
"eml_draft_" + | |
(MetacatUI.appUserModel.get("lastName") || "") + | |
".txt", | |
uploadFile: new Blob([emlDraftText], { type: "plain/text" }), | |
synced: true, | |
}); | |
return plainTextEML; | |
}, | |
/* | |
* Cleans up the given text so that it is XML-valid by escaping reserved characters, trimming white space, etc. | |
* | |
* @param {string} textString - The string to clean up | |
* @return {string} - The cleaned up string | |
*/ | |
cleanXMLText: function (textString) { | |
if (typeof textString != "string") return; | |
textString = textString.trim(); | |
//Check for XML/HTML elements | |
_.each(textString.match(/<\s*[^>]*>/g), function (xmlNode) { | |
//Encode <, >, and </ substrings | |
var tagName = xmlNode.replace(/>/g, ">"); | |
tagName = tagName.replace(/</g, "<"); | |
//Replace the xmlNode in the full text string | |
textString = textString.replace(xmlNode, tagName); | |
}); | |
//Remove Unicode characters that are not valid XML characters | |
//Create a regular expression that matches any character that is not a valid XML character | |
// (see https://www.w3.org/TR/xml/#charsets) | |
var invalidCharsRegEx = | |
/[^\u0009\u000a\u000d\u0020-\uD7FF\uE000-\uFFFD]/g; | |
textString = textString.replace(invalidCharsRegEx, ""); | |
return textString; | |
}, | |
/* | |
Dereference "reference" elements and replace them with a cloned copy | |
of the referenced content | |
@param xmlString The XML string with reference elements to transform | |
*/ | |
dereference: function (xmlString) { | |
var referencesList; // the array of references elements in the document | |
var referencedID; // The id of the referenced element | |
var referencesParentEl; // The parent of the given references element | |
var referencedEl; // The referenced DOM to be copied | |
var xmlDOM = $.parseXML(xmlString); | |
referencesList = xmlDOM.getElementsByTagName("references"); | |
if (referencesList.length) { | |
// Process each references elements | |
_.each( | |
referencesList, | |
function (referencesEl, index, referencesList) { | |
// Can't rely on the passed referencesEl since the list length changes | |
// because of the remove() below. Reuse referencesList[0] for every item: | |
// referencedID = $(referencesEl).text(); // doesn't work | |
referencesEl = referencesList[0]; | |
referencedID = $(referencesEl).text(); | |
referencesParentEl = $(referencesEl).parent()[0]; | |
if (typeof referencedID !== "undefined" && referencedID != "") { | |
referencedEl = xmlDOM.getElementById(referencedID); | |
if (typeof referencedEl != "undefined") { | |
// Clone the referenced element and replace the references element | |
var referencedClone = $(referencedEl).clone()[0]; | |
$(referencesParentEl) | |
.children(referencesEl.localName) | |
.replaceWith($(referencedClone).children()); | |
//$(referencesParentEl).append($(referencedClone).children()); | |
$(referencesParentEl).attr("id", DataONEObject.generateId()); | |
} | |
} | |
}, | |
xmlDOM, | |
); | |
} | |
return new XMLSerializer().serializeToString(xmlDOM); | |
}, | |
/* | |
* Uses the EML `title` to set the `fileName` attribute on this model. | |
*/ | |
setFileName: function () { | |
var title = ""; | |
// Get the title from the metadata | |
if (Array.isArray(this.get("title"))) { | |
title = this.get("title")[0]; | |
} else if (typeof this.get("title") == "string") { | |
title = this.get("title"); | |
} | |
//Max title length | |
var maxLength = 50; | |
//trim the string to the maximum length | |
var trimmedTitle = title.trim().substr(0, maxLength); | |
//re-trim if we are in the middle of a word | |
if (trimmedTitle.indexOf(" ") > -1) { | |
trimmedTitle = trimmedTitle.substr( | |
0, | |
Math.min(trimmedTitle.length, trimmedTitle.lastIndexOf(" ")), | |
); | |
} | |
//Replace all non alphanumeric characters with underscores | |
// and make sure there isn't more than one underscore in a row | |
trimmedTitle = trimmedTitle | |
.replace(/[^a-zA-Z0-9]/g, "_") | |
.replace(/_{2,}/g, "_"); | |
//Set the fileName on the model | |
this.set("fileName", trimmedTitle + ".xml"); | |
}, | |
trickleUpChange: function () { | |
if ( | |
!MetacatUI.rootDataPackage || | |
!MetacatUI.rootDataPackage.packageModel | |
) | |
return; | |
//Mark the package as changed | |
MetacatUI.rootDataPackage.packageModel.set("changed", true); | |
}, | |
/** | |
* Sets the xsi:schemaLocation attribute on the passed-in Element | |
* depending on the application configuration. | |
* | |
* @param {Element} eml: The root eml:eml element to modify | |
* @return {Element} The element, possibly modified | |
*/ | |
setSchemaLocation: function (eml) { | |
if (!MetacatUI || !MetacatUI.appModel) { | |
return eml; | |
} | |
var current = $(eml).attr("xsi:schemaLocation"), | |
format = MetacatUI.appModel.get("editorSerializationFormat"), | |
location = MetacatUI.appModel.get("editorSchemaLocation"); | |
// Return now if we can't do anything anyway | |
if (!format || !location) { | |
return eml; | |
} | |
// Simply add if the attribute isn't present to begin with | |
if (!current || typeof current !== "string") { | |
$(eml).attr("xsi:schemaLocation", format + " " + location); | |
return eml; | |
} | |
// Don't append if it's already present | |
if (current.indexOf(format) >= 0) { | |
return eml; | |
} | |
$(eml).attr("xsi:schemaLocation", current + " " + location); | |
return eml; | |
}, | |
createID: function () { | |
this.set("xmlID", uuid.v4()); | |
}, | |
/** | |
* Creates and adds an {@link EMLAnnotation} to this EML211 model with the given annotation data in JSON form. | |
* @param {object} annotationData The attribute data to set on the new {@link EMLAnnotation}. See {@link EMLAnnotation#defaults} for | |
* details on what attributes can be passed to the EMLAnnotation. In addition, there is an `elementName` property. | |
* @property {string} [annotationData.elementName] The name of the EML Element that this | |
annotation should be applied to. e.g. dataset, entity, attribute. Defaults to `dataset`. NOTE: Right now only dataset annotations are supported until | |
more annotation editing is added to the EML Editor. | |
* @property {Boolean} [annotationData.allowDuplicates] If false, this annotation will replace all annotations already set with the same propertyURI. | |
* By default, more than one annotation with a given propertyURI can be added (defaults to true) | |
*/ | |
addAnnotation: function (annotationData) { | |
try { | |
if (!annotationData || typeof annotationData != "object") { | |
return; | |
} | |
//If no element name is provided, default to the dataset element. | |
let elementName = ""; | |
if (!annotationData.elementName) { | |
elementName = "dataset"; | |
} else { | |
elementName = annotationData.elementName; | |
} | |
//Remove the elementName property so it isn't set on the EMLAnnotation model later. | |
delete annotationData.elementName; | |
//Check if duplicates are allowed | |
let allowDuplicates = annotationData.allowDuplicates; | |
delete annotationData.allowDuplicates; | |
//Create a new EMLAnnotation model | |
let annotation = new EMLAnnotation(annotationData); | |
//Update annotations set on the dataset element | |
if (elementName == "dataset") { | |
let annotations = this.get("annotations"); | |
//If the current annotations set on the EML model are not in Array form, change it to an array | |
if (!annotations) { | |
annotations = new EMLAnnotations(); | |
} | |
if (allowDuplicates === false) { | |
//Add the EMLAnnotation to the collection, making sure to remove duplicates first | |
annotations.replaceDuplicateWith(annotation); | |
} else { | |
annotations.add(annotation); | |
} | |
//Set the annotations and force the change to be recognized by the model | |
this.set("annotations", annotations, { silent: true }); | |
this.handleChange(this, { force: true }); | |
} else { | |
/** @todo Add annotation support for other EML Elements */ | |
} | |
} catch (e) { | |
console.error("Could not add Annotation to the EML: ", e); | |
} | |
}, | |
/** | |
* Finds annotations that are of the `data sensitivity` property from the NCEAS SENSO ontology. | |
* Returns undefined if none are found. This function returns EMLAnnotation models because the data | |
* sensitivity is stored in the EML Model as EMLAnnotations and added to EML as semantic annotations. | |
* @returns {EMLAnnotation[]|undefined} | |
*/ | |
getDataSensitivity: function () { | |
try { | |
let annotations = this.get("annotations"); | |
if (annotations) { | |
let found = annotations.where({ | |
propertyURI: this.get("dataSensitivityPropertyURI"), | |
}); | |
if (!found || !found.length) { | |
return; | |
} else { | |
return found; | |
} | |
} else { | |
return; | |
} | |
} catch (e) { | |
console.error("Failed to get Data Sensitivity from EML model: ", e); | |
return; | |
} | |
}, | |
}, | |
); | |
return EML211; | |
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚫 [eslint] <no-var> reported by reviewdog 🐶
Unexpected var, use let or const instead.
metacatui/src/js/models/metadata/eml211/EML211.js
Lines 53 to 2663 in 43c3062
var EML211 = ScienceMetadata.extend( | |
/** @lends EML211.prototype */ { | |
type: "EML", | |
defaults: function () { | |
return _.extend(ScienceMetadata.prototype.defaults(), { | |
id: "urn:uuid:" + uuid.v4(), | |
formatId: "https://eml.ecoinformatics.org/eml-2.2.0", | |
objectXML: null, | |
isEditable: false, | |
alternateIdentifier: [], | |
shortName: null, | |
title: [], | |
creator: [], // array of EMLParty objects | |
metadataProvider: [], // array of EMLParty objects | |
associatedParty: [], // array of EMLParty objects | |
contact: [], // array of EMLParty objects | |
publisher: [], // array of EMLParty objects | |
pubDate: null, | |
language: null, | |
series: null, | |
abstract: [], //array of EMLText objects | |
keywordSets: [], //array of EMLKeywordSet objects | |
additionalInfo: [], | |
intellectualRights: | |
"This work is dedicated to the public domain under the Creative Commons Universal 1.0 Public Domain Dedication. To view a copy of this dedication, visit https://creativecommons.org/publicdomain/zero/1.0/.", | |
distribution: [], // array of EMLDistribution objects | |
geoCoverage: [], //an array for EMLGeoCoverages | |
temporalCoverage: [], //an array of EMLTempCoverage models | |
taxonCoverage: [], //an array of EMLTaxonCoverages | |
purpose: [], | |
entities: [], //An array of EMLEntities | |
pubplace: null, | |
methods: new EMLMethods(), // An EMLMethods objects | |
project: null, // An EMLProject object, | |
annotations: null, // Dataset-level annotations | |
canonicalDataset: null, | |
dataSensitivityPropertyURI: | |
"http://purl.dataone.org/odo/SENSO_00000005", | |
nodeOrder: [ | |
"alternateidentifier", | |
"shortname", | |
"title", | |
"creator", | |
"metadataprovider", | |
"associatedparty", | |
"pubdate", | |
"language", | |
"series", | |
"abstract", | |
"keywordset", | |
"additionalinfo", | |
"intellectualrights", | |
"licensed", | |
"distribution", | |
"coverage", | |
"annotation", | |
"purpose", | |
"introduction", | |
"gettingstarted", | |
"acknowledgements", | |
"maintenance", | |
"contact", | |
"publisher", | |
"pubplace", | |
"methods", | |
"project", | |
"datatable", | |
"spatialraster", | |
"spatialvector", | |
"storedprocedure", | |
"view", | |
"otherentity", | |
"referencepublications", | |
"usagecitations", | |
"literaturecited", | |
], | |
}); | |
}, | |
units: new Units(), | |
initialize: function (attributes) { | |
// Call initialize for the super class | |
ScienceMetadata.prototype.initialize.call(this, attributes); | |
// EML211-specific init goes here | |
// this.set("objectXML", this.createXML()); | |
this.parse(this.createXML()); | |
this.on("sync", function () { | |
this.set("synced", true); | |
}); | |
this.stopListening(this, "change:canonicalDataset"); | |
this.listenTo( | |
this, | |
"change:canonicalDataset", | |
this.updateCanonicalDataset, | |
); | |
//Create a Unit collection | |
if (!this.units.length) this.createUnits(); | |
}, | |
url: function (options) { | |
var identifier; | |
if (options && options.update) { | |
identifier = this.get("oldPid") || this.get("seriesid"); | |
} else { | |
identifier = this.get("id") || this.get("seriesid"); | |
} | |
return ( | |
MetacatUI.appModel.get("objectServiceUrl") + | |
encodeURIComponent(identifier) | |
); | |
}, | |
updateCanonicalDataset() { | |
let uri = this.get("canonicalDataset"); | |
uri = uri?.length ? uri[0] : null; | |
let annotations = this.get("annotations"); | |
if (!annotations) { | |
annotations = new EMLAnnotations(); | |
this.set("annotations", annotations); | |
} | |
annotations.updateCanonicalDataset(uri); | |
}, | |
/* | |
* Maps the lower-case EML node names (valid in HTML DOM) to the camel-cased EML node names (valid in EML). | |
* Used during parse() and serialize() | |
*/ | |
nodeNameMap: function () { | |
return _.extend( | |
this.constructor.__super__.nodeNameMap(), | |
EMLDistribution.prototype.nodeNameMap(), | |
EMLGeoCoverage.prototype.nodeNameMap(), | |
EMLKeywordSet.prototype.nodeNameMap(), | |
EMLParty.prototype.nodeNameMap(), | |
EMLProject.prototype.nodeNameMap(), | |
EMLTaxonCoverage.prototype.nodeNameMap(), | |
EMLTemporalCoverage.prototype.nodeNameMap(), | |
EMLMethods.prototype.nodeNameMap(), | |
{ | |
accuracyreport: "accuracyReport", | |
actionlist: "actionList", | |
additionalclassifications: "additionalClassifications", | |
additionalinfo: "additionalInfo", | |
additionallinks: "additionalLinks", | |
additionalmetadata: "additionalMetadata", | |
allowfirst: "allowFirst", | |
alternateidentifier: "alternateIdentifier", | |
altitudedatumname: "altitudeDatumName", | |
altitudedistanceunits: "altitudeDistanceUnits", | |
altituderesolution: "altitudeResolution", | |
altitudeencodingmethod: "altitudeEncodingMethod", | |
altitudesysdef: "altitudeSysDef", | |
asneeded: "asNeeded", | |
associatedparty: "associatedParty", | |
attributeaccuracyexplanation: "attributeAccuracyExplanation", | |
attributeaccuracyreport: "attributeAccuracyReport", | |
attributeaccuracyvalue: "attributeAccuracyValue", | |
attributedefinition: "attributeDefinition", | |
attributelabel: "attributeLabel", | |
attributelist: "attributeList", | |
attributename: "attributeName", | |
attributeorientation: "attributeOrientation", | |
attributereference: "attributeReference", | |
awardnumber: "awardNumber", | |
awardurl: "awardUrl", | |
audiovisual: "audioVisual", | |
authsystem: "authSystem", | |
banddescription: "bandDescription", | |
bilinearfit: "bilinearFit", | |
binaryrasterformat: "binaryRasterFormat", | |
blockedmembernode: "blockedMemberNode", | |
booktitle: "bookTitle", | |
cameracalibrationinformationavailability: | |
"cameraCalibrationInformationAvailability", | |
casesensitive: "caseSensitive", | |
cellgeometry: "cellGeometry", | |
cellsizexdirection: "cellSizeXDirection", | |
cellsizeydirection: "cellSizeYDirection", | |
changehistory: "changeHistory", | |
changedate: "changeDate", | |
changescope: "changeScope", | |
chapternumber: "chapterNumber", | |
characterencoding: "characterEncoding", | |
checkcondition: "checkCondition", | |
checkconstraint: "checkConstraint", | |
childoccurences: "childOccurences", | |
citableclassificationsystem: "citableClassificationSystem", | |
cloudcoverpercentage: "cloudCoverPercentage", | |
codedefinition: "codeDefinition", | |
codeexplanation: "codeExplanation", | |
codesetname: "codesetName", | |
codeseturl: "codesetURL", | |
collapsedelimiters: "collapseDelimiters", | |
communicationtype: "communicationType", | |
compressiongenerationquality: "compressionGenerationQuality", | |
compressionmethod: "compressionMethod", | |
conferencedate: "conferenceDate", | |
conferencelocation: "conferenceLocation", | |
conferencename: "conferenceName", | |
conferenceproceedings: "conferenceProceedings", | |
constraintdescription: "constraintDescription", | |
constraintname: "constraintName", | |
constanttosi: "constantToSI", | |
controlpoint: "controlPoint", | |
cornerpoint: "cornerPoint", | |
customunit: "customUnit", | |
dataformat: "dataFormat", | |
datasetgpolygon: "datasetGPolygon", | |
datasetgpolygonoutergring: "datasetGPolygonOuterGRing", | |
datasetgpolygonexclusiongring: "datasetGPolygonExclusionGRing", | |
datatable: "dataTable", | |
datatype: "dataType", | |
datetime: "dateTime", | |
datetimedomain: "dateTimeDomain", | |
datetimeprecision: "dateTimePrecision", | |
defaultvalue: "defaultValue", | |
definitionattributereference: "definitionAttributeReference", | |
denomflatratio: "denomFlatRatio", | |
depthsysdef: "depthSysDef", | |
depthdatumname: "depthDatumName", | |
depthdistanceunits: "depthDistanceUnits", | |
depthencodingmethod: "depthEncodingMethod", | |
depthresolution: "depthResolution", | |
descriptorvalue: "descriptorValue", | |
dictref: "dictRef", | |
diskusage: "diskUsage", | |
domainDescription: "domainDescription", | |
editedbook: "editedBook", | |
encodingmethod: "encodingMethod", | |
endcondition: "endCondition", | |
entitycodelist: "entityCodeList", | |
entitydescription: "entityDescription", | |
entityname: "entityName", | |
entityreference: "entityReference", | |
entitytype: "entityType", | |
enumerateddomain: "enumeratedDomain", | |
errorbasis: "errorBasis", | |
errorvalues: "errorValues", | |
externalcodeset: "externalCodeSet", | |
externallydefinedformat: "externallyDefinedFormat", | |
fielddelimiter: "fieldDelimiter", | |
fieldstartcolumn: "fieldStartColumn", | |
fieldwidth: "fieldWidth", | |
filmdistortioninformationavailability: | |
"filmDistortionInformationAvailability", | |
foreignkey: "foreignKey", | |
formatname: "formatName", | |
formatstring: "formatString", | |
formatversion: "formatVersion", | |
fractiondigits: "fractionDigits", | |
fundername: "funderName", | |
funderidentifier: "funderIdentifier", | |
gettingstarted: "gettingStarted", | |
gring: "gRing", | |
gringpoint: "gRingPoint", | |
gringlatitude: "gRingLatitude", | |
gringlongitude: "gRingLongitude", | |
geogcoordsys: "geogCoordSys", | |
geometricobjectcount: "geometricObjectCount", | |
georeferenceinfo: "georeferenceInfo", | |
highwavelength: "highWavelength", | |
horizontalaccuracy: "horizontalAccuracy", | |
horizcoordsysdef: "horizCoordSysDef", | |
horizcoordsysname: "horizCoordSysName", | |
identifiername: "identifierName", | |
illuminationazimuthangle: "illuminationAzimuthAngle", | |
illuminationelevationangle: "illuminationElevationAngle", | |
imagingcondition: "imagingCondition", | |
imagequalitycode: "imageQualityCode", | |
imageorientationangle: "imageOrientationAngle", | |
intellectualrights: "intellectualRights", | |
imagedescription: "imageDescription", | |
isbn: "ISBN", | |
issn: "ISSN", | |
joincondition: "joinCondition", | |
keywordtype: "keywordType", | |
languagevalue: "LanguageValue", | |
languagecodestandard: "LanguageCodeStandard", | |
lensdistortioninformationavailability: | |
"lensDistortionInformationAvailability", | |
licensename: "licenseName", | |
licenseurl: "licenseURL", | |
linenumber: "lineNumber", | |
literalcharacter: "literalCharacter", | |
literallayout: "literalLayout", | |
literaturecited: "literatureCited", | |
lowwavelength: "lowWaveLength", | |
machineprocessor: "machineProcessor", | |
maintenanceupdatefrequency: "maintenanceUpdateFrequency", | |
matrixtype: "matrixType", | |
maxexclusive: "maxExclusive", | |
maxinclusive: "maxInclusive", | |
maxlength: "maxLength", | |
maxrecordlength: "maxRecordLength", | |
maxvalues: "maxValues", | |
measurementscale: "measurementScale", | |
metadatalist: "metadataList", | |
methodstep: "methodStep", | |
minexclusive: "minExclusive", | |
mininclusive: "minInclusive", | |
minlength: "minLength", | |
minvalues: "minValues", | |
missingvaluecode: "missingValueCode", | |
moduledocs: "moduleDocs", | |
modulename: "moduleName", | |
moduledescription: "moduleDescription", | |
multiband: "multiBand", | |
multipliertosi: "multiplierToSI", | |
nonnumericdomain: "nonNumericDomain", | |
notnullconstraint: "notNullConstraint", | |
notplanned: "notPlanned", | |
numberofbands: "numberOfBands", | |
numbertype: "numberType", | |
numericdomain: "numericDomain", | |
numfooterlines: "numFooterLines", | |
numheaderlines: "numHeaderLines", | |
numberofrecords: "numberOfRecords", | |
numberofvolumes: "numberOfVolumes", | |
numphysicallinesperrecord: "numPhysicalLinesPerRecord", | |
objectname: "objectName", | |
oldvalue: "oldValue", | |
operatingsystem: "operatingSystem", | |
orderattributereference: "orderAttributeReference", | |
originalpublication: "originalPublication", | |
otherentity: "otherEntity", | |
othermaintenanceperiod: "otherMaintenancePeriod", | |
parameterdefinition: "parameterDefinition", | |
packageid: "packageId", | |
pagerange: "pageRange", | |
parentoccurences: "parentOccurences", | |
parentsi: "parentSI", | |
peakresponse: "peakResponse", | |
personalcommunication: "personalCommunication", | |
physicallinedelimiter: "physicalLineDelimiter", | |
pointinpixel: "pointInPixel", | |
preferredmembernode: "preferredMemberNode", | |
preprocessingtypecode: "preProcessingTypeCode", | |
primarykey: "primaryKey", | |
primemeridian: "primeMeridian", | |
proceduralstep: "proceduralStep", | |
programminglanguage: "programmingLanguage", | |
projcoordsys: "projCoordSys", | |
projectionlist: "projectionList", | |
propertyuri: "propertyURI", | |
pubdate: "pubDate", | |
pubplace: "pubPlace", | |
publicationplace: "publicationPlace", | |
quantitativeaccuracyreport: "quantitativeAccuracyReport", | |
quantitativeaccuracyvalue: "quantitativeAccuracyValue", | |
quantitativeaccuracymethod: "quantitativeAccuracyMethod", | |
quantitativeattributeaccuracyassessment: | |
"quantitativeAttributeAccuracyAssessment", | |
querystatement: "queryStatement", | |
quotecharacter: "quoteCharacter", | |
radiometricdataavailability: "radiometricDataAvailability", | |
rasterorigin: "rasterOrigin", | |
recommendedunits: "recommendedUnits", | |
recommendedusage: "recommendedUsage", | |
referencedkey: "referencedKey", | |
referencetype: "referenceType", | |
relatedentry: "relatedEntry", | |
relationshiptype: "relationshipType", | |
reportnumber: "reportNumber", | |
reprintedition: "reprintEdition", | |
researchproject: "researchProject", | |
researchtopic: "researchTopic", | |
recorddelimiter: "recordDelimiter", | |
referencepublication: "referencePublication", | |
revieweditem: "reviewedItem", | |
rowcolumnorientation: "rowColumnOrientation", | |
runtimememoryusage: "runtimeMemoryUsage", | |
samplingdescription: "samplingDescription", | |
scalefactor: "scaleFactor", | |
sequenceidentifier: "sequenceIdentifier", | |
semiaxismajor: "semiAxisMajor", | |
shortname: "shortName", | |
simpledelimited: "simpleDelimited", | |
spatialraster: "spatialRaster", | |
spatialreference: "spatialReference", | |
spatialvector: "spatialVector", | |
standalone: "standAlone", | |
standardunit: "standardUnit", | |
startcondition: "startCondition", | |
studyareadescription: "studyAreaDescription", | |
storagetype: "storageType", | |
studyextent: "studyExtent", | |
studytype: "studyType", | |
textdelimited: "textDelimited", | |
textdomain: "textDomain", | |
textfixed: "textFixed", | |
textformat: "textFormat", | |
topologylevel: "topologyLevel", | |
tonegradation: "toneGradation", | |
totaldigits: "totalDigits", | |
totalfigures: "totalFigures", | |
totalpages: "totalPages", | |
totaltables: "totalTables", | |
triangulationindicator: "triangulationIndicator", | |
typesystem: "typeSystem", | |
uniquekey: "uniqueKey", | |
unittype: "unitType", | |
unitlist: "unitList", | |
usagecitation: "usageCitation", | |
valueuri: "valueURI", | |
valueattributereference: "valueAttributeReference", | |
verticalaccuracy: "verticalAccuracy", | |
vertcoordsys: "vertCoordSys", | |
virtualmachine: "virtualMachine", | |
wavelengthunits: "waveLengthUnits", | |
whitespace: "whiteSpace", | |
xintercept: "xIntercept", | |
xcoordinate: "xCoordinate", | |
"xsi:schemalocation": "xsi:schemaLocation", | |
xslope: "xSlope", | |
ycoordinate: "yCoordinate", | |
yintercept: "yIntercept", | |
yslope: "ySlope", | |
}, | |
); | |
}, | |
/** | |
* Fetch the EML from the MN object service | |
* @param {object} [options] - A set of options for this fetch() | |
* @property {boolean} [options.systemMetadataOnly=false] - If true, only the system metadata will be fetched. | |
* If false, the system metadata AND EML document will be fetched. | |
*/ | |
fetch: function (options) { | |
if (!options) var options = {}; | |
//Add the authorization header and other AJAX settings | |
_.extend(options, MetacatUI.appUserModel.createAjaxSettings(), { | |
dataType: "text", | |
}); | |
// Merge the system metadata into the object first | |
_.extend(options, { merge: true }); | |
this.fetchSystemMetadata(options); | |
//If we are retrieving system metadata only, then exit now | |
if (options.systemMetadataOnly) return; | |
//Call Backbone.Model.fetch to retrieve the info | |
return Backbone.Model.prototype.fetch.call(this, options); | |
}, | |
/* | |
Deserialize an EML 2.1.1 XML document | |
*/ | |
parse: function (response) { | |
// Save a reference to this model for use in setting the | |
// parentModel inside anonymous functions | |
var model = this; | |
//If the response is XML | |
if (typeof response == "string" && response.indexOf("<") == 0) { | |
//Look for a system metadata tag and call DataONEObject parse instead | |
if (response.indexOf("systemMetadata>") > -1) | |
return DataONEObject.prototype.parse.call(this, response); | |
response = this.cleanUpXML(response); | |
response = this.dereference(response); | |
this.set("objectXML", response); | |
var emlElement = $($.parseHTML(response)).filter("eml\\:eml"); | |
} | |
var datasetEl; | |
if (emlElement[0]) datasetEl = $(emlElement[0]).find("dataset"); | |
if (!datasetEl || !datasetEl.length) return {}; | |
var emlParties = [ | |
"metadataprovider", | |
"associatedparty", | |
"creator", | |
"contact", | |
"publisher", | |
], | |
emlDistribution = ["distribution"], | |
emlEntities = [ | |
"datatable", | |
"otherentity", | |
"spatialvector", | |
"spatialraster", | |
"storedprocedure", | |
"view", | |
], | |
emlText = ["abstract", "additionalinfo"], | |
emlMethods = ["methods"]; | |
var nodes = datasetEl.children(), | |
modelJSON = {}; | |
for (var i = 0; i < nodes.length; i++) { | |
var thisNode = nodes[i]; | |
var convertedName = | |
this.nodeNameMap()[thisNode.localName] || thisNode.localName; | |
//EML Party modules are stored in EMLParty models | |
if (_.contains(emlParties, thisNode.localName)) { | |
if (thisNode.localName == "metadataprovider") | |
var attributeName = "metadataProvider"; | |
else if (thisNode.localName == "associatedparty") | |
var attributeName = "associatedParty"; | |
else var attributeName = thisNode.localName; | |
if (typeof modelJSON[attributeName] == "undefined") | |
modelJSON[attributeName] = []; | |
modelJSON[attributeName].push( | |
new EMLParty({ | |
objectDOM: thisNode, | |
parentModel: model, | |
type: attributeName, | |
}), | |
); | |
} | |
//EML Distribution modules are stored in EMLDistribution models | |
else if (_.contains(emlDistribution, thisNode.localName)) { | |
if (typeof modelJSON[thisNode.localName] == "undefined") | |
modelJSON[thisNode.localName] = []; | |
modelJSON[thisNode.localName].push( | |
new EMLDistribution( | |
{ | |
objectDOM: thisNode, | |
parentModel: model, | |
}, | |
{ parse: true }, | |
), | |
); | |
} | |
//The EML Project is stored in the EMLProject model | |
else if (thisNode.localName == "project") { | |
modelJSON.project = new EMLProject({ | |
objectDOM: thisNode, | |
parentModel: model, | |
}); | |
} | |
//EML Temporal, Taxonomic, and Geographic Coverage modules are stored in their own models | |
else if (thisNode.localName == "coverage") { | |
var temporal = $(thisNode).children("temporalcoverage"), | |
geo = $(thisNode).children("geographiccoverage"), | |
taxon = $(thisNode).children("taxonomiccoverage"); | |
if (temporal.length) { | |
modelJSON.temporalCoverage = []; | |
_.each(temporal, function (t) { | |
modelJSON.temporalCoverage.push( | |
new EMLTemporalCoverage({ | |
objectDOM: t, | |
parentModel: model, | |
}), | |
); | |
}); | |
} | |
if (geo.length) { | |
modelJSON.geoCoverage = []; | |
_.each(geo, function (g) { | |
modelJSON.geoCoverage.push( | |
new EMLGeoCoverage({ | |
objectDOM: g, | |
parentModel: model, | |
}), | |
); | |
}); | |
} | |
if (taxon.length) { | |
modelJSON.taxonCoverage = []; | |
_.each(taxon, function (t) { | |
modelJSON.taxonCoverage.push( | |
new EMLTaxonCoverage({ | |
objectDOM: t, | |
parentModel: model, | |
}), | |
); | |
}); | |
} | |
} | |
//Parse EMLText modules | |
else if (_.contains(emlText, thisNode.localName)) { | |
if (typeof modelJSON[convertedName] == "undefined") | |
modelJSON[convertedName] = []; | |
modelJSON[convertedName].push( | |
new EMLText({ | |
objectDOM: thisNode, | |
parentModel: model, | |
}), | |
); | |
} else if (_.contains(emlMethods, thisNode.localName)) { | |
if (typeof modelJSON[thisNode.localName] === "undefined") | |
modelJSON[thisNode.localName] = []; | |
modelJSON[thisNode.localName] = new EMLMethods({ | |
objectDOM: thisNode, | |
parentModel: model, | |
}); | |
} | |
//Parse keywords | |
else if (thisNode.localName == "keywordset") { | |
//Start an array of keyword sets | |
if (typeof modelJSON["keywordSets"] == "undefined") | |
modelJSON["keywordSets"] = []; | |
modelJSON["keywordSets"].push( | |
new EMLKeywordSet({ | |
objectDOM: thisNode, | |
parentModel: model, | |
}), | |
); | |
} | |
//Parse intellectual rights | |
else if (thisNode.localName == "intellectualrights") { | |
var value = ""; | |
if ($(thisNode).children("para").length == 1) | |
value = $(thisNode).children("para").first().text().trim(); | |
else $(thisNode).text().trim(); | |
//If the value is one of our pre-defined options, then add it to the model | |
//if(_.contains(this.get("intellRightsOptions"), value)) | |
modelJSON["intellectualRights"] = value; | |
} | |
//Parse Entities | |
else if (_.contains(emlEntities, thisNode.localName)) { | |
//Start an array of Entities | |
if (typeof modelJSON["entities"] == "undefined") | |
modelJSON["entities"] = []; | |
//Create the model | |
var entityModel; | |
if (thisNode.localName == "otherentity") { | |
entityModel = new EMLOtherEntity( | |
{ | |
objectDOM: thisNode, | |
parentModel: model, | |
}, | |
{ | |
parse: true, | |
}, | |
); | |
} else if (thisNode.localName == "datatable") { | |
entityModel = new EMLDataTable( | |
{ | |
objectDOM: thisNode, | |
parentModel: model, | |
}, | |
{ | |
parse: true, | |
}, | |
); | |
} else { | |
entityModel = new EMLEntity( | |
{ | |
objectDOM: thisNode, | |
parentModel: model, | |
entityType: "application/octet-stream", | |
type: thisNode.localName, | |
}, | |
{ | |
parse: true, | |
}, | |
); | |
} | |
modelJSON["entities"].push(entityModel); | |
} | |
//Parse dataset-level annotations | |
else if (thisNode.localName === "annotation") { | |
if (!modelJSON["annotations"]) { | |
modelJSON["annotations"] = new EMLAnnotations(); | |
} | |
var annotationModel = new EMLAnnotation( | |
{ | |
objectDOM: thisNode, | |
}, | |
{ parse: true }, | |
); | |
modelJSON["annotations"].add(annotationModel); | |
} else { | |
//Is this a multi-valued field in EML? | |
if (Array.isArray(this.get(convertedName))) { | |
//If we already have a value for this field, then add this value to the array | |
if (Array.isArray(modelJSON[convertedName])) | |
modelJSON[convertedName].push(this.toJson(thisNode)); | |
//If it's the first value for this field, then create a new array | |
else modelJSON[convertedName] = [this.toJson(thisNode)]; | |
} else modelJSON[convertedName] = this.toJson(thisNode); | |
} | |
} | |
// Once all the nodes have been parsed, check if any of the annotations | |
// make up a canonical dataset reference | |
const annotations = modelJSON["annotations"]; | |
if (annotations) { | |
const canonicalDataset = annotations.getCanonicalURI(); | |
if (canonicalDataset) { | |
modelJSON["canonicalDataset"] = canonicalDataset; | |
} | |
} | |
return modelJSON; | |
}, | |
/* | |
* Retireves the model attributes and serializes into EML XML, to produce the new or modified EML document. | |
* Returns the EML XML as a string. | |
*/ | |
serialize: function () { | |
//Get the EML document | |
var xmlString = this.get("objectXML"), | |
html = $.parseHTML(xmlString), | |
eml = $(html).filter("eml\\:eml"), | |
datasetNode = $(eml).find("dataset"); | |
//Update the packageId on the eml node with the EML id | |
$(eml).attr("packageId", this.get("id")); | |
// Set id attribute on dataset node if needed | |
if (this.get("xmlID")) { | |
$(datasetNode).attr("id", this.get("xmlID")); | |
} | |
// Set schema version | |
$(eml).attr( | |
"xmlns:eml", | |
MetacatUI.appModel.get("editorSerializationFormat") || | |
"https://eml.ecoinformatics.org/eml-2.2.0", | |
); | |
// Set formatID | |
this.set( | |
"formatId", | |
MetacatUI.appModel.get("editorSerializationFormat") || | |
"https://eml.ecoinformatics.org/eml-2.2.0", | |
); | |
// Ensure xsi:schemaLocation has a value for the current format | |
eml = this.setSchemaLocation(eml); | |
var nodeNameMap = this.nodeNameMap(); | |
//Serialize the basic text fields | |
var basicText = ["alternateIdentifier", "title"]; | |
_.each( | |
basicText, | |
function (fieldName) { | |
var basicTextValues = this.get(fieldName); | |
if (!Array.isArray(basicTextValues)) | |
basicTextValues = [basicTextValues]; | |
// Remove existing nodes | |
datasetNode.children(fieldName.toLowerCase()).remove(); | |
// Create new nodes | |
var nodes = _.map(basicTextValues, function (value) { | |
if (value) { | |
var node = document.createElement(fieldName.toLowerCase()); | |
$(node).text(value); | |
return node; | |
} else { | |
return ""; | |
} | |
}); | |
var insertAfter = this.getEMLPosition(eml, fieldName.toLowerCase()); | |
if (insertAfter) { | |
insertAfter.after(nodes); | |
} else { | |
datasetNode.prepend(nodes); | |
} | |
}, | |
this, | |
); | |
// Serialize pubDate | |
// This one is special because it has a default behavior, unlike | |
// the others: When no pubDate is set, it should be set to | |
// the current year | |
var pubDate = this.get("pubDate"); | |
datasetNode.find("pubdate").remove(); | |
if (pubDate != null && pubDate.length > 0) { | |
var pubDateEl = document.createElement("pubdate"); | |
$(pubDateEl).text(pubDate); | |
this.getEMLPosition(eml, "pubdate").after(pubDateEl); | |
} | |
// Serialize the parts of EML that are eml-text modules | |
var textFields = ["abstract", "additionalInfo"]; | |
_.each( | |
textFields, | |
function (field) { | |
var fieldName = this.nodeNameMap()[field] || field; | |
// Get the EMLText model | |
var emlTextModels = Array.isArray(this.get(field)) | |
? this.get(field) | |
: [this.get(field)]; | |
if (!emlTextModels.length) return; | |
// Get the node from the EML doc | |
var nodes = datasetNode.find(fieldName); | |
// Update the DOMs for each model | |
_.each( | |
emlTextModels, | |
function (thisTextModel, i) { | |
//Don't serialize falsey values | |
if (!thisTextModel) return; | |
var node; | |
//Get the existing node or create a new one | |
if (nodes.length < i + 1) { | |
node = document.createElement(fieldName); | |
this.getEMLPosition(eml, fieldName).after(node); | |
} else { | |
node = nodes[i]; | |
} | |
$(node).html($(thisTextModel.updateDOM()).html()); | |
}, | |
this, | |
); | |
// Remove the extra nodes | |
this.removeExtraNodes(nodes, emlTextModels); | |
}, | |
this, | |
); | |
//Create a <coverage> XML node if there isn't one | |
if (datasetNode.children("coverage").length === 0) { | |
var coverageNode = $(document.createElement("coverage")), | |
coveragePosition = this.getEMLPosition(eml, "coverage"); | |
if (coveragePosition) coveragePosition.after(coverageNode); | |
else datasetNode.append(coverageNode); | |
} else { | |
var coverageNode = datasetNode.children("coverage").first(); | |
} | |
//Serialize the geographic coverage | |
if ( | |
typeof this.get("geoCoverage") !== "undefined" && | |
this.get("geoCoverage").length > 0 | |
) { | |
// Don't serialize if geoCoverage is invalid | |
var validCoverages = _.filter( | |
this.get("geoCoverage"), | |
function (cov) { | |
return cov.isValid(); | |
}, | |
); | |
//Get the existing geo coverage nodes from the EML | |
var existingGeoCov = datasetNode.find("geographiccoverage"); | |
//Update the DOM of each model | |
_.each( | |
validCoverages, | |
function (cov, position) { | |
//Update the existing node if it exists | |
if (existingGeoCov.length - 1 >= position) { | |
$(existingGeoCov[position]).replaceWith(cov.updateDOM()); | |
} | |
//Or, append new nodes | |
else { | |
var insertAfter = existingGeoCov.length | |
? datasetNode.find("geographiccoverage").last() | |
: null; | |
if (insertAfter) insertAfter.after(cov.updateDOM()); | |
else coverageNode.append(cov.updateDOM()); | |
} | |
}, | |
this, | |
); | |
//Remove existing taxon coverage nodes that don't have an accompanying model | |
this.removeExtraNodes( | |
datasetNode.find("geographiccoverage"), | |
validCoverages, | |
); | |
} else { | |
//If there are no geographic coverages, remove the nodes | |
coverageNode.children("geographiccoverage").remove(); | |
} | |
//Serialize the taxonomic coverage | |
if ( | |
typeof this.get("taxonCoverage") !== "undefined" && | |
this.get("taxonCoverage").length > 0 | |
) { | |
// Group the taxonomic coverage models into empty and non-empty | |
var sortedTaxonModels = _.groupBy( | |
this.get("taxonCoverage"), | |
function (t) { | |
if (_.flatten(t.get("taxonomicClassification")).length > 0) { | |
return "notEmpty"; | |
} else { | |
return "empty"; | |
} | |
}, | |
); | |
//Get the existing taxon coverage nodes from the EML | |
var existingTaxonCov = coverageNode.children("taxonomiccoverage"); | |
//Iterate over each taxon coverage and update it's DOM | |
if ( | |
sortedTaxonModels["notEmpty"] && | |
sortedTaxonModels["notEmpty"].length > 0 | |
) { | |
//Update the DOM of each model | |
_.each( | |
sortedTaxonModels["notEmpty"], | |
function (taxonCoverage, position) { | |
//Update the existing taxonCoverage node if it exists | |
if (existingTaxonCov.length - 1 >= position) { | |
$(existingTaxonCov[position]).replaceWith( | |
taxonCoverage.updateDOM(), | |
); | |
} | |
//Or, append new nodes | |
else { | |
coverageNode.append(taxonCoverage.updateDOM()); | |
} | |
}, | |
); | |
//Remove existing taxon coverage nodes that don't have an accompanying model | |
this.removeExtraNodes(existingTaxonCov, this.get("taxonCoverage")); | |
} | |
//If all the taxon coverages are empty, remove the parent taxonomicCoverage node | |
else if ( | |
!sortedTaxonModels["notEmpty"] || | |
sortedTaxonModels["notEmpty"].length == 0 | |
) { | |
existingTaxonCov.remove(); | |
} | |
} | |
//Serialize the temporal coverage | |
var existingTemporalCoverages = datasetNode.find("temporalcoverage"); | |
//Update the DOM of each model | |
_.each( | |
this.get("temporalCoverage"), | |
function (temporalCoverage, position) { | |
//Update the existing temporalCoverage node if it exists | |
if (existingTemporalCoverages.length - 1 >= position) { | |
$(existingTemporalCoverages[position]).replaceWith( | |
temporalCoverage.updateDOM(), | |
); | |
} | |
//Or, append new nodes | |
else { | |
coverageNode.append(temporalCoverage.updateDOM()); | |
} | |
}, | |
); | |
//Remove existing taxon coverage nodes that don't have an accompanying model | |
this.removeExtraNodes( | |
existingTemporalCoverages, | |
this.get("temporalCoverage"), | |
); | |
//Remove the temporal coverage if it is empty | |
if (!coverageNode.children("temporalcoverage").children().length) { | |
coverageNode.children("temporalcoverage").remove(); | |
} | |
//Remove the <coverage> node if it's empty | |
if (coverageNode.children().length == 0) { | |
coverageNode.remove(); | |
} | |
// Dataset-level annotations | |
datasetNode.children("annotation").remove(); | |
if (this.get("annotations")) { | |
this.get("annotations").each(function (annotation) { | |
if (annotation.isEmpty()) { | |
return; | |
} | |
var after = this.getEMLPosition(eml, "annotation"); | |
$(after).after(annotation.updateDOM()); | |
}, this); | |
//Since there is at least one annotation, the dataset node needs to have an id attribute. | |
datasetNode.attr("id", this.getUniqueEntityId(this)); | |
} | |
//If there is no creator, create one from the user | |
if (!this.get("creator").length) { | |
var party = new EMLParty({ parentModel: this, type: "creator" }); | |
party.createFromUser(); | |
this.set("creator", [party]); | |
} | |
//Serialize the creators | |
this.serializeParties(eml, "creator"); | |
//Serialize the metadata providers | |
this.serializeParties(eml, "metadataProvider"); | |
//Serialize the associated parties | |
this.serializeParties(eml, "associatedParty"); | |
//Serialize the contacts | |
this.serializeParties(eml, "contact"); | |
//Serialize the publishers | |
this.serializeParties(eml, "publisher"); | |
// Serialize methods | |
if (this.get("methods")) { | |
//If the methods model is empty, remove it from the EML | |
if (this.get("methods").isEmpty()) | |
datasetNode.find("methods").remove(); | |
else { | |
//Serialize the methods model | |
var methodsEl = this.get("methods").updateDOM(); | |
//If the methodsEl is an empty string or other falsey value, then remove the methods node | |
if (!methodsEl || !$(methodsEl).children().length) { | |
datasetNode.find("methods").remove(); | |
} else { | |
//Add the <methods> node to the EML | |
datasetNode.find("methods").detach(); | |
var insertAfter = this.getEMLPosition(eml, "methods"); | |
if (insertAfter) insertAfter.after(methodsEl); | |
else datasetNode.append(methodsEl); | |
} | |
} | |
} | |
//If there are no methods, then remove the methods nodes | |
else { | |
if (datasetNode.find("methods").length > 0) { | |
datasetNode.find("methods").remove(); | |
} | |
} | |
//Serialize the keywords | |
this.serializeKeywords(eml, "keywordSets"); | |
//Serialize the intellectual rights | |
if (this.get("intellectualRights")) { | |
if (datasetNode.find("intellectualRights").length) | |
datasetNode | |
.find("intellectualRights") | |
.html("<para>" + this.get("intellectualRights") + "</para>"); | |
else { | |
this.getEMLPosition(eml, "intellectualrights").after( | |
$(document.createElement("intellectualRights")).html( | |
"<para>" + this.get("intellectualRights") + "</para>", | |
), | |
); | |
} | |
} | |
// Serialize the distribution | |
const distributions = this.get("distribution"); | |
if (distributions && distributions.length > 0) { | |
// Remove existing nodes | |
datasetNode.children("distribution").remove(); | |
// Get the updated DOMs | |
const distributionDOMs = distributions.map((d) => d.updateDOM()); | |
// Insert the updated DOMs in their correct positions | |
distributionDOMs.forEach((dom, i) => { | |
const insertAfter = this.getEMLPosition(eml, "distribution"); | |
if (insertAfter) { | |
insertAfter.after(dom); | |
} else { | |
datasetNode.append(dom); | |
} | |
}); | |
} | |
//Detach the project elements from the DOM | |
if (datasetNode.find("project").length) { | |
datasetNode.find("project").detach(); | |
} | |
//If there is an EMLProject, update its DOM | |
if (this.get("project")) { | |
this.getEMLPosition(eml, "project").after( | |
this.get("project").updateDOM(), | |
); | |
} | |
//Get the existing taxon coverage nodes from the EML | |
var existingEntities = datasetNode.find( | |
"otherEntity, dataTable, spatialRaster, spatialVector, storedProcedure, view", | |
); | |
//Serialize the entities | |
_.each( | |
this.get("entities"), | |
function (entity, position) { | |
//Update the existing node if it exists | |
if (existingEntities.length - 1 >= position) { | |
//Remove the entity from the EML | |
$(existingEntities[position]).detach(); | |
//Insert it into the correct position | |
this.getEMLPosition(eml, entity.get("type").toLowerCase()).after( | |
entity.updateDOM(), | |
); | |
} | |
//Or, append new nodes | |
else { | |
//Inser the entity into the correct position | |
this.getEMLPosition(eml, entity.get("type").toLowerCase()).after( | |
entity.updateDOM(), | |
); | |
} | |
}, | |
this, | |
); | |
//Remove extra entities that have been removed | |
var numExtraEntities = | |
existingEntities.length - this.get("entities").length; | |
for ( | |
var i = existingEntities.length - numExtraEntities; | |
i < existingEntities.length; | |
i++ | |
) { | |
$(existingEntities)[i].remove(); | |
} | |
//Do a final check to make sure there are no duplicate ids in the EML | |
var elementsWithIDs = $(eml).find("[id]"), | |
//Get an array of all the ids in this EML doc | |
allIDs = _.map(elementsWithIDs, function (el) { | |
return $(el).attr("id"); | |
}); | |
//If there is at least one id in the EML... | |
if (allIDs && allIDs.length) { | |
//Boil the array down to just the unique values | |
var uniqueIDs = _.uniq(allIDs); | |
//If the unique array is shorter than the array of all ids, | |
// then there is a duplicate somewhere | |
if (uniqueIDs.length < allIDs.length) { | |
//For each element in the EML that has an id, | |
_.each(elementsWithIDs, function (el) { | |
//Get the id for this element | |
var id = $(el).attr("id"); | |
//If there is more than one element in the EML with this id, | |
if ($(eml).find("[id='" + id + "']").length > 1) { | |
//And if it is not a unit node, which we don't want to change, | |
if (!$(el).is("unit")) | |
//Then change the id attribute to a random uuid | |
$(el).attr("id", "urn-uuid-" + uuid.v4()); | |
} | |
}); | |
} | |
} | |
//Camel-case the XML | |
var emlString = ""; | |
_.each( | |
html, | |
function (rootEMLNode) { | |
emlString += this.formatXML(rootEMLNode); | |
}, | |
this, | |
); | |
return emlString; | |
}, | |
/* | |
* Given an EML DOM and party type, this function updated and/or adds the EMLParties to the EML | |
*/ | |
serializeParties: function (eml, type) { | |
//Remove the nodes from the EML for this party type | |
$(eml).children("dataset").children(type.toLowerCase()).remove(); | |
//Serialize each party of this type | |
_.each( | |
this.get(type), | |
function (party, i) { | |
//Get the last node of this type to insert after | |
var insertAfter = $(eml) | |
.children("dataset") | |
.children(type.toLowerCase()) | |
.last(); | |
//If there isn't a node found, find the EML position to insert after | |
if (!insertAfter.length) { | |
insertAfter = this.getEMLPosition(eml, type); | |
} | |
//Update the DOM of the EMLParty | |
var emlPartyDOM = party.updateDOM(); | |
//Make sure we don't insert empty EMLParty nodes into the EML | |
if ($(emlPartyDOM).children().length) { | |
//Insert the party DOM at the insert position | |
if (insertAfter && insertAfter.length) | |
insertAfter.after(emlPartyDOM); | |
//If an insert position still hasn't been found, then just append to the dataset node | |
else $(eml).find("dataset").append(emlPartyDOM); | |
} | |
}, | |
this, | |
); | |
//Create a certain parties from the current app user if none is given | |
if (type == "contact" && !this.get("contact").length) { | |
//Get the creators | |
var creators = this.get("creator"), | |
contacts = []; | |
_.each( | |
creators, | |
function (creator) { | |
//Clone the creator model and add it to the contacts array | |
var newModel = new EMLParty({ parentModel: this }); | |
newModel.set(creator.toJSON()); | |
newModel.set("type", type); | |
contacts.push(newModel); | |
}, | |
this, | |
); | |
this.set(type, contacts); | |
//Call this function again to serialize the new models | |
this.serializeParties(eml, type); | |
} | |
}, | |
serializeKeywords: function (eml) { | |
// Remove all existing keywordSets before appending | |
$(eml).find("dataset").find("keywordset").remove(); | |
if (this.get("keywordSets").length == 0) return; | |
// Create the new keywordSets nodes | |
var nodes = _.map(this.get("keywordSets"), function (kwd) { | |
return kwd.updateDOM(); | |
}); | |
this.getEMLPosition(eml, "keywordset").after(nodes); | |
}, | |
/* | |
* Remoes nodes from the EML that do not have an accompanying model | |
* (Were probably removed from the EML by the user during editing) | |
*/ | |
removeExtraNodes: function (nodes, models) { | |
// Remove the extra nodes | |
var extraNodes = nodes.length - models.length; | |
if (extraNodes > 0) { | |
for (var i = models.length; i < nodes.length; i++) { | |
$(nodes[i]).remove(); | |
} | |
} | |
}, | |
/* | |
* Saves the EML document to the server using the DataONE API | |
*/ | |
save: function (attributes, options) { | |
//Validate before we try anything else | |
if (!this.isValid()) { | |
this.trigger("invalid"); | |
this.trigger("cancelSave"); | |
return false; | |
} else { | |
this.trigger("valid"); | |
} | |
this.setFileName(); | |
//Set the upload transfer as in progress | |
this.set("uploadStatus", "p"); | |
//Reset the draftSaved attribute | |
this.set("draftSaved", false); | |
//Create the creator from the current user if none is provided | |
if (!this.get("creator").length) { | |
var party = new EMLParty({ parentModel: this, type: "creator" }); | |
party.createFromUser(); | |
this.set("creator", [party]); | |
} | |
//Create the contact from the current user if none is provided | |
if (!this.get("contact").length) { | |
var party = new EMLParty({ parentModel: this, type: "contact" }); | |
party.createFromUser(); | |
this.set("contact", [party]); | |
} | |
//If this is an existing object and there is no system metadata, retrieve it | |
if (!this.isNew() && !this.get("sysMetaXML")) { | |
var model = this; | |
//When the system metadata is fetched, try saving again | |
var fetchOptions = { | |
success: function (response) { | |
model.set(DataONEObject.prototype.parse.call(model, response)); | |
model.save(attributes, options); | |
}, | |
}; | |
//Fetch the system metadata now | |
this.fetchSystemMetadata(fetchOptions); | |
return; | |
} | |
//Create a FormData object to send data with our XHR | |
var formData = new FormData(); | |
try { | |
//Add the identifier to the XHR data | |
if (this.isNew()) { | |
formData.append("pid", this.get("id")); | |
} else { | |
//Create a new ID | |
this.updateID(); | |
//Add the ids to the form data | |
formData.append("newPid", this.get("id")); | |
formData.append("pid", this.get("oldPid")); | |
} | |
//Serialize the EML XML | |
var xml = this.serialize(); | |
var xmlBlob = new Blob([xml], { type: "application/xml" }); | |
//Get the size of the new EML XML | |
this.set("size", xmlBlob.size); | |
//Get the new checksum of the EML XML | |
var checksum = md5(xml); | |
this.set("checksum", checksum); | |
this.set("checksumAlgorithm", "MD5"); | |
//Create the system metadata XML | |
var sysMetaXML = this.serializeSysMeta(); | |
//Send the system metadata as a Blob | |
var sysMetaXMLBlob = new Blob([sysMetaXML], { | |
type: "application/xml", | |
}); | |
//Add the object XML and System Metadata XML to the form data | |
//Append the system metadata first, so we can take advantage of Metacat's streaming multipart handler | |
formData.append("sysmeta", sysMetaXMLBlob, "sysmeta"); | |
formData.append("object", xmlBlob); | |
} catch (error) { | |
//Reset the identifier since we didn't actually update the object | |
this.resetID(); | |
this.set("uploadStatus", "e"); | |
this.trigger("error"); | |
this.trigger("cancelSave"); | |
return false; | |
} | |
var model = this; | |
var saveOptions = options || {}; | |
_.extend( | |
saveOptions, | |
{ | |
data: formData, | |
cache: false, | |
contentType: false, | |
dataType: "text", | |
processData: false, | |
parse: false, | |
//Use the URL function to determine the URL | |
url: this.isNew() ? this.url() : this.url({ update: true }), | |
xhr: function () { | |
var xhr = new window.XMLHttpRequest(); | |
//Upload progress | |
xhr.upload.addEventListener( | |
"progress", | |
function (evt) { | |
if (evt.lengthComputable) { | |
var percentComplete = (evt.loaded / evt.total) * 100; | |
model.set("uploadProgress", percentComplete); | |
} | |
}, | |
false, | |
); | |
return xhr; | |
}, | |
success: function (model, response, xhr) { | |
model.set("numSaveAttempts", 0); | |
model.set("uploadStatus", "c"); | |
model.set("sysMetaXML", model.serializeSysMeta()); | |
model.set("oldPid", null); | |
model.fetch({ merge: true, systemMetadataOnly: true }); | |
model.trigger("successSaving", model); | |
}, | |
error: function (model, response, xhr) { | |
model.set("numSaveAttempts", model.get("numSaveAttempts") + 1); | |
var numSaveAttempts = model.get("numSaveAttempts"); | |
//Reset the identifier changes | |
model.resetID(); | |
if ( | |
numSaveAttempts < 3 && | |
(response.status == 408 || response.status == 0) | |
) { | |
//Try saving again in 10, 40, and 90 seconds | |
setTimeout( | |
function () { | |
model.save.call(model); | |
}, | |
numSaveAttempts * numSaveAttempts * 10000, | |
); | |
} else { | |
model.set("numSaveAttempts", 0); | |
//Get the error error information | |
var errorDOM = $($.parseHTML(response.responseText)), | |
errorContainer = errorDOM.filter("error"), | |
msgContainer = errorContainer.length | |
? errorContainer.find("description") | |
: errorDOM.not("style, title"), | |
errorMsg = msgContainer.length | |
? msgContainer.text() | |
: errorDOM; | |
//When there is no network connection (status == 0), there will be no response text | |
if (!errorMsg || response.status == 408 || response.status == 0) | |
errorMsg = | |
"There was a network issue that prevented your metadata from uploading. " + | |
"Make sure you are connected to a reliable internet connection."; | |
//Save the error message in the model | |
model.set("errorMessage", errorMsg); | |
//Set the model status as e for error | |
model.set("uploadStatus", "e"); | |
//Save the EML as a plain text file, until drafts are a supported feature | |
var copy = model.createTextCopy(); | |
//If the EML copy successfully saved, let the user know that there is a copy saved behind the scenes | |
model.listenToOnce(copy, "successSaving", function () { | |
model.set("draftSaved", true); | |
//Trigger the errorSaving event so other parts of the app know that the model failed to save | |
//And send the error message with it | |
model.trigger("errorSaving", errorMsg); | |
}); | |
//If the EML copy fails to save too, then just display the usual error message | |
model.listenToOnce(copy, "errorSaving", function () { | |
//Trigger the errorSaving event so other parts of the app know that the model failed to save | |
//And send the error message with it | |
model.trigger("errorSaving", errorMsg); | |
}); | |
//Save the EML plain text copy | |
copy.save(); | |
// Track the error | |
MetacatUI.analytics?.trackException( | |
`EML save error: ${errorMsg}, EML draft: ${copy.get("id")}`, | |
model.get("id"), | |
true, | |
); | |
} | |
}, | |
}, | |
MetacatUI.appUserModel.createAjaxSettings(), | |
); | |
return Backbone.Model.prototype.save.call( | |
this, | |
attributes, | |
saveOptions, | |
); | |
}, | |
/* | |
* Checks if this EML model has all the required values necessary to save to the server | |
*/ | |
validate: function () { | |
let errors = {}; | |
//A title is always required by EML | |
if (!this.get("title").length || !this.get("title")[0]) { | |
errors.title = "A title is required"; | |
} | |
// Validate the publication date | |
if (this.get("pubDate") != null) { | |
if (!this.isValidYearDate(this.get("pubDate"))) { | |
errors["pubDate"] = [ | |
"The value entered for publication date, '" + | |
this.get("pubDate") + | |
"' is not a valid value for this field. Enter with a year (e.g. 2017) or a date in the format YYYY-MM-DD.", | |
]; | |
} | |
} | |
// Validate the temporal coverage | |
errors.temporalCoverage = []; | |
//If temporal coverage is required and there aren't any, return an error | |
if ( | |
MetacatUI.appModel.get("emlEditorRequiredFields").temporalCoverage && | |
!this.get("temporalCoverage").length | |
) { | |
errors.temporalCoverage = [{ beginDate: "Provide a begin date." }]; | |
} | |
//If temporal coverage is required and they are all empty, return an error | |
else if ( | |
MetacatUI.appModel.get("emlEditorRequiredFields").temporalCoverage && | |
_.every(this.get("temporalCoverage"), function (tc) { | |
return tc.isEmpty(); | |
}) | |
) { | |
errors.temporalCoverage = [{ beginDate: "Provide a begin date." }]; | |
} | |
//If temporal coverage is not required, validate each one | |
else if ( | |
this.get("temporalCoverage").length || | |
(MetacatUI.appModel.get("emlEditorRequiredFields").temporalCoverage && | |
_.every(this.get("temporalCoverage"), function (tc) { | |
return tc.isEmpty(); | |
})) | |
) { | |
//Iterate over each temporal coverage and add it's validation errors | |
_.each(this.get("temporalCoverage"), function (temporalCoverage) { | |
if (!temporalCoverage.isValid() && !temporalCoverage.isEmpty()) { | |
errors.temporalCoverage.push(temporalCoverage.validationError); | |
} | |
}); | |
} | |
//Remove the temporalCoverage attribute if no errors were found | |
if (errors.temporalCoverage.length == 0) { | |
delete errors.temporalCoverage; | |
} | |
//Validate the EMLParty models | |
var partyTypes = [ | |
"associatedParty", | |
"contact", | |
"creator", | |
"metadataProvider", | |
"publisher", | |
]; | |
_.each( | |
partyTypes, | |
function (type) { | |
var people = this.get(type); | |
_.each( | |
people, | |
function (person, i) { | |
if (!person.isValid()) { | |
if (!errors[type]) errors[type] = [person.validationError]; | |
else errors[type].push(person.validationError); | |
} | |
}, | |
this, | |
); | |
}, | |
this, | |
); | |
//Validate the EMLGeoCoverage models | |
_.each( | |
this.get("geoCoverage"), | |
function (geoCoverageModel, i) { | |
if (!geoCoverageModel.isValid()) { | |
if (!errors.geoCoverage) | |
errors.geoCoverage = [geoCoverageModel.validationError]; | |
else errors.geoCoverage.push(geoCoverageModel.validationError); | |
} | |
}, | |
this, | |
); | |
//Validate the EMLTaxonCoverage model | |
var taxonModel = this.get("taxonCoverage")[0]; | |
if (!taxonModel.isEmpty() && !taxonModel.isValid()) { | |
errors = _.extend(errors, taxonModel.validationError); | |
} else if ( | |
taxonModel.isEmpty() && | |
this.get("taxonCoverage").length == 1 && | |
MetacatUI.appModel.get("emlEditorRequiredFields").taxonCoverage | |
) { | |
taxonModel.isValid(); | |
errors = _.extend(errors, taxonModel.validationError); | |
} | |
//Validate each EMLEntity model | |
_.each(this.get("entities"), function (entityModel) { | |
if (!entityModel.isValid()) { | |
if (!errors.entities) | |
errors.entities = [entityModel.validationError]; | |
else errors.entities.push(entityModel.validationError); | |
} | |
}); | |
//Validate the EML Methods | |
let emlMethods = this.get("methods"); | |
if (emlMethods) { | |
if (!emlMethods.isValid()) { | |
errors.methods = emlMethods.validationError; | |
} | |
} | |
// Validate each EMLAnnotation model | |
if (this.get("annotations")) { | |
this.get("annotations").each(function (model) { | |
if (model.isValid()) { | |
return; | |
} | |
if (!errors.annotations) { | |
errors.annotations = []; | |
} | |
errors.annotations.push(model.validationError); | |
}, this); | |
} | |
//Check the required fields for this MetacatUI configuration | |
for ([field, isRequired] of Object.entries( | |
MetacatUI.appModel.get("emlEditorRequiredFields"), | |
)) { | |
//If it's not required, then go to the next field | |
if (!isRequired) continue; | |
if (field == "alternateIdentifier") { | |
if ( | |
!this.get("alternateIdentifier").length || | |
_.every(this.get("alternateIdentifier"), function (altId) { | |
return altId.trim() == ""; | |
}) | |
) | |
errors.alternateIdentifier = | |
"At least one alternate identifier is required."; | |
} else if (field == "generalTaxonomicCoverage") { | |
if ( | |
!this.get("taxonCoverage").length || | |
!this.get("taxonCoverage")[0].get("generalTaxonomicCoverage") | |
) | |
errors.generalTaxonomicCoverage = | |
"Provide a description of the general taxonomic coverage of this data set."; | |
} else if (field == "geoCoverage") { | |
if (!this.get("geoCoverage").length) | |
errors.geoCoverage = "At least one location is required."; | |
} else if (field == "intellectualRights") { | |
if (!this.get("intellectualRights")) | |
errors.intellectualRights = | |
"Select usage rights for this data set."; | |
} else if (field == "studyExtentDescription") { | |
if ( | |
!this.get("methods") || | |
!this.get("methods").get("studyExtentDescription") | |
) | |
errors.studyExtentDescription = | |
"Provide a study extent description."; | |
} else if (field == "samplingDescription") { | |
if ( | |
!this.get("methods") || | |
!this.get("methods").get("samplingDescription") | |
) | |
errors.samplingDescription = "Provide a sampling description."; | |
} else if (field == "temporalCoverage") { | |
if (!this.get("temporalCoverage").length) | |
errors.temporalCoverage = | |
"Provide the date(s) for this data set."; | |
} else if (field == "taxonCoverage") { | |
if (!this.get("taxonCoverage").length) | |
errors.taxonCoverage = | |
"At least one taxa rank and value is required."; | |
} else if (field == "keywordSets") { | |
if (!this.get("keywordSets").length) | |
errors.keywordSets = "Provide at least one keyword."; | |
} | |
//The EMLMethods model will validate itself for required fields, but | |
// this is a rudimentary check to make sure the EMLMethods model was created | |
// in the first place | |
else if (field == "methods") { | |
if (!this.get("methods")) | |
errors.methods = "At least one method step is required."; | |
} else if (field == "funding") { | |
// Note: Checks for either the funding or award element. award | |
// element is checked by the project's objectDOM for now until | |
// EMLProject fully supports the award element | |
if ( | |
!this.get("project") || | |
!( | |
this.get("project").get("funding").length || | |
(this.get("project").get("objectDOM") && | |
this.get("project").get("objectDOM").querySelectorAll && | |
this.get("project").get("objectDOM").querySelectorAll("award") | |
.length > 0) | |
) | |
) | |
errors.funding = | |
"Provide at least one project funding number or name."; | |
} else if (field == "abstract") { | |
if (!this.get("abstract").length) | |
errors["abstract"] = "Provide an abstract."; | |
} else if (field == "dataSensitivity") { | |
if (!this.getDataSensitivity()) { | |
errors["dataSensitivity"] = | |
"Pick the category that best describes the level of sensitivity or restriction of the data."; | |
} | |
} | |
//If this is an EMLParty type, check that there is a party of this type in the model | |
else if ( | |
EMLParty.prototype.partyTypes | |
.map((t) => t.dataCategory) | |
.includes(field) | |
) { | |
//If this is an associatedParty role | |
if (EMLParty.prototype.defaults().roleOptions?.includes(field)) { | |
if ( | |
!this.get("associatedParty") | |
?.map((p) => p.get("roles")) | |
.flat() | |
.includes(field) | |
) { | |
errors[field] = | |
"Provide information about the people or organization(s) in the role: " + | |
EMLParty.prototype.partyTypes.find( | |
(t) => t.dataCategory == field, | |
)?.label; | |
} | |
} else if (!this.get(field)?.length) { | |
errors[field] = | |
"Provide information about the people or organization(s) in the role: " + | |
EMLParty.prototype.partyTypes.find( | |
(t) => t.dataCategory == field, | |
)?.label; | |
} | |
} else if (!this.get(field) || !this.get(field)?.length) { | |
errors[field] = "Provide a " + field + "."; | |
} | |
} | |
if (Object.keys(errors).length) return errors; | |
else { | |
return; | |
} | |
}, | |
/* Returns a boolean for whether the argument 'value' is a valid | |
value for EML's yearDate type which is used in a few places. | |
Note that this method considers a zero-length String to be valid | |
because the EML211.serialize() method will properly handle a null | |
or zero-length String by serializing out the current year. */ | |
isValidYearDate: function (value) { | |
return ( | |
value === "" || | |
/^\d{4}$/.test(value) || | |
/^\d{4}-\d{2}-\d{2}$/.test(value) | |
); | |
}, | |
/* | |
* Sends an AJAX request to fetch the system metadata for this EML object. | |
* Will not trigger a sync event since it does not use Backbone.Model.fetch | |
*/ | |
fetchSystemMetadata: function (options) { | |
if (!options) var options = {}; | |
else options = _.clone(options); | |
var model = this, | |
fetchOptions = _.extend( | |
{ | |
url: | |
MetacatUI.appModel.get("metaServiceUrl") + | |
encodeURIComponent(this.get("id")), | |
dataType: "text", | |
success: function (response) { | |
model.set(DataONEObject.prototype.parse.call(model, response)); | |
//Trigger a custom event that the sys meta was updated | |
model.trigger("sysMetaUpdated"); | |
}, | |
error: function () { | |
model.trigger("error"); | |
}, | |
}, | |
options, | |
); | |
//Add the authorization header and other AJAX settings | |
_.extend(fetchOptions, MetacatUI.appUserModel.createAjaxSettings()); | |
$.ajax(fetchOptions); | |
}, | |
/* | |
* Returns the nofde in the given EML document that the given node type | |
* should be inserted after | |
* | |
* Returns false if either the node is not found in the and this should | |
* be handled by the caller. | |
*/ | |
getEMLPosition: function (eml, nodeName) { | |
var nodeOrder = this.get("nodeOrder"); | |
var position = _.indexOf(nodeOrder, nodeName.toLowerCase()); | |
if (position == -1) { | |
return false; | |
} | |
// Go through each node in the node list and find the position where this | |
// node will be inserted after | |
for (var i = position - 1; i >= 0; i--) { | |
if ($(eml).find("dataset").children(nodeOrder[i]).length) { | |
return $(eml).find("dataset").children(nodeOrder[i]).last(); | |
} | |
} | |
return false; | |
}, | |
/* | |
* Checks if this model has updates that need to be synced with the server. | |
*/ | |
hasUpdates: function () { | |
if (this.constructor.__super__.hasUpdates.call(this)) return true; | |
//If nothing else has been changed, then this object hasn't had any updates | |
return false; | |
}, | |
/* | |
Add an entity into the EML 2.1.1 object | |
*/ | |
addEntity: function (emlEntity, position) { | |
//Get the current list of entities | |
var currentEntities = this.get("entities"); | |
if (typeof position == "undefined" || position == -1) | |
currentEntities.push(emlEntity); | |
//Add the entity model to the entity array | |
else currentEntities.splice(position, 0, emlEntity); | |
this.trigger("change:entities"); | |
this.trickleUpChange(); | |
return this; | |
}, | |
/* | |
Remove an entity from the EML 2.1.1 object | |
*/ | |
removeEntity: function (emlEntity) { | |
if (!emlEntity || typeof emlEntity != "object") return; | |
//Get the current list of entities | |
var entities = this.get("entities"); | |
entities = _.without(entities, emlEntity); | |
this.set("entities", entities); | |
}, | |
/* | |
* Find the entity model for a given DataONEObject | |
*/ | |
getEntity: function (dataONEObj) { | |
//If an EMLEntity model has been found for this object before, then return it | |
if (dataONEObj.get("metadataEntity")) { | |
dataONEObj.get("metadataEntity").set("dataONEObject", dataONEObj); | |
return dataONEObj.get("metadataEntity"); | |
} | |
var entity = _.find( | |
this.get("entities"), | |
function (e) { | |
//Matches of the checksum or identifier are definite matches | |
if (e.get("xmlID") == dataONEObj.getXMLSafeID()) return true; | |
else if ( | |
e.get("physicalMD5Checksum") && | |
e.get("physicalMD5Checksum") == dataONEObj.get("checksum") && | |
dataONEObj.get("checksumAlgorithm").toUpperCase() == "MD5" | |
) | |
return true; | |
else if ( | |
e.get("downloadID") && | |
e.get("downloadID") == dataONEObj.get("id") | |
) | |
return true; | |
// Get the file name from the EML for this entity | |
var fileNameFromEML = | |
e.get("physicalObjectName") || e.get("entityName"); | |
// If the EML file name matches the DataONEObject file name | |
if ( | |
fileNameFromEML && | |
dataONEObj.get("fileName") && | |
(fileNameFromEML.toLowerCase() == | |
dataONEObj.get("fileName").toLowerCase() || | |
fileNameFromEML.replace(/ /g, "_").toLowerCase() == | |
dataONEObj.get("fileName").toLowerCase()) | |
) { | |
//Get an array of all the other entities in this EML | |
var otherEntities = _.without(this.get("entities"), e); | |
// If this entity name matches the dataone object file name, AND no other dataone object file name | |
// matches, then we can assume this is the entity element for this file. | |
var otherMatchingEntity = _.find( | |
otherEntities, | |
function (otherE) { | |
// Get the file name from the EML for the other entities | |
var otherFileNameFromEML = | |
otherE.get("physicalObjectName") || | |
otherE.get("entityName"); | |
// If the file names match, return true | |
if ( | |
otherFileNameFromEML == dataONEObj.get("fileName") || | |
otherFileNameFromEML.replace(/ /g, "_") == | |
dataONEObj.get("fileName") | |
) | |
return true; | |
}, | |
); | |
// If this entity's file name didn't match any other file names in the EML, | |
// then this entity is a match for the given dataONEObject | |
if (!otherMatchingEntity) return true; | |
} | |
}, | |
this, | |
); | |
//If we found an entity, give it an ID and return it | |
if (entity) { | |
//If this entity has been matched to another DataONEObject already, then don't match it again | |
if (entity.get("dataONEObject") == dataONEObj) { | |
return entity; | |
} | |
//If this entity has been matched to a different DataONEObject already, then don't match it again. | |
//i.e. We will not override existing entity<->DataONEObject pairings | |
else if (entity.get("dataONEObject")) { | |
return; | |
} else { | |
entity.set("dataONEObject", dataONEObj); | |
} | |
//Create an XML-safe ID and set it on the Entity model | |
var entityID = this.getUniqueEntityId(dataONEObj); | |
entity.set("xmlID", entityID); | |
//Save a reference to this entity so we don't have to refind it later | |
dataONEObj.set("metadataEntity", entity); | |
return entity; | |
} | |
//See if one data object is of this type in the package | |
var matchingTypes = _.filter(this.get("entities"), function (e) { | |
return ( | |
e.get("formatName") == | |
(dataONEObj.get("formatId") || dataONEObj.get("mediaType")) | |
); | |
}); | |
if (matchingTypes.length == 1) { | |
//Create an XML-safe ID and set it on the Entity model | |
matchingTypes[0].set("xmlID", dataONEObj.getXMLSafeID()); | |
return matchingTypes[0]; | |
} | |
//If this EML is in a DataPackage with only one other DataONEObject, | |
// and there is only one entity in the EML, then we can assume they are the same entity | |
if (this.get("entities").length == 1) { | |
if ( | |
this.get("collections")[0] && | |
this.get("collections")[0].type == "DataPackage" && | |
this.get("collections")[0].length == 2 && | |
_.contains(this.get("collections")[0].models, dataONEObj) | |
) { | |
return this.get("entities")[0]; | |
} | |
} | |
return false; | |
}, | |
createEntity: function (dataONEObject) { | |
// Add or append an entity to the parent's entity list | |
var entityModel = new EMLOtherEntity({ | |
entityName: dataONEObject.get("fileName"), | |
entityType: | |
dataONEObject.get("formatId") || | |
dataONEObject.get("mediaType") || | |
"application/octet-stream", | |
dataONEObject: dataONEObject, | |
parentModel: this, | |
xmlID: dataONEObject.getXMLSafeID(), | |
}); | |
this.addEntity(entityModel); | |
//If this DataONEObject fails to upload, remove the EML entity | |
this.listenTo(dataONEObject, "errorSaving", function () { | |
this.removeEntity(dataONEObject.get("metadataEntity")); | |
//Listen for a successful save so the entity can be added back | |
this.listenToOnce(dataONEObject, "successSaving", function () { | |
this.addEntity(dataONEObject.get("metadataEntity")); | |
}); | |
}); | |
}, | |
/* | |
* Creates an XML-safe identifier that is unique to this EML document, | |
* based on the given DataONEObject model. It is intended for EML entity nodes in particular. | |
* | |
* @param {DataONEObject} - a DataONEObject model that this EML documents | |
* @return {string} - an identifier string unique to this EML document | |
*/ | |
getUniqueEntityId: function (dataONEObject) { | |
var uniqueId = ""; | |
uniqueId = dataONEObject.getXMLSafeID(); | |
//Get the EML string, if there is one, to check if this id already exists | |
var emlString = this.get("objectXML"); | |
//If this id already exists in the EML... | |
if (emlString && emlString.indexOf(' id="' + uniqueId + '"')) { | |
//Create a random uuid to use instead | |
uniqueId = "urn-uuid-" + uuid.v4(); | |
} | |
return uniqueId; | |
}, | |
/* | |
* removeParty - removes the given EMLParty model from this EML211 model's attributes | |
*/ | |
removeParty: function (partyModel) { | |
//The list of attributes this EMLParty might be stored in | |
var possibleAttr = [ | |
"creator", | |
"contact", | |
"metadataProvider", | |
"publisher", | |
"associatedParty", | |
]; | |
// Iterate over each possible attribute | |
_.each( | |
possibleAttr, | |
function (attr) { | |
if (_.contains(this.get(attr), partyModel)) { | |
this.set(attr, _.without(this.get(attr), partyModel)); | |
} | |
}, | |
this, | |
); | |
}, | |
/** | |
* Attempt to move a party one index forward within its sibling models | |
* | |
* @param {EMLParty} partyModel: The EMLParty model we're moving | |
*/ | |
movePartyUp: function (partyModel) { | |
var possibleAttr = [ | |
"creator", | |
"contact", | |
"metadataProvider", | |
"publisher", | |
"associatedParty", | |
]; | |
// Iterate over each possible attribute | |
_.each( | |
possibleAttr, | |
function (attr) { | |
if (!_.contains(this.get(attr), partyModel)) { | |
return; | |
} | |
// Make a clone because we're going to use splice | |
var models = _.clone(this.get(attr)); | |
// Find the index of the model we're moving | |
var index = _.findIndex(models, function (m) { | |
return m === partyModel; | |
}); | |
if (index === 0) { | |
// Already first | |
return; | |
} | |
if (index === -1) { | |
// Couldn't find the model | |
return; | |
} | |
// Do the move using splice and update the model | |
models.splice(index - 1, 0, models.splice(index, 1)[0]); | |
this.set(attr, models); | |
this.trigger("change:" + attr); | |
}, | |
this, | |
); | |
}, | |
/** | |
* Attempt to move a party one index forward within its sibling models | |
* | |
* @param {EMLParty} partyModel: The EMLParty model we're moving | |
*/ | |
movePartyDown: function (partyModel) { | |
var possibleAttr = [ | |
"creator", | |
"contact", | |
"metadataProvider", | |
"publisher", | |
"associatedParty", | |
]; | |
// Iterate over each possible attribute | |
_.each( | |
possibleAttr, | |
function (attr) { | |
if (!_.contains(this.get(attr), partyModel)) { | |
return; | |
} | |
// Make a clone because we're going to use splice | |
var models = _.clone(this.get(attr)); | |
// Find the index of the model we're moving | |
var index = _.findIndex(models, function (m) { | |
return m === partyModel; | |
}); | |
if (index === -1) { | |
// Couldn't find the model | |
return; | |
} | |
// Figure out where to put the new model | |
// Leave it in the same place if the next index doesn't exist | |
// Move one forward if it does | |
var newIndex = models.length <= index + 1 ? index : index + 1; | |
// Do the move using splice and update the model | |
models.splice(newIndex, 0, models.splice(index, 1)[0]); | |
this.set(attr, models); | |
this.trigger("change:" + attr); | |
}, | |
this, | |
); | |
}, | |
/* | |
* Adds the given EMLParty model to this EML211 model in the | |
* appropriate role array in the given position | |
* | |
* @param {EMLParty} - The EMLParty model to add | |
* @param {number} - The position in the role array in which to insert this EMLParty | |
* @return {boolean} - Returns true if the EMLParty was successfully added, false if it was cancelled | |
*/ | |
addParty: function (partyModel, position) { | |
//If the EMLParty model is empty, don't add it to the EML211 model | |
if (partyModel.isEmpty()) return false; | |
//Get the role of this EMLParty | |
var role = partyModel.get("type") || "associatedParty"; | |
//If this model already contains this EMLParty, then exit | |
if (_.contains(this.get(role), partyModel)) return false; | |
if (typeof position == "undefined") { | |
this.get(role).push(partyModel); | |
} else { | |
this.get(role).splice(position, 0, partyModel); | |
} | |
this.trigger("change:" + role); | |
return true; | |
}, | |
/** | |
* getPartiesByType - Gets an array of EMLParty members that have a particular party type or role. | |
* @param {string} partyType - A string that represents either the role or the party type. For example, "contact", "creator", "principalInvestigator", etc. | |
* @since 2.15.0 | |
*/ | |
getPartiesByType: function (partyType) { | |
try { | |
if (!partyType) { | |
return false; | |
} | |
var associatedPartyTypes = new EMLParty().get("roleOptions"), | |
isAssociatedParty = associatedPartyTypes.includes(partyType), | |
parties = []; | |
// For "contact", "creator", "metadataProvider", "publisher", each party type has it's own | |
// array in the EML model | |
if (!isAssociatedParty) { | |
parties = this.get(partyType); | |
// For "custodianSteward", "principalInvestigator", "collaboratingPrincipalInvestigator", etc., | |
// party members are listed in the EML model's associated parties array. Each associated party's | |
// party type is indicated in the role attribute. | |
} else { | |
parties = _.filter( | |
this.get("associatedParty"), | |
function (associatedParty) { | |
return associatedParty.get("roles").includes(partyType); | |
}, | |
); | |
} | |
return parties; | |
} catch (error) { | |
console.log( | |
"Error trying to find a list of party members in an EML model by type. Error details: " + | |
error, | |
); | |
} | |
}, | |
createUnits: function () { | |
this.units.fetch(); | |
}, | |
/* Initialize the object XML for brand spankin' new EML objects */ | |
createXML: function () { | |
let emlSystem = MetacatUI.appModel.get("emlSystem"); | |
emlSystem = | |
!emlSystem || typeof emlSystem != "string" ? "knb" : emlSystem; | |
var xml = | |
'<eml:eml xmlns:eml="https://eml.ecoinformatics.org/eml-2.2.0"></eml:eml>', | |
eml = $($.parseHTML(xml)); | |
// Set base attributes | |
eml.attr("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"); | |
eml.attr("xmlns:stmml", "http://www.xml-cml.org/schema/stmml-1.1"); | |
eml.attr( | |
"xsi:schemaLocation", | |
"https://eml.ecoinformatics.org/eml-2.2.0 https://eml.ecoinformatics.org/eml-2.2.0/eml.xsd", | |
); | |
eml.attr("packageId", this.get("id")); | |
eml.attr("system", emlSystem); | |
// Add the dataset | |
eml.append(document.createElement("dataset")); | |
eml.find("dataset").append(document.createElement("title")); | |
var emlString = $(document.createElement("div")) | |
.append(eml.clone()) | |
.html(); | |
return emlString; | |
}, | |
/* | |
Replace elements named "source" with "sourced" due to limitations | |
with using $.parseHTML() rather than $.parseXML() | |
@param xmlString The XML string to make the replacement in | |
*/ | |
cleanUpXML: function (xmlString) { | |
xmlString.replace("<source>", "<sourced>"); | |
xmlString.replace("</source>", "</sourced>"); | |
return xmlString; | |
}, | |
createTextCopy: function () { | |
var emlDraftText = | |
"EML draft for " + | |
this.get("id") + | |
"(" + | |
this.get("title") + | |
") by " + | |
MetacatUI.appUserModel.get("firstName") + | |
" " + | |
MetacatUI.appUserModel.get("lastName"); | |
if (this.get("uploadStatus") == "e" && this.get("errorMessage")) { | |
emlDraftText += | |
". This EML had the following save error: `" + | |
this.get("errorMessage") + | |
"` "; | |
} else { | |
emlDraftText += ": "; | |
} | |
emlDraftText += this.serialize(); | |
var plainTextEML = new DataONEObject({ | |
formatId: "text/plain", | |
fileName: | |
"eml_draft_" + | |
(MetacatUI.appUserModel.get("lastName") || "") + | |
".txt", | |
uploadFile: new Blob([emlDraftText], { type: "plain/text" }), | |
synced: true, | |
}); | |
return plainTextEML; | |
}, | |
/* | |
* Cleans up the given text so that it is XML-valid by escaping reserved characters, trimming white space, etc. | |
* | |
* @param {string} textString - The string to clean up | |
* @return {string} - The cleaned up string | |
*/ | |
cleanXMLText: function (textString) { | |
if (typeof textString != "string") return; | |
textString = textString.trim(); | |
//Check for XML/HTML elements | |
_.each(textString.match(/<\s*[^>]*>/g), function (xmlNode) { | |
//Encode <, >, and </ substrings | |
var tagName = xmlNode.replace(/>/g, ">"); | |
tagName = tagName.replace(/</g, "<"); | |
//Replace the xmlNode in the full text string | |
textString = textString.replace(xmlNode, tagName); | |
}); | |
//Remove Unicode characters that are not valid XML characters | |
//Create a regular expression that matches any character that is not a valid XML character | |
// (see https://www.w3.org/TR/xml/#charsets) | |
var invalidCharsRegEx = | |
/[^\u0009\u000a\u000d\u0020-\uD7FF\uE000-\uFFFD]/g; | |
textString = textString.replace(invalidCharsRegEx, ""); | |
return textString; | |
}, | |
/* | |
Dereference "reference" elements and replace them with a cloned copy | |
of the referenced content | |
@param xmlString The XML string with reference elements to transform | |
*/ | |
dereference: function (xmlString) { | |
var referencesList; // the array of references elements in the document | |
var referencedID; // The id of the referenced element | |
var referencesParentEl; // The parent of the given references element | |
var referencedEl; // The referenced DOM to be copied | |
var xmlDOM = $.parseXML(xmlString); | |
referencesList = xmlDOM.getElementsByTagName("references"); | |
if (referencesList.length) { | |
// Process each references elements | |
_.each( | |
referencesList, | |
function (referencesEl, index, referencesList) { | |
// Can't rely on the passed referencesEl since the list length changes | |
// because of the remove() below. Reuse referencesList[0] for every item: | |
// referencedID = $(referencesEl).text(); // doesn't work | |
referencesEl = referencesList[0]; | |
referencedID = $(referencesEl).text(); | |
referencesParentEl = $(referencesEl).parent()[0]; | |
if (typeof referencedID !== "undefined" && referencedID != "") { | |
referencedEl = xmlDOM.getElementById(referencedID); | |
if (typeof referencedEl != "undefined") { | |
// Clone the referenced element and replace the references element | |
var referencedClone = $(referencedEl).clone()[0]; | |
$(referencesParentEl) | |
.children(referencesEl.localName) | |
.replaceWith($(referencedClone).children()); | |
//$(referencesParentEl).append($(referencedClone).children()); | |
$(referencesParentEl).attr("id", DataONEObject.generateId()); | |
} | |
} | |
}, | |
xmlDOM, | |
); | |
} | |
return new XMLSerializer().serializeToString(xmlDOM); | |
}, | |
/* | |
* Uses the EML `title` to set the `fileName` attribute on this model. | |
*/ | |
setFileName: function () { | |
var title = ""; | |
// Get the title from the metadata | |
if (Array.isArray(this.get("title"))) { | |
title = this.get("title")[0]; | |
} else if (typeof this.get("title") == "string") { | |
title = this.get("title"); | |
} | |
//Max title length | |
var maxLength = 50; | |
//trim the string to the maximum length | |
var trimmedTitle = title.trim().substr(0, maxLength); | |
//re-trim if we are in the middle of a word | |
if (trimmedTitle.indexOf(" ") > -1) { | |
trimmedTitle = trimmedTitle.substr( | |
0, | |
Math.min(trimmedTitle.length, trimmedTitle.lastIndexOf(" ")), | |
); | |
} | |
//Replace all non alphanumeric characters with underscores | |
// and make sure there isn't more than one underscore in a row | |
trimmedTitle = trimmedTitle | |
.replace(/[^a-zA-Z0-9]/g, "_") | |
.replace(/_{2,}/g, "_"); | |
//Set the fileName on the model | |
this.set("fileName", trimmedTitle + ".xml"); | |
}, | |
trickleUpChange: function () { | |
if ( | |
!MetacatUI.rootDataPackage || | |
!MetacatUI.rootDataPackage.packageModel | |
) | |
return; | |
//Mark the package as changed | |
MetacatUI.rootDataPackage.packageModel.set("changed", true); | |
}, | |
/** | |
* Sets the xsi:schemaLocation attribute on the passed-in Element | |
* depending on the application configuration. | |
* | |
* @param {Element} eml: The root eml:eml element to modify | |
* @return {Element} The element, possibly modified | |
*/ | |
setSchemaLocation: function (eml) { | |
if (!MetacatUI || !MetacatUI.appModel) { | |
return eml; | |
} | |
var current = $(eml).attr("xsi:schemaLocation"), | |
format = MetacatUI.appModel.get("editorSerializationFormat"), | |
location = MetacatUI.appModel.get("editorSchemaLocation"); | |
// Return now if we can't do anything anyway | |
if (!format || !location) { | |
return eml; | |
} | |
// Simply add if the attribute isn't present to begin with | |
if (!current || typeof current !== "string") { | |
$(eml).attr("xsi:schemaLocation", format + " " + location); | |
return eml; | |
} | |
// Don't append if it's already present | |
if (current.indexOf(format) >= 0) { | |
return eml; | |
} | |
$(eml).attr("xsi:schemaLocation", current + " " + location); | |
return eml; | |
}, | |
createID: function () { | |
this.set("xmlID", uuid.v4()); | |
}, | |
/** | |
* Creates and adds an {@link EMLAnnotation} to this EML211 model with the given annotation data in JSON form. | |
* @param {object} annotationData The attribute data to set on the new {@link EMLAnnotation}. See {@link EMLAnnotation#defaults} for | |
* details on what attributes can be passed to the EMLAnnotation. In addition, there is an `elementName` property. | |
* @property {string} [annotationData.elementName] The name of the EML Element that this | |
annotation should be applied to. e.g. dataset, entity, attribute. Defaults to `dataset`. NOTE: Right now only dataset annotations are supported until | |
more annotation editing is added to the EML Editor. | |
* @property {Boolean} [annotationData.allowDuplicates] If false, this annotation will replace all annotations already set with the same propertyURI. | |
* By default, more than one annotation with a given propertyURI can be added (defaults to true) | |
*/ | |
addAnnotation: function (annotationData) { | |
try { | |
if (!annotationData || typeof annotationData != "object") { | |
return; | |
} | |
//If no element name is provided, default to the dataset element. | |
let elementName = ""; | |
if (!annotationData.elementName) { | |
elementName = "dataset"; | |
} else { | |
elementName = annotationData.elementName; | |
} | |
//Remove the elementName property so it isn't set on the EMLAnnotation model later. | |
delete annotationData.elementName; | |
//Check if duplicates are allowed | |
let allowDuplicates = annotationData.allowDuplicates; | |
delete annotationData.allowDuplicates; | |
//Create a new EMLAnnotation model | |
let annotation = new EMLAnnotation(annotationData); | |
//Update annotations set on the dataset element | |
if (elementName == "dataset") { | |
let annotations = this.get("annotations"); | |
//If the current annotations set on the EML model are not in Array form, change it to an array | |
if (!annotations) { | |
annotations = new EMLAnnotations(); | |
} | |
if (allowDuplicates === false) { | |
//Add the EMLAnnotation to the collection, making sure to remove duplicates first | |
annotations.replaceDuplicateWith(annotation); | |
} else { | |
annotations.add(annotation); | |
} | |
//Set the annotations and force the change to be recognized by the model | |
this.set("annotations", annotations, { silent: true }); | |
this.handleChange(this, { force: true }); | |
} else { | |
/** @todo Add annotation support for other EML Elements */ | |
} | |
} catch (e) { | |
console.error("Could not add Annotation to the EML: ", e); | |
} | |
}, | |
/** | |
* Finds annotations that are of the `data sensitivity` property from the NCEAS SENSO ontology. | |
* Returns undefined if none are found. This function returns EMLAnnotation models because the data | |
* sensitivity is stored in the EML Model as EMLAnnotations and added to EML as semantic annotations. | |
* @returns {EMLAnnotation[]|undefined} | |
*/ | |
getDataSensitivity: function () { | |
try { | |
let annotations = this.get("annotations"); | |
if (annotations) { | |
let found = annotations.where({ | |
propertyURI: this.get("dataSensitivityPropertyURI"), | |
}); | |
if (!found || !found.length) { | |
return; | |
} else { | |
return found; | |
} | |
} else { | |
return; | |
} | |
} catch (e) { | |
console.error("Failed to get Data Sensitivity from EML model: ", e); | |
return; | |
} | |
}, | |
}, | |
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚫 [eslint] <object-shorthand> reported by reviewdog 🐶
Expected method shorthand.
metacatui/src/js/models/metadata/eml211/EML211.js
Lines 57 to 131 in 43c3062
defaults: function () { | |
return _.extend(ScienceMetadata.prototype.defaults(), { | |
id: "urn:uuid:" + uuid.v4(), | |
formatId: "https://eml.ecoinformatics.org/eml-2.2.0", | |
objectXML: null, | |
isEditable: false, | |
alternateIdentifier: [], | |
shortName: null, | |
title: [], | |
creator: [], // array of EMLParty objects | |
metadataProvider: [], // array of EMLParty objects | |
associatedParty: [], // array of EMLParty objects | |
contact: [], // array of EMLParty objects | |
publisher: [], // array of EMLParty objects | |
pubDate: null, | |
language: null, | |
series: null, | |
abstract: [], //array of EMLText objects | |
keywordSets: [], //array of EMLKeywordSet objects | |
additionalInfo: [], | |
intellectualRights: | |
"This work is dedicated to the public domain under the Creative Commons Universal 1.0 Public Domain Dedication. To view a copy of this dedication, visit https://creativecommons.org/publicdomain/zero/1.0/.", | |
distribution: [], // array of EMLDistribution objects | |
geoCoverage: [], //an array for EMLGeoCoverages | |
temporalCoverage: [], //an array of EMLTempCoverage models | |
taxonCoverage: [], //an array of EMLTaxonCoverages | |
purpose: [], | |
entities: [], //An array of EMLEntities | |
pubplace: null, | |
methods: new EMLMethods(), // An EMLMethods objects | |
project: null, // An EMLProject object, | |
annotations: null, // Dataset-level annotations | |
canonicalDataset: null, | |
dataSensitivityPropertyURI: | |
"http://purl.dataone.org/odo/SENSO_00000005", | |
nodeOrder: [ | |
"alternateidentifier", | |
"shortname", | |
"title", | |
"creator", | |
"metadataprovider", | |
"associatedparty", | |
"pubdate", | |
"language", | |
"series", | |
"abstract", | |
"keywordset", | |
"additionalinfo", | |
"intellectualrights", | |
"licensed", | |
"distribution", | |
"coverage", | |
"annotation", | |
"purpose", | |
"introduction", | |
"gettingstarted", | |
"acknowledgements", | |
"maintenance", | |
"contact", | |
"publisher", | |
"pubplace", | |
"methods", | |
"project", | |
"datatable", | |
"spatialraster", | |
"spatialvector", | |
"storedprocedure", | |
"view", | |
"otherentity", | |
"referencepublications", | |
"usagecitations", | |
"literaturecited", | |
], | |
}); | |
}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚫 [eslint] <object-shorthand> reported by reviewdog 🐶
Expected method shorthand.
metacatui/src/js/models/metadata/eml211/EML211.js
Lines 135 to 156 in 43c3062
initialize: function (attributes) { | |
// Call initialize for the super class | |
ScienceMetadata.prototype.initialize.call(this, attributes); | |
// EML211-specific init goes here | |
// this.set("objectXML", this.createXML()); | |
this.parse(this.createXML()); | |
this.on("sync", function () { | |
this.set("synced", true); | |
}); | |
this.stopListening(this, "change:canonicalDataset"); | |
this.listenTo( | |
this, | |
"change:canonicalDataset", | |
this.updateCanonicalDataset, | |
); | |
//Create a Unit collection | |
if (!this.units.length) this.createUnits(); | |
}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚫 [eslint] <object-shorthand> reported by reviewdog 🐶
Expected method shorthand.
metacatui/src/js/models/metadata/eml211/EML211.js
Lines 158 to 169 in 43c3062
url: function (options) { | |
var identifier; | |
if (options && options.update) { | |
identifier = this.get("oldPid") || this.get("seriesid"); | |
} else { | |
identifier = this.get("id") || this.get("seriesid"); | |
} | |
return ( | |
MetacatUI.appModel.get("objectServiceUrl") + | |
encodeURIComponent(identifier) | |
); | |
}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚫 [eslint] <object-shorthand> reported by reviewdog 🐶
Expected method shorthand.
metacatui/src/js/models/metadata/eml211/EML211.js
Lines 508 to 767 in 43c3062
parse: function (response) { | |
// Save a reference to this model for use in setting the | |
// parentModel inside anonymous functions | |
var model = this; | |
//If the response is XML | |
if (typeof response == "string" && response.indexOf("<") == 0) { | |
//Look for a system metadata tag and call DataONEObject parse instead | |
if (response.indexOf("systemMetadata>") > -1) | |
return DataONEObject.prototype.parse.call(this, response); | |
response = this.cleanUpXML(response); | |
response = this.dereference(response); | |
this.set("objectXML", response); | |
var emlElement = $($.parseHTML(response)).filter("eml\\:eml"); | |
} | |
var datasetEl; | |
if (emlElement[0]) datasetEl = $(emlElement[0]).find("dataset"); | |
if (!datasetEl || !datasetEl.length) return {}; | |
var emlParties = [ | |
"metadataprovider", | |
"associatedparty", | |
"creator", | |
"contact", | |
"publisher", | |
], | |
emlDistribution = ["distribution"], | |
emlEntities = [ | |
"datatable", | |
"otherentity", | |
"spatialvector", | |
"spatialraster", | |
"storedprocedure", | |
"view", | |
], | |
emlText = ["abstract", "additionalinfo"], | |
emlMethods = ["methods"]; | |
var nodes = datasetEl.children(), | |
modelJSON = {}; | |
for (var i = 0; i < nodes.length; i++) { | |
var thisNode = nodes[i]; | |
var convertedName = | |
this.nodeNameMap()[thisNode.localName] || thisNode.localName; | |
//EML Party modules are stored in EMLParty models | |
if (_.contains(emlParties, thisNode.localName)) { | |
if (thisNode.localName == "metadataprovider") | |
var attributeName = "metadataProvider"; | |
else if (thisNode.localName == "associatedparty") | |
var attributeName = "associatedParty"; | |
else var attributeName = thisNode.localName; | |
if (typeof modelJSON[attributeName] == "undefined") | |
modelJSON[attributeName] = []; | |
modelJSON[attributeName].push( | |
new EMLParty({ | |
objectDOM: thisNode, | |
parentModel: model, | |
type: attributeName, | |
}), | |
); | |
} | |
//EML Distribution modules are stored in EMLDistribution models | |
else if (_.contains(emlDistribution, thisNode.localName)) { | |
if (typeof modelJSON[thisNode.localName] == "undefined") | |
modelJSON[thisNode.localName] = []; | |
modelJSON[thisNode.localName].push( | |
new EMLDistribution( | |
{ | |
objectDOM: thisNode, | |
parentModel: model, | |
}, | |
{ parse: true }, | |
), | |
); | |
} | |
//The EML Project is stored in the EMLProject model | |
else if (thisNode.localName == "project") { | |
modelJSON.project = new EMLProject({ | |
objectDOM: thisNode, | |
parentModel: model, | |
}); | |
} | |
//EML Temporal, Taxonomic, and Geographic Coverage modules are stored in their own models | |
else if (thisNode.localName == "coverage") { | |
var temporal = $(thisNode).children("temporalcoverage"), | |
geo = $(thisNode).children("geographiccoverage"), | |
taxon = $(thisNode).children("taxonomiccoverage"); | |
if (temporal.length) { | |
modelJSON.temporalCoverage = []; | |
_.each(temporal, function (t) { | |
modelJSON.temporalCoverage.push( | |
new EMLTemporalCoverage({ | |
objectDOM: t, | |
parentModel: model, | |
}), | |
); | |
}); | |
} | |
if (geo.length) { | |
modelJSON.geoCoverage = []; | |
_.each(geo, function (g) { | |
modelJSON.geoCoverage.push( | |
new EMLGeoCoverage({ | |
objectDOM: g, | |
parentModel: model, | |
}), | |
); | |
}); | |
} | |
if (taxon.length) { | |
modelJSON.taxonCoverage = []; | |
_.each(taxon, function (t) { | |
modelJSON.taxonCoverage.push( | |
new EMLTaxonCoverage({ | |
objectDOM: t, | |
parentModel: model, | |
}), | |
); | |
}); | |
} | |
} | |
//Parse EMLText modules | |
else if (_.contains(emlText, thisNode.localName)) { | |
if (typeof modelJSON[convertedName] == "undefined") | |
modelJSON[convertedName] = []; | |
modelJSON[convertedName].push( | |
new EMLText({ | |
objectDOM: thisNode, | |
parentModel: model, | |
}), | |
); | |
} else if (_.contains(emlMethods, thisNode.localName)) { | |
if (typeof modelJSON[thisNode.localName] === "undefined") | |
modelJSON[thisNode.localName] = []; | |
modelJSON[thisNode.localName] = new EMLMethods({ | |
objectDOM: thisNode, | |
parentModel: model, | |
}); | |
} | |
//Parse keywords | |
else if (thisNode.localName == "keywordset") { | |
//Start an array of keyword sets | |
if (typeof modelJSON["keywordSets"] == "undefined") | |
modelJSON["keywordSets"] = []; | |
modelJSON["keywordSets"].push( | |
new EMLKeywordSet({ | |
objectDOM: thisNode, | |
parentModel: model, | |
}), | |
); | |
} | |
//Parse intellectual rights | |
else if (thisNode.localName == "intellectualrights") { | |
var value = ""; | |
if ($(thisNode).children("para").length == 1) | |
value = $(thisNode).children("para").first().text().trim(); | |
else $(thisNode).text().trim(); | |
//If the value is one of our pre-defined options, then add it to the model | |
//if(_.contains(this.get("intellRightsOptions"), value)) | |
modelJSON["intellectualRights"] = value; | |
} | |
//Parse Entities | |
else if (_.contains(emlEntities, thisNode.localName)) { | |
//Start an array of Entities | |
if (typeof modelJSON["entities"] == "undefined") | |
modelJSON["entities"] = []; | |
//Create the model | |
var entityModel; | |
if (thisNode.localName == "otherentity") { | |
entityModel = new EMLOtherEntity( | |
{ | |
objectDOM: thisNode, | |
parentModel: model, | |
}, | |
{ | |
parse: true, | |
}, | |
); | |
} else if (thisNode.localName == "datatable") { | |
entityModel = new EMLDataTable( | |
{ | |
objectDOM: thisNode, | |
parentModel: model, | |
}, | |
{ | |
parse: true, | |
}, | |
); | |
} else { | |
entityModel = new EMLEntity( | |
{ | |
objectDOM: thisNode, | |
parentModel: model, | |
entityType: "application/octet-stream", | |
type: thisNode.localName, | |
}, | |
{ | |
parse: true, | |
}, | |
); | |
} | |
modelJSON["entities"].push(entityModel); | |
} | |
//Parse dataset-level annotations | |
else if (thisNode.localName === "annotation") { | |
if (!modelJSON["annotations"]) { | |
modelJSON["annotations"] = new EMLAnnotations(); | |
} | |
var annotationModel = new EMLAnnotation( | |
{ | |
objectDOM: thisNode, | |
}, | |
{ parse: true }, | |
); | |
modelJSON["annotations"].add(annotationModel); | |
} else { | |
//Is this a multi-valued field in EML? | |
if (Array.isArray(this.get(convertedName))) { | |
//If we already have a value for this field, then add this value to the array | |
if (Array.isArray(modelJSON[convertedName])) | |
modelJSON[convertedName].push(this.toJson(thisNode)); | |
//If it's the first value for this field, then create a new array | |
else modelJSON[convertedName] = [this.toJson(thisNode)]; | |
} else modelJSON[convertedName] = this.toJson(thisNode); | |
} | |
} | |
// Once all the nodes have been parsed, check if any of the annotations | |
// make up a canonical dataset reference | |
const annotations = modelJSON["annotations"]; | |
if (annotations) { | |
const canonicalDataset = annotations.getCanonicalURI(); | |
if (canonicalDataset) { | |
modelJSON["canonicalDataset"] = canonicalDataset; | |
} | |
} | |
return modelJSON; | |
}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚫 [eslint] <prefer-arrow-callback> reported by reviewdog 🐶
Unexpected function expression.
metacatui/src/js/models/metadata/eml211/EMLAnnotation.js
Lines 1 to 185 in 43c3062
define(["jquery", "underscore", "backbone"], function ($, _, Backbone) { | |
/** | |
* @class EMLAnnotation | |
* @classdesc Stores EML SemanticAnnotation elements. | |
* @classcategory Models/Metadata/EML211 | |
* @see https://eml.ecoinformatics.org/eml-2.2.0/eml-semantics.xsd | |
* @extends Backbone.Model | |
*/ | |
var EMLAnnotation = Backbone.Model.extend( | |
/** @lends EMLAnnotation.prototype */ { | |
type: "EMLAnnotation", | |
defaults: function () { | |
return { | |
isNew: true, | |
propertyLabel: null, | |
propertyURI: null, | |
valueLabel: null, | |
valueURI: null, | |
objectDOM: null, | |
objectXML: null, | |
}; | |
}, | |
initialize: function (attributes, opions) { | |
this.stopListening(this, "change", this.trickleUpChange); | |
this.listenTo(this, "change", this.trickleUpChange); | |
}, | |
parse: function (attributes, options) { | |
// If parsing, this is an existing annotation so it's not isNew | |
attributes.isNew = false; | |
var propertyURI = $(attributes.objectDOM).find("propertyuri"); | |
var valueURI = $(attributes.objectDOM).find("valueuri"); | |
if (propertyURI.length !== 1 || valueURI.length !== 1) { | |
return; | |
} | |
attributes.propertyURI = $(propertyURI).text().trim(); | |
attributes.valueURI = $(valueURI).text().trim(); | |
var propertyLabel = $(propertyURI).attr("label"); | |
var valueLabel = $(valueURI).attr("label"); | |
if (!propertyLabel || !valueLabel) { | |
return; | |
} | |
attributes.propertyLabel = propertyLabel.trim(); | |
attributes.valueLabel = valueLabel.trim(); | |
return attributes; | |
}, | |
validate: function () { | |
var errors = []; | |
if (this.isEmpty()) { | |
this.trigger("valid"); | |
return; | |
} | |
var propertyURI = this.get("propertyURI"); | |
if (!propertyURI || propertyURI.length <= 0) { | |
errors.push({ | |
category: "propertyURI", | |
message: "Property URI must be set.", | |
}); | |
} else if (propertyURI.match(/http[s]?:\/\/.+/) === null) { | |
errors.push({ | |
category: "propertyURI", | |
message: "Property URI should be an HTTP(S) URI.", | |
}); | |
} | |
var propertyLabel = this.get("propertyLabel"); | |
if (!propertyLabel || propertyLabel.length <= 0) { | |
errors.push({ | |
category: "propertyLabel", | |
message: "Property Label must be set.", | |
}); | |
} | |
var valueURI = this.get("valueURI"); | |
if (!valueURI || valueURI.length <= 0) { | |
errors.push({ | |
category: "valueURI", | |
message: "Value URI must be set.", | |
}); | |
} else if (valueURI.match(/http[s]?:\/\/.+/) === null) { | |
errors.push({ | |
category: "valueURI", | |
message: "Value URI should be an HTTP(S) URI.", | |
}); | |
} | |
var valueLabel = this.get("valueLabel"); | |
if (!valueLabel || valueLabel.length <= 0) { | |
errors.push({ | |
category: "valueLabel", | |
message: "Value Label must be set.", | |
}); | |
} | |
if (errors.length === 0) { | |
this.trigger("valid"); | |
return; | |
} | |
return errors; | |
}, | |
updateDOM: function (objectDOM) { | |
objectDOM = document.createElement("annotation"); | |
if (this.get("propertyURI")) { | |
var propertyURIEl = document.createElement("propertyuri"); | |
$(propertyURIEl).html(this.get("propertyURI")); | |
if (this.get("propertyLabel")) { | |
$(propertyURIEl).attr("label", this.get("propertyLabel")); | |
} | |
$(objectDOM).append(propertyURIEl); | |
} | |
if (this.get("valueURI")) { | |
var valueURIEl = document.createElement("valueuri"); | |
$(valueURIEl).html(this.get("valueURI")); | |
if (this.get("valueLabel")) { | |
$(valueURIEl).attr("label", this.get("valueLabel")); | |
} | |
$(objectDOM).append(valueURIEl); | |
} | |
return objectDOM; | |
}, | |
formatXML: function (xmlString) { | |
return DataONEObject.prototype.formatXML.call(this, xmlString); | |
}, | |
/** | |
* isEmpty | |
* | |
* Check whether the model's properties are all empty for the purpose | |
* of skipping the model during serialization to avoid invalid EML | |
* documents. | |
* | |
* @return {boolean} - Returns true if all child elements have no | |
* content | |
*/ | |
isEmpty: function () { | |
return ( | |
(typeof this.get("propertyLabel") !== "string" || | |
this.get("propertyLabel").length <= 0) && | |
(typeof this.get("propertyURI") !== "string" || | |
this.get("propertyURI").length <= 0) && | |
(typeof this.get("valueLabel") !== "string" || | |
this.get("valueLabel").length <= 0) && | |
(typeof this.get("valueURI") !== "string" || | |
this.get("valueURI").length <= 0) | |
); | |
}, | |
/* Let the top level package know of attribute changes from this object */ | |
trickleUpChange: function () { | |
MetacatUI.rootDataPackage.packageModel?.set("changed", true); | |
}, | |
}, | |
); | |
return EMLAnnotation; | |
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚫 [eslint] <no-var> reported by reviewdog 🐶
Unexpected var, use let or const instead.
metacatui/src/js/models/metadata/eml211/EMLAnnotation.js
Lines 9 to 182 in 43c3062
var EMLAnnotation = Backbone.Model.extend( | |
/** @lends EMLAnnotation.prototype */ { | |
type: "EMLAnnotation", | |
defaults: function () { | |
return { | |
isNew: true, | |
propertyLabel: null, | |
propertyURI: null, | |
valueLabel: null, | |
valueURI: null, | |
objectDOM: null, | |
objectXML: null, | |
}; | |
}, | |
initialize: function (attributes, opions) { | |
this.stopListening(this, "change", this.trickleUpChange); | |
this.listenTo(this, "change", this.trickleUpChange); | |
}, | |
parse: function (attributes, options) { | |
// If parsing, this is an existing annotation so it's not isNew | |
attributes.isNew = false; | |
var propertyURI = $(attributes.objectDOM).find("propertyuri"); | |
var valueURI = $(attributes.objectDOM).find("valueuri"); | |
if (propertyURI.length !== 1 || valueURI.length !== 1) { | |
return; | |
} | |
attributes.propertyURI = $(propertyURI).text().trim(); | |
attributes.valueURI = $(valueURI).text().trim(); | |
var propertyLabel = $(propertyURI).attr("label"); | |
var valueLabel = $(valueURI).attr("label"); | |
if (!propertyLabel || !valueLabel) { | |
return; | |
} | |
attributes.propertyLabel = propertyLabel.trim(); | |
attributes.valueLabel = valueLabel.trim(); | |
return attributes; | |
}, | |
validate: function () { | |
var errors = []; | |
if (this.isEmpty()) { | |
this.trigger("valid"); | |
return; | |
} | |
var propertyURI = this.get("propertyURI"); | |
if (!propertyURI || propertyURI.length <= 0) { | |
errors.push({ | |
category: "propertyURI", | |
message: "Property URI must be set.", | |
}); | |
} else if (propertyURI.match(/http[s]?:\/\/.+/) === null) { | |
errors.push({ | |
category: "propertyURI", | |
message: "Property URI should be an HTTP(S) URI.", | |
}); | |
} | |
var propertyLabel = this.get("propertyLabel"); | |
if (!propertyLabel || propertyLabel.length <= 0) { | |
errors.push({ | |
category: "propertyLabel", | |
message: "Property Label must be set.", | |
}); | |
} | |
var valueURI = this.get("valueURI"); | |
if (!valueURI || valueURI.length <= 0) { | |
errors.push({ | |
category: "valueURI", | |
message: "Value URI must be set.", | |
}); | |
} else if (valueURI.match(/http[s]?:\/\/.+/) === null) { | |
errors.push({ | |
category: "valueURI", | |
message: "Value URI should be an HTTP(S) URI.", | |
}); | |
} | |
var valueLabel = this.get("valueLabel"); | |
if (!valueLabel || valueLabel.length <= 0) { | |
errors.push({ | |
category: "valueLabel", | |
message: "Value Label must be set.", | |
}); | |
} | |
if (errors.length === 0) { | |
this.trigger("valid"); | |
return; | |
} | |
return errors; | |
}, | |
updateDOM: function (objectDOM) { | |
objectDOM = document.createElement("annotation"); | |
if (this.get("propertyURI")) { | |
var propertyURIEl = document.createElement("propertyuri"); | |
$(propertyURIEl).html(this.get("propertyURI")); | |
if (this.get("propertyLabel")) { | |
$(propertyURIEl).attr("label", this.get("propertyLabel")); | |
} | |
$(objectDOM).append(propertyURIEl); | |
} | |
if (this.get("valueURI")) { | |
var valueURIEl = document.createElement("valueuri"); | |
$(valueURIEl).html(this.get("valueURI")); | |
if (this.get("valueLabel")) { | |
$(valueURIEl).attr("label", this.get("valueLabel")); | |
} | |
$(objectDOM).append(valueURIEl); | |
} | |
return objectDOM; | |
}, | |
formatXML: function (xmlString) { | |
return DataONEObject.prototype.formatXML.call(this, xmlString); | |
}, | |
/** | |
* isEmpty | |
* | |
* Check whether the model's properties are all empty for the purpose | |
* of skipping the model during serialization to avoid invalid EML | |
* documents. | |
* | |
* @return {boolean} - Returns true if all child elements have no | |
* content | |
*/ | |
isEmpty: function () { | |
return ( | |
(typeof this.get("propertyLabel") !== "string" || | |
this.get("propertyLabel").length <= 0) && | |
(typeof this.get("propertyURI") !== "string" || | |
this.get("propertyURI").length <= 0) && | |
(typeof this.get("valueLabel") !== "string" || | |
this.get("valueLabel").length <= 0) && | |
(typeof this.get("valueURI") !== "string" || | |
this.get("valueURI").length <= 0) | |
); | |
}, | |
/* Let the top level package know of attribute changes from this object */ | |
trickleUpChange: function () { | |
MetacatUI.rootDataPackage.packageModel?.set("changed", true); | |
}, | |
}, | |
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚫 [eslint] <object-shorthand> reported by reviewdog 🐶
Expected method shorthand.
metacatui/src/js/models/metadata/eml211/EMLAnnotation.js
Lines 13 to 23 in 43c3062
defaults: function () { | |
return { | |
isNew: true, | |
propertyLabel: null, | |
propertyURI: null, | |
valueLabel: null, | |
valueURI: null, | |
objectDOM: null, | |
objectXML: null, | |
}; | |
}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚫 [eslint] <object-shorthand> reported by reviewdog 🐶
Expected method shorthand.
metacatui/src/js/models/metadata/eml211/EMLAnnotation.js
Lines 30 to 56 in 43c3062
parse: function (attributes, options) { | |
// If parsing, this is an existing annotation so it's not isNew | |
attributes.isNew = false; | |
var propertyURI = $(attributes.objectDOM).find("propertyuri"); | |
var valueURI = $(attributes.objectDOM).find("valueuri"); | |
if (propertyURI.length !== 1 || valueURI.length !== 1) { | |
return; | |
} | |
attributes.propertyURI = $(propertyURI).text().trim(); | |
attributes.valueURI = $(valueURI).text().trim(); | |
var propertyLabel = $(propertyURI).attr("label"); | |
var valueLabel = $(valueURI).attr("label"); | |
if (!propertyLabel || !valueLabel) { | |
return; | |
} | |
attributes.propertyLabel = propertyLabel.trim(); | |
attributes.valueLabel = valueLabel.trim(); | |
return attributes; | |
}, |
Left to do:
|
43c3062
to
4df698d
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remaining comments which cannot be posted as a review comment to avoid GitHub Rate Limit
eslint
🚫 [eslint] <object-shorthand> reported by reviewdog 🐶
Expected method shorthand.
metacatui/src/js/views/metadata/EML211View.js
Lines 2050 to 2085 in 4df698d
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", | |
), | |
); | |
}, |
🚫 [eslint] <one-var> reported by reviewdog 🐶
Split 'var' declarations into multiple statements.
metacatui/src/js/views/metadata/EML211View.js
Lines 2051 to 2054 in 4df698d
var category = $(e.target).attr("data-category"), | |
allBasicTexts = $( | |
".basic-text.new[data-category='" + category + "']", | |
); |
🚫 [eslint] <no-var> reported by reviewdog 🐶
Unexpected var, use let or const instead.
metacatui/src/js/views/metadata/EML211View.js
Lines 2051 to 2054 in 4df698d
var category = $(e.target).attr("data-category"), | |
allBasicTexts = $( | |
".basic-text.new[data-category='" + category + "']", | |
); |
🚫 [eslint] <vars-on-top> reported by reviewdog 🐶
All 'var' declarations must be at the top of the function scope.
metacatui/src/js/views/metadata/EML211View.js
Lines 2063 to 2065 in 4df698d
var newRow = $(document.createElement("div")).addClass( | |
"basic-text-row", | |
); |
🚫 [eslint] <no-var> reported by reviewdog 🐶
Unexpected var, use let or const instead.
metacatui/src/js/views/metadata/EML211View.js
Lines 2063 to 2065 in 4df698d
var newRow = $(document.createElement("div")).addClass( | |
"basic-text-row", | |
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚫 [eslint] <prefer-arrow-callback> reported by reviewdog 🐶
Unexpected function expression.
metacatui/src/js/views/metadata/EML211View.js
Lines 29 to 3151 in 4df698d
], function ( | |
_, | |
$, | |
Backbone, | |
ScienceMetadataView, | |
EMLGeoCoverageView, | |
EMLPartyView, | |
EMLMethodsView, | |
EMLTempCoverageView, | |
EML, | |
EMLGeoCoverage, | |
EMLKeywordSet, | |
EMLParty, | |
EMLProject, | |
EMLText, | |
EMLTaxonCoverage, | |
EMLTemporalCoverage, | |
EMLMethods, | |
Template, | |
PeopleTemplate, | |
EMLPartyCopyMenuTemplate, | |
OverviewTemplate, | |
DatesTemplate, | |
LocationsTemplate, | |
TaxonomicCoverageTemplate, | |
TaxonomicClassificationTable, | |
TaxonomicClassificationRow, | |
DataSensitivityTemplate, | |
) { | |
/** | |
* @class EMLView | |
* @classdesc An EMLView renders an editable view of an EML 2.1.1 document | |
* @classcategory Views/Metadata | |
* @extends ScienceMetadataView | |
*/ | |
var 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 .taxonomic-coverage": "updateTaxonCoverage", | |
"keyup .taxonomic-coverage .new input": "addNewTaxon", | |
"keyup .taxonomic-coverage .new select": "addNewTaxon", | |
"focusout .taxonomic-coverage tr": "showTaxonValidation", | |
"click .taxonomic-coverage-row .remove": "removeTaxonRank", | |
"mouseover .taxonomic-coverage .remove": "previewTaxonRemove", | |
"mouseout .taxonomic-coverage .remove": "previewTaxonRemove", | |
"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), | |
taxonomicCoverageTemplate: _.template(TaxonomicCoverageTemplate), | |
taxonomicClassificationTableTemplate: _.template( | |
TaxonomicClassificationTable, | |
), | |
taxonomicClassificationRowTemplate: _.template( | |
TaxonomicClassificationRow, | |
), | |
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); | |
//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 view = this; | |
const taxaSectionEl = this.$(".section.taxa"); | |
if (!taxaSectionEl) return; | |
taxaSectionEl.html($(document.createElement("h2")).text("Taxa")); | |
var taxonomy = this.model.get("taxonCoverage"); | |
// Render a set of tables for each taxonomicCoverage | |
if ( | |
typeof taxonomy !== "undefined" && | |
Array.isArray(taxonomy) && | |
taxonomy.length | |
) { | |
for (var i = 0; i < taxonomy.length; i++) { | |
taxaSectionEl.append(this.createTaxonomicCoverage(taxonomy[i])); | |
} | |
} else { | |
// Create a new one | |
var taxonCov = new EMLTaxonCoverage({ | |
parentModel: this.model, | |
}); | |
this.model.set("taxonCoverage", [taxonCov], { silent: true }); | |
taxaSectionEl.append(this.createTaxonomicCoverage(taxonCov)); | |
} | |
// updating the indexes of taxa-tables before rendering the information on page(view). | |
var taxaNums = this.$(".editor-header-index"); | |
for (var i = 0; i < taxaNums.length; i++) { | |
$(taxaNums[i]).text(i + 1); | |
} | |
// Insert the quick-add taxon options, if any are configured for this | |
// theme. See {@link AppModel#quickAddTaxa} | |
view.renderTaxaQuickAdd(); | |
// If duplicates are removed while saving, make sure to re-render the taxa | |
view.model.get("taxonCoverage").forEach(function (taxonCov) { | |
view.model.stopListening(taxonCov); | |
view.model.listenTo( | |
taxonCov, | |
"duplicateClassificationsRemoved", | |
function () { | |
view.renderTaxa(); | |
}, | |
); | |
}, view); | |
}, | |
/* | |
* 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"); | |
} | |
}, | |
// Creates a table to hold a single EMLTaxonCoverage element (table) for | |
// each root-level taxonomicClassification | |
createTaxonomicCoverage: function (coverage) { | |
var finishedEls = $( | |
this.taxonomicCoverageTemplate({ | |
generalTaxonomicCoverage: | |
coverage.get("generalTaxonomicCoverage") || "", | |
}), | |
), | |
coverageEl = finishedEls.filter(".taxonomic-coverage"); | |
coverageEl.data({ model: coverage }); | |
var classifications = coverage.get("taxonomicClassification"); | |
// Makes a table... for the root level | |
for (var i = 0; i < classifications.length; i++) { | |
coverageEl.append( | |
this.createTaxonomicClassificationTable(classifications[i]), | |
); | |
} | |
// Create a new, blank table for another taxonomicClassification | |
var newTableEl = this.createTaxonomicClassificationTable(); | |
coverageEl.append(newTableEl); | |
return finishedEls; | |
}, | |
createTaxonomicClassificationTable: function (classification) { | |
// updating the taxonomic table indexes before adding a new table to the page. | |
var taxaNums = this.$(".editor-header-index"); | |
for (var i = 0; i < taxaNums.length; i++) { | |
$(taxaNums[i]).text(i + 1); | |
} | |
// Adding the taxoSpeciesCounter to the table header for enhancement of the view | |
var finishedEl = $( | |
'<div class="row-striped root-taxonomic-classification-container"></div>', | |
); | |
$(finishedEl).append( | |
'<h6>Species <span class="editor-header-index">' + | |
(taxaNums.length + 1) + | |
"</span> </h6>", | |
); | |
// Add a remove button if this is not a new table | |
if (!(typeof classification === "undefined")) { | |
$(finishedEl).append( | |
this.createRemoveButton( | |
"taxonCoverage", | |
"taxonomicClassification", | |
".root-taxonomic-classification-container", | |
".taxonomic-coverage", | |
), | |
); | |
} | |
var tableEl = $(this.taxonomicClassificationTableTemplate()); | |
var tableBodyEl = $(document.createElement("tbody")); | |
var queue = [classification], | |
rows = [], | |
cur; | |
while (queue.length > 0) { | |
cur = queue.pop(); | |
// I threw this in here so I can this function without an | |
// argument to generate a new table from scratch | |
if (typeof cur === "undefined") { | |
continue; | |
} | |
cur.taxonRankName = cur.taxonRankName?.toLowerCase(); | |
rows.push(cur); | |
if (cur.taxonomicClassification) { | |
for (var i = 0; i < cur.taxonomicClassification.length; i++) { | |
queue.push(cur.taxonomicClassification[i]); | |
} | |
} | |
} | |
for (var j = 0; j < rows.length; j++) { | |
tableBodyEl.append(this.makeTaxonomicClassificationRow(rows[j])); | |
} | |
var newRowEl = this.makeNewTaxonomicClassificationRow(); | |
$(tableBodyEl).append(newRowEl); | |
$(tableEl).append(tableBodyEl); | |
// Add the new class to the entire table if it's a new one | |
if (typeof classification === "undefined") { | |
$(tableEl).addClass("new"); | |
} | |
$(finishedEl).append(tableEl); | |
return finishedEl; | |
}, | |
/** | |
* Create the HTML for a single row in a taxonomicClassification table | |
* @param {EMLTaxonCoverage#taxonomicClassification} classification A | |
* classification object from an EMLTaxonCoverage model, may include | |
* a taxonRank, taxonValue, taxonId, commonName, and nested | |
* taxonomicClassification objects | |
* @returns {jQuery} A jQuery object containing the HTML for a single | |
* row in a taxonomicClassification table | |
* @since 2.24.0 | |
*/ | |
makeTaxonomicClassificationRow: function (classification) { | |
try { | |
if (!classification) classification = {}; | |
var finishedEl = $( | |
this.taxonomicClassificationRowTemplate({ | |
taxonRankName: classification.taxonRankName || "", | |
taxonRankValue: classification.taxonRankValue || "", | |
}), | |
); | |
// Save a reference to other taxon attributes that we need to keep | |
// when serializing the model | |
if (classification.taxonId) { | |
$(finishedEl).data("taxonId", classification.taxonId); | |
} | |
if (classification.commonName) { | |
$(finishedEl).data("commonName", classification.commonName); | |
} | |
return finishedEl; | |
} catch (e) { | |
console.log("Error making taxonomic classification row: ", e); | |
} | |
}, | |
/** | |
* Create the HTML for a new row in a taxonomicClassification table | |
* @returns {jQuery} A jQuery object containing the HTML for a new row | |
* in a taxonomicClassification table | |
* @since 2.24.0 | |
*/ | |
makeNewTaxonomicClassificationRow: function () { | |
const row = this.makeTaxonomicClassificationRow({}); | |
$(row).addClass("new"); | |
return row; | |
}, | |
/* Update the underlying model and DOM for an EML TaxonomicCoverage | |
section. This method handles updating the underlying TaxonomicCoverage | |
models when the user changes form fields as well as inserting new | |
form fields automatically when the user needs them. | |
Since a dataset has multiple TaxonomicCoverage elements at the dataset | |
level, each Taxonomic Coverage is represented by a table element and | |
all taxonomicClassifications within are rows in that table. | |
TODO: Finish this function | |
TODO: Link this function into the DOM | |
*/ | |
updateTaxonCoverage: function (options) { | |
if (options.target) { | |
// Ignore the event if the target is a quick add taxon UI element. | |
const quickAddEl = $(this.taxonQuickAddEl); | |
if (quickAddEl && quickAddEl.has(options.target).length) { | |
return; | |
} | |
var e = options; | |
/* Getting `model` here is different than in other places because | |
the thing being updated is an `input` or `select` element which | |
is part of a `taxonomicClassification`. The model is | |
`TaxonCoverage` which has one or more | |
`taxonomicClassifications`. So we have to walk up to the | |
hierarchy from input < td < tr < tbody < table < div to get at | |
the underlying TaxonCoverage model. | |
*/ | |
var coverage = $(e.target).parents(".taxonomic-coverage"), | |
classificationEl = $(e.target).parents( | |
".root-taxonomic-classification", | |
), | |
model = $(coverage).data("model") || this.model, | |
category = $(e.target).attr("data-category"), | |
value = this.model.cleanXMLText($(e.target).val()); | |
//We can't update anything without a coverage, or | |
//classification | |
if (!coverage) return false; | |
if (!classificationEl) return false; | |
// Use `category` to determine if we're updating the generalTaxonomicCoverage or | |
// the taxonomicClassification | |
if (category && category === "generalTaxonomicCoverage") { | |
model.set("generalTaxonomicCoverage", value); | |
return; | |
} | |
} else { | |
var coverage = options.coverage, | |
model = $(coverage).data("model"); | |
} | |
// Find all of the root-level taxonomicClassifications | |
var classificationTables = $(coverage).find( | |
".root-taxonomic-classification", | |
); | |
if (!classificationTables) return false; | |
//TODO :This should probably (at least) be in its own View and | |
//definitely refactored into tidy functions.*/ | |
var rows, | |
collectedClassifications = []; | |
for (var i = 0; i < classificationTables.length; i++) { | |
rows = $(classificationTables[i]).find("tbody tr"); | |
if (!rows) continue; | |
var topLevelClassification = {}, | |
classification = topLevelClassification, | |
currentRank, | |
currentValue; | |
for (var j = 0; j < rows.length; j++) { | |
const thisRow = rows[j]; | |
currentRank = | |
this.model.cleanXMLText($(thisRow).find("select").val()) || ""; | |
currentValue = | |
this.model.cleanXMLText($(thisRow).find("input").val()) || ""; | |
// Maintain classification attributes that exist in the EML but are not visible in the editor | |
const taxonId = $(thisRow).data("taxonId"); | |
const commonName = $(thisRow).data("commonName"); | |
// Skip over rows with empty Rank or Value | |
if (!currentRank.length || !currentValue.length) { | |
continue; | |
} | |
//After the first row, start nesting taxonomicClassification objects | |
if (j > 0) { | |
classification.taxonomicClassification = [{}]; | |
classification = classification.taxonomicClassification[0]; | |
} | |
// Add it to the classification object | |
classification.taxonRankName = currentRank; | |
classification.taxonRankValue = currentValue; | |
classification.taxonId = taxonId; | |
classification.commonName = commonName; | |
} | |
//Add the top level classification to the array | |
if (Object.keys(topLevelClassification).length) | |
collectedClassifications.push(topLevelClassification); | |
} | |
if ( | |
!_.isEqual( | |
collectedClassifications, | |
model.get("taxonomicClassification"), | |
) | |
) { | |
model.set("taxonomicClassification", collectedClassifications); | |
this.model.trigger("change"); | |
} | |
// Handle adding new tables and rows | |
// Do nothing if the value isn't set | |
if (value) { | |
// Add a new row if this is itself a new row | |
if ($(e.target).parents("tr").first().is(".new")) { | |
var newRowEl = this.makeNewTaxonomicClassificationRow(); | |
$(e.target).parents("tbody").first().append(newRowEl); | |
$(e.target).parents("tr").first().removeClass("new"); | |
} | |
// Add a new classification table if this is itself a new table | |
if ($(classificationEl).is(".new")) { | |
$(classificationEl).removeClass("new"); | |
$(classificationEl).append( | |
this.createRemoveButton( | |
"taxonCoverage", | |
"taxonomicClassification", | |
".root-taxonomic-classification-container", | |
".taxonomic-coverage", | |
), | |
); | |
$(coverage).append(this.createTaxonomicClassificationTable()); | |
} | |
} | |
// update the quick add interface | |
this.updateQuickAddTaxa(); | |
}, | |
/** | |
* Update the options for the quick add taxon select interface. This | |
* ensures that only taxonomic classifications that are not already | |
* included in the taxonomic coverage are available for selection. | |
* @since 2.24.0 | |
*/ | |
updateQuickAddTaxa: function () { | |
const selects = this.taxonSelects; | |
if (!selects || !selects.length) return; | |
const taxa = this.getTaxonQuickAddOptions(); | |
if (!taxa || !taxa.length) return; | |
selects.forEach((select, i) => { | |
select.updateOptions(taxa[i].options); | |
}); | |
}, | |
/* | |
* Adds a new row and/or table to the taxonomic coverage section | |
*/ | |
addNewTaxon: function (e) { | |
// Don't do anything if the current classification doesn't have new content | |
if ($(e.target).val().trim() === "") return; | |
// If the row is new, add a new row to the table | |
if ($(e.target).parents("tr").is(".new")) { | |
var newRow = this.makeNewTaxonomicClassificationRow(); | |
//Append the new row and remove the new class from the old row | |
$(e.target).parents("tr").removeClass("new").after(newRow); | |
} | |
}, | |
/** | |
* Insert the "quick add" interface for adding common taxa to the | |
* taxonomic coverage section. Only renders if there is a list of taxa | |
* configured in the appModel. | |
*/ | |
renderTaxaQuickAdd: function () { | |
try { | |
const view = this; | |
// To render the taxon select, the view must be in editor mode and we | |
// need a list of taxa configured for the theme | |
if (!view.edit) return; | |
// remove any existing quick add interface: | |
if (view.taxonQuickAddEl) view.taxonQuickAddEl.remove(); | |
const quickAddTaxa = view.getTaxonQuickAddOptions(); | |
if (!quickAddTaxa || !quickAddTaxa.length) { | |
// If the taxa are configured as SID for a dataObject, then wait | |
// for the dataObject to be loaded | |
this.listenToOnce( | |
MetacatUI.appModel, | |
"change:quickAddTaxa", | |
this.renderTaxaQuickAdd, | |
); | |
return; | |
} | |
// Create & insert the basic HTML for the taxon select interface | |
const template = `<div class="taxa-quick-add"> | |
<p class="taxa-quick-add__text"> | |
<b>⭐️ Quick Add Taxa:</b> Select one or more common taxa. Click "Add" to add them to the list. | |
</p> | |
<div class="taxa-quick-add__controls"> | |
<div class="taxa-quick-add__selects"></div> | |
<button class="btn btn-primary taxa-quick-add__button">Add Taxa</button> | |
</div> | |
</div>`; | |
const parser = new DOMParser(); | |
const doc = parser.parseFromString(template, "text/html"); | |
const quickAddEl = doc.body.firstChild; | |
const button = quickAddEl.querySelector("button"); | |
const container = quickAddEl.querySelector( | |
".taxa-quick-add__selects", | |
); | |
const rowSelector = ".root-taxonomic-classification-container"; | |
const firstRow = document.querySelector(rowSelector); | |
firstRow.parentNode.insertBefore(quickAddEl, firstRow); | |
view.taxonQuickAddEl = quickAddEl; | |
// Update the taxon coverage when the button is clicked | |
const onButtonClick = () => { | |
const taxonSelects = view.taxonSelects; | |
if (!taxonSelects || !taxonSelects.length) return; | |
const selectedItems = taxonSelects | |
.map((select) => select.model.get("selected")) | |
.flat(); | |
if (!selectedItems || !selectedItems.length) return; | |
const selectedItemObjs = selectedItems.map((item) => { | |
try { | |
// It will be encoded JSON if it's a pre-defined taxon | |
return JSON.parse(decodeURIComponent(item)); | |
} catch (e) { | |
// Otherwise it will be a string a user typed in | |
return { | |
taxonRankName: "", | |
taxonRankValue: item, | |
}; | |
} | |
}); | |
view.addTaxa(selectedItemObjs); | |
taxonSelects.forEach((select) => | |
select.model.setSelected([], { silent: true }), | |
); | |
}; | |
button.removeEventListener("click", onButtonClick); | |
button.addEventListener("click", onButtonClick); | |
// Create the search selects | |
view.taxonSelects = []; | |
const componentPath = "views/searchSelect/SearchSelectView"; | |
require([componentPath], function (SearchSelect) { | |
quickAddTaxa.forEach((taxaList, i) => { | |
try { | |
const taxaInput = new SearchSelect({ | |
options: taxaList.options, | |
placeholderText: taxaList.placeholder, | |
inputLabel: taxaList.label, | |
allowMulti: true, | |
allowAdditions: true, | |
separatorTextOptions: false, | |
selected: [], | |
}); | |
container.appendChild(taxaInput.el); | |
taxaInput.render(); | |
view.taxonSelects.push(taxaInput); | |
} catch (e) { | |
console.log("Failed to create taxon select: ", e); | |
} | |
}); | |
}); | |
} catch (e) { | |
console.log("Failed to render taxon select: ", e); | |
} | |
}, | |
/** | |
* Get the list of options for the taxon quick add interface. Filter | |
* out any that have already been added to the taxonomic coverage. | |
* @returns {Object[]} An array of search select options | |
* @since 2.24.0 | |
*/ | |
getTaxonQuickAddOptions: function () { | |
const quickAddTaxa = MetacatUI.appModel.getQuickAddTaxa(); | |
if (!quickAddTaxa || !quickAddTaxa.length) return; | |
const coverages = this.model.get("taxonCoverage"); | |
for (const taxaList of quickAddTaxa) { | |
const opts = []; | |
for (const taxon of taxaList.taxa) { | |
// check that it is not a duplicate in any coverages | |
let isDuplicate = false; | |
for (cov of coverages) { | |
if (cov.isDuplicate(taxon)) { | |
isDuplicate = true; | |
break; | |
} | |
} | |
if (!isDuplicate) { | |
opts.push(this.taxonOptionToSearchSelectItem(taxon)); | |
} | |
} | |
taxaList.options = opts; | |
} | |
return quickAddTaxa; | |
}, | |
/** | |
* Reformats a taxon option, as provided in the appModel | |
* {@link AppModel#quickAddTaxa}, as a search select item. | |
* @param {Object} option A single taxon classification with at least a | |
* taxonRankValue and taxonRankName. It may also have a taxonId (object | |
* with provider and value) and a commonName. | |
* @returns {Object} A search select item with label, value, and | |
* description properties. | |
*/ | |
taxonOptionToSearchSelectItem: function (option) { | |
try { | |
// option must have a taxonRankValue and taxonRankName or it is invalid | |
if (!option.taxonRankValue || !option.taxonRankName) { | |
console.log("Invalid taxon option: ", option); | |
return null; | |
} | |
// Create a description | |
let description = option.taxonRankName + ": " + option.taxonRankValue; | |
if (option.taxonId) { | |
description += | |
" (" + | |
option.taxonId.provider + | |
": " + | |
option.taxonId.value + | |
")"; | |
} | |
// search select doesn't work with some of the json characters | |
const val = encodeURIComponent(JSON.stringify(option)); | |
return { | |
label: option.commonName || option.taxonRankValue, | |
value: val, | |
description: description, | |
}; | |
} catch (e) { | |
console.log( | |
"Failed to reformat taxon option as search select item: ", | |
e, | |
); | |
return null; | |
} | |
}, | |
/** | |
* Add new taxa to the EML model and re-render the taxa section. The new | |
* taxa will be added to the first <taxonomicCoverage> element in the EML | |
* model. If there is no <taxonomicCoverage> element, one will be created. | |
* @param {Object[]} newClassifications - An array of objects with any of | |
* the following properties: | |
* - taxonRankName: (sting) The name of the taxonomic rank, e.g. | |
* "Kingdom" | |
* - taxonRankValue: (string) The value of the taxonomic rank, e.g. | |
* "Animalia" | |
* - commonName: (string) The common name of the taxon, e.g. "Animals" | |
* - taxonId: (object) The official ID of the taxon, including "provider" | |
* and "value". | |
* - taxonomicClassification: (array) An array of nested taxonomic | |
* classifications | |
* @since 2.24.0 | |
* @example | |
* this.addTaxon([{ | |
* taxonRankName: "Kingdom", | |
* taxonRankValue: "Animalia", | |
* commonName: "Animals", | |
* taxonId: { | |
* provider: "https://www.itis.gov/", | |
* value: "202423" | |
* }]); | |
*/ | |
addTaxa: function (newClassifications) { | |
try { | |
// TODO: validate the new taxon before adding it to the model? | |
const taxonCoverages = this.model.get("taxonCoverage"); | |
// We expect that there is already a taxonCoverage array on the model. | |
// If the EML was made in the editor, there can only be one | |
// <taxonomicCoverage> element. Add the new taxon to its | |
// <taxonomicClassification> array. If there is more than one, then the | |
// new taxon will be added to the first <taxonomicCoverage> element. | |
if (taxonCoverages && taxonCoverages.length >= 1) { | |
const taxonCoverage = taxonCoverages[0]; | |
const classifications = taxonCoverage.get( | |
"taxonomicClassification", | |
); | |
const allClass = classifications.concat(newClassifications); | |
taxonCoverage.set("taxonomicClassification", allClass); | |
} else { | |
// If there is no <taxonomicCoverage> element for some reason, | |
// create one and add the new taxon to its <taxonomicClassification> | |
// array. | |
const newCov = new EMLTaxonCoverage({ | |
taxonomicClassification: newClassifications, | |
parentModel: this.model, | |
}); | |
this.model.set("taxonCoverage", [newCov]); | |
} | |
// Re-render the taxa section | |
this.renderTaxa(); | |
} catch (e) { | |
console.log("Error adding taxon to EML model: ", e); | |
} | |
}, | |
removeTaxonRank: function (e) { | |
var row = $(e.target).parents(".taxonomic-coverage-row"), | |
coverageEl = $(row).parents(".taxonomic-coverage"), | |
view = this; | |
//Animate the row away and then remove it | |
row.slideUp("fast", function () { | |
row.remove(); | |
view.updateTaxonCoverage({ coverage: coverageEl }); | |
}); | |
}, | |
/* | |
* After the user focuses out, show validation help, if needed | |
*/ | |
showTaxonValidation: function (e) { | |
//Get the text inputs and select menus | |
var row = $(e.target).parents("tr"), | |
allInputs = row.find("input, select"), | |
tableContainer = $(e.target).parents("table"), | |
errorInputs = []; | |
//If none of the inputs have a value and this is a new row, then do nothing | |
if ( | |
_.every(allInputs, function (i) { | |
return !i.value; | |
}) && | |
row.is(".new") | |
) | |
return; | |
//Add the error styling to any input with no value | |
_.each(allInputs, function (input) { | |
// Keep track of the number of clicks of each input element so we only show the | |
// error message after the user has focused on both input elements | |
if (!input.value) errorInputs.push(input); | |
}); | |
if (errorInputs.length) { | |
//Show the error message after a brief delay | |
setTimeout(function () { | |
//If the user focused on another element in the same row, don't do anything | |
if (_.contains(allInputs, document.activeElement)) return; | |
//Add the error styling | |
$(errorInputs).addClass("error"); | |
//Add the error message | |
if (!tableContainer.prev(".notification").length) { | |
tableContainer.before( | |
$(document.createElement("p")) | |
.addClass("error notification") | |
.text("Enter a rank name AND value in each row."), | |
); | |
} | |
}, 200); | |
} else { | |
allInputs.removeClass("error"); | |
if (!tableContainer.find(".error").length) | |
tableContainer.prev(".notification").remove(); | |
} | |
}, | |
previewTaxonRemove: function (e) { | |
var removeBtn = $(e.target); | |
if (removeBtn.parent().is(".root-taxonomic-classification")) { | |
removeBtn.parent().toggleClass("remove-preview"); | |
} else { | |
removeBtn | |
.parents(".taxonomic-coverage-row") | |
.toggleClass("remove-preview"); | |
} | |
}, | |
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.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; | |
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚫 [eslint] <no-var> reported by reviewdog 🐶
Unexpected var, use let or const instead.
metacatui/src/js/views/metadata/EML211View.js
Lines 64 to 3149 in 4df698d
var 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 .taxonomic-coverage": "updateTaxonCoverage", | |
"keyup .taxonomic-coverage .new input": "addNewTaxon", | |
"keyup .taxonomic-coverage .new select": "addNewTaxon", | |
"focusout .taxonomic-coverage tr": "showTaxonValidation", | |
"click .taxonomic-coverage-row .remove": "removeTaxonRank", | |
"mouseover .taxonomic-coverage .remove": "previewTaxonRemove", | |
"mouseout .taxonomic-coverage .remove": "previewTaxonRemove", | |
"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), | |
taxonomicCoverageTemplate: _.template(TaxonomicCoverageTemplate), | |
taxonomicClassificationTableTemplate: _.template( | |
TaxonomicClassificationTable, | |
), | |
taxonomicClassificationRowTemplate: _.template( | |
TaxonomicClassificationRow, | |
), | |
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); | |
//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 view = this; | |
const taxaSectionEl = this.$(".section.taxa"); | |
if (!taxaSectionEl) return; | |
taxaSectionEl.html($(document.createElement("h2")).text("Taxa")); | |
var taxonomy = this.model.get("taxonCoverage"); | |
// Render a set of tables for each taxonomicCoverage | |
if ( | |
typeof taxonomy !== "undefined" && | |
Array.isArray(taxonomy) && | |
taxonomy.length | |
) { | |
for (var i = 0; i < taxonomy.length; i++) { | |
taxaSectionEl.append(this.createTaxonomicCoverage(taxonomy[i])); | |
} | |
} else { | |
// Create a new one | |
var taxonCov = new EMLTaxonCoverage({ | |
parentModel: this.model, | |
}); | |
this.model.set("taxonCoverage", [taxonCov], { silent: true }); | |
taxaSectionEl.append(this.createTaxonomicCoverage(taxonCov)); | |
} | |
// updating the indexes of taxa-tables before rendering the information on page(view). | |
var taxaNums = this.$(".editor-header-index"); | |
for (var i = 0; i < taxaNums.length; i++) { | |
$(taxaNums[i]).text(i + 1); | |
} | |
// Insert the quick-add taxon options, if any are configured for this | |
// theme. See {@link AppModel#quickAddTaxa} | |
view.renderTaxaQuickAdd(); | |
// If duplicates are removed while saving, make sure to re-render the taxa | |
view.model.get("taxonCoverage").forEach(function (taxonCov) { | |
view.model.stopListening(taxonCov); | |
view.model.listenTo( | |
taxonCov, | |
"duplicateClassificationsRemoved", | |
function () { | |
view.renderTaxa(); | |
}, | |
); | |
}, view); | |
}, | |
/* | |
* 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"); | |
} | |
}, | |
// Creates a table to hold a single EMLTaxonCoverage element (table) for | |
// each root-level taxonomicClassification | |
createTaxonomicCoverage: function (coverage) { | |
var finishedEls = $( | |
this.taxonomicCoverageTemplate({ | |
generalTaxonomicCoverage: | |
coverage.get("generalTaxonomicCoverage") || "", | |
}), | |
), | |
coverageEl = finishedEls.filter(".taxonomic-coverage"); | |
coverageEl.data({ model: coverage }); | |
var classifications = coverage.get("taxonomicClassification"); | |
// Makes a table... for the root level | |
for (var i = 0; i < classifications.length; i++) { | |
coverageEl.append( | |
this.createTaxonomicClassificationTable(classifications[i]), | |
); | |
} | |
// Create a new, blank table for another taxonomicClassification | |
var newTableEl = this.createTaxonomicClassificationTable(); | |
coverageEl.append(newTableEl); | |
return finishedEls; | |
}, | |
createTaxonomicClassificationTable: function (classification) { | |
// updating the taxonomic table indexes before adding a new table to the page. | |
var taxaNums = this.$(".editor-header-index"); | |
for (var i = 0; i < taxaNums.length; i++) { | |
$(taxaNums[i]).text(i + 1); | |
} | |
// Adding the taxoSpeciesCounter to the table header for enhancement of the view | |
var finishedEl = $( | |
'<div class="row-striped root-taxonomic-classification-container"></div>', | |
); | |
$(finishedEl).append( | |
'<h6>Species <span class="editor-header-index">' + | |
(taxaNums.length + 1) + | |
"</span> </h6>", | |
); | |
// Add a remove button if this is not a new table | |
if (!(typeof classification === "undefined")) { | |
$(finishedEl).append( | |
this.createRemoveButton( | |
"taxonCoverage", | |
"taxonomicClassification", | |
".root-taxonomic-classification-container", | |
".taxonomic-coverage", | |
), | |
); | |
} | |
var tableEl = $(this.taxonomicClassificationTableTemplate()); | |
var tableBodyEl = $(document.createElement("tbody")); | |
var queue = [classification], | |
rows = [], | |
cur; | |
while (queue.length > 0) { | |
cur = queue.pop(); | |
// I threw this in here so I can this function without an | |
// argument to generate a new table from scratch | |
if (typeof cur === "undefined") { | |
continue; | |
} | |
cur.taxonRankName = cur.taxonRankName?.toLowerCase(); | |
rows.push(cur); | |
if (cur.taxonomicClassification) { | |
for (var i = 0; i < cur.taxonomicClassification.length; i++) { | |
queue.push(cur.taxonomicClassification[i]); | |
} | |
} | |
} | |
for (var j = 0; j < rows.length; j++) { | |
tableBodyEl.append(this.makeTaxonomicClassificationRow(rows[j])); | |
} | |
var newRowEl = this.makeNewTaxonomicClassificationRow(); | |
$(tableBodyEl).append(newRowEl); | |
$(tableEl).append(tableBodyEl); | |
// Add the new class to the entire table if it's a new one | |
if (typeof classification === "undefined") { | |
$(tableEl).addClass("new"); | |
} | |
$(finishedEl).append(tableEl); | |
return finishedEl; | |
}, | |
/** | |
* Create the HTML for a single row in a taxonomicClassification table | |
* @param {EMLTaxonCoverage#taxonomicClassification} classification A | |
* classification object from an EMLTaxonCoverage model, may include | |
* a taxonRank, taxonValue, taxonId, commonName, and nested | |
* taxonomicClassification objects | |
* @returns {jQuery} A jQuery object containing the HTML for a single | |
* row in a taxonomicClassification table | |
* @since 2.24.0 | |
*/ | |
makeTaxonomicClassificationRow: function (classification) { | |
try { | |
if (!classification) classification = {}; | |
var finishedEl = $( | |
this.taxonomicClassificationRowTemplate({ | |
taxonRankName: classification.taxonRankName || "", | |
taxonRankValue: classification.taxonRankValue || "", | |
}), | |
); | |
// Save a reference to other taxon attributes that we need to keep | |
// when serializing the model | |
if (classification.taxonId) { | |
$(finishedEl).data("taxonId", classification.taxonId); | |
} | |
if (classification.commonName) { | |
$(finishedEl).data("commonName", classification.commonName); | |
} | |
return finishedEl; | |
} catch (e) { | |
console.log("Error making taxonomic classification row: ", e); | |
} | |
}, | |
/** | |
* Create the HTML for a new row in a taxonomicClassification table | |
* @returns {jQuery} A jQuery object containing the HTML for a new row | |
* in a taxonomicClassification table | |
* @since 2.24.0 | |
*/ | |
makeNewTaxonomicClassificationRow: function () { | |
const row = this.makeTaxonomicClassificationRow({}); | |
$(row).addClass("new"); | |
return row; | |
}, | |
/* Update the underlying model and DOM for an EML TaxonomicCoverage | |
section. This method handles updating the underlying TaxonomicCoverage | |
models when the user changes form fields as well as inserting new | |
form fields automatically when the user needs them. | |
Since a dataset has multiple TaxonomicCoverage elements at the dataset | |
level, each Taxonomic Coverage is represented by a table element and | |
all taxonomicClassifications within are rows in that table. | |
TODO: Finish this function | |
TODO: Link this function into the DOM | |
*/ | |
updateTaxonCoverage: function (options) { | |
if (options.target) { | |
// Ignore the event if the target is a quick add taxon UI element. | |
const quickAddEl = $(this.taxonQuickAddEl); | |
if (quickAddEl && quickAddEl.has(options.target).length) { | |
return; | |
} | |
var e = options; | |
/* Getting `model` here is different than in other places because | |
the thing being updated is an `input` or `select` element which | |
is part of a `taxonomicClassification`. The model is | |
`TaxonCoverage` which has one or more | |
`taxonomicClassifications`. So we have to walk up to the | |
hierarchy from input < td < tr < tbody < table < div to get at | |
the underlying TaxonCoverage model. | |
*/ | |
var coverage = $(e.target).parents(".taxonomic-coverage"), | |
classificationEl = $(e.target).parents( | |
".root-taxonomic-classification", | |
), | |
model = $(coverage).data("model") || this.model, | |
category = $(e.target).attr("data-category"), | |
value = this.model.cleanXMLText($(e.target).val()); | |
//We can't update anything without a coverage, or | |
//classification | |
if (!coverage) return false; | |
if (!classificationEl) return false; | |
// Use `category` to determine if we're updating the generalTaxonomicCoverage or | |
// the taxonomicClassification | |
if (category && category === "generalTaxonomicCoverage") { | |
model.set("generalTaxonomicCoverage", value); | |
return; | |
} | |
} else { | |
var coverage = options.coverage, | |
model = $(coverage).data("model"); | |
} | |
// Find all of the root-level taxonomicClassifications | |
var classificationTables = $(coverage).find( | |
".root-taxonomic-classification", | |
); | |
if (!classificationTables) return false; | |
//TODO :This should probably (at least) be in its own View and | |
//definitely refactored into tidy functions.*/ | |
var rows, | |
collectedClassifications = []; | |
for (var i = 0; i < classificationTables.length; i++) { | |
rows = $(classificationTables[i]).find("tbody tr"); | |
if (!rows) continue; | |
var topLevelClassification = {}, | |
classification = topLevelClassification, | |
currentRank, | |
currentValue; | |
for (var j = 0; j < rows.length; j++) { | |
const thisRow = rows[j]; | |
currentRank = | |
this.model.cleanXMLText($(thisRow).find("select").val()) || ""; | |
currentValue = | |
this.model.cleanXMLText($(thisRow).find("input").val()) || ""; | |
// Maintain classification attributes that exist in the EML but are not visible in the editor | |
const taxonId = $(thisRow).data("taxonId"); | |
const commonName = $(thisRow).data("commonName"); | |
// Skip over rows with empty Rank or Value | |
if (!currentRank.length || !currentValue.length) { | |
continue; | |
} | |
//After the first row, start nesting taxonomicClassification objects | |
if (j > 0) { | |
classification.taxonomicClassification = [{}]; | |
classification = classification.taxonomicClassification[0]; | |
} | |
// Add it to the classification object | |
classification.taxonRankName = currentRank; | |
classification.taxonRankValue = currentValue; | |
classification.taxonId = taxonId; | |
classification.commonName = commonName; | |
} | |
//Add the top level classification to the array | |
if (Object.keys(topLevelClassification).length) | |
collectedClassifications.push(topLevelClassification); | |
} | |
if ( | |
!_.isEqual( | |
collectedClassifications, | |
model.get("taxonomicClassification"), | |
) | |
) { | |
model.set("taxonomicClassification", collectedClassifications); | |
this.model.trigger("change"); | |
} | |
// Handle adding new tables and rows | |
// Do nothing if the value isn't set | |
if (value) { | |
// Add a new row if this is itself a new row | |
if ($(e.target).parents("tr").first().is(".new")) { | |
var newRowEl = this.makeNewTaxonomicClassificationRow(); | |
$(e.target).parents("tbody").first().append(newRowEl); | |
$(e.target).parents("tr").first().removeClass("new"); | |
} | |
// Add a new classification table if this is itself a new table | |
if ($(classificationEl).is(".new")) { | |
$(classificationEl).removeClass("new"); | |
$(classificationEl).append( | |
this.createRemoveButton( | |
"taxonCoverage", | |
"taxonomicClassification", | |
".root-taxonomic-classification-container", | |
".taxonomic-coverage", | |
), | |
); | |
$(coverage).append(this.createTaxonomicClassificationTable()); | |
} | |
} | |
// update the quick add interface | |
this.updateQuickAddTaxa(); | |
}, | |
/** | |
* Update the options for the quick add taxon select interface. This | |
* ensures that only taxonomic classifications that are not already | |
* included in the taxonomic coverage are available for selection. | |
* @since 2.24.0 | |
*/ | |
updateQuickAddTaxa: function () { | |
const selects = this.taxonSelects; | |
if (!selects || !selects.length) return; | |
const taxa = this.getTaxonQuickAddOptions(); | |
if (!taxa || !taxa.length) return; | |
selects.forEach((select, i) => { | |
select.updateOptions(taxa[i].options); | |
}); | |
}, | |
/* | |
* Adds a new row and/or table to the taxonomic coverage section | |
*/ | |
addNewTaxon: function (e) { | |
// Don't do anything if the current classification doesn't have new content | |
if ($(e.target).val().trim() === "") return; | |
// If the row is new, add a new row to the table | |
if ($(e.target).parents("tr").is(".new")) { | |
var newRow = this.makeNewTaxonomicClassificationRow(); | |
//Append the new row and remove the new class from the old row | |
$(e.target).parents("tr").removeClass("new").after(newRow); | |
} | |
}, | |
/** | |
* Insert the "quick add" interface for adding common taxa to the | |
* taxonomic coverage section. Only renders if there is a list of taxa | |
* configured in the appModel. | |
*/ | |
renderTaxaQuickAdd: function () { | |
try { | |
const view = this; | |
// To render the taxon select, the view must be in editor mode and we | |
// need a list of taxa configured for the theme | |
if (!view.edit) return; | |
// remove any existing quick add interface: | |
if (view.taxonQuickAddEl) view.taxonQuickAddEl.remove(); | |
const quickAddTaxa = view.getTaxonQuickAddOptions(); | |
if (!quickAddTaxa || !quickAddTaxa.length) { | |
// If the taxa are configured as SID for a dataObject, then wait | |
// for the dataObject to be loaded | |
this.listenToOnce( | |
MetacatUI.appModel, | |
"change:quickAddTaxa", | |
this.renderTaxaQuickAdd, | |
); | |
return; | |
} | |
// Create & insert the basic HTML for the taxon select interface | |
const template = `<div class="taxa-quick-add"> | |
<p class="taxa-quick-add__text"> | |
<b>⭐️ Quick Add Taxa:</b> Select one or more common taxa. Click "Add" to add them to the list. | |
</p> | |
<div class="taxa-quick-add__controls"> | |
<div class="taxa-quick-add__selects"></div> | |
<button class="btn btn-primary taxa-quick-add__button">Add Taxa</button> | |
</div> | |
</div>`; | |
const parser = new DOMParser(); | |
const doc = parser.parseFromString(template, "text/html"); | |
const quickAddEl = doc.body.firstChild; | |
const button = quickAddEl.querySelector("button"); | |
const container = quickAddEl.querySelector( | |
".taxa-quick-add__selects", | |
); | |
const rowSelector = ".root-taxonomic-classification-container"; | |
const firstRow = document.querySelector(rowSelector); | |
firstRow.parentNode.insertBefore(quickAddEl, firstRow); | |
view.taxonQuickAddEl = quickAddEl; | |
// Update the taxon coverage when the button is clicked | |
const onButtonClick = () => { | |
const taxonSelects = view.taxonSelects; | |
if (!taxonSelects || !taxonSelects.length) return; | |
const selectedItems = taxonSelects | |
.map((select) => select.model.get("selected")) | |
.flat(); | |
if (!selectedItems || !selectedItems.length) return; | |
const selectedItemObjs = selectedItems.map((item) => { | |
try { | |
// It will be encoded JSON if it's a pre-defined taxon | |
return JSON.parse(decodeURIComponent(item)); | |
} catch (e) { | |
// Otherwise it will be a string a user typed in | |
return { | |
taxonRankName: "", | |
taxonRankValue: item, | |
}; | |
} | |
}); | |
view.addTaxa(selectedItemObjs); | |
taxonSelects.forEach((select) => | |
select.model.setSelected([], { silent: true }), | |
); | |
}; | |
button.removeEventListener("click", onButtonClick); | |
button.addEventListener("click", onButtonClick); | |
// Create the search selects | |
view.taxonSelects = []; | |
const componentPath = "views/searchSelect/SearchSelectView"; | |
require([componentPath], function (SearchSelect) { | |
quickAddTaxa.forEach((taxaList, i) => { | |
try { | |
const taxaInput = new SearchSelect({ | |
options: taxaList.options, | |
placeholderText: taxaList.placeholder, | |
inputLabel: taxaList.label, | |
allowMulti: true, | |
allowAdditions: true, | |
separatorTextOptions: false, | |
selected: [], | |
}); | |
container.appendChild(taxaInput.el); | |
taxaInput.render(); | |
view.taxonSelects.push(taxaInput); | |
} catch (e) { | |
console.log("Failed to create taxon select: ", e); | |
} | |
}); | |
}); | |
} catch (e) { | |
console.log("Failed to render taxon select: ", e); | |
} | |
}, | |
/** | |
* Get the list of options for the taxon quick add interface. Filter | |
* out any that have already been added to the taxonomic coverage. | |
* @returns {Object[]} An array of search select options | |
* @since 2.24.0 | |
*/ | |
getTaxonQuickAddOptions: function () { | |
const quickAddTaxa = MetacatUI.appModel.getQuickAddTaxa(); | |
if (!quickAddTaxa || !quickAddTaxa.length) return; | |
const coverages = this.model.get("taxonCoverage"); | |
for (const taxaList of quickAddTaxa) { | |
const opts = []; | |
for (const taxon of taxaList.taxa) { | |
// check that it is not a duplicate in any coverages | |
let isDuplicate = false; | |
for (cov of coverages) { | |
if (cov.isDuplicate(taxon)) { | |
isDuplicate = true; | |
break; | |
} | |
} | |
if (!isDuplicate) { | |
opts.push(this.taxonOptionToSearchSelectItem(taxon)); | |
} | |
} | |
taxaList.options = opts; | |
} | |
return quickAddTaxa; | |
}, | |
/** | |
* Reformats a taxon option, as provided in the appModel | |
* {@link AppModel#quickAddTaxa}, as a search select item. | |
* @param {Object} option A single taxon classification with at least a | |
* taxonRankValue and taxonRankName. It may also have a taxonId (object | |
* with provider and value) and a commonName. | |
* @returns {Object} A search select item with label, value, and | |
* description properties. | |
*/ | |
taxonOptionToSearchSelectItem: function (option) { | |
try { | |
// option must have a taxonRankValue and taxonRankName or it is invalid | |
if (!option.taxonRankValue || !option.taxonRankName) { | |
console.log("Invalid taxon option: ", option); | |
return null; | |
} | |
// Create a description | |
let description = option.taxonRankName + ": " + option.taxonRankValue; | |
if (option.taxonId) { | |
description += | |
" (" + | |
option.taxonId.provider + | |
": " + | |
option.taxonId.value + | |
")"; | |
} | |
// search select doesn't work with some of the json characters | |
const val = encodeURIComponent(JSON.stringify(option)); | |
return { | |
label: option.commonName || option.taxonRankValue, | |
value: val, | |
description: description, | |
}; | |
} catch (e) { | |
console.log( | |
"Failed to reformat taxon option as search select item: ", | |
e, | |
); | |
return null; | |
} | |
}, | |
/** | |
* Add new taxa to the EML model and re-render the taxa section. The new | |
* taxa will be added to the first <taxonomicCoverage> element in the EML | |
* model. If there is no <taxonomicCoverage> element, one will be created. | |
* @param {Object[]} newClassifications - An array of objects with any of | |
* the following properties: | |
* - taxonRankName: (sting) The name of the taxonomic rank, e.g. | |
* "Kingdom" | |
* - taxonRankValue: (string) The value of the taxonomic rank, e.g. | |
* "Animalia" | |
* - commonName: (string) The common name of the taxon, e.g. "Animals" | |
* - taxonId: (object) The official ID of the taxon, including "provider" | |
* and "value". | |
* - taxonomicClassification: (array) An array of nested taxonomic | |
* classifications | |
* @since 2.24.0 | |
* @example | |
* this.addTaxon([{ | |
* taxonRankName: "Kingdom", | |
* taxonRankValue: "Animalia", | |
* commonName: "Animals", | |
* taxonId: { | |
* provider: "https://www.itis.gov/", | |
* value: "202423" | |
* }]); | |
*/ | |
addTaxa: function (newClassifications) { | |
try { | |
// TODO: validate the new taxon before adding it to the model? | |
const taxonCoverages = this.model.get("taxonCoverage"); | |
// We expect that there is already a taxonCoverage array on the model. | |
// If the EML was made in the editor, there can only be one | |
// <taxonomicCoverage> element. Add the new taxon to its | |
// <taxonomicClassification> array. If there is more than one, then the | |
// new taxon will be added to the first <taxonomicCoverage> element. | |
if (taxonCoverages && taxonCoverages.length >= 1) { | |
const taxonCoverage = taxonCoverages[0]; | |
const classifications = taxonCoverage.get( | |
"taxonomicClassification", | |
); | |
const allClass = classifications.concat(newClassifications); | |
taxonCoverage.set("taxonomicClassification", allClass); | |
} else { | |
// If there is no <taxonomicCoverage> element for some reason, | |
// create one and add the new taxon to its <taxonomicClassification> | |
// array. | |
const newCov = new EMLTaxonCoverage({ | |
taxonomicClassification: newClassifications, | |
parentModel: this.model, | |
}); | |
this.model.set("taxonCoverage", [newCov]); | |
} | |
// Re-render the taxa section | |
this.renderTaxa(); | |
} catch (e) { | |
console.log("Error adding taxon to EML model: ", e); | |
} | |
}, | |
removeTaxonRank: function (e) { | |
var row = $(e.target).parents(".taxonomic-coverage-row"), | |
coverageEl = $(row).parents(".taxonomic-coverage"), | |
view = this; | |
//Animate the row away and then remove it | |
row.slideUp("fast", function () { | |
row.remove(); | |
view.updateTaxonCoverage({ coverage: coverageEl }); | |
}); | |
}, | |
/* | |
* After the user focuses out, show validation help, if needed | |
*/ | |
showTaxonValidation: function (e) { | |
//Get the text inputs and select menus | |
var row = $(e.target).parents("tr"), | |
allInputs = row.find("input, select"), | |
tableContainer = $(e.target).parents("table"), | |
errorInputs = []; | |
//If none of the inputs have a value and this is a new row, then do nothing | |
if ( | |
_.every(allInputs, function (i) { | |
return !i.value; | |
}) && | |
row.is(".new") | |
) | |
return; | |
//Add the error styling to any input with no value | |
_.each(allInputs, function (input) { | |
// Keep track of the number of clicks of each input element so we only show the | |
// error message after the user has focused on both input elements | |
if (!input.value) errorInputs.push(input); | |
}); | |
if (errorInputs.length) { | |
//Show the error message after a brief delay | |
setTimeout(function () { | |
//If the user focused on another element in the same row, don't do anything | |
if (_.contains(allInputs, document.activeElement)) return; | |
//Add the error styling | |
$(errorInputs).addClass("error"); | |
//Add the error message | |
if (!tableContainer.prev(".notification").length) { | |
tableContainer.before( | |
$(document.createElement("p")) | |
.addClass("error notification") | |
.text("Enter a rank name AND value in each row."), | |
); | |
} | |
}, 200); | |
} else { | |
allInputs.removeClass("error"); | |
if (!tableContainer.find(".error").length) | |
tableContainer.prev(".notification").remove(); | |
} | |
}, | |
previewTaxonRemove: function (e) { | |
var removeBtn = $(e.target); | |
if (removeBtn.parent().is(".root-taxonomic-classification")) { | |
removeBtn.parent().toggleClass("remove-preview"); | |
} else { | |
removeBtn | |
.parents(".taxonomic-coverage-row") | |
.toggleClass("remove-preview"); | |
} | |
}, | |
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.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; | |
}, | |
}, | |
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚫 [eslint] <object-shorthand> reported by reviewdog 🐶
Expected method shorthand.
metacatui/src/js/views/metadata/EML211View.js
Lines 243 to 336 in 4df698d
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); | |
//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(); | |
}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚫 [eslint] <vars-on-top> reported by reviewdog 🐶
All 'var' declarations must be at the top of the function scope.
metacatui/src/js/views/metadata/EML211View.js
Lines 299 to 302 in 4df698d
var altIdsEls = this.createBasicTextFields( | |
"alternateIdentifier", | |
"Add a new alternate identifier", | |
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚫 [eslint] <no-var> reported by reviewdog 🐶
Unexpected var, use let or const instead.
metacatui/src/js/views/metadata/EML211View.js
Lines 299 to 302 in 4df698d
var altIdsEls = this.createBasicTextFields( | |
"alternateIdentifier", | |
"Add a new alternate identifier", | |
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚫 [eslint] <object-shorthand> reported by reviewdog 🐶
Expected method shorthand.
metacatui/src/js/views/metadata/EML211View.js
Lines 1894 to 1978 in 4df698d
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; | |
}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚫 [eslint] <one-var> reported by reviewdog 🐶
Split 'var' declarations into multiple statements.
metacatui/src/js/views/metadata/EML211View.js
Lines 1910 to 1916 in 4df698d
var textRow = $(document.createElement("div")).addClass( | |
"basic-text-row", | |
), | |
input = $(document.createElement("input")) | |
.attr("type", "text") | |
.attr("data-category", category) | |
.addClass("basic-text"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚫 [eslint] <vars-on-top> reported by reviewdog 🐶
All 'var' declarations must be at the top of the function scope.
metacatui/src/js/views/metadata/EML211View.js
Lines 1910 to 1916 in 4df698d
var textRow = $(document.createElement("div")).addClass( | |
"basic-text-row", | |
), | |
input = $(document.createElement("input")) | |
.attr("type", "text") | |
.attr("data-category", category) | |
.addClass("basic-text"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚫 [eslint] <no-var> reported by reviewdog 🐶
Unexpected var, use let or const instead.
metacatui/src/js/views/metadata/EML211View.js
Lines 1910 to 1916 in 4df698d
var textRow = $(document.createElement("div")).addClass( | |
"basic-text-row", | |
), | |
input = $(document.createElement("input")) | |
.attr("type", "text") | |
.attr("data-category", category) | |
.addClass("basic-text"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚫 [eslint] <object-shorthand> reported by reviewdog 🐶
Expected method shorthand.
metacatui/src/js/views/metadata/EML211View.js
Lines 1980 to 2032 in 4df698d
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); | |
}, |
Add an input in the Overview page of the EML Editor View that allows users to identify the authoritative version of the dataset.