UNPKG

ampersand-state

Version:

An observable, extensible state object with derived watchable properties.

858 lines (763 loc) 31.4 kB
'use strict'; /*$AMPERSAND_VERSION*/ var uniqueId = require('lodash/uniqueId'); var assign = require('lodash/assign'); var cloneObj = function(obj) { return assign({}, obj); }; var omit = require('lodash/omit'); var escape = require('lodash/escape'); var forOwn = require('lodash/forOwn'); var includes = require('lodash/includes'); var isString = require('lodash/isString'); var isObject = require('lodash/isObject'); var isDate = require('lodash/isDate'); var isFunction = require('lodash/isFunction'); var _isEqual = require('lodash/isEqual'); // to avoid shadowing var has = require('lodash/has'); var result = require('lodash/result'); var union = require('lodash/union'); var Events = require('ampersand-events'); var KeyTree = require('key-tree-store'); var arrayNext = require('array-next'); var changeRE = /^change:/; var noop = function () {}; function Base(attrs, options) { options || (options = {}); this.cid || (this.cid = uniqueId('state')); this._events = {}; this._values = {}; this._eventBubblingHandlerCache = {}; 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, assign({silent: true, initial: true}, options)); this._changed = {}; if (this._derived) this._initDerived(); if (options.init !== false) this.initialize.apply(this, arguments); } assign(Base.prototype, Events, { // 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({}, assign(options || {}, { validate: true })); }, // Parse can be used remap/restructure/rename incoming properties // before they are applied to attributes. parse: function (resp, options) { //jshint unused:false 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 (options) { var attrOpts = assign({props: true}, options); var res = this.getAttributes(attrOpts, true); var setFromSerializedValue = function (value, key) { res[key] = this[key].serialize(); }.bind(this); forOwn(this._children, setFromSerializedValue); forOwn(this._collections, setFromSerializedValue); 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 wasChanging, changeEvents, newType, newVal, def, cast, err, attr, attrs, dataType, silent, unset, currentVal, initial, hasChanged, isEqual, onChange; // 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; // Initialize change tracking. wasChanging = this._changing; this._changing = true; changeEvents = []; // if not already changing, store previous if (initial) { this._previousAttributes = {}; } else if (!wasChanging) { this._previousAttributes = this.attributes; this._changed = {}; } // For each `set` attribute... for (var i = 0, keys = Object.keys(attrs), len = keys.length; i < len; i++) { attr = keys[i]; 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]) { if (!isObject(newVal)) { newVal = {}; } 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); onChange = this._getOnChangeForType(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 we require specific value and new one is not one of them, throw error (unless it has default value or we're unsetting it with undefined). if (newVal === undefined && def.required) { throw new TypeError('Required property \'' + attr + '\' must be of type ' + def.type + '. Tried to set ' + newVal); } if (newVal === null && 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) && newVal !== null && newVal !== undefined) { throw new TypeError('Property \'' + attr + '\' must be of type ' + def.type + '. Tried to set ' + newVal); } if (def.values && !includes(def.values, newVal)) { var defaultValue = result(def, 'default'); if (unset && defaultValue !== undefined) { newVal = defaultValue; } else if (!unset || (unset && newVal !== undefined)) { throw new TypeError('Property \'' + attr + '\' must be one of values: ' + def.values.join(', ') + '. Tried to set ' + newVal); } } // We know this has 'changed' if it's the initial set, so skip a potentially expensive isEqual check. hasChanged = initial || !isEqual(currentVal, newVal, attr); // enforce `setOnce` for properties if set if (def.setOnce && currentVal !== undefined && hasChanged) { throw new TypeError('Property \'' + attr + '\' can only be set once.'); } // set/unset attributes. // If this is not the initial set, keep track of changed attributes // and push to changeEvents array so we can fire events. if (hasChanged) { // This fires no matter what, even on initial set. onChange(newVal, currentVal, attr); // If this is a change (not an initial set), mark the change. // Note it's impossible to unset on the initial set (it will already be unset), // so we only include that logic here. if (!initial) { this._changed[attr] = newVal; this._previousAttributes[attr] = currentVal; if (unset) { // FIXME delete is very slow. Can we get away with setting to undefined? delete this._values[attr]; } if (!silent) { changeEvents.push({prev: currentVal, val: newVal, key: attr}); } } if (!unset) { this._values[attr] = newVal; } } else { // Not changed // FIXME delete is very slow. Can we get away with setting to undefined? delete this._changed[attr]; } } // Fire events. This array is not populated if we are told to be silent. if (changeEvents.length) this._pending = true; changeEvents.forEach(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 (wasChanging) return this; 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 cloneObj(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 !!Object.keys(this._changed).length; if (has(this._derived, attr)) { return this._derived[attr].depList.some(function (dep) { return this.hasChanged(dep); }, this); } 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() ? cloneObj(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 (attrs, options) { var self = this; attrs = Array.isArray(attrs) ? attrs : [attrs]; attrs.forEach(function (key) { var def = self._definition[key]; if (!def) return; var val; if (def.required) { val = result(def, 'default'); return self.set(key, val, options); } else { return self.set(key, val, assign({}, options, {unset: true})); } }); }, clear: function (options) { var self = this; Object.keys(this.attributes).forEach(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 dataType.compare.bind(this); return _isEqual; // if no compare function is defined, use _.isEqual }, _getOnChangeForType : function(type){ var dataType = this._dataTypes[type]; if (dataType && dataType.onChange) return dataType.onChange.bind(this); return noop; }, // 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 = assign({}, this.attributes, attrs); var error = this.validationError = this.validate(attrs, options) || null; if (!error) return true; this.trigger('invalid', this, error, assign(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 includes(['string', 'number', 'boolean', 'array', 'object', 'date', 'state', 'any'] .concat(Object.keys(this._dataTypes)), type) ? type : undefined; }, getAttributes: function (options, raw) { options = assign({ session: false, props: false, derived: false }, options || {}); var res = {}; var val, def; for (var 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 (raw && val && isFunction(val.serialize)) val = val.serialize(); if (typeof val === 'undefined') val = result(def, 'default'); if (typeof val !== 'undefined') res[item] = val; } } if (options.derived) { for (var derivedItem in this._derived) res[derivedItem] = this[derivedItem]; } return res; }, _initDerived: function () { var self = this; forOwn(this._derived, function (value, name) { var def = self._derived[name]; def.deps = def.depList; var update = function () { 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._safeSet(coll, new this._collections[coll](null, {parent: this})); } }, _initChildren: function () { var child; if (!this._children) return; for (child in this._children) { this._safeSet(child, new this._children[child]({}, {parent: this})); this.listenTo(this[child], 'all', this._getCachedEventBubblingHandler(child)); } }, // Returns a bound handler for doing event bubbling while // adding a name to the change string. _getCachedEventBubblingHandler: function (propertyName) { if (!this._eventBubblingHandlerCache[propertyName]) { this._eventBubblingHandlerCache[propertyName] = 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); } }.bind(this); } return this._eventBubblingHandlerCache[propertyName]; }, // 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; }, // expose safeSet method _safeSet: function safeSet(property, value) { if (property in this) { throw new Error('Encountered namespace collision while setting instance property `' + property + '`'); } this[property] = value; return this; } }); // 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 (Array.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 && def['default'] === undefined && !def.setOnce) def['default'] = object._getDefaultForType(type); def.test = desc.test; def.values = desc.values; } if (isSession) def.session = true; if (!type) { type = isString(desc) ? desc : desc.type; // TODO: start throwing a TypeError in future major versions instead of warning console.warn('Invalid data type of `' + type + '` for `' + name + '` property. Use one of the default types or define your own'); } // 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 () { if (!this._values) { throw Error('You may be trying to `extend` a state object with "' + name + '" which has been defined in `props` on the object being extended'); } var value = this._values[name]; var typeDef = this._dataTypes[def.type]; if (typeof value !== 'undefined') { if (typeDef && typeDef.get) { value = typeDef.get(value); } return value; } var defaultValue = result(def, 'default'); this._values[name] = defaultValue; // If we've set a defaultValue, fire a change handler effectively marking // its change from undefined to the default value. if (typeof defaultValue !== 'undefined') { var onChange = this._getOnChangeForType(def.type); onChange(defaultValue, value, name); } return defaultValue; } }); 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 def.depList.forEach(function (dep) { modelProto._deps[dep] = union(modelProto._deps[dep] || [], [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 (newVal == null) { newType = typeof null; } else if (!isDate(newVal)) { var err = null; 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)) err = true; } newVal = dateVal; newType = 'date'; if (err) { newType = typeof newVal; } } else { newType = 'date'; newVal = newVal.valueOf(); } return { val: newVal, type: newType }; }, get: function (val) { if (val == null) { return val; } return new Date(val); }, 'default': function () { return new Date(); } }, array: { set: function (newVal) { return { val: newVal, type: Array.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' && newVal === undefined) { 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) { return currentVal === newVal; }, onChange : function(newVal, previousVal, attributeName){ // if this has changed we want to also handle // event propagation if (previousVal) { this.stopListening(previousVal, 'all', this._getCachedEventBubblingHandler(attributeName)); } if (newVal != null) { this.listenTo(newVal, 'all', this._getCachedEventBubblingHandler(attributeName)); } } } }; // the extend method used to extend prototypes, maintain inheritance chains for instanceof // and allow for additions to the model definitions. function extend(protoProps) { /*jshint validthis:true*/ var parent = this; var child; // 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 assign(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 = assign({}, parent.prototype._derived); child.prototype._deps = assign({}, parent.prototype._deps); child.prototype._definition = assign({}, parent.prototype._definition); child.prototype._collections = assign({}, parent.prototype._collections); child.prototype._children = assign({}, parent.prototype._children); child.prototype._dataTypes = assign({}, parent.prototype._dataTypes || dataTypes); // Mix in all prototype properties to the subclass if supplied. if (protoProps) { var omitFromExtend = [ 'dataTypes', 'props', 'session', 'derived', 'collections', 'children' ]; for(var i = 0; i < arguments.length; i++) { var def = arguments[i]; if (def.dataTypes) { forOwn(def.dataTypes, function (def, name) { child.prototype._dataTypes[name] = def; }); } if (def.props) { forOwn(def.props, function (def, name) { createPropertyDefinition(child.prototype, name, def); }); } if (def.session) { forOwn(def.session, function (def, name) { createPropertyDefinition(child.prototype, name, def, true); }); } if (def.derived) { forOwn(def.derived, function (def, name) { createDerivedProperty(child.prototype, name, def); }); } if (def.collections) { forOwn(def.collections, function (constructor, name) { child.prototype._collections[name] = constructor; }); } if (def.children) { forOwn(def.children, function (constructor, name) { child.prototype._children[name] = constructor; }); } assign(child.prototype, omit(def, omitFromExtend)); } } // 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;