OILS / web / table / table-sort.js View on Github | oilshell.org

433 lines, 368 significant
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
49function 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
62function identity(x) {
63 return x;
64}
65
66function lowerCase(x) {
67 return x.toLowerCase();
68}
69
70// Parse as number.
71function 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
87function 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).
97function 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
130function 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.
143function 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.
156function 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.
212TableState.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.
218TableState.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
229TableState.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
249TableState.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.
260TableState.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
296var kTablePrefix = 't:';
297var kTablePrefixLength = 2;
298
299// Given a UrlHash instance and a list of tables, mutate tableStates.
300function 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.
320function 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">&nbsp;</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?
333function 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 ? '&#x25B4;' : '&#x25BE;';
342 } else {
343 span.innerHTML = '&nbsp;'; // 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.
350function 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
376function 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.
393function 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.
411function 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