Last active
September 15, 2016 09:03
-
-
Save insin/8936b2390c8e06892998 to your computer and use it in GitHub Desktop.
ObjectEditor with React.js (Live version: http://bl.ocks.org/insin/raw/8936b2390c8e06892998/)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** @jsx React.DOM */ | |
'use strict'; | |
// Utils | |
var cx = React.addons.classSet | |
function noop() {} | |
// Global toggle for shouldComponentUpdate for perf comparisons | |
var ALWAYS_UPDATE_COMPONENTS = false | |
/** | |
* Gets just type info from an object's toString, in lower case. | |
*/ | |
function getType(o) { | |
return Object.prototype.toString.call(o).slice(8, -1).toLowerCase() | |
} | |
/** | |
* Gets an appropriate editor constructor based on the given object's type. | |
*/ | |
function getEditorCtor(o) { | |
var type = getType(o) | |
var Editor = TYPE_TO_EDITOR[type] | |
if (!Editor) { | |
throw new Error('No editor available for type: ' + type) | |
} | |
return Editor | |
} | |
/** | |
* Creates an object containing the given prop and value. | |
*/ | |
function makeObj(prop, value) { | |
var update = {} | |
update[prop] = value | |
return update | |
} | |
// Container editors | |
/** | |
* Mixin for editors which can be top-level containers (Objects or Arrays). | |
*/ | |
var ContainerEditorMixin = { | |
propTypes: { | |
editing: React.PropTypes.bool, | |
onChange: React.PropTypes.func | |
}, | |
/** | |
* Top-level editors won't have had a "prop" property passed to them by a | |
* containing editor. | |
*/ | |
isTopLevel: function() { | |
return (typeof this.props.prop == 'undefined') | |
}, | |
/** | |
* The presence of an "editing" property on a top-level editor controls | |
* whether or not it can be used for editing, in which case it will take an | |
* initial reference to its value object as state. | |
*/ | |
isEditable: function() { | |
return (this.isTopLevel() && typeof this.props.editing != 'undefined') | |
}, | |
getInitialState: function() { | |
var initialState = {adding: false} | |
if (this.isEditable()) { | |
initialState.value = this.props.value | |
} | |
return initialState | |
}, | |
/** | |
* If an editor is being used to edit an object, we need to keep its state | |
* up to date with any prop changes. | |
*/ | |
componentWillReceiveProps: function(newProps) { | |
if (this.isEditable() && newProps.value !== this.props.value) { | |
this.setState({value: newProps.value}) | |
} | |
}, | |
shouldComponentUpdate: function(nextProps, nextState) { | |
return (ALWAYS_UPDATE_COMPONENTS || | |
nextProps.editing !== this.props.editing || // switching modes | |
this.isEditable() && nextState.value !== this.state.value || // editable value updated | |
nextProps.value !== this.props.value || // display value updated | |
nextState.adding != this.state.adding) // adding flag toggled | |
}, | |
/** | |
* Child editors will bubble up objects representing state changes in the | |
* format React.addons.update expects. Top-level components are responsible | |
* for applying the state changes. | |
*/ | |
onChange: function(update) { | |
if (this.isTopLevel()) { | |
var newState = React.addons.update(this.state, {value: update}) | |
this.setState(newState) | |
if (this.props.onChange) { | |
this.props.onChange(newState.value) | |
} | |
} | |
else { | |
this.props.onChange(makeObj(this.props.prop, update)) | |
} | |
}, | |
/** | |
* Getter for the object being dispalyed/edited, as top-level containers hold | |
* the object as state. | |
*/ | |
getValue: function() { | |
return (this.state.value || this.props.value) | |
}, | |
toggleAdding: function() { | |
this.setState({adding: !this.state.adding}) | |
} | |
} | |
var ObjectEditor = React.createClass({ | |
mixins: [ContainerEditorMixin], | |
propTypes: { | |
value: React.PropTypes.object | |
}, | |
handleAdd: function(newProp, obj) { | |
this.setState({adding: false}, function() { | |
this.onChange(makeObj(newProp, {$set: obj})) | |
}.bind(this)) | |
}, | |
validateProp: function(prop) { | |
return (prop && !Object.prototype.hasOwnProperty.call(this.getValue(), prop)) | |
}, | |
render: function() { | |
return <table className="object"><tbody> | |
<tr className="brace"> | |
<td colSpan="2"> | |
{'{ '} | |
{this.props.editing && (this.state.adding | |
? <AddProperty | |
onAdd={this.handleAdd} | |
onCancel={this.toggleAdding} | |
placeholder="prop name" | |
onValidateProp={this.validateProp} | |
/> | |
: <button type="button" onClick={this.toggleAdding}>+</button> | |
)} | |
</td> | |
</tr> | |
{this.renderProps()} | |
<tr className="brace"><td colSpan="2">}</td></tr> | |
</tbody></table> | |
}, | |
renderProps: function() { | |
var obj = this.getValue() | |
var rendered = [] | |
Object.keys(obj).forEach(function(prop) { | |
var value = obj[prop] | |
var Editor = getEditorCtor(value) | |
rendered.push(<tr className="line"> | |
<td className="prop">{prop}</td> | |
<td className="value"> | |
<Editor prop={prop} | |
value={value} | |
editing={this.props.editing} | |
onChange={this.onChange} /> | |
</td> | |
</tr>) | |
}, this) | |
return rendered | |
} | |
}) | |
var ArrayEditor = React.createClass({ | |
mixins: [ContainerEditorMixin], | |
propTypes: { | |
value: React.PropTypes.array | |
}, | |
handleAdd: function(index, obj) { | |
this.setState({adding: false}, function() { | |
index = (index == '' ? this.getValue().length : Number(index)) | |
this.onChange({$splice: [[index, 0, obj]]}) | |
}.bind(this)) | |
}, | |
validateIndex: function(index) { | |
if (/^\d+$/.test(index)) { | |
return (Number(index) <= this.getValue().length) | |
} | |
return (index == '') | |
}, | |
render: function() { | |
return <table className="array"><tbody> | |
<tr className="brace"> | |
<td colSpan="2"> | |
[ | |
{this.props.editing && (this.state.adding | |
? <AddProperty | |
onAdd={this.handleAdd} | |
onCancel={this.toggleAdding} | |
placeholder="index" | |
defaultProp={String(this.getValue().length)} | |
onValidateProp={this.validateIndex} | |
/> | |
: <button type="button" onClick={this.toggleAdding}>+</button> | |
)} | |
</td> | |
</tr> | |
{this.renderProps()} | |
<tr className="brace"><td colSpan="2">]</td></tr> | |
</tbody></table> | |
}, | |
renderProps: function() { | |
var arr = this.getValue() | |
var rendered = [] | |
for (var i = 0, l = arr.length; i < l; i++) { | |
var value = arr[i] | |
var Editor = getEditorCtor(value) | |
rendered.push(<tr className="line"> | |
<td className="prop">{i}</td> | |
<td className="value"> | |
<Editor prop={i} | |
value={value} | |
editing={this.props.editing} | |
onChange={this.onChange} /> | |
</td> | |
</tr>) | |
} | |
return rendered | |
} | |
}) | |
// Value editors | |
/** | |
* Mixin for editors which can't be top-level containers (value objects). | |
*/ | |
var ValueEditorMixin = { | |
propTypes: { | |
editing: React.PropTypes.bool, | |
onChange: React.PropTypes.func | |
} | |
} | |
var BooleanEditor = React.createClass({ | |
mixins: [ValueEditorMixin], | |
propTypes: { | |
value: React.PropTypes.bool | |
}, | |
onChange: function(e) { | |
this.props.onChange(makeObj(this.props.prop, {$set: e.target.checked})) | |
}, | |
render: function() { | |
if (!this.props.editing) { | |
return <div className="boolean">{new Boolean(this.props.value).toString()}</div> | |
} | |
return <div className="boolean"> | |
<input type="checkbox" checked={this.props.value} onChange={this.onChange}/> | |
</div> | |
} | |
}) | |
var DateEditor = React.createClass({ | |
mixins: [ValueEditorMixin], | |
propTypes: { | |
value: React.PropTypes.instanceOf(Date) | |
}, | |
getInitialState: function(date) { | |
date = date || this.props.value | |
return { | |
errorMessage: null, | |
input: date.toISOString().substring(0, 10) | |
} | |
}, | |
componentWillReceiveProps: function(newProps) { | |
if (newProps.value !== this.props.value) { | |
this.setState(this.getInitialState(newProps.value)) | |
} | |
}, | |
onChange: function(e) { | |
this.setState({input: e.target.value}, function() { | |
var errorMessage = null | |
try { | |
var newDate = new Date(this.state.input) | |
} | |
catch (e) { | |
errorMessage = e.message | |
} | |
if (errorMessage === null && | |
(isNaN(newDate) || newDate.toString() == 'Invalid Date')) { | |
errorMessage = 'Invalid Date' | |
} | |
if (errorMessage === null) { | |
this.props.onChange(makeObj(this.props.prop, {$set: newDate})) | |
} | |
else { | |
this.setState({errorMessage: errorMessage}) | |
} | |
}.bind(this)) | |
}, | |
render: function() { | |
if (!this.props.editing) { | |
return <div className="date">{this.state.input}</div> | |
} | |
return <div className="date"> | |
<input type="date" value={this.state.input} onChange={this.onChange}/> | |
{this.state.errorMessage && <p className="error">{this.state.errorMessage}</p>} | |
</div> | |
} | |
}) | |
var NumberEditor = React.createClass({ | |
mixins: [ValueEditorMixin], | |
propTypes: { | |
value: React.PropTypes.number | |
}, | |
getInitialState: function(num) { | |
num = num || this.props.value | |
return { | |
errorMessage: null, | |
input: num | |
} | |
}, | |
componentWillReceiveProps: function(newProps) { | |
if (newProps.value !== this.props.value) { | |
this.setState(this.getInitialState(newProps.value)) | |
} | |
}, | |
onChange: function(e) { | |
this.setState({input: e.target.value}, function() { | |
var newNumber = Number(this.state.input) | |
if (!isNaN(newNumber)) { | |
this.props.onChange(makeObj(this.props.prop, {$set: newNumber})) | |
} | |
else { | |
this.setState({errorMessage: 'Not a number'}) | |
} | |
}.bind(this)) | |
}, | |
render: function() { | |
if (!this.props.editing) { | |
return <div className="number">{this.state.input}</div> | |
} | |
return <div className="number"> | |
<input type="number" step="any" value={this.state.input} onChange={this.onChange}/> | |
{this.state.errorMessage && <p className="error">{this.state.errorMessage}</p>} | |
</div> | |
} | |
}) | |
var RegExpEditor = React.createClass({ | |
mixins: [ValueEditorMixin], | |
propTypes: { | |
value: React.PropTypes.instanceOf(RegExp) | |
}, | |
getInitialState: function(re) { | |
re = re || this.props.value | |
return { | |
g: re.global, | |
i: re.ignoreCase, | |
m: re.multiline, | |
source: re.source, | |
errorMessage: null | |
} | |
}, | |
componentWillReceiveProps: function(newProps) { | |
if (newProps.value !== this.props.value) { | |
this.setState(this.getInitialState(newProps.value)) | |
} | |
}, | |
onChange: function(e) { | |
var stateChange = {errorMessage: null} | |
if (e.target.name == 'source') { | |
stateChange.source = e.target.value | |
} | |
else { | |
stateChange[e.target.name] = e.target.checked | |
} | |
this.setState(stateChange, function() { | |
try { | |
var newRegExp = new RegExp(this.state.source, this.getFlags()) | |
this.props.onChange(makeObj(this.props.prop, {$set: newRegExp})) | |
} | |
catch (e) { | |
this.setState({errorMessage: e.message}) | |
} | |
}.bind(this)) | |
}, | |
getFlags: function() { | |
var flags = [] | |
if (this.state.g) flags.push('g') | |
if (this.state.i) flags.push('i') | |
if (this.state.m) flags.push('m') | |
return flags.join('') | |
}, | |
render: function() { | |
if (!this.props.editing) { | |
return <div className="regexp">/{this.state.source}/{this.getFlags()}</div> | |
} | |
return <div className="regexp"> | |
/<input type="text" name="source" value={this.state.source} onChange={this.onChange}/>/ | |
<label><input type="checkbox" name="g" checked={this.state.g} onChange={this.onChange}/> g</label> | |
<label><input type="checkbox" name="i" checked={this.state.i} onChange={this.onChange}/> i</label> | |
<label><input type="checkbox" name="m" checked={this.state.m} onChange={this.onChange}/> m</label> | |
{this.state.errorMessage && <p className="error">{this.state.errorMessage}</p>} | |
</div> | |
} | |
}) | |
var StringEditor = React.createClass({ | |
mixins: [ValueEditorMixin], | |
propTypes: { | |
value: React.PropTypes.string | |
}, | |
onChange: function(e) { | |
this.props.onChange(makeObj(this.props.prop, {$set: e.target.value})) | |
}, | |
render: function() { | |
if (!this.props.editing) { | |
return <div className="string">{this.props.value}</div> | |
} | |
return <div className="string"> | |
<input type="text" value={this.props.value} onChange={this.onChange}/> | |
</div> | |
} | |
}) | |
// Other components | |
var AddProperty = React.createClass({ | |
propTypes: { | |
defaultProp: React.PropTypes.string | |
, onAdd: React.PropTypes.func.isRequired | |
, onCancel: React.PropTypes.func.isRequired | |
, onValidateProp: React.PropTypes.func | |
, placeholder: React.PropTypes.string | |
}, | |
getDefaultProps: function() { | |
return { | |
placeholder: '' | |
, onValidateProp: function(prop) { return true } | |
} | |
}, | |
getInitialState: function() { | |
return { | |
prop: this.props.defaultProp || '' | |
, type: Object.keys(TYPE_TO_FACTORY)[0] | |
, hasChanged: false | |
} | |
}, | |
componentDidMount: function() { | |
this.refs.prop.getDOMNode().focus() | |
}, | |
shouldComponentUpdate: function(nextProps, nextState) { | |
return (this.state !== nextState) | |
}, | |
handleChange: function(e) { | |
var el = e.target | |
var change = makeObj(el.name, {$set: el.value}) | |
if (!this.state.hasChanged) { | |
change.hasChanged = {$set: true} | |
} | |
var newState = React.addons.update(this.state, change) | |
this.setState(newState) | |
}, | |
handleKeyDown: function(e) { | |
if (e.key == 'Enter') { | |
this.handleAdd() | |
} | |
else if (e.key == 'Escape') { | |
this.handleCancel() | |
} | |
}, | |
handleAdd: function() { | |
if (this.props.onValidateProp(this.state.prop)) { | |
this.props.onAdd(this.state.prop, TYPE_TO_FACTORY[this.state.type]()) | |
} | |
}, | |
handleCancel: function() { | |
this.props.onCancel() | |
}, | |
render: function() { | |
return <span onKeyDown={this.handleKeyDown}> | |
<input type="text" name="prop" ref="prop" value={this.state.prop} | |
className={cx({invalid: this.state.hasChanged && !this.props.onValidateProp(this.state.prop)})} | |
placeholder={this.props.placeholder} | |
onChange={this.handleChange} | |
/>{' '} | |
<select name="type" selectedValue={this.state.type} onChange={this.handleChange}> | |
{Object.keys(TYPE_TO_FACTORY).map(function(type) { | |
return <option value={type}>{type}</option> | |
})} | |
</select>{' '} | |
<button type="button" onClick={this.handleAdd}>+</button>{' '} | |
<button type="button" onClick={this.handleCancel}>×</button> | |
</span> | |
} | |
}) | |
var TYPE_TO_EDITOR = { | |
array: ArrayEditor | |
, boolean: BooleanEditor | |
, date: DateEditor | |
, number: NumberEditor | |
, object: ObjectEditor | |
, regexp: RegExpEditor | |
, string: StringEditor | |
} | |
var TYPE_TO_FACTORY = { | |
array: function() { return [] } | |
, boolean: function() { return false } | |
, date: function() { return new Date() } | |
, number: function() { return 0 } | |
, object: function() { return {} } | |
, regexp: function() { return new RegExp('') } | |
, string: function() { return '' } | |
} | |
var sampleObject = { | |
array: [true, new Date(), 123, /[a-z]\d{9}/i, 'abc'] | |
, boolean: true | |
, date: new Date() | |
, number: 123 | |
, object: { | |
array: [true, new Date(), 123, /[a-z]\d{9}/i, 'abc'] | |
, boolean: true | |
, date: new Date() | |
, number: 123 | |
, regexp: /[a-z]\d{9}/i | |
, string: 'abc' | |
} | |
, regexp: /[a-z]\d{9}/i | |
, string: 'abc' | |
} | |
var App = React.createClass({ | |
getInitialState: function() { | |
return { | |
editing: true | |
, sampleObject: this.props.object | |
, editedObject: this.props.object | |
} | |
}, | |
onToggleEditing: function(e) { | |
this.setState({editing: e.target.checked}) | |
}, | |
onChange: function(editedObject) { | |
this.setState({editedObject: editedObject}) | |
}, | |
render: function() { | |
return <div> | |
<div className="floatainer"> | |
<div className="editor"> | |
<h2>Empty Object</h2> | |
<ObjectEditor value={{}} editing={true}/> | |
</div> | |
</div> | |
<hr/> | |
<div className="floatainer"> | |
<div className="editor"> | |
<h2>Interactive <small><label><input type="checkbox" onChange={this.onToggleEditing} checked={this.state.editing}/> Edit</label></small></h2> | |
<ObjectEditor value={this.state.sampleObject} editing={this.state.editing} onChange={this.onChange}/> | |
</div> | |
<div className="editor"> | |
<h2>onChange</h2> | |
<ObjectEditor value={this.state.editedObject}/> | |
</div> | |
<div className="editor"> | |
<h2>Original</h2> | |
<ObjectEditor value={this.state.sampleObject}/> | |
</div> | |
</div> | |
</div> | |
} | |
}) | |
React.renderComponent(<App object={sampleObject}/>, document.getElementById('app')) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html> | |
<head> | |
<meta charset="utf-8"> | |
<title>ObjectEditor</title> | |
<script src="http://fb.me/react-with-addons-0.11.1.min.js"></script> | |
<script src="http://fb.me/JSXTransformer-0.11.1.js"></script> | |
<style> | |
body { | |
font-family: consolas, monospace; | |
} | |
.editor { | |
float: left; | |
} | |
.floatainer { | |
overflow: hidden; | |
} | |
input.invalid { | |
color: #f00; | |
} | |
td { | |
vertical-align: top; | |
} | |
td.prop { | |
text-align: right; | |
padding-right: 5px; | |
} | |
.error { | |
color: #f00; | |
} | |
.line { | |
background-color:rgba(114, 191, 131, 0.1); | |
} | |
</style> | |
</head> | |
<body> | |
<h1><ObjectEditor></h1> | |
<div id="app"></div> | |
<script type="text/jsx" src="app.jsx"></script> | |
<a href="https://gist.github.com/insin/8936b2390c8e06892998"><img style="position: absolute; top: 0; right: 0; border: 0;" src="https://s3.amazonaws.com/github/ribbons/forkme_right_darkblue_121621.png" alt="Fork me on GitHub"></a> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment