xref: /aosp_15_r20/external/rappor/ui/table-lib.js (revision 2abb31345f6c95944768b5222a9a5ed3fc68cc00)
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">&nbsp;</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 ? '&#x25B4;' : '&#x25BE;';
329    } else {
330      span.innerHTML = '&nbsp;';  // 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