aboutsummaryrefslogtreecommitdiff
path: root/ui/table-lib.js
diff options
context:
space:
mode:
Diffstat (limited to 'ui/table-lib.js')
-rw-r--r--ui/table-lib.js482
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">&nbsp;</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 ? '&#x25B4;' : '&#x25BE;';
+ } else {
+ span.innerHTML = '&nbsp;'; // 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('&');
+};