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
RapidContext
Access · Discovery · Insight
www.rapidcontext.com