Source RapidContext_Widget_Table.js

1/*
2 * RapidContext <https://www.rapidcontext.com/>
3 * Copyright (c) 2007-2026 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    const thead = RapidContext.UI.THEAD({}, document.createElement("tr"));
59    const tbody = RapidContext.UI.TBODY();
60    const table = RapidContext.UI.TABLE({ "class": "widgetTable" }, thead, tbody);
61    const 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    const table = this.firstChild;
102    const thead = table.firstChild;
103    const 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    const tr = evt.target.closest(".widgetTable > tbody > tr");
128    const row = tr && (tr.rowIndex - 1);
129    const isMulti = tr && this._selectMode === "multiple";
130    const isSingle = tr && this._selectMode !== "none";
131    if (isMulti && (evt.ctrlKey || evt.metaKey)) {
132        evt.preventDefault();
133        const 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        const start = this._selected[0];
147        this._selected = [];
148        const 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 = { ...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    const 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 (const 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 (const 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 (const 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        const table = this.firstChild;
281        const 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    const columns = this.getChildNodes();
330    const key = this.getIdKey() ?? "$id";
331    const 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    const sort = this.getSortKey();
337    if (sort) {
338        this.sortData(sort);
339    } else {
340        this._renderRows();
341    }
342    if (this._selectMode !== "none") {
343        const 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    const columns = this.getChildNodes();
366    const key = this.getIdKey() ?? "$id";
367    for (const obj of data) {
368        const idx = this._rows.findIndex((o) => o.$id === obj[key] || o.$data === obj);
369        if (idx >= 0) {
370            const row = this._rows[idx] = this._mapRow(columns, key, obj, idx);
371            const tr = document.createElement("tr");
372            tr.append(...columns.map((col) => col._render(row)));
373            const tbody = this.firstChild.lastChild;
374            tbody.children[idx].replaceWith(tr);
375        }
376    }
377    this._data = this._rows.map((o) => o.$data);
378    for (const 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    const id = (key && obj[key] != null) ? obj[key] : `id${idx}`;
397    const row = { $id: id, $data: obj };
398    for (const 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    const selectedIds = this.getSelectedIds();
413    this._selected = [];
414    for (const 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    const cmp = RapidContext.Data.compare((o) => o[field]);
425    this._rows.sort((direction == "desc") ? (a, b) => cmp(b, a) : cmp);
426    this._renderRows();
427    this._addSelectedIds(selectedIds);
428};
429
430/**
431 * Redraws the table from updated source data. Note that this method
432 * will not add or remove rows and keeps the current row order
433 * intact. For a more complete redraw of the table, use `setData()`.
434 */
435RapidContext.Widget.Table.prototype.redraw = function () {
436    const cols = this.getChildNodes();
437    for (const row of this._rows) {
438        for (const col of cols) {
439            row[col.field] = col._map(row.$data);
440        }
441    }
442    this._renderRows();
443    for (const sel of this._selected) {
444        this._markSelection(sel);
445    }
446};
447
448/**
449 * Renders the table rows.
450 */
451RapidContext.Widget.Table.prototype._renderRows = function () {
452    const cols = this.getChildNodes();
453    const tbody = this.firstChild.lastChild;
454    tbody.innerHTML = "";
455    for (const row of this._rows) {
456        const tr = document.createElement("tr");
457        tr.append(...cols.map((col) => col._render(row)));
458        tbody.append(tr);
459    }
460    if (this._rows.length == 0) {
461        // Add empty row to avoid browser bugs
462        tbody.append(document.createElement("tr"));
463    }
464};
465
466/**
467 * Returns the number of rows in the table. This is a convenience
468 * method for `getData().length`.
469 *
470 * @return {number} the number of table rows
471 */
472RapidContext.Widget.Table.prototype.getRowCount = function () {
473    return this._rows.length;
474};
475
476/**
477 * Returns the row id for the specified row index. If the row index
478 * is out of bounds, null will be returned. The row ids are the data
479 * values from the key column, or automatically generated internal
480 * values if no key column is set. Note that the row index uses the
481 * current table sort order.
482 *
483 * @param {number} index the row index, 0 <= index < row count
484 *
485 * @return {string} the unique row id, or null if not found
486 */
487RapidContext.Widget.Table.prototype.getRowId = function (index) {
488    const row = this._rows[index];
489    return row ? row.$id : null;
490};
491
492/**
493 * Returns the currently selected row ids. If no rows are selected,
494 * an empty array will be returned. The row ids are the data values
495 * from the key column, or automatically generated internal values
496 * if no key column is set.
497 *
498 * @return {Array} an array with the selected row ids
499 */
500RapidContext.Widget.Table.prototype.getSelectedIds = function () {
501    return this._selected.map((idx) => this._rows[idx].$id);
502};
503
504/**
505 * Returns the currently selected row data.
506 *
507 * @return {Object|Array} the data row selected, or
508 *         an array of selected data rows if multiple selection is enabled
509 */
510RapidContext.Widget.Table.prototype.getSelectedData = function () {
511    const data = this._selected.map((idx) => this._rows[idx].$data);
512    return (this._selectMode === "multiple") ? data : data[0];
513};
514
515/**
516 * Sets the selection to the specified row id values. If the current
517 * selection is changed the select signal will be emitted.
518 *
519 * @param {...(string|Array)} id the row ids or array with ids to select
520 *
521 * @return {Array} an array with the row ids actually modified
522 */
523RapidContext.Widget.Table.prototype.setSelectedIds = function (...ids) {
524    const $ids = RapidContext.Data.object([].concat(...ids), true);
525    const oldIds = RapidContext.Data.object(this.getSelectedIds(), true);
526    const res = [];
527    for (let i = 0; i < this._rows.length; i++) {
528        const rowId = this._rows[i].$id;
529        if ($ids[rowId] && !oldIds[rowId]) {
530            this._selected.push(i);
531            this._markSelection(i);
532            res.push(rowId);
533        } else if (!$ids[rowId] && oldIds[rowId]) {
534            const pos = this._selected.indexOf(i);
535            if (pos >= 0) {
536                this._selected.splice(pos, 1);
537                this._unmarkSelection(i);
538                res.push(rowId);
539            }
540        }
541    }
542    if (res.length > 0) {
543        this.emit("select");
544    }
545    return res;
546};
547
548/**
549 * Adds the specified row id values to the selection. If the current
550 * selection is changed the select signal will be emitted.
551 *
552 * @param {...(string|Array)} id the row ids or array with ids to select
553 *
554 * @return {Array} an array with the new row ids actually selected
555 */
556RapidContext.Widget.Table.prototype.addSelectedIds = function (...ids) {
557    const res = this._addSelectedIds(...ids);
558    if (res.length > 0) {
559        this.emit("select");
560    }
561    return res;
562};
563
564/**
565 * Adds the specified row id values to the selection. Note that this
566 * method does not emit any selection signal.
567 *
568 * @param {...(string|Array)} id the row ids or array with ids to select
569 *
570 * @return {Array} an array with the new row ids actually selected
571 */
572RapidContext.Widget.Table.prototype._addSelectedIds = function (...ids) {
573    const $ids = RapidContext.Data.object([].concat(...ids), true);
574    Object.assign($ids, RapidContext.Data.object(this.getSelectedIds(), false));
575    const res = [];
576    for (let i = 0; i < this._rows.length; i++) {
577        if ($ids[this._rows[i].$id] === true) {
578            this._selected.push(i);
579            this._markSelection(i);
580            res.push(this._rows[i].$id);
581        }
582    }
583    return res;
584};
585
586/**
587 * Removes the specified row id values from the selection. If the
588 * current selection is changed the select signal will be emitted.
589 *
590 * @param {...(string|Array)} id the row ids or array with ids to unselect
591 *
592 * @return {Array} an array with the row ids actually unselected
593 */
594RapidContext.Widget.Table.prototype.removeSelectedIds = function (...ids) {
595    const $ids = RapidContext.Data.object([].concat(...ids), true);
596    const res = [];
597    for (let i = 0; i < this._rows.length; i++) {
598        if ($ids[this._rows[i].$id] === true) {
599            const pos = this._selected.indexOf(i);
600            if (pos >= 0) {
601                this._selected.splice(pos, 1);
602                this._unmarkSelection(i);
603                res.push(this._rows[i].$id);
604            }
605        }
606    }
607    if (res.length > 0) {
608        this.emit("select");
609    }
610    return res;
611};
612
613/**
614 * Marks selected rows.
615 *
616 * @param {number} [index] the row index, or null to mark all
617 */
618RapidContext.Widget.Table.prototype._markSelection = function (index) {
619    const tbody = this.firstChild.lastChild;
620    const indices = (index == null) ? this._selected : [index];
621    for (const idx of indices) {
622        tbody.childNodes[idx].classList.add("selected");
623    }
624};
625
626/**
627 * Unmarks selected rows.
628 *
629 * @param {number} [index] the row index, or null to unmark all
630 */
631RapidContext.Widget.Table.prototype._unmarkSelection = function (index) {
632    const tbody = this.firstChild.lastChild;
633    const indices = (index == null) ? this._selected : [index];
634    for (const idx of indices) {
635        tbody.childNodes[idx].classList.remove("selected");
636    }
637};
638