diff --git a/.babelrc b/.babelrc index c13c5f6..e81a8fb 100755 --- a/.babelrc +++ b/.babelrc @@ -1,3 +1,3 @@ { - "presets": ["es2015"] + "presets": ["es2015", "stage-3", "stage-2"] } diff --git a/gulpfile.js b/gulpfile.js index 85cc649..1aec6d0 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -4,6 +4,7 @@ var gutil = require('gulp-util'); var shell = require('gulp-shell'); var mkdirp = require('mkdirp'); var webpack = require('webpack'); +var WebpackDevServer = require('webpack-dev-server'); var NAME = 'jsoneditor'; var NAME_MINIMALIST = 'jsoneditor-minimalist'; @@ -37,6 +38,7 @@ var loaders = [ var compiler = webpack({ entry: ENTRY, devtool: 'source-map', + debug: true, output: { library: 'jsoneditor', libraryTarget: 'umd', @@ -57,6 +59,7 @@ var compiler = webpack({ var compilerMinimalist = webpack({ entry: ENTRY, devtool: 'source-map', + debug: true, output: { library: 'jsoneditor', libraryTarget: 'umd', @@ -87,7 +90,7 @@ gulp.task('bundle', ['mkdir'], function (done) { compiler.run(function (err, stats) { if (err) { - gutil.log(err); + throw new gutil.PluginError('webpack', err); } gutil.log('bundled ' + NAME + '.js'); @@ -103,7 +106,7 @@ gulp.task('bundle-minimalist', ['mkdir'], function (done) { compilerMinimalist.run(function (err, stats) { if (err) { - gutil.log(err); + throw new gutil.PluginError('webpack', err); } gutil.log('bundled ' + NAME_MINIMALIST + '.js'); diff --git a/package.json b/package.json index d920c0b..dd074b2 100644 --- a/package.json +++ b/package.json @@ -23,15 +23,17 @@ "test": "ava test/*.test.js test/**/*.test.js --verbose" }, "dependencies": { - "ajv": "4.4.0", + "ajv": "4.5.0", "brace": "0.8.0", "javascript-natural-sort": "0.7.1", - "preact": "5.6.0" + "preact": "5.7.0" }, "devDependencies": { "ava": "0.16.0", "babel-core": "6.13.2", - "babel-loader": "6.2.4", + "babel-loader": "6.2.5", + "babel-preset-stage-2": "6.13.0", + "babel-preset-stage-3": "6.11.0", "css-loader": "0.23.1", "gulp": "3.9.1", "gulp-shell": "0.5.2", @@ -42,7 +44,7 @@ "mkdirp": "0.5.1", "style-loader": "0.13.1", "svg-url-loader": "1.1.0", - "webpack": "1.13.1" + "webpack": "1.13.2" }, "ava": { "require": [ diff --git a/src/TreeMode.js b/src/TreeMode.js index 5f31ae6..989499e 100644 --- a/src/TreeMode.js +++ b/src/TreeMode.js @@ -1,11 +1,13 @@ import { h, Component } from 'preact' -import { cloneDeep } from './utils/objectUtils' -import { setIn, updateIn, getIn, deleteIn } from './utils/immutabilityHelpers' -import { compareAsc, compareDesc } from './utils/arrayUtils' -import { stringConvert } from './utils/typeUtils' -import { isObject } from './utils/objectUtils' -import bindMethods from './utils/bindMethods' +import { setIn } from './utils/immutabilityHelpers' +import { + changeValue, changeProperty, changeType, + insert, append, duplicate, remove, + sort, + expand, + jsonToData, dataToJson +} from './jsonData' import JSONNode from './JSONNode' export default class TreeMode extends Component { @@ -14,11 +16,9 @@ export default class TreeMode extends Component { constructor (props) { super(props) - bindMethods(this) - const name = this.props.options && this.props.options.name || null const expand = this.props.options && this.props.options.expand || TreeMode.expand - + this.state = { options: { name @@ -43,13 +43,13 @@ export default class TreeMode extends Component { } } - render() { + render (props, state) { return h('div', {class: 'jsoneditor', contentEditable: 'false', onClick: JSONNode.hideContextMenu}, [ h('ul', {class: 'jsoneditor-list', contentEditable: 'false'}, [ h(JSONNode, { - data: this.state.data, - events: this.state.events, - options: this.state.options, + data: state.data, + events: state.events, + options: state.options, parent: null, prop: null }) @@ -57,224 +57,57 @@ export default class TreeMode extends Component { ]) } - handleChangeValue (path, value) { - console.log('handleChangeValue', path, value) - - const dataPath = toDataPath(this.state.data, path) - + handleChangeValue = (path, value) => { this.setState({ - data: setIn(this.state.data, dataPath.concat(['value']), value) + data: changeValue(this.state.data, path, value) }) } - handleChangeProperty (path, oldProp, newProp) { - console.log('handleChangeProperty', path, oldProp, newProp) - - const dataPath = toDataPath(this.state.data, path) - const object = getIn(this.state.data, dataPath) - const index = object.props.findIndex(p => p.name === oldProp) - + handleChangeProperty = (path, oldProp, newProp) => { this.setState({ - data: setIn(this.state.data, dataPath.concat(['props', index, 'name']), newProp) + data: changeProperty(this.state.data, path, oldProp, newProp) }) } - handleChangeType (path, type) { - console.log('handleChangeType', path, type) - - const dataPath = toDataPath(this.state.data, path) - const oldEntry = getIn(this.state.data, dataPath) - const newEntry = convertDataEntry(oldEntry, type) - + handleChangeType = (path, type) => { this.setState({ - data: setIn(this.state.data, dataPath, newEntry) + data: changeType(this.state.data, path, type) }) } - handleInsert (path, afterProp, type) { - console.log('handleInsert', path, afterProp, type) - - const dataPath = toDataPath(this.state.data, path) - const parent = getIn(this.state.data, dataPath) - - if (parent.type === 'array') { - this.setState({ - data: updateIn(this.state.data, dataPath.concat(['items']), (items) => { - const index = parseInt(afterProp) - const updatedItems = items.slice(0) - - updatedItems.splice(index + 1, 0, createDataEntry(type)) - - return updatedItems - }) - }) - } - else { // parent.type === 'object' - this.setState({ - data: updateIn(this.state.data, dataPath.concat(['props']), (props) => { - const index = props.findIndex(p => p.name === afterProp) - const updatedProps = props.slice(0) - - updatedProps.splice(index + 1, 0, { - name: '', - value: createDataEntry(type) - }) - - return updatedProps - }) - }) - } - } - - handleAppend (path, type) { - console.log('handleAppend', path, type) - - const dataPath = toDataPath(this.state.data, path) - const object = getIn(this.state.data, dataPath) - - if (object.type === 'array') { - this.setState({ - data: updateIn(this.state.data, dataPath.concat(['items']), (items) => { - const updatedItems = items.slice(0) - - updatedItems.push(createDataEntry(type)) - - return updatedItems - }) - }) - } - else { // object.type === 'object' - this.setState({ - data: updateIn(this.state.data, dataPath.concat(['props']), (props) => { - const updatedProps = props.slice(0) - - updatedProps.push({ - name: '', - value: createDataEntry(type) - }) - - return updatedProps - }) - }) - } - } - - handleDuplicate (path, prop) { - console.log('handleDuplicate', path) - - const dataPath = toDataPath(this.state.data, path) - const object = getIn(this.state.data, dataPath) - - if (object.type === 'array') { - this.setState({ - data: updateIn(this.state.data, dataPath.concat(['items']), (items) => { - const index = parseInt(prop) - const updatedItems = items.slice(0) - const original = items[index] - const duplicate = cloneDeep(original) - - updatedItems.splice(index + 1, 0, duplicate) - - return updatedItems - }) - }) - } - else { // object.type === 'object' - this.setState({ - data: updateIn(this.state.data, dataPath.concat(['props']), (props) => { - const index = props.findIndex(p => p.name === prop) - const updated = props.slice(0) - const original = props[index] - const duplicate = cloneDeep(original) - - updated.splice(index + 1, 0, duplicate) - - return updated - }) - }) - } - } - - handleRemove (path, prop) { - console.log('handleRemove', path) - - const object = getIn(this.state.data, toDataPath(this.state.data, path)) - - if (object.type === 'array') { - const dataPath = toDataPath(this.state.data, path.concat(prop)) - - this.setState({ - data: deleteIn(this.state.data, dataPath) - }) - } - else { // object.type === 'object' - const dataPath = toDataPath(this.state.data, path.concat(prop)) - - dataPath.pop() // remove the 'value' property, we want to remove the whole object property - this.setState({ - data: deleteIn(this.state.data, dataPath) - }) - } - } - - /** - * Order the items of an array or the properties of an object in ascending - * or descending order - * @param {Array.} path - * @param {'asc' | 'desc' | null} [order=null] If not provided, will toggle current ordering - */ - handleSort (path, order = null) { - console.log('handleSort', path, order) - - const dataPath = toDataPath(this.state.data, path) - const object = getIn(this.state.data, dataPath) - - let _order - if (order === 'asc' || order === 'desc') { - _order = order - } - else { - // toggle previous order - _order = object.order !== 'asc' ? 'asc' : 'desc' - - this.setState({ - data: setIn(this.state.data, dataPath.concat(['order']), _order) - }) - } - - if (object.type === 'array') { - this.setState({ - data: updateIn(this.state.data, dataPath.concat(['items']), (items) =>{ - const ordered = items.slice(0) - const compare = _order === 'desc' ? compareDesc : compareAsc - - ordered.sort((a, b) => compare(a.value, b.value)) - - return ordered - }) - }) - } - else { // object.type === 'object' - this.setState({ - data: updateIn(this.state.data, dataPath.concat(['props']), (props) => { - const orderedProps = props.slice(0) - const compare = _order === 'desc' ? compareDesc : compareAsc - - orderedProps.sort((a, b) => compare(a.name, b.name)) - - return orderedProps - }) - }) - } - } - - handleExpand(path, expand) { - console.log('handleExpand', path, expand) - - const dataPath = toDataPath(this.state.data, path) - + handleInsert = (path, afterProp, type) => { this.setState({ - data: setIn(this.state.data, dataPath.concat(['expanded']), expand) + data: insert(this.state.data, path, afterProp, type) + }) + } + + handleAppend = (path, type) => { + this.setState({ + data: append(this.state.data, path, type) + }) + } + + handleDuplicate = (path, type) => { + this.setState({ + data: duplicate(this.state.data, path, type) + }) + } + + handleRemove = (path, prop) => { + this.setState({ + data: remove(this.state.data, path, prop) + }) + } + + handleSort = (path, order = null) => { + this.setState({ + data: sort(this.state.data, path, order) + }) + } + + handleExpand = (path, doExpand) => { + this.setState({ + data: expand(this.state.data, path, doExpand) }) } @@ -315,151 +148,3 @@ export default class TreeMode extends Component { } } - -/** - * Convert a path of a JSON object into a path in the corresponding data model - * @param {Data} data - * @param {Array.} path - * @return {Array.} dataPath - * @private - */ -function toDataPath (data, path) { - if (path.length === 0) { - return [] - } - - let index - if (data.type === 'array') { - // index of an array - index = path[0] - - return ['items', index].concat(toDataPath(data.items[index], path.slice(1))) - } - else { - // object property. find the index of this property - index = data.props.findIndex(prop => prop.name === path[0]) - - return ['props', index, 'value'].concat(toDataPath(data.props[index].value, path.slice(1))) - } -} - -/** - * Convert a JSON object into the internally used data model - * @param {Array.} path - * @param {Object | Array | string | number | boolean | null} json - * @param {function(path: Array.)} expand - * @return {Data} - */ -function jsonToData (path, json, expand) { - if (Array.isArray(json)) { - return { - type: 'array', - expanded: expand(path), - items: json.map((child, index) => jsonToData(path.concat(index), child, expand)) - } - } - else if (isObject(json)) { - return { - type: 'object', - expanded: expand(path), - props: Object.keys(json).map(name => { - return { - name, - value: jsonToData(path.concat(name), json[name], expand) - } - }) - } - } - else { - return { - type: 'value', - value: json - } - } -} - -/** - * Convert the internal data model to a regular JSON object - * @param {Data} data - * @return {Object | Array | string | number | boolean | null} json - */ -function dataToJson (data) { - switch (data.type) { - case 'array': - return data.items.map(dataToJson) - - case 'object': - const object = {} - - data.props.forEach(prop => { - object[prop.name] = dataToJson(prop.value) - }) - - return object - - default: // type 'string' or 'value' - return data.value - } -} - - -/** - * Create a new data entry - * @param {'object' | 'array' | 'value' | 'string'} [type='value'] - * @return {*} - */ -function createDataEntry (type) { - if (type === 'array') { - return { - type, - expanded: true, - items: [] - } - } - else if (type === 'object') { - return { - type, - expanded: true, - props: [] - } - } - else { - return { - type, - value: '' - } - } -} - -/** - * Convert an entry into a different type. When possible, data is retained - * @param {Data} entry - * @param {'object' | 'array' | 'value' | 'string'} type - */ -function convertDataEntry (entry, type) { - const convertedEntry = createDataEntry(type) - - // convert contents from old value to new value where possible - if (type === 'value' && entry.type === 'string') { - convertedEntry.value = stringConvert(entry.value) - } - - if (type === 'string' && entry.type === 'value') { - convertedEntry.value = entry.value + '' - } - - if (type === 'object' && entry.type === 'array') { - convertedEntry.props = entry.items.map((item, index) => { - return { - name: index + '', - value: item - } - }) - } - - if (type === 'array' && entry.type === 'object') { - convertedEntry.items = entry.props.map(prop => prop.value) - } - - return convertedEntry -} \ No newline at end of file diff --git a/src/jsonData.js b/src/jsonData.js new file mode 100644 index 0000000..1c5c64c --- /dev/null +++ b/src/jsonData.js @@ -0,0 +1,410 @@ +/** + * This file contains functions to act on a JSONData object. + * All functions are pure and don't mutate the JSONData. + */ + +import { cloneDeep } from './utils/objectUtils' +import { setIn, updateIn, getIn, deleteIn } from './utils/immutabilityHelpers' +import { compareAsc, compareDesc } from './utils/arrayUtils' +import { stringConvert } from './utils/typeUtils' +import { isObject } from './utils/objectUtils' + +/** + * Change the value of a property or item + * @param {JSONData} data + * @param {Path} path + * @param {*} value + * @return {JSONData} + */ +export function changeValue (data, path, value) { + console.log('changeValue', data, value) + + const dataPath = toDataPath(data, path) + + return setIn(data, dataPath.concat(['value']), value) +} + +/** + * Change a property name + * @param {JSONData} data + * @param {Path} path + * @param {string} oldProp + * @param {string} newProp + * @return {JSONData} + */ +export function changeProperty (data, path, oldProp, newProp) { + console.log('changeProperty', path, oldProp, newProp) + + const dataPath = toDataPath(data, path) + const object = getIn(data, dataPath) + const index = object.props.findIndex(p => p.name === oldProp) + + return setIn(data, dataPath.concat(['props', index, 'name']), newProp) +} + +/** + * Change the type of a property or item + * @param {JSONData} data + * @param {Path} path + * @param {JSONDataType} type + * @return {JSONData} + */ +export function changeType (data, path, type) { + console.log('changeType', path, type) + + const dataPath = toDataPath(data, path) + const oldEntry = getIn(data, dataPath) + const newEntry = convertDataEntry(oldEntry, type) + + return setIn(data, dataPath, newEntry) +} + +/** + * Insert a new item after specified property or item + * @param {JSONData} data + * @param {Path} path + * @param {string | number} afterProp + * @param {JSONDataType} type + * @return {JSONData} + */ +export function insert (data, path, afterProp, type) { + console.log('insert', path, afterProp, type) + + const dataPath = toDataPath(data, path) + const parent = getIn(data, dataPath) + + if (parent.type === 'array') { + return updateIn(data, dataPath.concat(['items']), (items) => { + const index = parseInt(afterProp) + const updatedItems = items.slice(0) + + updatedItems.splice(index + 1, 0, createDataEntry(type)) + + return updatedItems + }) + } + else { // parent.type === 'object' + return updateIn(data, dataPath.concat(['props']), (props) => { + const index = props.findIndex(p => p.name === afterProp) + const updatedProps = props.slice(0) + + updatedProps.splice(index + 1, 0, { + name: '', + value: createDataEntry(type) + }) + + return updatedProps + }) + } +} + +/** + * Append a new item at the end of an object or array + * @param {JSONData} data + * @param {Path} path + * @param {JSONDataType} type + * @return {JSONData} + */ +export function append (data, path, type) { + console.log('append', path, type) + + const dataPath = toDataPath(data, path) + const object = getIn(data, dataPath) + + if (object.type === 'array') { + return updateIn(data, dataPath.concat(['items']), (items) => { + const updatedItems = items.slice(0) + + updatedItems.push(createDataEntry(type)) + + return updatedItems + }) + } + else { // object.type === 'object' + return updateIn(data, dataPath.concat(['props']), (props) => { + const updatedProps = props.slice(0) + + updatedProps.push({ + name: '', + value: createDataEntry(type) + }) + + return updatedProps + }) + } +} + +/** + * Duplicate a property or item + * @param {JSONData} data + * @param {Path} path + * @param {string | number} prop + * @return {JSONData} + */ +export function duplicate (data, path, prop) { + console.log('duplicate', path) + + const dataPath = toDataPath(data, path) + const object = getIn(data, dataPath) + + if (object.type === 'array') { + return updateIn(data, dataPath.concat(['items']), (items) => { + const index = parseInt(prop) + const updatedItems = items.slice(0) + const original = items[index] + const duplicate = cloneDeep(original) + + updatedItems.splice(index + 1, 0, duplicate) + + return updatedItems + }) + } + else { // object.type === 'object' + return updateIn(data, dataPath.concat(['props']), (props) => { + const index = props.findIndex(p => p.name === prop) + const updated = props.slice(0) + const original = props[index] + const duplicate = cloneDeep(original) + + updated.splice(index + 1, 0, duplicate) + + return updated + }) + } +} + +/** + * Remove an item or property + * @param {JSONData} data + * @param {Path} path + * @param {string | number} prop + * @return {JSONData} + */ +export function remove (data, path, prop) { + console.log('remove', path) + + const object = getIn(data, toDataPath(data, path)) + + if (object.type === 'array') { + const dataPath = toDataPath(data, path.concat(prop)) + + return deleteIn(data, dataPath) + } + else { // object.type === 'object' + const dataPath = toDataPath(data, path.concat(prop)) + + dataPath.pop() // remove the 'value' property, we want to remove the whole object property + return deleteIn(data, dataPath) + } +} + +/** + * Order the items of an array or the properties of an object in ascending + * or descending order + * @param {JSONData} data + * @param {Path} path + * @param {'asc' | 'desc' | null} [order=null] If not provided, will toggle current ordering + * @return {JSONData} + */ +export function sort (data, path, order = null) { + console.log('sort', path, order) + + const dataPath = toDataPath(data, path) + const object = getIn(data, dataPath) + + let _order + if (order === 'asc' || order === 'desc') { + _order = order + } + else { + // toggle previous order + _order = object.order !== 'asc' ? 'asc' : 'desc' + + data = setIn(data, dataPath.concat(['order']), _order) + } + + if (object.type === 'array') { + return updateIn(data, dataPath.concat(['items']), (items) =>{ + const ordered = items.slice(0) + const compare = _order === 'desc' ? compareDesc : compareAsc + + ordered.sort((a, b) => compare(a.value, b.value)) + + return ordered + }) + } + else { // object.type === 'object' + return updateIn(data, dataPath.concat(['props']), (props) => { + const orderedProps = props.slice(0) + const compare = _order === 'desc' ? compareDesc : compareAsc + + orderedProps.sort((a, b) => compare(a.name, b.name)) + + return orderedProps + }) + } +} + +/** + * Expand or collapse an item or property + * @param {JSONData} data + * @param {Path} path + * @param {boolean} expand + * @return {JSONData} + */ +export function expand (data, path, expand) { + console.log('expand', path, expand) + + const dataPath = toDataPath(data, path) + + return setIn(data, dataPath.concat(['expanded']), expand) +} + +/** + * Convert a path of a JSON object into a path in the corresponding data model + * @param {JSONData} data + * @param {Path} path + * @return {Path} dataPath + * @private + */ +export function toDataPath (data, path) { + if (path.length === 0) { + return [] + } + + let index + if (data.type === 'array') { + // index of an array + index = path[0] + + return ['items', index].concat(toDataPath(data.items[index], path.slice(1))) + } + else { + // object property. find the index of this property + index = data.props.findIndex(prop => prop.name === path[0]) + + return ['props', index, 'value'].concat(toDataPath(data.props[index].value, path.slice(1))) + } +} + +/** + * Convert a JSON object into the internally used data model + * @param {Path} path + * @param {Object | Array | string | number | boolean | null} json + * @param {function(path: Path)} expand + * @return {JSONData} + */ +export function jsonToData (path, json, expand) { + if (Array.isArray(json)) { + return { + type: 'array', + expanded: expand(path), + items: json.map((child, index) => jsonToData(path.concat(index), child, expand)) + } + } + else if (isObject(json)) { + return { + type: 'object', + expanded: expand(path), + props: Object.keys(json).map(name => { + return { + name, + value: jsonToData(path.concat(name), json[name], expand) + } + }) + } + } + else { + return { + type: 'value', + value: json + } + } +} + +/** + * Convert the internal data model to a regular JSON object + * @param {JSONData} data + * @return {Object | Array | string | number | boolean | null} json + */ +export function dataToJson (data) { + switch (data.type) { + case 'array': + return data.items.map(dataToJson) + + case 'object': + const object = {} + + data.props.forEach(prop => { + object[prop.name] = dataToJson(prop.value) + }) + + return object + + default: // type 'string' or 'value' + return data.value + } +} + + +/** + * Create a new data entry + * @param {JSONDataType} [type='value'] + * @return {JSONData} + */ +export function createDataEntry (type) { + if (type === 'array') { + return { + type, + expanded: true, + items: [] + } + } + else if (type === 'object') { + return { + type, + expanded: true, + props: [] + } + } + else { + return { + type, + value: '' + } + } +} + +/** + * Convert an entry into a different type. When possible, data is retained + * @param {JSONData} entry + * @param {JSONDataType} type + * @return {JSONData} + */ +export function convertDataEntry (entry, type) { + const convertedEntry = createDataEntry(type) + + // convert contents from old value to new value where possible + if (type === 'value' && entry.type === 'string') { + convertedEntry.value = stringConvert(entry.value) + } + + if (type === 'string' && entry.type === 'value') { + convertedEntry.value = entry.value + '' + } + + if (type === 'object' && entry.type === 'array') { + convertedEntry.props = entry.items.map((item, index) => { + return { + name: index + '', + value: item + } + }) + } + + if (type === 'array' && entry.type === 'object') { + convertedEntry.items = entry.props.map(prop => prop.value) + } + + return convertedEntry +} \ No newline at end of file diff --git a/src/typedef.js b/src/typedef.js index afedba2..fe6cba8 100644 --- a/src/typedef.js +++ b/src/typedef.js @@ -1,27 +1,31 @@ /** * @typedef {{ - * type: string, + * type: 'array', * expanded: boolean?, * menu: boolean?, - * props: Array.<{name: string, value: Data}>? + * props: Array.<{name: string, value: JSONData}>? * }} ObjectData * * @typedef {{ - * type: string, + * type: 'object', * expanded: boolean?, * menu: boolean?, - * items: Data[]? + * items: JSONData[]? * }} ArrayData * * @typedef {{ - * type: string, + * type: 'value' | 'string', * expanded: boolean?, * menu: boolean?, * value: *? * }} ValueData * - * @typedef {ObjectData | ArrayData | ValueData} Data + * @typedef {Array.} Path + * + * @typedef {ObjectData | ArrayData | ValueData} JSONData + * + * @typedef {'object' | 'array' | 'value' | 'string'} JSONDataType * * @typedef {{ * @@ -29,6 +33,6 @@ * * @typedef {{ * name: string?, - * expand: function (path: Array.)? + * expand: function (path: Path)? * }} SetOptions */ \ No newline at end of file