diff --git a/src/components/TreeMode.js b/src/components/TreeMode.js index c1be699..d985676 100644 --- a/src/components/TreeMode.js +++ b/src/components/TreeMode.js @@ -8,13 +8,13 @@ import Hammer from 'react-hammerjs' import jump from '../assets/jump.js/src/jump' import Ajv from 'ajv' -import { setIn, updateIn } from '../utils/immutabilityHelpers' +import { getIn, setIn, updateIn } from '../utils/immutabilityHelpers' import { parseJSON } from '../utils/jsonUtils' import { enrichSchemaError } from '../utils/schemaUtils' import { - jsonToEson, esonToJson, getInEson, updateInEson, pathExists, - expand, expandOne, expandPath, updateErrors, - search, applySearchResults, nextSearchResult, previousSearchResult, + jsonToEson, esonToJson, pathExists, + expand, expandOne, expandPath, applyErrors, + search, nextSearchResult, previousSearchResult, applySelection, pathsFromSelection, contentsFromPaths, compileJSONPointer, parseJSONPointer } from '../eson' @@ -195,9 +195,10 @@ export default class TreeMode extends Component { let eson = state.eson - // enrich the data with JSON Schema errors - // TODO: for optimization, we can apply errors only when the eson is changed? (a wrapper around setState or something?) Takes about 7ms in large documents - eson = updateErrors(eson, this.getErrors()) + // enrich the eson with selection and JSON Schema errors + // TODO: for optimization, we can apply errors only when the eson is changed? (a wrapper around setState or something?) + eson = applyErrors(eson, this.getErrors()) + eson = applySelection(eson, this.state.selection) return h('div', { className: `jsoneditor jsoneditor-mode-${props.mode}`, @@ -981,7 +982,7 @@ export default class TreeMode extends Component { * @return {boolean} Returns true when expanded, false otherwise */ isExpanded (path) { - return getInEson(this.state.data, path).expanded + return getIn(this.state.eson, path)._meta.expanded } /** diff --git a/src/eson.js b/src/eson.js index 8646dfa..2ccd88a 100644 --- a/src/eson.js +++ b/src/eson.js @@ -9,6 +9,7 @@ import { setIn, getIn, updateIn, deleteIn } from './utils/immutabilityHelpers' import { isObject } from './utils/typeUtils' import isEqual from 'lodash/isEqual' import isEmpty from 'lodash/isEmpty' +import range from 'lodash/range' import times from 'lodash/times' import initial from 'lodash/initial' import last from 'lodash/last' @@ -337,25 +338,38 @@ export function expandPath (eson, path, expanded = true) { * @param {JSONSchemaError[]} errors * @return {ESON} */ -export function updateErrors (eson, errors = []) { - let updatedEson = eson +export function applyErrors (eson, errors = []) { + const errorPaths = errors.map(error => error.dataPath) - if (!isEmpty(errors)) { - errors.forEach(error => { - const path = parseJSONPointer(error.dataPath) - // TODO: do we want to be able to store multiple errors per item? - updatedEson = setIn(updatedEson, path.concat(['_meta', 'error']), error) - }) - } + const esonWithErrors = errors.reduce((eson, error) => { + const path = parseJSONPointer(error.dataPath) + // TODO: do we want to be able to store multiple errors per item? + return setIn(eson, path.concat(['_meta', 'error']), error) + }, eson) // cleanup any old error messages - updatedEson = transform(updatedEson, function (value, path) { - return (value._meta.error && !contains(errors, value._meta.error)) - ? deleteIn(value, ['_meta', 'error']) - : value + return cleanupMetaData(esonWithErrors, 'error', errorPaths) +} + +/** + * Cleanup meta data from an eson object + * @param {ESON} eson Object to be cleaned up + * @param {String} field Field name, for example 'error' or 'selected' + * @param {String[] | JSONPath[]} [ignorePaths=[]] An optional array with paths to be ignored + * @return {ESON} + */ +export function cleanupMetaData(eson, field, ignorePaths = []) { + const pathsMap = {} + ignorePaths.forEach(path => { + const pathString = (typeof path === 'string') ? path : compileJSONPointer(path) + pathsMap[pathString] = true }) - return updatedEson + return transform(eson, function (value, path) { + return (value._meta[field] && !pathsMap[compileJSONPointer(path)]) + ? deleteIn(value, ['_meta', field]) + : value + }) } /** @@ -472,61 +486,70 @@ function setSearchStatus (eson, esonPointer, searchStatus) { } /** - * Merge searchResults into the eson object + * Merge selection status into the eson object, cleanup previous selection + * @param {ESON} eson + * @param {Selection} selection + * @return {ESON} Returns updated eson object */ -export function applySearchResults (eson: ESON, searchResults: ESONPointer[], activeSearchResult: ESONPointer) { - let updatedEson = eson - - searchResults.forEach(function (searchResult) { - if (searchResult.area === 'value') { - const esonPath = toEsonPath(updatedEson, searchResult.path).concat('searchResult') - const value = isEqual(searchResult, activeSearchResult) ? 'active' : 'normal' - updatedEson = setIn(updatedEson, esonPath, value) - } - - if (searchResult.area === 'property') { - const esonPath = toEsonPath(updatedEson, searchResult.path) - const propertyPath = initial(esonPath).concat('searchResult') - const value = isEqual(searchResult, activeSearchResult) ? 'active' : 'normal' - updatedEson = setIn(updatedEson, propertyPath, value) - } - }) - - return updatedEson -} - -/** - * Merge searchResults into the eson object - */ -export function applySelection (eson: ESON, selection: Selection) { +export function applySelection (eson, selection) { if (!selection) { - return eson + return cleanupMetaData(eson, 'selected') } - - if (selection.before) { - const esonPath = toEsonPath(eson, selection.before) - return setIn(eson, esonPath.concat('selected'), SELECTED_BEFORE) + else if (selection.before) { + const updatedEson = setIn(eson, selection.before.concat(['_meta', 'selected']), SELECTED_BEFORE) + return cleanupMetaData(updatedEson, 'selected', [selection.before]) } else if (selection.after) { - const esonPath = toEsonPath(eson, selection.after) - return setIn(eson, esonPath.concat('selected'), SELECTED_AFTER) + const updatedEson = setIn(eson, selection.after.concat(['_meta', 'selected']), SELECTED_AFTER) + return cleanupMetaData(updatedEson, 'selected', [selection.after]) } else { // selection.start and selection.end // find the parent node shared by both start and end of the selection const rootPath = findRootPath(selection) + let selectedPaths = null - return updateInEson(eson, rootPath, (root) => { - const { minIndex, maxIndex } = findSelectionIndices(root, rootPath, selection) + const updatedEson = updateIn(eson, rootPath, (root) => { + const start = selection.start[rootPath.length] + const end = selection.end[rootPath.length] - const childsKey = (root.type === 'Object') ? 'props' : 'items' // property name of the array with props/items - const childsBefore = root[childsKey].slice(0, minIndex) - const childsUpdated = root[childsKey].slice(minIndex, maxIndex) - .map((child, index) => setIn(child, ['value', 'selected'], index === 0 ? SELECTED_END : SELECTED)) - const childsAfter = root[childsKey].slice(maxIndex) - // FIXME: actually mark the end index as SELECTED_END, currently we select the first index + // TODO: simplify the update function. Use pathsFromSelection ? - return setIn(root, [childsKey], childsBefore.concat(childsUpdated, childsAfter)) + if (root._meta.type === 'Object') { + const startIndex = root._meta.keys.indexOf(start) + const endIndex = root._meta.keys.indexOf(end) + + const minIndex = Math.min(startIndex, endIndex) + const maxIndex = Math.max(startIndex, endIndex) + 1 // include max index itself + + const selectedProps = root._meta.keys.slice(minIndex, maxIndex) + selectedPaths = selectedProps.map(prop => rootPath.concat(prop)) + let updatedRoot = Object.assign({}, root) + selectedProps.forEach(prop => { + updatedRoot[prop] = setIn(updatedRoot[prop], ['_meta', 'selected'], prop === end ? SELECTED_END : SELECTED) + }) + + return updatedRoot + } + else { // root._meta.type === 'Array' + const startIndex = parseInt(start) + const endIndex = parseInt(end) + + const minIndex = Math.min(startIndex, endIndex) + const maxIndex = Math.max(startIndex, endIndex) + 1 // include max index itself + + const selectedIndices = range(minIndex, maxIndex) + selectedPaths = selectedIndices.map(index => rootPath.concat(index)) + + let updatedRoot = Object.assign({}, root) + selectedIndices.forEach(index => { + updatedRoot[index] = setIn(updatedRoot[index], ['_meta', 'selected'], index === endIndex ? SELECTED_END : SELECTED) + }) + + return updatedRoot + } }) + + return cleanupMetaData(updatedEson, 'selected', selectedPaths) } } @@ -598,10 +621,10 @@ export function findRootPath(selection) { else if (selection.after) { return initial(selection.after) } - else { // .start and .end + else { // selection.start and selection.end const sharedPath = findSharedPath(selection.start, selection.end) - if (sharedPath.length === selection.start.length && + if (sharedPath.length === selection.start.length || sharedPath.length === selection.end.length) { // there is just one node selected, return it's parent return initial(sharedPath) diff --git a/src/types.js b/src/types.js index be62ef4..212ab9f 100644 --- a/src/types.js +++ b/src/types.js @@ -134,6 +134,6 @@ export type ESONPatchResult = { } export type JSONSchemaError = { - path: string, // TODO: change type to JSONPath + dataPath: string, // TODO: change type to JSONPath message: string } diff --git a/test/eson.test.js b/test/eson.test.js index a90fd06..6956d7c 100644 --- a/test/eson.test.js +++ b/test/eson.test.js @@ -5,7 +5,7 @@ import { esonToJson, toEsonPath, toJsonPath, pathExists, transform, traverse, parseJSONPointer, compileJSONPointer, jsonToEson, - expand, expandOne, expandPath, updateErrors, search, nextSearchResult, previousSearchResult, + expand, expandOne, expandPath, applyErrors, search, nextSearchResult, previousSearchResult, applySelection, pathsFromSelection, SELECTED, SELECTED_END } from '../src/eson' @@ -222,7 +222,7 @@ test('add and remove errors', t => { {dataPath: '/nill', message: 'Null expected'} ] - const actual1 = updateErrors(eson, jsonSchemaErrors) + const actual1 = applyErrors(eson, jsonSchemaErrors) let expected = eson expected = setIn(expected, ['obj', 'arr', '2', 'last', '_meta', 'error'], jsonSchemaErrors[0]) @@ -230,11 +230,11 @@ test('add and remove errors', t => { t.deepEqual(actual1, expected) // re-applying the same errors should not change eson - const actual2 = updateErrors(actual1, jsonSchemaErrors) + const actual2 = applyErrors(actual1, jsonSchemaErrors) t.is(actual2, actual1) // clear errors - const actual3 = updateErrors(actual2, []) + const actual3 = applyErrors(actual2, []) t.deepEqual(actual3, eson) t.is(actual3.str, eson.str) // shouldn't have touched values not affected by the errors }) @@ -384,56 +384,97 @@ test('previousSearchResult', t => { }) test('selection (object)', t => { + const eson = jsonToEson({ + "obj": { + "arr": [1,2, {"first":3,"last":4}] + }, + "str": "hello world", + "nill": null, + "bool": false + }) const selection = { start: ['obj', 'arr', '2', 'last'], end: ['nill'] } - const actual = applySelection(ESON1, selection) - - let expected = ESON1 - expected = setIn(expected, toEsonPath(ESON1, ['obj']).concat(['selected']), SELECTED_END) - expected = setIn(expected, toEsonPath(ESON1, ['str']).concat(['selected']), SELECTED) - expected = setIn(expected, toEsonPath(ESON1, ['nill']).concat(['selected']), SELECTED) + const actual = applySelection(eson, selection) + let expected = eson + expected = setIn(expected, ['obj', '_meta', 'selected'], SELECTED) + expected = setIn(expected, ['str', '_meta', 'selected'], SELECTED) + expected = setIn(expected, ['nill', '_meta', 'selected'], SELECTED_END) t.deepEqual(actual, expected) + + // test whether old selection results are cleaned up + const selection2 = { + start: ['nill'], + end: ['bool'] + } + const actual2 = applySelection(actual, selection2) + let expected2 = eson + expected2 = setIn(expected2, ['nill', '_meta', 'selected'], SELECTED) + expected2 = setIn(expected2, ['bool', '_meta', 'selected'], SELECTED_END) + t.deepEqual(actual2, expected2) }) test('selection (array)', t => { + const eson = jsonToEson({ + "obj": { + "arr": [1,2, {"first":3,"last":4}] + }, + "str": "hello world", + "nill": null, + "bool": false + }) const selection = { start: ['obj', 'arr', '1'], end: ['obj', 'arr', '0'] // note the "wrong" order of start and end } - const actual = applySelection(ESON1, selection) + const actual = applySelection(eson, selection) - // FIXME: SELECTE_END should be selection.start, not the first - let expected = ESON1 - expected = setIn(expected, toEsonPath(ESON1, ['obj', 'arr', '0']).concat(['selected']), SELECTED_END) - expected = setIn(expected, toEsonPath(ESON1, ['obj', 'arr', '1']).concat(['selected']), SELECTED) + let expected = eson + expected = setIn(expected, ['obj', 'arr', '0', '_meta', 'selected'], SELECTED_END) + expected = setIn(expected, ['obj', 'arr', '1', '_meta', 'selected'], SELECTED) t.deepEqual(actual, expected) }) test('selection (value)', t => { + const eson = jsonToEson({ + "obj": { + "arr": [1,2, {"first":3,"last":4}] + }, + "str": "hello world", + "nill": null, + "bool": false + }) const selection = { start: ['obj', 'arr', '2', 'first'], end: ['obj', 'arr', '2', 'first'] } - const actual = applySelection(ESON1, selection) - const expected = setIn(ESON1, toEsonPath(ESON1, ['obj', 'arr', '2', 'first']).concat(['selected']), SELECTED_END) + const actual = applySelection(eson, selection) + const expected = setIn(eson, ['obj', 'arr', '2', 'first', '_meta', 'selected'], SELECTED_END) t.deepEqual(actual, expected) }) test('selection (node)', t => { + const eson = jsonToEson({ + "obj": { + "arr": [1,2, {"first":3,"last":4}] + }, + "str": "hello world", + "nill": null, + "bool": false + }) const selection = { start: ['obj', 'arr'], end: ['obj', 'arr'] } - const actual = applySelection(ESON1, selection) - const expected = setIn(ESON1, toEsonPath(ESON1, ['obj', 'arr']).concat(['selected']), SELECTED_END) + const actual = applySelection(eson, selection) + const expected = setIn(eson, ['obj', 'arr', '_meta', 'selected'], SELECTED_END) t.deepEqual(actual, expected) })