Implemented support for async custom validation

This commit is contained in:
jos 2018-08-15 12:21:27 +02:00
parent 620c5e0b89
commit f11f763171
6 changed files with 272 additions and 103 deletions

View File

@ -94,8 +94,10 @@ Constructs a new JSONEditor.
Set a callback function for custom validation. Available in all modes. 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 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 an array with errors or null if there are no errors. The function can also return a `Promise` resolving with
structure: `{path: Array.<string | number>, message: string}`. Example: 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.<string | number>, message: string}`.
Example:
```js ```js
var options = { var options = {
@ -114,6 +116,8 @@ Constructs a new JSONEditor.
} }
``` ```
See also option `schema` for JSON schema validation.
- `{boolean} escapeUnicode` - `{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. 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 [http://json-schema.org/](http://json-schema.org/) for more information.
See also option `onValidate` for custom validation.
- `{Object} schemaRefs` - `{Object} schemaRefs`
Schemas that are referenced using the `$ref` property from the JSON schema that are set in the `schema` option, Schemas that are referenced using the `$ref` property from the JSON schema that are set in the `schema` option,

View File

@ -54,7 +54,7 @@
// - at lease one member of the team must be adult // - at lease one member of the team must be adult
var errors = []; 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 // check whether each team member has name and age filled in correctly
json.team.forEach(function (member, index) { json.team.forEach(function (member, index) {
if (typeof member !== 'object') { if (typeof member !== 'object') {

View File

@ -0,0 +1,90 @@
<!DOCTYPE HTML>
<html>
<head>
<title>JSONEditor | Custom validation (asynchronous)</title>
<link href="../dist/jsoneditor.css" rel="stylesheet" type="text/css">
<script src="../dist/jsoneditor.js"></script>
<style type="text/css">
body {
width: 600px;
font: 11pt sans-serif;
}
#jsoneditor {
width: 100%;
height: 500px;
}
</style>
</head>
<body>
<h1>Asynchronous custom validation</h1>
<p>
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.
</p>
<div id="jsoneditor"></div>
<script>
var json = {
customers: [
{name: 'Joe'},
{name: 'Sarah'},
{name: 'Harry'},
]
};
var options = {
mode: 'tree',
modes: ['code', 'text', 'tree'],
onValidate: function (json) {
// in this validation function we fake sending a request to a server
// to validate the existence of customers
if (json && Array.isArray(json.customers)) {
return Promise
.all(json.customers.map(function (customer, index) {
return isExistingCustomer(customer && customer.name).then(function (exists) {
if (!exists) {
return {
path: ['customers', index],
message: 'Customer ' + customer.name + ' doesn\'t exist in our database'
}
}
else {
return null;
}
});
}))
.then(function (errors) {
return errors.filter(function (error) {
return error != null;
})
});
}
else {
return null;
}
}
};
// create the editor
var container = document.getElementById('jsoneditor');
var editor = new JSONEditor(container, options, json);
editor.expandAll();
// this function fakes a request (asynchronous) to a server to validate the existence of a customer
function isExistingCustomer (customerName) {
return new Promise(function (resolve, reject) {
setTimeout(function () {
var customers = ['Joe', 'Harry', 'Megan'];
var exists = customers.indexOf(customerName) !== -1;
resolve(exists);
}, 500);
})
}
</script>
</body>
</html>

View File

@ -90,6 +90,7 @@ textmode.create = function (container, options) {
this.aceEditor = undefined; // ace code editor this.aceEditor = undefined; // ace code editor
this.textarea = undefined; // plain text editor (fallback when Ace is not available) this.textarea = undefined; // plain text editor (fallback when Ace is not available)
this.validateSchema = null; this.validateSchema = null;
this.validationSequence = 0;
this.annotations = []; this.annotations = [];
// create a debounced validate function // create a debounced validate function
@ -697,19 +698,8 @@ textmode.updateText = function(jsonText) {
* Throws an exception when no JSON schema is configured * Throws an exception when no JSON schema is configured
*/ */
textmode.validate = function () { 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 doValidate = false;
var errors = []; var schemaErrors = [];
var json; var json;
try { try {
json = this.get(); // this can fail when there is no valid json json = this.get(); // this can fail when there is no valid json
@ -725,19 +715,50 @@ textmode.validate = function () {
if (this.validateSchema) { if (this.validateSchema) {
var valid = this.validateSchema(json); var valid = this.validateSchema(json);
if (!valid) { if (!valid) {
errors = this.validateSchema.errors.map(function (error) { schemaErrors = this.validateSchema.errors.map(function (error) {
return util.improveSchemaError(error); return util.improveSchemaError(error);
}); });
} }
} }
// execute custom validation // execute custom validation and after than merge and render all errors
if (this.options.onValidate) { this.validationSequence++;
try { var me = this;
var customValidationPathErrors = this.options.onValidate(json); 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)) { if (Array.isArray(customValidationPathErrors)) {
var customValidationErrors = customValidationPathErrors return customValidationPathErrors
.filter(function (error) { .filter(function (error) {
var valid = util.isValidValidationError(error); var valid = util.isValidValidationError(error);
@ -756,16 +777,32 @@ textmode.validate = function () {
message: error.message message: error.message
} }
}); });
errors = errors.concat(customValidationErrors);
} }
} else {
catch (err) { return null;
console.error(err); }
} });
}
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 (errors.length > 0) {
if (this.aceEditor) { if (this.aceEditor) {
var jsonText = this.getText(); var jsonText = this.getText();
@ -775,9 +812,9 @@ textmode.validate = function () {
acc.push(curr.dataPath); acc.push(curr.dataPath);
} }
return acc; return acc;
}, errorPaths); }, errorPaths);
var errorLocations = util.getPositionForPath(jsonText, errorPaths); var errorLocations = util.getPositionForPath(jsonText, errorPaths);
me.annotations = errorLocations.map(function (errLoc) { this.annotations = errorLocations.map(function (errLoc) {
var validationErrors = errors.filter(function(err){ return err.dataPath === errLoc.path; }); var validationErrors = errors.filter(function(err){ return err.dataPath === errLoc.path; });
var message = validationErrors.map(function(err) { return err.message }).join('\n'); var message = validationErrors.map(function(err) { return err.message }).join('\n');
if (message) { if (message) {
@ -792,7 +829,7 @@ textmode.validate = function () {
return {}; return {};
}); });
me._refreshAnnotations(); this._refreshAnnotations();
} else { } else {
var validationErrors = document.createElement('div'); var validationErrors = document.createElement('div');
@ -828,18 +865,18 @@ textmode.validate = function () {
} }
} else { } else {
if (this.aceEditor) { if (this.aceEditor) {
me.annotations = []; this.annotations = [];
me._refreshAnnotations(); this._refreshAnnotations();
} }
} }
if (me.options.statusBar) { if (this.options.statusBar) {
var showIndication = !!errors.length; var showIndication = !!errors.length;
me.validationErrorIndication.validationErrorIcon.style.display = showIndication ? 'inline' : 'none'; this.validationErrorIndication.validationErrorIcon.style.display = showIndication ? 'inline' : 'none';
me.validationErrorIndication.validationErrorCount.style.display = showIndication ? 'inline' : 'none'; this.validationErrorIndication.validationErrorCount.style.display = showIndication ? 'inline' : 'none';
if (showIndication) { if (showIndication) {
me.validationErrorIndication.validationErrorCount.innerText = errors.length; this.validationErrorIndication.validationErrorCount.innerText = errors.length;
me.validationErrorIndication.validationErrorIcon.title = errors.length + ' schema validation error(s) found'; this.validationErrorIndication.validationErrorIcon.title = errors.length + ' schema validation error(s) found';
} }
} }

View File

@ -67,6 +67,7 @@ treemode.create = function (container, options) {
nodes: [] nodes: []
}; };
this.validateSchema = null; // will be set in .setSchema(schema) this.validateSchema = null; // will be set in .setSchema(schema)
this.validationSequence = 0;
this.errorNodes = []; this.errorNodes = [];
this.node = null; this.node = null;
@ -512,13 +513,6 @@ treemode._onChange = function () {
* Throws an exception when no JSON schema is configured * Throws an exception when no JSON schema is configured
*/ */
treemode.validate = function () { treemode.validate = function () {
// clear all current errors
if (this.errorNodes) {
this.errorNodes.forEach(function (node) {
node.setError(null);
});
}
var root = this.node; var root = this.node;
if (!root) { // TODO: this should be redundant but is needed on mode switch if (!root) { // TODO: this should be redundant but is needed on mode switch
return; return;
@ -551,36 +545,58 @@ treemode.validate = function () {
} }
} }
// execute custom validation // execute custom validation and after than merge and render all errors
var customValidationErrors = this._validateCustom(json); 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 var parentPairs = errorNodes
.reduce(function (all, entry) { .reduce(function (all, entry) {
return entry.node return entry.node
.findParents() .findParents()
.filter(function (parent) { .filter(function (parent) {
return !all.some(function (pair) { return !all.some(function (pair) {
return pair[0] === parent; return pair[0] === parent;
}); });
}) })
.map(function (parent) { .map(function (parent) {
return [parent, entry.node]; return [parent, entry.node];
}) })
.concat(all); .concat(all);
}, []); }, []);
this.errorNodes = parentPairs this.errorNodes = parentPairs
.map(function (pair) { .map(function (pair) {
return { return {
node: pair[0], node: pair[0],
child: pair[1], child: pair[1],
error: { error: {
message: pair[0].type === 'object' message: pair[0].type === 'object'
? 'Contains invalid properties' // object ? 'Contains invalid properties' // object
: 'Contains invalid items' // array : 'Contains invalid items' // array
} }
}; };
}) })
.concat(errorNodes) .concat(errorNodes)
.map(function setError (entry) { .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) { treemode._validateCustom = function (json) {
try { try {
if (this.options.onValidate) { if (this.options.onValidate) {
var root = this.node; var root = this.node;
var customValidationPathErrors = this.options.onValidate(json); var customValidateResults = this.options.onValidate(json);
if (Array.isArray(customValidationPathErrors)) { var resultPromise = util.isPromise(customValidateResults)
return customValidationPathErrors ? customValidateResults
.filter(function (error) { : Promise.resolve(customValidateResults);
var valid = util.isValidValidationError(error);
if (!valid) { return resultPromise.then(function (customValidationPathErrors) {
console.warn('Ignoring a custom validation error with invalid structure. ' + if (Array.isArray(customValidationPathErrors)) {
'Expected structure: {path: [...], message: "..."}. ' + return customValidationPathErrors
'Actual error:', error); .filter(function (error) {
} var valid = util.isValidValidationError(error);
return valid; if (!valid) {
}) console.warn('Ignoring a custom validation error with invalid structure. ' +
.map(function (error) { 'Expected structure: {path: [...], message: "..."}. ' +
var node; 'Actual error:', error);
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 { return valid;
node: node, })
error: error .map(function (error) {
} var node;
}) try {
.filter(function (entry) { node = (error && error.path) ? root.findNodeByPath(error.path) : null
return entry && entry.node && entry.error && entry.error.message }
}); 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) { catch (err) {
console.error(err); return Promise.reject(err);
} }
return []; return Promise.resolve(null);
}; };
/** /**

View File

@ -758,6 +758,15 @@ exports.improveSchemaError = function (error) {
return 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 * Test whether a custom validation error has the correct structure
* @param {*} validationError The error to be checked. * @param {*} validationError The error to be checked.