This repository has been archived on 2018-10-12. You can view files and clone it, but cannot push or open issues or pull requests.
node-task/public/js/ink.table.js

696 lines
26 KiB
JavaScript

/**
* 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 = ['<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._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*?</.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._element
});
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( );
}
}
};
Common.createUIComponent(Table);
return Table;
});