Source RapidContext_Widget_Table.js

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