From 785ab5205ccf0f1b0322cfe50f3e6dd06304d226 Mon Sep 17 00:00:00 2001 From: jos Date: Thu, 22 Dec 2016 12:08:32 +0100 Subject: [PATCH] React API mostly working (WIP) --- .babelrc | 3 +- .flowconfig | 10 +++ examples/10_react_component.html | 94 ++++++++++++++++++++++ gulpfile.js | 3 +- package.json | 25 +++--- src/components/Ace.js | 49 ++++++----- src/components/CodeMode.js | 26 +++--- src/components/JSONEditor.js | 74 +++++++++++++++++ src/components/JSONNode.js | 14 ++-- src/components/TextMode.js | 134 ++++++++++++++++++++----------- src/components/TreeMode.js | 91 ++++++++++++++++----- src/develop.html | 12 +-- src/flow/LessModule.js | 2 + src/index.js | 41 +++++++++- src/types.js | 91 +++++++++++++++++++++ 15 files changed, 541 insertions(+), 128 deletions(-) create mode 100644 .flowconfig create mode 100644 examples/10_react_component.html create mode 100644 src/components/JSONEditor.js create mode 100644 src/flow/LessModule.js create mode 100644 src/types.js diff --git a/.babelrc b/.babelrc index e81a8fb..458cea5 100755 --- a/.babelrc +++ b/.babelrc @@ -1,3 +1,4 @@ { - "presets": ["es2015", "stage-3", "stage-2"] + "presets": ["es2015", "stage-3", "stage-2"], + "plugins": ["transform-flow-strip-types"] } diff --git a/.flowconfig b/.flowconfig new file mode 100644 index 0000000..570e96e --- /dev/null +++ b/.flowconfig @@ -0,0 +1,10 @@ +[ignore] + +[include] +./src + +[libs] + +[options] +module.name_mapper.extension='less' -> '/src/flow/LessModule.js' +suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe diff --git a/examples/10_react_component.html b/examples/10_react_component.html new file mode 100644 index 0000000..cafbebe --- /dev/null +++ b/examples/10_react_component.html @@ -0,0 +1,94 @@ + + + + + React component | JSONEditor + + + + + + + +

+ This demo shows how to load JSONEditor as a React Component +

