Implemented JSON Schema support for mode `text` and `code`

This commit is contained in:
jos 2016-11-11 20:18:31 +01:00
parent 18d63fcd9a
commit 68a5b1476f
12 changed files with 405 additions and 138 deletions

1
.gitignore vendored
View File

@ -4,4 +4,3 @@ downloads
node_modules node_modules
*.zip *.zip
npm-debug.log npm-debug.log
yarn.lock

View File

@ -59,12 +59,14 @@
} }
var options = { var options = {
schema: schema modes: ['code', 'tree']
} }
// create the editor // create the editor
var container = document.getElementById('jsoneditor') var container = document.getElementById('jsoneditor')
var editor = jsoneditor(container, options, json) var editor = jsoneditor(container, options)
editor.setSchema(schema)
editor.set(json)
</script> </script>
</body> </body>
</html> </html>

View File

@ -35,7 +35,7 @@
var container = document.getElementById('jsoneditor') var container = document.getElementById('jsoneditor')
var options = { var options = {
mode: 'code', mode: 'code',
onLoadAce: function (aceEditor, container, options) { onLoadAce: function (aceEditor, container) {
// we can adjust configuration of the ace editor, // we can adjust configuration of the ace editor,
// or create a completely new instance of ace editor. // or create a completely new instance of ace editor.

99
src/components/Ace.js Normal file
View File

@ -0,0 +1,99 @@
import { h, Component } from 'preact'
import ace from '../assets/ace'
/**
* Usage:
*
* <Ace value={"{}"}
* ace={Object}
* indentation={2}
* onChange={function(value: String)}
* onLoadAce={function(aceEditor, container)} />
*
*/
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())
}
}
}

View File

@ -1,6 +1,6 @@
import { h } from 'preact' import { h } from 'preact'
import TextMode from './TextMode' import TextMode from './TextMode'
import ace from '../assets/ace' import Ace from './Ace'
/** /**
* CodeMode (powered by Ace editor) * CodeMode (powered by Ace editor)
@ -11,7 +11,8 @@ import ace from '../assets/ace'
* options={Object} * options={Object}
* onChange={function(text: string)} * onChange={function(text: string)}
* onChangeMode={function(mode: 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: * Methods:
@ -30,81 +31,30 @@ export default class CodeMode extends TextMode {
constructor (props) { constructor (props) {
super(props) super(props)
this.state = {} this.state = {
text: '{}'
this.id = 'id' + Math.round(Math.random() * 1e6) // unique enough id within the JSONEditor }
this.aceEditor = null
} }
render (props, state) { render (props, state) {
return h('div', {class: 'jsoneditor jsoneditor-mode-code'}, [ return h('div', {class: 'jsoneditor jsoneditor-mode-code'}, [
this.renderMenu(), 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 () { handleChange = (text) => {
const options = this.props.options || {} 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) { if (this.props.options && this.props.options.onChangeText) {
// TODO: pass a diff // TODO: pass a diff
this.props.options.onChangeText() this.props.options.onChangeText()
@ -116,7 +66,7 @@ export default class CodeMode extends TextMode {
* @param {string} text * @param {string} text
*/ */
setText (text) { setText (text) {
this.aceEditor.setValue(text, -1) this.setState({text})
} }
/** /**
@ -124,6 +74,6 @@ export default class CodeMode extends TextMode {
* @return {string} text * @return {string} text
*/ */
getText () { getText () {
return this.aceEditor.getValue() return this.state.text
} }
} }

View File

