You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

514 lines
16 KiB

  1. /* ******************************
  2. API
  3. Author: Jack Lukic
  4. Notes: First Commit May 08, 2012
  5. These are modules which bind API functionality to the DOM
  6. Requires: nada
  7. Initialization:
  8. $('.button')
  9. .apiButton({
  10. success: function() {}
  11. })
  12. ;
  13. in our example api is automapped to an object literal
  14. @ quirky.config.endpoint.api
  15. HTML:
  16. <div class="button" action="follow" data-id="5">
  17. URL : quirky.config.endpoint.api.follow
  18. Given Value: /follow/{$id}/
  19. Sent Value : /follow/5/
  20. (4 ways to map api endpoint, each will be looked for in succession)
  21. url mapping order:
  22. first : defined in plugin init as url (arbitrary url)
  23. second : defined in plugin init as action (action in obj literal grouping 'api')
  24. third : defined in data-url
  25. fourth : defined in data-action
  26. beforeSend:
  27. this callback can be used to modify request settings before XHR
  28. it also can be used to look for for pre-conditions to prevent API
  29. call by returning "false"
  30. ****************************** */
  31. ;(function ( $, window, document, undefined ) {
  32. $.api = $.fn.api = function(parameters) {
  33. var
  34. settings = $.extend(true, {}, $.api.settings, parameters),
  35. // if this keyword isn't a jQuery object, create one
  36. context = (typeof this != 'function')
  37. ? this
  38. : $('<div/>'),
  39. // context defines the element used for loading/error state
  40. $context = (settings.stateContext)
  41. ? $(settings.stateContext)
  42. : $(context),
  43. // module is the thing that initiates the api action, can be independent of context
  44. $module = typeof this == 'object'
  45. ? $(context)
  46. : $context,
  47. action = $module.data(settings.metadata.action) || settings.action || false,
  48. className = settings.className,
  49. metadata = settings.metadata,
  50. errors = settings.errors,
  51. module
  52. ;
  53. module = {
  54. initialize: function() {
  55. var
  56. exitConditions = false,
  57. runSettings,
  58. loadingTimer = new Date().getTime(),
  59. loadingDelay,
  60. promise,
  61. url,
  62. urlVariables,
  63. formData = {},
  64. data,
  65. ajaxSettings = {},
  66. xhr,
  67. errors = settings.errors
  68. ;
  69. // serialize parent form if requested!
  70. if(settings.serializeForm && $(this).toJSON() !== undefined) {
  71. formData = $module
  72. .closest('form')
  73. .toJSON()
  74. ;
  75. $.extend(true, settings.data, formData);
  76. module.debug('Adding form data to API Request', formData);
  77. }
  78. // let beforesend change settings object
  79. runSettings = $.proxy(settings.beforeSend, $module)(settings);
  80. // check for exit conditions
  81. if(runSettings !== undefined && !runSettings) {
  82. module.error(errors.beforeSend);
  83. module.reset();
  84. return;
  85. }
  86. if(action) {
  87. module.debug('Initializing API Request for: ', action);
  88. if(settings.api[action] !== undefined) {
  89. url = settings.api[action];
  90. }
  91. else {
  92. module.error(errors.missingAction);
  93. }
  94. }
  95. // override with url if specified
  96. if(settings.url) {
  97. url = settings.url;
  98. module.debug('Using specified url: ', url);
  99. }
  100. if(!url) {
  101. module.error(errors.missingURL);
  102. module.reset();
  103. }
  104. // replace url data in url
  105. urlVariables = url.match(settings.regExpTemplate);
  106. if(urlVariables) {
  107. module.debug('Looking for URL variables', urlVariables);
  108. $.each(urlVariables, function(index, templateValue){
  109. var
  110. term = templateValue.substr( 2, templateValue.length - 3),
  111. termValue = ($.isPlainObject(parameters.urlData) && parameters.urlData[term] !== undefined)
  112. ? parameters.urlData[term]
  113. : ($module.data(term) !== undefined)
  114. ? $module.data(term)
  115. : settings.urlData[term]
  116. ;
  117. module.verbose('Looking for variable', term, $module, $module.data(term), settings.urlData[term]);
  118. // remove optional value
  119. if(termValue === false) {
  120. module.debug('Removing variable from URL', urlVariables);
  121. url = url.replace('/' + templateValue, '');
  122. }
  123. // undefined condition
  124. else if(termValue === undefined || !termValue) {
  125. module.error(errors.missingParameter + term);
  126. exitConditions = true;
  127. }
  128. else {
  129. url = url.replace(templateValue, termValue);
  130. }
  131. });
  132. }
  133. // exit conditions reached from missing url parameters
  134. if( exitConditions ) {
  135. module.reset();
  136. return;
  137. }
  138. // promise handles notification on api request, so loading min. delay can occur for all notifications
  139. promise =
  140. $.Deferred()
  141. .always(function() {
  142. if(settings.stateContext) {
  143. $context
  144. .removeClass(className.loading)
  145. ;
  146. }
  147. $.proxy(settings.complete, $module)();
  148. })
  149. .done(function(response) {
  150. module.debug('API request successful');
  151. // take a stab at finding success state if json
  152. if(settings.dataType == 'json') {
  153. if(response.success === true) {
  154. $.proxy(settings.success, $context)(response, settings, $module);
  155. }
  156. else {
  157. module.debug('JSON success flag is not set.');
  158. if (response.error !== undefined) {
  159. $.proxy(settings.failure, $context)(response.error, settings, $module);
  160. }
  161. else if ($.isArray(response.errors)) {
  162. $.proxy(settings.failure, $context)(response.errors[0], settings, $module);
  163. }
  164. else if(response.message !== undefined) {
  165. $.proxy(settings.failure, $context)(response.message, settings, $module);
  166. }
  167. else {
  168. $.proxy(settings.failure, $context)(errors.error, settings, $module);
  169. }
  170. }
  171. }
  172. // otherwise
  173. else {
  174. $.proxy(settings.success, $context)(response, settings, $module);
  175. }
  176. })
  177. .fail(function(xhr, status, httpMessage) {
  178. var
  179. errorMessage = (settings.errors[status] !== undefined)
  180. ? settings.errors[status]
  181. : httpMessage,
  182. response
  183. ;
  184. // let em know unless request aborted
  185. if(xhr !== undefined) {
  186. // readyState 4 = done, anything less is not really sent
  187. if(xhr.readyState !== undefined && xhr.readyState == 4) {
  188. // if http status code returned and json returned error, look for it
  189. if( xhr.status != 200 && httpMessage !== undefined && httpMessage !== '') {
  190. module.error(errors.statusMessage + httpMessage);
  191. }
  192. else {
  193. if(status == 'error' && settings.dataType == 'json') {
  194. try {
  195. response = $.parseJSON(xhr.responseText);
  196. if(response && response.error !== undefined) {
  197. errorMessage = response.error;
  198. }
  199. }
  200. catch(error) {
  201. module.error(errors.JSONParse);
  202. }
  203. }
  204. }
  205. $context
  206. .removeClass(className.loading)
  207. .addClass(className.error)
  208. ;
  209. // show error state only for duration specified in settings
  210. if(settings.errorLength > 0) {
  211. setTimeout(function(){
  212. $context
  213. .removeClass(className.error)
  214. ;
  215. }, settings.errorLength);
  216. }
  217. module.debug('API Request error:', errorMessage);
  218. $.proxy(settings.failure, $context)(errorMessage, settings, this);
  219. }
  220. else {
  221. module.debug('Request Aborted (Most likely caused by page change)');
  222. }
  223. }
  224. })
  225. ;
  226. // look for params in data
  227. $.extend(true, ajaxSettings, settings, {
  228. type : settings.method || settings.type,
  229. data : data,
  230. url : url,
  231. beforeSend : settings.beforeXHR
  232. });
  233. if(settings.stateContext) {
  234. $context
  235. .addClass(className.loading)
  236. ;
  237. }
  238. if(settings.progress) {
  239. module.verbose('Adding progress events');
  240. $.extend(true, ajaxSettings, {
  241. xhr: function() {
  242. var
  243. xhr = new window.XMLHttpRequest()
  244. ;
  245. xhr.upload.addEventListener('progress', function(event) {
  246. var
  247. percentComplete
  248. ;
  249. if (event.lengthComputable) {
  250. percentComplete = Math.round(event.loaded / event.total * 10000) / 100 + '%';
  251. $.proxy(settings.progress, $context)(percentComplete, event);
  252. }
  253. }, false);
  254. xhr.addEventListener('progress', function(event) {
  255. var
  256. percentComplete
  257. ;
  258. if (event.lengthComputable) {
  259. percentComplete = Math.round(event.loaded / event.total * 10000) / 100 + '%';
  260. $.proxy(settings.progress, $context)(percentComplete, event);
  261. }
  262. }, false);
  263. return xhr;
  264. }
  265. });
  266. }
  267. module.verbose('Creating AJAX request with settings: ', ajaxSettings);
  268. xhr =
  269. $.ajax(ajaxSettings)
  270. .always(function() {
  271. // calculate if loading time was below minimum threshold
  272. loadingDelay = ( settings.loadingLength - (new Date().getTime() - loadingTimer) );
  273. settings.loadingDelay = loadingDelay < 0
  274. ? 0
  275. : loadingDelay
  276. ;
  277. })
  278. .done(function(response) {
  279. var
  280. context = this
  281. ;
  282. setTimeout(function(){
  283. promise.resolveWith(context, [response]);
  284. }, settings.loadingDelay);
  285. })
  286. .fail(function(xhr, status, httpMessage) {
  287. var
  288. context = this
  289. ;
  290. // page triggers abort on navigation, dont show error
  291. if(status != 'abort') {
  292. setTimeout(function(){
  293. promise.rejectWith(context, [xhr, status, httpMessage]);
  294. }, settings.loadingDelay);
  295. }
  296. else {
  297. $context
  298. .removeClass(className.error)
  299. .removeClass(className.loading)
  300. ;
  301. }
  302. })
  303. ;
  304. if(settings.stateContext) {
  305. $module
  306. .data(metadata.promise, promise)
  307. .data(metadata.xhr, xhr)
  308. ;
  309. }
  310. },
  311. // reset api request
  312. reset: function() {
  313. $module
  314. .data(metadata.promise, false)
  315. .data(metadata.xhr, false)
  316. ;
  317. $context
  318. .removeClass(className.error)
  319. .removeClass(className.loading)
  320. ;
  321. module.error(errors.exitConditions);
  322. },
  323. /* standard module */
  324. setting: function(name, value) {
  325. if(value === undefined) {
  326. return settings[name];
  327. }
  328. settings[name] = value;
  329. },
  330. verbose: function() {
  331. if(settings.verbose) {
  332. module.debug.apply(this, arguments);
  333. }
  334. },
  335. debug: function() {
  336. var
  337. output = [],
  338. message = settings.moduleName + ': ' + arguments[0],
  339. variables = [].slice.call( arguments, 1 ),
  340. log = console.info || console.log || function(){}
  341. ;
  342. log = Function.prototype.bind.call(log, console);
  343. if(settings.debug) {
  344. output.push(message);
  345. log.apply(console, output.concat(variables) );
  346. }
  347. },
  348. error: function() {
  349. var
  350. output = [],
  351. errorMessage = settings.moduleName + ': ' + arguments[0],
  352. variables = [].slice.call( arguments, 1 ),
  353. log = console.warn || console.log || function(){}
  354. ;
  355. log = Function.prototype.bind.call(log, console);
  356. if(settings.debug) {
  357. output.push(errorMessage);
  358. output.concat(variables);
  359. log.apply(console, output.concat(variables) );
  360. }
  361. }
  362. };
  363. module.initialize();
  364. return this;
  365. };
  366. // handle DOM attachment to API functionality
  367. $.fn.apiButton = function(parameters) {
  368. $(this)
  369. .each(function(){
  370. var
  371. // if only function passed it is success callback
  372. $module = $(this),
  373. element = this,
  374. selector = $(this).selector || '',
  375. settings = ( $.isFunction(parameters) )
  376. ? $.extend(true, {}, $.api.settings, $.fn.apiButton.settings, { stateContext: this, success: parameters })
  377. : $.extend(true, {}, $.api.settings, $.fn.apiButton.settings, { stateContext: this}, parameters),
  378. module
  379. ;
  380. module = {
  381. initialize: function() {
  382. if(settings.context && selector !== '') {
  383. $(settings.context)
  384. .on(selector, 'click.' + settings.namespace, module.click)
  385. ;
  386. }
  387. else {
  388. $module
  389. .on('click.' + settings.namespace, module.click)
  390. ;
  391. }
  392. },
  393. click: function() {
  394. if(!settings.filter || $(this).filter(settings.filter).size() === 0) {
  395. $.proxy( $.api, this )(settings);
  396. }
  397. }
  398. };
  399. module.initialize();
  400. })
  401. ;
  402. return this;
  403. };
  404. $.api.settings = {
  405. moduleName : 'API Module',
  406. namespace : 'api',
  407. verbose : true,
  408. debug : true,
  409. api : {},
  410. beforeSend : function(settings) {
  411. return settings;
  412. },
  413. beforeXHR : function(xhr) {},
  414. success : function(response) {},
  415. complete : function(response) {},
  416. failure : function(errorCode) {},
  417. progress : false,
  418. errors : {
  419. missingAction : 'API action used but no url was defined',
  420. missingURL : 'URL not specified for the API action',
  421. missingParameter : 'Missing an essential URL parameter: ',
  422. timeout : 'Your request timed out',
  423. error : 'There was an error with your request',
  424. parseError : 'There was an error parsing your request',
  425. JSONParse : 'JSON could not be parsed during error handling',
  426. statusMessage : 'Server gave an error: ',
  427. beforeSend : 'The before send function has aborted the request',
  428. exitConditions : 'API Request Aborted. Exit conditions met'
  429. },
  430. className: {
  431. loading : 'loading',
  432. error : 'error'
  433. },
  434. metadata: {
  435. action : 'action',
  436. promise : 'promise',
  437. xhr : 'xhr'
  438. },
  439. regExpTemplate: /\{\$([A-z]+)\}/g,
  440. action : false,
  441. url : false,
  442. urlData : false,
  443. serializeForm : false,
  444. stateContext : false,
  445. method : 'get',
  446. data : {},
  447. dataType : 'json',
  448. cache : true,
  449. loadingLength : 200,
  450. errorLength : 2000
  451. };
  452. $.fn.apiButton.settings = {
  453. filter : '.disabled, .loading',
  454. context : false,
  455. stateContext : false
  456. };
  457. })( jQuery, window , document );