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,
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",
"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"
}
}

View File

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

View File

@ -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}`,

View File

@ -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]
}
}
/**
* 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
/**
* @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 {{
* 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<JSON>;
/**************************** GENERIC JSON TYPES ******************************/
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 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,

View File

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