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.

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