@ -1,6 +1,8 @@
import { h, Component } from 'preact' import { h, Component } from 'preact'
import Ajv from 'ajv'
import { parseJSON } from '../utils/jsonUtils' import { parseJSON } from '../utils/jsonUtils'
import { escapeUnicodeChars } from '../utils/stringUtils' import { escapeUnicodeChars } from '../utils/stringUtils'
import { enrichSchemaError, limitErrors } from '../utils/schemaUtils'
import { jsonToData, dataToJson, patchData } from '../jsonData' import { jsonToData, dataToJson, patchData } from '../jsonData'
import ModeButton from './menu/ModeButton' import ModeButton from './menu/ModeButton'
@ -13,6 +15,7 @@ import ModeButton from './menu/ModeButton'
* options={Object} * options={Object}
* onChange={function(text: string)} * onChange={function(text: string)}
* onChangeMode={function(mode: string)} * onChangeMode={function(mode: string)}
* onError={function(error: Error)}
* /> * />
* *
* Methods: * Methods:
@ -33,7 +36,8 @@ export default class TextMode extends Component {
super(props) super(props)
this.state = { this.state = {
text: '{}' text: '{}',
compiledSchema: null
} }
} }
@ -47,7 +51,9 @@ export default class TextMode extends Component {
value: this.state.text, value: this.state.text,
onInput: this.handleChange onInput: this.handleChange
}) })
]) ]),
this.renderSchemaErrors ()
]) ])
} }
@ -73,11 +79,86 @@ export default class TextMode extends Component {
modes: this.props.options.modes, modes: this.props.options.modes,
mode: this.props.mode, mode: this.props.mode,
onChangeMode: this.props.onChangeMode, 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 * Get the configured indentation
* @return {number} * @return {number}
@ -106,7 +187,7 @@ export default class TextMode extends Component {
this.format() this.format()
} }
catch (err) { catch (err) {
this.handleError(err) this.props.onError(err)
} }
} }
@ -116,17 +197,7 @@ export default class TextMode extends Component {
this.compact() this.compact()
} }
catch (err) { catch (err) {
this.handleError(err) this.props.onError(err)
}
}
/** @protected */
handleError = (err) => {
if (this.props.options && this.props.options.onError) {
this.props.options.onError(err)
}
else {
console.error(err)
} }
} }
@ -205,11 +276,4 @@ export default class TextMode extends Component {
getText () { getText () {
return this.state.text return this.state.text
} }
/**
* Destroy the editor
*/
destroy () {
}
} }

View File

