From c19334894cd950076ed213160257e620de668b4b Mon Sep 17 00:00:00 2001 From: jos Date: Wed, 29 Nov 2017 21:52:18 +0100 Subject: [PATCH] New ESON model (WIP) --- package-lock.json | 69 ++++++++++++ package.json | 1 + src/components/JSONNode.js | 168 ++++++++++++---------------- src/components/TreeMode.js | 79 +++++++------ src/eson.js | 186 ++++++++++++++++++------------- src/patchEson.js | 4 +- src/types.js | 36 ++++-- src/utils/immutabilityHelpers.js | 32 +++++- src/utils/typeUtils.js | 16 ++- test/eson.test.js | 26 +++++ test/immutabilityHelpers.test.js | 24 +++- 11 files changed, 414 insertions(+), 227 deletions(-) diff --git a/package-lock.json b/package-lock.json index b71f504..692e87e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2130,6 +2130,15 @@ "array-find-index": "1.0.2" } }, + "d": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.0.tgz", + "integrity": "sha1-dUu1v+VUUdpppYuU1F9MWwRi1Y8=", + "dev": true, + "requires": { + "es5-ext": "0.10.37" + } + }, "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -2200,6 +2209,17 @@ "integrity": "sha1-SLaZwn4zS/ifEIkr5DL25MfTSn8=", "dev": true }, + "deep-map": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/deep-map/-/deep-map-1.5.0.tgz", + "integrity": "sha1-6qWVy4F4PKKADyakLgnxbn1PuJA=", + "dev": true, + "requires": { + "es6-weak-map": "2.0.2", + "lodash": "4.17.4", + "tslib": "1.8.0" + } + }, "defaults": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", @@ -2547,6 +2567,49 @@ "is-arrayish": "0.2.1" } }, + "es5-ext": { + "version": "0.10.37", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.37.tgz", + "integrity": "sha1-DudB0Ui4AGm6J9AgOTdWryV978M=", + "dev": true, + "requires": { + "es6-iterator": "2.0.3", + "es6-symbol": "3.1.1" + } + }, + "es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c=", + "dev": true, + "requires": { + "d": "1.0.0", + "es5-ext": "0.10.37", + "es6-symbol": "3.1.1" + } + }, + "es6-symbol": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.1.tgz", + "integrity": "sha1-vwDvT9q2uhtG7Le2KbTH7VcVzHc=", + "dev": true, + "requires": { + "d": "1.0.0", + "es5-ext": "0.10.37" + } + }, + "es6-weak-map": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.2.tgz", + "integrity": "sha1-XjqzIlH/0VOKH45f+hNXdy+S2W8=", + "dev": true, + "requires": { + "d": "1.0.0", + "es5-ext": "0.10.37", + "es6-iterator": "2.0.3", + "es6-symbol": "3.1.1" + } + }, "escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -7372,6 +7435,12 @@ "integrity": "sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=", "dev": true }, + "tslib": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.8.0.tgz", + "integrity": "sha512-ymKWWZJST0/CkgduC2qkzjMOWr4bouhuURNXCn/inEX0L57BnRG6FhX76o7FOnsjHazCjfU2LKeSrlS2sIKQJg==", + "dev": true + }, "tty-browserify": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz", diff --git a/package.json b/package.json index 0dbed9f..1f25394 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "babel-preset-stage-3": "6.17.0", "browser-sync": "2.18.6", "css-loader": "0.26.1", + "deep-map": "1.5.0", "flow-bin": "0.37.4", "graceful-fs": "4.1.11", "gulp": "3.9.1", diff --git a/src/components/JSONNode.js b/src/components/JSONNode.js index 46941a0..399a528 100644 --- a/src/components/JSONNode.js +++ b/src/components/JSONNode.js @@ -8,7 +8,7 @@ import FloatingMenu from './menu/FloatingMenu' import { escapeHTML, unescapeHTML } from '../utils/stringUtils' import { getInnerText, insideRect, findParentWithAttribute } from '../utils/domUtils' import { stringConvert, valueType, isUrl } from '../utils/typeUtils' -import { compileJSONPointer, SELECTED, SELECTED_END, SELECTED_AFTER, SELECTED_BEFORE } from '../eson' +import { compileJSONPointer, mapEsonArray, SELECTED, SELECTED_END, SELECTED_AFTER, SELECTED_BEFORE } from '../eson' import type { ESONObjectProperty, ESON, SearchResultStatus, Path } from '../types' @@ -36,12 +36,6 @@ export default class JSONNode extends PureComponent { hover: false } - constructor (props) { - super(props) - - this.path = this.getPath(props) - } - componentWillMount (props) { } @@ -51,26 +45,20 @@ export default class JSONNode extends PureComponent { } } - componentWillReceiveProps (nextProps) { - this.path = this.getPath(nextProps) - } - render () { - const { props } = this - - if (props.data.type === 'Array') { - return this.renderJSONArray(props) + if (this.props.eson._meta.type === 'Object') { + return this.renderJSONObject(this.props) } - else if (props.data.type === 'Object') { - return this.renderJSONObject(props) + else if (this.props.eson._meta.type === 'Array') { + return this.renderJSONArray(this.props) } - else { - return this.renderJSONValue(props) + else { // no Object or Array + return this.renderJSONValue(this.props) } } - renderJSONObject ({prop, index, data, options, events}) { - const childCount = data.props.length + renderJSONObject ({prop, index, eson, options, events}) { + const keys = eson._meta.keys const node = h('div', { key: 'node', onKeyDown: this.handleKeyDown, @@ -79,20 +67,20 @@ export default class JSONNode extends PureComponent { this.renderExpandButton(), // this.renderActionMenu('update', this.state.menu, this.handleCloseActionMenu), // this.renderActionMenuButton(), - this.renderProperty(prop, index, data, options), - this.renderReadonly(`{${childCount}}`, `Array containing ${childCount} items`), + this.renderProperty(prop, index, eson, options), + this.renderReadonly(`{${keys.length}}`, `Array containing ${keys.length} items`), // this.renderFloatingMenuButton(), - this.renderError(data.error) + this.renderError(eson._meta.error) // FIXME: render error ]) let childs - if (data.expanded) { - if (data.props.length > 0) { - const props = data.props.map(prop => h(this.constructor, { - key: prop.id, - parent: this, - prop, - data: prop.value, + if (eson._meta.expanded) { + if (keys.length > 0) { + const props = keys.map(key => h(this.constructor, { + key: eson[key]._meta.id, + // parent: this, + prop: key, + eson: eson[key], options, events })) @@ -106,7 +94,7 @@ export default class JSONNode extends PureComponent { } } - const floatingMenu = (data.selected === SELECTED_END) + const floatingMenu = (eson._meta.selected === SELECTED_END) ? this.renderFloatingMenu([ {type: 'sort'}, {type: 'duplicate'}, @@ -120,16 +108,14 @@ export default class JSONNode extends PureComponent { const insertArea = this.renderInsertBeforeArea() return h('div', { - 'data-path': compileJSONPointer(this.path), - className: this.getContainerClassName(data.selected, this.state.hover), + 'data-path': compileJSONPointer(this.props.eson._meta.path), + className: this.getContainerClassName(eson._meta.selected, this.state.hover), onMouseOver: this.handleMouseOver, onMouseLeave: this.handleMouseLeave }, [node, floatingMenu, insertArea, childs]) } - // TODO: extract a function renderChilds shared by both renderJSONObject and renderJSONArray (rename .props and .items to .childs?) - renderJSONArray ({prop, index, data, options, events}) { - const childCount = data.items.length + renderJSONArray ({prop, index, eson, options, events}) { const node = h('div', { key: 'node', onKeyDown: this.handleKeyDown, @@ -138,20 +124,20 @@ export default class JSONNode extends PureComponent { this.renderExpandButton(), // this.renderActionMenu('update', this.state.menu, this.handleCloseActionMenu), // this.renderActionMenuButton(), - this.renderProperty(prop, index, data, options), - this.renderReadonly(`[${childCount}]`, `Array containing ${childCount} items`), + this.renderProperty(prop, index, eson, options), + this.renderReadonly(`[${eson._meta.length}]`, `Array containing ${eson._meta.length} items`), // this.renderFloatingMenuButton(), - this.renderError(data.error) + this.renderError(eson._meta.error) ]) let childs - if (data.expanded) { - if (data.items.length > 0) { - const items = data.items.map((item, index) => h(this.constructor, { - key : item.id, - parent: this, + if (eson._meta.expanded) { + if (eson._meta.length > 0) { + const items = mapEsonArray(eson, (item, index) => h(this.constructor, { + key : item._meta.id, + // parent: this, index, - data: item.value, + eson: item, options, events })) @@ -165,7 +151,7 @@ export default class JSONNode extends PureComponent { } } - const floatingMenu = (data.selected === SELECTED_END) + const floatingMenu = (eson._meta.selected === SELECTED_END) ? this.renderFloatingMenu([ {type: 'sort'}, {type: 'duplicate'}, @@ -179,14 +165,14 @@ export default class JSONNode extends PureComponent { const insertArea = this.renderInsertBeforeArea() return h('div', { - 'data-path': compileJSONPointer(this.path), - className: this.getContainerClassName(data.selected, this.state.hover), + 'data-path': compileJSONPointer(this.props.eson._meta.path), + className: this.getContainerClassName(eson._meta.selected, this.state.hover), onMouseOver: this.handleMouseOver, onMouseLeave: this.handleMouseLeave }, [node, floatingMenu, insertArea, childs]) } - renderJSONValue ({prop, index, data, options}) { + renderJSONValue ({prop, index, eson, options}) { const node = h('div', { key: 'node', onKeyDown: this.handleKeyDown, @@ -195,14 +181,14 @@ export default class JSONNode extends PureComponent { this.renderPlaceholder(), // this.renderActionMenu('update', this.state.menu, this.handleCloseActionMenu), // this.renderActionMenuButton(), - this.renderProperty(prop, index, data, options), + this.renderProperty(prop, index, eson, options), this.renderSeparator(), - this.renderValue(data.value, data.searchResult, options), + this.renderValue(eson._meta.value, eson._meta.searchResult, options), // this.renderFloatingMenuButton(), - this.renderError(data.error) + this.renderError(eson._meta.error) ]) - const floatingMenu = (data.selected === SELECTED_END) + const floatingMenu = (eson._meta.selected === SELECTED_END) ? this.renderFloatingMenu([ // {text: 'String', onClick: this.props.events.onChangeType, type: 'checkbox', checked: false}, {type: 'duplicate'}, @@ -216,15 +202,15 @@ export default class JSONNode extends PureComponent { const insertArea = this.renderInsertBeforeArea() return h('div', { - 'data-path': compileJSONPointer(this.path), - className: this.getContainerClassName(data.selected, this.state.hover), + 'data-path': compileJSONPointer(this.props.eson._meta.path), + className: this.getContainerClassName(eson._meta.selected, this.state.hover), onMouseOver: this.handleMouseOver, onMouseLeave: this.handleMouseLeave }, [node, floatingMenu, insertArea]) } renderInsertBeforeArea () { - const floatingMenu = (this.props.data.selected === SELECTED_BEFORE) + const floatingMenu = (this.props.eson._meta.selected === SELECTED_BEFORE) ? this.renderFloatingMenu([ {type: 'insertStructure'}, {type: 'insertValue'}, @@ -248,7 +234,7 @@ export default class JSONNode extends PureComponent { */ renderAppend (text) { return h('div', { - 'data-path': compileJSONPointer(this.path) + '/-', + 'data-path': compileJSONPointer(this.props.eson._meta.path) + '/-', className: 'jsoneditor-node', onKeyDown: this.handleKeyDownAppend }, [ @@ -268,12 +254,12 @@ export default class JSONNode extends PureComponent { } // TODO: simplify the method renderProperty - renderProperty (prop?: ESONObjectProperty, index?: number, data: ESON, options: {escapeUnicode: boolean, isPropertyEditable: (Path) => boolean}) { + renderProperty (prop?: ESONObjectProperty, index?: number, eson: ESON, options: {escapeUnicode: boolean, isPropertyEditable: (Path) => boolean}) { const isIndex = typeof index === 'number' if (!prop && !isIndex) { // root node - const rootName = JSONNode.getRootName(data, options) + const rootName = JSONNode.getRootName(eson, options) return h('div', { key: 'property', @@ -283,11 +269,11 @@ export default class JSONNode extends PureComponent { }, rootName) } - const editable = !isIndex && (!options.isPropertyEditable || options.isPropertyEditable(this.path)) + const editable = !isIndex && (!options.isPropertyEditable || options.isPropertyEditable(this.props.eson._meta.path)) - 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 + const emptyClassName = (prop != null && prop.length === 0) ? ' jsoneditor-empty' : '' + const searchClassName = prop != null ? JSONNode.getSearchResultClass(prop.searchResult) : '' + const escapedPropName = prop != null ? escapeHTML(prop, options.escapeUnicode) : null if (editable) { return h('div', { @@ -317,7 +303,7 @@ export default class JSONNode extends PureComponent { const itsAnUrl = isUrl(value) const isEmpty = escapedValue.length === 0 - const editable = !options.isValueEditable || options.isValueEditable(this.path) + const editable = !options.isValueEditable || options.isValueEditable(this.props.eson._meta.path) if (editable) { return h('div', { key: 'value', @@ -409,7 +395,7 @@ export default class JSONNode extends PureComponent { } target.className = JSONNode.getValueClass(type, itsAnUrl, isEmpty) + - JSONNode.getSearchResultClass(this.props.data.searchResult) + JSONNode.getSearchResultClass(this.props.eson._meta.searchResult) target.title = itsAnUrl ? JSONNode.URL_TITLE : '' // remove all classNames from childs (needed for IE and Edge) @@ -462,7 +448,7 @@ export default class JSONNode extends PureComponent { } renderExpandButton () { - const className = `jsoneditor-button jsoneditor-${this.props.data.expanded ? 'expanded' : 'collapsed'}` + const className = `jsoneditor-button jsoneditor-${this.props.eson._meta.expanded ? 'expanded' : 'collapsed'}` return h('div', {key: 'expand', className: 'jsoneditor-button-container'}, h('button', { @@ -483,9 +469,9 @@ export default class JSONNode extends PureComponent { return h(ActionMenu, { key: 'menu', - path: this.path, + path: this.props.eson._meta.path, events: this.props.events, - type: this.props.data.type, + type: this.props.eson._meta.type, // TODO: fix type menuType, open: true, @@ -527,7 +513,7 @@ export default class JSONNode extends PureComponent { renderFloatingMenu (items) { return h(FloatingMenu, { key: 'floating-menu', - path: this.path, + path: this.props.eson._meta.path, events: this.props.events, items }) @@ -609,17 +595,15 @@ export default class JSONNode extends PureComponent { this.setState({ appendMenu: null }) } - static getRootName (data, options) { + static getRootName (eson, options) { return typeof options.name === 'string' ? options.name - : (data.type === 'Object' || data.type === 'Array') - ? data.type - : valueType(data.value) + : valueType(eson) } /** @private */ handleChangeProperty = (event) => { - const parentPath = initial(this.path) + const parentPath = initial(this.props.eson._meta.path) const oldProp = this.props.prop.name const newProp = unescapeHTML(getInnerText(event.target)) @@ -632,8 +616,8 @@ export default class JSONNode extends PureComponent { handleChangeValue = (event) => { const value = this.getValueFromEvent(event) - if (value !== this.props.data.value) { - this.props.events.onChangeValue(this.path, value) + if (value !== this.props.eson._meta.value) { + this.props.events.onChangeValue(this.props.eson._meta.path, value) } } @@ -650,24 +634,24 @@ export default class JSONNode extends PureComponent { if (keyBinding === 'duplicate') { event.preventDefault() - this.props.events.onDuplicate(this.path) + this.props.events.onDuplicate(this.props.eson._meta.path) } if (keyBinding === 'insert') { event.preventDefault() - this.props.events.onInsert(this.path, 'value') + this.props.events.onInsert(this.props.eson._meta.path, 'value') } if (keyBinding === 'remove') { event.preventDefault() - this.props.events.onRemove(this.path) + this.props.events.onRemove(this.props.eson._meta.path) } if (keyBinding === 'expand') { event.preventDefault() const recurse = false - const expanded = !this.props.data.expanded - this.props.events.onExpand(this.path, expanded, recurse) + const expanded = !this.props.eson._meta.expanded + this.props.events.onExpand(this.props.eson._meta.path, expanded, recurse) } if (keyBinding === 'actionMenu') { @@ -682,7 +666,7 @@ export default class JSONNode extends PureComponent { if (keyBinding === 'insert') { event.preventDefault() - this.props.events.onAppend(this.path, 'value') + this.props.events.onAppend(this.props.eson._meta.path, 'value') } if (keyBinding === 'actionMenu') { @@ -703,9 +687,10 @@ export default class JSONNode extends PureComponent { /** @private */ handleExpand = (event) => { const recurse = event.ctrlKey - const expanded = !this.props.data.expanded + const path = this.props.eson._meta.path + const expanded = !this.props.eson._meta.expanded - this.props.events.onExpand(this.path, expanded, recurse) + this.props.events.onExpand(path, expanded, recurse) } /** @@ -724,17 +709,6 @@ export default class JSONNode extends PureComponent { } } - // FIXME: this construction with passing parents to determine the path is not very nice. Move determining of the path to the ESON model. We cannot generate the path whilst rendering, that defeats the efficiency of PureComponent - getPath (props = this.props) { - const parentPath = props.parent ? props.parent.path : [] - - return props.prop - ? parentPath.concat(props.prop.name) - : typeof props.index !== 'undefined' - ? parentPath.concat(props.index) - : parentPath - } - /** * Get the value of the target of an event, and convert it to it's type * @param event @@ -743,7 +717,7 @@ export default class JSONNode extends PureComponent { */ getValueFromEvent (event) { const stringValue = unescapeHTML(getInnerText(event.target)) - return this.props.data.type === 'string' + return this.props.eson._meta.type === 'string' ? stringValue : stringConvert(stringValue) } diff --git a/src/components/TreeMode.js b/src/components/TreeMode.js index 9e268f7..0d8b23c 100644 --- a/src/components/TreeMode.js +++ b/src/components/TreeMode.js @@ -8,11 +8,11 @@ import Hammer from 'react-hammerjs' import jump from '../assets/jump.js/src/jump' import Ajv from 'ajv' -import { setIn } from '../utils/immutabilityHelpers' +import { setIn, updateIn } from '../utils/immutabilityHelpers' import { parseJSON } from '../utils/jsonUtils' import { enrichSchemaError } from '../utils/schemaUtils' import { - jsonToEson, esonToJson, getInEson, updateInEson, pathExists, + toEson2, jsonToEson, esonToJson, getInEson, updateInEson, pathExists, expand, expandPath, addErrors, search, applySearchResults, nextSearchResult, previousSearchResult, applySelection, pathsFromSelection, contentsFromPaths, @@ -20,7 +20,7 @@ import { } from '../eson' import { patchEson } from '../patchEson' import { - duplicate, insert, insertBefore, append, remove, removeAll, replace, + duplicate, insertBefore, append, remove, removeAll, replace, createEntry, changeType, changeValue, changeProperty, sort } from '../actions' import JSONNode from './JSONNode' @@ -56,7 +56,9 @@ export default class TreeMode extends Component { constructor (props) { super(props) - const data = jsonToEson(this.props.data || {}, TreeMode.expandAll, []) + const json = this.props.json || {} + const expandCallback = this.props.expand || TreeMode.expandRoot + const eson = expand(toEson2(json), expandCallback) this.id = Math.round(Math.random() * 1e5) // TODO: create a uuid here? @@ -79,9 +81,10 @@ export default class TreeMode extends Component { } this.state = { - data, + json, + eson, - history: [data], + history: [eson], historyIndex: 0, events: { @@ -148,11 +151,17 @@ export default class TreeMode extends Component { // Apply json if (nextProps.json !== currentProps.json) { - this.patch([{ - op: 'replace', - path: '', - value: nextProps.json - }]) + // FIXME: merge _meta from existing eson + this.setState({ + json: nextProps.json, + eson: toEson2(nextProps.json) // FIXME: how to handle expand? + }) + // TODO: cleanup + // this.patch([{ + // op: 'replace', + // path: '', + // value: nextProps.json + // }]) } // Apply JSON Schema @@ -172,7 +181,7 @@ export default class TreeMode extends Component { // TODO: apply patch } - render () { + render() { const { props, state } = this const Node = (props.mode === 'view') @@ -182,21 +191,23 @@ export default class TreeMode extends Component { : JSONNode // enrich the data with JSON Schema errors - let data = state.data - const errors = this.getErrors() - if (errors.length) { - data = addErrors(data, this.getErrors()) - } + let eson = state.eson + // TODO: reimplement errors + // const errors = this.getErrors() + // if (errors.length) { + // data = addErrors(data, this.getErrors()) + // } // enrich the data with search results - // TODO: performance improvements in search would be nice though it's acceptable right now - const searchResults = this.state.search.text ? search(data, this.state.search.text) : null - if (searchResults) { - data = applySearchResults(data, searchResults, this.state.search.active) - } - if (this.state.selection) { - data = applySelection(data, this.state.selection) - } + // TODO: reimplement search and selection + const searchResults = [] + // const searchResults = this.state.search.text ? search(data, this.state.search.text) : null + // if (searchResults) { + // data = applySearchResults(data, searchResults, this.state.search.active) + // } + // if (this.state.selection) { + // data = applySelection(data, this.state.selection) + // } return h('div', { className: `jsoneditor jsoneditor-mode-${props.mode}`, @@ -220,12 +231,11 @@ export default class TreeMode extends Component { onMouseDown: this.handleTouchStart, onTouchStart: this.handleTouchStart, className: 'jsoneditor-list jsoneditor-root' + - (data.selected ? ' jsoneditor-selected' : '')}, + (eson._meta.selected ? ' jsoneditor-selected' : '')}, h(Node, { - data, + eson, events: state.events, options: props, - path: [], prop: null }) ) @@ -313,7 +323,7 @@ export default class TreeMode extends Component { */ getErrors () { if (this.state.compiledSchema) { - const valid = this.state.compiledSchema(esonToJson(this.state.data)) + const valid = this.state.compiledSchema(this.state.json) if (!valid) { return this.state.compiledSchema.errors.map(enrichSchemaError) } @@ -547,14 +557,14 @@ export default class TreeMode extends Component { handleExpand = (path, expanded, recurse) => { if (recurse) { this.setState({ - data: updateInEson(this.state.data, path, function (child) { + eson: updateIn(this.state.eson, path, function (child) { return expand(child, (path) => true, expanded) }) }) } else { this.setState({ - data: expand(this.state.data, path, expanded) + eson: expand(this.state.eson, path, expanded) }) } } @@ -885,10 +895,11 @@ export default class TreeMode extends Component { set (json) { // FIXME: when both json and expand are being changed via React, this.props must be updated before set(json) is called // TODO: document option expand - const expand = this.props.expand || TreeMode.expandRoot + const expandCallback = this.props.expand || TreeMode.expandRoot this.setState({ - data: jsonToEson(json, expand, []), + json: json, + eson: expand(toEson2(json), expandCallback), // FIXME: expand eson // TODO: do we want to keep history when .set(json) is called? (currently we remove history) history: [], @@ -901,7 +912,7 @@ export default class TreeMode extends Component { * @returns {Object | Array | string | number | boolean | null} json */ get () { - return esonToJson(this.state.data) + return this.state.json } /** diff --git a/src/eson.js b/src/eson.js index b0d9cf4..5895707 100644 --- a/src/eson.js +++ b/src/eson.js @@ -5,7 +5,7 @@ * All functions are pure and don't mutate the ESON. */ -import { setIn, getIn, updateIn, deleteIn } from './utils/immutabilityHelpers' +import { setIn, getIn, updateIn, deleteIn, transform } from './utils/immutabilityHelpers' import { isObject } from './utils/typeUtils' import isEqual from 'lodash/isEqual' import times from 'lodash/times' @@ -25,6 +25,51 @@ export const SELECTED_END = 2 export const SELECTED_BEFORE = 3 export const SELECTED_AFTER = 4 +/** + * + * @param {JSONType} json + * @param {JSONPath} path + * @return {ESON} + */ +// TODO: rename to jsonToEson after refactoring of ESON is finished +export function toEson2 (json, path = []) { + const id = createId() + + if (isObject(json)) { + let eson = {} + const keys = Object.keys(json) + keys.forEach((key) => eson[key] = toEson2(json[key], path.concat(key))) + eson._meta = { id, path, type: 'Object', keys } + return eson + } + else if (Array.isArray(json)) { + let eson = {} + json.forEach((value, index) => eson[index] = toEson2(value, path.concat(index))) + eson._meta = { id, path, type: 'Array', length: json.length } + return eson + } + else { // json is a number, string, boolean, or null + return { + _meta: { id, path, type: 'value', value: json } + } + } +} + +/** + * Map over an eson array + * @param {ESONArray} esonArray + * @param {function (value, index, array)} callback + * @return {Array} + */ +export function mapEsonArray (esonArray, callback) { + const length = esonArray._meta.length + let result = [] + for (let i = 0; i < length; i++) { + result[i] = callback(esonArray[i], i, esonArray) + } + return result +} + /** * Expand function which will expand all nodes * @param {Path} path @@ -49,7 +94,7 @@ export function jsonToEson (json, expand = expandAll, path: JSONPath = [], type: expanded: expand(path), items: json.map((child, index) => { return { - id: getId(), // TODO: use id based on index (only has to be unique within this array) + id: createId(), // TODO: use id based on index (only has to be unique within this array) value: jsonToEson(child, expand, path.concat(index)) } }) @@ -61,7 +106,7 @@ export function jsonToEson (json, expand = expandAll, path: JSONPath = [], type: expanded: expand(path), props: Object.keys(json).map((name, index) => { return { - id: getId(), // TODO: use id based on index (only has to be unique within this array) + id: createId(), // TODO: use id based on index (only has to be unique within this array) name, value: jsonToEson(json[name], expand, path.concat(name)) } @@ -207,31 +252,25 @@ export function deleteInEson (eson: ESON, jsonPath: JSONPath) : JSONType { /** * Expand or collapse one or multiple items or properties * @param {ESON} eson - * @param {function(path: Path) : boolean | Path} callback + * @param {function(Path) : boolean | Path} filterCallback * When a path, the object/array at this path will be expanded/collapsed * When a function, all objects and arrays for which callback * returns true will be expanded/collapsed * @param {boolean} [expanded=true] New expanded state: true to expand, false to collapse * @return {ESON} */ -export function expand (eson: ESON, callback: Path | (Path) => boolean, expanded: boolean = true) { +export function expand (eson, filterCallback, expanded = true) { // console.log('expand', callback, expand) - if (typeof callback === 'function') { - return transform(eson, function (value: ESON, path: Path, root: ESON) : ESON { - if (value.type === 'Array' || value.type === 'Object') { - if (callback(path)) { - return setIn(value, ['expanded'], expanded) - } - } - - return value + if (typeof filterCallback === 'function') { + return transform(eson, function (value, path) { + return (value && value._meta && (value._meta.type === 'Array' || value._meta.type === 'Object') && filterCallback(path)) + ? setIn(value, ['_meta', 'expanded'], expanded) + : value }) } - else if (Array.isArray(callback)) { - const esonPath: Path = toEsonPath(eson, callback) - - return setIn(eson, esonPath.concat(['expanded']), expanded) + else if (Array.isArray(filterCallback)) { + return setIn(eson, filterCallback.concat(['_meta', 'expanded']), expanded) } else { throw new Error('Callback function or path expected') @@ -511,54 +550,54 @@ function findSharedPath (path1: JSONPath, path2: JSONPath): JSONPath { return path1.slice(0, i) } - -/** - * Recursively transform ESON: a recursive "map" function - * @param {ESON} eson - * @param {function(value: ESON, path: Path, root: ESON)} callback - * @return {ESON} Returns the transformed eson object - */ -export function transform (eson: ESON, callback: RecurseCallback) : ESON { - return recurseTransform (eson, [], eson, callback) -} - -/** - * Recursively transform ESON - * @param {ESON} value - * @param {JSONPath} path - * @param {ESON} root The root object, object at path=[] - * @param {function(value: ESON, path: Path, root: ESON)} callback - * @return {ESON} Returns the transformed eson object - */ -function recurseTransform (value: ESON, path: JSONPath, root: ESON, callback: RecurseCallback) : ESON { - let updatedValue: ESON = callback(value, path, root) - - if (value.type === 'Array') { - let updatedItems = updatedValue.items - - updatedValue.items.forEach((item, index) => { - const updatedItem = recurseTransform(item.value, path.concat(String(index)), root, callback) - updatedItems = setIn(updatedItems, [index, 'value'], updatedItem) - }) - - updatedValue = setIn(updatedValue, ['items'], updatedItems) - } - - if (value.type === 'Object') { - let updatedProps = updatedValue.props - - updatedValue.props.forEach((prop, index) => { - const updatedItem = recurseTransform(prop.value, path.concat(prop.name), root, callback) - updatedProps = setIn(updatedProps, [index, 'value'], updatedItem) - }) - - updatedValue = setIn(updatedValue, ['props'], updatedProps) - } - - // (for type 'string' or 'value' there are no childs to traverse) - - return updatedValue -} +// +// /** +// * Recursively transform ESON: a recursive "map" function +// * @param {ESON} eson +// * @param {function(value: ESON, path: Path, root: ESON)} callback +// * @return {ESON} Returns the transformed eson object +// */ +// export function transform (eson: ESON, callback: RecurseCallback) : ESON { +// return recurseTransform (eson, [], eson, callback) +// } +// +// /** +// * Recursively transform ESON +// * @param {ESON} value +// * @param {JSONPath} path +// * @param {ESON} root The root object, object at path=[] +// * @param {function(value: ESON, path: Path, root: ESON)} callback +// * @return {ESON} Returns the transformed eson object +// */ +// function recurseTransform (value: ESON, path: JSONPath, root: ESON, callback: RecurseCallback) : ESON { +// let updatedValue: ESON = callback(value, path, root) +// +// if (value.type === 'Array') { +// let updatedItems = updatedValue.items +// +// updatedValue.items.forEach((item, index) => { +// const updatedItem = recurseTransform(item.value, path.concat(String(index)), root, callback) +// updatedItems = setIn(updatedItems, [index, 'value'], updatedItem) +// }) +// +// updatedValue = setIn(updatedValue, ['items'], updatedItems) +// } +// +// if (value.type === 'Object') { +// let updatedProps = updatedValue.props +// +// updatedValue.props.forEach((prop, index) => { +// const updatedItem = recurseTransform(prop.value, path.concat(prop.name), root, callback) +// updatedProps = setIn(updatedProps, [index, 'value'], updatedItem) +// }) +// +// updatedValue = setIn(updatedValue, ['props'], updatedProps) +// } +// +// // (for type 'string' or 'value' there are no childs to traverse) +// +// return updatedValue +// } /** * Recursively loop over a ESON object: a recursive "forEach" function. @@ -709,7 +748,7 @@ export function compileJSONPointer (path: Path) { .join('') } -// TODO: move getId and createUniqueId to a separate file +// TODO: move createId to a separate file /** * Do a case insensitive search for a search text in a text @@ -725,19 +764,8 @@ export function containsCaseInsensitive (text: string, search: string): boolean * Get a new "unique" id. Id's are created from an incremental counter. * @return {number} */ -// TODO: use createUniqueId instead of getId() -export function getId () : number { +export function createId () : number { _id++ return _id } let _id = 0 - -/** - * Find a unique id from an array with properties each having an id field. - * The - * @param {{id: string}} array - */ -// TODO: use createUniqueId instead of getId() -function createUniqueId (array) { - return Math.max(...array.map(item => item.id)) + 1 -} diff --git a/src/patchEson.js b/src/patchEson.js index 99c1702..bd4ada9 100644 --- a/src/patchEson.js +++ b/src/patchEson.js @@ -8,7 +8,7 @@ import { jsonToEson, esonToJson, toEsonPath, getInEson, setInEson, deleteInEson, parseJSONPointer, compileJSONPointer, - expandAll, pathExists, resolvePathIndex, findPropertyIndex, findNextProp, getId + expandAll, pathExists, resolvePathIndex, findPropertyIndex, findNextProp, createId } from './eson' /** @@ -197,7 +197,7 @@ export function remove (data: ESON, path: string) { * @return {{data: ESON, revert: ESONPatch}} * @private */ -export function add (data: ESON, path: string, value: ESON, options, id = getId()) { +export function add (data: ESON, path: string, value: ESON, options, id = createId()) { const pathArray = parseJSONPointer(path) const parentPath = pathArray.slice(0, pathArray.length - 1) const esonPath = toEsonPath(data, parentPath) diff --git a/src/types.js b/src/types.js index 2ae7b25..0e5f63b 100644 --- a/src/types.js +++ b/src/types.js @@ -20,8 +20,12 @@ * ace: Object? * }} Options * + * @typedef {string[]} Path + * */ +// FIXME: redefine all ESON related types + /**************************** GENERIC JSON TYPES ******************************/ @@ -48,24 +52,32 @@ export type ESONArrayItem = { } export type ESONObject = { - type: 'Object', - expanded?: boolean, - selected?: boolean, - props: ESONObjectProperty[] + _meta: { + type: 'Object', + path: JSONPath, + expanded?: boolean, + selected?: boolean, + } } export type ESONArray = { - type: 'Array', - expanded?: boolean, - selected?: boolean, - items: ESONArrayItem[] + _meta: { + type: 'Array', + path: JSONPath, + expanded?: boolean, + selected?: boolean, + length: number + } } export type ESONValue = { - type: 'value' | 'string', - value?: any, - selected?: boolean, - searchResult?: SearchResultStatus + _meta: { + type: 'value' | 'string', + path: JSONPath, + value: null | boolean | string | number, + selected?: boolean, + searchResult?: SearchResultStatus + } } export type ESON = ESONObject | ESONArray | ESONValue diff --git a/src/utils/immutabilityHelpers.js b/src/utils/immutabilityHelpers.js index fa06a13..30c534f 100644 --- a/src/utils/immutabilityHelpers.js +++ b/src/utils/immutabilityHelpers.js @@ -1,7 +1,7 @@ 'use strict'; import clone from 'lodash/clone' -import { isObjectOrArray } from './typeUtils' +import { isObjectOrArray, isObject } from './typeUtils' /** * Immutability helpers @@ -11,6 +11,7 @@ import { isObjectOrArray } from './typeUtils' * https://www.npmjs.com/package/seamless-immutable * https://www.npmjs.com/package/ih * https://www.npmjs.com/package/mutatis + * https://github.com/mariocasciaro/object-path-immutable */ @@ -70,6 +71,35 @@ export function setIn (object, path, value) { return updatedObject } } + +export function transform (object, callback, path = []) { + const updated = callback(object, path) + + if (Array.isArray(updated)) { + let changed = false + let updatedItems = [] + for (let i = 0; i < updated.length; i++) { + updatedItems[i] = transform(updated[i], callback, path.concat(i)) + changed = changed || updatedItems[i] !== updated[i] + } + return changed ? updatedItems : updated + } + else if (isObject(updated)) { + let changed = false + let updatedProps = {} + for (let key in updated) { + if (updated.hasOwnProperty(key)) { + updatedProps[key] = transform(updated[key], callback, path.concat(key)) + changed = changed || updatedProps[key] !== updated[key] + } + } + return changed ? updatedProps : updated + } + else { // updated is a value + return updated + } +} + /** * helper function to replace a nested property in an object with a new value * without mutating the object itself. diff --git a/src/utils/typeUtils.js b/src/utils/typeUtils.js index be68be8..ea8aafa 100644 --- a/src/utils/typeUtils.js +++ b/src/utils/typeUtils.js @@ -10,7 +10,21 @@ export function isObject (value) { return typeof value === 'object' && value !== null && - !Array.isArray(value) + !Array.isArray(value) && + (!value._meta || typeof value._meta.value === 'undefined') +} + +/** + * Test whether a value is not an object or array, but null, number, string, or + * boolean. + * @param {*} value + * @return {boolean} + */ +export function isValue (value) { + return (value === null || + typeof value === 'number' || + typeof value === 'string' || + typeof value === 'boolean') } /** diff --git a/test/eson.test.js b/test/eson.test.js index edbe119..b52bd16 100644 --- a/test/eson.test.js +++ b/test/eson.test.js @@ -4,10 +4,12 @@ import { setIn, getIn } from '../src/utils/immutabilityHelpers' import { jsonToEson, esonToJson, toEsonPath, toJsonPath, pathExists, transform, traverse, parseJSONPointer, compileJSONPointer, + toEson2, expand, addErrors, search, applySearchResults, nextSearchResult, previousSearchResult, applySelection, pathsFromSelection, SELECTED, SELECTED_END } from '../src/eson' +import deepMap from "deep-map/lib/index" const JSON1 = loadJSON('./resources/json1.json') const ESON1 = loadJSON('./resources/eson1.json') @@ -35,6 +37,25 @@ test('toJsonPath', t => { t.deepEqual(toJsonPath(ESON1, esonPath), jsonPath) }) +test('toEson2', t => { + t.deepEqual(replaceIds2(toEson2(1)), {_meta: {id: '[ID]', path: [], type: 'value', value: 1}}) + t.deepEqual(replaceIds2(toEson2("foo")), {_meta: {id: '[ID]', path: [], type: 'value', value: "foo"}}) + t.deepEqual(replaceIds2(toEson2(null)), {_meta: {id: '[ID]', path: [], type: 'value', value: null}}) + t.deepEqual(replaceIds2(toEson2(false)), {_meta: {id: '[ID]', path: [], type: 'value', value: false}}) + t.deepEqual(replaceIds2(toEson2({a:1, b: 2})), { + _meta: {id: '[ID]', path: [], type: 'Object', keys: ['a', 'b']}, + a: {_meta: {id: '[ID]', path: ['a'], type: 'value', value: 1}}, + b: {_meta: {id: '[ID]', path: ['b'], type: 'value', value: 2}} + }) + + printJSON(replaceIds2(toEson2([1,2]))) + t.deepEqual(replaceIds2(toEson2([1,2])), { + _meta: {id: '[ID]', path: [], type: 'Array', length: 2}, + 0: {_meta: {id: '[ID]', path: [0], type: 'value', value: 1}}, + 1: {_meta: {id: '[ID]', path: [1], type: 'value', value: 2}} + }) +}) + test('jsonToEson', t => { function expand (path) { return true @@ -396,6 +417,11 @@ function replaceIds (data, value = '[ID]') { } } +// helper function to replace all id properties with a constant value +function replaceIds2 (data, key = 'id', value = '[ID]') { + return deepMap(data, (v, k) => k === key ? value : v) +} + // helper function to print JSON in the console function printJSON (json, message = null) { if (message) { diff --git a/test/immutabilityHelpers.test.js b/test/immutabilityHelpers.test.js index 7770c3e..1a283a5 100644 --- a/test/immutabilityHelpers.test.js +++ b/test/immutabilityHelpers.test.js @@ -1,5 +1,5 @@ import test from 'ava'; -import { getIn, setIn, updateIn, deleteIn, insertAt } from '../src/utils/immutabilityHelpers' +import { getIn, setIn, updateIn, deleteIn, insertAt, transform } from '../src/utils/immutabilityHelpers' test('getIn', t => { @@ -276,3 +276,25 @@ test('insertAt', t => { const updated = insertAt(obj, ['a', '2'], 8) t.deepEqual(updated, {a: [1,2,8,3]}) }) + +test('transform (no change)', t => { + const obj = { a: [1,2,3]} + + const updated = transform(obj, (value, path) => value) + t.deepEqual(updated, obj) + t.is(updated, obj) +}) + +test('transform (change based on value)', t => { + const obj = { a: [1,2,3]} + + const updated = transform(obj, (value, path) => value === 2 ? 20 : value) + t.deepEqual(updated, { a: [1,20,3]}) +}) + +test('transform (change based on path)', t => { + const obj = { a: [1,2,3]} + + const updated = transform(obj, (value, path) => path.join('.') === 'a.1' ? 20 : value) + t.deepEqual(updated, { a: [1,20,3]}) +})