-
-
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
Support for all Bioportal API ontologies #2502
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] <no-var> reported by reviewdog 🐶
Unexpected var, use let or const instead.
metacatui/src/js/views/filters/SemanticFilterView.js
Lines 20 to 143 in 24e22d9
var SemanticFilterView = FilterView.extend( | |
/** @lends SemanticFilterView.prototype */ { | |
/** | |
* @inheritdoc | |
*/ | |
model: null, | |
/** | |
* @inheritdoc | |
*/ | |
modelClass: Filter, | |
className: "filter semantic", | |
// Template is an empty function because this view delegates to the | |
// AnnotationFilterView. See render() method. | |
template: function () {}, | |
/** | |
* Render an instance of a Semantic Filter View. | |
* | |
* Note that this View doesn't have a template and instead delegates to | |
* the AnnotationFilterView which renders a SearchSelectView which | |
* renders an NCBOTree. | |
* @since 2.22.0 | |
*/ | |
render: function () { | |
try { | |
var templateVars = this.model.toJSON(); | |
templateVars.id = this.model.cid; | |
// Renders the template and inserts the FilterEditorView if the mode is uiBuilder | |
FilterView.prototype.render.call(this, templateVars); | |
var viewOpts = { | |
useSearchSelect: true, | |
placeholderText: templateVars.placeholder, | |
inputLabel: null, // Hides label and uses FilterView label | |
ontology: this.model.get("ontology"), | |
startingRoot: this.model.get("startingRoot"), | |
}; | |
var subView = new AnnotationFilterView(viewOpts); | |
this.$el.append(subView.el); | |
subView.render(); | |
var view = this; | |
subView.on("annotationSelected", function (event, item) { | |
// Get the value of the associated input | |
var term = !item || !item.value ? input.val() : item.value; | |
var label = !item || !item.filterLabel ? null : item.filterLabel; | |
// Set up a label mapping for the term so we can display a | |
// human-readable label for it in the UI | |
view.setLabelMapping(term, label); | |
// Set the value, supports multiple values | |
var currentValue = view.model.get("values"); | |
var newValuesArray = _.flatten(new Array(currentValue, term)); | |
view.model.set("values", newValuesArray); | |
view.defocus(); | |
}); | |
} catch (error) { | |
console.log( | |
"There was an error rendering a SemanticFilterView." + | |
" Error details: " + | |
error, | |
); | |
} | |
}, | |
/** | |
* Helper function which defocuses the dropdown portion of the | |
* SearchSelectView used by this View's AnnotationFilterView. When the | |
* user clicks an item in the NCBOTree widget, we want the | |
* SearchSelectView's dropdown to go away and I couldn't find any API | |
* to do that so we have this code. See the render() method to see how it's | |
* called. | |
* | |
* Note: This isn't really a stable API and is really something we might | |
* remove in the future if we refactor the NCBOTree widget. | |
* @since 2.22.0 | |
*/ | |
defocus: function () { | |
this.$el.find("div.menu").removeClass("visible").addClass("hidden"); | |
this.$el | |
.find("div.fluid.ui.dropdown") | |
.removeClass("active") | |
.removeClass("visible"); | |
this.$el.find("input").blur(); | |
}, | |
/** | |
* Set the human-readable label for a term URI. | |
* | |
* For most uses of the Filter model, the value(s) set on the model can | |
* be shown directly in the UI. But for Semantic searches, we need to | |
* be able to display a human-readable label for the value because the | |
* value is likely an opaque URI. | |
* | |
* Rather than fetch and/or store all the possible labels for all | |
* possible URIs, we store a label for whichever terms the user chooses | |
* and keep that around until we need it in the UI. | |
* | |
* @param {string} term The term URI to set a label for | |
* @param {string} label The label to set | |
* @since 2.22.0 | |
*/ | |
setLabelMapping: function (term, label) { | |
var newMappings; | |
if (this.model.get("valueLabels")) { | |
newMappings = _.clone(this.model.get("valueLabels")); | |
} else { | |
newMappings = new Object(); | |
} | |
newMappings[term] = label; | |
this.model.set("valueLabels", newMappings); | |
}, | |
}, | |
); |
🚫 [eslint] <object-shorthand> reported by reviewdog 🐶
Expected method shorthand.
metacatui/src/js/views/filters/SemanticFilterView.js
Lines 46 to 91 in 24e22d9
render: function () { | |
try { | |
var templateVars = this.model.toJSON(); | |
templateVars.id = this.model.cid; | |
// Renders the template and inserts the FilterEditorView if the mode is uiBuilder | |
FilterView.prototype.render.call(this, templateVars); | |
var viewOpts = { | |
useSearchSelect: true, | |
placeholderText: templateVars.placeholder, | |
inputLabel: null, // Hides label and uses FilterView label | |
ontology: this.model.get("ontology"), | |
startingRoot: this.model.get("startingRoot"), | |
}; | |
var subView = new AnnotationFilterView(viewOpts); | |
this.$el.append(subView.el); | |
subView.render(); | |
var view = this; | |
subView.on("annotationSelected", function (event, item) { | |
// Get the value of the associated input | |
var term = !item || !item.value ? input.val() : item.value; | |
var label = !item || !item.filterLabel ? null : item.filterLabel; | |
// Set up a label mapping for the term so we can display a | |
// human-readable label for it in the UI | |
view.setLabelMapping(term, label); | |
// Set the value, supports multiple values | |
var currentValue = view.model.get("values"); | |
var newValuesArray = _.flatten(new Array(currentValue, term)); | |
view.model.set("values", newValuesArray); | |
view.defocus(); | |
}); | |
} catch (error) { | |
console.log( | |
"There was an error rendering a SemanticFilterView." + | |
" Error details: " + | |
error, | |
); | |
} | |
}, |
🚫 [eslint] <vars-on-top> reported by reviewdog 🐶
All 'var' declarations must be at the top of the function scope.
metacatui/src/js/views/filters/SemanticFilterView.js
Lines 54 to 60 in 24e22d9
var viewOpts = { | |
useSearchSelect: true, | |
placeholderText: templateVars.placeholder, | |
inputLabel: null, // Hides label and uses FilterView label | |
ontology: this.model.get("ontology"), | |
startingRoot: this.model.get("startingRoot"), | |
}; |
🚫 [eslint] <no-var> reported by reviewdog 🐶
Unexpected var, use let or const instead.
metacatui/src/js/views/filters/SemanticFilterView.js
Lines 54 to 60 in 24e22d9
var viewOpts = { | |
useSearchSelect: true, | |
placeholderText: templateVars.placeholder, | |
inputLabel: null, // Hides label and uses FilterView label | |
ontology: this.model.get("ontology"), | |
startingRoot: this.model.get("startingRoot"), | |
}; |
🚫 [eslint] <prefer-arrow-callback> reported by reviewdog 🐶
Unexpected function expression.
metacatui/src/js/views/metadata/EML211View.js
Lines 29 to 3135 in 24e22d9
], 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); | |
//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") | |
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") { | |
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") { | |
$(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") 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 3133 in 24e22d9
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); | |
//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") | |
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") { | |
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") { | |
$(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") 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 2490 to 2587 in 24e22d9
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); | |
} | |
}, |
🚫 [eslint] <import/no-dynamic-require> reported by reviewdog 🐶
Calls to require() should use string literals
metacatui/src/js/views/metadata/EML211View.js
Lines 2564 to 2583 in 24e22d9
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); | |
} | |
}); | |
}); |
🚫 [eslint] <prefer-arrow-callback> reported by reviewdog 🐶
Unexpected function expression.
metacatui/src/js/views/metadata/EML211View.js
Lines 2564 to 2583 in 24e22d9
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); | |
} | |
}); | |
}); |
🚫 [eslint] <prefer-arrow-callback> reported by reviewdog 🐶
Unexpected function expression.
metacatui/src/js/views/queryBuilder/QueryBuilderView.js
Lines 10 to 489 in 24e22d9
], function ( | |
$, | |
_, | |
Backbone, | |
Filters, | |
QueryFields, | |
SearchSelect, | |
QueryRule, | |
Template, | |
) { | |
/** | |
* @class QueryBuilderView | |
* @classdesc A view that provides a UI for users to construct a complex search | |
* through the DataONE Solr index | |
* @classcategory Views/QueryBuilder | |
* @screenshot views/QueryBuilderView.png | |
* @extends Backbone.View | |
* @constructor | |
* @since 2.14.0 | |
*/ | |
var QueryBuilderView = Backbone.View.extend( | |
/** @lends QueryBuilderView.prototype */ | |
{ | |
/** | |
* The type of View this is | |
* @type {string} | |
*/ | |
type: "QueryBuilderView", | |
/** | |
* The HTML class names for this view element | |
* @type {string} | |
*/ | |
className: "query-builder", | |
/** | |
* A JQuery selector for the element in the template that will contain the query | |
* rules | |
* @type {string} | |
*/ | |
rulesContainerSelector: ".rules-container", | |
/** | |
* An ID for the element in the template that a user should click to add a new | |
* rule. A unique ID will be appended to this ID, and the ID will be added to the | |
* template. | |
* @type {string} | |
* @since 2.17.0 | |
*/ | |
addRuleButtonID: "add-rule-", | |
/** | |
* An ID for the element in the template that a user should click to add a new | |
* rule group. A unique ID will be appended to this ID, and the ID will be added | |
* to the template. | |
* @type {string} | |
* @since 2.17.0 | |
*/ | |
addRuleGroupButtonID: "add-rule-group-", | |
/** | |
* A JQuery selector for the element in the template that will contain the input | |
* allowing a user to switch the exclude attribute from "include" to "exclude" | |
* (i.e. to switch between exclude:false and exclude:true in the filterGroup | |
* model.) | |
* @type {string} | |
* @since 2.17.0 | |
*/ | |
excludeInputSelector: ".exclude-input", | |
/** | |
* A JQuery selector for the element in the template that will contain the input | |
* allowing a user to switch the operator from "all" to "any" (i.e. to switch | |
* between operator:"AND" and exclude:"OR" in the filterGroup model.) | |
* @type {string} | |
* @since 2.17.0 | |
*/ | |
operatorInputSelector: ".operator-input", | |
/** | |
* The maximum number of levels nested Rule Groups (i.e. nested FilterGroup | |
* models) that a user is permitted to *build* in the Query Builder. If a | |
* Portal/Collection document is loaded into the Query Builder that has more than | |
* the maximum allowable nested levels, those levels will still be displayed. This | |
* only prevents the "Add Rule Group" button from being shown. | |
* @type {number} | |
* @since 2.17.0 | |
*/ | |
nestedLevelsAllowed: 1, | |
/** | |
* An array of hex color codes used to help distinguish between different rules | |
* @type {string[]} | |
*/ | |
ruleColorPalette: [ | |
"#44AA99", | |
"#137733", | |
"#c9a538", | |
"#CC6677", | |
"#882355", | |
"#AA4499", | |
"#332288", | |
], | |
/** | |
* Query fields to exclude in the metadata field selector of each Query Rule. This | |
* is a list of field names that exist in the query service index (i.e. Solr), but | |
* which should be hidden in the Query Builder | |
* @type {string[]} | |
*/ | |
excludeFields: [], | |
/** | |
* Query fields to exclude in the metadata field selector for any Query Rules that | |
* are in nested Query Builders (i.e. in nested Filter Groups). This is a list of | |
* field names that exist in the query service index (i.e. Solr), but which should | |
* be hidden in nested Query Builders | |
* @type {string[]} | |
*/ | |
nestedExcludeFields: [], | |
/** | |
* Query fields that do not exist in the query service index, but which we would | |
* like to show as options in the Query Builder field input. | |
* | |
* @type {SpecialField[]} | |
* | |
* @since 2.15.0 | |
*/ | |
specialFields: [], | |
/** | |
* A Filters collection that stores filters to be edited with this Query Builder, | |
* e.g. the definitionFilters in a Collection or Portal model. If a filterGroup is | |
* set, then collection doesn't necessarily need to be set, as the Filters | |
* collection from within the FilterGroup model will automatically be set on view. | |
* @type {Filters} | |
*/ | |
collection: null, | |
/** | |
* The FilterGroup model that stores the filters, the exclude attribute, and the | |
* group operator to be edited with this Query Builder. This does not need to be | |
* set; just a Filters collection can be set on the view instead, but then there | |
* will be no input to switch between the include & exclude and any & all, since | |
* these are the exclude and operator attributes on the filterGroup model. | |
* @type {FilterGroup} | |
* @since 2.17.0 | |
*/ | |
filterGroup: null, | |
/** | |
* The primary HTML template for this view | |
* @type {Underscore.template} | |
*/ | |
template: _.template(Template), | |
/** | |
* events - A function that specifies a set of DOM events that will be bound to | |
* methods on your View through Backbone.delegateEvents. | |
* @see {@link https://backbonejs.org/#View-events} | |
* | |
* @return {Object} The events hash | |
*/ | |
events: function () { | |
try { | |
var events = {}; | |
var addRuleAction = "click #" + this.addRuleButtonID + this.cid; | |
events[addRuleAction] = "addQueryRule"; | |
var addRuleGroupAction = | |
"click #" + this.addRuleGroupButtonID + this.cid; | |
events[addRuleGroupAction] = "addQueryRuleGroup"; | |
return events; | |
} catch (e) { | |
console.error( | |
"Failed to specify events for the Query Builder View," + | |
" error message: " + | |
e, | |
); | |
} | |
}, | |
/** | |
* The list of QueryRuleViews that are contained within this queryBuilder | |
* @type {QueryRuleView[]} | |
*/ | |
rules: [], | |
/** | |
* Creates a new QueryBuilderView | |
* @param {Object} options - A literal object with options to pass to the view | |
*/ | |
initialize: function (options) { | |
try { | |
// Get all the options and apply them to this view | |
if (typeof options == "object") { | |
var optionKeys = Object.keys(options); | |
_.each( | |
optionKeys, | |
function (key, i) { | |
this[key] = options[key]; | |
}, | |
this, | |
); | |
} | |
// If neither a Filters collection nor a FilterGroup model is provided in the | |
// options for this view, then create a new FilterGroup model and set it on | |
// the view. | |
if (!this.collection && !this.filterGroup) { | |
this.filterGroup = new FilterGroup(); | |
} | |
// If there is a FilterGroup model set, but no Filters collection, then use | |
// the Filters from within the FilterGroup model as the Filters collection. | |
if (!this.collection && this.filterGroup) { | |
this.collection = this.filterGroup.get("filters"); | |
} | |
} catch (e) { | |
console.error( | |
"Failed to initialize the Query Builder view, error message:", | |
e, | |
); | |
} | |
}, | |
/** | |
* render - Render the view | |
* | |
* @return {QueryBuilder} Returns the view | |
*/ | |
render: function () { | |
try { | |
// Ensure the query fields are cached for the Query Field Select View and the | |
// Query Rule View | |
if ( | |
typeof MetacatUI.queryFields === "undefined" || | |
MetacatUI.queryFields.length === 0 | |
) { | |
MetacatUI.queryFields = new QueryFields(); | |
this.listenToOnce(MetacatUI.queryFields, "sync", this.render); | |
MetacatUI.queryFields.fetch(); | |
return; | |
} | |
// Insert the template into the view | |
this.$el.html( | |
this.template({ | |
addRuleButtonID: this.addRuleButtonID + this.cid, | |
addRuleGroupButtonID: this.addRuleGroupButtonID + this.cid, | |
}), | |
); | |
// Nested Query Builders are used to display nested filterGroup models. | |
// They need to be styled slightly different from the parent Query Builder. | |
if (this.parentRule) { | |
this.$el.addClass("nested"); | |
} | |
// Remove the rule group button ID if no more nested Query Builders are | |
// allowed. | |
if ( | |
typeof this.nestedLevelsAllowed == "number" && | |
this.nestedLevelsAllowed < 1 | |
) { | |
this.$el.find("#" + this.addRuleGroupButtonID + this.cid).remove(); | |
} | |
// Save the rules container element to the view before we add any nested | |
// QueryBuilders (nested FilterGroups), since their rules container uses the | |
// same selector. | |
this.rulesContainer = this.$el.find(this.rulesContainerSelector); | |
// If there is a FilterGroup model set on this view (not just a Filters | |
// collection) then render the inputs that allow a user to edit the "exclude" | |
// and "operator" attributes | |
if (this.filterGroup) { | |
this.renderExcludeOperatorInputs(); | |
} | |
// Add a row for each rule that exists already in the model | |
if ( | |
this.collection && | |
this.collection.models && | |
this.collection.models.length | |
) { | |
this.collection.models.forEach(function (model) { | |
this.addQueryRule(model); | |
}, this); | |
} | |
// Render a new Query Rule at the end | |
this.addQueryRule(); | |
return this; | |
} catch (e) { | |
console.error( | |
"Failed to render a Query Builder view, error message: ", | |
e, | |
); | |
} | |
}, | |
/** | |
* Insert two inputs: one that allows the user to edit the "exclude" attribute in | |
* the FilterGroup model by selecting either "include" or "exclude"; and a second | |
* that allows the user to edit the "operator" attribute in the FilterGroup model | |
* by selecting between "all" and "any". | |
* @since 2.17.0 | |
*/ | |
renderExcludeOperatorInputs: function () { | |
if (!this.filterGroup) { | |
console.log( | |
"A filterGroup model is required to edit the exclude and " + | |
"operator attributes in a Query Builder View.", | |
); | |
return; | |
} | |
// Select the elements in the template where the two inputs should be inserted | |
var excludeContainer = this.$el.find(this.excludeInputSelector); | |
var operatorContainer = this.$el.find(this.operatorInputSelector); | |
// Create the exclude input | |
var excludeInput = new SearchSelect({ | |
options: [ | |
{ | |
label: "Include", | |
value: "false", | |
description: | |
"Include all datasets with metadata that matches the rules" + | |
" that are set below.", | |
}, | |
{ | |
label: "Exclude", | |
value: "true", | |
description: | |
"Match any dataset except those with metadata that match" + | |
" the rules that are set below", | |
}, | |
], | |
allowMulti: false, | |
allowAdditions: false, | |
inputLabel: "", | |
selected: [this.filterGroup.get("exclude").toString()], | |
clearable: false, | |
}); | |
// Create the operator input | |
var operatorInput = new SearchSelect({ | |
options: [ | |
{ | |
label: "all", | |
value: "AND", | |
description: | |
"For a dataset to match, it must have metadata that " + | |
"matches every rule set below.", | |
}, | |
{ | |
label: "any", | |
value: "OR", | |
description: | |
"For a dataset to match, its metadata only needs to " + | |
"match one of the rules set below.", | |
}, | |
], | |
allowMulti: false, | |
allowAdditions: false, | |
inputLabel: "", | |
selected: [this.filterGroup.get("operator")], | |
clearable: false, | |
}); | |
// Update the FilterGroup model when the user changes the operator or exclude | |
// options. newValues will always be an Array, but since these inputs don't | |
// allow multiple selections (allowMulti: false), then there will only ever be | |
// one value. | |
this.stopListening(excludeInput.model); | |
this.listenTo( | |
excludeInput.model, | |
"change:selected", | |
function (_model, newValues) { | |
// Convert the string (necessary to be used as a value in SearchSelect) | |
// to a boolean. It should be "true" or "false". | |
var newExclude = newValues[0] == "true"; | |
this.filterGroup.set("exclude", newExclude); | |
}, | |
); | |
this.stopListening(operatorInput.model); | |
this.listenTo( | |
operatorInput, | |
"change:selected", | |
function (_model, newValues) { | |
this.filterGroup.set("operator", newValues[0]); | |
}, | |
); | |
// Render the inputs and insert them into the view. Replace the default text | |
// within the containers otherwise. | |
excludeContainer.html(excludeInput.render().el); | |
operatorContainer.html(operatorInput.render().el); | |
}, | |
/** | |
* Appends a new row (Query Rule View) to the end of the Query Builder | |
* | |
* @param {Filter|FilterGroup} filterModel The Filter model or FilterGroup model | |
* for which to create a rule. If none is provided, then a Filter group model | |
* will be created and added to the collection. | |
*/ | |
addQueryRule: function (filterModel) { | |
try { | |
// Ensure that the object passed to this function is a filter. When the "add | |
// rule" button is clicked, the Event object is passed to this function | |
// instead. If no filter model is provided, assume that this is a new rule | |
if ( | |
!filterModel || | |
(filterModel && !/filter/i.test(filterModel.type)) | |
) { | |
filterModel = this.collection.add({ | |
nodeName: "filter", | |
operator: "OR", | |
fieldsOperator: "OR", | |
}); | |
} | |
// Don't show invisible rules | |
if (filterModel.get("isInvisible")) { | |
return; | |
} | |
// insert QueryRuleView | |
var rule = new QueryRule({ | |
model: filterModel, | |
ruleColorPalette: this.ruleColorPalette, | |
excludeFields: this.excludeFields, | |
nestedExcludeFields: this.nestedExcludeFields, | |
specialFields: this.specialFields, | |
parentRule: this.parentRule, | |
nestedLevelsAllowed: this.nestedLevelsAllowed, | |
}); | |
// Insert and render the rule | |
this.rulesContainer.append(rule.el); | |
rule.render(); | |
// Add the rule to the list of rule sub-views | |
// TODO: is this really needed? are they removed when rule removed? | |
this.rules.push(rule); | |
} catch (e) { | |
console.error("Error adding a Query Rule, error message:", e); | |
} | |
}, | |
/** | |
* Exactly the same as {@link QueryBuilderView#addQueryRule}, except that if no | |
* model is provided to this function, then a FilterGroup model will be created | |
* instead of a Filter model. | |
* @param {FilterGroup} filterGroupModel | |
*/ | |
addQueryRuleGroup: function (filterGroupModel) { | |
try { | |
// Ensure that the object passed to this function is a filter. When the "add | |
// rule" button is clicked, the Event object is passed to this function | |
// instead. If no filter model is provided, assume that this is a new rule | |
if ( | |
!filterGroupModel || | |
(filterGroupModel && filterGroupModel.type != "FilterGroup") | |
) { | |
filterGroupModel = this.collection.add({ | |
filterType: "FilterGroup", | |
}); | |
} | |
this.addQueryRule(filterGroupModel); | |
} catch (error) { | |
console.log( | |
"Error adding a Query Rule Group in a Query Builder View. " + | |
"Error details: " + | |
error, | |
); | |
} | |
}, | |
}, | |
); | |
return QueryBuilderView; | |
}); |
🚫 [eslint] <no-var> reported by reviewdog 🐶
Unexpected var, use let or const instead.
metacatui/src/js/views/queryBuilder/QueryBuilderView.js
Lines 30 to 487 in 24e22d9
var QueryBuilderView = Backbone.View.extend( | |
/** @lends QueryBuilderView.prototype */ | |
{ | |
/** | |
* The type of View this is | |
* @type {string} | |
*/ | |
type: "QueryBuilderView", | |
/** | |
* The HTML class names for this view element | |
* @type {string} | |
*/ | |
className: "query-builder", | |
/** | |
* A JQuery selector for the element in the template that will contain the query | |
* rules | |
* @type {string} | |
*/ | |
rulesContainerSelector: ".rules-container", | |
/** | |
* An ID for the element in the template that a user should click to add a new | |
* rule. A unique ID will be appended to this ID, and the ID will be added to the | |
* template. | |
* @type {string} | |
* @since 2.17.0 | |
*/ | |
addRuleButtonID: "add-rule-", | |
/** | |
* An ID for the element in the template that a user should click to add a new | |
* rule group. A unique ID will be appended to this ID, and the ID will be added | |
* to the template. | |
* @type {string} | |
* @since 2.17.0 | |
*/ | |
addRuleGroupButtonID: "add-rule-group-", | |
/** | |
* A JQuery selector for the element in the template that will contain the input | |
* allowing a user to switch the exclude attribute from "include" to "exclude" | |
* (i.e. to switch between exclude:false and exclude:true in the filterGroup | |
* model.) | |
* @type {string} | |
* @since 2.17.0 | |
*/ | |
excludeInputSelector: ".exclude-input", | |
/** | |
* A JQuery selector for the element in the template that will contain the input | |
* allowing a user to switch the operator from "all" to "any" (i.e. to switch | |
* between operator:"AND" and exclude:"OR" in the filterGroup model.) | |
* @type {string} | |
* @since 2.17.0 | |
*/ | |
operatorInputSelector: ".operator-input", | |
/** | |
* The maximum number of levels nested Rule Groups (i.e. nested FilterGroup | |
* models) that a user is permitted to *build* in the Query Builder. If a | |
* Portal/Collection document is loaded into the Query Builder that has more than | |
* the maximum allowable nested levels, those levels will still be displayed. This | |
* only prevents the "Add Rule Group" button from being shown. | |
* @type {number} | |
* @since 2.17.0 | |
*/ | |
nestedLevelsAllowed: 1, | |
/** | |
* An array of hex color codes used to help distinguish between different rules | |
* @type {string[]} | |
*/ | |
ruleColorPalette: [ | |
"#44AA99", | |
"#137733", | |
"#c9a538", | |
"#CC6677", | |
"#882355", | |
"#AA4499", | |
"#332288", | |
], | |
/** | |
* Query fields to exclude in the metadata field selector of each Query Rule. This | |
* is a list of field names that exist in the query service index (i.e. Solr), but | |
* which should be hidden in the Query Builder | |
* @type {string[]} | |
*/ | |
excludeFields: [], | |
/** | |
* Query fields to exclude in the metadata field selector for any Query Rules that | |
* are in nested Query Builders (i.e. in nested Filter Groups). This is a list of | |
* field names that exist in the query service index (i.e. Solr), but which should | |
* be hidden in nested Query Builders | |
* @type {string[]} | |
*/ | |
nestedExcludeFields: [], | |
/** | |
* Query fields that do not exist in the query service index, but which we would | |
* like to show as options in the Query Builder field input. | |
* | |
* @type {SpecialField[]} | |
* | |
* @since 2.15.0 | |
*/ | |
specialFields: [], | |
/** | |
* A Filters collection that stores filters to be edited with this Query Builder, | |
* e.g. the definitionFilters in a Collection or Portal model. If a filterGroup is | |
* set, then collection doesn't necessarily need to be set, as the Filters | |
* collection from within the FilterGroup model will automatically be set on view. | |
* @type {Filters} | |
*/ | |
collection: null, | |
/** | |
* The FilterGroup model that stores the filters, the exclude attribute, and the | |
* group operator to be edited with this Query Builder. This does not need to be | |
* set; just a Filters collection can be set on the view instead, but then there | |
* will be no input to switch between the include & exclude and any & all, since | |
* these are the exclude and operator attributes on the filterGroup model. | |
* @type {FilterGroup} | |
* @since 2.17.0 | |
*/ | |
filterGroup: null, | |
/** | |
* The primary HTML template for this view | |
* @type {Underscore.template} | |
*/ | |
template: _.template(Template), | |
/** | |
* events - A function that specifies a set of DOM events that will be bound to | |
* methods on your View through Backbone.delegateEvents. | |
* @see {@link https://backbonejs.org/#View-events} | |
* | |
* @return {Object} The events hash | |
*/ | |
events: function () { | |
try { | |
var events = {}; | |
var addRuleAction = "click #" + this.addRuleButtonID + this.cid; | |
events[addRuleAction] = "addQueryRule"; | |
var addRuleGroupAction = | |
"click #" + this.addRuleGroupButtonID + this.cid; | |
events[addRuleGroupAction] = "addQueryRuleGroup"; | |
return events; | |
} catch (e) { | |
console.error( | |
"Failed to specify events for the Query Builder View," + | |
" error message: " + | |
e, | |
); | |
} | |
}, | |
/** | |
* The list of QueryRuleViews that are contained within this queryBuilder | |
* @type {QueryRuleView[]} | |
*/ | |
rules: [], | |
/** | |
* Creates a new QueryBuilderView | |
* @param {Object} options - A literal object with options to pass to the view | |
*/ | |
initialize: function (options) { | |
try { | |
// Get all the options and apply them to this view | |
if (typeof options == "object") { | |
var optionKeys = Object.keys(options); | |
_.each( | |
optionKeys, | |
function (key, i) { | |
this[key] = options[key]; | |
}, | |
this, | |
); | |
} | |
// If neither a Filters collection nor a FilterGroup model is provided in the | |
// options for this view, then create a new FilterGroup model and set it on | |
// the view. | |
if (!this.collection && !this.filterGroup) { | |
this.filterGroup = new FilterGroup(); | |
} | |
// If there is a FilterGroup model set, but no Filters collection, then use | |
// the Filters from within the FilterGroup model as the Filters collection. | |
if (!this.collection && this.filterGroup) { | |
this.collection = this.filterGroup.get("filters"); | |
} | |
} catch (e) { | |
console.error( | |
"Failed to initialize the Query Builder view, error message:", | |
e, | |
); | |
} | |
}, | |
/** | |
* render - Render the view | |
* | |
* @return {QueryBuilder} Returns the view | |
*/ | |
render: function () { | |
try { | |
// Ensure the query fields are cached for the Query Field Select View and the | |
// Query Rule View | |
if ( | |
typeof MetacatUI.queryFields === "undefined" || | |
MetacatUI.queryFields.length === 0 | |
) { | |
MetacatUI.queryFields = new QueryFields(); | |
this.listenToOnce(MetacatUI.queryFields, "sync", this.render); | |
MetacatUI.queryFields.fetch(); | |
return; | |
} | |
// Insert the template into the view | |
this.$el.html( | |
this.template({ | |
addRuleButtonID: this.addRuleButtonID + this.cid, | |
addRuleGroupButtonID: this.addRuleGroupButtonID + this.cid, | |
}), | |
); | |
// Nested Query Builders are used to display nested filterGroup models. | |
// They need to be styled slightly different from the parent Query Builder. | |
if (this.parentRule) { | |
this.$el.addClass("nested"); | |
} | |
// Remove the rule group button ID if no more nested Query Builders are | |
// allowed. | |
if ( | |
typeof this.nestedLevelsAllowed == "number" && | |
this.nestedLevelsAllowed < 1 | |
) { | |
this.$el.find("#" + this.addRuleGroupButtonID + this.cid).remove(); | |
} | |
// Save the rules container element to the view before we add any nested | |
// QueryBuilders (nested FilterGroups), since their rules container uses the | |
// same selector. | |
this.rulesContainer = this.$el.find(this.rulesContainerSelector); | |
// If there is a FilterGroup model set on this view (not just a Filters | |
// collection) then render the inputs that allow a user to edit the "exclude" | |
// and "operator" attributes | |
if (this.filterGroup) { | |
this.renderExcludeOperatorInputs(); | |
} | |
// Add a row for each rule that exists already in the model | |
if ( | |
this.collection && | |
this.collection.models && | |
this.collection.models.length | |
) { | |
this.collection.models.forEach(function (model) { | |
this.addQueryRule(model); | |
}, this); | |
} | |
// Render a new Query Rule at the end | |
this.addQueryRule(); | |
return this; | |
} catch (e) { | |
console.error( | |
"Failed to render a Query Builder view, error message: ", | |
e, | |
); | |
} | |
}, | |
/** | |
* Insert two inputs: one that allows the user to edit the "exclude" attribute in | |
* the FilterGroup model by selecting either "include" or "exclude"; and a second | |
* that allows the user to edit the "operator" attribute in the FilterGroup model | |
* by selecting between "all" and "any". | |
* @since 2.17.0 | |
*/ | |
renderExcludeOperatorInputs: function () { | |
if (!this.filterGroup) { | |
console.log( | |
"A filterGroup model is required to edit the exclude and " + | |
"operator attributes in a Query Builder View.", | |
); | |
return; | |
} | |
// Select the elements in the template where the two inputs should be inserted | |
var excludeContainer = this.$el.find(this.excludeInputSelector); | |
var operatorContainer = this.$el.find(this.operatorInputSelector); | |
// Create the exclude input | |
var excludeInput = new SearchSelect({ | |
options: [ | |
{ | |
label: "Include", | |
value: "false", | |
description: | |
"Include all datasets with metadata that matches the rules" + | |
" that are set below.", | |
}, | |
{ | |
label: "Exclude", | |
value: "true", | |
description: | |
"Match any dataset except those with metadata that match" + | |
" the rules that are set below", | |
}, | |
], | |
allowMulti: false, | |
allowAdditions: false, | |
inputLabel: "", | |
selected: [this.filterGroup.get("exclude").toString()], | |
clearable: false, | |
}); | |
// Create the operator input | |
var operatorInput = new SearchSelect({ | |
options: [ | |
{ | |
label: "all", | |
value: "AND", | |
description: | |
"For a dataset to match, it must have metadata that " + | |
"matches every rule set below.", | |
}, | |
{ | |
label: "any", | |
value: "OR", | |
description: | |
"For a dataset to match, its metadata only needs to " + | |
"match one of the rules set below.", | |
}, | |
], | |
allowMulti: false, | |
allowAdditions: false, | |
inputLabel: "", | |
selected: [this.filterGroup.get("operator")], | |
clearable: false, | |
}); | |
// Update the FilterGroup model when the user changes the operator or exclude | |
// options. newValues will always be an Array, but since these inputs don't | |
// allow multiple selections (allowMulti: false), then there will only ever be | |
// one value. | |
this.stopListening(excludeInput.model); | |
this.listenTo( | |
excludeInput.model, | |
"change:selected", | |
function (_model, newValues) { | |
// Convert the string (necessary to be used as a value in SearchSelect) | |
// to a boolean. It should be "true" or "false". | |
var newExclude = newValues[0] == "true"; | |
this.filterGroup.set("exclude", newExclude); | |
}, | |
); | |
this.stopListening(operatorInput.model); | |
this.listenTo( | |
operatorInput, | |
"change:selected", | |
function (_model, newValues) { | |
this.filterGroup.set("operator", newValues[0]); | |
}, | |
); | |
// Render the inputs and insert them into the view. Replace the default text | |
// within the containers otherwise. | |
excludeContainer.html(excludeInput.render().el); | |
operatorContainer.html(operatorInput.render().el); | |
}, | |
/** | |
* Appends a new row (Query Rule View) to the end of the Query Builder | |
* | |
* @param {Filter|FilterGroup} filterModel The Filter model or FilterGroup model | |
* for which to create a rule. If none is provided, then a Filter group model | |
* will be created and added to the collection. | |
*/ | |
addQueryRule: function (filterModel) { | |
try { | |
// Ensure that the object passed to this function is a filter. When the "add | |
// rule" button is clicked, the Event object is passed to this function | |
// instead. If no filter model is provided, assume that this is a new rule | |
if ( | |
!filterModel || | |
(filterModel && !/filter/i.test(filterModel.type)) | |
) { | |
filterModel = this.collection.add({ | |
nodeName: "filter", | |
operator: "OR", | |
fieldsOperator: "OR", | |
}); | |
} | |
// Don't show invisible rules | |
if (filterModel.get("isInvisible")) { | |
return; | |
} | |
// insert QueryRuleView | |
var rule = new QueryRule({ | |
model: filterModel, | |
ruleColorPalette: this.ruleColorPalette, | |
excludeFields: this.excludeFields, | |
nestedExcludeFields: this.nestedExcludeFields, | |
specialFields: this.specialFields, | |
parentRule: this.parentRule, | |
nestedLevelsAllowed: this.nestedLevelsAllowed, | |
}); | |
// Insert and render the rule | |
this.rulesContainer.append(rule.el); | |
rule.render(); | |
// Add the rule to the list of rule sub-views | |
// TODO: is this really needed? are they removed when rule removed? | |
this.rules.push(rule); | |
} catch (e) { | |
console.error("Error adding a Query Rule, error message:", e); | |
} | |
}, | |
/** | |
* Exactly the same as {@link QueryBuilderView#addQueryRule}, except that if no | |
* model is provided to this function, then a FilterGroup model will be created | |
* instead of a Filter model. | |
* @param {FilterGroup} filterGroupModel | |
*/ | |
addQueryRuleGroup: function (filterGroupModel) { | |
try { | |
// Ensure that the object passed to this function is a filter. When the "add | |
// rule" button is clicked, the Event object is passed to this function | |
// instead. If no filter model is provided, assume that this is a new rule | |
if ( | |
!filterGroupModel || | |
(filterGroupModel && filterGroupModel.type != "FilterGroup") | |
) { | |
filterGroupModel = this.collection.add({ | |
filterType: "FilterGroup", | |
}); | |
} | |
this.addQueryRule(filterGroupModel); | |
} catch (error) { | |
console.log( | |
"Error adding a Query Rule Group in a Query Builder View. " + | |
"Error details: " + | |
error, | |
); | |
} | |
}, | |
}, | |
); |
🚫 [eslint] <prefer-arrow-callback> reported by reviewdog 🐶
Unexpected function expression.
metacatui/src/js/views/searchSelect/AnnotationFilterView.js
Lines 1 to 676 in 24e22d9
define(["jquery", "underscore", "backbone", "bioportal"], function ( | |
$, | |
_, | |
Backbone, | |
Bioportal, | |
) { | |
/** | |
* @class AnnotationFilter | |
* @classdesc A view that renders an annotation filter interface, which uses | |
* the bioportal tree search to select ontology terms. | |
* @classcategory Views/SearchSelect | |
* @extends Backbone.View | |
* @constructor | |
* @since 2.14.0 | |
* @screenshot views/searchSelect/AnnotationFilterView.png | |
*/ | |
return Backbone.View.extend( | |
/** @lends AnnotationFilterView.prototype */ | |
{ | |
/** | |
* The type of View this is | |
* @type {string} | |
*/ | |
type: "AnnotationFilter", | |
/** | |
* The HTML class names for this view element | |
* @type {string} | |
*/ | |
className: "filter annotation-filter", | |
/** | |
* The selector for the element that will show/hide the annotation | |
* popover interface when clicked. Searches within body. | |
* @type {string} | |
*/ | |
popoverTriggerSelector: "", | |
/** | |
* If set to true, instead of showing the annotation tree interface in | |
* a popover, show it in a multi-select input interface, which allows | |
* the user to select multiple annotations. | |
* @type {boolean} | |
*/ | |
multiselect: false, | |
/** | |
* If true, this filter will be added to the query but will | |
* act in the "background", like a default filter | |
* @type {boolean} | |
* @since 2.22.0 | |
*/ | |
isInvisible: true, | |
/** | |
* If set to true, instead of showing the annotation tree interface in | |
* a popover, show it on the custom search filter interface, which allows | |
* the user to filter search based on the annotations. | |
* @type {boolean} | |
* @since 2.22.0 | |
*/ | |
useSearchSelect: false, | |
/** | |
* The acronym of the ontology or ontologies to render a tree from. | |
* | |
* Must be an ontology that's present on BioPortal. | |
* | |
* TODO: Test out comma-separated lists. How does that render? | |
* @type {string} | |
* @since 2.22.0 | |
*/ | |
defaultOntology: "ECSO", | |
/** | |
* The URL that indicates the concept where the tree should start | |
* @type {string} | |
*/ | |
defaultStartingRoot: | |
"http://ecoinformatics.org/oboe/oboe.1.2/oboe-core.owl#MeasurementType", | |
/** | |
* Creates a new AnnotationFilterView | |
* @param {Object} options - A literal object with options to pass to the view | |
*/ | |
initialize: function (options) { | |
try { | |
// Get all the options and apply them to this view | |
if (typeof options == "object") { | |
var optionKeys = Object.keys(options); | |
_.each( | |
optionKeys, | |
function (key, i) { | |
// Only override non-null values so we can pass in nulls and | |
// still trigger default behavior | |
if (typeof options[key] === "undefined") { | |
return; | |
} | |
this[key] = options[key]; | |
}, | |
this, | |
); | |
} | |
// Mix in defaults if needed | |
if (!this.ontology) { | |
this.ontology = this.defaultOntology; | |
this.startingRoot = this.defaultStartingRoot; | |
} | |
} catch (e) { | |
console.log( | |
"Failed to initialize an Annotation Filter View, error message:", | |
e, | |
); | |
} | |
}, | |
/** | |
* render - Render the view | |
* | |
* @return {AnnotationFilter} Returns the view | |
*/ | |
render: function () { | |
try { | |
if (!MetacatUI.appModel.get("bioportalAPIKey")) { | |
console.log( | |
"A bioportal key is required for the Annotation Filter View. Please set a key in the MetacatUI config. The view will not render.", | |
); | |
return; | |
} | |
var view = this; | |
if (view.multiselect || view.useSearchSelect) { | |
view.createMultiselect(); | |
} else { | |
view.setUpTree(); | |
view.createPopoverHTML(); | |
view.setListeners(); | |
} | |
return this; | |
} catch (e) { | |
console.log( | |
"Failed to render an Annotation Filter View, error message: " + e, | |
); | |
} | |
}, | |
/** | |
* setUpTree - Create the HTML for the annotation tree | |
*/ | |
setUpTree: function () { | |
try { | |
var view = this; | |
view.treeEl = $('<div id="bioportal-tree"></div>').NCBOTree({ | |
apikey: MetacatUI.appModel.get("bioportalAPIKey"), | |
ontology: view.ontology, | |
width: "400", | |
startingRoot: view.startingRoot, | |
}); | |
// Make an element that contains the tree and reset/jumpUp buttons | |
var buttonProps = | |
"data-trigger='hover' data-placement='top' data-container='body' style='margin-right: 3px'"; | |
view.treeContent = $("<div></div>"); | |
view.buttonContainer = $( | |
'<div class="ncbo-tree-buttons-container"></div>', | |
); | |
view.jumpUpButton = $( | |
"<button class='icon icon-level-up tooltip-this btn' id='jumpUp' data-title='Go up to parent' " + | |
buttonProps + | |
" ></button>", | |
); | |
view.resetButton = $( | |
"<button class='icon icon-undo tooltip-this btn' id='resetTree' data-title='Reset tree' " + | |
buttonProps + | |
" ></button>", | |
); | |
$(view.buttonContainer).append(view.jumpUpButton); | |
$(view.buttonContainer).append(view.resetButton); | |
$(view.treeContent).append(view.buttonContainer); | |
$(view.treeContent).append(view.treeEl); | |
} catch (e) { | |
console.log( | |
"Failed to set up an annotation tree, error message: " + e, | |
); | |
} | |
}, | |
/** | |
* createMultiselect - Create a searchable multi-select interface | |
* that includes an annotation filter tree. | |
*/ | |
createMultiselect: function () { | |
try { | |
var view = this; | |
require(["views/searchSelect/SearchSelectView"], function ( | |
SearchSelect, | |
) { | |
view.multiSelectView = new SearchSelect({ | |
placeholderText: view.placeholderText | |
? view.placeholderText | |
: "Search for or select a value", | |
icon: view.icon, | |
separatorText: view.separatorText, | |
inputLabel: view.inputLabel, | |
}); | |
view.$el.append(view.multiSelectView.el); | |
view.multiSelectView.render(); | |
// If there are pre-selected values, get the user-facing labels | |
// and then update the multiselect | |
if (view.selected && view.selected.length) { | |
view.getClassLabels.call(view, view.updateMultiselect); | |
} else { | |
// Otherwise, update the multi-select right away with tree element | |
view.updateMultiselect.call(view); | |
} | |
//Forward the separatorChanged event from the SearchSelectView to this AnnotationFilterView | |
//(perhaps this view should have been a subclass?) | |
view.multiSelectView.on("separatorChanged", (separatorText) => { | |
view.trigger("separatorChanged", separatorText); | |
}); | |
}); | |
} catch (e) { | |
console.log( | |
"Failed to create the multi-select interface for an Annotation Filter View, error message: " + | |
e, | |
); | |
} | |
}, | |
/** | |
* updateMultiselect - Functions to run once a SearchSelect view has | |
* been rendered and inserted into this view, and the labels for any | |
* pre-selected annotation values have been fetched. Updates the | |
* hidden menu of items and the selected items. | |
*/ | |
updateMultiselect: function () { | |
try { | |
var view = this; | |
// Check if this is the first time we are updating this multiselect. | |
// If it is, then don't trigger the event that updates the model, | |
// because nothing has changed. | |
if (view.updateMultiselectTimes === undefined) { | |
view.updateMultiselectTimes = 0; | |
} else { | |
view.updateMultiselectTimes++; | |
} | |
// Re-init the tree | |
view.setUpTree(); | |
// Re-render the multiselect menu with the new options. These options | |
// will be hidden from view, but they must be present in the DOM for | |
// the multi-select interface to function correctly. | |
// Add an empty item to the list of selected values, so that | |
// the dropdown menu is always expandable. | |
if (view.options === undefined) { | |
view.options = []; | |
} | |
view.options.push({ value: "" }); | |
view.multiSelectView.options = view.options; | |
view.multiSelectView.updateMenu(); | |
// Make sure the new menu is attached before updating list of selected | |
// annotations | |
setTimeout(function () { | |
var silent = view.updateMultiselectTimes === 0; | |
var newValues = _.reject(view.selected, function (val) { | |
return val === ""; | |
}); | |
view.multiSelectView.changeSelection(newValues, silent); | |
}, 25); | |
// Add the annotation tree to the menu content | |
view.multiSelectView.$el.find(".menu").append(view.treeContent); | |
view.searchInput = view.multiSelectView.$selectUI.find("input"); | |
// Simulate a search in the annotation tree when the user | |
// searches in the multiSelect interface | |
view.searchInput.on("keyup", function (e) { | |
var treeInput = view.treeContent.find("input.ncboAutocomplete"); | |
treeInput.val(e.target.value).keydown(); | |
}); | |
view.setListeners(); | |
} catch (e) { | |
console.log( | |
"Failed to update an annotation filter with selected values, error message: " + | |
e, | |
); | |
} | |
}, | |
/** | |
* getClassLabels - Given an array of bioontology IDs set in | |
* view.selected, query the bioontology API to find the user-friendly | |
* labels (prefLabels) | |
* | |
* @param {function} callback A function to call once the labels have | |
* been found (or not). The function will be called with the formatted | |
* response: an array with an object for each ID with the properties | |
* value (the original ID) and label (the user-friendly label, or the | |
* value again if no label was found) | |
*/ | |
getClassLabels: function (callback) { | |
try { | |
var view = this; | |
if (!view.selected || !view.selected.length) { | |
return; | |
} | |
const ontologyCollection = _.map(view.selected, function (id) { | |
return { | |
class: id, | |
ontology: | |
"http://data.bioontology.org/ontologies/" + view.ontology, | |
}; | |
}); | |
const bioData = JSON.stringify({ | |
"http://www.w3.org/2002/07/owl#Class": { | |
collection: ontologyCollection, | |
display: "prefLabel", | |
}, | |
}); | |
const formatResponse = function (response, success) { | |
if (view.options === undefined) { | |
view.options = []; | |
} | |
view.selected.forEach(function (item, index) { | |
if (success) { | |
var match = _.findWhere(response[Object.keys(response)[0]], { | |
"@id": item, | |
}); | |
} else { | |
var match = null; | |
} | |
view.options[index] = { | |
value: item, | |
label: match ? match.prefLabel : item, | |
}; | |
}); | |
}; | |
// Get the pre-selected values | |
$.ajax({ | |
type: "POST", | |
url: "http://data.bioontology.org/batch?display_context=false", | |
headers: { | |
Authorization: | |
"apikey token=" + MetacatUI.appModel.get("bioportalAPIKey"), | |
Accept: "application/json", | |
"Content-Type": "application/json", | |
}, | |
processData: false, | |
data: bioData, | |
crossDomain: true, | |
timeout: 5000, | |
success: function (response) { | |
formatResponse(response, true); | |
callback.call(view); | |
}, | |
error: function (response) { | |
console.log( | |
"Error finding class labels for the Annotation Filter, error response:", | |
response, | |
); | |
formatResponse(response, false); | |
callback.call(view); | |
}, | |
}); | |
} catch (e) { | |
console.log( | |
"Failed to fetch labels for bioontology IDs, error message: " + e, | |
); | |
} | |
}, | |
/** | |
* createPopoverHTML - Create the HTML for annotation filters that are | |
* displayed as a popup (e.g. in the search catalog) | |
* | |
* @return {type} description | |
*/ | |
createPopoverHTML: function () { | |
try { | |
var view = this; | |
$("body").append( | |
$('<div id="bioportal-popover" data-category="annotation"></div>'), | |
); | |
$(view.popoverTriggerSelector) | |
.popover({ | |
html: true, | |
placement: "bottom", | |
trigger: "manual", | |
content: view.treeContent, | |
container: "#bioportal-popover", | |
}) | |
.on("click", function () { | |
if ($($(this).data().popover.options.content).is(":visible")) { | |
// Detach the tree from the popover so it doesn't get removed by Bootstrap | |
$(this).data().popover.options.content.detach(); | |
// Hide the popover | |
$(this).popover("hide"); | |
} else { | |
// Get the popover content | |
var content = | |
$(this).data().popoverContent || | |
$(this).data().popover.options.content.detach(); | |
// Cache it | |
$(this).data({ | |
popoverContent: content, | |
}); | |
// Show the popover | |
$(this).popover("show"); | |
// Insert the tree into the popover content | |
$(this).data().popover.options.content = content; | |
// Ensure tooltips are activated | |
$(".tooltip-this").tooltip(); | |
} | |
}); | |
} catch (e) { | |
console.log( | |
"Failed to create popover HTML for an annotation filter, error message: " + | |
e, | |
); | |
} | |
}, | |
/** | |
* setListeners - Sets listeners on the tree elements. Must be run | |
* after the tree HTML is created. | |
*/ | |
setListeners: function () { | |
try { | |
var view = this; | |
view.treeEl.off(); | |
view.jumpUpButton.off(); | |
view.resetButton.off(); | |
view.treeEl.on( | |
"afterSelect", | |
function (event, classId, prefLabel, selectedNode) { | |
view.selectConcept.call( | |
view, | |
event, | |
classId, | |
prefLabel, | |
selectedNode, | |
); | |
}, | |
); | |
view.treeEl.on("afterJumpToClass", function (event, classId) { | |
view.afterJumpToClass.call(view, event, classId); | |
}); | |
view.treeEl.on("afterExpand", function () { | |
view.afterExpand.call(view); | |
}); | |
view.jumpUpButton.on("click", function () { | |
view.jumpUp.call(view); | |
}); | |
view.resetButton.on("click", function () { | |
view.resetTree.call(view); | |
}); | |
if (view.multiselect) { | |
view.treeEl.off("searchItemSelected"); | |
view.treeEl.on("searchItemSelected", function () { | |
view.searchInput.val(""); | |
}); | |
view.stopListening(view.multiSelectView, "changeSelection"); | |
view.listenTo( | |
view.multiSelectView, | |
"changeSelection", | |
function (newValues) { | |
// When values are removed, update the interface | |
if (newValues != view.selected) { | |
view.selected = newValues; | |
// So that the function doesn't trigger an endless loop | |
delete view.updateMultiselectTimes; | |
view.updateMultiselect(); | |
} | |
view.trigger("changeSelection", newValues); | |
}, | |
); | |
} | |
} catch (e) { | |
console.log( | |
"Failed to set listeners in an Annotation Filter View, error message: " + | |
e, | |
); | |
} | |
}, | |
/** | |
* selectConcept - Actions that are performed after the user selects | |
* a concept from the annotation tree interface. Triggers an event for | |
* any parent views, hides and resets the annotation popup. | |
* | |
* @param {object} event The "afterSelect" event | |
* @param {string} classId The ID for the selected concept (a URL) | |
* @param {string} prefLabel The label for the selected concept | |
* @param {jQuery} selectedNode The element that was clicked | |
*/ | |
selectConcept: function (event, classId, prefLabel, selectedNode) { | |
try { | |
var view = this; | |
// Get the concept info | |
var item = { | |
value: classId, | |
label: prefLabel, | |
filterLabel: prefLabel, | |
desc: "", | |
}; | |
// Trigger an event so that the parent view can update filters, etc. | |
view.trigger("annotationSelected", event, item); | |
// Hide the popover | |
if (!view.multiselect) { | |
var annotationFilterEl = $(view.popoverTriggerSelector); | |
annotationFilterEl.trigger("click"); | |
$(selectedNode).trigger("mouseout"); | |
view.resetTree(); | |
// Update the multi-select with the new options | |
} else { | |
view.options.push(item); | |
view.selected.push(item.value); | |
view.updateMultiselect(); | |
} | |
// Ensure tooltips are removed | |
$("body > .tooltip").remove(); | |
// Prevent default action | |
return false; | |
} catch (e) { | |
console.log( | |
"Failed to select an annotation concept, error message: " + e, | |
); | |
} | |
}, | |
/** | |
* afterExpand - Actions to perform when the user expands a concept in | |
* the tree | |
*/ | |
afterExpand: function () { | |
try { | |
// Ensure tooltips are activated | |
$(".tooltip-this").tooltip(); | |
} catch (e) { | |
console.log( | |
"Failed to initialize tooltips in the annotation filter, error message: " + | |
e, | |
); | |
} | |
}, | |
/** | |
* afterJumpToClass - Called when a user searches for and selects a | |
* concept from the search results | |
* | |
* @param {type} event The jump to class event | |
* @param {type} classId The ID for the selected concept (a URL) | |
*/ | |
afterJumpToClass: function (event, classId) { | |
try { | |
var view = this; | |
// Re-root the tree at this concept | |
var tree = view.treeEl.data("NCBOTree"); | |
var options = tree.options(); | |
$.extend(options, { | |
startingRoot: classId, | |
}); | |
// Force a re-render | |
tree.init(); | |
// Ensure the tooltips are activated | |
$(".tooltip-this").tooltip(); | |
} catch (e) { | |
console.log( | |
"Failed to re-render the annotation filter after jump to class, error message: " + | |
e, | |
); | |
} | |
}, | |
/** | |
* jumpUp - Jumps up to the parent concept in the UI | |
* | |
* @return {boolean} Returns false | |
*/ | |
jumpUp: function () { | |
try { | |
// Re-root the tree at the parent concept of the root | |
var view = this, | |
tree = view.treeEl.data("NCBOTree"), | |
options = tree.options(), | |
startingRoot = options.startingRoot; | |
if (startingRoot == view.startingRoot) { | |
return false; | |
} | |
var parentId = $( | |
"a[data-id='" + encodeURIComponent(startingRoot) + "'", | |
).attr("data-subclassof"); | |
// Re-root | |
$.extend(options, { | |
startingRoot: parentId, | |
}); | |
// Force a re-render | |
tree.init(); | |
// Ensure the tooltips are activated | |
$(".tooltip-this").tooltip(); | |
return false; | |
} catch (e) { | |
console.log( | |
"Failed to jump to parent concept in the annotation filter, error message: " + | |
e, | |
); | |
} | |
}, | |
/** | |
* resetTree - Collapse all expanded concepts | |
* | |
* @return {boolean} Returns false | |
*/ | |
resetTree: function () { | |
try { | |
var view = this; | |
// Re-root the tree at the original concept | |
var tree = view.treeEl.data("NCBOTree"); | |
var options = tree.options(); | |
// Re-root | |
$.extend(options, { | |
startingRoot: view.startingRoot, | |
}); | |
tree.changeOntology(view.ontology); | |
// Force a re-render | |
tree.init(); | |
// Ensure the tooltips are activated | |
$(".tooltip-this").tooltip(); | |
return false; | |
} catch (e) { | |
console.log( | |
"Failed to reset the annotation filter tree, error message: " + e, | |
); | |
} | |
}, | |
}, | |
); | |
}); |
🚫 [eslint] <object-shorthand> reported by reviewdog 🐶
Expected method shorthand.
metacatui/src/js/views/searchSelect/AnnotationFilterView.js
Lines 124 to 149 in 24e22d9
render: function () { | |
try { | |
if (!MetacatUI.appModel.get("bioportalAPIKey")) { | |
console.log( | |
"A bioportal key is required for the Annotation Filter View. Please set a key in the MetacatUI config. The view will not render.", | |
); | |
return; | |
} | |
var view = this; | |
if (view.multiselect || view.useSearchSelect) { | |
view.createMultiselect(); | |
} else { | |
view.setUpTree(); | |
view.createPopoverHTML(); | |
view.setListeners(); | |
} | |
return this; | |
} catch (e) { | |
console.log( | |
"Failed to render an Annotation Filter View, error message: " + e, | |
); | |
} | |
}, |
🚫 [eslint] <object-shorthand> reported by reviewdog 🐶
Expected method shorthand.
metacatui/src/js/views/searchSelect/AnnotationFilterView.js
Lines 197 to 235 in 24e22d9
createMultiselect: function () { | |
try { | |
var view = this; | |
require(["views/searchSelect/SearchSelectView"], function ( | |
SearchSelect, | |
) { | |
view.multiSelectView = new SearchSelect({ | |
placeholderText: view.placeholderText | |
? view.placeholderText | |
: "Search for or select a value", | |
icon: view.icon, | |
separatorText: view.separatorText, | |
inputLabel: view.inputLabel, | |
}); | |
view.$el.append(view.multiSelectView.el); | |
view.multiSelectView.render(); | |
// If there are pre-selected values, get the user-facing labels | |
// and then update the multiselect | |
if (view.selected && view.selected.length) { | |
view.getClassLabels.call(view, view.updateMultiselect); | |
} else { | |
// Otherwise, update the multi-select right away with tree element | |
view.updateMultiselect.call(view); | |
} | |
//Forward the separatorChanged event from the SearchSelectView to this AnnotationFilterView | |
//(perhaps this view should have been a subclass?) | |
view.multiSelectView.on("separatorChanged", (separatorText) => { | |
view.trigger("separatorChanged", separatorText); | |
}); | |
}); | |
} catch (e) { | |
console.log( | |
"Failed to create the multi-select interface for an Annotation Filter View, error message: " + | |
e, | |
); | |
} | |
}, |
🚫 [eslint] <vars-on-top> reported by reviewdog 🐶
All 'var' declarations must be at the top of the function scope.
var view = this; |
🚫 [eslint] <no-var> reported by reviewdog 🐶
Unexpected var, use let or const instead.
var view = this; |
🚫 [eslint] <import/no-dynamic-require> reported by reviewdog 🐶
Calls to require() should use string literals
metacatui/src/js/views/searchSelect/AnnotationFilterView.js
Lines 201 to 228 in 24e22d9
require(["views/searchSelect/SearchSelectView"], function ( | |
SearchSelect, | |
) { | |
view.multiSelectView = new SearchSelect({ | |
placeholderText: view.placeholderText | |
? view.placeholderText | |
: "Search for or select a value", | |
icon: view.icon, | |
separatorText: view.separatorText, | |
inputLabel: view.inputLabel, | |
}); | |
view.$el.append(view.multiSelectView.el); | |
view.multiSelectView.render(); | |
// If there are pre-selected values, get the user-facing labels | |
// and then update the multiselect | |
if (view.selected && view.selected.length) { | |
view.getClassLabels.call(view, view.updateMultiselect); | |
} else { | |
// Otherwise, update the multi-select right away with tree element | |
view.updateMultiselect.call(view); | |
} | |
//Forward the separatorChanged event from the SearchSelectView to this AnnotationFilterView | |
//(perhaps this view should have been a subclass?) | |
view.multiSelectView.on("separatorChanged", (separatorText) => { | |
view.trigger("separatorChanged", separatorText); | |
}); | |
}); |
Unexpected unnamed function.
require(["views/searchSelect/SearchSelectView"], function ( |
🚫 [eslint] <prefer-arrow-callback> reported by reviewdog 🐶
Unexpected function expression.
metacatui/src/js/views/searchSelect/AnnotationFilterView.js
Lines 201 to 228 in 24e22d9
require(["views/searchSelect/SearchSelectView"], function ( | |
SearchSelect, | |
) { | |
view.multiSelectView = new SearchSelect({ | |
placeholderText: view.placeholderText | |
? view.placeholderText | |
: "Search for or select a value", | |
icon: view.icon, | |
separatorText: view.separatorText, | |
inputLabel: view.inputLabel, | |
}); | |
view.$el.append(view.multiSelectView.el); | |
view.multiSelectView.render(); | |
// If there are pre-selected values, get the user-facing labels | |
// and then update the multiselect | |
if (view.selected && view.selected.length) { | |
view.getClassLabels.call(view, view.updateMultiselect); | |
} else { | |
// Otherwise, update the multi-select right away with tree element | |
view.updateMultiselect.call(view); | |
} | |
//Forward the separatorChanged event from the SearchSelectView to this AnnotationFilterView | |
//(perhaps this view should have been a subclass?) | |
view.multiSelectView.on("separatorChanged", (separatorText) => { | |
view.trigger("separatorChanged", separatorText); | |
}); | |
}); |
🚫 [eslint] <spaced-comment> reported by reviewdog 🐶
Expected exception block, space or tab after '//' in comment.
//Forward the separatorChanged event from the SearchSelectView to this AnnotationFilterView |
🚫 [eslint] <spaced-comment> reported by reviewdog 🐶
Expected exception block, space or tab after '//' in comment.
//(perhaps this view should have been a subclass?) |
@@ -90,10 +90,8 @@ require.config({ | |||
markdownTableToJson: MetacatUI.root + "/components/markdown-table-to-json", | |||
// Polyfill required for using dropzone with older browsers | |||
corejs: MetacatUI.root + "/components/core-js", |
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-template> reported by reviewdog 🐶
Unexpected string concatenation.
corejs: MetacatUI.root + "/components/core-js", | |
corejs: `${MetacatUI.root }/components/core-js`, |
MetacatUI.root + "/components/semanticUI/transition.min", | ||
semanticUIdropdown: MetacatUI.root + "/components/semanticUI/dropdown.min", | ||
// Custom semantic bundle used for searchable multi-select dropdown component | ||
semantic: `${MetacatUI.root}/js/common/Semantic`, | ||
// To make elements drag and drop, sortable | ||
sortable: MetacatUI.root + "/components/sortable.min", |
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-template> reported by reviewdog 🐶
Unexpected string concatenation.
sortable: MetacatUI.root + "/components/sortable.min", | |
sortable: `${MetacatUI.root }/components/sortable.min`, |
MetacatUI.root + "/components/semanticUI/transition.min", | ||
semanticUIdropdown: MetacatUI.root + "/components/semanticUI/dropdown.min", | ||
// Custom semantic bundle used for searchable multi-select dropdown component | ||
semantic: `${MetacatUI.root}/js/common/Semantic`, | ||
// To make elements drag and drop, sortable | ||
sortable: MetacatUI.root + "/components/sortable.min", | ||
//Cesium |
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.
//Cesium | |
// Cesium |
Note: this PR is based off of the yet-to-be merged PR #2477 and includes the commits from that PR. I will re-base it with develop once that branch is merged, for now, new commits comprise those AFTER July 25th ("Fix SearchSelect tests; format & lint fixes" |
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] <no-var> reported by reviewdog 🐶
Unexpected var, use let or const instead.
metacatui/src/js/views/queryBuilder/QueryBuilderView.js
Lines 30 to 487 in ac3bf1b
var QueryBuilderView = Backbone.View.extend( | |
/** @lends QueryBuilderView.prototype */ | |
{ | |
/** | |
* The type of View this is | |
* @type {string} | |
*/ | |
type: "QueryBuilderView", | |
/** | |
* The HTML class names for this view element | |
* @type {string} | |
*/ | |
className: "query-builder", | |
/** | |
* A JQuery selector for the element in the template that will contain the query | |
* rules | |
* @type {string} | |
*/ | |
rulesContainerSelector: ".rules-container", | |
/** | |
* An ID for the element in the template that a user should click to add a new | |
* rule. A unique ID will be appended to this ID, and the ID will be added to the | |
* template. | |
* @type {string} | |
* @since 2.17.0 | |
*/ | |
addRuleButtonID: "add-rule-", | |
/** | |
* An ID for the element in the template that a user should click to add a new | |
* rule group. A unique ID will be appended to this ID, and the ID will be added | |
* to the template. | |
* @type {string} | |
* @since 2.17.0 | |
*/ | |
addRuleGroupButtonID: "add-rule-group-", | |
/** | |
* A JQuery selector for the element in the template that will contain the input | |
* allowing a user to switch the exclude attribute from "include" to "exclude" | |
* (i.e. to switch between exclude:false and exclude:true in the filterGroup | |
* model.) | |
* @type {string} | |
* @since 2.17.0 | |
*/ | |
excludeInputSelector: ".exclude-input", | |
/** | |
* A JQuery selector for the element in the template that will contain the input | |
* allowing a user to switch the operator from "all" to "any" (i.e. to switch | |
* between operator:"AND" and exclude:"OR" in the filterGroup model.) | |
* @type {string} | |
* @since 2.17.0 | |
*/ | |
operatorInputSelector: ".operator-input", | |
/** | |
* The maximum number of levels nested Rule Groups (i.e. nested FilterGroup | |
* models) that a user is permitted to *build* in the Query Builder. If a | |
* Portal/Collection document is loaded into the Query Builder that has more than | |
* the maximum allowable nested levels, those levels will still be displayed. This | |
* only prevents the "Add Rule Group" button from being shown. | |
* @type {number} | |
* @since 2.17.0 | |
*/ | |
nestedLevelsAllowed: 1, | |
/** | |
* An array of hex color codes used to help distinguish between different rules | |
* @type {string[]} | |
*/ | |
ruleColorPalette: [ | |
"#44AA99", | |
"#137733", | |
"#c9a538", | |
"#CC6677", | |
"#882355", | |
"#AA4499", | |
"#332288", | |
], | |
/** | |
* Query fields to exclude in the metadata field selector of each Query Rule. This | |
* is a list of field names that exist in the query service index (i.e. Solr), but | |
* which should be hidden in the Query Builder | |
* @type {string[]} | |
*/ | |
excludeFields: [], | |
/** | |
* Query fields to exclude in the metadata field selector for any Query Rules that | |
* are in nested Query Builders (i.e. in nested Filter Groups). This is a list of | |
* field names that exist in the query service index (i.e. Solr), but which should | |
* be hidden in nested Query Builders | |
* @type {string[]} | |
*/ | |
nestedExcludeFields: [], | |
/** | |
* Query fields that do not exist in the query service index, but which we would | |
* like to show as options in the Query Builder field input. | |
* | |
* @type {SpecialField[]} | |
* | |
* @since 2.15.0 | |
*/ | |
specialFields: [], | |
/** | |
* A Filters collection that stores filters to be edited with this Query Builder, | |
* e.g. the definitionFilters in a Collection or Portal model. If a filterGroup is | |
* set, then collection doesn't necessarily need to be set, as the Filters | |
* collection from within the FilterGroup model will automatically be set on view. | |
* @type {Filters} | |
*/ | |
collection: null, | |
/** | |
* The FilterGroup model that stores the filters, the exclude attribute, and the | |
* group operator to be edited with this Query Builder. This does not need to be | |
* set; just a Filters collection can be set on the view instead, but then there | |
* will be no input to switch between the include & exclude and any & all, since | |
* these are the exclude and operator attributes on the filterGroup model. | |
* @type {FilterGroup} | |
* @since 2.17.0 | |
*/ | |
filterGroup: null, | |
/** | |
* The primary HTML template for this view | |
* @type {Underscore.template} | |
*/ | |
template: _.template(Template), | |
/** | |
* events - A function that specifies a set of DOM events that will be bound to | |
* methods on your View through Backbone.delegateEvents. | |
* @see {@link https://backbonejs.org/#View-events} | |
* | |
* @return {Object} The events hash | |
*/ | |
events: function () { | |
try { | |
var events = {}; | |
var addRuleAction = "click #" + this.addRuleButtonID + this.cid; | |
events[addRuleAction] = "addQueryRule"; | |
var addRuleGroupAction = | |
"click #" + this.addRuleGroupButtonID + this.cid; | |
events[addRuleGroupAction] = "addQueryRuleGroup"; | |
return events; | |
} catch (e) { | |
console.error( | |
"Failed to specify events for the Query Builder View," + | |
" error message: " + | |
e, | |
); | |
} | |
}, | |
/** | |
* The list of QueryRuleViews that are contained within this queryBuilder | |
* @type {QueryRuleView[]} | |
*/ | |
rules: [], | |
/** | |
* Creates a new QueryBuilderView | |
* @param {Object} options - A literal object with options to pass to the view | |
*/ | |
initialize: function (options) { | |
try { | |
// Get all the options and apply them to this view | |
if (typeof options == "object") { | |
var optionKeys = Object.keys(options); | |
_.each( | |
optionKeys, | |
function (key, i) { | |
this[key] = options[key]; | |
}, | |
this, | |
); | |
} | |
// If neither a Filters collection nor a FilterGroup model is provided in the | |
// options for this view, then create a new FilterGroup model and set it on | |
// the view. | |
if (!this.collection && !this.filterGroup) { | |
this.filterGroup = new FilterGroup(); | |
} | |
// If there is a FilterGroup model set, but no Filters collection, then use | |
// the Filters from within the FilterGroup model as the Filters collection. | |
if (!this.collection && this.filterGroup) { | |
this.collection = this.filterGroup.get("filters"); | |
} | |
} catch (e) { | |
console.error( | |
"Failed to initialize the Query Builder view, error message:", | |
e, | |
); | |
} | |
}, | |
/** | |
* render - Render the view | |
* | |
* @return {QueryBuilder} Returns the view | |
*/ | |
render: function () { | |
try { | |
// Ensure the query fields are cached for the Query Field Select View and the | |
// Query Rule View | |
if ( | |
typeof MetacatUI.queryFields === "undefined" || | |
MetacatUI.queryFields.length === 0 | |
) { | |
MetacatUI.queryFields = new QueryFields(); | |
this.listenToOnce(MetacatUI.queryFields, "sync", this.render); | |
MetacatUI.queryFields.fetch(); | |
return; | |
} | |
// Insert the template into the view | |
this.$el.html( | |
this.template({ | |
addRuleButtonID: this.addRuleButtonID + this.cid, | |
addRuleGroupButtonID: this.addRuleGroupButtonID + this.cid, | |
}), | |
); | |
// Nested Query Builders are used to display nested filterGroup models. | |
// They need to be styled slightly different from the parent Query Builder. | |
if (this.parentRule) { | |
this.$el.addClass("nested"); | |
} | |
// Remove the rule group button ID if no more nested Query Builders are | |
// allowed. | |
if ( | |
typeof this.nestedLevelsAllowed == "number" && | |
this.nestedLevelsAllowed < 1 | |
) { | |
this.$el.find("#" + this.addRuleGroupButtonID + this.cid).remove(); | |
} | |
// Save the rules container element to the view before we add any nested | |
// QueryBuilders (nested FilterGroups), since their rules container uses the | |
// same selector. | |
this.rulesContainer = this.$el.find(this.rulesContainerSelector); | |
// If there is a FilterGroup model set on this view (not just a Filters | |
// collection) then render the inputs that allow a user to edit the "exclude" | |
// and "operator" attributes | |
if (this.filterGroup) { | |
this.renderExcludeOperatorInputs(); | |
} | |
// Add a row for each rule that exists already in the model | |
if ( | |
this.collection && | |
this.collection.models && | |
this.collection.models.length | |
) { | |
this.collection.models.forEach(function (model) { | |
this.addQueryRule(model); | |
}, this); | |
} | |
// Render a new Query Rule at the end | |
this.addQueryRule(); | |
return this; | |
} catch (e) { | |
console.error( | |
"Failed to render a Query Builder view, error message: ", | |
e, | |
); | |
} | |
}, | |
/** | |
* Insert two inputs: one that allows the user to edit the "exclude" attribute in | |
* the FilterGroup model by selecting either "include" or "exclude"; and a second | |
* that allows the user to edit the "operator" attribute in the FilterGroup model | |
* by selecting between "all" and "any". | |
* @since 2.17.0 | |
*/ | |
renderExcludeOperatorInputs: function () { | |
if (!this.filterGroup) { | |
console.log( | |
"A filterGroup model is required to edit the exclude and " + | |
"operator attributes in a Query Builder View.", | |
); | |
return; | |
} | |
// Select the elements in the template where the two inputs should be inserted | |
var excludeContainer = this.$el.find(this.excludeInputSelector); | |
var operatorContainer = this.$el.find(this.operatorInputSelector); | |
// Create the exclude input | |
var excludeInput = new SearchSelect({ | |
options: [ | |
{ | |
label: "Include", | |
value: "false", | |
description: | |
"Include all datasets with metadata that matches the rules" + | |
" that are set below.", | |
}, | |
{ | |
label: "Exclude", | |
value: "true", | |
description: | |
"Match any dataset except those with metadata that match" + | |
" the rules that are set below", | |
}, | |
], | |
allowMulti: false, | |
allowAdditions: false, | |
inputLabel: "", | |
selected: [this.filterGroup.get("exclude").toString()], | |
clearable: false, | |
}); | |
// Create the operator input | |
var operatorInput = new SearchSelect({ | |
options: [ | |
{ | |
label: "all", | |
value: "AND", | |
description: | |
"For a dataset to match, it must have metadata that " + | |
"matches every rule set below.", | |
}, | |
{ | |
label: "any", | |
value: "OR", | |
description: | |
"For a dataset to match, its metadata only needs to " + | |
"match one of the rules set below.", | |
}, | |
], | |
allowMulti: false, | |
allowAdditions: false, | |
inputLabel: "", | |
selected: [this.filterGroup.get("operator")], | |
clearable: false, | |
}); | |
// Update the FilterGroup model when the user changes the operator or exclude | |
// options. newValues will always be an Array, but since these inputs don't | |
// allow multiple selections (allowMulti: false), then there will only ever be | |
// one value. | |
this.stopListening(excludeInput.model); | |
this.listenTo( | |
excludeInput.model, | |
"change:selected", | |
function (_model, newValues) { | |
// Convert the string (necessary to be used as a value in SearchSelect) | |
// to a boolean. It should be "true" or "false". | |
var newExclude = newValues[0] == "true"; | |
this.filterGroup.set("exclude", newExclude); | |
}, | |
); | |
this.stopListening(operatorInput.model); | |
this.listenTo( | |
operatorInput, | |
"change:selected", | |
function (_model, newValues) { | |
this.filterGroup.set("operator", newValues[0]); | |
}, | |
); | |
// Render the inputs and insert them into the view. Replace the default text | |
// within the containers otherwise. | |
excludeContainer.html(excludeInput.render().el); | |
operatorContainer.html(operatorInput.render().el); | |
}, | |
/** | |
* Appends a new row (Query Rule View) to the end of the Query Builder | |
* | |
* @param {Filter|FilterGroup} filterModel The Filter model or FilterGroup model | |
* for which to create a rule. If none is provided, then a Filter group model | |
* will be created and added to the collection. | |
*/ | |
addQueryRule: function (filterModel) { | |
try { | |
// Ensure that the object passed to this function is a filter. When the "add | |
// rule" button is clicked, the Event object is passed to this function | |
// instead. If no filter model is provided, assume that this is a new rule | |
if ( | |
!filterModel || | |
(filterModel && !/filter/i.test(filterModel.type)) | |
) { | |
filterModel = this.collection.add({ | |
nodeName: "filter", | |
operator: "OR", | |
fieldsOperator: "OR", | |
}); | |
} | |
// Don't show invisible rules | |
if (filterModel.get("isInvisible")) { | |
return; | |
} | |
// insert QueryRuleView | |
var rule = new QueryRule({ | |
model: filterModel, | |
ruleColorPalette: this.ruleColorPalette, | |
excludeFields: this.excludeFields, | |
nestedExcludeFields: this.nestedExcludeFields, | |
specialFields: this.specialFields, | |
parentRule: this.parentRule, | |
nestedLevelsAllowed: this.nestedLevelsAllowed, | |
}); | |
// Insert and render the rule | |
this.rulesContainer.append(rule.el); | |
rule.render(); | |
// Add the rule to the list of rule sub-views | |
// TODO: is this really needed? are they removed when rule removed? | |
this.rules.push(rule); | |
} catch (e) { | |
console.error("Error adding a Query Rule, error message:", e); | |
} | |
}, | |
/** | |
* Exactly the same as {@link QueryBuilderView#addQueryRule}, except that if no | |
* model is provided to this function, then a FilterGroup model will be created | |
* instead of a Filter model. | |
* @param {FilterGroup} filterGroupModel | |
*/ | |
addQueryRuleGroup: function (filterGroupModel) { | |
try { | |
// Ensure that the object passed to this function is a filter. When the "add | |
// rule" button is clicked, the Event object is passed to this function | |
// instead. If no filter model is provided, assume that this is a new rule | |
if ( | |
!filterGroupModel || | |
(filterGroupModel && filterGroupModel.type != "FilterGroup") | |
) { | |
filterGroupModel = this.collection.add({ | |
filterType: "FilterGroup", | |
}); | |
} | |
this.addQueryRule(filterGroupModel); | |
} catch (error) { | |
console.log( | |
"Error adding a Query Rule Group in a Query Builder View. " + | |
"Error details: " + | |
error, | |
); | |
} | |
}, | |
}, | |
); |
🚫 [eslint] <prefer-arrow-callback> reported by reviewdog 🐶
Unexpected function expression.
metacatui/src/js/views/searchSelect/AnnotationFilterView.js
Lines 1 to 676 in ac3bf1b
define(["jquery", "underscore", "backbone", "bioportal"], function ( | |
$, | |
_, | |
Backbone, | |
Bioportal, | |
) { | |
/** | |
* @class AnnotationFilter | |
* @classdesc A view that renders an annotation filter interface, which uses | |
* the bioportal tree search to select ontology terms. | |
* @classcategory Views/SearchSelect | |
* @extends Backbone.View | |
* @constructor | |
* @since 2.14.0 | |
* @screenshot views/searchSelect/AnnotationFilterView.png | |
*/ | |
return Backbone.View.extend( | |
/** @lends AnnotationFilterView.prototype */ | |
{ | |
/** | |
* The type of View this is | |
* @type {string} | |
*/ | |
type: "AnnotationFilter", | |
/** | |
* The HTML class names for this view element | |
* @type {string} | |
*/ | |
className: "filter annotation-filter", | |
/** | |
* The selector for the element that will show/hide the annotation | |
* popover interface when clicked. Searches within body. | |
* @type {string} | |
*/ | |
popoverTriggerSelector: "", | |
/** | |
* If set to true, instead of showing the annotation tree interface in | |
* a popover, show it in a multi-select input interface, which allows | |
* the user to select multiple annotations. | |
* @type {boolean} | |
*/ | |
multiselect: false, | |
/** | |
* If true, this filter will be added to the query but will | |
* act in the "background", like a default filter | |
* @type {boolean} | |
* @since 2.22.0 | |
*/ | |
isInvisible: true, | |
/** | |
* If set to true, instead of showing the annotation tree interface in | |
* a popover, show it on the custom search filter interface, which allows | |
* the user to filter search based on the annotations. | |
* @type {boolean} | |
* @since 2.22.0 | |
*/ | |
useSearchSelect: false, | |
/** | |
* The acronym of the ontology or ontologies to render a tree from. | |
* | |
* Must be an ontology that's present on BioPortal. | |
* | |
* TODO: Test out comma-separated lists. How does that render? | |
* @type {string} | |
* @since 2.22.0 | |
*/ | |
defaultOntology: "ECSO", | |
/** | |
* The URL that indicates the concept where the tree should start | |
* @type {string} | |
*/ | |
defaultStartingRoot: | |
"http://ecoinformatics.org/oboe/oboe.1.2/oboe-core.owl#MeasurementType", | |
/** | |
* Creates a new AnnotationFilterView | |
* @param {Object} options - A literal object with options to pass to the view | |
*/ | |
initialize: function (options) { | |
try { | |
// Get all the options and apply them to this view | |
if (typeof options == "object") { | |
var optionKeys = Object.keys(options); | |
_.each( | |
optionKeys, | |
function (key, i) { | |
// Only override non-null values so we can pass in nulls and | |
// still trigger default behavior | |
if (typeof options[key] === "undefined") { | |
return; | |
} | |
this[key] = options[key]; | |
}, | |
this, | |
); | |
} | |
// Mix in defaults if needed | |
if (!this.ontology) { | |
this.ontology = this.defaultOntology; | |
this.startingRoot = this.defaultStartingRoot; | |
} | |
} catch (e) { | |
console.log( | |
"Failed to initialize an Annotation Filter View, error message:", | |
e, | |
); | |
} | |
}, | |
/** | |
* render - Render the view | |
* | |
* @return {AnnotationFilter} Returns the view | |
*/ | |
render: function () { | |
try { | |
if (!MetacatUI.appModel.get("bioportalAPIKey")) { | |
console.log( | |
"A bioportal key is required for the Annotation Filter View. Please set a key in the MetacatUI config. The view will not render.", | |
); | |
return; | |
} | |
var view = this; | |
if (view.multiselect || view.useSearchSelect) { | |
view.createMultiselect(); | |
} else { | |
view.setUpTree(); | |
view.createPopoverHTML(); | |
view.setListeners(); | |
} | |
return this; | |
} catch (e) { | |
console.log( | |
"Failed to render an Annotation Filter View, error message: " + e, | |
); | |
} | |
}, | |
/** | |
* setUpTree - Create the HTML for the annotation tree | |
*/ | |
setUpTree: function () { | |
try { | |
var view = this; | |
view.treeEl = $('<div id="bioportal-tree"></div>').NCBOTree({ | |
apikey: MetacatUI.appModel.get("bioportalAPIKey"), | |
ontology: view.ontology, | |
width: "400", | |
startingRoot: view.startingRoot, | |
}); | |
// Make an element that contains the tree and reset/jumpUp buttons | |
var buttonProps = | |
"data-trigger='hover' data-placement='top' data-container='body' style='margin-right: 3px'"; | |
view.treeContent = $("<div></div>"); | |
view.buttonContainer = $( | |
'<div class="ncbo-tree-buttons-container"></div>', | |
); | |
view.jumpUpButton = $( | |
"<button class='icon icon-level-up tooltip-this btn' id='jumpUp' data-title='Go up to parent' " + | |
buttonProps + | |
" ></button>", | |
); | |
view.resetButton = $( | |
"<button class='icon icon-undo tooltip-this btn' id='resetTree' data-title='Reset tree' " + | |
buttonProps + | |
" ></button>", | |
); | |
$(view.buttonContainer).append(view.jumpUpButton); | |
$(view.buttonContainer).append(view.resetButton); | |
$(view.treeContent).append(view.buttonContainer); | |
$(view.treeContent).append(view.treeEl); | |
} catch (e) { | |
console.log( | |
"Failed to set up an annotation tree, error message: " + e, | |
); | |
} | |
}, | |
/** | |
* createMultiselect - Create a searchable multi-select interface | |
* that includes an annotation filter tree. | |
*/ | |
createMultiselect: function () { | |
try { | |
var view = this; | |
require(["views/searchSelect/SearchSelectView"], function ( | |
SearchSelect, | |
) { | |
view.multiSelectView = new SearchSelect({ | |
placeholderText: view.placeholderText | |
? view.placeholderText | |
: "Search for or select a value", | |
icon: view.icon, | |
separatorText: view.separatorText, | |
inputLabel: view.inputLabel, | |
}); | |
view.$el.append(view.multiSelectView.el); | |
view.multiSelectView.render(); | |
// If there are pre-selected values, get the user-facing labels | |
// and then update the multiselect | |
if (view.selected && view.selected.length) { | |
view.getClassLabels.call(view, view.updateMultiselect); | |
} else { | |
// Otherwise, update the multi-select right away with tree element | |
view.updateMultiselect.call(view); | |
} | |
//Forward the separatorChanged event from the SearchSelectView to this AnnotationFilterView | |
//(perhaps this view should have been a subclass?) | |
view.multiSelectView.on("separatorChanged", (separatorText) => { | |
view.trigger("separatorChanged", separatorText); | |
}); | |
}); | |
} catch (e) { | |
console.log( | |
"Failed to create the multi-select interface for an Annotation Filter View, error message: " + | |
e, | |
); | |
} | |
}, | |
/** | |
* updateMultiselect - Functions to run once a SearchSelect view has | |
* been rendered and inserted into this view, and the labels for any | |
* pre-selected annotation values have been fetched. Updates the | |
* hidden menu of items and the selected items. | |
*/ | |
updateMultiselect: function () { | |
try { | |
var view = this; | |
// Check if this is the first time we are updating this multiselect. | |
// If it is, then don't trigger the event that updates the model, | |
// because nothing has changed. | |
if (view.updateMultiselectTimes === undefined) { | |
view.updateMultiselectTimes = 0; | |
} else { | |
view.updateMultiselectTimes++; | |
} | |
// Re-init the tree | |
view.setUpTree(); | |
// Re-render the multiselect menu with the new options. These options | |
// will be hidden from view, but they must be present in the DOM for | |
// the multi-select interface to function correctly. | |
// Add an empty item to the list of selected values, so that | |
// the dropdown menu is always expandable. | |
if (view.options === undefined) { | |
view.options = []; | |
} | |
view.options.push({ value: "" }); | |
view.multiSelectView.options = view.options; | |
view.multiSelectView.updateMenu(); | |
// Make sure the new menu is attached before updating list of selected | |
// annotations | |
setTimeout(function () { | |
var silent = view.updateMultiselectTimes === 0; | |
var newValues = _.reject(view.selected, function (val) { | |
return val === ""; | |
}); | |
view.multiSelectView.changeSelection(newValues, silent); | |
}, 25); | |
// Add the annotation tree to the menu content | |
view.multiSelectView.$el.find(".menu").append(view.treeContent); | |
view.searchInput = view.multiSelectView.$selectUI.find("input"); | |
// Simulate a search in the annotation tree when the user | |
// searches in the multiSelect interface | |
view.searchInput.on("keyup", function (e) { | |
var treeInput = view.treeContent.find("input.ncboAutocomplete"); | |
treeInput.val(e.target.value).keydown(); | |
}); | |
view.setListeners(); | |
} catch (e) { | |
console.log( | |
"Failed to update an annotation filter with selected values, error message: " + | |
e, | |
); | |
} | |
}, | |
/** | |
* getClassLabels - Given an array of bioontology IDs set in | |
* view.selected, query the bioontology API to find the user-friendly | |
* labels (prefLabels) | |
* | |
* @param {function} callback A function to call once the labels have | |
* been found (or not). The function will be called with the formatted | |
* response: an array with an object for each ID with the properties | |
* value (the original ID) and label (the user-friendly label, or the | |
* value again if no label was found) | |
*/ | |
getClassLabels: function (callback) { | |
try { | |
var view = this; | |
if (!view.selected || !view.selected.length) { | |
return; | |
} | |
const ontologyCollection = _.map(view.selected, function (id) { | |
return { | |
class: id, | |
ontology: | |
"http://data.bioontology.org/ontologies/" + view.ontology, | |
}; | |
}); | |
const bioData = JSON.stringify({ | |
"http://www.w3.org/2002/07/owl#Class": { | |
collection: ontologyCollection, | |
display: "prefLabel", | |
}, | |
}); | |
const formatResponse = function (response, success) { | |
if (view.options === undefined) { | |
view.options = []; | |
} | |
view.selected.forEach(function (item, index) { | |
if (success) { | |
var match = _.findWhere(response[Object.keys(response)[0]], { | |
"@id": item, | |
}); | |
} else { | |
var match = null; | |
} | |
view.options[index] = { | |
value: item, | |
label: match ? match.prefLabel : item, | |
}; | |
}); | |
}; | |
// Get the pre-selected values | |
$.ajax({ | |
type: "POST", | |
url: "http://data.bioontology.org/batch?display_context=false", | |
headers: { | |
Authorization: | |
"apikey token=" + MetacatUI.appModel.get("bioportalAPIKey"), | |
Accept: "application/json", | |
"Content-Type": "application/json", | |
}, | |
processData: false, | |
data: bioData, | |
crossDomain: true, | |
timeout: 5000, | |
success: function (response) { | |
formatResponse(response, true); | |
callback.call(view); | |
}, | |
error: function (response) { | |
console.log( | |
"Error finding class labels for the Annotation Filter, error response:", | |
response, | |
); | |
formatResponse(response, false); | |
callback.call(view); | |
}, | |
}); | |
} catch (e) { | |
console.log( | |
"Failed to fetch labels for bioontology IDs, error message: " + e, | |
); | |
} | |
}, | |
/** | |
* createPopoverHTML - Create the HTML for annotation filters that are | |
* displayed as a popup (e.g. in the search catalog) | |
* | |
* @return {type} description | |
*/ | |
createPopoverHTML: function () { | |
try { | |
var view = this; | |
$("body").append( | |
$('<div id="bioportal-popover" data-category="annotation"></div>'), | |
); | |
$(view.popoverTriggerSelector) | |
.popover({ | |
html: true, | |
placement: "bottom", | |
trigger: "manual", | |
content: view.treeContent, | |
container: "#bioportal-popover", | |
}) | |
.on("click", function () { | |
if ($($(this).data().popover.options.content).is(":visible")) { | |
// Detach the tree from the popover so it doesn't get removed by Bootstrap | |
$(this).data().popover.options.content.detach(); | |
// Hide the popover | |
$(this).popover("hide"); | |
} else { | |
// Get the popover content | |
var content = | |
$(this).data().popoverContent || | |
$(this).data().popover.options.content.detach(); | |
// Cache it | |
$(this).data({ | |
popoverContent: content, | |
}); | |
// Show the popover | |
$(this).popover("show"); | |
// Insert the tree into the popover content | |
$(this).data().popover.options.content = content; | |
// Ensure tooltips are activated | |
$(".tooltip-this").tooltip(); | |
} | |
}); | |
} catch (e) { | |
console.log( | |
"Failed to create popover HTML for an annotation filter, error message: " + | |
e, | |
); | |
} | |
}, | |
/** | |
* setListeners - Sets listeners on the tree elements. Must be run | |
* after the tree HTML is created. | |
*/ | |
setListeners: function () { | |
try { | |
var view = this; | |
view.treeEl.off(); | |
view.jumpUpButton.off(); | |
view.resetButton.off(); | |
view.treeEl.on( | |
"afterSelect", | |
function (event, classId, prefLabel, selectedNode) { | |
view.selectConcept.call( | |
view, | |
event, | |
classId, | |
prefLabel, | |
selectedNode, | |
); | |
}, | |
); | |
view.treeEl.on("afterJumpToClass", function (event, classId) { | |
view.afterJumpToClass.call(view, event, classId); | |
}); | |
view.treeEl.on("afterExpand", function () { | |
view.afterExpand.call(view); | |
}); | |
view.jumpUpButton.on("click", function () { | |
view.jumpUp.call(view); | |
}); | |
view.resetButton.on("click", function () { | |
view.resetTree.call(view); | |
}); | |
if (view.multiselect) { | |
view.treeEl.off("searchItemSelected"); | |
view.treeEl.on("searchItemSelected", function () { | |
view.searchInput.val(""); | |
}); | |
view.stopListening(view.multiSelectView, "changeSelection"); | |
view.listenTo( | |
view.multiSelectView, | |
"changeSelection", | |
function (newValues) { | |
// When values are removed, update the interface | |
if (newValues != view.selected) { | |
view.selected = newValues; | |
// So that the function doesn't trigger an endless loop | |
delete view.updateMultiselectTimes; | |
view.updateMultiselect(); | |
} | |
view.trigger("changeSelection", newValues); | |
}, | |
); | |
} | |
} catch (e) { | |
console.log( | |
"Failed to set listeners in an Annotation Filter View, error message: " + | |
e, | |
); | |
} | |
}, | |
/** | |
* selectConcept - Actions that are performed after the user selects | |
* a concept from the annotation tree interface. Triggers an event for | |
* any parent views, hides and resets the annotation popup. | |
* | |
* @param {object} event The "afterSelect" event | |
* @param {string} classId The ID for the selected concept (a URL) | |
* @param {string} prefLabel The label for the selected concept | |
* @param {jQuery} selectedNode The element that was clicked | |
*/ | |
selectConcept: function (event, classId, prefLabel, selectedNode) { | |
try { | |
var view = this; | |
// Get the concept info | |
var item = { | |
value: classId, | |
label: prefLabel, | |
filterLabel: prefLabel, | |
desc: "", | |
}; | |
// Trigger an event so that the parent view can update filters, etc. | |
view.trigger("annotationSelected", event, item); | |
// Hide the popover | |
if (!view.multiselect) { | |
var annotationFilterEl = $(view.popoverTriggerSelector); | |
annotationFilterEl.trigger("click"); | |
$(selectedNode).trigger("mouseout"); | |
view.resetTree(); | |
// Update the multi-select with the new options | |
} else { | |
view.options.push(item); | |
view.selected.push(item.value); | |
view.updateMultiselect(); | |
} | |
// Ensure tooltips are removed | |
$("body > .tooltip").remove(); | |
// Prevent default action | |
return false; | |
} catch (e) { | |
console.log( | |
"Failed to select an annotation concept, error message: " + e, | |
); | |
} | |
}, | |
/** | |
* afterExpand - Actions to perform when the user expands a concept in | |
* the tree | |
*/ | |
afterExpand: function () { | |
try { | |
// Ensure tooltips are activated | |
$(".tooltip-this").tooltip(); | |
} catch (e) { | |
console.log( | |
"Failed to initialize tooltips in the annotation filter, error message: " + | |
e, | |
); | |
} | |
}, | |
/** | |
* afterJumpToClass - Called when a user searches for and selects a | |
* concept from the search results | |
* | |
* @param {type} event The jump to class event | |
* @param {type} classId The ID for the selected concept (a URL) | |
*/ | |
afterJumpToClass: function (event, classId) { | |
try { | |
var view = this; | |
// Re-root the tree at this concept | |
var tree = view.treeEl.data("NCBOTree"); | |
var options = tree.options(); | |
$.extend(options, { | |
startingRoot: classId, | |
}); | |
// Force a re-render | |
tree.init(); | |
// Ensure the tooltips are activated | |
$(".tooltip-this").tooltip(); | |
} catch (e) { | |
console.log( | |
"Failed to re-render the annotation filter after jump to class, error message: " + | |
e, | |
); | |
} | |
}, | |
/** | |
* jumpUp - Jumps up to the parent concept in the UI | |
* | |
* @return {boolean} Returns false | |
*/ | |
jumpUp: function () { | |
try { | |
// Re-root the tree at the parent concept of the root | |
var view = this, | |
tree = view.treeEl.data("NCBOTree"), | |
options = tree.options(), | |
startingRoot = options.startingRoot; | |
if (startingRoot == view.startingRoot) { | |
return false; | |
} | |
var parentId = $( | |
"a[data-id='" + encodeURIComponent(startingRoot) + "'", | |
).attr("data-subclassof"); | |
// Re-root | |
$.extend(options, { | |
startingRoot: parentId, | |
}); | |
// Force a re-render | |
tree.init(); | |
// Ensure the tooltips are activated | |
$(".tooltip-this").tooltip(); | |
return false; | |
} catch (e) { | |
console.log( | |
"Failed to jump to parent concept in the annotation filter, error message: " + | |
e, | |
); | |
} | |
}, | |
/** | |
* resetTree - Collapse all expanded concepts | |
* | |
* @return {boolean} Returns false | |
*/ | |
resetTree: function () { | |
try { | |
var view = this; | |
// Re-root the tree at the original concept | |
var tree = view.treeEl.data("NCBOTree"); | |
var options = tree.options(); | |
// Re-root | |
$.extend(options, { | |
startingRoot: view.startingRoot, | |
}); | |
tree.changeOntology(view.ontology); | |
// Force a re-render | |
tree.init(); | |
// Ensure the tooltips are activated | |
$(".tooltip-this").tooltip(); | |
return false; | |
} catch (e) { | |
console.log( | |
"Failed to reset the annotation filter tree, error message: " + e, | |
); | |
} | |
}, | |
}, | |
); | |
}); |
🚫 [eslint] <object-shorthand> reported by reviewdog 🐶
Expected method shorthand.
metacatui/src/js/views/searchSelect/AnnotationFilterView.js
Lines 124 to 149 in ac3bf1b
render: function () { | |
try { | |
if (!MetacatUI.appModel.get("bioportalAPIKey")) { | |
console.log( | |
"A bioportal key is required for the Annotation Filter View. Please set a key in the MetacatUI config. The view will not render.", | |
); | |
return; | |
} | |
var view = this; | |
if (view.multiselect || view.useSearchSelect) { | |
view.createMultiselect(); | |
} else { | |
view.setUpTree(); | |
view.createPopoverHTML(); | |
view.setListeners(); | |
} | |
return this; | |
} catch (e) { | |
console.log( | |
"Failed to render an Annotation Filter View, error message: " + e, | |
); | |
} | |
}, |
🚫 [eslint] <object-shorthand> reported by reviewdog 🐶
Expected method shorthand.
metacatui/src/js/views/searchSelect/AnnotationFilterView.js
Lines 197 to 235 in ac3bf1b
createMultiselect: function () { | |
try { | |
var view = this; | |
require(["views/searchSelect/SearchSelectView"], function ( | |
SearchSelect, | |
) { | |
view.multiSelectView = new SearchSelect({ | |
placeholderText: view.placeholderText | |
? view.placeholderText | |
: "Search for or select a value", | |
icon: view.icon, | |
separatorText: view.separatorText, | |
inputLabel: view.inputLabel, | |
}); | |
view.$el.append(view.multiSelectView.el); | |
view.multiSelectView.render(); | |
// If there are pre-selected values, get the user-facing labels | |
// and then update the multiselect | |
if (view.selected && view.selected.length) { | |
view.getClassLabels.call(view, view.updateMultiselect); | |
} else { | |
// Otherwise, update the multi-select right away with tree element | |
view.updateMultiselect.call(view); | |
} | |
//Forward the separatorChanged event from the SearchSelectView to this AnnotationFilterView | |
//(perhaps this view should have been a subclass?) | |
view.multiSelectView.on("separatorChanged", (separatorText) => { | |
view.trigger("separatorChanged", separatorText); | |
}); | |
}); | |
} catch (e) { | |
console.log( | |
"Failed to create the multi-select interface for an Annotation Filter View, error message: " + | |
e, | |
); | |
} | |
}, |
🚫 [eslint] <import/no-dynamic-require> reported by reviewdog 🐶
Calls to require() should use string literals
metacatui/src/js/views/searchSelect/AnnotationFilterView.js
Lines 201 to 228 in ac3bf1b
require(["views/searchSelect/SearchSelectView"], function ( | |
SearchSelect, | |
) { | |
view.multiSelectView = new SearchSelect({ | |
placeholderText: view.placeholderText | |
? view.placeholderText | |
: "Search for or select a value", | |
icon: view.icon, | |
separatorText: view.separatorText, | |
inputLabel: view.inputLabel, | |
}); | |
view.$el.append(view.multiSelectView.el); | |
view.multiSelectView.render(); | |
// If there are pre-selected values, get the user-facing labels | |
// and then update the multiselect | |
if (view.selected && view.selected.length) { | |
view.getClassLabels.call(view, view.updateMultiselect); | |
} else { | |
// Otherwise, update the multi-select right away with tree element | |
view.updateMultiselect.call(view); | |
} | |
//Forward the separatorChanged event from the SearchSelectView to this AnnotationFilterView | |
//(perhaps this view should have been a subclass?) | |
view.multiSelectView.on("separatorChanged", (separatorText) => { | |
view.trigger("separatorChanged", separatorText); | |
}); | |
}); |
🚫 [eslint] <prefer-arrow-callback> reported by reviewdog 🐶
Unexpected function expression.
metacatui/src/js/views/searchSelect/AnnotationFilterView.js
Lines 201 to 228 in ac3bf1b
require(["views/searchSelect/SearchSelectView"], function ( | |
SearchSelect, | |
) { | |
view.multiSelectView = new SearchSelect({ | |
placeholderText: view.placeholderText | |
? view.placeholderText | |
: "Search for or select a value", | |
icon: view.icon, | |
separatorText: view.separatorText, | |
inputLabel: view.inputLabel, | |
}); | |
view.$el.append(view.multiSelectView.el); | |
view.multiSelectView.render(); | |
// If there are pre-selected values, get the user-facing labels | |
// and then update the multiselect | |
if (view.selected && view.selected.length) { | |
view.getClassLabels.call(view, view.updateMultiselect); | |
} else { | |
// Otherwise, update the multi-select right away with tree element | |
view.updateMultiselect.call(view); | |
} | |
//Forward the separatorChanged event from the SearchSelectView to this AnnotationFilterView | |
//(perhaps this view should have been a subclass?) | |
view.multiSelectView.on("separatorChanged", (separatorText) => { | |
view.trigger("separatorChanged", separatorText); | |
}); | |
}); |
For searching for ontology classes in batches via the bioportal API Issue #1654
- Includes formatting & linting fixes - Standardize the behaviour of fetching remote info for preselected values in SsearchSelect view - Use the SearchSelectOptions view in the SearchSelect view - Remove unused view property from BioOntologyBrowserView Issue #1654
Instead of Annotation Filter View Issue #1654
- Add reset method to SearchSelectView - Allow setting the desired ontologies to search within from init of Bioont. Select View. - Remove unused imports from FilterGroupView Issue #1654
Add icon to bioontology button Issue #1654
- This adds a caching mechanism for the results from BioPortal - Also add Util methods required by BioontologyResults Issue #1654
- Instead of a generic Backbone collection - Add method for fetching ontology details Issue #1654
- Use the BioontologyResults collection in the BioontologyBatch model so we can cache results from BioPortal - Add the `bioportalOntologies` config option in the AppModel, set to some ontologies that are used currently. Issue #1654
- Make icon option for category headers in search select - Reduce size of headers in compact mode in search select Issue #1654
Slows down everything if called too often Issue #1654
SemanticUI's modal module conflicts with the Bootstrap modal that we use Use the Boostrap modal in BioontologySelectView instead Issue #1654
Prevent button from spilling onto new row Issue #1654
in BioontologySelectView Issue #1654
Issue #1654
28ba57e
to
a76b32e
Compare
|
||
return json; | ||
}, | ||
|
||
/** | ||
* Convert number of bytes into human readable format | ||
* @param integer bytes Number of bytes to convert |
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.
Missing JSDoc @param "integer" type.
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.
Thank you for your hard work @robyngit to get this feature ready, it looks fantastic! Excited to see it live in production. 🎉🥳🎊
This PR introduces two new views that allow searching for & browsing through multiple ontologies at once. They can be configured to use any ontology in the Bioportal, but default to the set configured in a new property in the appModel.
The BioontologySelectView shows only the classes that exist in the catalog, grouped by ontology. Labels for these ontologies are fetched from the Bioportal API and cached in local storage.
Next to the BioontologySelectView, there is a button to open the BioontologyBrowserView in a modal. This view shows all classes within each configured ontology in an accordion (tree-like) view. As the user clicks on a class, a small panel at the bottom shows the class description and links to the class on the Bioportal. They are able to select a class here and add it to the SearchSelect. The BioontologyBrowserView can also be used independently in other views.
BioontologySelectView is used in place of the now deprecated AnnotationFilterView. Here is where the views are currently used:
sem_annotation
is selected as the query fieldsem_annotation
is selected as the only query field for a filterOther changes: