diff --git a/HISTORY.md b/HISTORY.md index 4e70355..4e62aab 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -3,6 +3,11 @@ https://github.com/josdejong/jsoneditor +## not yet released, version 5.23.0 + +- Implemented support for custom validation using a new `onValidate` callback. + + ## 2018-08-13, version 5.22.0 - Implemented `onEvent` callback triggered when an event occurs in a JSON diff --git a/docs/api.md b/docs/api.md index de2f12e..dedf186 100644 --- a/docs/api.md +++ b/docs/api.md @@ -89,6 +89,31 @@ Constructs a new JSONEditor. Set a callback function triggered right after the mode is changed by the user. Only applicable when the mode can be changed by the user (i.e. when option `modes` is set). +- `{function} onValidate(json)` + + 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: + + ```js + var options = { + onValidate: function (json) { + var errors = []; + + if (json && json.customer && !json.customer.address) { + errors.push({ + path: ['customer'], + message: 'Required property "address" missing.' + }); + } + + return errors; + } + } + ``` + - `{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. diff --git a/examples/18_custom_validation.html b/examples/18_custom_validation.html new file mode 100644 index 0000000..1bc3d59 --- /dev/null +++ b/examples/18_custom_validation.html @@ -0,0 +1,110 @@ + + + + JSONEditor | Custom validation + + + + + + + +

Custom validation

+

+ This example demonstrates how to run custom validation on a JSON object. + The validation is available in all modes. +

+ +
+ + + + diff --git a/src/js/JSONEditor.js b/src/js/JSONEditor.js index d7fe84e..65fdb84 100644 --- a/src/js/JSONEditor.js +++ b/src/js/JSONEditor.js @@ -153,9 +153,10 @@ JSONEditor.prototype.DEBOUNCE_INTERVAL = 150; JSONEditor.VALID_OPTIONS = [ 'ajv', 'schema', 'schemaRefs','templates', - 'ace', 'theme','autocomplete', + 'ace', 'theme', 'autocomplete', 'onChange', 'onChangeJSON', 'onChangeText', - 'onEditable', 'onError', 'onEvent', 'onModeChange', 'onSelectionChange', 'onTextSelectionChange', + 'onEditable', 'onError', 'onEvent', 'onModeChange', 'onValidate', + 'onSelectionChange', 'onTextSelectionChange', 'escapeUnicode', 'history', 'search', 'mode', 'modes', 'name', 'indentation', 'sortObjectKeys', 'navigationBar', 'statusBar', 'languages', 'language' ]; diff --git a/src/js/textmode.js b/src/js/textmode.js index 294e81d..1568558 100644 --- a/src/js/textmode.js +++ b/src/js/textmode.js @@ -720,12 +720,49 @@ textmode.validate = function () { } // only validate the JSON when parsing the JSON succeeded - if (doValidate && this.validateSchema) { - var valid = this.validateSchema(json); - if (!valid) { - errors = this.validateSchema.errors.map(function (error) { - return util.improveSchemaError(error); - }); + if (doValidate) { + // execute JSON schema validation (ajv) + if (this.validateSchema) { + var valid = this.validateSchema(json); + if (!valid) { + errors = this.validateSchema.errors.map(function (error) { + return util.improveSchemaError(error); + }); + } + } + + // execute custom validation + if (this.options.onValidate) { + try { + var customValidationPathErrors = this.options.onValidate(json); + + if (Array.isArray(customValidationPathErrors)) { + var customValidationErrors = customValidationPathErrors + .filter(function (error) { + var valid = util.isValidValidationError(error); + + if (!valid) { + console.warn('Ignoring a custom validation error with invalid structure. ' + + 'Expected structure: {path: [...], message: "..."}. ' + + 'Actual error:', error); + } + + return valid; + }) + .map(function (error) { + // change data structure into the structure matching the JSON schema errors + return { + dataPath: util.stringifyPath(error.path), + message: error.message + } + }); + + errors = errors.concat(customValidationErrors); + } + } + catch (err) { + console.error(err); + } } } @@ -736,7 +773,7 @@ textmode.validate = function () { errors.reduce(function(acc, curr) { if(acc.indexOf(curr.dataPath) === -1) { acc.push(curr.dataPath); - }; + } return acc; }, errorPaths); var errorLocations = util.getPositionForPath(jsonText, errorPaths); diff --git a/src/js/treemode.js b/src/js/treemode.js index 8faa487..afe66ff 100644 --- a/src/js/treemode.js +++ b/src/js/treemode.js @@ -524,13 +524,15 @@ treemode.validate = function () { return; } + var json = root.getValue(); + // check for duplicate keys var duplicateErrors = root.validate(); - // validate the JSON + // execute JSON schema validation var schemaErrors = []; if (this.validateSchema) { - var valid = this.validateSchema(root.getValue()); + var valid = this.validateSchema(json); if (!valid) { // apply all new errors schemaErrors = this.validateSchema.errors @@ -549,7 +551,10 @@ treemode.validate = function () { } } - var errorNodes = duplicateErrors.concat(schemaErrors); + // execute custom validation + var customValidationErrors = this._validateCustom(json); + + var errorNodes = duplicateErrors.concat(schemaErrors, customValidationErrors); var parentPairs = errorNodes .reduce(function (all, entry) { return entry.node @@ -584,6 +589,58 @@ treemode.validate = function () { }); }; +/** + * execute custom validation if configured. Returns an empty array if not. + */ +treemode._validateCustom = function (json) { + try { + if (this.options.onValidate) { + var root = this.node; + var customValidationPathErrors = this.options.onValidate(json); + + if (Array.isArray(customValidationPathErrors)) { + return customValidationPathErrors + .filter(function (error) { + var valid = util.isValidValidationError(error); + + if (!valid) { + console.warn('Ignoring a custom validation error with invalid structure. ' + + 'Expected structure: {path: [...], message: "..."}. ' + + 'Actual error:', 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); + } + + return { + node: node, + error: error + } + }) + .filter(function (entry) { + return entry && entry.node && entry.error && entry.error.message + }); + } + } + } + catch (err) { + console.error(err); + } + + return []; +}; + /** * Refresh the rendered contents */ diff --git a/src/js/util.js b/src/js/util.js index 4a42594..ede0d4c 100644 --- a/src/js/util.js +++ b/src/js/util.js @@ -716,6 +716,19 @@ exports.parsePath = function parsePath(jsonPath) { return [prop].concat(parsePath(remainder)) }; +/** + * Stringify an array with a path in a JSON path like '.items[3].name' + * @param {Array.} path + * @returns {string} + */ +exports.stringifyPath = function stringifyPath(path) { + return path + .map(function (p) { + return typeof p === 'number' ? ('[' + p + ']') : ('.' + p); + }) + .join(''); +}; + /** * Improve the error message of a JSON schema error * @param {Object} error @@ -745,6 +758,17 @@ exports.improveSchemaError = function (error) { return error; }; +/** + * Test whether a custom validation error has the correct structure + * @param {*} validationError The error to be checked. + * @returns {boolean} Returns true if the structure is ok, false otherwise + */ +exports.isValidValidationError = function (validationError) { + return typeof validationError === 'object' && + Array.isArray(validationError.path) && + typeof validationError.message === 'string'; +}; + /** * Test whether the child rect fits completely inside the parent rect. * @param {ClientRect} parent