/* ****************************** API Author: Jack Lukic Notes: First Commit May 08, 2012 These are modules which bind API functionality to the DOM Requires: nada Initialization: $('.button') .apiButton({ success: function() {} }) ; in our example api is automapped to an object literal @ quirky.config.endpoint.api HTML:
URL : quirky.config.endpoint.api.follow Given Value: /follow/{$id}/ Sent Value : /follow/5/ (4 ways to map api endpoint, each will be looked for in succession) url mapping order: first : defined in plugin init as url (arbitrary url) second : defined in plugin init as action (action in obj literal grouping 'api') third : defined in data-url fourth : defined in data-action beforeSend: this callback can be used to modify request settings before XHR it also can be used to look for for pre-conditions to prevent API call by returning "false" ****************************** */ ;(function ( $, window, document, undefined ) { $.api = $.fn.api = function(parameters) { var settings = $.extend(true, {}, $.api.settings, parameters), // if this keyword isn't a jQuery object, create one context = (typeof this != 'function') ? this : $('
'), // context defines the element used for loading/error state $context = (settings.stateContext) ? $(settings.stateContext) : $(context), // module is the thing that initiates the api action, can be independent of context $module = typeof this == 'object' ? $(context) : $context, element = this, time = new Date().getTime(), performance = [], moduleSelector = $module.selector || '', moduleNamespace = settings.namespace + '-module', className = settings.className, metadata = settings.metadata, error = settings.error, instance = $module.data(moduleNamespace), query = arguments[0], methodInvoked = (instance !== undefined && typeof query == 'string'), queryArguments = [].slice.call(arguments, 1), module, invokedResponse ; module = { initialize: function() { var runSettings, loadingTimer = new Date().getTime(), loadingDelay, promise, url, formData = {}, data, ajaxSettings = {}, xhr ; // serialize parent form if requested! if(settings.serializeForm && $(this).toJSON() !== undefined) { formData = module.get.formData(); module.debug('Adding form data to API Request', formData); $.extend(true, settings.data, formData); } // let beforeSend change settings object runSettings = $.proxy(settings.beforeSend, $module)(settings); // check for exit conditions if(runSettings !== undefined && !runSettings) { module.error(error.beforeSend); module.reset(); return; } // get real url from template url = module.get.url( module.get.templateURL() ); // exit conditions reached from missing url parameters if( !url ) { module.error(error.missingURL); module.reset(); return; } // promise handles notification on api request, so loading min. delay can occur for all notifications promise = $.Deferred() .always(function() { if(settings.stateContext) { $context .removeClass(className.loading) ; } $.proxy(settings.complete, $module)(); }) .done(function(response) { module.debug('API request successful'); // take a stab at finding success state if json if(settings.dataType == 'json') { if (response.error !== undefined) { $.proxy(settings.failure, $context)(response.error, settings, $module); } else if ($.isArray(response.errors)) { $.proxy(settings.failure, $context)(response.errors[0], settings, $module); } else { $.proxy(settings.success, $context)(response, settings, $module); } } // otherwise else { $.proxy(settings.success, $context)(response, settings, $module); } }) .fail(function(xhr, status, httpMessage) { var errorMessage = (settings.error[status] !== undefined) ? settings.error[status] : httpMessage, response ; // let em know unless request aborted if(xhr !== undefined) { // readyState 4 = done, anything less is not really sent if(xhr.readyState !== undefined && xhr.readyState == 4) { // if http status code returned and json returned error, look for it if( xhr.status != 200 && httpMessage !== undefined && httpMessage !== '') { module.error(error.statusMessage + httpMessage); } else { if(status == 'error' && settings.dataType == 'json') { try { response = $.parseJSON(xhr.responseText); if(response && response.error !== undefined) { errorMessage = response.error; } } catch(error) { module.error(error.JSONParse); } } } $context .removeClass(className.loading) .addClass(className.error) ; // show error state only for duration specified in settings if(settings.errorLength > 0) { setTimeout(function(){ $context .removeClass(className.error) ; }, settings.errorLength); } module.debug('API Request error:', errorMessage); $.proxy(settings.failure, $context)(errorMessage, settings, this); } else { module.debug('Request Aborted (Most likely caused by page change)'); } } }) ; // look for params in data $.extend(true, ajaxSettings, settings, { success : function(){}, failure : function(){}, complete : function(){}, type : settings.method || settings.type, data : data, url : url, beforeSend : settings.beforeXHR }); if(settings.stateContext) { $context .addClass(className.loading) ; } if(settings.progress) { module.verbose('Adding progress events'); $.extend(true, ajaxSettings, { xhr: function() { var xhr = new window.XMLHttpRequest() ; xhr.upload.addEventListener('progress', function(event) { var percentComplete ; if (event.lengthComputable) { percentComplete = Math.round(event.loaded / event.total * 10000) / 100 + '%'; $.proxy(settings.progress, $context)(percentComplete, event); } }, false); xhr.addEventListener('progress', function(event) { var percentComplete ; if (event.lengthComputable) { percentComplete = Math.round(event.loaded / event.total * 10000) / 100 + '%'; $.proxy(settings.progress, $context)(percentComplete, event); } }, false); return xhr; } }); } module.verbose('Creating AJAX request with settings: ', ajaxSettings); xhr = $.ajax(ajaxSettings) .always(function() { // calculate if loading time was below minimum threshold loadingDelay = ( settings.loadingLength - (new Date().getTime() - loadingTimer) ); settings.loadingDelay = loadingDelay < 0 ? 0 : loadingDelay ; }) .done(function(response) { var context = this ; setTimeout(function(){ promise.resolveWith(context, [response]); }, settings.loadingDelay); }) .fail(function(xhr, status, httpMessage) { var context = this ; // page triggers abort on navigation, dont show error if(status != 'abort') { setTimeout(function(){ promise.rejectWith(context, [xhr, status, httpMessage]); }, settings.loadingDelay); } else { $context .removeClass(className.error) .removeClass(className.loading) ; } }) ; if(settings.stateContext) { $module .data(metadata.promise, promise) .data(metadata.xhr, xhr) ; } }, get: { formData: function() { return $module .closest('form') .toJSON() ; }, templateURL: function() { var action = $module.data(settings.metadata.action) || settings.action || false, url ; if(action) { module.debug('Creating url for: ', action); if(settings.api[action] !== undefined) { url = settings.api[action]; } else { module.error(error.missingAction); } } // override with url if specified if(settings.url) { url = settings.url; module.debug('Getting url', url); } return url; }, url: function(url, urlData) { var urlVariables ; if(url) { urlVariables = url.match(settings.regExpTemplate); urlData = urlData || settings.urlData; if(urlVariables) { module.debug('Looking for URL variables', urlVariables); $.each(urlVariables, function(index, templateValue){ var term = templateValue.substr( 2, templateValue.length - 3), termValue = ($.isPlainObject(urlData) && urlData[term] !== undefined) ? urlData[term] : ($module.data(term) !== undefined) ? $module.data(term) : urlData[term] ; module.verbose('Looking for variable', term, $module, $module.data(term), urlData[term]); // remove optional value if(termValue === false) { module.debug('Removing variable from URL', urlVariables); url = url.replace('/' + templateValue, ''); } // undefined condition else if(termValue === undefined || !termValue) { module.error(error.missingParameter + term); url = false; return false; } else { url = url.replace(templateValue, termValue); } }); } } return url; } }, // reset api request reset: function() { $module .data(metadata.promise, false) .data(metadata.xhr, false) ; $context .removeClass(className.error) .removeClass(className.loading) ; }, setting: function(name, value) { if(value !== undefined) { if( $.isPlainObject(name) ) { $.extend(true, settings, name); } else { settings[name] = value; } } else { return settings[name]; } }, internal: function(name, value) { if(value !== undefined) { if( $.isPlainObject(name) ) { $.extend(true, module, name); } else { module[name] = value; } } else { return module[name]; } }, debug: function() { if(settings.debug) { if(settings.performance) { module.performance.log(arguments); } else { module.debug = Function.prototype.bind.call(console.info, console, settings.moduleName + ':'); module.debug.apply(console, arguments); } } }, verbose: function() { if(settings.verbose && settings.debug) { if(settings.performance) { module.performance.log(arguments); } else { module.verbose = Function.prototype.bind.call(console.info, console, settings.moduleName + ':'); module.verbose.apply(console, arguments); } } }, error: function() { module.error = Function.prototype.bind.call(console.error, console, settings.moduleName + ':'); module.error.apply(console, arguments); }, performance: { log: function(message) { var currentTime, executionTime, previousTime ; if(settings.performance) { currentTime = new Date().getTime(); previousTime = time || currentTime; executionTime = currentTime - previousTime; time = currentTime; performance.push({ 'Element' : element, 'Name' : message[0], 'Arguments' : [].slice.call(message, 1) || '', 'Execution Time' : executionTime }); } clearTimeout(module.performance.timer); module.performance.timer = setTimeout(module.performance.display, 100); }, display: function() { var title = settings.moduleName + ':', totalTime = 0 ; clearTimeout(module.performance.timer); time = false; $.each(performance, function(index, data) { totalTime += data['Execution Time']; }); title += ' ' + totalTime + 'ms'; if(moduleSelector) { title += ' \'' + moduleSelector + '\''; } if( (console.group !== undefined || console.table !== undefined) && performance.length > 0) { console.groupCollapsed(title); if(console.table) { console.table(performance); } else { $.each(performance, function(index, data) { console.log(data['Name'] + ': ' + data['Execution Time']+'ms'); }); } console.groupEnd(); } performance = []; } }, invoke: function(query, passedArguments, context) { var maxDepth, found, response ; passedArguments = passedArguments || queryArguments; context = element || context; if(typeof query == 'string' && instance !== undefined) { query = query.split(/[\. ]/); maxDepth = query.length - 1; $.each(query, function(depth, value) { var camelCaseValue = (depth != maxDepth) ? value + query[depth + 1].charAt(0).toUpperCase() + query[depth + 1].slice(1) : query ; if( $.isPlainObject( instance[value] ) && (depth != maxDepth) ) { instance = instance[value]; } else if( $.isPlainObject( instance[camelCaseValue] ) && (depth != maxDepth) ) { instance = instance[camelCaseValue]; } else if( instance[value] !== undefined ) { found = instance[value]; return false; } else if( instance[camelCaseValue] !== undefined ) { found = instance[camelCaseValue]; return false; } else { module.error(error.method); return false; } }); } if ( $.isFunction( found ) ) { response = found.apply(context, passedArguments); } else if(found !== undefined) { response = found; } if($.isArray(invokedResponse)) { invokedResponse.push(response); } else if(typeof invokedResponse == 'string') { invokedResponse = [invokedResponse, response]; } else if(response !== undefined) { invokedResponse = response; } return found; } }; if(methodInvoked) { if(instance === undefined) { module.initialize(); } module.invoke(query); } else { if(instance !== undefined) { module.destroy(); } module.initialize(); } return (invokedResponse !== undefined) ? invokedResponse : this ; }; // handle DOM attachment to API functionality $.fn.apiButton = function(parameters) { $(this) .each(function(){ var // if only function passed it is success callback $module = $(this), selector = $(this).selector || '', settings = ( $.isFunction(parameters) ) ? $.extend(true, {}, $.api.settings, $.fn.apiButton.settings, { stateContext: this, success: parameters }) : $.extend(true, {}, $.api.settings, $.fn.apiButton.settings, { stateContext: this}, parameters), module ; module = { initialize: function() { if(settings.context && selector !== '') { $(settings.context) .on(selector, 'click.' + settings.namespace, module.click) ; } else { $module .on('click.' + settings.namespace, module.click) ; } }, click: function() { if(!settings.filter || $(this).filter(settings.filter).size() === 0) { $.proxy( $.api, this )(settings); } } }; module.initialize(); }) ; return this; }; $.api.settings = { name : 'API', namespace : 'api', debug : true, verbose : true, performance : true, api : {}, beforeSend : function(settings) { return settings; }, beforeXHR : function(xhr) {}, success : function(response) {}, complete : function(response) {}, failure : function(errorCode) {}, progress : false, error : { missingAction : 'API action used but no url was defined', missingURL : 'URL not specified for the API action', missingParameter : 'Missing an essential URL parameter: ', timeout : 'Your request timed out', error : 'There was an error with your request', parseError : 'There was an error parsing your request', JSONParse : 'JSON could not be parsed during error handling', statusMessage : 'Server gave an error: ', beforeSend : 'The before send function has aborted the request', exitConditions : 'API Request Aborted. Exit conditions met' }, className: { loading : 'loading', error : 'error' }, metadata: { action : 'action', promise : 'promise', xhr : 'xhr' }, regExpTemplate: /\{\$([A-z]+)\}/g, action : false, url : false, urlData : false, serializeForm : false, stateContext : false, method : 'get', data : {}, dataType : 'json', cache : true, loadingLength : 200, errorLength : 2000 }; $.fn.apiButton.settings = { filter : '.disabled, .loading', context : false, stateContext : false }; })( jQuery, window , document );