@ -112,7 +112,7 @@ export default class TreeMode extends Component {
modes: this.props.options.modes, modes: this.props.options.modes,
mode: this.props.mode, mode: this.props.mode,
onChangeMode: this.props.onChangeMode, 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) 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. * Emit an onChange event when there is a listener for it.
* @param {JSONPatch} patch * @param {JSONPatch} patch
@ -372,6 +362,16 @@ export default class TreeMode extends Component {
return JSON.stringify(this.get(), null, indentation) 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 * Expand one or multiple objects or arrays
* @param {Path | function (path: Path) : boolean} callback * @param {Path | function (path: Path) : boolean} callback
@ -422,13 +422,6 @@ export default class TreeMode extends Component {
: TreeMode.expandAll(path) : TreeMode.expandAll(path)
} }
/**
* Destroy the editor
*/
destroy () {
}
/** /**
* Default function to determine whether or not to expand a node initially * Default function to determine whether or not to expand a node initially
* *

View File

@ -13,7 +13,7 @@
<style> <style>
#container { #container {
height: 500px; height: 400px;
width: 100%; width: 100%;
max-width : 800px; max-width : 800px;
} }
@ -24,11 +24,13 @@
<button id="setJSON">Set JSON</button> <button id="setJSON">Set JSON</button>
<button id="getJSON">Get JSON</button> <button id="getJSON">Get JSON</button>
<button id="toggleSchema">Toggle JSON Schema</button>
<label for="mode">mode: <label for="mode">mode:
<select id="mode"> <select id="mode">
<option value="text">text</option> <option value="text">text</option>
<option value="code">code</option> <option value="code" selected>code</option>
<option value="tree" selected>tree</option> <option value="tree">tree</option>
<option value="form">form</option> <option value="form">form</option>
<option value="view">view</option> <option value="view">view</option>
</select> </select>
@ -83,6 +85,28 @@
} }
}) })
const schema = {
"title": "Example Schema",
"type": "object",
"properties": {
"firstName": {
"type": "string"
},
"lastName": {
"type": "string"
},
"gender": {
"enum": ["male", "female"]
},
"age": {
"description": "Age in years",
"type": "integer",
"minimum": 0
}
},
"required": ["firstName", "lastName"]
}
// set json // set json
document.getElementById('setJSON').onclick = function () { document.getElementById('setJSON').onclick = function () {
editor.set(largeJson, { editor.set(largeJson, {
@ -92,6 +116,13 @@
}) })
} }
// set schema
let hasSchema = false
document.getElementById('toggleSchema').onclick = function () {
editor.setSchema(hasSchema ? null : schema)
hasSchema = !hasSchema
}
// get json // get json
document.getElementById('getJSON').onclick = function () { document.getElementById('getJSON').onclick = function () {
const json = editor.get() const json = editor.get()

View File

@ -1,4 +1,4 @@
import { h, render } from 'preact' import { h, render, } from 'preact'
import CodeMode from './components/CodeMode' import CodeMode from './components/CodeMode'
import TextMode from './components/TextMode' import TextMode from './components/TextMode'
import TreeMode from './components/TreeMode' import TreeMode from './components/TreeMode'
@ -30,13 +30,9 @@ function jsoneditor (container, options = {}) {
const editor = { const editor = {
isJSONEditor: true, isJSONEditor: true,
utils: {
compileJSONPointer,
parseJSONPointer
},
_container: container, _container: container,
_options: options, _options: options,
_schema: null,
_modes: modes, _modes: modes,
_mode: null, _mode: null,
_element: null, _element: null,
@ -76,6 +72,16 @@ function jsoneditor (container, options = {}) {
return editor._component.getText() return editor._component.getText()
} }
/**
* Set a JSON schema for validation of the JSON object.
* To remove the schema, call JSONEditor.setSchema(null)
* @param {Object | null} schema
*/
editor.setSchema = function (schema) {
editor._schema = schema || null
editor._component.setSchema(schema)
}
/** /**
* Expand one or multiple objects or arrays. * Expand one or multiple objects or arrays.
* *
@ -154,15 +160,33 @@ function jsoneditor (container, options = {}) {
} }
} }
function handleError (err) {
if (editor._options && editor._options.onError) {
editor._options.onError(err)
}
else {
console.error(err)
}
}
// create new component // create new component
element = render( element = render(
h(constructor, { h(constructor, {
mode, mode,
options: editor._options, options: editor._options,
onChangeMode: handleChangeMode onChangeMode: handleChangeMode,
onError: handleError
}), }),
editor._container) editor._container)
// apply JSON schema (if any)
try {
element._component.setSchema(editor._schema)
}
catch (err) {
handleError(err)
}
// set JSON (this can throw an error) // set JSON (this can throw an error)
const text = editor._component ? editor._component.getText() : '{}' const text = editor._component ? editor._component.getText() : '{}'
element._component.setText(text) element._component.setText(text)
@ -174,8 +198,7 @@ function jsoneditor (container, options = {}) {
if (success) { if (success) {
// destroy previous component // destroy previous component
if (editor._element) { if (editor._element) {
editor._element._component.destroy() unrender(container, editor._element)
editor._element.parentNode.removeChild(editor._element)
} }
editor._mode = mode editor._mode = mode
@ -185,7 +208,8 @@ function jsoneditor (container, options = {}) {
else { else {
// TODO: fall back to text mode when loading code mode failed? // TODO: fall back to text mode when loading code mode failed?
// remove the just created component if any (where construction or setText failed) // remove the just created component if an error occurred during construction
// (for example when construction or setText failed)
const childCount = editor._container.children.length const childCount = editor._container.children.length
if (childCount !== initialChildCount) { if (childCount !== initialChildCount) {
editor._container.removeChild(editor._container.lastChild) editor._container.removeChild(editor._container.lastChild)
@ -194,11 +218,31 @@ function jsoneditor (container, options = {}) {
} }
} }
// TODO: implement destroy /**
* Remove the editor from the DOM and clean up workers
*/
editor.destroy = function () {
unrender(container, editor._element)
}
editor.setMode(options && options.mode || 'tree') editor.setMode(options && options.mode || 'tree')
return editor return editor
} }
// expose util functions
jsoneditor.utils = {
compileJSONPointer,
parseJSONPointer
}
/**
* Destroy a rendered preact component
* @param container
* @param root
*/
function unrender (container, root) {
render('', container, root);
}
module.exports = jsoneditor module.exports = jsoneditor

