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.
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.<string | number>, 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.<string | number>, 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,

View File

@ -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') {

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.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';
}
}

View File

@ -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);
};
/**

View File

@ -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.