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 { escapeHTML, unescapeHTML } from './utils/stringUtils'
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 {
constructor (props) {
super(props)
this.state = {
expanded: false,
value: props.value
}
this.handleChangeField = this.handleChangeField.bind(this)
this.handleBlurValue = this.handleBlurValue.bind(this)
this.handleChangeProperty = this.handleChangeProperty.bind(this)
this.handleChangeValue = this.handleChangeValue.bind(this)
this.handleClickValue = this.handleClickValue.bind(this)
this.handleKeyDownValue = this.handleKeyDownValue.bind(this)
@ -21,10 +16,10 @@ export default class JSONNode extends Component {
}
render (props) {
if (Array.isArray(props.value)) {
if (props.data.type === 'array') {
return this.renderJSONArray(props)
}
else if (isObject(props.value)) {
else if (props.data.type === 'object') {
return this.renderJSONObject(props)
}
else {
@ -32,27 +27,27 @@ export default class JSONNode extends Component {
}
}
renderJSONObject ({parent, index, field, value, onChangeValue, onChangeField}) {
const childCount = Object.keys(value).length
renderJSONObject ({data, index, options, onChangeValue, onChangeProperty, onExpand}) {
const childCount = data.childs.length
const contents = [
h('div', {class: 'jsoneditor-node jsoneditor-object'}, [
this.renderExpandButton(),
this.renderField(parent, index, field, value),
this.renderProperty(data, index, options),
this.renderSeparator(),
this.renderReadonly(`{${childCount}}`, `Array containing ${childCount} items`)
])
]
if (this.state.expanded) {
const childs = this.state.expanded && Object.keys(value).map(f => {
return h(JSONNode, {
parent: this,
field: f,
value: value[f],
onChangeValue,
onChangeField
})
})
if (data.expanded) {
const childs = data.childs.map(child => {
return h(JSONNode, {
data: child,
options,
onChangeValue,
onChangeProperty,
onExpand
})
})
contents.push(h('ul', {class: 'jsoneditor-list'}, childs))
}
@ -60,27 +55,28 @@ export default class JSONNode extends Component {
return h('li', {}, contents)
}
renderJSONArray ({parent, index, field, value, onChangeValue, onChangeField}) {
const childCount = value.length
renderJSONArray ({data, index, options, onChangeValue, onChangeProperty, onExpand}) {
const childCount = data.childs.length
const contents = [
h('div', {class: 'jsoneditor-node jsoneditor-array'}, [
this.renderExpandButton(),
this.renderField(parent, index, field, value),
this.renderProperty(data, index, options),
this.renderSeparator(),
this.renderReadonly(`[${childCount}]`, `Array containing ${childCount} items`)
])
]
if (this.state.expanded) {
const childs = this.state.expanded && value.map((v, i) => {
return h(JSONNode, {
parent: this,
index: i,
value: v,
onChangeValue,
onChangeField
})
})
if (data.expanded) {
const childs = data.childs.map((child, index) => {
return h(JSONNode, {
data: child,
index,
options,
onChangeValue,
onChangeProperty,
onExpand
})
})
contents.push(h('ul', {class: 'jsoneditor-list'}, childs))
}
@ -88,13 +84,13 @@ export default class JSONNode extends Component {
return h('li', {}, contents)
}
renderJSONValue ({parent, index, field, value}) {
renderJSONValue ({data, index, options}) {
return h('li', {}, [
h('div', {class: 'jsoneditor-node'}, [
h('div', {class: 'jsoneditor-button-placeholder'}),
this.renderField(parent, index, field, value),
this.renderProperty(data, index, options),
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)
}
renderField (parent, index, field, value) {
const readonly = !parent || index !== undefined
const content = !parent
? valueType(value) // render 'object' or 'array', or 'number' as field
renderProperty (data, index, options) {
const property = last(data.path)
const isProperty = typeof property === 'string'
const content = isProperty
? escapeHTML(property) // render the property name
: index !== undefined
? index // render the array index of the item
: escapeHTML(field) // render the property name
? index // render the array index of the item
: JSONNode._rootName(data, options)
return h('div', {
class: 'jsoneditor-field' + (readonly ? ' jsoneditor-readonly' : ''),
contentEditable: !readonly,
class: 'jsoneditor-property' + (isProperty ? '' : ' jsoneditor-readonly'),
contentEditable: isProperty,
spellCheck: 'false',
onBlur: this.handleChangeField
onInput: this.handleChangeProperty
}, content)
}
static _rootName (data, options) {
return typeof options.name === 'string'
? options.name
: (data.type === 'object' || data.type === 'array')
? data.type
: valueType(data.value)
}
renderSeparator() {
return h('div', {class: 'jsoneditor-separator'}, ':')
}
@ -133,7 +138,6 @@ export default class JSONNode extends Component {
contentEditable: true,
spellCheck: 'false',
onInput: this.handleChangeValue,
onBlur: this.handleBlurValue,
onClick: this.handleClickValue,
onKeyDown: this.handleKeyDownValue,
title: _isUrl ? 'Ctrl+Click or ctrl+Enter to open url' : null
@ -141,43 +145,28 @@ export default class JSONNode extends Component {
}
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'},
h('button', {class: className, onClick: this.handleExpand})
)
}
shouldComponentUpdate(nextProps, nextState) {
return Object.keys(nextProps).some(prop => this.props[prop] !== nextProps[prop]) ||
(this.state && Object.keys(nextState).some(prop => this.state[prop] !== nextState[prop]))
return Object.keys(nextProps).some(prop => this.props[prop] !== nextProps[prop])
}
componentWillReceiveProps(nextProps) {
this.setState({
value: nextProps.value
})
}
handleChangeProperty (event) {
const property = unescapeHTML(getInnerText(event.target))
const oldPath = this.props.data.path
const newPath = oldPath.slice(0, oldPath.length - 1).concat(property)
handleChangeField (event) {
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)
}
this.props.onChangeProperty(oldPath, newPath)
}
handleChangeValue (event) {
this.setState({
value: this._getValueFromEvent(event)
})
const value = this._getValueFromEvent(event)
this.props.onChangeValue(this.props.data.path, value)
}
handleClickValue (event) {
@ -193,9 +182,7 @@ export default class JSONNode extends Component {
}
handleExpand (event) {
this.setState({
expanded: !this.state.expanded
})
this.props.onExpand(this.props.data.path, !this.props.data.expanded)
}
_openLinkIfUrl (event) {
@ -212,19 +199,4 @@ export default class JSONNode extends Component {
_getValueFromEvent (event) {
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 { 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'
export default class Main extends Component {
@ -8,50 +10,173 @@ export default class Main extends Component {
super(props)
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.onChangeField = this.onChangeField.bind(this)
this._onExpand = this._onExpand.bind(this)
this._onChangeValue = this._onChangeValue.bind(this)
this._onChangeProperty = this._onChangeProperty.bind(this)
}
render(props, state) {
return h('div', {class: 'jsoneditor', onInput: this.onInput}, [
render() {
return h('div', {class: 'jsoneditor'}, [
h('ul', {class: 'jsoneditor-list'}, [
h(JSONNode, {
parent: null,
field: null,
value: state.json,
onChangeField: this.onChangeField,
onChangeValue: this.onChangeValue
data: this.state.data,
options: this.state.options,
onChangeProperty: this._onChangeProperty,
onChangeValue: this._onChangeValue,
onExpand: this._onExpand
})
])
])
}
onChangeValue (path, value) {
console.log('onChangeValue', path, value)
_onChangeValue (path, value) {
console.log('_onChangeValue', path, value)
const modelPath = Main._pathToModelPath(this.state.data, path).concat('value')
this.setState({
json: setIn(this.state.json, path, value)
data: setIn(this.state.data, modelPath, value)
})
}
onChangeField (path, oldField, newField) {
console.log('onChangeField', path, newField, oldField)
_onChangeProperty (oldPath, newPath) {
console.log('_onChangeProperty', oldPath, newPath)
const oldObject = getIn(this.state.json, path)
const newObject = renameField(oldObject, oldField, newField)
const modelPath = Main._pathToModelPath(this.state.data, oldPath).concat('path')
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 () {
return this.state.json
return Main._modelToJson(this.state.data)
}
// TODO: comment
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>
// create the editor
const container = document.getElementById('container');
const options = {};
const options = {
name: 'myObject'
};
const editor = jsoneditor(container, options);
const json = {
'array': [1, 2, 3],

View File

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

View File

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