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