From d387de366a559057f9ff421f74b0fd49bbc98155 Mon Sep 17 00:00:00 2001 From: Meir Rotstein Date: Sun, 12 Aug 2018 13:41:02 +0300 Subject: [PATCH] Show validation errors inline in code mode (#560) * util.getPositionForPath * utils.getPositionForPath - allow multiple paths * code mode - show validation errors on gutter * show all validation errors with scroll indication on text mode * import json-source-map in favor of getting validation errors location * revert dist change * add statusbar indication for validation errors * reset valodation errors indication + code clean * change display indication for validationErrorIndication * extend schema validatin example with additional errors to demonstrate recent changes * minor css change --- examples/07_json_schema_validation.html | 21 +++- package.json | 1 + src/css/jsoneditor.css | 54 ++++++++ src/css/statusbar.css | 12 ++ src/js/textmode.js | 160 ++++++++++++++++++------ src/js/util.js | 38 ++++++ 6 files changed, 243 insertions(+), 43 deletions(-) diff --git a/examples/07_json_schema_validation.html b/examples/07_json_schema_validation.html index e54e3b4..045a2b1 100644 --- a/examples/07_json_schema_validation.html +++ b/examples/07_json_schema_validation.html @@ -43,6 +43,9 @@ "gender": { "enum": ["male", "female"] }, + "availableToHire": { + "type": "boolean" + }, "age": { "description": "Age in years", "type": "integer", @@ -58,12 +61,20 @@ var job = { "title": "Job description", "type": "object", + "required": ["address"], "properties": { "company": { "type": "string" }, "role": { "type": "string" + }, + "address": { + "type": "string" + }, + "salary": { + "type": "number", + "minimum": 120 } } }; @@ -72,16 +83,20 @@ firstName: 'John', lastName: 'Doe', gender: null, - age: 28, + age: "28", + availableToHire: 1, job: { company: 'freelance', - role: 'developer' + role: 'developer', + salary: 100 } }; var options = { schema: schema, - schemaRefs: {"job": job} + schemaRefs: {"job": job}, + mode: 'tree', + modes: ['code', 'text', 'tree'] }; // create the editor diff --git a/package.json b/package.json index b043918..8b450de 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "brace": "0.11.0", "javascript-natural-sort": "0.7.1", "jmespath": "0.15.0", + "json-source-map": "^0.4.0", "mobius1-selectr": "2.4.1", "picomodal": "3.0.0" }, diff --git a/src/css/jsoneditor.css b/src/css/jsoneditor.css index 48753d2..d5a28d0 100644 --- a/src/css/jsoneditor.css +++ b/src/css/jsoneditor.css @@ -462,6 +462,34 @@ div.jsoneditor-tree .jsoneditor-schema-error { /* JSON schema errors displayed at the bottom of the editor in mode text and code */ +.jsoneditor .jsoneditor-validation-errors-container { + max-height: 130px; + overflow-y: auto; +} + +.jsoneditor .jsoneditor-additional-errors { + position: absolute; + margin: auto; + bottom: 31px; + left: calc(50% - 92px); + color: #808080; + background-color: #ebebeb; + padding: 7px 15px; + border-radius: 8px; +} + +.jsoneditor .jsoneditor-additional-errors.visible{ + visibility: visible; + opacity: 1; + transition: opacity 2s linear; +} + +.jsoneditor .jsoneditor-additional-errors.hidden{ + visibility: hidden; + opacity: 0; + transition: visibility 0s 2s, opacity 2s linear; +} + .jsoneditor .jsoneditor-text-errors { width: 100%; border-collapse: collapse; @@ -483,3 +511,29 @@ div.jsoneditor-tree .jsoneditor-schema-error { background: url('./img/jsoneditor-icons.svg') -168px -48px; } +.fadein { + -webkit-animation: fadein .3s; + animation: fadein .3s; + -moz-animation: fadein .3s; + -o-animation: fadein .3s; +} + +@-webkit-keyframes fadein { + 0% {opacity: 0} + 100% {opacity: 1} +} + +@-moz-keyframes fadein{ + 0% {opacity: 0} + 100% {opacity: 1} +} + +@keyframes fadein { + 0% {opacity: 0} + 100% {opacity: 1} +} + +@-o-keyframes fadein { + 0% {opacity: 0} + 100% {opacity: 1} +} \ No newline at end of file diff --git a/src/css/statusbar.css b/src/css/statusbar.css index 27d5dd1..73e097b 100644 --- a/src/css/statusbar.css +++ b/src/css/statusbar.css @@ -22,3 +22,15 @@ div.jsoneditor-statusbar > .jsoneditor-curserinfo-val { div.jsoneditor-statusbar > .jsoneditor-curserinfo-count { margin-left: 4px; } +div.jsoneditor-statusbar > .jsoneditor-validation-error-icon { + float: right; + width: 24px; + height: 24px; + padding: 0; + margin-top: 1px; + background: url("img/jsoneditor-icons.svg") -168px -48px; +} +div.jsoneditor-statusbar > .jsoneditor-validation-error-count { + float: right; + margin: 0 4px 0 0; +} diff --git a/src/js/textmode.js b/src/js/textmode.js index 62ad9c8..294e81d 100644 --- a/src/js/textmode.js +++ b/src/js/textmode.js @@ -7,8 +7,6 @@ var util = require('./util'); // create a mixin with the functions for text mode var textmode = {}; -var MAX_ERRORS = 3; // maximum number of displayed errors at the bottom - var DEFAULT_THEME = 'ace/theme/jsoneditor'; /** @@ -92,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.annotations = []; // create a debounced validate function this._debouncedValidate = util.debounce(this.validate.bind(this), this.DEBOUNCE_INTERVAL); @@ -189,15 +188,23 @@ textmode.create = function (container, options) { this.content.appendChild(this.editorDom); var aceEditor = _ace.edit(this.editorDom); + var aceSession = aceEditor.getSession(); aceEditor.$blockScrolling = Infinity; aceEditor.setTheme(this.theme); aceEditor.setOptions({ readOnly: isReadOnly }); aceEditor.setShowPrintMargin(false); aceEditor.setFontSize(13); - aceEditor.getSession().setMode('ace/mode/json'); - aceEditor.getSession().setTabSize(this.indentation); - aceEditor.getSession().setUseSoftTabs(true); - aceEditor.getSession().setUseWrapMode(true); + aceSession.setMode('ace/mode/json'); + aceSession.setTabSize(this.indentation); + aceSession.setUseSoftTabs(true); + aceSession.setUseWrapMode(true); + + // replace ace setAnnotations with custom function that also covers jsoneditor annotations + var originalSetAnnotations = aceSession.setAnnotations; + aceSession.setAnnotations = function (annotations) { + originalSetAnnotations.call(this, annotations && annotations.length ? annotations : me.annotations); + }; + aceEditor.commands.bindKey('Ctrl-L', null); // disable Ctrl+L (is used by the browser to select the address bar) aceEditor.commands.bindKey('Command-L', null); // disable Ctrl+L (is used by the browser to select the address bar) this.aceEditor = aceEditor; @@ -257,10 +264,20 @@ textmode.create = function (container, options) { } var validationErrorsContainer = document.createElement('div'); - validationErrorsContainer.className = 'validation-errors-container'; + validationErrorsContainer.className = 'jsoneditor-validation-errors-container'; this.dom.validationErrorsContainer = validationErrorsContainer; this.frame.appendChild(validationErrorsContainer); + var additinalErrorsIndication = document.createElement('div'); + additinalErrorsIndication.style.display = 'none'; + additinalErrorsIndication.className = "jsoneditor-additional-errors fadein"; + additinalErrorsIndication.innerHTML = "Scroll for more ▿"; + this.dom.additinalErrorsIndication = additinalErrorsIndication; + validationErrorsContainer.appendChild(additinalErrorsIndication); + validationErrorsContainer.onscroll = function () { + additinalErrorsIndication.style.display = me.dom.validationErrorsContainer.scrollTop === 0 ? 'block' : 'none'; + } + if (options.statusBar) { util.addClassName(this.content, 'has-status-bar'); @@ -310,6 +327,22 @@ textmode.create = function (container, options) { statusBar.appendChild(countVal); statusBar.appendChild(countLabel); + + var validationErrorIcon = document.createElement('span'); + validationErrorIcon.className = 'jsoneditor-validation-error-icon'; + validationErrorIcon.style.display = 'none'; + + var validationErrorCount = document.createElement('span'); + validationErrorCount.className = 'jsoneditor-validation-error-count'; + validationErrorCount.style.display = 'none'; + + this.validationErrorIndication = { + validationErrorIcon: validationErrorIcon, + validationErrorCount: validationErrorCount + }; + + statusBar.appendChild(validationErrorCount); + statusBar.appendChild(validationErrorIcon); } this.setSchema(this.options.schema, this.options.schemaRefs); @@ -486,6 +519,10 @@ textmode._emitSelectionChange = function () { } } +textmode._refreshAnnotations = function () { + this.aceEditor && this.aceEditor.getSession().setAnnotations(); +} + /** * Destroy the editor. Clean up DOM, event listeners, and web workers. */ @@ -637,7 +674,7 @@ textmode.setText = function(jsonText) { this.onChangeDisabled = false; } // validate JSON schema - this.validate(); + this._debouncedValidate(); }; /** @@ -660,10 +697,12 @@ 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 = ''; @@ -690,40 +729,81 @@ textmode.validate = function () { } } - if (errors.length > 0) { - // limit the number of displayed errors - var limit = errors.length > MAX_ERRORS; - if (limit) { - errors = errors.slice(0, MAX_ERRORS); - var hidden = this.validateSchema.errors.length - MAX_ERRORS; - errors.push('(' + hidden + ' more errors...)') + if (errors.length > 0) { + if (this.aceEditor) { + var jsonText = this.getText(); + var errorPaths = []; + errors.reduce(function(acc, curr) { + if(acc.indexOf(curr.dataPath) === -1) { + acc.push(curr.dataPath); + }; + return acc; + }, errorPaths); + var errorLocations = util.getPositionForPath(jsonText, errorPaths); + me.annotations = errorLocations.map(function (errLoc) { + var validationErrors = errors.filter(function(err){ return err.dataPath === errLoc.path; }); + var validationError = validationErrors.reduce(function(acc, curr) { acc.message += '\n' + curr.message; return acc; }); + if (validationError) { + return { + row: errLoc.line, + column: errLoc.column, + text: "Schema Validation Error: \n" + validationError.message, + type: "warning", + source: "jsoneditor", + } + } + + return {}; + }); + me._refreshAnnotations(); + + } else { + var validationErrors = document.createElement('div'); + validationErrors.innerHTML = '' + + '' + + errors.map(function (error) { + var message; + if (typeof error === 'string') { + message = ''; + } + else { + message = '' + + ''; + } + + return '' + message + '' + }).join('') + + '' + + '
' + error + '
' + error.dataPath + '' + error.message + '
'; + + this.dom.validationErrors = validationErrors; + this.dom.validationErrorsContainer.appendChild(validationErrors); + this.dom.additinalErrorsIndication.title = errors.length + " errors total"; + + if (this.dom.validationErrorsContainer.clientHeight < this.dom.validationErrorsContainer.scrollHeight) { + this.dom.additinalErrorsIndication.style.display = 'block'; + } + + var height = this.dom.validationErrorsContainer.clientHeight + (this.dom.statusBar ? this.dom.statusBar.clientHeight : 0); + // var height = validationErrors.clientHeight + (this.dom.statusBar ? this.dom.statusBar.clientHeight : 0); + this.content.style.marginBottom = (-height) + 'px'; + this.content.style.paddingBottom = height + 'px'; } + } else { + if (this.aceEditor) { + me.annotations = []; + me._refreshAnnotations(); + } + } - var validationErrors = document.createElement('div'); - validationErrors.innerHTML = '' + - '' + - errors.map(function (error) { - var message; - if (typeof error === 'string') { - message = ''; - } - else { - message = '' + - ''; - } - - return '' + message + '' - }).join('') + - '' + - '
' + error + '
' + error.dataPath + '' + error.message + '
'; - - this.dom.validationErrors = validationErrors; - this.dom.validationErrorsContainer.appendChild(validationErrors); - - var height = validationErrors.clientHeight + - (this.dom.statusBar ? this.dom.statusBar.clientHeight : 0); - this.content.style.marginBottom = (-height) + 'px'; - this.content.style.paddingBottom = height + 'px'; + if (me.options.statusBar) { + var showIndication = !!errors.length; + me.validationErrorIndication.validationErrorIcon.style.display = showIndication ? 'inline' : 'none'; + me.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'; + } } // update the height of the ace editor diff --git a/src/js/util.js b/src/js/util.js index eb98c36..fdec576 100644 --- a/src/js/util.js +++ b/src/js/util.js @@ -1,6 +1,7 @@ 'use strict'; var jsonlint = require('./assets/jsonlint/jsonlint'); +var jsonMap = require('json-source-map'); /** * Parse JSON using the parser built-in in the browser. @@ -905,6 +906,43 @@ exports.getIndexForPosition = function(el, row, column) { return -1; } +/** + * Returns location of json paths in certain json string + * @param {String} text json string + * @param {Array} paths array of json paths + * @returns {Array<{path: String, line: Number, row: Number}>} + */ +exports.getPositionForPath = function(text, paths) { + var me = this; + var result = []; + var jsmap; + if (!paths || !paths.length) { + return result; + } + + try { + jsmap = jsonMap.parse(text); + } catch (err) { + return result; + } + + paths.forEach(function (path) { + var pathArr = me.parsePath(path); + var pointerName = pathArr.length ? "/" + pathArr.join("/") : ""; + var pointer = jsmap.pointers[pointerName]; + if (pointer) { + result.push({ + path: path, + line: pointer.key ? pointer.key.line : (pointer.value ? pointer.value.line : 0), + column: pointer.key ? pointer.key.column : (pointer.value ? pointer.value.column : 0) + }); + } + }); + + return result; + +} + if (typeof Element !== 'undefined') { // Polyfill for array remove