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