Implemented a data model, fixed ordering of object properties, implemented options `name` and `expand`

This commit is contained in:
jos 2016-07-15 22:43:59 +02:00
parent fd631cd34c
commit fda4f94655
8 changed files with 247 additions and 149 deletions

View File

@ -1,19 +1,14 @@
import { h, Component } from 'preact' import { h, Component } from 'preact'
import { escapeHTML, unescapeHTML } from './utils/stringUtils' import { escapeHTML, unescapeHTML } from './utils/stringUtils'
import { getInnerText } from './utils/domUtils' import { getInnerText } from './utils/domUtils'
import {stringConvert, valueType, isUrl, isObject} from './utils/typeUtils' import {stringConvert, valueType, isUrl} from './utils/typeUtils'
import { last } from './utils/arrayUtils'
export default class JSONNode extends Component { export default class JSONNode extends Component {
constructor (props) { constructor (props) {
super(props) super(props)
this.state = { this.handleChangeProperty = this.handleChangeProperty.bind(this)
expanded: false,
value: props.value
}
this.handleChangeField = this.handleChangeField.bind(this)
this.handleBlurValue = this.handleBlurValue.bind(this)
this.handleChangeValue = this.handleChangeValue.bind(this) this.handleChangeValue = this.handleChangeValue.bind(this)
this.handleClickValue = this.handleClickValue.bind(this) this.handleClickValue = this.handleClickValue.bind(this)
this.handleKeyDownValue = this.handleKeyDownValue.bind(this) this.handleKeyDownValue = this.handleKeyDownValue.bind(this)
@ -21,10 +16,10 @@ export default class JSONNode extends Component {
} }
render (props) { render (props) {
if (Array.isArray(props.value)) { if (props.data.type === 'array') {
return this.renderJSONArray(props) return this.renderJSONArray(props)
} }
else if (isObject(props.value)) { else if (props.data.type === 'object') {
return this.renderJSONObject(props) return this.renderJSONObject(props)
} }
else { else {
@ -32,27 +27,27 @@ export default class JSONNode extends Component {
} }
} }
renderJSONObject ({parent, index, field, value, onChangeValue, onChangeField}) { renderJSONObject ({data, index, options, onChangeValue, onChangeProperty, onExpand}) {
const childCount = Object.keys(value).length const childCount = data.childs.length
const contents = [ const contents = [
h('div', {class: 'jsoneditor-node jsoneditor-object'}, [ h('div', {class: 'jsoneditor-node jsoneditor-object'}, [
this.renderExpandButton(), this.renderExpandButton(),
this.renderField(parent, index, field, value), this.renderProperty(data, index, options),
this.renderSeparator(), this.renderSeparator(),
this.renderReadonly(`{${childCount}}`, `Array containing ${childCount} items`) this.renderReadonly(`{${childCount}}`, `Array containing ${childCount} items`)
]) ])
] ]
if (this.state.expanded) { if (data.expanded) {
const childs = this.state.expanded && Object.keys(value).map(f => { const childs = data.childs.map(child => {
return h(JSONNode, { return h(JSONNode, {
parent: this, data: child,
field: f, options,
value: value[f], onChangeValue,
onChangeValue, onChangeProperty,
onChangeField onExpand
}) })
}) })
contents.push(h('ul', {class: 'jsoneditor-list'}, childs)) contents.push(h('ul', {class: 'jsoneditor-list'}, childs))
} }
@ -60,27 +55,28 @@ export default class JSONNode extends Component {
return h('li', {}, contents) return h('li', {}, contents)
} }
renderJSONArray ({parent, index, field, value, onChangeValue, onChangeField}) { renderJSONArray ({data, index, options, onChangeValue, onChangeProperty, onExpand}) {
const childCount = value.length const childCount = data.childs.length
const contents = [ const contents = [
h('div', {class: 'jsoneditor-node jsoneditor-array'}, [ h('div', {class: 'jsoneditor-node jsoneditor-array'}, [
this.renderExpandButton(), this.renderExpandButton(),
this.renderField(parent, index, field, value), this.renderProperty(data, index, options),
this.renderSeparator(), this.renderSeparator(),
this.renderReadonly(`[${childCount}]`, `Array containing ${childCount} items`) this.renderReadonly(`[${childCount}]`, `Array containing ${childCount} items`)
]) ])
] ]
if (this.state.expanded) { if (data.expanded) {
const childs = this.state.expanded && value.map((v, i) => { const childs = data.childs.map((child, index) => {
return h(JSONNode, { return h(JSONNode, {
parent: this, data: child,
index: i, index,
value: v, options,
onChangeValue, onChangeValue,
onChangeField onChangeProperty,
}) onExpand
}) })
})
contents.push(h('ul', {class: 'jsoneditor-list'}, childs)) contents.push(h('ul', {class: 'jsoneditor-list'}, childs))
} }
@ -88,13 +84,13 @@ export default class JSONNode extends Component {
return h('li', {}, contents) return h('li', {}, contents)
} }
renderJSONValue ({parent, index, field, value}) { renderJSONValue ({data, index, options}) {
return h('li', {}, [ return h('li', {}, [
h('div', {class: 'jsoneditor-node'}, [ h('div', {class: 'jsoneditor-node'}, [
h('div', {class: 'jsoneditor-button-placeholder'}), h('div', {class: 'jsoneditor-button-placeholder'}),
this.renderField(parent, index, field, value), this.renderProperty(data, index, options),
this.renderSeparator(), this.renderSeparator(),
this.renderValue(this.state.value) this.renderValue(data.value)
]) ])
]) ])
} }
@ -103,22 +99,31 @@ export default class JSONNode extends Component {
return h('div', {class: 'jsoneditor-readonly', contentEditable: false, title}, text) return h('div', {class: 'jsoneditor-readonly', contentEditable: false, title}, text)
} }
renderField (parent, index, field, value) { renderProperty (data, index, options) {
const readonly = !parent || index !== undefined const property = last(data.path)
const content = !parent const isProperty = typeof property === 'string'
? valueType(value) // render 'object' or 'array', or 'number' as field const content = isProperty
? escapeHTML(property) // render the property name
: index !== undefined : index !== undefined
? index // render the array index of the item ? index // render the array index of the item
: escapeHTML(field) // render the property name : JSONNode._rootName(data, options)
return h('div', { return h('div', {
class: 'jsoneditor-field' + (readonly ? ' jsoneditor-readonly' : ''), class: 'jsoneditor-property' + (isProperty ? '' : ' jsoneditor-readonly'),
contentEditable: !readonly, contentEditable: isProperty,
spellCheck: 'false', spellCheck: 'false',
onBlur: this.handleChangeField onInput: this.handleChangeProperty
}, content) }, content)
} }
static _rootName (data, options) {
return typeof options.name === 'string'
? options.name
: (data.type === 'object' || data.type === 'array')
? data.type
: valueType(data.value)
}
renderSeparator() { renderSeparator() {
return h('div', {class: 'jsoneditor-separator'}, ':') return h('div', {class: 'jsoneditor-separator'}, ':')
} }
@ -133,7 +138,6 @@ export default class JSONNode extends Component {
contentEditable: true, contentEditable: true,
spellCheck: 'false', spellCheck: 'false',
onInput: this.handleChangeValue, onInput: this.handleChangeValue,
onBlur: this.handleBlurValue,
onClick: this.handleClickValue, onClick: this.handleClickValue,
onKeyDown: this.handleKeyDownValue, onKeyDown: this.handleKeyDownValue,
title: _isUrl ? 'Ctrl+Click or ctrl+Enter to open url' : null title: _isUrl ? 'Ctrl+Click or ctrl+Enter to open url' : null
@ -141,43 +145,28 @@ export default class JSONNode extends Component {
} }
renderExpandButton () { renderExpandButton () {
const className = `jsoneditor-button jsoneditor-${this.state.expanded ? 'expanded' : 'collapsed'}` const className = `jsoneditor-button jsoneditor-${this.props.data.expanded ? 'expanded' : 'collapsed'}`
return h('div', {class: 'jsoneditor-button-container'}, return h('div', {class: 'jsoneditor-button-container'},
h('button', {class: className, onClick: this.handleExpand}) h('button', {class: className, onClick: this.handleExpand})
) )
} }
shouldComponentUpdate(nextProps, nextState) { shouldComponentUpdate(nextProps, nextState) {
return Object.keys(nextProps).some(prop => this.props[prop] !== nextProps[prop]) || return Object.keys(nextProps).some(prop => this.props[prop] !== nextProps[prop])
(this.state && Object.keys(nextState).some(prop => this.state[prop] !== nextState[prop]))
} }
componentWillReceiveProps(nextProps) { handleChangeProperty (event) {
this.setState({ const property = unescapeHTML(getInnerText(event.target))
value: nextProps.value const oldPath = this.props.data.path
}) const newPath = oldPath.slice(0, oldPath.length - 1).concat(property)
}
handleChangeField (event) { this.props.onChangeProperty(oldPath, newPath)
const path = this.props.parent.getPath()
const newField = unescapeHTML(getInnerText(event.target))
const oldField = this.props.field
if (newField !== oldField) {
this.props.onChangeField(path, oldField, newField)
}
}
handleBlurValue (event) {
const path = this.getPath()
if (this.state.value !== this.props.value) {
this.props.onChangeValue(path, this.state.value)
}
} }
handleChangeValue (event) { handleChangeValue (event) {
this.setState({ const value = this._getValueFromEvent(event)
value: this._getValueFromEvent(event)
}) this.props.onChangeValue(this.props.data.path, value)
} }
handleClickValue (event) { handleClickValue (event) {
@ -193,9 +182,7 @@ export default class JSONNode extends Component {
} }
handleExpand (event) { handleExpand (event) {
this.setState({ this.props.onExpand(this.props.data.path, !this.props.data.expanded)
expanded: !this.state.expanded
})
} }
_openLinkIfUrl (event) { _openLinkIfUrl (event) {
@ -212,19 +199,4 @@ export default class JSONNode extends Component {
_getValueFromEvent (event) { _getValueFromEvent (event) {
return stringConvert(unescapeHTML(getInnerText(event.target))) return stringConvert(unescapeHTML(getInnerText(event.target)))
} }
getPath () {
const path = []
let node = this
while (node) {
path.unshift(node.props.field || node.props.index)
node = node.props.parent
}
path.shift() // remove the root node again (null)
return path
}
} }

View File

@ -1,6 +1,8 @@
import { h, Component } from 'preact' import { h, Component } from 'preact'
import { getIn, setIn, renameField } from './utils/objectUtils' import { setIn } from './utils/objectUtils'
import { last } from './utils/arrayUtils'
import { isObject } from './utils/typeUtils'
import JSONNode from './JSONNode' import JSONNode from './JSONNode'
export default class Main extends Component { export default class Main extends Component {
@ -8,50 +10,173 @@ export default class Main extends Component {
super(props) super(props)
this.state = { this.state = {
json: props.json || {} options: Object.assign({
name: null,
expand: Main.expand
}, props.options || {}),
data: {
type: 'object',
expanded: true,
path: [],
childs: []
}
} }
this.onChangeValue = this.onChangeValue.bind(this) this._onExpand = this._onExpand.bind(this)
this.onChangeField = this.onChangeField.bind(this) this._onChangeValue = this._onChangeValue.bind(this)
this._onChangeProperty = this._onChangeProperty.bind(this)
} }
render(props, state) { render() {
return h('div', {class: 'jsoneditor', onInput: this.onInput}, [ return h('div', {class: 'jsoneditor'}, [
h('ul', {class: 'jsoneditor-list'}, [ h('ul', {class: 'jsoneditor-list'}, [
h(JSONNode, { h(JSONNode, {
parent: null, data: this.state.data,
field: null, options: this.state.options,
value: state.json, onChangeProperty: this._onChangeProperty,
onChangeField: this.onChangeField, onChangeValue: this._onChangeValue,
onChangeValue: this.onChangeValue onExpand: this._onExpand
}) })
]) ])
]) ])
} }
onChangeValue (path, value) { _onChangeValue (path, value) {
console.log('onChangeValue', path, value) console.log('_onChangeValue', path, value)
const modelPath = Main._pathToModelPath(this.state.data, path).concat('value')
this.setState({ this.setState({
json: setIn(this.state.json, path, value) data: setIn(this.state.data, modelPath, value)
}) })
} }
onChangeField (path, oldField, newField) { _onChangeProperty (oldPath, newPath) {
console.log('onChangeField', path, newField, oldField) console.log('_onChangeProperty', oldPath, newPath)
const modelPath = Main._pathToModelPath(this.state.data, oldPath).concat('path')
const oldObject = getIn(this.state.json, path)
const newObject = renameField(oldObject, oldField, newField)
this.setState({ this.setState({
json: setIn(this.state.json, path, newObject) data: setIn(this.state.data, modelPath, newPath)
}) })
} }
_onExpand(path, expand) {
const modelPath = Main._pathToModelPath(this.state.data, path).concat('expanded')
this.setState({
data: setIn(this.state.data, modelPath, expand)
})
}
// TODO: comment
get () { get () {
return this.state.json return Main._modelToJson(this.state.data)
} }
// TODO: comment
set (json) { set (json) {
this.setState({json}) this.setState({
data: Main._jsonToModel([], json, this.state.options.expand)
})
} }
/**
* Default function to determine whether or not to expand a node initially
* @param {Array.<string | number>} path
* @return {boolean}
*/
static expand (path) {
return path.length === 0
}
/**
* Convert a path of a JSON object into a path in the corresponding data model
* @param {Model} model
* @param {Array.<string | number>} path
* @return {Array.<string | number>} modelPath
* @private
*/
static _pathToModelPath (model, path) {
if (path.length === 0) {
return []
}
let index
if (typeof path[0] === 'number') {
// index of an array
index = path[0]
}
else {
// object property. find the index of this property
index = model.childs.findIndex(child => last(child.path) === path[0])
}
return ['childs', index]
.concat(Main._pathToModelPath(model.childs[index], path.slice(1)))
}
/**
* Convert a JSON object into the internally used data model
* @param {Array.<string | number>} path
* @param {Object | Array | string | number | boolean | null} value
* @param {function(path: Array.<string>)} expand
* @return {Model}
* @private
*/
static _jsonToModel (path, value, expand) {
if (Array.isArray(value)) {
return {
type: 'array',
expanded: expand(path),
path,
childs: value.map((child, index) => Main._jsonToModel(path.concat(index), child, expand))
}
}
else if (isObject(value)) {
return {
type: 'object',
expanded: expand(path),
path,
childs: Object.keys(value).map(prop => {
return Main._jsonToModel(path.concat(prop), value[prop], expand)
})
}
}
else {
return {
type: 'auto',
path,
value
}
}
}
/**
* Convert the internal data model to a regular JSON object
* @param {Model} model
* @return {Object | Array | string | number | boolean | null} json
* @private
*/
static _modelToJson (model) {
if (model.type === 'array') {
return model.childs.map(Main._modelToJson)
}
else if (model.type === 'object') {
const object = {}
model.childs.forEach(child => {
const prop = last(child.path)
object[prop] = Main._modelToJson(child)
})
return object
}
else {
// type 'auto' or 'string'
return model.value
}
}
} }

View File

@ -16,7 +16,9 @@
<script> <script>
// create the editor // create the editor
const container = document.getElementById('container'); const container = document.getElementById('container');
const options = {}; const options = {
name: 'myObject'
};
const editor = jsoneditor(container, options); const editor = jsoneditor(container, options);
const json = { const json = {
'array': [1, 2, 3], 'array': [1, 2, 3],

View File

@ -4,11 +4,12 @@ import Main from './Main'
/** /**
* Factory function to create a new JSONEditor * Factory function to create a new JSONEditor
* @param container * @param container
* @param {Options} options
* @return {*} * @return {*}
* @constructor * @constructor
*/ */
export default function jsoneditor (container) { export default function jsoneditor (container, options) {
const elem = render(h(Main), container) const elem = render(h(Main, {options}), container)
return elem._component return elem._component
} }

View File

@ -31,7 +31,7 @@ ul.jsoneditor-list {
padding-left: 2px; padding-left: 2px;
} }
.jsoneditor-field, .jsoneditor-property,
.jsoneditor-value, .jsoneditor-value,
.jsoneditor-readonly, .jsoneditor-readonly,
.jsoneditor-separator { .jsoneditor-separator {
@ -42,7 +42,7 @@ ul.jsoneditor-list {
font-size: 10pt; font-size: 10pt;
} }
.jsoneditor-field, .jsoneditor-property,
.jsoneditor-value, .jsoneditor-value,
.jsoneditor-readonly { .jsoneditor-readonly {
min-width: 24px; min-width: 24px;
@ -58,17 +58,17 @@ ul.jsoneditor-list {
font-size: 0; font-size: 0;
} }
.jsoneditor-field, .jsoneditor-property,
.jsoneditor-value { .jsoneditor-value {
border-radius: 1px; border-radius: 1px;
} }
.jsoneditor-field:focus, .jsoneditor-property:focus,
.jsoneditor-value:focus { .jsoneditor-value:focus {
box-shadow: 0 0 3px 1px #008fd5; box-shadow: 0 0 3px 1px #008fd5;
} }
.jsoneditor-field:hover, .jsoneditor-property:hover,
.jsoneditor-value:hover { .jsoneditor-value:hover {
background-color: #f5f5f5; background-color: #f5f5f5;
} }

17
src/typedef.js Normal file
View File

@ -0,0 +1,17 @@
/**
* @typedef {{
* type: string,
* expanded: boolean?,
* path: Array.<string | number>,
* value: *?,
* childs: Model[]?
* }} Model
*/
/**
* @typedef {{
* name: string?
* expand: function?
* }} Options
*/

8
src/utils/arrayUtils.js Normal file
View File

@ -0,0 +1,8 @@
/**
* Returns the last item of an array
* @param {Array} array
* @return {*}
*/
export function last (array) {
return array[array.length - 1]
}

View File

@ -79,30 +79,3 @@ export function setIn (object, path, value) {
return updated return updated
} }
/**
* Rename a field in an object without mutating the object itself.
* The order of the fields in the object is maintained.
* @param {Object} object
* @param {string} oldField
* @param {string} newField
* @return {Object} Returns a clone of the object where property `oldField` is
* renamed to `newField`
*/
export function renameField(object, oldField, newField) {
const renamed = {}
// important: maintain the order in which we add the properties to newValue,
// else the field will "jump" to another place
Object.keys(object).forEach(field => {
if (field === oldField) {
renamed[newField] = object[oldField]
}
else {
renamed[field] = object[field]
}
})
return renamed
}