Restructured search data model

This commit is contained in:
jos 2016-12-30 13:45:43 +01:00
parent 198e8edf85
commit 100efb35ae
8 changed files with 156 additions and 166 deletions

View File

@ -152,3 +152,15 @@ jsoneditor:
This will update `./dist/jsoneditor.js` on every change in the source code, This will update `./dist/jsoneditor.js` on every change in the source code,
but it will **NOT** update the minimalist version. 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
```

View File

@ -21,7 +21,8 @@
"start": "gulp watch", "start": "gulp watch",
"build": "gulp", "build": "gulp",
"flow": "flow; test $? -eq 0 -o $? -eq 2", "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": { "dependencies": {
"ajv": "4.9.2", "ajv": "4.9.2",
@ -55,9 +56,16 @@
"webpack": "1.14.0" "webpack": "1.14.0"
}, },
"ava": { "ava": {
"files": [
"test/**/*.test.js"
],
"source": [
"./src/**/*"
],
"require": [ "require": [
"babel-register" "babel-register"
], ],
"concurrency": 4,
"babel": "inherit" "babel": "inherit"
} }
} }

View File

@ -8,6 +8,10 @@ import { escapeHTML, unescapeHTML } from '../utils/stringUtils'
import { getInnerText, insideRect } from '../utils/domUtils' import { getInnerText, insideRect } from '../utils/domUtils'
import { stringConvert, valueType, isUrl } from '../utils/typeUtils' 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 * @type {JSONNode | null} activeContextMenu singleton holding the JSONNode having
* the active (visible) context menu * 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 childCount = data.props.length
const contents = [ const contents = [
h('div', {key: 'node', className: 'jsoneditor-node jsoneditor-object'}, [ h('div', {key: 'node', className: 'jsoneditor-node jsoneditor-object'}, [
this.renderExpandButton(), this.renderExpandButton(),
this.renderActionMenuButton(), this.renderActionMenuButton(),
this.renderProperty(prop, data, options), this.renderProperty(prop, index, data, options),
this.renderReadonly(`{${childCount}}`, `Array containing ${childCount} items`), this.renderReadonly(`{${childCount}}`, `Array containing ${childCount} items`),
this.renderError(data.error) this.renderError(data.error)
]) ])
@ -54,7 +58,7 @@ export default class JSONNode extends Component {
return h(this.constructor, { return h(this.constructor, {
key: prop.name, key: prop.name,
parent: this, parent: this,
prop: prop.name, prop: prop,
data: prop.value, data: prop.value,
options, options,
events events
@ -73,13 +77,13 @@ export default class JSONNode extends Component {
return h('li', {}, contents) return h('li', {}, contents)
} }
renderJSONArray ({prop, data, options, events}) { renderJSONArray ({prop, index, data, options, events}) {
const childCount = data.items.length const childCount = data.items.length
const contents = [ const contents = [
h('div', {key: 'node', className: 'jsoneditor-node jsoneditor-array'}, [ h('div', {key: 'node', className: 'jsoneditor-node jsoneditor-array'}, [
this.renderExpandButton(), this.renderExpandButton(),
this.renderActionMenuButton(), this.renderActionMenuButton(),
this.renderProperty(prop, data, options), this.renderProperty(prop, index, data, options),
this.renderReadonly(`[${childCount}]`, `Array containing ${childCount} items`), this.renderReadonly(`[${childCount}]`, `Array containing ${childCount} items`),
this.renderError(data.error) this.renderError(data.error)
]) ])
@ -91,7 +95,7 @@ export default class JSONNode extends Component {
return h(this.constructor, { return h(this.constructor, {
key: index, key: index,
parent: this, parent: this,
prop: index, index,
data: child, data: child,
options, options,
events events
@ -109,14 +113,14 @@ export default class JSONNode extends Component {
return h('li', {}, contents) return h('li', {}, contents)
} }
renderJSONValue ({prop, data, options}) { renderJSONValue ({prop, index, data, options}) {
return h('li', {}, return h('li', {},
h('div', {className: 'jsoneditor-node'}, [ h('div', {className: 'jsoneditor-node'}, [
this.renderPlaceholder(), this.renderPlaceholder(),
this.renderActionMenuButton(), this.renderActionMenuButton(),
this.renderProperty(prop, data, options), this.renderProperty(prop, index, data, options),
this.renderSeparator(), this.renderSeparator(),
this.renderValue(data.value, data.searchValue, options), this.renderValue(data.value, data.searchResult, options),
this.renderError(data.error) this.renderError(data.error)
]) ])
) )
@ -145,8 +149,10 @@ export default class JSONNode extends Component {
return h('div', {key: 'readonly', className: 'jsoneditor-readonly', title}, text) return h('div', {key: 'readonly', className: 'jsoneditor-readonly', title}, text)
} }
renderProperty (prop, data, options) { renderProperty (prop: ?PropertyData, index: ?number, data: JSONData, options) {
if (prop === null) { const isIndex = typeof index === 'number'
if (!prop && !isIndex) {
// root node // root node
const rootName = JSONNode.getRootName(data, options) const rootName = JSONNode.getRootName(data, options)
@ -159,29 +165,27 @@ export default class JSONNode extends Component {
}, rootName) }, rootName)
} }
const isIndex = typeof prop === 'number' // FIXME: pass an explicit prop isIndex or editable
const editable = !isIndex && (!options.isPropertyEditable || options.isPropertyEditable(this.getPath())) const editable = !isIndex && (!options.isPropertyEditable || options.isPropertyEditable(this.getPath()))
const emptyClassName = (prop.length === 0 ? ' jsoneditor-empty' : '') const emptyClassName = (prop && prop.name.length === 0) ? ' jsoneditor-empty' : ''
const searchClassName = data.searchProperty ? ' jsoneditor-highlight': ''; const searchClassName = prop ? JSONNode.getSearchResultClass(prop.searchResult) : ''
const escapedPropName = prop ? escapeHTML(prop.name, options.escapeUnicode) : null
if (editable) { if (editable) {
const escapedProp = escapeHTML(prop, options.escapeUnicode)
return h('div', { return h('div', {
key: 'property', key: 'property',
className: 'jsoneditor-property' + emptyClassName + searchClassName, className: 'jsoneditor-property' + emptyClassName + searchClassName,
contentEditable: 'true', contentEditable: 'true',
spellCheck: 'false', spellCheck: 'false',
onBlur: this.handleChangeProperty onBlur: this.handleChangeProperty
}, escapedProp) }, escapedPropName)
} }
else { else {
return h('div', { return h('div', {
key: 'property', key: 'property',
className: 'jsoneditor-property jsoneditor-readonly' + searchClassName, className: 'jsoneditor-property jsoneditor-readonly' + searchClassName,
spellCheck: 'false' spellCheck: 'false'
}, prop) }, isIndex ? index : escapedPropName)
} }
} }
@ -189,7 +193,7 @@ export default class JSONNode extends Component {
return h('div', {key: 'separator', className: 'jsoneditor-separator'}, ':') return h('div', {key: 'separator', className: 'jsoneditor-separator'}, ':')
} }
renderValue (value, searchValue, options) { renderValue (value, searchResult, options) {
const escapedValue = escapeHTML(value, options.escapeUnicode) const escapedValue = escapeHTML(value, options.escapeUnicode)
const type = valueType (value) const type = valueType (value)
const itsAnUrl = isUrl(value) const itsAnUrl = isUrl(value)
@ -200,7 +204,8 @@ export default class JSONNode extends Component {
return h('div', { return h('div', {
key: 'value', key: 'value',
ref: 'value', ref: 'value',
className: JSONNode.getValueClass(type, itsAnUrl, isEmpty, searchValue), className: JSONNode.getValueClass(type, itsAnUrl, isEmpty) +
JSONNode.getSearchResultClass(searchResult),
contentEditable: 'true', contentEditable: 'true',
spellCheck: 'false', spellCheck: 'false',
onBlur: this.handleChangeValue, onBlur: this.handleChangeValue,
@ -283,7 +288,10 @@ export default class JSONNode extends Component {
target = target.parentNode 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 : '' target.title = itsAnUrl ? JSONNode.URL_TITLE : ''
// remove all classNames from childs (needed for IE and Edge) // remove all classNames from childs (needed for IE and Edge)
@ -295,18 +303,29 @@ export default class JSONNode extends Component {
* @param {string} type * @param {string} type
* @param {boolean} isUrl * @param {boolean} isUrl
* @param {boolean} isEmpty * @param {boolean} isEmpty
* @param {'normal' | 'active'} [searchValue]
* @return {string} * @return {string}
* @public * @public
*/ */
static getValueClass (type, isUrl, isEmpty, searchValue) { static getValueClass (type, isUrl, isEmpty) {
return 'jsoneditor-value ' + return 'jsoneditor-value ' +
'jsoneditor-' + type + 'jsoneditor-' + type +
(isUrl ? ' jsoneditor-url' : '') + (isUrl ? ' jsoneditor-url' : '') +
(isEmpty ? ' jsoneditor-empty' : '') + (isEmpty ? ' jsoneditor-empty' : '')
(searchValue === 'active' }
? ' jsoneditor-highlight-active'
: (searchValue ? ' jsoneditor-highlight' : '')) /**
* 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 */ /** @private */
handleChangeProperty = (event) => { handleChangeProperty = (event) => {
const parentPath = this.props.parent.getPath() const parentPath = this.props.parent.getPath()
const oldProp = this.props.prop const oldProp = this.props.prop.name
const newProp = unescapeHTML(getInnerText(event.target)) const newProp = unescapeHTML(getInnerText(event.target))
if (newProp !== oldProp) { if (newProp !== oldProp) {
@ -525,8 +544,12 @@ export default class JSONNode extends Component {
? this.props.parent.getPath() ? this.props.parent.getPath()
: [] : []
if (this.props.prop !== null) { if (typeof this.props.index === 'number') {
path.push(this.props.prop) path.push(String(this.props.index))
}
if (this.props.prop) {
path.push(this.props.prop.name)
} }
return path return path

View File

@ -124,7 +124,7 @@ export default class TreeMode extends Component {
// data = addFocus(data, searchResults[0]) // TODO: change to using focus from state // data = addFocus(data, searchResults[0]) // TODO: change to using focus from state
} }
console.log('data', data) // console.log('data', data)
return h('div', { return h('div', {
className: `jsoneditor jsoneditor-mode-${props.mode}`, className: `jsoneditor jsoneditor-mode-${props.mode}`,

View File

@ -516,17 +516,18 @@ export function addErrors (data, errors) {
export function search (data, text): SearchResult[] { export function search (data, text): SearchResult[] {
let results: SearchResult[] = [] let results: SearchResult[] = []
traverse(data, function (value, path, root) { traverse(data, function (value, path) {
// check property name // check property name
const prop = last(path) if (path.length > 0) {
const prop = last(path)
if (typeof prop === 'string' && containsCaseInsensitive(prop, text)) { if (containsCaseInsensitive(prop, text)) {
// only add search result when this is an object property name, // only add search result when this is an object property name,
// don't add search result for array indices // don't add search result for array indices
const parentPath = path.slice(0, path.length - 1) const parentPath = allButLast(path)
const parent = getIn(root, toDataPath(data, parentPath)) const parent = getIn(data, toDataPath(data, parentPath))
if (parent.type === 'Object') { if (parent.type === 'Object') {
results.push({ dataPath: path, type: 'property' }) results.push({ dataPath: path, type: 'property' })
}
} }
} }
@ -550,15 +551,16 @@ export function addSearchResults (data, searchResults: SearchResult[], activeSea
if (searchResults) { if (searchResults) {
searchResults.forEach(function (searchResult) { searchResults.forEach(function (searchResult) {
if (searchResult.type === 'value') { 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' const value = isEqual(searchResult, activeSearchResult) ? 'active' : 'normal'
updatedData = setIn(updatedData, dataPath, value) updatedData = setIn(updatedData, dataPath, value)
} }
if (searchResult.type === 'property') { 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' 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 * @param {String} search
* @return {boolean} Returns true if `search` is found in `text` * @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 return String(text).toLowerCase().indexOf(search.toLowerCase()) !== -1
} }
@ -799,6 +801,13 @@ export function compileJSONPointer (path) {
/** /**
* Returns the last item of an array * Returns the last item of an array
*/ */
function last (array: Array): any { function last (array: []): any {
return array[array.length - 1] return array[array.length - 1]
} }
/**
* Returns a copy of the array having the last item removed
*/
function allButLast (array: []): any {
return array.slice(0, array.length - 1)
}

View File

@ -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.<string>} 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
*/

View File

@ -1,29 +1,9 @@
// @flow // @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.<string>} Path
*
* @typedef {ObjectData | ArrayData | ValueData} JSONData
* *
* @typedef {'Object' | 'Array' | 'value' | 'string'} JSONDataType * @typedef {'Object' | 'Array' | 'value' | 'string'} JSONDataType
*
* @typedef {{ * @typedef {{
* patch: JSONPatch, * patch: JSONPatch,
* revert: JSONPatch, * revert: JSONPatch,
@ -57,36 +37,63 @@
* expand: function (path: Path)? * expand: function (path: Path)?
* }} PatchOptions * }} 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 }; /**************************** GENERIC JSON TYPES ******************************/
type JSONArrayType = Array<JSON>;
export type JSONType = | string | number | boolean | null | JSONObjectType | JSONArrayType;
export type JSONObjectType = { [key:string]: JSONType };
export type JSONArrayType = Array<JSONType>;
/********************** 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 Path = string[]
export type SearchResult = { export type SearchResult = {
dataPath: Path, 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 = { export type SetOptions = {
expand?: (path: Path) => boolean expand?: (path: Path) => boolean
} }
export type JSONEditorMode = {
setSchema: (schema?: Object) => void,
set: (JSON) => void,
setText: (text: string) => void,
getText: () => string
}
export type JSONPatchAction = { export type JSONPatchAction = {
op: string, // TODO: define allowed ops op: string, // TODO: define allowed ops
path?: string, path?: string,

View File

@ -277,9 +277,9 @@ const JSON_DATA_EXAMPLE_SEARCH_L = {
name: 'last', name: 'last',
value: { value: {
type: 'value', type: 'value',
value: 4, value: 4
searchProperty: 'active' },
} searchResult: 'active'
} }
] ]
} }
@ -294,7 +294,7 @@ const JSON_DATA_EXAMPLE_SEARCH_L = {
value: { value: {
type: 'value', type: 'value',
value: 'hello world', value: 'hello world',
searchValue: 'normal' searchResult: 'normal'
} }
}, },
{ {
@ -302,18 +302,18 @@ const JSON_DATA_EXAMPLE_SEARCH_L = {
value: { value: {
type: 'value', type: 'value',
value: null, value: null,
searchProperty: 'normal', searchResult: 'normal'
searchValue: 'normal' },
} searchResult: 'normal'
}, },
{ {
name: 'bool', name: 'bool',
value: { value: {
type: 'value', type: 'value',
value: false, value: false,
searchProperty: 'normal', searchResult: 'normal'
searchValue: 'normal' },
} searchResult: 'normal'
} }
] ]
} }
@ -935,7 +935,7 @@ test('search', t => {
const activeSearchResult = searchResults[0] const activeSearchResult = searchResults[0]
const updatedData = addSearchResults(JSON_DATA_EXAMPLE, searchResults, activeSearchResult) 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) t.deepEqual(updatedData, JSON_DATA_EXAMPLE_SEARCH_L)
}) })