1// Sortable HTML table. 2// 3// Usage: 4// 5// Each page should have gTableStates and gUrlHash variables. This library 6// only provides functions / classes, not instances. 7// 8// Then use these public functions on those variables. They should be hooked 9// up to initialization and onhashchange events. 10// 11// - makeTablesSortable 12// - updateTables 13// 14// Life of a click 15// 16// - query existing TableState object to find the new state 17// - mutate urlHash 18// - location.hash = urlHash.encode() 19// - onhashchange 20// - decode location.hash into urlHash 21// - update DOM 22// 23// HTML generation requirements: 24// - <table id="foo"> 25// - need <colgroup> for types. 26// - For numbers, class="num-cell" as well as <col type="number"> 27// - single <thead> and <tbody> 28 29'use strict'; 30 31function appendMessage(elem, msg) { 32 // TODO: escape HTML? 33 elem.innerHTML += msg + '<br />'; 34} 35 36function userError(errElem, msg) { 37 if (errElem) { 38 appendMessage(errElem, msg); 39 } else { 40 console.log(msg); 41 } 42} 43 44// 45// Key functions for column ordering 46// 47// TODO: better naming convention? 48 49function identity(x) { 50 return x; 51} 52 53function lowerCase(x) { 54 return x.toLowerCase(); 55} 56 57// Parse as number. 58function asNumber(x) { 59 var stripped = x.replace(/[ \t\r\n]/g, ''); 60 if (stripped === 'NA') { 61 // return lowest value, so NA sorts below everything else. 62 return -Number.MAX_VALUE; 63 } 64 var numClean = x.replace(/[$,]/g, ''); // remove dollar signs and commas 65 return parseFloat(numClean); 66} 67 68// as a date. 69// 70// TODO: Parse into JS date object? 71// http://stackoverflow.com/questions/19430561/how-to-sort-a-javascript-array-of-objects-by-date 72// Uses getTime(). Hm. 73 74function asDate(x) { 75 return x; 76} 77 78// 79// Table Implementation 80// 81 82// Given a column array and a key function, construct a permutation of the 83// indices [0, n). 84function makePermutation(colArray, keyFunc) { 85 var pairs = []; // (index, result of keyFunc on cell) 86 87 var n = colArray.length; 88 for (var i = 0; i < n; ++i) { 89 var value = colArray[i]; 90 91 // NOTE: This could be a URL, so you need to extract that? 92 // If it's a URL, take the anchor text I guess. 93 var key = keyFunc(value); 94 95 pairs.push([key, i]); 96 } 97 98 // Sort by computed key 99 pairs.sort(function(a, b) { 100 if (a[0] < b[0]) { 101 return -1; 102 } else if (a[0] > b[0]) { 103 return 1; 104 } else { 105 return 0; 106 } 107 }); 108 109 // Extract the permutation as second column 110 var perm = []; 111 for (var i = 0; i < pairs.length; ++i) { 112 perm.push(pairs[i][1]); // append index 113 } 114 return perm; 115} 116 117function extractCol(rows, colIndex) { 118 var colArray = []; 119 for (var i = 0; i < rows.length; ++i) { 120 var row = rows[i]; 121 colArray.push(row.cells[colIndex].textContent); 122 } 123 return colArray; 124} 125 126// Given an array of DOM row objects, and a list of sort functions (one per 127// column), return a list of permutations. 128// 129// Right now this is eager. Could be lazy later. 130function makeAllPermutations(rows, keyFuncs) { 131 var numCols = keyFuncs.length; 132 var permutations = []; 133 for (var i = 0; i < numCols; ++i) { 134 var colArray = extractCol(rows, i); 135 var keyFunc = keyFuncs[i]; 136 var p = makePermutation(colArray, keyFunc); 137 permutations.push(p); 138 } 139 return permutations; 140} 141 142// Model object for a table. (Mostly) independent of the DOM. 143function TableState(table, keyFuncs) { 144 this.table = table; 145 keyFuncs = keyFuncs || []; // array of column 146 147 // these are mutated 148 this.sortCol = -1; // not sorted by any col 149 this.ascending = false; // if sortCol is sorted in ascending order 150 151 if (table === null) { // hack so we can pass dummy table 152 console.log('TESTING'); 153 return; 154 } 155 156 var bodyRows = table.tBodies[0].rows; 157 this.orig = []; // pointers to row objects in their original order 158 for (var i = 0; i < bodyRows.length; ++i) { 159 this.orig.push(bodyRows[i]); 160 } 161 162 this.colElems = []; 163 var colgroup = table.getElementsByTagName('colgroup')[0]; 164 165 // copy it into an array 166 if (!colgroup) { 167 throw new Error('<colgroup> is required'); 168 } 169 170 for (var i = 0; i < colgroup.children.length; ++i) { 171 var colElem = colgroup.children[i]; 172 var colType = colElem.getAttribute('type'); 173 var keyFunc; 174 switch (colType) { 175 case 'case-sensitive': 176 keyFunc = identity; 177 break; 178 case 'case-insensitive': 179 keyFunc = lowerCase; 180 break; 181 case 'number': 182 keyFunc = asNumber; 183 break; 184 case 'date': 185 keyFunc = asDate; 186 break; 187 default: 188 throw new Error('Invalid column type ' + colType); 189 } 190 keyFuncs[i] = keyFunc; 191 192 this.colElems.push(colElem); 193 } 194 195 this.permutations = makeAllPermutations(this.orig, keyFuncs); 196} 197 198// Reset sort state. 199TableState.prototype.resetSort = function() { 200 this.sortCol = -1; // not sorted by any col 201 this.ascending = false; // if sortCol is sorted in ascending order 202}; 203 204// Change state for a click on a column. 205TableState.prototype.doClick = function(colIndex) { 206 if (this.sortCol === colIndex) { // same column; invert direction 207 this.ascending = !this.ascending; 208 } else { // different column 209 this.sortCol = colIndex; 210 // first click makes it *descending*. Typically you want to see the 211 // largest values first. 212 this.ascending = false; 213 } 214}; 215 216TableState.prototype.decode = function(stateStr, errElem) { 217 var sortCol = parseInt(stateStr); // parse leading integer 218 var lastChar = stateStr[stateStr.length - 1]; 219 220 var ascending; 221 if (lastChar === 'a') { 222 ascending = true; 223 } else if (lastChar === 'd') { 224 ascending = false; 225 } else { 226 // The user could have entered a bad ID 227 userError(errElem, 'Invalid state string ' + stateStr); 228 return; 229 } 230 231 this.sortCol = sortCol; 232 this.ascending = ascending; 233} 234 235 236TableState.prototype.encode = function() { 237 if (this.sortCol === -1) { 238 return ''; // default state isn't serialized 239 } 240 241 var s = this.sortCol.toString(); 242 s += this.ascending ? 'a' : 'd'; 243 return s; 244}; 245 246// Update the DOM with using this object's internal state. 247TableState.prototype.updateDom = function() { 248 var tHead = this.table.tHead; 249 setArrows(tHead, this.sortCol, this.ascending); 250 251 // Highlight the column that the table is sorted by. 252 for (var i = 0; i < this.colElems.length; ++i) { 253 // set or clear it. NOTE: This means we can't have other classes on the 254 // <col> tags, which is OK. 255 var className = (i === this.sortCol) ? 'highlight' : ''; 256 this.colElems[i].className = className; 257 } 258 259 var n = this.orig.length; 260 var tbody = this.table.tBodies[0]; 261 262 if (this.sortCol === -1) { // reset it and return 263 for (var i = 0; i < n; ++i) { 264 tbody.appendChild(this.orig[i]); 265 } 266 return; 267 } 268 269 var perm = this.permutations[this.sortCol]; 270 if (this.ascending) { 271 for (var i = 0; i < n; ++i) { 272 var index = perm[i]; 273 tbody.appendChild(this.orig[index]); 274 } 275 } else { // descending, apply the permutation in reverse order 276 for (var i = n - 1; i >= 0; --i) { 277 var index = perm[i]; 278 tbody.appendChild(this.orig[index]); 279 } 280 } 281}; 282 283var kTablePrefix = 't:'; 284var kTablePrefixLength = 2; 285 286// Given a UrlHash instance and a list of tables, mutate tableStates. 287function decodeState(urlHash, tableStates, errElem) { 288 var keys = urlHash.getKeysWithPrefix(kTablePrefix); // by convention, t:foo=1a 289 for (var i = 0; i < keys.length; ++i) { 290 var key = keys[i]; 291 var tableId = key.substring(kTablePrefixLength); 292 293 if (!tableStates.hasOwnProperty(tableId)) { 294 // The user could have entered a bad ID 295 userError(errElem, 'Invalid table ID [' + tableId + ']'); 296 return; 297 } 298 299 var state = tableStates[tableId]; 300 var stateStr = urlHash.get(key); // e.g. '1d' 301 302 state.decode(stateStr, errElem); 303 } 304} 305 306// Add <span> element for sort arrows. 307function addArrowSpans(tHead) { 308 var tHeadCells = tHead.rows[0].cells; 309 for (var i = 0; i < tHeadCells.length; ++i) { 310 var colHead = tHeadCells[i]; 311 // Put a space in so the width is relatively constant 312 colHead.innerHTML += ' <span class="sortArrow"> </span>'; 313 } 314} 315 316// Go through all the cells in the header. Clear the arrow if there is one. 317// Set the one on the correct column. 318// 319// How to do this? Each column needs a <span></span> modify the text? 320function setArrows(tHead, sortCol, ascending) { 321 var tHeadCells = tHead.rows[0].cells; 322 323 for (var i = 0; i < tHeadCells.length; ++i) { 324 var colHead = tHeadCells[i]; 325 var span = colHead.getElementsByTagName('span')[0]; 326 327 if (i === sortCol) { 328 span.innerHTML = ascending ? '▴' : '▾'; 329 } else { 330 span.innerHTML = ' '; // clear it 331 } 332 } 333} 334 335// Given the URL hash, table states, tableId, and column index that was 336// clicked, visit a new location. 337function makeClickHandler(urlHash, tableStates, id, colIndex) { 338 return function() { // no args for onclick= 339 var clickedState = tableStates[id]; 340 341 clickedState.doClick(colIndex); 342 343 // now urlHash has non-table state, and tableStates is the table state. 344 for (var tableId in tableStates) { 345 var state = tableStates[tableId]; 346 347 var stateStr = state.encode(); 348 var key = kTablePrefix + tableId; 349 350 if (stateStr === '') { 351 urlHash.del(key); 352 } else { 353 urlHash.set(key, stateStr); 354 } 355 } 356 357 // move to new location 358 location.hash = urlHash.encode(); 359 }; 360} 361 362// Go through cells and register onClick 363function registerClick(table, urlHash, tableStates) { 364 var id = table.id; // id is required 365 366 var tHeadCells = table.tHead.rows[0].cells; 367 for (var colIndex = 0; colIndex < tHeadCells.length; ++colIndex) { 368 var colHead = tHeadCells[colIndex]; 369 // NOTE: in ES5, could use 'bind'. 370 colHead.onclick = makeClickHandler(urlHash, tableStates, id, colIndex); 371 } 372} 373 374// 375// Public Functions (TODO: Make a module?) 376// 377 378// Parse the URL fragment, and update all tables. Errors are printed to a DOM 379// element. 380function updateTables(urlHash, tableStates, statusElem) { 381 // State should come from the hash alone, so reset old state. (We want to 382 // keep the permutations though.) 383 for (var tableId in tableStates) { 384 tableStates[tableId].resetSort(); 385 } 386 387 decodeState(urlHash, tableStates, statusElem); 388 389 for (var name in tableStates) { 390 var state = tableStates[name]; 391 state.updateDom(); 392 } 393} 394 395// Takes a {tableId: spec} object. The spec should be an array of sortable 396// items. 397// Returns a dictionary of table states. 398function makeTablesSortable(urlHash, tables, tableStates) { 399 for (var i = 0; i < tables.length; ++i) { 400 var table = tables[i]; 401 var tableId = table.id; 402 403 registerClick(table, urlHash, tableStates); 404 tableStates[tableId] = new TableState(table); 405 406 addArrowSpans(table.tHead); 407 } 408 return tableStates; 409} 410 411// table-sort.js can use t:holidays=1d 412// 413// metric.html can use: 414// 415// metric=Foo.bar 416// 417// day.html could use 418// 419// jobId=X&metric=Foo.bar&day=2015-06-01 420 421// helper 422function _decode(s) { 423 var obj = {}; 424 var parts = s.split('&'); 425 for (var i = 0; i < parts.length; ++i) { 426 if (parts[i].length === 0) { 427 continue; // quirk: ''.split('&') is [''] ? Should be a 0-length array. 428 } 429 var pair = parts[i].split('='); 430 obj[pair[0]] = pair[1]; // for now, assuming no = 431 } 432 return obj; 433} 434 435// UrlHash Constructor. 436// Args: 437// hashStr: location.hash 438function UrlHash(hashStr) { 439 this.reset(hashStr); 440} 441 442UrlHash.prototype.reset = function(hashStr) { 443 var h = hashStr.substring(1); // without leading # 444 // Internal storage is string -> string 445 this.dict = _decode(h); 446} 447 448UrlHash.prototype.set = function(name, value) { 449 this.dict[name] = value; 450}; 451 452UrlHash.prototype.del = function(name) { 453 delete this.dict[name]; 454}; 455 456UrlHash.prototype.get = function(name ) { 457 return this.dict[name]; 458}; 459 460// e.g. Table states have keys which start with 't:'. 461UrlHash.prototype.getKeysWithPrefix = function(prefix) { 462 var keys = []; 463 for (var name in this.dict) { 464 if (name.indexOf(prefix) === 0) { 465 keys.push(name); 466 } 467 } 468 return keys; 469}; 470 471// Return a string reflecting internal key-value pairs. 472UrlHash.prototype.encode = function() { 473 var parts = []; 474 for (var name in this.dict) { 475 var s = name; 476 s += '='; 477 var value = this.dict[name]; 478 s += encodeURIComponent(value); 479 parts.push(s); 480 } 481 return parts.join('&'); 482}; 483