Skip to content

Instantly share code, notes, and snippets.

@richardbutler
Last active August 29, 2015 13:59
Show Gist options
  • Save richardbutler/eb197c28f9c5a3c275ff to your computer and use it in GitHub Desktop.
Save richardbutler/eb197c28f9c5a3c275ff to your computer and use it in GitHub Desktop.
Catalyst - data binding for React
//------------------------------------------------------------------------------
//
// Dependencies
//
//------------------------------------------------------------------------------
var Emitter = require('component-emitter');
var Bindable = require('./Bindable');
//------------------------------------------------------------------------------
//
// Constructor
//
//------------------------------------------------------------------------------
function ArrayList(source) {
this.reset(source);
}
//------------------------------------------------------------------------------
//
// Static methods
//
//------------------------------------------------------------------------------
ArrayList.isArrayList = function(value) {
return value instanceof ArrayList;
};
//------------------------------------------------------------------------------
//
// Events
//
//------------------------------------------------------------------------------
/**
* Event: any change happened - add, remove, reset, refresh, update.
* @type {string}
*/
ArrayList.CHANGE = 'change';
/**
* Event: an item, or collection of items, was added.
* @type {string}
*/
ArrayList.ADD = 'add';
/**
* Event: an item, or collection of items, was removed.
* @type {string}
*/
ArrayList.REMOVE = 'remove';
/**
* Event: the collection was reset with new data.
* @type {string}
*/
ArrayList.RESET = 'reset';
/**
* Event: an item within the collection was changed in some way.
* @type {string}
*/
ArrayList.UPDATE = 'update';
//------------------------------------------------------------------------------
//
// ArrayList prototype
//
//------------------------------------------------------------------------------
ArrayList.prototype = Object.create(null, {
//--------------------------------------------------------------------------
//
// Public properties
//
//--------------------------------------------------------------------------
source: {
writable: true,
enumerable: true
},
//--------------------------------------------------------------------------
//
// Public methods
//
//--------------------------------------------------------------------------
add: {
writable: false,
value: function(items, index) {
var list = this;
items = toArray(items);
items
.filter(Bindable.isBindable)
.forEach(function(item) {
item.on(Bindable.CHANGE, list.onItemChange, list);
});
var args = [typeof index === 'undefined' ? this.source.length : index, 0].concat(items);
this.source.splice.apply(this.source, args);
this.emitChange({
type: ArrayList.ADD,
items: items
});
}
},
push: {
writable: false,
value: function() {
if (arguments.length) {
this.add([].slice.call(arguments));
}
}
},
unshift: {
writable: false,
value: function() {
if (arguments.length) {
this.add([].slice.call(arguments), 0);
}
}
},
remove: {
writable: false,
value: function(items) {
var list = this;
var source = this.source;
items = toArray(items).slice();
items.forEach(function(item) {
if (Bindable.isBindable(item)) {
item.off(Bindable.CHANGE, list.onItemChange, list);
}
source.splice(source.indexOf(item), 1);
});
this.emitChange({
type: ArrayList.REMOVE,
items: items
});
}
},
pop: {
writable: false,
value: function() {
var item;
if (this.length) {
item = this.last();
this.remove(item);
}
return item;
}
},
shift: {
writable: false,
value: function() {
var item;
if (this.length) {
item = this.first();
this.remove(item);
}
return item;
}
},
removeAll: {
writable: false,
value: function() {
this.remove(this.source);
}
},
reset: {
writable: false,
value: function(items) {
items = toArray(items);
this.source = items.slice();
this.emitChange({ type: ArrayList.RESET });
}
},
at: {
writable: false,
value: function(index) {
return this.source[index];
}
},
first: {
writable: false,
value: function() {
return this.at(0);
}
},
last: {
writable: false,
value: function() {
return this.length ? this.at(this.length - 1) : undefined;
}
},
contains: {
writable: false,
value: function(item) {
return this.indexOf(item) >= 0;
}
},
indexOf: {
writable: false,
value: function(item) {
return this.source.indexOf(item);
}
},
length: {
get: function() {
return this.source ? this.source.length : 0;
}
},
toArray: {
writable: false,
value: function() {
return this.source.slice();
}
},
emitChange: {
writable: false,
value: function(event) {
this.emit(event);
this.emit({
type: ArrayList.CHANGE,
relatedEvent: event
});
}
},
//--------------------------------------------------------------------------
//
// Event handlers
//
//--------------------------------------------------------------------------
onItemChange: {
writable: false,
value: function(event) {
this.emit({
type: ArrayList.UPDATE,
relatedEvent: event
});
this.emit({
type: ArrayList.CHANGE,
relatedEvent: event
});
}
}
});
function proxyMethod(name) {
Object.defineProperty(ArrayList.prototype, name, {
writable: false,
value: function() {
return Array.prototype[name].apply(this.source, arguments);
}
});
}
// Proxy methods to underlying source array
['forEach', 'map', 'filter', 'reduce', 'join'].forEach(proxyMethod);
// Mixin the Emitter
Emitter(ArrayList.prototype);
// Seal the prototype
Object.seal(ArrayList.prototype);
function toArray(items) {
if (!items) return [];
return Array.isArray(items) ? items : [items];
}
module.exports = ArrayList;
var chai = require('chai');
var expect = chai.expect;
var sinon = require('sinon');
var C = require('..');
chai.config.includeStack = true;
describe('ArrayList', function() {
describe('bindings', function() {
it('should detect simple changes', function() {
var Model = C.Bindable.define({
name: { writable: true }
});
var foo = new Model({ name: 'foo' });
var bar = new Model({ name: 'bar' });
var baz = new Model({ name: 'baz' });
var list = new C.ArrayList([foo, bar]);
var add = sinon.spy();
var change = sinon.spy();
var update = sinon.spy();
expect(list.at(0)).to.equal(foo);
expect(list.at(1)).to.equal(bar);
expect(list.length).to.equal(2);
list.on(C.ArrayList.ADD, add);
list.on(C.ArrayList.CHANGE, change);
list.on(C.ArrayList.UPDATE, update);
list.add(baz);
expect(list.length).to.equal(3);
baz.name = 'BAZ';
sinon.assert.calledOnce(add);
sinon.assert.calledWith(add);
sinon.assert.calledTwice(change);
sinon.assert.calledOnce(update);
});
});
describe('methods', function() {
var empty, list, add, remove, change, reset, refresh;
beforeEach(function() {
add = sinon.spy();
remove = sinon.spy();
change = sinon.spy();
reset = sinon.spy();
refresh = sinon.spy();
empty = new C.ArrayList();
list = new C.ArrayList(['one', 'two', 'three']);
list.on(C.ArrayList.ADD, add);
list.on(C.ArrayList.REMOVE, remove);
list.on(C.ArrayList.CHANGE, change);
list.on(C.ArrayList.RESET, reset);
list.on(C.ArrayList.REFRESH, refresh);
});
it('first', function() {
expect(empty.first()).to.be.undefined;
expect(list.first()).to.equal('one');
});
it('last', function() {
expect(empty.last()).to.be.undefined;
expect(list.last()).to.equal('three');
});
it('filter', function() {
expect(list.filter(function(d) { return d === 'two' })[0]).to.equal('two');
});
it('length', function() {
expect(empty.length).to.equal(0);
expect(list.length).to.equal(3);
});
it('contains', function() {
expect(empty.contains('foo')).to.be.false;
expect(list.contains('one')).to.be.true;
expect(list.contains('ONE')).to.be.false;
});
it('at', function() {
expect(empty.at(17)).to.be.undefined;
expect(list.at(0)).to.equal('one');
expect(list.at(2)).to.equal('three');
});
describe('add', function() {
var items = ['four', 'five'];
it('to end', function() {
list.add(items);
expect(list.length).to.equal(5);
sinon.assert.calledWith(add, {
type: C.ArrayList.ADD,
items: items,
target: list
});
sinon.assert.calledOnce(change);
});
it('at index', function() {
list.add(items, 1);
expect(list.join(',')).to.equal(['one', 'four', 'five', 'two', 'three'].join(','));
sinon.assert.calledWith(add, {
type: C.ArrayList.ADD,
items: items,
target: list
});
sinon.assert.calledOnce(change);
});
});
it('remove', function() {
var items = ['four', 'five'];
list.add(items);
expect(list.length).to.equal(5);
sinon.assert.calledWith(add, {
type: C.ArrayList.ADD,
items: items,
target: list
});
sinon.assert.calledOnce(change);
});
it('removeAll', function() {
var items = list.toArray();
list.removeAll();
expect(list.length).to.equal(0);
sinon.assert.calledWith(remove, {
type: C.ArrayList.REMOVE,
items: items,
target: list
});
sinon.assert.calledOnce(change);
});
it('push', function() {
list.push('four', 'five');
expect(list.length).to.equal(5);
expect(list.last()).to.equal('five');
sinon.assert.calledOnce(add);
sinon.assert.calledOnce(change);
});
it('pop', function() {
expect(list.pop()).to.equal('three');
expect(list.join(',')).to.equal(['one', 'two'].join(','));
sinon.assert.calledOnce(remove);
sinon.assert.calledOnce(change);
});
it('shift', function() {
expect(list.shift()).to.equal('one');
expect(list.join(',')).to.equal(['two', 'three'].join(','));
sinon.assert.calledOnce(remove);
sinon.assert.calledOnce(change);
});
it('unshift', function() {
list.unshift('minusone', 'zero');
expect(list.length).to.equal(5);
expect(list.first()).to.equal('minusone');
expect(list.last()).to.equal('three');
sinon.assert.calledOnce(add);
sinon.assert.calledOnce(change);
});
});
});
var Emitter = require('component-emitter');
var ClassRegistry = require('./ClassRegistry');
var proto = {
eventsSuspended: {
value: false,
enumerable: true
},
emitChange: {
value: function(property, newValue, oldValue) {
if (this.eventsSuspended || oldValue === newValue) return;
this.emit({
type: Bindable.CHANGE,
property: property,
newValue: newValue,
oldValue: oldValue
});
},
enumerable: true
},
watch: {
value: function(property, handler, context) {
this.on(Bindable.CHANGE, function(event) {
if (event.property === property) {
handler.apply(context || this, arguments);
}
}, context);
},
enumerable: true
},
onChildChange: {
value: function(event) {
var model = this;
var property = Object.keys(this.__values__).filter(function(key) {
return event.target === model[key];
})[0];
if (event.property) {
property += '.' + event.property;
}
this.emit({
type: Bindable.CHANGE,
property: property,
relatedEvent: event
});
},
enumerable: true
}
};
var Bindable = {
CHANGE: 'change',
isBindable: function(obj) {
// Duck typing
return obj && (typeof obj.__values__ !== 'undefined');
},
fromObject: function(values) {
var config = Object.keys(values).reduce(function(config, key) {
config[key] = { enumerable: true, writable: true, defaultValue: values[key] };
return config;
}, {});
var Model = Bindable.define(config);
return new Model(values);
},
define: function() {
var className = arguments.length >= 2 ? arguments[0] : undefined;
var schema = arguments[arguments.length - 1];
// Collate default values
var defaults = Object.keys(schema).reduce(function(defaults, key) {
defaults[key] = schema[key].defaultValue;
return defaults;
}, {});
function ObjectProxy(attributes) {
var bindable = this;
Object.defineProperty(this, '__values__', {
writable: false,
enumerable: true,
value: {}
});
// Suspend events
this.eventsSuspended = true;
function assign(attributes) {
if (typeof attributes === 'object') {
for (var key in attributes) {
if (attributes.hasOwnProperty(key)) {
bindable[key] = attributes[key];
}
}
}
}
// Set default values, without triggering events
assign(defaults);
// Set passed initial values, without triggering events
assign(attributes);
// Events are okay again
this.eventsSuspended = false;
}
// Set constructor
ObjectProxy.prototype.constructor = ObjectProxy;
// Mixin the Emitter
Emitter(ObjectProxy.prototype);
// Create additional properties
Object.defineProperties(ObjectProxy.prototype, proto);
// Attach event triggers to getters and setters
Object.keys(schema).forEach(function(key) {
var keyConfig = schema[key];
Bindable.defineProperty(ObjectProxy.prototype, key, keyConfig);
});
// Seal the prototype
Object.seal(ObjectProxy.prototype);
// Assign className, if passed
if (className) {
ObjectProxy.className = className;
ClassRegistry.register(className, ObjectProxy);
}
return ObjectProxy;
},
defineProperty: function(obj, key, keyConfig) {
// Don't rewrite read-only properties
if (keyConfig.writable === false) return;
// If we're writable, remove the key as we're rewriting the setter
delete keyConfig.writable;
var setter = keyConfig['set'];
keyConfig['set'] = function(value) {
var oldValue = this[key];
if (isBindableObject(oldValue)) {
oldValue.off(Bindable.CHANGE, this.onChildChange, this);
}
if (typeof setter === 'function') {
setter.apply(this, arguments);
} else {
this.__values__[key] = value;
}
if (isBindableObject(value)) {
value.on(Bindable.CHANGE, this.onChildChange, this);
}
this.emitChange(key, value, oldValue);
};
keyConfig['get'] = keyConfig['get'] || function() {
return this.__values__[key];
};
// Kill the default value and assign it as a default
if (keyConfig.value) {
// We're creating a property at define-time
keyConfig.defaultValue = keyConfig.value;
}
delete keyConfig.value;
// Define property from config
Object.defineProperty(obj, key, keyConfig);
if (obj.__values__) {
// We're adding a dynamic property at runtime
obj[key] = keyConfig.defaultValue;
}
}
};
/**
* Naively check if an object is bindable - for now, just duck-typing against
* event dispatching capabilities is enough.
*
* @param {object} value
* @return {boolean}
*/
function isBindableObject(value) {
return value && typeof value.on === 'function';
}
module.exports = Bindable;
var chai = require('chai');
var expect = chai.expect;
var sinon = require('sinon');
var _ = require('lodash');
var Bindable = require('..').Bindable;
var ClassRegistry = require('..').ClassRegistry;
chai.config.includeStack = true;
describe('Bindable', function() {
afterEach(function() {
ClassRegistry.clear();
});
describe('simple binding', function() {
var Model;
var model;
beforeEach(function() {
Model = Bindable.define('Model', {
foo: { writable: true }
});
model = new Model({ foo: 'bar' });
});
it('should add the class to the registry', function() {
expect(Model.className).to.equal('Model');
expect(ClassRegistry.getClass('Model')).to.equal(Model);
});
it('should detect simple changes', function() {
var change = sinon.spy();
expect(model.foo).to.equal('bar');
var event = {
type: Bindable.CHANGE,
property: 'foo',
newValue: 'baz',
oldValue: 'bar',
target: model
};
model.on(Bindable.CHANGE, change);
model.foo = 'baz';
sinon.assert.calledWith(change, event);
});
it('shouldn\'t fire if the value is the same', function() {
var changeFoo = sinon.spy();
expect(model.foo).to.equal('bar');
model.on(Bindable.CHANGE, changeFoo);
model.foo = 'baz';
expect(model.foo).to.equal('baz');
sinon.assert.calledOnce(changeFoo);
model.foo = 'baz';
sinon.assert.calledOnce(changeFoo);
});
});
describe('child binding', function() {
var User;
var model;
var john, paul;
beforeEach(function() {
User = Bindable.define({
name: { defaultValue: 'John' },
age: { defaultValue: 30 }
});
john = new User();
paul = new User({ name: 'Paul', age: 35 });
model = Bindable.fromObject({
user: john
});
});
it('should remove event handlers when another value is assigned', function() {
expect(john.callbacks['change']).to.be.an('array');
expect(john.callbacks['change'].length).to.equal(1);
model.user = paul;
expect(john.callbacks['change'].length).to.equal(0);
expect(paul.callbacks['change']).to.be.an('array');
expect(paul.callbacks['change'].length).to.equal(1);
});
it('should receive events from child objects', function() {
var change = sinon.spy();
model.on(Bindable.CHANGE, change);
model.user.age = 32;
sinon.assert.calledOnce(change);
});
});
});
var Bindable = require('./Bindable');
var types = {};
var ClassRegistry = {
createByType: function(type, options) {
var cls = this.getClass(type);
return new cls(options);
},
register: function(type, cls) {
types[type.toLowerCase()] = cls;
},
getClass: function(type) {
return types[type.toLowerCase()];
},
clear: function() {
types = {};
}
};
module.exports = ClassRegistry;
var chai = require('chai');
var expect = chai.expect;
var ClassRegistry = require('..').ClassRegistry;
describe('ClassRegistry', function() {
afterEach(function() {
ClassRegistry.clear();
});
it('should register a class', function() {
var Model = {};
ClassRegistry.register('Model', Model);
expect(ClassRegistry.getClass('Model')).to.equal(Model);
expect(ClassRegistry.getClass('model')).to.equal(Model);
});
});
/**
* Expose `Emitter`.
*/
module.exports = Emitter;
/**
* Initialize a new `Emitter`.
*
* @api public
*/
function Emitter(obj) {
if (obj) return mixin(obj);
}
/**
* Mixin the emitter properties.
*
* @param {Object} obj
* @return {Object}
* @api private
*/
function mixin(obj) {
for (var key in Emitter.prototype) {
obj[key] = Emitter.prototype[key];
}
return obj;
}
/**
* Listen on the given `event` with `fn`.
*
* @param {String} event
* @param {Function} fn
* @param {Object} [context]
* @return {Emitter}
* @api public
*/
Emitter.prototype.on =
Emitter.prototype.addEventListener = function(event, fn, context){
this.callbacks = this.callbacks || {};
(this.callbacks[event] = this.callbacks[event] || [])
.push({ fn: fn, context: context });
return this;
};
/**
* Adds an `event` listener that will be invoked a single
* time then automatically removed.
*
* @param {String} event
* @param {Function} fn
* @param {Object} [context]
* @return {Emitter}
* @api public
*/
Emitter.prototype.once = function(event, fn, context){
var self = this;
this.callbacks = this.callbacks || {};
function on() {
self.off(event, on);
fn.apply(context || this, arguments);
}
on.fn = fn;
this.on(event, on);
return this;
};
/**
* Remove the given callback for `event` or all
* registered callbacks.
*
* @param {String} event
* @param {Function} fn
* @param {Object} context
* @return {Emitter}
* @api public
*/
Emitter.prototype.off =
Emitter.prototype.removeListener =
Emitter.prototype.removeAllListeners =
Emitter.prototype.removeEventListener = function(event, fn, context){
this.callbacks = this.callbacks || {};
// all
if (0 == arguments.length) {
this.callbacks = {};
return this;
}
// specific event
var callbacks = this.callbacks[event];
if (!callbacks) return this;
// remove all handlers
if (1 == arguments.length) {
delete this.callbacks[event];
return this;
}
// remove specific handler
var cb;
for (var i = 0; i < callbacks.length; i++) {
cb = callbacks[i];
if (cb.fn === fn && ((!context && !cb.context) || cb.context === context)) {
callbacks.splice(i, 1);
break;
}
}
return this;
};
/**
* Emit `event` with the given args.
*
* @param {Object} event
* @return {Emitter}
*/
Emitter.prototype.emit =
Emitter.prototype.dispatchEvent = function(event){
this.callbacks = this.callbacks || {};
var callbacks = this.callbacks[event.type];
event.target = this;
if (callbacks) {
callbacks = callbacks.slice(0);
for (var i = 0, len = callbacks.length; i < len; ++i) {
var callback = callbacks[i];
callback.fn.apply(callback.context || this, arguments);
}
}
return this;
};
/**
* Return array of callbacks for `event`.
*
* @param {String} event
* @return {Array}
* @api public
*/
Emitter.prototype.listeners = function(event){
this.callbacks = this.callbacks || {};
return this.callbacks[event] || [];
};
/**
* Check if this emitter has `event` handlers.
*
* @param {String} event
* @return {Boolean}
* @api public
*/
Emitter.prototype.hasListeners = function(event){
return !! this.listeners(event).length;
};
var C = {
Bindable: require('./lib/Bindable'),
ArrayList: require('./lib/ArrayList'),
JSONReviver: require('./lib/JSONReviver'),
ClassRegistry: require('./lib/ClassRegistry')
};
if (typeof window !== 'undefined') {
window.C = C;
}
module.exports = C;
var Bindable = require('./Bindable');
var ArrayList = require('./ArrayList');
var ClassRegistry = require('./ClassRegistry');
function JSONReviver(options) {
options = options || {};
var typeKey = options.typeKey || '@class';
function resolveType(value) {
var type = value[typeKey];
return type ? ClassRegistry.getClass(type) : undefined;
}
return function revive(key, value) {
if (Array.isArray(value)) {
return new ArrayList(value);
}
if (typeof value === 'object') {
var Type = resolveType(value);
return Type ? new Type(value) : Bindable.fromObject(value);
}
return value;
};
}
module.exports = JSONReviver;
var expect = require('chai').expect;
var C = require('..');
describe('JSONReviver', function() {
it('should revive', function() {
var data = {
foo: 'bar',
bar: { name: 'baz' },
items: [
'one',
{ name: 'two', type: 'two-type' },
[{ name: 'three', number: 3}]
]
};
var json = JSON.stringify(data);
var inflated = JSON.parse(json, C.JSONReviver());
expect(C.Bindable.isBindable(inflated));
expect(C.Bindable.isBindable(inflated.bar));
expect(inflated.bar.name).to.equal('baz');
expect(inflated.items instanceof C.ArrayList);
expect(inflated.items.length).to.equal(3);
expect(C.Bindable.isBindable(inflated.items.at(2)));
expect(inflated.items.at(2) instanceof C.ArrayList);
expect(C.Bindable.isBindable(inflated.items.at(2).at(0)));
});
});
{
"name": "catalyst",
"version": "0.1.0",
"dependencies": {
"component-emitter": "^1.1.2",
"lodash": "^2.4.1"
},
"devDependencies": {
"chai": "^1.9.1",
"sinon": "^1.9.1"
}
}
/** @jsx React.DOM */
(function() {
var Person = React.createClass({
componentWillMount: function() {
this.componentWillReceiveProps(this.props);
},
componentWillReceiveProps: function(props) {
if (props.data) {
props.data.on(C.Bindable.CHANGE, this.onChange);
}
},
render: function() {
var data = this.props.data;
if (!data) {
return (
<div />
);
}
return (
<div className="preview">
<div className="avatar">
<img id="avatar" src={ data.picture } />
</div>
<div className="info">
<h3>Hi, my name is <span className="gen_first_name">{ data.name.first }</span> <span className="gen_last_name">{ data.name.last }</span></h3>
<div>
<p>
Name: <span className="gen_first_name">{ data.name.first }</span> <span className="gen_last_name">{ data.name.last }</span><br />
Gender: <span className="gen_gender">{ data.gender }</span><br />
Email: <span className="gen_email_address">{ data.email }</span><br />
Profile Photo URL: <span className="gen_profile_url"><a href={ data.picture }>{ data.picture }</a></span>
</p>
<ul>
{ data.tags.map(this.renderTag) }
</ul>
</div>
</div>
</div>
);
},
renderTag: function(tag, index) {
return (
<li key={ 'tag-' + index }>{ tag }</li>
);
},
onChange: function(event) {
console.log('change', event);
this.forceUpdate();
}
});
var person = React.renderComponent(<Person />, document.getElementById('person'));
console.log('getting json');
$.ajax({
url: 'http://api.randomuser.me/?seed=crazyBird',
type: 'get',
dataType: 'text'
})
.then(function(json) {
console.log('got json', json);
var data = JSON.parse(json, C.JSONReviver()).results.at(0).user;
C.Bindable.defineProperty(data, 'tags', {
value: new C.ArrayList(['one', 'two', 'three'])
});
window.personData = data;
console.log('revived data', data);
person.setProps({ data: data });
});
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment