UNPKG

backbone-xhr-events

Version:

Backbone plugin giving you full featured XHR observer capabilities

367 lines (324 loc) 14 kB
/*! * backbone-xhr-events v1.0.1 * https://github.com/jhudson8/backbone-async-event * * Copyright (c) 2014 Joe Hudson<joehud_AT_gmail.com> * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ (function(main) { if (typeof define === 'function' && define.amd) { define(['backbone', 'underscore'], function(Backbone, _) { // AMD return main(Backbone, _); }); } else if (typeof exports !== 'undefined' && typeof require !== 'undefined') { // CommonJS module.exports = main(require('backbone'), require('underscore')); } else { main(Backbone, _); } })(function(Backbone, _) { // main body start // ANY OVERRIDES MUST BE DEFINED BEFORE LOADING OF THIS SCRIPT // Backbone.xhrCompleteEventName: event triggered on models when all XHR requests have been completed var xhrCompleteEventName = Backbone.xhrCompleteEventName = Backbone.xhrCompleteEventName || 'xhr:complete'; // the model attribute which can be used to return an array of all current XHR request events var xhrLoadingAttribute = Backbone.xhrModelLoadingAttribute = Backbone.xhrModelLoadingAttribute || 'xhrActivity'; // Backbone.xhrEventName: the event triggered on models and the global bus to signal an XHR request var xhrEventName = Backbone.xhrEventName = Backbone.xhrEventName || 'xhr'; // Backbone.xhrGlobalAttribute: global event handler attribute name (on Backbone) used to subscribe to all model xhr events var xhrGlobalAttribute = Backbone.xhrGlobalAttribute = Backbone.xhrGlobalAttribute || 'xhrEvents'; // initialize the global event bus var globalXhrBus = Backbone[xhrGlobalAttribute] = _.extend({}, Backbone.Events); var SUCCESS = 'success'; var ERROR = 'error'; var Context = function(method, model, options) { this.method = method; this.model = model; this.options = options; this._handler = {}; }; _.extend(Context.prototype, { abort: function() { if (!this.aborted) { this.aborted = true; this.type = 'abort'; this.triggerAll('abort'); if (this.xhr) { this.xhr.abort(); } } }, preventDefault: function() { this._defaultPrevented = true; return this._handler; }, triggerAll: function() { var args = _.toArray(arguments); args.push(this); this.trigger.apply(this, args); _.each(this._forwardTo, function(context) { args.splice(args.length-1, 1, context); context.triggerAll.apply(context, args); }); }, pushLoadActivity: function() { var model = this.model, loads = model[xhrLoadingAttribute] = (model[xhrLoadingAttribute] || []); loads.push(this); _.each(this._forwardTo, function(context) { context.pushLoadActivity(); }); }, removeLoadEntry: function() { function _remove(context) { var model = context.model, loads = model[xhrLoadingAttribute] || [], index = loads.indexOf(context); if (index >= 0) { loads.splice(index, 1); } // if there are no more cuncurrent XHRs, model[xhrLoadingAttribute] should always be undefind if (loads.length === 0) { model[xhrLoadingAttribute] = undefined; model.trigger(xhrCompleteEventName, context); } } _remove(this); _.each(this._forwardTo, _remove); } }, Backbone.Events); // allow backbone to send xhr events on models var _sync = Backbone.sync; Backbone.sync = function(method, model, options) { options = options || {}; var context = initializeXHRLoading(method, model, options); if (context._defaultPrevented) { // it is assumed that either context.options.success or context.options.error will be called return; } var xhr = _sync.call(this, method, model, options); context.xhr = xhr; return xhr; }; // provide helper flags to determine model fetched status globalXhrBus.on(xhrEventName + ':read', function(context) { var model = context.model; context.on(SUCCESS, function() { model.hasBeenFetched = true; model.hadFetchError = false; }); context.on(ERROR, function() { model.hadFetchError = true; }); }); // execute the callback directly if the model is fetch // initiate a fetch with this callback as the success option if not fetched // or plug into the current fetch if in progress Backbone.Model.prototype.ensureFetched = Backbone.Collection.prototype.ensureFetched = function(success, error) { var model = this; function successWrapper() { if (success) { success(model); } } if (this.hasBeenFetched) { return successWrapper(); } // find current fetch call (if any) var _fetch = _.find(this[xhrLoadingAttribute], function(req) { return req.method === 'read'; }); if (_fetch) { _fetch.on('success', successWrapper); if (error) { _fetch.on('error', error); } } else { this.fetch({ success: successWrapper, error: error }); } }; // forward all or some XHR events from the source object to the dest object Backbone.forwardXHREvents = function(sourceModel, destModel, typeOrCallback) { var handler = handleForwardedEvents(!_.isFunction(typeOrCallback) && typeOrCallback, sourceModel, destModel); if (_.isFunction(typeOrCallback)) { // forward the events *only* while the function is executing wile keeping "this" as the context try { sourceModel.on(xhrEventName, handler, destModel); typeOrCallback.call(this); } finally { Backbone.stopXHRForwarding(sourceModel, destModel); } } else { var eventName = typeOrCallback ? (xhrEventName + ':') + typeOrCallback : xhrEventName; sourceModel.on(eventName, handler, destModel); } }; // stop the XHR forwarding Backbone.stopXHRForwarding = function(sourceModel, destModel, type) { type = type || '_all'; var eventForwarders = getEventForwardingCache(sourceModel, destModel), handler = eventForwarders[type]; if (handler) { delete eventForwarders[type]; sourceModel.off(xhrEventName, handler, destModel); } // clear model cache var count = 0; _.each(eventForwarders, function() { count ++; }); if (!count) { delete sourceModel._eventForwarders[destModel]; _.each(sourceModel._eventForwarders, function() { count ++; }); if (!count) { delete sourceModel._eventForwarders; } } }; function getEventForwardingCache(sourceModel, destModel) { var eventForwarders = sourceModel._eventForwarders = (sourceModel._eventForwarders || {}); if (!eventForwarders[destModel]) { eventForwarders[destModel] = {}; } return eventForwarders[destModel]; } function handleForwardedEvents(type, sourceModel, destModel) { var eventForwarders = getEventForwardingCache(sourceModel, destModel); type = type || '_all'; var func = eventForwarders[type]; if (!func) { // cache it so we can unbind when we need to func = function(sourceContext) { var forwardTo = sourceContext._forwardTo = (sourceContext._forwardTo || []); forwardTo.push(initializeXHRLoading(sourceContext.method, destModel, sourceContext.options || {}, true)); }; eventForwarders[type] = func; } return func; } // set up the XHR eventing behavior // "model" is to trigger events on and "sourceModel" is the model to provide to the success/error callbacks // these are the same unless there is event forwarding in which case the "sourceModel" is the model that actually // triggered the events and "model" is just forwarding those events function initializeXHRLoading(method, model, options, forwarding) { var eventName = options.event || method, context = new Context(method, model, options), scopedEventName = xhrEventName + ':' + eventName, finished; // at this point, all we can do is call the complete event var origCallbacks = { success: options.success, error: options.error }; function finish(type) { if (!forwarding && !finished) { finished = true; context.removeLoadEntry(); type = type || 'halt'; // trigger the complete event context.triggerAll('complete', type); } } // allow complete callbacks to be executed from the preventDefault response context._handler.complete = finish; function wrapCallback(type) { function triggerEvents() { if (!finished) { try { var args = Array.prototype.slice.call(arguments, 0, 3); // options callback var typeCallback = origCallbacks[type]; if (typeCallback) { typeCallback.apply(context, args); } // trigger the success/error event args.splice(0, 0, type); args.push(context); context.triggerAll.apply(context, args); } finally { finish(type); } } } // allow success/error callbacks to be executed from the preventDefault response context._handler[type] = triggerEvents; // success: (data, status, xhr); error: (xhr, type, error) options[type] = function(p1, p2, p3) { if (!context._defaultPrevented) { context.triggerAll('after-send', p1, p2, p3, type); // if context.preventDefault is true, it is assumed that the option success or callback will be manually called if (context._defaultPrevented) { return; } else if (context.data) { p1 = context.data || p1; } triggerEvents(p1, p2, p3); } }; } if (!forwarding) { // wrap the orig callbacks wrapCallback(SUCCESS); wrapCallback(ERROR); } // trigger the model xhr events model.trigger(xhrEventName, context, eventName); model.trigger(scopedEventName, context); if (!forwarding) { // don't call global events if this is XHR forwarding globalXhrBus.trigger(xhrEventName, context, eventName); globalXhrBus.trigger(scopedEventName, context); // allow for 1 last override var _beforeSend = options.beforeSend; options.beforeSend = function(xhr, settings) { context.xhr = xhr; context.xhrSettings = settings; if (_beforeSend) { var rtn = _beforeSend.call(this, xhr, settings); if (rtn === false) { return rtn; } } context.triggerAll('before-send', xhr, settings); if (context._defaultPrevented) { return false; } context.pushLoadActivity(); }; } return context; } // allow fetch state flags to be reset if the collection has been reset or the model has been cleared _.each({ 'reset': Backbone.Collection, 'clear': Backbone.Model }, function(Clazz, key) { var protoFunc = Clazz.prototype[key]; Clazz.prototype[key] = function(models) { if (key === 'clear' || _.isUndefined(models)) { this.hasBeenFetched = this.hadFetchError = false; } protoFunc.apply(this, arguments); }; }); // main body end });