UNPKG

ampersand-state

Version:

An observable, extensible state object with derived watchable properties.

775 lines (683 loc) 26.9 kB
/*$AMPERSAND_VERSION*/ var _ = require('underscore'); var BBEvents = require('backbone-events-standalone'); var KeyTree = require('key-tree-store'); var arrayNext = require('array-next'); var changeRE = /^change:/; function Base(attrs, options) { options || (options = {}); this.cid || (this.cid = _.uniqueId('state')); this._events = {}; 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 = {}; 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'); } else if (extraProperties) { throw new TypeError('Invalid value for extraProperties: "' + extraProperties + '"'); } } 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(', ') + '. Tried to set ' + newVal); } hasChanged = !isEqual(currentVal, newVal, attr); // enforce `setOnce` for properties if set if (def.setOnce && currentVal !== undefined && hasChanged && !initial) { throw new TypeError('Property \'' + attr + '\' 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]; if (!def) continue; isEqual = this._getCompareForType(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(_.keys(this.attributes), function (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](null, {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: { get: function () { return true; }, set: function () { } } }); // helper for creating/storing property definitions and creating // appropriate getters/setters function createPropertyDefinition(object, name, desc, isSession) { var def = object._definition[name] = {}; var type, descArray; if (_.isString(desc)) { // grab our type if all we've got is a string type = object._ensureValidType(desc); if (type) def.type = type; } else { //Transform array of ['type', required, default] to object form if (_.isArray(desc)) { descArray = desc; desc = { type: descArray[0], required: descArray[1], default: descArray[2] }; } type = object._ensureValidType(desc.type); if (type) def.type = type; if (desc.required) def.required = true; if (desc.default && typeof desc.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.default = desc.default; def.allowNull = desc.allowNull ? desc.allowNull : false; if (desc.setOnce) def.setOnce = true; if (def.required && _.isUndefined(def.default) && !def.setOnce) 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; } result = _.result(def, 'default'); this._values[name] = result; return result; } }); 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 { var dateVal = new Date(newVal).valueOf(); if (isNaN(dateVal)) { // If the newVal cant be parsed, then try parseInt first dateVal = new Date(parseInt(newVal, 10)).valueOf(); if (isNaN(dateVal)) throw TypeError; } newVal = dateVal; 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) { if (currentVal) { 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) { var omitFromExtend = [ 'dataTypes', 'props', 'session', 'derived', 'collections', 'children' ]; if (def.dataTypes) { _.each(def.dataTypes, function (def, name) { child.prototype._dataTypes[name] = def; }); } if (def.props) { _.each(def.props, function (def, name) { createPropertyDefinition(child.prototype, name, def); }); } if (def.session) { _.each(def.session, function (def, name) { createPropertyDefinition(child.prototype, name, def, true); }); } if (def.derived) { _.each(def.derived, function (def, name) { createDerivedProperty(child.prototype, name, def); }); } if (def.collections) { _.each(def.collections, function (constructor, name) { child.prototype._collections[name] = constructor; }); } if (def.children) { _.each(def.children, function (constructor, name) { child.prototype._children[name] = constructor; }); } _.extend(child.prototype, _.omit(def, omitFromExtend)); }); } 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; // Our main exports module.exports = Base;