southbay
Version:
Cocoa-inspired key-value coding, binding, and observing for node
367 lines (362 loc) • 10.8 kB
JavaScript
require('./polyfills.js');
var southbay = require('./base.js');
/**
* The KEYVALUEOBSERVING options ('KVO') used in message dispatching. NEW
* Indicates that a passed exports.Change Dictionary should contain the new value of an obseved key
* @type {Number}
*/
exports.NEW = 1;
/**
* Indicates that a passed exports.Change Dictionary should contain the old value of an obseved key
* @type {Number}
*/
exports.OLD = 2;
/**
* Base is an Object that implements our Cocoa inspired
* key-value-[coding|binding|observing] methods
* @param {Object} model An optional data model
* @constructor
*/
exports.Base = function(model) {
this._data_ = model || {};
this._proposedValue_ = void 0;
this._bindings_ = {};
this._proposedKeyPath_ = '';
this._proposedObject_ = null;
this._proposedBinding_ = '';
this._isBinding_ = false;
this._observers_ = {};
this._isSettingObserver_ = false;
this._proposedChange_ = null;
this._proposedObserver_ = null;
this._proposedOptions_ = null;
};
/**
* Extend a southbay class with instance methods
* @param {Object} instance An optional set of instance methods
* @param {Object=} model An optional data model
* @returns {Object} The assembled Object
*/
exports.Base.extend = function(instance, klass) {
var o = southbay.inherits(this, instance, klass);
o.extend = this.extend;
return o;
};
/**
* In order to be notified of changes to a property, an observing object
* must register with the object to be observed. Begins a chain
* @param {Object} obj Who will be doing the observing
* @returns {Object} this
* @see southbay.Base.forKeyPath
*/
exports.Base.prototype.addObserver = function(obj) {
this._isSettingObserver_ = true;
this._proposedObserver_ = obj;
return this;
};
/**
* Begin the chain in which this object will bind a method that will
* be called by another object when a specific key(path) is modified
* @param {String} binding
* @return {Object} this
*/
exports.Base.prototype.bind = function(binding) {
this._isBinding_ = true;
this._proposedBinding_ = binding;
return this;
};
/**
* A helper method that abstracts the responsibility of identifying
* and calling bindings when `key` or `keypath` are set in KVB objects
* @private
*/
exports.Base.prototype._callBound_ = function(path) {
var i, curr, arr = this._bindings_[path];
for(i = 0; i < arr.length; i++) {
curr = arr[i];
// shortest path is no options
if(!curr.options) curr.binding(this._proposedValue_);
else {
// handle falsy _proposedValue_
if(!this._proposedValue_) {
// this will need to change if more options are added
curr.binding(curr.options.placeholder || this._proposedValue_);
} else {
// there is a _proposedValue_. Pass it through a transformer if present
// if the transformer results in a falsy val, check for the placeholder
curr.binding(curr.options.transformer ? curr.options.transformer(
this._proposedValue_) || curr.options.placeholder : this._proposedValue_);
}
}
}
};
/**
* The change dictionary passed from the object with the keypath
* being observed
* @param {Object} dict
* @returns {object} this
*/
exports.Base.prototype.change = function(dict) {
this._proposedChange_ = dict;
return this;
};
/**
* For the Observable class objects, context is the method to invoke
* once a notification has been recieved
* @param {String} context A method to call
*/
exports.Base.prototype.context = function(method) {
if(this._isSettingObserver_) {
var c = {
object: this._proposedObserver_,
options: this._proposedOptions_,
context: method
};
// a keypath may have many observers
if(this._proposedKeyPath_ in this._observers_) {
this._observers_[this._proposedKeyPath_].push(c);
} else {
this._observers_[this._proposedKeyPath_] = [c];
}
this._isSettingObserver_ = false;
this._proposedObserver_ = null;
this._proposedKeyPath_ = '';
this._proposedOptions_ = null;
} else {
// execute the specified response to a notification
// passes in path, object and change dict
this[method](this._proposedKeyPath_, this._proposedObject_,
this._proposedChange_);
this._proposedKeyPath_ = '';
this._proposedObject_ = null;
this._proposedChange_ = null;
}
};
/**
* The key for a value being stored in *this* object's key-value-store via
* the setValue() method
* Checks for any registered bindings or observers and messages them
* if present
* @param {String} key The name of the key
* @see southbay.Base.setValue
*/
exports.Base.prototype.forKey = function(key) {
// observers?
if(this._observers_[key]) {
this._notifyObservers_(key);
}
this._data_[key] = this._proposedValue_;
// bindings?
if(this._bindings_[key]) {
this._callBound_(key);
}
this._proposedValue_ = void 0;
};
/**
* Traverse the keypath and get each object
* (or make blank ones) eventually setting the _proposedValue_
* at the end of the path
* Checks for any registered bindings or observers and messages them
* if present
* @param {string} path The keypath to traverse when setting a value
*/
exports.Base.prototype.forKeyPath = function(path) {
// setting an observer?
if(this._isSettingObserver_) {
this._proposedKeyPath_ = path;
return this;
}
// observers?
if(this._observers_[path]) {
this._notifyObservers_(path);
}
var key, curr = this._data_, p;
p = path.split('.');
for (key; p.length && (key = p.shift());) {
if(!p.length) {
curr[key] = this._proposedValue_;
} else if (curr[key]) {
curr = curr[key];
} else {
curr = curr[key] = {};
}
}
// bindings?
if(this._bindings_[path]) {
this._callBound_(path);
}
this._proposedValue_ = void 0;
};
/**
* A helper method that abstracts the responsibility of identifying
* and calling observers when `key` or `keypath` are set in Observable objects
* @private
*/
exports.Base.prototype._notifyObservers_ = function(path) {
var i, j, opts, arr = this._observers_[path];
for(i = 0; i < arr.length; i++) {
// handle the options, build a change dict if desired
opts = arr[i].options;
if(opts) {
this._proposedChange_ = {};
// TODO other settings for kind?
this._proposedChange_.Kind = southbay.SETTING;
for(j = 0; j < opts.length; j++) {
// NOTE use cases ATM are only OLD and NEW
switch(opts[j]) {
case southbay.NEW:
this._proposedChange_.New = this._proposedValue_;
break;
case southbay.OLD:
this._proposedChange_.Old = this.valueForKeyPath(path);
break;
default:
console.log(opts[j] + " currently unsupported");
break;
}
}
}
arr[i].object.observeValueForKeyPath(path).ofObject(
this).change(this._proposedChange_).context(arr[i].context);
// clear out the change object that we made
this._proposedChange_ = null;
}
};
/**
* An observer must posses this method in order to respond to changes
* to an observed object[keypath]. Called when the value at keypath changes
* @param {String} path
* @returns {Object} this
*/
exports.Base.prototype.observeValueForKeyPath = function(path) {
this._proposedKeyPath_ = path;
return this;
};
/**
* Continuing the chain along the observer notification
* @param {Object} obj
* @returns {Object} this
*/
exports.Base.prototype.ofObject = function(obj) {
this._proposedObject_ = obj;
return this;
};
/**
* One of a few methods serving multiple purposes in southbay.
* Any binding optons are passed in a dictionary, different from the options
* used for an observer. Valid observer values are the KEYVALUEOBSERVING types.
* Can be sent a falsy value if no change object is desired back
* @param {...number|object} KEYVALUEOBSERVING types or a dict
* @returns {undefined|object}
*/
exports.Base.prototype.options = function(dict) {
if(this._isBinding_) {
var b = {
binding: this[this._proposedBinding_].bind(this),
options: dict || null
};
// a keypath may have many bindings
if(this._proposedKeyPath_ in this._proposedObject_._bindings_) {
this._proposedObject_._bindings_[this._proposedKeyPath_].push(b);
} else {
this._proposedObject_._bindings_[this._proposedKeyPath_] = [b];
}
this._isBinding_ = false;
this._proposedObject_ = null;
this._proposedKeyPath_ = '';
} else {
if(arguments.length > 0) {
this._proposedOptions_ = Array.prototype.slice.call(arguments, 0);
}
return this;
}
};
/**
* The value that is going to be set for a key in *this* object's key-value-store.
* This method is used by chaining it to the forKey() method. Argument can be of
* any valid JS type
*
* @example
* Foo.setValue({bar:true}).forKey('baz');
*
* @param {*} value The value to be set, can be falsy
* @returns {Object} this
*/
exports.Base.prototype.setValue = function(value) {
this._proposedValue_ = value;
return this;
};
/**
* Invokes setValue:forKey for each key value pair
* @param {Object} dict The keys and values to set
*/
exports.Base.prototype.setValuesForKeys = function(dict) {
for(var p in dict) {
if(dict.hasOwnProperty(p)) this.setValue(dict[p]).forKey(p);
}
};
/**
* The object that this object will create a binding with
* @param {Object} obj
* @returns {Object} this
*/
exports.Base.prototype.toObject = function(obj) {
this._proposedObject_ = obj;
return this;
};
/**
* Remove a binding present on this object
* @param {String} binding The name of the binding
*/
exports.Base.prototype.unbind = function(binding) {
delete this._bindings_[binding];
};
/**
* Returns the value associated with a key in *this* objects key-value-store
* @param {String} key The name of the key
* @returns The value associated with the key or undefined if not found
*/
exports.Base.prototype.valueForKey = function(key) {
return this._data_[key];
};
/**
* Extract a value located at key-path relative to this objects
* key-value-store
* @param {String} path The key in the form of a dot-delimited path
* @returns the value at keypath or undefined if not found
*/
exports.Base.prototype.valueForKeyPath = function(path) {
var key, curr = this._data_, p;
p = path.split('.');
for (key; p.length && (key = p.shift());) {
if(!p.length) {
return curr[key];
} else {
curr = curr[key];
}
}
return curr;
};
/**
* Assembles and returns dict object of key:value pairs for each
* passed in key
* @param {array} arr An array of keys
* @returns {object}
*/
exports.Base.prototype.valuesForKeys = function(arr) {
var i, len, dict = {};
for (i = 0, len = arr.length; i < len; i++) {
dict[arr[i]] = this.valueForKey(arr[i]);
}
return dict;
};
/**
* The keypath to a value on <toObject> that this object wishes
* to synchronize with
* @param {String} path
* @returns {Object} this
*/
exports.Base.prototype.withKeyPath = function(path) {
this._proposedKeyPath_ = path;
return this;
};