From 100efb35ae6d422d845e98151baa94ad340372ec Mon Sep 17 00:00:00 2001 From: jos Date: Fri, 30 Dec 2016 13:45:43 +0100 Subject: [PATCH] Restructured search data model --- README.md | 12 ++++++ package.json | 10 ++++- src/components/JSONNode.js | 83 ++++++++++++++++++++++++-------------- src/components/TreeMode.js | 2 +- src/jsonData.js | 41 +++++++++++-------- src/typedef.js | 69 ------------------------------- src/types.js | 83 +++++++++++++++++++++----------------- test/jsonData.test.js | 22 +++++----- 8 files changed, 156 insertions(+), 166 deletions(-) delete mode 100644 src/typedef.js diff --git a/README.md b/README.md index 7ec4100..4b809d5 100644 --- a/README.md +++ b/README.md @@ -152,3 +152,15 @@ jsoneditor: This will update `./dist/jsoneditor.js` on every change in the source code, but it will **NOT** update the minimalist version. + +- Run unit tests: + + ``` + npm test + ``` + + or to watch for changes and re-run tests automatically: + + ``` + npm run watch:test + ``` diff --git a/package.json b/package.json index b2dfb09..9d61b07 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "start": "gulp watch", "build": "gulp", "flow": "flow; test $? -eq 0 -o $? -eq 2", - "test": "ava test/*.test.js test/**/*.test.js --verbose" + "test": "ava --verbose", + "watch:test": "ava --verbose --watch" }, "dependencies": { "ajv": "4.9.2", @@ -55,9 +56,16 @@ "webpack": "1.14.0" }, "ava": { + "files": [ + "test/**/*.test.js" + ], + "source": [ + "./src/**/*" + ], "require": [ "babel-register" ], + "concurrency": 4, "babel": "inherit" } } diff --git a/src/components/JSONNode.js b/src/components/JSONNode.js index 0264237..fd99909 100644 --- a/src/components/JSONNode.js +++ b/src/components/JSONNode.js @@ -8,6 +8,10 @@ import { escapeHTML, unescapeHTML } from '../utils/stringUtils' import { getInnerText, insideRect } from '../utils/domUtils' import { stringConvert, valueType, isUrl } from '../utils/typeUtils' +import type { + PropertyData, ObjectData, ArrayData, JSONData, + SearchResult, SearchResultStatus } from '../types' + /** * @type {JSONNode | null} activeContextMenu singleton holding the JSONNode having * the active (visible) context menu @@ -36,13 +40,13 @@ export default class JSONNode extends Component { } } - renderJSONObject ({prop, data, options, events}) { + renderJSONObject ({prop, index, data, options, events}) { const childCount = data.props.length const contents = [ h('div', {key: 'node', className: 'jsoneditor-node jsoneditor-object'}, [ this.renderExpandButton(), this.renderActionMenuButton(), - this.renderProperty(prop, data, options), + this.renderProperty(prop, index, data, options), this.renderReadonly(`{${childCount}}`, `Array containing ${childCount} items`), this.renderError(data.error) ]) @@ -54,7 +58,7 @@ export default class JSONNode extends Component { return h(this.constructor, { key: prop.name, parent: this, - prop: prop.name, + prop: prop, data: prop.value, options, events @@ -73,13 +77,13 @@ export default class JSONNode extends Component { return h('li', {}, contents) } - renderJSONArray ({prop, data, options, events}) { + renderJSONArray ({prop, index, data, options, events}) { const childCount = data.items.length const contents = [ h('div', {key: 'node', className: 'jsoneditor-node jsoneditor-array'}, [ this.renderExpandButton(), this.renderActionMenuButton(), - this.renderProperty(prop, data, options), + this.renderProperty(prop, index, data, options), this.renderReadonly(`[${childCount}]`, `Array containing ${childCount} items`), this.renderError(data.error) ]) @@ -91,7 +95,7 @@ export default class JSONNode extends Component { return h(this.constructor, { key: index, parent: this, - prop: index, + index, data: child, options, events @@ -109,14 +113,14 @@ export default class JSONNode extends Component { return h('li', {}, contents) } - renderJSONValue ({prop, data, options}) { + renderJSONValue ({prop, index, data, options}) { return h('li', {}, h('div', {className: 'jsoneditor-node'}, [ this.renderPlaceholder(), this.renderActionMenuButton(), - this.renderProperty(prop, data, options), + this.renderProperty(prop, index, data, options), this.renderSeparator(), - this.renderValue(data.value, data.searchValue, options), + this.renderValue(data.value, data.searchResult, options), this.renderError(data.error) ]) ) @@ -145,8 +149,10 @@ export default class JSONNode extends Component { return h('div', {key: 'readonly', className: 'jsoneditor-readonly', title}, text) } - renderProperty (prop, data, options) { - if (prop === null) { + renderProperty (prop: ?PropertyData, index: ?number, data: JSONData, options) { + const isIndex = typeof index === 'number' + + if (!prop && !isIndex) { // root node const rootName = JSONNode.getRootName(data, options) @@ -159,29 +165,27 @@ export default class JSONNode extends Component { }, rootName) } - const isIndex = typeof prop === 'number' // FIXME: pass an explicit prop isIndex or editable const editable = !isIndex && (!options.isPropertyEditable || options.isPropertyEditable(this.getPath())) - const emptyClassName = (prop.length === 0 ? ' jsoneditor-empty' : '') - const searchClassName = data.searchProperty ? ' jsoneditor-highlight': ''; + const emptyClassName = (prop && prop.name.length === 0) ? ' jsoneditor-empty' : '' + const searchClassName = prop ? JSONNode.getSearchResultClass(prop.searchResult) : '' + const escapedPropName = prop ? escapeHTML(prop.name, options.escapeUnicode) : null if (editable) { - const escapedProp = escapeHTML(prop, options.escapeUnicode) - return h('div', { key: 'property', className: 'jsoneditor-property' + emptyClassName + searchClassName, contentEditable: 'true', spellCheck: 'false', onBlur: this.handleChangeProperty - }, escapedProp) + }, escapedPropName) } else { return h('div', { key: 'property', className: 'jsoneditor-property jsoneditor-readonly' + searchClassName, spellCheck: 'false' - }, prop) + }, isIndex ? index : escapedPropName) } } @@ -189,7 +193,7 @@ export default class JSONNode extends Component { return h('div', {key: 'separator', className: 'jsoneditor-separator'}, ':') } - renderValue (value, searchValue, options) { + renderValue (value, searchResult, options) { const escapedValue = escapeHTML(value, options.escapeUnicode) const type = valueType (value) const itsAnUrl = isUrl(value) @@ -200,7 +204,8 @@ export default class JSONNode extends Component { return h('div', { key: 'value', ref: 'value', - className: JSONNode.getValueClass(type, itsAnUrl, isEmpty, searchValue), + className: JSONNode.getValueClass(type, itsAnUrl, isEmpty) + + JSONNode.getSearchResultClass(searchResult), contentEditable: 'true', spellCheck: 'false', onBlur: this.handleChangeValue, @@ -283,7 +288,10 @@ export default class JSONNode extends Component { target = target.parentNode } - target.className = JSONNode.getValueClass(type, itsAnUrl, isEmpty) + console.log('value', this.props) + + target.className = JSONNode.getValueClass(type, itsAnUrl, isEmpty) + + JSONNode.getSearchResultClass(this.props.data.searchResult) target.title = itsAnUrl ? JSONNode.URL_TITLE : '' // remove all classNames from childs (needed for IE and Edge) @@ -295,18 +303,29 @@ export default class JSONNode extends Component { * @param {string} type * @param {boolean} isUrl * @param {boolean} isEmpty - * @param {'normal' | 'active'} [searchValue] * @return {string} * @public */ - static getValueClass (type, isUrl, isEmpty, searchValue) { + static getValueClass (type, isUrl, isEmpty) { return 'jsoneditor-value ' + 'jsoneditor-' + type + (isUrl ? ' jsoneditor-url' : '') + - (isEmpty ? ' jsoneditor-empty' : '') + - (searchValue === 'active' - ? ' jsoneditor-highlight-active' - : (searchValue ? ' jsoneditor-highlight' : '')) + (isEmpty ? ' jsoneditor-empty' : '') + } + + /** + * Get the css style given a search result type + */ + static getSearchResultClass (searchResultStatus: ?SearchResultStatus) { + if (searchResultStatus === 'active') { + return ' jsoneditor-highlight-active' + } + + if (searchResultStatus === 'normal') { + return ' jsoneditor-highlight' + } + + return '' } /** @@ -400,7 +419,7 @@ export default class JSONNode extends Component { /** @private */ handleChangeProperty = (event) => { const parentPath = this.props.parent.getPath() - const oldProp = this.props.prop + const oldProp = this.props.prop.name const newProp = unescapeHTML(getInnerText(event.target)) if (newProp !== oldProp) { @@ -525,8 +544,12 @@ export default class JSONNode extends Component { ? this.props.parent.getPath() : [] - if (this.props.prop !== null) { - path.push(this.props.prop) + if (typeof this.props.index === 'number') { + path.push(String(this.props.index)) + } + + if (this.props.prop) { + path.push(this.props.prop.name) } return path diff --git a/src/components/TreeMode.js b/src/components/TreeMode.js index 1ea4ff5..ed796ca 100644 --- a/src/components/TreeMode.js +++ b/src/components/TreeMode.js @@ -124,7 +124,7 @@ export default class TreeMode extends Component { // data = addFocus(data, searchResults[0]) // TODO: change to using focus from state } - console.log('data', data) + // console.log('data', data) return h('div', { className: `jsoneditor jsoneditor-mode-${props.mode}`, diff --git a/src/jsonData.js b/src/jsonData.js index bc71761..691c5b6 100644 --- a/src/jsonData.js +++ b/src/jsonData.js @@ -516,17 +516,18 @@ export function addErrors (data, errors) { export function search (data, text): SearchResult[] { let results: SearchResult[] = [] - traverse(data, function (value, path, root) { + traverse(data, function (value, path) { // check property name - const prop = last(path) - - if (typeof prop === 'string' && containsCaseInsensitive(prop, text)) { - // only add search result when this is an object property name, - // don't add search result for array indices - const parentPath = path.slice(0, path.length - 1) - const parent = getIn(root, toDataPath(data, parentPath)) - if (parent.type === 'Object') { - results.push({ dataPath: path, type: 'property' }) + if (path.length > 0) { + const prop = last(path) + if (containsCaseInsensitive(prop, text)) { + // only add search result when this is an object property name, + // don't add search result for array indices + const parentPath = allButLast(path) + const parent = getIn(data, toDataPath(data, parentPath)) + if (parent.type === 'Object') { + results.push({ dataPath: path, type: 'property' }) + } } } @@ -550,15 +551,16 @@ export function addSearchResults (data, searchResults: SearchResult[], activeSea if (searchResults) { searchResults.forEach(function (searchResult) { if (searchResult.type === 'value') { - const dataPath = toDataPath(data, searchResult.dataPath).concat('searchValue') + const dataPath = toDataPath(data, searchResult.dataPath).concat('searchResult') const value = isEqual(searchResult, activeSearchResult) ? 'active' : 'normal' updatedData = setIn(updatedData, dataPath, value) } if (searchResult.type === 'property') { - const dataPath = toDataPath(data, searchResult.dataPath).concat('searchProperty') + const valueDataPath = toDataPath(data, searchResult.dataPath) + const propertyDataPath = allButLast(valueDataPath).concat('searchResult') const value = isEqual(searchResult, activeSearchResult) ? 'active' : 'normal' - updatedData = setIn(updatedData, dataPath, value) + updatedData = setIn(updatedData, propertyDataPath, value) } }) } @@ -591,7 +593,7 @@ export function addFocus (data, focusOn) { * @param {String} search * @return {boolean} Returns true if `search` is found in `text` */ -export function containsCaseInsensitive (text, search) { +export function containsCaseInsensitive (text: string, search: string): boolean { return String(text).toLowerCase().indexOf(search.toLowerCase()) !== -1 } @@ -799,6 +801,13 @@ export function compileJSONPointer (path) { /** * Returns the last item of an array */ -function last (array: Array): any { +function last (array: []): any { return array[array.length - 1] -} \ No newline at end of file +} + +/** + * Returns a copy of the array having the last item removed + */ +function allButLast (array: []): any { + return array.slice(0, array.length - 1) +} diff --git a/src/typedef.js b/src/typedef.js deleted file mode 100644 index 2f48e98..0000000 --- a/src/typedef.js +++ /dev/null @@ -1,69 +0,0 @@ -/** - * @typedef {{ - * type: 'Array', - * expanded: boolean?, - * props: Array.<{name: string, value: JSONData}>? - * }} ObjectData - * - * @typedef {{ - * type: 'Object', - * expanded: boolean?, - * items: JSONData[]? - * }} ArrayData - * - * @typedef {{ - * type: 'value' | 'string', - * value: *? - * }} ValueData - * - * @typedef {Array.} Path - * - * @typedef {ObjectData | ArrayData | ValueData} JSONData - * - * @typedef {'Object' | 'Array' | 'value' | 'string'} JSONDataType - * - * @typedef {Array.<{op: string, path?: string, from?: string, value?: *}>} JSONPatch - * - * @typedef {{ - * patch: JSONPatch, - * revert: JSONPatch, - * error: null | Error - * }} JSONPatchResult - * - * @typedef {{ - * dataPath: string, - * message: string - * }} JSONSchemaError - * - * @typedef {{ - * name: string?, - * mode: 'code' | 'form' | 'text' | 'tree' | 'view'?, - * modes: string[]?, - * history: boolean?, - * indentation: number | string?, - * onChange: function (patch: JSONPatch, revert: JSONPatch)?, - * onChangeText: function ()?, - * onChangeMode: function (mode: string, prevMode: string)?, - * onError: function (err: Error)?, - * isPropertyEditable: function (Path)? - * isValueEditable: function (Path)?, - * escapeUnicode: boolean?, - * ajv: Object? - * ace: Object? - * }} Options - * - * @typedef {{ - * expand: function (path: Path)? - * }} SetOptions - * - * @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/src/types.js b/src/types.js index 98a64d6..2249968 100644 --- a/src/types.js +++ b/src/types.js @@ -1,29 +1,9 @@ // @flow /** - * @typedef {{ - * type: 'Array', - * expanded: boolean?, - * props: Array.<{name: string, value: JSONData}>? - * }} ObjectData - * - * @typedef {{ - * type: 'Object', - * expanded: boolean?, - * items: JSONData[]? - * }} ArrayData - * - * @typedef {{ - * type: 'value' | 'string', - * value: *? - * }} ValueData - * - * @typedef {Array.} Path - * - * @typedef {ObjectData | ArrayData | ValueData} JSONData * * @typedef {'Object' | 'Array' | 'value' | 'string'} JSONDataType - + * * @typedef {{ * patch: JSONPatch, * revert: JSONPatch, @@ -57,36 +37,63 @@ * 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 */ -type JSONType = | string | number | boolean | null | JSONObjectType | JSONArrayType; -type JSONObjectType = { [key:string]: JSON }; -type JSONArrayType = Array; + +/**************************** GENERIC JSON TYPES ******************************/ + +export type JSONType = | string | number | boolean | null | JSONObjectType | JSONArrayType; +export type JSONObjectType = { [key:string]: JSONType }; +export type JSONArrayType = Array; + + +/********************** TYPES FOR THE JSON DATA MODEL *************************/ + +export type SearchResultStatus = 'normal' | 'active' +export type SearchResultType = 'value' | 'property' + +export type PropertyData = { + name: string, + value: JSONData, + searchResult: ?SearchResultStatus +} + +export type ObjectData = { + type: 'Object', + expanded: ?boolean, + props: PropertyData[] +} + +export type ArrayData = { + type: 'Array', + expanded: ?boolean, + items: ?JSONData[] +} + +export type ValueData = { + type: 'value' | 'string', + value: ?any, + searchResult: ?SearchResultStatus +} + +export type JSONData = ObjectData | ArrayData | ValueData + + export type Path = string[] export type SearchResult = { dataPath: Path, - type: 'value' | 'property' + type: SearchResultType } +// TODO: SearchResult.dataPath is an array, JSONSchemaError.dataPath is a string -> make this consistent + +// TODO: remove SetOptions, merge into Options (everywhere in the public API) export type SetOptions = { expand?: (path: Path) => boolean } -export type JSONEditorMode = { - setSchema: (schema?: Object) => void, - set: (JSON) => void, - setText: (text: string) => void, - getText: () => string -} - export type JSONPatchAction = { op: string, // TODO: define allowed ops path?: string, diff --git a/test/jsonData.test.js b/test/jsonData.test.js index 268b6f1..3b51cf8 100644 --- a/test/jsonData.test.js +++ b/test/jsonData.test.js @@ -277,9 +277,9 @@ const JSON_DATA_EXAMPLE_SEARCH_L = { name: 'last', value: { type: 'value', - value: 4, - searchProperty: 'active' - } + value: 4 + }, + searchResult: 'active' } ] } @@ -294,7 +294,7 @@ const JSON_DATA_EXAMPLE_SEARCH_L = { value: { type: 'value', value: 'hello world', - searchValue: 'normal' + searchResult: 'normal' } }, { @@ -302,18 +302,18 @@ const JSON_DATA_EXAMPLE_SEARCH_L = { value: { type: 'value', value: null, - searchProperty: 'normal', - searchValue: 'normal' - } + searchResult: 'normal' + }, + searchResult: 'normal' }, { name: 'bool', value: { type: 'value', value: false, - searchProperty: 'normal', - searchValue: 'normal' - } + searchResult: 'normal' + }, + searchResult: 'normal' } ] } @@ -935,7 +935,7 @@ test('search', t => { const activeSearchResult = searchResults[0] const updatedData = addSearchResults(JSON_DATA_EXAMPLE, searchResults, activeSearchResult) - //console.log(JSON.stringify(updatedData, null, 2)) + // console.log(JSON.stringify(updatedData, null, 2)) t.deepEqual(updatedData, JSON_DATA_EXAMPLE_SEARCH_L) })