diff --git a/frontend/src/utils/async-table/async-table.js b/frontend/src/utils/async-table/async-table.js index c9508c7d6..0f9f69fdc 100644 --- a/frontend/src/utils/async-table/async-table.js +++ b/frontend/src/utils/async-table/async-table.js @@ -310,11 +310,19 @@ var asyncTableUtil = function(element, app) { } function changePagesizeHandler(event) { + var paginationParamKey = asyncTableId + '-pagination'; var pagesizeParamKey = asyncTableId + '-pagesize'; var pageParamKey = asyncTableId + '-page'; + + var paginationParamEl = pagesizeForm.querySelector('[name="' + paginationParamKey + '"]'); var url = new URL(getLocalStorageParameter('currentTableUrl') || window.location.href); url.searchParams.set(pagesizeParamKey, event.target.value); url.searchParams.set(pageParamKey, 0); + + if (paginationParamEl) { + var encodedValue = encodeURIComponent(paginationParamEl.value); + url.searchParams.set(paginationParamKey, encodedValue); + } updateTableFrom(url.href); } diff --git a/static/js/utils/asyncTable.js b/static/js/utils/asyncTable.js new file mode 100644 index 000000000..ccc441038 --- /dev/null +++ b/static/js/utils/asyncTable.js @@ -0,0 +1,430 @@ +(function collonadeClosure() { + 'use strict'; + + /** + * + * Async Table Utility + * makes table filters, sorting and pagination behave asynchronously via AJAX calls + * + * Attribute: uw-async-table + * + * Example usage: + * (regular table) + */ + + var INPUT_DEBOUNCE = 600; + var HEADER_HEIGHT = 80; + + var ASYNC_TABLE_UTIL_NAME = 'asyncTable'; + var ASYNC_TABLE_UTIL_SELECTOR = '[uw-async-table]'; + + var ASYNC_TABLE_LOCAL_STORAGE_KEY = 'ASYNC_TABLE'; + var ASYNC_TABLE_SCROLLTABLE_SELECTOR = '.scrolltable'; + var ASYNC_TABLE_INITIALIZED_CLASS = 'async-table--initialized'; + var ASYNC_TABLE_LOADING_CLASS = 'async-table--loading'; + + var ASYNC_TABLE_FILTER_FORM_SELECTOR = '.table-filter-form'; + var ASYNC_TABLE_FILTER_FORM_ID_SELECTOR = '[name="form-identifier"]'; + + + var asyncTableUtil = function(element) { + var asyncTableHeader; + var asyncTableId; + + var ths = []; + var pageLinks = []; + var pagesizeForm; + var scrollTable; + var cssIdPrefix = ''; + + var tableFilterInputs = { + search: [], + input: [], + change: [], + select: [], + } + + function init() { + if (!element) { + throw new Error('Async Table utility cannot be setup without an element!'); + } + + if (element.classList.contains(ASYNC_TABLE_INITIALIZED_CLASS)) { + return false; + } + + // param asyncTableDbHeader + if (element.dataset.asyncTableDbHeader !== undefined) { + asyncTableHeader = element.dataset.asyncTableDbHeader; + } + + var rawTableId = element.querySelector('table').id; + cssIdPrefix = findCssIdPrefix(rawTableId); + asyncTableId = rawTableId.replace(cssIdPrefix, ''); + + // find scrolltable wrapper + scrollTable = element.querySelector(ASYNC_TABLE_SCROLLTABLE_SELECTOR); + if (!scrollTable) { + throw new Error('Async Table cannot be set up without a scrolltable element!'); + } + + setupSortableHeaders(); + setupPagination(); + setupPageSizeSelect(); + setupTableFilter(); + + processLocalStorage(); + + // clear currentTableUrl from previous requests + setLocalStorageParameter('currentTableUrl', null); + + // mark initialized + element.classList.add(ASYNC_TABLE_INITIALIZED_CLASS); + + return { + name: ASYNC_TABLE_UTIL_NAME, + element: element, + destroy: function() {}, + }; + } + + function setupSortableHeaders() { + ths = Array.from(scrollTable.querySelectorAll('th.sortable')).map(function(th) { + return { element: th }; + }); + + ths.forEach(function(th) { + th.clickHandler = function(event) { + setLocalStorageParameter('horizPos', (scrollTable || {}).scrollLeft); + linkClickHandler(event); + }; + th.element.addEventListener('click', th.clickHandler); + }); + } + + function setupPagination() { + var pagination = element.querySelector('#' + cssIdPrefix + asyncTableId + '-pagination'); + if (pagination) { + pageLinks = Array.from(pagination.querySelectorAll('.page-link')).map(function(link) { + return { element: link }; + }); + + pageLinks.forEach(function(link) { + link.clickHandler = function(event) { + var tableBoundingRect = scrollTable.getBoundingClientRect(); + if (tableBoundingRect.top < HEADER_HEIGHT) { + var scrollTo = { + top: (scrollTable.offsetTop || 0) - HEADER_HEIGHT, + left: scrollTable.offsetLeft || 0, + behavior: 'smooth', + }; + setLocalStorageParameter('scrollTo', scrollTo); + } + linkClickHandler(event); + } + link.element.addEventListener('click', link.clickHandler); + }); + } + } + + function setupPageSizeSelect() { + // pagesize form + pagesizeForm = element.querySelector('#' + cssIdPrefix + asyncTableId + '-pagesize-form'); + + if (pagesizeForm) { + var pagesizeSelect = pagesizeForm.querySelector('[name=' + asyncTableId + '-pagesize]'); + pagesizeSelect.addEventListener('change', changePagesizeHandler); + } + } + + function setupTableFilter() { + var tableFilterForm = element.querySelector(ASYNC_TABLE_FILTER_FORM_SELECTOR); + + if (tableFilterForm) { + gatherTableFilterInputs(tableFilterForm); + addTableFilterEventListeners(tableFilterForm); + } + } + + function gatherTableFilterInputs(tableFilterForm) { + Array.from(tableFilterForm.querySelectorAll('input[type="search"]')).forEach(function(input) { + tableFilterInputs.search.push(input); + }); + + Array.from(tableFilterForm.querySelectorAll('input[type="text"]')).forEach(function(input) { + tableFilterInputs.input.push(input); + }); + + Array.from(tableFilterForm.querySelectorAll('input:not([type="text"]):not([type="search"])')).forEach(function(input) { + tableFilterInputs.change.push(input); + }); + + Array.from(tableFilterForm.querySelectorAll('select')).forEach(function(input) { + tableFilterInputs.select.push(input); + }); + } + + function addTableFilterEventListeners(tableFilterForm) { + tableFilterInputs.search.forEach(function(input) { + var debouncedInput = debounce(function() { + if (input.value.length === 0 || input.value.length > 2) { + updateFromTableFilter(tableFilterForm); + } + }, INPUT_DEBOUNCE); + input.addEventListener('input', debouncedInput); + }); + + tableFilterInputs.input.forEach(function(input) { + var debouncedInput = debounce(function() { + if (input.value.length === 0 || input.value.length > 2) { + updateFromTableFilter(tableFilterForm); + } + }, INPUT_DEBOUNCE); + input.addEventListener('input', debouncedInput); + }); + + tableFilterInputs.change.forEach(function(input) { + input.addEventListener('change', function() { + updateFromTableFilter(tableFilterForm); + }); + }); + + tableFilterInputs.select.forEach(function(input) { + input.addEventListener('change', function() { + updateFromTableFilter(tableFilterForm); + }); + }); + + tableFilterForm.addEventListener('submit', function(event) { + event.preventDefault(); + updateFromTableFilter(tableFilterForm); + }); + } + + function updateFromTableFilter(tableFilterForm) { + var url = serializeTableFilterToURL(); + var callback = null; + + var focusedInput = tableFilterForm.querySelector(':focus, :active'); + // focus previously focused input + if (focusedInput && focusedInput.selectionStart !== null) { + var selectionStart = focusedInput.selectionStart; + // remove the following part of the id to get rid of the random + // (yet somewhat structured) prefix we got from nudging. + var prefix = findCssIdPrefix(focusedInput.id); + var focusId = focusedInput.id.replace(prefix, ''); + callback = function(wrapper) { + var idPrefix = getLocalStorageParameter('cssIdPrefix'); + var toBeFocused = wrapper.querySelector('#' + idPrefix + focusId); + if (toBeFocused) { + toBeFocused.focus(); + toBeFocused.selectionStart = selectionStart; + } + }; + } + updateTableFrom(url, callback); + } + + function serializeTableFilterToURL() { + var url = new URL(getLocalStorageParameter('currentTableUrl') || window.location.href); + + var formIdElement = element.querySelector(ASYNC_TABLE_FILTER_FORM_ID_SELECTOR); + if (!formIdElement) { + // cannot serialize the filter form without an identifier + return; + } + + url.searchParams.set('form-identifier', formIdElement.value); + url.searchParams.set('_hasdata', 'true'); + url.searchParams.set(asyncTableId + '-page', '0'); + + tableFilterInputs.search.forEach(function(input) { + url.searchParams.set(input.name, input.value); + }); + + tableFilterInputs.input.forEach(function(input) { + url.searchParams.set(input.name, input.value); + }); + + tableFilterInputs.change.forEach(function(input) { + if (input.checked) { + url.searchParams.set(input.name, input.value); + } + }); + + tableFilterInputs.select.forEach(function(select) { + var options = Array.from(select.querySelectorAll('option')); + var selected = options.find(function(option) { return option.selected }); + if (selected) { + url.searchParams.set(select.name, selected.value); + } + }); + + return url; + } + + function processLocalStorage() { + var scrollTo = getLocalStorageParameter('scrollTo'); + if (scrollTo && scrollTable) { + window.scrollTo(scrollTo); + } + setLocalStorageParameter('scrollTo', null); + + var horizPos = getLocalStorageParameter('horizPos'); + if (horizPos && scrollTable) { + scrollTable.scrollLeft = horizPos; + } + setLocalStorageParameter('horizPos', null); + } + + function removeListeners() { + ths.forEach(function(th) { + th.element.removeEventListener('click', th.clickHandler); + }); + + pageLinks.forEach(function(link) { + link.element.removeEventListener('click', link.clickHandler); + }); + + if (pagesizeForm) { + var pagesizeSelect = pagesizeForm.querySelector('[name=' + asyncTableId + '-pagesize]') + pagesizeSelect.removeEventListener('change', changePagesizeHandler); + } + } + + function linkClickHandler(event) { + event.preventDefault(); + var url = getClickDestination(event.target); + if (!url.match(/^http/)) { + url = window.location.origin + window.location.pathname + url; + } + updateTableFrom(url); + } + + function getClickDestination(el) { + if (!el.matches('a') && !el.querySelector('a')) { + return ''; + } + return el.getAttribute('href') || el.querySelector('a').getAttribute('href'); + } + + function changePagesizeHandler(event) { + var paginationParamKey = asyncTableId + '-pagination'; + var pagesizeParamKey = asyncTableId + '-pagesize'; + var pageParamKey = asyncTableId + '-page'; + + var paginationParamEl = pagesizeForm.querySelector('[name="' + paginationParamKey + '"]'); + var url = new URL(getLocalStorageParameter('currentTableUrl') || window.location.href); + url.searchParams.set(pagesizeParamKey, event.target.value); + url.searchParams.set(pageParamKey, 0); + + if (paginationParamEl) { + var encodedValue = encodeURIComponent(paginationParamEl.value); + url.searchParams.set(paginationParamKey, encodedValue); + } + updateTableFrom(url.href); + } + + // fetches new sorted element from url with params and replaces contents of current element + function updateTableFrom(url, callback) { + if (!HttpClient) { + throw new Error('HttpClient not found!'); + } + + element.classList.add(ASYNC_TABLE_LOADING_CLASS); + + var headers = { + 'Accept': 'text/html', + [asyncTableHeader]: asyncTableId + }; + + HttpClient.get({ + url: url, + headers: headers, + accept: HttpClient.ACCEPT.TEXT_HTML, + }).then(function(response) { + return HtmlHelpers.parseResponse(response); + }).then(function(response) { + setLocalStorageParameter('currentTableUrl', url.href); + // reset table + removeListeners(); + element.classList.remove(ASYNC_TABLE_INITIALIZED_CLASS); + // update table with new + updateWrapperContents(response); + + if (UtilRegistry) { + UtilRegistry.setupAll(element); + } + + if (callback && typeof callback === 'function') { + setLocalStorageParameter('cssIdPrefix', response.idPrefix); + callback(element); + setLocalStorageParameter('cssIdPrefix', ''); + } + }).catch(function(err) { + console.error(err); + }).finally(function() { + element.classList.remove(ASYNC_TABLE_LOADING_CLASS); + }); + } + + function updateWrapperContents(response) { + var newPage = document.createElement('div'); + newPage.appendChild(response.element); + var newWrapperContents = newPage.querySelector('#' + response.idPrefix + element.id); + element.innerHTML = newWrapperContents.innerHTML; + } + + return init(); + }; + + // returns any random nudged prefix found in the given id + function findCssIdPrefix(id) { + var matcher = /r\d*?__/; + var maybePrefix = id.match(matcher); + if (maybePrefix && maybePrefix[0]) { + return maybePrefix[0] + } + return ''; + } + + function setLocalStorageParameter(key, value) { + var currentLSState = JSON.parse(window.localStorage.getItem(ASYNC_TABLE_LOCAL_STORAGE_KEY)) || {}; + if (value !== null) { + currentLSState[key] = value; + } else { + delete currentLSState[key]; + } + window.localStorage.setItem(ASYNC_TABLE_LOCAL_STORAGE_KEY, JSON.stringify(currentLSState)); + } + + function getLocalStorageParameter(key) { + var currentLSState = JSON.parse(window.localStorage.getItem(ASYNC_TABLE_LOCAL_STORAGE_KEY)) || {}; + return currentLSState[key]; + } + + // debounce function, taken from Underscore.js + function debounce(func, wait, immediate) { + var timeout; + return function() { + var context = this, args = arguments; + var later = function() { + timeout = null; + if (!immediate) func.apply(context, args); + }; + var callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) func.apply(context, args); + }; + } + + // register async table utility + if (UtilRegistry) { + UtilRegistry.register({ + name: ASYNC_TABLE_UTIL_NAME, + selector: ASYNC_TABLE_UTIL_SELECTOR, + setup: asyncTableUtil, + }); + } +})();