Implemented expand/collapse methods and ctrl+expand/ctrl+collapse

This commit is contained in:
jos 2016-08-21 12:45:25 +02:00
parent d8a0079032
commit c5a68b1da3
8 changed files with 558 additions and 37 deletions

View File

@ -217,7 +217,13 @@ export default class JSONNode extends Component {
renderExpandButton () { renderExpandButton () {
const className = `jsoneditor-button jsoneditor-${this.props.data.expanded ? 'expanded' : 'collapsed'}` const className = `jsoneditor-button jsoneditor-${this.props.data.expanded ? 'expanded' : 'collapsed'}`
return h('div', {class: 'jsoneditor-button-container'}, return h('div', {class: 'jsoneditor-button-container'},
h('button', {class: className, onClick: this.handleExpand}) h('button', {
class: className,
onClick: this.handleExpand,
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) { 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) { handleContextMenu (event) {

View File

@ -1,12 +1,12 @@
import { h, Component } from 'preact' import { h, Component } from 'preact'
import { setIn } from './utils/immutabilityHelpers' import { setIn, updateIn } from './utils/immutabilityHelpers'
import { import {
changeValue, changeProperty, changeType, changeValue, changeProperty, changeType,
insert, append, duplicate, remove, insert, append, duplicate, remove,
sort, sort,
expand, expand,
jsonToData, dataToJson jsonToData, dataToJson, toDataPath
} from './jsonData' } from './jsonData'
import JSONNode from './JSONNode' import JSONNode from './JSONNode'
@ -106,10 +106,21 @@ export default class TreeMode extends Component {
}) })
} }
handleExpand = (path, doExpand) => { handleExpand = (path, expanded, recurse) => {
this.setState({ if (recurse) {
data: expand(this.state.data, path, doExpand) 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) 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 expand
// TODO: implement getText and setText // TODO: implement getText and setText

View File

@ -36,11 +36,50 @@ function jsoneditor (container, options) {
*/ */
get: function () { get: function () {
return component.get() return component.get()
} },
// TODO: implement getText // TODO: implement getText
// TODO: implement setText // 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)
},
} }
} }

View File

@ -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 {JSONData} data
* @param {Path} path * @param {function(path: Path) : boolean | Path} callback
* @param {boolean} expand * 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} * @return {JSONData}
*/ */
export function expand (data, path, expand) { export function expand (data, callback, expanded) {
console.log('expand', path, expand) // 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 * Convert a path of a JSON object into a path in the corresponding data model
* @param {JSONData} data * @param {JSONData} data

View File

@ -1,23 +1,19 @@
// TODO: rename type 'array' to 'Array' and 'object' to 'Object'
/** /**
* @typedef {{ * @typedef {{
* type: 'array', * type: 'array',
* expanded: boolean?, * expanded: boolean?,
* menu: boolean?,
* props: Array.<{name: string, value: JSONData}>? * props: Array.<{name: string, value: JSONData}>?
* }} ObjectData * }} ObjectData
* *
* @typedef {{ * @typedef {{
* type: 'object', * type: 'object',
* expanded: boolean?, * expanded: boolean?,
* menu: boolean?,
* items: JSONData[]? * items: JSONData[]?
* }} ArrayData * }} ArrayData
* *
* @typedef {{ * @typedef {{
* type: 'value' | 'string', * type: 'value' | 'string',
* expanded: boolean?,
* menu: boolean?,
* value: *? * value: *?
* }} ValueData * }} ValueData
* *
@ -35,4 +31,78 @@
* name: string?, * name: string?,
* expand: function (path: Path)? * expand: function (path: Path)?
* }} SetOptions * }} SetOptions
*/ */
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
}
}
]
}

View File

@ -17,7 +17,7 @@ import { isObject, clone } from './objectUtils'
* helper function to get a nested property in an object or array * helper function to get a nested property in an object or array
* *
* @param {Object | Array} object * @param {Object | Array} object
* @param {Array.<string | number>} path * @param {Path} path
* @return {* | undefined} Returns the field when found, or undefined when the * @return {* | undefined} Returns the field when found, or undefined when the
* path doesn't exist * 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 * helper function to replace a nested property in an object with a new value
* without mutating the object itself. * without mutating the object itself.
* *
* Note: does not work with Arrays! * @param {Object | Array} object
* * @param {Path} path
* @param {Object} object
* @param {Array.<string>} path
* @param {*} value * @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) { export function setIn (object, path, value) {
if (path.length === 0) { if (path.length === 0) {
@ -67,16 +65,22 @@ export function setIn (object, path, value) {
updated = clone(object) updated = clone(object)
} }
updated[key] = setIn(updated[key], path.slice(1), value) const updatedValue = setIn(updated[key], path.slice(1), value)
if (updated[key] === updatedValue) {
return updated // 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 * helper function to replace a nested property in an object with a new value
* without mutating the object itself. * without mutating the object itself.
* *
* @param {Object | Array} object * @param {Object | Array} object
* @param {Array.<string | number>} path * @param {Path} path
* @param {function} callback * @param {function} callback
* @return {Object | Array} Returns a new, updated object or array * @return {Object | Array} Returns a new, updated object or array
*/ */
@ -97,9 +101,15 @@ export function updateIn (object, path, callback) {
updated = clone(object) updated = clone(object)
} }
updated[key] = updateIn(updated[key], path.slice(1), callback) const updatedValue = updateIn(object[key], path.slice(1), callback)
if (updated[key] === updatedValue) {
return updated // 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. * without mutating the object itself.
* *
* @param {Object | Array} object * @param {Object | Array} object
* @param {Array.<string | number>} path * @param {Path} path
* @return {Object | Array} Returns a new, updated object or array * @return {Object | Array} Returns a new, updated object or array
*/ */
export function deleteIn (object, path) { export function deleteIn (object, path) {

View File

@ -136,6 +136,22 @@ test('setIn change object into array', t => {
t.deepEqual (updated, [, , 'foo']) 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 => { test('updateIn', t => {
const obj = { const obj = {
a: { 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 => { test('deleteIn', t => {
const obj = { const obj = {
a: { a: {

277
test/jsonData.test.js Normal file
View File

@ -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)
})