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.

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