okaylib
Version:
Extensible MVC library
1,553 lines (1,492 loc) • 42.7 kB
JavaScript
/*!
* ok.js: Model/View/Controller framework
* @module ok
*/
(function (factory, root, _, ok) {
// amd
if (typeof define === 'function' && define.amd) {
define('ok', ['underscore'], factory);
}
// commonjs
else if (typeof module !== 'undefined' && typeof require === 'function') {
_ = require('underscore');
ok = factory(_, root);
module.exports = ok;
}
// globals
else {
ok = factory(root._, root);
root.ok = ok;
root.okaylib = ok;
}
})(function (_, root) {
'use strict';
/**
* @exports ok
*/
var ok = {
/**
* Current version of ok.js
* @const {string}
*/
VERSION: '0.5.6'
};
if (!_) {
throw new Error('ok relies on underscore (underscorejs.org)');
}
// prevent global namespace collisions
var _old = root ? root.ok : null;
ok.noConflict = root ? function () {
root.ok = _old;
return ok;
} : _.constant(ok);
// internal reference to common prototypes
var $Array = Array.prototype;
var $Object = Object.prototype;
var $Function = Function.prototype;
// convenience functions
var slice = function (arr, start, end) {
return $Array.slice.call(arr, start, end);
};
var hasProperty = function (obj, property) {
return $Object.hasOwnProperty.call(obj, property);
};
var isObject = function (obj) {
return $Object.toString.call(obj) === '[object Object]';
};
/**
* Notification of data being added to a collection
* @event add
* @property {*} item Item which has been added
*/
var EVENT_ADD = 'add';
/**
* Notification of data being removed from a collection
* @event remove
* @property {*} item Item which has been removed
*/
var EVENT_REMOVE = 'remove';
/**
* Notification of a change of data
* @event change
* @property {module:ok.Events} triggeredBy Reference to the object that
* fired the event
* @property {*} newValue New value which has just been set
* @property {*} oldValue Old value which has just been overwritten
*/
var EVENT_CHANGE = 'change';
/**
* Notification of data being sorted
* @event sort
* @property {module:ok.Items} items Collection of newly sorted items
*/
var EVENT_SORT = 'sort';
/**
* Insert a superconstructor into the prototype chain for a constructor
* @param {Function} Child Constructor function
* @param {Function} Parent Superconstructor function
*/
ok.inherits = function (Child, Parent) {
// from backbone. surrogate class.
var Class = function () {
this.constructor = Child;
};
Class.prototype = Parent.prototype;
Child.prototype = new Class();
Child.__super__ = Parent.prototype;
};
/**
* Takes a superconstructor and returns a child constructor with its
* prototype extended
* @param {Function} Parent Superconstructor function
* @param {...Object} protos Prototype object
* @return {Function} Child constructor function
*/
ok.extendClass = function (Parent) {
var name, value;
var constructor;
var protos = slice(arguments, 1);
var statics = {};
var proto = _.reduce(protos, function (proto, item) {
if (typeof item === 'function') {
_.extend(statics, item);
item = item.prototype;
}
return _.extend(proto, item);
}, {});
var mergedProto = ok.mergePrototypes(Parent.prototype, proto);
// sub class
var Class = proto && hasProperty(proto, 'constructor') ?
proto.constructor :
function Class () { return Parent.apply(this, arguments); };
ok.inherits(Class, Parent);
// copy static properties from super class to sub class
_.extend(Class, Parent);
_.extend(Class, statics);
// copy prototype from super class to sub class
_.extend(Class.prototype, proto);
_.extend(Class.prototype, mergedProto);
// shortcut
Class.fn = Class.prototype;
Class.__super__ = Parent.prototype;
return Class;
};
/**
* Takes a prototype object and returns a child constructor which inherits from
* the current context (`this`, must be a function) and implements the new
* prototype. Can be passed multiple prototypes which will each be applied in
* the order they are given.
* @this {Function} Parent Superconstructor function
* @param {...Object} protos Prototype objects
* @return {Function} Child constructor function
*/
ok.extendThisClass = function () {
var protos = slice(arguments);
protos.unshift(this);
return ok.extendClass.apply(this, protos);
};
/**
* Get the super constructor of a given constructor or prototype. Derives the
* parent from the given child's `__super__` property which is automatically
* assigned by `ok.extendClass()`. If a super constructor is not found,
* `Object` is returned.
* @param {Function|Object} obj Constructor or prototype
* @return {Function} Parent constructor
*/
ok.getSuper = function (obj) {
var Class = (typeof obj === 'function') ? obj : obj.constructor;
var superProto = Class.__super__;
var Super = superProto ? superProto.constructor : Object;
return Super;
};
/**
* Get the class which implemented a given method or property.
* @param {Function|Object} obj Constructor or prototype
* @param {String} method Method or property name
* @return {Function} Constructor which implements the given method or property
*/
ok.getImplementor = function (obj, method) {
var Class = (typeof obj === 'function') ? obj : obj.constructor;
var Super;
// avoid infinite loop
if (Class === Object) {
// method can only be found on Object
if (hasProperty($Object, method)) {
return Object;
}
// method cannot be found
return null;
}
// this instance implements the method
if (_.isObject(obj) && hasProperty(obj, method)) {
return Class;
}
// this class implements the method
if (hasProperty(Class.prototype, method)) {
return Class;
}
// this class does not implement the method
Super = ok.getSuper(Class);
return ok.getImplementor(Super, method);
};
/**
* Calls a super function if it is implemented, no-op otherwise. If no arguments
* are given, the super constructor will be returned.
* @param {String=} method Method to call. If no method given, the super
* constructor itself will be called.
* @param {Array} args Arguments to send to super function. Can be an arguments
* object or an array.
* @return {*} Result of super function call
*/
ok.sup = function (first, args) {
var superFn, result;
var Class = this.constructor;
var Super = this.__currentSuper__ ?
ok.getSuper(this.__currentSuper__) :
ok.getSuper(Class);
this.__currentSuper__ = Super;
if (typeof first === 'string') {
Super = ok.getImplementor(Super, first);
superFn = Super && Super.prototype[first];
result = (typeof superFn === 'function') ?
superFn.apply(this, args) :
undefined;
}
else if (_.isArray(first) || _.isArguments(first)) {
result = Super.apply(this, first);
}
else {
result = Super;
}
delete this.__currentSuper__;
return result;
};
/**
* Return a new instance of a constructor.
* @param {Function} Class Constructor
* @param {Array} args Arguments passed through to constructor
* @return {Object} New instance of given constructor
*/
ok.createWithArguments = function (Class, args) {
args = slice(args);
args.unshift(null);
// from http://stackoverflow.com/a/18240186
// also see http://stackoverflow.com/a/8843181
return new ($Function.bind.apply(Class, args))();
};
/**
* Return a new instance of this constructor.
* @this {Function} Constructor to create a new instance of
* @param {Array} args Arguments passed through to constructor
* @return {Object} New instance of given constructor
*/
ok.createThisWithArguments = function (args) {
return ok.createWithArguments(this, args);
};
/**
* Return a new instance of a constructor.
* @param {Function} Class Constructor
* @param {...*} args Arguments passed through to constructor
* @return {Object} New instance of given constructor
*/
ok.create = function (Class) {
var args = slice(arguments, 1);
return ok.createWithArguments(Class, args);
};
/**
* Return a new instance of the current context (`this`, must be a function)
* @this {Function} Class Constructor
* @param {...*} args Arguments passed through to constructor
* @return {Object} New instance of this constructor
*/
ok.createThis = function () {
return ok.createWithArguments(this, arguments);
};
/**
* Apply members to the current context.
* @this {*} Object to apply map to
* @param {Object} map Key/value pairs to apply
* @return {*} Context for chaining
*/
ok.include = function (map) {
return _.extend(this, map);
};
/**
* Naiive clone implementation based on expected get/set pattern used by Base.
* @this {Object} instance Instance to clone
* @param {...*} args Arguments to pass to clone's constructor
* @return {Object} Clone of this instance
*/
ok.cloneThis = function () {
var data;
var Constructor = this.constructor;
var clone = ok.createThis.apply(Constructor, arguments);
if (typeof this.get === 'function' && typeof clone.set === 'function') {
data = this.get();
clone.set(data);
}
return clone;
};
/**
* Return a plain array representing the given object.
* @param {Arguments|Array|Object} obj Object to convert to plain array
* @return {Array} Plain array
*/
ok.toArray = function (obj) {
var result = [];
if (obj === undefined || obj === null) {
result.push(obj);
}
else if (typeof obj.forEach === 'function') {
obj.forEach(function (item) {
result.push(item);
});
}
else if (typeof obj.length === 'number') {
_.forEach(slice(obj), function (item) {
result.push(item);
});
}
else {
result.push(obj);
}
return result;
};
/**
* Combine variables. Arrays will be concatenated, and objects will be merged.
* Inputs will not be modified.
* @params {...*} Values to merge
* @result {Object|Array} Merged value
*/
ok.mergeValues = function () {
// merge if any argument is an object
if (_.any(arguments, isObject)) {
return _.reduce(arguments, function (result, arg) {
return _.extend(result, arg);
}, {});
}
// otherwise concatenate as array
return $Array.concat.apply([], _.without(arguments, null, undefined));
};
/**
* Combine two prototypes using the `mergeProperties` property of the first.
* Each property existing in both prototypes will be merged.
* @property {Object} oldProto First prototype
* @property {Object} newProto Second prototype
* @return {Object} Merged prototype
*/
ok.mergePrototypes = function (oldProto, newProto) {
var properties;
var mergedProto = {};
oldProto = oldProto || {};
newProto = newProto || {};
properties = _.uniq(ok.mergeValues(
oldProto.mergeProperties,
newProto.mergeProperties
));
_.forEach(properties, function (prop) {
mergedProto[prop] = ok.mergeValues(
oldProto[prop],
newProto[prop]
);
});
return mergedProto;
};
/**
* Class which implements the observable pattern. Exposes methods for listening
* to and triggering arbitrary events.
* @constructor
*/
ok.Events = function Events () {};
var eventIndex = 0;
ok.Events.prototype = {
/**
* Adds a callback to the event queue which is executed when an event fires
* @param {string} event Event name
* @param {Function} fn Callback function. Executed when event fires.
* @param {*=} context Optional context to apply to callback
*/
on: function (event, fn, context) {
this._events = this._events || {};
var e = this._events[event] || (this._events[event] = []);
e.push([fn, context]);
},
/**
* Observe another object by adding a callback to its event queue which is
* executed when an event fires
* @param {Events} obj Object to listen to
* @param {string} event Event name
* @param {Function} fn Callback function. Executed when event fires.
* @param {*=} context Optional context to apply to callback
*/
listenTo: function (obj, event, fn, context) {
var listeningTo = this._listeningTo || (this._listeningTo = {});
var id = obj._listenId || (obj._listenId = eventIndex++);
listeningTo[id] = obj;
if (!fn && typeof event === 'object') {
fn = this;
}
if (!context) {
context = this;
}
obj.on(event, fn, context);
},
/**
* Removes a callback from the event queue
* @param {string} event Event name
* @param {Function} fn Callback function. No longer executed when event
* fires.
*/
off: function (event, fn) {
this._events = this._events || {};
var e = this._events[event];
if (e) {
for (var i = 0, l = e.length; i < l; i++) {
if (e[i][0] === fn) {
e.splice(i, 1);
i--;
l--;
}
}
}
},
/**
* Stop observing another object
* @param {Events=} obj Object to stop observing. Omit to stop observing all
* objects.
* @param {string=} event Event name. Omit to stop observing all events on
* this object.
* @param {Function} fn Callback function. Stops this function executing
* when `event` is triggered.
*/
stopListening: function (obj, event, fn) {
var listeningTo = this._listeningTo;
if (!listeningTo) {
return;
}
var remove = !event && !fn;
if (!fn && typeof event === 'object') {
fn = this;
}
if (obj) {
(listeningTo = {})[obj._listenId] = obj;
}
for (var id in listeningTo) {
obj = listeningTo[id];
obj.off(event, fn, this);
if (remove) {
delete this._listeningTo[id];
}
}
},
/**
* Trigger an event and execute all callbacks in the event queue
* @param {string} event Event name
* @param {...*} args Event arguments passed through to all callbacks
*/
trigger: function (event/*, args... */) {
this._events = this._events || {};
var e = this._events[event];
if (e) {
for (var i = 0, l = e.length; i < l; i++){
e[i][0].apply(e[i][1] || this, slice(arguments, 1));
}
}
}
};
ok.Events.fn = ok.Events.prototype;
/**
* Base class. All ok.js classes extend from this base (except {@link Items}).
* @constructor
* @augments {module:ok.Events}
* @param {...*} args Arguments passed to through to {@link module:ok.Base#init}
*/
ok.Base = function Base (/* args... */) {
var args = slice(arguments);
if (this.init) {
this.init.apply(this, args);
}
};
ok.Base.fn = ok.Base.prototype;
_.extend(ok.Base.fn, ok.Events.fn);
/**
* Initialization for this instance.
* @virtual
*/
ok.Base.fn.init = function () {
// no-op
};
/**
* Naiive clone implementation based on expected get/set pattern used by Base.
* @param {...*} args Arguments to pass to clone's constructor
* @return {Object} Clone of this instance
*/
ok.Base.fn.clone = ok.cloneThis;
/**
* Call a super function in the prototype chain.
* @param {String=} method Method to call. If no method given, the super
* constructor itself will be called.
* @param {Array} args Arguments to send to super function. Can be an arguments
* object or an array.
* @see module:ok.sup
*/
ok.Base.fn.sup = ok.sup;
/**
* Defines which properties will be merged when this class is extended. Can
* itself be merged.
* @property {String[]}
* @see module:ok.mergePrototypes
*/
ok.Base.fn.mergeProperties = ['mergeProperties'];
/**
* Extend the current object or prototype's members.
* @see module:ok.include
*/
ok.Base.fn.include = ok.include;
/**
* Extend the current class's static members.
* @see module:ok.include
*/
ok.Base.include = ok.include;
/**
* Create a new instance of this class
* @static
* @function
* @param {...*} args Arguments passed through to the constructor function
* @see module:ok.createThis
*/
ok.Base.create = ok.createThis;
/**
* Create a new child constructor which extends from this class
* @static
* @function
* @param {Object} proto Prototype object
* @see module:ok.extendThisClass
*/
ok.Base.extend = ok.extendThisClass;
/**
* Returns the constructor's name.
* @static
* @return {String} String representation of this class
*/
ok.Base.toString = function (Class) {
var thisName = String(this.name);
var result;
if (thisName) {
result = Class ? '(subclass of ' + thisName + ')' : thisName;
}
else {
result = ok.getSuper(this).toString(this);
}
return result;
};
/**
* Define partial functionality to be mixed in with other classes. Methods are
* defined in a way that allows for use as a static function as well. The
* first argument is reserved for the function's context.
* @constructor
* @augments {module:ok.Base}
* @param {Object} statics Mixin members
*/
ok.Mixin = ok.Base.extend({
constructor: function Mixin (statics) {
var mixin;
var proto = {};
_.forEach(statics, function (item, key) {
if (typeof item === 'function') {
proto[key] = function () {
var args = slice(arguments);
args.unshift(this);
return item.apply(mixin, args);
};
}
else {
proto[key] = item;
}
});
mixin = ok.Base.extend(proto);
_.extend(mixin, statics);
delete mixin.fn.constructor;
return mixin;
}
});
/**
* Data node. Exposes common interface for child classes.
* @constructor
* @augments {module:ok.Base}
* @param {...*} args Arguments passed to through to {@link module:ok.Base#init}
*/
ok.Data = ok.Base.extend(/** @lends module:ok.Data.prototype */{
// rename constructor
constructor: function Data () {
return ok.Base.apply(this, arguments);
},
/**
* Get the simple data representation of this element
* @virtual
* @return {?*} Data
*/
get: function () {
return null;
},
/**
* Set the simple data representation of this element
* @virtual
* @param {?(...*)} args Data
*/
set: function () {
// no-op
}
});
/**
* Properties are data containers.
* @class
* @augments {module:ok.Data}
* @param {...*} args Passed through to {@link #init}
*/
ok.Property = ok.Data.extend(/** @lends module:ok.Property.prototype */{
/**
* Raw value storage
* @type {*}
* @private
*/
_value: null,
/**
* Properties which are not initialized with a value will be given this
* value by default
* @type {*}
*/
defaultValue: null,
/**
* Optionally initialize this property with a value
* @param {*=} initValue Initial value for this property
*/
constructor: function Property (initValue) {
if (arguments.length) {
this.set(initValue);
}
else {
this.set(this.defaultValue);
}
ok.Data.apply(this, arguments);
},
/**
* Getter which returns the internal value
* @return {*} Value of this property
*/
getValue: function () {
return this._value;
},
/**
* Replace the internal property with a new value and trigger a 'change'
* @param {*} newValue New property value
* @fires change
*/
setValue: function (newValue) {
var oldValue = this._value;
if (oldValue !== newValue) {
this._value = newValue;
this.trigger(EVENT_CHANGE, this, newValue, oldValue);
}
},
/**
* Sugar for {@link #getValue}
* @return {*} Value of this property
*/
get: function () {
return this.getValue();
},
/**
* Sugar for {@link #setValue}
* @param {*} newValue New property value
* @fires change
*/
set: function (newValue) {
this.setValue(newValue);
}
});
/**
* Maps are a collection of properties associated with property names.
* @class
* @augments {module:ok.Data}
* @param {...*} args Passed through to {@link #init}
*/
ok.Map = ok.Data.extend(/** @lends module:ok.Map.prototype */{
/**
* Internal hash which persists properties
* @type {Object}
*/
properties: null,
/**
* Define constructors for each property
* @type {?Object}
*/
schema: null,
/**
* Optional hash of properties to initialize all instances with
* @type {?Object}
*/
defaults: null,
/**
* All new properties will be declared using this constructor
* @type {Function}
* @default {@link module:ok.Property}
*/
defaultConstructor: ok.Property,
/**
* Initialize properties hash by extending {@link #defaults} with
* `properties`
* @param {Object=} properties Hash of properties to initialize this with
*/
constructor: function Map (properties) {
ok.Data.apply(this, arguments);
var defaults = this.getDefaults();
if (!this.properties) {
this.properties = {};
}
if (defaults) {
this.initProperties(defaults);
}
if (properties) {
this.setMap(properties);
}
},
/**
* Remove all events listeners
* @deprecated Use {@link #stopListening} (with no arguments) instead
*/
destroy: function () {
var properties = this.properties;
var prop;
_.forEach(properties, function (prop, name) {
prop = this.getProperty(name);
this.stopListening(prop, EVENT_CHANGE);
}, this);
},
/**
* Get defaults hash. If it is a function, execute it and use the result.
* @return {Object} Defaults
*/
getDefaults: function () {
var defaults = this.constructor.prototype.defaults;
if (typeof defaults === 'function') {
return defaults.apply(this);
}
else {
return defaults;
}
},
/**
* Declare the values of a hash of properties
* @param {Object} properties Hash of properties and values
*/
initProperties: function (properties) {
properties = properties || {};
for (var name in properties) {
this.initProperty(name, properties[name], properties);
}
},
/**
* Declare the value of a single property
* @param {string} name Property name
* @param {*} value Property value
* @return {module:ok.Property} New property instance
*/
initProperty: function (name, value) {
var prop = this.getProperty(name);
var Constructor;
var context = this;
if (!prop) {
Constructor = this.getConstructor(name, value);
prop = new Constructor();
prop = this.setProperty(name, prop);
}
if (typeof value !== 'undefined') {
prop.set(value);
}
return prop;
},
/**
* Called when a property is changed
* @param {module:ok.Property} changed Property which has changed
* @param {*} newValue New value which has just been set
* @param {*} oldValue Old value which has just been overwritten
* @fires change
*/
change: function (changed, newValue, oldValue) {
this.trigger(EVENT_CHANGE, changed, newValue, oldValue);
},
/**
* Determines what constructor to use when initializing a property
* @param {string} name Property name
* @param {*} value Property value
* @return {Function} Constructor function
*/
getConstructor: function (name, value) {
var constructor = this.schema && this.schema[name];
return constructor || this.defaultConstructor;
},
/**
* Get the values of one or more properties
* @param {...string=} name Optional property names. If omitted, a map of
* all properties will be returned. If one property name is given then the
* value of that property will be returned. Otherwise, if more than one
* property name is given, the values of those properties will be returned
* as an array.
* @return {Object|Array|*} Result of the get operation depending on the
* number of property names given.
*/
get: function (name) {
var args = arguments;
var len = args.length;
if (len === 0) {
return this.getMap();
}
else if (len === 1) {
return this.getValue(name);
}
else {
return this.getValues.apply(this, args);
}
},
/**
* Get the values of all properties
* @return {Object} Map of all properties. Each property has had its `get`
* function invoked.
*/
getMap: function () {
var map = this.properties;
var pairs = _.map(map, function (prop, name) {
return [name, prop.get()];
});
var result = _.object(pairs);
return result;
},
/**
* Get the value of a single property
* @param {string} name Property name
* @return {*} Result of property's `get` function when invoked.
*/
getValue: function (name) {
var prop = this.getProperty(name);
return prop && prop.get();
},
/**
* Get the value of multiple properties
* @param {...string} names Property names
* @return {Array} Array of values from each property's `get` function
*/
getValues: function () {
var result = [];
var args = arguments;
var l = args.length;
var name, value;
for (var i = 0; i < l; i++) {
name = args[i];
value = this.getValue(name);
result.push(value);
}
return result;
},
/**
* Get a single property by name
* @param {string} name Property name
* @return {module:ok.Property} Property object
*/
getProperty: function (name) {
return this.properties[name];
},
/**
* Get a single property by name. Shorthand for `getProperty()`.
* @param {string} name Property name
* @return {module:ok.Property} Property object
*/
property: function (name) {
return this.getProperty(name);
},
/**
* Set the value of one or more properties
* @method module:ok.Map#set
* @param {Object} attrs Hash of property names and values to set
*/
/**
* Set the value of a single property
* @param {string} name Property name
* @param {*} newValue Property value
*/
set: function (name, newValue) {
if (arguments.length > 1) {
this.setValue(name, newValue);
}
else {
var attrs = name;
this.setMap(attrs);
}
},
/**
* Set values of properties using an object
* @param {Object} attrs Hash of property names and values to set
*/
setMap: function (attrs) {
attrs = attrs || {};
_.forEach(attrs, function (val, name) {
this.setValue(name, val);
}, this);
},
/**
* Set the value of a single property
* @param {string} name Property name
* @param {*} newValue Property value
*/
setValue: function (name, newValue) {
var property = this.getProperty(name);
if (!property) {
this.initProperty(name, newValue);
}
else {
property.set(newValue);
}
},
/**
* Set a single property to a new value
* @param {string} name Property name
* @param {module:ok.Property} prop Property object
* @return {module:ok.Property} The new property
*/
setProperty: function (name, prop) {
this.unsetProperty(name);
this.properties[name] = prop;
this.listenTo(prop, EVENT_CHANGE, this.change);
return prop;
},
/**
* Remove a single property from the map
* @param {String} name Property name
* @return {?module:ok.Property} Removed property or `null`
*/
unsetProperty: function (name) {
var prop = this.properties[name];
if (prop) {
this.stopListening(prop, EVENT_CHANGE, this.change);
delete this.properties[name];
return prop;
}
return null;
}
});
/**
* Extended array with added convenience methods and events.
* @class
* @augments {Array}
* @augments {module:ok.Base}
*/
ok.Items = ok.extendClass(Array, ok.Base.fn, /** @lends module:ok.Items.prototype */{
constructor: function Items (items) {
if (!(this instanceof ok.Items)) {
return ok.createWithArguments(ok.Items, arguments);
}
Array.call(this);
if (items) {
this.set(ok.toArray(items));
}
},
/**
* Remove an item off the top of the stack
* @return {*} Value of popped item
* @fires remove
*/
pop: function () {
var item = $Array.pop.apply(this);
this.trigger(EVENT_REMOVE, item);
return item;
},
/**
* Push new items to the top of the stack
* @param {...*} New items to push
* @return {int} New length after items have been pushed
* @fires add
*/
push: function (/* items... */) {
var items = slice(arguments);
var item;
for (var i = 0, l = items.length; i < l; i++) {
item = items[i];
$Array.push.call(this, item);
this.trigger(EVENT_ADD, item, this.length);
}
return this.length;
},
/**
* Shift a single item from the bottom of the stack
* @return {*} Value of shifted item
* @fires remove
*/
shift: function () {
var item = $Array.shift.apply(this);
this.trigger(EVENT_REMOVE, item);
return item;
},
/**
* Sorts the items according to a given comparison function
* @param {Function} compare Compare function
* @return {module:ok.Items} Newly sorted items array
* @fires sort
*/
sort: function () {
var result = $Array.sort.apply(this, arguments);
this.trigger(EVENT_SORT, this);
return result;
},
/**
* Remove and/or insert items.
* @param {int} index Position to begin splicing
* @param {int=} remove Count of items to remove. Can be 0. If omitted, all
* items until the end of the array will be removed.
* @param {...*=} newItems Items to be inserted at this index
* @return {int} The number of items which have been removed
* @fires add
* @fires remove
*/
splice: function (index, remove/*, newItems... */) {
var newItems = slice(arguments, 2);
var removed = 0;
if (remove > 0) {
removed = this.remove(index, remove);
}
if (newItems.length) {
this.insert.apply(this, [index].concat(newItems));
}
return removed;
},
/**
* Add new items to the bottom of the stack
* @param {...*} items Items to add
* @return {int} New length after items have been added
* @fires add
*/
unshift: function (/* items... */) {
var items = slice(arguments);
var item;
for (var i = 0, l = items.length; i < l; i++) {
item = items[i];
$Array.unshift.call(this, item);
this.trigger(EVENT_ADD, item, 0);
}
return this.length;
},
/**
* Remove items from the array
* @param {int=} start Start index. If omitted, will start at 0.
* @param {int=} length Number of items to remove. If omitted, will remove
* all items until the end of the array.
* @return {Array} Collection of removed items
* @fires remove
*/
remove: function (start, length) {
var removed, item;
if (arguments.length < 1) {
start = 0;
}
if (arguments.length < 2) {
length = this.length - start;
}
removed = [];
while (start < this.length && length-- > 0) {
item = this[start];
$Array.splice.call(this, start, 1);
this.trigger(EVENT_REMOVE, item);
removed.push(item);
}
return removed;
},
/**
* Remove all items from the array
* @return {int} New length after items have been removed (always zero)
* @fires remove
*/
empty: function () {
this.remove();
return this.length;
},
/**
* Insert items into the array
* @param {int} start Starting index
* @param {...*} items New items to insert
* @return {int} New length after items have been added
* @fires add
*/
insert: function (start/*, items... */) {
var items = slice(arguments, 1);
var item, index;
for (var i = 0, l = items.length; i < l; i++) {
item = items[i];
index = start + i;
$Array.splice.call(this, index, 0, item);
this.trigger(EVENT_ADD, item, index);
}
return this.length;
},
/**
* Set the contents of this array. Empties it first.
* @param {Array} items New contents of array
* @return {int} New length after items have been added
* @fires remove
* @fires add
*/
set: function (items) {
var args = slice(items);
args.unshift(0);
this.empty();
this.insert.apply(this, args);
return this.length;
},
/**
* Get the item at a given index. Can be negative. If no index is given, a
* reference to the array will be returned.
* @param {int=} Index of item to get
* @return {?ok.Items|*} Item at given index or whole array
*/
get: function (index) {
if (arguments.length < 1) {
return this;
}
if (index < 0) {
index = this.length + index;
}
if (hasProperty(this, index)) {
return this[index];
}
return null;
},
/**
* Return a plain array representation of this object.
* @return {Array}
*/
toArray: function () {
return ok.toArray(this);
}
});
var invokeMethod = function (obj, methodName, args) {
var result;
args = slice(args);
args.unshift(obj);
result = _[methodName].apply(_, args);
return result;
};
// these methods return a copy of input array which we then wrap
var itemsMethodsWrap = ['collect', 'compact', 'difference', 'filter', 'flatten',
'foldl', 'foldr', 'initial', 'inject', 'intersection', 'invoke', 'map',
'partition', 'pluck', 'reduceRight', 'reject', 'rest', 'select', 'shuffle',
'sortBy', 'union', 'uniq', 'unique', 'where', 'without', 'zip'];
_.forEach(itemsMethodsWrap, function (methodName) {
ok.Items.fn[methodName] = function () {
var result = invokeMethod(this, methodName, arguments);
result = ok.Items.create(result);
return result;
};
});
// these methods return a value within the array or another result not an array
var itemsMethodsNowrap = ['all', 'any', 'contains', 'countBy', 'detect', 'each',
'every', 'find', 'findWhere', 'forEach', 'groupBy', 'include', 'indexBy',
'indexOf', 'lastIndexOf', 'max', 'min', 'object', 'reduce', 'size', 'some',
'sortedIndex'];
_.forEach(itemsMethodsNowrap, function (methodName) {
ok.Items.fn[methodName] = function () {
var result = invokeMethod(this, methodName, arguments);
return result;
};
});
// these are special methods whose return value depends on their inputs
var itemsMethodsSpecial = ['sample', 'first', 'last'];
_.forEach(itemsMethodsSpecial, function (methodName) {
ok.Items.fn[methodName] = function () {
var result = invokeMethod(this, methodName, arguments);
if (arguments.length > 0) {
result = ok.Items.create(result);
}
return result;
};
});
/**
* Create a new instance of this class
* @static
* @function
* @param {...*} args Arguments passed through to the constructor function
* @see module:ok.createThis
*/
ok.Items.create = ok.createThis;
/**
* Extend the current object or prototype's members.
* @see module:ok.include
*/
ok.Items.include = ok.include;
/**
* Create a new child constructor which extends from this class
* @static
* @function
* @param {Object} proto Prototype object
* @see module:ok.extendThisClass
*/
ok.Items.extend = ok.extendThisClass;
/**
* Collections maintain an array of items
* @class
* @augments {module:ok.Data}
* @param {...*} items Initialize the collection with these items
*/
ok.Collection = ok.Data.extend(/** @lends module:ok.Collection.prototype */{
/**
* Internal array of items
* @type {module:ok.Items}
*/
items: null,
/**
* Length of items array. Kept in sync with items array.
* @type {int}
*/
length: 0,
/**
* All new properties will be declared using this constructor
* @type {Function}
*/
defaultConstructor: ok.Property,
/**
* Initialize with items
*/
constructor: function Collection (items) {
this.items = new ok.Items();
this.start();
if (items) {
this.add(items);
}
this.init();
},
/**
* Begin listening to changes on the internal items storage array
*/
start: function () {
this.stop();
this.listenTo(this.items, EVENT_ADD, this.triggerAdd);
this.listenTo(this.items, EVENT_REMOVE, this.triggerRemove);
this.listenTo(this.items, EVENT_SORT, this.triggerSort);
this.listenTo(this.items, EVENT_ADD, this.updateLength);
this.listenTo(this.items, EVENT_REMOVE, this.updateLength);
this.listenTo(this.items, EVENT_ADD, this.watchItem);
this.listenTo(this.items, EVENT_REMOVE, this.unwatchItem);
},
/**
* Stop listening to change on the internal items storage array
*/
stop: function () {
this.stopListening(this.items);
},
/**
* Handler for the internal items storage array. Called when an item is
* added.
* @param {*} item Newly added item
* @param {int} index Position of new item
* @fires add
*/
triggerAdd: function (item, index) {
this.trigger(EVENT_ADD, item, index);
},
/**
* Handler for the internal items storage array. Called when an item is
* removed.
* @param {*} item Newly removed item
* @fires remove
*/
triggerRemove: function (item) {
this.trigger(EVENT_REMOVE, item);
},
/**
* Handler for the internal items storage array. Called when the array is
* sorted.
* @param {Array.<*>} items Items array after it has been sorted
* @fires sort
*/
triggerSort: function (items) {
this.trigger(EVENT_SORT, items);
},
/**
* Handler for the internal items storage array. Called when the array is
* changed.
* @param {Array.<*>} items Items array after it has been sorted
* @fires change
*/
triggerChange: function (item, newValue, oldValue) {
this.trigger(EVENT_CHANGE, item, newValue);
},
/**
* Maintain the length property of this collection. Keep it in sync with the
* length of the internal items storage array.
*/
updateLength: function () {
this.length = this.items.length;
},
/**
* Add one or more items
* @param {*|Array.<*>} items A single item or array of items which will be
* added to this collection
* @fires add
*/
add: function () {
var items = _.flatten(arguments);
for (var i = 0, l = items.length; i < l; i++) {
this.addItem(items[i], this.items.length);
}
},
/**
* Add a single item to this collection
* @param {*} item Item to add to collection
* @param {int} index Position to add the item
* @fires add
*/
addItem: function (item/*, index*/) {
var old = item;
var Constructor;
if (!(item instanceof ok.Base)) {
Constructor = this.getConstructor(item);
item = new Constructor(item);
}
var identified = this.identify(item);
if (identified) {
identified.set(old);
}
else {
var index = this.findInsertIndex(item);
this.items.insert(index + 1, item);
}
},
/**
* Watch a new item for changes
* @param {*} item New item in `items` array
*/
watchItem: function (item) {
this.listenTo(item, EVENT_CHANGE, this.triggerChange);
},
/**
* Stop watching an item for changes
* @param {*} item Item in `items` array
*/
unwatchItem: function (item) {
this.stopListening(item, EVENT_CHANGE, this.triggerChange);
},
/**
* Determine where a newly inserted item would fit in this collection. Find
* the index of the item to insert after, or -1 to insert at the first
* index.
* @param {*} item Item to be added to collection
* @return {int} Index of the item to insert after
* @todo Rephrase
*/
findInsertIndex: function (item) {
var index = -1;
this.items.forEach(function (comparedTo, newIndex) {
if (this.comparator(comparedTo, item) <= 0) {
index = newIndex;
return false;
}
}, this);
return index;
},
/**
* Determines what constructor to use when initializing a property
* @param {*} value New item value
* @return {Function} Constructor for new item
*/
getConstructor: function (value) {
return this.defaultConstructor;
},
/**
* Remove a specific item from the collection
* @param {*} item Item to remove
* @return {int} Number of items which have been removed
* @fires remove
*/
remove: function (item) {
var items = this.items;
var removed = 0;
for (var i = 0, l = items.length; i < l; i++) {
if (items[i] === item) {
items.splice(i, 1);
i--;
removed++;
}
}
return removed;
},
/**
* Remove all items from this collection
* @fires remove
*/
empty: function () {
return this.items.empty();
},
/**
* Reset the entire collection
* @param {Array.<*>=} newItems New items to add to the collection
* @fires remove
* @fires add
*/
set: function (newItems) {
this.empty();
if (newItems) {
this.add(newItems);
}
},
/**
* Returns an array of each item's value. Invokes `get()` on all children.
* @return {Array.<*>} Serialized array
*/
get: function () {
var result = this.items.invoke('get');
return result;
},
/**
* Determine if an item already exists in this collection
* @param {*} item Item to find
* @return {?*} Item, or `null` if not found
*/
identify: function (item) {
var contained = this.items.contains(item);
return contained ? item : null;
},
/**
* Used to compare two items when sorting.
* @param {*} a Left item for comparison
* @param {*} b Right item for comparison
* @return {int} A negative value means `a` is smaller than `b`. A positive
* value means `a` is larger than `b`. A zero value means `a` and `b` are
* equal.
*/
comparator: function (a, b) {
return 0;
},
/**
* Sort the collection.
* @param {Function=} comparator Comparator function. Receives two items and
* is expected to return a signed integer which will be used to determine
* the items' order. If omitted, the collection's {@link
* module:ok.Collection#comparator} will be used.
*/
sort: function (comparator) {
if (comparator) {
this.comparator = comparator;
}
this.items.sort(this.comparator);
},
/**
* Get the item at a given index
* @param {int} index Index of item
* @return Item at given index
*/
at: function (index) {
return this.items.get(index);
}
});
_.forEach(itemsMethodsWrap, function (methodName) {
ok.Collection.fn[methodName] = function () {
var result;
var args = slice(arguments);
args.unshift(this.items);
result = _[methodName].apply(_, args);
result = ok.Items.create(result);
return result;
};
});
_.forEach(itemsMethodsNowrap, function (methodName) {
ok.Collection.fn[methodName] = function () {
var result;
var args = slice(arguments);
args.unshift(this.items);
result = _[methodName].apply(_, args);
return result;
};
});
_.forEach(itemsMethodsSpecial, function (methodName) {
ok.Collection.fn[methodName] = function () {
var result;
var args = slice(arguments);
var len = args.length;
args.unshift(this.items);
result = _[methodName].apply(_, args);
if (len > 0) {
result = ok.Items.create(result);
}
return result;
};
});
/**
* Controllers are the 'glue' that sits between models/collections and views.
* @class
* @augments {module:ok.Base}
*/
ok.Controller = ok.Base.extend(/** @lends module:ok.Controller.prototype */{
// rename constructor
constructor: function Controller () {
return ok.Base.apply(this, arguments);
}
});
return ok;
}, this);