Created
September 12, 2013 01:05
-
-
Save subimage/6532026 to your computer and use it in GitHub Desktop.
Extensions used in production for Cashboard (http://cashboardapp.com) that allow for serialized request queue, amongst other nice things. For use with backbone-prototype.js (https://gist.github.com/subimage/6532044) Used in production for Cashboard (http://cashboardapp.com)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Overrides Backbone.js features for our specific use in Cashboard. | |
(function(){ | |
// How many times do we try to re-contact the server | |
// if we're offline or the server is before erroring out? | |
var BACKBONE_RETRY_ATTEMPTS=99; | |
// Max timeout should be 10 seconds. | |
var MAX_TIMEOUT=10000; | |
// Helper function to get a value from a Backbone object as a property | |
// or as a function. | |
var getValue = function(object, prop) { | |
if (!(object && object[prop])) return null; | |
return _.isFunction(object[prop]) ? object[prop]() : object[prop]; | |
}; | |
var addUrlTimestamp = function(url) { | |
var timestamp = new Date().getTime(); | |
if(url.indexOf('?') == -1) { | |
url += "?cache_timestamp="+timestamp; | |
} else { | |
url += "&cache_timestamp="+timestamp; | |
} | |
return url; | |
} | |
// Singleton that allows for queuing requests so that we fire them one at a time, | |
// after the previous one has completed. | |
// | |
// Helps to not overload the server and ensures we submit items in a serial | |
// FIFO fashion. | |
// | |
// Triggers 'change' events and passes how many requests are pending | |
// to any interested listeners. | |
Backbone.queue = { | |
pending: false, | |
requests: [], | |
size: function() { | |
return this.requests.length; | |
}, | |
requestNext: function() { | |
var next = this.requests.shift(); | |
if (next) { | |
return this.request(next); | |
} else { | |
this.pending = false; | |
return false; | |
} | |
}, | |
request: function(requestArgs) { | |
var self=this; | |
var url = requestArgs['url']; | |
var model = requestArgs['model']; | |
var method = requestArgs['options']['method']; | |
// Prevents a couple of nasty bugs that happen when creating items | |
// offline, then operating on them | |
if (model && typeof model.isNew == 'function' && !model.isNew()) { | |
// to lazy-load urls with ID if we've added things offline | |
url = model.url(); | |
// to prevent multiple POST requests on queued save requests | |
if (method == 'POST') { | |
requestArgs['options']['method'] = method = 'PUT'; | |
} | |
} | |
// upon completion of this request, pop the next off the stack, | |
// ensuring we respect other oncomplete callbacks | |
var oldCompleteCallback = requestArgs['options']['onComplete']; | |
oldCompleteCallback || (oldCompleteCallback = requestArgs['options']['complete']); | |
requestArgs['options'].onComplete = function() { | |
if (oldCompleteCallback) oldCompleteCallback(); | |
var next = self.requestNext(); | |
self.trigger('change', self.size()); | |
return next; | |
}; | |
// Add timestamp to all requests so Chrome doesn't cache | |
url = addUrlTimestamp(url); | |
//console.log(method + ' - ' + url); | |
//console.log(requestArgs['options']); | |
// Allows us to throttle retry requests so we don't pound the server. | |
var timeout = requestArgs['options']['retryTimeout']; | |
if (timeout) { | |
setTimeout(function() { | |
new Ajax.Request(url, requestArgs['options']); | |
}, timeout | |
); | |
} else { | |
return new Ajax.Request(url, requestArgs['options']); | |
} | |
}, | |
add: function(url, model, options) { | |
var args = {url: url, model: model, options: options} | |
if (this.pending) { | |
// add retries to the top of the stack | |
if (options['retryAttempts'] > 0) { | |
this.requests.unshift(args); | |
} else { | |
this.requests.push(args); | |
} | |
} else { | |
this.pending=true; | |
this.request(args); | |
} | |
this.trigger('change', this.size()); | |
} | |
}; | |
_.extend(Backbone.queue, Backbone.Events); | |
// Overloaded sync function that allows us to... | |
// * queue requests in an orderly fashion | |
// * retry failed communications with the server using a prototype | |
// on0: event handler | |
// * use a json namespace (outer tag) because our API requires it | |
Backbone.sync = function(operation, model, options) { | |
var self=this, args=arguments; | |
var methodMap = { | |
'create': 'POST', | |
'update': 'PUT', | |
'delete': 'DELETE', | |
'read': 'GET' | |
}; | |
var httpMethod = methodMap[operation]; | |
// http auth is handled by our api proxy rewriter :) | |
var params = { | |
method: httpMethod, | |
contentType: 'application/json', | |
requestHeaders: {Accept: 'application/json'} | |
}; | |
// Ensure that we have a URL. | |
if (!options.url) { | |
url = getValue(model, 'url') || urlError(); | |
} else { | |
url = options.url; | |
} | |
// Need to wrap 'create' and 'update' methods with proper | |
// prefix for our server to understand. | |
if (!options.parameters && model && (operation == 'create' || operation == 'update')) { | |
var postAttrs; | |
if (options.postAttrs) { | |
postAttrs = options.postAttrs; | |
} else { | |
postAttrs = model.toJSON(); | |
} | |
// need to namespace for updates in our API | |
if (model && model.namespace) { | |
json = {} | |
json[model.namespace] = postAttrs; | |
} else { | |
json = postAttrs; | |
} | |
options.postBody = JSON.stringify(json); | |
} | |
// Allows us to re-try failed requests to the server | |
var errorHandler = options.error; | |
options.retryAttempts || (options.retryAttempts = 0); | |
options.on0 = function(resp) { | |
var errorHandlerArgs = arguments; | |
if (BACKBONE_RETRY_ATTEMPTS > options.retryAttempts) { | |
options.retryAttempts++; | |
options.retryTimeout = (1000*options.retryAttempts); | |
options.retryTimeout = (options.retryTimeout > MAX_TIMEOUT ? MAX_TIMEOUT : options.retryTimeout); | |
console.log("re-trying request #"+options.retryAttempts); | |
console.log("waiting "+options.retryTimeout+" ms before next attempt"); | |
Backbone.sync.apply(self, args); | |
} else { | |
errorHandler.apply(self, errorHandlerArgs); | |
} | |
}; | |
// Handle unauthorized | |
options.on401 = function(resp) { | |
handleAjaxLogout(); | |
}; | |
// Serialize requests so we ensure data integrity & non-blocking UI. | |
return Backbone.queue.add(url, model, _.extend(params, options)); | |
}; | |
Backbone.BaseModel = Backbone.Model.extend({ | |
// for storing nested collection names | |
initialize: function() { | |
_.bindAll(this, 'nestCollection'); | |
this._nestedAttributes = []; | |
this.lazySave = _.debounce(this.save, 1000); | |
}, | |
// sets updated_at attr so we can compare local changes | |
// with ones made on the server. | |
set: function(key, value, options) { | |
var attrs, attr, val; | |
if (_.isObject(key) || key == null) { | |
attrs = key; | |
options = value; | |
} else { | |
attrs = {}; | |
attrs[key] = value; | |
} | |
// Date comparison to ensure we don't clobber local changes | |
var serverUpdated = attrs['updated_at']; | |
var clientUpdated = this.attributes['updated_at']; | |
var getDateFrom = function(possibleDate) { | |
if(typeof possibleDate == 'string') { | |
return Date.fromServerString(possibleDate); | |
} else { | |
return possibleDate; | |
} | |
}; | |
// Don't update the client data if we have an updated_at | |
// attribute from the server, and it's behind our local updated_at. | |
// EXCEPTION - ALWAYS SET ID | |
if(serverUpdated && clientUpdated && !this.isNew()) { | |
// Compare seconds instead of milliseconds which | |
// can be buggy with times returning from server. | |
var serverUpdatedInSeconds = Math.floor(getDateFrom(serverUpdated)/1000); | |
var clientUpdatedInSeconds = Math.floor(getDateFrom(clientUpdated)/1000); | |
if(clientUpdatedInSeconds > serverUpdatedInSeconds) { | |
//console.log("clientDate: "+clientUpdatedInSeconds+"\nserverDate: "+serverUpdatedInSeconds); | |
//console.log("server date is behind client date - returning not setting"); | |
// still want to run success callbacks, etc... | |
return true; | |
} | |
} | |
this.attributes['updated_at'] = new Date(); | |
return Backbone.Model.prototype.set.call(this, attrs, options); | |
}, | |
// Allow for nesting attributes in JSON from a model | |
// Original code snatched from: https://gist.github.com/1610397 | |
nestCollection: function(attributeName, collectionType) { | |
var self=this; | |
self._nestedAttributes.push(attributeName); | |
var nestedCollection = new collectionType(self.get(attributeName)); | |
self[attributeName] = nestedCollection; | |
for (var i = 0; i < nestedCollection.length; i++) { | |
self.attributes[attributeName][i] = nestedCollection.at(i).attributes; | |
} | |
nestedCollection.bind('add', function(initiative) { | |
if (!self.get(attributeName)) { | |
self.attributes[attributeName] = []; | |
} | |
self.get(attributeName).push(initiative.attributes); | |
}); | |
nestedCollection.bind('remove', function(initiative) { | |
var updateObj = {}; | |
updateObj[attributeName] = _.without(self.get(attributeName), initiative.attributes); | |
self.set(updateObj); | |
}); | |
// Deal with parsing fetched info | |
self.parse = function(response) { | |
if (response) { | |
self._nestedAttributes.each(function(attr){ | |
//console.log("parsing nested "+attr); | |
//console.log(response[attr]); | |
self[attr].freshen(response[attr]); | |
}); | |
} | |
return Backbone.Model.prototype.parse.call(self, response); | |
} | |
return nestedCollection; | |
}, | |
// Parses date in the format returned by the server, then | |
// outputs it in a pretty way for the user to look at. | |
formatDate: function(propertyName) { | |
var dateStr = this.get(propertyName); | |
if (dateStr != '' && dateStr != null) { | |
var d = Date.fromServerString(dateStr); | |
return d.toCalendarString(); | |
} else { | |
return null; | |
} | |
}, | |
// Parses date in the format returned by the server, then | |
// outputs it in a pretty way for the user to look at. | |
formatTime: function(propertyName) { | |
var dateStr = this.get(propertyName); | |
if (dateStr != '' && dateStr != null) { | |
var d = Date.fromServerString(dateStr); | |
return d.toCalendarString(true); | |
} else { | |
return null; | |
} | |
}, | |
// Sets data from JS date/time into string on our model | |
setDateTime: function(propertyName, val) { | |
var d = (typeof val == "string" ? Date.fromCalendarString(val) : val); | |
this.set(propertyName, d.toServerString()); | |
} | |
}); | |
// A smarter view class that allows us to manage disposing | |
// of bound events when re-rendering. | |
// This prevents memory leaks with constantly creating new views | |
// during render. | |
Backbone.BaseView = Backbone.View.extend({ | |
// specifically stop the enter key from submitting the form. | |
// if we don't do this - the form will submit & refresh the page | |
captureEnterKey: function(e) { | |
if(e.keyCode==13) { | |
var source = Event.element(e); | |
if(source.nodeName == 'TEXTAREA' && e.shiftKey != true) return; | |
Event.stop(e); | |
} | |
}, | |
bindTo: function(model, ev, callback) { | |
this.bindings || (this.bindings = []); | |
model.bind(ev, callback, this); | |
return this.bindings.push({ | |
model: model, | |
ev: ev, | |
callback: callback | |
}); | |
}, | |
unbindFromAll: function() { | |
_.each(this.bindings, function(binding) { | |
return binding.model.unbind(binding.ev, binding.callback); | |
}); | |
return this.bindings = []; | |
}, | |
dispose: function() { | |
this.disposeViews(); | |
this.unbindFromAll(); | |
this.unbind(); | |
return this.remove(); | |
}, | |
// 'position' is one of: above, below, top, bottom | |
renderView: function(el, position, view) { | |
this.views || (this.views = []); | |
position || (position = 'bottom'); | |
var opts = {}; | |
view.parentView = this; | |
opts[position] = view.$el; | |
el.insert(opts); | |
if(position=='top') { | |
this.views.unshift(view); | |
} else { | |
this.views.push(view); | |
} | |
return view; | |
}, | |
disposeViews: function() { | |
if (this.views) { | |
_(this.views).each(function(view) { | |
return view.dispose(); | |
}); | |
} | |
return this.views = []; | |
} | |
}); | |
Backbone.BaseCollection = Backbone.Collection.extend({ | |
// Holds id of locally deleted items we ignore if we | |
// 'freshen' the collection and changes haven't propagated yet. | |
_localDeletedIds: [], | |
// Take an array of raw objects | |
// If the ID matches a model in the collection, set that model | |
// If the ID is not found in the collection, add it | |
// If a model in the collection is no longer available, remove it | |
// | |
// Keeps local changes, in case we've added things in the meantime. | |
freshen: function(objects) { | |
var model; | |
objects || (objects = []); | |
// only fire 'freshen' when something in the collection | |
// has changed | |
var somethingChanged = (this.size() != objects.size()); | |
// Mark all for removal, unless local only change | |
this.each(function(m) { | |
if (!m.isNew()) m._remove = true; | |
}); | |
// Apply each object | |
_(objects).each(function(attrs) { | |
model = this.get(attrs.id); | |
if (model) { | |
if( | |
(model.get('updated_at') && attrs['updated_at']) && | |
(model.get('updated_at') != attrs['updated_at'])) { | |
somethingChanged = true; | |
} | |
model.set(attrs); // existing model | |
delete model._remove | |
} else { | |
// add new models, accounting for local deletions | |
var locallyDeleted = _.find(this._localDeletedIds, | |
function(id){ return id == attrs.id }); | |
if (!locallyDeleted) this.add(attrs); | |
} | |
}, this); | |
// Now check for any that are still marked for removal | |
var toRemove = this.filter(function(m) { | |
return m._remove; | |
}) | |
_(toRemove).each(function(m) { | |
this.remove(m); | |
}, this); | |
var eventName = 'freshen'; | |
if(somethingChanged) eventName += ':changed'; | |
this.trigger(eventName, this); | |
}, | |
remove: function(models, options) { | |
models = _.isArray(models) ? models.slice() : [models]; | |
for (i = 0, l = models.length; i < l; i++) { | |
if (models[i].id) this._localDeletedIds.push(models[i].id); | |
} | |
return Backbone.Collection.prototype.remove.call(this, models, options); | |
} | |
}); | |
// Takes an array of models, sorts by rank+id and returns them. | |
Backbone.sortByRankAndId = function(modelArr) { | |
return _.sortBy(modelArr, function(m) { | |
var rank = parseInt(m.get("rank")) || 0; | |
var id = (parseFloat(-m.id)/Math.pow(10,10)); | |
return rank+id; | |
}); | |
}; | |
// Throw an error when a URL is needed, and none is supplied. | |
var urlError = function() { | |
throw new Error('A "url" property or function must be specified'); | |
}; | |
}).call(this); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment