diff options
Diffstat (limited to 'ui/table-lib.js')
-rw-r--r-- | ui/table-lib.js | 482 |
1 files changed, 482 insertions, 0 deletions
diff --git a/ui/table-lib.js b/ui/table-lib.js new file mode 100644 index 0000000..64913dc --- /dev/null +++ b/ui/table-lib.js @@ -0,0 +1,482 @@ +// 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: +// - <table id="foo"> +// - need <colgroup> for types. +// - For numbers, class="num-cell" as well as <col type="number"> +// - single <thead> and <tbody> + +'use strict'; + +function appendMessage(elem, msg) { + // TODO: escape HTML? + elem.innerHTML += msg + '<br />'; +} + +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('<colgroup> 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 + // <col> 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 <span> 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 += ' <span class="sortArrow"> </span>'; + } +} + +// 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 <span></span> 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('&'); +}; |