import { jsOMS } from '../../Utils/oLib.js'; import { TableView } from '../../Views/TableView.js'; import { Request } from '../../Message/Request/Request.js'; import { ResponseType } from '../../Message/Response/ResponseType.js'; /** * Table manager class. * * @copyright Dennis Eichhorn * @license OMS License 2.2 * @version 1.0.0 * @since 1.0.0 * * @feature Karaka/jsOMS#55 * Implement filtering and sorting based on backend * * @feature Karaka/jsOMS#57 * Advanced filtering * The current filtering implementation is only column by column connected with &&. * Consider to implement a much more advanced filtering where different combinations are possible such as || &&, different ordering with parenthesis etc. * This can be extremely powerful but will be complex for standard users. * This advanced filtering should probably be a little bit hidden? * * @feature Karaka/jsOMS#59 * Data download * There is a small icon in the top right corner of tables which allows (not yet to be honest) to download the data in the table. * Whether the backend should be queried for this or only the frontend data should be collected (current situation) should depend on if the table has an api endpoint defined. * * @feature Allow column drag/drop ordering which is also saved in the front end * * @feature Implement a filter highlight function. * Either in forms or in tables, where the filter icon is highlighted, if a filter is defined. * One solution could be to put an additional hidden filter checkbox in front of the filter icon and check for filter changes * (bubble up) and then activate this hidden checkbox if a filter is defined. * In CSS just define the filter icon as active/highlighted, if the hidden check box is active. * This means we have two hidden checkboxes in front of the filter icon (one in case the filter menu is open = popup * is visible and another one for highlighting the filter icon if a filter is defined). * https://github.com/Karaka-Management/Karaka/issues/148 * * @todo How to preserve form filter data to the next page? * Not an issue, in the future we don't want to reload the whole page, * but only exchange the table/list content with the backend response -> the header/filter will not get changed and * remains as defined. This means for tables (maybe even forms?) to setup content replacement earlier than for other pages?! * https://github.com/Karaka-Management/Karaka/issues/149 */ export class Table { /** * @constructor * * @param {Object} app Application * * @since 1.0.0 */ constructor (app) { this.app = app; /** @var {Object } */ this.tables = {}; this.ignore = {}; }; /** * Bind & rebind UI elements. * * @param {null|string} [id] Element id * * @return {void} * * @since 1.0.0 */ bind (id = null) { if (id !== null && typeof this.ignore[id] === 'undefined') { this.bindTable(id); return; } const tables = document.getElementsByTagName('table'); const length = !tables ? 0 : tables.length; for (let i = 0; i < length; ++i) { const tableId = tables[i].getAttribute('id'); if (typeof tableId !== 'undefined' && tableId !== null && typeof this.ignore[tableId] === 'undefined') { this.bindTable(tableId); } } }; /** * Bind & rebind UI element. * * @param {Object} id Element id * * @return {void} * * @since 1.0.0 */ bindTable (id = null) { if (id === null) { jsOMS.Log.Logger.instance.info('A table doesn\'t have an ID.'); return; } this.tables[id] = new TableView(id); this.bindExport(this.tables[id]); const sorting = this.tables[id].getSorting(); let length = sorting.length; for (let i = 0; i < length; ++i) { this.bindSorting(sorting[i], id); } const filters = this.tables[id].getFilter(); length = filters.length; for (let i = 0; i < length; ++i) { this.bindFiltering(filters[i], id); } const checkboxes = this.tables[id].getCheckboxes(); length = checkboxes.length; for (let i = 0; i < length; ++i) { this.bindCheckbox(checkboxes[i], id); } const header = this.tables[id].getHeader(); this.bindColumnVisibility(header, id); }; /** * Export a table * * @param {Element} exports Export button * * @return {void} * * @since 1.0.0 */ bindExport (exports) { const button = exports.getExport(); if (typeof button === 'undefined' || button === null) { return; } button.addEventListener('click', function (event) { window.omsApp.logger.log(exports.serialize()); /** * @todo Karaka/jsOMS#90 * Implement export * Either create download in JS from this data or make round trip to server who then sends the data. * The export should be possible (if available) in json, csv, excel, word, pdf, ... * If no endpoint is specified or reachable the client side should create a json or csv export. */ }); }; /** * Bind column visibility * * @param {Element} header Header * * @return {void} * * @since 1.0.0 */ bindColumnVisibility (header) { const self = this; const columns = header.querySelectorAll('td'); const length = columns.length; for (let i = 0; i < length; ++i) { const state = JSON.parse(window.localStorage.getItem('ui-state-' + this.id + '-header-' + i)); const rows = header.parentElement.getElementsByTagName('tr'); const rowLength = rows.length; if (state === '1' && !jsOMS.hasClass(columns[i], 'vh')) { for (let j = 0; j < rowLength; ++j) { const cols = rows[j].getElementsByTagName('td'); if (cols.length < length) { continue; } jsOMS.addClass(cols[i], 'vh'); } } else if ((state === '0' || state === null) && jsOMS.hasClass(columns[i], 'vh')) { for (let j = 0; j < rowLength; ++j) { const cols = rows[j].getElementsByTagName('td'); if (cols.length < length) { continue; } jsOMS.removeClass(cols[i], 'vh'); } } } header.addEventListener('contextmenu', function (event) { jsOMS.preventAll(event); if (document.getElementById('table-ctx-menu') !== null) { return; } const tpl = document.getElementById('table-ctx-menu-tpl'); if (tpl === null) { return; } const output = document.importNode(tpl.content, true); tpl.parentNode.appendChild(output); const menu = document.getElementById('table-ctx-menu'); const columns = header.querySelectorAll('td'); const length = columns.length; const baseMenuLine = menu.getElementsByClassName('context-line')[0].cloneNode(true); // @todo simplify by doing it for the whole header? event bubbling for (let i = 0; i < length; ++i) { if (columns[i].firstElementChild.innerText.trim() === '') { continue; } const menuLine = baseMenuLine.cloneNode(true); const lineId = menuLine.firstElementChild.getAttribute('get') + i; menuLine.firstElementChild.setAttribute('for', lineId); menuLine.firstElementChild.firstElementChild.setAttribute('id', lineId); menuLine.firstElementChild.appendChild(document.createTextNode(columns[i].firstElementChild.innerText.trim())); const isHidden = jsOMS.hasClass(columns[i], 'vh'); menu.getElementsByTagName('ul')[0].appendChild(menuLine); menu.querySelector('ul').lastElementChild.querySelector('input[type="checkbox"]').checked = !isHidden; menu.querySelector('ul').lastElementChild.querySelector('input[type="checkbox"]').addEventListener('change', function () { const rows = header.parentElement.getElementsByTagName('tr'); const rowLength = rows.length; const isHidden = jsOMS.hasClass(columns[i], 'vh'); if (isHidden) { window.localStorage.setItem('ui-state-' + self.id + '-header-' + i, JSON.stringify('0')); } else { window.localStorage.setItem('ui-state-' + self.id + '-header-' + i, JSON.stringify('1')); } for (let j = 0; j < rowLength; ++j) { const cols = rows[j].getElementsByTagName('td'); if (isHidden) { jsOMS.removeClass(cols[i], 'vh'); } else { jsOMS.addClass(cols[i], 'vh'); } } }); } menu.getElementsByTagName('ul')[0].removeChild(menu.getElementsByClassName('context-line')[0]); const rect = tpl.parentElement.getBoundingClientRect(); menu.style.top = (event.clientY - rect.top) + 'px'; menu.style.left = (event.clientX - rect.left) + 'px'; document.addEventListener('click', Table.hideMenuClickHandler); }); }; static hideMenuClickHandler (event) { const menu = document.getElementById('table-ctx-menu'); const isClickedOutside = !menu.contains(event.target); if (isClickedOutside) { menu.parentNode.removeChild(menu); document.removeEventListener('click', Table.hideMenuClickHandler); } }; /** * Sorts the table. * * @param {Element} sorting Sort button * @param {string} id Table id * * @return {void} * * @since 1.0.0 */ bindSorting (sorting, id) { sorting.addEventListener('click', function (event) { if (this.firstElementChild === null || this.firstElementChild.tagName.toLowerCase() === 'a') { // page is getting reloaded return; } const table = document.getElementById(id); const rows = table.getElementsByTagName('tbody')[0].rows; const rowLength = rows.length; const cellId = this.closest('td').cellIndex; const sortType = jsOMS.hasClass(this, 'sort-asc') ? 1 : -1; let j; let i; let row1; let row2; let content1; let content2; let order = false; let shouldSwitch = false; const columnName = this.closest('td').getAttribute('data-name'); // only necessary for retrieving remote data table.setAttribute('data-sorting', (sortType > 0 ? '+' : '-') + (columnName !== null ? columnName : cellId)); if (table.getAttribute('data-src') !== null) { Table.getRemoteData(table); return; } do { order = false; for (j = 0; j < rowLength - 1; ++j) { shouldSwitch = false; row1 = rows[j].getElementsByTagName('td')[cellId]; content1 = row1.getAttribute('data-content') !== null ? row1.getAttribute('data-content').toLowerCase() : row1.textContent.toLowerCase(); content1 = !isNaN(content1) ? parseFloat(content1) : (!isNaN(new Date(content1)) ? new Date(content1) : content1 ); for (i = j + 1; i < rowLength; ++i) { row2 = rows[i].getElementsByTagName('td')[cellId]; content2 = row2.getAttribute('data-content') !== null ? row2.getAttribute('data-content').toLowerCase() : row2.textContent.toLowerCase(); content2 = !isNaN(content2) ? parseFloat(content2) : (!isNaN(new Date(content2)) ? new Date(content2) : content2); if (sortType === 1 && content1 > content2) { shouldSwitch = true; break; } else if (sortType === -1 && content1 < content2) { shouldSwitch = true; break; } } if (shouldSwitch === true) { break; } } if (shouldSwitch) { rows[j].parentNode.insertBefore(rows[i], rows[j]); order = true; } } while (order); }); }; /** * Filters the table. * * @param {Element} filtering Filter button * @param {string} id Table id * * @return {void} * * @since 1.0.0 */ bindFiltering (filtering, id) { filtering.addEventListener('click', function (event) { }); }; /** * Checkbox select. * * @param {Element} checkbox Filter button * @param {string} id Table id * * @return {void} * * @since 1.0.0 */ bindCheckbox (checkbox, id) { checkbox.addEventListener('click', function (event) { const columnId = checkbox.closest('td').cellIndex; const rows = checkbox.closest('table').querySelectorAll('tbody tr'); const rowLength = rows.length; const status = checkbox.checked; for (let i = 0; i < rowLength; ++i) { if (typeof rows[i].cells[columnId] === 'undefined') { break; } const box = rows[i].cells[columnId].querySelector('input[type=checkbox]'); if (box !== null) { box.checked = status; } } }); } static getRemoteData (table) { const data = { limit: table.getAttribute('data-limit'), offset: table.getAttribute('data-offset'), sorting: table.getAttribute('data-sorting'), filter: table.getAttribute('data-filter') }; const request = new Request(); request.setData(data); request.setType(ResponseType.JSON); request.setUri(table.getAttribute('data-src')); request.setMethod('GET'); request.setRequestHeader('Content-Type', 'application/json'); request.setSuccess(function (xhr) { Table.emptyTable(table.getElementsByTagName('tbody')[0]); Table.addToTable(table.getElementsByTagName('tbody')[0], JSON.parse(xhr.response)[0]); }); request.send(); }; static emptyTable (table) { const rows = table.getElementsByTagName('tr'); const length = rows.length; for (let i = 0; i < length; ++i) { rows[i].parentNode.removeChild(rows[i]); } }; static addToTable (table, data) { const dataLength = data.length; console.table(data); for (let i = 0; i < dataLength; ++i) { // set readable value const newRow = table.getElementsByTagName('template')[0].content.cloneNode(true); let fields = newRow.querySelectorAll('[data-tpl-text]'); let fieldLength = fields.length; for (let j = 0; j < fieldLength; ++j) { fields[j].appendChild( document.createTextNode( jsOMS.getArray(fields[j].getAttribute('data-tpl-text'), data[i]) ) ); } // set internal value fields = newRow.querySelectorAll('[data-tpl-value]'); fieldLength = fields.length; for (let j = 0; j < fieldLength; ++j) { fields[j].setAttribute( 'data-value', jsOMS.getArray(fields[j].getAttribute('data-tpl-value'), data[i]) ); } table.appendChild(newRow); // @todo bind buttons if required (e.g. remove, edit button) } }; };