Skip to content

Instantly share code, notes, and snippets.

@rtorr
Created June 25, 2014 15:04
Show Gist options
  • Save rtorr/7a0ba28badb533a5c9f6 to your computer and use it in GitHub Desktop.
Save rtorr/7a0ba28badb533a5c9f6 to your computer and use it in GitHub Desktop.
Browser standalone ampersand-state
(function(root, factory) {
if (typeof exports !== 'undefined') {
var _ = require('underscore');
var BBEvents = require('backbone-events-standalone');
var KeyTree = require('key-tree-store');
var arrayNext = require('array-next');
factory(root, exports, _, BBEvents, KeyTree, arrayNext);
} else {
root.AmpersandState = factory(root, {}, root._, (root.Backbone.Events || root.BackboneEvents), root.HJ_KeyTreeStore, root.HJ_arrayNext);
}
}(this, function(root, AmpersandState, _, BBEvents, KeyTree, arrayNext) {
var KeyTree = KeyTree;
var arrayNext = arrayNext;
var changeRE = /^change:/;
function Base(attrs, options) {
options || (options = {});
this.cid = _.uniqueId('state');
this._values = {};
this._definition = Object.create(this._definition);
if (options.parse) attrs = this.parse(attrs, options);
this.parent = options.parent;
this.collection = options.collection;
this._keyTree = new KeyTree();
this._initCollections();
this._initChildren();
this._cache = {};
this._previousAttributes = {};
this._events = {};
if (attrs) this.set(attrs, _.extend({silent: true, initial: true}, options));
this._changed = {};
if (this._derived) this._initDerived();
if (options.init !== false) this.initialize.apply(this, arguments);
}
_.extend(Base.prototype, BBEvents, {
// can be allow, ignore, reject
extraProperties: 'ignore',
idAttribute: 'id',
namespaceAttribute: 'namespace',
typeAttribute: 'modelType',
// Stubbed out to be overwritten
initialize: function () {
return this;
},
// Get ID of model per configuration.
// Should *always* be how ID is determined by other code.
getId: function () {
return this[this.idAttribute];
},
// Get namespace of model per configuration.
// Should *always* be how namespace is determined by other code.
getNamespace: function () {
return this[this.namespaceAttribute];
},
// Get type of model per configuration.
// Should *always* be how type is determined by other code.
getType: function () {
return this[this.typeAttribute];
},
// A model is new if it has never been saved to the server, and lacks an id.
isNew: function () {
return this.getId() == null;
},
// get HTML-escaped value of attribute
escape: function (attr) {
return _.escape(this.get(attr));
},
// Check if the model is currently in a valid state.
isValid: function (options) {
return this._validate({}, _.extend(options || {}, { validate: true }));
},
// Parse can be used remap/restructure/rename incoming properties
// before they are applied to attributes.
parse: function (resp, options) {
return resp;
},
// Serialize is the inverse of `parse` it lets you massage data
// on the way out. Before, sending to server, for example.
serialize: function () {
var res = this.getAttributes({props: true}, true);
_.each(this._children, function (value, key) {
res[key] = this[key].serialize();
}, this);
_.each(this._collections, function (value, key) {
res[key] = this[key].serialize();
}, this);
return res;
},
// Main set method used by generated setters/getters and can
// be used directly if you need to pass options or set multiple
// properties at once.
set: function (key, value, options) {
var self = this;
var extraProperties = this.extraProperties;
var triggers = [];
var changing, changes, newType, newVal, def, cast, err, attr,
attrs, dataType, silent, unset, currentVal, initial, hasChanged, isEqual;
// Handle both `"key", value` and `{key: value}` -style arguments.
if (_.isObject(key) || key === null) {
attrs = key;
options = value;
} else {
attrs = {};
attrs[key] = value;
}
options = options || {};
if (!this._validate(attrs, options)) return false;
// Extract attributes and options.
unset = options.unset;
silent = options.silent;
initial = options.initial;
changes = [];
changing = this._changing;
this._changing = true;
// if not already changing, store previous
if (!changing) {
this._previousAttributes = this.attributes;
this._changed = {};
}
// For each `set` attribute...
for (attr in attrs) {
newVal = attrs[attr];
newType = typeof newVal;
currentVal = this._values[attr];
def = this._definition[attr];
if (!def) {
// if this is a child model or collection
if (this._children[attr] || this._collections[attr]) {
this[attr].set(newVal, options);
continue;
} else if (extraProperties === 'ignore') {
continue;
} else if (extraProperties === 'reject') {
throw new TypeError('No "' + attr + '" property defined on ' + (this.type || 'this') + ' model and extraProperties not set to "ignore" or "allow"');
} else if (extraProperties === 'allow') {
def = this._createPropertyDefinition(attr, 'any');
}
}
isEqual = this._getCompareForType(def.type);
dataType = this._dataTypes[def.type];
// check type if we have one
if (dataType && dataType.set) {
cast = dataType.set(newVal);
newVal = cast.val;
newType = cast.type;
}
// If we've defined a test, run it
if (def.test) {
err = def.test.call(this, newVal, newType);
if (err) {
throw new TypeError('Property \'' + attr + '\' failed validation with error: ' + err);
}
}
// If we are required but undefined, throw error.
// If we are null and are not allowing null, throw error
// If we have a defined type and the new type doesn't match, and we are not null, throw error.
if (_.isUndefined(newVal) && def.required) {
throw new TypeError('Required property \'' + attr + '\' must be of type ' + def.type + '. Tried to set ' + newVal);
}
if (_.isNull(newVal) && def.required && !def.allowNull) {
throw new TypeError('Property \'' + attr + '\' must be of type ' + def.type + ' (cannot be null). Tried to set ' + newVal);
}
if ((def.type && def.type !== 'any' && def.type !== newType) && !_.isNull(newVal) && !_.isUndefined(newVal)) {
throw new TypeError('Property \'' + attr + '\' must be of type ' + def.type + '. Tried to set ' + newVal);
}
if (def.values && !_.contains(def.values, newVal)) {
throw new TypeError('Property \'' + attr + '\' must be one of values: ' + def.values.join(', '));
}
hasChanged = !isEqual(currentVal, newVal, attr);
// enforce `setOnce` for properties if set
if (def.setOnce && currentVal !== undefined && hasChanged) {
throw new TypeError('Property \'' + key + '\' can only be set once.');
}
// keep track of changed attributes
// and push to changes array
if (hasChanged) {
changes.push({prev: currentVal, val: newVal, key: attr});
self._changed[attr] = newVal;
} else {
delete self._changed[attr];
}
}
// actually update our values
_.each(changes, function (change) {
self._previousAttributes[change.key] = change.prev;
if (unset) {
delete self._values[change.key];
} else {
self._values[change.key] = change.val;
}
});
if (!silent && changes.length) self._pending = true;
if (!silent) {
_.each(changes, function (change) {
self.trigger('change:' + change.key, self, change.val, options);
});
}
// You might be wondering why there's a `while` loop here. Changes can
// be recursively nested within `"change"` events.
if (changing) return this;
if (!silent) {
while (this._pending) {
this._pending = false;
this.trigger('change', this, options);
}
}
this._pending = false;
this._changing = false;
return this;
},
get: function (attr) {
return this[attr];
},
// Toggle boolean properties or properties that have a `values`
// array in its definition.
toggle: function (property) {
var def = this._definition[property];
if (def.type === 'boolean') {
// if it's a bool, just flip it
this[property] = !this[property];
} else if (def && def.values) {
// If it's a property with an array of values
// skip to the next one looping back if at end.
this[property] = arrayNext(def.values, this[property]);
} else {
throw new TypeError('Can only toggle properties that are type `boolean` or have `values` array.');
}
return this;
},
// Get all of the attributes of the model at the time of the previous
// `"change"` event.
previousAttributes: function () {
return _.clone(this._previousAttributes);
},
// Determine if the model has changed since the last `"change"` event.
// If you specify an attribute name, determine if that attribute has changed.
hasChanged: function (attr) {
if (attr == null) return !_.isEmpty(this._changed);
return _.has(this._changed, attr);
},
// Return an object containing all the attributes that have changed, or
// false if there are no changed attributes. Useful for determining what
// parts of a view need to be updated and/or what attributes need to be
// persisted to the server. Unset attributes will be set to undefined.
// You can also pass an attributes object to diff against the model,
// determining if there *would be* a change.
changedAttributes: function (diff) {
if (!diff) return this.hasChanged() ? _.clone(this._changed) : false;
var val, changed = false;
var old = this._changing ? this._previousAttributes : this.attributes;
var def, isEqual;
for (var attr in diff) {
def = this._definition[attr];
isEqual = this._getCompareForType(def && def.type);
if (isEqual(old[attr], (val = diff[attr]))) continue;
(changed || (changed = {}))[attr] = val;
}
return changed;
},
toJSON: function () {
return this.serialize();
},
unset: function (attr, options) {
var def = this._definition[attr];
var type = def.type;
var val;
if (def.required) {
val = _.result(def, 'default');
return this.set(attr, val, options);
} else {
return this.set(attr, val, _.extend({}, options, {unset: true}));
}
},
clear: function (options) {
var self = this;
_.each(this.attributes, function (val, key) {
self.unset(key, options);
});
return this;
},
previous: function (attr) {
if (attr == null || !Object.keys(this._previousAttributes).length) return null;
return this._previousAttributes[attr];
},
// Get default values for a certain type
_getDefaultForType: function (type) {
var dataType = this._dataTypes[type];
return dataType && dataType.default;
},
// Determine which comparison algorithm to use for comparing a property
_getCompareForType: function (type) {
var dataType = this._dataTypes[type];
if (dataType && dataType.compare) return _.bind(dataType.compare, this);
return _.isEqual;
},
// Run validation against the next complete set of model attributes,
// returning `true` if all is well. Otherwise, fire an `"invalid"` event.
_validate: function (attrs, options) {
if (!options.validate || !this.validate) return true;
attrs = _.extend({}, this.attributes, attrs);
var error = this.validationError = this.validate(attrs, options) || null;
if (!error) return true;
this.trigger('invalid', this, error, _.extend(options || {}, {validationError: error}));
return false;
},
_createPropertyDefinition: function (name, desc, isSession) {
return createPropertyDefinition(this, name, desc, isSession);
},
// just makes friendlier errors when trying to define a new model
// only used when setting up original property definitions
_ensureValidType: function (type) {
return _.contains(['string', 'number', 'boolean', 'array', 'object', 'date', 'any'].concat(_.keys(this._dataTypes)), type) ? type : undefined;
},
getAttributes: function (options, raw) {
options || (options = {});
_.defaults(options, {
session: false,
props: false,
derived: false
});
var res = {};
var val, item, def;
for (item in this._definition) {
def = this._definition[item];
if ((options.session && def.session) || (options.props && !def.session)) {
val = (raw) ? this._values[item] : this[item];
if (typeof val === 'undefined') val = _.result(def, 'default');
if (typeof val !== 'undefined') res[item] = val;
}
}
if (options.derived) {
for (item in this._derived) res[item] = this[item];
}
return res;
},
_initDerived: function () {
var self = this;
_.each(this._derived, function (value, name) {
var def = self._derived[name];
def.deps = def.depList;
var update = function (options) {
options = options || {};
var newVal = def.fn.call(self);
if (self._cache[name] !== newVal || !def.cache) {
if (def.cache) {
self._previousAttributes[name] = self._cache[name];
}
self._cache[name] = newVal;
self.trigger('change:' + name, self, self._cache[name]);
}
};
def.deps.forEach(function (propString) {
self._keyTree.add(propString, update);
});
});
this.on('all', function (eventName) {
if (changeRE.test(eventName)) {
self._keyTree.get(eventName.split(':')[1]).forEach(function (fn) {
fn();
});
}
}, this);
},
_getDerivedProperty: function (name, flushCache) {
// is this a derived property that is cached
if (this._derived[name].cache) {
//set if this is the first time, or flushCache is set
if (flushCache || !this._cache.hasOwnProperty(name)) {
this._cache[name] = this._derived[name].fn.apply(this);
}
return this._cache[name];
} else {
return this._derived[name].fn.apply(this);
}
},
_initCollections: function () {
var coll;
if (!this._collections) return;
for (coll in this._collections) {
this[coll] = new this._collections[coll]([], {parent: this});
}
},
_initChildren: function () {
var child;
if (!this._children) return;
for (child in this._children) {
this[child] = new this._children[child]({}, {parent: this});
this.listenTo(this[child], 'all', this._getEventBubblingHandler(child));
}
},
// Returns a bound handler for doing event bubbling while
// adding a name to the change string.
_getEventBubblingHandler: function (propertyName) {
return _.bind(function (name, model, newValue) {
if (changeRE.test(name)) {
this.trigger('change:' + propertyName + '.' + name.split(':')[1], model, newValue);
} else if (name === 'change') {
this.trigger('change', this);
}
}, this);
},
// Check that all required attributes are present
_verifyRequired: function () {
var attrs = this.attributes; // should include session
for (var def in this._definition) {
if (this._definition[def].required && typeof attrs[def] === 'undefined') {
return false;
}
}
return true;
}
});
// getter for attributes
Object.defineProperties(Base.prototype, {
attributes: {
get: function () {
return this.getAttributes({props: true, session: true});
}
},
all: {
get: function () {
return this.getAttributes({
session: true,
props: true,
derived: true
});
}
},
isState: {
value: true
}
});
// helper for creating/storing property definitions and creating
// appropriate getters/setters
function createPropertyDefinition(object, name, desc, isSession) {
var def = object._definition[name] = {};
var type;
if (_.isString(desc)) {
// grab our type if all we've got is a string
type = object._ensureValidType(desc);
if (type) def.type = type;
} else {
type = object._ensureValidType(desc[0] || desc.type);
if (type) def.type = type;
if (desc[1] || desc.required) def.required = true;
// set default if defined
def.default = !_.isUndefined(desc[2]) ? desc[2] : desc.default;
if (typeof def.default === 'object') {
throw new TypeError('The default value for ' + name + ' cannot be an object/array, must be a value or a function which returns a value/object/array');
}
def.allowNull = desc.allowNull ? desc.allowNull : false;
if (desc.setOnce) def.setOnce = true;
if (def.required && _.isUndefined(def.default)) def.default = object._getDefaultForType(type);
def.test = desc.test;
def.values = desc.values;
}
if (isSession) def.session = true;
// define a getter/setter on the prototype
// but they get/set on the instance
Object.defineProperty(object, name, {
set: function (val) {
this.set(name, val);
},
get: function () {
var result = this._values[name];
var typeDef = this._dataTypes[def.type];
if (typeof result !== 'undefined') {
if (typeDef && typeDef.get) {
result = typeDef.get(result);
}
return result;
}
return _.result(def, 'default');
}
});
return def;
}
// helper for creating derived property definitions
function createDerivedProperty(modelProto, name, definition) {
var def = modelProto._derived[name] = {
fn: _.isFunction(definition) ? definition : definition.fn,
cache: (definition.cache !== false),
depList: definition.deps || []
};
// add to our shared dependency list
_.each(def.depList, function (dep) {
modelProto._deps[dep] = _(modelProto._deps[dep] || []).union([name]);
});
// defined a top-level getter for derived names
Object.defineProperty(modelProto, name, {
get: function () {
return this._getDerivedProperty(name);
},
set: function () {
throw new TypeError('"' + name + '" is a derived property, it can\'t be set directly.');
}
});
}
var dataTypes = {
string: {
default: function () {
return '';
}
},
date: {
set: function (newVal) {
var newType;
if (!_.isDate(newVal)) {
try {
newVal = new Date(parseInt(newVal, 10));
if (!_.isDate(newVal)) throw TypeError;
newVal = newVal.valueOf();
if (_.isNaN(newVal)) throw TypeError;
newType = 'date';
} catch (e) {
newType = typeof newVal;
}
} else {
newType = 'date';
newVal = newVal.valueOf();
}
return {
val: newVal,
type: newType
};
},
get: function (val) {
return new Date(val);
},
default: function () {
return new Date();
}
},
array: {
set: function (newVal) {
return {
val: newVal,
type: _.isArray(newVal) ? 'array' : typeof newVal
};
},
default: function () {
return [];
}
},
object: {
set: function (newVal) {
var newType = typeof newVal;
// we have to have a way of supporting "missing" objects.
// Null is an object, but setting a value to undefined
// should work too, IMO. We just override it, in that case.
if (newType !== 'object' && _.isUndefined(newVal)) {
newVal = null;
newType = 'object';
}
return {
val: newVal,
type: newType
};
},
default: function () {
return {};
}
},
// the `state` data type is a bit special in that setting it should
// also bubble events
state: {
set: function (newVal) {
var isInstance = newVal instanceof Base || (newVal && newVal.isState);
if (isInstance) {
return {
val: newVal,
type: 'state'
};
} else {
return {
val: newVal,
type: typeof newVal
};
}
},
compare: function (currentVal, newVal, attributeName) {
var isSame = currentVal === newVal;
// if this has changed we want to also handle
// event propagation
if (!isSame) {
this.stopListening(currentVal);
if (newVal != null) {
this.listenTo(newVal, 'all', this._getEventBubblingHandler(attributeName));
}
}
return isSame;
}
}
};
// the extend method used to extend prototypes, maintain inheritance chains for instanceof
// and allow for additions to the model definitions.
function extend(protoProps) {
var parent = this;
var child;
var args = [].slice.call(arguments);
var prop, item;
// The constructor function for the new subclass is either defined by you
// (the "constructor" property in your `extend` definition), or defaulted
// by us to simply call the parent's constructor.
if (protoProps && protoProps.hasOwnProperty('constructor')) {
child = protoProps.constructor;
} else {
child = function () {
return parent.apply(this, arguments);
};
}
// Add static properties to the constructor function from parent
_.extend(child, parent);
// Set the prototype chain to inherit from `parent`, without calling
// `parent`'s constructor function.
var Surrogate = function () { this.constructor = child; };
Surrogate.prototype = parent.prototype;
child.prototype = new Surrogate();
// set prototype level objects
child.prototype._derived = _.extend({}, parent.prototype._derived);
child.prototype._deps = _.extend({}, parent.prototype._deps);
child.prototype._definition = _.extend({}, parent.prototype._definition);
child.prototype._collections = _.extend({}, parent.prototype._collections);
child.prototype._children = _.extend({}, parent.prototype._children);
child.prototype._dataTypes = _.extend({}, parent.prototype._dataTypes || dataTypes);
// Mix in all prototype properties to the subclass if supplied.
if (protoProps) {
args.forEach(function processArg(def) {
if (def.dataTypes) {
_.each(def.dataTypes, function (def, name) {
child.prototype._dataTypes[name] = def;
});
delete def.dataTypes;
}
if (def.props) {
_.each(def.props, function (def, name) {
createPropertyDefinition(child.prototype, name, def);
});
delete def.props;
}
if (def.session) {
_.each(def.session, function (def, name) {
createPropertyDefinition(child.prototype, name, def, true);
});
delete def.session;
}
if (def.derived) {
_.each(def.derived, function (def, name) {
createDerivedProperty(child.prototype, name, def);
});
delete def.derived;
}
if (def.collections) {
_.each(def.collections, function (constructor, name) {
child.prototype._collections[name] = constructor;
});
delete def.collections;
}
if (def.children) {
_.each(def.children, function (constructor, name) {
child.prototype._children[name] = constructor;
});
delete def.children;
}
_.extend(child.prototype, def);
});
}
var toString = Object.prototype.toString;
// Set a convenience property in case the parent's prototype is needed
// later.
child.__super__ = parent.prototype;
return child;
}
Base.extend = extend;
return Base;
}));
(function(root, factory) {
if (typeof exports !== 'undefined') {
factory(root, exports);
} else {
root.HJ_arrayNext = factory(root, {});
}
}(this, function(root, HJ_arrayNext) {
HJ_arrayNext = function arrayNext(array, currentItem) {
var len = array.length;
var newIndex = array.indexOf(currentItem) + 1;
if (newIndex > (len - 1)) newIndex = 0;
return array[newIndex];
};
return HJ_arrayNext;
}));
(function(root, factory) {
if (typeof exports !== 'undefined') {
factory(root, exports);
} else {
root.HJ_KeyTreeStore = factory(root, {});
}
}(this, function(root, HJ_KeyTreeStore) {
HJ_KeyTreeStore = function() {
this.storage = {};
};
// add an object to the store
HJ_KeyTreeStore.prototype.add = function (keypath, obj) {
var arr = this.storage[keypath] || (this.storage[keypath] = []);
arr.push(obj);
};
// remove an object
HJ_KeyTreeStore.prototype.remove = function (obj) {
var path, arr;
for (path in this.storage) {
arr = this.storage[path];
arr.some(function (item, index) {
if (item === obj) {
arr.splice(index, 1);
return true;
}
});
}
};
// get array of all all relevant functions, without keys
HJ_KeyTreeStore.prototype.get = function (keypath) {
var res = [];
var key;
for (key in this.storage) {
if (!keypath || keypath === key || key.indexOf(keypath + '.') === 0) {
res = res.concat(this.storage[key]);
}
}
return res;
};
// get all results that match keypath but still grouped by key
HJ_KeyTreeStore.prototype.getGrouped = function (keypath) {
var res = {};
var key;
for (key in this.storage) {
if (!keypath || keypath === key || key.indexOf(keypath + '.') === 0) {
res[key] = slice.call(this.storage[key]);
}
}
return res;
};
// get all results that match keypath but still grouped by key
HJ_KeyTreeStore.prototype.getAll = function (keypath) {
var res = {};
var key;
for (key in this.storage) {
if (keypath === key || key.indexOf(keypath + '.') === 0) {
res[key] = slice.call(this.storage[key]);
}
}
return res;
};
// run all matches with optional context
HJ_KeyTreeStore.prototype.run = function (keypath, context) {
var args = slice.call(arguments, 2);
this.get(keypath).forEach(function (fn) {
fn.apply(context || this, args);
});
};
return HJ_KeyTreeStore;
}));
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment