Restructured search data model
This commit is contained in:
parent
198e8edf85
commit
100efb35ae
12
README.md
12
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
|
||||
```
|
||||
|
|
10
package.json
10
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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}`,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
*/
|
83
src/types.js
83
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.<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,
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
|
Loading…
Reference in New Issue