Flatten `META` symbol, remove ordering of object keys from ESON model, simplify patch actions (WIP)

This commit is contained in:
jos 2018-08-19 20:36:57 +02:00
parent d6ad4c87d0
commit 56124cf17f
31 changed files with 3846 additions and 3116 deletions

3463
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -19,9 +19,10 @@
"bugs": "https://github.com/josdejong/jsoneditor/issues", "bugs": "https://github.com/josdejong/jsoneditor/issues",
"private": false, "private": false,
"dependencies": { "dependencies": {
"ajv": "6.5.2", "ajv": "6.5.3",
"brace": "0.11.1", "brace": "0.11.1",
"javascript-natural-sort": "0.7.1", "javascript-natural-sort": "0.7.1",
"jest": "23.5.0",
"lodash": "4.17.10", "lodash": "4.17.10",
"mitt": "1.1.3", "mitt": "1.1.3",
"prop-types": "15.6.2", "prop-types": "15.6.2",
@ -43,8 +44,8 @@
"css-loader": "1.0.0", "css-loader": "1.0.0",
"node-sass-chokidar": "1.3.3", "node-sass-chokidar": "1.3.3",
"npm-run-all": "4.1.3", "npm-run-all": "4.1.3",
"preact": "8.3.0", "preact": "8.3.1",
"preact-compat": "3.18.2", "preact-compat": "3.18.3",
"react": "16.4.2", "react": "16.4.2",
"react-dom": "16.4.2", "react-dom": "16.4.2",
"react-scripts": "1.1.4", "react-scripts": "1.1.4",

View File

@ -159,7 +159,7 @@ class App extends Component {
handlePatch = (patch, revert) => { handlePatch = (patch, revert) => {
this.log('onPatch patch=', patch, ', revert=', revert) this.log('onPatch patch=', patch, ', revert=', revert)
window.patch = patch window.immutableJsonPatch = patch
window.revert = revert window.revert = revert
} }

View File

@ -66,7 +66,7 @@
name: 'myObject', name: 'myObject',
onPatch: function (patch, revert) { onPatch: function (patch, revert) {
log('onPatch patch=', patch, ', revert=', revert) log('onPatch patch=', patch, ', revert=', revert)
window.patch = patch window.immutableJsonPatch = patch
window.revert = revert window.revert = revert
}, },
onPatchText: function (patch, revert) { onPatchText: function (patch, revert) {

View File

@ -1,72 +1,61 @@
import last from 'lodash/last' import last from 'lodash/last'
import initial from 'lodash/initial' import initial from 'lodash/initial'
import isEmpty from 'lodash/isEmpty' import isEmpty from 'lodash/isEmpty'
import { import { findRootPath, findSelectionIndices, pathsFromSelection } from './eson'
META,
compileJSONPointer, esonToJson, findNextProp,
pathsFromSelection, findRootPath, findSelectionIndices
} from './eson'
import { getIn } from './utils/immutabilityHelpers' import { getIn } from './utils/immutabilityHelpers'
import { findUniqueName } from './utils/stringUtils' import { findUniqueName } from './utils/stringUtils'
import { isObject, stringConvert } from './utils/typeUtils' import { isObject, stringConvert } from './utils/typeUtils'
import { compareAsc, compareDesc } from './utils/arrayUtils' import { compareAsc, compareDesc } from './utils/arrayUtils'
import { compileJSONPointer } from './jsonPointer'
/** /**
* Create a JSONPatch to change the value of a property or item * Create a JSONPatch to change the value of a property or item
* @param {ESON} eson * @param {JSON} eson
* @param {Path} path * @param {Path} path
* @param {*} value * @param {*} value
* @return {Array} * @return {Array}
*/ */
export function changeValue (eson, path, value) { export function changeValue (eson, path, value) {
// console.log('changeValue', data, value) // console.log('changeValue', data, value)
const oldDataValue = getIn(eson, path)
return [{ return [{
op: 'replace', op: 'replace',
path: compileJSONPointer(path), path: compileJSONPointer(path),
value: value, value
meta: {
type: oldDataValue[META].type
}
}] }]
} }
/** /**
* Create a JSONPatch to change a property name * Create a JSONPatch to change a property name
* @param {ESON} eson * @param {JSON} json
* @param {Path} parentPath * @param {Path} parentPath
* @param {string} oldProp * @param {string} oldProp
* @param {string} newProp * @param {string} newProp
* @return {Array} * @return {Array}
*/ */
export function changeProperty (eson, parentPath, oldProp, newProp) { export function changeProperty (json, parentPath, oldProp, newProp) {
// console.log('changeProperty', parentPath, oldProp, newProp) // console.log('changeProperty', parentPath, oldProp, newProp)
const parent = getIn(eson, parentPath) const parent = getIn(json, parentPath)
// prevent duplicate property names // prevent duplicate property names
const uniqueNewProp = findUniqueName(newProp, parent[META].props) const uniqueNewProp = findUniqueName(newProp, parent)
return [{ return [{
op: 'move', op: 'move',
from: compileJSONPointer(parentPath.concat(oldProp)), from: compileJSONPointer(parentPath.concat(oldProp)),
path: compileJSONPointer(parentPath.concat(uniqueNewProp)), path: compileJSONPointer(parentPath.concat(uniqueNewProp))
meta: {
before: findNextProp(parent, oldProp)
}
}] }]
} }
/** /**
* Create a JSONPatch to change the type of a property or item * Create a JSONPatch to change the type of a property or item
* @param {ESON} eson * @param {JSON} json
* @param {Path} path * @param {Path} path
* @param {ESONType} type * @param {ESONType} type
* @return {Array} * @return {Array}
*/ */
export function changeType (eson, path, type) { export function changeType (json, path, type) {
const oldValue = esonToJson(getIn(eson, path)) const oldValue = getIn(json, path)
const newValue = convertType(oldValue, type) const newValue = convertType(oldValue, type)
// console.log('changeType', path, type, oldValue, newValue) // console.log('changeType', path, type, oldValue, newValue)
@ -74,10 +63,7 @@ export function changeType (eson, path, type) {
return [{ return [{
op: 'replace', op: 'replace',
path: compileJSONPointer(path), path: compileJSONPointer(path),
value: newValue, value: newValue
meta: {
type
}
}] }]
} }
@ -88,42 +74,37 @@ export function changeType (eson, path, type) {
* a unique property name for the duplicated node in case of duplicating * a unique property name for the duplicated node in case of duplicating
* and object property * and object property
* *
* @param {ESON} eson * @param {JSON} json
* @param {Selection} selection * @param {Selection} selection
* @return {Array} * @return {Array}
*/ */
export function duplicate (eson, selection) { export function duplicate (json, selection) {
// console.log('duplicate', path) // console.log('duplicate', path)
if (!selection.start || !selection.end) { if (!selection.start || !selection.end) {
return [] return []
} }
const rootPath = findRootPath(selection) const rootPath = findRootPath(selection)
const root = getIn(eson, rootPath) const root = getIn(json, rootPath)
const { maxIndex } = findSelectionIndices(root, rootPath, selection) const { maxIndex } = findSelectionIndices(root, rootPath, selection)
const paths = pathsFromSelection(eson, selection) const paths = pathsFromSelection(json, selection)
if (root[META].type === 'Array') { if (Array.isArray(root)) {
return paths.map((path, offset) => ({ return paths.map((path, offset) => ({
op: 'copy', op: 'copy',
from: compileJSONPointer(path), from: compileJSONPointer(path),
path: compileJSONPointer(rootPath.concat(maxIndex + offset)) path: compileJSONPointer(rootPath.concat(maxIndex + offset))
})) }))
} }
else { // root[META].type === 'Object' else { // 'object'
const before = root[META].props[maxIndex] || null
return paths.map(path => { return paths.map(path => {
const prop = last(path) const prop = last(path)
const newProp = findUniqueName(prop, root[META].props) const newProp = findUniqueName(prop, root)
return { return {
op: 'copy', op: 'copy',
from: compileJSONPointer(path), from: compileJSONPointer(path),
path: compileJSONPointer(rootPath.concat(newProp)), path: compileJSONPointer(rootPath.concat(newProp))
meta: {
before
}
} }
}) })
} }
@ -136,38 +117,31 @@ export function duplicate (eson, selection) {
* a unique property name for the inserted node in case of duplicating * a unique property name for the inserted node in case of duplicating
* and object property * and object property
* *
* @param {ESON} eson * @param {JSON} json
* @param {Path} path * @param {Path} path
* @param {Array.<{name?: string, value: JSON, type?: ESONType}>} values * @param {Array.<{name?: string, value: JSON}>} values
* @return {Array} * @return {Array}
*/ */
export function insertBefore (eson, path, values) { // TODO: find a better name and define datastructure for values export function insertBefore (json, path, values) { // TODO: find a better name and define datastructure for values
// TODO: refactor. path should be parent path
const parentPath = initial(path) const parentPath = initial(path)
const parent = getIn(eson, parentPath) const parent = getIn(json, parentPath)
if (parent[META].type === 'Array') { if (Array.isArray(parent)) {
const startIndex = parseInt(last(path), 10) const startIndex = parseInt(last(path), 10)
return values.map((entry, offset) => ({ return values.map((entry, offset) => ({
op: 'add', op: 'add',
path: compileJSONPointer(parentPath.concat(startIndex + offset)), path: compileJSONPointer(parentPath.concat(startIndex + offset)),
value: entry.value, value: entry.value
meta: {
type: entry.type
}
})) }))
} }
else { // parent[META].type === 'Object' else { // 'object'
const before = last(path)
return values.map(entry => { return values.map(entry => {
const newProp = findUniqueName(entry.name, parent[META].props) const newProp = findUniqueName(entry.name, parent)
return { return {
op: 'add', op: 'add',
path: compileJSONPointer(parentPath.concat(newProp)), path: compileJSONPointer(parentPath.concat(newProp)),
value: entry.value, value: entry.value
meta: {
type: entry.type,
before
}
} }
}) })
} }
@ -180,40 +154,31 @@ export function insertBefore (eson, path, values) { // TODO: find a better name
* a unique property name for the inserted node in case of duplicating * a unique property name for the inserted node in case of duplicating
* and object property * and object property
* *
* @param {ESON} eson * @param {JSON} json
* @param {Path} path * @param {Path} path
* @param {Array.<{name?: string, value: JSON, type?: ESONType}>} values * @param {Array.<{name?: string, value: JSON}>} values
* @return {Array} * @return {Array}
*/ */
export function insertAfter (eson, path, values) { // TODO: find a better name and define datastructure for values export function insertAfter (json, path, values) { // TODO: find a better name and define datastructure for values
// TODO: refactor. path should be parent path
const parentPath = initial(path) const parentPath = initial(path)
const parent = getIn(eson, parentPath) const parent = getIn(json, parentPath)
if (parent[META].type === 'Array') { if (Array.isArray(parent)) {
const startIndex = parseInt(last(path), 10) const startIndex = parseInt(last(path), 10)
return values.map((entry, offset) => ({ return values.map((entry, offset) => ({
op: 'add', op: 'add',
path: compileJSONPointer(parentPath.concat(startIndex + 1 + offset)), // +1 to insert after path: compileJSONPointer(parentPath.concat(startIndex + 1 + offset)), // +1 to insert after
value: entry.value, value: entry.value
meta: {
type: entry.type
}
})) }))
} }
else { // parent[META].type === 'Object' else { // 'object'
const prop = last(path)
const propIndex = parent[META].props.indexOf(prop)
const before = parent[META].props[propIndex + 1]
return values.map(entry => { return values.map(entry => {
const newProp = findUniqueName(entry.name, parent[META].props) const newProp = findUniqueName(entry.name, parent)
return { return {
op: 'add', op: 'add',
path: compileJSONPointer(parentPath.concat(newProp)), path: compileJSONPointer(parentPath.concat(newProp)),
value: entry.value, value: entry.value
meta: {
type: entry.type,
before
}
} }
}) })
} }
@ -221,20 +186,20 @@ export function insertAfter (eson, path, values) { // TODO: find a better name
/** /**
* Insert values at the start of an Object or Array * Insert values at the start of an Object or Array
* @param {ESON} eson * @param {JSON} json
* @param {Path} parentPath * @param {Path} parentPath
* @param {Array.<{name?: string, value: JSON, type?: ESONType}>} values * @param {Array.<{name?: string, value: JSON}>} values
* @return {Array} * @return {Array}
*/ */
export function insertInside (eson, parentPath, values) { export function insertInside (json, parentPath, values) {
const parent = getIn(eson, parentPath) const parent = getIn(json, parentPath)
if (parent[META].type === 'Array') { if (Array.isArray(parent)) {
return insertBefore(eson, parentPath.concat('0'), values) return insertBefore(json, parentPath.concat('0'), values)
} }
else if (parent[META].type === 'Object') { else if (parent && typeof parent === 'object') {
const firstProp = parent[META].props[0] || null // TODO: refactor. path should be parent path
return insertBefore(eson, parentPath.concat(firstProp), values) return insertBefore(json, parentPath.concat('foobar'), values)
} }
else { else {
throw new Error('Cannot insert in a value, only in an Object or Array') throw new Error('Cannot insert in a value, only in an Object or Array')
@ -248,43 +213,34 @@ export function insertInside (eson, parentPath, values) {
* a unique property name for the inserted node in case of duplicating * a unique property name for the inserted node in case of duplicating
* and object property * and object property
* *
* @param {ESON} eson * @param {JSON} json
* @param {Selection} selection * @param {Selection} selection
* @param {Array.<{name?: string, value: JSON, state: Object}>} values * @param {Array.<{name?: string, value: JSON}>} values
* @return {Array} * @return {Array}
*/ */
export function replace (eson, selection, values) { // TODO: find a better name and define datastructure for values export function replace (json, selection, values) { // TODO: find a better name and define datastructure for values
const rootPath = findRootPath(selection) const rootPath = findRootPath(selection)
const root = getIn(eson, rootPath) const root = getIn(json, rootPath)
const { minIndex, maxIndex } = findSelectionIndices(root, rootPath, selection) const { minIndex, maxIndex } = findSelectionIndices(root, rootPath, selection)
if (root[META].type === 'Array') { if (Array.isArray(root)) {
const removeActions = removeAll(pathsFromSelection(eson, selection)) const removeActions = removeAll(pathsFromSelection(json, selection))
const insertActions = values.map((entry, offset) => ({ const insertActions = values.map((entry, offset) => ({
op: 'add', op: 'add',
path: compileJSONPointer(rootPath.concat(minIndex + offset)), path: compileJSONPointer(rootPath.concat(minIndex + offset)),
value: entry.value, value: entry.value
meta: {
state: entry.state
}
})) }))
return removeActions.concat(insertActions) return removeActions.concat(insertActions)
} }
else { // root[META].type === 'Object' else { // root is Object
const before = root[META].props[maxIndex] || null const removeActions = removeAll(pathsFromSelection(json, selection))
const removeActions = removeAll(pathsFromSelection(eson, selection))
const insertActions = values.map(entry => { const insertActions = values.map(entry => {
const newProp = findUniqueName(entry.name, root[META].props) const newProp = findUniqueName(entry.name, root)
return { return {
op: 'add', op: 'add',
path: compileJSONPointer(rootPath.concat(newProp)), path: compileJSONPointer(rootPath.concat(newProp)),
value: entry.value, value: entry.value
meta: {
before,
state: entry.state
}
} }
}) })
@ -299,37 +255,31 @@ export function replace (eson, selection, values) { // TODO: find a better name
* a unique property name for the inserted node in case of duplicating * a unique property name for the inserted node in case of duplicating
* and object property * and object property
* *
* @param {ESON} eson * @param {JSON} json
* @param {Path} parentPath * @param {Path} parentPath
* @param {ESONType} type * @param {ESONType} type
* @return {Array} * @return {Array}
*/ */
export function append (eson, parentPath, type) { export function append (json, parentPath, type) {
// console.log('append', parentPath, value) // console.log('append', parentPath, value)
const parent = getIn(eson, parentPath) const parent = getIn(json, parentPath)
const value = createEntry(type) const value = createEntry(type)
if (parent[META].type === 'Array') { if (Array.isArray(parent)) {
return [{ return [{
op: 'add', op: 'add',
path: compileJSONPointer(parentPath.concat('-')), path: compileJSONPointer(parentPath.concat('-')),
value, value
meta: {
type
}
}] }]
} }
else { // parent[META].type === 'Object' else { // 'object'
const newProp = findUniqueName('', parent[META].props) const newProp = findUniqueName('', parent)
return [{ return [{
op: 'add', op: 'add',
path: compileJSONPointer(parentPath.concat(newProp)), path: compileJSONPointer(parentPath.concat(newProp)),
value, value
meta: {
type
}
}] }]
} }
} }
@ -364,55 +314,35 @@ export function removeAll (paths) {
/** /**
* Create a JSONPatch to order the items of an array or the properties of an object in ascending * Create a JSONPatch to order the items of an array or the properties of an object in ascending
* or descending order * or descending order
* @param {ESON} eson * @param {JSON} json
* @param {Path} path * @param {Path} path
* @param {'asc' | 'desc' | null} [order=null] If not provided, will toggle current ordering * @param {'asc' | 'desc' | null} [order=null] If not provided, will toggle current ordering
* @return {Array} * @return {Array}
*/ */
export function sort (eson, path, order = null) { export function sort (json, path, order = null) {
const compare = order === 'desc' ? compareDesc : compareAsc const compare = order === 'desc' ? compareDesc : compareAsc
const reverseCompare = (a, b) => -compare(a, b) const reverseCompare = (a, b) => -compare(a, b)
const object = getIn(eson, path) const object = getIn(json, path)
if (object[META].type === 'Array') { if (Array.isArray(object)) {
const items = object.map(item => item[META].value)
const createAction = ({item, fromIndex, toIndex}) => ({ const createAction = ({item, fromIndex, toIndex}) => ({
op: 'move', op: 'move',
from: compileJSONPointer(path.concat(String(fromIndex))), from: compileJSONPointer(path.concat(String(fromIndex))),
path: compileJSONPointer(path.concat(String(toIndex))) path: compileJSONPointer(path.concat(String(toIndex)))
}) })
const actions = sortWithComparator(items, compare).map(createAction) const actions = sortWithComparator(object, compare).map(createAction)
// when no order is provided, test whether ordering ascending // when no order is provided, test whether ordering ascending
// changed anything. If not, sort descending // changed anything. If not, sort descending
if (!order && isEmpty(actions)) { if (!order && isEmpty(actions)) {
return sortWithComparator(items, reverseCompare).map(createAction) return sortWithComparator(object, reverseCompare).map(createAction)
} }
return actions return actions
} }
else { // object[META].type === 'Object' else { // object is an Object, we don't allow sorting properties
return []
const props = object[META].props
const createAction = ({item, beforeItem, fromIndex, toIndex}) => ({
op: 'move',
from: compileJSONPointer(path.concat(item)),
path: compileJSONPointer(path.concat(item)),
meta: {
before: beforeItem
}
})
const actions = sortWithComparator(props, compare).map(createAction)
// when no order is provided, test whether ordering ascending
// changed anything. If not, sort descending
if (!order && isEmpty(actions)) {
return sortWithComparator(props, reverseCompare).map(createAction)
}
return actions
} }
} }
@ -466,10 +396,10 @@ function sortWithComparator (items, comparator) {
* @return {Array | Object | string} * @return {Array | Object | string}
*/ */
export function createEntry (type) { export function createEntry (type) {
if (type === 'Array') { if (type === 'array') {
return [] return []
} }
else if (type === 'Object') { else if (type === 'object') {
return {} return {}
} }
else { else {
@ -503,7 +433,7 @@ export function convertType (value, type) {
} }
} }
if (type === 'Object') { if (type === 'object') {
let object = {} let object = {}
if (Array.isArray(value)) { if (Array.isArray(value)) {
@ -513,7 +443,7 @@ export function convertType (value, type) {
return object return object
} }
if (type === 'Array') { if (type === 'array') {
let array = [] let array = []
if (isObject(value)) { if (isObject(value)) {

View File

@ -1,9 +1,11 @@
'use strict' 'use strict'
import { sort } from './actions' import { sort } from './actions'
import { assertDeepEqualEson } from './utils/assertDeepEqualEson' import { createAssertEqualEson } from './utils/assertEqualEson'
import {esonToJson, expandOne, jsonToEson, META} from './eson' import { ID, syncEson } from './eson'
import {patchEson} from './patchEson' import { immutableJsonPatch } from './immutableJsonPatch'
const assertEqualEson = createAssertEqualEson(expect)
// TODO: test changeValue // TODO: test changeValue
// TODO: test changeProperty // TODO: test changeProperty
@ -16,66 +18,28 @@ import {patchEson} from './patchEson'
// TODO: test removeAll // TODO: test removeAll
it('sort root Array', () => { it('sort root Array', () => {
const eson = jsonToEson([1,3,2]) const eson = syncEson([1,3,2])
assertDeepEqualEson(patchEson(eson, sort(eson, [])).data, jsonToEson([1,2,3])) assertEqualEson(immutableJsonPatch(eson, sort(eson, [])).json, syncEson([1,2,3]))
assertDeepEqualEson(patchEson(eson, sort(eson, [], 'asc')).data, jsonToEson([1,2,3])) assertEqualEson(immutableJsonPatch(eson, sort(eson, [], 'asc')).json, syncEson([1,2,3]))
assertDeepEqualEson(patchEson(eson, sort(eson, [], 'desc')).data, jsonToEson([3,2,1])) assertEqualEson(immutableJsonPatch(eson, sort(eson, [], 'desc')).json, syncEson([3,2,1]))
}) })
it('sort nested Array', () => { it('sort nested Array', () => {
const eson = jsonToEson({arr: [4,1,8,5,3,9,2,7,6]}) const eson = syncEson({arr: [4,1,8,5,3,9,2,7,6]})
const actual = patchEson(eson, sort(eson, ['arr'])).data const actual = immutableJsonPatch(eson, sort(eson, ['arr'])).json
const expected = jsonToEson({arr: [1,2,3,4,5,6,7,8,9]}) const expected = syncEson({arr: [1,2,3,4,5,6,7,8,9]})
assertDeepEqualEson(actual, expected) assertEqualEson(actual, expected)
}) })
it('sort nested Array reverse order', () => { it('sort nested Array reverse order', () => {
// no order provided -> order ascending, but if nothing changes, order descending // no order provided -> order ascending, but if nothing changes, order descending
const eson = jsonToEson({arr: [1,2,3,4,5,6,7,8,9]}) const eson = syncEson({arr: [1,2,3,4,5,6,7,8,9]})
const actual = patchEson(eson, sort(eson, ['arr'])).data const actual = immutableJsonPatch(eson, sort(eson, ['arr'])).json
const expected = jsonToEson({arr: [9,8,7,6,5,4,3,2,1]}) const expected = syncEson({arr: [9,8,7,6,5,4,3,2,1]})
assertDeepEqualEson(actual, expected) assertEqualEson(actual, expected)
// id's and META should be the same // id's and META should be the same
expect(actual.arr[META].id).toEqual(eson.arr[META].id) expect(actual.arr[ID]).toEqual(eson.arr[ID])
expect(actual.arr[7][META].id).toEqual(eson.arr[1][META].id) expect(actual.arr[7][ID]).toEqual(eson.arr[1][ID])
})
it('sort root Object', () => {
const eson = jsonToEson({c: 2, b: 3, a:4})
expect(patchEson(eson, sort(eson, [])).data[META].props).toEqual(['a', 'b', 'c'])
expect(patchEson(eson, sort(eson, [], 'asc')).data[META].props).toEqual(['a', 'b', 'c'])
expect(patchEson(eson, sort(eson, [], 'desc')).data[META].props).toEqual(['c', 'b', 'a'])
})
it('sort nested Object', () => {
const eson = jsonToEson({obj: {c: 2, b: 3, a:4}})
eson.obj[META].expanded = true
eson.obj.c[META].expanded = true
const actual = patchEson(eson, sort(eson, ['obj'])).data
// should keep META data
expect(actual.obj[META].props).toEqual(['a', 'b', 'c'])
expect(actual.obj[META].expanded).toEqual(true)
expect(actual.obj.c[META].expanded).toEqual(true)
expect(actual.obj[META].id).toEqual(eson.obj[META].id)
expect(actual.obj.a[META].id).toEqual(eson.obj.a[META].id)
expect(actual.obj.b[META].id).toEqual(eson.obj.b[META].id)
expect(actual.obj.c[META].id).toEqual(eson.obj.c[META].id)
// asc, desc
expect(patchEson(eson, sort(eson, ['obj'])).data.obj[META].props).toEqual(['a', 'b', 'c'])
expect(patchEson(eson, sort(eson, ['obj'], 'asc')).data.obj[META].props).toEqual(['a', 'b', 'c'])
expect(patchEson(eson, sort(eson, ['obj'], 'desc')).data.obj[META].props).toEqual(['c', 'b', 'a'])
})
it('sort nested Object (larger)', () => {
const eson = jsonToEson({obj: {h:1, c:1, e:1, d:1, g:1, b:1, a:1, f:1}})
const actual = patchEson(eson, sort(eson, ['obj'])).data
expect(actual.obj[META].props).toEqual(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'])
}) })

View File

@ -50,8 +50,6 @@ export default class JSONEditor extends PureComponent {
} }
handleChangeMode = (mode) => { handleChangeMode = (mode) => {
console.log('changeMode', mode, this.props.onChangeMode)
if (this.props.onChangeMode) { if (this.props.onChangeMode) {
this.props.onChangeMode(mode, this.props.mode) this.props.onChangeMode(mode, this.props.mode)
} }

View File

@ -1,24 +1,27 @@
import { createElement as h, PureComponent } from 'react' import { createElement as h, PureComponent } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import initial from 'lodash/initial' import initial from 'lodash/initial'
import naturalSort from 'javascript-natural-sort'
import FloatingMenu from './menu/FloatingMenu' import FloatingMenu from './menu/FloatingMenu'
import { escapeHTML, unescapeHTML } from '../utils/stringUtils' import { escapeHTML, unescapeHTML } from '../utils/stringUtils'
import { getInnerText, insideRect } from '../utils/domUtils' import { getInnerText, insideRect } from '../utils/domUtils'
import { stringConvert, valueType, isUrl } from '../utils/typeUtils' import { stringConvert, valueType, isUrl } from '../utils/typeUtils'
import { import {
compileJSONPointer,
META,
SELECTED, SELECTED_START, SELECTED_END, SELECTED_AFTER, SELECTED_INSIDE, SELECTED, SELECTED_START, SELECTED_END, SELECTED_AFTER, SELECTED_INSIDE,
SELECTED_FIRST, SELECTED_LAST SELECTED_FIRST, SELECTED_LAST
} from '../eson' } from '../eson'
import { compileJSONPointer } from '../jsonPointer'
import { ERROR, EXPANDED, ID, SEARCH_PROPERTY, SEARCH_VALUE, SELECTION, TYPE, VALUE } from '../eson'
export default class JSONNode extends PureComponent { export default class JSONNode extends PureComponent {
static URL_TITLE = 'Ctrl+Click or Ctrl+Enter to open url' static URL_TITLE = 'Ctrl+Click or Ctrl+Enter to open url'
static propTypes = { static propTypes = {
parentPath: PropTypes.array,
prop: PropTypes.string, // in case of an object property prop: PropTypes.string, // in case of an object property
value: PropTypes.oneOfType([ PropTypes.object, PropTypes.array ]).isRequired, index: PropTypes.number, // in case of an array item
eson: PropTypes.any, // enriched JSON object: Object, Array, number, string, or null
emit: PropTypes.func.isRequired, emit: PropTypes.func.isRequired,
findKeyBinding: PropTypes.func.isRequired, findKeyBinding: PropTypes.func.isRequired,
@ -31,10 +34,17 @@ export default class JSONNode extends PureComponent {
}) })
} }
state = { constructor(props) {
super(props)
this.state = {
menu: null, // can contain object {anchor, root} menu: null, // can contain object {anchor, root}
appendMenu: null, // can contain object {anchor, root} appendMenu: null, // can contain object {anchor, root}
hover: null hover: null,
path: this.props.parentPath
? this.props.parentPath.concat('index' in this.props ? this.props.index : this.props.prop)
: []
}
} }
componentWillUnmount () { componentWillUnmount () {
@ -44,14 +54,12 @@ export default class JSONNode extends PureComponent {
} }
render () { render () {
// console.log('JSONNode.render ' + JSON.stringify(this.props.value[META].path)) if (this.props.eson[TYPE] === 'array') {
const type = this.props.value[META].type
if (type === 'Object') {
return this.renderJSONObject()
}
else if (type === 'Array') {
return this.renderJSONArray() return this.renderJSONArray()
} }
else if (this.props.eson[TYPE] === 'object') {
return this.renderJSONObject()
}
else { // no Object or Array else { // no Object or Array
return this.renderJSONValue() return this.renderJSONValue()
} }
@ -59,8 +67,10 @@ export default class JSONNode extends PureComponent {
renderJSONObject () { renderJSONObject () {
// TODO: refactor renderJSONObject (too large/complex) // TODO: refactor renderJSONObject (too large/complex)
const meta = this.props.value[META] const eson = this.props.eson
const props = meta.props const jsonProps = Object.keys(eson).sort(naturalSort)
const jsonPropsCount = jsonProps.length
const nodeStart = h('div', { const nodeStart = h('div', {
key: 'node', key: 'node',
onKeyDown: this.handleKeyDown, onKeyDown: this.handleKeyDown,
@ -71,26 +81,27 @@ export default class JSONNode extends PureComponent {
this.renderProperty(), this.renderProperty(),
this.renderSeparator(), this.renderSeparator(),
this.renderDelimiter('{', 'jsoneditor-delimiter-start'), this.renderDelimiter('{', 'jsoneditor-delimiter-start'),
!meta.expanded !this.props.eson[EXPANDED]
? [ ? [
this.renderTag(`${props.length} ${props.length === 1 ? 'prop' : 'props'}`, this.renderTag(`${jsonPropsCount} ${jsonPropsCount === 1 ? 'prop' : 'props'}`,
`Object containing ${props.length} ${props.length === 1 ? 'property' : 'properties'}`), `Object containing ${jsonPropsCount} ${jsonPropsCount === 1 ? 'property' : 'properties'}`),
this.renderDelimiter('}', 'jsoneditor-delimiter-end jsoneditor-delimiter-collapsed'), this.renderDelimiter('}', 'jsoneditor-delimiter-end jsoneditor-delimiter-collapsed'),
this.renderInsertAfter() this.renderInsertAfter()
] ]
: [ : [
this.renderInsertBefore() this.renderInsertBefore()
], ],
this.renderError(meta.error) this.renderError(this.props.eson[ERROR])
]) ])
let childs let childs
if (meta.expanded) { if (this.props.eson[EXPANDED]) {
if (props.length > 0) { if (jsonPropsCount > 0) {
const propsChilds = props.map(prop => h(this.constructor, { const propsChilds = jsonProps.map((prop) => h(this.constructor, {
key: this.props.value[prop][META].id, key: eson[prop][ID],
parentPath: this.state.path,
prop, prop,
value: this.props.value[prop], eson: eson[prop],
emit: this.props.emit, emit: this.props.emit,
findKeyBinding: this.props.findKeyBinding, findKeyBinding: this.props.findKeyBinding,
options: this.props.options options: this.props.options
@ -105,8 +116,9 @@ export default class JSONNode extends PureComponent {
} }
} }
const floatingMenu = this.renderFloatingMenu('Object', meta.selected) // FIXME
const nodeEnd = meta.expanded const floatingMenu = this.renderFloatingMenu('object', this.props.eson[SELECTION])
const nodeEnd = this.props.eson[EXPANDED]
? h('div', {key: 'node-end', className: 'jsoneditor-node-end', 'data-area': 'empty'}, [ ? h('div', {key: 'node-end', className: 'jsoneditor-node-end', 'data-area': 'empty'}, [
this.renderDelimiter('}', 'jsoneditor-delimiter-end'), this.renderDelimiter('}', 'jsoneditor-delimiter-end'),
this.renderInsertAfter() this.renderInsertAfter()
@ -114,9 +126,9 @@ export default class JSONNode extends PureComponent {
: null : null
return h('div', { return h('div', {
'data-path': compileJSONPointer(meta.path), 'data-path': compileJSONPointer(this.state.path),
'data-area': 'empty', 'data-area': 'empty',
className: this.getContainerClassName(meta.selected, this.state.hover), className: this.getContainerClassName(this.props.eson[SELECTION], this.state.hover),
// onMouseOver: this.handleMouseOver, // onMouseOver: this.handleMouseOver,
// onMouseLeave: this.handleMouseLeave // onMouseLeave: this.handleMouseLeave
}, [floatingMenu, nodeStart, childs, nodeEnd]) }, [floatingMenu, nodeStart, childs, nodeEnd])
@ -124,8 +136,7 @@ export default class JSONNode extends PureComponent {
renderJSONArray () { renderJSONArray () {
// TODO: refactor renderJSONArray (too large/complex) // TODO: refactor renderJSONArray (too large/complex)
const meta = this.props.value[META] const count = this.props.eson.length
const count = this.props.value.length
const nodeStart = h('div', { const nodeStart = h('div', {
key: 'node', key: 'node',
onKeyDown: this.handleKeyDown, onKeyDown: this.handleKeyDown,
@ -135,25 +146,27 @@ export default class JSONNode extends PureComponent {
this.renderProperty(), this.renderProperty(),
this.renderSeparator(), this.renderSeparator(),
this.renderDelimiter('[', 'jsoneditor-delimiter-start'), this.renderDelimiter('[', 'jsoneditor-delimiter-start'),
!meta.expanded !this.props.eson[EXPANDED]
? [ ? [
this.renderTag(`${count} ${count === 1 ? 'item' : 'items'}`, this.renderTag(`${count} ${count === 1 ? 'item' : 'items'}`,
`Array containing ${count} item${count === 1 ? 'item' : 'items'}`), `Array containing ${count} ${count === 1 ? 'item' : 'items'}`),
this.renderDelimiter(']', 'jsoneditor-delimiter-end jsoneditor-delimiter-collapsed'), this.renderDelimiter(']', 'jsoneditor-delimiter-end jsoneditor-delimiter-collapsed'),
this.renderInsertAfter(), this.renderInsertAfter(),
] ]
: [ : [
this.renderInsertBefore() this.renderInsertBefore()
], ],
this.renderError(meta.error) this.renderError(this.props.eson[ERROR])
]) ])
let childs let childs
if (meta.expanded) { if (this.props.eson[EXPANDED]) {
if (count > 0) { if (count > 0) {
const items = this.props.value.map(item => h(this.constructor, { const items = this.props.eson.map((item, index) => h(this.constructor, {
key : item[META].id, key: item[ID],
value: item, parentPath: this.state.path,
index,
eson: item,
options: this.props.options, options: this.props.options,
emit: this.props.emit, emit: this.props.emit,
findKeyBinding: this.props.findKeyBinding findKeyBinding: this.props.findKeyBinding
@ -168,8 +181,8 @@ export default class JSONNode extends PureComponent {
} }
} }
const floatingMenu = this.renderFloatingMenu('Array', meta.selected) const floatingMenu = this.renderFloatingMenu('array', this.props.eson[SELECTION])
const nodeEnd = meta.expanded const nodeEnd = this.props.eson[EXPANDED]
? h('div', {key: 'node-end', className: 'jsoneditor-node-end', 'data-area': 'empty'}, [ ? h('div', {key: 'node-end', className: 'jsoneditor-node-end', 'data-area': 'empty'}, [
this.renderDelimiter(']', 'jsoneditor-delimiter-end'), this.renderDelimiter(']', 'jsoneditor-delimiter-end'),
this.renderInsertAfter() this.renderInsertAfter()
@ -177,16 +190,15 @@ export default class JSONNode extends PureComponent {
: null : null
return h('div', { return h('div', {
'data-path': compileJSONPointer(meta.path), 'data-path': compileJSONPointer(this.state.path),
'data-area': 'empty', 'data-area': 'empty',
className: this.getContainerClassName(meta.selected, this.state.hover), className: this.getContainerClassName(this.props.eson[SELECTION], this.state.hover),
// onMouseOver: this.handleMouseOver, // onMouseOver: this.handleMouseOver,
// onMouseLeave: this.handleMouseLeave // onMouseLeave: this.handleMouseLeave
}, [floatingMenu, nodeStart, childs, nodeEnd]) }, [floatingMenu, nodeStart, childs, nodeEnd])
} }
renderJSONValue () { renderJSONValue () {
const meta = this.props.value[META]
const node = h('div', { const node = h('div', {
key: 'node', key: 'node',
onKeyDown: this.handleKeyDown, onKeyDown: this.handleKeyDown,
@ -195,19 +207,19 @@ export default class JSONNode extends PureComponent {
this.renderPlaceholder(), this.renderPlaceholder(),
this.renderProperty(), this.renderProperty(),
this.renderSeparator(), this.renderSeparator(),
this.renderValue(meta.value, meta.searchValue, this.props.options), this.renderValue(this.props.eson[VALUE], this.props.eson[SEARCH_VALUE], this.props.options), // FIXME
this.renderInsertAfter(), this.renderInsertAfter(),
this.renderError(meta.error) this.renderError(this.props.eson[ERROR])
]) ])
const floatingMenu = this.renderFloatingMenu('value', meta.selected) const floatingMenu = this.renderFloatingMenu('value', this.props.eson[SELECTION])
// const insertArea = this.renderInsertBeforeArea() // const insertArea = this.renderInsertBeforeArea()
return h('div', { return h('div', {
'data-path': compileJSONPointer(meta.path), 'data-path': compileJSONPointer(this.state.path),
'data-area': 'empty', 'data-area': 'empty',
className: this.getContainerClassName(meta.selected, this.state.hover), className: this.getContainerClassName(this.props.eson[SELECTION], this.state.hover),
// onMouseOver: this.handleMouseOver, // onMouseOver: this.handleMouseOver,
// onMouseLeave: this.handleMouseLeave // onMouseLeave: this.handleMouseLeave
}, [node, floatingMenu]) }, [node, floatingMenu])
@ -238,7 +250,7 @@ export default class JSONNode extends PureComponent {
*/ */
renderAppend (text) { renderAppend (text) {
return h('div', { return h('div', {
'data-path': compileJSONPointer(this.props.value[META].path) + '/-', 'data-path': compileJSONPointer(this.state.path) + '/-',
'data-area': 'empty', 'data-area': 'empty',
className: 'jsoneditor-node', className: 'jsoneditor-node',
onKeyDown: this.handleKeyDownAppend onKeyDown: this.handleKeyDownAppend
@ -287,10 +299,10 @@ export default class JSONNode extends PureComponent {
} }
const editable = !this.props.options.isPropertyEditable || const editable = !this.props.options.isPropertyEditable ||
this.props.options.isPropertyEditable(this.props.value[META].path) this.props.options.isPropertyEditable(this.state.path)
const emptyClassName = (this.props.prop != null && this.props.prop.length === 0) ? ' jsoneditor-empty' : '' const emptyClassName = (this.props.prop != null && this.props.prop.length === 0) ? ' jsoneditor-empty' : ''
const searchClassName = this.props.prop != null ? JSONNode.getSearchResultClass(this.props.value[META].searchProperty) : '' const searchClassName = this.props.prop != null ? JSONNode.getSearchResultClass(this.props.eson[SEARCH_PROPERTY]) : ''
const escapedPropName = this.props.prop != null ? escapeHTML(this.props.prop, this.props.options.escapeUnicode) : null const escapedPropName = this.props.prop != null ? escapeHTML(this.props.prop, this.props.options.escapeUnicode) : null
if (editable) { if (editable) {
@ -341,7 +353,7 @@ export default class JSONNode extends PureComponent {
const itsAnUrl = isUrl(value) const itsAnUrl = isUrl(value)
const isEmpty = escapedValue.length === 0 const isEmpty = escapedValue.length === 0
const editable = !options.isValueEditable || options.isValueEditable(this.props.value[META].path) const editable = !options.isValueEditable || options.isValueEditable(this.state.path)
if (editable) { if (editable) {
return h('div', { return h('div', {
key: 'value', key: 'value',
@ -460,7 +472,7 @@ export default class JSONNode extends PureComponent {
} }
target.className = JSONNode.getValueClass(type, itsAnUrl, isEmpty) + target.className = JSONNode.getValueClass(type, itsAnUrl, isEmpty) +
JSONNode.getSearchResultClass(this.props.value[META].searchValue) JSONNode.getSearchResultClass(this.props.eson[SEARCH_VALUE])
target.title = itsAnUrl ? JSONNode.URL_TITLE : '' target.title = itsAnUrl ? JSONNode.URL_TITLE : ''
// remove all classNames from childs (needed for IE and Edge) // remove all classNames from childs (needed for IE and Edge)
@ -514,7 +526,7 @@ export default class JSONNode extends PureComponent {
} }
renderExpandButton () { renderExpandButton () {
const className = `jsoneditor-button jsoneditor-${this.props.value[META].expanded ? 'expanded' : 'collapsed'}` const className = `jsoneditor-button jsoneditor-${this.props.eson[EXPANDED] ? 'expanded' : 'collapsed'}`
return h('div', {key: 'expand', className: 'jsoneditor-button-container'}, return h('div', {key: 'expand', className: 'jsoneditor-button-container'},
h('button', { h('button', {
@ -541,7 +553,7 @@ export default class JSONNode extends PureComponent {
return h(FloatingMenu, { return h(FloatingMenu, {
key: 'floating-menu', key: 'floating-menu',
path: this.props.value[META].path, path: this.state.path,
emit: this.props.emit, emit: this.props.emit,
items: this.getFloatingMenuItems(type, selected), items: this.getFloatingMenuItems(type, selected),
position: isLastOfMultiple || isAfter ? 'bottom' : 'top' position: isLastOfMultiple || isAfter ? 'bottom' : 'top'
@ -569,7 +581,7 @@ export default class JSONNode extends PureComponent {
] ]
} }
if (type === 'Object') { if (type === 'object') {
return [ return [
{type: 'sort'}, {type: 'sort'},
{type: 'duplicate'}, {type: 'duplicate'},
@ -580,7 +592,7 @@ export default class JSONNode extends PureComponent {
] ]
} }
if (type === 'Array') { if (type === 'array') {
return [ return [
{type: 'sort'}, {type: 'sort'},
{type: 'duplicate'}, {type: 'duplicate'},
@ -636,12 +648,12 @@ export default class JSONNode extends PureComponent {
static getRootName (value, options) { static getRootName (value, options) {
return typeof options.name === 'string' return typeof options.name === 'string'
? options.name ? options.name
: value[META].type : value[TYPE]
} }
/** @private */ /** @private */
handleChangeProperty = (event) => { handleChangeProperty = (event) => {
const parentPath = initial(this.props.value[META].path) const parentPath = initial(this.state.path)
const oldProp = this.props.prop const oldProp = this.props.prop
const newProp = unescapeHTML(getInnerText(event.target)) const newProp = unescapeHTML(getInnerText(event.target))
@ -653,9 +665,9 @@ export default class JSONNode extends PureComponent {
/** @private */ /** @private */
handleChangeValue = (event) => { handleChangeValue = (event) => {
const value = this.getValueFromEvent(event) const value = this.getValueFromEvent(event)
const path = this.props.value[META].path const path = this.state.path
if (value !== this.props.value[META].value) { if (value !== this.props.eson[VALUE]) {
this.props.emit('changeValue', {path, value}) this.props.emit('changeValue', {path, value})
} }
} }
@ -670,7 +682,7 @@ export default class JSONNode extends PureComponent {
/** @private */ /** @private */
handleKeyDown = (event) => { handleKeyDown = (event) => {
const keyBinding = this.props.findKeyBinding(event) const keyBinding = this.props.findKeyBinding(event)
const path = this.props.value[META].path const path = this.state.path
if (keyBinding === 'duplicate') { if (keyBinding === 'duplicate') {
event.preventDefault() event.preventDefault()
@ -690,7 +702,7 @@ export default class JSONNode extends PureComponent {
if (keyBinding === 'expand') { if (keyBinding === 'expand') {
event.preventDefault() event.preventDefault()
const recurse = false const recurse = false
const expanded = !this.props.value[META].expanded const expanded = !this.props.eson[EXPANDED]
this.props.emit('expand', {path, expanded, recurse}) this.props.emit('expand', {path, expanded, recurse})
} }
@ -703,7 +715,7 @@ export default class JSONNode extends PureComponent {
/** @private */ /** @private */
handleKeyDownAppend = (event) => { handleKeyDownAppend = (event) => {
const keyBinding = this.props.findKeyBinding(event) const keyBinding = this.props.findKeyBinding(event)
const path = this.props.value[META].path const path = this.state.path
if (keyBinding === 'insert') { if (keyBinding === 'insert') {
event.preventDefault() event.preventDefault()
@ -728,8 +740,8 @@ export default class JSONNode extends PureComponent {
/** @private */ /** @private */
handleExpand = (event) => { handleExpand = (event) => {
const recurse = event.ctrlKey const recurse = event.ctrlKey
const path = this.props.value[META].path const path = this.state.path
const expanded = !this.props.value[META].expanded const expanded = !this.props.eson[EXPANDED]
this.props.emit('expand', {path, expanded, recurse}) this.props.emit('expand', {path, expanded, recurse})
} }
@ -758,7 +770,7 @@ export default class JSONNode extends PureComponent {
*/ */
getValueFromEvent (event) { getValueFromEvent (event) {
const stringValue = unescapeHTML(getInnerText(event.target)) const stringValue = unescapeHTML(getInnerText(event.target))
return this.props.value[META].type === 'string' return this.state.type === 'string' // FIXME
? stringValue ? stringValue
: stringConvert(stringValue) : stringConvert(stringValue)
} }

View File

@ -3,12 +3,11 @@ import Ajv from 'ajv'
import { parseJSON } from '../utils/jsonUtils' import { parseJSON } from '../utils/jsonUtils'
import { escapeUnicodeChars } from '../utils/stringUtils' import { escapeUnicodeChars } from '../utils/stringUtils'
import { enrichSchemaError, limitErrors } from '../utils/schemaUtils' import { enrichSchemaError, limitErrors } from '../utils/schemaUtils'
import { jsonToEson, esonToJson } from '../eson'
import { patchEson } from '../patchEson'
import { createFindKeyBinding } from '../utils/keyBindings' import { createFindKeyBinding } from '../utils/keyBindings'
import { KEY_BINDINGS } from '../constants' import { KEY_BINDINGS } from '../constants'
import ModeButton from './menu/ModeButton' import ModeButton from './menu/ModeButton'
import { immutableJsonPatch } from '../immutableJsonPatch'
const AJV_OPTIONS = { const AJV_OPTIONS = {
allErrors: true, allErrors: true,
@ -331,10 +330,9 @@ export default class TextMode extends Component {
patch (actions) { patch (actions) {
const json = this.get() const json = this.get()
const data = jsonToEson(json) const result = immutableJsonPatch(json, actions)
const result = patchEson(data, actions)
this.set(esonToJson(result.data)) this.set(result.data)
return { return {
patch: actions, patch: actions,

View File

@ -8,21 +8,24 @@ import Hammer from 'react-hammerjs'
import jump from '../assets/jump.js/src/jump' import jump from '../assets/jump.js/src/jump'
import Ajv from 'ajv' import Ajv from 'ajv'
import { getIn, updateIn } from '../utils/immutabilityHelpers' import { existsIn, setIn, updateIn } from '../utils/immutabilityHelpers'
import { parseJSON } from '../utils/jsonUtils' import { parseJSON } from '../utils/jsonUtils'
import { enrichSchemaError } from '../utils/schemaUtils' import { enrichSchemaError } from '../utils/schemaUtils'
import { compileJSONPointer, parseJSONPointer } from '../jsonPointer'
import { import {
META, append,
jsonToEson, esonToJson, pathExists, changeProperty,
expand, expandOne, expandPath, applyErrors, changeType,
search, nextSearchResult, previousSearchResult, changeValue,
applySelection, pathsFromSelection, contentsFromPaths, createEntry,
compileJSONPointer, parseJSONPointer duplicate,
} from '../eson' insertAfter,
import { patchEson } from '../patchEson' insertBefore,
import { insertInside,
duplicate, insertBefore, insertAfter, insertInside, append, remove, removeAll, replace, remove,
createEntry, changeType, changeValue, changeProperty, sort removeAll,
replace,
sort
} from '../actions' } from '../actions'
import JSONNode from './JSONNode' import JSONNode from './JSONNode'
import JSONNodeView from './JSONNodeView' import JSONNodeView from './JSONNodeView'
@ -30,11 +33,32 @@ import JSONNodeForm from './JSONNodeForm'
import ModeButton from './menu/ModeButton' import ModeButton from './menu/ModeButton'
import Search from './menu/Search' import Search from './menu/Search'
import { import {
moveUp, moveDown, moveLeft, moveRight, moveDownSibling, moveHome, moveEnd, findBaseNode,
findNode, findBaseNode, selectFind, searchHasFocus, setSelection findNode,
moveDown,
moveDownSibling,
moveEnd,
moveHome,
moveLeft,
moveRight,
moveUp,
searchHasFocus,
selectFind,
setSelection
} from './utils/domSelector' } from './utils/domSelector'
import { createFindKeyBinding } from '../utils/keyBindings' import { createFindKeyBinding } from '../utils/keyBindings'
import { KEY_BINDINGS } from '../constants' import { KEY_BINDINGS } from '../constants'
import { immutableJsonPatch } from '../immutableJsonPatch'
import {
applyErrors, applySelection, contentsFromPaths,
expand,
EXPANDED,
expandPath,
nextSearchResult, pathsFromSelection, previousSearchResult,
search,
syncEson,
toEsonPatchAction
} from '../eson'
const AJV_OPTIONS = { const AJV_OPTIONS = {
allErrors: true, allErrors: true,
@ -56,12 +80,9 @@ export default class TreeMode extends PureComponent {
constructor (props) { constructor (props) {
super(props) super(props)
// const json = this.props.json || {}
// const expandCallback = this.props.expand || TreeMode.expandRoot
// const eson = expand(jsonToEson(json), expandCallback)
const json = {} const json = {}
const eson = jsonToEson(json) const expandCallback = this.props.expand || TreeMode.expandRoot
const eson = expand(syncEson(json, {}), expandCallback)
this.keyDownActions = { this.keyDownActions = {
'up': this.moveUp, 'up': this.moveUp,
@ -155,14 +176,11 @@ export default class TreeMode extends PureComponent {
// Apply json // Apply json
if (nextProps.json !== this.state.json) { if (nextProps.json !== this.state.json) {
// FIXME: merge meta data from existing eson
const callback = this.props.expand || TreeMode.expandRoot
const json = nextProps.json const json = nextProps.json
const eson = expand(jsonToEson(json), callback)
this.setState({ this.setState({
json, json,
eson eson: syncEson(json, this.state.eson)
}) })
// FIXME: use patch again -> patch should keep existing meta data when for the unchanged parts of the json // FIXME: use patch again -> patch should keep existing meta data when for the unchanged parts of the json
// this.patch([{ // this.patch([{
@ -231,9 +249,10 @@ export default class TreeMode extends PureComponent {
onMouseDown: this.handleTouchStart, onMouseDown: this.handleTouchStart,
onTouchStart: this.handleTouchStart, onTouchStart: this.handleTouchStart,
className: 'jsoneditor-list jsoneditor-root' + className: 'jsoneditor-list jsoneditor-root' +
(eson[META].selected ? ' jsoneditor-selected' : '')}, (/*eson[META].selected*/ false ? ' jsoneditor-selected' : '')}, // FIXME
h(Node, { h(Node, {
value: eson, path: [],
eson,
emit: this.emitter.emit, emit: this.emitter.emit,
findKeyBinding: this.findKeyBinding, findKeyBinding: this.findKeyBinding,
options: this.state.options options: this.state.options
@ -603,7 +622,7 @@ export default class TreeMode extends PureComponent {
} }
else { else {
this.setState({ this.setState({
eson: expandOne(this.state.eson, path, expanded) eson: setIn(this.state.eson, path.concat(EXPANDED), expanded)
}) })
} }
} }
@ -626,6 +645,7 @@ export default class TreeMode extends PureComponent {
} }
handleSearch = (text) => { handleSearch = (text) => {
// FIXME
// FIXME: also apply search when eson is changed // FIXME: also apply search when eson is changed
const { eson, searchResult } = search(this.state.eson, text) const { eson, searchResult } = search(this.state.eson, text)
if (searchResult.matches.length > 0) { if (searchResult.matches.length > 0) {
@ -639,7 +659,7 @@ export default class TreeMode extends PureComponent {
} }
else { else {
this.setState({ this.setState({
eson, eson: eson,
searchResult searchResult
}) })
} }
@ -657,7 +677,7 @@ export default class TreeMode extends PureComponent {
const { eson, searchResult } = nextSearchResult(this.state.eson, this.state.searchResult) const { eson, searchResult } = nextSearchResult(this.state.eson, this.state.searchResult)
this.setState({ this.setState({
eson, eson: expandPath(eson, initial(searchResult.active.path)),
searchResult searchResult
}) })
@ -682,7 +702,7 @@ export default class TreeMode extends PureComponent {
const { eson, searchResult } = previousSearchResult(this.state.eson, this.state.searchResult) const { eson, searchResult } = previousSearchResult(this.state.eson, this.state.searchResult)
this.setState({ this.setState({
eson, eson: expandPath(eson, initial(searchResult.active.path)),
searchResult searchResult
}) })
@ -700,15 +720,15 @@ export default class TreeMode extends PureComponent {
} }
/** /**
* Apply a ESONPatch to the current JSON document and emit a change event * Apply a JSONPatch to the current JSON document and emit a change event
* @param {ESONPatch} actions * @param {JSONPatch} actions
* @private * @private
*/ */
handlePatch = (actions) => { handlePatch = (actions) => {
// apply changes // apply changes
const result = this.patch(actions) const result = this.patch(actions)
this.emitOnChange (actions, result.revert, result.eson, result.json) this.emitOnChange (actions, result.revert, result.json)
} }
handleTouchStart = (event) => { handleTouchStart = (event) => {
@ -717,15 +737,15 @@ export default class TreeMode extends PureComponent {
return return
} }
const pointer = this.findESONPointerFromElement(event.target) const pointer = this.findJSONPointerFromElement(event.target)
const clickedOnEmptySpace = (event.target.nodeName === 'DIV') && const clickedOnEmptySpace = (event.target.nodeName === 'DIV') &&
(event.target.contentEditable !== 'true') (event.target.contentEditable !== 'true')
// TODO: cleanup // TODO: cleanup
// console.log('handleTouchStart', clickedOnEmptySpace && pointer, pointer && this.selectionFromESONPointer(pointer)) // console.log('handleTouchStart', clickedOnEmptySpace && pointer, pointer && this.selectionFromJSONPointer(pointer))
if (clickedOnEmptySpace && pointer) { if (clickedOnEmptySpace && pointer) {
this.setState({ selection: this.selectionFromESONPointer(pointer)}) this.setState({ selection: this.selectionFromJSONPointer(pointer)})
} }
else { else {
this.setState({ selection: null }) this.setState({ selection: null })
@ -780,11 +800,11 @@ export default class TreeMode extends PureComponent {
} }
/** /**
* Find ESON pointer from an HTML element * Find JSON pointer from an HTML element
* @param {Element} element * @param {Element} element
* @return {ESONPointer | null} * @return {ESONPointer | null}
*/ */
findESONPointerFromElement (element) { findJSONPointerFromElement (element) {
const path = this.findDataPathFromElement(element) const path = this.findDataPathFromElement(element)
const area = (element && element.getAttribute && element.getAttribute('data-area')) || null const area = (element && element.getAttribute && element.getAttribute('data-area')) || null
@ -792,11 +812,11 @@ export default class TreeMode extends PureComponent {
} }
/** /**
* Get selection from an ESON pointer * Get selection from an JSONPointer
* @param {ESONPointer} pointer * @param {ESONPointer} pointer
* @return {Selection} * @return {Selection}
*/ */
selectionFromESONPointer (pointer) { selectionFromJSONPointer (pointer) {
// FIXME: does pointer have .area === 'after' ? if so adjust type defs // FIXME: does pointer have .area === 'after' ? if so adjust type defs
if (pointer.area === 'after') { if (pointer.area === 'after') {
return {after: pointer.path} return {after: pointer.path}
@ -836,10 +856,9 @@ export default class TreeMode extends PureComponent {
* @private * @private
* @param {ESONPatch} patch * @param {ESONPatch} patch
* @param {ESONPatch} revert * @param {ESONPatch} revert
* @param {ESON} eson
* @param {JSON} json * @param {JSON} json
*/ */
emitOnChange (patch, revert, eson, json) { emitOnChange (patch, revert, json) {
const onPatch = this.props.onPatch const onPatch = this.props.onPatch
if (onPatch) { if (onPatch) {
setTimeout(() => onPatch(patch, revert)) setTimeout(() => onPatch(patch, revert))
@ -885,16 +904,18 @@ export default class TreeMode extends PureComponent {
const historyIndex = this.state.historyIndex const historyIndex = this.state.historyIndex
const historyItem = history[historyIndex] const historyItem = history[historyIndex]
const result = patchEson(this.state.eson, historyItem.undo) const jsonResult = immutableJsonPatch(this.state.json, historyItem.undo)
const esonResult = immutableJsonPatch(this.state.eson, historyItem.undo.map(toEsonPatchAction))
// FIXME: apply search // FIXME: apply search
this.setState({ this.setState({
eson: result.data, json: jsonResult.json,
eson: esonResult.json,
history, history,
historyIndex: historyIndex + 1 historyIndex: historyIndex + 1
}) })
this.emitOnChange(historyItem.undo, historyItem.redo, result.data, esonToJson(result.data)) this.emitOnChange(historyItem.undo, historyItem.redo, jsonResult.json)
} }
} }
@ -904,45 +925,43 @@ export default class TreeMode extends PureComponent {
const historyIndex = this.state.historyIndex - 1 const historyIndex = this.state.historyIndex - 1
const historyItem = history[historyIndex] const historyItem = history[historyIndex]
const result = patchEson(this.state.eson, historyItem.redo) const jsonResult = immutableJsonPatch(this.state.json, historyItem.redo)
const esonResult = immutableJsonPatch(this.state.eson, historyItem.undo.map(toEsonPatchAction))
// FIXME: apply search // FIXME: apply search
this.setState({ this.setState({
eson: result.data, json: jsonResult.json,
eson: esonResult.json,
history, history,
historyIndex historyIndex
}) })
this.emitOnChange(historyItem.redo, historyItem.undo, result.data, esonToJson(result.data)) this.emitOnChange(historyItem.redo, historyItem.undo, jsonResult.json)
} }
} }
/** /**
* Apply a ESONPatch to the current JSON document * Apply a JSONPatch to the current JSON document
* @param {ESONPatch} actions ESONPatch actions * @param {JSONPatch} actions ESONPatch actions
* @param {ESONPatchOptions} [options] If no expand function is provided, the * @return {Object} Returns a object result containing the
* expanded state will be kept as is for
* existing paths. New paths will be fully
* expanded.
* @return {ESONPatchAction} Returns a ESONPatch result containing the
* patch, a patch to revert the action, and * patch, a patch to revert the action, and
* an error object which is null when successful * an error object which is null when successful
*/ */
patch (actions, options = {}) { patch (actions) {
if (!Array.isArray(actions)) { if (!Array.isArray(actions)) {
throw new TypeError('Array with patch actions expected') throw new TypeError('Array with patch actions expected')
} }
const expand = options.expand || (path => this.expandKeepOrExpandAll(path)) console.log('patch', actions)
const result = patchEson(this.state.eson, actions, expand)
const eson = result.data const jsonResult = immutableJsonPatch(this.state.json, actions)
const json = esonToJson(eson) // FIXME: apply the patch to the json too, instead of completely replacing it const esonResult = immutableJsonPatch(this.state.eson, actions.map(toEsonPatchAction))
if (this.props.history !== false) { if (this.props.history !== false) {
// update data and store history // update data and store history
const historyItem = { const historyItem = {
redo: actions, redo: actions,
undo: result.revert undo: jsonResult.revert
} }
const history = [historyItem] const history = [historyItem]
@ -951,8 +970,8 @@ export default class TreeMode extends PureComponent {
// FIXME: apply search // FIXME: apply search
this.setState({ this.setState({
eson, json: jsonResult.json,
json, eson: esonResult.json,
history, history,
historyIndex: 0 historyIndex: 0
}) })
@ -961,18 +980,16 @@ export default class TreeMode extends PureComponent {
// update data and don't store history // update data and don't store history
// FIXME: apply search // FIXME: apply search
this.setState({ this.setState({
eson, json: jsonResult.json,
json eson: esonResult.json
}) })
} }
return { return {
patch: actions, patch: actions,
revert: result.revert, revert: jsonResult.revert,
error: result.error, error: jsonResult.error,
data: eson, // FIXME: shouldn't pass data here? json: jsonResult.json // FIXME: shouldn't pass json here?
eson, // FIXME: shouldn't pass eson here
json // FIXME: shouldn't pass json here
} }
} }
@ -982,13 +999,11 @@ export default class TreeMode extends PureComponent {
*/ */
set (json) { set (json) {
// FIXME: when both json and expand are being changed via React, this.props must be updated before set(json) is called // FIXME: when both json and expand are being changed via React, this.props must be updated before set(json) is called
// TODO: document option expand
const expandCallback = this.props.expand || TreeMode.expandRoot
// FIXME: apply search // FIXME: apply search
this.setState({ this.setState({
json: json, json,
eson: expand(jsonToEson(json), expandCallback), eson: syncEson(json, this.state.eson), // FIXME: reset eson in set, keep in update?
// TODO: do we want to keep history when .set(json) is called? (currently we remove history) // TODO: do we want to keep history when .set(json) is called? (currently we remove history)
history: [], history: [],
@ -1090,29 +1105,7 @@ export default class TreeMode extends PureComponent {
* @param {Path} path * @param {Path} path
*/ */
exists (path) { exists (path) {
return pathExists(this.state.eson, path) return existsIn(this.state.json, path)
}
/**
* Test whether an Array or Object at a certain path is expanded.
* When the node does not exist, the function throws an error
* @param {Path} path
* @return {boolean} Returns true when expanded, false otherwise
*/
isExpanded (path) {
return getIn(this.state.eson, path)[META].expanded
}
/**
* Expand function which keeps the expanded state the same as the current data.
* When the path doesn't yet exist, it will be expanded.
* @param {Path} path
* @return {boolean}
*/
expandKeepOrExpandAll (path) {
return this.exists(path)
? this.isExpanded(path)
: TreeMode.expandAll(path)
} }
/** /**

View File

@ -101,14 +101,14 @@ const CREATE_TYPE = {
insertObjectAfter: (path, emit) => h('button', { insertObjectAfter: (path, emit) => h('button', {
key: 'insertObjectAfter', key: 'insertObjectAfter',
className: MENU_ITEM_CLASS_NAME, className: MENU_ITEM_CLASS_NAME,
onClick: () => emit('insertAfter', {path, type: 'Object'}), onClick: () => emit('insertAfter', {path, type: 'object'}),
title: 'Insert Object' title: 'Insert Object'
}, 'Insert Object'), }, 'Insert Object'),
insertArrayAfter: (path, emit) => h('button', { insertArrayAfter: (path, emit) => h('button', {
key: 'insertArrayAfter', key: 'insertArrayAfter',
className: MENU_ITEM_CLASS_NAME, className: MENU_ITEM_CLASS_NAME,
onClick: () => emit('insertAfter', {path, type: 'Array'}), onClick: () => emit('insertAfter', {path, type: 'array'}),
title: 'Insert Array' title: 'Insert Array'
}, 'Insert Array'), }, 'Insert Array'),
@ -129,14 +129,14 @@ const CREATE_TYPE = {
insertObjectInside: (path, emit) => h('button', { insertObjectInside: (path, emit) => h('button', {
key: 'insertObjectInside', key: 'insertObjectInside',
className: MENU_ITEM_CLASS_NAME, className: MENU_ITEM_CLASS_NAME,
onClick: () => emit('insertInside', {path, type: 'Object'}), onClick: () => emit('insertInside', {path, type: 'object'}),
title: 'Insert Object' title: 'Insert Object'
}, 'Insert Object'), }, 'Insert Object'),
insertArrayInside: (path, emit) => h('button', { insertArrayInside: (path, emit) => h('button', {
key: 'insertArrayInside', key: 'insertArrayInside',
className: MENU_ITEM_CLASS_NAME, className: MENU_ITEM_CLASS_NAME,
onClick: () => emit('insertInside', {path, type: 'Array'}), onClick: () => emit('insertInside', {path, type: 'array'}),
title: 'Insert Array' title: 'Insert Array'
}, 'Insert Array'), }, 'Insert Array'),
@ -164,7 +164,9 @@ export default class FloatingMenu extends PureComponent {
}) })
]).isRequired ]).isRequired
).isRequired, ).isRequired,
path: PropTypes.arrayOf(PropTypes.string).isRequired, path: PropTypes.arrayOf(PropTypes.oneOfType([
PropTypes.string, PropTypes.number
])).isRequired,
emit: PropTypes.func.isRequired, emit: PropTypes.func.isRequired,
position: PropTypes.string // 'top' or 'bottom' position: PropTypes.string // 'top' or 'bottom'
} }

View File

@ -2,7 +2,7 @@ import {
selectContentEditable, hasClassName, selectContentEditable, hasClassName,
findParentWithAttribute, findParentWithClassName findParentWithAttribute, findParentWithClassName
} from '../../utils/domUtils' } from '../../utils/domUtils'
import { compileJSONPointer, parseJSONPointer } from '../../eson' import { compileJSONPointer, parseJSONPointer } from '../../jsonPointer'
// singleton // singleton
let lastInputName = null let lastInputName = null

View File

@ -1,16 +1,21 @@
/** import { deleteIn, getIn, setIn, shallowCloneWithSymbols, transform, updateIn } from './utils/immutabilityHelpers'
* This file contains functions to act on a ESON object.
* All functions are pure and don't mutate the ESON.
*/
import { setIn, getIn, updateIn, deleteIn, cloneWithSymbols } from './utils/immutabilityHelpers'
import { isObject } from './utils/typeUtils'
import isEqual from 'lodash/isEqual'
import isEmpty from 'lodash/isEmpty'
import range from 'lodash/range' import range from 'lodash/range'
import times from 'lodash/times' import { compileJSONPointer, parseJSONPointer } from './jsonPointer'
import initial from 'lodash/initial'
import last from 'lodash/last' import last from 'lodash/last'
import initial from 'lodash/initial'
import isEmpty from 'lodash/isEmpty'
import isEqual from 'lodash/isEqual'
import naturalSort from 'javascript-natural-sort'
import times from 'lodash/times'
export const ID = typeof Symbol === 'function' ? Symbol('id') : '@jsoneditor-id'
export const TYPE = typeof Symbol === 'function' ? Symbol('type') : '@jsoneditor-type' // 'object', 'array', 'value', or 'undefined'
export const VALUE = typeof Symbol === 'function' ? Symbol('value') : '@jsoneditor-value'
export const EXPANDED = typeof Symbol === 'function' ? Symbol('expanded') : '@jsoneditor-expanded'
export const ERROR = typeof Symbol === 'function' ? Symbol('error') : '@jsoneditor-error'
export const SEARCH_PROPERTY = typeof Symbol === 'function' ? Symbol('searchProperty') : '@jsoneditor-search-property'
export const SEARCH_VALUE = typeof Symbol === 'function' ? Symbol('searchValue') : '@jsoneditor-search-value'
export const SELECTION = typeof Symbol === 'function' ? Symbol('selection') : '@jsoneditor-selection'
export const SELECTED = 1 export const SELECTED = 1
export const SELECTED_START = 2 export const SELECTED_START = 2
@ -22,122 +27,88 @@ export const SELECTED_AFTER = 64
export const SELECTED_EMPTY = 128 export const SELECTED_EMPTY = 128
export const SELECTED_EMPTY_BEFORE = 256 export const SELECTED_EMPTY_BEFORE = 256
export const META = Symbol('meta') // TODO: comment
export function syncEson(json, eson) {
const jsonType = getType(json)
const esonType = eson ? eson[TYPE] : 'undefined'
const sameType = jsonType === esonType
/** if (jsonType === 'array') {
* Expand function which will expand all nodes // TODO: instead of creating updatedEson beforehand, only created as soon as we have a changed item
* @param {Path} path let changed = (esonType !== jsonType) || (eson.length !== esonType.length)
* @return {boolean} let updatedEson = []
*/
export function expandAll (path) {
return true
}
/** for (let i = 0; i < json.length; i++) {
* const esonI = eson ? eson[i] : undefined
* @param {JSON} json
* @param {Path} path
* @return {ESON}
*/
export function jsonToEson (json, path = []) {
const id = createId()
if (isObject(json)) { updatedEson[i] = syncEson(json[i], esonI)
let eson = {}
const props = Object.keys(json)
props.forEach((prop) => eson[prop] = jsonToEson(json[prop], path.concat(prop)))
eson[META] = { id, path, type: 'Object', props }
return eson
}
else if (Array.isArray(json)) {
let eson = json.map((value, index) => jsonToEson(value, path.concat(String(index))))
eson[META] = { id, path, type: 'Array' }
return eson
}
else { // json is a number, string, boolean, or null
let eson = {}
eson[META] = { id, path, type: 'value', value: json }
return eson
}
}
/** if (updatedEson[i] !== esonI) {
* Convert an ESON object to a JSON object changed = true
* @param {ESON} eson
* @return {Object | Array | string | number | boolean | null} json
*/
export function esonToJson (eson) {
switch (eson[META].type) {
case 'Array':
return eson.map(item => esonToJson(item))
case 'Object':
const object = {}
eson[META].props.forEach(prop => {
object[prop] = esonToJson(eson[prop])
})
return object
default: // type 'string' or 'value'
return eson[META].value
}
}
/**
* Transform an eson object, traverse over the whole object (excluding the _meta)
* objects, and allow replacing Objects/Arrays/values
* @param {ESON} eson
* @param {function (ESON, Path) : ESON} callback
* @param {Path} [path]
* @return {ESON}
*/
export function transform (eson, callback, path = []) {
const updated = callback(eson, path)
if (updated[META].type === 'Object') {
let changed = false
let updatedChilds = {}
for (let key in updated) {
if (updated.hasOwnProperty(key)) {
updatedChilds[key] = transform(updated[key], callback, path.concat(key))
changed = changed || (updatedChilds[key] !== updated[key])
} }
} }
updatedChilds[META] = updated[META]
return changed ? updatedChilds : updated
}
else if (updated[META].type === 'Array') {
let changed = false
let updatedChilds = []
for (let i = 0; i < updated.length; i++) {
updatedChilds[i] = transform(updated[i], callback, path.concat(String(i)))
changed = changed || (updatedChilds[i] !== updated[i])
}
updatedChilds[META] = updated[META]
return changed ? updatedChilds : updated
}
else { // eson[META].type === 'value'
return updated
}
}
/** if (changed) {
* Recursively update all paths in an eson object, array or value updatedEson[ID] = sameType ? eson[ID] : createId()
* @param {ESON} eson updatedEson[TYPE] = jsonType
* @param {Path} [path] updatedEson[VALUE] = json
* @return {ESON} updatedEson[EXPANDED] = sameType ? eson[EXPANDED] : false
*/
export function updatePaths(eson, path = []) { return updatedEson
return transform(eson, function (value, path) {
if (!isEqual(value[META].path, path)) {
return setIn(value, [META, 'path'], path)
} }
else { else {
return value return eson
}
}
else if (jsonType === 'object') {
const jsonKeys = Object.keys(json)
const esonKeys = esonType === 'object' ? Object.keys(eson) : []
// TODO: instead of creating updatedEson beforehand, only created as soon as we have a changed item
let changed = (esonType !== jsonType) || (jsonKeys.length !== esonKeys.length)
let updatedEson = {}
for (let i = 0; i < jsonKeys.length; i++) {
const key = jsonKeys[i]
const esonValue = eson ? eson[key] : undefined
updatedEson[key] = syncEson(json[key], esonValue)
if (updatedEson[key] !== esonValue) {
changed = true
}
}
if (changed) {
updatedEson[ID] = sameType ? eson[ID] : createId()
updatedEson[TYPE] = jsonType
updatedEson[VALUE] = json
updatedEson[EXPANDED] = sameType ? eson[EXPANDED] : false
return updatedEson
}
else {
return eson
}
}
else if (jsonType === 'value') { // json is a value
if (sameType) {
return eson
}
else {
const updatedEson = {}
updatedEson[ID] = sameType ? eson[ID] : createId()
updatedEson[TYPE] = jsonType
updatedEson[VALUE] = json
updatedEson[EXPANDED] = false
return updatedEson
}
}
else { // jsonType === 'undefined'
return undefined
} }
}, path)
} }
/** /**
@ -151,7 +122,7 @@ export function updatePaths(eson, path = []) {
*/ */
export function expand (eson, callback) { export function expand (eson, callback) {
return transform(eson, function (value, path) { return transform(eson, function (value, path) {
if (value[META].type === 'Array' || value[META].type === 'Object') { if (value[TYPE] === 'array' || value[TYPE] === 'object') {
const expanded = callback(path) const expanded = callback(path)
return (typeof expanded === 'boolean') return (typeof expanded === 'boolean')
? expandOne(value, [], expanded) // adjust expanded state ? expandOne(value, [], expanded) // adjust expanded state
@ -171,7 +142,7 @@ export function expand (eson, callback) {
* @return {ESON} * @return {ESON}
*/ */
export function expandOne (eson, path, expanded = true) { export function expandOne (eson, path, expanded = true) {
return setIn(eson, path.concat([META, 'expanded']), expanded) return setIn(eson, path.concat(EXPANDED), expanded)
} }
/** /**
@ -206,7 +177,7 @@ export function applyErrors (eson, errors = []) {
const esonWithErrors = errors.reduce((eson, error) => { const esonWithErrors = errors.reduce((eson, error) => {
const path = parseJSONPointer(error.dataPath) const path = parseJSONPointer(error.dataPath)
// TODO: do we want to be able to store multiple errors per item? // TODO: do we want to be able to store multiple errors per item?
return setIn(eson, path.concat([META, 'error']), error) return setIn(eson, path.concat(ERROR), error)
}, eson) }, eson)
// cleanup any old error messages // cleanup any old error messages
@ -216,11 +187,12 @@ export function applyErrors (eson, errors = []) {
/** /**
* Cleanup meta data from an eson object * Cleanup meta data from an eson object
* @param {ESON} eson Object to be cleaned up * @param {ESON} eson Object to be cleaned up
* @param {String} field Field name, for example 'error' or 'selected' * @param {String | Symbol} symbol A meta data field name, for example ERROR or SELECTED
* @param {Path[]} [ignorePaths=[]] An optional array with paths to be ignored * @param {Array.<string | Path>} [ignorePaths=[]] An optional array with paths to be ignored
* @return {ESON} * @return {ESON}
*/ */
export function cleanupMetaData(eson, field, ignorePaths = []) { export function cleanupMetaData(eson, symbol, ignorePaths = []) {
// TODO: change ignorePaths to an object with path as key and true as value
const pathsMap = {} const pathsMap = {}
ignorePaths.forEach(path => { ignorePaths.forEach(path => {
const pathString = (typeof path === 'string') ? path : compileJSONPointer(path) const pathString = (typeof path === 'string') ? path : compileJSONPointer(path)
@ -228,8 +200,8 @@ export function cleanupMetaData(eson, field, ignorePaths = []) {
}) })
return transform(eson, function (value, path) { return transform(eson, function (value, path) {
return (value[META][field] && !pathsMap[compileJSONPointer(path)]) return (value[symbol] && !pathsMap[compileJSONPointer(path)])
? deleteIn(value, [META, field]) ? deleteIn(value, [symbol])
: value : value
}) })
} }
@ -253,23 +225,23 @@ export function search (eson, text) {
// check property name // check property name
const prop = last(path) const prop = last(path)
if (text !== '' && containsCaseInsensitive(prop, text) && if (text !== '' && containsCaseInsensitive(prop, text) &&
getIn(eson, initial(path))[META].type === 'Object') { // parent must be an Object getIn(eson, initial(path))[TYPE] === 'object') { // parent must be an Object
const searchState = isEmpty(matches) ? 'active' : 'normal' const searchState = isEmpty(matches) ? 'active' : 'normal'
matches.push({path, area: 'property'}) matches.push({path, area: 'property'})
updatedValue = setIn(updatedValue, [META, 'searchProperty'], searchState) updatedValue = setIn(updatedValue, [SEARCH_PROPERTY], searchState)
} }
else { else {
updatedValue = deleteIn(updatedValue, [META, 'searchProperty']) updatedValue = deleteIn(updatedValue, [SEARCH_PROPERTY])
} }
// check value // check value
if (value[META].type === 'value' && text !== '' && containsCaseInsensitive(value[META].value, text)) { if (value[TYPE] === 'value' && text !== '' && containsCaseInsensitive(value[VALUE], text)) {
const searchState = isEmpty(matches) ? 'active' : 'normal' const searchState = isEmpty(matches) ? 'active' : 'normal'
matches.push({path, area: 'value'}) matches.push({path, area: 'value'})
updatedValue = setIn(updatedValue, [META, 'searchValue'], searchState) updatedValue = setIn(updatedValue, [SEARCH_VALUE], searchState)
} }
else { else {
updatedValue = deleteIn(updatedValue, [META, 'searchValue']) updatedValue = deleteIn(updatedValue, [SEARCH_VALUE])
} }
return updatedValue return updatedValue
@ -346,9 +318,9 @@ export function nextSearchResult (eson, searchResult) {
* @return {Object|Array} * @return {Object|Array}
*/ */
function setSearchStatus (eson, esonPointer, searchStatus) { function setSearchStatus (eson, esonPointer, searchStatus) {
const metaProp = esonPointer.area === 'property' ? 'searchProperty': 'searchValue' const searchSymbol = esonPointer.area === 'property' ? SEARCH_PROPERTY : SEARCH_VALUE
return setIn(eson, esonPointer.path.concat([META, metaProp]), searchStatus) return setIn(eson, esonPointer.path.concat([searchSymbol]), searchStatus)
} }
/** /**
@ -362,23 +334,19 @@ export function applySelection (eson, selection) {
return cleanupMetaData(eson, 'selected') return cleanupMetaData(eson, 'selected')
} }
else if (selection.inside) { else if (selection.inside) {
const updatedEson = setIn(eson, selection.inside.concat([META, 'selected']), const updatedEson = setIn(eson, selection.inside.concat([SELECTION]), SELECTED_INSIDE)
SELECTED_INSIDE)
return cleanupMetaData(updatedEson, 'selected', [selection.inside]) return cleanupMetaData(updatedEson, 'selected', [selection.inside])
} }
else if (selection.after) { else if (selection.after) {
const updatedEson = setIn(eson, selection.after.concat([META, 'selected']), const updatedEson = setIn(eson, selection.after.concat([SELECTION]), SELECTED_AFTER)
SELECTED_AFTER)
return cleanupMetaData(updatedEson, 'selected', [selection.after]) return cleanupMetaData(updatedEson, 'selected', [selection.after])
} }
else if (selection.empty) { else if (selection.empty) {
const updatedEson = setIn(eson, selection.empty.concat([META, 'selected']), const updatedEson = setIn(eson, selection.empty.concat([SELECTION]), SELECTED_EMPTY)
SELECTED_EMPTY)
return cleanupMetaData(updatedEson, 'selected', [selection.empty]) return cleanupMetaData(updatedEson, 'selected', [selection.empty])
} }
else if (selection.emptyBefore) { else if (selection.emptyBefore) {
const updatedEson = setIn(eson, selection.emptyBefore.concat([META, 'selected']), const updatedEson = setIn(eson, selection.emptyBefore.concat([SELECTION]), SELECTED_EMPTY_BEFORE)
SELECTED_EMPTY_BEFORE)
return cleanupMetaData(updatedEson, 'selected', [selection.emptyBefore]) return cleanupMetaData(updatedEson, 'selected', [selection.emptyBefore])
} }
else { // selection.start and selection.end else { // selection.start and selection.end
@ -392,30 +360,31 @@ export function applySelection (eson, selection) {
// TODO: simplify the update function. Use pathsFromSelection ? // TODO: simplify the update function. Use pathsFromSelection ?
if (root[META].type === 'Object') { if (root[TYPE] === 'object') {
const startIndex = root[META].props.indexOf(start) const props = Object.keys(root).sort(naturalSort) // TODO: create a util function getSortedProps
const endIndex = root[META].props.indexOf(end) const startIndex = props.indexOf(start)
const endIndex = props.indexOf(end)
const firstIndex = Math.min(startIndex, endIndex) const firstIndex = Math.min(startIndex, endIndex)
const lastIndex = Math.max(startIndex, endIndex) const lastIndex = Math.max(startIndex, endIndex)
const firstProp = root[META].props[firstIndex] const firstProp = props[firstIndex]
const lastProp = root[META].props[lastIndex] const lastProp = props[lastIndex]
const selectedProps = root[META].props.slice(firstIndex, lastIndex + 1)// include max index itself const selectedProps = props.slice(firstIndex, lastIndex + 1)// include max index itself
selectedPaths = selectedProps.map(prop => rootPath.concat(prop)) selectedPaths = selectedProps.map(prop => rootPath.concat(prop))
let updatedObj = cloneWithSymbols(root) let updatedObj = shallowCloneWithSymbols(root)
selectedProps.forEach(prop => { selectedProps.forEach(prop => {
const selected = SELECTED + const selected = SELECTED +
(prop === start ? SELECTED_START : 0) + (prop === start ? SELECTED_START : 0) +
(prop === end ? SELECTED_END : 0) + (prop === end ? SELECTED_END : 0) +
(prop === firstProp ? SELECTED_FIRST : 0) + (prop === firstProp ? SELECTED_FIRST : 0) +
(prop === lastProp ? SELECTED_LAST : 0) (prop === lastProp ? SELECTED_LAST : 0)
updatedObj[prop] = setIn(updatedObj[prop], [META, 'selected'], selected) updatedObj[prop] = setIn(updatedObj[prop], [SELECTION], selected)
}) })
return updatedObj return updatedObj
} }
else { // root[META].type === 'Array' else { // root[TYPE] === 'array'
const startIndex = parseInt(start, 10) const startIndex = parseInt(start, 10)
const endIndex = parseInt(end, 10) const endIndex = parseInt(end, 10)
@ -426,14 +395,14 @@ export function applySelection (eson, selection) {
selectedPaths = selectedIndices.map(index => rootPath.concat(String(index))) selectedPaths = selectedIndices.map(index => rootPath.concat(String(index)))
let updatedArr = root.slice() let updatedArr = root.slice()
updatedArr = cloneWithSymbols(root) updatedArr = shallowCloneWithSymbols(root)
selectedIndices.forEach(index => { selectedIndices.forEach(index => {
const selected = SELECTED + const selected = SELECTED +
(index === startIndex ? SELECTED_START : 0) + (index === startIndex ? SELECTED_START : 0) +
(index === endIndex ? SELECTED_END : 0) + (index === endIndex ? SELECTED_END : 0) +
(index === firstIndex ? SELECTED_FIRST : 0) + (index === firstIndex ? SELECTED_FIRST : 0) +
(index === lastIndex ? SELECTED_LAST : 0) (index === lastIndex ? SELECTED_LAST : 0)
updatedArr[index] = setIn(updatedArr[index], [META, 'selected'], selected) updatedArr[index] = setIn(updatedArr[index], [SELECTION], selected)
}) })
return updatedArr return updatedArr
@ -459,8 +428,9 @@ export function findSelectionIndices (root, rootPath, selection) {
const end = (selection.after || selection.inside || selection.end)[rootPath.length] const end = (selection.after || selection.inside || selection.end)[rootPath.length]
// if no object we assume it's an Array // if no object we assume it's an Array
const startIndex = root[META].type === 'Object' ? root[META].props.indexOf(start) : parseInt(start, 10) const props = Object.keys(root).sort(naturalSort) // TODO: create a util function getSortedProps
const endIndex = root[META].type === 'Object' ? root[META].props.indexOf(end) : parseInt(end, 10) const startIndex = root[TYPE] === 'object' ? props.indexOf(start) : parseInt(start, 10)
const endIndex = root[TYPE] === 'object' ? props.indexOf(end) : parseInt(end, 10)
const minIndex = Math.min(startIndex, endIndex) const minIndex = Math.min(startIndex, endIndex)
const maxIndex = Math.max(startIndex, endIndex) + const maxIndex = Math.max(startIndex, endIndex) +
@ -469,92 +439,23 @@ export function findSelectionIndices (root, rootPath, selection) {
return { minIndex, maxIndex } return { minIndex, maxIndex }
} }
/**
* Get the JSON paths from a selection, sorted from first to last
* @param {ESON} eson
* @param {Selection} selection
* @return {Path[]}
*/
export function pathsFromSelection (eson, selection) {
// find the parent node shared by both start and end of the selection
const rootPath = findRootPath(selection)
const root = getIn(eson, rootPath)
const { minIndex, maxIndex } = findSelectionIndices(root, rootPath, selection)
if (root[META].type === 'Object') {
return times(maxIndex - minIndex, i => rootPath.concat(root[META].props[i + minIndex]))
}
else { // root[META].type === 'Array'
return times(maxIndex - minIndex, i => rootPath.concat(String(i + minIndex)))
}
}
/** /**
* Get the contents of a list with paths * Get the contents of a list with paths
* @param {ESON} data * @param {ESON} eson
* @param {Path[]} paths * @param {Path[]} paths
* @return {Array.<{name: string, value: JSON, state: Object}>} * @return {Array.<{name: string, value: JSON, state: Object}>}
*/ */
export function contentsFromPaths (data, paths) { export function contentsFromPaths (eson, paths) {
return paths.map(path => { return paths.map(path => {
const esonValue = getIn(data, path) const value = getIn(eson, path.concat(VALUE))
return { return {
name: last(path), name: last(path),
value: esonToJson(esonValue), value,
state: getEsonState(esonValue) state: {} // FIXME: state
} }
}) })
} }
/**
* Get an object with paths and state (expanded, type) of an eson object
* @param {ESON} eson
* @return {Object.<key, Object>} An object with compiled JSON paths as key,
* And a META object as state
*/
export function getEsonState (eson) {
let state = {}
transform(eson, function (eson, path) {
let meta = {}
if (eson[META].expanded === true) {
meta.expanded = true
}
if (eson[META].type === 'string') {
meta.type = 'string'
}
if (!isEmpty(meta)) {
state[compileJSONPointer(path)] = meta
}
return eson
})
return state
}
/**
* Merge ESON meta data to an ESON object: expanded state, type
* @param {ESON} data
* @param {Object.<String, Object>} state
* @return {ESON}
*/
export function applyEsonState(data, state) {
let updatedData = data
for (let path in state) {
if (state.hasOwnProperty(path)) {
const metaPath = parseJSONPointer(path).concat(META)
updatedData = updateIn(updatedData, metaPath, function (meta) {
return Object.assign({}, meta, state[path])
})
}
}
return updatedData
}
/** /**
* Find the root path of a selection: the parent node shared by both start * Find the root path of a selection: the parent node shared by both start
* and end of the selection * and end of the selection
@ -605,93 +506,56 @@ function findSharedPath (path1, path2) {
} }
/** /**
* Test whether a path exists in the eson object * Get the JSON paths from a selection, sorted from first to last
* @param {ESON} eson * @param {ESON} eson
* @param {Path} path * @param {Selection} selection
* @return {boolean} Returns true if the path exists, else returns false * @return {Path[]}
* @private
*/ */
export function pathExists (eson, path) { // TODO: move pathsFromSelection to a separate file clipboard.js or selection.js?
if (eson === undefined) { export function pathsFromSelection (eson, selection) {
return false // find the parent node shared by both start and end of the selection
} const rootPath = findRootPath(selection)
const root = getIn(eson, rootPath)
if (path.length === 0) { const { minIndex, maxIndex } = findSelectionIndices(root, rootPath, selection)
return true
}
if (Array.isArray(eson)) { if (root[TYPE] === 'object') {
// index of an array const props = Object.keys(root).sort(naturalSort) // TODO: create a util function getSortedProps
return pathExists(eson[parseInt(path[0], 10)], path.slice(1)) return times(maxIndex - minIndex, i => rootPath.concat(props[i + minIndex]))
} }
else { // Object else { // root[TYPE] === 'array'
// object property. find the index of this property return times(maxIndex - minIndex, i => rootPath.concat(String(i + minIndex)))
return pathExists(eson[path[0]], path.slice(1))
} }
} }
/** /**
* Resolve the index for `arr/-`, replace it with an index value equal to the * Convert the value of a JSON Patch action into a ESON object
* length of the array * @param {JSONPatchAction} action
* @param {ESON} eson * @returns {ESONPatchAction}
* @param {Path} path
* @return {Path}
*/ */
export function resolvePathIndex (eson, path) { export function toEsonPatchAction (action) {
if (path[path.length - 1] === '-') { return ('value' in action)
const parentPath = initial(path) ? setIn(action, ['value'], syncEson(action.value))
const parent = getIn(eson, parentPath) : action
}
if (Array.isArray(parent)) { // TODO: comment
const index = parent.length export function getType (any) {
return parentPath.concat(String(index)) if (any === undefined) {
} return 'undefined'
} }
return path if (Array.isArray(any)) {
return 'array'
}
if (any && typeof any === 'object') {
return 'object'
}
return 'value'
} }
/**
* Find the property after provided property
* @param {ESON} parent
* @param {string} prop
* @return {string | null} Returns the name of the next property,
* or null if there is none
*/
export function findNextProp (parent, prop) {
const index = parent[META].props.indexOf(prop)
return parent[META].props[index + 1] || null
}
// TODO: move parseJSONPointer and compileJSONPointer to a separate file
/**
* Parse a JSON Pointer
* WARNING: this is not a complete implementation
* @param {string} pointer
* @return {Path}
*/
export function parseJSONPointer (pointer) {
const path = pointer.split('/')
path.shift() // remove the first empty entry
return path.map(p => p.replace(/~1/g, '/').replace(/~0/g, '~'))
}
/**
* Compile a JSON Pointer
* WARNING: this is not a complete implementation
* @param {Path} path
* @return {string}
*/
export function compileJSONPointer (path) {
return path
.map(p => '/' + String(p).replace(/~/g, '~0').replace(/\//g, '~1'))
.join('')
}
// TODO: move createId to a separate file
/** /**
* Do a case insensitive search for a search text in a text * Do a case insensitive search for a search text in a text
* @param {String} text * @param {String} text
@ -706,8 +570,8 @@ export function containsCaseInsensitive (text, search) {
* Get a new "unique" id. Id's are created from an incremental counter. * Get a new "unique" id. Id's are created from an incremental counter.
* @return {number} * @return {number}
*/ */
export function createId () { function createId () {
_id++ _id++
return _id return 'node-' + _id
} }
let _id = 0 let _id = 0

View File

@ -1,57 +1,67 @@
'use strict'
import { readFileSync } from 'fs'
import { setIn, getIn, deleteIn } from './utils/immutabilityHelpers'
import { import {
META, applyErrors,
esonToJson, pathExists, transform, applySelection, ERROR,
parseJSONPointer, compileJSONPointer, expand,
jsonToEson, EXPANDED,
expand, expandOne, expandPath, applyErrors, search, nextSearchResult, expandOne,
expandPath,
ID,
nextSearchResult,
pathsFromSelection,
previousSearchResult, previousSearchResult,
applySelection, pathsFromSelection, search, SEARCH_PROPERTY, SEARCH_VALUE,
getEsonState, SELECTED,
SELECTED, SELECTED_START, SELECTED_END, SELECTED_FIRST, SELECTED_LAST SELECTED_END,
SELECTED_FIRST,
SELECTED_LAST,
SELECTED_START, SELECTION,
syncEson,
TYPE
} from './eson' } from './eson'
import 'console.table' import { getIn, setIn } from './utils/immutabilityHelpers'
import repeat from 'lodash/repeat' import { createAssertEqualEson } from './utils/assertEqualEson'
import { assertDeepEqualEson } from './utils/assertDeepEqualEson'
test('jsonToEson', () => { const assertEqualEson = createAssertEqualEson(expect)
assertDeepEqualEson(jsonToEson(1), {[META]: {id: '[ID]', path: [], type: 'value', value: 1}})
assertDeepEqualEson(jsonToEson("foo"), {[META]: {id: '[ID]', path: [], type: 'value', value: "foo"}}) test('syncEson', () => {
assertDeepEqualEson(jsonToEson(null), {[META]: {id: '[ID]', path: [], type: 'value', value: null}}) const json1 = {
assertDeepEqualEson(jsonToEson(false), {[META]: {id: '[ID]', path: [], type: 'value', value: false}}) arr: [1,2,3],
assertDeepEqualEson(jsonToEson({a:1, b: 2}), { obj: {a : 2}
[META]: {id: '[ID]', path: [], type: 'Object', props: ['a', 'b']}, }
a: {[META]: {id: '[ID]', path: ['a'], type: 'value', value: 1}},
b: {[META]: {id: '[ID]', path: ['b'], type: 'value', value: 2}} const nodeState1 = syncEson(json1, undefined)
expect(nodeState1).toEqual({
arr: [{}, {}, {}],
obj: {a: {}}
})
expect(nodeState1.arr[0][ID]).toBeDefined()
expect(nodeState1.arr[0][TYPE]).toEqual('value')
expect(nodeState1.arr[TYPE]).toEqual('array')
expect(nodeState1[TYPE]).toEqual('object')
const json2 = {
arr: [1, 2],
obj: {a : 2, b : 4}
}
const nodeState2 = syncEson(json2, nodeState1)
expect(nodeState2).toEqual({
arr: [{}, {}],
obj: {a: {}, b: {}}
}) })
const actual = jsonToEson([1,2]) // ID's should be the same for unchanged contents
const expected = [ expect(nodeState2[ID]).toEqual(nodeState1[ID])
{[META]: {id: '[ID]', path: ['0'], type: 'value', value: 1}}, expect(nodeState2.arr[ID]).toEqual(nodeState1.arr[ID])
{[META]: {id: '[ID]', path: ['1'], type: 'value', value: 2}} expect(nodeState2.arr[0][ID]).toEqual(nodeState1.arr[0][ID])
] expect(nodeState2.arr[1][ID]).toEqual(nodeState1.arr[1][ID])
expected[META] = {id: '[ID]', path: [], type: 'Array'} expect(nodeState2.obj[ID]).toEqual(nodeState1.obj[ID])
assertDeepEqualEson(actual, expected) expect(nodeState2.obj.a[ID]).toEqual(nodeState1.obj.a[ID])
})
test('esonToJson', () => {
const json = {
"obj": {
"arr": [1,2, {"first":3,"last":4}]
},
"str": "hello world",
"nill": null,
"bool": false
}
const eson = jsonToEson(json)
expect(esonToJson(eson)).toEqual(json)
}) })
test('expand a single path', () => { test('expand a single path', () => {
const eson = jsonToEson({ const eson = syncEson({
"obj": { "obj": {
"arr": [1,2, {"first":3,"last":4}] "arr": [1,2, {"first":3,"last":4}]
}, },
@ -62,16 +72,14 @@ test('expand a single path', () => {
const path = ['obj', 'arr', 2] const path = ['obj', 'arr', 2]
const collapsed = expandOne(eson, path, false) const collapsed = expandOne(eson, path, false)
expect(collapsed.obj.arr[2][META].expanded).toEqual(false) expect(collapsed.obj.arr[2][EXPANDED]).toEqual(false)
assertDeepEqualEson(deleteIn(collapsed, path.concat([META, 'expanded'])), eson)
const expanded = expandOne(eson, path, true) const expanded = expandOne(eson, path, true)
expect(expanded.obj.arr[2][META].expanded).toEqual(true) expect(expanded.obj.arr[2][EXPANDED]).toEqual(true)
assertDeepEqualEson(deleteIn(expanded, path.concat([META, 'expanded'])), eson)
}) })
test('expand all objects/arrays on a path', () => { test('expand all objects/arrays on a path', () => {
const eson = jsonToEson({ const eson = syncEson({
"obj": { "obj": {
"arr": [1,2, {"first":3,"last":4}] "arr": [1,2, {"first":3,"last":4}]
}, },
@ -83,28 +91,20 @@ test('expand all objects/arrays on a path', () => {
const path = ['obj', 'arr', 2] const path = ['obj', 'arr', 2]
const collapsed = expandPath(eson, path, false) const collapsed = expandPath(eson, path, false)
expect(collapsed[META].expanded).toEqual(false) expect(collapsed[EXPANDED]).toEqual(false)
expect(collapsed.obj[META].expanded).toEqual(false) expect(collapsed.obj[EXPANDED]).toEqual(false)
expect(collapsed.obj.arr[META].expanded).toEqual(false) expect(collapsed.obj.arr[EXPANDED]).toEqual(false)
expect(collapsed.obj.arr[2][META].expanded).toEqual(false) expect(collapsed.obj.arr[2][EXPANDED]).toEqual(false)
const expanded = expandPath(eson, path, true) const expanded = expandPath(eson, path, true)
expect(expanded[META].expanded).toEqual(true) expect(expanded[EXPANDED]).toEqual(true)
expect(expanded.obj[META].expanded).toEqual(true) expect(expanded.obj[EXPANDED]).toEqual(true)
expect(expanded.obj.arr[META].expanded).toEqual(true) expect(expanded.obj.arr[EXPANDED]).toEqual(true)
expect(expanded.obj.arr[2][META].expanded).toEqual(true) expect(expanded.obj.arr[2][EXPANDED]).toEqual(true)
let orig = expanded
orig = deleteIn(orig, [].concat([META, 'expanded']))
orig = deleteIn(orig, ['obj'].concat([META, 'expanded']))
orig = deleteIn(orig, ['obj', 'arr'].concat([META, 'expanded']))
orig = deleteIn(orig, ['obj', 'arr', 2].concat([META, 'expanded']))
assertDeepEqualEson(orig, eson)
}) })
test('expand a callback', () => { test('expand a callback', () => {
const eson = jsonToEson({ const eson = syncEson({
"obj": { "obj": {
"arr": [1,2, {"first":3,"last":4}] "arr": [1,2, {"first":3,"last":4}]
}, },
@ -114,25 +114,20 @@ test('expand a callback', () => {
}) })
function callback (path) { function callback (path) {
console.log('callback')
return (path.length >= 1) return (path.length >= 1)
? false // collapse ? true // expand
: undefined // leave untouched : undefined // leave untouched
} }
const collapsed = expand(eson, callback) const expanded = expand(eson, callback)
expect(collapsed[META].expanded).toEqual(undefined) expect(expanded[EXPANDED]).toEqual(false)
expect(collapsed.obj[META].expanded).toEqual(false) expect(expanded.obj[EXPANDED]).toEqual(true)
expect(collapsed.obj.arr[META].expanded).toEqual(false) expect(expanded.obj.arr[EXPANDED]).toEqual(true)
expect(collapsed.obj.arr[2][META].expanded).toEqual(false) expect(expanded.obj.arr[2][EXPANDED]).toEqual(true)
let orig = collapsed
orig = deleteIn(orig, ['obj'].concat([META, 'expanded']))
orig = deleteIn(orig, ['obj', 'arr'].concat([META, 'expanded']))
orig = deleteIn(orig, ['obj', 'arr', 2].concat([META, 'expanded']))
assertDeepEqualEson(orig, eson)
}) })
test('expand a callback should not change the object when nothing happens', () => { test('expand a callback should not change the object when nothing happens', () => {
const eson = jsonToEson({a: [1,2,3], b: {c: 4}}) const eson = syncEson({a: [1,2,3], b: {c: 4}})
function callback (path) { function callback (path) {
return undefined return undefined
} }
@ -141,69 +136,8 @@ test('expand a callback should not change the object when nothing happens', () =
expect(collapsed).toBe(eson) expect(collapsed).toBe(eson)
}) })
test('transform (no change)', () => {
const eson = jsonToEson({a: [1,2,3], b: {c: 4}})
const updated = transform(eson, (value, path) => value)
assertDeepEqualEson(updated, eson)
expect(updated).toBe(eson)
})
test('transform (change based on value)', () => {
const eson = jsonToEson({a: [1,2,3], b: {c: 4}})
const updated = transform(eson,
(value, path) => value[META].value === 2 ? jsonToEson(20, path) : value)
const expected = jsonToEson({a: [1,20,3], b: {c: 4}})
assertDeepEqualEson(updated, expected)
expect(updated.b).toBe(eson.b) // should not have replaced b
})
test('transform (change based on path)', () => {
const eson = jsonToEson({a: [1,2,3], b: {c: 4}})
const updated = transform(eson,
(value, path) => path.join('.') === 'a.1' ? jsonToEson(20, path) : value)
const expected = jsonToEson({a: [1,20,3], b: {c: 4}})
assertDeepEqualEson(updated, expected)
expect(updated.b).toBe(eson.b) // should not have replaced b
})
test('pathExists', () => {
const eson = jsonToEson({
"obj": {
"arr": [1,2, {"first":3,"last":4}]
},
"str": "hello world",
"nill": null,
"bool": false
})
expect(pathExists(eson, ['obj', 'arr', 2, 'first'])).toEqual(true)
expect(pathExists(eson, ['obj', 'foo'])).toEqual(false)
expect(pathExists(eson, ['obj', 'foo', 'bar'])).toEqual(false)
expect(pathExists(eson, [])).toEqual(true)
})
test('parseJSONPointer', () => {
expect(parseJSONPointer('/obj/a')).toEqual(['obj', 'a'])
expect(parseJSONPointer('/arr/-')).toEqual(['arr', '-'])
expect(parseJSONPointer('/foo/~1~0 ~0~1')).toEqual(['foo', '/~ ~/'])
expect(parseJSONPointer('/obj')).toEqual(['obj'])
expect(parseJSONPointer('/')).toEqual([''])
expect(parseJSONPointer('')).toEqual([])
})
test('compileJSONPointer', () => {
expect(compileJSONPointer(['foo', 'bar'])).toEqual('/foo/bar')
expect(compileJSONPointer(['foo', '/~ ~/'])).toEqual('/foo/~1~0 ~0~1')
expect(compileJSONPointer([''])).toEqual('/')
expect(compileJSONPointer([])).toEqual('')
})
test('add and remove errors', () => { test('add and remove errors', () => {
const eson = jsonToEson({ const eson = syncEson({
"obj": { "obj": {
"arr": [1,2, {"first":3,"last":4}] "arr": [1,2, {"first":3,"last":4}]
}, },
@ -220,9 +154,13 @@ test('add and remove errors', () => {
const actual1 = applyErrors(eson, jsonSchemaErrors) const actual1 = applyErrors(eson, jsonSchemaErrors)
let expected = eson let expected = eson
expected = setIn(expected, ['obj', 'arr', '2', 'last', META, 'error'], jsonSchemaErrors[0]) expected = setIn(expected, ['obj', 'arr', '2', 'last', ERROR], jsonSchemaErrors[0])
expected = setIn(expected, ['nill', META, 'error'], jsonSchemaErrors[1]) expected = setIn(expected, ['nill', ERROR], jsonSchemaErrors[1])
assertDeepEqualEson(actual1, expected)
console.log(actual1)
console.log(expected)
assertEqualEson(actual1, expected)
// re-applying the same errors should not change eson // re-applying the same errors should not change eson
const actual2 = applyErrors(actual1, jsonSchemaErrors) const actual2 = applyErrors(actual1, jsonSchemaErrors)
@ -230,12 +168,12 @@ test('add and remove errors', () => {
// clear errors // clear errors
const actual3 = applyErrors(actual2, []) const actual3 = applyErrors(actual2, [])
assertDeepEqualEson(actual3, eson) assertEqualEson(actual3, eson)
expect(actual3.str).toEqual(eson.str) // shouldn't have touched values not affected by the errors expect(actual3.str).toEqual(eson.str) // shouldn't have touched values not affected by the errors
}) })
test('search', () => { test('search', () => {
const eson = jsonToEson({ const eson = syncEson({
"obj": { "obj": {
"arr": [1,2, {"first":3,"last":4}] "arr": [1,2, {"first":3,"last":4}]
}, },
@ -259,18 +197,18 @@ test('search', () => {
expect(active).toEqual({path: ['obj', 'arr', '2', 'last'], area: 'property'}) expect(active).toEqual({path: ['obj', 'arr', '2', 'last'], area: 'property'})
let expected = esonWithSearch let expected = esonWithSearch
expected = setIn(expected, ['obj', 'arr', '2', 'last', META, 'searchProperty'], 'active') expected = setIn(expected, ['obj', 'arr', '2', 'last', SEARCH_PROPERTY], 'active')
expected = setIn(expected, ['str', META, 'searchValue'], 'normal') expected = setIn(expected, ['str', SEARCH_VALUE], 'normal')
expected = setIn(expected, ['nill', META, 'searchProperty'], 'normal') expected = setIn(expected, ['nill', SEARCH_PROPERTY], 'normal')
expected = setIn(expected, ['nill', META, 'searchValue'], 'normal') expected = setIn(expected, ['nill', SEARCH_VALUE], 'normal')
expected = setIn(expected, ['bool', META, 'searchProperty'], 'normal') expected = setIn(expected, ['bool', SEARCH_PROPERTY], 'normal')
expected = setIn(expected, ['bool', META, 'searchValue'], 'normal') expected = setIn(expected, ['bool', SEARCH_VALUE], 'normal')
assertDeepEqualEson(esonWithSearch, expected) assertEqualEson(esonWithSearch, expected)
}) })
test('search number', () => { test('search number', () => {
const eson = jsonToEson({ const eson = syncEson({
"2": "two", "2": "two",
"arr": ["a", "b", "c", "2"] "arr": ["a", "b", "c", "2"]
}) })
@ -285,7 +223,7 @@ test('search number', () => {
}) })
test('nextSearchResult', () => { test('nextSearchResult', () => {
const eson = jsonToEson({ const eson = syncEson({
"obj": { "obj": {
"arr": [1,2, {"first":3,"last":4}] "arr": [1,2, {"first":3,"last":4}]
}, },
@ -302,31 +240,31 @@ test('nextSearchResult', () => {
]) ])
expect(first.searchResult.active).toEqual({path: ['obj', 'arr'], area: 'property'}) expect(first.searchResult.active).toEqual({path: ['obj', 'arr'], area: 'property'})
expect(getIn(first.eson, ['obj', 'arr', META, 'searchProperty'])).toEqual('active') expect(getIn(first.eson, ['obj', 'arr', SEARCH_PROPERTY])).toEqual('active')
expect(getIn(first.eson, ['obj', 'arr', '2', 'last', META, 'searchProperty'])).toEqual('normal') expect(getIn(first.eson, ['obj', 'arr', '2', 'last', SEARCH_PROPERTY])).toEqual('normal')
expect(getIn(first.eson, ['bool', META, 'searchValue'])).toEqual('normal') expect(getIn(first.eson, ['bool', SEARCH_VALUE])).toEqual('normal')
const second = nextSearchResult(first.eson, first.searchResult) const second = nextSearchResult(first.eson, first.searchResult)
expect(second.searchResult.active).toEqual({path: ['obj', 'arr', '2', 'last'], area: 'property'}) expect(second.searchResult.active).toEqual({path: ['obj', 'arr', '2', 'last'], area: 'property'})
expect(getIn(second.eson, ['obj', 'arr', META, 'searchProperty'])).toEqual('normal') expect(getIn(second.eson, ['obj', 'arr', SEARCH_PROPERTY])).toEqual('normal')
expect(getIn(second.eson, ['obj', 'arr', '2', 'last', META, 'searchProperty'])).toEqual('active') expect(getIn(second.eson, ['obj', 'arr', '2', 'last', SEARCH_PROPERTY])).toEqual('active')
expect(getIn(second.eson, ['bool', META, 'searchValue'])).toEqual('normal') expect(getIn(second.eson, ['bool', SEARCH_VALUE])).toEqual('normal')
const third = nextSearchResult(second.eson, second.searchResult) const third = nextSearchResult(second.eson, second.searchResult)
expect(third.searchResult.active).toEqual({path: ['bool'], area: 'value'}) expect(third.searchResult.active).toEqual({path: ['bool'], area: 'value'})
expect(getIn(third.eson, ['obj', 'arr', META, 'searchProperty'])).toEqual('normal') expect(getIn(third.eson, ['obj', 'arr', SEARCH_PROPERTY])).toEqual('normal')
expect(getIn(third.eson, ['obj', 'arr', '2', 'last', META, 'searchProperty'])).toEqual('normal') expect(getIn(third.eson, ['obj', 'arr', '2', 'last', SEARCH_PROPERTY])).toEqual('normal')
expect(getIn(third.eson, ['bool', META, 'searchValue'])).toEqual('active') expect(getIn(third.eson, ['bool', SEARCH_VALUE])).toEqual('active')
const wrappedAround = nextSearchResult(third.eson, third.searchResult) const wrappedAround = nextSearchResult(third.eson, third.searchResult)
expect(wrappedAround.searchResult.active).toEqual({path: ['obj', 'arr'], area: 'property'}) expect(wrappedAround.searchResult.active).toEqual({path: ['obj', 'arr'], area: 'property'})
expect(getIn(wrappedAround.eson, ['obj', 'arr', META, 'searchProperty'])).toEqual('active') expect(getIn(wrappedAround.eson, ['obj', 'arr', SEARCH_PROPERTY])).toEqual('active')
expect(getIn(wrappedAround.eson, ['obj', 'arr', '2', 'last', META, 'searchProperty'])).toEqual('normal') expect(getIn(wrappedAround.eson, ['obj', 'arr', '2', 'last', SEARCH_PROPERTY])).toEqual('normal')
expect(getIn(wrappedAround.eson, ['bool', META, 'searchValue'])).toEqual('normal') expect(getIn(wrappedAround.eson, ['bool', SEARCH_VALUE])).toEqual('normal')
}) })
test('previousSearchResult', () => { test('previousSearchResult', () => {
const eson = jsonToEson({ const eson = syncEson({
"obj": { "obj": {
"arr": [1,2, {"first":3,"last":4}] "arr": [1,2, {"first":3,"last":4}]
}, },
@ -343,31 +281,31 @@ test('previousSearchResult', () => {
]) ])
expect(init.searchResult.active).toEqual({path: ['obj', 'arr'], area: 'property'}) expect(init.searchResult.active).toEqual({path: ['obj', 'arr'], area: 'property'})
expect(getIn(init.eson, ['obj', 'arr', META, 'searchProperty'])).toEqual('active') expect(getIn(init.eson, ['obj', 'arr', SEARCH_PROPERTY])).toEqual('active')
expect(getIn(init.eson, ['obj', 'arr', '2', 'last', META, 'searchProperty'])).toEqual('normal') expect(getIn(init.eson, ['obj', 'arr', '2', 'last', SEARCH_PROPERTY])).toEqual('normal')
expect(getIn(init.eson, ['bool', META, 'searchValue'])).toEqual('normal') expect(getIn(init.eson, ['bool', SEARCH_VALUE])).toEqual('normal')
const third = previousSearchResult(init.eson, init.searchResult) const third = previousSearchResult(init.eson, init.searchResult)
expect(third.searchResult.active).toEqual({path: ['bool'], area: 'value'}) expect(third.searchResult.active).toEqual({path: ['bool'], area: 'value'})
expect(getIn(third.eson, ['obj', 'arr', META, 'searchProperty'])).toEqual('normal') expect(getIn(third.eson, ['obj', 'arr', SEARCH_PROPERTY])).toEqual('normal')
expect(getIn(third.eson, ['obj', 'arr', '2', 'last', META, 'searchProperty'])).toEqual('normal') expect(getIn(third.eson, ['obj', 'arr', '2', 'last', SEARCH_PROPERTY])).toEqual('normal')
expect(getIn(third.eson, ['bool', META, 'searchValue'])).toEqual('active') expect(getIn(third.eson, ['bool', SEARCH_VALUE])).toEqual('active')
const second = previousSearchResult(third.eson, third.searchResult) const second = previousSearchResult(third.eson, third.searchResult)
expect(second.searchResult.active).toEqual({path: ['obj', 'arr', '2', 'last'], area: 'property'}) expect(second.searchResult.active).toEqual({path: ['obj', 'arr', '2', 'last'], area: 'property'})
expect(getIn(second.eson, ['obj', 'arr', META, 'searchProperty'])).toEqual('normal') expect(getIn(second.eson, ['obj', 'arr', SEARCH_PROPERTY])).toEqual('normal')
expect(getIn(second.eson, ['obj', 'arr', '2', 'last', META, 'searchProperty'])).toEqual('active') expect(getIn(second.eson, ['obj', 'arr', '2', 'last', SEARCH_PROPERTY])).toEqual('active')
expect(getIn(second.eson, ['bool', META, 'searchValue'])).toEqual('normal') expect(getIn(second.eson, ['bool', SEARCH_VALUE])).toEqual('normal')
const first = previousSearchResult(second.eson, second.searchResult) const first = previousSearchResult(second.eson, second.searchResult)
expect(first.searchResult.active).toEqual({path: ['obj', 'arr'], area: 'property'}) expect(first.searchResult.active).toEqual({path: ['obj', 'arr'], area: 'property'})
expect(getIn(first.eson, ['obj', 'arr', META, 'searchProperty'])).toEqual('active') expect(getIn(first.eson, ['obj', 'arr', SEARCH_PROPERTY])).toEqual('active')
expect(getIn(first.eson, ['obj', 'arr', '2', 'last', META, 'searchProperty'])).toEqual('normal') expect(getIn(first.eson, ['obj', 'arr', '2', 'last', SEARCH_PROPERTY])).toEqual('normal')
expect(getIn(first.eson, ['bool', META, 'searchValue'])).toEqual('normal') expect(getIn(first.eson, ['bool', SEARCH_VALUE])).toEqual('normal')
}) })
test('selection (object)', () => { test('selection (object)', () => {
const eson = jsonToEson({ const eson = syncEson({
"obj": { "obj": {
"arr": [1,2, {"first":3,"last":4}] "arr": [1,2, {"first":3,"last":4}]
}, },
@ -383,10 +321,10 @@ test('selection (object)', () => {
const actual = applySelection(eson, selection) const actual = applySelection(eson, selection)
let expected = eson let expected = eson
expected = setIn(expected, ['obj', META, 'selected'], SELECTED + SELECTED_START + SELECTED_FIRST) expected = setIn(expected, ['obj', SELECTION], SELECTED + SELECTED_START + SELECTED_FIRST)
expected = setIn(expected, ['str', META, 'selected'], SELECTED) expected = setIn(expected, ['str', SELECTION], SELECTED)
expected = setIn(expected, ['nill', META, 'selected'], SELECTED + SELECTED_END + SELECTED_LAST) expected = setIn(expected, ['nill', SELECTION], SELECTED + SELECTED_END + SELECTED_LAST)
assertDeepEqualEson(actual, expected) assertEqualEson(actual, expected)
// test whether old selection results are cleaned up // test whether old selection results are cleaned up
const selection2 = { const selection2 = {
@ -395,13 +333,13 @@ test('selection (object)', () => {
} }
const actual2 = applySelection(actual, selection2) const actual2 = applySelection(actual, selection2)
let expected2 = eson let expected2 = eson
expected2 = setIn(expected2, ['nill', META, 'selected'], SELECTED + SELECTED_START + SELECTED_FIRST) expected2 = setIn(expected2, ['nill', SELECTION], SELECTED + SELECTED_START + SELECTED_FIRST)
expected2 = setIn(expected2, ['bool', META, 'selected'], SELECTED + SELECTED_END + SELECTED_LAST) expected2 = setIn(expected2, ['bool', SELECTION], SELECTED + SELECTED_END + SELECTED_LAST)
assertDeepEqualEson(actual2, expected2) assertEqualEson(actual2, expected2)
}) })
test('selection (array)', () => { test('selection (array)', () => {
const eson = jsonToEson({ const eson = syncEson({
"obj": { "obj": {
"arr": [1,2, {"first":3,"last":4}] "arr": [1,2, {"first":3,"last":4}]
}, },
@ -417,16 +355,16 @@ test('selection (array)', () => {
const actual = applySelection(eson, selection) const actual = applySelection(eson, selection)
let expected = eson let expected = eson
expected = setIn(expected, ['obj', 'arr', '0', META, 'selected'], expected = setIn(expected, ['obj', 'arr', '0', SELECTION],
SELECTED + SELECTED_END + SELECTED_FIRST) SELECTED + SELECTED_END + SELECTED_FIRST)
expected = setIn(expected, ['obj', 'arr', '1', META, 'selected'], expected = setIn(expected, ['obj', 'arr', '1', SELECTION],
SELECTED + SELECTED_START + SELECTED_LAST) SELECTED + SELECTED_START + SELECTED_LAST)
assertDeepEqualEson(actual, expected) assertEqualEson(actual, expected)
}) })
test('selection (value)', () => { test('selection (value)', () => {
const eson = jsonToEson({ const eson = syncEson({
"obj": { "obj": {
"arr": [1,2, {"first":3,"last":4}] "arr": [1,2, {"first":3,"last":4}]
}, },
@ -440,13 +378,13 @@ test('selection (value)', () => {
} }
const actual = applySelection(eson, selection) const actual = applySelection(eson, selection)
const expected = setIn(eson, ['obj', 'arr', '2', 'first', META, 'selected'], const expected = setIn(eson, ['obj', 'arr', '2', 'first', SELECTION],
SELECTED + SELECTED_START + SELECTED_END + SELECTED_FIRST + SELECTED_LAST) SELECTED + SELECTED_START + SELECTED_END + SELECTED_FIRST + SELECTED_LAST)
assertDeepEqualEson(actual, expected) assertEqualEson(actual, expected)
}) })
test('selection (node)', () => { test('selection (node)', () => {
const eson = jsonToEson({ const eson = syncEson({
"obj": { "obj": {
"arr": [1,2, {"first":3,"last":4}] "arr": [1,2, {"first":3,"last":4}]
}, },
@ -460,26 +398,26 @@ test('selection (node)', () => {
} }
const actual = applySelection(eson, selection) const actual = applySelection(eson, selection)
const expected = setIn(eson, ['obj', 'arr', META, 'selected'], const expected = setIn(eson, ['obj', 'arr', SELECTION],
SELECTED + SELECTED_START + SELECTED_END + SELECTED_FIRST + SELECTED_LAST) SELECTED + SELECTED_START + SELECTED_END + SELECTED_FIRST + SELECTED_LAST)
assertDeepEqualEson(actual, expected) assertEqualEson(actual, expected)
}) })
test('pathsFromSelection (object)', () => { test('pathsFromSelection (object)', () => {
const eson = jsonToEson({ const json = {
"obj": { "obj": {
"arr": [1,2, {"first":3,"last":4}] "arr": [1,2, {"first":3,"last":4}]
}, },
"str": "hello world", "str": "hello world",
"nill": null, "nill": null,
"bool": false "bool": false
}) }
const selection = { const selection = {
start: ['obj', 'arr', '2', 'last'], start: ['obj', 'arr', '2', 'last'],
end: ['nill'] end: ['nill']
} }
expect(pathsFromSelection(eson, selection)).toEqual([ expect(pathsFromSelection(json, selection)).toEqual([
['obj'], ['obj'],
['str'], ['str'],
['nill'] ['nill']
@ -487,138 +425,56 @@ test('pathsFromSelection (object)', () => {
}) })
test('pathsFromSelection (array)', () => { test('pathsFromSelection (array)', () => {
const eson = jsonToEson({ const json = {
"obj": { "obj": {
"arr": [1,2, {"first":3,"last":4}] "arr": [1,2, {"first":3,"last":4}]
}, },
"str": "hello world", "str": "hello world",
"nill": null, "nill": null,
"bool": false "bool": false
}) }
const selection = { const selection = {
start: ['obj', 'arr', '1'], start: ['obj', 'arr', '1'],
end: ['obj', 'arr', '0'] // note the "wrong" order of start and end end: ['obj', 'arr', '0'] // note the "backward" order of start and end
} }
expect(pathsFromSelection(eson, selection)).toEqual([ expect(pathsFromSelection(json, selection)).toEqual([
['obj', 'arr', '0'], ['obj', 'arr', '0'],
['obj', 'arr', '1'] ['obj', 'arr', '1']
]) ])
}) })
test('pathsFromSelection (value)', () => { test('pathsFromSelection (value)', () => {
const eson = jsonToEson({ const json = {
"obj": { "obj": {
"arr": [1,2, {"first":3,"last":4}] "arr": [1,2, {"first":3,"last":4}]
}, },
"str": "hello world", "str": "hello world",
"nill": null, "nill": null,
"bool": false "bool": false
}) }
const selection = { const selection = {
start: ['obj', 'arr', '2', 'first'], start: ['obj', 'arr', '2', 'first'],
end: ['obj', 'arr', '2', 'first'] end: ['obj', 'arr', '2', 'first']
} }
expect(pathsFromSelection(eson, selection)).toEqual([ expect(pathsFromSelection(json, selection)).toEqual([
['obj', 'arr', '2', 'first'], ['obj', 'arr', '2', 'first'],
]) ])
}) })
test('pathsFromSelection (before)', () => {
const eson = jsonToEson({
"obj": {
"arr": [1,2, {"first":3,"last":4}]
},
"str": "hello world",
"nill": null,
"bool": false
})
const selection = {
after: ['obj', 'arr', '2', 'first']
}
expect(pathsFromSelection(eson, selection)).toEqual([])
})
test('pathsFromSelection (after)', () => { test('pathsFromSelection (after)', () => {
const eson = jsonToEson({ const json = {
"obj": { "obj": {
"arr": [1,2, {"first":3,"last":4}] "arr": [1,2, {"first":3,"last":4}]
}, },
"str": "hello world", "str": "hello world",
"nill": null, "nill": null,
"bool": false "bool": false
}) }
const selection = { const selection = {
after: ['obj', 'arr', '2', 'first'] after: ['obj', 'arr', '2', 'first']
} }
expect(pathsFromSelection(eson, selection)).toEqual([]) expect(pathsFromSelection(json, selection)).toEqual([])
}) })
test('getEsonState', () => {
const eson = jsonToEson({
"obj": {
"arr": ["1",2, {"first":3,"last":4}]
},
"str": "hello world",
"nill": null,
"bool": false
})
eson.obj[META].expanded = true
eson.obj.arr[META].expanded = false
eson.obj.arr[0][META].type = 'string'
eson.obj.arr[2][META].expanded = true
const state = getEsonState(eson)
expect(state).toEqual({
'/obj': { expanded: true },
'/obj/arr/0': { type: 'string' },
'/obj/arr/2': { expanded: true },
})
})
// TODO: test applyEsonState
// helper function to print JSON in the console
function printJSON (json, message = null) {
if (message) {
console.log(message)
}
console.log(JSON.stringify(json, null, 2))
}
function printESON (eson, message = null) {
if (message) {
console.log(message)
}
let data = []
transform(eson, function (value, path) {
// const strPath = padEnd(, 20)
// console.log(`${strPath} ${'value' in value[META] ? value[META].value : ''} ${JSON.stringify(value[META])}`)
data.push({
path: '[' + path.join(', ') + ']',
value: repeat(' ', path.length) + (value[META].type === 'Object'
? '{...}'
: value[META].type === 'Array'
? '[...]'
: JSON.stringify(value[META].value)),
meta: JSON.stringify(value[META])
})
return value
})
console.table(data)
}
// helper function to load a JSON file
function loadJSON (filename) {
return JSON.parse(readFileSync(__dirname + '/' + filename, 'utf-8'))
}

View File

@ -0,0 +1,287 @@
import isEqual from 'lodash/isEqual'
import initial from 'lodash/initial'
import { setIn, getIn, deleteIn, insertAt, existsIn } from './utils/immutabilityHelpers'
import { parseJSONPointer, compileJSONPointer } from './jsonPointer'
/**
* Apply a patch to a JSON object
* The original JSON object will not be changed,
* instead, the patch is applied in an immutable way
* @param {JSON} json
* @param {JSONPatch} patch Array with JSON patch actions
* @return {{json: JSON, revert: JSONPatch, error: Error | null}}
*/
export function immutableJsonPatch (json, patch) {
let updatedJson = json
let revert = []
for (let i = 0; i < patch.length; i++) {
const action = patch[i]
const path = action.path ? parseJSONPointer(action.path) : null
const from = action.from ? parseJSONPointer(action.from) : null
switch (action.op) {
case 'add': {
const result = add(updatedJson, path, action.value)
updatedJson = result.json
revert = result.revert.concat(revert)
break
}
case 'remove': {
const result = remove(updatedJson, path)
updatedJson = result.json
revert = result.revert.concat(revert)
break
}
case 'replace': {
const result = replace(updatedJson, path, action.value)
updatedJson = result.json
revert = result.revert.concat(revert)
break
}
case 'copy': {
if (!action.from) {
return {
json: updatedJson,
revert: [],
error: new Error('Property "from" expected in copy action ' + JSON.stringify(action))
}
}
const result = copy(updatedJson, path, from)
updatedJson = result.json
revert = result.revert.concat(revert)
break
}
case 'move': {
if (!action.from) {
return {
json: updatedJson,
revert: [],
error: new Error('Property "from" expected in move action ' + JSON.stringify(action))
}
}
const result = move(updatedJson, path, from)
updatedJson = result.json
revert = result.revert.concat(revert)
break
}
case 'test': {
// when a test fails, cancel the whole patch and return the error
const error = test(updatedJson, path, action.value)
if (error) {
return { json, revert: [], error}
}
break
}
default: {
// unknown patch operation. Cancel the whole patch and return an error
return {
json,
revert: [],
error: new Error('Unknown JSONPatch op ' + JSON.stringify(action.op))
}
}
}
}
return {
json: updatedJson,
revert,
error: null
}
}
/**
* Replace an existing item
* @param {JSON} json
* @param {Path} path
* @param {JSON} value
* @return {{json: JSON, revert: JSONPatch}}
*/
export function replace (json, path, value) {
const oldValue = getIn(json, path)
return {
json: setIn(json, path, value),
revert: [{
op: 'replace',
path: compileJSONPointer(path),
value: oldValue
}]
}
}
/**
* Remove an item or property
* @param {JSON} json
* @param {Path} path
* @return {{json: JSON, revert: JSONPatch}}
*/
export function remove (json, path) {
const oldValue = getIn(json, path)
return {
json: deleteIn(json, path),
revert: [{
op: 'add',
path: compileJSONPointer(path),
value: oldValue
}]
}
}
/**
* @param {JSON} json
* @param {Path} path
* @param {JSON} value
* @return {{json: JSON, revert: JSONPatch}}
* @private
*/
export function add (json, path, value) {
const resolvedPath = resolvePathIndex(json, path)
const parent = getIn(json, initial(path))
const parentIsArray = Array.isArray(parent)
const updatedJson = parentIsArray
? insertAt(json, resolvedPath, value)
: setIn(json, resolvedPath, value)
if (!parentIsArray && existsIn(json, resolvedPath)) {
const oldValue = getIn(json, resolvedPath)
return {
json: updatedJson,
revert: [{
op: 'replace',
path: compileJSONPointer(resolvedPath),
value: oldValue
}]
}
}
else {
return {
json: updatedJson,
revert: [{
op: 'remove',
path: compileJSONPointer(resolvedPath)
}]
}
}
}
/**
* Copy a value
* @param {JSON} json
* @param {Path} path
* @param {Path} from
* @return {{json: JSON, revert: ESONPatch}}
* @private
*/
export function copy (json, path, from) {
const value = getIn(json, from)
return add(json, path, value)
}
/**
* Move a value
* @param {JSON} json
* @param {Path} path
* @param {Path} from
* @return {{json: JSON, revert: ESONPatch}}
* @private
*/
export function move (json, path, from) {
const resolvedPath = resolvePathIndex(json, path)
const parent = getIn(json, initial(path))
const parentIsArray = Array.isArray(parent)
const oldValue = getIn(json, path)
const value = getIn(json, from)
const removedJson = remove(json, from).json
const updatedJson = parentIsArray
? insertAt(removedJson, resolvedPath, value)
: setIn(removedJson, resolvedPath, value)
if (oldValue !== undefined && !parentIsArray) {
// replaces an existing value in an object
return {
json: updatedJson,
revert: [
{
op: 'move',
from: compileJSONPointer(resolvedPath),
path: compileJSONPointer(from)
},
{
op: 'add',
path: compileJSONPointer(resolvedPath),
value: oldValue
}
]
}
}
else {
return {
json: updatedJson,
revert: [
{
op: 'move',
from: compileJSONPointer(resolvedPath),
path: compileJSONPointer(from)
}
]
}
}
}
/**
* Test whether the data contains the provided value at the specified path.
* Throws an error when the test fails.
* @param {JSON} json
* @param {Path} path
* @param {*} value
* @return {null | Error} Returns an error when the tests, returns null otherwise
*/
export function test (json, path, value) {
if (value === undefined) {
return new Error('Test failed, no value provided')
}
if (!existsIn(json, path)) {
return new Error('Test failed, path not found')
}
const actualValue = getIn(json, path)
if (!isEqual(actualValue, value)) {
return new Error('Test failed, value differs')
}
}
/**
* Resolve the path of an index like '''
* @param {JSON} json
* @param {Path} path
* @returns {Path} Returns the resolved path
*/
export function resolvePathIndex (json, path) {
const parent = getIn(json, initial(path))
return (path[path.length - 1] === '-')
? path.slice(0, path.length - 1).concat(parent.length)
: path
}

View File

@ -0,0 +1,334 @@
'use strict'
import { immutableJsonPatch } from './immutableJsonPatch'
test('test toBe', () => {
const a = { x: 2 }
const b = { x: 2 }
// just to be sure toBe does what I think it does...
expect(a).toBe(a)
expect(b).not.toBe(a)
expect(b).toEqual(a)
})
test('jsonpatch add', () => {
const json = {
arr: [1,2,3],
obj: {a : 2}
}
const patch = [
{op: 'add', path: '/obj/b', value: {foo: 'bar'}}
]
const result = immutableJsonPatch(json, patch)
expect(result.json).toEqual({
arr: [1,2,3],
obj: {a : 2, b: {foo: 'bar'}}
})
expect(result.revert).toEqual([
{op: 'remove', path: '/obj/b'}
])
expect(result.json.arr).toBe(json.arr)
})
test('jsonpatch add: insert in matrix', () => {
const json = {
arr: [1,2,3],
obj: {a : 2}
}
const patch = [
{op: 'add', path: '/arr/1', value: 4}
]
const result = immutableJsonPatch(json, patch)
expect(result.json).toEqual({
arr: [1,4,2,3],
obj: {a : 2}
})
expect(result.revert).toEqual([
{op: 'remove', path: '/arr/1'}
])
expect(result.json.obj).toBe(json.obj)
})
test('jsonpatch add: append to matrix', () => {
const json = {
arr: [1,2,3],
obj: {a : 2}
}
const patch = [
{op: 'add', path: '/arr/-', value: 4}
]
const result = immutableJsonPatch(json, patch)
expect(result.json).toEqual({
arr: [1,2,3,4],
obj: {a : 2}
})
expect(result.revert).toEqual([
{op: 'remove', path: '/arr/3'}
])
expect(result.json.obj).toBe(json.obj)
})
test('jsonpatch remove', () => {
const json = {
arr: [1,2,3],
obj: {a : 4},
unchanged: {}
}
const patch = [
{op: 'remove', path: '/obj/a'},
{op: 'remove', path: '/arr/1'},
]
const result = immutableJsonPatch(json, patch)
expect(result.json).toEqual({
arr: [1,3],
obj: {},
unchanged: {}
})
expect(result.revert).toEqual([
{op: 'add', path: '/arr/1', value: 2},
{op: 'add', path: '/obj/a', value: 4}
])
// test revert
const result2 = immutableJsonPatch(result.json, result.revert)
expect(result2.json).toEqual(json)
expect(result2.revert).toEqual(patch)
expect(result.json.unchanged).toBe(json.unchanged)
})
test('jsonpatch replace', () => {
const json = {
arr: [1,2,3],
obj: {a : 4},
unchanged: {}
}
const patch = [
{op: 'replace', path: '/obj/a', value: 400},
{op: 'replace', path: '/arr/1', value: 200},
]
const result = immutableJsonPatch(json, patch)
expect(result.json).toEqual({
arr: [1,200,3],
obj: {a: 400},
unchanged: {}
})
expect(result.revert).toEqual([
{op: 'replace', path: '/arr/1', value: 2},
{op: 'replace', path: '/obj/a', value: 4}
])
// test revert
const result2 = immutableJsonPatch(result.json, result.revert)
expect(result2.json).toEqual(json)
expect(result2.revert).toEqual([
{op: 'replace', path: '/obj/a', value: 400},
{op: 'replace', path: '/arr/1', value: 200}
])
expect(result.json.unchanged).toBe(json.unchanged)
})
test('jsonpatch copy', () => {
const json = {
arr: [1,2,3],
obj: {a : 4}
}
const patch = [
{op: 'copy', from: '/obj', path: '/arr/2'},
]
const result = immutableJsonPatch(json, patch)
expect(result.json).toEqual({
arr: [1, 2, {a:4}, 3],
obj: {a: 4}
})
expect(result.revert).toEqual([
{op: 'remove', path: '/arr/2'}
])
// test revert
const result2 = immutableJsonPatch(result.json, result.revert)
expect(result2.json).toEqual(json)
expect(result2.revert).toEqual([
{op: 'add', path: '/arr/2', value: {a: 4}}
])
expect(result.json.obj).toBe(json.obj)
expect(result.json.arr[2]).toBe(json.obj)
})
test('jsonpatch move', () => {
const json = {
arr: [1,2,3],
obj: {a : 4},
unchanged: {}
}
const patch = [
{op: 'move', from: '/obj', path: '/arr/2'},
]
const result = immutableJsonPatch(json, patch)
expect(result.error).toEqual(null)
expect(result.json).toEqual({
arr: [1, 2, {a:4}, 3],
unchanged: {}
})
expect(result.revert).toEqual([
{op: 'move', from: '/arr/2', path: '/obj'}
])
// test revert
const result2 = immutableJsonPatch(result.json, result.revert)
expect(result2.json).toEqual(json)
expect(result2.revert).toEqual(patch)
expect(result.json.arr[2]).toBe(json.obj)
expect(result.json.unchanged).toBe(json.unchanged)
})
test('jsonpatch move and replace', () => {
const json = { a: 2, b: 3 }
const patch = [
{op: 'move', from: '/a', path: '/b'},
]
const result = immutableJsonPatch(json, patch)
expect(result.json).toEqual({ b : 2 })
expect(result.revert).toEqual([
{op:'move', from: '/b', path: '/a'},
{op:'add', path:'/b', value: 3}
])
// test revert
const result2 = immutableJsonPatch(result.json, result.revert)
expect(result2.json).toEqual(json)
expect(result2.revert).toEqual([
{op: 'remove', path: '/b'},
{op: 'move', from: '/a', path: '/b'}
])
})
test('jsonpatch move and replace (nested)', () => {
const json = {
arr: [1,2,3],
obj: {a : 4},
unchanged: {}
}
const patch = [
{op: 'move', from: '/obj', path: '/arr'},
]
const result = immutableJsonPatch(json, patch)
expect(result.json).toEqual({
arr: {a:4},
unchanged: {}
})
expect(result.revert).toEqual([
{op:'move', from: '/arr', path: '/obj'},
{op:'add', path:'/arr', value: [1,2,3]}
])
// test revert
const result2 = immutableJsonPatch(result.json, result.revert)
expect(result2.json).toEqual(json)
expect(result2.revert).toEqual([
{op: 'remove', path: '/arr'},
{op: 'move', from: '/obj', path: '/arr'}
])
expect(result.json.unchanged).toBe(json.unchanged)
expect(result2.json.unchanged).toBe(json.unchanged)
})
test('jsonpatch test (ok)', () => {
const json = {
arr: [1,2,3],
obj: {a : 4}
}
const patch = [
{op: 'test', path: '/arr', value: [1,2,3]},
{op: 'add', path: '/added', value: 'ok'}
]
const result = immutableJsonPatch(json, patch)
expect(result.json).toEqual({
arr: [1,2,3],
obj: {a : 4},
added: 'ok'
})
expect(result.revert).toEqual([
{op: 'remove', path: '/added'}
])
})
test('jsonpatch test (fail: path not found)', () => {
const json = {
arr: [1,2,3],
obj: {a : 4}
}
const patch = [
{op: 'test', path: '/arr/5', value: [1,2,3]},
{op: 'add', path: '/added', value: 'ok'}
]
const result = immutableJsonPatch(json, patch)
// patch shouldn't be applied
expect(result.json).toEqual({
arr: [1,2,3],
obj: {a : 4}
})
expect(result.revert).toEqual([])
expect(result.error.toString()).toEqual('Error: Test failed, path not found')
})
test('jsonpatch test (fail: value not equal)', () => {
const json = {
arr: [1,2,3],
obj: {a : 4}
}
const patch = [
{op: 'test', path: '/obj', value: {a:4, b: 6}},
{op: 'add', path: '/added', value: 'ok'}
]
const result = immutableJsonPatch(json, patch)
// patch shouldn't be applied
expect(result.json).toEqual({
arr: [1,2,3],
obj: {a : 4}
})
expect(result.revert).toEqual([])
expect(result.error.toString()).toEqual('Error: Test failed, value differs')
})

View File

@ -4,7 +4,7 @@ import JSONEditor from './components/JSONEditor'
import CodeMode from './components/CodeMode' import CodeMode from './components/CodeMode'
import TextMode from './components/TextMode' import TextMode from './components/TextMode'
import TreeMode from './components/TreeMode' import TreeMode from './components/TreeMode'
import { compileJSONPointer, parseJSONPointer } from './eson' import { compileJSONPointer, parseJSONPointer } from './jsonPointer'
const modes = { const modes = {
code: CodeMode, code: CodeMode,

View File

@ -0,0 +1,24 @@
/**
* Parse a JSON Pointer
* WARNING: this is not a complete implementation
* @param {string} pointer
* @return {Path}
*/
export function parseJSONPointer (pointer) {
const path = pointer.split('/')
path.shift() // remove the first empty entry
return path.map(p => p.replace(/~1/g, '/').replace(/~0/g, '~'))
}
/**
* Compile a JSON Pointer
* WARNING: this is not a complete implementation
* @param {Path} path
* @return {string}
*/
export function compileJSONPointer (path) {
return path
.map(p => '/' + String(p).replace(/~/g, '~0').replace(/\//g, '~1'))
.join('')
}

View File

@ -0,0 +1,17 @@
import {compileJSONPointer, parseJSONPointer} from './jsonPointer'
test('parseJSONPointer', () => {
expect(parseJSONPointer('/obj/a')).toEqual(['obj', 'a'])
expect(parseJSONPointer('/arr/-')).toEqual(['arr', '-'])
expect(parseJSONPointer('/foo/~1~0 ~0~1')).toEqual(['foo', '/~ ~/'])
expect(parseJSONPointer('/obj')).toEqual(['obj'])
expect(parseJSONPointer('/')).toEqual([''])
expect(parseJSONPointer('')).toEqual([])
})
test('compileJSONPointer', () => {
expect(compileJSONPointer(['foo', 'bar'])).toEqual('/foo/bar')
expect(compileJSONPointer(['foo', '/~ ~/'])).toEqual('/foo/~1~0 ~0~1')
expect(compileJSONPointer([''])).toEqual('/')
expect(compileJSONPointer([])).toEqual('')
})

View File

@ -1,356 +0,0 @@
import isEqual from 'lodash/isEqual'
import initial from 'lodash/initial'
import last from 'lodash/last'
import {
setIn, updateIn, getIn, deleteIn, insertAt,
cloneWithSymbols
} from './utils/immutabilityHelpers'
import {
META,
jsonToEson, esonToJson, updatePaths,
parseJSONPointer, compileJSONPointer,
expandAll, pathExists, resolvePathIndex, createId, applyEsonState
} from './eson'
/**
* Apply a patch to a ESON object
* @param {ESON} eson
* @param {Array} patch A JSON patch
* @param {function(path: Path)} [expand] Optional function to determine
* what nodes must be expanded
* @return {{data: ESON, revert: Object[], error: Error | null}}
*/
export function patchEson (eson, patch, expand = expandAll) {
let updatedEson = eson
let revert = []
for (let i = 0; i < patch.length; i++) {
const action = patch[i]
const path = action.path ? parseJSONPointer(action.path) : null
const from = action.from ? parseJSONPointer(action.from) : null
const options = action.meta
// TODO: check whether action.op and action.path exist
switch (action.op) {
case 'add': {
const newValue = jsonToEson(action.value, path)
const newValueWithState = (options && options.state)
? applyEsonState(newValue, options.state)
: newValue
const result = add(updatedEson, path, newValueWithState, options)
updatedEson = result.data
revert = result.revert.concat(revert)
break
}
case 'remove': {
const result = remove(updatedEson, path)
updatedEson = result.data
revert = result.revert.concat(revert)
break
}
case 'replace': {
const newValue = jsonToEson(action.value, path)
const newValueWithState = (options && options.state)
? applyEsonState(newValue, options.state)
: newValue
const result = replace(updatedEson, path, newValueWithState)
updatedEson = result.data
revert = result.revert.concat(revert)
break
}
case 'copy': {
if (!action.from) {
return {
data: eson,
revert: [],
error: new Error('Property "from" expected in copy action ' + JSON.stringify(action))
}
}
const result = copy(updatedEson, path, from, options)
updatedEson = result.data
revert = result.revert.concat(revert)
break
}
case 'move': {
if (!action.from) {
return {
data: eson,
revert: [],
error: new Error('Property "from" expected in move action ' + JSON.stringify(action))
}
}
const result = move(updatedEson, path, from, options)
updatedEson = result.data
revert = result.revert.concat(revert)
break
}
case 'test': {
// when a test fails, cancel the whole patch and return the error
const error = test(updatedEson, path, action.value)
if (error) {
return { data: eson, revert: [], error}
}
break
}
default: {
// unknown ESONPatch operation. Cancel the whole patch and return an error
return {
data: eson,
revert: [],
error: new Error('Unknown ESONPatch op ' + JSON.stringify(action.op))
}
}
}
}
return {
data: updatedEson,
revert,
error: null
}
}
/**
* Replace an existing item
* @param {ESON} data
* @param {Path} path
* @param {ESON} value
* @return {{data: ESON, revert: ESONPatch}}
*/
export function replace (data, path, value) {
const oldValue = getIn(data, path)
// keep the original id
let newValue = setIn(value, [META, 'id'], oldValue[META].id)
return {
data: setIn(data, path, newValue),
revert: [{
op: 'replace',
path: compileJSONPointer(path),
value: esonToJson(oldValue),
meta: {
type: oldValue[META].type
}
}]
}
}
/**
* Remove an item or property
* @param {ESON} data
* @param {Path} path
* @return {{data: ESON, revert: ESONPatch}}
*/
export function remove (data, path) {
// console.log('remove', path)
const parentPath = initial(path)
const parent = getIn(data, parentPath)
const dataValue = getIn(data, path)
const value = esonToJson(dataValue)
if (parent[META].type === 'Array') {
return {
data: updatePaths(deleteIn(data, path)),
revert: [{
op: 'add',
path: compileJSONPointer(path),
value,
meta: {
type: dataValue[META].type
}
}]
}
}
else { // parent[META].type === 'Object'
const prop = last(path)
const index = parent[META].props.indexOf(prop)
const nextProp = parent[META].props[index + 1] || null
let updatedParent = deleteIn(parent, [prop]) // delete property itself
updatedParent = deleteIn(updatedParent, [META, 'props', index]) // delete property from the props list
return {
data: setIn(data, parentPath, updatePaths(updatedParent, parentPath)),
revert: [{
op: 'add',
path: compileJSONPointer(path),
value,
meta: {
type: dataValue[META].type,
before: nextProp
}
}]
}
}
}
/**
* @param {ESON} data
* @param {Path} path
* @param {ESON} value
* @param {{before?: string}} [options]
* @return {{data: ESON, revert: ESONPatch}}
* @private
*/
export function add (data, path, value, options) {
const parentPath = initial(path)
const parent = getIn(data, parentPath)
const resolvedPath = resolvePathIndex(data, path)
const prop = last(resolvedPath)
let updatedEson
if (parent[META].type === 'Array') {
updatedEson = updatePaths(insertAt(data, resolvedPath, value))
}
else { // parent[META].type === 'Object'
updatedEson = updateIn(data, parentPath, (parent) => {
const oldValue = getIn(data, path)
const props = parent[META].props
const existingIndex = props.indexOf(prop)
if (existingIndex !== -1) {
// replace existing item, keep existing id
const newValue = setIn(value, [META, 'id'], oldValue[META].id)
return setIn(parent, [prop], updatePaths(newValue, path))
}
else {
// insert new item
const index = (options && typeof options.before === 'string')
? props.indexOf(options.before) // insert before
: props.length // append
let updatedKeys = props.slice()
updatedKeys.splice(index, 0, prop)
const updatedParent = setIn(parent, [prop], updatePaths(value, parentPath.concat(prop)))
return setIn(updatedParent, [META, 'props'], updatedKeys)
}
})
}
if (parent[META].type === 'Object' && pathExists(data, resolvedPath)) {
const oldValue = getIn(data, resolvedPath)
return {
data: updatedEson,
revert: [{
op: 'replace',
path: compileJSONPointer(resolvedPath),
value: esonToJson(oldValue),
meta: { type: oldValue[META].type }
}]
}
}
else {
return {
data: updatedEson,
revert: [{
op: 'remove',
path: compileJSONPointer(resolvedPath)
}]
}
}
}
/**
* Copy a value
* @param {ESON} data
* @param {Path} path
* @param {Path} from
* @param {{before?: string}} [options]
* @return {{data: ESON, revert: ESONPatch}}
* @private
*/
export function copy (data, path, from, options) {
const value = getIn(data, from)
// create new id for the copied item
let updatedValue = cloneWithSymbols(value)
updatedValue[META] = setIn(updatedValue[META], ['id'], createId())
return add(data, path, updatedValue, options)
}
/**
* Move a value
* @param {ESON} data
* @param {Path} path
* @param {Path} from
* @param {{before?: string}} [options]
* @return {{data: ESON, revert: ESONPatch}}
* @private
*/
export function move (data, path, from, options) {
const dataValue = getIn(data, from)
const parentPathFrom = initial(from)
const parent = getIn(data, parentPathFrom)
const result1 = remove(data, from)
const result2 = add(result1.data, path, dataValue, options)
const before = result1.revert[0].meta.before
const beforeNeeded = (parent[META].type === 'Object' && before)
if (result2.revert[0].op === 'replace') {
const value = result2.revert[0].value
const type = result2.revert[0].meta.type
const options = beforeNeeded ? { type, before } : { type }
return {
data: result2.data,
revert: [
{ op: 'move', from: compileJSONPointer(path), path: compileJSONPointer(from) },
{ op: 'add', path: compileJSONPointer(path), value, meta: options}
]
}
}
else { // result2.revert[0].op === 'remove'
return {
data: result2.data,
revert: beforeNeeded
? [{ op: 'move', from: compileJSONPointer(path), path: compileJSONPointer(from), meta: { before } }]
: [{ op: 'move', from: compileJSONPointer(path), path: compileJSONPointer(from) }]
}
}
}
/**
* Test whether the data contains the provided value at the specified path.
* Throws an error when the test fails.
* @param {ESON} data
* @param {Path} path
* @param {*} value
* @return {null | Error} Returns an error when the tests, returns null otherwise
*/
export function test (data, path, value) {
if (value === undefined) {
return new Error('Test failed, no value provided')
}
if (!pathExists(data, path)) {
return new Error('Test failed, path not found')
}
const actualValue = getIn(data, path)
if (!isEqual(esonToJson(actualValue), value)) {
return new Error('Test failed, value differs')
}
}

View File

@ -1,572 +0,0 @@
'use strict'
import { readFileSync } from 'fs'
import { META, jsonToEson, esonToJson, expandOne } from './eson'
import { patchEson } from './patchEson'
import { assertDeepEqualEson } from './utils/assertDeepEqualEson'
test('jsonpatch add', () => {
const json = {
arr: [1,2,3],
obj: {a : 2}
}
const patch = [
{op: 'add', path: '/obj/b', value: {foo: 'bar'}}
]
const data = jsonToEson(json)
const result = patchEson(data, patch)
const patchedData = result.data
const revert = result.revert
assertDeepEqualEson(patchedData, jsonToEson({
arr: [1,2,3],
obj: {a : 2, b: {foo: 'bar'}}
}))
expect(revert).toEqual([
{op: 'remove', path: '/obj/b'}
])
})
test('jsonpatch add: insert in matrix', () => {
const json = {
arr: [1,2,3],
obj: {a : 2}
}
const patch = [
{op: 'add', path: '/arr/1', value: 4}
]
const data = jsonToEson(json)
const result = patchEson(data, patch)
const patchedData = result.data
const revert = result.revert
assertDeepEqualEson(patchedData, jsonToEson({
arr: [1,4,2,3],
obj: {a : 2}
}))
expect(revert).toEqual([
{op: 'remove', path: '/arr/1'}
])
})
test('jsonpatch add: append to matrix', () => {
const json = {
arr: [1,2,3],
obj: {a : 2}
}
const patch = [
{op: 'add', path: '/arr/-', value: 4}
]
const data = jsonToEson(json)
const result = patchEson(data, patch)
const patchedData = result.data
const revert = result.revert
assertDeepEqualEson(patchedData, jsonToEson({
arr: [1,2,3,4],
obj: {a : 2}
}))
expect(revert).toEqual([
{op: 'remove', path: '/arr/3'}
])
})
test('jsonpatch add: apply eson state', () => {
const json = {
a: 2
}
const patch = [
{
op: 'add',
path: '/b',
value: {c: {d: 3}},
meta: {
state: {
'': { expanded: true },
'/c/d': { expanded: true }
}
}
}
]
const data = jsonToEson(json)
const result = patchEson(data, patch)
const patchedData = result.data
let expected = jsonToEson({
a: 2,
b: {c: {d: 3}}
})
expected = expandOne(expected, ['b'], true)
expected = expandOne(expected, ['b', 'c', 'd'], true)
assertDeepEqualEson(patchedData, expected)
})
test('jsonpatch remove', () => {
const json = {
arr: [1,2,3],
obj: {a : 4}
}
const patch = [
{op: 'remove', path: '/obj/a'},
{op: 'remove', path: '/arr/1'},
]
const data = jsonToEson(json)
const result = patchEson(data, patch)
const patchedData = result.data
const revert = result.revert
const patchedJson = esonToJson(patchedData)
assertDeepEqualEson(patchedData, jsonToEson({
arr: [1,3],
obj: {}
}))
expect(revert).toEqual([
{op: 'add', path: '/arr/1', value: 2, meta: {type: 'value'}},
{op: 'add', path: '/obj/a', value: 4, meta: {type: 'value', before: null}}
])
// test revert
const data2 = jsonToEson(patchedJson)
const result2 = patchEson(data2, revert)
const patchedData2 = result2.data
const revert2 = result2.revert
const patchedJson2 = esonToJson(patchedData2)
expect(patchedJson2).toEqual(json)
expect(revert2).toEqual(patch)
})
test('jsonpatch replace', () => {
const json = {
arr: [1,2,3],
obj: {a : 4}
}
const patch = [
{op: 'replace', path: '/obj/a', value: 400},
{op: 'replace', path: '/arr/1', value: 200},
]
const data = jsonToEson(json)
const result = patchEson(data, patch)
const patchedData = result.data
const revert = result.revert
const patchedJson = esonToJson(patchedData)
assertDeepEqualEson(patchedData, jsonToEson({
arr: [1,200,3],
obj: {a: 400}
}))
expect(revert).toEqual([
{op: 'replace', path: '/arr/1', value: 2, meta: {type: 'value'}},
{op: 'replace', path: '/obj/a', value: 4, meta: {type: 'value'}}
])
// test revert
const data2 = jsonToEson(patchedJson)
const result2 = patchEson(data2, revert)
const patchedData2 = result2.data
const revert2 = result2.revert
const patchedJson2 = esonToJson(patchedData2)
expect(patchedJson2).toEqual(json)
expect(revert2).toEqual([
{op: 'replace', path: '/obj/a', value: 400, meta: {type: 'value'}},
{op: 'replace', path: '/arr/1', value: 200, meta: {type: 'value'}}
])
})
test('jsonpatch replace (keep ids intact)', () => {
const json = { value: 42 }
const patch = [
{op: 'replace', path: '/value', value: 100}
]
const data = jsonToEson(json)
const valueId = data.value[META].id
const patchedData = patchEson(data, patch).data
const patchedValueId = patchedData.value[META].id
expect(patchedValueId).toEqual(valueId)
})
test('jsonpatch replace: apply eson state', () => {
const json = {
a: 2,
b: 4
}
const patch = [
{
op: 'replace',
path: '/b',
value: {c: {d: 3}},
meta: {
state: {
'': { expanded: true },
'/c/d': { expanded: true }
}
}
}
]
const data = jsonToEson(json)
const result = patchEson(data, patch)
const patchedData = result.data
let expected = jsonToEson({
a: 2,
b: {c: {d: 3}}
})
expected = expandOne(expected, ['b'], true)
expected = expandOne(expected, ['b', 'c', 'd'], true)
assertDeepEqualEson(patchedData, expected)
})
test('jsonpatch copy', () => {
const json = {
arr: [1,2,3],
obj: {a : 4}
}
const patch = [
{op: 'copy', from: '/obj', path: '/arr/2'},
]
const data = jsonToEson(json)
const result = patchEson(data, patch)
const patchedData = result.data
const revert = result.revert
const patchedJson = esonToJson(patchedData)
expect(patchedJson).toEqual({
arr: [1, 2, {a:4}, 3],
obj: {a: 4}
})
expect(revert).toEqual([
{op: 'remove', path: '/arr/2'}
])
// test revert
const data2 = jsonToEson(patchedJson)
const result2 = patchEson(data2, revert)
const patchedData2 = result2.data
const revert2 = result2.revert
const patchedJson2 = esonToJson(patchedData2)
expect(patchedJson2).toEqual(json)
expect(revert2).toEqual([
{op: 'add', path: '/arr/2', value: {a: 4}, meta: {type: 'Object'}}
])
})
test('jsonpatch copy (keeps the same ids)', () => {
const json = { foo: { bar: 42 } }
const patch = [
{op: 'copy', from: '/foo', path: '/copied'}
]
const data = jsonToEson(json)
const fooId = data.foo[META].id
const barId = data.foo.bar[META].id
const patchedData = patchEson(data, patch).data
const patchedFooId = patchedData.foo[META].id
const patchedBarId = patchedData.foo.bar[META].id
const copiedId = patchedData.copied[META].id
const patchedCopiedBarId = patchedData.copied.bar[META].id
expect(patchedFooId).toEqual(fooId)
expect(patchedBarId).toEqual(barId)
expect(copiedId).not.toEqual(fooId)
// The id's of the copied childs are the same, that's okish, they will not bite each other
expect(patchedCopiedBarId).toEqual(patchedBarId)
})
test('jsonpatch move', () => {
const json = {
arr: [1,2,3],
obj: {a : 4}
}
const patch = [
{op: 'move', from: '/obj', path: '/arr/2'},
]
const data = jsonToEson(json)
const result = patchEson(data, patch)
const patchedData = result.data
const revert = result.revert
const patchedJson = esonToJson(patchedData)
expect(result.error).toEqual(null)
expect(patchedJson).toEqual({
arr: [1, 2, {a:4}, 3]
})
expect(revert).toEqual([
{op: 'move', from: '/arr/2', path: '/obj'}
])
// test revert
const data2 = jsonToEson(patchedJson)
const result2 = patchEson(data2, revert)
const patchedData2 = result2.data
const revert2 = result2.revert
const patchedJson2 = esonToJson(patchedData2)
expect(patchedJson2).toEqual(json)
expect(revert2).toEqual(patch)
})
test('jsonpatch move before', () => {
const json = {
arr: [1,2,3],
obj: {a : 4},
zzz: 'zzz'
}
const patch = [
{op: 'move', from: '/obj', path: '/arr/2'},
]
const data = jsonToEson(json)
const result = patchEson(data, patch)
const patchedData = result.data
const revert = result.revert
const patchedJson = esonToJson(patchedData)
expect(result.error).toEqual(null)
expect(patchedJson).toEqual({
arr: [1, 2, {a:4}, 3],
zzz: 'zzz'
})
expect(revert).toEqual([
{op: 'move', from: '/arr/2', path: '/obj', meta: {before: 'zzz'}}
])
// test revert
const data2 = jsonToEson(patchedJson)
const result2 = patchEson(data2, revert)
const patchedData2 = result2.data
const revert2 = result2.revert
const patchedJson2 = esonToJson(patchedData2)
expect(patchedJson2).toEqual(json)
expect(revert2).toEqual(patch)
})
test('jsonpatch move and replace', () => {
const json = { a: 2, b: 3 }
const patch = [
{op: 'move', from: '/a', path: '/b'},
]
const data = jsonToEson(json)
const result = patchEson(data, patch)
const patchedData = result.data
const revert = result.revert
const patchedJson = esonToJson(patchedData)
// id of the replaced B must be kept intact
expect(patchedData.b[META].id).toEqual(data.b[META].id)
assertDeepEqualEson(patchedData, jsonToEson({b: 2}))
expect(patchedJson).toEqual({ b : 2 })
expect(revert).toEqual([
{op:'move', from: '/b', path: '/a'},
{op:'add', path:'/b', value: 3, meta: {type: 'value', before: 'b'}}
])
// test revert
const data2 = jsonToEson(patchedJson)
const result2 = patchEson(data2, revert)
const patchedData2 = result2.data
const revert2 = result2.revert
const patchedJson2 = esonToJson(patchedData2)
expect(patchedJson2).toEqual(json)
expect(revert2).toEqual([
{op: 'remove', path: '/b'},
{op: 'move', from: '/a', path: '/b'}
])
})
test('jsonpatch move and replace (nested)', () => {
const json = {
arr: [1,2,3],
obj: {a : 4}
}
const patch = [
{op: 'move', from: '/obj', path: '/arr'},
]
const data = jsonToEson(json)
const result = patchEson(data, patch)
const patchedData = result.data
const revert = result.revert
const patchedJson = esonToJson(patchedData)
expect(patchedJson).toEqual({
arr: {a:4}
})
expect(revert).toEqual([
{op:'move', from: '/arr', path: '/obj'},
{op:'add', path:'/arr', value: [1,2,3], meta: {type: 'Array'}}
])
// test revert
const data2 = jsonToEson(patchedJson)
const result2 = patchEson(data2, revert)
const patchedData2 = result2.data
const revert2 = result2.revert
const patchedJson2 = esonToJson(patchedData2)
expect(patchedJson2).toEqual(json)
expect(revert2).toEqual([
{op: 'remove', path: '/arr'},
{op: 'move', from: '/obj', path: '/arr'}
])
})
test('jsonpatch move (keep id intact)', () => {
const json = { value: 42 }
const patch = [
{op: 'move', from: '/value', path: '/moved'}
]
const data = jsonToEson(json)
const valueId = data.value[META].id
const patchedData = patchEson(data, patch).data
const patchedValueId = patchedData.moved[META].id
expect(patchedValueId).toEqual(valueId)
})
test('jsonpatch move and replace (keep ids intact)', () => {
const json = { a: 2, b: 3 }
const patch = [
{op: 'move', from: '/a', path: '/b'}
]
const data = jsonToEson(json)
const bId = data.b[META].id
expect(data[META].props).toEqual(['a', 'b'])
const patchedData = patchEson(data, patch).data
expect(patchedData.b[META].id).toEqual(bId)
expect(patchedData[META].props).toEqual(['b'])
})
test('jsonpatch test (ok)', () => {
const json = {
arr: [1,2,3],
obj: {a : 4}
}
const patch = [
{op: 'test', path: '/arr', value: [1,2,3]},
{op: 'add', path: '/added', value: 'ok'}
]
const data = jsonToEson(json)
const result = patchEson(data, patch)
const patchedData = result.data
const revert = result.revert
const patchedJson = esonToJson(patchedData)
expect(patchedJson).toEqual({
arr: [1,2,3],
obj: {a : 4},
added: 'ok'
})
expect(revert).toEqual([
{op: 'remove', path: '/added'}
])
})
test('jsonpatch test (fail: path not found)', () => {
const json = {
arr: [1,2,3],
obj: {a : 4}
}
const patch = [
{op: 'test', path: '/arr/5', value: [1,2,3]},
{op: 'add', path: '/added', value: 'ok'}
]
const data = jsonToEson(json)
const result = patchEson(data, patch)
const patchedData = result.data
const revert = result.revert
const patchedJson = esonToJson(patchedData)
// patch shouldn't be applied
expect(patchedJson).toEqual({
arr: [1,2,3],
obj: {a : 4}
})
expect(revert).toEqual([])
expect(result.error.toString()).toEqual('Error: Test failed, path not found')
})
test('jsonpatch test (fail: value not equal)', () => {
const json = {
arr: [1,2,3],
obj: {a : 4}
}
const patch = [
{op: 'test', path: '/obj', value: {a:4, b: 6}},
{op: 'add', path: '/added', value: 'ok'}
]
const data = jsonToEson(json)
const result = patchEson(data, patch)
const patchedData = result.data
const revert = result.revert
const patchedJson = esonToJson(patchedData)
// patch shouldn't be applied
expect(patchedJson).toEqual({
arr: [1,2,3],
obj: {a : 4}
})
expect(revert).toEqual([])
expect(result.error.toString()).toEqual('Error: Test failed, value differs')
})
// helper function to print JSON in the console
function printJSON (json, message = null) {
if (message) {
console.log(message)
}
console.log(JSON.stringify(json, null, 2))
}
// helper function to load a JSON file
function loadJSON (filename) {
return JSON.parse(readFileSync(__dirname + '/' + filename, 'utf-8'))
}

View File

@ -57,7 +57,20 @@
*/ */
/** /**
* @typedef {'Object' | 'Array' | 'value' | 'string'} ESONType * @typedef {'object' | 'array' | 'value' | 'string'} ESONType
*/
/**
* @typedef {{
* op: 'add' | 'remove' | 'replace' | 'copy' | 'move' | 'test',
* path: string,
* from?: string,
* value?: *
* }} JSONPatchAction
*/
/**
* @typedef {JSONPatchAction[]} JSONPatch
*/ */
/** /**

View File

@ -1,39 +0,0 @@
import {META} from "../eson"
import lodashTransform from "lodash/transform"
export function assertDeepEqualEson (actual, expected, path = [], ignoreIds = true) {
if (expected === undefined) {
throw new Error('Argument "expected" is undefined')
}
// console.log('assertDeepEqualEson', actual, expected)
const actualMeta = ignoreIds ? normalizeMetaIds(actual[META]) : actual[META]
const expectedMeta = ignoreIds ? normalizeMetaIds(expected[META]) : expected[META]
expect(actualMeta).toEqual(expectedMeta) // `Meta data not equal, path=[${path.join(', ')}], actual[META]=${JSON.stringify(actualMeta)}, expected[META]=${JSON.stringify(expectedMeta)}`
if (actualMeta.type === 'Array') {
expect(actual.length).toEqual(expected.length) // 'Actual lengths of arrays should be equal, path=[${path.join(\', \')}]'
actual.forEach((item, index) => assertDeepEqualEson(actual[index], expected[index], path.concat(index)), ignoreIds)
}
else if (actualMeta.type === 'Object') {
expect(Object.keys(actual).sort()).toEqual(Object.keys(expected).sort()) // 'Actual properties should be equal, path=[${path.join(\', \')}]'
actualMeta.props.forEach(key => assertDeepEqualEson(actual[key], expected[key], path.concat(key)), ignoreIds)
}
else { // actual[META].type === 'value'
expect(Object.keys(actual)).toEqual([]) // 'Value should not contain additional properties, path=[${path.join(\', \')}]'
}
}
function normalizeMetaIds (meta) {
return lodashTransform(meta, (result, value, key) => {
if (key === 'id') {
result[key] = '[ID]'
}
else {
result[key] = value
}
}, {})
}

View File

@ -0,0 +1,40 @@
import { ID, TYPE, VALUE } from '../eson'
import uniq from 'lodash/uniq'
import each from 'lodash/each'
export function createAssertEqualEson(expect) {
function assertEqualEson (actual, expected, ignoreIds = true) {
if (expected === undefined) {
throw new Error('Argument "expected" is undefined')
}
// regular deep equal
expect(actual).toEqual(expected)
assertEqualEsonKeys(actual, expected, ignoreIds)
}
function assertEqualEsonKeys (actual, expected, ignoreIds = true) {
// collect all symbols
const symbols = uniq(Object.getOwnPropertySymbols(actual)
.concat(Object.getOwnPropertySymbols(expected)))
// test whether all meta data is the same
symbols
.filter(symbol => symbol !== ID || ignoreIds)
.forEach(symbol => expect(actual[symbol]).toEqual(expected[symbol]))
if (actual[TYPE] === 'array') {
each(expected, (item, index) => assertEqualEsonKeys(actual[index], expected[index], ignoreIds))
}
else if (actual[TYPE] === 'object') {
each(actual, (value, key) => assertEqualEsonKeys(actual[key], expected[key]), ignoreIds)
}
else { // actual[TYPE] === 'value'
expect(actual[VALUE]).toEqual(expected[VALUE])
}
}
return assertEqualEson
}

View File

@ -17,7 +17,7 @@ import { isObjectOrArray } from './typeUtils'
* @param {*} value * @param {*} value
* @return {*} * @return {*}
*/ */
export function cloneWithSymbols (value) { export function shallowCloneWithSymbols (value) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
// copy array items // copy array items
let arr = value.slice() let arr = value.slice()
@ -97,7 +97,7 @@ export function setIn (object, path, value) {
return object return object
} }
else { else {
const updatedObject = cloneWithSymbols(object) const updatedObject = shallowCloneWithSymbols(object)
updatedObject[key] = updatedValue updatedObject[key] = updatedValue
return updatedObject return updatedObject
} }
@ -129,7 +129,7 @@ export function updateIn (object, path, callback) {
return object return object
} }
else { else {
const updatedObject = cloneWithSymbols(object) const updatedObject = shallowCloneWithSymbols(object)
updatedObject[key] = updatedValue updatedObject[key] = updatedValue
return updatedObject return updatedObject
} }
@ -159,7 +159,7 @@ export function deleteIn (object, path) {
return object return object
} }
else { else {
const updatedObject = cloneWithSymbols(object) const updatedObject = shallowCloneWithSymbols(object)
if (Array.isArray(updatedObject)) { if (Array.isArray(updatedObject)) {
updatedObject.splice(key, 1) updatedObject.splice(key, 1)
@ -179,7 +179,7 @@ export function deleteIn (object, path) {
return object return object
} }
else { else {
const updatedObject = cloneWithSymbols(object) const updatedObject = shallowCloneWithSymbols(object)
updatedObject[key] = updatedValue updatedObject[key] = updatedValue
return updatedObject return updatedObject
} }
@ -205,9 +205,89 @@ export function insertAt (object, path, value) {
throw new TypeError('Array expected at path ' + JSON.stringify(parentPath)) throw new TypeError('Array expected at path ' + JSON.stringify(parentPath))
} }
const updatedItems = cloneWithSymbols(items) const updatedItems = shallowCloneWithSymbols(items)
updatedItems.splice(index, 0, value) updatedItems.splice(index, 0, value)
return updatedItems return updatedItems
}) })
} }
/**
* Transform a JSON object, traverse over the whole object,
* and allow replacing Objects/Arrays/values.
* Does not iterate over symbols.
* @param {JSON} json
* @param {function (json: JSON, path: Path) : JSON} callback
* @param {Path} [path]
* @return {JSON}
*/
export function transform (json, callback, path = []) {
const updated1 = callback(json, path)
if (Array.isArray(json)) { // array
let updated2 = undefined
for (let i = 0; i < updated1.length; i++) {
const before = updated1[i]
// we stringify the index here, so the Path only contains strings and can be safely
// stringified/parsed to JSONPointer without loosing information.
// We do not want to rely on path keys being numeric/string.
const after = transform(before, callback, path.concat(i + ''))
if (after !== before) {
if (!updated2) {
updated2 = shallowCloneWithSymbols(updated1)
}
updated2[i] = after
}
}
return updated2 ? updated2 : updated1
}
else if (json && typeof json === 'object') { // object
let updated2 = undefined
for (let key in updated1) {
if (updated1.hasOwnProperty(key)) {
const before = updated1[key]
const after = transform(before, callback, path.concat(key))
if (after !== before) {
if (!updated2) {
updated2 = shallowCloneWithSymbols(updated1)
}
updated2[key] = after
}
}
}
return updated2 ? updated2 : updated1
}
else { // number, string, boolean, null
return updated1
}
}
/**
* Test whether a path exists in a JSON object
* @param {ESON} json
* @param {Path} path
* @return {boolean} Returns true if the path exists, else returns false
* @private
*/
export function existsIn (json, path) {
if (json === undefined) {
return false
}
if (path.length === 0) {
return true
}
if (Array.isArray(json)) {
// index of an array
return existsIn(json[parseInt(path[0], 10)], path.slice(1))
}
else { // Object
// object property. find the index of this property
return existsIn(json[path[0]], path.slice(1))
}
}

View File

@ -1,4 +1,4 @@
import { getIn, setIn, updateIn, deleteIn, insertAt } from './immutabilityHelpers' import { deleteIn, existsIn, getIn, insertAt, setIn, transform, updateIn } from './immutabilityHelpers'
test('getIn', () => { test('getIn', () => {
const obj = { const obj = {
@ -274,3 +274,47 @@ test('insertAt', () => {
const updated = insertAt(obj, ['a', '2'], 8) const updated = insertAt(obj, ['a', '2'], 8)
expect(updated).toEqual({a: [1,2,8,3]}) expect(updated).toEqual({a: [1,2,8,3]})
}) })
test('transform (no change)', () => {
const eson = {a: [1,2,3], b: {c: 4}}
const updated = transform(eson, (value, path) => value)
expect(updated).toBe(eson)
})
test('transform (change based on value)', () => {
const eson = {a: [1,2,3], b: {c: 4}}
const updated = transform(eson,
(value, path) => value === 2 ? 20 : value)
const expected = {a: [1,20,3], b: {c: 4}}
expect(updated).toEqual(expected)
expect(updated.b).toBe(eson.b) // should not have replaced b
})
test('transform (change based on path)', () => {
const eson = {a: [1,2,3], b: {c: 4}}
const updated = transform(eson,
(value, path) => path.join('.') === 'a.1' ? 20 : value)
const expected = {a: [1,20,3], b: {c: 4}}
expect(updated).toEqual(expected)
expect(updated.b).toBe(eson.b) // should not have replaced b
})
test('existsIn', () => {
const json = {
"obj": {
"arr": [1,2, {"first":3,"last":4}]
},
"str": "hello world",
"nill": null,
"bool": false
}
expect(existsIn(json, ['obj', 'arr', 2, 'first'])).toEqual(true)
expect(existsIn(json, ['obj', 'foo'])).toEqual(false)
expect(existsIn(json, ['obj', 'foo', 'bar'])).toEqual(false)
expect(existsIn(json, [])).toEqual(true)
})

View File

@ -0,0 +1,25 @@
const map = new WeakMap()
let counter = 0;
/**
* Generate a unique key for an object or array (by object reference)
* @param {Object | Array} item
* @returns {string | null}
* Returns the generated key.
* If the item is no Object or Array, null is returned
*/
export function weakKey(item) {
if (!item || (!Array.isArray(item) && typeof item !== 'object')) {
return null
}
let k = map.get(item)
if (!k) {
k = 'key-' + counter
counter++
map.set(item, k)
}
return k
}

View File

@ -0,0 +1,26 @@
import { weakKey } from './reactUtils'
test('weakKey should keep the key the same for objects and arrays', () => {
const a = {x: 1}
const b = {x: 1}
const c = b
const d = [1, 2, 3]
const e = [1, 2, 3]
expect(weakKey(a)).toEqual(weakKey(a))
expect(weakKey(b)).toEqual(weakKey(b))
expect(weakKey(a)).not.toEqual(weakKey(b))
expect(weakKey(b)).toEqual(weakKey(c))
expect(weakKey(d)).toEqual(weakKey(d))
expect(weakKey(e)).toEqual(weakKey(e))
expect(weakKey(d)).not.toEqual(weakKey(e))
expect(weakKey(d)).not.toEqual(weakKey(e))
})
test('weakKey should return null for non-object and non-array items', () => {
expect(weakKey('foo')).toBeNull()
expect(weakKey(123)).toBeNull()
expect(weakKey(null)).toBeNull()
expect(weakKey(undefined)).toBeNull()
expect(weakKey(true)).toBeNull()
})

View File

@ -98,13 +98,13 @@ export function escapeJSON (text) {
* Find a unique name. Suffix the name with ' (copy)', '(copy 2)', etc * Find a unique name. Suffix the name with ' (copy)', '(copy 2)', etc
* until a unique name is found * until a unique name is found
* @param {string} name * @param {string} name
* @param {Array.<string>} invalidNames * @param {Object} existingProps Object with existing props
*/ */
export function findUniqueName (name, invalidNames) { export function findUniqueName (name, existingProps) {
let validName = name let validName = name
let i = 1 let i = 1
while (invalidNames.includes(validName)) { while (validName in existingProps) {
const copy = 'copy' + (i > 1 ? (' ' + i) : '') const copy = 'copy' + (i > 1 ? (' ' + i) : '')
validName = `${name} (${copy})` validName = `${name} (${copy})`
i++ i++

View File

@ -18,8 +18,8 @@ test('unescapeHTML', () => {
}) })
test('findUniqueName', () => { test('findUniqueName', () => {
expect(findUniqueName('other', ['a', 'b', 'c'])).toEqual('other') expect(findUniqueName('other', {'a': true, 'b': true, 'c': true})).toEqual('other')
expect(findUniqueName('b', ['a', 'b', 'c'])).toEqual('b (copy)') expect(findUniqueName('b', {'a': true, 'b': true, 'c': true})).toEqual('b (copy)')
expect(findUniqueName('b', ['a', 'b', 'c', 'b (copy)'])).toEqual('b (copy 2)') expect(findUniqueName('b', {'a': true, 'b': true, 'c': true, 'b (copy)': true})).toEqual('b (copy 2)')
expect(findUniqueName('b', ['a', 'b', 'c', 'b (copy)', 'b (copy 2)'])).toEqual('b (copy 3)') expect(findUniqueName('b', {'a': true, 'b': true, 'c': true, 'b (copy)': true, 'b (copy 2)': true})).toEqual('b (copy 3)')
}) })

View File

@ -62,10 +62,10 @@ export function valueType(value) {
return 'regexp' return 'regexp'
} }
if (Array.isArray(value)) { if (Array.isArray(value)) {
return 'Array' return 'array'
} }
return 'Object' return 'object'
} }
/** /**