/** * Sort and paginate tabular data * @module Ink.UI.Table_1 * @version 1 */ Ink.createModule('Ink.UI.Table', '1', ['Ink.Util.Url_1','Ink.UI.Pagination_1','Ink.Net.Ajax_1','Ink.UI.Common_1','Ink.Dom.Event_1','Ink.Dom.Css_1','Ink.Dom.Element_1','Ink.Dom.Selector_1','Ink.Util.Array_1','Ink.Util.String_1', 'Ink.Util.Json_1'], function(InkUrl,Pagination, Ajax, Common, Event, Css, Element, Selector, InkArray, InkString, Json) { 'use strict'; var rNumber = /\d/g; // Turn into a number, if we can. For sorting data which could be numeric or not. function maybeTurnIntoNumber(value) { if( !isNaN(value) && rNumber.test(value) ){ return parseInt(value, 10); } else if( !isNaN(value) ){ return parseFloat(value); } return value; } function cmp (a, b) { if( a === b ){ return 0; } return ( ( a > b ) ? 1 : -1 ); } // cmp function for comparing data which might be a number. function numberishEnabledCmp (a, b) { var aValue = maybeTurnIntoNumber(Element.textContent(a)); var bValue = maybeTurnIntoNumber(Element.textContent(b)); return cmp(aValue, bValue); } // Object.keys polyfill function keys(obj) { if (typeof Object.keys !== 'undefined') { return Object.keys(obj); } var ret = []; for (var k in obj) if (obj.hasOwnProperty(k)) { ret.push(k); } return ret; } /** * The Table component transforms the native/DOM table element into a sortable, paginated component. * You can use this component to display data from a JSON endpoint, or from table rows in the DOM. Displaying from the DOM is more practical, but sometimes you don't want to load everything at once (if you have a HUGE table). In those cases, you should configure Ink.UI.Table to get data from JSON endpoint. * To enable sorting, just set the `data-sortable` attribute of your table headers (they must be in the `thead` of the table) to "true". To enable pagination, you should pass either an `Ink.UI.Pagination` instance or a selector to create the Ink.UI.Pagination element on. * * @class Ink.UI.Table * @constructor * @version 1 * @param {String|DOMElement} selector * @param {Object} [options] Options * @param {Number} [options.pageSize] Number of rows per page. Omit to avoid paginating. * @param {String} [options.endpoint] Endpoint to get the records via AJAX. Omit if you don't want to do AJAX * @param {Function} [options.createEndpointUrl] Callback to customise what URL the AJAX endpoint is at. Receives three arguments: base (the "endpoint" option), sort (`{ order: 'asc' or 'desc', field: fieldname }`) and page ({ page: page number, size: items per page }) * @param {Function} [options.getDataFromEndPoint] Callback to allow the user to retrieve the data himself given an URL. Must accept two arguments: `url` and `callback`. This `callback` will take as a single argument a JavaScript object. * @param {Function} [options.processJSONRows] Retrieve an array of rows from the data which came from AJAX. * @param {Function} [options.processJSONHeaders] Get an object with all the headers' names as keys, and a { label, sortable } object as value. Example: `{col1: {label: "Column 1"}, col2: {label: "Column 2", sortable: true}`. Takes a single argument, the JSON response. * @param {Function} [options.processJSONRow] Process a row object before it gets on the table. * @param {Function} [options.processJSONField] Process the field data before putting it on the table. You can return HTML, a DOM element, or a string here. Arguments you receive: `(column, fieldData, rowIndex)`. * @param {Function} [options.processJSONField.FIELD_NAME] The same as processJSONField, but for a particular field. * @param {Function} [options.processJSONTotalRows] A callback where you have a chance to say how many rows are in the dataset (not only on this page) you have on the collection. You get as an argument the JSON response. * @param {Function} [options.getSortKey] A function taking a `{ columnIndex, columnName, data, element }` object and returning a value which serves as a sort key for the sorting operation. For example, if you want to sort by a `data-sort-key` atribute, set `getSortKey` to: function (cell) { return cell.element.getAttribute('data-sort-key'); } * @param {Function} [options.getSortKey.FIELD_NAME] Same as `options.getSortKey`, but for a particular field. * @param {Object} [options.tdClassNames] An object mapping each field to what classes it gets. Example: `{ name: "large-10", isBoss: "hide-small" }` * @param {Mixed} [options.pagination] Pagination instance, element or selector. * @param {Object} [options.paginationOptions] Override the options with which we instantiate the Ink.UI.Pagination. * @param {Boolean} [options.allowResetSorting] Allow sort order to be set to "none" in addition to "ascending" and "descending" * @param {String|Array} [options.visibleFields] Set of fields which get shown on the table * * @sample Ink_UI_Table_1.html */ function Table(){ Common.BaseUIComponent.apply(this, arguments); } Table._name = 'Table_1'; // Most processJSON* functions can just default to this. function sameSame(obj) { return obj; } Table._optionDefinition = { pageSize: ['Integer', null], caretUpClass: ['String', 'fa fa-caret-up'], caretDownClass: ['String', 'fa fa-caret-down'], endpoint: ['String', null], createEndpointUrl: ['Function', null /* default func uses above option */], getDataFromEndPoint: ['Function', null /* by default use plain ajax for JSON */], processJSONRows: ['Function', sameSame], processJSONRow: ['Function', sameSame], processJSONField: ['Function', sameSame], processJSONHeaders: ['Function', function (dt) { return dt.fields; }], processJSONTotalRows: ['Function', function (dt) { return dt.length || dt.totalRows; }], getSortKey: ['Function', null], pagination: ['Element', null], allowResetSorting: ['Boolean', false], visibleFields: ['String', null], tdClassNames: ['Object', {}], paginationOptions: ['Object', null] }; Table.prototype = { _validate: function () { if( this._element.nodeName.toLowerCase() !== 'table' ){ throw new Error('[Ink.UI.Table] :: The element is not a table'); } }, /** * Init function called by the constructor * * @method _init * @private */ _init: function(){ /** * Checking if it's in markup mode or endpoint mode */ this._markupMode = !this._options.endpoint; if( this._options.visibleFields ){ this._options.visibleFields = this._options.visibleFields.toString().split(/[, ]+/g); } this._thead = this._element.tHead || this._element.createTHead(); this._headers = Selector.select('th', this._thead); /** * Initializing variables */ this._handlers = { thClick: null }; this._originalFields = [ // field headers from the DOM ]; this._sortableFields = { // Identifies which columns are sorted and how. // columnIndex: 'none'|'asc'|'desc' }; this._originalData = this._data = []; this._pagination = null; this._totalRows = 0; this._handlers.thClick = Event.observeDelegated(this._element, 'click', 'thead th[data-sortable="true"]', Ink.bindMethod(this, '_onThClick')); /** * If not is in markup mode, we have to do the initial request * to get the first data and the headers */ if( !this._markupMode ) { /* Endpoint mode */ this._getData( ); } else /* Markup mode */ { this._resetSortOrder(); this._addHeadersClasses(); /** * Getting the table's data */ this._data = Selector.select('tbody tr', this._element); this._originalData = this._data.slice(0); this._totalRows = this._data.length; /** * Set pagination if options tell us to */ this._setPagination(); } }, /** * Add the classes in this._options.tdClassNames to our table headers. * @method _addHeadersClasses * @private */ _addHeadersClasses: function () { var headerLabel; var classNames; for (var i = 0, len = this._headers.length; i < len; i++) { headerLabel = Element.textContent(this._headers[i]); classNames = this._options.tdClassNames[headerLabel]; // TODO do not find header labels this way. But how? if (classNames) { Css.addClassName(this._headers[i], classNames); } } }, /** * Click handler. This will mainly handle the sorting (when you click in the headers) * * @method _onThClick * @param {Event} event Event obj * @private */ _onThClick: function( event ){ var tgtEl = Event.element(event), paginated = this._options.pageSize !== undefined; Event.stop(event); var index = InkArray.keyValue(tgtEl, this._headers, true); var sortable = index !== false && this._sortableFields[index] !== undefined; if( !sortable ){ return; } if( !this._markupMode && paginated ){ this._invertSortOrder(index, false); } else { if ( (this._sortableFields[index] === 'desc') && this._options.allowResetSorting ) { this._setSortOrderOfColumn(index, null); this._data = this._originalData.slice(0); } else { this._invertSortOrder(index, true); } var tbody = Selector.select('tbody',this._element)[0]; Common.cleanChildren(tbody); InkArray.each(this._data, Ink.bindMethod(tbody, 'appendChild')); if (this._pagination) { this._pagination.setCurrent(0); this._paginate(1); } } }, _invertSortOrder: function (index, sortAndReverse) { var isAscending = this._sortableFields[index] === 'asc'; for (var i = 0, len = this._headers.length; i < len; i++) { this._setSortOrderOfColumn(i, null); } if (sortAndReverse) { this._sort(index); if (isAscending) { this._data.reverse(); } } this._setSortOrderOfColumn(index, !isAscending); }, _setSortOrderOfColumn: function(index, up) { var header = this._headers[index]; var caretHtml = ['']; var order = 'none'; if (up === true) { caretHtml = ['']; order = 'asc'; } else if (up === false) { caretHtml = ['']; order = 'desc'; } this._sortableFields[index] = order; header.innerHTML = Element.textContent(header) + caretHtml.join(''); }, /** * Applies and/or changes the CSS classes in order to show the right columns * * @method _paginate * @param {Number} page Current page * @private */ _paginate: function( page ){ if (!this._pagination) { return; } var pageSize = this._options.pageSize; // Hide everything except the items between these indices var firstIndex = (page - 1) * pageSize; var lastIndex = firstIndex + pageSize; InkArray.each(this._data, function(item, index){ if (index >= firstIndex && index < lastIndex) { Css.removeClassName(item,'hide-all'); } else { Css.addClassName(item,'hide-all'); } }); }, /* register fields into this._originalFields, whether they come from JSON or a table. * @method _registerFieldNames * @private * @param [names] The field names in an array **/ _registerFieldNames: function (names) { this._originalFields = []; InkArray.forEach(names, Ink.bind(function (field) { if( !this._fieldIsVisible(field) ){ return; // The user deems this not to be necessary to see. } this._originalFields.push(field); }, this)); }, _fieldIsVisible: function (field) { return !this._options.visibleFields || (this._options.visibleFields.indexOf(field) !== -1); }, /** * Sorts by a specific column. * * @method _sort * @param {Number} index Column number (starting at 0) * @private */ _sort: function( index ){ // TODO this is THE worst way to declare field names. Incompatible with i18n and a lot of other things. var fieldName = Element.textContent(this._headers[index]); var keyFunction = this._options.getSortKey; if (keyFunction) { keyFunction = typeof keyFunction[fieldName] === 'function' ? keyFunction[fieldName] : typeof keyFunction === 'function' ? keyFunction : null; } var self = this; this._data.sort(function (trA, trB) { var elementA = Ink.ss('td', trA)[index]; var elementB = Ink.ss('td', trB)[index]; if (keyFunction) { return cmp(userKey(elementA), userKey(elementB)); } else { return numberishEnabledCmp(elementA, elementB, index); } }); function userKey(element) { return keyFunction.call(self, { columnIndex: index, columnName: fieldName, data: Element.textContent(element), element: element }); } }, /** * Assembles the headers markup * * @method _createHeadersFromJson * @param {Object} headers Key-value object that contains the fields as keys, their configuration (label and sorting ability) as value * @private */ _createHeadersFromJson: function( headers ){ this._registerFieldNames(keys(headers)); if (this._thead.children.length) { return; } var tr = this._thead.insertRow(0); var th; for (var i = 0, len = headers.length; i < len; i++) { if (this._fieldIsVisible(headers[i])) { th = Element.create('th'); th = this._createSingleHeaderFromJson(headers[i], th); tr.appendChild(th); this._headers.push(th); } } }, _createSingleHeaderFromJson: function (header, th) { if (header.sortable) { th.setAttribute('data-sortable','true'); } if (header.label){ Element.setTextContent(th, header.label); } return th; }, /** * Reset the sort order as marked on the table headers to "none" * * @method _resetSortOrder * @private */ _resetSortOrder: function(){ /** * Setting the sortable columns and its event listeners */ for (var i = 0, len = this._headers.length; i < len; i++) { var dataset = Element.data( this._headers[i] ); if (dataset.sortable && dataset.sortable.toString() === 'true') { this._sortableFields[i] = 'none'; } } }, /** * This method gets the rows from AJAX and places them as and * * @method _createRowsFromJSON * @param {Object} rows Array of objects with the data to be showed * @private */ _createRowsFromJSON: function( rows ){ var tbody = Selector.select('tbody',this._element)[0]; if( !tbody ){ tbody = document.createElement('tbody'); this._element.appendChild( tbody ); } else { Element.setHTML(tbody, ''); } this._data = []; var row; for (var trIndex in rows) { if (rows.hasOwnProperty(trIndex)) { row = this._options.processJSONRow(rows[trIndex]); this._createSingleRowFromJson(tbody, row, trIndex); } } this._originalData = this._data.slice(0); }, _createSingleRowFromJson: function (tbody, row, rowIndex) { var tr = document.createElement('tr'); tbody.appendChild( tr ); for( var field in row ){ if (row.hasOwnProperty(field)) { this._createFieldFromJson(tr, row[field], field, rowIndex); } } this._data.push(tr); }, _createFieldFromJson: function (tr, fieldData, fieldName, rowIndex) { if (!this._fieldIsVisible(fieldName)) { return; } var processor = this._options.processJSONField[fieldName] || // per-field callback this._options.processJSONField; // generic callback var result; if (typeof processor === 'function') { result = processor(fieldData, fieldName, rowIndex); } else { result = fieldData; } var elm = this._elOrFieldData(result); var className = this._options.tdClassNames[fieldName]; if (className) { Css.addClassName(elm, className); } tr.appendChild(elm); }, _elOrFieldData: function (processed) { if (Common.isDOMElement(processed)) { return processed; } var isString = typeof processed === 'string'; var isNumber = typeof processed === 'number'; var elm = Element.create('td'); if (isString && /^\s*?