UNPKG

prodio

Version:

Simplified project management

697 lines (600 loc) 27.2 kB
/** * 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; } // Most processJSON* functions can just default to this. function sameSame(obj) { return obj; } /** * 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 */ var Table = function( selector, options ){ /** * Get the root element */ this._rootElement = Common.elOrSelector(selector, 'Ink.UI.Table :'); if( this._rootElement.nodeName.toLowerCase() !== 'table' ){ throw new Error('[Ink.UI.Table] :: The element is not a table'); } this._options = Common.options({ 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] }, options || {}, this._rootElement); /** * 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._rootElement.tHead || this._rootElement.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._rootElement, 'click', 'thead th[data-sortable="true"]', Ink.bindMethod(this, '_onThClick')); this._init(); }; Table.prototype = { /** * Init function called by the constructor * * @method _init * @private */ _init: function(){ /** * 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._rootElement); 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._rootElement)[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 = ['<i class="', this._options.caretUpClass, '"></i>']; order = 'asc'; } else if (up === false) { caretHtml = ['<i class="', this._options.caretDownClass, '"></i>']; 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 <tr> and <td> * * @method _createRowsFromJSON * @param {Object} rows Array of objects with the data to be showed * @private */ _createRowsFromJSON: function( rows ){ var tbody = Selector.select('tbody',this._rootElement)[0]; if( !tbody ){ tbody = document.createElement('tbody'); this._rootElement.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*?</.test(processed)) { Element.setHTML(elm, processed); } else if (isString || isNumber) { Element.setTextContent(elm, processed); } else { throw new Error('Ink.UI.Table Unknown result from processJSONField: ' + processed); } return elm; }, /** * Sets the AJAX endpoint. * Useful to change the endpoint in runtime. * * @method setEndpoint * @public * @param {String} endpoint New endpoint */ setEndpoint: function( endpoint, currentPage ){ if( !this._markupMode ){ this._options.endpoint = endpoint; if (this._pagination) { this._pagination.setCurrent((!!currentPage) ? parseInt(currentPage,10) : 0 ); } } }, /** * Sets the instance's pagination, if necessary. * * Precondition: this._totalRows needs to be known. * * @method _setPagination * @private */ _setPagination: function(){ /* If user doesn't say they want pagination, bail. */ if( this._options.pageSize == null ){ return; } /** * Fetch pagination from options. Can be a selector string, an element or a Pagination instance. */ var paginationEl = this._options.pagination; if ( paginationEl instanceof Pagination ) { this._pagination = paginationEl; return; } if (!paginationEl) { paginationEl = Element.create('nav', { className: 'ink-navigation', insertAfter: this._rootElement }); Element.create('ul', { className: 'pagination', insertBottom: paginationEl }); } var paginationOptions = Ink.extendObj({ totalItemCount: this._totalRows, itemsPerPage: this._options.pageSize, onChange: Ink.bind(function (_, pageNo) { this._paginate(pageNo + 1); }, this) }, this._options.paginationOptions || {}); this._pagination = new Pagination(paginationEl, paginationOptions); this._paginate(1); }, /** * Method to choose which is the best way to get the data based on the endpoint: * - AJAX * - JSONP * * @method _getData * @private */ _getData: function( ){ var sortOrder = this._getSortOrder() || null; var page = null; if (this._pagination) { page = { size: this._options.pageSize, page: this._pagination.getCurrent() + 1 }; } this._getDataViaAjax( this._getUrl( sortOrder, page) ); }, /** * Return an object describing sort order { field: [field name] , * order: ["asc" or "desc"] }, or null if there is no sorting * going on. * @method _getSortOrder * @private */ _getSortOrder: function () { var index; for (index in this._sortableFields) if (this._sortableFields.hasOwnProperty(index)) { if( this._sortableFields[index] !== 'none' ){ break; } } if (!index) { return null; // no sorting going on } return { field: this._originalFields[index], order: this._sortableFields[index] }; }, _getUrl: function (sort, page) { var urlCreator = this._options.createEndpointUrl || function (endpoint, sort, page /* TODO implement filters too */) { endpoint = InkUrl.parseUrl(endpoint); endpoint.query = endpoint.query || {}; if (sort) { endpoint.query.sortOrder = sort.order; endpoint.query.sortField = sort.field; } if (page) { endpoint.query['rows_per_page'] = page.size; endpoint.query['page'] = page.page; } return InkUrl.format(endpoint); }; var ret = urlCreator(this._options.endpoint, sort, page); if (typeof ret !== 'string') { throw new TypeError('Ink.UI.Table_1: ' + 'createEndpointUrl did not return a string!'); } return ret; }, /** * Gets the data via AJAX and calls this._onAjaxSuccess with the response. * * Will call options.getDataFromEndpoint( Uri, callback ) if available. * * @param endpointUri Endpoint to get data from, after processing. */ _getDataViaAjax: function( endpointUri ){ var success = Ink.bind(function( JSONData ){ this._onAjaxSuccess( JSONData ); }, this); if (!this._options.getDataFromEndpoint) { new Ajax( endpointUri, { method: 'GET', contentType: 'application/json', sanitizeJSON: true, onSuccess: Ink.bind(function( response ){ if( response.status === 200 ){ success(Json.parse(response.responseText)); } }, this) }); } else { this._options.getDataFromEndpoint( endpointUri, success ); } }, _onAjaxSuccess: function (jsonResponse) { var paginated = this._options.pageSize != null; var rows = this._options.processJSONRows(jsonResponse); this._headers = Selector.select('th', this._thead); // If headers not in DOM, get from JSON if( this._headers.length === 0 ) { var headers = this._options.processJSONHeaders( jsonResponse); if (!headers || !headers.length || !headers[0]) { throw new Error('Ink.UI.Table: processJSONHeaders option must return an array of objects!'); } this._createHeadersFromJson( headers ); this._resetSortOrder(); this._addHeadersClasses(); } this._createRowsFromJSON( rows ); this._totalRows = this._rowLength = rows.length; if( paginated ){ this._totalRows = this._options.processJSONTotalRows(jsonResponse); this._setPagination( ); } } }; return Table; });