diff --git a/docs/api.md b/docs/api.md index dedf186..661612f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -94,8 +94,10 @@ Constructs a new JSONEditor. Set a callback function for custom validation. Available in all modes. On a change of the JSON, the callback function is invoked with the changed data. The function should return - an array with errors (or an empty array when the document is valid). The returned errors must have the following - structure: `{path: Array., message: string}`. Example: + an array with errors or null if there are no errors. The function can also return a `Promise` resolving with + the errors retrieved via an asynchronous validation (like sending a request to a server for validation). + The returned errors must have the following structure: `{path: Array., message: string}`. + Example: ```js var options = { @@ -114,6 +116,8 @@ Constructs a new JSONEditor. } ``` + See also option `schema` for JSON schema validation. + - `{boolean} escapeUnicode` If true, unicode characters are escaped and displayed as their hexadecimal code (like `\u260E`) instead of of the character itself (like `☎`). False by default. @@ -146,6 +150,8 @@ Constructs a new JSONEditor. See [http://json-schema.org/](http://json-schema.org/) for more information. + See also option `onValidate` for custom validation. + - `{Object} schemaRefs` Schemas that are referenced using the `$ref` property from the JSON schema that are set in the `schema` option, diff --git a/examples/18_custom_validation.html b/examples/18_custom_validation.html index 1bc3d59..9e9641e 100644 --- a/examples/18_custom_validation.html +++ b/examples/18_custom_validation.html @@ -54,7 +54,7 @@ // - at lease one member of the team must be adult var errors = []; - if (Array.isArray(json.team)) { + if (json && Array.isArray(json.team)) { // check whether each team member has name and age filled in correctly json.team.forEach(function (member, index) { if (typeof member !== 'object') { diff --git a/examples/19_custom_validation_async.html b/examples/19_custom_validation_async.html new file mode 100644 index 0000000..8cf4670 --- /dev/null +++ b/examples/19_custom_validation_async.html @@ -0,0 +1,90 @@ + + + + JSONEditor | Custom validation (asynchronous) + + + + + + + +

Asynchronous custom validation

+

+ This example demonstrates how to run asynchronous custom validation on a JSON object. + The names are checked asynchronously and the results "come in" half a second later. + Known names in this example are 'Joe', 'Harry', 'Megan'. For other names, a validation error will be displayed. +

+ +
+ + + + diff --git a/src/js/textmode.js b/src/js/textmode.js index 6c5027e..7f39df2 100644 --- a/src/js/textmode.js +++ b/src/js/textmode.js @@ -90,6 +90,7 @@ textmode.create = function (container, options) { this.aceEditor = undefined; // ace code editor this.textarea = undefined; // plain text editor (fallback when Ace is not available) this.validateSchema = null; + this.validationSequence = 0; this.annotations = []; // create a debounced validate function @@ -697,19 +698,8 @@ textmode.updateText = function(jsonText) { * Throws an exception when no JSON schema is configured */ textmode.validate = function () { - var me = this; - // clear all current errors - if (this.dom.validationErrors) { - this.dom.validationErrors.parentNode.removeChild(this.dom.validationErrors); - this.dom.validationErrors = null; - this.dom.additinalErrorsIndication.style.display = 'none'; - - this.content.style.marginBottom = ''; - this.content.style.paddingBottom = ''; - } - var doValidate = false; - var errors = []; + var schemaErrors = []; var json; try { json = this.get(); // this can fail when there is no valid json @@ -725,19 +715,50 @@ textmode.validate = function () { if (this.validateSchema) { var valid = this.validateSchema(json); if (!valid) { - errors = this.validateSchema.errors.map(function (error) { + schemaErrors = this.validateSchema.errors.map(function (error) { return util.improveSchemaError(error); }); } } - // execute custom validation - if (this.options.onValidate) { - try { - var customValidationPathErrors = this.options.onValidate(json); + // execute custom validation and after than merge and render all errors + this.validationSequence++; + var me = this; + var seq = this.validationSequence; + this._validateCustom(json) + .then(function (customValidationErrors) { + // only apply when there was no other validation started whilst resolving async results + if (seq === me.validationSequence) { + var errors = schemaErrors.concat(customValidationErrors || []); + me._renderValidationErrors(errors); + } + }) + .catch(function (err) { + console.error(err); + }); + } + else { + this._renderValidationErrors([]); + } +}; +/** + * Execute custom validation if configured. + * + * Returns a promise resolving with the custom errors (or nothing). + */ +textmode._validateCustom = function (json) { + if (this.options.onValidate) { + try { + var customValidateResults = this.options.onValidate(json); + + var resultPromise = util.isPromise(customValidateResults) + ? customValidateResults + : Promise.resolve(customValidateResults); + + return resultPromise.then(function (customValidationPathErrors) { if (Array.isArray(customValidationPathErrors)) { - var customValidationErrors = customValidationPathErrors + return customValidationPathErrors .filter(function (error) { var valid = util.isValidValidationError(error); @@ -756,16 +777,32 @@ textmode.validate = function () { message: error.message } }); - - errors = errors.concat(customValidationErrors); } - } - catch (err) { - console.error(err); - } + else { + return null; + } + }); + } + catch (err) { + return Promise.reject(err); } } + return Promise.resolve(null); +}; + +textmode._renderValidationErrors = function(errors) { + // clear all current errors + if (this.dom.validationErrors) { + this.dom.validationErrors.parentNode.removeChild(this.dom.validationErrors); + this.dom.validationErrors = null; + this.dom.additinalErrorsIndication.style.display = 'none'; + + this.content.style.marginBottom = ''; + this.content.style.paddingBottom = ''; + } + + // render the new errors if (errors.length > 0) { if (this.aceEditor) { var jsonText = this.getText(); @@ -775,9 +812,9 @@ textmode.validate = function () { acc.push(curr.dataPath); } return acc; - }, errorPaths); - var errorLocations = util.getPositionForPath(jsonText, errorPaths); - me.annotations = errorLocations.map(function (errLoc) { + }, errorPaths); + var errorLocations = util.getPositionForPath(jsonText, errorPaths); + this.annotations = errorLocations.map(function (errLoc) { var validationErrors = errors.filter(function(err){ return err.dataPath === errLoc.path; }); var message = validationErrors.map(function(err) { return err.message }).join('\n'); if (message) { @@ -792,7 +829,7 @@ textmode.validate = function () { return {}; }); - me._refreshAnnotations(); + this._refreshAnnotations(); } else { var validationErrors = document.createElement('div'); @@ -828,18 +865,18 @@ textmode.validate = function () { } } else { if (this.aceEditor) { - me.annotations = []; - me._refreshAnnotations(); + this.annotations = []; + this._refreshAnnotations(); } } - if (me.options.statusBar) { + if (this.options.statusBar) { var showIndication = !!errors.length; - me.validationErrorIndication.validationErrorIcon.style.display = showIndication ? 'inline' : 'none'; - me.validationErrorIndication.validationErrorCount.style.display = showIndication ? 'inline' : 'none'; + this.validationErrorIndication.validationErrorIcon.style.display = showIndication ? 'inline' : 'none'; + this.validationErrorIndication.validationErrorCount.style.display = showIndication ? 'inline' : 'none'; if (showIndication) { - me.validationErrorIndication.validationErrorCount.innerText = errors.length; - me.validationErrorIndication.validationErrorIcon.title = errors.length + ' schema validation error(s) found'; + this.validationErrorIndication.validationErrorCount.innerText = errors.length; + this.validationErrorIndication.validationErrorIcon.title = errors.length + ' schema validation error(s) found'; } } diff --git a/src/js/treemode.js b/src/js/treemode.js index afe66ff..dc2e38b 100644 --- a/src/js/treemode.js +++ b/src/js/treemode.js @@ -67,6 +67,7 @@ treemode.create = function (container, options) { nodes: [] }; this.validateSchema = null; // will be set in .setSchema(schema) + this.validationSequence = 0; this.errorNodes = []; this.node = null; @@ -512,13 +513,6 @@ treemode._onChange = function () { * Throws an exception when no JSON schema is configured */ treemode.validate = function () { - // clear all current errors - if (this.errorNodes) { - this.errorNodes.forEach(function (node) { - node.setError(null); - }); - } - var root = this.node; if (!root) { // TODO: this should be redundant but is needed on mode switch return; @@ -551,36 +545,58 @@ treemode.validate = function () { } } - // execute custom validation - var customValidationErrors = this._validateCustom(json); + // execute custom validation and after than merge and render all errors + this.validationSequence++; + var me = this; + var seq = this.validationSequence; + this._validateCustom(json) + .then(function (customValidationErrors) { + // only apply when there was no other validation started whilst resolving async results + if (seq === me.validationSequence) { + var errorNodes = [].concat(duplicateErrors, schemaErrors, customValidationErrors || []); + me._renderValidationErrors(errorNodes); + } + }) + .catch(function (err) { + console.error(err); + }); +}; - var errorNodes = duplicateErrors.concat(schemaErrors, customValidationErrors); +treemode._renderValidationErrors = function (errorNodes) { + // clear all current errors + if (this.errorNodes) { + this.errorNodes.forEach(function (node) { + node.setError(null); + }); + } + + // render the new errors var parentPairs = errorNodes .reduce(function (all, entry) { - return entry.node - .findParents() - .filter(function (parent) { - return !all.some(function (pair) { - return pair[0] === parent; - }); - }) - .map(function (parent) { - return [parent, entry.node]; - }) - .concat(all); + return entry.node + .findParents() + .filter(function (parent) { + return !all.some(function (pair) { + return pair[0] === parent; + }); + }) + .map(function (parent) { + return [parent, entry.node]; + }) + .concat(all); }, []); this.errorNodes = parentPairs .map(function (pair) { - return { - node: pair[0], - child: pair[1], - error: { - message: pair[0].type === 'object' - ? 'Contains invalid properties' // object - : 'Contains invalid items' // array - } - }; + return { + node: pair[0], + child: pair[1], + error: { + message: pair[0].type === 'object' + ? 'Contains invalid properties' // object + : 'Contains invalid items' // array + } + }; }) .concat(errorNodes) .map(function setError (entry) { @@ -590,55 +606,66 @@ treemode.validate = function () { }; /** - * execute custom validation if configured. Returns an empty array if not. + * Execute custom validation if configured. + * + * Returns a promise resolving with the custom errors (or nothing). */ treemode._validateCustom = function (json) { try { if (this.options.onValidate) { var root = this.node; - var customValidationPathErrors = this.options.onValidate(json); + var customValidateResults = this.options.onValidate(json); - if (Array.isArray(customValidationPathErrors)) { - return customValidationPathErrors - .filter(function (error) { - var valid = util.isValidValidationError(error); + var resultPromise = util.isPromise(customValidateResults) + ? customValidateResults + : Promise.resolve(customValidateResults); - if (!valid) { - console.warn('Ignoring a custom validation error with invalid structure. ' + - 'Expected structure: {path: [...], message: "..."}. ' + - 'Actual error:', error); - } + return resultPromise.then(function (customValidationPathErrors) { + if (Array.isArray(customValidationPathErrors)) { + return customValidationPathErrors + .filter(function (error) { + var valid = util.isValidValidationError(error); - return valid; - }) - .map(function (error) { - var node; - try { - node = (error && error.path) ? root.findNodeByPath(error.path) : null - } - catch (err) { - // stay silent here, we throw a generic warning if no node is found - } - if (!node) { - console.warn('Ignoring validation error: node not found. Path:', error.path, 'Error:', error); - } + if (!valid) { + console.warn('Ignoring a custom validation error with invalid structure. ' + + 'Expected structure: {path: [...], message: "..."}. ' + + 'Actual error:', error); + } - return { - node: node, - error: error - } - }) - .filter(function (entry) { - return entry && entry.node && entry.error && entry.error.message - }); - } + return valid; + }) + .map(function (error) { + var node; + try { + node = (error && error.path) ? root.findNodeByPath(error.path) : null + } + catch (err) { + // stay silent here, we throw a generic warning if no node is found + } + if (!node) { + console.warn('Ignoring validation error: node not found. Path:', error.path, 'Error:', error); + } + + return { + node: node, + error: error + } + }) + .filter(function (entry) { + return entry && entry.node && entry.error && entry.error.message + }); + } + else { + return null; + } + }); } } catch (err) { - console.error(err); + return Promise.reject(err); } - return []; + return Promise.resolve(null); }; /** diff --git a/src/js/util.js b/src/js/util.js index ede0d4c..bce49c8 100644 --- a/src/js/util.js +++ b/src/js/util.js @@ -758,6 +758,15 @@ exports.improveSchemaError = function (error) { return error; }; +/** + * Test whether something is a Promise + * @param {*} object + * @returns {boolean} Returns true when object is a promise, false otherwise + */ +exports.isPromise = function (object) { + return object && typeof object.then === 'function' && typeof object.catch === 'function'; +}; + /** * Test whether a custom validation error has the correct structure * @param {*} validationError The error to be checked.