diff --git a/src/JSONNode.js b/src/JSONNode.js index b121bf5..06e3571 100644 --- a/src/JSONNode.js +++ b/src/JSONNode.js @@ -217,7 +217,13 @@ export default class JSONNode extends Component { renderExpandButton () { const className = `jsoneditor-button jsoneditor-${this.props.data.expanded ? 'expanded' : 'collapsed'}` return h('div', {class: 'jsoneditor-button-container'}, - h('button', {class: className, onClick: this.handleExpand}) + h('button', { + class: className, + onClick: this.handleExpand, + title: + 'Click to expand/collapse this field. \n' + + 'Ctrl+Click to expand/collapse including all childs.' + }) ) } @@ -480,7 +486,10 @@ export default class JSONNode extends Component { } handleExpand (event) { - this.props.events.onExpand(this.getPath(), !this.props.data.expanded) + const recurse = event.ctrlKey + const expanded = !this.props.data.expanded + + this.props.events.onExpand(this.getPath(), expanded, recurse) } handleContextMenu (event) { diff --git a/src/TreeMode.js b/src/TreeMode.js index 2e4ca24..70b0ea4 100644 --- a/src/TreeMode.js +++ b/src/TreeMode.js @@ -1,12 +1,12 @@ import { h, Component } from 'preact' -import { setIn } from './utils/immutabilityHelpers' +import { setIn, updateIn } from './utils/immutabilityHelpers' import { changeValue, changeProperty, changeType, insert, append, duplicate, remove, sort, expand, - jsonToData, dataToJson + jsonToData, dataToJson, toDataPath } from './jsonData' import JSONNode from './JSONNode' @@ -106,10 +106,21 @@ export default class TreeMode extends Component { }) } - handleExpand = (path, doExpand) => { - this.setState({ - data: expand(this.state.data, path, doExpand) - }) + handleExpand = (path, expanded, recurse) => { + if (recurse) { + const dataPath = toDataPath(this.state.data, path) + + this.setState({ + data: updateIn (this.state.data, dataPath, function (child) { + return expand(child, (path) => true, expanded) + }) + }) + } + else { + this.setState({ + data: expand(this.state.data, path, expanded) + }) + } } /** @@ -133,6 +144,26 @@ export default class TreeMode extends Component { return dataToJson(this.state.data) } + /** + * Expand one or multiple objects or arrays + * @param {Path | function (path: Path) : boolean} callback + */ + expand (callback) { + this.setState({ + data: expand(this.state.data, callback, true) + }) + } + + /** + * Collapse one or multiple objects or arrays + * @param {Path | function (path: Path) : boolean} callback + */ + collapse (callback) { + this.setState({ + data: expand(this.state.data, callback, false) + }) + } + // TODO: implement expand // TODO: implement getText and setText diff --git a/src/index.js b/src/index.js index 59e2f3f..7a2ecbc 100644 --- a/src/index.js +++ b/src/index.js @@ -36,11 +36,50 @@ function jsoneditor (container, options) { */ get: function () { return component.get() - } + }, // TODO: implement getText // TODO: implement setText - // TODO: implement expand + + + /** + * Expand one or multiple objects or arrays. + * + * Example usage: + * + * // expand one item at a specific path + * editor.expand(['foo', 1, 'bar']) + * + * // expand all items nested at a maximum depth of 2 + * editor.expand(function (path) { + * return path.length <= 2 + * }) + * + * @param {Path | function (path: Path) : boolean} callback + */ + expand (callback) { + component.expand(callback) + }, + + /** + * Collapse one or multiple objects or arrays + * + * Example usage: + * + * // collapse one item at a specific path + * editor.collapse(['foo', 1, 'bar']) + * + * // collapse all items nested deeper than 2 + * editor.collapse(function (path) { + * return path.length > 2 + * }) + * + * @param {Path | function (path: Path) : boolean} callback + */ + collapse (callback) { + component.collapse(callback) + }, + } } diff --git a/src/jsonData.js b/src/jsonData.js index 1c5c64c..4d84662 100644 --- a/src/jsonData.js +++ b/src/jsonData.js @@ -246,20 +246,79 @@ export function sort (data, path, order = null) { } /** - * Expand or collapse an item or property + * Expand or collapse one or multiple items or properties * @param {JSONData} data - * @param {Path} path - * @param {boolean} expand + * @param {function(path: Path) : boolean | Path} callback + * When a path, the object/array at this path will be expanded/collapsed + * When a function, all objects and arrays for which callback + * returns true will be expanded/collapsed + * @param {boolean} expanded New expanded state: true to expand, false to collapse * @return {JSONData} */ -export function expand (data, path, expand) { - console.log('expand', path, expand) +export function expand (data, callback, expanded) { + // console.log('expand', callback, expand) - const dataPath = toDataPath(data, path) + if (typeof callback === 'function') { + return expandRecursive(data, [], callback, expanded) + } + else if (Array.isArray(callback)) { + const dataPath = toDataPath(data, callback) - return setIn(data, dataPath.concat(['expanded']), expand) + return setIn(data, dataPath.concat(['expanded']), expanded) + } + else { + throw new Error('Callback function or path expected') + } } +/** + * Traverse the json data, change the expanded state of all items/properties for + * which `callback` returns true + * @param {JSONData} data + * @param {Path} path + * @param {function(path: Path)} callback + * All objects and arrays for which callback returns true will be + * expanded/collapsed + * @param {boolean} expanded New expanded state: true to expand, false to collapse + * @return {*} + */ +export function expandRecursive (data, path, callback, expanded) { + switch (data.type) { + case 'array': { + let updatedData = callback(path) + ? setIn(data, ['expanded'], expanded) + : data + let updatedItems = updatedData.items + + updatedData.items.forEach((item, index) => { + updatedItems = setIn(updatedItems, [index], + expandRecursive(item, path.concat(index), callback, expanded)) + }) + + return setIn(updatedData, ['items'], updatedItems) + } + + case 'object': { + let updatedData = callback(path) + ? setIn(data, ['expanded'], expanded) + : data + let updatedProps = updatedData.props + + updatedData.props.forEach((prop, index) => { + updatedProps = setIn(updatedProps, [index, 'value'], + expandRecursive(prop.value, path.concat(prop.name), callback, expanded)) + }) + + return setIn(updatedData, ['props'], updatedProps) + } + + default: // type 'string' or 'value' + // don't do anything: a value can't be expanded, only arrays and objects can + return data + } +} + + /** * Convert a path of a JSON object into a path in the corresponding data model * @param {JSONData} data diff --git a/src/typedef.js b/src/typedef.js index fe6cba8..116bbe1 100644 --- a/src/typedef.js +++ b/src/typedef.js @@ -1,23 +1,19 @@ - +// TODO: rename type 'array' to 'Array' and 'object' to 'Object' /** * @typedef {{ * type: 'array', * expanded: boolean?, - * menu: boolean?, * props: Array.<{name: string, value: JSONData}>? * }} ObjectData * * @typedef {{ * type: 'object', * expanded: boolean?, - * menu: boolean?, * items: JSONData[]? * }} ArrayData * * @typedef {{ * type: 'value' | 'string', - * expanded: boolean?, - * menu: boolean?, * value: *? * }} ValueData * @@ -35,4 +31,78 @@ * name: string?, * expand: function (path: Path)? * }} SetOptions - */ \ No newline at end of file + */ + +var ans = { + "type": "object", + "expanded": true, + "props": [ + { + "name": "obj", + "value": { + "type": "object", + "expanded": true, + "props": [ + { + "name": "arr", + "value": { + "type": "array", + "expanded": true, + "items": [ + { + "type": "value", + "value": 1 + }, + { + "type": "value", + "value": 2 + }, + { + "type": "object", + "expanded": true, + "props": [ + { + "name": "a", + "value": { + "type": "value", + "value": 3 + } + }, + { + "name": "b", + "value": { + "type": "value", + "value": 4 + } + } + ] + } + ] + } + } + ] + } + }, + { + "name": "str", + "value": { + "type": "value", + "value": "hello world" + } + }, + { + "name": "nill", + "value": { + "type": "value", + "value": null + } + }, + { + "name": "bool", + "value": { + "type": "value", + "value": false + } + } + ] +} diff --git a/src/utils/immutabilityHelpers.js b/src/utils/immutabilityHelpers.js index 0c89f0e..682d35f 100644 --- a/src/utils/immutabilityHelpers.js +++ b/src/utils/immutabilityHelpers.js @@ -17,7 +17,7 @@ import { isObject, clone } from './objectUtils' * helper function to get a nested property in an object or array * * @param {Object | Array} object - * @param {Array.} path + * @param {Path} path * @return {* | undefined} Returns the field when found, or undefined when the * path doesn't exist */ @@ -43,12 +43,10 @@ export function getIn (object, path) { * helper function to replace a nested property in an object with a new value * without mutating the object itself. * - * Note: does not work with Arrays! - * - * @param {Object} object - * @param {Array.} path + * @param {Object | Array} object + * @param {Path} path * @param {*} value - * @return {Object} Returns a new, updated object + * @return {Object | Array} Returns a new, updated object or array */ export function setIn (object, path, value) { if (path.length === 0) { @@ -67,16 +65,22 @@ export function setIn (object, path, value) { updated = clone(object) } - updated[key] = setIn(updated[key], path.slice(1), value) - - return updated + const updatedValue = setIn(updated[key], path.slice(1), value) + if (updated[key] === updatedValue) { + // return original object unchanged when the new value is identical to the old one + return object + } + else { + updated[key] = updatedValue + return updated + } } /** * helper function to replace a nested property in an object with a new value * without mutating the object itself. * * @param {Object | Array} object - * @param {Array.} path + * @param {Path} path * @param {function} callback * @return {Object | Array} Returns a new, updated object or array */ @@ -97,9 +101,15 @@ export function updateIn (object, path, callback) { updated = clone(object) } - updated[key] = updateIn(updated[key], path.slice(1), callback) - - return updated + const updatedValue = updateIn(object[key], path.slice(1), callback) + if (updated[key] === updatedValue) { + // return original object unchanged when the new value is identical to the old one + return object + } + else { + updated[key] = updatedValue + return updated + } } /** @@ -107,7 +117,7 @@ export function updateIn (object, path, callback) { * without mutating the object itself. * * @param {Object | Array} object - * @param {Array.} path + * @param {Path} path * @return {Object | Array} Returns a new, updated object or array */ export function deleteIn (object, path) { diff --git a/test/immutabilityHelpers.test.js b/test/immutabilityHelpers.test.js index 2149e9e..21a7676 100644 --- a/test/immutabilityHelpers.test.js +++ b/test/immutabilityHelpers.test.js @@ -136,6 +136,22 @@ test('setIn change object into array', t => { t.deepEqual (updated, [, , 'foo']) }) +test('setIn identical value should return the original object', t => { + const obj = {a:1, b:2} + + const updated = setIn(obj, ['b'], 2) + + t.is(updated, obj) // strict equal +}) + +test('setIn identical value should return the original object (2)', t => { + const obj = {a:1, b: { c: 2}} + + const updated = setIn(obj, ['b', 'c'], 2) + + t.is(updated, obj) // strict equal +}) + test('updateIn', t => { const obj = { a: { @@ -210,6 +226,16 @@ test('updateIn (3)', t => { }) }) +test('updateIn return identical value should return the original object', t => { + const obj = { + a: 2, + b: 3 + } + + const updated = updateIn(obj, ['b' ], (value) => 3) + t.is(updated, obj) +}) + test('deleteIn', t => { const obj = { a: { diff --git a/test/jsonData.test.js b/test/jsonData.test.js new file mode 100644 index 0000000..9417eb4 --- /dev/null +++ b/test/jsonData.test.js @@ -0,0 +1,277 @@ +import test from 'ava'; +import { jsonToData, dataToJson, expand } from '../src/jsonData' + + +// TODO: test all functions like append, insert, duplicate etc. + +const JSON_EXAMPLE = { + obj: { + arr: [1,2, {a:3,b:4}] + }, + str: 'hello world', + nill: null, + bool: false +} + +const JSON_DATA_EXAMPLE = { + type: 'object', + expanded: true, + props: [ + { + name: 'obj', + value: { + type: 'object', + expanded: true, + props: [ + { + name: 'arr', + value: { + type: 'array', + expanded: true, + items: [ + { + type: 'value', + value: 1 + }, + { + type: 'value', + value: 2 + }, + { + type: 'object', + expanded: true, + props: [ + { + name: 'a', + value: { + type: 'value', + value: 3 + } + }, + { + name: 'b', + value: { + type: 'value', + value: 4 + } + } + ] + }, + ] + } + } + ] + } + }, + { + name: 'str', + value: { + type: 'value', + value: 'hello world' + } + }, + { + name: 'nill', + value: { + type: 'value', + value: null + } + }, + { + name: 'bool', + value: { + type: 'value', + value: false + } + } + ] +} + +const JSON_DATA_EXAMPLE_COLLAPSED_1 = { + type: 'object', + expanded: true, + props: [ + { + name: 'obj', + value: { + type: 'object', + expanded: true, + props: [ + { + name: 'arr', + value: { + type: 'array', + expanded: true, + items: [ + { + type: 'value', + value: 1 + }, + { + type: 'value', + value: 2 + }, + { + type: 'object', + expanded: false, + props: [ + { + name: 'a', + value: { + type: 'value', + value: 3 + } + }, + { + name: 'b', + value: { + type: 'value', + value: 4 + } + } + ] + }, + ] + } + } + ] + } + }, + { + name: 'str', + value: { + type: 'value', + value: 'hello world' + } + }, + { + name: 'nill', + value: { + type: 'value', + value: null + } + }, + { + name: 'bool', + value: { + type: 'value', + value: false + } + } + ] +} + +const JSON_DATA_EXAMPLE_COLLAPSED_2 = { + type: 'object', + expanded: true, + props: [ + { + name: 'obj', + value: { + type: 'object', + expanded: false, + props: [ + { + name: 'arr', + value: { + type: 'array', + expanded: false, + items: [ + { + type: 'value', + value: 1 + }, + { + type: 'value', + value: 2 + }, + { + type: 'object', + expanded: false, + props: [ + { + name: 'a', + value: { + type: 'value', + value: 3 + } + }, + { + name: 'b', + value: { + type: 'value', + value: 4 + } + } + ] + }, + ] + } + } + ] + } + }, + { + name: 'str', + value: { + type: 'value', + value: 'hello world' + } + }, + { + name: 'nill', + value: { + type: 'value', + value: null + } + }, + { + name: 'bool', + value: { + type: 'value', + value: false + } + } + ] +} + +test('jsonToData', t => { + function expand (path) { + return true + } + + t.deepEqual(jsonToData([], JSON_EXAMPLE, expand), JSON_DATA_EXAMPLE) +}) + +test('dataToJson', t => { + t.deepEqual(dataToJson(JSON_DATA_EXAMPLE), JSON_EXAMPLE) +}) + +test('expand a single path', t => { + const collapsed = expand(JSON_DATA_EXAMPLE, ['obj', 'arr', 2], false) + + t.deepEqual(collapsed, JSON_DATA_EXAMPLE_COLLAPSED_1) +}) + +test('expand a callback', t => { + function callback (path) { + return path.length >= 1 + } + const expanded = false + const collapsed = expand(JSON_DATA_EXAMPLE, callback, expanded) + + t.deepEqual(collapsed, JSON_DATA_EXAMPLE_COLLAPSED_2) +}) + +test('expand a callback should not change the object when nothing happens', t => { + function callback (path) { + return false + } + const expanded = false + const collapsed = expand(JSON_DATA_EXAMPLE, callback, expanded) + + t.is(collapsed, JSON_DATA_EXAMPLE) +}) + + +