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