View File

@ -567,3 +567,37 @@ textarea.jsoneditor-text {
font-size: @fontSize; font-size: @fontSize;
color: @black; color: @black;
} }
div.jsoneditor-code {
width: 100%;
height: 100%;
min-height: @contentsMinHeight;
}
/* JSON schema errors displayed at the bottom of the editor in mode text and code */
.jsoneditor-text-errors {
width: 100%;
border-collapse: collapse;
background-color: #ffef8b;
border-top: 1px solid #ffd700;
font-family: @fontFamily;
font-size: @fontSize;
td {
padding: 3px 6px;
vertical-align: middle;
}
.jsoneditor-schema-error {
user-select: none;
outline: none;
border: none;
width: 24px;
height: 24px;
padding: 0;
margin: 0 4px 0 0;
background: url('img/jsoneditor-icons.svg') -168px -46px;
}
}

View File

@ -32,17 +32,19 @@
* *
* @typedef {{ * @typedef {{
* name: string?, * name: string?,
* mode?: 'code' | 'form' | 'text' | 'tree' | 'view', * mode: 'code' | 'form' | 'text' | 'tree' | 'view'?,
* modes?: string[], * modes: string[]?,
* history?: boolean, * history: boolean?,
* indentation?: number | string, * indentation: number | string?,
* onChange?: function (patch: JSONPatch, revert: JSONPatch), * onChange: function (patch: JSONPatch, revert: JSONPatch)?,
* onChangeText?: function (), * onChangeText: function ()?,
* onChangeMode?: function (mode: string, prevMode: string), * onChangeMode: function (mode: string, prevMode: string)?,
* onError?: function (err: Error), * onError: function (err: Error)?,
* isPropertyEditable?: function (Path) : boolean * isPropertyEditable: function (Path)?
* isValueEditable?: function (Path) : boolean, * isValueEditable: function (Path)?,
* escapeUnicode:? boolean * escapeUnicode: boolean?,
* ajv: Object?
* ace: Object?
* }} Options * }} Options
* *
* @typedef {{ * @typedef {{

49
src/utils/schemaUtils.js Normal file
View File

@ -0,0 +1,49 @@
// TODO: make MAX_ERRORS configurable
export const MAX_ERRORS = 3; // maximum number of displayed errors at the bottom
/**
* Enrich the error message of a JSON schema error
* @param {Object} error
* @return {Object} The improved error
*/
export function enrichSchemaError(error) {
if (error.keyword === 'enum' && Array.isArray(error.schema)) {
let enums = error.schema
if (enums) {
enums = enums.map(JSON.stringify)
if (enums.length > 5) {
const more = ['(' + (enums.length - 5) + ' more...)']
enums = enums.slice(0, 5)
enums.push(more)
}
error.message = 'should be equal to one of: ' + enums.join(', ')
}
}
if (error.keyword === 'additionalProperties') {
error.message = 'should NOT have additional property: ' + error.params.additionalProperty
}
return error
}
/**
* Limit the number of errors.
* If the number of errors exceeds the maximum, the tail is removed and
* a message that there are more errors is added
* @param {Array} errors
* @return {Array} Returns limited items
*/
export function limitErrors (errors) {
if (errors.length > MAX_ERRORS) {
const hidden = errors.length - MAX_ERRORS
let limitedErrors = errors.slice(0, MAX_ERRORS)
limitedErrors.push('(' + hidden + ' more errors...)')
return limitedErrors
}
return errors
}