From 9d1ff5d117db3f9cadbf69a0282677f1b3d46189 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 20 Jul 2023 19:58:27 -0400 Subject: [PATCH 01/14] Add models, views, collections for Missing Values - Enables entering missing value codes in the Attribute editor - Works but lacks styling & unit tests Issue #612 --- .../metadata/eml/EMLMissingValueCodes.js | 83 +++++++ src/js/models/metadata/eml211/EMLAttribute.js | 53 +++- .../metadata/eml211/EMLMissingValueCode.js | 140 +++++++++++ src/js/templates/metadata/eml-attribute.html | 3 + .../views/metadata/EML211MissingValueView.js | 229 ++++++++++++++++++ .../views/metadata/EML211MissingValuesView.js | 156 ++++++++++++ src/js/views/metadata/EMLAttributeView.js | 10 + 7 files changed, 668 insertions(+), 6 deletions(-) create mode 100644 src/js/collections/metadata/eml/EMLMissingValueCodes.js create mode 100644 src/js/models/metadata/eml211/EMLMissingValueCode.js create mode 100644 src/js/views/metadata/EML211MissingValueView.js create mode 100644 src/js/views/metadata/EML211MissingValuesView.js diff --git a/src/js/collections/metadata/eml/EMLMissingValueCodes.js b/src/js/collections/metadata/eml/EMLMissingValueCodes.js new file mode 100644 index 000000000..30f09391a --- /dev/null +++ b/src/js/collections/metadata/eml/EMLMissingValueCodes.js @@ -0,0 +1,83 @@ +"use strict"; + +define(["backbone", "models/metadata/eml211/EMLMissingValueCode"], function ( + Backbone, + EMLMissingValueCode +) { + /** + * @class EMLMissingValueCodes + * @classdesc A collection of EMLMissingValueCodes. + * @classcategory Collections/Metadata/EML + * @since x.x.x + */ + var EMLMissingValueCodes = Backbone.Collection.extend( + /** @lends EMLMissingValueCodes.prototype */ + { + /** + * The reference to the model class that this collection is made of. + * @type {Backbone.Model} + */ + model: EMLMissingValueCode, + + /** + * Parse the incoming XML nodes + * @param {jQuery|Element} objectDOM - The XML DOM element that represents + */ + parse: function (objectDOM) { + const collection = this; + + if (!objectDOM) return; + const $objectDOM = $(objectDOM); + + // Loop through each missingValueCode node + const opts = { parse: true }; + for (var i = 0; i < $objectDOM.length; i++) { + const missingValueCodeNode = $objectDOM[i]; + // Create a new missingValueCode model & add it to the collection + const attrs = { objectDOM: missingValueCodeNode }; + const missingValueCode = new EMLMissingValueCode(attrs, opts); + collection.add(missingValueCode); + } + + return collection; + }, + + /** + * Update the DOM with the current model state for each model in the + * collection, then return the set of updated DOMs. Warning: this will + * remove any empty models from the collection. + * @returns {Element[]} An array of updated DOM elements + */ + updateDOM: function () { + this.removeEmptyModels(); + const objectDOMs = this.map((model) => model.updateDOM()); + return objectDOMs; + }, + + /** + * Remove any empty models from the collection + */ + removeEmptyModels: function () { + this.remove(this.filter((model) => model.isEmpty())); + }, + + /** + * Validate the collection of missing value codes. This will remove any + * empty models from the collection. + * @returns {Array} An array of error messages + */ + validate: function () { + this.removeEmptyModels(); + const errors = []; + this.forEach((model) => { + if (!model.isValid()) { + errors.push(model.validationError); + } + }); + return errors.length ? errors : null; + }, + } + ); + + return EMLMissingValueCodes; +}); diff --git a/src/js/models/metadata/eml211/EMLAttribute.js b/src/js/models/metadata/eml211/EMLAttribute.js index a4533848d..47e993b38 100644 --- a/src/js/models/metadata/eml211/EMLAttribute.js +++ b/src/js/models/metadata/eml211/EMLAttribute.js @@ -1,7 +1,9 @@ define(["jquery", "underscore", "backbone", "uuid", "models/metadata/eml211/EMLMeasurementScale", "models/metadata/eml211/EMLAnnotation", + "collections/metadata/eml/EMLMissingValueCodes", "models/DataONEObject"], - function($, _, Backbone, uuid, EMLMeasurementScale, EMLAnnotation, + function ($, _, Backbone, uuid, EMLMeasurementScale, EMLAnnotation, + EMLMissingValueCodes, DataONEObject) { /** @@ -25,7 +27,7 @@ define(["jquery", "underscore", "backbone", "uuid", storageType: [], // Zero or more storage types typeSystem: [], // Zero or more system types for storage type measurementScale: null, // An EML{Non}NumericDomain or EMLDateTimeDomain object - missingValueCode: [], // Zero or more {code: value, definition: value} objects + missingValueCodes: new EMLMissingValueCodes(), // An EMLMissingValueCodes collection accuracy: null, // An EMLAccuracy object coverage: null, // an EMLCoverage object methods: [], // Zero or more EMLMethods objects @@ -72,14 +74,33 @@ define(["jquery", "underscore", "backbone", "uuid", /* Initialize an EMLAttribute object */ initialize: function(attributes, options) { + + if (!attributes) { + var attributes = {}; + } + // If initialized with missingValueCode as an array, convert it to a collection + if ( + attributes.missingValueCodes && + attributes.missingValueCodes instanceof Array + ) { + this.missingValueCodes = + new EMLMissingValueCodes(attributes.missingValueCode); + } + + this.stopListening(this.get("missingValueCodes")); + this.listenTo( + this.get("missingValueCodes"), + "update", + this.trickleUpChange + ) this.on( "change:attributeName " + "change:attributeLabel " + "change:attributeDefinition " + "change:storageType " + "change:measurementScale " + - "change:missingValueCode " + + "change:missingValueCodes " + "change:accuracy " + "change:coverage " + "change:methods " + @@ -130,7 +151,6 @@ define(["jquery", "underscore", "backbone", "uuid", attributes.typeSystem.push(type || null); }); - var measurementScale = $objectDOM.find("measurementscale")[0]; if ( measurementScale ) { attributes.measurementScale = @@ -151,6 +171,12 @@ define(["jquery", "underscore", "backbone", "uuid", attributes.annotation.push(annotation); }, this); + // Add the missingValueCodes as a collection + attributes.missingValueCodes = new EMLMissingValueCodes(); + attributes.missingValueCodes.parse( + $objectDOM.children("missingvaluecode") + ); + attributes.objectDOM = $objectDOM[0]; return attributes; @@ -187,7 +213,6 @@ define(["jquery", "underscore", "backbone", "uuid", // This is new, create it } else { objectDOM = document.createElement(type); - } // update the id attribute @@ -291,7 +316,7 @@ define(["jquery", "underscore", "backbone", "uuid", } } } - //If there is no attirbute definition, then return an empty String + // If there is no attribute definition, then return an empty String // because it is invalid else{ return ""; @@ -390,6 +415,22 @@ define(["jquery", "underscore", "backbone", "uuid", $(after).after(anno.updateDOM()); }, this); + // Update the missingValueCodes + nodeToInsertAfter = undefined; + var missingValueCodes = this.get("missingValueCodes"); + $(objectDOM).children("missingvaluecode").remove(); + if (missingValueCodes) { + var missingValueCodeNodes = missingValueCodes.updateDOM(); + if (missingValueCodeNodes) { + nodeToInsertAfter = this.getEMLPosition(objectDOM, "missingValueCode"); + if (typeof nodeToInsertAfter === "undefined") { + $(objectDOM).append(missingValueCodeNodes); + } else { + $(nodeToInsertAfter).after(missingValueCodeNodes); + } + } + } + return objectDOM; }, diff --git a/src/js/models/metadata/eml211/EMLMissingValueCode.js b/src/js/models/metadata/eml211/EMLMissingValueCode.js new file mode 100644 index 000000000..139b53332 --- /dev/null +++ b/src/js/models/metadata/eml211/EMLMissingValueCode.js @@ -0,0 +1,140 @@ +define(["backbone"], function (Backbone) { + /** + * @class EMLMissingValueCode + * @classdesc A missing value code is a code that is used to indicate + * that a value is missing from the data. + * @see https://eml.ecoinformatics.org/schema/eml-attribute_xsd.html + * @classcategory Models/Metadata/EML211 + * @since x.x.x + */ + var EMLMissingValueCode = Backbone.Model.extend( + /** @lends EMLMissingValueCode.prototype */ { + /** + * The default attributes for an EMLMissingValueCode model. + * @returns {object} The default attributes + * @property {string} type - The element type in the DOM + * @property {string} code - The missing value code + * @property {string} codeExplanation - The explanation for the missing value code + * @property {string[]} nodeOrder - The order of the EML nodes in this object + */ + defaults: function () { + return { + type: "missingValueCode", + code: "", + codeExplanation: "", + nodeOrder: ["code", "codeExplanation"], + }; + }, + + /* + * Parse the incoming attribute's XML elements. + */ + parse: function (attributes, options) { + if (!attributes) return {}; + const objectDOM = attributes.objectDOM || attributes.objectXML; + if (!objectDOM) return {}; + const $objectDOM = $(objectDOM); + + this.defaults().nodeOrder.forEach((node) => { + attributes[node] = $objectDOM.children(node.toLowerCase()).text(); + }); + + attributes.objectDOM = $objectDOM[0]; + return attributes; + }, + + /** + * Create an XML string from the model's attributes. + * @return {string} The XML string + */ + serialize: function () { + const xml = this.updateDOM().outerHTML; + const nodes = this.get("nodeOrder"); + // replace lowercase node names with camelCase + nodes.forEach((node) => { + xml.replace(`<${node.toLowerCase()}>`, `<${node}>`); + xml.replace(``, ``); + }); + return xml; + }, + + /** + * Copy the original XML and update fields in a DOM object + * @param {object} objectDOM - The DOM object to update + */ + updateDOM: function (objectDOM) { + const type = this.get("type") || "missingValueCode"; + if (!objectDOM) { + objectDOM = this.get("objectDOM") || this.get("objectXML"); + } + if (!objectDOM) { + objectDOM = document.createElement(type); + } + const $objectDOM = $(objectDOM); + + this.get("nodeOrder").forEach((nodeName) => { + // Remove any existing nodes + $objectDOM.children(nodeName.toLowerCase()).remove(); + + const newValue = this.get(nodeName)?.trim(); + + // Add the new node + if (newValue) { + const node = document.createElement(nodeName); + $(node).text(newValue); + $objectDOM.append(node); + } + }); + + return $objectDOM[0]; + }, + + /** + * Return true if all of the model's attributes are empty + * @return {boolean} + */ + isEmpty: function () { + return !this.get("code") && !this.get("codeExplanation"); + }, + + /** + * Validate the model attributes + * @return {object} The validation errors, if any + */ + validate: function () { + if (this.isEmpty()) return undefined; + + const errors = []; + + // Need a code and an explanation. Both must be non-empty strings. + let code = this.get("code"); + let codeExplanation = this.get("codeExplanation"); + if ( + !code || + !codeExplanation || + typeof code !== "string" || + typeof codeExplanation !== "string" + ) { + errors.missingValueCode = + "Missing value code and explanation are required."; + return errors; + } + code = code.trim(); + codeExplanation = codeExplanation.trim(); + this.set("code", code); + this.set("codeExplanation", codeExplanation); + + // Code must be a non-empty string + if (!code || !codeExplanation) { + errors.missingValueCode = + "Missing value code and explanation are required."; + return errors; + } + + return errors.length > 0 ? errors : undefined; + }, + } + ); + + return EMLMissingValueCode; +}); diff --git a/src/js/templates/metadata/eml-attribute.html b/src/js/templates/metadata/eml-attribute.html index d4deaf808..5553cdac4 100644 --- a/src/js/templates/metadata/eml-attribute.html +++ b/src/js/templates/metadata/eml-attribute.html @@ -1,3 +1,4 @@ +

Attribute

Describe the attributes (i.e., fields or variables) of this file. @@ -57,4 +58,6 @@

