diff --git a/src/components/JSONNode.js b/src/components/JSONNode.js index 239ae43..04a86fe 100644 --- a/src/components/JSONNode.js +++ b/src/components/JSONNode.js @@ -36,13 +36,13 @@ export default class JSONNode extends Component { } } - renderJSONObject ({prop, data, search, options, events}) { + renderJSONObject ({prop, data, options, events}) { const childCount = data.props.length const contents = [ h('div', {class: 'jsoneditor-node jsoneditor-object'}, [ this.renderExpandButton(), this.renderActionMenuButton(), - this.renderProperty(prop, data, search, options), + this.renderProperty(prop, data, options), this.renderReadonly(`{${childCount}}`, `Array containing ${childCount} items`), this.renderError(data.error) ]) @@ -56,7 +56,6 @@ export default class JSONNode extends Component { parent: this, prop: prop.name, data: prop.value, - search: prop.search, options, events }) @@ -74,13 +73,13 @@ export default class JSONNode extends Component { return h('li', {}, contents) } - renderJSONArray ({prop, data, search, options, events}) { + renderJSONArray ({prop, data, options, events}) { const childCount = data.items.length const contents = [ h('div', {class: 'jsoneditor-node jsoneditor-array'}, [ this.renderExpandButton(), this.renderActionMenuButton(), - this.renderProperty(prop, data, search, options), + this.renderProperty(prop, data, options), this.renderReadonly(`[${childCount}]`, `Array containing ${childCount} items`), this.renderError(data.error) ]) @@ -110,14 +109,14 @@ export default class JSONNode extends Component { return h('li', {}, contents) } - renderJSONValue ({prop, data, search, options}) { + renderJSONValue ({prop, data, options}) { return h('li', {}, [ h('div', {class: 'jsoneditor-node'}, [ this.renderPlaceholder(), this.renderActionMenuButton(), - this.renderProperty(prop, data, search, options), + this.renderProperty(prop, data, options), this.renderSeparator(), - this.renderValue(data.value, data.search, options), + this.renderValue(data.value, data.searchValue, options), this.renderError(data.error) ]) ]) @@ -146,7 +145,7 @@ export default class JSONNode extends Component { return h('div', {class: 'jsoneditor-readonly', title}, text) } - renderProperty (prop, data, search, options) { + renderProperty (prop, data, options) { if (prop === null) { // root node const rootName = JSONNode.getRootName(data, options) @@ -162,7 +161,7 @@ export default class JSONNode extends Component { const editable = !isIndex && (!options.isPropertyEditable || options.isPropertyEditable(this.getPath())) const emptyClassName = (prop.length === 0 ? ' jsoneditor-empty' : '') - const searchClassName = search ? ' jsoneditor-highlight': ''; + const searchClassName = data.searchProperty ? ' jsoneditor-highlight': ''; if (editable) { const escapedProp = escapeHTML(prop, options.escapeUnicode) @@ -186,7 +185,7 @@ export default class JSONNode extends Component { return h('div', {class: 'jsoneditor-separator'}, ':') } - renderValue (value, searchResult, options) { + renderValue (value, searchValue, options) { const escapedValue = escapeHTML(value, options.escapeUnicode) const type = valueType (value) const itsAnUrl = isUrl(value) @@ -195,7 +194,7 @@ export default class JSONNode extends Component { const editable = !options.isValueEditable || options.isValueEditable(this.getPath()) if (editable) { return h('div', { - class: JSONNode.getValueClass(type, itsAnUrl, isEmpty, searchResult), + class: JSONNode.getValueClass(type, itsAnUrl, isEmpty, searchValue), contentEditable: 'true', spellCheck: 'false', onBlur: this.handleChangeValue, @@ -286,17 +285,16 @@ export default class JSONNode extends Component { * @param {string} type * @param {boolean} isUrl * @param {boolean} isEmpty - * @param {boolean | 'selected'} [searchResult] + * @param {boolean} [searchValue] * @return {string} * @public */ - static getValueClass (type, isUrl, isEmpty, searchResult) { + static getValueClass (type, isUrl, isEmpty, searchValue) { return 'jsoneditor-value ' + 'jsoneditor-' + type + (isUrl ? ' jsoneditor-url' : '') + (isEmpty ? ' jsoneditor-empty' : '') + - (searchResult === 'selected' ? ' jsoneditor-highlight-primary' : - searchResult ? ' jsoneditor-highlight' : '') + (searchValue ? ' jsoneditor-highlight' : '') } /** diff --git a/src/components/TreeMode.js b/src/components/TreeMode.js index c048574..23d44bd 100644 --- a/src/components/TreeMode.js +++ b/src/components/TreeMode.js @@ -6,7 +6,7 @@ import { parseJSON } from '../utils/jsonUtils' import { enrichSchemaError } from '../utils/schemaUtils' import { jsonToData, dataToJson, toDataPath, patchData, pathExists, - expand, addErrors, search + expand, addErrors, search, addSearchResults } from '../jsonData' import { duplicate, insert, append, remove, @@ -66,16 +66,19 @@ export default class TreeMode extends Component { ? JSONNodeForm : JSONNode - // enrich the data with JSON Schema errors and search results + // enrich the data with JSON Schema errors let data = state.data const errors = this.getErrors() if (errors.length) { data = addErrors(data, this.getErrors()) } - if (this.state.search.text) { - data = search(data, this.state.search.text) - console.log('data', data) + + // enrich the data with search results + const searchResults = this.state.search.text ? search(data, this.state.search.text) : null + if (searchResults) { + data = addSearchResults(data, searchResults) } + // TODO: pass number of search results to search box in top menu return h('div', { class: `jsoneditor jsoneditor-mode-${props.mode}`, diff --git a/src/components/menu/Search.js b/src/components/menu/Search.js index d4fda95..ccc306b 100644 --- a/src/components/menu/Search.js +++ b/src/components/menu/Search.js @@ -15,7 +15,7 @@ export default class Search extends Component { // TODO: show number of search results left from the input box // TODO: prev/next // TODO: focus on search results - // TODO: expand next search result if not expanded + // TODO: expand the focused search result if not expanded return h('div', {class: 'jsoneditor-search'}, h('input', {type: 'text', value: state.text, onInput: this.handleChange}) diff --git a/src/jsonData.js b/src/jsonData.js index 46d2920..efcbf03 100644 --- a/src/jsonData.js +++ b/src/jsonData.js @@ -117,7 +117,7 @@ export function toDataPath (data, path) { * @param {Array} patch A JSON patch * @param {function(path: Path)} [expand] Optional function to determine * what nodes must be expanded - * @return {{data: JSONData, revert: Array., error: Error | null}} + * @return {{data: JSONData, revert: Object[], error: Error | null}} */ export function patchData (data, patch, expand = expandAll) { let updatedData = data @@ -486,7 +486,7 @@ export function expand (data, callback, expanded) { * into the data * * @param {JSONData} data - * @param {Array.} errors + * @param {JSONSchemaError[]} errors */ export function addErrors (data, errors) { let updatedData = data @@ -507,38 +507,61 @@ export function addErrors (data, errors) { * * @param {JSONData} data * @param {string} text - * @return {JSONData} Returns an updated `data` object containing the search results + * @return {SearchResult[]} Returns a list with search results */ -// TODO: change search to return an array with paths, create a separate method addSearch similar to addErrors export function search (data, text) { - return transform(data, function (value) { - // search in values - if (value.type === 'value') { - if (containsCaseInsensitive(value.value, text)) { - return setIn(value, ['search'], true) - } - else { - return deleteIn(value, ['search']) - } - } + let results = [] - // search object property names - if (value.type === 'Object') { - let updatedProps = value.props - updatedProps.forEach((prop, index) => { - if (containsCaseInsensitive(prop.name, text)) { - updatedProps = setIn(updatedProps, [index, 'search'], true) + traverse(data, function (value, path) { + // search in values + if (value.type === 'value') { + if (containsCaseInsensitive(value.value, text)) { + results.push({ + dataPath: path, + value: true + }) } - else { - updatedProps = deleteIn(updatedProps, [index, 'search']) - } - }) + } - return setIn(value, ['props'], updatedProps) - } - - return value + // search object property names + if (value.type === 'Object') { + value.props.forEach((prop) => { + if (containsCaseInsensitive(prop.name, text)) { + results.push({ + dataPath: path.concat(prop.name), + property: true + }) + } + }) + } }) + + return results +} + +/** + * Merge searchResults into the data + * + * @param {JSONData} data + * @param {SearchResult[]} searchResults + */ +export function addSearchResults (data, searchResults) { + let updatedData = data + + if (searchResults) { + searchResults.forEach(function (searchResult) { + if (searchResult.value) { + const dataPath = toDataPath(data, searchResult.dataPath).concat('searchValue') + updatedData = setIn(updatedData, dataPath, true) + } + if (searchResult.property) { + const dataPath = toDataPath(data, searchResult.dataPath).concat('searchProperty') + updatedData = setIn(updatedData, dataPath, true) + } + }) + } + + return updatedData } /** @@ -552,7 +575,7 @@ export function containsCaseInsensitive (text, search) { } /** - * + * Recursively transform JSONData: a recursive "map" function * @param {JSONData} data * @param {function(value: JSONData, path: Path, root: JSONData)} callback * @return {JSONData} Returns the transformed data @@ -600,12 +623,51 @@ function recurseTransform (value, path, root, callback) { } default: // type 'string' or 'value' - // don't do anything: a value can't be expanded, only arrays and objects can + // no childs to traverse } return updatedValue } +/** + * Recursively loop over a JSONData object: a recursive "forEach" function. + * @param {JSONData} data + * @param {function(value: JSONData, path: Path, root: JSONData)} callback + */ +export function traverse (data, callback) { + return recurseTraverse (data, [], data, callback) +} + +/** + * Recursively traverse a JSONData object + * @param {JSONData} value + * @param {Path} path + * @param {JSONData | null} root The root object, object at path=[] + * @param {function(value: JSONData, path: Path, root: JSONData)} callback + */ +function recurseTraverse (value, path, root, callback) { + callback(value, path, root) + + switch (value.type) { + case 'Array': { + value.items.forEach((item, index) => { + recurseTraverse(item, path.concat(String(index)), root, callback) + }) + break + } + + case 'Object': { + value.props.forEach((prop) => { + recurseTraverse(prop.value, path.concat(prop.name), root, callback) + }) + break + } + + default: // type 'string' or 'value' + // no childs to traverse + } +} + /** * Test whether a path exists in the json data * @param {JSONData} data diff --git a/src/typedef.js b/src/typedef.js index 0ddd711..2f48e98 100644 --- a/src/typedef.js +++ b/src/typedef.js @@ -59,4 +59,11 @@ * @typedef {{ * expand: function (path: Path)? * }} PatchOptions + * + * @typedef {{ + * dataPath: Path, + * property: boolean?, + * value: boolean? + * }} SearchResult + * // TODO: SearchResult.dataPath is an array, JSONSchemaError.dataPath is a string -> make this consistent */ diff --git a/test/jsonData.test.js b/test/jsonData.test.js index 95b68f1..c089eea 100644 --- a/test/jsonData.test.js +++ b/test/jsonData.test.js @@ -1,14 +1,14 @@ import test from 'ava'; import { - jsonToData, dataToJson, patchData, pathExists, transform, search, + jsonToData, dataToJson, patchData, pathExists, transform, traverse, parseJSONPointer, compileJSONPointer, - expand, addErrors + expand, addErrors, search, addSearchResults } from '../src/jsonData' const JSON_EXAMPLE = { obj: { - arr: [1,2, {a:3,b:4}] + arr: [1,2, {first:3,last:4}] }, str: 'hello world', nill: null, @@ -44,14 +44,14 @@ const JSON_DATA_EXAMPLE = { expanded: true, props: [ { - name: 'a', + name: 'first', value: { type: 'value', value: 3 } }, { - name: 'b', + name: 'last', value: { type: 'value', value: 4 @@ -118,14 +118,14 @@ const JSON_DATA_EXAMPLE_COLLAPSED_1 = { expanded: false, props: [ { - name: 'a', + name: 'first', value: { type: 'value', value: 3 } }, { - name: 'b', + name: 'last', value: { type: 'value', value: 4 @@ -192,14 +192,14 @@ const JSON_DATA_EXAMPLE_COLLAPSED_2 = { expanded: false, props: [ { - name: 'a', + name: 'first', value: { type: 'value', value: 3 } }, { - name: 'b', + name: 'last', value: { type: 'value', value: 4 @@ -237,14 +237,13 @@ const JSON_DATA_EXAMPLE_COLLAPSED_2 = { ] } -// after search for 'O' (case insensitive) -const JSON_DATA_EXAMPLE_SEARCH_1 = { +// after search for 'L' (case insensitive) +const JSON_DATA_EXAMPLE_SEARCH_L = { type: 'Object', expanded: true, props: [ { name: 'obj', - search: true, value: { type: 'Object', expanded: true, @@ -268,21 +267,22 @@ const JSON_DATA_EXAMPLE_SEARCH_1 = { expanded: true, props: [ { - name: 'a', + name: 'first', value: { type: 'value', value: 3 } }, { - name: 'b', + name: 'last', value: { type: 'value', - value: 4 + value: 4, + searchProperty: true } } ] - }, + } ] } } @@ -294,98 +294,25 @@ const JSON_DATA_EXAMPLE_SEARCH_1 = { value: { type: 'value', value: 'hello world', - search: true + searchValue: true } }, { name: 'nill', value: { type: 'value', - value: null - } - }, - { - name: 'bool', - search: true, - value: { - type: 'value', - value: false - } - } - ] -} - -// after search for '2' -const JSON_DATA_EXAMPLE_SEARCH_2 = { - 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, - search: true - }, - { - 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 + value: null, + searchProperty: true, + searchValue: true } }, { name: 'bool', value: { type: 'value', - value: false + value: false, + searchProperty: true, + searchValue: true } } ] @@ -426,7 +353,7 @@ const JSON_DATA_SMALL = { const JSON_SCHEMA_ERRORS = [ - {dataPath: '/obj/arr/2/b', message: 'String expected'}, + {dataPath: '/obj/arr/2/last', message: 'String expected'}, {dataPath: '/nill', message: 'Null expected'} ] @@ -459,14 +386,14 @@ const JSON_DATA_EXAMPLE_ERRORS = { expanded: true, props: [ { - name: 'a', + name: 'first', value: { type: 'value', value: 3 } }, { - name: 'b', + name: 'last', value: { type: 'value', value: 4, @@ -545,7 +472,7 @@ test('expand a callback should not change the object when nothing happens', t => }) test('pathExists', t => { - t.is(pathExists(JSON_DATA_EXAMPLE, ['obj', 'arr', 2, 'a']), true) + t.is(pathExists(JSON_DATA_EXAMPLE, ['obj', 'arr', 2, 'first']), true) t.is(pathExists(JSON_DATA_EXAMPLE, ['obj', 'foo']), false) t.is(pathExists(JSON_DATA_EXAMPLE, ['obj', 'foo', 'bar']), false) t.is(pathExists(JSON_DATA_EXAMPLE, []), true) @@ -931,7 +858,7 @@ test('transform', t => { let log = [] const transformed = transform(JSON_DATA_SMALL, function (value, path, root) { - t.truthy(root === JSON_DATA_SMALL) + t.is(root, JSON_DATA_SMALL) log.push([value, path, root]) @@ -958,20 +885,56 @@ test('transform', t => { // t.deepEqual(log[index], EXPECTED_LOG[index], 'should have equal log at index ' + index ) // }) t.deepEqual(log, EXPECTED_LOG) - t.truthy(transformed !== JSON_DATA_SMALL) - t.truthy(transformed.props[0].value !== JSON_DATA_SMALL.props[0].value) - t.truthy(transformed.props[0].value.props[0].value !== JSON_DATA_SMALL.props[0].value.props[0].value) - t.truthy(JSON_DATA_SMALL.props[1].value === JSON_DATA_SMALL.props[1].value) - t.truthy(JSON_DATA_SMALL.props[1].value.items[0] === JSON_DATA_SMALL.props[1].value.items[0]) + t.not(transformed, JSON_DATA_SMALL) + t.not(transformed.props[0].value, JSON_DATA_SMALL.props[0].value) + t.not(transformed.props[0].value.props[0].value, JSON_DATA_SMALL.props[0].value.props[0].value) + t.is(transformed.props[1].value, JSON_DATA_SMALL.props[1].value) + t.is(transformed.props[1].value.items[0], JSON_DATA_SMALL.props[1].value.items[0]) }) +test('traverse', t => { + // {obj: {a: 2}, arr: [3]} + + let log = [] + const returnValue = traverse(JSON_DATA_SMALL, function (value, path, root) { + t.is(root, JSON_DATA_SMALL) + + log.push([value, path, root]) + }) + + t.is(returnValue, undefined) + + const EXPECTED_LOG = [ + [JSON_DATA_SMALL, [], JSON_DATA_SMALL], + [JSON_DATA_SMALL.props[0].value, ['obj'], JSON_DATA_SMALL], + [JSON_DATA_SMALL.props[0].value.props[0].value, ['obj', 'a'], JSON_DATA_SMALL], + [JSON_DATA_SMALL.props[1].value, ['arr'], JSON_DATA_SMALL], + [JSON_DATA_SMALL.props[1].value.items[0], ['arr', '0'], JSON_DATA_SMALL], + ] + + // log.forEach((row, index) => { + // t.deepEqual(log[index], EXPECTED_LOG[index], 'should have equal log at index ' + index ) + // }) + t.deepEqual(log, EXPECTED_LOG) +}) + test('search', t => { - const result1 = search(JSON_DATA_EXAMPLE, 'O') - t.deepEqual(result1, JSON_DATA_EXAMPLE_SEARCH_1) + const searchResults = search(JSON_DATA_EXAMPLE, 'L') + // console.log(searchResults) - // search for something else. Should clean up earlier search results - const result2 = search(result1, '2') - t.deepEqual(result2, JSON_DATA_EXAMPLE_SEARCH_2) + t.deepEqual(searchResults, [ + {dataPath: ['nill'], property: true}, + {dataPath: ['bool'], property: true}, + {dataPath: ['obj', 'arr', '2', 'last'], property: true}, + {dataPath: ['str'], value: true}, + {dataPath: ['nill'], value: true}, + {dataPath: ['bool'], value: true} + ]) + + const updatedData = addSearchResults(JSON_DATA_EXAMPLE, searchResults) + // console.log(JSON.stringify(updatedData, null, 2)) + + t.deepEqual(updatedData, JSON_DATA_EXAMPLE_SEARCH_L) })