1 /*
  2  * RapidContext <https://www.rapidcontext.com/>
  3  * Copyright (c) 2007-2022 Per Cederberg. All rights reserved.
  4  *
  5  * This program is free software: you can redistribute it and/or
  6  * modify it under the terms of the BSD license.
  7  *
  8  * This program is distributed in the hope that it will be useful,
  9  * but WITHOUT ANY WARRANTY; without even the implied warranty of
 10  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 11  *
 12  * See the RapidContext LICENSE for more details.
 13  */
 14
 15 // Namespace initialization
 16 if (typeof(RapidContext) == "undefined") {
 17     RapidContext = {};
 18 }
 19 RapidContext.Widget = RapidContext.Widget || { Classes: {}};
 20
 21 /**
 22  * Creates a new data table widget.
 23  *
 24  * @constructor
 25  * @param {Object} attrs the widget and node attributes
 26  * @param {String} [attrs.select] the row selection mode ('none', 'one' or
 27  *            'multiple'), defaults to 'one'
 28  * @param {String} [attrs.key] the unique key identifier column field,
 29  *            defaults to null
 30  * @param {Boolean} [attrs.hidden] the hidden widget flag, defaults to false
 31  * @param {Widget} [...] the child table columns
 32  *
 33  * @return {Widget} the widget DOM node
 34  *
 35  * @class The table widget class. Used to provide a sortable and scrolling
 36  *     data table, using an outer `<div>` HTML element around a `<table>`. The
 37  *     `Table` widget can only have `TableColumn` child nodes, each providing a
 38  *     visible data column in the table.
 39  * @extends RapidContext.Widget
 40  *
 41  * @example {JavaScript}
 42  * var attrs1 = { field: "id", title: "Identifier", key: true, type: "number" };
 43  * var attrs2 = { field: "name", title: "Name", maxLength: 50, sort: "asc" };
 44  * var attrs3 = { field: "modified", title: "Last Modified", type: "datetime" };
 45  * var col1 = RapidContext.Widget.TableColumn(attrs1);
 46  * var col2 = RapidContext.Widget.TableColumn(attrs2);
 47  * var col3 = RapidContext.Widget.TableColumn(attrs3);
 48  * var exampleTable = RapidContext.Widget.Table({}, col1, col2, col3);
 49  * RapidContext.Util.registerSizeConstraints(exampleTable, "50%", "100%");
 50  *
 51  * @example {User Interface XML}
 52  * <Table id="exampleTable" w="50%" h="100%">
 53  *   <TableColumn field="id" title="Identifier" key="true" type="number" />
 54  *   <TableColumn field="name" title="Name" maxLength="50" sort="asc" />
 55  *   <TableColumn field="modified" title="Last Modified" type="datetime" />
 56  * </Table>
 57  */
 58 RapidContext.Widget.Table = function (attrs/*, ...*/) {
 59     var thead = MochiKit.DOM.THEAD({}, MochiKit.DOM.TR());
 60     var tbody = MochiKit.DOM.TBODY();
 61     var table = MochiKit.DOM.TABLE({ "class": "widgetTable" }, thead, tbody);
 62     var o = MochiKit.DOM.DIV({}, table);
 63     RapidContext.Widget._widgetMixin(o, arguments.callee);
 64     MochiKit.DOM.addElementClass(o, "widgetTable");
 65     o.resizeContent = o._resizeContent;
 66     o._rows = [];
 67     o._data = null;
 68     o._keyField = null;
 69     o._selected = [];
 70     o._selectMode = "one";
 71     o._mouseX = 0;
 72     o._mouseY = 0;
 73     o.setAttrs(attrs);
 74     o.addAll(MochiKit.Base.extend(null, arguments, 1));
 75     tbody.onmousedown = RapidContext.Widget._eventHandler("Table", "_handleMouseDown");
 76     tbody.onmouseup = RapidContext.Widget._eventHandler("Table", "_handleMouseUp");
 77     return o;
 78 };
 79
 80 // Register widget class
 81 RapidContext.Widget.Classes.Table = RapidContext.Widget.Table;
 82
 83 /**
 84  * Emitted when the table data is cleared. This event signal carries
 85  * no event information.
 86  *
 87  * @name RapidContext.Widget.Table#onclear
 88  * @event
 89  */
 90
 91 /**
 92  * Emitted when the table selection changes. This event signal
 93  * carries no event information.
 94  *
 95  * @name RapidContext.Widget.Table#onselect
 96  * @event
 97  */
 98
 99 /**
100  * Returns the widget container DOM node.
101  *
102  * @return {Node} the container DOM node
103  */
104 RapidContext.Widget.Table.prototype._containerNode = function () {
105     var table = this.firstChild;
106     var thead = table.firstChild;
107     var tr = thead.firstChild;
108     return tr;
109 };
110
111 /**
112  * Updates the widget or HTML DOM node attributes.
113  *
114  * @param {Object} attrs the widget and node attributes to set
115  * @param {String} [attrs.select] the row selection mode ('none', 'one' or
116  *            'multiple')
117  * @param {String} [attrs.key] the unique key identifier column field
118  * @param {Boolean} [attrs.hidden] the hidden widget flag
119  */
120 RapidContext.Widget.Table.prototype.setAttrs = function (attrs) {
121     attrs = MochiKit.Base.update({}, attrs);
122     var locals = RapidContext.Util.mask(attrs, ["select", "key"]);
123     if (typeof(locals.select) != "undefined") {
124         this._selectMode = locals.select;
125     }
126     if (typeof(locals.key) != "undefined") {
127         this.setIdKey(locals.key);
128     }
129     this.__setAttrs(attrs);
130 };
131
132 /**
133  * Adds a single child table column widget to this widget.
134  *
135  * @param {Widget} child the table column widget to add
136  */
137 RapidContext.Widget.Table.prototype.addChildNode = function (child) {
138     if (!RapidContext.Widget.isWidget(child, "TableColumn")) {
139         throw new Error("Table widget can only have TableColumn children");
140     }
141     this.clear();
142     this._containerNode().appendChild(child);
143 };
144
145 /**
146  * Removes a single child table column widget from this widget.
147  * This will also clear all the data in the table.
148  *
149  * @param {Widget} child the table column widget to remove
150  */
151 RapidContext.Widget.Table.prototype.removeChildNode = function (child) {
152     this.clear();
153     this._containerNode().removeChild(child);
154 };
155
156 /**
157  * Returns the column index of a field.
158  *
159  * @param {String} field the field name
160  *
161  * @return {Number} the column index, or
162  *         -1 if not found
163  */
164 RapidContext.Widget.Table.prototype.getColumnIndex = function (field) {
165     var cols = this.getChildNodes();
166     for (var i = 0; i < cols.length; i++) {
167         if (cols[i].field === field) {
168             return i;
169         }
170     }
171     return -1;
172 };
173
174 /**
175  * Returns the unique key identifier column field, or null if none
176  * was set.
177  *
178  * @return {String} the key column field name, or
179  *         null for none
180  */
181 RapidContext.Widget.Table.prototype.getIdKey = function () {
182     if (this._keyField) {
183         return this._keyField;
184     }
185     var cols = this.getChildNodes();
186     for (var i = 0; i < cols.length; i++) {
187         if (cols[i].key) {
188             return cols[i].field;
189         }
190     }
191     return null;
192 };
193
194 /**
195  * Sets the unique key identifier column field. Note that this
196  * method will regenerate all row identifiers if the table already
197  * contains data.
198  *
199  * @param {String} key the new key column field name
200  */
201 RapidContext.Widget.Table.prototype.setIdKey = function (key) {
202     this._keyField = key;
203     for (var i = 0; this._rows != null && i < this._rows.length; i++) {
204         var row = this._rows[i];
205         if (this._keyField != null && row.$data[this._keyField] != null) {
206             row.$id = row.$data[this._keyField];
207         }
208     }
209 };
210
211 /**
212  * Returns the current sort key for the table.
213  *
214  * @return {String} the current sort field, or
215  *         null for none
216  */
217 RapidContext.Widget.Table.prototype.getSortKey = function () {
218     var cols = this.getChildNodes();
219     for (var i = 0; i < cols.length; i++) {
220         if (cols[i].sort != null && cols[i].sort != "none") {
221             return cols[i].field;
222         }
223     }
224     return null;
225 };
226
227 /**
228  * Returns a table cell element.
229  *
230  * @param {Number} row the row index
231  * @param {Number} col the column index
232  *
233  * @return {Node} the table cell element node, or
234  *         null if not found
235  */
236 RapidContext.Widget.Table.prototype.getCellElem = function (row, col) {
237     try {
238         var table = this.firstChild;
239         var tbody = table.lastChild;
240         return tbody.childNodes[row].childNodes[col];
241     } catch (e) {
242         return null;
243     }
244 };
245
246 /**
247  * Clears all the data in the table. The column headers will not be
248  * affected by this method. Use `removeAll()` or `removeChildNode()` to
249  * also remove columns.
250  */
251 RapidContext.Widget.Table.prototype.clear = function () {
252     this.setData([]);
253 };
254
255 /**
256  * Returns an array with the data in the table. The array returned
257  * should correspond exactly to the one previously set, i.e. it has
258  * not been sorted or modified in other ways.
259  *
260  * @return {Array} an array with the data in the table
261  */
262 RapidContext.Widget.Table.prototype.getData = function () {
263     return this._data;
264 };
265
266 /**
267  * Sets the table data. The table data is an array of objects, each
268  * having properties corresponding to the table column fields. Any
269  * object property not mapped to a table column will be ignored (i.e.
270  * a hidden column). See the `TableColumn` class for data mapping
271  * details. Note that automatically generated row ids will be reset
272  * by this method and any selection on such tables is lost.
273  *
274  * @param {Array} data an array with data objects
275  *
276  * @example
277  * var data = [
278  *     { id: 1, name: "John Doe", modified: "@1300000000000" },
279  *     { id: 2, name: "First Last", modified: new Date() },
280  *     { id: 3, name: "Another Name", modified: "2004-11-30 13:33:20" }
281  * ];
282  * table.setData(data);
283  */
284 RapidContext.Widget.Table.prototype.setData = function (data) {
285     var cols = this.getChildNodes();
286     var selectedIds = this.getSelectedIds();
287     RapidContext.Widget.emitSignal(this, "onclear");
288     this._data = data;
289     this._rows = [];
290     this._selected = [];
291     for (var i = 0; data != null && i < data.length; i++) {
292         var row = { $id: "id" + i, $data: data[i] };
293         for (var j = 0; j < cols.length; j++) {
294             cols[j]._map(data[i], row);
295         }
296         if (this._keyField != null && data[i][this._keyField] != null) {
297             row.$id = data[i][this._keyField];
298         }
299         this._rows.push(row);
300     }
301     var key = this.getSortKey();
302     if (key) {
303         this.sortData(key);
304     } else {
305         this._renderRows();
306     }
307     if (this.getIdKey() != null) {
308         this._addSelectedIds(selectedIds);
309     }
310 };
311
312 /**
313  * Sorts the table data by field and direction.
314  *
315  * @param {String} field the sort field
316  * @param {String} [direction] the sort direction, either "asc" or
317  *            "desc"
318  */
319 RapidContext.Widget.Table.prototype.sortData = function (field, direction) {
320     var cols = this.getChildNodes();
321     var selectedIds = this.getSelectedIds();
322     this._selected = [];
323     for (var i = 0; i < cols.length; i++) {
324         if (cols[i].field === field) {
325             if (cols[i].sort == "none") {
326                 // Skip sorting if not allowed
327                 return;
328             } else if (direction == null) {
329                 direction = cols[i].sort || "asc";
330             }
331             cols[i].setAttrs({ sort: direction });
332         } else if (cols[i].sort != "none") {
333             cols[i].setAttrs({ sort: null });
334         }
335     }
336     this._rows.sort(MochiKit.Base.keyComparator(field));
337     if (direction == "desc") {
338         this._rows.reverse();
339     }
340     this._renderRows();
341     this._addSelectedIds(selectedIds);
342 };
343
344 /**
345  * Redraws the table from updated source data. Note that this method
346  * will not add or remove rows and keeps the current row order
347  * intact. For a more complete redraw of the table, use `setData()`.
348  */
349 RapidContext.Widget.Table.prototype.redraw = function () {
350     var cols = this.getChildNodes();
351     for (var i = 0; i < this._rows.length; i++) {
352         var row = this._rows[i];
353         for (var j = 0; j < cols.length; j++) {
354             cols[j]._map(row.$data, row);
355         }
356     }
357     this._renderRows();
358     for (var i = 0; i < this._selected.length; i++) {
359         this._markSelection(this._selected[i]);
360     }
361 };
362
363 /**
364  * Renders the table rows.
365  */
366 RapidContext.Widget.Table.prototype._renderRows = function () {
367     var cols = this.getChildNodes();
368     var tbody = this.firstChild.lastChild;
369     MochiKit.DOM.replaceChildNodes(tbody);
370     for (var i = 0; i < this._rows.length; i++) {
371         var tr = MochiKit.DOM.TR();
372         if (i % 2 == 1) {
373             MochiKit.DOM.addElementClass(tr, "widgetTableAlt");
374         }
375         for (var j = 0; j < cols.length; j++) {
376             tr.appendChild(cols[j]._render(this._rows[i]));
377         }
378         tr.rowNo = i;
379         tbody.appendChild(tr);
380     }
381     if (this._rows.length == 0) {
382         // Add empty row to avoid browser bugs
383         tbody.appendChild(MochiKit.DOM.TR());
384     }
385 };
386
387 /**
388  * Returns the number of rows in the table. This is a convenience
389  * method for `getData().length`.
390  *
391  * @return {Number} the number of table rows
392  */
393 RapidContext.Widget.Table.prototype.getRowCount = function () {
394     return this._rows.length;
395 }
396
397 /**
398  * Returns the row id for the specified row index. If the row index
399  * is out of bounds, null will be returned. The row ids are the data
400  * values from the key column, or automatically generated internal
401  * values if no key column is set. Note that the row index uses the
402  * current table sort order.
403  *
404  * @param {Number} index the row index, 0 <= index < row count
405  *
406  * @return {String} the unique row id, or null if not found
407  */
408 RapidContext.Widget.Table.prototype.getRowId = function (index) {
409     if (index >= 0 && index < this._rows.length) {
410         return this._rows[index].$id;
411     } else {
412         return null;
413     }
414 }
415
416 /**
417  * Returns the currently selected row ids. If no rows are selected,
418  * an empty array will be returned. The row ids are the data values
419  * from the key column, or automatically generated internal values
420  * if no key column is set.
421  *
422  * @return {Array} an array with the selected row ids
423  */
424 RapidContext.Widget.Table.prototype.getSelectedIds = function () {
425     var res = [];
426     for (var i = 0; i < this._selected.length; i++) {
427         res.push(this._rows[this._selected[i]].$id);
428     }
429     return res;
430 };
431
432 /**
433  * Returns the currently selected row data.
434  *
435  * @return {Object/Array} the data row selected, or
436  *         an array of selected data rows if multiple selection is enabled
437  */
438 RapidContext.Widget.Table.prototype.getSelectedData = function () {
439     if (this._selectMode === "multiple") {
440         var res = [];
441         for (var i = 0; i < this._selected.length; i++) {
442             res.push(this._rows[this._selected[i]].$data);
443         }
444         return res;
445     } else if (this._selected.length > 0) {
446         return this._rows[this._selected[0]].$data;
447     } else {
448         return null;
449     }
450 };
451
452 /**
453  * Sets the selection to the specified row id values. If the current
454  * selection is changed the select signal will be emitted.
455  *
456  * @param {String/Array} [...] the row ids or array with ids to select
457  *
458  * @return {Array} an array with the row ids actually modified
459  */
460 RapidContext.Widget.Table.prototype.setSelectedIds = function () {
461     var args = MochiKit.Base.flattenArguments(arguments);
462     var ids = RapidContext.Util.dict(args, true);
463     var oldIds = RapidContext.Util.dict(this.getSelectedIds(), true);
464     var res = [];
465     for (var i = 0; i < this._rows.length; i++) {
466         var rowId = this._rows[i].$id;
467         if (ids[rowId] && !oldIds[rowId]) {
468             this._selected.push(i);
469             this._markSelection(i);
470             res.push(rowId);
471         } else if (!ids[rowId] && oldIds[rowId]) {
472             var pos = MochiKit.Base.findIdentical(this._selected, i);
473             if (pos >= 0) {
474                 this._selected.splice(pos, 1);
475                 this._unmarkSelection(i);
476                 res.push(rowId);
477             }
478         }
479     }
480     if (res.length > 0) {
481         RapidContext.Widget.emitSignal(this, "onselect");
482     }
483     return res;
484 };
485
486 /**
487  * Adds the specified row id values to the selection. If the current
488  * selection is changed the select signal will be emitted.
489  *
490  * @param {String/Array} [...] the row ids or array with ids to select
491  *
492  * @return {Array} an array with the new row ids actually selected
493  */
494 RapidContext.Widget.Table.prototype.addSelectedIds = function () {
495     var res = this._addSelectedIds(arguments);
496     if (res.length > 0) {
497         RapidContext.Widget.emitSignal(this, "onselect");
498     }
499     return res;
500 };
501
502 /**
503  * Adds the specified row id values to the selection. Note that this
504  * method does not emit any selection signal.
505  *
506  * @param {String/Array} [...] the row ids or array with ids to select
507  *
508  * @return {Array} an array with the new row ids actually selected
509  */
510 RapidContext.Widget.Table.prototype._addSelectedIds = function () {
511     var args = MochiKit.Base.flattenArguments(arguments);
512     var ids = RapidContext.Util.dict(args, true);
513     var res = [];
514     MochiKit.Base.update(ids, RapidContext.Util.dict(this.getSelectedIds(), false));
515     for (var i = 0; i < this._rows.length; i++) {
516         if (ids[this._rows[i].$id]) {
517             this._selected.push(i);
518             this._markSelection(i);
519             res.push(this._rows[i].$id);
520         }
521     }
522     return res;
523 };
524
525 /**
526  * Removes the specified row id values from the selection. If the
527  * current selection is changed the select signal will be emitted.
528  *
529  * @param {String/Array} [...] the row ids or array with ids to unselect
530  *
531  * @return {Array} an array with the row ids actually unselected
532  */
533 RapidContext.Widget.Table.prototype.removeSelectedIds = function () {
534     var args = MochiKit.Base.flattenArguments(arguments);
535     var ids = RapidContext.Util.dict(args, true);
536     var res = [];
537     for (var i = 0; i < this._rows.length; i++) {
538         if (ids[this._rows[i].$id]) {
539             var pos = MochiKit.Base.findIdentical(this._selected, i);
540             if (pos >= 0) {
541                 this._selected.splice(pos, 1);
542                 this._unmarkSelection(i);
543                 res.push(this._rows[i].$id);
544             }
545         }
546     }
547     if (res.length > 0) {
548         RapidContext.Widget.emitSignal(this, "onselect");
549     }
550     return res;
551 };
552
553 /**
554  * Handles the mouse up event by stopping text selection in some cases.
555  *
556  * @param {Event} evt the MochiKit.Signal.Event object
557  */
558 RapidContext.Widget.Table.prototype._handleMouseDown = function (evt) {
559     this._mouseX = evt.mouse().page.x;
560     this._mouseY = evt.mouse().page.y;
561     if (evt.modifier().ctrl || evt.modifier().meta || evt.modifier().shift) {
562         evt.stop();
563         return false;
564     } else {
565         return true;
566     }
567 }
568
569 /**
570  * Handles the mouse up event by changing the selection if appropriate.
571  *
572  * @param {Event} evt the MochiKit.Signal.Event object
573  */
574 RapidContext.Widget.Table.prototype._handleMouseUp = function (evt) {
575     var moveX = Math.abs(evt.mouse().page.x - this._mouseX);
576     var moveY = Math.abs(evt.mouse().page.y - this._mouseY);
577     var moveXY = Math.sqrt(moveX * moveX + moveY * moveY);
578     var tr = MochiKit.DOM.getFirstParentByTagAndClassName(evt.target(), "TR");
579     if (tr == null || tr.rowNo == null || !MochiKit.DOM.isChildNode(tr, this)) {
580         return true;
581     } else if (moveXY > 5.0) {
582         return true;
583     }
584     var row = tr.rowNo;
585     if (this._selectMode === "multiple") {
586         if (evt.modifier().ctrl || evt.modifier().meta) {
587             var pos = MochiKit.Base.findIdentical(this._selected, row);
588             if (pos >= 0) {
589                 this._unmarkSelection(row);
590                 this._selected.splice(pos, 1);
591             } else {
592                 this._selected.push(row);
593                 this._markSelection(row);
594             }
595         } else if (evt.modifier().shift) {
596             var start = row;
597             if (this._selected.length > 0) {
598                 start = this._selected[0];
599             }
600             this._unmarkSelection();
601             this._selected = [];
602             if (row >= start) {
603                 for (var i = start; i <= row; i++) {
604                     this._selected.push(i);
605                 }
606             } else {
607                 for (var i = start; i >= row; i--) {
608                     this._selected.push(i);
609                 }
610             }
611             this._markSelection();
612         } else {
613             this._unmarkSelection();
614             this._selected = [row];
615             this._markSelection(row);
616         }
617     } else if (this._selectMode !== "none") {
618         this._unmarkSelection();
619         this._selected = [row];
620         this._markSelection(row);
621     }
622     RapidContext.Widget.emitSignal(this, "onselect");
623     if (evt.modifier().ctrl || evt.modifier().meta || evt.modifier().shift) {
624         evt.stop();
625         return false;
626     } else {
627         return true;
628     }
629 };
630
631 /**
632  * Marks selected rows.
633  *
634  * @param {Number} indexOrNull the row index, or null for the array
635  */
636 RapidContext.Widget.Table.prototype._markSelection = function (indexOrNull) {
637     if (indexOrNull == null) {
638         for (var i = 0; i < this._selected.length; i++) {
639             this._markSelection(this._selected[i]);
640         }
641     } else {
642         var tbody = this.firstChild.lastChild;
643         var tr = tbody.childNodes[indexOrNull];
644         MochiKit.DOM.addElementClass(tr, "selected");
645     }
646 };
647
648 /**
649  * Unmarks selected rows.
650  *
651  * @param {Number} indexOrNull the row index, or null for the array
652  */
653 RapidContext.Widget.Table.prototype._unmarkSelection = function (indexOrNull) {
654     if (indexOrNull == null) {
655         for (var i = 0; i < this._selected.length; i++) {
656             this._unmarkSelection(this._selected[i]);
657         }
658     } else {
659         var tbody = this.firstChild.lastChild;
660         var tr = tbody.childNodes[indexOrNull];
661         MochiKit.DOM.removeElementClass(tr, "selected");
662     }
663 };
664
665 /**
666  * Called when table content should be resized. This method is also called when
667  * the widget is made visible in a container after being hidden.
668  */
669 RapidContext.Widget.Table.prototype._resizeContent = function () {
670     // Work-around to restore scrollTop for WebKit browsers
671     if (this.scrollTop == 0 && this._selected.length > 0) {
672         var index = this._selected[0];
673         var tbody = this.firstChild.lastChild;
674         var tr = tbody.childNodes[index];
675         var h = this.clientHeight;
676         var y = tr.offsetTop + tr.offsetHeight;
677         this.scrollTop = Math.round(y - h / 2);
678     }
679     var thead = this.firstChild.firstChild;
680     RapidContext.Util.resizeElements(thead);
681 };
682