diff --git a/src/js/models/metadata/eml211/EMLGeoCoverage.js b/src/js/models/metadata/eml211/EMLGeoCoverage.js index 192694470..c03ad5973 100644 --- a/src/js/models/metadata/eml211/EMLGeoCoverage.js +++ b/src/js/models/metadata/eml211/EMLGeoCoverage.js @@ -1,440 +1,560 @@ /* global define */ -define(['jquery', 'underscore', 'backbone', 'models/DataONEObject'], - function ($, _, Backbone, DataONEObject) { - - /** - * @class EMLGeoCoverage - * @classdesc A description of geographic coverage of a dataset, per the EML 2.1.1 metadata standard - * @classcategory Models/Metadata/EML211 - * @extends Backbone.Model - * @constructor - */ - var EMLGeoCoverage = Backbone.Model.extend( - /** @lends EMLGeoCoverage.prototype */{ - - defaults: { - objectXML: null, - objectDOM: null, - parentModel: null, - description: null, - east: null, - north: null, - south: null, - west: null - }, - - initialize: function (attributes) { - if (attributes && attributes.objectDOM) this.set(this.parse(attributes.objectDOM)); - - //specific attributes to listen to - this.on("change:description " + - "change:east " + - "change:west " + - "change:south " + - "change:north", - 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 { - "altitudemaximum": "altitudeMaximum", - "altitudeminimum": "altitudeMinimum", - "altitudeunits": "altitudeUnits", - "boundingaltitudes": "boundingAltitudes", - "boundingcoordinates": "boundingCoordinates", - "eastboundingcoordinate": "eastBoundingCoordinate", - "geographiccoverage": "geographicCoverage", - "geographicdescription": "geographicDescription", - "northboundingcoordinate": "northBoundingCoordinate", - "southboundingcoordinate": "southBoundingCoordinate", - "westboundingcoordinate": "westBoundingCoordinate" - } - }, - - /** Based on this example serialization - - Rhine-Main-Observatory - - 9.0005 - 9.0005 - 50.1600 - 50.1600 - - - **/ - parse: function (objectDOM) { - - var modelJSON = {}; - - if (!objectDOM) { - if (this.get("objectDOM")) - var objectDOM = this.get("objectDOM"); - else - return {}; - } - - //Create a jQuery object of the DOM - var $objectDOM = $(objectDOM); - - //Get the geographic description - modelJSON.description = $objectDOM.children('geographicdescription').text(); - - //Get the bounding coordinates - var boundingCoordinates = $objectDOM.children('boundingcoordinates'); - if (boundingCoordinates) { - modelJSON.east = boundingCoordinates.children('eastboundingcoordinate').text().replace("+", ""); - modelJSON.north = boundingCoordinates.children('northboundingcoordinate').text().replace("+", ""); - modelJSON.south = boundingCoordinates.children('southboundingcoordinate').text().replace("+", ""); - modelJSON.west = boundingCoordinates.children('westboundingcoordinate').text().replace("+", ""); - } - - return modelJSON; - }, - - 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; - - if (!this.isValid()) { - return ""; - } - - if (this.get("objectDOM")) { - objectDOM = $(this.get("objectDOM").cloneNode(true)); - } else { - objectDOM = $(document.createElement("geographiccoverage")); - } - - //If only one point is given, make sure both points are the same - if ((this.get("north") && this.get("west")) && (!this.get("south") && !this.get("east"))) { - this.set("south", this.get("north")); - this.set("east", this.get("west")); - } - else if ((this.get("south") && this.get("east")) && (!this.get("north") && !this.get("west"))) { - this.set("north", this.get("south")); - this.set("west", this.get("east")); - } - - // Description - if (!objectDOM.children("geographicdescription").length) - objectDOM.append($(document.createElement("geographicdescription")).text(this.get("description"))); - else - objectDOM.children("geographicdescription").text(this.get("description")); - - // Create the bounding coordinates element - var boundingCoordinates = objectDOM.find("boundingcoordinates"); - if (!boundingCoordinates.length) { - boundingCoordinates = document.createElement("boundingcoordinates"); - objectDOM.append(boundingCoordinates); - } - - //Empty out the coordinates first - $(boundingCoordinates).empty(); - - //Add the four coordinate values - $(boundingCoordinates).append($(document.createElement("westboundingcoordinate")).text(this.get("west")), - $(document.createElement("eastboundingcoordinate")).text(this.get("east")), - $(document.createElement("northboundingcoordinate")).text(this.get("north")), - $(document.createElement("southboundingcoordinate")).text(this.get("south"))); - - return objectDOM; - }, - - /** - * Sometimes we'll need to add a space between error messages, but only if an - * error has already been triggered. Use addSpace to accomplish this. - * - * @param {string} msg The string that will be appended - * @param {bool} front A flag that when set will append the whitespace to the front of 'msg' - * @return {string} The string that was passed in, 'msg', with whitespace appended - */ - addSpace: function (msg, front) { - if (typeof front === "undefined") { - front = false; - } - if (msg) { - if (front) { - return (" " + msg); - } - return msg += " "; - } - return msg; - }, - - /** - * Because the same error messages are used in a couple of different places, we centralize the strings - * and access here. - * - * @param {string} area Specifies the area that the error message belongs to. - * Browse through the switch statement to find the one you need. - * @return {string} The error message - */ - getErrorMessage: function (area) { - switch (area) { - case "north": - return "The Northwest latitude must be between -90 and 90."; - break; - case "east": - return "The Southeast longitude must be between -180 and 180."; - break; - case "south": - return "The Southeast latitude must be between -90 and 90."; - break; - case "west": - return "The Northwest longitude must be between -180 and 180."; - break; - case "missing": - return "Each coordinate must include a latitude AND longitude."; - break; - case "description": - return "Each location must have a description."; - break; - case "needPair": - return "Each location description must have at least one coordinate pair."; - break; - default: - return ""; - break; - } - }, - - /** - * Generates an object that describes the current state of each latitude - * and longitude box. The status includes whether there is a value and - * if the value is valid. - * - * @return {array} An array containing the current state of each coordinate box - */ - getCoordinateStatus: function () { - var north = this.get("north"), - east = this.get("east"), - south = this.get("south"), - west = this.get("west"); - - return { - 'north': { - isSet: typeof north !== "undefined" && north != null && north !== "", - isValid: this.validateCoordinate(north, -90, 90) - }, - 'east': { - isSet: typeof east !== "undefined" && east != null && east !== "", - isValid: this.validateCoordinate(east, -180, 180) - }, - 'south': { - isSet: typeof south !== "undefined" && south != null && south !== "", - isValid: this.validateCoordinate(south, -90, 90) - }, - 'west': { - isSet: typeof west !== "undefined" && west != null && west !== "", - isValid: this.validateCoordinate(west, -180, 180) - }, - } - }, - - /** - * Checks the status object for conditions that warrant an error message to the user. This is called - * during the validation processes (validate() and updateModel()) after the status object has been - * created by getCoordinateStatus(). - * - * @param status The status object, holding the state of the coordinates - * @return {string} Any errors that need to be displayed to the user - */ - generateStatusErrors: function (status) { - var errorMsg = ""; - - // Northwest Latitude - if (status.north.isSet && !status.north.isValid) { - errorMsg = this.addSpace(errorMsg); - errorMsg += this.getErrorMessage("north"); - } - // Northwest Longitude - if (status.west.isSet && !status.west.isValid) { - errorMsg = this.addSpace(errorMsg); - errorMsg += this.getErrorMessage("west"); - } - // Southeast Latitude - if (status.south.isSet && !status.south.isValid) { - errorMsg = this.addSpace(errorMsg); - errorMsg += this.getErrorMessage("south"); - } - // Southeast Longitude - if (status.east.isSet && !status.east.isValid) { - errorMsg = this.addSpace(errorMsg); - errorMsg += this.getErrorMessage("east"); - } - return errorMsg; - - }, - - /** - * This grabs the various location elements and validates the user input. In the case of an error, - * we append an error string (errMsg) so that we display all of the messages at the same time. This - * validates the entire location row by adding extra checks for a description and for coordinate pairs - * - * @return {string} The error messages that the user will see - */ - validate: function () { - var errors = {}; - - if (!this.get("description")) { - errors.description = this.getErrorMessage("description"); - } - - var pointStatuses = this.getCoordinateStatus(); -/* - if (!this.checkForPairs(pointStatuses)) { - errorMsg = this.addSpace(errorMsg); - errorMsg += this.getErrorMessage("needPair"); - } - - if( this.hasMissingPoint(pointStatuses) ) { - //errorMsg = this.addSpace(errorMsg); - errors += this.getErrorMessage("missing"); - } -*/ - // errorMsg += this.addSpace(this.generateStatusErrors(pointStatuses), true); - - if( !pointStatuses.north.isSet && !pointStatuses.south.isSet && - !pointStatuses.east.isSet && !pointStatuses.west.isSet){ - errors.north = this.getErrorMessage("needPair"); - errors.west = ""; - } - - //Check that all the values are correct - if( pointStatuses.north.isSet && !pointStatuses.north.isValid ) - errors.north = this.getErrorMessage("north"); - if( pointStatuses.south.isSet && !pointStatuses.south.isValid ) - errors.south = this.getErrorMessage("south"); - if( pointStatuses.east.isSet && !pointStatuses.east.isValid ) - errors.east = this.getErrorMessage("east"); - if( pointStatuses.west.isSet && !pointStatuses.west.isValid ) - errors.west = this.getErrorMessage("west"); - - if( pointStatuses.north.isSet && !pointStatuses.west.isSet ) - errors.west = this.getErrorMessage("missing"); - else if( !pointStatuses.north.isSet && pointStatuses.west.isSet ) - errors.north = this.getErrorMessage("missing"); - else if( pointStatuses.south.isSet && !pointStatuses.east.isSet ) - errors.east = this.getErrorMessage("missing"); - else if( !pointStatuses.south.isSet && pointStatuses.east.isSet ) - errors.south = this.getErrorMessage("missing"); - - if( Object.keys(errors).length ) - return errors; - else - return false; - }, - - /** - * Checks for any coordinates with missing counterparts. - * - * @param status The status of the coordinates - * @return {bool} True if there are missing coordinates, false otherwise - */ - hasMissingPoint: function (status) { - if ((status.north.isSet && !status.west.isSet) || - (!status.north.isSet && status.west.isSet)) { - return true - } else if ((status.south.isSet && !status.east.isSet) || - (!status.south.isSet && status.east.isSet)) { - return true; - } - - return false; - - }, - - /** - * Checks that there are either two or four coordinate values. If there aren't, - * it means that the user still needs to enter coordinates. - * - * @param status The current state of the coordinates - * @return {bool} True if there are pairs, false otherwise - */ - checkForPairs: function (status) { - var isSet = _.filter(status, function (coord) { return coord.isSet == true; }); - - if (isSet.length == 0) { - return false; - } - return true; - }, - - /** - * Validate a coordinate String by making sure it can be coerced into a number and - * is within the given bounds. - * Note: Min and max are inclusive - * - * @param value {string} The value of the edit area that will be validated - * @param min The minimum value that 'value' can be - * @param max The maximum value that 'value' can be - * @return {bool} True if the validation passed, otherwise false - */ - validateCoordinate: function (value, min, max) { - - if (typeof value === "undefined" || value === null || value === "" && isNaN(value)) { - return false; - } - - var parsed = Number(value); - - if (isNaN(parsed)) { - return false; - } - - if (parsed < min || parsed > max) { - return false; - } - - return true; - }, - - /* - * 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; - - while (emlModel.type !== "EML" && tries < 6){ - emlModel = emlModel.get("parentModel"); - tries++; - } - - if( emlModel && emlModel.type == "EML") - return emlModel; - else - return false; - - }, - - trickleUpChange: function () { - this.get("parentModel").trigger("change"); - this.get("parentModel").trigger("change:geoCoverage"); - }, - - formatXML: function (xmlString) { - return DataONEObject.prototype.formatXML.call(this, xmlString); - } +define(["jquery", "underscore", "backbone", "models/DataONEObject"], function ( + $, + _, + Backbone, + DataONEObject +) { + /** + * @class EMLGeoCoverage + * @classdesc A description of geographic coverage of a dataset, per the EML + * 2.1.1 metadata standard + * @classcategory Models/Metadata/EML211 + * @extends Backbone.Model + * @constructor + */ + var EMLGeoCoverage = Backbone.Model.extend( + /** @lends EMLGeoCoverage.prototype */ { + defaults: { + objectXML: null, + objectDOM: null, + parentModel: null, + description: null, + east: null, + north: null, + south: null, + west: null, + }, + + initialize: function (attributes) { + if (attributes && attributes.objectDOM) + this.set(this.parse(attributes.objectDOM)); + + //specific attributes to listen to + this.on( + "change:description " + + "change:east " + + "change:west " + + "change:south " + + "change:north", + 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 { + altitudemaximum: "altitudeMaximum", + altitudeminimum: "altitudeMinimum", + altitudeunits: "altitudeUnits", + boundingaltitudes: "boundingAltitudes", + boundingcoordinates: "boundingCoordinates", + eastboundingcoordinate: "eastBoundingCoordinate", + geographiccoverage: "geographicCoverage", + geographicdescription: "geographicDescription", + northboundingcoordinate: "northBoundingCoordinate", + southboundingcoordinate: "southBoundingCoordinate", + westboundingcoordinate: "westBoundingCoordinate", + }; + }, + + /** + * Parses the objectDOM to populate this model with data. + * @param {Element} objectDOM - The EML object element + * @returns {Object} The EMLGeoCoverage data + * + * @example - Example input XML + * + * Rhine-Main-Observatory + * + * 9.0005 + * 9.0005 + * 50.1600 + * 50.1600 + * + * + */ + parse: function (objectDOM) { + var modelJSON = {}; + + if (!objectDOM) { + if (this.get("objectDOM")) var objectDOM = this.get("objectDOM"); + else return {}; + } + + //Create a jQuery object of the DOM + var $objectDOM = $(objectDOM); + + //Get the geographic description + modelJSON.description = $objectDOM + .children("geographicdescription") + .text(); + + //Get the bounding coordinates + var boundingCoordinates = $objectDOM.children("boundingcoordinates"); + if (boundingCoordinates) { + modelJSON.east = boundingCoordinates + .children("eastboundingcoordinate") + .text() + .replace("+", ""); + modelJSON.north = boundingCoordinates + .children("northboundingcoordinate") + .text() + .replace("+", ""); + modelJSON.south = boundingCoordinates + .children("southboundingcoordinate") + .text() + .replace("+", ""); + modelJSON.west = boundingCoordinates + .children("westboundingcoordinate") + .text() + .replace("+", ""); + } + + return modelJSON; + }, + + /** + * Converts this EMLGeoCoverage to XML + * @returns {string} The XML string + */ + serialize: function () { + + const objectDOM = this.updateDOM(); + let xmlString = objectDOM?.outerHTML; + if (!xmlString) xmlString = objectDOM?.[0]?.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; + + if (!this.isValid()) { + return ""; + } + + if (this.get("objectDOM")) { + objectDOM = $(this.get("objectDOM").cloneNode(true)); + } else { + objectDOM = $(document.createElement("geographiccoverage")); + } + + //If only one point is given, make sure both points are the same + if ( + this.get("north") && + this.get("west") && + !this.get("south") && + !this.get("east") + ) { + this.set("south", this.get("north")); + this.set("east", this.get("west")); + } else if ( + this.get("south") && + this.get("east") && + !this.get("north") && + !this.get("west") + ) { + this.set("north", this.get("south")); + this.set("west", this.get("east")); + } + + // Description + if (!objectDOM.children("geographicdescription").length) + objectDOM.append( + $(document.createElement("geographicdescription")).text( + this.get("description") + ) + ); + else + objectDOM + .children("geographicdescription") + .text(this.get("description")); + + // Create the bounding coordinates element + var boundingCoordinates = objectDOM.find("boundingcoordinates"); + if (!boundingCoordinates.length) { + boundingCoordinates = document.createElement("boundingcoordinates"); + objectDOM.append(boundingCoordinates); + } + + //Empty out the coordinates first + $(boundingCoordinates).empty(); + + //Add the four coordinate values + $(boundingCoordinates).append( + $(document.createElement("westboundingcoordinate")).text( + this.get("west") + ), + $(document.createElement("eastboundingcoordinate")).text( + this.get("east") + ), + $(document.createElement("northboundingcoordinate")).text( + this.get("north") + ), + $(document.createElement("southboundingcoordinate")).text( + this.get("south") + ) + ); + + return objectDOM; + }, + + /** + * Sometimes we'll need to add a space between error messages, but only if + * an error has already been triggered. Use addSpace to accomplish this. + * + * @param {string} msg The string that will be appended + * @param {bool} front A flag that when set will append the whitespace to + * the front of 'msg' + * @return {string} The string that was passed in, 'msg', with whitespace + * appended + */ + addSpace: function (msg, front) { + if (typeof front === "undefined") { + front = false; + } + if (msg) { + if (front) { + return " " + msg; + } + return (msg += " "); + } + return msg; + }, + + /** + * Because the same error messages are used in a couple of different + * places, we centralize the strings and access here. + * + * @param {string} area Specifies the area that the error message belongs + * to. Browse through the switch statement to find the one you need. + * @return {string} The error message + */ + getErrorMessage: function (area) { + switch (area) { + case "north": + return "The Northwest latitude must be between -90 and 90."; + break; + case "east": + return "The Southeast longitude must be between -180 and 180."; + break; + case "south": + return "The Southeast latitude must be between -90 and 90."; + break; + case "west": + return "The Northwest longitude must be between -180 and 180."; + break; + case "missing": + return "Each coordinate must include a latitude AND longitude."; + break; + case "description": + return "Each location must have a description."; + break; + case "needPair": + return "Each location description must have at least one coordinate pair."; + break; + case "northSouthReversed": + return "The North latitude must be greater than the South latitude."; + break; + case "crossesAntiMeridian": + return "The bounding box cannot cross the anti-meridian."; + break; + case "containsPole": + return "The bounding box cannot contain the North or South pole."; + default: + return ""; + break; + } + }, + + /** + * Generates an object that describes the current state of each latitude + * and longitude box. The status includes whether there is a value and if + * the value is valid. + * + * @return {array} An array containing the current state of each + * coordinate box, including: value (the value of the coordinate converted + * to a number), isSet (whether the coordinate has a value), and isValid + * (whether the value is in the correct range) + */ + getCoordinateStatus: function () { + var north = this.get("north"), + east = this.get("east"), + south = this.get("south"), + west = this.get("west"); + + const isDefined = (value) => + typeof value !== "undefined" && value != null && value !== ""; + + return { + north: { + value: Number(north), + isSet: isDefined(north), + isValid: this.validateCoordinate(north, -90, 90), + }, + east: { + value: Number(east), + isSet: isDefined(east), + isValid: this.validateCoordinate(east, -180, 180), + }, + south: { + value: Number(south), + isSet: isDefined(south), + isValid: this.validateCoordinate(south, -90, 90), + }, + west: { + value: Number(west), + isSet: isDefined(west), + isValid: this.validateCoordinate(west, -180, 180), + }, + }; + }, + + /** + * Checks the status object for conditions that warrant an error message + * to the user. This is called during the validation processes (validate() + * and updateModel()) after the status object has been created by + * getCoordinateStatus(). + * + * @param status The status object, holding the state of the coordinates + * @return {string} Any errors that need to be displayed to the user + */ + generateStatusErrors: function (status) { + var errorMsg = ""; + + // Northwest Latitude + if (status.north.isSet && !status.north.isValid) { + errorMsg = this.addSpace(errorMsg); + errorMsg += this.getErrorMessage("north"); + } + // Northwest Longitude + if (status.west.isSet && !status.west.isValid) { + errorMsg = this.addSpace(errorMsg); + errorMsg += this.getErrorMessage("west"); + } + // Southeast Latitude + if (status.south.isSet && !status.south.isValid) { + errorMsg = this.addSpace(errorMsg); + errorMsg += this.getErrorMessage("south"); + } + // Southeast Longitude + if (status.east.isSet && !status.east.isValid) { + errorMsg = this.addSpace(errorMsg); + errorMsg += this.getErrorMessage("east"); + } + return errorMsg; + }, + + /** + * This grabs the various location elements and validates the user input. + * In the case of an error, we append an error string (errMsg) so that we + * display all of the messages at the same time. This validates the entire + * location row by adding extra checks for a description and for + * coordinate pairs + * + * @return {string} The error messages that the user will see + */ + validate: function () { + var errors = {}; + + if (!this.get("description")) { + errors.description = this.getErrorMessage("description"); + } + + var pointStatuses = this.getCoordinateStatus(); + + if ( + !pointStatuses.north.isSet && + !pointStatuses.south.isSet && + !pointStatuses.east.isSet && + !pointStatuses.west.isSet + ) { + errors.north = this.getErrorMessage("needPair"); + errors.west = ""; + } + + //Check that all the values are correct + if (pointStatuses.north.isSet && !pointStatuses.north.isValid) + errors.north = this.getErrorMessage("north"); + if (pointStatuses.south.isSet && !pointStatuses.south.isValid) + errors.south = this.getErrorMessage("south"); + if (pointStatuses.east.isSet && !pointStatuses.east.isValid) + errors.east = this.getErrorMessage("east"); + if (pointStatuses.west.isSet && !pointStatuses.west.isValid) + errors.west = this.getErrorMessage("west"); + + if (pointStatuses.north.isSet && !pointStatuses.west.isSet) + errors.west = this.getErrorMessage("missing"); + else if (!pointStatuses.north.isSet && pointStatuses.west.isSet) + errors.north = this.getErrorMessage("missing"); + else if (pointStatuses.south.isSet && !pointStatuses.east.isSet) + errors.east = this.getErrorMessage("missing"); + else if (!pointStatuses.south.isSet && pointStatuses.east.isSet) + errors.south = this.getErrorMessage("missing"); + + // Verify latitudes: north should be > south. Don't allow bounding boxes + // to contain the north or south poles (doesn't really work) + if ( + pointStatuses.north.isSet && + pointStatuses.south.isSet && + pointStatuses.north.isValid && + pointStatuses.south.isValid + ) { + if (pointStatuses.north.value < pointStatuses.south.value) { + const msg = this.getErrorMessage("northSouthReversed"); + errors.north = msg; + errors.south = msg; + } + if ( + pointStatuses.north.value == 90 || + pointStatuses.south.value == -90 + ) { + const msg = this.getErrorMessage("containsPole"); + errors.north = msg; + errors.south = msg; + } + } + + // For longitudes, don't allow bounding boxes that attempt to traverse + // the anti-meridian + if ( + pointStatuses.east.isSet && + pointStatuses.west.isSet && + pointStatuses.east.isValid && + pointStatuses.west.isValid + ) { + if (pointStatuses.east.value < pointStatuses.west.value) { + const msg = this.getErrorMessage("crossesAntiMeridian"); + errors.east = msg; + errors.west = msg; + } + } + + + if (Object.keys(errors).length) return errors; + else return false; + }, + + /** + * Checks for any coordinates with missing counterparts. + * + * @param status The status of the coordinates + * @return {bool} True if there are missing coordinates, false otherwise + */ + hasMissingPoint: function (status) { + if ( + (status.north.isSet && !status.west.isSet) || + (!status.north.isSet && status.west.isSet) + ) { + return true; + } else if ( + (status.south.isSet && !status.east.isSet) || + (!status.south.isSet && status.east.isSet) + ) { + return true; + } + + return false; + }, + + /** + * Checks that there are either two or four coordinate values. If there + * aren't, it means that the user still needs to enter coordinates. + * + * @param status The current state of the coordinates + * @return {bool} True if there are pairs, false otherwise + */ + checkForPairs: function (status) { + var isSet = _.filter(status, function (coord) { + return coord.isSet == true; }); - return EMLGeoCoverage; - }); + if (isSet.length == 0) { + return false; + } + return true; + }, + + /** + * Validate a coordinate String by making sure it can be coerced into a + * number and is within the given bounds. Note: Min and max are inclusive + * + * @param value {string} The value of the edit area that will be validated + * @param min The minimum value that 'value' can be + * @param max The maximum value that 'value' can be + * @return {bool} True if the validation passed, otherwise false + */ + validateCoordinate: function (value, min, max) { + if ( + typeof value === "undefined" || + value === null || + (value === "" && isNaN(value)) + ) { + return false; + } + + var parsed = Number(value); + + if (isNaN(parsed)) { + return false; + } + + if (parsed < min || parsed > max) { + return false; + } + + return true; + }, + + /** + * 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 + */ + getParentEML: function () { + var emlModel = this.get("parentModel"), + tries = 0; + + while (emlModel.type !== "EML" && tries < 6) { + emlModel = emlModel.get("parentModel"); + tries++; + } + + if (emlModel && emlModel.type == "EML") return emlModel; + else return false; + }, + + /** + * Apply the change event on the parent EML model + */ + trickleUpChange: function () { + const parentModel = this.get("parentModel"); + if (!parentModel) return; + parentModel.trigger("change"); + parentModel.trigger("change:geoCoverage"); + }, + + /** + * See DataONEObject.formatXML() + */ + formatXML: function (xmlString) { + return DataONEObject.prototype.formatXML.call(this, xmlString); + }, + } + ); + + return EMLGeoCoverage; +}); diff --git a/src/js/templates/metadata/EMLGeoCoverage.html b/src/js/templates/metadata/EMLGeoCoverage.html index cec7cdb9c..dcfa0f9bd 100644 --- a/src/js/templates/metadata/EMLGeoCoverage.html +++ b/src/js/templates/metadata/EMLGeoCoverage.html @@ -9,13 +9,13 @@
- - + +
- - + +
diff --git a/src/js/views/metadata/EMLGeoCoverageView.js b/src/js/views/metadata/EMLGeoCoverageView.js index 6b404231a..8b73c610e 100644 --- a/src/js/views/metadata/EMLGeoCoverageView.js +++ b/src/js/views/metadata/EMLGeoCoverageView.js @@ -1,253 +1,327 @@ /* global define */ -define(['underscore', 'jquery', 'backbone', - 'models/metadata/eml211/EMLGeoCoverage', - 'text!templates/metadata/EMLGeoCoverage.html'], - function (_, $, Backbone, EMLGeoCoverage, EMLGeoCoverageTemplate) { - - /** - * @class EMlGeoCoverageView - * @classdesc The EMLGeoCoverage renders the content of an EMLGeoCoverage model - * @classcategory Views/Metadata - * @extends Backbone.View - */ - var EMLGeoCoverageView = Backbone.View.extend( - /** @lends EMLGeoCoverageView.prototype */{ - - type: "EMLGeoCoverageView", - - tagName: "div", - - className: "row-fluid eml-geocoverage", - - attributes: { - "data-category": "geoCoverage" - }, - - editTemplate: _.template(EMLGeoCoverageTemplate), - - initialize: function (options) { - if (!options) - var options = {}; - - this.isNew = options.isNew || (options.model ? false : true); - this.model = options.model || new EMLGeoCoverage(); - this.edit = options.edit || false; - }, - - events: { - "change": "updateModel", - "mouseover .remove": "toggleRemoveClass", - "mouseout .remove": "toggleRemoveClass" - }, - - render: function (e) { - //Save the view and model on the element - this.$el.data({ - model: this.model, - view: this - }); - - this.$el.html(this.editTemplate({ - edit: this.edit, - model: this.model.toJSON() - })); - - if (this.isNew) { - this.$el.addClass("new"); - } - - return this; - }, - - /** - * Updates the model. - * If this is called from the user switching between latitude and longitude boxes, - * we check to see if the input was valid and display any errors if we need to. - * - * @param e The event +define([ + "underscore", + "jquery", + "backbone", + "models/metadata/eml211/EMLGeoCoverage", + "text!templates/metadata/EMLGeoCoverage.html", +], function (_, $, Backbone, EMLGeoCoverage, EMLGeoCoverageTemplate) { + /** + * @class EMlGeoCoverageView + * @classdesc The EMLGeoCoverage renders the content of an EMLGeoCoverage + * model + * @classcategory Views/Metadata + * @extends Backbone.View + */ + var EMLGeoCoverageView = Backbone.View.extend( + /** @lends EMLGeoCoverageView.prototype */ { + type: "EMLGeoCoverageView", + + /** + * The HTML tag name for this view element + * @type {string} + * @default "div" + */ + tagName: "div", + + /** + * The class names to add to this view's HTML element + */ + className: "row-fluid eml-geocoverage", + + /** + * Attributes for the HTML element. + */ + attributes: { + "data-category": "geoCoverage", + }, + + /** + * Events applied to this view's HTML elements by Backbone. + */ + events: { + change: "updateModel", // <- TODO: does this work? + "mouseover .remove": "toggleRemoveClass", + "mouseout .remove": "toggleRemoveClass", + }, + + /** + * The template to use for this view in edit mode + */ + editTemplate: _.template(EMLGeoCoverageTemplate), + + /** + * Initializes the EMLGeoCoverageView + * @param {Object} options - A literal object with options to pass to the + * view + * @param {EMLGeoCoverage} options.model - The EMLGeoCoverage model to + * render + * @param {boolean} options.edit - Flag to toggle whether this view is in + * edit mode + * @param {boolean} options.isNew - Flag to toggle whether this view is + * new + */ + initialize: function (options) { + if (!options) var options = {}; + + this.isNew = options.isNew || (options.model ? false : true); + this.model = options.model || new EMLGeoCoverage(); + this.edit = options.edit || false; + }, + + /** + * Renders the EMLGeoCoverageView + * @returns {EMLGeoCoverageView} Returns the view + */ + render: function () { + try { + // Save the view and model on the element + this.$el.data({ + model: this.model, + view: this, + }); + + this.$el.html( + this.editTemplate({ + edit: this.edit, + model: this.model.toJSON(), + }) + ); + + if (this.isNew) { + this.$el.addClass("new"); + } + + return this; + } catch (e) { + console.log("Error rendering EMLGeoCoverageView: ", e); + return this; + } + }, + + /** + * Updates the model. If this is called from the user switching between + * latitude and longitude boxes, we check to see if the input was valid + * and display any errors if we need to. + * + * @param {Event} e - The event that triggered this function + */ + updateModel: function (e) { + if (!e) return false; + + e.preventDefault(); + + //Get the attribute and value + var element = $(e.target), + value = element.val(), + attribute = element.attr("data-attribute"); + + //Get the attribute that was changed + if (!attribute) return false; + + var emlModel = this.model.getParentEML(); + if (emlModel) { + value = emlModel.cleanXMLText(value); + } + + //Are the NW and SE points the same? i.e. is this a single point and not + //a box? + var isSinglePoint = + this.model.get("north") != null && + this.model.get("north") == this.model.get("south") && + this.model.get("west") != null && + this.model.get("west") == this.model.get("east"), + hasEmptyInputs = + this.$("[data-attribute='north']").val() == "" || + this.$("[data-attribute='south']").val() == "" || + this.$("[data-attribute='west']").val() == "" || + this.$("[data-attribute='east']").val() == ""; + + //Update the model + if (value == "") this.model.set(attribute, null); + else this.model.set(attribute, value); + + //If the NW and SE points are the same point... + if (isSinglePoint && hasEmptyInputs) { + /* If the user updates one of the empty number inputs, then we can + * assume they do not want a single point and are attempting to + * enter a second point. So we should empty the value from the model + * for the corresponding coordinate For example, if the UI shows a + * lat,long pair of NW: [10] [30] SE: [ ] [ ] then the model values + * would be N: 10, W: 30, S: 10, E: 30 if the user updates that to: + * NW: [10] [30] SE: [5] [ ] then we want to remove the "east" value + * of "30", so the model would be: N: 10, W: 30, S: 5, E: null + */ + if ( + attribute == "north" && + this.$("[data-attribute='west']").val() == "" + ) + this.model.set("west", null); + else if ( + attribute == "south" && + this.$("[data-attribute='east']").val() == "" + ) + this.model.set("east", null); + else if ( + attribute == "east" && + this.$("[data-attribute='south']").val() == "" + ) + this.model.set("south", null); + else if ( + attribute == "west" && + this.$("[data-attribute='north']").val() == "" + ) + this.model.set("north", null); + /* + * If the user removes one of the latitude or longitude values, reset + * the opposite point + */ else if ( + ((attribute == "north" && this.model.get("north") == null) || + (attribute == "west" && this.model.get("west") == null)) && + this.$("[data-attribute='south']").val() == "" && + this.$("[data-attribute='east']").val() == "" + ) { + this.model.set("south", null); + this.model.set("east", null); + } else if ( + ((attribute == "south" && this.model.get("south") == null) || + (attribute == "east" && this.model.get("east") == null)) && + this.$("[data-attribute='north']").val() == "" && + this.$("[data-attribute='west']").val() == "" + ) { + this.model.set("north", null); + this.model.set("west", null); + } else if (attribute == "north" && this.model.get("north") != null) + /* Otherwise, if the non-empty number inputs are updated, we simply + * update the corresponding value in the other point */ - updateModel: function (e) { - if (!e) return false; - - e.preventDefault(); - - //Get the attribute and value - var element = $(e.target), - value = element.val(), - attribute = element.attr("data-attribute"); - - //Get the attribute that was changed - if (!attribute) return false; - - var emlModel = this.model.getParentEML(); - if(emlModel){ - value = emlModel.cleanXMLText(value); - } - - //Are the NW and SE points the same? i.e. is this a single point and not a box? - var isSinglePoint = (this.model.get("north") != null && this.model.get("north") == this.model.get("south")) && - (this.model.get("west") != null && this.model.get("west") == this.model.get("east")), - hasEmptyInputs = this.$("[data-attribute='north']").val() == "" || - this.$("[data-attribute='south']").val() == "" || - this.$("[data-attribute='west']").val() == "" || - this.$("[data-attribute='east']").val() == ""; - - //Update the model - if (value == "") - this.model.set(attribute, null); - else - this.model.set(attribute, value); - - //If the NW and SE points are the same point... - if (isSinglePoint && hasEmptyInputs) { - /* If the user updates one of the empty number inputs, then we can assume they do not - * want a single point and are attempting to enter a second point. So we should empty the - * value from the model for the corresponding coordinate - * For example, if the UI shows a lat,long pair of NW: [10] [30] SE: [ ] [ ] then the model - * values would be N: 10, W: 30, S: 10, E: 30 - * if the user updates that to: NW: [10] [30] SE: [5] [ ] - * then we want to remove the "east" value of "30", so the model would be: N: 10, W: 30, S: 5, E: null - */ - if (attribute == "north" && this.$("[data-attribute='west']").val() == "") - this.model.set("west", null); - else if (attribute == "south" && this.$("[data-attribute='east']").val() == "") - this.model.set("east", null); - else if (attribute == "east" && this.$("[data-attribute='south']").val() == "") - this.model.set("south", null); - else if (attribute == "west" && this.$("[data-attribute='north']").val() == "") - this.model.set("north", null); - - /* - * If the user removes one of the latitude or longitude values, reset the opposite point - */ - else if (((attribute == "north" && this.model.get("north") == null) || - (attribute == "west" && this.model.get("west") == null)) && - (this.$("[data-attribute='south']").val() == "" && - this.$("[data-attribute='east']").val() == "")) { - this.model.set("south", null); - this.model.set("east", null); - } else if (((attribute == "south" && this.model.get("south") == null) || - (attribute == "east" && this.model.get("east") == null)) && - (this.$("[data-attribute='north']").val() == "" && this.$("[data-attribute='west']").val() == "")) { - this.model.set("north", null); - this.model.set("west", null); - } - /* Otherwise, if the non-empty number inputs are updated, - * we simply update the corresponding value in the other point - */ - else if (attribute == "north" && this.model.get("north") != null) - this.model.set("south", value); - else if (attribute == "south" && this.model.get("south") != null) - this.model.set("north", value); - else if (attribute == "west" && this.model.get("west") != null) - this.model.set("east", value); - else if (attribute == "east" && this.model.get("east") != null) - this.model.set("west", value); - } - else { - - //Find out if we are missing a complete NW or SE point - var isMissingNWPoint = (this.model.get("north") == null && this.model.get("west") == null), - isMissingSEPoint = (this.model.get("south") == null && this.model.get("east") == null); - - // If there is a full NW point but no SE point, we can assume the user wants a single point and - // so we will copy the NW values to the SE - if (this.model.get("north") != null && this.model.get("west") != null && isMissingSEPoint) { - this.model.set("south", this.model.get("north")); - this.model.set("east", this.model.get("west")); - } - // Same for when there is a SE point but no NW point - else if (this.model.get("south") != null && this.model.get("east") != null && isMissingNWPoint) { - this.model.set("north", this.model.get("south")); - this.model.set("west", this.model.get("east")); - } - } - - - // Validate the coordinate boxes - //this.validateCoordinates(e); - - //If this model is part of the EML inside the root data package, mark the package as changed - if (this.model.get("parentModel")) { - if (this.model.get("parentModel").type == "EML" && _.contains(MetacatUI.rootDataPackage.models, this.model.get("parentModel"))) { - MetacatUI.rootDataPackage.packageModel.set("changed", true); - } - } - - this.validate(); - }, - - /** - * Checks to see if any error messages need to be removed. If not, then it performs validation - * across the row and displays any errors. This id called when the user clicks out of an edit box - * on to the page. - * - * @param e The event - * @param options - */ - validate: function (e, options) { - - //Query for the EMlGeoCoverageView element that the user is actively interacting with - var activeGeoCovEl = $(document.activeElement).parents(".eml-geocoverage"); - - //If the user is not actively in this view, then exit - if (activeGeoCovEl.length && activeGeoCovEl[0] == this.el) - return; - - //If the model is valid, then remove error styling and exit - if( this.model.isValid() ) { - this.$(".error").removeClass("error"); - this.$el.removeClass("error"); - this.$(".notification").empty(); - this.model.trigger("valid"); - - return; - } - else{ - - this.showValidation(); - - } - - }, - - /* - * Resets the error messaging and displays the current error messages for this model - * This function is used by the EML211EditorView during the package validation process - */ - showValidation: function(){ - this.$(".error").removeClass("error"); - this.$el.removeClass("error"); - this.$(".notification").empty(); - - var errorMessages = ""; - - for( field in this.model.validationError ){ - this.$("[data-attribute='" + field + "']").addClass("error"); - - errorMessages += this.model.validationError[field] + " "; - } - - this.$(".notification").text(errorMessages).addClass("error"); - }, - - /** - * Highlight what will be removed when the remove icon is hovered over - * - */ - toggleRemoveClass: function () { - this.$el.toggleClass("remove-preview"); - }, - - /** - * Unmarks this view as new - * - */ - notNew: function () { - this.$el.removeClass("new"); - this.isNew = false; - } - }); - - return EMLGeoCoverageView; - }); + this.model.set("south", value); + else if (attribute == "south" && this.model.get("south") != null) + this.model.set("north", value); + else if (attribute == "west" && this.model.get("west") != null) + this.model.set("east", value); + else if (attribute == "east" && this.model.get("east") != null) + this.model.set("west", value); + } else { + //Find out if we are missing a complete NW or SE point + var isMissingNWPoint = + this.model.get("north") == null && this.model.get("west") == null, + isMissingSEPoint = + this.model.get("south") == null && this.model.get("east") == null; + + // If there is a full NW point but no SE point, we can assume the user + // wants a single point and so we will copy the NW values to the SE + if ( + this.model.get("north") != null && + this.model.get("west") != null && + isMissingSEPoint + ) { + this.model.set("south", this.model.get("north")); + this.model.set("east", this.model.get("west")); + } + // Same for when there is a SE point but no NW point + else if ( + this.model.get("south") != null && + this.model.get("east") != null && + isMissingNWPoint + ) { + this.model.set("north", this.model.get("south")); + this.model.set("west", this.model.get("east")); + } + } + + // Validate the coordinate boxes this.validateCoordinates(e); + + //If this model is part of the EML inside the root data package, mark + //the package as changed + if (this.model.get("parentModel")) { + if ( + this.model.get("parentModel").type == "EML" && + _.contains( + MetacatUI.rootDataPackage.models, + this.model.get("parentModel") + ) + ) { + MetacatUI.rootDataPackage.packageModel.set("changed", true); + } + } + + this.validate(); + }, + + /** + * Checks to see if any error messages need to be removed. If not, then it + * performs validation across the row and displays any errors. This id + * called when the user clicks out of an edit box on to the page. + * + * @param {Event} e - The event that triggered this function + * @param {Object} options - Validation options + */ + validate: function (e, options) { + //Query for the EMlGeoCoverageView element that the user is actively + //interacting with + var activeGeoCovEl = $(document.activeElement).parents( + ".eml-geocoverage" + ); + + //If the user is not actively in this view, then exit + if (activeGeoCovEl.length && activeGeoCovEl[0] == this.el) return; + + //If the model is valid, then remove error styling and exit + if (this.model.isValid()) { + this.$(".error").removeClass("error"); + this.$el.removeClass("error"); + this.$(".notification").empty(); + this.model.trigger("valid"); + + return; + } else { + this.showValidation(); + } + }, + + /* + * Resets the error messaging and displays the current error messages for + * this model This function is used by the EML211EditorView during the + * package validation process + */ + showValidation: function () { + this.$(".error").removeClass("error"); + this.$el.removeClass("error"); + this.$(".notification").empty(); + + const errorObj = this.model.validationError; + // Get all of the field keys + const fields = Object.keys(errorObj); + // Get all of the error messages (values). Remove duplicates. + let errorMessages = [...new Set(Object.values(errorObj))]; + // Join the error messages into a single string + errorMessages = errorMessages.join(" "); + + // Highlight the fields that need to be fixed + fields.forEach((field) => { + this.$("[data-attribute='" + field + "']").addClass("error"); + }) + // Show the combined error message + this.$(".notification").text(errorMessages).addClass("error"); + }, + + /** + * Highlight what will be removed when the remove icon is hovered over. + */ + toggleRemoveClass: function () { + this.$el.toggleClass("remove-preview"); + }, + + /** + * Unmark this view as news + */ + notNew: function () { + this.$el.removeClass("new"); + this.isNew = false; + }, + } + ); + + return EMLGeoCoverageView; +}); diff --git a/test/config/tests.json b/test/config/tests.json index a558e0dec..842aa0e71 100644 --- a/test/config/tests.json +++ b/test/config/tests.json @@ -21,6 +21,7 @@ "./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/metadata/eml211/EMLGeoCoverage.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/EMLGeoCoverage.spec.js b/test/js/specs/unit/models/metadata/eml211/EMLGeoCoverage.spec.js new file mode 100644 index 000000000..de3717d30 --- /dev/null +++ b/test/js/specs/unit/models/metadata/eml211/EMLGeoCoverage.spec.js @@ -0,0 +1,162 @@ +define([ + "../../../../../../../../src/js/models/metadata/eml211/EMLGeoCoverage", +], function (EMLGeoCoverage) { + // Configure the Chai assertion library + var should = chai.should(); + var expect = chai.expect; + + describe("EMLGeoCoverage Test Suite", function () { + /* Set up */ + beforeEach(function () { + let testEML = ` + Rhine-Main-Observatory + + 9.0005 + 9.0005 + 50.1600 + 50.1600 + + `; + // remove ALL whitespace + testEML = testEML.replace(/\s/g, ""); + this.testEML = testEML; + }); + + /* Tear down */ + afterEach(function () { + delete this.testEML; + }); + + describe("Initialization", function () { + it("should create a EMLGeoCoverage instance", function () { + new EMLGeoCoverage().should.be.instanceof(EMLGeoCoverage); + }); + }); + + describe("parse()", function () { + it("should parse EML", function () { + + var emlGeoCoverage = new EMLGeoCoverage( + { objectDOM: this.testEML }, + { parse: true } + ); + + emlGeoCoverage + .get("description") + .should.equal("Rhine-Main-Observatory"); + emlGeoCoverage.get("east").should.equal("9.0005"); + emlGeoCoverage.get("north").should.equal("50.1600"); + emlGeoCoverage.get("south").should.equal("50.1600"); + emlGeoCoverage.get("west").should.equal("9.0005"); + }); + }); + + describe("serialize()", function () { + + it("should serialize to XML", function () { + var emlGeoCoverage = new EMLGeoCoverage( + { objectDOM: this.testEML }, + { parse: true } + ); + var xmlString = emlGeoCoverage.serialize(); + xmlString.should.equal(this.testEML); + }); + }); + + describe("validation", function () { + it("should get the status of the coordinates", function () { + var emlGeoCoverage = new EMLGeoCoverage( + { objectDOM: this.testEML }, + { parse: true } + ); + var status = emlGeoCoverage.getCoordinateStatus(); + status.north.isSet.should.equal(true); + status.north.isValid.should.equal(true); + status.east.isSet.should.equal(true); + status.east.isValid.should.equal(true); + status.south.isSet.should.equal(true); + status.south.isValid.should.equal(true); + status.west.isSet.should.equal(true); + status.west.isValid.should.equal(true); + }); + + it("should validate the coordinates", function () { + var emlGeoCoverage = new EMLGeoCoverage( + { objectDOM: this.testEML }, + { parse: true } + ); + var status = emlGeoCoverage.getCoordinateStatus(); + var errors = emlGeoCoverage.generateStatusErrors(status); + expect(errors).to.be.empty; + }); + + it("should give an error if the coordinates are invalid", function () { + var emlGeoCoverage = new EMLGeoCoverage( + { objectDOM: this.testEML }, + { parse: true } + ); + emlGeoCoverage.set("north", "100"); + var errors = emlGeoCoverage.validate(); + errors.north.should.equal( + "The Northwest latitude must be between -90 and 90." + ); + }); + + it("should give an error if the coordinates are missing", function () { + var emlGeoCoverage = new EMLGeoCoverage( + { objectDOM: this.testEML }, + { parse: true } + ); + emlGeoCoverage.set("north", ""); + var errors = emlGeoCoverage.validate(); + errors.north.should.equal("Each coordinate must include a latitude AND longitude."); + }); + + it("should give an error if the north and south coordinates are reversed", function () { + var emlGeoCoverage = new EMLGeoCoverage( + { objectDOM: this.testEML }, + { parse: true } + ); + emlGeoCoverage.set("north", "40"); + emlGeoCoverage.set("south", "50"); + var errors = emlGeoCoverage.validate(); + const msg = "The North latitude must be greater than the South latitude."; + errors.north.should.equal(msg); + errors.south.should.equal(msg); + }); + + it("should give an error if the bounds cross the anti-meridian", function () { + var emlGeoCoverage = new EMLGeoCoverage( + { objectDOM: this.testEML }, + { parse: true } + ); + emlGeoCoverage.set("west", "170"); + emlGeoCoverage.set("east", "-170"); + var errors = emlGeoCoverage.validate(); + errors.west.should.equal("The bounding box cannot cross the anti-meridian."); + errors.east.should.equal("The bounding box cannot cross the anti-meridian."); + }); + + it("should give an error if the bounds contain the north pole", function () { + var emlGeoCoverage = new EMLGeoCoverage( + { objectDOM: this.testEML }, + { parse: true } + ); + emlGeoCoverage.set("north", "90"); + var errors = emlGeoCoverage.validate(); + errors.north.should.equal("The bounding box cannot contain the North or South pole."); + }); + + it("should give an error if the bounds contain the south pole", function () { + var emlGeoCoverage = new EMLGeoCoverage( + { objectDOM: this.testEML }, + { parse: true } + ); + emlGeoCoverage.set("south", "-90"); + var errors = emlGeoCoverage.validate(); + errors.south.should.equal("The bounding box cannot contain the North or South pole."); + }); + + }); + }); +}); \ No newline at end of file