diff --git a/.gitignore b/.gitignore index 21f5caf..6d062db 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,3 @@ downloads node_modules *.zip npm-debug.log -yarn.lock diff --git a/examples/_07_json_schema_validation.html b/examples/07_json_schema_validation.html similarity index 92% rename from examples/_07_json_schema_validation.html rename to examples/07_json_schema_validation.html index cbee05a..07d8f0c 100644 --- a/examples/_07_json_schema_validation.html +++ b/examples/07_json_schema_validation.html @@ -59,12 +59,14 @@ } var options = { - schema: schema + modes: ['code', 'tree'] } // create the editor var container = document.getElementById('jsoneditor') - var editor = jsoneditor(container, options, json) + var editor = jsoneditor(container, options) + editor.setSchema(schema) + editor.set(json) diff --git a/examples/08_custom_ace.html b/examples/08_custom_ace.html index f5df411..a625f08 100644 --- a/examples/08_custom_ace.html +++ b/examples/08_custom_ace.html @@ -35,7 +35,7 @@ var container = document.getElementById('jsoneditor') var options = { mode: 'code', - onLoadAce: function (aceEditor, container, options) { + onLoadAce: function (aceEditor, container) { // we can adjust configuration of the ace editor, // or create a completely new instance of ace editor. diff --git a/src/components/Ace.js b/src/components/Ace.js new file mode 100644 index 0000000..6e45ff1 --- /dev/null +++ b/src/components/Ace.js @@ -0,0 +1,99 @@ +import { h, Component } from 'preact' +import ace from '../assets/ace' + +/** + * Usage: + * + * + * + */ +export default class Ace extends Component { + constructor (props) { + super(props) + + this.aceEditor = null + } + + render (props, state) { + return h('div', {id: this.id, class: 'jsoneditor-code'}) + } + + shouldComponentUpdate () { + // always prevent rerendering, that would destroy the DOM of the Ace editor + return false + } + + componentDidMount () { + const container = this.base + + // use ace from bundle, and if not available + // try to use from options or else from global + const _ace = ace || this.props.ace || window['ace'] + + let aceEditor = null + if (_ace && _ace.edit) { + // create ace editor + aceEditor = _ace.edit(container) + + // bundle and load jsoneditor theme for ace editor + require('../assets/ace/theme-jsoneditor') + + // configure ace editor + aceEditor.$blockScrolling = Infinity + aceEditor.setTheme('ace/theme/jsoneditor') + aceEditor.setShowPrintMargin(false) + aceEditor.setFontSize(13) + aceEditor.getSession().setMode('ace/mode/json') + aceEditor.getSession().setTabSize(this.props.indentation || 2) + aceEditor.getSession().setUseSoftTabs(true) + aceEditor.getSession().setUseWrapMode(true) + 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) + } + else { + // ace is excluded from the bundle. + } + + // allow changing the config or completely replacing aceEditor + this.aceEditor = this.props.onLoadAce + ? this.props.onLoadAce(aceEditor, container) || aceEditor + : aceEditor + + // register onchange event + this.aceEditor.on('change', this.handleChange) + + // set value, the text contents for the editor + this.aceEditor.setValue(this.props.value || '', -1) + } + + componentWillReceiveProps (nextProps) { + if (nextProps.value !== this.aceEditor.getValue()) { + this.aceEditor.setValue(nextProps.value, -1) + } + + if (nextProps.indentation != undefined) { + this.aceEditor.getSession().setTabSize(this.props.indentation) + } + + // TODO: only resize only when needed + setTimeout(() => { + this.aceEditor.resize(false); + }, 0) + } + + componentWillUnmount () { + // neatly destroy ace editor, it has created a worker for validation + this.aceEditor.destroy() + } + + handleChange = () => { + if (this.props && this.props.onChange) { + // TODO: pass a diff + this.props.onChange(this.aceEditor.getValue()) + } + } +} \ No newline at end of file diff --git a/src/components/CodeMode.js b/src/components/CodeMode.js index ef068d2..7092150 100644 --- a/src/components/CodeMode.js +++ b/src/components/CodeMode.js @@ -1,6 +1,6 @@ import { h } from 'preact' import TextMode from './TextMode' -import ace from '../assets/ace' +import Ace from './Ace' /** * CodeMode (powered by Ace editor) @@ -11,7 +11,8 @@ import ace from '../assets/ace' * options={Object} * onChange={function(text: string)} * onChangeMode={function(mode: string)} - * onLoadAce={function(aceEditor: Object, container: Element, options: Object) : Object} + * onError={function(error: Error)} + * onLoadAce={function(aceEditor: Object, container: Element) : Object} * /> * * Methods: @@ -30,81 +31,30 @@ export default class CodeMode extends TextMode { constructor (props) { super(props) - this.state = {} - - this.id = 'id' + Math.round(Math.random() * 1e6) // unique enough id within the JSONEditor - this.aceEditor = null + this.state = { + text: '{}' + } } render (props, state) { return h('div', {class: 'jsoneditor jsoneditor-mode-code'}, [ this.renderMenu(), - h('div', {class: 'jsoneditor-contents', id: this.id}) + h('div', {class: 'jsoneditor-contents'}, h(Ace, { + value: this.state.text, + onChange: this.handleChange, + onLoadAce: this.props.options.onLoadAce, + indentation: this.props.options.indentation, + ace: this.props.options.ace + })), + + this.renderSchemaErrors () ]) } - componentDidMount () { - const options = this.props.options || {} + handleChange = (text) => { + this.setState({ text }) - const container = this.base.querySelector('#' + this.id) - - // use ace from bundle, and if not available try to use from global - const _ace = ace || window['ace'] - - let aceEditor = null - if (_ace && _ace.edit) { - // create ace editor - aceEditor = _ace.edit(container) - - // bundle and load jsoneditor theme for ace editor - require('../assets/ace/theme-jsoneditor') - - // configure ace editor - aceEditor.$blockScrolling = Infinity - aceEditor.setTheme('ace/theme/jsoneditor') - aceEditor.setShowPrintMargin(false) - aceEditor.setFontSize(13) - aceEditor.getSession().setMode('ace/mode/json') - aceEditor.getSession().setTabSize(options.indentation || 2) - aceEditor.getSession().setUseSoftTabs(true) - aceEditor.getSession().setUseWrapMode(true) - 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) - } - else { - // ace is excluded from the bundle. - } - - // allow changing the config or completely replacing aceEditor - this.aceEditor = options.onLoadAce - ? options.onLoadAce(aceEditor, container, options) || aceEditor - : aceEditor - - // register onchange event - this.aceEditor.on('change', this.handleChange) - - // set initial text - this.setText('{}') - } - - componentWillUnmount () { - this.destroy() - } - - /** - * Destroy the editor - */ - destroy () { - // neatly destroy ace editor - this.aceEditor.destroy() - } - - componentDidUpdate () { - // TODO: handle changes in props - } - - handleChange = () => { if (this.props.options && this.props.options.onChangeText) { // TODO: pass a diff this.props.options.onChangeText() @@ -116,7 +66,7 @@ export default class CodeMode extends TextMode { * @param {string} text */ setText (text) { - this.aceEditor.setValue(text, -1) + this.setState({text}) } /** @@ -124,6 +74,6 @@ export default class CodeMode extends TextMode { * @return {string} text */ getText () { - return this.aceEditor.getValue() + return this.state.text } } \ No newline at end of file diff --git a/src/components/TextMode.js b/src/components/TextMode.js index 40a6608..fdd31c1 100644 --- a/src/components/TextMode.js +++ b/src/components/TextMode.js @@ -1,6 +1,8 @@ import { h, Component } from 'preact' +import Ajv from 'ajv' import { parseJSON } from '../utils/jsonUtils' import { escapeUnicodeChars } from '../utils/stringUtils' +import { enrichSchemaError, limitErrors } from '../utils/schemaUtils' import { jsonToData, dataToJson, patchData } from '../jsonData' import ModeButton from './menu/ModeButton' @@ -13,6 +15,7 @@ import ModeButton from './menu/ModeButton' * options={Object} * onChange={function(text: string)} * onChangeMode={function(mode: string)} + * onError={function(error: Error)} * /> * * Methods: @@ -33,7 +36,8 @@ export default class TextMode extends Component { super(props) this.state = { - text: '{}' + text: '{}', + compiledSchema: null } } @@ -47,7 +51,9 @@ export default class TextMode extends Component { value: this.state.text, onInput: this.handleChange }) - ]) + ]), + + this.renderSchemaErrors () ]) } @@ -73,11 +79,86 @@ export default class TextMode extends Component { modes: this.props.options.modes, mode: this.props.mode, onChangeMode: this.props.onChangeMode, - onError: this.handleError + onError: this.props.onError }) ]) } + /** @protected */ + renderSchemaErrors () { + // TODO: move the JSON Schema stuff into a separate Component? + if (!this.state.compiledSchema) { + return null + } + + try { + // TODO: only validate again when json is changed since last validation + const json = this.get(); // this can fail when there is no valid json + const valid = this.state.compiledSchema(json) + if (!valid) { + const allErrors = this.state.compiledSchema.errors.map(enrichSchemaError) + const limitedErrors = limitErrors(allErrors) + + return h('table', {class: 'jsoneditor-text-errors'}, + h('tbody', {}, limitedErrors.map(TextMode.renderSchemaError)) + ) + } + } + catch (err) { + // no valid JSON, don't validate + return null + } + } + + /** + * Render a table row of a single JSON schema error + * @param {Error | string} error + * @return {JSX.Element} + */ + static renderSchemaError (error) { + const icon = h('input', {type: 'button', class: 'jsoneditor-schema-error'}) + + if (typeof error === 'string') { + return h('tr', {}, + h('td', {}, icon), + h('td', {colSpan: 2}, h('pre', {}, error)) + ) + } + else { + return h('tr', {}, [ + h('td', {}, icon), + h('td', {}, error.dataPath), + h('td', {}, error.message) + ]) + } + } + + /** + * Set a JSON schema for validation of the JSON object. + * To remove the schema, call JSONEditor.setSchema(null) + * @param {Object | null} schema + */ + setSchema (schema) { + if (schema) { + const ajv = this.props.options.ajv || + Ajv && Ajv({ allErrors: true, verbose: true }) + + if (!ajv) { + throw new Error('Cannot validate JSON: ajv not available. ' + + 'Provide ajv via options or use a JSONEditor bundle including ajv.') + } + + this.setState({ + compiledSchema: ajv.compile(schema) + }) + } + else { + this.setState({ + compiledSchema: null + }) + } + } + /** * Get the configured indentation * @return {number} @@ -106,7 +187,7 @@ export default class TextMode extends Component { this.format() } catch (err) { - this.handleError(err) + this.props.onError(err) } } @@ -116,17 +197,7 @@ export default class TextMode extends Component { this.compact() } catch (err) { - this.handleError(err) - } - } - - /** @protected */ - handleError = (err) => { - if (this.props.options && this.props.options.onError) { - this.props.options.onError(err) - } - else { - console.error(err) + this.props.onError(err) } } @@ -205,11 +276,4 @@ export default class TextMode extends Component { getText () { return this.state.text } - - /** - * Destroy the editor - */ - destroy () { - - } } \ No newline at end of file diff --git a/src/components/TreeMode.js b/src/components/TreeMode.js index 1b6da9a..6967c72 100644 --- a/src/components/TreeMode.js +++ b/src/components/TreeMode.js @@ -112,7 +112,7 @@ export default class TreeMode extends Component { modes: this.props.options.modes, mode: this.props.mode, onChangeMode: this.props.onChangeMode, - onError: this.handleError + onError: this.props.onError }) ]) } @@ -213,16 +213,6 @@ export default class TreeMode extends Component { this.emitOnChange (actions, result.revert) } - /** @private */ - handleError = (err) => { - if (this.props.options && this.props.options.onError) { - this.props.options.onError(err) - } - else { - console.error(err) - } - } - /** * Emit an onChange event when there is a listener for it. * @param {JSONPatch} patch @@ -372,6 +362,16 @@ export default class TreeMode extends Component { return JSON.stringify(this.get(), null, indentation) } + /** + * Set a JSON schema for validation of the JSON object. + * To remove the schema, call JSONEditor.setSchema(null) + * @param {Object | null} schema + */ + setSchema (schema) { + // TODO: implement setSchema for TreeMode + console.error('setSchema not yet implemented for TreeMode') + } + /** * Expand one or multiple objects or arrays * @param {Path | function (path: Path) : boolean} callback @@ -422,13 +422,6 @@ export default class TreeMode extends Component { : TreeMode.expandAll(path) } - /** - * Destroy the editor - */ - destroy () { - - } - /** * Default function to determine whether or not to expand a node initially * diff --git a/src/develop.html b/src/develop.html index 480b631..07e9560 100644 --- a/src/develop.html +++ b/src/develop.html @@ -13,7 +13,7 @@