| 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 |
|