import { Utility } from '../../core/utility'; import { StorageManager, LOCATION } from '../../lib/storage-manager/storage-manager'; import { Datepicker } from '../form/datepicker'; import { HttpClient } from '../../services/http-client/http-client'; import { EventManager, EventWrapper, EVENT_TYPE } from '../../lib/event-manager/event-manager'; import debounce from 'lodash.debounce'; import throttle from 'lodash.throttle'; import './async-table-filter.sass'; import './async-table.sass'; const ATTR_SUBMIT_LOCKED = 'submit-locked'; const INPUT_DEBOUNCE = 600; const FILTER_DEBOUNCE = 100; const HEADER_HEIGHT = 80; const ASYNC_TABLE_STORAGE_KEY = 'ASYNC_TABLE'; const ASYNC_TABLE_STORAGE_VERSION = '2.0.0'; const ASYNC_TABLE_SCROLLTABLE_SELECTOR = '.scrolltable'; const ASYNC_TABLE_INITIALIZED_CLASS = 'async-table--initialized'; const ASYNC_TABLE_LOADING_CLASS = 'async-table--loading'; const ASYNC_TABLE_FILTER_FORM_SELECTOR = '.table-filter-form'; @Utility({ selector: '[uw-async-table]', }) export class AsyncTable { _element; _app; _eventManager; _asyncTableHeader; _asyncTableId; _ths = []; _pageLinks = []; _pagesizeForm; _scrollTable; _cssIdPrefix = ''; _cancelPendingUpdates = []; _tableFilterInputs = { search: [], input: [], change: [], select: [], }; _ignoreRequest = false; _windowStorage; _historyStorage; _active = true; constructor(element, app) { if (!element) { throw new Error('Async Table utility cannot be setup without an element!'); } if (!app) { throw new Error('Async Table utility cannot be setup without an app!'); } this._element = element; this._app = app; this._eventManager = new EventManager(); if (this._element.classList.contains(ASYNC_TABLE_INITIALIZED_CLASS)) { return false; } // param asyncTableDbHeader if (this._element.dataset.asyncTableDbHeader !== undefined) { this._asyncTableHeader = this._element.dataset.asyncTableDbHeader; } const table = this._element.querySelector('table, .div__course-teaser'); if (!table) { throw new Error('Async Table utility needs a or a
in its element!'); } const rawTableId = table.id; this._cssIdPrefix = findCssIdPrefix(rawTableId); this._asyncTableId = rawTableId.replace(this._cssIdPrefix, ''); if (!this._asyncTableId) { throw new Error('Async Table cannot be set up without an ident!'); } this._windowStorage = new StorageManager([ASYNC_TABLE_STORAGE_KEY, this._asyncTableId], ASYNC_TABLE_STORAGE_VERSION, { location: LOCATION.WINDOW }); this._historyStorage = new StorageManager([ASYNC_TABLE_STORAGE_KEY, this._asyncTableId], ASYNC_TABLE_STORAGE_VERSION, { location: LOCATION.HISTORY }); // find scrolltable wrapper this._scrollTable = this._element.querySelector(ASYNC_TABLE_SCROLLTABLE_SELECTOR); if (!this._scrollTable) { throw new Error('Async Table cannot be set up without a scrolltable element!'); } this._processStorage(); this._setupTableFilter(); this._windowStorage.remove('currentTableUrl'); if (!('currentTableUrl' in this._element.dataset)) { this._element.dataset['currentTableUrl'] = document.location.href; this._historyStorage.save('currentTableUrl', document.location.href, { location: LOCATION.HISTORY, history: { push: false } }); } this._historyListener(); this._historyStorage.addHistoryListener(this._historyListener.bind(this)); // mark initialized if (this._active) this._element.classList.add(ASYNC_TABLE_INITIALIZED_CLASS); } _historyListener(historyState) { if (!this._active) return; const windowUrl = this._element.dataset['currentTableUrl']; const historyUrl = historyState ? historyState['currentTableUrl'] : this._historyStorage.load('currentTableUrl'); this._debugLog('_historyListener', historyState, windowUrl, historyUrl); if (this._isEquivalentUrl(windowUrl, historyUrl || document.location.href)) return; this._debugLog('_historyListener', historyUrl); this._updateTableFrom(historyUrl || document.location.href, undefined, true); } _isEquivalentUrl(a, b) { return a === b; } start() { this._startSortableHeaders(); this._startPagination(); this._startPageSizeSelect(); this._startTableFilter(); } destroy() { this._windowStorage.clear(this._windowStorage._options); this._eventManager.cleanUp(); this._active = false; if (this._element.classList.contains(ASYNC_TABLE_INITIALIZED_CLASS)) this._element.classList.remove(ASYNC_TABLE_INITIALIZED_CLASS); } _startSortableHeaders() { this._ths = Array.from(this._scrollTable.querySelectorAll('th.sortable, .course-header')) .map((th) => ({ element: th })); this._ths.forEach((th) => { th.clickHandler = (event) => { this._windowStorage.save('horizPos', (this._scrollTable || {}).scrollLeft); this._linkClickHandler(event); }; const linkClickEvent = new EventWrapper(EVENT_TYPE.CLICK, th.clickHandler.bind(this), th.element); this._eventManager.registerNewListener(linkClickEvent); }); } _startPagination() { const pagination = this._element.querySelector('#' + this._cssIdPrefix + this._asyncTableId + '-pagination'); if (pagination) { this._pageLinks = Array.from(pagination.querySelectorAll('.page-link')) .map((link) => ({ element: link })); this._pageLinks.forEach((link) => { link.clickHandler = (event) => { const tableBoundingRect = this._scrollTable.getBoundingClientRect(); if (tableBoundingRect.top < HEADER_HEIGHT) { const scrollTo = { top: (this._scrollTable.offsetTop || 0) - HEADER_HEIGHT, left: this._scrollTable.offsetLeft || 0, behavior: 'smooth', }; this._windowStorage.save('scrollTo', scrollTo); } this._linkClickHandler(event); }; const clickEvent = new EventWrapper(EVENT_TYPE.CLICK, link.clickHandler.bind(this), link.element); this._eventManager.registerNewListener(clickEvent); }); } } _startPageSizeSelect() { // pagesize form this._pagesizeForm = this._element.querySelector('#' + this._cssIdPrefix + this._asyncTableId + '-pagesize-form'); if (this._pagesizeForm) { const pagesizeSelect = this._pagesizeForm.querySelector('[name=' + this._asyncTableId + '-pagesize]'); const pageSizeChangeEvent = new EventWrapper(EVENT_TYPE.CHANGE, this._changePagesizeHandler.bind(this), pagesizeSelect); this._eventManager.registerNewListener(pageSizeChangeEvent); } } _setupTableFilter() { const tableFilterForm = this._element.querySelector(ASYNC_TABLE_FILTER_FORM_SELECTOR); if (tableFilterForm) { this._gatherTableFilterInputs(tableFilterForm); } } _startTableFilter() { const tableFilterForm = this._element.querySelector(ASYNC_TABLE_FILTER_FORM_SELECTOR); if (tableFilterForm) { this._addTableFilterEventListeners(tableFilterForm); } } _gatherTableFilterInputs(tableFilterForm) { Array.from(tableFilterForm.querySelectorAll('input')).forEach((input) => { const inputType = input.getAttribute('type'); if (inputType === 'search') { this._tableFilterInputs.search.push(input); } else if (['text','date','time','datetime-local'].includes(inputType)) { this._tableFilterInputs.input.push(input); } else { this._tableFilterInputs.change.push(input); } }); Array.from(tableFilterForm.querySelectorAll('select')).forEach((input) => this._tableFilterInputs.select.push(input)); } _addTableFilterEventListeners(tableFilterForm) { const debouncedUpdateFromTableFilter = throttle((() => this._updateFromTableFilter(tableFilterForm)).bind(this), FILTER_DEBOUNCE, { leading: true, trailing: false }); [...this._tableFilterInputs.search, ...this._tableFilterInputs.input].forEach((input) => { this._cancelPendingUpdates.push(() => { this._eventManager.removeAllObserversFromUtil();}); const debouncedInput = debounce(() => { const submitLockedAttr = input.getAttribute(ATTR_SUBMIT_LOCKED); const submitLocked = submitLockedAttr === 'true'; if (!submitLocked && (input.value.length === 0 || input.value.length > 2)) { debouncedUpdateFromTableFilter(); } else if (submitLockedAttr === 'true') { // observe the submit lock of the input element this._eventManager.registerNewMutationObserver(((mutations, observer) => { for (const mutation of mutations) { // if the submit lock has been released, trigger an update and disconnect this observer if (mutation.target === input && mutation.attributeName === ATTR_SUBMIT_LOCKED && mutation.oldValue === 'true' && mutation.target.getAttribute(mutation.attributeName) === 'false') { debouncedUpdateFromTableFilter(); observer.disconnect(); break; } } }).bind(this), input, { attributes: true, attributeFilter: [ATTR_SUBMIT_LOCKED], attributeOldValue: true, }); } }, INPUT_DEBOUNCE); this._cancelPendingUpdates.push(debouncedInput.cancel); const inputHandler =() => { this._ignoreRequest = true; debouncedInput(); }; const inputEvent = new EventWrapper(EVENT_TYPE.INPUT, inputHandler.bind(this), input ); this._eventManager.registerNewListener(inputEvent); }); this._tableFilterInputs.change.forEach((input) => { const changeHandler = () => { //if (this._element.classList.contains(ASYNC_TABLE_LOADING_CLASS)) this._ignoreRequest = true; debouncedUpdateFromTableFilter(); }; const changeEvent = new EventWrapper(EVENT_TYPE.CHANGE, changeHandler.bind(this), input); this._eventManager.registerNewListener(changeEvent); }); this._tableFilterInputs.select.forEach((input) => { const selectChangeHandler = () => { this._ignoreRequest = true; debouncedUpdateFromTableFilter(); }; const selectEvent = new EventWrapper(EVENT_TYPE.CHANGE, selectChangeHandler.bind(this), input); this._eventManager.registerNewListener(selectEvent); }); const submitEventHandler = (event) =>{ event.preventDefault(); this._ignoreRequest = true; debouncedUpdateFromTableFilter(); }; const submitEvent = new EventWrapper(EVENT_TYPE.SUBMIT, submitEventHandler.bind(this), tableFilterForm); this._eventManager.registerNewListener(submitEvent); } _updateFromTableFilter(tableFilterForm) { const url = this._serializeTableFilterToURL(tableFilterForm); let callback = null; const focusedInput = tableFilterForm.querySelector(':focus, :active'); // focus previously focused input if (focusedInput && focusedInput.selectionStart !== null) { const selectionStart = focusedInput.selectionStart; // remove the following part of the id to get rid of the random // (yet somewhat structured) prefix we got from nudging. const prefix = findCssIdPrefix(focusedInput.id); const focusId = focusedInput.id.replace(prefix, ''); callback = function(wrapper) { const idPrefix = this._windowStorage.load('cssIdPrefix'); const toBeFocused = wrapper.querySelector('#' + idPrefix + focusId); if (toBeFocused) { toBeFocused.focus(); toBeFocused.selectionStart = selectionStart; } }; } this._updateTableFrom(url, callback && callback.bind(this)); } _serializeTableFilterToURL(tableFilterForm) { const url = new URL(this._windowStorage.load('currentTableUrl') || window.location.href); // create new FormData and format any date values const formData = Datepicker.unformatAll(tableFilterForm, new FormData(tableFilterForm)); this._debugLog('_serializeTableFilterToURL', Array.from(formData.entries()), url.href); const searchParams = new URLSearchParams(Array.from(formData.entries())); url.search = searchParams.toString(); this._debugLog('_serializeTableFilterToURL', url.href); return url; } _processStorage() { const scrollTo = this._windowStorage.load('scrollTo'); if (scrollTo && this._scrollTable) { window.scrollTo(scrollTo); } this._windowStorage.remove('scrollTo'); const horizPos = this._windowStorage.load('horizPos'); if (horizPos && this._scrollTable) { this._scrollTable.scrollLeft = horizPos; } this._windowStorage.remove('horizPos'); } _removeListeners() { this._ths.forEach(function(th) { th.element.removeEventListener('click', th.clickHandler); }); this._pageLinks.forEach(function(link) { link.element.removeEventListener('click', link.clickHandler); }); if (this._pagesizeForm) { const pagesizeSelect = this._pagesizeForm.querySelector('[name=' + this._asyncTableId + '-pagesize]'); pagesizeSelect.removeEventListener('change', this._changePagesizeHandler); } } _linkClickHandler = (event) => { event.preventDefault(); let url = this._getClickDestination(event.target); if (!url) return; if (!url.match(/^http/)) { url = window.location.origin + window.location.pathname + url; } this._updateTableFrom(url); }; _getClickDestination(el) { if (!el.matches('a') && !el.querySelector('a')) { return null; } return el.getAttribute('href') || el.querySelector('a').getAttribute('href'); } _changePagesizeHandler = () => { const url = new URL(this._windowStorage.load('currentTableUrl') || window.location.href); // create new FormData and format any date values const formData = Datepicker.unformatAll(this._pagesizeForm, new FormData(this._pagesizeForm)); for (const k of url.searchParams.keys()) { url.searchParams.delete(k); } for (const kv of formData.entries()) { url.searchParams.append(kv[0], kv[1]); } this._updateTableFrom(url.href); }; // fetches new sorted element from url with params and replaces contents of current element _updateTableFrom(url, callback, isPopState) { url = new URL(url); const cancelPendingUpdates = (() => { this._cancelPendingUpdates.forEach(f => f()); }).bind(this); [0, INPUT_DEBOUNCE * 1.5, FILTER_DEBOUNCE * 1.5].forEach(delay => window.setTimeout(cancelPendingUpdates, delay)); this._ignoreRequest = false; this._element.classList.add(ASYNC_TABLE_LOADING_CLASS); const headers = { 'Accept': HttpClient.ACCEPT.TEXT_HTML, [this._asyncTableHeader]: this._asyncTableId, }; this._app.httpClient.get({ url: url, headers: headers, }).then( (response) => this._app.htmlHelpers.parseResponse(response), ).then((response) => { // check if request should be ignored if (this._ignoreRequest) { return false; } if (!isPopState) this._historyStorage.save('currentTableUrl', url.href, { location: LOCATION.HISTORY, history: { push: true, url: response.headers.get('DB-Table-Canonical-URL') || url.href } }); this._windowStorage.save('currentTableUrl', url.href); // reset table this._removeListeners(); this._active = false; this._element.classList.remove(ASYNC_TABLE_INITIALIZED_CLASS); this._element.dataset['currentTableUrl'] = url.href; // update table with new this._element.innerHTML = response.element.innerHTML; this._app.utilRegistry.initAll(this._element); if (callback && typeof callback === 'function') { this._windowStorage.save('cssIdPrefix', response.idPrefix); callback(this._element); this._windowStorage.remove('cssIdPrefix'); } }).catch((err) => console.error(err), ).finally(() => this._element.classList.remove(ASYNC_TABLE_LOADING_CLASS)); } _debugLog() {} //_debugLog(fName, ...args) { // console.log(`[DEBUGLOG] AsyncTable.${fName}`, { args: args, instance: this }); // } } // returns any random nudged prefix found in the given id function findCssIdPrefix(id) { const matcher = /r\d*?__/; const maybePrefix = id.match(matcher); if (maybePrefix && maybePrefix[0]) { return maybePrefix[0]; } return ''; }