Source RapidContext_Widget_Table.js

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