480 lines
16 KiB
JavaScript
480 lines
16 KiB
JavaScript
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 <table> or a <div .div__course-teaser> 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 '';
|
|
}
|