Implemented JSON schema support for tree/form/view mode

This commit is contained in:
jos 2016-11-12 15:22:55 +01:00
parent fe3bc56d53
commit 70810655b8
7 changed files with 258 additions and 33 deletions

View File

@ -3,7 +3,7 @@ import { h, Component } from 'preact'
import ActionButton from './menu/ActionButton'
import AppendActionButton from './menu/AppendActionButton'
import { escapeHTML, unescapeHTML } from '../utils/stringUtils'
import { getInnerText } from '../utils/domUtils'
import { getInnerText, insideRect } from '../utils/domUtils'
import { stringConvert, valueType, isUrl } from '../utils/typeUtils'
/**
@ -43,7 +43,8 @@ export default class JSONNode extends Component {
this.renderExpandButton(),
this.renderActionMenuButton(),
this.renderProperty(prop, data, options),
this.renderReadonly(`{${childCount}}`, `Array containing ${childCount} items`)
this.renderReadonly(`{${childCount}}`, `Array containing ${childCount} items`),
this.renderError(data.error)
])
]
@ -79,7 +80,8 @@ export default class JSONNode extends Component {
this.renderExpandButton(),
this.renderActionMenuButton(),
this.renderProperty(prop, data, options),
this.renderReadonly(`[${childCount}]`, `Array containing ${childCount} items`)
this.renderReadonly(`[${childCount}]`, `Array containing ${childCount} items`),
this.renderError(data.error)
])
]
@ -114,7 +116,8 @@ export default class JSONNode extends Component {
this.renderActionMenuButton(),
this.renderProperty(prop, data, options),
this.renderSeparator(),
this.renderValue(data.value, options)
this.renderValue(data.value, options),
this.renderError(data.error)
])
])
}
@ -206,6 +209,50 @@ export default class JSONNode extends Component {
}
}
renderError (error) {
if (error) {
return h('button', {
type: 'button',
class: 'jsoneditor-schema-error',
onFocus: this.updatePopoverDirection,
onMouseOver: this.updatePopoverDirection
},
h('div', {class: 'jsoneditor-popover jsoneditor-right'}, error.message)
)
}
else {
return null
}
}
/**
* Find the best position for the popover: right, above, below, or left
* from the warning icon.
* @param event
*/
updatePopoverDirection = (event) => {
if (event.target.nodeName === 'BUTTON') {
const popover = event.target.firstChild
const directions = ['right', 'above', 'below', 'left']
for (let i = 0; i < directions.length; i++) {
const direction = directions[i]
popover.className = 'jsoneditor-popover jsoneditor-' + direction
// FIXME: the contentRect is that of the whole contents, not the visible window
const contents = this.base.parentNode.parentNode
const contentRect = contents.getBoundingClientRect()
const popoverRect = popover.getBoundingClientRect()
const margin = 20 // account for a scroll bar
if (insideRect(contentRect, popoverRect, margin)) {
// we found a location that fits, stop here
break
}
}
}
}
/**
* Note: this function manipulates the className and title of the editable div
* outside of Preact, so the user gets immediate feedback

View File

@ -1,16 +1,28 @@
import { h, Component } from 'preact'
import Ajv from 'ajv'
import { updateIn, getIn } from '../utils/immutabilityHelpers'
import { expand, jsonToData, dataToJson, toDataPath, patchData, pathExists } from '../jsonData'
import { parseJSON } from '../utils/jsonUtils'
import { enrichSchemaError } from '../utils/schemaUtils'
import {
duplicate, insert, append, remove, changeType, changeValue, changeProperty, sort
jsonToData, dataToJson, toDataPath, patchData, pathExists,
expand, addErrors
} from '../jsonData'
import {
duplicate, insert, append, remove,
changeType, changeValue, changeProperty, sort
} from '../actions'
import JSONNode from './JSONNode'
import JSONNodeView from './JSONNodeView'
import JSONNodeForm from './JSONNodeForm'
import ModeButton from './menu/ModeButton'
const AJV_OPTIONS = {
allErrors: true,
verbose: true,
jsonPointers: true
}
const MAX_HISTORY_ITEMS = 1000 // maximum number of undo/redo items to be kept in memory
export default class TreeMode extends Component {
@ -49,23 +61,25 @@ export default class TreeMode extends Component {
? JSONNodeForm
: JSONNode
const data = addErrors(state.data, this.getErrors())
return h('div', {
class: `jsoneditor jsoneditor-mode-${props.mode}`,
'data-jsoneditor': 'true'
}, [
this.renderMenu(),
h('div', {class: 'jsoneditor-contents jsoneditor-tree-contents', onClick: this.handleHideMenus}, [
h('ul', {class: 'jsoneditor-list jsoneditor-root'}, [
h('div', {class: 'jsoneditor-contents jsoneditor-tree-contents', onClick: this.handleHideMenus},
h('ul', {class: 'jsoneditor-list jsoneditor-root'},
h(Node, {
data: state.data,
data,
events: state.events,
options: props.options,
parent: null,
prop: null
})
])
])
)
)
])
}
@ -120,6 +134,23 @@ export default class TreeMode extends Component {
return h('div', {class: 'jsoneditor-menu'}, items)
}
/**
* Validate the JSON against the configured JSON schema
* Returns an array with the errors when not valid, returns an empty array
* when valid.
* @return {Array.<JSONSchemaError>}
*/
getErrors () {
if (this.state.compiledSchema) {
const valid = this.state.compiledSchema(dataToJson(this.state.data))
if (!valid) {
return this.state.compiledSchema.errors.map(enrichSchemaError)
}
}
return []
}
/** @private */
handleHideMenus = () => {
JSONNode.hideActionMenu()
@ -367,9 +398,25 @@ export default class TreeMode extends Component {
* To remove the schema, call JSONEditor.setSchema(null)
* @param {Object | null} schema
*/
// TODO: deduplicate this function, it's also implemented in TextMode
setSchema (schema) {
// TODO: implement setSchema for TreeMode
console.error('setSchema not yet implemented for TreeMode')
if (schema) {
const ajv = this.props.options.ajv || Ajv && Ajv(AJV_OPTIONS)
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
})
}
}
/**

View File

@ -29,8 +29,8 @@
<label for="mode">mode:
<select id="mode">
<option value="text">text</option>
<option value="code" selected>code</option>
<option value="tree">tree</option>
<option value="code">code</option>
<option value="tree" selected>tree</option>
<option value="form">form</option>
<option value="view">view</option>
</select>

View File

@ -447,8 +447,6 @@ export function test (data, path, value) {
}
}
// TODO: move expand, collapse, mergeErrors to actions.js
/**
* Expand or collapse one or multiple items or properties
* @param {JSONData} data
@ -533,11 +531,13 @@ function expandRecursive (data, path, callback, expanded) {
export function addErrors (data, errors) {
let updatedData = data
if (errors) {
errors.forEach(error => {
const dataPath = toDataPath(data, parseJSONPointer(error.dataPath))
// TODO: do we want to be able to store multiple errors per item?
updatedData = setIn(updatedData, dataPath.concat('error'), error)
})
}
return updatedData
}

View File

@ -1,3 +1,5 @@
@import url('./popover.less');
@fontFamily: droid sans mono, consolas, monospace, courier new, courier, sans-serif;
@fontSize: 10pt;
@black: #1A1A1A;
@ -597,15 +599,16 @@ div.jsoneditor-code {
}
}
}
}
.jsoneditor-schema-error {
user-select: none;
//user-select: none;
outline: none;
border: none;
width: 24px;
height: 24px;
width: 20px;
height: 20px;
padding: 0;
margin: 0 4px 0 0;
background: url('img/jsoneditor-icons.svg') -168px -46px;
}
margin: 0 4px;
background: url('img/jsoneditor-icons.svg') -171px -49px;
}

116
src/popover.less Normal file
View File

@ -0,0 +1,116 @@
/* schema error popover */
.jsoneditor-schema-error {
position:relative;
.jsoneditor-popover {
background-color: #4c4c4c;
border-radius: 3px;
box-shadow: 0 0 5px rgba(0,0,0,0.4);
color: #fff;
display: none;
padding: 7px 10px;
position: absolute;
width: 200px;
z-index: 4;
}
.jsoneditor-popover.jsoneditor-above {
bottom: 32px;
left: -98px;
}
.jsoneditor-popover.jsoneditor-below {
top: 32px;
left: -98px;
}
.jsoneditor-popover.jsoneditor-left {
top: -7px;
right: 32px;
}
.jsoneditor-popover.jsoneditor-right {
top: -7px;
left: 32px;
}
.jsoneditor-popover:before {
border-right: 7px solid transparent;
border-left: 7px solid transparent;
content: '';
display: block;
left: 50%;
margin-left: -7px;
position: absolute;
}
.jsoneditor-popover.jsoneditor-above:before {
border-top: 7px solid #4c4c4c;
bottom: -7px;
}
.jsoneditor-popover.jsoneditor-below:before {
border-bottom: 7px solid #4c4c4c;
top: -7px;
}
.jsoneditor-popover.jsoneditor-left:before {
border-left: 7px solid #4c4c4c;
border-top: 7px solid transparent;
border-bottom: 7px solid transparent;
content: '';
top: 19px;
right: -14px;
left: inherit;
margin-left: inherit;
margin-top: -7px;
position: absolute;
}
.jsoneditor-popover.jsoneditor-right:before {
border-right: 7px solid #4c4c4c;
border-top: 7px solid transparent;
border-bottom: 7px solid transparent;
content: '';
top: 19px;
left: -14px;
margin-left: inherit;
margin-top: -7px;
position: absolute;
}
&:hover .jsoneditor-popover,
&:focus .jsoneditor-popover {
display: block;
-webkit-animation: fade-in .3s linear 1, move-up .3s linear 1;
-moz-animation: fade-in .3s linear 1, move-up .3s linear 1;
-ms-animation: fade-in .3s linear 1, move-up .3s linear 1;
}
@-webkit-keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@-moz-keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@-ms-keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/*@-webkit-keyframes move-up {*/
/*from { bottom: 24px; }*/
/*to { bottom: 32px; }*/
/*}*/
/*@-moz-keyframes move-up {*/
/*from { bottom: 24px; }*/
/*to { bottom: 32px; }*/
/*}*/
/*@-ms-keyframes move-up {*/
/*from { bottom: 24px; }*/
/*to { bottom: 32px; }*/
/*}*/
}

View File

@ -93,6 +93,18 @@ export function findParentNode (elem, attr, value) {
return null
}
/**
* Test whether the child rect fits completely inside the parent rect.
* @param {ClientRect} parent
* @param {ClientRect} child
* @param {number} [margin=0]
*/
export function insideRect (parent, child, margin = 0) {
return child.left - margin >= parent.left
&& child.right + margin <= parent.right
&& child.top - margin >= parent.top
&& child.bottom + margin <= parent.bottom
}
/**
* Returns the version of Internet Explorer or a -1