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
*.zip
npm-debug.log
yarn.lock

View File

@ -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)
</script>
</body>
</html>

View File

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

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 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
}
}

View File

@ -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 () {
}
}

View File

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

View File

@ -13,7 +13,7 @@
<style>
#container {
height: 500px;
height: 400px;
width: 100%;
max-width : 800px;
}
@ -24,11 +24,13 @@
<button id="setJSON">Set JSON</button>
<button id="getJSON">Get JSON</button>
<button id="toggleSchema">Toggle JSON Schema</button>
<label for="mode">mode:
<select id="mode">
<option value="text">text</option>
<option value="code">code</option>
<option value="tree" selected>tree</option>
<option value="code" selected>code</option>
<option value="tree">tree</option>
<option value="form">form</option>
<option value="view">view</option>
</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
document.getElementById('setJSON').onclick = function () {
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
document.getElementById('getJSON').onclick = function () {
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 TextMode from './components/TextMode'
import TreeMode from './components/TreeMode'
@ -30,13 +30,9 @@ function jsoneditor (container, options = {}) {
const editor = {
isJSONEditor: true,
utils: {
compileJSONPointer,
parseJSONPointer
},
_container: container,
_options: options,
_schema: null,
_modes: modes,
_mode: null,
_element: null,
@ -76,6 +72,16 @@ function jsoneditor (container, options = {}) {
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.
*
@ -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
element = render(
h(constructor, {
mode,
options: editor._options,
onChangeMode: handleChangeMode
onChangeMode: handleChangeMode,
onError: handleError
}),
editor._container)
// apply JSON schema (if any)
try {
element._component.setSchema(editor._schema)
}
catch (err) {
handleError(err)
}
// set JSON (this can throw an error)
const text = editor._component ? editor._component.getText() : '{}'
element._component.setText(text)
@ -174,8 +198,7 @@ function jsoneditor (container, options = {}) {
if (success) {
// destroy previous component
if (editor._element) {
editor._element._component.destroy()
editor._element.parentNode.removeChild(editor._element)
unrender(container, editor._element)
}
editor._mode = mode
@ -185,7 +208,8 @@ function jsoneditor (container, options = {}) {
else {
// 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
if (childCount !== initialChildCount) {
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')
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

View File

@ -567,3 +567,37 @@ textarea.jsoneditor-text {
font-size: @fontSize;
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 {{
* name: string?,
* mode?: 'code' | 'form' | 'text' | 'tree' | 'view',
* modes?: string[],
* history?: boolean,
* indentation?: number | string,
* onChange?: function (patch: JSONPatch, revert: JSONPatch),
* onChangeText?: function (),
* onChangeMode?: function (mode: string, prevMode: string),
* onError?: function (err: Error),
* isPropertyEditable?: function (Path) : boolean
* isValueEditable?: function (Path) : boolean,
* escapeUnicode:? boolean
* mode: 'code' | 'form' | 'text' | 'tree' | 'view'?,
* modes: string[]?,
* history: boolean?,
* indentation: number | string?,
* onChange: function (patch: JSONPatch, revert: JSONPatch)?,
* onChangeText: function ()?,
* onChangeMode: function (mode: string, prevMode: string)?,
* onError: function (err: Error)?,
* isPropertyEditable: function (Path)?
* isValueEditable: function (Path)?,
* escapeUnicode: boolean?,
* ajv: Object?
* ace: Object?
* }} Options
*
* @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
}