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