Implemented JSON schema support for tree/form/view mode
This commit is contained in:
parent
fe3bc56d53
commit
70810655b8
|
@ -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
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
||||
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)
|
||||
})
|
||||
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
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
outline: none;
|
||||
border: none;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
margin: 0 4px 0 0;
|
||||
background: url('img/jsoneditor-icons.svg') -168px -46px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.jsoneditor-schema-error {
|
||||
//user-select: none;
|
||||
outline: none;
|
||||
border: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
margin: 0 4px;
|
||||
background: url('img/jsoneditor-icons.svg') -171px -49px;
|
||||
}
|
|
@ -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; }*/
|
||||
/*}*/
|
||||
|
||||
}
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue