From 979743ad0d108238aa523db01c474db190b23dde Mon Sep 17 00:00:00 2001
From: Chris Hallberg <crhallberg@gmail.com>
Date: Thu, 21 Sep 2017 12:39:40 -0400
Subject: [PATCH] Perform Search Result AJAX on scroll (#950)

* Add hunt.js. Rewrite check_item_status for hunt.

* Add checkSingleItemStatusAjax.

* Check Save Statuses. Parameter fixes.

* eslint

* Rewrite checking all item statuses.

* Rewrite checking all save statuses.

* Update to Hunt 3.0.0.

* eslint

* Duplicate code reduction.

* eslint

* Remove individual functions to reduce and decrease redundancy.

* Queue up and combine AJAX requests. 200ms delay.

* Remove need for container element passing.

* Fix Javascript problems.

* Fix JS problems of elements prematurely being deleted and hiding not sticking.

* Check all statuses if Hunt not found.

* Fix save statuses.

* Fix intermittent drops and how did saved lists ever work?

* Avoid parallel item status lookups.

* Style fix.

* Add AJAX serialization to save statuses.

* Fix ajax-pending class

* eslint catch.

* Add Hunt scrolling to openurl embedding.

* eslint

* saveRunning clearing.

* Fix loading display. Fix result display edge-cases.

* Fix savedList reloading.
---
 themes/bootstrap3/js/check_item_statuses.js   | 214 ++++++++++--------
 themes/bootstrap3/js/check_save_statuses.js   | 168 ++++++++++----
 themes/bootstrap3/js/openurl.js               |  16 +-
 themes/bootstrap3/js/vendor/hunt.min.js       |   1 +
 .../bootstrap3/templates/search/results.phtml |   1 +
 5 files changed, 260 insertions(+), 140 deletions(-)
 create mode 100644 themes/bootstrap3/js/vendor/hunt.min.js

diff --git a/themes/bootstrap3/js/check_item_statuses.js b/themes/bootstrap3/js/check_item_statuses.js
index 48be5222529..23485ec8a57 100644
--- a/themes/bootstrap3/js/check_item_statuses.js
+++ b/themes/bootstrap3/js/check_item_statuses.js
@@ -1,4 +1,6 @@
-/*global VuFind */
+/*global Hunt, VuFind */
+/*exported checkItemStatuses, itemStatusFail */
+
 function linkCallnumbers(callnumber, callnumber_handler) {
   if (callnumber_handler) {
     var cns = callnumber.split(',\t');
@@ -9,108 +11,142 @@ function linkCallnumbers(callnumber, callnumber_handler) {
   }
   return callnumber;
 }
-
-function checkItemStatuses(_container) {
-  var container = _container || $('body');
-
-  var elements = {};
-  var data = $.map(container.find('.ajaxItem'), function ajaxItemMap(record) {
-    if ($(record).find('.hiddenId').length === 0) {
-      return null;
-    }
-    var datum = $(record).find('.hiddenId').val();
-    if (typeof elements[datum] === 'undefined') {
-      elements[datum] = $();
+function displayItemStatus(result, $item) {
+  $item.removeClass('.ajax-pending');
+  $item.find('.status').empty().append(result.availability_message);
+  $item.find('.ajax-availability').removeClass('ajax-availability hidden');
+  if (typeof(result.full_status) != 'undefined'
+    && result.full_status.length > 0
+    && $item.find('.callnumAndLocation').length > 0
+  ) {
+    // Full status mode is on -- display the HTML and hide extraneous junk:
+    $item.find('.callnumAndLocation').empty().append(result.full_status);
+    $item.find('.callnumber,.hideIfDetailed,.location,.status').addClass('hidden');
+  } else if (typeof(result.missing_data) != 'undefined'
+    && result.missing_data
+  ) {
+    // No data is available -- hide the entire status area:
+    $item.find('.callnumAndLocation,.status').addClass('hidden');
+  } else if (result.locationList) {
+    // We have multiple locations -- build appropriate HTML and hide unwanted labels:
+    $item.find('.callnumber,.hideIfDetailed,.location').addClass('hidden');
+    var locationListHTML = "";
+    for (var x = 0; x < result.locationList.length; x++) {
+      locationListHTML += '<div class="groupLocation">';
+      if (result.locationList[x].availability) {
+        locationListHTML += '<span class="text-success"><i class="fa fa-ok" aria-hidden="true"></i> '
+          + result.locationList[x].location + '</span> ';
+      } else if (typeof(result.locationList[x].status_unknown) !== 'undefined'
+          && result.locationList[x].status_unknown
+      ) {
+        if (result.locationList[x].location) {
+          locationListHTML += '<span class="text-warning"><i class="fa fa-status-unknown" aria-hidden="true"></i> '
+            + result.locationList[x].location + '</span> ';
+        }
+      } else {
+        locationListHTML += '<span class="text-danger"><i class="fa fa-remove" aria-hidden="true"></i> '
+          + result.locationList[x].location + '</span> ';
+      }
+      locationListHTML += '</div>';
+      locationListHTML += '<div class="groupCallnumber">';
+      locationListHTML += (result.locationList[x].callnumbers)
+           ? linkCallnumbers(result.locationList[x].callnumbers, result.locationList[x].callnumber_handler) : '';
+      locationListHTML += '</div>';
     }
-    elements[datum] = elements[datum].add($(record));
-    return datum;
-  });
-  if (!data.length) {
+    $item.find('.locationDetails').removeClass('hidden');
+    $item.find('.locationDetails').html(locationListHTML);
+  } else {
+    // Default case -- load call number and location into appropriate containers:
+    $item.find('.callnumber').empty().append(linkCallnumbers(result.callnumber, result.callnumber_handler) + '<br/>');
+    $item.find('.location').empty().append(
+      result.reserve === 'true'
+        ? result.reserve_message
+        : result.location
+    );
+  }
+}
+function itemStatusFail(response, textStatus) {
+  $('.ajax-pending').empty();
+  if (textStatus === 'abort' || typeof response.responseJSON === 'undefined') {
     return;
   }
+  // display the error message on each of the ajax status place holder
+  $('.ajax-pending').addClass('text-danger').append(response.responseJSON.data);
+}
+
+var itemStatusIds = [];
+var itemStatusEls = {};
+var itemStatusTimer = null;
+var itemStatusDelay = 200;
+var itemStatusRunning = false;
 
-  $(".ajax-availability").removeClass('hidden');
+function runItemAjaxForQueue() {
+  // Only run one item status AJAX request at a time:
+  if (itemStatusRunning) {
+    itemStatusTimer = setTimeout(runItemAjaxForQueue, itemStatusDelay);
+    return;
+  }
+  itemStatusRunning = true;
   $.ajax({
     dataType: 'json',
     method: 'POST',
     url: VuFind.path + '/AJAX/JSON?method=getItemStatuses',
-    data: {'id': data}
+    data: { 'id': itemStatusIds }
   })
   .done(function checkItemStatusDone(response) {
-    $.each(response.data, function checkItemDoneEach(i, result) {
-      var item = elements[result.id];
-      if (!item) {
-        return;
-      }
-
-      item.find('.status').empty().append(result.availability_message);
-      if (typeof(result.full_status) != 'undefined'
-        && result.full_status.length > 0
-        && item.find('.callnumAndLocation').length > 0
-      ) {
-        // Full status mode is on -- display the HTML and hide extraneous junk:
-        item.find('.callnumAndLocation').empty().append(result.full_status);
-        item.find('.callnumber').addClass('hidden');
-        item.find('.location').addClass('hidden');
-        item.find('.hideIfDetailed').addClass('hidden');
-        item.find('.status').addClass('hidden');
-      } else if (typeof(result.missing_data) != 'undefined'
-        && result.missing_data
-      ) {
-        // No data is available -- hide the entire status area:
-        item.find('.callnumAndLocation').addClass('hidden');
-        item.find('.status').addClass('hidden');
-      } else if (result.locationList) {
-        // We have multiple locations -- build appropriate HTML and hide unwanted labels:
-        item.find('.callnumber').addClass('hidden');
-        item.find('.hideIfDetailed').addClass('hidden');
-        item.find('.location').addClass('hidden');
-        var locationListHTML = "";
-        for (var x = 0; x < result.locationList.length; x++) {
-          locationListHTML += '<div class="groupLocation">';
-          if (result.locationList[x].availability) {
-            locationListHTML += '<i class="fa fa-ok text-success" aria-hidden="true"></i> <span class="text-success">'
-              + result.locationList[x].location + '</span> ';
-          } else if (typeof(result.locationList[x].status_unknown) !== 'undefined'
-              && result.locationList[x].status_unknown
-          ) {
-            if (result.locationList[x].location) {
-              locationListHTML += '<i class="fa fa-status-unknown text-warning" aria-hidden="true"></i> <span class="text-warning">'
-                + result.locationList[x].location + '</span> ';
-            }
-          } else {
-            locationListHTML += '<i class="fa fa-remove text-danger" aria-hidden="true"></i> <span class="text-danger"">'
-              + result.locationList[x].location + '</span> ';
-          }
-          locationListHTML += '</div>';
-          locationListHTML += '<div class="groupCallnumber">';
-          locationListHTML += (result.locationList[x].callnumbers)
-               ? linkCallnumbers(result.locationList[x].callnumbers, result.locationList[x].callnumber_handler) : '';
-          locationListHTML += '</div>';
-        }
-        item.find('.locationDetails').removeClass('hidden');
-        item.find('.locationDetails').empty().append(locationListHTML);
-      } else {
-        // Default case -- load call number and location into appropriate containers:
-        item.find('.callnumber').empty().append(linkCallnumbers(result.callnumber, result.callnumber_handler) + '<br/>');
-        item.find('.location').empty().append(
-          result.reserve === 'true'
-            ? result.reserve_message
-            : result.location
-        );
-      }
-    });
-
-    $(".ajax-availability").removeClass('ajax-availability');
+    for (var j = 0; j < response.data.length; j++) {
+      displayItemStatus(response.data[j], itemStatusEls[response.data[j].id]);
+      itemStatusIds.splice(itemStatusIds.indexOf(response.data[j].id), 1);
+    }
+    itemStatusRunning = false;
   })
   .fail(function checkItemStatusFail(response, textStatus) {
-    $('.ajax-availability').empty();
-    if (textStatus === 'abort' || typeof response.responseJSON === 'undefined') { return; }
-    // display the error message on each of the ajax status place holder
-    $('.ajax-availability').append(response.responseJSON.data).addClass('text-danger');
+    itemStatusFail(response, textStatus);
+    itemStatusRunning = false;
   });
 }
 
+function itemQueueAjax(id, el) {
+  clearTimeout(itemStatusTimer);
+  itemStatusIds.push(id);
+  itemStatusEls[id] = el;
+  itemStatusTimer = setTimeout(runItemAjaxForQueue, itemStatusDelay);
+  el.addClass('ajax-pending').removeClass('hidden')
+    .find('.status').removeClass('hidden');
+}
+
+function checkItemStatus(el) {
+  var $item = $(el);
+  if ($item.find('.hiddenId').length === 0) {
+    return false;
+  }
+  var id = $item.find('.hiddenId').val();
+  itemQueueAjax(id + '', $item);
+}
+
+function checkItemStatuses(_container) {
+  var container = _container instanceof Element
+    ? _container
+    : document.body;
+
+  var ajaxItems = $(container).find('.ajaxItem');
+  for (var i = 0; i < ajaxItems.length; i++) {
+    var id = $(ajaxItems[i]).find('.hiddenId').val();
+    itemQueueAjax(id, $(ajaxItems[i]));
+  }
+  // Stop looking for a scroll loader
+  if (itemStatusObserver) {
+    itemStatusObserver.disconnect();
+  }
+}
+var itemStatusObserver = null;
 $(document).ready(function checkItemStatusReady() {
-  checkItemStatuses();
+  if (typeof Hunt === 'undefined') {
+    checkItemStatuses();
+  } else {
+    itemStatusObserver = new Hunt(
+      $('.ajaxItem').toArray(),
+      { enter: checkItemStatus }
+    );
+  }
 });
diff --git a/themes/bootstrap3/js/check_save_statuses.js b/themes/bootstrap3/js/check_save_statuses.js
index d8ee5dc8689..dd6a81dd329 100644
--- a/themes/bootstrap3/js/check_save_statuses.js
+++ b/themes/bootstrap3/js/check_save_statuses.js
@@ -1,58 +1,124 @@
-/*global htmlEncode, userIsLoggedIn, VuFind */
+/*global htmlEncode, userIsLoggedIn, Hunt, VuFind */
 /*exported checkSaveStatuses, checkSaveStatusesCallback */
 
-function checkSaveStatuses(_container) {
-  if (!userIsLoggedIn) {
+function displaySaveStatus(itemLists, $item) {
+  if (itemLists.length > 0) {
+    var html = '<ul>' + itemLists.map(function convertToLi(l) {
+      return '<li><a href="' + l.list_url + '">' + htmlEncode(l.list_title) + '</a></li>';
+    }).join('') + '</ul>';
+    $item.find('.savedLists')
+      .removeClass('ajax-pending').addClass('loaded')
+      .find('.js-load').replaceWith(html);
+  }
+}
+
+function saveStatusFail(response, textStatus) {
+  $('.ajax-pending').empty();
+  if (textStatus === 'abort' || typeof response.responseJSON === 'undefined') {
+    $('.ajax-pending .savedLists').addClass('hidden');
     return;
   }
-  var container = _container || $('body');
+  // display the error message on each of the ajax status place holder
+  $('.ajax-pending .savedLists').addClass('alert-danger').append(response.responseJSON.data);
+}
 
-  var elements = {};
-  var data = $.map(container.find('.result,.record'), function checkSaveRecordMap(record) {
-    if ($(record).find('.hiddenId').length === 0 || $(record).find('.hiddenSource').length === 0) {
-      return null;
+var saveStatusObjs = [];
+var saveStatusEls = {};
+var saveStatusTimer = null;
+var saveStatusDelay = 200;
+var saveStatusRunning = false;
+
+function runSaveAjaxForQueue() {
+  // Only run one save status AJAX request at a time:
+  if (saveStatusRunning) {
+    saveStatusTimer = setTimeout(runSaveAjaxForQueue, saveStatusDelay);
+    return;
+  }
+  saveStatusRunning = true;
+  var ids = [];
+  var sources = [];
+  for (var i = 0; i < saveStatusObjs.length; i++) {
+    ids.push(saveStatusObjs[i].id);
+    sources.push(saveStatusObjs[i].source);
+  }
+  $.ajax({
+    dataType: 'json',
+    method: 'POST',
+    url: VuFind.path + '/AJAX/JSON?method=getSaveStatuses',
+    data: {
+      'id': ids,
+      'source': sources
     }
-    var datum = {
-      id: $(record).find('.hiddenId').val(),
-      source: $(record).find('.hiddenSource')[0].value
-    };
-    var key = datum.source + '|' + datum.id;
-    if (typeof elements[key] === 'undefined') {
-      elements[key] = $();
+  })
+  .done(function checkSaveStatusDone(response) {
+    for (var id in response.data) {
+      if (response.data.hasOwnProperty(id)) {
+        displaySaveStatus(response.data[id], saveStatusEls[id]);
+      }
+      // Remove populated ids from the queue
+      for (var j = 0; j < saveStatusObjs; j++) {
+        if (saveStatusObjs[j].id === id) {
+          saveStatusObjs.splice(j, 1);
+        }
+      }
     }
-    elements[key] = elements[key].add($(record).find('.savedLists'));
-    return datum;
+    saveStatusObjs = [];
+    saveStatusRunning = false;
+  })
+  .fail(function checkItemStatusFail(response, textStatus) {
+    saveStatusFail(response, textStatus);
+    saveStatusRunning = false;
   });
-  if (data.length) {
-    var ids = [];
-    var srcs = [];
-    for (var d = 0; d < data.length; d++) {
-      ids.push(data[d].id);
-      srcs.push(data[d].source);
+}
+function saveQueueAjax(obj, el) {
+  clearTimeout(saveStatusTimer);
+  saveStatusObjs.push(obj);
+  saveStatusEls[obj.source + '|' + obj.id] = el;
+  saveStatusTimer = setTimeout(runSaveAjaxForQueue, saveStatusDelay);
+  el.find('.savedLists')
+    .append('<span class="js-load">' + VuFind.translate('loading') + '...</span>')
+    .addClass('ajax-pending').removeClass('loaded hidden');
+  el.find('.savedLists ul').remove();
+}
+
+function checkSaveStatus(el) {
+  if (!userIsLoggedIn) {
+    return;
+  }
+  var $item = $(el);
+
+  var $id = $item.find('.hiddenId');
+  var $source = $item.find('.hiddenSource');
+  if ($id.length === 0 || $source.length === 0) {
+    return null;
+  }
+  saveQueueAjax({
+    id: $id.val() + '',
+    source: $source.val() + ''
+  }, $item);
+}
+
+function checkSaveStatuses(_container) {
+  if (!userIsLoggedIn) {
+    return;
+  }
+  var container = _container || $('body');
+
+  var ajaxItems = container.find('.result,.record');
+  for (var i = 0; i < ajaxItems.length; i++) {
+    var $id = $(ajaxItems[i]).find('.hiddenId');
+    var $source = $(ajaxItems[i]).find('.hiddenSource');
+    if ($id.length > 0 && $source.length > 0) {
+      var idval = $id.val();
+      saveQueueAjax({
+        id: idval,
+        source: $source.val()
+      }, $(ajaxItems[i]));
     }
-    $.ajax({
-      dataType: 'json',
-      method: 'POST',
-      url: VuFind.path + '/AJAX/JSON?method=getSaveStatuses',
-      data: {id: ids, source: srcs}
-    })
-    .done(function checkSaveStatusDone(response) {
-      for (var sel in response.data) {
-        if (response.data.hasOwnProperty(sel)) {
-          var list = elements[sel];
-          if (!list) {
-            list = $('.savedLists');
-          }
-          var html = list.find('strong')[0].outerHTML + '<ul>';
-          for (var i = 0; i < response.data[sel].length; i++) {
-            html += '<li><a href="' + response.data[sel][i].list_url + '">'
-              + htmlEncode(response.data[sel][i].list_title) + '</a></li>';
-          }
-          html += '</ul>';
-          list.html(html).addClass('loaded');
-        }
-      }
-    });
+  }
+  // Stop looking for a scroll loader
+  if (saveStatusObserver) {
+    saveStatusObserver.disconnect();
   }
 }
 
@@ -61,6 +127,14 @@ function checkSaveStatusesCallback() {
   checkSaveStatuses();
 }
 
+var saveStatusObserver = null;
 $(document).ready(function checkSaveStatusFail() {
-  checkSaveStatuses();
+  if (typeof Hunt === 'undefined') {
+    checkSaveStatuses();
+  } else {
+    saveStatusObserver = new Hunt(
+      $('.result,.record').toArray(),
+      { enter: checkSaveStatus }
+    );
+  }
 });
diff --git a/themes/bootstrap3/js/openurl.js b/themes/bootstrap3/js/openurl.js
index b0771c5aab1..8056d7df8c3 100644
--- a/themes/bootstrap3/js/openurl.js
+++ b/themes/bootstrap3/js/openurl.js
@@ -1,4 +1,4 @@
-/*global extractClassParams, VuFind */
+/*global extractClassParams, Hunt, VuFind */
 VuFind.register('openurl', function OpenUrl() {
   function _loadResolverLinks($target, openUrl, searchClassId) {
     $target.addClass('ajax_availability');
@@ -21,7 +21,8 @@ VuFind.register('openurl', function OpenUrl() {
     });
   }
 
-  function embedOpenUrlLinks(element) {
+  function embedOpenUrlLinks(el) {
+    var element = $(el);
     // Extract the OpenURL associated with the clicked element:
     var openUrl = element.children('span.openUrl:first').attr('title');
 
@@ -53,11 +54,18 @@ VuFind.register('openurl', function OpenUrl() {
 
     // assign action to the openUrlEmbed link class
     container.find('.openUrlEmbed a').unbind('click').click(function openUrlEmbedClick() {
-      embedOpenUrlLinks($(this));
+      embedOpenUrlLinks(this);
       return false;
     });
 
-    container.find('.openUrlEmbed.openUrlEmbedAutoLoad a').trigger('click');
+    if (typeof Hunt === 'undefined') {
+      container.find('.openUrlEmbed.openUrlEmbedAutoLoad a').trigger('click');
+    } else {
+      new Hunt(
+        container.find('.openUrlEmbed.openUrlEmbedAutoLoad a').toArray(),
+        { enter: embedOpenUrlLinks }
+      );
+    }
   }
   return {
     init: init,
diff --git a/themes/bootstrap3/js/vendor/hunt.min.js b/themes/bootstrap3/js/vendor/hunt.min.js
new file mode 100644
index 00000000000..b04b90844a9
--- /dev/null
+++ b/themes/bootstrap3/js/vendor/hunt.min.js
@@ -0,0 +1 @@
+(function(root,factory){"use strict";if(typeof define==="function"&&define.amd){define(function(){return factory()})}else if(typeof exports==="object"){module.exports=factory()}else{root.Hunt=factory()}})(this,function(){"use strict";var THROTTLE_INTERVAL=100;var noop=function(){};var Hunted=function(element,config){this.element=element;this.visible=false;for(var prop in config){if(config.hasOwnProperty(prop)){this[prop]=config[prop]}}if(typeof element.dataset.huntPersist!=="undefined"){try{this.persist=JSON.parse(element.dataset.huntPersist)}catch(e){console.log("Invalid data-hunt-persist value",e)}}if(typeof element.dataset.huntOffset!=="undefined"){try{this.offset=JSON.parse(element.dataset.huntOffset)}catch(e){console.log("Invalid data-hunt-offset value",e)}}};Hunted.prototype.offset=0;Hunted.prototype.persist=false;Hunted.prototype.enter=noop;Hunted.prototype.out=noop;var HuntObserver=function(elements,options){if(elements instanceof Node===false&&typeof elements.length!=="number"||typeof options!=="object"){throw new TypeError("Arguments must be an element or a list of them and an object")}if(elements instanceof Node===true){elements=[elements]}this._viewportHeight=window.innerHeight;this._huntedElements=[];var i=0;var len=elements.length;for(;i<len;i++){this._huntedElements.push(new Hunted(elements[i],options))}_connect.call(this);i=len=null;return this};HuntObserver.prototype._huntElements=function(){var len=this._huntedElements.length;var hunted;var rect;var isOnViewport;while(len){--len;hunted=this._huntedElements[len];rect=hunted.element.getBoundingClientRect();isOnViewport=rect.top-hunted.offset<this._viewportHeight&&rect.top>=-(rect.height+hunted.offset);if(!hunted.visible&&isOnViewport){hunted.enter.call(this,hunted.element);hunted.visible=true}if(hunted.visible&&!isOnViewport){hunted.out.call(this,hunted.element);hunted.visible=false;if(!hunted.persist){this._huntedElements.splice(len,1);if(this._huntedElements.length===0){this.disconnect()}}}}len=hunted=rect=isOnViewport=null};HuntObserver.prototype.trigger=HuntObserver.prototype._huntElements;HuntObserver.prototype._updateMetrics=function(){this._viewportHeight=window.innerHeight;this._huntElements()};var _connect=function(){this._throttledHuntElements=throttle(this._huntElements.bind(this));this._throttledUpdateMetrics=throttle(this._updateMetrics.bind(this));window.addEventListener("scroll",this._throttledHuntElements);window.addEventListener("resize",this._throttledUpdateMetrics);this._huntElements()};HuntObserver.prototype.disconnect=function(){window.removeEventListener("scroll",this._throttledHuntElements);window.removeEventListener("resize",this._throttledUpdateMetrics)};var throttle=function(fn){var timer=null;return function throttledAction(){if(timer){return}timer=setTimeout(function(){fn.apply(this,arguments);timer=null},THROTTLE_INTERVAL)}};return HuntObserver});
\ No newline at end of file
diff --git a/themes/bootstrap3/templates/search/results.phtml b/themes/bootstrap3/templates/search/results.phtml
index f2782d321f6..9922b8390c3 100644
--- a/themes/bootstrap3/templates/search/results.phtml
+++ b/themes/bootstrap3/templates/search/results.phtml
@@ -47,6 +47,7 @@
   }
 
   // Load Javascript dependencies into header:
+  $this->headScript()->appendFile("vendor/hunt.min.js");
   $this->headScript()->appendFile("check_item_statuses.js");
   $this->headScript()->appendFile("check_save_statuses.js");
 ?>
-- 
GitLab