Skip to content

Instantly share code, notes, and snippets.

@subimage
Created September 12, 2013 01:05
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save subimage/6532026 to your computer and use it in GitHub Desktop.
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)
// 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