/**
* ModernTable Library
* Library for displaying a data table from a JSON source.
* @author SimpleNotSimpler
* @version 1.0.8
* @license MIT
*
* @requires modern-table.css {@link modern-table.css}
*
* Recommended (for number/date formatting):
* Number formatting: format-int-number library {@link https://github.com/simplenotsimpler/format-intl-number}
* Date formatting: Moment.js {@link https://momentjs.com/}
*
*/
/**
*
* @class ModernTable
* @classdesc A simple ES6 class targeting modern browsers that dynamically creates a data table from a JSON source. Displays nicely within Bootstrap Card.
*
* <ul><strong>RECOMMENDED</strong>:
* <li>Number formatting: <a href="https://github.com/simplenotsimpler/format-intl-number">format-intl-number library</a></li>
* <li>Date formatting: <a href="https://momentjs.com/">Moment.js</a></li>
* </ul>
*
* <ul><strong>NOTES:</strong>
* <li>Underscores (_) used to indicate private (local) methods that should not be used outside of the class.</li>
* <li>tableFooter can affect the width of the first column.</li>
* </ul>
*
* @param {string} tableContainerID The ID of the element that contains the table.
* @param {string} tableID The ID we want to assign to the table.
* @param {string} dataURL URL used to fetch JSON data to build table.
* @param {Object} [options] Optional configuration object.
* @param {string} [options.tableClasses] Enter classes as a single string same as if entering in the class attribute.
* @param {string} [options.tableCaption] String for specifying table caption.
* @param {string} [options.tableFooter] String for specifying table footer.
* @param {boolean} [options.enableSearch=true] - boolean to enable using the search box.
* @param {string} [options.searchClasses] Classes to be used on the search element.
* @param {Object} [options.colConfig] Column configuration object. Key is column name.
* @param {string} [options.colConfig.colName] The name of the column in the model to set options for.
* @param {string} [options.colConfig.colName.format] Column format: 'date-us', 'number-grouped', 'number-ungrouped', 'currency-us', 'percent'.
* @param {string} [options.colConfig.colName.dateSource] Date format of source data.
* @param {string} [options.colConfig.colName.dateDisplay] Format that the date should be displayed in.
* @param {number} [options.colConfig.colName.numDecimals=0] Number of decimals to display.
* @param {string} [options.colConfig.colName.alignment] Cell alignment: left, center, and right.
*
*/
class ModernTable {
constructor(
//required parameters
tableContainerID = '',
tableID = '',
dataURL = '',
//optional parameters via config object
{
tableClasses = '',
tableCaption = '',
tableFooter = '',
enableStickyHeader = true,
enableSearch = true,
searchClasses = '',
colConfig = {}
} = {}
) {
this.tableContainer = document.getElementById(`${tableContainerID}`);
this.tableID = tableID;
this.dataURL = dataURL;
this.tableClasses = tableClasses;
this.tableCaption = tableCaption;
this.tableFooter = tableFooter;
this.enableStickyHeader = enableStickyHeader;
this.enableSearch = enableSearch;
this.searchClasses = searchClasses;
//because using async functions with fetching data, this is a globabl variable rather than being passed
this.colConfig = colConfig;
this.defaultAlignment = 'left';
//call last
this._buildTable();
}
/**
* @summary Builds the table by fetching the data & rendering the table in the GUI.
* @private
* @memberof ModernTable
*/
async _buildTable() {
const data = await this._fetchData(this.dataURL);
this._renderTable(data);
}
/**
* @summary Fetches data and renders table if data was successfully fetched.
* @private
* @param {string} urlToBeFetched
* @returns {Object[]} responseJSON
* @memberof ModernTable
*/
async _fetchData(urlToBeFetched) {
try {
const response = await fetch(urlToBeFetched);
if (response.ok) {
//check for content type per:
// https://stackoverflow.com/questions/37121301/how-to-check-if-the-response-of-a-fetch-is-a-json-object-in-javascript
// answered May 9 '16 at 17:02 nils
const responseContentType = response.headers.get("content-type");
if (responseContentType && responseContentType.indexOf("application/json") !== -1) {
const responseJSON = await response.json();
return responseJSON;
} else {
throw new JSONSyntaxError(`<br><strong>Data URL:</strong> ${urlToBeFetched}`);
}
} else {
throw new HTTPError(`${response.status} ${response.statusText} <br><strong>Data URL:</strong> ${urlToBeFetched}`);
}
} catch (error) {
const tblErrorHandler = new TableErrorHandler(error);
}
}
/**
* @summary Processes fetched data into an HTML table.
* @private
* @param {Object[]} tableData
* @memberof ModernTable
*/
_renderTable(tableData) {
const table = document.createElement('table');
// note: order is critical. table body must be rendered FIRST !!!
// otherwise everything is rendered in the head
this._renderTableBody(table, tableData);
this._renderTableHead(table, tableData);
if (this.tableClasses) {
table.className = this.tableClasses;
}
if (this.tableCaption) {
table.createCaption().innerText = this.tableCaption;
// adding mt-caption to table colors the table text when try override
// and does not color the caption itself
// so need this class only on the caption (makes sense)
// table.classList.add('mt-caption');
table.caption.classList.add('mt-caption');
}
if (this.tableFooter) {
table.createTFoot().innerText = this.tableFooter;
}
// add class to table so retains border on table header when have sticky header
table.classList.add('mt-table');
table.id = this.tableID;
//add class to container so fixes table overflow
this.tableContainer.classList.add('mt-table-height');
//add table almost last
this.tableContainer.insertAdjacentElement('afterbegin', table);
if (this.enableSearch) {
this._renderTableSearch(table);
}
}
/**
*
* @summary Creates a search as you type search element.
* @private
* @param {Object} elementTable
* @memberof ModernTable
*/
_renderTableSearch(elementTable) {
const tableSearchBox = document.createElement('input');
tableSearchBox.className = this.searchClasses;
tableSearchBox.setAttribute('type', 'search');
tableSearchBox.setAttribute('placeholder', 'Search table');
tableSearchBox.setAttribute('aria-label', 'Search');
tableSearchBox.setAttribute('size', '50');
elementTable.insertAdjacentElement('beforebegin', tableSearchBox);
tableSearchBox.addEventListener('keyup', (e) => {
this._cancelSearch(e);
this._filterTable(e, elementTable.rows);
});
}
/**
* @summary Provides a method for canceling a search.
* @private
* @param {event} e
* @memberof ModernTable
*/
_cancelSearch(e) {
/* ESC = 27, CTRL+Z / CMD+Z = 90
http://keycodes.atjayjo.com/#charcode */
if (e.keyCode === 27 || e.keyCode === 90) {
e.target.value = '';
}
}
/**
* @summary Loops through each cell in each row and checks if matches searchText.
* @private
* @param {event} e
* @param {array} rows - rows collection of a table element
* @memberof ModernTable
*/
_filterTable(e, rows) {
const searchText = e.target.value.toLowerCase();
let isMatch = true;
// Accessing Table cells collection via the row
// https://www.w3schools.com/jsref/coll_table_cells.asp
// Keep names generic. Otherwise, renaming can break code
// e.g. tableRow.tableCells doesn't work.
for (let row of rows) {
for (let cell of row.cells) {
if (cell.textContent.toLowerCase().indexOf(searchText) > -1) {
isMatch = true;
}
}
if (isMatch) {
row.style.display = '';
isMatch = false;
} else {
row.style.display = 'none';
}
}
}
/**
* @summary Process fetched data into a table body.
* @private
* @param {string} elementTable
* @param {Object} tableData
* @memberof ModernTable
*/
_renderTableBody(elementTable, tableData) {
const dataRows = tableData;
for (let dataRow of dataRows) {
let tableRow = elementTable.insertRow();
let tableCell;
//switched to using entries so that can more easily check the key for formatting
const dataCells = Object.entries(dataRow);
for (let [dataKey, dataCell] of dataCells) {
let alignment = this.defaultAlignment;
let alignmentClass;
tableCell = tableRow.insertCell();
//from https://dmitripavlutin.com/7-tips-to-handle-undefined-in-javascript/
// section 2.2 Tip # 3
//check that colConfig was set before call stuff
if (this.colConfig) {
if (dataKey in this.colConfig) {
// format table cell
dataCell = this._formatCellValue(dataCell, dataKey);
alignment = this._setAlignment(dataKey);
}
}
alignmentClass = `align-text-${alignment}`;
tableCell.innerText = dataCell;
tableCell.classList.add(alignmentClass);
}
}
}
/**
*
* @summary Creates table row from fetched data's keys.
* @private
* @param {string} elementTable
* @param {Object[]} tableData
* @memberof ModernTable
*/
_renderTableHead(elementTable, tableData) {
const firstRow = tableData[0];
const colHeaders = Object.keys(firstRow);
let alignment = this.defaultAlignment;
let alignmentClass;
let colHeader;
let tableTH;
let tableHead = elementTable.createTHead();
let tableHeadRow = tableHead.insertRow();
for (colHeader of colHeaders) {
//have to set the colTitle to colHeader so have a default for later
let colTitle = colHeader;
if (this.colConfig) {
if (colHeader in this.colConfig) {
//set column title if it had been configured manually
colTitle = this._setColTitle(colHeader);
//set alignment for table header cell
alignment = this._setAlignment(colHeader);
}
}
//often when get data from databases, columns will have underscores to separate words
colTitle = colTitle.replace('_', ' ');
//create table element, set text & scope (for screen readers)
tableTH = document.createElement('th');
tableTH.innerText = colTitle;
tableTH.setAttribute('scope', 'col');
// https://css-tricks.com/almanac/properties/t/text-transform/
// https://codepen.io/mariemosley/pen/0f4293fce0d14aafc3818c950ab0ded3
// best practice is to use classList.add
tableTH.classList.add('mt-col-header-capitalize');
if (this.enableStickyHeader) {
tableTH.classList.add('mt-header-sticky');
tableTH.classList.add('mt-thead-style');
}
alignmentClass = `align-text-${alignment}`;
tableTH.classList.add(alignmentClass);
//before end position === appendChild location
tableHeadRow.insertAdjacentElement('beforeend', tableTH);
}
}
/**
* @summary Sets the column title from colTitle configuration if explicitly set.
* @private
* @param {string} configKey
* @returns {string} colTitle
* @memberof ModernTable
*/
_setColTitle(configKey) {
let colTitle = configKey;
let currentColConfig = this.colConfig[configKey];
if ('colTitle' in currentColConfig) {
colTitle = currentColConfig['colTitle'];
}
return colTitle;
}
/**
* @summary Sets the alignment for a column from the alignment colConfig.
* @private
* @param {string} configKey
* @returns {string} alignment
* @memberof ModernTable
*/
_setAlignment(configKey) {
let alignment;
let currentColConfig = this.colConfig[configKey];
if ('alignment' in currentColConfig) {
alignment = currentColConfig['alignment'];
} else {
alignment = this.defaultAlignment;
}
return alignment;
}
/**
*
* @summary Formats table cells with number and/or date formatting.
*
* Notes:
* - Recommended to use [format-intl-number library](https://github.com/simplenotsimpler/format-intl-number) or [Moment.js](https://momentjs.com/) for formatting.
* - However, other formatting libraries can be used if desired.
* - Gracefully fails to no number/date formatting if libraries are not loaded or the format option was not specified in the colConfig.
* @private
* @param {*} cellValue
* @param {string} configKey
* @returns cellValue
* @memberof ModernTable
*/
_formatCellValue(cellValue, configKey) {
let currentColConfig = this.colConfig[configKey];
//check that format was set in options
if ('format' in currentColConfig) {
let currentColFormat = currentColConfig['format'];
// make sure the formatIntlNumber library loaded
if (currentColFormat !== 'date-us' && typeof formatIntlNumber !=='undefined' ) {
if ('numDecimals' in currentColConfig) {
let numDecimals = currentColConfig['numDecimals'];
cellValue = formatIntlNumber(cellValue, currentColFormat, numDecimals);
} else {
cellValue = formatIntlNumber(cellValue, currentColFormat);
}
} else if (currentColFormat === 'date-us' && typeof moment !=='undefined' ) {
let dateFrom = currentColConfig['dateFrom'];
let dateTo = currentColConfig['dateTo'];
//strict mode recommended per https://momentjs.com/guides/#/parsing/strict-mode/
cellValue = moment(cellValue, dateFrom, true).format(dateTo);
}
}
return cellValue;
}
}
/**
* @summary Processes an error into a user friendly version displayed in the gui.
* @class TableErrorHandler *
* @param {Object} error
*/
class TableErrorHandler {
constructor(error) {
this.tableContainer = document.getElementById('table-container');
this.tableErrorContainer = document.createElement('section');
this.tableErrorContainerHeader = document.createElement('h1');
this.tableErrorContainerBody = document.createElement('div');
this.tableContainer.insertAdjacentElement('beforebegin', this.tableErrorContainer);
this.tableErrorContainer.insertAdjacentElement('afterbegin', this.tableErrorContainerHeader);
this.tableErrorContainer.insertAdjacentElement('beforeend', this.tableErrorContainerBody);
this.tableErrorContainer.className = 'table-error';
this.tableErrorContainerHeader.innerHTML = `Error Displaying Table`;
//this splits error stack into clean separate lines. makes so all have proper indent.
// from https://stackoverflow.com/questions/784539/how-do-i-replace-all-line-breaks-in-a-string-with-br-tags
// answered Apr 24 '09 at 4:43 eyelidlessness
const prettyErrorStack = error.stack.replace(/(?:\r\n|\r|\n)/g, '<br>');
this.tableErrorContainerBody.innerHTML = `
<h2>Our apologies. Please contact your system administrator with these details:</h2>
<section class="table-error-details">
<p><strong>${error.name}</strong>: ${error.message}</p>
<p><strong>Stack trace:</strong></p>
<p class="table-error-details">${prettyErrorStack}</p>
</section>
`;
}
}
/**
* @summary Custom errror for processing HTTP errors.
* @class HTTPError
* @extends {Error}
*/
class HTTPError extends Error {
constructor(message) {
super(message);
this.name = 'HTTPError';
}
}
/**
*
* @summary Custom error for JSON Syntax errors. Passes the stack trace.
* @class JSONSyntaxError
* @extends {SyntaxError}
*/
class JSONSyntaxError extends SyntaxError {
constructor(message) {
super(message);
this.name = 'JSONSyntaxError';
this.stack = (new Error()).stack;
}
}