// Sortable HTML table. // // Usage: // // Each page should have gTableStates and gUrlHash variables. This library // only provides functions / classes, not instances. // // Then use these public functions on those variables. They should be hooked // up to initialization and onhashchange events. // // - makeTablesSortable // - updateTables // // Life of a click // // - query existing TableState object to find the new state // - mutate urlHash // - location.hash = urlHash.encode() // - onhashchange // - decode location.hash into urlHash // - update DOM // // HTML generation requirements: // - // - need for types. // - For numbers, class="num-cell" as well as // - single and 'use strict'; function appendMessage(elem, msg) { // TODO: escape HTML? elem.innerHTML += msg + '
'; } function userError(errElem, msg) { if (errElem) { appendMessage(errElem, msg); } else { console.log(msg); } } // // Key functions for column ordering // // TODO: better naming convention? function identity(x) { return x; } function lowerCase(x) { return x.toLowerCase(); } // Parse as number. function asNumber(x) { var stripped = x.replace(/[ \t\r\n]/g, ''); if (stripped === 'NA') { // return lowest value, so NA sorts below everything else. return -Number.MAX_VALUE; } var numClean = x.replace(/[$,]/g, ''); // remove dollar signs and commas return parseFloat(numClean); } // as a date. // // TODO: Parse into JS date object? // http://stackoverflow.com/questions/19430561/how-to-sort-a-javascript-array-of-objects-by-date // Uses getTime(). Hm. function asDate(x) { return x; } // // Table Implementation // // Given a column array and a key function, construct a permutation of the // indices [0, n). function makePermutation(colArray, keyFunc) { var pairs = []; // (index, result of keyFunc on cell) var n = colArray.length; for (var i = 0; i < n; ++i) { var value = colArray[i]; // NOTE: This could be a URL, so you need to extract that? // If it's a URL, take the anchor text I guess. var key = keyFunc(value); pairs.push([key, i]); } // Sort by computed key pairs.sort(function(a, b) { if (a[0] < b[0]) { return -1; } else if (a[0] > b[0]) { return 1; } else { return 0; } }); // Extract the permutation as second column var perm = []; for (var i = 0; i < pairs.length; ++i) { perm.push(pairs[i][1]); // append index } return perm; } function extractCol(rows, colIndex) { var colArray = []; for (var i = 0; i < rows.length; ++i) { var row = rows[i]; colArray.push(row.cells[colIndex].textContent); } return colArray; } // Given an array of DOM row objects, and a list of sort functions (one per // column), return a list of permutations. // // Right now this is eager. Could be lazy later. function makeAllPermutations(rows, keyFuncs) { var numCols = keyFuncs.length; var permutations = []; for (var i = 0; i < numCols; ++i) { var colArray = extractCol(rows, i); var keyFunc = keyFuncs[i]; var p = makePermutation(colArray, keyFunc); permutations.push(p); } return permutations; } // Model object for a table. (Mostly) independent of the DOM. function TableState(table, keyFuncs) { this.table = table; keyFuncs = keyFuncs || []; // array of column // these are mutated this.sortCol = -1; // not sorted by any col this.ascending = false; // if sortCol is sorted in ascending order if (table === null) { // hack so we can pass dummy table console.log('TESTING'); return; } var bodyRows = table.tBodies[0].rows; this.orig = []; // pointers to row objects in their original order for (var i = 0; i < bodyRows.length; ++i) { this.orig.push(bodyRows[i]); } this.colElems = []; var colgroup = table.getElementsByTagName('colgroup')[0]; // copy it into an array if (!colgroup) { throw new Error('is required'); } for (var i = 0; i < colgroup.children.length; ++i) { var colElem = colgroup.children[i]; var colType = colElem.getAttribute('type'); var keyFunc; switch (colType) { case 'case-sensitive': keyFunc = identity; break; case 'case-insensitive': keyFunc = lowerCase; break; case 'number': keyFunc = asNumber; break; case 'date': keyFunc = asDate; break; default: throw new Error('Invalid column type ' + colType); } keyFuncs[i] = keyFunc; this.colElems.push(colElem); } this.permutations = makeAllPermutations(this.orig, keyFuncs); } // Reset sort state. TableState.prototype.resetSort = function() { this.sortCol = -1; // not sorted by any col this.ascending = false; // if sortCol is sorted in ascending order }; // Change state for a click on a column. TableState.prototype.doClick = function(colIndex) { if (this.sortCol === colIndex) { // same column; invert direction this.ascending = !this.ascending; } else { // different column this.sortCol = colIndex; // first click makes it *descending*. Typically you want to see the // largest values first. this.ascending = false; } }; TableState.prototype.decode = function(stateStr, errElem) { var sortCol = parseInt(stateStr); // parse leading integer var lastChar = stateStr[stateStr.length - 1]; var ascending; if (lastChar === 'a') { ascending = true; } else if (lastChar === 'd') { ascending = false; } else { // The user could have entered a bad ID userError(errElem, 'Invalid state string ' + stateStr); return; } this.sortCol = sortCol; this.ascending = ascending; } TableState.prototype.encode = function() { if (this.sortCol === -1) { return ''; // default state isn't serialized } var s = this.sortCol.toString(); s += this.ascending ? 'a' : 'd'; return s; }; // Update the DOM with using this object's internal state. TableState.prototype.updateDom = function() { var tHead = this.table.tHead; setArrows(tHead, this.sortCol, this.ascending); // Highlight the column that the table is sorted by. for (var i = 0; i < this.colElems.length; ++i) { // set or clear it. NOTE: This means we can't have other classes on the // tags, which is OK. var className = (i === this.sortCol) ? 'highlight' : ''; this.colElems[i].className = className; } var n = this.orig.length; var tbody = this.table.tBodies[0]; if (this.sortCol === -1) { // reset it and return for (var i = 0; i < n; ++i) { tbody.appendChild(this.orig[i]); } return; } var perm = this.permutations[this.sortCol]; if (this.ascending) { for (var i = 0; i < n; ++i) { var index = perm[i]; tbody.appendChild(this.orig[index]); } } else { // descending, apply the permutation in reverse order for (var i = n - 1; i >= 0; --i) { var index = perm[i]; tbody.appendChild(this.orig[index]); } } }; var kTablePrefix = 't:'; var kTablePrefixLength = 2; // Given a UrlHash instance and a list of tables, mutate tableStates. function decodeState(urlHash, tableStates, errElem) { var keys = urlHash.getKeysWithPrefix(kTablePrefix); // by convention, t:foo=1a for (var i = 0; i < keys.length; ++i) { var key = keys[i]; var tableId = key.substring(kTablePrefixLength); if (!tableStates.hasOwnProperty(tableId)) { // The user could have entered a bad ID userError(errElem, 'Invalid table ID [' + tableId + ']'); return; } var state = tableStates[tableId]; var stateStr = urlHash.get(key); // e.g. '1d' state.decode(stateStr, errElem); } } // Add element for sort arrows. function addArrowSpans(tHead) { var tHeadCells = tHead.rows[0].cells; for (var i = 0; i < tHeadCells.length; ++i) { var colHead = tHeadCells[i]; // Put a space in so the width is relatively constant colHead.innerHTML += '  '; } } // Go through all the cells in the header. Clear the arrow if there is one. // Set the one on the correct column. // // How to do this? Each column needs a modify the text? function setArrows(tHead, sortCol, ascending) { var tHeadCells = tHead.rows[0].cells; for (var i = 0; i < tHeadCells.length; ++i) { var colHead = tHeadCells[i]; var span = colHead.getElementsByTagName('span')[0]; if (i === sortCol) { span.innerHTML = ascending ? '▴' : '▾'; } else { span.innerHTML = ' '; // clear it } } } // Given the URL hash, table states, tableId, and column index that was // clicked, visit a new location. function makeClickHandler(urlHash, tableStates, id, colIndex) { return function() { // no args for onclick= var clickedState = tableStates[id]; clickedState.doClick(colIndex); // now urlHash has non-table state, and tableStates is the table state. for (var tableId in tableStates) { var state = tableStates[tableId]; var stateStr = state.encode(); var key = kTablePrefix + tableId; if (stateStr === '') { urlHash.del(key); } else { urlHash.set(key, stateStr); } } // move to new location location.hash = urlHash.encode(); }; } // Go through cells and register onClick function registerClick(table, urlHash, tableStates) { var id = table.id; // id is required var tHeadCells = table.tHead.rows[0].cells; for (var colIndex = 0; colIndex < tHeadCells.length; ++colIndex) { var colHead = tHeadCells[colIndex]; // NOTE: in ES5, could use 'bind'. colHead.onclick = makeClickHandler(urlHash, tableStates, id, colIndex); } } // // Public Functions (TODO: Make a module?) // // Parse the URL fragment, and update all tables. Errors are printed to a DOM // element. function updateTables(urlHash, tableStates, statusElem) { // State should come from the hash alone, so reset old state. (We want to // keep the permutations though.) for (var tableId in tableStates) { tableStates[tableId].resetSort(); } decodeState(urlHash, tableStates, statusElem); for (var name in tableStates) { var state = tableStates[name]; state.updateDom(); } } // Takes a {tableId: spec} object. The spec should be an array of sortable // items. // Returns a dictionary of table states. function makeTablesSortable(urlHash, tables, tableStates) { for (var i = 0; i < tables.length; ++i) { var table = tables[i]; var tableId = table.id; registerClick(table, urlHash, tableStates); tableStates[tableId] = new TableState(table); addArrowSpans(table.tHead); } return tableStates; } // table-sort.js can use t:holidays=1d // // metric.html can use: // // metric=Foo.bar // // day.html could use // // jobId=X&metric=Foo.bar&day=2015-06-01 // helper function _decode(s) { var obj = {}; var parts = s.split('&'); for (var i = 0; i < parts.length; ++i) { if (parts[i].length === 0) { continue; // quirk: ''.split('&') is [''] ? Should be a 0-length array. } var pair = parts[i].split('='); obj[pair[0]] = pair[1]; // for now, assuming no = } return obj; } // UrlHash Constructor. // Args: // hashStr: location.hash function UrlHash(hashStr) { this.reset(hashStr); } UrlHash.prototype.reset = function(hashStr) { var h = hashStr.substring(1); // without leading # // Internal storage is string -> string this.dict = _decode(h); } UrlHash.prototype.set = function(name, value) { this.dict[name] = value; }; UrlHash.prototype.del = function(name) { delete this.dict[name]; }; UrlHash.prototype.get = function(name ) { return this.dict[name]; }; // e.g. Table states have keys which start with 't:'. UrlHash.prototype.getKeysWithPrefix = function(prefix) { var keys = []; for (var name in this.dict) { if (name.indexOf(prefix) === 0) { keys.push(name); } } return keys; }; // Return a string reflecting internal key-value pairs. UrlHash.prototype.encode = function() { var parts = []; for (var name in this.dict) { var s = name; s += '='; var value = this.dict[name]; s += encodeURIComponent(value); parts.push(s); } return parts.join('&'); };