Skip to content

Instantly share code, notes, and snippets.

@ebaxt
Created May 2, 2011 10:13
Show Gist options
  • Save ebaxt/951405 to your computer and use it in GitHub Desktop.
Save ebaxt/951405 to your computer and use it in GitHub Desktop.
js-model with Plugin.Format
/* js-model JavaScript library, version 0.10.1
* (c) 2010-2011 Ben Pickles
*
* Released under MIT license.
*/
var Model = function(name, func) {
// The model constructor.
var model = function(attributes) {
this.attributes = this.setAttributeValues(Model.Utils.extend({}, attributes))
this.changes = {};
this.errors = new Model.Errors(this);
this.uid = [name, Model.UID.generate()].join("-")
if (Model.Utils.isFunction(this.initialize)) this.initialize()
};
// Use module functionality to extend itself onto the constructor. Meta!
Model.Module.extend.call(model, Model.Module)
model._name = name
model.collection = []
model.unique_key = "id"
model
.extend(Model.Callbacks)
.extend(Model.ClassMethods)
model.prototype = new Model.Base
model.prototype.constructor = model
if (Model.Utils.isFunction(func)) func.call(model, model, model.prototype)
return model;
};
Model.Callbacks = {
bind: function(event, callback) {
this.callbacks = this.callbacks || {}
this.callbacks[event] = this.callbacks[event] || [];
this.callbacks[event].push(callback);
return this;
},
trigger: function(name, data) {
this.callbacks = this.callbacks || {}
var callbacks = this.callbacks[name];
if (callbacks) {
for (var i = 0; i < callbacks.length; i++) {
callbacks[i].apply(this, data || []);
}
}
return this;
},
unbind: function(event, callback) {
this.callbacks = this.callbacks || {}
if (callback) {
var callbacks = this.callbacks[event] || [];
for (var i = 0; i < callbacks.length; i++) {
if (callbacks[i] === callback) {
this.callbacks[event].splice(i, 1);
}
}
} else {
delete this.callbacks[event];
}
return this;
}
};
Model.ClassMethods = {
add: function(model) {
var id = model.id()
if (Model.Utils.inArray(this.collection, model) === -1 && !(id && this.find(id))) {
this.collection.push(model)
this.trigger("add", [model])
}
return this;
},
all: function() {
return this.collection.slice()
},
// Convenience method to allow a simple method of chaining class methods.
chain: function(collection) {
return Model.Utils.extend({}, this, { collection: collection })
},
count: function() {
return this.all().length;
},
detect: function(func) {
var all = this.all(),
model
for (var i = 0, length = all.length; i < length; i++) {
model = all[i]
if (func.call(model, model, i)) return model
}
},
each: function(func, context) {
var all = this.all()
for (var i = 0, length = all.length; i < length; i++) {
func.call(context || all[i], all[i], i, all)
}
return this;
},
find: function(id) {
return this.detect(function() {
return this.id() == id;
})
},
first: function() {
return this.all()[0]
},
load: function(callback) {
if (this._persistence) {
var self = this
this._persistence.read(function(models) {
for (var i = 0, length = models.length; i < length; i++) {
self.add(models[i])
}
if (callback) callback.call(self, models)
})
}
return this
},
last: function() {
var all = this.all();
return all[all.length - 1]
},
map: function(func, context) {
var all = this.all()
var values = []
for (var i = 0, length = all.length; i < length; i++) {
values.push(func.call(context || all[i], all[i], i, all))
}
return values
},
persistence: function(adapter) {
if (arguments.length == 0) {
return this._persistence
} else {
var options = Array.prototype.slice.call(arguments, 1)
options.unshift(this)
this._persistence = adapter.apply(adapter, options)
return this
}
},
pluck: function(attribute) {
var all = this.all()
var plucked = []
for (var i = 0, length = all.length; i < length; i++) {
plucked.push(all[i].attr(attribute))
}
return plucked
},
remove: function(model) {
var index
for (var i = 0, length = this.collection.length; i < length; i++) {
if (this.collection[i] === model) {
index = i
break
}
}
if (index != undefined) {
this.collection.splice(index, 1);
this.trigger("remove", [model]);
return true;
} else {
return false;
}
},
reverse: function() {
return this.chain(this.all().reverse())
},
select: function(func, context) {
var all = this.all(),
selected = [],
model
for (var i = 0, length = all.length; i < length; i++) {
model = all[i]
if (func.call(context || model, model, i, all)) selected.push(model)
}
return this.chain(selected);
},
sort: function(func) {
var sorted = this.all().sort(func)
return this.chain(sorted);
},
sortBy: function(attribute_or_func) {
var is_func = Model.Utils.isFunction(attribute_or_func)
var extract = function(model) {
return attribute_or_func.call(model)
}
return this.sort(function(a, b) {
var a_attr = is_func ? extract(a) : a.attr(attribute_or_func)
var b_attr = is_func ? extract(b) : b.attr(attribute_or_func)
if (a_attr < b_attr) {
return -1
} else if (a_attr > b_attr) {
return 1
} else {
return 0
}
})
},
use: function(plugin) {
var args = Array.prototype.slice.call(arguments, 1)
args.unshift(this)
plugin.apply(this, args)
return this
}
};
Model.Errors = function(model) {
this.errors = {};
this.model = model;
};
Model.Errors.prototype = {
add: function(attribute, message) {
if (!this.errors[attribute]) this.errors[attribute] = [];
this.errors[attribute].push(message);
return this
},
all: function() {
return this.errors;
},
clear: function() {
this.errors = {};
return this
},
each: function(func) {
for (var attribute in this.errors) {
for (var i = 0; i < this.errors[attribute].length; i++) {
func.call(this, attribute, this.errors[attribute][i]);
}
}
return this
},
on: function(attribute) {
return this.errors[attribute] || [];
},
size: function() {
var count = 0;
this.each(function() { count++; });
return count;
}
};
Model.InstanceMethods = {
asJSON: function() {
return this.attr()
},
setAttributeValue: function(name, value) {
return value
},
setAttributeValues: function(attributes) {
return attributes
},
attr: function(name, value) {
if (arguments.length === 0) {
// Combined attributes/changes object.
return Model.Utils.extend({}, this.attributes, this.changes);
} else if (arguments.length === 2) {
// Don't write to attributes yet, store in changes for now.
if (this.attributes[name] === value) {
// Clean up any stale changes.
delete this.changes[name];
} else {
this.changes[name] = this.setAttributeValue(name, value)
}
return this;
} else if (typeof name === "object") {
// Mass-assign attributes.
for (var key in name) {
this.attr(key, name[key]);
}
return this;
} else {
// Changes take precedent over attributes.
return (name in this.changes) ?
this.changes[name] :
this.attributes[name];
}
},
callPersistMethod: function(method, callback) {
var self = this;
// Automatically manage adding and removing from the model's Collection.
var manageCollection = function() {
if (method === "destroy") {
self.constructor.remove(self)
} else {
self.constructor.add(self)
}
};
// Wrap the existing callback in this function so we always manage the
// collection and trigger events from here rather than relying on the
// persistence adapter to do it for us. The persistence adapter is
// only required to execute the callback with a single argument - a
// boolean to indicate whether the call was a success - though any
// other arguments will also be forwarded to the original callback.
var wrappedCallback = function(success) {
if (success) {
// Merge any changes into attributes and clear changes.
self.merge(self.changes).reset();
// Add/remove from collection if persist was successful.
manageCollection();
// Trigger the event before executing the callback.
self.trigger(method);
}
// Store the return value of the callback.
var value;
// Run the supplied callback.
if (callback) value = callback.apply(self, arguments);
return value;
};
if (this.constructor._persistence) {
this.constructor._persistence[method](this, wrappedCallback);
} else {
wrappedCallback.call(this, true);
}
},
destroy: function(callback) {
this.callPersistMethod("destroy", callback);
return this;
},
id: function() {
return this.attributes[this.constructor.unique_key];
},
merge: function(attributes) {
Model.Utils.extend(this.attributes, attributes);
return this;
},
newRecord: function() {
return this.id() === undefined
},
reset: function() {
this.errors.clear();
this.changes = {};
return this;
},
save: function(callback) {
if (this.valid()) {
var method = this.newRecord() ? "create" : "update";
this.callPersistMethod(method, callback);
} else if (callback) {
callback(false);
}
return this;
},
valid: function() {
this.errors.clear();
this.validate();
return this.errors.size() === 0;
},
validate: function() {
return this;
}
};
Model.localStorage = function(klass) {
if (!window.localStorage) {
return {
create: function(model, callback) {
callback(true)
},
destroy: function(model, callback) {
callback(true)
},
read: function(callback) {
callback([])
},
update: function(model, callback) {
callback(true)
}
}
}
var collection_uid = [klass._name, "collection"].join("-")
var readIndex = function() {
var data = localStorage[collection_uid]
return data ? JSON.parse(data) : []
}
var writeIndex = function(uids) {
localStorage.setItem(collection_uid, JSON.stringify(uids))
}
var addToIndex = function(uid) {
var uids = readIndex()
if (Model.Utils.inArray(uids, uid) === -1) {
uids.push(uid)
writeIndex(uids)
}
}
var removeFromIndex = function(uid) {
var uids = readIndex()
var index = Model.Utils.inArray(uids, uid)
if (index > -1) {
uids.splice(index, 1)
writeIndex(uids)
}
}
var store = function(model) {
localStorage.setItem(model.uid, JSON.stringify(model.asJSON()))
addToIndex(model.uid)
}
return {
create: function(model, callback) {
store(model)
callback(true)
},
destroy: function(model, callback) {
localStorage.removeItem(model.uid)
removeFromIndex(model.uid)
callback(true)
},
read: function(callback) {
if (!callback) return false
var existing_uids = klass.map(function() { return this.uid })
var uids = readIndex()
var models = []
var attributes, model, uid
for (var i = 0, length = uids.length; i < length; i++) {
uid = uids[i]
if (Model.Utils.inArray(existing_uids, uid) == -1) {
attributes = JSON.parse(localStorage[uid])
model = new klass(attributes)
model.uid = uid
models.push(model)
}
}
callback(models)
},
update: function(model, callback) {
store(model)
callback(true)
}
}
};
Model.Log = function() {
if (window.console) window.console.log.apply(window.console, arguments);
};
Model.Module = {
extend: function(obj) {
Model.Utils.extend(this, obj)
return this
},
include: function(obj) {
Model.Utils.extend(this.prototype, obj)
return this
}
};
Model.REST = function(klass, resource, methods) {
var PARAM_NAME_MATCHER = /:([\w\d]+)/g;
var resource_param_names = (function() {
var resource_param_names = []
var param_name
while ((param_name = PARAM_NAME_MATCHER.exec(resource)) !== null) {
resource_param_names.push(param_name[1])
}
return resource_param_names
})()
return Model.Utils.extend({
path: function(model) {
var path = resource;
jQuery.each(resource_param_names, function(i, param) {
path = path.replace(":" + param, model.attributes[param]);
});
return path;
},
create: function(model, callback) {
return this.xhr('POST', this.create_path(model), model, callback);
},
create_path: function(model) {
return this.path(model);
},
destroy: function(model, callback) {
return this.xhr('DELETE', this.destroy_path(model), model, callback);
},
destroy_path: function(model) {
return this.update_path(model);
},
params: function(model) {
var params;
if (model) {
var attributes = model.asJSON()
delete attributes[model.constructor.unique_key];
params = {};
params[model.constructor._name.toLowerCase()] = attributes;
} else {
params = null;
}
if(jQuery.ajaxSettings.data){
params = Model.Utils.extend({}, jQuery.ajaxSettings.data, params)
}
return JSON.stringify(params)
},
read: function(callback) {
return this.xhr("GET", this.read_path(), null, function(success, xhr, data) {
data = jQuery.makeArray(data)
var models = []
for (var i = 0, length = data.length; i < length; i++) {
models.push(new klass(data[i]))
}
callback(models)
})
},
read_path: function() {
return resource
},
update: function(model, callback) {
return this.xhr('PUT', this.update_path(model), model, callback);
},
update_path: function(model) {
return [this.path(model), model.id()].join('/');
},
xhr: function(method, url, model, callback) {
var self = this;
var data = method == 'GET' ? undefined : this.params(model);
return jQuery.ajax({
type: method,
url: url,
contentType: "application/json",
dataType: "json",
data: data,
dataFilter: function(data, type) {
return /\S/.test(data) ? data : undefined;
},
complete: function(xhr, textStatus) {
self.xhrComplete(xhr, textStatus, model, callback)
}
});
},
xhrComplete: function(xhr, textStatus, model, callback) {
// Allow custom handlers to be defined per-HTTP status code.
var handler = Model.REST["handle" + xhr.status]
if (handler) handler.call(this, xhr, textStatus, model)
var success = textStatus === "success"
var data = Model.REST.parseResponseData(xhr)
// Remote data is the definitive source, update model.
if (success && model && data) model.attr(data)
if (callback) callback.call(model, success, xhr, data)
}
}, methods)
};
// TODO: Remove in v1 if it ever gets there.
Model.RestPersistence = Model.REST;
// Rails' preferred failed validation response code, assume the response
// contains errors and replace current model errors with them.
Model.REST.handle422 = function(xhr, textStatus, model) {
var data = Model.REST.parseResponseData(xhr);
if (data) {
model.errors.clear()
for (var attribute in data) {
for (var i = 0; i < data[attribute].length; i++) {
model.errors.add(attribute, data[attribute][i])
}
}
}
};
Model.REST.parseResponseData = function(xhr) {
try {
return /\S/.test(xhr.responseText) ?
jQuery.parseJSON(xhr.responseText) :
undefined;
} catch(e) {
Model.Log(e);
}
};
Model.UID = {
counter: 0,
generate: function() {
return [new Date().valueOf(), this.counter++].join("-")
},
reset: function() {
this.counter = 0
return this
}
};
Model.Utils = {
extend: function(receiver) {
var objs = Array.prototype.slice.call(arguments, 1)
for (var i = 0, length = objs.length; i < length; i++) {
for (var property in objs[i]) {
receiver[property] = objs[i][property]
}
}
return receiver
},
inArray: function(array, obj) {
if (array.indexOf) return array.indexOf(obj)
for (var i = 0, length = array.length; i < length; i++) {
if (array[i] === obj) return i
}
return -1
},
isFunction: function(obj) {
return Object.prototype.toString.call(obj) === "[object Function]"
}
}
Model.VERSION = "0.10.1";
Model.Base = (function() {
function Base() {}
Base.prototype = Model.Utils.extend({}, Model.Callbacks, Model.InstanceMethods)
return Base
})();
if (!(typeof Plugin === 'function')) {
Plugin = function() {
}
}
Plugin.Coerce = (function() {
var plugin = function (klass, aTypes) {
var attributeTypes = aTypes;
var augmentSetAttributeValue = function(orig_fn) {
return function() {
var name = arguments[0]
var value = arguments[1]
return coerceAttribute(name, orig_fn.call(this, name, value))
}
}
var augmentSetAttributeValues = function(orig_fn) {
return function() {
return coerceAttributes(orig_fn.apply(this, arguments))
}
}
function coerceAttribute(name, value) {
if (!(attributeTypes === undefined || attributeTypes[name] === undefined)) {
return plugin[attributeTypes[name]](value);
} else {
return value
}
}
function coerceAttributes(attributes) {
for (var name in attributes) {
attributes[name] = coerceAttribute(name, attributes[name])
}
return attributes
}
klass.prototype.setAttributeValue = augmentSetAttributeValue(klass.prototype.setAttributeValue)
klass.prototype.setAttributeValues = augmentSetAttributeValues(klass.prototype.setAttributeValues)
}
plugin.integer = function(value) {
return parseInt(value)
}
plugin['boolean'] = function(value) {
return (!(value === false || value === "false" ||
value === 0 || value === "0"));
}
plugin['float'] = function(value) {
return parseFloat(value)
}
plugin.isoDate = function(value) {
//http://zetafleet.com/blog/javascript-dateparse-for-iso-8601
/**
* Date.parse with progressive enhancement for ISO-8601, version 2
* © 2010 Colin Snover <http://zetafleet.com>
* Released under MIT license.
*/
(function () {
var origParse = Date.parse;
Date.parse = function (date) {
var timestamp = origParse(date), minutesOffset = 0, struct;
if (isNaN(timestamp) && (struct = /^(\d{4}|[+\-]\d{6})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{3,}))?)?(?:(Z)|([+\-])(\d{2})(?::?(\d{2}))?))?/.exec(date))) {
if (struct[8] !== 'Z') {
minutesOffset = +struct[10] * 60 + (+struct[11]);
if (struct[9] === '+') {
minutesOffset = 0 - minutesOffset;
}
}
timestamp = Date.UTC(+struct[1], +struct[2] - 1, +struct[3], +struct[4], +struct[5] + minutesOffset, +struct[6], +struct[7].substr(0, 3));
}
return timestamp;
};
}());
return new Date(Date.parse(value))
}
return plugin
})();
if (!(typeof Plugin === 'function')) {
Plugin = function() {
}
}
Plugin.Format = (function() {
var plugin = function (klass, formattedFields) {
var attributeFormats = formattedFields;
var augmentSetAttributeValue = function(orig_fn) {
return function() {
var name = arguments[0]
var value = arguments[1]
return orig_fn.call(this, name, formatAttribute(name, value))
}
}
var augmentSetAttributeValues = function(orig_fn) {
return function() {
return orig_fn.call(this, formatAttributes(arguments))
}
}
function formatAttribute(name, value) {
if (!(attributeFormats === undefined || attributeFormats[name] === undefined)) {
return plugin[attributeFormats[name] + 'Write'](value);
} else {
return value
}
}
function formatAttributes(attributes) {
for (var name in attributes[0]) {
attributes[name] = formatAttribute(name, attributes[0][name])
}
return attributes
}
var attrf = function(attributeName) {
return plugin[attributeFormats[attributeName] + 'Read'](klass.prototype.attr.call(this, attributeName).toString())
}
klass.prototype.setAttributeValue = augmentSetAttributeValue(klass.prototype.setAttributeValue)
klass.prototype.setAttributeValues = augmentSetAttributeValues(klass.prototype.setAttributeValues)
klass.prototype.attrf = attrf;
}
plugin.commaAsDecimalSeparatorWrite = function(value) {
return value.toString().replace(',', '.');
}
plugin.commaAsDecimalSeparatorRead = function(value) {
return value.toString().replace('.', ',')
}
return plugin
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment