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.

558 lines
17 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. instance = $module.data('module-' + settings.namespace),
  48. element = $module.get(),
  49. query = arguments[0],
  50. methodInvoked = (typeof query == 'string'),
  51. queryArguments = [].slice.call(arguments, 1),
  52. invokedResponse,
  53. className = settings.className,
  54. metadata = settings.metadata,
  55. errors = settings.errors,
  56. module
  57. ;
  58. module = {
  59. initialize: function() {
  60. var
  61. exitConditions = false,
  62. runSettings,
  63. loadingTimer = new Date().getTime(),
  64. loadingDelay,
  65. promise,
  66. url,
  67. urlVariables,
  68. formData = {},
  69. data,
  70. ajaxSettings = {},
  71. xhr,
  72. errors = settings.errors
  73. ;
  74. // serialize parent form if requested!
  75. if(settings.serializeForm && $(this).toJSON() !== undefined) {
  76. formData = module.get.formData();
  77. module.debug('Adding form data to API Request', formData);
  78. $.extend(true, settings.data, formData);
  79. }
  80. // let beforeSend change settings object
  81. runSettings = $.proxy(settings.beforeSend, $module)(settings);
  82. // check for exit conditions
  83. if(runSettings !== undefined && !runSettings) {
  84. module.error(errors.beforeSend);
  85. module.reset();
  86. return;
  87. }
  88. // get real url from template
  89. url = module.get.url( module.get.templateURL() );
  90. // exit conditions reached from missing url parameters
  91. if( !url ) {
  92. module.error(errors.missingURL);
  93. module.reset();
  94. return;
  95. }
  96. // promise handles notification on api request, so loading min. delay can occur for all notifications
  97. promise =
  98. $.Deferred()
  99. .always(function() {
  100. if(settings.stateContext) {
  101. $context
  102. .removeClass(className.loading)
  103. ;
  104. }
  105. $.proxy(settings.complete, $module)();
  106. })
  107. .done(function(response) {
  108. module.debug('API request successful');
  109. // take a stab at finding success state if json
  110. if(settings.dataType == 'json') {
  111. if (response.error !== undefined) {
  112. $.proxy(settings.failure, $context)(response.error, settings, $module);
  113. }
  114. else if ($.isArray(response.errors)) {
  115. $.proxy(settings.failure, $context)(response.errors[0], settings, $module);
  116. }
  117. else {
  118. $.proxy(settings.success, $context)(response, settings, $module);
  119. }
  120. }
  121. // otherwise
  122. else {
  123. $.proxy(settings.success, $context)(response, settings, $module);
  124. }
  125. })
  126. .fail(function(xhr, status, httpMessage) {
  127. var
  128. errorMessage = (settings.errors[status] !== undefined)
  129. ? settings.errors[status]
  130. : httpMessage,
  131. response
  132. ;
  133. // let em know unless request aborted
  134. if(xhr !== undefined) {
  135. // readyState 4 = done, anything less is not really sent
  136. if(xhr.readyState !== undefined && xhr.readyState == 4) {
  137. // if http status code returned and json returned error, look for it
  138. if( xhr.status != 200 && httpMessage !== undefined && httpMessage !== '') {
  139. module.error(errors.statusMessage + httpMessage);
  140. }
  141. else {
  142. if(status == 'error' && settings.dataType == 'json') {
  143. try {
  144. response = $.parseJSON(xhr.responseText);
  145. if(response && response.error !== undefined) {
  146. errorMessage = response.error;
  147. }
  148. }
  149. catch(error) {
  150. module.error(errors.JSONParse);
  151. }
  152. }
  153. }
  154. $context
  155. .removeClass(className.loading)
  156. .addClass(className.error)
  157. ;
  158. // show error state only for duration specified in settings
  159. if(settings.errorLength > 0) {
  160. setTimeout(function(){
  161. $context
  162. .removeClass(className.error)
  163. ;
  164. }, settings.errorLength);
  165. }
  166. module.debug('API Request error:', errorMessage);
  167. $.proxy(settings.failure, $context)(errorMessage, settings, this);
  168. }
  169. else {
  170. module.debug('Request Aborted (Most likely caused by page change)');
  171. }
  172. }
  173. })
  174. ;
  175. // look for params in data
  176. $.extend(true, ajaxSettings, settings, {
  177. success : function(){},
  178. failure : function(){},
  179. complete : function(){},
  180. type : settings.method || settings.type,
  181. data : data,
  182. url : url,
  183. beforeSend : settings.beforeXHR
  184. });
  185. if(settings.stateContext) {
  186. $context
  187. .addClass(className.loading)
  188. ;
  189. }
  190. if(settings.progress) {
  191. module.verbose('Adding progress events');
  192. $.extend(true, ajaxSettings, {
  193. xhr: function() {
  194. var
  195. xhr = new window.XMLHttpRequest()
  196. ;
  197. xhr.upload.addEventListener('progress', function(event) {
  198. var
  199. percentComplete
  200. ;
  201. if (event.lengthComputable) {
  202. percentComplete = Math.round(event.loaded / event.total * 10000) / 100 + '%';
  203. $.proxy(settings.progress, $context)(percentComplete, event);
  204. }
  205. }, false);
  206. xhr.addEventListener('progress', function(event) {
  207. var
  208. percentComplete
  209. ;
  210. if (event.lengthComputable) {
  211. percentComplete = Math.round(event.loaded / event.total * 10000) / 100 + '%';
  212. $.proxy(settings.progress, $context)(percentComplete, event);
  213. }
  214. }, false);
  215. return xhr;
  216. }
  217. });
  218. }
  219. module.verbose('Creating AJAX request with settings: ', ajaxSettings);
  220. xhr =
  221. $.ajax(ajaxSettings)
  222. .always(function() {
  223. // calculate if loading time was below minimum threshold
  224. loadingDelay = ( settings.loadingLength - (new Date().getTime() - loadingTimer) );
  225. settings.loadingDelay = loadingDelay < 0
  226. ? 0
  227. : loadingDelay
  228. ;
  229. })
  230. .done(function(response) {
  231. var
  232. context = this
  233. ;
  234. setTimeout(function(){
  235. promise.resolveWith(context, [response]);
  236. }, settings.loadingDelay);
  237. })
  238. .fail(function(xhr, status, httpMessage) {
  239. var
  240. context = this
  241. ;
  242. // page triggers abort on navigation, dont show error
  243. if(status != 'abort') {
  244. setTimeout(function(){
  245. promise.rejectWith(context, [xhr, status, httpMessage]);
  246. }, settings.loadingDelay);
  247. }
  248. else {
  249. $context
  250. .removeClass(className.error)
  251. .removeClass(className.loading)
  252. ;
  253. }
  254. })
  255. ;
  256. if(settings.stateContext) {
  257. $module
  258. .data(metadata.promise, promise)
  259. .data(metadata.xhr, xhr)
  260. ;
  261. }
  262. },
  263. get: {
  264. formData: function() {
  265. return $module
  266. .closest('form')
  267. .toJSON()
  268. ;
  269. },
  270. templateURL: function() {
  271. var
  272. action = $module.data(settings.metadata.action) || settings.action || false,
  273. url
  274. ;
  275. if(action) {
  276. module.debug('Creating url for: ', action);
  277. if(settings.api[action] !== undefined) {
  278. url = settings.api[action];
  279. }
  280. else {
  281. module.error(errors.missingAction);
  282. }
  283. }
  284. // override with url if specified
  285. if(settings.url) {
  286. url = settings.url;
  287. module.debug('Getting url', url);
  288. }
  289. return url;
  290. },
  291. url: function(url, urlData) {
  292. var
  293. missingTerm = false,
  294. urlVariables
  295. ;
  296. if(url) {
  297. urlVariables = url.match(settings.regExpTemplate);
  298. urlData = urlData || settings.urlData;
  299. if(urlVariables) {
  300. module.debug('Looking for URL variables', urlVariables);
  301. $.each(urlVariables, function(index, templateValue){
  302. var
  303. term = templateValue.substr( 2, templateValue.length - 3),
  304. termValue = ($.isPlainObject(urlData) && urlData[term] !== undefined)
  305. ? urlData[term]
  306. : ($module.data(term) !== undefined)
  307. ? $module.data(term)
  308. : urlData[term]
  309. ;
  310. module.verbose('Looking for variable', term, $module, $module.data(term), urlData[term]);
  311. // remove optional value
  312. if(termValue === false) {
  313. module.debug('Removing variable from URL', urlVariables);
  314. url = url.replace('/' + templateValue, '');
  315. }
  316. // undefined condition
  317. else if(termValue === undefined || !termValue) {
  318. module.error(errors.missingParameter + term);
  319. url = false;
  320. return false;
  321. }
  322. else {
  323. url = url.replace(templateValue, termValue);
  324. }
  325. });
  326. }
  327. }
  328. return url;
  329. }
  330. },
  331. // reset api request
  332. reset: function() {
  333. $module
  334. .data(metadata.promise, false)
  335. .data(metadata.xhr, false)
  336. ;
  337. $context
  338. .removeClass(className.error)
  339. .removeClass(className.loading)
  340. ;
  341. },
  342. /* standard module */
  343. setting: function(name, value) {
  344. if(value === undefined) {
  345. return settings[name];
  346. }
  347. settings[name] = value;
  348. },
  349. verbose: function() {
  350. if(settings.verbose && settings.debug) {
  351. module.verbose = Function.prototype.bind.call(console.info, console, settings.moduleName + ':');
  352. }
  353. },
  354. debug: function() {
  355. if(settings.debug) {
  356. module.debug = Function.prototype.bind.call(console.info, console, settings.moduleName + ':');
  357. }
  358. },
  359. error: function() {
  360. if(console.log !== undefined) {
  361. module.error = Function.prototype.bind.call(console.error, console, settings.moduleName + ':');
  362. }
  363. },
  364. invoke: function(query, passedArguments, context) {
  365. var
  366. maxDepth,
  367. found
  368. ;
  369. passedArguments = passedArguments || queryArguments;
  370. context = element || context;
  371. if(typeof query == 'string' && instance !== undefined) {
  372. query = query.split('.');
  373. maxDepth = query.length - 1;
  374. $.each(query, function(depth, value) {
  375. if( $.isPlainObject( instance[value] ) && (depth != maxDepth) ) {
  376. instance = instance[value];
  377. return true;
  378. }
  379. else if( instance[value] !== undefined ) {
  380. found = instance[value];
  381. return true;
  382. }
  383. module.error(errors.method);
  384. return false;
  385. });
  386. }
  387. if ( $.isFunction( found ) ) {
  388. instance.verbose('Executing invoked function', found);
  389. return found.apply(context, passedArguments);
  390. }
  391. return found || false;
  392. }
  393. };
  394. if(methodInvoked) {
  395. if(instance === undefined) {
  396. module.initialize();
  397. }
  398. invokedResponse = module.invoke(query);
  399. }
  400. else {
  401. if(instance !== undefined) {
  402. module.destroy();
  403. }
  404. module.initialize();
  405. }
  406. return (invokedResponse)
  407. ? invokedResponse
  408. : this
  409. ;
  410. };
  411. // handle DOM attachment to API functionality
  412. $.fn.apiButton = function(parameters) {
  413. $(this)
  414. .each(function(){
  415. var
  416. // if only function passed it is success callback
  417. $module = $(this),
  418. element = this,
  419. selector = $(this).selector || '',
  420. settings = ( $.isFunction(parameters) )
  421. ? $.extend(true, {}, $.api.settings, $.fn.apiButton.settings, { stateContext: this, success: parameters })
  422. : $.extend(true, {}, $.api.settings, $.fn.apiButton.settings, { stateContext: this}, parameters),
  423. module
  424. ;
  425. module = {
  426. initialize: function() {
  427. if(settings.context && selector !== '') {
  428. $(settings.context)
  429. .on(selector, 'click.' + settings.namespace, module.click)
  430. ;
  431. }
  432. else {
  433. $module
  434. .on('click.' + settings.namespace, module.click)
  435. ;
  436. }
  437. },
  438. click: function() {
  439. if(!settings.filter || $(this).filter(settings.filter).size() === 0) {
  440. $.proxy( $.api, this )(settings);
  441. }
  442. }
  443. };
  444. module.initialize();
  445. })
  446. ;
  447. return this;
  448. };
  449. $.api.settings = {
  450. moduleName : 'API Module',
  451. namespace : 'api',
  452. debug : true,
  453. verbose : true,
  454. api : {},
  455. beforeSend : function(settings) {
  456. return settings;
  457. },
  458. beforeXHR : function(xhr) {},
  459. success : function(response) {},
  460. complete : function(response) {},
  461. failure : function(errorCode) {},
  462. progress : false,
  463. errors : {
  464. missingAction : 'API action used but no url was defined',
  465. missingURL : 'URL not specified for the API action',
  466. missingParameter : 'Missing an essential URL parameter: ',
  467. timeout : 'Your request timed out',
  468. error : 'There was an error with your request',
  469. parseError : 'There was an error parsing your request',
  470. JSONParse : 'JSON could not be parsed during error handling',
  471. statusMessage : 'Server gave an error: ',
  472. beforeSend : 'The before send function has aborted the request',
  473. exitConditions : 'API Request Aborted. Exit conditions met'
  474. },
  475. className: {
  476. loading : 'loading',
  477. error : 'error'
  478. },
  479. metadata: {
  480. action : 'action',
  481. promise : 'promise',
  482. xhr : 'xhr'
  483. },
  484. regExpTemplate: /\{\$([A-z]+)\}/g,
  485. action : false,
  486. url : false,
  487. urlData : false,
  488. serializeForm : false,
  489. stateContext : false,
  490. method : 'get',
  491. data : {},
  492. dataType : 'json',
  493. cache : true,
  494. loadingLength : 200,
  495. errorLength : 2000
  496. };
  497. $.fn.apiButton.settings = {
  498. filter : '.disabled, .loading',
  499. context : false,
  500. stateContext : false
  501. };
  502. })( jQuery, window , document );