Definition
+
+ diff --git a/src/js/views/metadata/EML211MissingValueView.js b/src/js/views/metadata/EML211MissingValueView.js new file mode 100644 index 000000000..7be9b4955 --- /dev/null +++ b/src/js/views/metadata/EML211MissingValueView.js @@ -0,0 +1,229 @@ +/* global define */ +define(["backbone", "models/metadata/eml211/EMLMissingValueCode"], function ( + Backbone, + EMLMissingValueCode +) { + /** + * @class EMLMissingValueView + * @classdesc An EMLMissingValueView provides an editing interface for a + * single EML Missing Value Code. The view provides two inputs, one of the + * code and one for the code explanation. If the model is part of a + * collection, the view will also provide a button to remove the model from + * the collection. + * @classcategory Views/Metadata + * @screenshot views/metadata/EMLMissingValueView.png // <- TODO + * @extends Backbone.View + * @since x.x.x + */ + var EMLMissingValueView = Backbone.View.extend( + /** @lends EMLMissingValueView.prototype */ { + tagName: "div", + + /** + * The className to add to the view container + * @type {string} + */ + className: "eml-missing-values", + + /** + * The classes to add to the HTML elements in this view + * @type {Object} + * @property {string} removeButton - The class to add to the remove button + */ + classes: { + removeButton: "remove", + codeInput: "code", + codeExplanationInput: "codeExplanation", + }, + + /** + * User-facing text strings that will be displayed in this view. + * @type {Object} + * @property {string} codePlaceholder - The placeholder text for the code + * input + * @property {string} codeExplanationPlaceholder - The placeholder text + * for the code explanation input + */ + text: { + codePlaceholder: "Missing Value Code", + codeExplanationPlaceholder: "Missing Value Code Explanation", + }, + + /** + * Set this to true if this row is for a blank input row. This will + * prevent the view from rendering a remove button until the user starts + * typing. + * @type {boolean} + * @default false + */ + isNew: false, + + /** + * Creates a new EMLMissingValueView + * @param {Object} options - A literal object with options to pass to the + * view + * @param {EMLAttribute} [options.model] - The EMLMissingValueCode model + * to render. If no model is provided, an empty model will be created. + */ + initialize: function (options) { + if (!options || typeof options != "object") options = {}; + this.model = options.model || new EMLMissingValueCode(); + this.isNew = options.isNew === true; + }, + + /** + * Renders this view + * @return {EMLMissingValueView} A reference to this view + */ + render: function () { + try { + if (!this.model) { + console.warn( + "An EMLMissingValueView model is required to render this view." + ); + return this; + } + + this.el.innerHTML = ""; + + this.renderInput("code"); + this.renderInput("codeExplanation"); + + // Don't show a remove button if the model is marked as new + if (!this.isNew) { + this.renderRemoveButton(); + } + + // Set a listener for when the user types anything + this.setListeners(); + + return this; + } catch (error) { + console.log("Error rendering EMLMissingValueView", error); + } + }, + + /** + * Set listeners on the view's DOM elements + */ + setListeners: function () { + this.removeListeners(); + // Listen for typing in inputs + this.el.addEventListener("input", this.handleTyping.bind(this)); + }, + + /** + * Remove listeners from the view's DOM elements + */ + removeListeners: function () { + this.el.removeEventListener("input", this.handleTyping.bind(this)); + }, + + /** + * When a user types anything into any input, update the model and show + * the remove button if it was not yet rendered. + * @param {Event} e - The event that was triggered by the user + */ + handleTyping: function (e) { + // If the user has typed in a new row, render a remove button and mark + // the row as no longer new + if (this.isNew) { + this.trigger("change:isNew"); + this.isNew = false; + if (!this.removeButton) { + this.renderRemoveButton(); + } + } + // Update the model with the new value in whichever input was typed in + this.updateModelFromInput(e.target.name); + }, + + /** + * Create and insert the input element for one of the model's attributes. + * This will add the input to the end of the view's element. + * @param {string} attr - The name of the attribute to create an input + * for, either "code" or "codeExplanation" + * @return {Element} The input element + */ + renderInput(attr) { + if (!this.model) return; + const elName = `${attr}Input`; + const placeholder = this.text[`${attr}Placeholder`]; + const classStr = this.classes[elName]; + if (this[elName]) this[elName].remove(); + const input = document.createElement("input"); + input.classList.add(classStr); + input.setAttribute("type", "text"); + input.setAttribute("name", attr); + input.setAttribute("placeholder", placeholder); + input.setAttribute("value", this.model.get(attr)); + this.el.appendChild(input); + this[elName] = input; + return input; + }, + + /** + * Create and insert the remove button + * @return {Element} The remove button + */ + renderRemoveButton: function () { + // The model must be part of a collection to remove it from anything + if (!this.model.collection) return; + if (this.button) this.button.remove(); + const button = document.createElement("button"); + button.setAttribute("type", "button"); + button.classList.add(this.classes.removeButton); + button.textContent = "Remove"; + this.el.appendChild(button); + this.button = button; + // remove self when the button is clicked + button.addEventListener("click", this.removeSelf.bind(this)); + return button; + }, + + /** + * Update the model with the value in the input + * @param {string} attr - The name of the attribute to update, either + * "code" or "codeExplanation" + * @return {string} The new value + */ + updateModelFromInput: function (attr) { + if (!this.model) return; + const newVal = this[`${attr}Input`]?.value; + this.model.set(attr, newVal); + return newVal; + }, + + /** + * Remove this view from the DOM and collection and remove any event + * listeners + */ + removeSelf: function () { + this.removeListeners(); + // Remove the model from the collection, if it exists + if (this.model.collection) { + this.model.collection.remove(this.model); + } + // Remove the view from the DOM + this.remove(); + }, + + /** + * Shows validation errors on this view + */ + showValidation: function () { + //TODO + }, + + /** + * Hides validation errors on this view + * @param {Event} e - The event that was triggered by the user + */ + hideValidation: function () { + // TODO + }, + } + ); + + return EMLMissingValueView; +}); diff --git a/src/js/views/metadata/EML211MissingValuesView.js b/src/js/views/metadata/EML211MissingValuesView.js new file mode 100644 index 000000000..b430fa900 --- /dev/null +++ b/src/js/views/metadata/EML211MissingValuesView.js @@ -0,0 +1,156 @@ +/* global define */ +define([ + "backbone", + "models/metadata/eml211/EMLMissingValueCode", + "collections/metadata/eml/EMLMissingValueCodes", + "views/metadata/EML211MissingValueView", +], function ( + Backbone, + EMLMissingValueCode, + EMLMissingValueCodes, + EML211MissingValueView +) { + /** + * @class EMLMissingValuesView + * @classdesc An EMLMissingValuesView provides an editing interface for an EML + * Missing Value Codes collection. For each missing value code, the view + * provides two inputs, one of the code and one for the code explanation. Each + * missing value code can be removed from the collection by clicking the + * "Remove" button next to the code. A new row of inputs will automatically be + * added to the view when the user starts typing in the last row of inputs. + * @classcategory Views/Metadata + * @screenshot views/metadata/EMLMissingValuesView.png // <- TODO + * @extends Backbone.View + * @since x.x.x + */ + var EMLMissingValuesView = Backbone.View.extend( + /** @lends EMLMissingValuesView.prototype */ { + tagName: "div", + + /** + * The className to add to the view container + * @type {string} + */ + className: "eml-missing-values", + + /** + * Creates a new EMLMissingValuesView + * @param {Object} options - A literal object with options to pass to the + * view + * @param {EMLAttribute} [options.collection] - The EMLMissingValueCodes + * collection to render in this view + */ + initialize: function (options) { + if (!options || typeof options != "object") options = {}; + this.collection = options.collection || new EMLMissingValueCodes(); + }, + + /** + * Renders this view + * @return {EMLMissingValuesView} A reference to this view + */ + render: function () { + if (!this.collection) { + console.warn( + `The EMLMissingValuesView requires a MissingValueCodes collection` + + ` to render.` + ); + return; + } + this.setListeners(); + this.el.innerHTML = ""; + // TODO: add description & title (use template?) + this.collection.each((model) => { + this.addRow(model); + }); + // For entry of new values + this.addNewRow(); + + return this; + }, + + /** + * Add a new, empty Missing Value Code model to the collection. This will + * trigger the creation of a new row in the view. + */ + addNewRow: function () { + this.collection.add(new EMLMissingValueCode()); + }, + + /** + * Set listeners required for this view + */ + setListeners: function () { + this.removeListeners(); + // Add a row to the view when a model is added to the collection + this.listenTo(this.collection, "add", this.addRow); + }, + + /** + * Remove listeners that were previously set for this view + */ + removeListeners: function () { + this.stopListening(this.collection, "add"); + }, + + /** + * Tests is a model should be considered "new" for the purposes of + * displaying it in the view. A "new" model is used to render a blank row + * in the view for entry of a new missing value code. We consider it new + * if it's the last in the collection and both attributes are blank. + * @param {EMLMissingValueCode} model - The model to test + * @return {boolean} Whether or not the model is new + */ + modelIsNew: function (model) { + if (!model || !model.collection) return false; + const i = model.collection.indexOf(model); + const isLast = i === model.collection.length - 1; + return isLast && model.isEmpty(); + }, + + /** + * Creates a new row view for a missing value code model and inserts it + * into this view at the end. + * @param {EMLMissingValueCode} model - The model to create a row for + * @returns {EML211MissingValueView} The row view that was created + */ + addRow: function (model) { + if (!model instanceof EMLMissingValueCode) return; + + // New rows will not have a remove button until the user starts typing + const isNew = this.modelIsNew(model); + + // Create and render the row view + const rowView = new EML211MissingValueView({ + model: model, + isNew: isNew, + }).render(); + + // Insert the row into the view + this.el.append(rowView.el); + + // If a user types in the last row, add a new row + if (isNew) { + this.listenToOnce(rowView, "change:isNew", this.addNewRow); + } + }, + + /** + * Shows validation errors on this view + */ + showValidation: function () { + //TODO + }, + + /** + * Hides validation errors on this view + * @param {Event} e - The event that was triggered by the user + */ + hideValidation: function () { + // TODO + }, + } + ); + + return EMLMissingValuesView; +}); diff --git a/src/js/views/metadata/EMLAttributeView.js b/src/js/views/metadata/EMLAttributeView.js index 426750d08..1da89034c 100644 --- a/src/js/views/metadata/EMLAttributeView.js +++ b/src/js/views/metadata/EMLAttributeView.js @@ -7,6 +7,7 @@ define([ "models/metadata/eml211/EMLAttribute", "models/metadata/eml211/EMLMeasurementScale", "views/metadata/EMLMeasurementScaleView", + "views/metadata/EML211MissingValuesView", "text!templates/metadata/eml-attribute.html", ], function ( _, @@ -16,6 +17,7 @@ define([ EMLAttribute, EMLMeasurementScale, EMLMeasurementScaleView, + EML211MissingValuesView, EMLAttributeTemplate ) { /** @@ -113,6 +115,14 @@ define([ this.$(".measurement-scale-container").append(measurementScaleView.el); this.measurementScaleView = measurementScaleView; + // Create and insert a missing values view + const missingValuesView = new EML211MissingValuesView({ + collection: this.model.get("missingValueCodes"), + }); + missingValuesView.render(); + this.$(".missing-values-container").append(missingValuesView.el); + this.missingValuesView = missingValuesView; + // Mark this view DOM as new if it is a new attribute if (this.isNew) { this.$el.addClass("new"); From c07a210a626212fc7f38f40ee16d71867f7500df Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 20 Jul 2023 22:01:08 -0400 Subject: [PATCH 02/14] Format, add text, & test Missing Value Code editor Fix two minor bugs found while testing Issue #612 --- src/css/metacatui-common.css | 31 ++++- .../metadata/eml/EMLMissingValueCodes.js | 5 +- .../metadata/eml211/EMLMissingValueCode.js | 7 +- .../views/metadata/EML211MissingValueView.js | 83 ++++++++++--- .../views/metadata/EML211MissingValuesView.js | 109 +++++++++++++++++- 5 files changed, 208 insertions(+), 27 deletions(-) diff --git a/src/css/metacatui-common.css b/src/css/metacatui-common.css index 12284f21f..f786db5ce 100644 --- a/src/css/metacatui-common.css +++ b/src/css/metacatui-common.css @@ -20,6 +20,15 @@ a:hover{ pointer-events: none; opacity: .8; } +.reset-btn-styles { + background: none; + color: inherit; + border: none; + padding: 0; + font: inherit; + cursor: pointer; + outline: inherit; +} .footnote{ font-size: .7em; } @@ -8674,6 +8683,24 @@ textarea.medium{ .eml-attribute input { height: auto; } +.eml-missing-value-rows { + display: grid; + gap: 0.7rem; + grid-auto-flow: row; + margin-bottom: 3rem; +} +.eml-missing-value { + display: grid; + grid-template-columns: 1fr 4fr 1.5rem; + gap: 0.5rem; +} +.eml-missing-value input { + margin-bottom: 0; + width: auto; +} +.eml-missing-value button { + font-size: 1.1rem; +} .eml-measurement-scale .options{ box-sizing: border-box; padding-top: 10px; @@ -9036,6 +9063,4 @@ body > #extension-is-installed{ } .citation.header button.show-authors{ margin-left: 5px; -} - - +} \ No newline at end of file diff --git a/src/js/collections/metadata/eml/EMLMissingValueCodes.js b/src/js/collections/metadata/eml/EMLMissingValueCodes.js index 30f09391a..f0573e22d 100644 --- a/src/js/collections/metadata/eml/EMLMissingValueCodes.js +++ b/src/js/collections/metadata/eml/EMLMissingValueCodes.js @@ -44,12 +44,10 @@ define(["backbone", "models/metadata/eml211/EMLMissingValueCode"], function ( /** * Update the DOM with the current model state for each model in the - * collection, then return the set of updated DOMs. Warning: this will - * remove any empty models from the collection. + * collection, then return the set of updated DOMs. * @returns {Element[]} An array of updated DOM elements */ updateDOM: function () { - this.removeEmptyModels(); const objectDOMs = this.map((model) => model.updateDOM()); return objectDOMs; }, @@ -67,7 +65,6 @@ define(["backbone", "models/metadata/eml211/EMLMissingValueCode"], function ( * @returns {Array} An array of error messages */ validate: function () { - this.removeEmptyModels(); const errors = []; this.forEach((model) => { if (!model.isValid()) { diff --git a/src/js/models/metadata/eml211/EMLMissingValueCode.js b/src/js/models/metadata/eml211/EMLMissingValueCode.js index 139b53332..4febd2d92 100644 --- a/src/js/models/metadata/eml211/EMLMissingValueCode.js +++ b/src/js/models/metadata/eml211/EMLMissingValueCode.js @@ -86,7 +86,12 @@ define(["backbone"], function (Backbone) { } }); - return $objectDOM[0]; + if (this.isEmpty()) { + return null; + } else { + return $objectDOM[0]; + } + }, /** diff --git a/src/js/views/metadata/EML211MissingValueView.js b/src/js/views/metadata/EML211MissingValueView.js index 7be9b4955..f4a4461ed 100644 --- a/src/js/views/metadata/EML211MissingValueView.js +++ b/src/js/views/metadata/EML211MissingValueView.js @@ -1,8 +1,9 @@ /* global define */ -define(["backbone", "models/metadata/eml211/EMLMissingValueCode"], function ( - Backbone, - EMLMissingValueCode -) { +define([ + "jquery", + "backbone", + "models/metadata/eml211/EMLMissingValueCode", +], function ($, Backbone, EMLMissingValueCode) { /** * @class EMLMissingValueView * @classdesc An EMLMissingValueView provides an editing interface for a @@ -19,11 +20,17 @@ define(["backbone", "models/metadata/eml211/EMLMissingValueCode"], function ( /** @lends EMLMissingValueView.prototype */ { tagName: "div", + /** + * The type of View this is + * @type {string} + */ + type: "EMLMissingValueCodeView", + /** * The className to add to the view container * @type {string} */ - className: "eml-missing-values", + className: "eml-missing-value", /** * The classes to add to the HTML elements in this view @@ -31,7 +38,7 @@ define(["backbone", "models/metadata/eml211/EMLMissingValueCode"], function ( * @property {string} removeButton - The class to add to the remove button */ classes: { - removeButton: "remove", + removeButton: "reset-btn-styles", codeInput: "code", codeExplanationInput: "codeExplanation", }, @@ -43,12 +50,22 @@ define(["backbone", "models/metadata/eml211/EMLMissingValueCode"], function ( * input * @property {string} codeExplanationPlaceholder - The placeholder text * for the code explanation input + * @property {string} removeButton - The text for the remove button */ text: { codePlaceholder: "Missing Value Code", codeExplanationPlaceholder: "Missing Value Code Explanation", + removeButton: "Remove", }, + /** + * The HTML for the remove button + * @type {string} + */ + buttonHTML: ``, + /** * Set this to true if this row is for a blank input row. This will * prevent the view from rendering a remove button until the user starts @@ -130,9 +147,9 @@ define(["backbone", "models/metadata/eml211/EMLMissingValueCode"], function ( if (this.isNew) { this.trigger("change:isNew"); this.isNew = false; - if (!this.removeButton) { - this.renderRemoveButton(); - } + } + if (!this.removeButton) { + this.renderRemoveButton(); } // Update the model with the new value in whichever input was typed in this.updateModelFromInput(e.target.name); @@ -168,19 +185,55 @@ define(["backbone", "models/metadata/eml211/EMLMissingValueCode"], function ( */ renderRemoveButton: function () { // The model must be part of a collection to remove it from anything - if (!this.model.collection) return; - if (this.button) this.button.remove(); - const button = document.createElement("button"); - button.setAttribute("type", "button"); + if (!this.model.collection) { + console.warn( + "The model must be part of a collection to render a remove button." + ); + return; + } + if (this.removeButton) this.removeButton.remove(); + + const buttonHTML = this.buttonHTML || ``; + const $button = $(buttonHTML).tooltip({ + title: this.text.removeButton || "Remove", + placement: "top", + trigger: "hover", + }); + const button = $button[0]; + button.classList.add(this.classes.removeButton); - button.textContent = "Remove"; + button.setAttribute("type", "button"); this.el.appendChild(button); - this.button = button; + // remove self when the button is clicked button.addEventListener("click", this.removeSelf.bind(this)); + // Show a preview of what will happen when the button is clicked + button.addEventListener("mouseover", this.previewRemove.bind(this)); + // Undo the preview when the mouse leaves the button + button.addEventListener("mouseout", this.undoPreviewRemove.bind(this)); + + this.removeButton = button; return button; }, + /** + * When the button is hovered over, indicate visually that the row will + * be removed when the button is clicked + */ + previewRemove: function () { + this.codeInput.style.opacity = 0.5; + this.codeExplanationInput.style.opacity = 0.5; + }, + + /** + * When the button is no longer hovered over, undo the visual indication + * that the row will be removed when the button is clicked + */ + undoPreviewRemove: function () { + this.codeInput.style.opacity = 1; + this.codeExplanationInput.style.opacity = 1; + }, + /** * Update the model with the value in the input * @param {string} attr - The name of the attribute to update, either diff --git a/src/js/views/metadata/EML211MissingValuesView.js b/src/js/views/metadata/EML211MissingValuesView.js index b430fa900..373d84a78 100644 --- a/src/js/views/metadata/EML211MissingValuesView.js +++ b/src/js/views/metadata/EML211MissingValuesView.js @@ -27,12 +27,54 @@ define([ /** @lends EMLMissingValuesView.prototype */ { tagName: "div", + /** + * The type of View this is + * @type {string} + */ + type: "EMLMissingValueCodesView", + /** * The className to add to the view container * @type {string} */ className: "eml-missing-values", + /** + * The classes to add to the HTML elements in this view + * @type {Object} + * @property {string} title - The class to add to the title element + * @property {string} description - The class to add to the description + * paragraph element + * @property {string} notification - The class to add to the validation + * message container element + * @property {string} rows - The class to add to the container element for + * the missing value code rows + */ + classes: { + title: "", + description: "subtle", + notification: "notification", + rows: "eml-missing-value-rows", + }, + + /** + * User-facing text strings that will be displayed in this view. + * @type {Object} + * @property {string} title - The title text for this view + * @property {string[]} description - The description text for this view. + * Each string in the array will be rendered as a separate paragraph. + */ + text: { + title: "Missing Value Codes", + description: [ + `Specify the symbols or codes used to denote missing or + unavailable data in this attribute. Enter the symbol or number + representing the missing data along with a brief description + of why this code is used.`, + `Examples: "-9999, Sensor down time" or "NA, record not available"`, + ] + }, + /** * Creates a new EMLMissingValuesView * @param {Object} options - A literal object with options to pass to the @@ -59,14 +101,50 @@ define([ } this.setListeners(); this.el.innerHTML = ""; - // TODO: add description & title (use template?) + this.renderText(); + this.renderRows(); + + return this; + }, + + /** + * Add the title, description, and placeholder for a validation message. + */ + renderText: function () { + + this.title = document.createElement("h5"); + this.title.innerHTML = this.text.title; + this.el.appendChild(this.title); + + this.text.description.forEach(descText => { + this.description = document.createElement("p"); + this.description.classList.add(this.classes.description); + this.description.innerHTML = descText; + this.el.appendChild(this.description); + }); + + this.notification = document.createElement("p"); + this.notification.classList.add(this.classes.notification); + this.notification.setAttribute("data-category", "missingValueCodes"); + this.el.appendChild(this.notification); + + }, + + /** + * Renders the rows for each missing value code in the collection, and + * adds a new row for entry of a new missing value code. + */ + renderRows: function () { + // Create the div to hold each row + this.rows = document.createElement("div"); + this.rows.classList.add(this.classes.rows); + this.el.appendChild(this.rows); + this.collection.each((model) => { this.addRow(model); }); // For entry of new values this.addNewRow(); - - return this; }, /** @@ -84,6 +162,8 @@ define([ this.removeListeners(); // Add a row to the view when a model is added to the collection this.listenTo(this.collection, "add", this.addRow); + // Make sure that removed models are removed from the view + this.listenTo(this.collection, "remove", this.removeRow); }, /** @@ -91,6 +171,7 @@ define([ */ removeListeners: function () { this.stopListening(this.collection, "add"); + this.stopListening(this.collection, "remove"); }, /** @@ -126,8 +207,12 @@ define([ isNew: isNew, }).render(); + // Add the model ID to the row view so we can match it to the model + // Used by this.removeRow() + rowView.el.setAttribute("data-model-id", model.cid); + // Insert the row into the view - this.el.append(rowView.el); + this.rows.append(rowView.el); // If a user types in the last row, add a new row if (isNew) { @@ -135,6 +220,22 @@ define([ } }, + /** + * Removes a row view from this view + * @param {EMLMissingValueCode} model - The model to remove a row for + * @returns {EML211MissingValueView} The row view that was removed + */ + removeRow: function (model) { + if (!model instanceof EMLMissingValueCode) return; + const rowView = this.el.querySelector( + `[data-model-id="${model.cid}"]` + ); + if (rowView) { + rowView.remove(); + return rowView; + } + }, + /** * Shows validation errors on this view */ From 95ac205260983337ddc8c0c8d0dce05593943b75 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 20 Jul 2023 22:08:00 -0400 Subject: [PATCH 03/14] Add screenshots for new Missing Value views Issue #612 --- .../views/metadata/EMLMissingValueView.png | Bin 0 -> 12494 bytes .../views/metadata/EMLMissingValuesView.png | Bin 0 -> 77166 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/screenshots/views/metadata/EMLMissingValueView.png create mode 100644 docs/screenshots/views/metadata/EMLMissingValuesView.png diff --git a/docs/screenshots/views/metadata/EMLMissingValueView.png b/docs/screenshots/views/metadata/EMLMissingValueView.png new file mode 100644 index 0000000000000000000000000000000000000000..ee8cf7dbef3210e1bd63b8d0e5e13e2c92f94d47 GIT binary patch literal 12494 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ýgyjbQi`|JTFU z5!}E1;_k2a>alvTT1o2N@);dFt zQxuiRvd$t~@)1aA^S1}WEJ$E;1cWK(rm@)$JBax8!RU`3whK)AlDpgkRcxgG-wI0! zIcYvq9O4?di1#Udx4efEj}l&?-i*SwwUbXV(%J*jKVAh9KjEd{Gd|F{so})qs<4vql;JSAe8nXhs9pH--r#b#@H2Y=#_V*TcvTd=V z0&#>OR+C%haDd|E;hXkWg^&e!M0m$dQF2YydEeUiPus5rKrxHsSy2PufL6k2*R-!c zH1|Vx#qiGRjnA&)^H3=p@Cd1Fgto@}Pu;J&e7TQj#rh&hWfmAqtmj5~gZG32Tr=gg zrYd=wnJ6mbfx%s$R1ROqzsF<7P}Myg-PeQR@hEGGu7n%%%eknCyVw_K21fkCN?;y- zic554l}rMuYw+Be5;phSoJ`WD6Vr0!JA+ty|T_sUMe4Wr?@7(3$b} z+_nz!`>o~ex7)8&QQ#YLlp}_??pWQOd>GNaVkq}|TD1}nD14R=r|$Evf}wz-MnEgR zl5q4T`wGj~~9a?>k6C^wzrIPs?mj@IRQy|+? zoP6F~1{GS}#&bE>r2l=mpYOOm$Za2+#dQ9OZ4Zm2QzMHi1Cwhcgoz@*H9KtYanyU8 z`Fpl=1E5}!F072>IOzm7I}8|>->lfmAVXo)I~`OR_|W7E@Y{70T!e=TmL&I<#Cf>J zZaW-nVdIfJXJ-sFtJ}JeC^;blU$lR_cIq(`x=F^XeoA1+#veY!vhy4C{%+|I@l+eF z9~|RujF$I*+xG$eFsw~!&B+>>r7m+kW8eqTc(YY;!pqPuJW5t*^6xGZcFE_eiP+;MSCA zEt8ifHBQ-B$cP5*UAzQ>>g?*I6KxSp44F$G^9-cpweEhDYN^+z%jn9l><<4vuQ>dP zh*6=na{oPe3em><&0}%E`xKtR4tu?M0lbtQ)eVqwxVQmu`)~t%nT!HUxKQmPUg2?^ zg_|N#Ve|qgzR5*yM)q6DV-D9x6_b7KR+|G_MYpXgPui1xtQ!uT4G#h^&MPWo-^8us zz>w>e;X#*$pESi&z_A!n6IIxd{Pn4O_jmM?(pmx1c=dr<(s8bl#CY%|M+U-< zzRu@)Ivt9DJSGYIqQhxk91LBn`rzk&Q<6^)UoFUyC0Ws2^tVR|cz*Rw@nr|H{4MV# z^>0?FS>4~EmwU5^vRtbLmR!1@TrFwcVXXazndI^wc@dXG4w*wul`RvbN4;n=r&0FU ze9pYTtvTn-g&~%oyEQw;;&}g@)K??+hYi8O7cXBg@L@^KqB@#m+&KxTr9v<5`#EuQ zma4q+#esNCWt`RP&Z`P`!HLZ*0HxC85_QwVvDHfRsPpsN(I-pJ@iMrqFuUot1~tnx7`ROShW> zW1A4SmY`ccjF$)5FV}+w3CStr+P4LY26hE94CK>zbfgyRKwR3jm7Hh(xnKKSD8ad3 z+pDVOhib8ru;fXt_Ld2xqGv#dLAR`jy;!@-AHV-+H~O+ProemkF6u;khXnyw?4aCo zNr<5m5`?dBOyEl03`9 zqRtZ%jk@FA!~K0@2r?aiZ!SSOLEC%n=RDEYbCJ~o3yIBr%3XB9A7ER#0r_?lbs)=m zgA3o%ka7x>cVk0nGi9j}(h_gc*F1hkziQu8A+(!>Wh@8hsJWWt0(R9{_nz3pB#>At zGw-0n=J6+e0K!gg8GaTB*_V~r?AJ=9=X#IwtinaplLb2@b)s((g}s~W9MAX?jy>tt zc-L?l?c{mJ?|C$i>WD1IvmAGb4@I*^ZhT?CQ1A4bzu!Ko>A7-sd}E;eNp|=gOU{tdX}D&ht+I`HR7a_dmI*99{xh00MIL^iYzP zTnw&GyVHVQ;Q}C|l5k9;AHYno_uZTul>6fS1F2Oy%KDgzZ&J@n0~yLM-pg^9ZBr zJ;wv}(|PEO9KEPx_$WBDs&{UVX$&4=8G|Y#B|71DSbodu{s#LL&#^dhF#dMdiZ#R^ zQ#4|#3Oko0-#iiQ7T&CkjWoNoMQ+&ba3NrETgU}k-{HH6aGSmZ)?il{3ZN;5X};%# z*+;sv@O*paS=RHO8tOH`2Fq#rFK7M|^e#diAxs2WCeY7RbAK)6B6*71WZqNjEH{9< zoo3t&+&b+yiaeSKcQm{gb@JO%OEE0AU#$SvSO@>&4RZd(6$i*nXK|dWI4-(|zuf!h zj6>kUkWqDhlsF9m)7V4=2At}TkdFf^782uvpf(K4+uY&xE`3ENg^I=M8{`QM)Tqw9 zIos7IH9mcZR!x^e`OM)~8E*lL&r#Te-R5Akj!;u6mCcZFV8ye@oNRe%teq657BxI%r(|W8f3;q5h@H{$ZQ{)?$p`npy7lZ=QCb(SPApWG=h4=Zb2(%v(g)KS&OvwuS^( z@_^P$$i`jQbUD+H+u;+=Ah zi3tx3;AO~sCpk-@2NWMLYPs*Q__P!z>5~CtQDgyX~#Oa(C z(LXtVVvEsQp!eGKrarow#D*!89lskHYR<)EH0DqxES?jr-QS}{@X(Bo3~e4`Ey)oi zv9%DY{~|WW0)4jl8%Kf=i?{Ka|CaQ06D1w!4@M7Q9xVdXhm~;{*O)%p_K8|UIWc!E z!OG7##|E3g6Et`R@}a-0VJadhveaFrl?>3!uIMP<9(E9zm+#m4P(?Q7JzzLzKsb30 z6;m719VUyaZyg>TGqi`zMV}kEI?-`0gOZ5GHgBsD??1ELFOJ%5_q4BxVW@?9<{io=g0Zw2F@ICxG)RF#j~new#GYN4U9fLtsLX$0i__J{a%q1_mC?`F4@d?Cy-X``;}m7s>0t9m6e0%PA)n! zc*do&aIR9*>gWg(iDRpfCS1oWb5t|NLq>-fVq^`}9LveVZ@GD_syU?7(WV5Rfhl`= zJy@4+9?wwYH22=kbTxDUeVpZH#Jl*XZ*;l)yeNuuFQ zKLugb_~{j4KF8el(ahYK`K&d8-N+bZXnbzXaqIW)eKvUn*zIxvE)MU_;1v_?LNhAj zSnqA+ytbbp1=nJr1};)_u-Wat5x94$S^TEXp7C&IGdFxY%5070QQ%LB&$?@@u ztjA}bt4WfZ8SrV)%Bz=4!m%2fr*mR00V^`MH)r!mfqAS)9uL6%Rm*^gV`$c*K&-Dy9fUyzI39(4!w-Gu97;` zivQF;-la?G=L1Ghf^m-aRlFGQ!kCt0OqS>IpHQ#j(MTg@1ikzcHLLrd_9!bJ_`+^t~madw~=%lq|tQ9KudQ zdQ4@;#L&KLbF#(_ia8~5TkE~8JB#R=tp+x8v^YTkyG#2hBaOrRIA4e$1+32BZ4F+g zBI(y0{u!?^FB&O~e<3X*P@COv1G%NN52jStddY&3SkJXr$T-EZ9J9!) zVn}wI+<0eky(DeGG|6OTJh~5c=1~E{EdRvS=}JqQ^--y+CCnn!X>gIK(>@>D(-UNS z`HMg24RgT;X{Qz9H1d-&{}#4^;DHGUtn*5!jI!g!q5V&Fa^8m(orPbr3+XJ>U~p91 z3oe`AD1qW)YdeA_tGT(DwkZX*&?p!dhJD{=0w(;+Wg;odq7+rY$yn>lr~wzJ;Fn5R zl}5^$D+-}hQ;8~=L3P6tVx~Qg6?L+Y5qyiXLYy*!_NjCMY9^?!RZXDk6K@*0F$)a# zAQf)-aOT%3gR5;nzskrPL&cH1*lYK^l!+38-qH>|<1_59?EF(gt+fdcbZNI z`1G*oeFWxQSA=3@+_J4N|L&8kbG0adMjshwoS;DVM7{?;F z)+&Z|PDLaKJ_EFFf-+8eB@Q_Z^Zl{Rh`3=?}YO41Kv7OOce$U{8LM7}vtyr#x7cm2V>rZCs7_2r(ec+EWekpM6Rq>u3}$(ImyO}AWvI! z$^Y_lx5IAYbO0NvPF(2pAmg7dr!RWAy#6j0QU*kJ$`xyvq?E=Lc|BBi>byR%NAVXT zdH}wTDcs1J0Y&3uG1sw9`IkP1NjI5V0~rEq0(R68tQcih2V+l7vJ9PI=1{ayA{~`W zk}o@4kv|Sg`LU8~*(#1ayYc#=Y*YsIpDtSpnw|3YF>>FG<1Hj z^vAj@EM!}&6t4^~(WiVM8Ke6gM|AS8a*kJ_@um{-ukFpdv!B_>JY}@$vgeB=ZvH%M0*wdOFv(wJ7rb+t$aB;ocNE0`^V1l5+IVJ@Di9W z^`HSo-BaVt?O><_EGrh_NZ^I8>q^8Kfh_)nKg4!=}fe=IPi8T}nP@WAX$nrwD* z`0ZTZi@lYZ) z5&#C&Xaa~%U-u%SQGCD2%syV_IfCLkL^t=FuOEQH< zqej!TvZKU4>($#V)bDP@>%Awn8ONCuCbf3D*!`PVbO^9k{e7a{;~hmc7~vTa0MGJU zdr^#4E0Om!B}37}lb&|s39PUS(4t<+!dw8jisnu(?G?Z{s`k{tudfHdhw59Y9yW@- zJEB16-USymoFT>WLsbuzN2ci-DIY*3yZa}Mm28K05x_C-0_d|mWeb&b2!M3r&NW$f z11NAb?v}aHk%_!@fOYoC6byeG?uW64iE#o@T}=`+y|p)LJ?kd7Ra*}_*0ybO;d~RPVf_r%9ZBCq8<(QG?nn1B5CZn>JX*4`DXV^ z`p!6S{gTs-;YhYJlX<+GcH`aY0CV`K-dFKDoL-5ruYo^203=^l1(tslKybJV><>AK zyhfpB8RIM|^34Ei1B(b6v;%l{!`t8KqT)CL>)-wG!cKoDs|^e86@FQsbbxOGJiW9R zeaWIHo%jU+B9IH0^GK*0_-wPGZ8U#}kF)}RJ%uO6Q zDwx{`7Vjx&6zgaeeQw|t=|pEpRyNq*V=iqv?Ppfy3?F|ldVl_24g&Bn{>@h}_(0KZ zJ`ne2kD!I1Rw3fTH*D*ynQL6e>2FOzLF&!l(KYXenYY;c0D&QP6y(NC zgTrmeQWxJDK%o>5Q(pcAc-zX36Jeh8^bY+ouU8ap-N#$$;lMS-4kKoZJP`%34ebu8 zXUWIFg@6=l*RWavAikABF4#W5H7?{@zxH}H3_1DK>fsK)5(Jn8q6Rm|=EwV^U;v)v z73NiKkYrtrUIN^t4zAP_thtG6ofdDeYXEmhO69)&ySw^)pwfK29avZJc>LiL-Elk5 zln(wW)r$REchD2_+e#hG%f{VeW$B3&8Niw{cL#EL-(8a^X=d94Ow2{?0B79u-hlE& z@b&x_gt*seJ{~~d9W>RM4HcKWU+f-dv3b4j-hE;keuA-fcOC;r6(>sc!FB#}*rnNF zuvze-7BCowZc_7?OFoZw02~*3Lhz7+nYuqT7?WaP#ZW63-MzxV3KC1CJ?e}Kthv#wnw#9K8ao$@(OXx03UM}IC z%(^F`?11%Z$D6ISH{u7NUxxeu&Q^jSIL|^D_-P0E1;wFT`%PDiE4_44``dSo0dsH0 zPluXgdsQqXC=bC@kdlOV9-iUhJKZrk7rA}ViK`{U>=KS zWG(PhfOD1Y30oXt&E05r{6T9J%q3CC4-oJnt@`p6H#r~7cGF)f{o;O&L7|)O`ovVJ zdsRQh6S;YJq*WF1^Fe#VG^zR^1KC5YP5XoAAgZKHUOqBgZ zNJo_Sc?O4J-+JHA4uI=)vwcbf;Q;SD4Y=Lb`z*;0h*f3uwIXCY#Ztvl3f`ymUmm`# zlNr^|S_u_-;&DWybX0y~X9mZyXg#lK0O5Z6yY@k_F87MQo|z~->FbsNw&M4nED zkabw?b=pDf-*S+xtv3LK${Y{bWnmfCO^@8Q=xL}zn;r<)5&eKGYvI4_KE3rrqXNb2 zx|?j1aG|(@k86;2Ux4T%}qLllENV?ujP$dxx>cJ%Bw3lw>iR&$#*BCV;-Wvb_ zL(}f}P3_6)#7|SZ>a+g36Sx3-WjDyt6G+JXG4SbTbNqTD!zH6jIz>SolXLU;O)~z- zS6cEHgxEO%xbWAg61DWsF9-ncbS)vFVIhd%1&ufj z8Z`zRHj}%%xiSZq1K?<30)ifA07#l5m}N|2kezR@tt!KOdIiNEJ6m{NfY-V4@#X## z`lF1aug;C`r63+kDnRK=LY+(tUBf?e4yUq*nzVHL$NNNbyXxddjsXb3)YtTkVA|h@V^UT6CGPEji z7~dnhAM#2UPQUBV{|QPnjWrGM+)RTz7Y{i|UE5_fzDTQH^EiLuql|hgle|B3dZ9>q zLE`V0bAhb0GDIM8`Yhwq@_XIEa?SzDRJTJ?7}|yKRr2Wjg$aBu`=L2hhOt4foC?3a z4~H{eeL`#ti^Vh}eHWer+c{X^ZZ_|euwL_=6lV*^ADQ;MU;2Rq#5Gm=HB&t?u3M%> zDjf#}Di1CV#w#Vz$5MKa$+l`X)MflIs1*K^d_ z2R$kGTRFyJursmFRG^Ydy!musj-T+Rhc@)|*KS4p2)t;dJ+T;M zXTz5B%MWnqqov;Mtk^><1OSnBdniFN(U^vEDAtZBduSPt-GBj%AgM4Sd)a;3!B0!R zUW?JQ0PCV*^A`^lETNO4 zDL!+#t~7G^zX;=SApfTK>q;Qav+XQ_!G`0;PlaNVzs&Nqui`x&gHz@GEM^RTU_`A% zqVEYyC9@&B{KnkUgEIw@tvyXdj44(f6nDoki8y05i1)FhZUf#YFBE~-&$KkX{Ar%S z3W%_=gQ>k;?`$6LVyIA}3scoac?JPCO8?bmmBmtk^fxYAD2~3J0bA#%z}W#w1V;Ay zdgA9KBePEYX1##Utv9J|r$j!@joq}S5jbR7G59e-!WAa#A`}vleHAvRO+%_14|dk8 zT~%j?4vSUR9xbOFPV`O;8;BVNccosB6t3dF+#eR~I+3r^ z4gvJEJ%;or-LjV5JMntg&AgvQ`}FT)#IJF=j|(5RCBerqyP6ARn9v@|iRZ29Qtb-n z>d(X~?ZfGlAqXB}@0Oh$Hst!bFyBKeU97GZU58<0@icZ5jA3Q4eIshRp#0*^u*D#H7jJe<*HPD1^ z%pglfYZqb`hdOd~-0dW6G>qMQ)Amv)Twuw8~*j3F`PE_cQTwNRD8U3H8#u-_^)C?r=#aPexnv5c4*#zVsWCTk|CELO|Bk=SR`P zwz`C)l0`@o9Wr&;5j_E+NeHk-lU>;Io!ldA&S?B21r*k~D0+l5h!IVHc$jUbyqJsg z{;_3_#ctRgej3otv7yKnfbBMRb?PP)$i9r6wqX<(635yfCwSD{7VO(sPnIB}aQv1eQ+SAr!cH>l<7V)rDWyV2)agX= z5C=Wy>B82VetP`J=`HN)n40x0;ESX!-c8gGUD4Cew&&AxuB3aY&^77aG%Ufd{V`cX zoPNKFl=aSP>@Z8$lwcr4=^b!p?b%e+w$K0{V-p>|a>^z4f6dj)W{w99eA540hV`q&yA`l~BqPuXOpLpill`_N98iw-v4T5ay<*7Jj*uKL^L6ZL!s)!-+O z-u4sYDiNQ3Bgs~c=F?bclvPho8mOzVuDyw4fLY=GY$FHqRkvp9%$oz213xEQ(=T8~#<5-9r`p8N;-pDz$`dbMv7vJyqa}$3I{Or+|xvxKE@Yhe`$8_Hu1MiEG4TpxGbXgi2KAy%t0uxHpW z+y#nEL;2xuu|p_`WKP_NgJ5Q`TnL(Zb*#Zm8t=YW#`nrGGco>U_gpyE5uZZvHy37* zvYQ=l9Tg6lNz(?ezuqIe^uLN! zynrvRT45G^_&C12=SUgry=8>YizjFOGVvRwcTqG8(4i8WvACkK%=}SubOyaY!)hB( zeTAPheo(C&u+UNtQ3M~a(1xEh4*lX`#^~(~+FIBpC-ciB#*0&dzN0;BZ`{Dd{HdZE z(qVFq9itT^u&(+rxNZK*s*o7oL8k2NmCCj%Sr>y z38hYSj)bq1;qJJiISXHVh6irHWP%_J(3Sv@JUo(3oU%itLfEotlwu%6Kw4VAQ$V3t z-b;R$p&zH+2!(hqtfXMB<`t;7p^z&wCXzRnpuYMQ9^Y9P5Y_!x;ES@_wSsu zsid0U-=B?piX>woN%e0!4{)zC@^G_;^`psCG z2JL1fwhzM9x3rB9I!CgrwJNFCoGIOi8PVaUOzbz7*-LfA>xfur@n2_>TB=gKbbLqhc%gSXq7|mjbG}9Y6&ZJX?_Fwm4ZM=c zg})`y&7P;Aqs>JvbK}=hVK15ZHhH7>dioXLHoHWOLPVY%s8aKn###TEuy}g!#fO%J zE&r{bHxuFR0q-Ga4NcU$X&y(!6ThvhulH0@+!fQ~{KXi+kpP4F_V%rVr&NbCnWC;r z)$h3n3bImy7!rq*d$57fa6$FrYsF`X$79jvWT||kzUT`xV}A&k{{jtP#))7#2}W@i!nQ)MGta6-%lCi4$KqY{j%8i zQdcX&-95y9w}`gge)4uTg%O3uUB%1D*C_FEEBLv`FfU-mPRTAV<)tFql=pU=E+Vv& z2_-R@ByD%j6Dw7=qYf^G+EK9SU<}MGWCfjbO2#X-f80idJl$}FMD7dG{o&ngO=#ii zt!5ya@~o?<$`x0G7%qOfK&b>x!LKwnp1Wz(KaRG{Znk11-iX2-w7ss*Br8%yhOefs zu2Og8xi2|RamLj>*Ipb>-@$|`ttzN&(sH~?3W*WwsNCB1;@JmP6l*+W#)A6l9sxL+(+-@ zZKFg$B$Xn0-_JNiAm(}?(QCoulkOc&;HXGdyZzjqLyU4d$#fji(TG~+Q@r!eU>5|d zp{D#k)qCbPbA9>3$8`y*Y)EN|yBhM@YCxfd@<=pn0vBTA&HWQM?P!v2(1oq~>EJXc zCWdsB`gD#@H0mW_XnEH*TnVsXEhVnxByb82X3ukywR?&PMJctEI_%c0$7Z?ti$(9j z&+*xkAYCrD-fF+D!n{QGpZN*K3_%`lSdp4uDl3TlQc3T9wtW^*mx#rtI3!f;o#rxp ze`&Hr3JD{7XIsYO4J7jg%Hzj(YN$ziF;qq!9bP2nZSj0MlR}Dtmc!PmG|S8bEQB+w zo8&|xF9{kGO4#9wQB=di?-g6H#d|m7a_rm8C0}=s3AfwnT}EH=Q|I)$YrUaR-6Q=N z%w+-hLluzFGk080=bRr4BoK;U#Fd8Xdz(?WBwl?pd%H3G-G)rln}o6FdIwRLg{I{= z4k?g6`3sZKLnFL@#Fp(Q<@_g6DsDoeLn`56;gE#>&OHzFFSL7jbDQzX*-j9iXbz_n zH>terLswph{V*3B#?4CA&Pj6*1=<49^jT!cQHmT4c6*SMNbLX=;hEB;do)hEt6(bt>IU2E>3mmf{)$0<6oKUoZYRS^WPKsiNAO{K(vMDn}i%pCyt{+7&1w? zNyNO7!ni_V*URk7;a{{;X~a6drv^?G10~yTou)?JhmZbZyI5IM$2f>#Xk!7144~_k zLNy{cB&@9@iS;f5G9RqGO8?1KBqK6G#s`8*b66Fn#zxLjlTyhVIY%ZY&JbByW>3y2 z6G3~`-==ZP>!pXg%A7W#&kxCsM~it~r0IAjSM+-nhXv?lL+%w7!w1gfFif|A*X-Qg z){s9zTfx)BLjldF!@sKAdAtH>4|Wu#-h|P0;E?`pzyCwAO7#oA80$D2P1(WV1~sQO zpvSq0rM2vgcbZrK7&QE$&{~d=8!otgB(AJ zPRBs>oK3oha2KxN8R~UE2|w0jOFObCN4>xr1ywh0M*%M45{?%lQ?F9#{TUQUeK$RE z9K8Kq?9TWc1WCX0+rKVH@v|x3dgfQDTXQ{=-8G-bBhS*#J?V~>%P#w82~k3D2@9J(GDc9pK#wHsKq_O|LO|sn=@rcznw?jb zH}dO88?f-@U`dhw^l4@Fpo!Wfzrv*Ia*1unxg7Z<%hByw|3f5~#M($8 zdW)dfsPHzqwN{g5W>z8%f`hwot>|l?HQ#{uM$^)<*Lj>+HMX1S+5yR>g#dXkUfEvN z%{k8J0xE9@46o*@DaWrVjbD3Lm$#H}sd!;2J1XquRjj=g;g!FCM`$&lVLE1slua_) zPO=|I)aYU&X}JyXne`%T*<$nok>sXFgQyhbHYG~vn+LPF?>+xLpl*V{C=VofLC$L( z?vyO;T5)iGSMmOKrK!@ggCa-~;{H*EUQNbd_(LO8zrd1ycB=KIGnQT&ki>iu>0s~8 zr#4G3$03Znvg%1mdMvYJ++f+1v4Wo)E2@VwaKn2*%)9gh<2%1&cIa`wpd)$r2UACH zNG%rT6|syZSLx?}PUD>2?dJrxwFKDKj~D4m6^aM_1;KmDyyTW%p<*TISVc5qbfNyO z7;MOUi<+FiEpP9X|$_H3j?sbt9{lJMquD-VovXvZ_HR=~vkzlV_)LTvDL;0)MQ{uKvo+EXZZkurPEak51{=T9+Zwcm- z%(~iJp;#g-n2&fF!jT-60kSQeQT}i_*R<9kaO)Nc>ZvsHR+FFn^b&&Quq)yq>eLrC zdAv2_lIG&(#F#0xO;d2unt*RIRqcfPK_&WlmlgD7qv(ad*r+FN#h=M1 zqkrcHQPeNLi%CrzzS?q1198&F-HfUyE4ay}EahcIq`!wLMcKXrT|ZksmaFv^>fRH; z3-Y?wg7dQ#=AQ-II&Lkfh9wbmG!UYkjsZn7*R;G?&6Trg;)XB~l6RSik#DY8QzUNp z1WK`#fWI+3sJYFm8TsOuVssyw$&nOF`$qF}urkcyv#0*pz$~NY4U4A0Fr?tYqODXd?WHOPDZ1fcNv@H(k+rUp>=BDd_&3M8=FY_RoXZV&lUv(viF5PRr#ci zW#G=x+*|!~vfAE-v{e*cG zEGAfVjOoOgH%aq(g^;>l2pG}yFU2~oQ|KXOJv zY(L9THakA{Tn8>z{l@|->9ng)JYXR?;+`AS23tujeo$$QgJuCVDrHUd-U~8@O?lOG zoWjkyiuDM3>`Bv57ckwxpQ?e9EvcT36T8!b?7Ws#tx}lH8?O(Zk!PFH+!#rrAWBnk z@?dqCW@rdq;CV8r0qv-|2P)2fY@dWv>H)H%ZSrBa68lTlmQWxeE#BIr(QE1Itn-N( z*RtGM|J&ijnDqJlLLJ3jUZ3$9qUazF!_P$|CUu8H6=Bl1CYxJ3JPzeW@nL5-z|%i# zvxFQi-WeKr+t>rK9p#@&{i~Js=dXy3MtK&46*4-Zqp(@wdS8KREhw2a=GqJav~caF zY)E5z=&;U~0B#VhOYx_?3jP=JW>HI4CQ_3wWDPcbCmmARZrVUyen9-^Jof9Nuwxn| z)vp|dR1%9qfQ$T>MN0*QRK(CW`8cBch1>T%0z8y$O75~iUF%aZ3#_@yMIbrr$JM(C zlg!?_(U9O|3i{oxnM(H~HSKCn6h`q=gX((EH5j;^fU@>g`5+tBHmt_z-Vvo*rTS*d z;s%O^ZGyj44$YgbU}t)|mbO>vy{0W^_6+v68ep+$Q=HPj0D+S3xk(Fp6~)>W++n|g z_-cBx(9deC@^8&n|mhH?EpQZ$uK+&Mm{h&u!c$y&K5syvYWak@( z=!SsSjR%QjB1L&0GaQf~Cs`~-FfZLk-&~LvQEc(E*%$-o&RyhD$4+Dt)d*;ect56Rn}lLC!QN{waFDkM=rb5eB^bFHx3 zKWK$eUt2B0Yn~kAOM{FW%lW(WS*sz`VoTsZDQo}{O0fKI`OBi*}T`LF{i#;W2xpXyuLwgkVzC8T*Dp1 z1S^FBiE4Nkl3VqTafNB}z3>|Uue?M$bQqHi==v14sz0?^<6@}2@rJ}n=xw@Lb4@KV zyifvS!}qXmdWgSy_H`28Ja~C~&$XSl*C@OL+i0+*$yQwsjmauPK`cHX5BZ2wwvgmw z6iJ+R&&U-CvrxXx6QAUo54&3#0C&ofIps~SaCRYP^2~{w#=CBRb>Nxi4}sR?zkk`bX^usl|JXCLwh|?CM@n-9xvS-dvUTn^yo=x zHOD`Rc@L5S$u4g5OX!z!HighApSv%jy^6dxy|!D#T_w@{mA0l=mY{(<_1o%l@ZvLy zyBtt8KzL^NG0L2ZL>E&^N{&{dD?%btMHZ;}1o3Q6p{FmWf6rl8#3Uw@Wt_ws&G6M9 zF1(=c_T_yv>LrxWuxGqGexpY^SLPX4mYW_V1H=y38E-pCOW(3t(`=$61#Se#MB_+4 zC#$i0xxnpit?8Nn_4co3A`B|_b~ev5fwMIi$(^*Uvi(Id|63EcrsV3De5E6h$_&?h z)F|CLnzZythi%+tphxgGql65lR6o%x6q&X|xj2bsN}N-Mv)ep0i{E75DAf#5Jahwk{0PkN>W5hIwTb7l$4eh5D<`*5|Hltt_`=| z&-;AeAMmc<`mHtVUUM_sYwvx_NCZET%XraT?z%=&?v6t@wUwIBZ` zIuo}2Ybsfby#_pQ^IN{DO}9 z*D05YA|_*VQD5X?jrtIS(d&qK;?Ez}76-YvmOTo)Blus?TL3jbOOv^9NF=7YK=_+u zCi8%DFlq3`zWyMJ6E}=&`o_J!^`nzLrX9>V2?tKQ=JmTSEdpUremUG>sHe2mx?;sd z4pXar?`db2k0B^5F0Xd%ytsXZDtA4$5^pm863_%Afnl~3~C9|4`oQJ zD}C@~)FsbQT8~d(ynwTtnh{AUlOKDy;ujdv!7sc?n3vO*M|aicz+XS%Tp`_!Q2Zx$ zm5s1e&V7)0sp*0Bv2q~kVQaNbp}l3yR~l1-8@r$_%Ir4_DFzPf@*7I_d{W*4gwhjv z{UzM}D*Z1HpNbEz>`4a?%nnSNC;noB+fFWi*&!I?|9z~?;U?Nj$W>ys#JUoP8DusS zhMUBge2seF<5V=vJxdo6Uh%v+WOAWCo0-H?pNKVvTl*#D=Nyufg~1f{tAyls`$3qs zQ*eq2+noO6SG$c-Hf9>7uGY4Li{@!*)Tfgjv_?aMCY(&1Lu> zfM{X8B4#>NsQ`%<&*#yrLQ<(?jEhSyr&VnCW6GJ~udpSVy8GYkEDcu`oiryE=W9NW z3V-tpL?8!>IqQ~KuZRv6E=MyUX(^xez9}k|ke`*iqEwS-XnB+nE$XN^#A=MWAXt4< zk!|dNuhaG86ZY}VaDcB#U7M z+cd}9&n!?L9yF3uNW@EbKYF?XFiD|dUHeTZB{Ghjj~rcC-VoujRd27qi^dX9$n?K8 zH&MeEUB*Tfq17uYdeNtHs|bHwtd0ZOybxM$y)pRB}8aUV5H| z_PL5xgxcB6Fiw2(>y3s++E4kUi`sNP$F~<-Z>6psD9Er@hp#=p2VxEsDUq~#KTEb& zqUi1ov=C>Cwct@+~`kFpaZ+`;Bl#j{91o7?1y<58YlKwqwb& zz`_J7PqrkDy3f5Ej{=Khh%B_ubcJ2!(kYTQhiHqroh2x*EOjdEstGbvPIF`q2wQsO z)D#aomA_lIdG01IJ7FgtYJM}+Y@JzTHoW`Hxiwxn&au7pJ>+ESQg4H>0h20?RO*9E z)(%(L(E@V6y2)Y&wC1f*W%nDw$*{1MC7bp>@aMm1@;Sg3ClhUITg1x5dF_1P%*i4r zJ(EU;Dm$b#kF2!2(GMpKL|}bBS8+a-XO2q?dtroM^>bo?z%wA=@`8aO*N-BsA78T+>t%J|xlZx5WrJqbb(Ho+* z1t;H{l>-?Z7b#VXwolzJR{eggP|kSu&NHU2vr5{Zwb#RW#q7I7pZS_bE(DlH@?sJZ z;Jl+S*-JT=E4Va=HYXMH;w>3{vd$)*mv9RIb9v0(b#5wiX0OqcZero1=n2m`x0DHx zP@|8PI@Z(D8D(D$V&+fXkjiml`@ptlYNa<`aV4Xs@KeCEc-QbP-EU+-!c(8T$rx;M zH+3c2VKshQAJ$s*?W_BJR#n-}*l=9=*@v0qX?cxp8P+WY={MiG?;49bP&i2LMI64@ zbno9cK532M_6jPU3CSdQ`kSn6n#MlGJ+ZjavixJ-GSOxAlSMPeiJxg&J5D9eMFqeq z`~WCe;BRD*zDS*`9JqnFV)qa^i_dT!{oo_GUxsu=Zr^=yUj_-glgf|5e(#=iuJ;}A zKSK4K@5}Ks^jR6F|B^g{vim4sBjoRG{(K1)M!$r?oQ(d){omXCb3dQ2R~e)X_}8lM z-}vvFF>%j;HwtyeR^5cA|9^gDqr720Uu)JB%KqcEF7EgLzuf;vSFVf%HkALEMl1gj z)li-ei6B7GdLE5UhR-Ldy@z%P10%QfcqO-CqaU|%#~W^s!))c&PF|lcKjkusKSj*_m#%({}rEoKAvywOuSL4st zOJ(q3KS}XbqiG+o0Khoia?U)xT9Mm7uR+*fh}jceM*iCQCPvHz<+3I_GXY3>K4Y?S zl88W8vUs(I7<y2tW2F!ia13M(o16E=!ozKmowH+f7t#4UnMN3z`;ugGRlA?}9wYW4lmlaTf)JM_K=D5m zO5N3wB_ahgh>6aAC+o81cqPv`Kr8boK!n9JIwSxHo>e+&gPoEE` z)D)pjlv)Q<{Xzsg2Z?Lq&XwPg2wtt^?{Jd+&a*x48>p9r*~W z4j?&wx`~4;|G5xf9J(+KXNbv%81@jY-x_+b91fpijysny9BK!GWB)vkHxb(yh{en8 zsQWhLOt`$$h21fjy7cBTqXL3*pEC{ikAi9FBLUY@xfha(V~`=^xNB zIb8z zr+)}N{SRM=)|*sfGcv*t5`@@3Z0o0c^^z-qfLiIraSN9x(doR+={1O>+njbUrlN+0 z7SIQWP1YXs_WPp6FBP2rDrj7-n52uphgdWo8Ylz%Js8?Roa}ISFvkQ?{Tr%SV%>+( z5A(8csu}UUTl&7p>y%^{#*jCB&=>jrR7L2eLJJyRMl{l-W9;#g;QR9NX zA#)+)889+#r&^w6t6T3)dU-joxlN8yQoTw}Va4Sq!Q>3B-w&yWgPM$2Co5kTNOXJ4XNow^CNnj^$snRc!ENll2GtcEBh2>OG?lHdx8 z8Pk27VT>knVXUQEL7Tx!e?J$~T%|wX()b$g6l^~JS~ z)1itwGEcBnFj9MJ^_pqpp2S;rKYRc+s-Tn9ZECND6fe)prK|+%1!C^#-vFylIRO~o z1EA_fyBl?xzGz`qTD!G?Uz@tk?vyc*mUv<#+=+k2N>qpRRe)p&tmq72Sm97XX4B?e z=jyAtI;a54@Ut&gn3cO>M??L|Sbd8XRDkFV5X8JDl9EvUP2Fx;fz0PoF78smA=`W% z8>%5`9MZKBpRbWT8ITOi)5-P`c6q^SSr3XeU|Kx8vLY#V9+u1YUZwxjdp6cG5gt_CUedi=I; zx~G5GuV@+?ohn)-J8N{vbZ*9|vO|i&nn_Ol&RB7@+S&}^_w>&0@a($m%gd!NGIios zSHKD~;I*HD8wPL`)bRd(&G1S_eheJeUka%!xJ9~B-mUKhR*JPx+PseCMnG}H%uQFn zvpM}!7RY{D?yC%wrBZM`jKR>O-*@ubbJ`#iuVS^C*f^>`J3>)U=phVxPC!lWTQ`y0 zt!Kx*XQ5TQ5TM_Tf0Lb2RkhRq3HWOC|P*UlRiPq4m?K=ocNM^iFBOiPxq@t=4E2lhR;Slxj=*&`k- zd&``q+XSj@qxNl(Je`%4RcpXwg<}NKdL6`iQK=jxO&%oKf}(^lLuB%81Vf7FFZGnC zgGY{G+az{D?my*{84OD8!}vK*E^H{aSV$OE6j?kVHDf*Ye2Lzy2$4n+&OzPrCCaMy!KU4SnpP?4n4b=4DV%Xbr~&5MO)}6GtcGEA3Pt=gLajGDuZV=Y2qN4_}FyLN5{h?MO_d+4L(?&!_ zZW8(YA!WA&M0hXMj5gC+n<8MLMeBhsEz4@a-*|R1c~;Bwz8Mc=T&NR8dynq+DzP*;7qMrD-K_&1i1$#CHoz-B-$swx4rQ<;i5 zrAZgFDUqWrpbU#`6Myj$5Q`G$Vo?jh4g07kx}UV>&g#Ayl@b~_fH>NcJloP7D6dGMR#5w#EhKCr|;=LZyx)(NCT+9ptP|Al1G(R~oEU4$A#kHAZ# zJZ2MH=juqqmaXgc_BRtbhV@y(~$hrYz%A2(Mta`8nI{{}j&XwA~X;C^r$e1|=z zhBhEgTE+Ioq^6)A92uXxg>G0#c^z zuKviYmS@w1UL$ccpKY>2x>6)l3e;42zA{>m97kNIIi)UtoO0phe}jy>>(uIN8PBP; z>RZreIHW-IA^Sk94H!6se!wU*`sUl9J9#)Xf|lj9Pm;2KXP(J=d-v>1%JJQ+p=J$- zn3+*dTcf40faZZXrEZ~pN@A~``RS!N3Yb}Ff1&ci&yqsgrW2mWzCe^Bc|CrzcI4$I=_a&GP-jbRrKE6Deeu`e`y7ijIlc*tHA7+L(0B{j5^F4zrxm4z|# zdeQx<7fqn>|MXGm_SG>XXU>rl;Nr;6hVh=D=#R92SK&{~2^)-5g)yxAqHP_0oKC?u z8kQ+ESgeh=VIu+cFy$xi#j13*6O_^;prUtwUt9&T-!3OXoL_%>=!}m6w;w9OPcxe9 zh_<6xD!R3*^8Pk6u_jsyCrS zR%|5q<0W@r_%t40>PcwcM1m{fzZjJHmynVP{U z`tY>XdZ9^4f2?JcyVtjV3) zbvWmvO76N{f@J(7lT!!LR)m-9CrMz|w>B;PsK~`xP3+}yAcdJFj=nz(6)DU`Tg|$D z4`D!n9x&NjkA^1v3TnVY-8fVdRw&hrX~DAsbg~_bXV#+;SNvLHSU=;wpliD3!<4<6 z1%E(XVBeMQ%lXS`$_K~V?!8SOC@wCzoSou%7>7DO#NFNEcmA8*U zM0om=BR56swWlC=N}EM;*?oRZ=S7_1WY=Cn217$T&K;}JG90sg6RI%Pgot*2VUqj6 zxz_u!HwknV4<+^KqZbpWgU$))1H4e4(}RX6^5#ff>y70Kdd6233~4rbP%9L*ZTY5R znH%3vf#5L=@n>0ePUgt+7#Sp^`TVs(nt}XG)C&_y?tT&Nuk8HAVLENFJsGX)_5wgD;0o6cxrEcBl zc{iVBHu!$a*4vL-y%uZQfoNL=Uw{OS>p?y&s!7}N-1p=HIoQiIzVys*k5S!05QT*G z$$Fq9E$GHl!NUv-hGQr)g5?rLjRY~(eB$nO2bHPXhsV*ZB2WCYtuEBX95GNAOV=EY zU!ps%dIp1-@m09C#9%f&r~!ltfTO04Fi4}->f33x_<6H#f2Q(f3%dDKF?F4gD{6_T zm6F&Z!XUja zfxyf2x76fI#=&|exy$6&PT>~A+`dK8YI{XUyN5z$A_a?{m%wWW&od^0;7T7WBGwe;mpA45Sp-iC?N@2CpyVu7j;>QmWDe&kY|74ZC^g!Rkp)jCw&4=qA zg2DA?u5o9KfDx`eIfHDR(MQ~TV|A?drg3K7bv58ZCqOQA5K{kQ-YBy+%LW?c@R6z6 zbmn`sdrsYRVp|3S|J11ffh9(CSFqBUwb9WGZ{n~ra^BU z+LG^hmr;&aCAGgM6TwX!Xc>{?S_OJn+&UlZ?xo~XLU9rlUjayC`o70PABr@1`~r}*q}r|KafQAy2=)nyNwhFPa`PEoNX=Si)$+3e^RAPik{bJ3N`tmiX52)_QS)igqUU2$f0W843}r-n zLhNXiXj;hm+Xwy=W4V)Z9UVCEU*nR^bl?4WE!VUIsr;45vI*@mx!Kr`bOaF4LzUM)ldxu0bv21xE8e><@f!mTC_daU- zBD9?93OVsft#Qi(-|c|QW1RdJ#8P~^@Av>y^*l<_^$7K)r(nkZCzcO;D!U>S30lO0 z)FFW1Di3tipvS57a=W-1>);n&VO~|_OHGq7@6Qy*V6=#AbZmUz$peL?x(pWtJmAb- z8x?w+%Cq}@(u4x8GcH(MhAt@T&rl>NTMue*=lP(wjs!;*k2~Ebd7o)%Rj^+XCH2Ip zx#M^B8r_y*h2fIec_hkb0&y;%h7ts=aPxjd1!HtKqg5-%uz6E9wDY^a)k}c+=#dQX zoX1hGLVNK90aLi0W&r)+{38oYUf{EhAh4YM#WN-l>-rnP51Pwfj}mQBsePHL3QbG^ zF=*$>qlsY(9`8~RrxL_jJiqxO-$fwFqb}`J&7asQ@*XseH@HS-&}83Q0iGGuP?EWw z!gx2EoUB4|rH!PLFH&-8dGuUcdtZb=F@&VEFVqCsUEAl+qr&ufHbfqgv(c?Kc>e5E z7x(HmvjDpLB3_ET0!57n8I?{#U%-4k`-jV*>`3ZA;Vmrd0A&#U|0lA439<{4?|f>d zh-6TmO~}ZB;!sFFXhMi#Z@&&n{Bt1)RzVY*EB*G63g;5WNoIis6n00VUtEbhj-SMS zZfO8xqmKl7Rq>b4!?>8FQd@=O)S%3Zj|p@*Sm68_A3ZKq2>7o912|&pf&4psmx+O< z)*n6(6=PgnNm@xIqWL!>&kEUNuEa~!`geShVK(9E=T>Pq{5Z%Rf&oVqILxX3O{d{ok2VIghVj?H8z%pTzR<$D_cu{skB!XrSl-luV+Y zy?-&y5->JVA?tBU@*59HJA1J3$Vr0}J7#b1KQI0}7UB6=3>{^4cfeRJAryf-%fo@d z4-W*}SI-yuu_S~EvWQtRG=iY!Yue?OI)76ci~p50*alnRRE048(el0R$LA?3?W$+S zBY=jc2{hv(ZjGSVw=f7!gl<0Q-vF{WjerY_f(}rQo#(wk{_3`xy2~SYit@i%9CT={ z>F!hw#V%Ge6awCw!Y5x|!_7g&%y9GP7it#OWzLLt$fNN=`z1WL(Esip>iK+65>00> zlN}h#FS+OMzR7uuBs*34W|!}_4>B!iPipUzTvLPtx+OWo?DF4zWRlRfVN>vKJ^JA^ z3B?grI~LJGB05mapi=r`>mi`x(vGuST@g2`l!tJPt8upqp}4ep3J*#)4vJ0lo4=OD z+vXKod0Jc`1ITRLZg;o%3%;(Zxzt)2(9dx5TF?-mKs%Dm3HlkJNqL6|+Cx5c%!=`j-u$~?RWLLUB~KI|)aO6ukV1pwV;V+& z1|8;_(+b&c|C6Rc$SLxaJq7lM-CHz^d<}E>z8Odb<+$;s4{eUkYH*$7`W8EPp2W?^`fnkP!nI1mh#D|2x|cgaHc} zG~Y>fgZMw*?ayYx1@M9#M85Ce?flOVkYxD;4LU5E@o!$~?_2zVMLdxDnX> z;RPIpj0i~zC97D{z`ufh!1yZ-*yN$StHB`l<~GQ10+Kj9=pZAizC>M9!qv4Z(?k-z zzQ}L*2^;--$(jsb9{Okzl&zkvOi*Ildy_1pWA?rsJNKXM>w^KKf+&3v#F%L3$;pff#3}vRRcw6i@cYlfpzO>@nM%L| zwjX%EM}%WW@WK%w)fvH@#(5++1Y?`X~29bNVpHt$Pf-gly4x^ zf8`7TLttKo<16W@-v&Demzdk1R&5GUC`Nce%xMES9h59!xG94ErN8I>9ZAmU*>&+o zOmc(&P$4=cCjNjt?k1GRM!Uvi(C{oOodYP6Tj=aEp+p$?32wlSZvqYu$u;Ff%K6?D z{l!D}2)yZmFMkInBg*MN%Vq1pB(c2EbFH_EfR1=MfJMp3z-VBw8Nme9H}GLJLPLoS zMjIg6UsYxB+4pvl+W*udIdPEWZ&AzlSO=2d6VO4)$^(cn9?0B(93sf^h*5)q+4+U^ z#*NIyq!F^lub6PP2<->NL66v;{`XUnDde1;$6A%q-^86V@VQL~T|m})hc@*@BSyV0X&|^Yo-56IN+-9dU1}coxEY-e($8W1f&$D^Mz@IEpd+x z{hx*4-iEFVpwkY4DImJq;J*PHxcUXsxw6r1CN7AviY@E(K6P~d zT?g2dgrT0~c4x_Mzo{V8Y#g;*g94GyX#g0(bI%9NjjCwMK(0OWS+-9(8n2V{0{9fe zrT%w3bWls&rtPJHBf4z0mrxK}O{zh1ZyXbVCE8z}*5!!(K26RM>oGNzP&g&}?greu z>~+xLo6pUZu;LW(t>V`oblaq#$yW)i1W4L}E+WlO9PeMl7o~V-V+$F4_{tq0UM@A@ zVWa?;8O8i%oz`*0&=AZTJlXO>oDWkLr?pRlWYedTc{caB3q!$Y-I|15F|Pwo2~Kyq`NKw zD2lGD{6{Yv06%&3>bX3Y?im7-){lgW;cWMNS*6Ghmr^1<4_*CZd7F!OX(XeH#XI)thKQTkZ0~8{p_Z za{A$b3Xf*s#+DnFgb+ENAoRM)50%4FgE){j-{fEN>U1CAy&XQxtpdmsc<`Cw|&3AKD1$=$5MOTZ9?y=-%vXGZSqPs9v}dJ$Wa1Wvwg)!r+_&cEW+0EKe1P&Pf%JIIs+Em}!U|}N z8Xi(+hiDsHUjaE#@@r4*ngGh6&G3g3*KbEhw`4q~Jr2B|km*v$V8n`zH?R0QhG{HnvLGLY z!o6m-=ZjV#z;qBR8QrF`a6h`OR(C*V&hE(K(;H2?VNC=BSLtl50~37e#Z-uD;55ns z_2UiBo9z8iEib_jO}a`{i~K#$k*|I`%1+Mv*BUkj2)~cy4&GOAnYK7GgOI_$Pw3dC z3;?Wg8U!-J{T|-9+GgY{+z%ynn~JQ;zG)!#cPr|zXys`W(Hua8ply8%54)l&G&ihR z4T&k{9ZboReaOYIEuP;e;*;lllYB*&j}OTS$WbkuRP+kv6Qgdo_@(2 z=`;%JW?#-jt6%r$;&(mzB3ah@a!h)k9d7Pl1?tMT zSPpIXarPxfAR59#K;^tY_RFMPUB5eyhk0t&_~loS0{^9$@3GY_)F#U_9Y4LM7E#1D zdCvPwE7`f;n`3{MuP)?Uk|WVENrz5BeXZ5!8!yTI79IJqF5Xz^pQR zK|QjMIkdgO0YbuL2L3$V4b#+WG);DkIatDgBZgG7d5b4{^R_H?MZ6?>(Q5&?#z;DO z>#fo~2$Y6uNr~2D7n`5>y7kIb!x3T>9woD(%+q%*<_n=uKfmrGic@ku|W@@+j>NJ*)BRTzP1-a70@6K1Cu0TfhF z27P3lz{gImJ8vn-neZ?|y~O#M>4W$JD81{gk`&E__aE#pt>z0wCKpU*C_&n!)ZCi< zmmp^MUcVbMTM_3?zt>q)Nds{xVKP}2{tg>fU$khE=s>F4#jaTCB3U(vF`kBi ze^7<}Qr_5>uuws9F>WBLt8yNS^pF|37Ak!=_WBg}hC*&c1wCT(9*Pv};PhzCMx$Y+ z_bEe(cyHzDUfo`8C5?oR`mLPB)m01)GYE80bGgUo`Unh}s5+zbC;~Ude@7ABl>V}x zPV{BA+UX(naQn~hj}pBhd8ig&A$K0#kSf!Vst`U@>1BwBlB`Qw3xYuuxiq1|IBB6B zv1wl0>Fef>4d^(2(wA>Fd$zhaZ9OrOkd*&&^ ztCIB7_y`hT%sRs1tf+uPnzJr^I$MawlD4AhtDmcAEQ;z9x5ZYDuZb@jGo$5ImW3&b zXO|@i%v){vo4ABewg+gE=m8&0o2|skIt{!j^yoz#BZ-glc}Bza#8A|85vVVwC9KxP zjm(7s8iIEFWa?IBe<`VXj;PY?ev0;8>xZslA6b)ep#;uOY)}|2b)|-c218AfcdvCX z^Ln0hq4-DJV%J(Iy(ydKJ3BDki%|K%Z^o9Mc!UkkVnIU*Zp8EFytmP?fraIm!8Ctm zhaa~V9K6sG=Q$32Dkr|Dp95(5U@3cKZW(HxfV|r3b3DqO<#%=j=@rH)O?!4BS1Btt zy5X|vXY+%!Et40Y@LK$m;W zL%mr}7&!xFUHGs9w&EZ9pbY1+(C2BH*hVafbu@@KnrR4BE&QJSsHofd=vh%$!2rAw zvpwHuQEtk~i_MgCw-fUc*IiWSHlnU-($SO1n40B?dAhQkN^wQhG9Yz+FSpe2HU}h) zdAd$7g#}`@=Ewyk9UUeXns*Jf3v5Zyjv+oSRqfW{&+yeQ3`yN)y&OXScJ4rX=u5Mm z&M6*&{V%D|aP%ymII@l2b;e3TS)H!Imglt?zidQg3jx@+jS;{a=e<4-A=*h`IR#7d z1|8@D?G1gNy|2T$M27ODTk#lk85-CKhXgkV_$fad4B%E`OB55PSn(2>$$+Dq{cR_} zF59i51u;EvW zN33tMbLda+w^A;|9q74I`)RzP^cU&5E^Fux>|7J_jv#u#fOqR~LoVSgF4-`cHw;Dn zWy0Nm86;Yg_iJ#Q0(MCz0J80??d=*&wJe79TosS^jL1xJ{FrOK-}au3oaU)a+IA4f z7x$Q#bKx zcvsve35Z%$%ADC<4iP?CkjeN~&l2W%If6HPn}{0C3&V8qO|Ym4n~M_*ZD`O)1al&tIjgOhe_ zVT;;@SkPsj!!cpzwo11@g&w8K-K0c*+O>v*N`2sY**P<`%{I^7p!-Q9FD9MG$Y>D( zDXZ_kXt}B`V!L{v{6r1`z6t7oFYt_Ro2W>C@p*4bcf4OkHk1*W-TFz$Q@Cc;lt|RI zQ5_tF0#F$2T})55B#O7`&Pg*Z`?eoUg$nsb#^sj&PsKrAvfq_;d_MD9y_ucm4SxNJ zj^iF;{TyLZxoA^^Y?YjhHwck+lkyJdmc3#^#mq!S_40d_PMdiG{3itR`cmDKV@a1LQ$3DsD!g5}KNsEkU(jMvgJ82RkZg=LSTW5+w z?R;Ex&QfR_&GYW%o$oatZOw4MAL|5o2n0-nE}}b$J)#p-?Z#i4cI>2#Zd)z+l_|FC zizdBcp6Y0UFC(W>!MNl%VEHp~Z`$&v$3)o)x7$#c;VLa>Z(Ff#PA4Er?-g~R8^BcJ zEJVG%gyC{VWH^~ z)hy7QS&0SA)qpvxyyYn+Vvm-Wnt`^rHRoa6|B}HV-rL2T|KS=y;pa>|oHICDJ=`6W z5WU4!P|VH*cXp@%?E4nufcuMEF0pDRj=*ql01JR))C18L{Ky=2Dtv@Q*Y6&#l8GAQ zVqDYM-#*l{pC6h|Ek)JLw-uNqI`yzhY`kpE2W8kbBS!W+9tDQ%DBk zhyC>i+ci#gm*efRhrb@u#a@T+6In}07FPbq(^XXO!eZA>{cIcfaM<8lH!!u~Oe`TLBl-Ky17dg5K>}szA@MI)Nk_3mQOh?gd zbkc{Dgl(;|I7dWOjW1#B=0zHi^Zx9{aGCcYq_JX@Vu*+-k5vL0n5sL0U?&l%Qs2pY z>j4@+$hdm4xC!oeWYS1AOcJwWvD1*xlrU~;&~bJQbv;aq)zl%+9@Z$bVk$z38TEv4Z)2jr(|J|q71$BSTSo z;7`m2->-?!pEF$eRdQZN^B$^XQkh&3gMtioP~i7;&Q8Y{t!X+wp~v3$>wcL)+G1DQ z%`J{0`_R4V;tBa?9uv{8xiX+ZreE~S8We<6@-_4Ad~jR=4#scBbXMPufyJ$r#{;IOTAq3=1q zQ9oHXFwo1f({=o7_mp}!*j(~yo1Tx^!YFGUg9Ag1-q>X4U|Z4FRtM&cIWvj7*s7Ss zNohO)&qvZQjo%kl~I%x$uJCEAgegH`_WG{H(NcOs}xH<52(58h)37CPtH-;j%T4 zrPaRIYTt%?SWb>6eFFLLt&BPnF>CKkpQ4{83uTQFamn%<<$CEfOP2U2UK$?mJZ_D9 zA6Q`IU@8*rSHb^EkJI7=i~M@2T8Za|)ewh)UqDfLE*ej2_UXo>{?EosyUf>E;Li6C z^;jTy0Q;;6+>o%0VQSLHN(cqogb;#&e9lQ|%J~6U5A(oCWynGrR|;&oEG?y_J;5r| z)B~260CWb<7l&|fUTCXjktJ&(p|wOad8MfzdoxOR>}*ZH&D7LRvXX1UjC4R)5T$?_ zGSu+KW8Juj-bUjQ{uwiYi!T}(RY$d^BW=hQQaXm{D%})gSq=sSOsj>Z&CVtmgMz z5d|!##LTt=6*@S}^>J&!KfG2OS$TT0I}uo7!PHukb(tAAhmWXlmCS=SD&I0?4s}4} z{KkC$+4Drzm}#2nz|2z&=9L`Obx9Eat+V$&BrZxR?VHM)cy}r&>9;*aGiAe_PP_#? zAx~|@}SJ-1p5hMLA-(8o zY}1#^7#ft2Eu{yh$MX)>OBgoii^@P-FHc^-6{--KtQioF0u~>n3Lw|FF1R=(Uo zoejKDIX2HTxpjz&us-eVjVfb4QS};Y&358Gc05vkMAE(_yNx~-b+pfZ*=IKGZy zzdd30b6nUAd9Pea>1!dy26-&>L>Ad6nT_|UaT*PheDW%uWVZd<8-Vg<02don2Equ# z4Wo}WT`aFAA8WWA^GArpwtf7yoUP8YOEZA8)SX@8O@z|^)Dx97T_9E0kdjff>>;dA z|J`MYb)dgL*IY+}ymrpgM(gbB`a*>3SZHog5UtEH-){j2C%&e$&noL1TLakf_m}pF zV&2upGax;B%G5tghT*NmkD4uW7lM*Qg}e11m>>|7rWg*(ILL%q?zrJ*mzZ$etYXT0@%>45SM=ch^g;_Da(j>iwLCW8ltQmM0hv3yHOu=f9ZUX`C{5Jh)4pzu~p5mjxk)B zsCswQVpbl_{&jz$tve-IWCd@5t*H6#>qjc)sHqfnUbs@;jIXUl(AmZU;+ zU^=j}&SSQ?6;CaGyw9XX_+8bB3$E%lyEhGBh2)o$r1tr?r?O)fP--^3;$I_fPCfc1 zDf^q}wNI<4XJ-5jE$uU;!P6y!Y}WR8QOyuBe3Y~G(dG!{Dkqyv5EHymAwjK3JrH$iC<)?vC<6pkJur%5R- zeJbL1ltO4LdxsUr@@|dRH2IHSTVZxl>T=33dG)teWQXa)i$C4j`KTu}f;ATL6SkP} zo(kfTHeUJsqOt;&8PbuwewQO`%&~W8dca_w>@}QC)5 zhI!fm9Cf0eOfR&i;e35pJOfzX$6){1X9hh1@)nBvmFsw)_1>4m z`aYBzx!E|tY2KwY8!f2WrDEaucon=^`^7Ucq&O7pf_yNU`D0UUP(Vf*Bbxg>2zdnA zDWmn9wsfG9UUPA^52GFO!o%!CLduAo?0e@dFU%d5ZsP zaTxJMX&VCu=6PH>)$k3%m~-F88*1_$R%|4W;r^St5DskVxlk)sD!Im?b4v5 zXv~g+P#EVQ*K_13j#~Z>3n03HdSB%|s^VP%4N{?2cb7mmu0ViWMj5*L*KGrUF_;FR z6+EbL87fDHuu=aP`@??$IjbG5v;irHAHQi&B48(Z5=NZk5To`J8W4%*khp0Nffhsn z4M*6VX=Eh;h2BQ+?Nw!U^Zs_9`&Ayq+nQvY=}>Ncu~cgXC+rCr5#64df(2d!?X>Kpk|PO5L*6 z@z>W)RbXIgYus?+0dVcwqksqzMaUr*4jszNW<;3+4|B2bZ7>aAWis`6=%vsb&;+Nz z^j6@()l+-F)Zgm7Y# Date: Thu, 20 Jul 2023 22:50:01 -0400 Subject: [PATCH 04/14] Add tests for MissingValueCode model & collection Issue #612 --- .../metadata/eml/EMLMissingValueCodes.js | 13 ++- .../metadata/eml211/EMLMissingValueCode.js | 41 ++++------ test/config/tests.json | 2 + .../metadata/eml/EMLMissingValueCodes.spec.js | 81 +++++++++++++++++++ .../eml211/EMLMissingValueCode.spec.js | 78 ++++++++++++++++++ 5 files changed, 185 insertions(+), 30 deletions(-) create mode 100644 test/js/specs/unit/collections/metadata/eml/EMLMissingValueCodes.spec.js create mode 100644 test/js/specs/unit/models/metadata/eml211/EMLMissingValueCode.spec.js diff --git a/src/js/collections/metadata/eml/EMLMissingValueCodes.js b/src/js/collections/metadata/eml/EMLMissingValueCodes.js index f0573e22d..e4d81b315 100644 --- a/src/js/collections/metadata/eml/EMLMissingValueCodes.js +++ b/src/js/collections/metadata/eml/EMLMissingValueCodes.js @@ -29,12 +29,15 @@ define(["backbone", "models/metadata/eml211/EMLMissingValueCode"], function ( if (!objectDOM) return; const $objectDOM = $(objectDOM); + // Get all of the missingValueCode nodes + const nodeName = "missingvaluecode"; + const nodes = $objectDOM.filter(nodeName); // Loop through each missingValueCode node const opts = { parse: true }; - for (var i = 0; i < $objectDOM.length; i++) { - const missingValueCodeNode = $objectDOM[i]; + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; // Create a new missingValueCode model & add it to the collection - const attrs = { objectDOM: missingValueCodeNode }; + const attrs = { objectDOM: node }; const missingValueCode = new EMLMissingValueCode(attrs, opts); collection.add(missingValueCode); } @@ -71,7 +74,9 @@ define(["backbone", "models/metadata/eml211/EMLMissingValueCode"], function ( errors.push(model.validationError); } }); - return errors.length ? errors : null; + // return errors.length ? errors : null; + // For now, if there is at least one error, just return the first one + return errors.length ? errors[0] : null; }, } ); diff --git a/src/js/models/metadata/eml211/EMLMissingValueCode.js b/src/js/models/metadata/eml211/EMLMissingValueCode.js index 4febd2d92..3653a7d40 100644 --- a/src/js/models/metadata/eml211/EMLMissingValueCode.js +++ b/src/js/models/metadata/eml211/EMLMissingValueCode.js @@ -48,12 +48,14 @@ define(["backbone"], function (Backbone) { * @return {string} The XML string */ serialize: function () { - const xml = this.updateDOM().outerHTML; - const nodes = this.get("nodeOrder"); + let xml = this.updateDOM().outerHTML; + const elNames = this.get("nodeOrder"); + elNames.push(this.get("type")); // replace lowercase node names with camelCase - nodes.forEach((node) => { - xml.replace(`<${node.toLowerCase()}>`, `<${node}>`); - xml.replace(``, ``); + elNames.forEach((elName) => { + let elNameLower = elName.toLowerCase(); + xml = xml.replace(`<${elNameLower}>`, `<${elName}>`); + xml = xml.replace(``, ``); }); return xml; }, @@ -91,7 +93,6 @@ define(["backbone"], function (Backbone) { } else { return $objectDOM[0]; } - }, /** @@ -104,39 +105,27 @@ define(["backbone"], function (Backbone) { /** * Validate the model attributes - * @return {object} The validation errors, if any + * @return {object|undefined} The validation errors, if any */ - validate: function () { + validate() { if (this.isEmpty()) return undefined; - const errors = []; + const errors = {}; // Need a code and an explanation. Both must be non-empty strings. - let code = this.get("code"); - let codeExplanation = this.get("codeExplanation"); - if ( - !code || - !codeExplanation || - typeof code !== "string" || - typeof codeExplanation !== "string" - ) { - errors.missingValueCode = - "Missing value code and explanation are required."; - return errors; - } - code = code.trim(); - codeExplanation = codeExplanation.trim(); + let code = this.get("code")?.trim(); + let codeExplanation = this.get("codeExplanation")?.trim(); + this.set("code", code); this.set("codeExplanation", codeExplanation); - // Code must be a non-empty string if (!code || !codeExplanation) { errors.missingValueCode = - "Missing value code and explanation are required."; + "Both a missing value code and explanation are required."; return errors; } - return errors.length > 0 ? errors : undefined; + return Object.keys(errors).length > 0 ? errors : undefined; }, } ); diff --git a/test/config/tests.json b/test/config/tests.json index cffe268a7..8f18c6b46 100644 --- a/test/config/tests.json +++ b/test/config/tests.json @@ -18,6 +18,8 @@ "./js/specs/unit/models/metadata/eml211/EMLOtherEntity.spec.js", "./js/specs/unit/models/metadata/eml211/EMLParty.spec.js", "./js/specs/unit/models/metadata/eml211/EMLTemporalCoverage.spec.js", + "./js/specs/unit/collections/metadata/eml/EMLMissingValueCodes.spec.js", + "./js/specs/unit/models/metadata/eml211/EMLMissingValueCode.spec.js", "./js/specs/unit/models/maps/assets/CesiumImagery.spec.js", "./js/specs/unit/collections/maps/Geohashes.spec.js", "./js/specs/unit/models/connectors/Filters-Map.spec.js", diff --git a/test/js/specs/unit/collections/metadata/eml/EMLMissingValueCodes.spec.js b/test/js/specs/unit/collections/metadata/eml/EMLMissingValueCodes.spec.js new file mode 100644 index 000000000..f40f1e592 --- /dev/null +++ b/test/js/specs/unit/collections/metadata/eml/EMLMissingValueCodes.spec.js @@ -0,0 +1,81 @@ +define([ + "../../../../../../../../src/js/collections/metadata/eml/EMLMissingValueCodes", +], function (EMLMissingValueCodes) { + // Configure the Chai assertion library + var should = chai.should(); + var expect = chai.expect; + + describe("EMLMissingValueCodes Test Suite", function () { + /* Set up */ + beforeEach(function () { + this.emlMissingValueCodes = new EMLMissingValueCodes(); + }); + + /* Tear down */ + afterEach(function () { + delete this.emlMissingValueCodes; + }); + + describe("Initialization", function () { + it("should create a EMLMissingValueCodes instance", function () { + new EMLMissingValueCodes().should.be.instanceof(EMLMissingValueCodes); + }); + }); + + describe("Parsing", function () { + it("should parse an EMLMissingValueCodes from XML", function () { + var xmlString = ` + 9999 + Sensor down + + + 9998 + Technician error + `; + + this.emlMissingValueCodes.parse(xmlString); + + this.emlMissingValueCodes.length.should.equal(2); + this.emlMissingValueCodes.at(0).get("code").should.equal("9999"); + this.emlMissingValueCodes + .at(0) + .get("codeExplanation") + .should.equal("Sensor down"); + this.emlMissingValueCodes.at(1).get("code").should.equal("9998"); + this.emlMissingValueCodes + .at(1) + .get("codeExplanation") + .should.equal("Technician error"); + + }); + }); + + describe("Validation", function () { + it("should validate valid EMLMissingValueCodes", function () { + this.emlMissingValueCodes.add({ + code: "9999", + codeExplanation: "Sensor down", + }) + var errors = this.emlMissingValueCodes.validate(); + + expect(errors).to.be.null; + }); + + it("should validate invalid EMLMissingValueCodes", function () { + + this.emlMissingValueCodes.add({ + code: "", + codeExplanation: "Sensor down", + }) + + var errors = this.emlMissingValueCodes.validate(); + + errors.should.be.an("object"); + errors.should.have.property("missingValueCode"); + errors.missingValueCode.should.be.a("string"); + + }); + }); + + }); +}); diff --git a/test/js/specs/unit/models/metadata/eml211/EMLMissingValueCode.spec.js b/test/js/specs/unit/models/metadata/eml211/EMLMissingValueCode.spec.js new file mode 100644 index 000000000..ca88cb250 --- /dev/null +++ b/test/js/specs/unit/models/metadata/eml211/EMLMissingValueCode.spec.js @@ -0,0 +1,78 @@ +define([ + "../../../../../../../../src/js/models/metadata/eml211/EMLMissingValueCode", +], function (EMLMissingValueCode) { + // Configure the Chai assertion library + var should = chai.should(); + var expect = chai.expect; + + describe("EMLMissingValueCode Test Suite", function () { + /* Set up */ + beforeEach(function () { + this.emlMissingValueCode = new EMLMissingValueCode(); + }); + + /* Tear down */ + afterEach(function () { + delete this.emlMissingValueCode; + }); + + describe("Initialization", function () { + it("should create a EMLMissingValueCode instance", function () { + new EMLMissingValueCode().should.be.instanceof(EMLMissingValueCode); + }); + }); + + describe("Parsing", function () { + it("should parse an EMLMissingValueCode from XML", function () { + var xmlString = + "9999Missing value"; + + var emlMissingValueCode = new EMLMissingValueCode( + { + objectDOM: xmlString, + }, + { parse: true } + ); + emlMissingValueCode.get("code").should.equal("9999"); + emlMissingValueCode + .get("codeExplanation") + .should.equal("Missing value"); + }); + }); + + describe("Serializing", function () { + it("should serialize the EMLMissingValueCode to XML", function () { + var emlMissingValueCode = new EMLMissingValueCode({ + code: "9999", + codeExplanation: "Missing value", + }); + var xmlString = emlMissingValueCode.serialize(); + xmlString.should.be.a("string"); + xmlString.should.equal( + "9999Missing value" + ); + }); + }); + + describe("Validation", function () { + it("should validate a valid EMLMissingValueCode", function () { + var emlMissingValueCode = new EMLMissingValueCode({ + code: "9999", + codeExplanation: "Missing value", + }); + var errors = emlMissingValueCode.validate(); + expect(errors).to.be.undefined; + }); + + it("should not validate an invalid EMLMissingValueCode", function () { + var emlMissingValueCode = new EMLMissingValueCode({ + code: "-999", + codeExplanation: "", + }); + var errors = emlMissingValueCode.validate(); + expect(errors).to.be.an("object"); + expect(errors.missingValueCode).to.be.a("string"); + }); + }); + }); +}); From 27a77f002e965961dddd9d00b7491feb114ea670 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 20 Jul 2023 23:11:41 -0400 Subject: [PATCH 05/14] Display missing value code validation errors Issue #612 --- src/js/models/metadata/eml211/EMLAttribute.js | 34 ++++++++++--------- .../views/metadata/EML211MissingValueView.js | 2 +- .../views/metadata/EML211MissingValuesView.js | 4 +-- 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/src/js/models/metadata/eml211/EMLAttribute.js b/src/js/models/metadata/eml211/EMLAttribute.js index 47e993b38..4e0b1381a 100644 --- a/src/js/models/metadata/eml211/EMLAttribute.js +++ b/src/js/models/metadata/eml211/EMLAttribute.js @@ -481,25 +481,27 @@ define(["jquery", "underscore", "backbone", "uuid", errors.measurementScale = "Choose a measurement scale category for this attribute."; } else{ - var measurementScaleIsValid = measurementScaleModel.isValid(); - - // If there is a measurement scale model and it is valid and there are no other - // errors, then trigger this model as valid and exit. - if( measurementScaleIsValid && !Object.keys(errors).length ){ - - this.trigger("valid", this); - return; - - } - else if( !measurementScaleIsValid ){ + if( !measurementScaleModel.isValid() ){ errors.measurementScale = "More information is needed."; } - } - - //If there is at least one error, then return the errors object - if(Object.keys(errors).length) - return errors; + } + + // Validate the missing value codes + var missingValueCodesErrors = this.get("missingValueCodes")?.validate(); + if (missingValueCodesErrors) { + // Just display the first error message + errors.missingValueCodes = Object.values(missingValueCodesErrors)[0] + } + // If there is a measurement scale model and it is valid and there are no other + // errors, then trigger this model as valid and exit. + if (!Object.keys(errors).length) { + this.trigger("valid", this); + return; + } else { + //If there is at least one error, then return the errors object + return errors; + } }, /* diff --git a/src/js/views/metadata/EML211MissingValueView.js b/src/js/views/metadata/EML211MissingValueView.js index f4a4461ed..bac4ac39d 100644 --- a/src/js/views/metadata/EML211MissingValueView.js +++ b/src/js/views/metadata/EML211MissingValueView.js @@ -12,7 +12,7 @@ define([ * collection, the view will also provide a button to remove the model from * the collection. * @classcategory Views/Metadata - * @screenshot views/metadata/EMLMissingValueView.png // <- TODO + * @screenshot views/metadata/EMLMissingValueView.png * @extends Backbone.View * @since x.x.x */ diff --git a/src/js/views/metadata/EML211MissingValuesView.js b/src/js/views/metadata/EML211MissingValuesView.js index 373d84a78..2563fcab8 100644 --- a/src/js/views/metadata/EML211MissingValuesView.js +++ b/src/js/views/metadata/EML211MissingValuesView.js @@ -19,7 +19,7 @@ define([ * "Remove" button next to the code. A new row of inputs will automatically be * added to the view when the user starts typing in the last row of inputs. * @classcategory Views/Metadata - * @screenshot views/metadata/EMLMissingValuesView.png // <- TODO + * @screenshot views/metadata/EMLMissingValuesView.png * @extends Backbone.View * @since x.x.x */ @@ -101,6 +101,7 @@ define([ } this.setListeners(); this.el.innerHTML = ""; + this.el.setAttribute("data-category", "missingValueCodes"); this.renderText(); this.renderRows(); @@ -125,7 +126,6 @@ define([ this.notification = document.createElement("p"); this.notification.classList.add(this.classes.notification); - this.notification.setAttribute("data-category", "missingValueCodes"); this.el.appendChild(this.notification); }, From e45bd0d41c6f250fa9b2bdefe127f9526deae04c Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 20 Jul 2023 23:32:30 -0400 Subject: [PATCH 06/14] Use consistent naming for missing value code views Issue #612 --- ...ueView.png => EMLMissingValueCodeView.png} | Bin ...sView.png => EMLMissingValueCodesView.png} | Bin ...eView.js => EML211MissingValueCodeView.js} | 20 ++++++------- ...View.js => EML211MissingValueCodesView.js} | 28 +++++++++--------- src/js/views/metadata/EMLAttributeView.js | 12 ++++---- 5 files changed, 30 insertions(+), 30 deletions(-) rename docs/screenshots/views/metadata/{EMLMissingValueView.png => EMLMissingValueCodeView.png} (100%) rename docs/screenshots/views/metadata/{EMLMissingValuesView.png => EMLMissingValueCodesView.png} (100%) rename src/js/views/metadata/{EML211MissingValueView.js => EML211MissingValueCodeView.js} (93%) rename src/js/views/metadata/{EML211MissingValuesView.js => EML211MissingValueCodesView.js} (90%) diff --git a/docs/screenshots/views/metadata/EMLMissingValueView.png b/docs/screenshots/views/metadata/EMLMissingValueCodeView.png similarity index 100% rename from docs/screenshots/views/metadata/EMLMissingValueView.png rename to docs/screenshots/views/metadata/EMLMissingValueCodeView.png diff --git a/docs/screenshots/views/metadata/EMLMissingValuesView.png b/docs/screenshots/views/metadata/EMLMissingValueCodesView.png similarity index 100% rename from docs/screenshots/views/metadata/EMLMissingValuesView.png rename to docs/screenshots/views/metadata/EMLMissingValueCodesView.png diff --git a/src/js/views/metadata/EML211MissingValueView.js b/src/js/views/metadata/EML211MissingValueCodeView.js similarity index 93% rename from src/js/views/metadata/EML211MissingValueView.js rename to src/js/views/metadata/EML211MissingValueCodeView.js index bac4ac39d..89424be3c 100644 --- a/src/js/views/metadata/EML211MissingValueView.js +++ b/src/js/views/metadata/EML211MissingValueCodeView.js @@ -5,19 +5,19 @@ define([ "models/metadata/eml211/EMLMissingValueCode", ], function ($, Backbone, EMLMissingValueCode) { /** - * @class EMLMissingValueView - * @classdesc An EMLMissingValueView provides an editing interface for a + * @class EMLMissingValueCodeView + * @classdesc An EMLMissingValueCodeView provides an editing interface for a * single EML Missing Value Code. The view provides two inputs, one of the * code and one for the code explanation. If the model is part of a * collection, the view will also provide a button to remove the model from * the collection. * @classcategory Views/Metadata - * @screenshot views/metadata/EMLMissingValueView.png + * @screenshot views/metadata/EMLMissingValueCodeView.png * @extends Backbone.View * @since x.x.x */ - var EMLMissingValueView = Backbone.View.extend( - /** @lends EMLMissingValueView.prototype */ { + var EMLMissingValueCodeView = Backbone.View.extend( + /** @lends EMLMissingValueCodeView.prototype */ { tagName: "div", /** @@ -76,7 +76,7 @@ define([ isNew: false, /** - * Creates a new EMLMissingValueView + * Creates a new EMLMissingValueCodeView * @param {Object} options - A literal object with options to pass to the * view * @param {EMLAttribute} [options.model] - The EMLMissingValueCode model @@ -90,13 +90,13 @@ define([ /** * Renders this view - * @return {EMLMissingValueView} A reference to this view + * @return {EMLMissingValueCodeView} A reference to this view */ render: function () { try { if (!this.model) { console.warn( - "An EMLMissingValueView model is required to render this view." + "An EMLMissingValueCodeView model is required to render this view." ); return this; } @@ -116,7 +116,7 @@ define([ return this; } catch (error) { - console.log("Error rendering EMLMissingValueView", error); + console.log("Error rendering EMLMissingValueCodeView", error); } }, @@ -278,5 +278,5 @@ define([ } ); - return EMLMissingValueView; + return EMLMissingValueCodeView; }); diff --git a/src/js/views/metadata/EML211MissingValuesView.js b/src/js/views/metadata/EML211MissingValueCodesView.js similarity index 90% rename from src/js/views/metadata/EML211MissingValuesView.js rename to src/js/views/metadata/EML211MissingValueCodesView.js index 2563fcab8..49c20d4c2 100644 --- a/src/js/views/metadata/EML211MissingValuesView.js +++ b/src/js/views/metadata/EML211MissingValueCodesView.js @@ -3,28 +3,28 @@ define([ "backbone", "models/metadata/eml211/EMLMissingValueCode", "collections/metadata/eml/EMLMissingValueCodes", - "views/metadata/EML211MissingValueView", + "views/metadata/EML211MissingValueCodeView", ], function ( Backbone, EMLMissingValueCode, EMLMissingValueCodes, - EML211MissingValueView + EML211MissingValueCodeView ) { /** - * @class EMLMissingValuesView - * @classdesc An EMLMissingValuesView provides an editing interface for an EML + * @class EMLMissingValueCodesView + * @classdesc An EMLMissingValueCodesView provides an editing interface for an EML * Missing Value Codes collection. For each missing value code, the view * provides two inputs, one of the code and one for the code explanation. Each * missing value code can be removed from the collection by clicking the * "Remove" button next to the code. A new row of inputs will automatically be * added to the view when the user starts typing in the last row of inputs. * @classcategory Views/Metadata - * @screenshot views/metadata/EMLMissingValuesView.png + * @screenshot views/metadata/EMLMissingValueCodesView.png * @extends Backbone.View * @since x.x.x */ - var EMLMissingValuesView = Backbone.View.extend( - /** @lends EMLMissingValuesView.prototype */ { + var EMLMissingValueCodesView = Backbone.View.extend( + /** @lends EMLMissingValueCodesView.prototype */ { tagName: "div", /** @@ -76,7 +76,7 @@ define([ }, /** - * Creates a new EMLMissingValuesView + * Creates a new EMLMissingValueCodesView * @param {Object} options - A literal object with options to pass to the * view * @param {EMLAttribute} [options.collection] - The EMLMissingValueCodes @@ -89,12 +89,12 @@ define([ /** * Renders this view - * @return {EMLMissingValuesView} A reference to this view + * @return {EMLMissingValueCodesView} A reference to this view */ render: function () { if (!this.collection) { console.warn( - `The EMLMissingValuesView requires a MissingValueCodes collection` + + `The EMLMissingValueCodesView requires a MissingValueCodes collection` + ` to render.` ); return; @@ -193,7 +193,7 @@ define([ * Creates a new row view for a missing value code model and inserts it * into this view at the end. * @param {EMLMissingValueCode} model - The model to create a row for - * @returns {EML211MissingValueView} The row view that was created + * @returns {EML211MissingValueCodeView} The row view that was created */ addRow: function (model) { if (!model instanceof EMLMissingValueCode) return; @@ -202,7 +202,7 @@ define([ const isNew = this.modelIsNew(model); // Create and render the row view - const rowView = new EML211MissingValueView({ + const rowView = new EML211MissingValueCodeView({ model: model, isNew: isNew, }).render(); @@ -223,7 +223,7 @@ define([ /** * Removes a row view from this view * @param {EMLMissingValueCode} model - The model to remove a row for - * @returns {EML211MissingValueView} The row view that was removed + * @returns {EML211MissingValueCodeView} The row view that was removed */ removeRow: function (model) { if (!model instanceof EMLMissingValueCode) return; @@ -253,5 +253,5 @@ define([ } ); - return EMLMissingValuesView; + return EMLMissingValueCodesView; }); diff --git a/src/js/views/metadata/EMLAttributeView.js b/src/js/views/metadata/EMLAttributeView.js index 1da89034c..25837cc37 100644 --- a/src/js/views/metadata/EMLAttributeView.js +++ b/src/js/views/metadata/EMLAttributeView.js @@ -7,7 +7,7 @@ define([ "models/metadata/eml211/EMLAttribute", "models/metadata/eml211/EMLMeasurementScale", "views/metadata/EMLMeasurementScaleView", - "views/metadata/EML211MissingValuesView", + "views/metadata/EML211MissingValueCodesView", "text!templates/metadata/eml-attribute.html", ], function ( _, @@ -17,7 +17,7 @@ define([ EMLAttribute, EMLMeasurementScale, EMLMeasurementScaleView, - EML211MissingValuesView, + EML211MissingValueCodesView, EMLAttributeTemplate ) { /** @@ -116,12 +116,12 @@ define([ this.measurementScaleView = measurementScaleView; // Create and insert a missing values view - const missingValuesView = new EML211MissingValuesView({ + const MissingValueCodesView = new EML211MissingValueCodesView({ collection: this.model.get("missingValueCodes"), }); - missingValuesView.render(); - this.$(".missing-values-container").append(missingValuesView.el); - this.missingValuesView = missingValuesView; + MissingValueCodesView.render(); + this.$(".missing-values-container").append(MissingValueCodesView.el); + this.MissingValueCodesView = MissingValueCodesView; // Mark this view DOM as new if it is a new attribute if (this.isNew) { From c7bb2692d527969fbebfbd31196cbca3b9dce7c9 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Fri, 21 Jul 2023 19:17:23 -0400 Subject: [PATCH 07/14] Remove unused code Issue #612 --- .../metadata/EML211MissingValueCodeView.js | 15 ----------- .../metadata/EML211MissingValueCodesView.js | 25 +++---------------- 2 files changed, 3 insertions(+), 37 deletions(-) diff --git a/src/js/views/metadata/EML211MissingValueCodeView.js b/src/js/views/metadata/EML211MissingValueCodeView.js index 89424be3c..fa8688934 100644 --- a/src/js/views/metadata/EML211MissingValueCodeView.js +++ b/src/js/views/metadata/EML211MissingValueCodeView.js @@ -260,21 +260,6 @@ define([ // Remove the view from the DOM this.remove(); }, - - /** - * Shows validation errors on this view - */ - showValidation: function () { - //TODO - }, - - /** - * Hides validation errors on this view - * @param {Event} e - The event that was triggered by the user - */ - hideValidation: function () { - // TODO - }, } ); diff --git a/src/js/views/metadata/EML211MissingValueCodesView.js b/src/js/views/metadata/EML211MissingValueCodesView.js index 49c20d4c2..03c20ecdd 100644 --- a/src/js/views/metadata/EML211MissingValueCodesView.js +++ b/src/js/views/metadata/EML211MissingValueCodesView.js @@ -72,7 +72,7 @@ define([ representing the missing data along with a brief description of why this code is used.`, `Examples: "-9999, Sensor down time" or "NA, record not available"`, - ] + ], }, /** @@ -112,12 +112,11 @@ define([ * Add the title, description, and placeholder for a validation message. */ renderText: function () { - this.title = document.createElement("h5"); this.title.innerHTML = this.text.title; this.el.appendChild(this.title); - this.text.description.forEach(descText => { + this.text.description.forEach((descText) => { this.description = document.createElement("p"); this.description.classList.add(this.classes.description); this.description.innerHTML = descText; @@ -127,7 +126,6 @@ define([ this.notification = document.createElement("p"); this.notification.classList.add(this.classes.notification); this.el.appendChild(this.notification); - }, /** @@ -227,29 +225,12 @@ define([ */ removeRow: function (model) { if (!model instanceof EMLMissingValueCode) return; - const rowView = this.el.querySelector( - `[data-model-id="${model.cid}"]` - ); + const rowView = this.el.querySelector(`[data-model-id="${model.cid}"]`); if (rowView) { rowView.remove(); return rowView; } }, - - /** - * Shows validation errors on this view - */ - showValidation: function () { - //TODO - }, - - /** - * Hides validation errors on this view - * @param {Event} e - The event that was triggered by the user - */ - hideValidation: function () { - // TODO - }, } ); From e2f409ce3d17abe5ce5ab4d8ca9b84be48da8c3d Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Fri, 21 Jul 2023 16:10:13 -0400 Subject: [PATCH 08/14] Turn on spatial filter after 1st user interaction In new Cesium-based catalog search view Issue #2160 --- src/js/templates/search/catalogSearch.html | 2 +- src/js/views/maps/CesiumWidgetView.js | 191 ++++++++++++++------- src/js/views/search/CatalogSearchView.js | 64 ++++++- 3 files changed, 187 insertions(+), 70 deletions(-) diff --git a/src/js/templates/search/catalogSearch.html b/src/js/templates/search/catalogSearch.html index ea4de5f67..367890c8b 100644 --- a/src/js/templates/search/catalogSearch.html +++ b/src/js/templates/search/catalogSearch.html @@ -16,7 +16,7 @@ id="toggle-spatial-filter" type="checkbox" name="map" - checked="true" + <% if (mapFilterOn) { %> checked="true" <% } %> class="map-filter-toggle__checkbox" /> diff --git a/src/js/views/maps/CesiumWidgetView.js b/src/js/views/maps/CesiumWidgetView.js index 938036820..e9fc02087 100644 --- a/src/js/views/maps/CesiumWidgetView.js +++ b/src/js/views/maps/CesiumWidgetView.js @@ -194,10 +194,6 @@ define( // raised. view.camera.percentChanged = 0.1 - // Zoom functions executed after each scene render - view.scene.postRender.addEventListener(function () { - view.postRender(); - }); // Disable HDR lighting for better performance and to avoid changing imagery colors. view.scene.highDynamicRange = false; @@ -224,81 +220,148 @@ define( view.updateDataSourceDisplay.call(view) }) + view.setListeners(); + view.addLayers(); + // Go to the home position, if one is set. view.flyHome(0) - // If users are allowed to click on features for more details, initialize - // picking behavior on the map. + // If users are allowed to click on features for more details, + // initialize picking behavior on the map. if (view.model.get('showFeatureInfo')) { view.initializePicking() } - // Set listeners for when the Cesium camera changes a significant amount. - view.camera.changed.addEventListener(function () { - view.trigger('moved') - view.model.trigger('moved') - // Update the bounding box for the visible area in the Map model - view.updateViewExtent() - // If the scale bar is showing, update the pixel to meter scale on the map - // model when the camera angle/zoom level changes - if (view.model.get('showScaleBar')) { - view.updateCurrentScale() - } - }) + return this - view.camera.moveEnd.addEventListener(function () { - view.trigger('moveEnd') - view.model.trigger('moveEnd') - }) - view.camera.moveStart.addEventListener(function () { - view.trigger('moveStart') - view.model.trigger('moveStart') - }) + } + catch (error) { + console.log( + 'Failed to render a CesiumWidgetView. Error details: ' + error + ); + } + }, - // Sets listeners for when the mouse moves, depending on the value of the map - // model's showScaleBar and showFeatureInfo attributes - view.setMouseMoveListeners() - - // When the appearance of a layer has been updated, then tell Cesium to - // re-render the scene. Each layer model triggers the 'appearanceChanged' - // function whenever the color, opacity, etc. has been updated in the - // associated Cesium model. - view.stopListening(view.model.get('layers'), 'appearanceChanged') - view.listenTo(view.model.get('layers'), 'appearanceChanged', view.requestRender) - - // Other views may trigger an event on the layer/asset model that indicates - // that the map should navigate to the extent of the data, or on the Map model - // to navigate to the home position. - view.stopListening(view.model.get('layers'), 'flyToExtent') - view.listenTo(view.model.get('layers'), 'flyToExtent', view.flyTo) - view.stopListening(view.model, 'flyHome') - view.listenTo(view.model, 'flyHome', view.flyHome) - - // Add each layer from the Map model to the Cesium widget. Render using the - // function configured in the View's mapAssetRenderFunctions property. Add in - // reverse order for layers to appear in the correct order on the map. - const layers = view.model.get('layers') - _.each(layers.last(layers.length).reverse(), function (mapAsset) { - view.addAsset(mapAsset) - }); + /** + * Set all of the listeners for the CesiumWidgetView. This function is + * called during the render function. + * @since x.x.x + */ + setListeners: function () { + + const view = this; - // The Cesium Widget will support just one terrain option to start. Later, - // we'll allow users to switch between terrains if there is more than one. - var terrains = view.model.get('terrains') - var terrainModel = terrains ? terrains.first() : false; - if (terrainModel) { - view.addAsset(terrainModel) + // Zoom functions executed after each scene render + view.scene.postRender.addEventListener(function () { + view.postRender(); + }); + + // When the user first interacts with the map, update the model. + // Ignore the event where the user just moves the mouse over the map. + view.listenOnceForInteraction(function () { + view.model.set('firstInteraction', true); + }, ["MOUSE_MOVE"]); + + // Set listeners for when the Cesium camera changes a significant + // amount. + view.camera.changed.addEventListener(function () { + view.trigger('moved') + view.model.trigger('moved') + // Update the bounding box for the visible area in the Map model + view.updateViewExtent() + // If the scale bar is showing, update the pixel to meter scale on + // the map model when the camera angle/zoom level changes + if (view.model.get('showScaleBar')) { + view.updateCurrentScale() } + }) + + view.camera.moveEnd.addEventListener(function () { + view.trigger('moveEnd') + view.model.trigger('moveEnd') + }) + view.camera.moveStart.addEventListener(function () { + view.trigger('moveStart') + view.model.trigger('moveStart') + }) + + // Sets listeners for when the mouse moves, depending on the value + // of the map model's showScaleBar and showFeatureInfo attributes + view.setMouseMoveListeners() + + // When the appearance of a layer has been updated, then tell Cesium + // to re-render the scene. Each layer model triggers the + // 'appearanceChanged' function whenever the color, opacity, etc. + // has been updated in the associated Cesium model. + view.stopListening(view.model.get('layers'), 'appearanceChanged') + view.listenTo(view.model.get('layers'), 'appearanceChanged', view.requestRender) + + // Other views may trigger an event on the layer/asset model that + // indicates that the map should navigate to the extent of the data, + // or on the Map model to navigate to the home position. + view.stopListening(view.model.get('layers'), 'flyToExtent') + view.listenTo(view.model.get('layers'), 'flyToExtent', view.flyTo) + view.stopListening(view.model, 'flyHome') + view.listenTo(view.model, 'flyHome', view.flyHome) + }, + /** + * Listen for any user interaction with the map. Once an interaction has + * occurred, run the callback function and stop listening for + * interactions. Useful for detecting the first user interaction with the + * map. + * @param {function} callback - The function to run once the interaction + * has occurred. + * @param {string[]} ignore - An array of Cesium.ScreenSpaceEventType + * labels to ignore. See + * {@link https://cesium.com/learn/cesiumjs/ref-doc/ScreenSpaceEventType.html} + * @since x.x.x + */ + listenOnceForInteraction: function ( + callback, + ignore = [] + ) { + const view = this; + const events = Cesium.ScreenSpaceEventType; + const inputHandler = new Cesium.ScreenSpaceEventHandler( + view.scene.canvas + ); + if (!ignore || !Array.isArray(ignore)) ignore = []; + + Object.entries(events).forEach(function ([label, value]) { + if (ignore.includes(label)) return; + inputHandler.setInputAction(function () { + callback(); + inputHandler.destroy(); + }, value); + }); + }, + /** + * Add all of the model's layers to the map. This function is called + * during the render function. + * @since x.x.x + */ + addLayers: function () { - return this + const view = this; - } - catch (error) { - console.log( - 'Failed to render a CesiumWidgetView. Error details: ' + error - ); + // Add each layer from the Map model to the Cesium widget. Render + // using the function configured in the View's mapAssetRenderFunctions + // property. Add in reverse order for layers to appear in the correct + // order on the map. + const layers = view.model.get('layers') + _.each(layers.last(layers.length).reverse(), function (mapAsset) { + view.addAsset(mapAsset) + }); + + // The Cesium Widget will support just one terrain option to start. + // Later, we'll allow users to switch between terrains if there is + // more than one. + var terrains = view.model.get('terrains') + var terrainModel = terrains ? terrains.first() : false; + if (terrainModel) { + view.addAsset(terrainModel) } }, diff --git a/src/js/views/search/CatalogSearchView.js b/src/js/views/search/CatalogSearchView.js index 2dccc5559..b4066dd21 100644 --- a/src/js/views/search/CatalogSearchView.js +++ b/src/js/views/search/CatalogSearchView.js @@ -93,13 +93,25 @@ define([ filtersVisible: true, /** - * Whether to limit the search to the extent of the map. If true, the - * search will update when the user pans or zooms the map. + * Whether to limit the search to the extent of the map. When true, the + * search will update when the user pans or zooms the map. This property + * will be updated when the user clicks the map filter toggle. Whatever is + * set during the initial render will be the default. * @type {boolean} * @since 2.25.0 + * @default false + */ + limitSearchToMapArea: false, + + /** + * Whether to limit the search to the extent the first time the user + * interacts with the map. This only applies if limitSearchToMapArea is + * initially set to false. + * @type {boolean} + * @since x.x.x * @default true */ - limitSearchToMapArea: true, + limitSearchToMapOnInteraction: true, /** * The View that displays the search results. The render method will be @@ -300,8 +312,9 @@ define([ addSpatialFilter: options.addSpatialFilter !== false, }); } - model.connect(); + this.model = model; + this.model.connect(); }, /** @@ -317,6 +330,9 @@ define([ // Render the search components this.renderComponents(); + + // Set up the initial map toggle state + this.setMapToggleState(); }, /** @@ -354,6 +370,27 @@ define([ this.toggleMapVisibility(this.mapVisible); }, + /** + * Sets the initial state of the map filter toggle. Optionally listens + * for the first user interaction with the map before turning on the + * spatial filter. + * @since x.x.x + */ + setMapToggleState: function () { + // Set the initial state of the spatial filter + this.toggleMapFilter(this.limitSearchToMapArea); + + if (this.limitSearchToMapOnInteraction && !this.limitSearchToMapArea) { + this.listenToOnce( + this.model.get("map"), + "change:firstInteraction", + function () { + this.toggleMapFilter(true); + } + ); + } + }, + /** * Sets up the basic components of this view * @since 2.22.0 @@ -373,7 +410,11 @@ define([ this.addLinkedData(); // Render the template - this.$el.html(this.template({})); + this.$el.html( + this.template({ + mapFilterOn: this.limitSearchToMapArea === true, + }) + ); } catch (e) { console.log( "There was an error setting up the CatalogSearchView:" + e @@ -817,12 +858,25 @@ define([ ? !this.limitSearchToMapArea // the opposite of the current mode : newSetting; // the provided new mode if it is a boolean + // Select the map filter toggle checkbox so that we can keep it in sync + // with the new setting + let mapFilterToggle = this.el.querySelector(this.mapFilterToggle); + // If it's not a checkbox input, find the child checkbox input + if (mapFilterToggle && mapFilterToggle.tagName != "INPUT") { + mapFilterToggle = mapFilterToggle.querySelector("input"); + } if (newSetting) { // If true, then the filter should be ON this.model.connectFiltersMap(); + if (mapFilterToggle) { + mapFilterToggle.checked = true; + } } else { // If false, then the filter should be OFF this.model.disconnectFiltersMap(true); + if (mapFilterToggle) { + mapFilterToggle.checked = false; + } } this.limitSearchToMapArea = newSetting; }, From 5411110f5be14957e9710a44cc2ea2159fb1dadd Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Fri, 21 Jul 2023 17:04:00 -0400 Subject: [PATCH 09/14] Standardize formatting in EMLDistribution model Issue #1380 --- .../models/metadata/eml211/EMLDistribution.js | 228 +++++++++--------- 1 file changed, 120 insertions(+), 108 deletions(-) diff --git a/src/js/models/metadata/eml211/EMLDistribution.js b/src/js/models/metadata/eml211/EMLDistribution.js index b53008c73..844ead921 100644 --- a/src/js/models/metadata/eml211/EMLDistribution.js +++ b/src/js/models/metadata/eml211/EMLDistribution.js @@ -1,122 +1,134 @@ /* global define */ -define(['jquery', 'underscore', 'backbone', 'models/DataONEObject'], - function($, _, Backbone, DataONEObject) { - - var EMLDistribution = Backbone.Model.extend({ - - defaults: { - objectXML: null, - objectDOM: null, - mediumName: null, - mediumVolume: null, - mediumFormat: null, - mediumNote: null, - onlineDescription: null, - parentModel: null - }, - - initialize: function(attributes){ - if(attributes.objectDOM) this.parse(attributes.objectDOM); - - this.on("change:mediumName change:mediumVolume change:mediumFormat " + - "change:mediumNote change:onlineDescription", this.trickleUpChange); - }, - - /* - * Maps the lower-case EML node names (valid in HTML DOM) to the camel-cased EML node names (valid in EML). - * Used during parse() and serialize() - */ - nodeNameMap: function(){ - return{ - "authsystem" : "authSystem", - "connectiondefinition" : "connectionDefinition", - "mediumdensity" : "mediumDensity", - "mediumdensityunits" : "mediumDensityUnits", - "mediumformat" : "mediumFormat", - "mediumname" : "mediumName", - "mediumnote" : "mediumNote", - "mediumvolume" : "mediumVolume", - "onlinedescription" : "onlineDescription" - } - }, - - parse: function(objectDOM){ - if(!objectDOM) - var xml = this.get("objectDOM"); - - var offline = $(xml).find("offline"), - online = $(xml).find("online"); - - if(offline.length){ - if($(offline).children("mediumname").length) this.parseNode($(offline).children("mediumname")); - if($(offline).children("mediumvolume").length) this.parseNode($(offline).children("mediumvolume")); - if($(offline).children("mediumformat").length) this.parseNode($(offline).children("mediumformat")); - if($(offline).children("mediumnote").length) this.parseNode($(offline).children("mediumnote")); - } - - if(online.length){ - if($(online).children("onlinedescription").length) this.parseNode($(online).children("onlinedescription")); - } - }, - - parseNode: function(node){ - if(!node || (Array.isArray(node) && !node.length)) - return; - - this.set($(node)[0].localName, $(node).text()); - }, - - serialize: function(){ - var objectDOM = this.updateDOM(), - xmlString = objectDOM.outerHTML; - - //Camel-case the XML - xmlString = this.formatXML(xmlString); - - return xmlString; - }, - - /* - * Makes a copy of the original XML DOM and updates it with the new values from the model. - */ - updateDOM: function(){ - var objectDOM = this.get("objectDOM").cloneNode(true); - - // Remove empty (zero-length or whitespace-only) nodes - $(objectDOM).find("*").filter(function() { return $.trim(this.innerHTML) === ""; } ).remove(); - - return objectDOM; - }, +define(["jquery", "underscore", "backbone", "models/DataONEObject"], function ( + $, + _, + Backbone, + DataONEObject +) { + var EMLDistribution = Backbone.Model.extend({ + defaults: { + objectXML: null, + objectDOM: null, + mediumName: null, + mediumVolume: null, + mediumFormat: null, + mediumNote: null, + onlineDescription: null, + parentModel: null, + }, + + initialize: function (attributes) { + if (attributes.objectDOM) this.parse(attributes.objectDOM); + + this.on( + "change:mediumName change:mediumVolume change:mediumFormat " + + "change:mediumNote change:onlineDescription", + this.trickleUpChange + ); + }, /* - * Climbs up the model heirarchy until it finds the EML model - * - * @return {EML211 or false} - Returns the EML 211 Model or false if not found - */ - getParentEML: function(){ + * Maps the lower-case EML node names (valid in HTML DOM) to the camel-cased + * EML node names (valid in EML). Used during parse() and serialize() + */ + nodeNameMap: function () { + return { + authsystem: "authSystem", + connectiondefinition: "connectionDefinition", + mediumdensity: "mediumDensity", + mediumdensityunits: "mediumDensityUnits", + mediumformat: "mediumFormat", + mediumname: "mediumName", + mediumnote: "mediumNote", + mediumvolume: "mediumVolume", + onlinedescription: "onlineDescription", + }; + }, + + parse: function (objectDOM) { + if (!objectDOM) var xml = this.get("objectDOM"); + + var offline = $(xml).find("offline"), + online = $(xml).find("online"); + + if (offline.length) { + if ($(offline).children("mediumname").length) + this.parseNode($(offline).children("mediumname")); + if ($(offline).children("mediumvolume").length) + this.parseNode($(offline).children("mediumvolume")); + if ($(offline).children("mediumformat").length) + this.parseNode($(offline).children("mediumformat")); + if ($(offline).children("mediumnote").length) + this.parseNode($(offline).children("mediumnote")); + } + + if (online.length) { + if ($(online).children("onlinedescription").length) + this.parseNode($(online).children("onlinedescription")); + } + }, + + parseNode: function (node) { + if (!node || (Array.isArray(node) && !node.length)) return; + + this.set($(node)[0].localName, $(node).text()); + }, + + serialize: function () { + var objectDOM = this.updateDOM(), + xmlString = objectDOM.outerHTML; + + //Camel-case the XML + xmlString = this.formatXML(xmlString); + + return xmlString; + }, + + /* + * Makes a copy of the original XML DOM and updates it with the new values + * from the model. + */ + updateDOM: function () { + var objectDOM = this.get("objectDOM").cloneNode(true); + + // Remove empty (zero-length or whitespace-only) nodes + $(objectDOM) + .find("*") + .filter(function () { + return $.trim(this.innerHTML) === ""; + }) + .remove(); + + return objectDOM; + }, + + /* + * Climbs up the model heirarchy until it finds the EML model + * + * @return {EML211 or false} - Returns the EML 211 Model or false if not + * found + */ + getParentEML: function () { var emlModel = this.get("parentModel"), - tries = 0; + tries = 0; - while (emlModel.type !== "EML" && tries < 6){ + while (emlModel.type !== "EML" && tries < 6) { emlModel = emlModel.get("parentModel"); tries++; } - if( emlModel && emlModel.type == "EML") - return emlModel; - else - return false; - + if (emlModel && emlModel.type == "EML") return emlModel; + else return false; }, - trickleUpChange: function(){ - MetacatUI.rootDataPackage.packageModel.set("changed", true); - }, + trickleUpChange: function () { + MetacatUI.rootDataPackage.packageModel.set("changed", true); + }, - formatXML: function(xmlString){ - return DataONEObject.prototype.formatXML.call(this, xmlString); - } - }); + formatXML: function (xmlString) { + return DataONEObject.prototype.formatXML.call(this, xmlString); + }, + }); - return EMLDistribution; + return EMLDistribution; }); From e9a4f013f44af684224d127c548822a0e954b12b Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Fri, 28 Jul 2023 14:54:43 -0400 Subject: [PATCH 10/14] Add serializing and parsing to EMLDistribution - Use in the EML211 model Issue #1380 --- src/js/models/metadata/eml211/EML211.js | 25 ++- .../models/metadata/eml211/EMLDistribution.js | 211 ++++++++++++++---- 2 files changed, 194 insertions(+), 42 deletions(-) diff --git a/src/js/models/metadata/eml211/EML211.js b/src/js/models/metadata/eml211/EML211.js index 42a833005..8d5eb77f0 100644 --- a/src/js/models/metadata/eml211/EML211.js +++ b/src/js/models/metadata/eml211/EML211.js @@ -55,8 +55,7 @@ define(['jquery', 'underscore', 'backbone', 'uuid', keywordSets: [], //array of EMLKeywordSet objects additionalInfo: [], intellectualRights: "This work is dedicated to the public domain under the Creative Commons Universal 1.0 Public Domain Dedication. To view a copy of this dedication, visit https://creativecommons.org/publicdomain/zero/1.0/.", - onlineDist: [], // array of EMLOnlineDist objects - offlineDist: [], // array of EMLOfflineDist objects + distribution: [], // array of EMLDistribution objects geoCoverage : [], //an array for EMLGeoCoverages temporalCoverage : [], //an array of EMLTempCoverage models taxonCoverage : [], //an array of EMLTaxonCoverages @@ -515,13 +514,13 @@ define(['jquery', 'underscore', 'backbone', 'uuid', })); } //EML Distribution modules are stored in EMLDistribution models - else if(_.contains(emlDistribution, thisNode.localName)){ + else if(_.contains(emlDistribution, thisNode.localName)) { if(typeof modelJSON[thisNode.localName] == "undefined") modelJSON[thisNode.localName] = []; modelJSON[thisNode.localName].push(new EMLDistribution({ objectDOM: thisNode, parentModel: model - })); + }, { parse: true })); } //The EML Project is stored in the EMLProject model else if(thisNode.localName == "project"){ @@ -1028,6 +1027,24 @@ define(['jquery', 'underscore', 'backbone', 'uuid', .html("" + this.get("intellectualRights") + "")); } } + + // Serialize the distribution + const distributions = this.get('distribution'); + if (distributions && distributions.length > 0) { + // Remove existing nodes + datasetNode.children('distribution').remove(); + // Get the updated DOMs + const distributionDOMs = distributions.map(d => d.updateDOM()); + // Insert the updated DOMs in their correct positions + distributionDOMs.forEach((dom, i) => { + const insertAfter = this.getEMLPosition(eml, 'distribution'); + if (insertAfter) { + insertAfter.after(dom); + } else { + datasetNode.append(dom); + } + }); + } //Detach the project elements from the DOM if(datasetNode.find("project").length){ diff --git a/src/js/models/metadata/eml211/EMLDistribution.js b/src/js/models/metadata/eml211/EMLDistribution.js index 844ead921..b15c6fdf4 100644 --- a/src/js/models/metadata/eml211/EMLDistribution.js +++ b/src/js/models/metadata/eml211/EMLDistribution.js @@ -5,24 +5,61 @@ define(["jquery", "underscore", "backbone", "models/DataONEObject"], function ( Backbone, DataONEObject ) { + /** + * @class EMLDistribution + * @classdesc Information on how the resource is distributed online and offline + * @classcategory Models/Metadata/EML211 + * @see https://eml.ecoinformatics.org/schema/eml-resource_xsd.html#DistributionType + * @extends Backbone.Model + * @constructor + */ var EMLDistribution = Backbone.Model.extend({ defaults: { + type: "distribution", objectXML: null, objectDOM: null, mediumName: null, mediumVolume: null, mediumFormat: null, mediumNote: null, + url: null, onlineDescription: null, parentModel: null, }, - initialize: function (attributes) { - if (attributes.objectDOM) this.parse(attributes.objectDOM); + /** + * The direct children of the node that can have values, and + * that are supported by this model. "inline" is not supported yet. A + * distribution may have ONE of these nodes. + * @type {string[]} + * @since x.x.x + */ + distLocations: ["offline", "online"], + + /** + * lower-case EML node names that belong within the node. These must be in the correct order. + * @type {string[]} + * @since x.x.x + */ + offlineNodes: ["mediumname", "mediumvolume", "mediumformat", "mediumnote"], - this.on( - "change:mediumName change:mediumVolume change:mediumFormat " + - "change:mediumNote change:onlineDescription", + /** + * lower-case EML node names that belong within the node. These must be in the correct order. + * @type {string[]} + * @since x.x.x + */ + onlineNodes: ["url"], + + /** + * Initializes this EMLDistribution object + * @param {Object} options - A literal object with options to pass to the + * model + */ + initialize: function (attributes, options) { + const nodeAttr = Object.values(this.nodeNameMap()); + this.listenTo( + this, + "change:" + nodeAttr.join(" change:"), this.trickleUpChange ); }, @@ -41,69 +78,167 @@ define(["jquery", "underscore", "backbone", "models/DataONEObject"], function ( mediumname: "mediumName", mediumnote: "mediumNote", mediumvolume: "mediumVolume", - onlinedescription: "onlineDescription", + url: "url", }; }, - parse: function (objectDOM) { - if (!objectDOM) var xml = this.get("objectDOM"); - - var offline = $(xml).find("offline"), - online = $(xml).find("online"); - - if (offline.length) { - if ($(offline).children("mediumname").length) - this.parseNode($(offline).children("mediumname")); - if ($(offline).children("mediumvolume").length) - this.parseNode($(offline).children("mediumvolume")); - if ($(offline).children("mediumformat").length) - this.parseNode($(offline).children("mediumformat")); - if ($(offline).children("mediumnote").length) - this.parseNode($(offline).children("mediumnote")); - } - - if (online.length) { - if ($(online).children("onlinedescription").length) - this.parseNode($(online).children("onlinedescription")); - } - }, + /** + * Parses the given XML node or object and sets the model's attributes + * @param {Object} attributes - the attributes passed in when the model is + * instantiated. Should include objectDOM or objectXML to be parsed. + */ + parse: function (attributes) { + if (!attributes) attributes = {}; + const objectDOM = attributes.objectDOM || attributes.objectXML; + if (!objectDOM) return attributes; + const $objectDOM = $(objectDOM); - parseNode: function (node) { - if (!node || (Array.isArray(node) && !node.length)) return; + const nodeNameMap = this.nodeNameMap(); + this.distLocations.forEach((distLocation) => { + const location = $objectDOM.find(distLocation); + if (location.length) { + this[`${distLocation}Nodes`].forEach((nodeName) => { + const value = location.children(nodeName)?.text()?.trim(); + if (value.length) { + attributes[nodeNameMap[nodeName]] = value; + } + }); + } + }); - this.set($(node)[0].localName, $(node).text()); + return attributes; }, + /** + * Returns the XML string representation of this model + * @returns {string} + */ serialize: function () { - var objectDOM = this.updateDOM(), - xmlString = objectDOM.outerHTML; + const objectDOM = this.updateDOM(); + const xmlString = objectDOM.outerHTML; - //Camel-case the XML + // Camel-case the XML xmlString = this.formatXML(xmlString); return xmlString; }, + /** + * Check if the model has values for the given distribution location. + * @param {string} location - one of the names of the direct children of the + * node, i.e. any of the values in this.distLocations. + * @returns {boolean} - true if the model has values for the given location, + * false otherwise. + * @since x.x.x + */ + hasValuesForDistributionLocation(location) { + const nodeNameMap = this.nodeNameMap(); + return this[`${location}Nodes`].some((nodeName) => { + return this.get(nodeNameMap[nodeName]); + }); + }, + /* * Makes a copy of the original XML DOM and updates it with the new values * from the model. */ updateDOM: function () { - var objectDOM = this.get("objectDOM").cloneNode(true); + const objectDOM = + this.get("objectDOM") || document.createElement(this.get("type")); + const $objectDOM = $(objectDOM); // Remove empty (zero-length or whitespace-only) nodes - $(objectDOM) + $objectDOM .find("*") .filter(function () { - return $.trim(this.innerHTML) === ""; + return !$.trim($(this).text()); }) .remove(); + const nodeNameMap = this.nodeNameMap(); + + // Determine if this is an online, offline, or inline distribution + const distLocation = this.distLocations.find((location) => { + return this.hasValuesForDistributionLocation(location); + }); + + // Remove all other distribution locations + this.distLocations.forEach((location) => { + if (location !== distLocation) { + $objectDOM.find(location).remove(); + } + }); + + // Add the distribution location if it doesn't exist + if (!$objectDOM.find(distLocation).length) { + $objectDOM.append(`<${distLocation}>`); + } + + // For each node in the distribution location, add the value from the + // model. If the model value is empty, remove the node. Make sure that we + // don't replace any existing nodes, since not all nodes are supported by + // this model yet. We also need to ensure that the nodes are in the + // correct order. + this[`${distLocation}Nodes`].forEach((nodeName) => { + const nodeValue = this.get(nodeNameMap[nodeName]); + if (nodeValue) { + const node = $objectDOM.find(`${distLocation} > ${nodeName}`); + if (node.length) { + node.text(nodeValue); + } else { + const newNode = $(`<${nodeName}>${nodeValue}`); + const position = this.getEMLPosition(objectDOM, nodeName); + if (position) { + newNode.insertAfter(position); + } else { + $objectDOM.children(distLocation).append(newNode); + } + } + } else { + $objectDOM.find(`${distLocation} > ${nodeName}`).remove(); + } + }); return objectDOM; }, /* - * Climbs up the model heirarchy until it finds the EML model + * Returns the node in the object DOM that the given node type should be + * inserted after. + * @param {string} nodeName - The name of the node to find the position for + * @return {jQuery} - The jQuery object of the node that the given node + * should be inserted after, or false if the node is not supported by this + * model. + * @since x.x.x + */ + getEMLPosition: function (objectDOM, nodeName) { + // If this is a top level node, return false since it should be inserted + // within the node, and there must only be one. + if (this.distLocations.includes(nodeName)) return false; + + // Handle according to whether it's an online or offline node + const nodeNameMap = this.nodeNameMap(); + this.distLocations.forEach((distLocation) => { + const nodeOrder = this[`${distLocation}Nodes`]; + const siblingNodes = $(objectDOM).find(distLocation).children(); + let position = nodeOrder.indexOf(nodeName); + if (position > -1) { + // Go through each node in the node list and find the position where this + // node will be inserted after + for (var i = position - 1; i >= 0; i--) { + const checkNode = siblingNodes.filter(nodeOrder[i]); + if (checkNode.length) { + return checkNode.last(); + } + } + } + }); + + // If we get here, the node is not supported by this model + return false; + }, + + /* + * Climbs up the model hierarchy until it finds the EML model * * @return {EML211 or false} - Returns the EML 211 Model or false if not * found From 2c1d8f4a47297b4780c950a0f171ee34100d1f7b Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Mon, 31 Jul 2023 14:23:40 -0400 Subject: [PATCH 11/14] Add unit tests for EMLDistribution model Issue #1380 --- .../models/metadata/eml211/EMLDistribution.js | 5 +- test/config/tests.json | 1 + .../metadata/eml211/EMLDistribution.spec.js | 189 ++++++++++++++++++ 3 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 test/js/specs/unit/models/metadata/eml211/EMLDistribution.spec.js diff --git a/src/js/models/metadata/eml211/EMLDistribution.js b/src/js/models/metadata/eml211/EMLDistribution.js index b15c6fdf4..297e2ae2b 100644 --- a/src/js/models/metadata/eml211/EMLDistribution.js +++ b/src/js/models/metadata/eml211/EMLDistribution.js @@ -169,6 +169,9 @@ define(["jquery", "underscore", "backbone", "models/DataONEObject"], function ( } }); + // If there is no distribution location, return the DOM + if (!distLocation) return objectDOM; + // Add the distribution location if it doesn't exist if (!$objectDOM.find(distLocation).length) { $objectDOM.append(`<${distLocation}>`); @@ -257,7 +260,7 @@ define(["jquery", "underscore", "backbone", "models/DataONEObject"], function ( }, trickleUpChange: function () { - MetacatUI.rootDataPackage.packageModel.set("changed", true); + MetacatUI.rootDataPackage?.packageModel?.set("changed", true); }, formatXML: function (xmlString) { diff --git a/test/config/tests.json b/test/config/tests.json index 8f18c6b46..a558e0dec 100644 --- a/test/config/tests.json +++ b/test/config/tests.json @@ -20,6 +20,7 @@ "./js/specs/unit/models/metadata/eml211/EMLTemporalCoverage.spec.js", "./js/specs/unit/collections/metadata/eml/EMLMissingValueCodes.spec.js", "./js/specs/unit/models/metadata/eml211/EMLMissingValueCode.spec.js", + "./js/specs/unit/models/metadata/eml211/EMLDistribution.spec.js", "./js/specs/unit/models/maps/assets/CesiumImagery.spec.js", "./js/specs/unit/collections/maps/Geohashes.spec.js", "./js/specs/unit/models/connectors/Filters-Map.spec.js", diff --git a/test/js/specs/unit/models/metadata/eml211/EMLDistribution.spec.js b/test/js/specs/unit/models/metadata/eml211/EMLDistribution.spec.js new file mode 100644 index 000000000..788640476 --- /dev/null +++ b/test/js/specs/unit/models/metadata/eml211/EMLDistribution.spec.js @@ -0,0 +1,189 @@ +define([ + "../../../../../../../../src/js/models/metadata/eml211/EMLDistribution", +], function (EMLDistribution) { + // Configure the Chai assertion library + var should = chai.should(); + var expect = chai.expect; + + describe("EMLDistribution Test Suite", function () { + /* Set up */ + beforeEach(function () { + this.emlDistribution = new EMLDistribution(); + }); + + /* Tear down */ + afterEach(function () { + delete this.emlDistribution; + }); + + describe("Initialization", function () { + it("should create a EMLDistribution instance", function () { + new EMLDistribution().should.be.instanceof(EMLDistribution); + }); + }); + + describe("Parse", function () { + it("should parse online URL from EMLDistribution model", function () { + var objectDOM = new DOMParser().parseFromString( + "" + + " " + + " http://www.dataone.org" + + " " + + "", + "text/xml" + ).documentElement; + + var emlDistribution = new EMLDistribution( + { + objectDOM: objectDOM, + }, + { parse: true } + ); + + emlDistribution.get("url").should.equal("http://www.dataone.org"); + }); + + it("should parse offline elements from EMLDistribution model", function () { + var objectDOM = new DOMParser().parseFromString( + "" + + " " + + " CD-ROM" + + " 1" + + " ISO9660" + + " Some notes" + + " " + + "", + "text/xml" + ).documentElement; + + var emlDistribution = new EMLDistribution( + { + objectDOM: objectDOM, + }, + { parse: true } + ); + + emlDistribution.get("mediumName").should.equal("CD-ROM"); + emlDistribution.get("mediumVolume").should.equal("1"); + emlDistribution.get("mediumFormat").should.equal("ISO9660"); + emlDistribution.get("mediumNote").should.equal("Some notes"); + }); + }); + + describe("Update DOM", function () { + it("should update the DOM with the new values", function () { + var objectDOM = new DOMParser().parseFromString( + "" + + " " + + " http://www.dataone.org" + + " " + + "", + "text/xml" + ).documentElement; + + var emlDistribution = new EMLDistribution( + { + objectDOM: objectDOM, + }, + { parse: true } + ); + + emlDistribution.set("url", "http://www.dataone.org/updated"); + + var updatedDOM = emlDistribution.updateDOM(); + + updatedDOM + .querySelector("url") + .textContent.should.equal("http://www.dataone.org/updated"); + }); + + it("should create a new node if one does not exist", function () { + var objectDOM = new DOMParser().parseFromString( + "" + + " " + + " CD-ROM" + + " " + + "", + "text/xml" + ).documentElement; + + var emlDistribution = new EMLDistribution( + { + objectDOM: objectDOM, + }, + { parse: true } + ); + + emlDistribution.set("mediumName", "CD-ROM"); + + var updatedDOM = emlDistribution.updateDOM(); + + updatedDOM + .querySelector("mediumName") + .textContent.should.equal("CD-ROM"); + }); + + it("should create a DOM if one doesn't exist", function () { + var emlDistribution = new EMLDistribution({ + mediumName: "CD-ROM", + }); + + var updatedDOM = emlDistribution.updateDOM(); + + updatedDOM + .querySelector("mediumName") + .textContent.should.equal("CD-ROM"); + // check that mediumName is within the offline node + updatedDOM.querySelector("offline > mediumName").should.not.equal(null); + }); + + it("should remove nodes if the value is empty", function () { + var objectDOM = new DOMParser().parseFromString( + "" + + " " + + " http://www.dataone.org" + + " " + + "", + "text/xml" + ).documentElement; + + var emlDistribution = new EMLDistribution( + { + objectDOM: objectDOM, + }, + { parse: true } + ); + + emlDistribution.set("url", ""); + + var updatedDOM = emlDistribution.updateDOM(); + expect(updatedDOM.querySelector("url")).to.equal(null); + }); + + it("should not remove id, system, nor scope attributes from the distribution node", function () { + var objectDOM = new DOMParser().parseFromString( + "" + + " " + + " http://www.dataone.org" + + " " + + "", + "text/xml" + ).documentElement; + + var emlDistribution = new EMLDistribution( + { + objectDOM: objectDOM, + }, + { parse: true } + ); + + emlDistribution.set("url", ""); + + var updatedDOM = emlDistribution.updateDOM(); + expect(updatedDOM.getAttribute("id")).to.equal("123"); + expect(updatedDOM.getAttribute("system")).to.equal("eml"); + expect(updatedDOM.getAttribute("scope")).to.equal("system"); + }); + }); + }); +}); From efedb1f313c7d69f55470e5e559226a6d201b149 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Mon, 31 Jul 2023 14:45:59 -0400 Subject: [PATCH 12/14] Add support for the distribution url function attr Include tests for parsing and updating the DOM when there's a url function Issue #1380 --- .../models/metadata/eml211/EMLDistribution.js | 81 ++++++++++++++++--- .../metadata/eml211/EMLDistribution.spec.js | 75 ++++++++++++++--- 2 files changed, 135 insertions(+), 21 deletions(-) diff --git a/src/js/models/metadata/eml211/EMLDistribution.js b/src/js/models/metadata/eml211/EMLDistribution.js index 297e2ae2b..86cbb6167 100644 --- a/src/js/models/metadata/eml211/EMLDistribution.js +++ b/src/js/models/metadata/eml211/EMLDistribution.js @@ -7,13 +7,42 @@ define(["jquery", "underscore", "backbone", "models/DataONEObject"], function ( ) { /** * @class EMLDistribution - * @classdesc Information on how the resource is distributed online and offline + * @classdesc Information on how the resource is distributed online and + * offline * @classcategory Models/Metadata/EML211 - * @see https://eml.ecoinformatics.org/schema/eml-resource_xsd.html#DistributionType + * @see + * https://eml.ecoinformatics.org/schema/eml-resource_xsd.html#DistributionType * @extends Backbone.Model * @constructor */ var EMLDistribution = Backbone.Model.extend({ + /** + * Default values for an EML 211 Distribution model. This is essentially a + * flattened version of the EML 2.1.1 DistributionType, including nodes and + * node attributes. Not all nodes are supported by this model yet. + * @type {Object} + * @property {string} type - The name of the top-level XML element that this + * model represents (distribution) + * @property {string} objectXML - The XML string representation of the + * distribution + * @property {Element} objectDOM - The DOM representation of the + * distribution + * @property {string} mediumName - The name of the medium on which the + * offline distribution is stored + * @property {string} mediumVolume - The volume number of the medium on + * which the offline distribution is stored + * @property {string} mediumFormat - The format of the medium on which the + * offline distribution is stored + * @property {string} mediumNote - A note about the medium on which the + * offline distribution is stored + * @property {string} url - The URL of the online distribution + * @property {string} urlFunction - The purpose of the URL. May be either + * "information" or "download". + * @property {string} onlineDescription - A description of the online + * distribution + * @property {EML211} parentModel - The parent model of this distribution + * model + */ defaults: { type: "distribution", objectXML: null, @@ -23,6 +52,7 @@ define(["jquery", "underscore", "backbone", "models/DataONEObject"], function ( mediumFormat: null, mediumNote: null, url: null, + urlFunction: null, onlineDescription: null, parentModel: null, }, @@ -37,19 +67,28 @@ define(["jquery", "underscore", "backbone", "models/DataONEObject"], function ( distLocations: ["offline", "online"], /** - * lower-case EML node names that belong within the node. These must be in the correct order. + * lower-case EML node names that belong within the node. These + * must be in the correct order. * @type {string[]} * @since x.x.x */ offlineNodes: ["mediumname", "mediumvolume", "mediumformat", "mediumnote"], /** - * lower-case EML node names that belong within the node. These must be in the correct order. + * lower-case EML node names that belong within the node. These + * must be in the correct order. * @type {string[]} * @since x.x.x */ onlineNodes: ["url"], + /** + * the allowed values for the urlFunction attribute + * @type {string[]} + * @since x.x.x + */ + urlFunctionTypes: ["information", "download"], + /** * Initializes this EMLDistribution object * @param {Object} options - A literal object with options to pass to the @@ -106,6 +145,12 @@ define(["jquery", "underscore", "backbone", "models/DataONEObject"], function ( } }); + // Check for a urlFunction attribute if there is a url node + const url = $objectDOM.find("url"); + if (url.length) { + attributes.urlFunction = url.attr("function") || null; + } + return attributes; }, @@ -201,17 +246,29 @@ define(["jquery", "underscore", "backbone", "models/DataONEObject"], function ( $objectDOM.find(`${distLocation} > ${nodeName}`).remove(); } }); + + // Add the urlFunction attribute if one is set in the model. Remove it if + // it's not set. + const url = $objectDOM.find("url") + if (url) { + const urlFunction = this.get("urlFunction"); + if (urlFunction) { + url.attr("function", urlFunction); + } else { + url.removeAttr("function"); + } + } + + return objectDOM; }, /* * Returns the node in the object DOM that the given node type should be - * inserted after. - * @param {string} nodeName - The name of the node to find the position for - * @return {jQuery} - The jQuery object of the node that the given node - * should be inserted after, or false if the node is not supported by this - * model. - * @since x.x.x + * inserted after. @param {string} nodeName - The name of the node to find + * the position for @return {jQuery} - The jQuery object of the node that + * the given node should be inserted after, or false if the node is not + * supported by this model. @since x.x.x */ getEMLPosition: function (objectDOM, nodeName) { // If this is a top level node, return false since it should be inserted @@ -225,8 +282,8 @@ define(["jquery", "underscore", "backbone", "models/DataONEObject"], function ( const siblingNodes = $(objectDOM).find(distLocation).children(); let position = nodeOrder.indexOf(nodeName); if (position > -1) { - // Go through each node in the node list and find the position where this - // node will be inserted after + // Go through each node in the node list and find the position where + // this node will be inserted after for (var i = position - 1; i >= 0; i--) { const checkNode = siblingNodes.filter(nodeOrder[i]); if (checkNode.length) { diff --git a/test/js/specs/unit/models/metadata/eml211/EMLDistribution.spec.js b/test/js/specs/unit/models/metadata/eml211/EMLDistribution.spec.js index 788640476..fbba237c8 100644 --- a/test/js/specs/unit/models/metadata/eml211/EMLDistribution.spec.js +++ b/test/js/specs/unit/models/metadata/eml211/EMLDistribution.spec.js @@ -6,15 +6,6 @@ define([ var expect = chai.expect; describe("EMLDistribution Test Suite", function () { - /* Set up */ - beforeEach(function () { - this.emlDistribution = new EMLDistribution(); - }); - - /* Tear down */ - afterEach(function () { - delete this.emlDistribution; - }); describe("Initialization", function () { it("should create a EMLDistribution instance", function () { @@ -68,6 +59,26 @@ define([ emlDistribution.get("mediumFormat").should.equal("ISO9660"); emlDistribution.get("mediumNote").should.equal("Some notes"); }); + + it("should parse the url function attribute", function () { + var objectDOM = new DOMParser().parseFromString( + "" + + " " + + " http://www.dataone.org" + + " " + + "", + "text/xml" + ).documentElement; + + var emlDistribution = new EMLDistribution( + { + objectDOM: objectDOM, + }, + { parse: true } + ); + + emlDistribution.get("urlFunction").should.equal("information"); + }); }); describe("Update DOM", function () { @@ -184,6 +195,52 @@ define([ expect(updatedDOM.getAttribute("system")).to.equal("eml"); expect(updatedDOM.getAttribute("scope")).to.equal("system"); }); + + it("should add the url function attribute", function () { + var objectDOM = new DOMParser().parseFromString( + "" + + " " + + " http://www.dataone.org" + + " " + + "", + "text/xml" + ).documentElement; + + var emlDistribution = new EMLDistribution( + { + objectDOM: objectDOM, + }, + { parse: true } + ); + + emlDistribution.set("urlFunction", "information"); + + var updatedDOM = emlDistribution.updateDOM(); + updatedDOM.querySelector("url").getAttribute("function").should.equal("information"); + }); + + it("should remove the url function attribute if the value is empty", function () { + var objectDOM = new DOMParser().parseFromString( + "" + + " " + + " http://www.dataone.org" + + " " + + "", + "text/xml" + ).documentElement; + + var emlDistribution = new EMLDistribution( + { + objectDOM: objectDOM, + }, + { parse: true } + ); + + emlDistribution.set("urlFunction", ""); + + var updatedDOM = emlDistribution.updateDOM(); + expect(updatedDOM.querySelector("url").getAttribute("function")).to.equal(null); + }); }); }); }); From e94e6625015d5e40b1519766f6e594d5f8e34dc5 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Mon, 31 Jul 2023 18:24:03 -0400 Subject: [PATCH 13/14] Move/consolidate DOI methods to AppModel - CitationModel, DataONEObject, SolrResult, and MetadataView previously all used very similar DOI methods - These methods have been moved to the appModel, the other views/models call those - Also added getCanonicalDOIIRI to DataONEObject Relates to #1380 --- src/js/models/AppModel.js | 70 ++++++++++++++++++++++++++++++++++ src/js/models/CitationModel.js | 22 ++--------- src/js/models/DataONEObject.js | 57 ++++++++++----------------- src/js/models/SolrResult.js | 41 ++------------------ src/js/views/MetadataView.js | 10 +---- 5 files changed, 99 insertions(+), 101 deletions(-) diff --git a/src/js/models/AppModel.js b/src/js/models/AppModel.js index 377f4d100..c8d6c914e 100644 --- a/src/js/models/AppModel.js +++ b/src/js/models/AppModel.js @@ -2398,6 +2398,76 @@ define(['jquery', 'underscore', 'backbone'], this.set("description", this.defaults.description); }, + /** + * Remove all DOI prefixes from a DOI string, including https, http, doi.org, + * dx.doi.org, and doi:. + * @param {string} str - The DOI string to remove prefixes from. + * @returns {string} - The DOI string without any prefixes. + * @since x.x.x + */ + removeAllDOIPrefixes: function (str) { + if (!str) return ""; + // Remove https and http prefixes + str = str.replace(/^(https?:\/\/)?/, ""); + // Remove domain prefixes, like doi.org and dx.doi.org + str = str.replace(/^(doi\.org\/|dx\.doi\.org\/)/, ""); + // Remove doi: prefix + str = str.replace(/^doi:/, ""); + return str; + }, + + /** + * Check if a string is a valid DOI. + * @param {string} doi - The string to check. + * @returns {boolean} - True if the string is a valid DOI, false otherwise. + * @since x.x.x + */ + isDOI: function (str) { + try { + if (!str) return false; + str = this.removeAllDOIPrefixes(str); + const doiRegex = /^10\.[0-9]{4,}(?:[.][0-9]+)*\/[^\s"<>]+$/; + return doiRegex.test(str); + } catch (e) { + console.error("Error checking if string is a DOI", e); + return false; + } + }, + + /** + * Get the URL for the online location of the object being cited when it + * has a DOI. If the DOI is not passed to the function, or if the string + * is not a DOI, then an empty string is returned. + * @param {string} str - The DOI string, handles both DOI and DOI URL, + * with or without prefixes + * @returns {string} - The DOI URL + * @since 2.23.0 + */ + DOItoURL: function (str) { + if (!str) return ""; + str = this.removeAllDOIPrefixes(str); + if (!this.isDOI(str)) return ""; + return "https://doi.org/" + str; + }, + + /** + * Get the DOI from a DOI URL. The URL can be http or https, can include the + * "doi:" prefix or not, and can use "dx.doi.org" or "doi.org" as the + * domain. If a string is not passed to the function, or if the string is + * not for a DOI URL, then an empty string is returned. + * @param {string} url - The DOI URL + * @returns {string} - The DOI string, including the "doi:" prefix + * @since x.x.x + */ + URLtoDOI: function (url) { + if (!url) return ""; + const doiURLRegex = + /https?:\/\/(dx\.)?doi\.org\/(doi:)?(10\.[0-9]{4,}(?:[.][0-9]+)*\/[^\s"<>]+)/; + const doiURLMatch = url.match(doiURLRegex); + if (doiURLMatch) return "doi:" + doiURLMatch[3]; + return ""; + }, + }); return AppModel; }); diff --git a/src/js/models/CitationModel.js b/src/js/models/CitationModel.js index 602083710..817f44cc4 100644 --- a/src/js/models/CitationModel.js +++ b/src/js/models/CitationModel.js @@ -977,15 +977,7 @@ define(["jquery", "underscore", "backbone", "collections/Citations"], function ( * @since 2.23.0 */ isDOI: function (str) { - try { - if (!str) return false; - str = this.removeAllDOIPrefixes(str); - const doiRegex = /^10\.[0-9]{4,}(?:[.][0-9]+)*\/[^\s"<>]+$/; - return doiRegex.test(str); - } catch (e) { - console.error("Error checking if string is a DOI", e); - return false; - } + return MetacatUI.appModel.isDOI(str); }, /** @@ -999,10 +991,7 @@ define(["jquery", "underscore", "backbone", "collections/Citations"], function ( * @since 2.23.0 */ DOItoURL: function (str) { - if (!str) return ""; - str = this.removeAllDOIPrefixes(str); - if (!this.isDOI(str)) return ""; - return "https://doi.org/" + str; + return MetacatUI.appModel.DOItoURL(str); }, /** @@ -1015,12 +1004,7 @@ define(["jquery", "underscore", "backbone", "collections/Citations"], function ( * @since 2.23.0 */ URLtoDOI: function (url) { - if (!url) return ""; - const doiURLRegex = - /https?:\/\/(dx\.)?doi\.org\/(doi:)?(10\.[0-9]{4,}(?:[.][0-9]+)*\/[^\s"<>]+)/; - const doiURLMatch = url.match(doiURLRegex); - if (doiURLMatch) return "doi:" + doiURLMatch[3]; - return ""; + return MetacatUI.appModel.URLtoDOI(url); }, /** diff --git a/src/js/models/DataONEObject.js b/src/js/models/DataONEObject.js index 76d99c2e4..8d8c3cd23 100644 --- a/src/js/models/DataONEObject.js +++ b/src/js/models/DataONEObject.js @@ -1814,6 +1814,22 @@ define(['jquery', 'underscore', 'backbone', 'uuid', 'he', 'collections/AccessPol createViewURL: function(){ return MetacatUI.root + "/view/" + encodeURIComponent((this.get("seriesId") || this.get("id"))); }, + + /** + * Check if the seriesID or PID matches a DOI regex, and if so, return + * a canonical IRI for the DOI. + * @return {string|null} - The canonical IRI for the DOI, or null if + * neither the seriesId nor the PID match a DOI regex. + * @since x.x.x + */ + getCanonicalDOIIRI: function () { + const id = this.get("id"); + const seriesId = this.get("seriesId"); + let DOI = null; + if (this.isDOI(seriesId)) DOI = seriesId; + else if (this.isDOI(id)) DOI = id; + return MetacatUI.appModel.DOItoURL(DOI); + }, /** * Converts the identifier string to a string safe to use in an XML id attribute @@ -2132,43 +2148,10 @@ define(['jquery', 'underscore', 'backbone', 'uuid', 'he', 'collections/AccessPol * @returns {boolean} True if it is a DOI */ isDOI: function(customString) { - var DOI_PREFIXES = ["doi:10.", "http://dx.doi.org/10.", "http://doi.org/10.", "http://doi.org/doi:10.", - "https://dx.doi.org/10.", "https://doi.org/10.", "https://doi.org/doi:10."], - DOI_REGEX = new RegExp(/^10.\d{4,9}\/[-._;()/:A-Z0-9]+$/i);; - - //If a custom string is given, then check that instead of the seriesId and id from the model - if( typeof customString == "string" ){ - for (var i=0; i < DOI_PREFIXES.length; i++) { - if (customString.toLowerCase().indexOf(DOI_PREFIXES[i].toLowerCase()) == 0 ) - return true; - } - - //If there is no DOI prefix, check for a DOI without the prefix using a regular expression - if( DOI_REGEX.test(customString) ){ - return true; - } - - } - else{ - var seriesId = this.get("seriesId"), - pid = this.get("id"); - - for (var i=0; i < DOI_PREFIXES.length; i++) { - if (seriesId && seriesId.toLowerCase().indexOf(DOI_PREFIXES[i].toLowerCase()) == 0 ) - return true; - else if (pid && pid.toLowerCase().indexOf(DOI_PREFIXES[i].toLowerCase()) == 0 ) - return true; - } - - //If there is no DOI prefix, check for a DOI without the prefix using a regular expression - if( DOI_REGEX.test(seriesId) || DOI_REGEX.test(pid) ){ - return true; - } - - } - - return false; - }, + return isDOI(customString) || + isDOI(this.get("id")) || + isDOI(this.get("seriesId")); + }, /** * Creates an array of objects that represent Member Nodes that could possibly be this diff --git a/src/js/models/SolrResult.js b/src/js/models/SolrResult.js index f6e179d50..2e51cd8fa 100644 --- a/src/js/models/SolrResult.js +++ b/src/js/models/SolrResult.js @@ -208,43 +208,10 @@ define(['jquery', 'underscore', 'backbone'], * @param {string} customString - Optional. An identifier string to check instead of the id and seriesId attributes on the model * @returns {boolean} True if it is a DOI */ - isDOI: function(customString) { - var DOI_PREFIXES = ["doi:10.", "http://dx.doi.org/10.", "http://doi.org/10.", "http://doi.org/doi:10.", - "https://dx.doi.org/10.", "https://doi.org/10.", "https://doi.org/doi:10."], - DOI_REGEX = new RegExp(/^10.\d{4,9}\/[-._;()/:A-Z0-9]+$/i);; - - //If a custom string is given, then check that instead of the seriesId and id from the model - if( typeof customString == "string" ){ - for (var i=0; i < DOI_PREFIXES.length; i++) { - if (customString.toLowerCase().indexOf(DOI_PREFIXES[i].toLowerCase()) == 0 ) - return true; - } - - //If there is no DOI prefix, check for a DOI without the prefix using a regular expression - if( DOI_REGEX.test(customString) ){ - return true; - } - - } - else{ - var seriesId = this.get("seriesId"), - pid = this.get("id"); - - for (var i=0; i < DOI_PREFIXES.length; i++) { - if (seriesId && seriesId.toLowerCase().indexOf(DOI_PREFIXES[i].toLowerCase()) == 0 ) - return true; - else if (pid && pid.toLowerCase().indexOf(DOI_PREFIXES[i].toLowerCase()) == 0 ) - return true; - } - - //If there is no DOI prefix, check for a DOI without the prefix using a regular expression - if( DOI_REGEX.test(seriesId) || DOI_REGEX.test(pid) ){ - return true; - } - - } - - return false; + isDOI: function (customString) { + return MetacatUI.appModel.isDOI(customString) || + MetacatUI.appModel.isDOI(this.get("id")) || + MetacatUI.appModel.isDOI(this.get("seriesId")); }, /* diff --git a/src/js/views/MetadataView.js b/src/js/views/MetadataView.js index a7dffeadc..94c9f9343 100644 --- a/src/js/views/MetadataView.js +++ b/src/js/views/MetadataView.js @@ -2905,15 +2905,9 @@ define(['jquery', * Note: Really could be generalized to more identifier schemes. */ getCanonicalDOIIRI: function (identifier) { - var pattern = /(10\.\d{4,9}\/[-\._;()\/:A-Z0-9]+)$/, - match = identifier.match(pattern); - - if (match === null || match.length !== 2 || match[1].length <= 0) { - return null; - } - - return "https://doi.org/" + match[1]; + return MetacatUI.appModel.DOItoURL(identifier) || null; }, + /** * Insert citation information as meta tags into the head of the page * From 8bfe9b35f8413628c99f1020e561c44ca9b44194 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 20 Jul 2023 22:50:01 -0400 Subject: [PATCH 14/14] Add tests for MissingValueCode model & collection Issue #612 --- test/config/tests.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/config/tests.json b/test/config/tests.json index a558e0dec..a520d1748 100644 --- a/test/config/tests.json +++ b/test/config/tests.json @@ -21,6 +21,8 @@ "./js/specs/unit/collections/metadata/eml/EMLMissingValueCodes.spec.js", "./js/specs/unit/models/metadata/eml211/EMLMissingValueCode.spec.js", "./js/specs/unit/models/metadata/eml211/EMLDistribution.spec.js", + "./js/specs/unit/collections/metadata/eml/EMLMissingValueCodes.spec.js", + "./js/specs/unit/models/metadata/eml211/EMLMissingValueCode.spec.js", "./js/specs/unit/models/maps/assets/CesiumImagery.spec.js", "./js/specs/unit/collections/maps/Geohashes.spec.js", "./js/specs/unit/models/connectors/Filters-Map.spec.js",