+ +
+ + + + + + + + + + + + \ No newline at end of file diff --git a/gulpfile.js b/gulpfile.js index 87dcb2a..293c880 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -47,7 +47,8 @@ var loaders = [ // create a single instance of the compiler to allow caching var plugins = [ - bannerPlugin,] + bannerPlugin +] if (!WATCHING) { plugins.push(new webpack.optimize.UglifyJsPlugin()) plugins.push(new webpack.DefinePlugin({ diff --git a/package.json b/package.json index 0ba30ae..6346a9e 100644 --- a/package.json +++ b/package.json @@ -20,25 +20,28 @@ "scripts": { "start": "gulp watch", "build": "gulp", + "flow": "flow; test $? -eq 0 -o $? -eq 2", "test": "ava test/*.test.js test/**/*.test.js --verbose" }, "dependencies": { - "ajv": "4.8.2", + "ajv": "4.9.2", "brace": "0.9.0", "javascript-natural-sort": "0.7.1", - "lodash": "4.16.6", - "react": "^15.3.2", - "react-dom": "^15.3.2" + "lodash": "4.17.2", + "react": "15.4.1", + "react-dom": "15.4.1" }, "devDependencies": { - "ava": "0.16.0", - "babel-core": "6.18.2", - "babel-loader": "6.2.7", + "ava": "0.17.0", + "babel-core": "6.20.0", + "babel-loader": "6.2.9", + "babel-plugin-transform-flow-strip-types": "6.18.0", "babel-preset-stage-2": "6.18.0", "babel-preset-stage-3": "6.17.0", - "browser-sync": "2.17.5", - "css-loader": "0.25.0", - "graceful-fs": "4.1.10", + "browser-sync": "2.18.2", + "css-loader": "0.26.1", + "flow-bin": "0.36.0", + "graceful-fs": "4.1.11", "gulp": "3.9.1", "gulp-shell": "0.5.2", "gulp-util": "3.0.7", @@ -48,7 +51,7 @@ "mkdirp": "0.5.1", "style-loader": "0.13.1", "svg-url-loader": "1.1.0", - "webpack": "1.13.3" + "webpack": "1.14.0" }, "ava": { "require": [ diff --git a/src/components/Ace.js b/src/components/Ace.js index 5b0e3a3..1a25c4b 100644 --- a/src/components/Ace.js +++ b/src/components/Ace.js @@ -1,3 +1,5 @@ +// @flow + import { createElement as h, Component } from 'react' import ace from '../assets/ace' @@ -12,13 +14,10 @@ import ace from '../assets/ace' * */ export default class Ace extends Component { - constructor (props) { - super(props) + aceEditor = null + settingValue = false // Used to prevent Ace from emitting onChange event whilst we're setting a value programmatically - this.aceEditor = null - } - - render (props, state) { + render () { return h('div', {ref: 'container', className: 'jsoneditor-code'}) } @@ -64,35 +63,45 @@ export default class Ace extends Component { : aceEditor // register onchange event - this.aceEditor.on('change', this.handleChange) - - // set value, the text contents for the editor - this.aceEditor.setValue(this.props.value || '', -1) - } - - componentWillReceiveProps (nextProps) { - if (nextProps.value !== this.aceEditor.getValue()) { - this.aceEditor.setValue(nextProps.value, -1) + if (this.aceEditor) { + this.aceEditor.on('change', this.handleChange) } - if (nextProps.indentation != undefined) { + // set value, the text contents for the editor + if (this.aceEditor) { + this.aceEditor.setValue(this.props.value || '', -1) + } + } + + componentWillReceiveProps (nextProps: {value: string, indentation?: number}) { + if (this.aceEditor && nextProps.value !== this.aceEditor.getValue()) { + this.settingValue = true + this.aceEditor.setValue(nextProps.value, -1) + this.settingValue = false + } + + if (this.aceEditor && nextProps.indentation != undefined) { this.aceEditor.getSession().setTabSize(this.props.indentation) } // TODO: only resize only when needed setTimeout(() => { - this.aceEditor.resize(false); + if (this.aceEditor) { + this.aceEditor.resize(false); + } }, 0) } componentWillUnmount () { // neatly destroy ace editor instance - this.aceEditor.destroy() - this.aceEditor = null + if (this.aceEditor) { + this.aceEditor.destroy() + this.aceEditor = null + } } handleChange = () => { - if (this.props && this.props.onChange) { + if (this.props && this.props.onChange && this.aceEditor && !this.settingValue) { // TODO: pass a diff this.props.onChange(this.aceEditor.getValue()) } diff --git a/src/components/CodeMode.js b/src/components/CodeMode.js index 9877b81..16689d4 100644 --- a/src/components/CodeMode.js +++ b/src/components/CodeMode.js @@ -1,3 +1,5 @@ +// @flow + import { createElement as h, Component } from 'react' import TextMode from './TextMode' import Ace from './Ace' @@ -28,11 +30,12 @@ import Ace from './Ace' * */ export default class CodeMode extends TextMode { - constructor (props) { + constructor (props: {options: {onLoadAce: Function, indentation: number}}) { super(props) this.state = { - text: '{}' + text: '{}', + compiledSchema: null } } @@ -42,22 +45,15 @@ export default class CodeMode extends TextMode { h('div', {key: 'contents', className: 'jsoneditor-contents'}, h(Ace, { value: this.state.text, - onChange: this.handleChange, - onLoadAce: this.props.options.onLoadAce, - indentation: this.props.options.indentation, - ace: this.props.options.ace + onChange: this.handleChangeText, + onLoadAce: this.props.onLoadAce, + indentation: this.props.indentation, + ace: this.props.ace })), this.renderSchemaErrors () ]) } +} - handleChange = (text) => { - this.setState({ text }) - - if (this.props.options && this.props.options.onChangeText) { - // TODO: pass a diff - this.props.options.onChangeText() - } - } -} \ No newline at end of file +// TODO: define propTypes diff --git a/src/components/JSONEditor.js b/src/components/JSONEditor.js new file mode 100644 index 0000000..c3ffb78 --- /dev/null +++ b/src/components/JSONEditor.js @@ -0,0 +1,74 @@ +// @flow + +import { createElement as h, Component, PropTypes } from 'react' +import { render, unmountComponentAtNode} from 'react-dom' +import CodeMode from './CodeMode' +import TextMode from './TextMode' +import TreeMode from './TreeMode' + +export default class JSONEditor extends Component { + static modeConstructors = { + code: CodeMode, + form: TreeMode, + text: TextMode, + tree: TreeMode, + view: TreeMode + } + + state = { + mode: 'tree' + } + + render () { + const mode = this.state.mode // We use mode from state, not from props! + const ModeConstructor = JSONEditor.modeConstructors[mode] + + if (!ModeConstructor) { + // TODO: show an on screen error instead of throwing an error? + throw new Error('Unknown mode "' + mode + '". ' + + 'Choose from: ' + Object.keys(this.props.modes).join(', ')) + } + + return h(ModeConstructor, { + ...this.props, + mode, + onError: this.handleError, + onChangeMode: this.handleChangeMode + }) + } + + componentWillMount () { + if (this.props.mode) { + this.setState({ mode: this.props.mode }) + } + } + + componentWillReceiveProps (nextProps: {mode: ?string}) { + if (nextProps.mode !== this.props.mode) { + this.setState({ mode: nextProps.mode }) + } + } + + handleError = (err: Error) => { + if (this.props.onError) { + this.props.onError(err) + } + else { + console.error(err) + } + } + + handleChangeMode = (mode: string) => { + const prevMode = this.state.mode + + this.setState({ mode }) + + if (this.props.onChangeMode) { + this.props.onChangeMode(mode, prevMode) + } + } +} + +JSONEditor.propTypes = { + mode: PropTypes.string +} diff --git a/src/components/JSONNode.js b/src/components/JSONNode.js index ff13fba..371ea1d 100644 --- a/src/components/JSONNode.js +++ b/src/components/JSONNode.js @@ -1,3 +1,5 @@ +// @flow weak + import { createElement as h, Component } from 'react' import ActionButton from './menu/ActionButton' @@ -15,13 +17,9 @@ let activeContextMenu = null export default class JSONNode extends Component { static URL_TITLE = 'Ctrl+Click or Ctrl+Enter to open url' - constructor (props) { - super(props) - - this.state = { - menu: null, // context menu - appendMenu: null, // append context menu (used in placeholder of empty object/array) - } + state = { + menu: null, // context menu + appendMenu: null, // append context menu (used in placeholder of empty object/array) } render () { @@ -484,7 +482,7 @@ export default class JSONNode extends Component { /** * Singleton function to hide the currently visible context menu if any. - * @private + * @protected */ static hideActionMenu () { if (activeContextMenu) { diff --git a/src/components/TextMode.js b/src/components/TextMode.js index f7a9e90..4c2196e 100644 --- a/src/components/TextMode.js +++ b/src/components/TextMode.js @@ -1,3 +1,5 @@ +// @flow weak + import { createElement as h, Component } from 'react' import Ajv from 'ajv' import { parseJSON } from '../utils/jsonUtils' @@ -18,7 +20,9 @@ const AJV_OPTIONS = { * Usage: * * { // do nothing... } @@ -206,18 +249,14 @@ export default class TextMode extends Component { * @protected */ handleInput = (event) => { - this.setText(event.target.value) - - if (this.props.options && this.props.options.onChangeText) { - // TODO: pass a diff - this.props.options.onChangeText() - } + this.handleChangeText(event.target.value) } /** @protected */ handleFormat = () => { try { - this.format() + const formatted = TextMode.format(this.getText(), TextMode.getIndentation(this.props)) + this.handleChangeText(formatted) } catch (err) { this.props.onError(err) @@ -227,7 +266,8 @@ export default class TextMode extends Component { /** @protected */ handleCompact = () => { try { - this.compact() + const compacted = TextMode.compact(this.getText()) + this.handleChangeText(compacted) } catch (err) { this.props.onError(err) @@ -235,22 +275,22 @@ export default class TextMode extends Component { } /** - * Format the json + * Apply new text to the state, and emit an onChangeText event if there is a change */ - format () { - const json = this.get() - const text = JSON.stringify(json, null, this.getIndentation()) - this.setText(text) + handleChangeText = (text: string) => { + if (this.props.onChangeText && text !== this.state.text) { + const appliedText = this.setText(text) + this.props.onChangeText(appliedText) + } + else { + this.setText(text) + } + + // TODO: also invoke a patch action } - /** - * Compact the json - */ - compact () { - const json = this.get() - const text = JSON.stringify(json) - this.setText(text) - } + // TODO: implement method patchText + // TODO: implement callback onPatchText /** * Apply a JSONPatch to the current JSON document @@ -279,7 +319,7 @@ export default class TextMode extends Component { * @param {Object | Array | string | number | boolean | null} json JSON data */ set (json) { - this.setText(JSON.stringify(json, null, this.getIndentation())) + this.setText(JSON.stringify(json, null, TextMode.getIndentation(this.props))) } /** @@ -292,14 +332,15 @@ export default class TextMode extends Component { /** * Set a string containing a JSON document - * @param {string} text */ - setText (text) { - this.setState({ - text: this.props.options.escapeUnicode - ? escapeUnicodeChars(text) - : text - }) + setText (text: string) : string { + const normalizedText = this.props.escapeUnicode + ? escapeUnicodeChars(text) + : text + + this.setState({ text: normalizedText }) + + return normalizedText } /** @@ -309,4 +350,7 @@ export default class TextMode extends Component { getText () { return this.state.text } -} \ No newline at end of file +} + +// TODO: define propTypes + diff --git a/src/components/TreeMode.js b/src/components/TreeMode.js index a48f509..b9c2e74 100644 --- a/src/components/TreeMode.js +++ b/src/components/TreeMode.js @@ -59,6 +59,43 @@ export default class TreeMode extends Component { } } + componentWillMount () { + this.applyProps(this.props, {}) + } + + componentWillReceiveProps (nextProps) { + this.applyProps(nextProps, this.props) + } + + // TODO: create some sort of watcher structure for these props? Is there a Reactpattern for that? + applyProps (nextProps, currentProps) { + // Apply text + if (nextProps.text !== currentProps.text) { + this.patch([{ + op: 'replace', + path: '', + value: parseJSON(nextProps.text) // FIXME: this can fail, handle error correctly + }]) + } + + // Apply json + if (nextProps.json !== currentProps.json) { + this.patch([{ + op: 'replace', + path: '', + value: nextProps.json + }]) + } + + // Apply JSON Schema + if (nextProps.schema !== currentProps.schema) { + this.setSchema(nextProps.schema) + } + + // TODO: apply patchText + // TODO: apply patch + } + render () { const { props, state } = this @@ -95,7 +132,7 @@ export default class TreeMode extends Component { h(Node, { data, events: state.events, - options: props.options, + options: props, parent: null, prop: null }) @@ -120,7 +157,7 @@ export default class TreeMode extends Component { }) ] - if (this.props.mode !== 'view' && this.props.options.history != false) { + if (this.props.mode !== 'view' && this.props.history != false) { items = items.concat([ h('div', {key: 'history-separator', className: 'jsoneditor-vertical-menu-separator'}), @@ -141,13 +178,13 @@ export default class TreeMode extends Component { ]) } - if (this.props.options.modes ) { + if (this.props.modes ) { items = items.concat([ h('div', {key: 'mode-separator', className: 'jsoneditor-vertical-menu-separator'}), h(ModeButton, { key: 'mode', - modes: this.props.options.modes, + modes: this.props.modes, mode: this.props.mode, onChangeMode: this.props.onChangeMode, onError: this.props.onError @@ -155,7 +192,7 @@ export default class TreeMode extends Component { ]) } - if (this.props.options.search !== false) { + if (this.props.search !== false) { // option search is true or undefined items = items.concat([ h('div', {key: 'search', className: 'jsoneditor-menu-panel-right'}, @@ -283,18 +320,34 @@ export default class TreeMode extends Component { // apply changes const result = this.patch(actions) - this.emitOnChange (actions, result.revert) + this.emitOnChange (actions, result.revert, result.data) } /** * Emit an onChange event when there is a listener for it. * @param {JSONPatch} patch * @param {JSONPatch} revert + * @param {JSONData} data * @private */ - emitOnChange (patch, revert) { - if (this.props.options.onChange) { - this.props.options.onChange(patch, revert) + emitOnChange (patch, revert, data) { + if (this.props.onPatch) { + this.props.onPatch(patch, revert) + } + + if (this.props.onChange || this.props.onChangeText) { + const json = dataToJson(data) + + if (this.props.onChange) { + this.props.onChange(json) + } + + if (this.props.onChangeText) { + const indentation = this.props.indentation || 2 + const text = JSON.stringify(json, null, indentation) + + this.props.onChangeText(text) + } } } @@ -362,7 +415,7 @@ export default class TreeMode extends Component { const result = patchData(this.state.data, actions, expand) const data = result.data - if (this.props.options.history != false) { + if (this.props.history != false) { // update data and store history const historyItem = { redo: actions, @@ -387,19 +440,19 @@ export default class TreeMode extends Component { return { patch: actions, revert: result.revert, - error: result.error + error: result.error, + data // FIXME: shouldn't pass data here } } /** * Set JSON object in editor * @param {Object | Array | string | number | boolean | null} json JSON data - * @param {SetOptions} [options] If no expand function is provided, - * The root will be expanded and all other nodes - * will be collapsed. */ - set (json, options = {}) { - const expand = options.expand || TreeMode.expandRoot + 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 this.setState({ data: jsonToData(json, expand, []), @@ -431,7 +484,7 @@ export default class TreeMode extends Component { * @return {string} text */ getText () { - const indentation = this.props.options.indentation || 2 + const indentation = this.props.indentation || 2 return JSON.stringify(this.get(), null, indentation) } @@ -443,7 +496,7 @@ export default class TreeMode extends Component { // TODO: deduplicate this function, it's also implemented in TextMode setSchema (schema) { if (schema) { - const ajv = this.props.options.ajv || Ajv && Ajv(AJV_OPTIONS) + const ajv = this.props.ajv || Ajv && Ajv(AJV_OPTIONS) if (!ajv) { throw new Error('Cannot validate JSON: ajv not available. ' + @@ -534,3 +587,5 @@ export default class TreeMode extends Component { } } + +// TODO: describe PropTypes \ No newline at end of file diff --git a/src/develop.html b/src/develop.html index 940688e..7dcf870 100644 --- a/src/develop.html +++ b/src/develop.html @@ -77,7 +77,11 @@ indentation: 4, escapeUnicode: true, history: true, - search: true + search: true, + + expand: function (path) { + return true + } } const editor = jsoneditor(container, options) const json = { @@ -92,11 +96,7 @@ 'unicode': 'A unicode character: \u260E', 'url': 'http://jsoneditoronline.org' } - editor.set(json, { - expand: function (path) { - return true - } - }) + editor.set(json) const schema = { "title": "Example Schema", diff --git a/src/flow/LessModule.js b/src/flow/LessModule.js new file mode 100644 index 0000000..37d49d3 --- /dev/null +++ b/src/flow/LessModule.js @@ -0,0 +1,2 @@ +// @flow +declare export default string diff --git a/src/index.js b/src/index.js index e3812ba..abc825e 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,6 @@ -import { createElement as h, Component } from 'react' -import { render, unmountComponentAtNode} from 'react-dom' +import React, { createElement as h, Component } from 'react' +import ReactDOM, { render, unmountComponentAtNode} from 'react-dom' +import JSONEditor from './components/JSONEditor' import CodeMode from './components/CodeMode' import TextMode from './components/TextMode' import TreeMode from './components/TreeMode' @@ -45,6 +46,7 @@ function jsoneditor (container, options = {}) { * @param {SetOptions} [options] */ editor.set = function (json, options = {}) { + // TODO: remove options from editor.set, move them to global options instead editor._component.set(json, options) } @@ -72,6 +74,30 @@ function jsoneditor (container, options = {}) { return editor._component.getText() } + /** + * Format the json. + * Only applicable for mode 'text' and 'code' (in other modes nothing will + * happen) + */ + editor.format = function () { + const formatted = TextMode.format(editor._component.getText(), TextMode.getIndentation(this.props)) + editor._component.setText(formatted) + + // TODO: test whether this doesn't destroy the current state + } + + /** + * Compact the json. + * Only applicable for mode 'text' and 'code' (in other modes nothing will + * happen) + */ + editor.compact = function () { + const compacted = TextMode.compact(editor._component.getText()) + editor._component.setText(compacted) + + // TODO: test whether this doesn't destroy the current state + } + /** * Set a JSON schema for validation of the JSON object. * To remove the schema, call JSONEditor.setSchema(null) @@ -134,6 +160,8 @@ function jsoneditor (container, options = {}) { * @param {'tree' | 'text'} mode */ editor.setMode = function (mode) { + // TODO: strongly simplify .setMode, no error handling or logic here + if (mode === editor._mode) { // mode stays the same. do nothing return @@ -172,8 +200,8 @@ function jsoneditor (container, options = {}) { // create new component component = render( h(constructor, { + ...options, mode, - options: editor._options, onChangeMode: handleChangeMode, onError: handleError }), @@ -233,4 +261,11 @@ jsoneditor.utils = { parseJSONPointer } +// expose React component +jsoneditor.JSONEditor = JSONEditor + +// expose React itself +jsoneditor.React = React +jsoneditor.ReactDOM = ReactDOM + module.exports = jsoneditor diff --git a/src/types.js b/src/types.js new file mode 100644 index 0000000..1bf0226 --- /dev/null +++ b/src/types.js @@ -0,0 +1,91 @@ +// @flow + +/** + * @typedef {{ + * type: 'Array', + * expanded: boolean?, + * props: Array.<{name: string, value: JSONData}>? + * }} ObjectData + * + * @typedef {{ + * type: 'Object', + * expanded: boolean?, + * items: JSONData[]? + * }} ArrayData + * + * @typedef {{ + * type: 'value' | 'string', + * value: *? + * }} ValueData + * + * @typedef {Array.} Path + * + * @typedef {ObjectData | ArrayData | ValueData} JSONData + * + * @typedef {'Object' | 'Array' | 'value' | 'string'} JSONDataType + + * @typedef {{ + * patch: JSONPatch, + * revert: JSONPatch, + * 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?, + * expand: function(path: Path) : boolean?, + * ajv: Object?, + * ace: Object? + * }} Options + * + * @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 + */ + +type JSONType = | string | number | boolean | null | JSONObjectType | JSONArrayType; +type JSONObjectType = { [key:string]: JSON }; +type JSONArrayType = Array; + +export type Path = string[] + +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, + from?: string, + value?: any +} +export type JSONPatch = JSONPatchAction[]