TableWidget.js (58920B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 "use strict"; 5 6 const EventEmitter = require("resource://devtools/shared/event-emitter.js"); 7 loader.lazyRequireGetter( 8 this, 9 ["clearNamedTimeout", "setNamedTimeout"], 10 "resource://devtools/client/shared/widgets/view-helpers.js", 11 true 12 ); 13 loader.lazyRequireGetter( 14 this, 15 "naturalSortCaseInsensitive", 16 "resource://devtools/shared/natural-sort.js", 17 true 18 ); 19 loader.lazyGetter(this, "standardSessionString", () => { 20 const l10n = new Localization(["devtools/client/storage.ftl"], true); 21 return l10n.formatValueSync("storage-expires-session"); 22 }); 23 24 const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js"); 25 26 const HTML_NS = "http://www.w3.org/1999/xhtml"; 27 const AFTER_SCROLL_DELAY = 100; 28 29 // Different types of events emitted by the Various components of the 30 // TableWidget. 31 const EVENTS = { 32 CELL_EDIT: "cell-edit", 33 COLUMN_SORTED: "column-sorted", 34 COLUMN_TOGGLED: "column-toggled", 35 FIELDS_EDITABLE: "fields-editable", 36 HEADER_CONTEXT_MENU: "header-context-menu", 37 ROW_EDIT: "row-edit", 38 ROW_CONTEXT_MENU: "row-context-menu", 39 ROW_REMOVED: "row-removed", 40 ROW_SELECTED: "row-selected", 41 ROW_UPDATED: "row-updated", 42 TABLE_CLEARED: "table-cleared", 43 TABLE_FILTERED: "table-filtered", 44 SCROLL_END: "scroll-end", 45 }; 46 Object.defineProperty(this, "EVENTS", { 47 value: EVENTS, 48 enumerable: true, 49 writable: false, 50 }); 51 52 /** 53 * A table widget with various features like resizble/toggleable columns, 54 * sorting, keyboard navigation etc. 55 * 56 */ 57 class TableWidget extends EventEmitter { 58 static EVENTS = EVENTS; 59 60 /** 61 * @param {Node} node 62 * The container element for the table widget. 63 * @param {object} options 64 * - initialColumns: map of key vs display name for initial columns of 65 * the table. See @setupColumns for more info. 66 * - uniqueId: the column which will be the unique identifier of each 67 * entry in the table. Default: name. 68 * - wrapTextInElements: Don't ever use 'value' attribute on labels. 69 * Default: false. 70 * - emptyText: Localization ID for the text to display when there are 71 * no entries in the table to display. 72 * - highlightUpdated: true to highlight the changed/added row. 73 * - removableColumns: Whether columns are removeable. If set to false, 74 * the context menu in the headers will not appear. 75 * - firstColumn: key of the first column that should appear. 76 * - cellContextMenuId: ID of a <menupopup> element to be set as a 77 * context menu of every cell. 78 */ 79 constructor(node, options = {}) { 80 super(); 81 82 this.document = node.ownerDocument; 83 this.window = this.document.defaultView; 84 this._parent = node; 85 86 const { 87 initialColumns, 88 emptyText, 89 uniqueId, 90 highlightUpdated, 91 removableColumns, 92 firstColumn, 93 wrapTextInElements, 94 cellContextMenuId, 95 l10n, 96 } = options; 97 this.emptyText = emptyText || ""; 98 this.uniqueId = uniqueId || "name"; 99 this.wrapTextInElements = wrapTextInElements || false; 100 this.firstColumn = firstColumn || ""; 101 this.highlightUpdated = highlightUpdated || false; 102 this.removableColumns = removableColumns !== false; 103 this.cellContextMenuId = cellContextMenuId; 104 this.l10n = l10n; 105 106 this.tbody = this.document.createXULElement("hbox"); 107 this.tbody.className = "table-widget-body theme-body"; 108 this.tbody.setAttribute("flex", "1"); 109 this.tbody.setAttribute("tabindex", "0"); 110 this._parent.appendChild(this.tbody); 111 this.afterScroll = this.afterScroll.bind(this); 112 this.tbody.addEventListener("scroll", this.onScroll.bind(this)); 113 114 // Prepare placeholder 115 this.placeholder = this.document.createElement("div"); 116 this.placeholder.className = "table-widget-empty-text"; 117 this._parent.appendChild(this.placeholder); 118 this.setPlaceholder(this.emptyText); 119 120 this.items = new Map(); 121 this.columns = new Map(); 122 123 // Setup the column headers context menu to allow users to hide columns at 124 // will. 125 if (this.removableColumns) { 126 this.onPopupCommand = this.onPopupCommand.bind(this); 127 this.setupHeadersContextMenu(); 128 } 129 130 if (initialColumns) { 131 this.setColumns(initialColumns, uniqueId); 132 } 133 134 this.bindSelectedRow = id => { 135 this.selectedRow = id; 136 }; 137 this.on(EVENTS.ROW_SELECTED, this.bindSelectedRow); 138 139 this.onChange = this.onChange.bind(this); 140 this.onEditorDestroyed = this.onEditorDestroyed.bind(this); 141 this.onEditorTab = this.onEditorTab.bind(this); 142 this.onKeydown = this.onKeydown.bind(this); 143 this.onMousedown = this.onMousedown.bind(this); 144 this.onRowRemoved = this.onRowRemoved.bind(this); 145 146 this.document.addEventListener("keydown", this.onKeydown); 147 this.document.addEventListener("mousedown", this.onMousedown); 148 } 149 150 items = null; 151 editBookmark = null; 152 scrollIntoViewOnUpdate = null; 153 /** 154 * Return true if the table body has a scrollbar. 155 */ 156 get hasScrollbar() { 157 return this.tbody.scrollHeight > this.tbody.clientHeight; 158 } 159 160 /** 161 * Getter for the headers context menu popup id. 162 */ 163 get headersContextMenu() { 164 if (this.menupopup) { 165 return this.menupopup.id; 166 } 167 return null; 168 } 169 170 /** 171 * Select the row corresponding to the json object `id` 172 */ 173 set selectedRow(id) { 174 for (const column of this.columns.values()) { 175 if (id || id === "") { 176 column.selectRow(id[this.uniqueId] || id); 177 } else { 178 column.selectedRow = null; 179 column.selectRow(null); 180 } 181 } 182 } 183 184 /** 185 * Is a row currently selected? 186 * 187 * @return {boolean} 188 * true or false. 189 */ 190 get hasSelectedRow() { 191 return ( 192 this.columns.get(this.uniqueId) && 193 this.columns.get(this.uniqueId).selectedRow 194 ); 195 } 196 197 /** 198 * Returns the json object corresponding to the selected row. 199 */ 200 get selectedRow() { 201 return this.items.get(this.columns.get(this.uniqueId).selectedRow); 202 } 203 204 /** 205 * Selects the row at index `index`. 206 */ 207 set selectedIndex(index) { 208 for (const column of this.columns.values()) { 209 column.selectRowAt(index); 210 } 211 } 212 213 /** 214 * Returns the index of the selected row. 215 */ 216 get selectedIndex() { 217 return this.columns.get(this.uniqueId).selectedIndex; 218 } 219 220 /** 221 * Returns the index of the selected row disregarding hidden rows. 222 */ 223 get visibleSelectedIndex() { 224 const column = this.firstVisibleColumn; 225 const cells = column.visibleCellNodes; 226 227 for (let i = 0; i < cells.length; i++) { 228 if (cells[i].classList.contains("theme-selected")) { 229 return i; 230 } 231 } 232 233 return -1; 234 } 235 236 /** 237 * Returns the first visible column. 238 */ 239 get firstVisibleColumn() { 240 for (const column of this.columns.values()) { 241 if (column._private) { 242 continue; 243 } 244 245 if (column.column.clientHeight > 0) { 246 return column; 247 } 248 } 249 250 return null; 251 } 252 253 /** 254 * returns all editable columns. 255 */ 256 get editableColumns() { 257 const filter = columns => { 258 columns = [...columns].filter(col => { 259 if (col.clientWidth === 0) { 260 return false; 261 } 262 263 const cell = col.querySelector(".table-widget-cell"); 264 265 for (const selector of this._editableFieldsEngine.selectors) { 266 if (cell.matches(selector)) { 267 return true; 268 } 269 } 270 271 return false; 272 }); 273 274 return columns; 275 }; 276 277 const columns = this._parent.querySelectorAll(".table-widget-column"); 278 return filter(columns); 279 } 280 281 /** 282 * Ensures all cells in a specific row have consistent heights by synchronizing their styles. 283 * This method calculates the maximum height of the visible cells in the specified row, 284 * but only applies changes if there's a mismatch. 285 * 286 * @param {string} uniqueRowIndex - The unique identifier for the row to synchronize. 287 */ 288 syncRowHeight(uniqueRowIndex) { 289 const cellsInRow = [ 290 ...this.tbody.querySelectorAll( 291 `div:not([hidden=""]) label.table-widget-cell[data-id="${uniqueRowIndex}"]` 292 ), 293 ]; 294 295 if (cellsInRow.length === 0) { 296 return; 297 } 298 299 // Collect the heights of all visible cells 300 const cellHeights = cellsInRow.map(cell => cell.clientHeight); 301 302 // Find the max height 303 const maxHeight = Math.max(...cellHeights); 304 305 // Only apply new height if there’s a mismatch 306 const hasMismatch = cellHeights.some(height => height !== maxHeight); 307 if (!hasMismatch) { 308 return; 309 } 310 311 for (const cell of cellsInRow) { 312 if (cell.style.height !== `${maxHeight}px`) { 313 cell.style.height = `${maxHeight}px`; 314 } 315 } 316 } 317 318 /** 319 * Emit all cell edit events. 320 */ 321 async onChange(data) { 322 const changedField = data.change.field; 323 const colName = changedField.parentNode.id; 324 const column = this.columns.get(colName); 325 const uniqueId = column.table.uniqueId; 326 const itemIndex = column.cellNodes.indexOf(changedField); 327 const items = {}; 328 329 for (const [name, col] of this.columns) { 330 items[name] = col.cellNodes[itemIndex].value; 331 } 332 333 const change = { 334 host: this.host, 335 key: uniqueId, 336 field: colName, 337 oldValue: data.change.oldValue, 338 newValue: data.change.newValue, 339 items, 340 }; 341 342 // A rows position in the table can change as the result of an edit. In 343 // order to ensure that the correct row is highlighted after an edit we 344 // save the uniqueId in editBookmark. 345 this.editBookmark = 346 colName === uniqueId ? change.newValue : items[uniqueId]; 347 348 // Pass an AbortController instance along so the edit can be aborted in the listeners 349 const abortController = new this.window.AbortController(); 350 await this.emitAsync(EVENTS.CELL_EDIT, change, abortController); 351 352 // If the abortController was aborted, we only need to revert the field value 353 if (abortController.signal.aborted) { 354 changedField.value = data.change.oldValue; 355 return; 356 } 357 358 this.syncRowHeight(change.items.uniqueKey); 359 } 360 361 onEditorDestroyed() { 362 this._editableFieldsEngine = null; 363 } 364 365 /** 366 * Called by the inplace editor when Tab / Shift-Tab is pressed in edit-mode. 367 * Because tables are live any row, column, cell or table can be added, 368 * deleted or moved by deleting and adding e.g. a row again. 369 * 370 * This presents various challenges when navigating via the keyboard so please 371 * keep this in mind whenever editing this method. 372 * 373 * @param {Event} event 374 * Keydown event 375 */ 376 onEditorTab(event) { 377 const textbox = event.target; 378 const editor = this._editableFieldsEngine; 379 380 if (textbox.id !== editor.INPUT_ID) { 381 return; 382 } 383 384 const column = textbox.parentNode; 385 386 // Changing any value can change the position of the row depending on which 387 // column it is currently sorted on. In addition to this, the table cell may 388 // have been edited and had to be recreated when the user has pressed tab or 389 // shift+tab. Both of these situations require us to recover our target, 390 // select the appropriate row and move the textbox on to the next cell. 391 if (editor.changePending) { 392 // We need to apply a change, which can mean that the position of cells 393 // within the table can change. Because of this we need to wait for 394 // EVENTS.ROW_EDIT and then move the textbox. 395 this.once(EVENTS.ROW_EDIT, uniqueId => { 396 let columnObj; 397 const cols = this.editableColumns; 398 let rowIndex = this.visibleSelectedIndex; 399 const colIndex = cols.indexOf(column); 400 let newIndex; 401 402 // If the row has been deleted we should bail out. 403 if (!uniqueId) { 404 return; 405 } 406 407 // Find the column we need to move to. 408 if (event.shiftKey) { 409 // Navigate backwards on shift tab. 410 if (colIndex === 0) { 411 if (rowIndex === 0) { 412 return; 413 } 414 newIndex = cols.length - 1; 415 } else { 416 newIndex = colIndex - 1; 417 } 418 } else if (colIndex === cols.length - 1) { 419 const id = cols[0].id; 420 columnObj = this.columns.get(id); 421 const maxRowIndex = columnObj.visibleCellNodes.length - 1; 422 if (rowIndex === maxRowIndex) { 423 return; 424 } 425 newIndex = 0; 426 } else { 427 newIndex = colIndex + 1; 428 } 429 430 const newcol = cols[newIndex]; 431 columnObj = this.columns.get(newcol.id); 432 433 // Select the correct row even if it has moved due to sorting. 434 const dataId = editor.currentTarget.getAttribute("data-id"); 435 if (this.items.get(dataId)) { 436 this.emit(EVENTS.ROW_SELECTED, dataId); 437 } else { 438 this.emit(EVENTS.ROW_SELECTED, uniqueId); 439 } 440 441 // EVENTS.ROW_SELECTED may have changed the selected row so let's save 442 // the result in rowIndex. 443 rowIndex = this.visibleSelectedIndex; 444 445 // Edit the appropriate cell. 446 const cells = columnObj.visibleCellNodes; 447 const cell = cells[rowIndex]; 448 editor.edit(cell); 449 450 // Remove flash-out class... it won't have been auto-removed because the 451 // cell was hidden for editing. 452 cell.classList.remove("flash-out"); 453 }); 454 } 455 456 // Begin cell edit. We always do this so that we can begin editing even in 457 // the case that the previous edit will cause the row to move. 458 const cell = this.getEditedCellOnTab(event, column); 459 editor.edit(cell); 460 461 // Prevent default input tabbing behaviour 462 event.preventDefault(); 463 } 464 465 /** 466 * Get the cell that will be edited next on tab / shift tab and highlight the 467 * appropriate row. Edits etc. are not taken into account. 468 * 469 * This is used to tab from one field to another without editing and makes the 470 * editor much more responsive. 471 * 472 * @param {Event} event 473 * Keydown event 474 */ 475 getEditedCellOnTab(event, column) { 476 let cell = null; 477 const cols = this.editableColumns; 478 const rowIndex = this.visibleSelectedIndex; 479 const colIndex = cols.indexOf(column); 480 const maxCol = cols.length - 1; 481 const maxRow = this.columns.get(column.id).visibleCellNodes.length - 1; 482 483 if (event.shiftKey) { 484 // Navigate backwards on shift tab. 485 if (colIndex === 0) { 486 if (rowIndex === 0) { 487 this._editableFieldsEngine.completeEdit(); 488 return null; 489 } 490 491 column = cols[cols.length - 1]; 492 const cells = this.columns.get(column.id).visibleCellNodes; 493 cell = cells[rowIndex - 1]; 494 495 const rowId = cell.getAttribute("data-id"); 496 this.emit(EVENTS.ROW_SELECTED, rowId); 497 } else { 498 column = cols[colIndex - 1]; 499 const cells = this.columns.get(column.id).visibleCellNodes; 500 cell = cells[rowIndex]; 501 } 502 } else if (colIndex === maxCol) { 503 // If in the rightmost column on the last row stop editing. 504 if (rowIndex === maxRow) { 505 this._editableFieldsEngine.completeEdit(); 506 return null; 507 } 508 509 // If in the rightmost column of a row then move to the first column of 510 // the next row. 511 column = cols[0]; 512 const cells = this.columns.get(column.id).visibleCellNodes; 513 cell = cells[rowIndex + 1]; 514 515 const rowId = cell.getAttribute("data-id"); 516 this.emit(EVENTS.ROW_SELECTED, rowId); 517 } else { 518 // Navigate forwards on tab. 519 column = cols[colIndex + 1]; 520 const cells = this.columns.get(column.id).visibleCellNodes; 521 cell = cells[rowIndex]; 522 } 523 524 return cell; 525 } 526 527 /** 528 * Reset the editable fields engine if the currently edited row is removed. 529 * 530 * @param {string} event 531 * The event name "event-removed." 532 * @param {object} row 533 * The values from the removed row. 534 */ 535 onRowRemoved(row) { 536 if (!this._editableFieldsEngine || !this._editableFieldsEngine.isEditing) { 537 return; 538 } 539 540 const removedKey = row[this.uniqueId]; 541 const column = this.columns.get(this.uniqueId); 542 543 if (removedKey in column.items) { 544 return; 545 } 546 547 // The target is lost so we need to hide the remove the textbox from the DOM 548 // and reset the target nodes. 549 this.onEditorTargetLost(); 550 } 551 552 /** 553 * Cancel an edit because the edit target has been lost. 554 */ 555 onEditorTargetLost() { 556 const editor = this._editableFieldsEngine; 557 558 if (!editor || !editor.isEditing) { 559 return; 560 } 561 562 editor.cancelEdit(); 563 } 564 565 /** 566 * Keydown event handler for the table. Used for keyboard navigation amongst 567 * rows. 568 */ 569 onKeydown(event) { 570 // If we are in edit mode bail out. 571 if (this._editableFieldsEngine && this._editableFieldsEngine.isEditing) { 572 return; 573 } 574 575 // We need to get the first *visible* selected cell. Some columns are hidden 576 // e.g. because they contain a unique compound key for cookies that is never 577 // displayed in the UI. To do this we get all selected cells and filter out 578 // any that are hidden. 579 const selectedCells = [ 580 ...this.tbody.querySelectorAll(".theme-selected"), 581 ].filter(cell => cell.clientWidth > 0); 582 // Select the first visible selected cell. 583 const selectedCell = selectedCells[0]; 584 if (!selectedCell) { 585 return; 586 } 587 588 let colName; 589 let column; 590 let visibleCells; 591 let index; 592 let cell; 593 594 switch (event.keyCode) { 595 case KeyCodes.DOM_VK_UP: 596 event.preventDefault(); 597 598 colName = selectedCell.parentNode.id; 599 column = this.columns.get(colName); 600 visibleCells = column.visibleCellNodes; 601 index = visibleCells.indexOf(selectedCell); 602 603 if (index > 0) { 604 index--; 605 } else { 606 index = visibleCells.length - 1; 607 } 608 609 cell = visibleCells[index]; 610 611 this.emit(EVENTS.ROW_SELECTED, cell.getAttribute("data-id")); 612 break; 613 case KeyCodes.DOM_VK_DOWN: 614 event.preventDefault(); 615 616 colName = selectedCell.parentNode.id; 617 column = this.columns.get(colName); 618 visibleCells = column.visibleCellNodes; 619 index = visibleCells.indexOf(selectedCell); 620 621 if (index === visibleCells.length - 1) { 622 index = 0; 623 } else { 624 index++; 625 } 626 627 cell = visibleCells[index]; 628 629 this.emit(EVENTS.ROW_SELECTED, cell.getAttribute("data-id")); 630 break; 631 } 632 } 633 634 /** 635 * Close any editors if the area "outside the table" is clicked. In reality, 636 * the table covers the whole area but there are labels filling the top few 637 * rows. This method clears any inline editors if an area outside a textbox or 638 * label is clicked. 639 */ 640 onMousedown({ target }) { 641 const localName = target.localName; 642 643 if (localName === "input" || !this._editableFieldsEngine) { 644 return; 645 } 646 647 // Force any editor fields to hide due to XUL focus quirks. 648 this._editableFieldsEngine.blur(); 649 } 650 651 /** 652 * Make table fields editable. 653 * 654 * @param {string | Array} editableColumns 655 * An array or comma separated list of editable column names. 656 */ 657 makeFieldsEditable(editableColumns) { 658 const selectors = []; 659 660 if (typeof editableColumns === "string") { 661 editableColumns = [editableColumns]; 662 } 663 664 for (const id of editableColumns) { 665 selectors.push("#" + id + " .table-widget-cell"); 666 } 667 668 for (const [name, column] of this.columns) { 669 if (!editableColumns.includes(name)) { 670 column.column.setAttribute("readonly", ""); 671 } 672 } 673 674 if (this._editableFieldsEngine) { 675 this._editableFieldsEngine.selectors = selectors; 676 this._editableFieldsEngine.items = this.items; 677 } else { 678 this._editableFieldsEngine = new EditableFieldsEngine({ 679 root: this.tbody, 680 onTab: this.onEditorTab, 681 onTriggerEvent: "dblclick", 682 selectors, 683 items: this.items, 684 }); 685 686 this._editableFieldsEngine.on("change", this.onChange); 687 this._editableFieldsEngine.on("destroyed", this.onEditorDestroyed); 688 689 this.on(EVENTS.ROW_REMOVED, this.onRowRemoved); 690 this.on(EVENTS.TABLE_CLEARED, this._editableFieldsEngine.cancelEdit); 691 692 this.emit(EVENTS.FIELDS_EDITABLE, this._editableFieldsEngine); 693 } 694 } 695 696 destroy() { 697 this.off(EVENTS.ROW_SELECTED, this.bindSelectedRow); 698 this.off(EVENTS.ROW_REMOVED, this.onRowRemoved); 699 700 this.document.removeEventListener("keydown", this.onKeydown); 701 this.document.removeEventListener("mousedown", this.onMousedown); 702 703 if (this._editableFieldsEngine) { 704 this.off(EVENTS.TABLE_CLEARED, this._editableFieldsEngine.cancelEdit); 705 this._editableFieldsEngine.off("change", this.onChange); 706 this._editableFieldsEngine.off("destroyed", this.onEditorDestroyed); 707 this._editableFieldsEngine.destroy(); 708 this._editableFieldsEngine = null; 709 } 710 711 if (this.menupopup) { 712 this.menupopup.removeEventListener("command", this.onPopupCommand); 713 this.menupopup.remove(); 714 } 715 } 716 717 /** 718 * Sets the localization ID of the description to be shown when the table is empty. 719 * 720 * @param {string} l10nID 721 * The ID of the localization string. 722 * @param {string} learnMoreURL 723 * A URL referring to a website with further information related to 724 * the data shown in the table widget. 725 */ 726 setPlaceholder(l10nID, learnMoreURL) { 727 if (learnMoreURL) { 728 let placeholderLink = this.placeholder.firstElementChild; 729 if (!placeholderLink) { 730 placeholderLink = this.document.createElement("a"); 731 placeholderLink.setAttribute("target", "_blank"); 732 placeholderLink.setAttribute("data-l10n-name", "learn-more-link"); 733 this.placeholder.appendChild(placeholderLink); 734 } 735 placeholderLink.setAttribute("href", learnMoreURL); 736 } else { 737 // Remove link element if no learn more URL is given 738 this.placeholder.firstElementChild?.remove(); 739 } 740 741 this.l10n.setAttributes(this.placeholder, l10nID); 742 } 743 744 /** 745 * Prepares the context menu for the headers of the table columns. This 746 * context menu allows users to toggle various columns, only with an exception 747 * of the unique columns and when only two columns are visible in the table. 748 */ 749 setupHeadersContextMenu() { 750 let popupset = this.document.getElementsByTagName("popupset")[0]; 751 if (!popupset) { 752 popupset = this.document.createXULElement("popupset"); 753 this.document.documentElement.appendChild(popupset); 754 } 755 756 this.menupopup = this.document.createXULElement("menupopup"); 757 this.menupopup.id = "table-widget-column-select"; 758 this.menupopup.addEventListener("command", this.onPopupCommand); 759 popupset.appendChild(this.menupopup); 760 this.populateMenuPopup(); 761 } 762 763 /** 764 * Populates the header context menu with the names of the columns along with 765 * displaying which columns are hidden or visible. 766 * 767 * @param {Array} privateColumns=[] 768 * An array of column names that should never appear in the table. This 769 * allows us to e.g. have an invisible compound primary key for a 770 * table's rows. 771 */ 772 populateMenuPopup(privateColumns = []) { 773 if (!this.menupopup) { 774 return; 775 } 776 777 this.menupopup.replaceChildren(); 778 779 for (const column of this.columns.values()) { 780 if (privateColumns.includes(column.id)) { 781 continue; 782 } 783 784 const menuitem = this.document.createXULElement("menuitem"); 785 menuitem.setAttribute("label", column.header.getAttribute("value")); 786 menuitem.setAttribute("data-id", column.id); 787 menuitem.setAttribute("type", "checkbox"); 788 menuitem.toggleAttribute("checked", !column.hidden); 789 if (column.id == this.uniqueId) { 790 menuitem.setAttribute("disabled", "true"); 791 } 792 this.menupopup.appendChild(menuitem); 793 } 794 const checked = this.menupopup.querySelectorAll("menuitem[checked]"); 795 if (checked.length == 2) { 796 checked[checked.length - 1].setAttribute("disabled", "true"); 797 } 798 } 799 800 /** 801 * Event handler for the `command` event on the column headers context menu 802 */ 803 onPopupCommand(event) { 804 const item = event.originalTarget; 805 let checked = item.hasAttribute("checked"); 806 const id = item.getAttribute("data-id"); 807 this.emit(EVENTS.HEADER_CONTEXT_MENU, id, checked); 808 checked = this.menupopup.querySelectorAll("menuitem[checked]"); 809 const disabled = this.menupopup.querySelectorAll("menuitem[disabled]"); 810 if (checked.length == 2) { 811 checked[checked.length - 1].setAttribute("disabled", "true"); 812 } else if (disabled.length > 1) { 813 disabled[disabled.length - 1].removeAttribute("disabled"); 814 } 815 } 816 817 /** 818 * Creates the columns in the table. Without calling this method, data cannot 819 * be inserted into the table unless `initialColumns` was supplied. 820 * 821 * @param {object} columns 822 * A key value pair representing the columns of the table. Where the 823 * key represents the id of the column and the value is the displayed 824 * label in the header of the column. 825 * @param {string} sortOn 826 * The id of the column on which the table will be initially sorted on. 827 * @param {Array} hiddenColumns 828 * Ids of all the columns that are hidden by default. 829 * @param {Array} privateColumns=[] 830 * An array of column names that should never appear in the table. This 831 * allows us to e.g. have an invisible compound primary key for a 832 * table's rows. 833 */ 834 setColumns( 835 columns, 836 sortOn = this.sortedOn, 837 hiddenColumns = [], 838 privateColumns = [] 839 ) { 840 for (const column of this.columns.values()) { 841 column.destroy(); 842 } 843 844 this.columns.clear(); 845 846 if (!(sortOn in columns)) { 847 sortOn = null; 848 } 849 850 if (!(this.firstColumn in columns)) { 851 this.firstColumn = null; 852 } 853 854 if (this.firstColumn) { 855 this.columns.set( 856 this.firstColumn, 857 new Column(this, this.firstColumn, columns[this.firstColumn]) 858 ); 859 } 860 861 for (const id in columns) { 862 if (!sortOn) { 863 sortOn = id; 864 } 865 866 if (this.firstColumn && id == this.firstColumn) { 867 continue; 868 } 869 870 this.columns.set(id, new Column(this, id, columns[id])); 871 if (hiddenColumns.includes(id) || privateColumns.includes(id)) { 872 // Hide the column. 873 this.columns.get(id).toggleColumn(); 874 875 if (privateColumns.includes(id)) { 876 this.columns.get(id).private = true; 877 } 878 } 879 } 880 this.sortedOn = sortOn; 881 this.sortBy(this.sortedOn); 882 this.populateMenuPopup(privateColumns); 883 } 884 885 /** 886 * Returns true if the passed string or the row json object corresponds to the 887 * selected item in the table. 888 */ 889 isSelected(item) { 890 if (typeof item == "object") { 891 item = item[this.uniqueId]; 892 } 893 894 return this.selectedRow && item == this.selectedRow[this.uniqueId]; 895 } 896 897 /** 898 * Selects the row corresponding to the `id` json. 899 */ 900 selectRow(id) { 901 this.selectedRow = id; 902 } 903 904 /** 905 * Selects the next row. Cycles over to the first row if last row is selected 906 */ 907 selectNextRow() { 908 for (const column of this.columns.values()) { 909 column.selectNextRow(); 910 } 911 } 912 913 /** 914 * Selects the previous row. Cycles over to the last row if first row is 915 * selected. 916 */ 917 selectPreviousRow() { 918 for (const column of this.columns.values()) { 919 column.selectPreviousRow(); 920 } 921 } 922 923 /** 924 * Clears any selected row. 925 */ 926 clearSelection() { 927 this.selectedIndex = -1; 928 } 929 930 /** 931 * Adds a row into the table. 932 * 933 * @param {object} item 934 * The object from which the key-value pairs will be taken and added 935 * into the row. This object can have any arbitarary key value pairs, 936 * but only those will be used whose keys match to the ids of the 937 * columns. 938 * @param {boolean} suppressFlash 939 * true to not flash the row while inserting the row. 940 */ 941 push(item, suppressFlash) { 942 if (!this.sortedOn || !this.columns) { 943 console.error("Can't insert item without defining columns first"); 944 return; 945 } 946 947 if (this.items.has(item[this.uniqueId])) { 948 this.update(item); 949 return; 950 } 951 952 if (this.editBookmark && !this.items.has(this.editBookmark)) { 953 // Key has been updated... update bookmark. 954 this.editBookmark = item[this.uniqueId]; 955 } 956 957 const index = this.columns.get(this.sortedOn).push(item); 958 for (const [key, column] of this.columns) { 959 if (key != this.sortedOn) { 960 column.insertAt(item, index); 961 } 962 column.updateZebra(); 963 } 964 this.items.set(item[this.uniqueId], item); 965 this.tbody.removeAttribute("empty"); 966 967 if (!suppressFlash) { 968 this.emit(EVENTS.ROW_UPDATED, item[this.uniqueId]); 969 } 970 971 this.emit(EVENTS.ROW_EDIT, item[this.uniqueId]); 972 this.syncRowHeight(item[this.uniqueId]); 973 } 974 975 /** 976 * Removes the row associated with the `item` object. 977 */ 978 remove(item) { 979 if (typeof item != "object") { 980 item = this.items.get(item); 981 } 982 if (!item) { 983 return; 984 } 985 const removed = this.items.delete(item[this.uniqueId]); 986 987 if (!removed) { 988 return; 989 } 990 for (const column of this.columns.values()) { 991 column.remove(item); 992 column.updateZebra(); 993 } 994 if (this.items.size === 0) { 995 this.selectedRow = null; 996 this.tbody.setAttribute("empty", "empty"); 997 } 998 999 this.emit(EVENTS.ROW_REMOVED, item); 1000 } 1001 1002 /** 1003 * Updates the items in the row corresponding to the `item` object previously 1004 * used to insert the row using `push` method. The linking is done via the 1005 * `uniqueId` key's value. 1006 */ 1007 update(item) { 1008 const oldItem = this.items.get(item[this.uniqueId]); 1009 if (!oldItem) { 1010 return; 1011 } 1012 this.items.set(item[this.uniqueId], item); 1013 1014 let changed = false; 1015 for (const column of this.columns.values()) { 1016 if (item[column.id] != oldItem[column.id]) { 1017 column.update(item); 1018 changed = true; 1019 } 1020 } 1021 if (changed) { 1022 this.emit(EVENTS.ROW_UPDATED, item[this.uniqueId]); 1023 this.emit(EVENTS.ROW_EDIT, item[this.uniqueId]); 1024 } 1025 } 1026 1027 /** 1028 * Removes all of the rows from the table. 1029 */ 1030 clear() { 1031 this.items.clear(); 1032 for (const column of this.columns.values()) { 1033 column.clear(); 1034 } 1035 this.tbody.setAttribute("empty", "empty"); 1036 this.setPlaceholder(this.emptyText); 1037 1038 this.selectedRow = null; 1039 1040 this.emit(EVENTS.TABLE_CLEARED, this); 1041 } 1042 1043 /** 1044 * Sorts the table by a given column. 1045 * 1046 * @param {string} column 1047 * The id of the column on which the table should be sorted. 1048 */ 1049 sortBy(column) { 1050 this.emit(EVENTS.COLUMN_SORTED, column); 1051 this.sortedOn = column; 1052 1053 if (!this.items.size) { 1054 return; 1055 } 1056 1057 // First sort the column to "sort by" explicitly. 1058 const sortedItems = this.columns.get(column).sort([...this.items.values()]); 1059 1060 // Then, sort all the other columns (id !== column) only based on the 1061 // sortedItems provided by the first sort. 1062 // Each column keeps track of the fact that it is the "sort by" column or 1063 // not, so this will not shuffle the items and will just make sure each 1064 // column displays the correct value. 1065 for (const [id, col] of this.columns) { 1066 if (id !== column) { 1067 col.sort(sortedItems); 1068 } 1069 } 1070 } 1071 1072 /** 1073 * Filters the table based on a specific value 1074 * 1075 * @param {string} value: The filter value 1076 * @param {Array} ignoreProps: Props to ignore while filtering 1077 */ 1078 filterItems(value, ignoreProps = []) { 1079 if (this.filteredValue == value) { 1080 return; 1081 } 1082 if (this._editableFieldsEngine) { 1083 this._editableFieldsEngine.completeEdit(); 1084 } 1085 1086 this.filteredValue = value; 1087 if (!value) { 1088 this.emit(EVENTS.TABLE_FILTERED, []); 1089 return; 1090 } 1091 // Shouldn't be case-sensitive 1092 value = value.toLowerCase(); 1093 1094 const itemsToHide = [...this.items.keys()]; 1095 // Loop through all items and hide unmatched items 1096 for (const [id, val] of this.items) { 1097 for (const prop in val) { 1098 const column = this.columns.get(prop); 1099 if (ignoreProps.includes(prop) || column.hidden) { 1100 continue; 1101 } 1102 1103 const propValue = val[prop].toString().toLowerCase(); 1104 if (propValue.includes(value)) { 1105 itemsToHide.splice(itemsToHide.indexOf(id), 1); 1106 break; 1107 } 1108 } 1109 } 1110 this.emit(EVENTS.TABLE_FILTERED, itemsToHide); 1111 } 1112 1113 /** 1114 * Calls the afterScroll function when the user has stopped scrolling 1115 */ 1116 onScroll() { 1117 clearNamedTimeout("table-scroll"); 1118 setNamedTimeout("table-scroll", AFTER_SCROLL_DELAY, this.afterScroll); 1119 } 1120 1121 /** 1122 * Emits the "scroll-end" event when the whole table is scrolled 1123 */ 1124 afterScroll() { 1125 const maxScrollTop = this.tbody.scrollHeight - this.tbody.clientHeight; 1126 // Emit scroll-end event when 9/10 of the table is scrolled 1127 if (this.tbody.scrollTop >= 0.9 * maxScrollTop) { 1128 this.emit("scroll-end"); 1129 } 1130 } 1131 } 1132 1133 module.exports.TableWidget = TableWidget; 1134 1135 /** 1136 * A single column object in the table. 1137 */ 1138 class Column { 1139 /** 1140 * @param {TableWidget} table 1141 * The table object to which the column belongs. 1142 * @param {string} id 1143 * Id of the column. 1144 * @param {string} header 1145 * The displayed string on the column's header. 1146 */ 1147 constructor(table, id, header) { 1148 // By default cells are visible in the UI. 1149 this._private = false; 1150 1151 this.tbody = table.tbody; 1152 this.document = table.document; 1153 this.window = table.window; 1154 this.id = id; 1155 this.uniqueId = table.uniqueId; 1156 this.wrapTextInElements = table.wrapTextInElements; 1157 this.table = table; 1158 this.cells = []; 1159 this.items = {}; 1160 1161 this.highlightUpdated = table.highlightUpdated; 1162 1163 this.column = this.document.createElementNS(HTML_NS, "div"); 1164 this.column.id = id; 1165 this.column.className = "table-widget-column"; 1166 this.tbody.appendChild(this.column); 1167 1168 this.splitter = this.document.createXULElement("splitter"); 1169 this.splitter.className = "devtools-side-splitter"; 1170 this.tbody.appendChild(this.splitter); 1171 1172 this.header = this.document.createXULElement("label"); 1173 this.header.className = "devtools-toolbar table-widget-column-header"; 1174 this.header.setAttribute("value", header); 1175 this.column.appendChild(this.header); 1176 if (table.headersContextMenu) { 1177 this.header.setAttribute("context", table.headersContextMenu); 1178 } 1179 this.toggleColumn = this.toggleColumn.bind(this); 1180 this.table.on(EVENTS.HEADER_CONTEXT_MENU, this.toggleColumn); 1181 1182 this.onColumnSorted = this.onColumnSorted.bind(this); 1183 this.table.on(EVENTS.COLUMN_SORTED, this.onColumnSorted); 1184 1185 this.onRowUpdated = this.onRowUpdated.bind(this); 1186 this.table.on(EVENTS.ROW_UPDATED, this.onRowUpdated); 1187 1188 this.onTableFiltered = this.onTableFiltered.bind(this); 1189 this.table.on(EVENTS.TABLE_FILTERED, this.onTableFiltered); 1190 1191 this.onClick = this.onClick.bind(this); 1192 this.onMousedown = this.onMousedown.bind(this); 1193 this.column.addEventListener("click", this.onClick); 1194 this.column.addEventListener("mousedown", this.onMousedown); 1195 } 1196 1197 // items is a cell-id to cell-index map. It is basically a reverse map of the 1198 // this.cells object and is used to quickly reverse lookup a cell by its id 1199 // instead of looping through the cells array. This reverse map is not kept 1200 // upto date in sync with the cells array as updating it is in itself a loop 1201 // through all the cells of the columns. Thus update it on demand when it goes 1202 // out of sync with this.cells. 1203 items = null; 1204 1205 // _itemsDirty is a flag which becomes true when this.items goes out of sync 1206 // with this.cells 1207 _itemsDirty = null; 1208 1209 selectedRow = null; 1210 1211 cells = null; 1212 1213 /** 1214 * Gets whether the table is sorted on this column or not. 1215 * 0 - not sorted. 1216 * 1 - ascending order 1217 * 2 - descending order 1218 */ 1219 get sorted() { 1220 return this._sortState || 0; 1221 } 1222 1223 /** 1224 * Returns a boolean indicating whether the column is hidden. 1225 */ 1226 get hidden() { 1227 return this.column.hidden; 1228 } 1229 1230 /** 1231 * Get the private state of the column (visibility in the UI). 1232 */ 1233 get private() { 1234 return this._private; 1235 } 1236 1237 /** 1238 * Set the private state of the column (visibility in the UI). 1239 * 1240 * @param {boolean} state 1241 * Private (true or false) 1242 */ 1243 set private(state) { 1244 this._private = state; 1245 } 1246 1247 /** 1248 * Sets the sorted value 1249 */ 1250 set sorted(value) { 1251 if (!value) { 1252 this.header.removeAttribute("sorted"); 1253 } else { 1254 this.header.setAttribute( 1255 "sorted", 1256 value == 1 ? "ascending" : "descending" 1257 ); 1258 } 1259 this._sortState = value; 1260 } 1261 1262 /** 1263 * Gets the selected row in the column. 1264 */ 1265 get selectedIndex() { 1266 if (!this.selectedRow) { 1267 return -1; 1268 } 1269 return this.items[this.selectedRow]; 1270 } 1271 1272 get cellNodes() { 1273 return [...this.column.querySelectorAll(".table-widget-cell")]; 1274 } 1275 1276 get visibleCellNodes() { 1277 const editor = this.table._editableFieldsEngine; 1278 const nodes = this.cellNodes.filter(node => { 1279 // If the cell is currently being edited we should class it as visible. 1280 if (editor && editor.currentTarget === node) { 1281 return true; 1282 } 1283 return node.clientWidth !== 0; 1284 }); 1285 1286 return nodes; 1287 } 1288 1289 /** 1290 * Called when the column is sorted by. 1291 * 1292 * @param {string} column 1293 * The id of the column being sorted by. 1294 */ 1295 onColumnSorted(column) { 1296 if (column != this.id) { 1297 this.sorted = 0; 1298 return; 1299 } else if (this.sorted == 0 || this.sorted == 2) { 1300 this.sorted = 1; 1301 } else { 1302 this.sorted = 2; 1303 } 1304 this.updateZebra(); 1305 } 1306 1307 onTableFiltered(itemsToHide) { 1308 this._updateItems(); 1309 if (!this.cells) { 1310 return; 1311 } 1312 for (const cell of this.cells) { 1313 cell.hidden = false; 1314 } 1315 for (const id of itemsToHide) { 1316 this.cells[this.items[id]].hidden = true; 1317 } 1318 this.updateZebra(); 1319 } 1320 1321 /** 1322 * Called when a row is updated e.g. a cell is changed. This means that 1323 * for a new row this method will be called once for each column. If a single 1324 * cell is changed this method will be called just once. 1325 * 1326 * @param {string} event 1327 * The event name of the event. i.e. EVENTS.ROW_UPDATED 1328 * @param {string} id 1329 * The unique id of the object associated with the row. 1330 */ 1331 onRowUpdated(id) { 1332 this._updateItems(); 1333 1334 if (this.highlightUpdated && this.items[id] != null) { 1335 if (this.table.scrollIntoViewOnUpdate) { 1336 const cell = this.cells[this.items[id]]; 1337 1338 // When a new row is created this method is called once for each column 1339 // as each cell is updated. We can only scroll to cells if they are 1340 // visible. We check for visibility and once we find the first visible 1341 // cell in a row we scroll it into view and reset the 1342 // scrollIntoViewOnUpdate flag. 1343 if (cell.label.clientHeight > 0) { 1344 cell.scrollIntoView(); 1345 1346 this.table.scrollIntoViewOnUpdate = null; 1347 } 1348 } 1349 1350 if (this.table.editBookmark) { 1351 // A rows position in the table can change as the result of an edit. In 1352 // order to ensure that the correct row is highlighted after an edit we 1353 // save the uniqueId in editBookmark. Here we send the signal that the 1354 // row has been edited and that the row needs to be selected again. 1355 this.table.emit(EVENTS.ROW_SELECTED, this.table.editBookmark); 1356 this.table.editBookmark = null; 1357 } 1358 1359 this.cells[this.items[id]].flash(); 1360 } 1361 1362 this.updateZebra(); 1363 } 1364 1365 destroy() { 1366 this.table.off(EVENTS.COLUMN_SORTED, this.onColumnSorted); 1367 this.table.off(EVENTS.HEADER_CONTEXT_MENU, this.toggleColumn); 1368 this.table.off(EVENTS.ROW_UPDATED, this.onRowUpdated); 1369 this.table.off(EVENTS.TABLE_FILTERED, this.onTableFiltered); 1370 1371 this.column.removeEventListener("click", this.onClick); 1372 this.column.removeEventListener("mousedown", this.onMousedown); 1373 1374 this.splitter.remove(); 1375 this.column.remove(); 1376 this.cells = null; 1377 this.items = null; 1378 this.selectedRow = null; 1379 } 1380 1381 /** 1382 * Selects the row at the `index` index 1383 */ 1384 selectRowAt(index) { 1385 if (this.selectedRow != null) { 1386 this.cells[this.items[this.selectedRow]].classList.remove( 1387 "theme-selected" 1388 ); 1389 } 1390 1391 const cell = this.cells[index]; 1392 if (cell) { 1393 cell.classList.add("theme-selected"); 1394 this.selectedRow = cell.id; 1395 } else { 1396 this.selectedRow = null; 1397 } 1398 } 1399 1400 /** 1401 * Selects the row with the object having the `uniqueId` value as `id` 1402 */ 1403 selectRow(id) { 1404 this._updateItems(); 1405 this.selectRowAt(this.items[id]); 1406 } 1407 1408 /** 1409 * Selects the next row. Cycles to first if last row is selected. 1410 */ 1411 selectNextRow() { 1412 this._updateItems(); 1413 let index = this.items[this.selectedRow] + 1; 1414 if (index == this.cells.length) { 1415 index = 0; 1416 } 1417 this.selectRowAt(index); 1418 } 1419 1420 /** 1421 * Selects the previous row. Cycles to last if first row is selected. 1422 */ 1423 selectPreviousRow() { 1424 this._updateItems(); 1425 let index = this.items[this.selectedRow] - 1; 1426 if (index == -1) { 1427 index = this.cells.length - 1; 1428 } 1429 this.selectRowAt(index); 1430 } 1431 1432 /** 1433 * Pushes the `item` object into the column. If this column is sorted on, 1434 * then inserts the object at the right position based on the column's id 1435 * key's value. 1436 * 1437 * @returns {number} 1438 * The index of the currently pushed item. 1439 */ 1440 push(item) { 1441 const value = item[this.id]; 1442 1443 if (this.sorted) { 1444 let index; 1445 if (this.sorted == 1) { 1446 index = this.cells.findIndex(element => { 1447 return ( 1448 naturalSortCaseInsensitive( 1449 value, 1450 element.value, 1451 standardSessionString 1452 ) === -1 1453 ); 1454 }); 1455 } else { 1456 index = this.cells.findIndex(element => { 1457 return ( 1458 naturalSortCaseInsensitive( 1459 value, 1460 element.value, 1461 standardSessionString 1462 ) === 1 1463 ); 1464 }); 1465 } 1466 index = index >= 0 ? index : this.cells.length; 1467 if (index < this.cells.length) { 1468 this._itemsDirty = true; 1469 } 1470 this.items[item[this.uniqueId]] = index; 1471 this.cells.splice(index, 0, new Cell(this, item, this.cells[index])); 1472 return index; 1473 } 1474 1475 this.items[item[this.uniqueId]] = this.cells.length; 1476 return this.cells.push(new Cell(this, item)) - 1; 1477 } 1478 1479 /** 1480 * Inserts the `item` object at the given `index` index in the table. 1481 */ 1482 insertAt(item, index) { 1483 if (index < this.cells.length) { 1484 this._itemsDirty = true; 1485 } 1486 this.items[item[this.uniqueId]] = index; 1487 this.cells.splice(index, 0, new Cell(this, item, this.cells[index])); 1488 this.updateZebra(); 1489 } 1490 1491 /** 1492 * Event handler for the command event coming from the header context menu. 1493 * Toggles the column if it was requested by the user. 1494 * When called explicitly without parameters, it toggles the corresponding 1495 * column. 1496 * 1497 * @param {string} event 1498 * The name of the event. i.e. EVENTS.HEADER_CONTEXT_MENU 1499 * @param {string} id 1500 * Id of the column to be toggled 1501 * @param {string} checked 1502 * true if the column is visible 1503 */ 1504 toggleColumn(id, checked) { 1505 if (!arguments.length) { 1506 // Act like a toggling method when called with no params 1507 id = this.id; 1508 checked = this.column.hidden; 1509 } 1510 if (id != this.id) { 1511 return; 1512 } 1513 if (checked) { 1514 this.column.hidden = false; 1515 this.tbody.insertBefore(this.splitter, this.column.nextSibling); 1516 } else { 1517 this.column.hidden = true; 1518 this.splitter.remove(); 1519 } 1520 } 1521 1522 /** 1523 * Removes the corresponding item from the column and hide the last visible 1524 * splitter with CSS, so we do not add splitter elements for hidden columns. 1525 */ 1526 remove(item) { 1527 this._updateItems(); 1528 const index = this.items[item[this.uniqueId]]; 1529 if (index == null) { 1530 return; 1531 } 1532 1533 if (index < this.cells.length) { 1534 this._itemsDirty = true; 1535 } 1536 this.cells[index].destroy(); 1537 this.cells.splice(index, 1); 1538 delete this.items[item[this.uniqueId]]; 1539 } 1540 1541 /** 1542 * Updates the corresponding item from the column. 1543 */ 1544 update(item) { 1545 this._updateItems(); 1546 1547 const index = this.items[item[this.uniqueId]]; 1548 if (index == null) { 1549 return; 1550 } 1551 1552 this.cells[index].value = item[this.id]; 1553 } 1554 1555 /** 1556 * Updates the `this.items` cell-id vs cell-index map to be in sync with 1557 * `this.cells`. 1558 */ 1559 _updateItems() { 1560 if (!this._itemsDirty) { 1561 return; 1562 } 1563 for (let i = 0; i < this.cells.length; i++) { 1564 this.items[this.cells[i].id] = i; 1565 } 1566 this._itemsDirty = false; 1567 } 1568 1569 /** 1570 * Clears the current column 1571 */ 1572 clear() { 1573 this.cells = []; 1574 this.items = {}; 1575 this._itemsDirty = false; 1576 while (this.header.nextSibling) { 1577 this.header.nextSibling.remove(); 1578 } 1579 } 1580 1581 /** 1582 * Sorts the given items and returns the sorted list if the table was sorted 1583 * by this column. 1584 */ 1585 sort(items) { 1586 // Only sort the array if we are sorting based on this column 1587 if (this.sorted == 1) { 1588 items.sort((a, b) => { 1589 const val1 = Node.isInstance(a[this.id]) 1590 ? a[this.id].textContent 1591 : a[this.id]; 1592 const val2 = Node.isInstance(b[this.id]) 1593 ? b[this.id].textContent 1594 : b[this.id]; 1595 return naturalSortCaseInsensitive(val1, val2, standardSessionString); 1596 }); 1597 } else if (this.sorted > 1) { 1598 items.sort((a, b) => { 1599 const val1 = Node.isInstance(a[this.id]) 1600 ? a[this.id].textContent 1601 : a[this.id]; 1602 const val2 = Node.isInstance(b[this.id]) 1603 ? b[this.id].textContent 1604 : b[this.id]; 1605 return naturalSortCaseInsensitive(val2, val1, standardSessionString); 1606 }); 1607 } 1608 1609 if (this.selectedRow) { 1610 this.cells[this.items[this.selectedRow]].classList.remove( 1611 "theme-selected" 1612 ); 1613 } 1614 this.items = {}; 1615 // Otherwise, just use the sorted array passed to update the cells value. 1616 for (const [i, item] of items.entries()) { 1617 // See Bug 1706679 (Intermittent) 1618 // Sometimes we would reach the situation in which we were trying to sort 1619 // and item that was no longer available in the TableWidget. 1620 // We should find exactly what is triggering it. 1621 if (!this.cells[i]) { 1622 continue; 1623 } 1624 this.items[item[this.uniqueId]] = i; 1625 this.cells[i].value = item[this.id]; 1626 this.cells[i].id = item[this.uniqueId]; 1627 } 1628 if (this.selectedRow) { 1629 this.cells[this.items[this.selectedRow]].classList.add("theme-selected"); 1630 } 1631 this._itemsDirty = false; 1632 this.updateZebra(); 1633 return items; 1634 } 1635 1636 updateZebra() { 1637 this._updateItems(); 1638 let i = 0; 1639 for (const cell of this.cells) { 1640 if (!cell.hidden) { 1641 i++; 1642 } 1643 1644 const even = !(i % 2); 1645 cell.classList.toggle("even", even); 1646 } 1647 } 1648 1649 /** 1650 * Click event handler for the column. Used to detect click on header for 1651 * sorting. 1652 */ 1653 onClick(event) { 1654 const target = event.originalTarget; 1655 1656 if (target.nodeType !== target.ELEMENT_NODE || target == this.column) { 1657 return; 1658 } 1659 1660 if (event.button == 0 && target == this.header) { 1661 this.table.sortBy(this.id); 1662 1663 // Get all cell heights in the clicked column 1664 const cellHeights = [ 1665 ...this.table.tbody.querySelectorAll( 1666 `.table-widget-column#${this.id} .table-widget-cell` 1667 ), 1668 ].map(cell => cell.clientHeight); 1669 1670 // Sort heights from smallest to largest 1671 cellHeights.sort((a, b) => a - b); 1672 1673 // Check for height mismatches 1674 for (let i = 1; i < cellHeights.length; i++) { 1675 if (cellHeights[i] !== cellHeights[i - 1]) { 1676 // Sync row heights only if necessary 1677 for (const rowId in this.items) { 1678 this.table.syncRowHeight(rowId); 1679 } 1680 return; // Exit early once we know we need to sync 1681 } 1682 } 1683 } 1684 } 1685 1686 /** 1687 * Mousedown event handler for the column. Used to select rows. 1688 */ 1689 onMousedown(event) { 1690 const target = event.originalTarget; 1691 1692 if ( 1693 target.nodeType !== target.ELEMENT_NODE || 1694 target == this.column || 1695 target == this.header 1696 ) { 1697 return; 1698 } 1699 if (event.button == 0) { 1700 const closest = target.closest("[data-id]"); 1701 if (!closest) { 1702 return; 1703 } 1704 1705 const dataid = closest.getAttribute("data-id"); 1706 this.table.emit(EVENTS.ROW_SELECTED, dataid); 1707 } 1708 } 1709 } 1710 1711 /** 1712 * A single cell in a column 1713 */ 1714 class Cell { 1715 /** 1716 * @param {Column} column 1717 * The column object to which the cell belongs. 1718 * @param {object} item 1719 * The object representing the row. It contains a key value pair 1720 * representing the column id and its associated value. The value 1721 * can be a DOMNode that is appended or a string value. 1722 * @param {Cell} nextCell 1723 * The cell object which is next to this cell. null if this cell is last 1724 * cell of the column 1725 */ 1726 constructor(column, item, nextCell) { 1727 const document = column.document; 1728 1729 this.wrapTextInElements = column.wrapTextInElements; 1730 this.label = document.createXULElement("label"); 1731 this.label.setAttribute("crop", "end"); 1732 this.label.className = "table-widget-cell"; 1733 1734 if (nextCell) { 1735 column.column.insertBefore(this.label, nextCell.label); 1736 } else { 1737 column.column.appendChild(this.label); 1738 } 1739 1740 if (column.table.cellContextMenuId) { 1741 this.label.setAttribute("context", column.table.cellContextMenuId); 1742 this.label.addEventListener("contextmenu", () => { 1743 // Make the ID of the clicked cell available as a property on the table. 1744 // It's then available for the popupshowing or command handler. 1745 column.table.contextMenuRowId = this.id; 1746 }); 1747 } 1748 1749 this.value = item[column.id]; 1750 this.id = item[column.uniqueId]; 1751 } 1752 1753 set id(value) { 1754 this._id = value; 1755 this.label.setAttribute("data-id", value); 1756 } 1757 1758 get id() { 1759 return this._id; 1760 } 1761 1762 get hidden() { 1763 return this.label.hidden; 1764 } 1765 1766 set hidden(value) { 1767 this.label.hidden = value; 1768 } 1769 1770 set value(value) { 1771 this._value = value; 1772 if (value == null) { 1773 this.label.setAttribute("value", ""); 1774 return; 1775 } 1776 1777 if (this.wrapTextInElements && !Node.isInstance(value)) { 1778 const span = this.label.ownerDocument.createElementNS(HTML_NS, "span"); 1779 span.textContent = value; 1780 value = span; 1781 } 1782 1783 if (Node.isInstance(value)) { 1784 this.label.removeAttribute("value"); 1785 this.label.replaceChildren(value); 1786 } else { 1787 this.label.setAttribute("value", value + ""); 1788 } 1789 } 1790 1791 get value() { 1792 return this._value; 1793 } 1794 1795 get classList() { 1796 return this.label.classList; 1797 } 1798 1799 /** 1800 * Flashes the cell for a brief time. This when done for with cells in all 1801 * columns, makes it look like the row is being highlighted/flashed. 1802 */ 1803 flash() { 1804 if (!this.label.parentNode) { 1805 return; 1806 } 1807 this.label.classList.remove("flash-out"); 1808 // Cause a reflow so that the animation retriggers on adding back the class 1809 let a = this.label.parentNode.offsetWidth; // eslint-disable-line 1810 const onAnimEnd = () => { 1811 this.label.classList.remove("flash-out"); 1812 this.label.removeEventListener("animationend", onAnimEnd); 1813 }; 1814 this.label.addEventListener("animationend", onAnimEnd); 1815 this.label.classList.add("flash-out"); 1816 } 1817 1818 focus() { 1819 this.label.focus(); 1820 } 1821 1822 scrollIntoView() { 1823 this.label.scrollIntoView(false); 1824 } 1825 1826 destroy() { 1827 this.label.remove(); 1828 this.label = null; 1829 } 1830 } 1831 1832 /** 1833 * Simple widget to make nodes matching a CSS selector editable. 1834 */ 1835 class EditableFieldsEngine extends EventEmitter { 1836 /** 1837 * @param {object} options 1838 * An object with the following format: 1839 * { 1840 * // The node that will act as a container for the editor e.g. a 1841 * // div or table. 1842 * root: someNode, 1843 * 1844 * // The onTab event to be handled by the caller. 1845 * onTab: function(event) { ... } 1846 * 1847 * // Optional event used to trigger the editor. By default this is 1848 * // dblclick. 1849 * onTriggerEvent: "dblclick", 1850 * 1851 * // Array or comma separated string of CSS Selectors matching 1852 * // elements that are to be made editable. 1853 * selectors: [ 1854 * "#name .table-widget-cell", 1855 * "#value .table-widget-cell" 1856 * ] 1857 * } 1858 */ 1859 constructor(options) { 1860 super(); 1861 1862 if (!Array.isArray(options.selectors)) { 1863 options.selectors = [options.selectors]; 1864 } 1865 1866 this.root = options.root; 1867 this.selectors = options.selectors; 1868 this.onTab = options.onTab; 1869 this.onTriggerEvent = options.onTriggerEvent || "dblclick"; 1870 this.items = options.items; 1871 1872 this.edit = this.edit.bind(this); 1873 this.cancelEdit = this.cancelEdit.bind(this); 1874 this.destroy = this.destroy.bind(this); 1875 1876 this.onTrigger = this.onTrigger.bind(this); 1877 this.root.addEventListener(this.onTriggerEvent, this.onTrigger); 1878 } 1879 1880 INPUT_ID = "inlineEditor"; 1881 1882 get changePending() { 1883 return this.isEditing && this.textbox.value !== this.currentValue; 1884 } 1885 1886 get isEditing() { 1887 return this.root && !this.textbox.hidden; 1888 } 1889 1890 get textbox() { 1891 if (!this._textbox) { 1892 const doc = this.root.ownerDocument; 1893 this._textbox = doc.createElementNS(HTML_NS, "input"); 1894 this._textbox.id = this.INPUT_ID; 1895 1896 this.onKeydown = this.onKeydown.bind(this); 1897 this._textbox.addEventListener("keydown", this.onKeydown); 1898 1899 this.completeEdit = this.completeEdit.bind(this); 1900 doc.addEventListener("blur", this.completeEdit); 1901 } 1902 1903 return this._textbox; 1904 } 1905 1906 /** 1907 * Called when a trigger event is detected (default is dblclick). 1908 * 1909 * @param {EventTarget} target 1910 * Calling event's target. 1911 */ 1912 onTrigger({ target }) { 1913 this.edit(target); 1914 } 1915 1916 /** 1917 * Handle keydowns when in edit mode: 1918 * - <escape> revert the value and close the textbox. 1919 * - <return> apply the value and close the textbox. 1920 * - <tab> Handled by the consumer's `onTab` callback. 1921 * - <shift><tab> Handled by the consumer's `onTab` callback. 1922 * 1923 * @param {Event} event 1924 * The calling event. 1925 */ 1926 onKeydown(event) { 1927 if (!this.textbox) { 1928 return; 1929 } 1930 1931 switch (event.keyCode) { 1932 case KeyCodes.DOM_VK_ESCAPE: 1933 this.cancelEdit(); 1934 event.preventDefault(); 1935 break; 1936 case KeyCodes.DOM_VK_RETURN: 1937 this.completeEdit(); 1938 break; 1939 case KeyCodes.DOM_VK_TAB: 1940 if (this.onTab) { 1941 this.onTab(event); 1942 } 1943 break; 1944 } 1945 } 1946 1947 /** 1948 * Overlay the target node with an edit field. 1949 * 1950 * @param {Node} target 1951 * Dom node to be edited. 1952 */ 1953 edit(target) { 1954 if (!target) { 1955 return; 1956 } 1957 1958 // Some item names and values are not parsable by the client or server so should not be 1959 // editable. 1960 const name = target.getAttribute("data-id"); 1961 const item = this.items.get(name); 1962 if ("isValueEditable" in item && !item.isValueEditable) { 1963 return; 1964 } 1965 1966 target.scrollIntoView(false); 1967 target.focus(); 1968 1969 if (!target.matches(this.selectors.join(","))) { 1970 return; 1971 } 1972 1973 // If we are actively editing something complete the edit first. 1974 if (this.isEditing) { 1975 this.completeEdit(); 1976 } 1977 1978 this.copyStyles(target, this.textbox); 1979 1980 target.parentNode.insertBefore(this.textbox, target); 1981 this.currentTarget = target; 1982 this.textbox.value = this.currentValue = target.value; 1983 target.hidden = true; 1984 this.textbox.hidden = false; 1985 1986 this.textbox.focus(); 1987 this.textbox.select(); 1988 } 1989 1990 completeEdit() { 1991 if (!this.isEditing) { 1992 return; 1993 } 1994 1995 const oldValue = this.currentValue; 1996 const newValue = this.textbox.value; 1997 const changed = oldValue !== newValue; 1998 1999 this.textbox.hidden = true; 2000 2001 if (!this.currentTarget) { 2002 return; 2003 } 2004 2005 this.currentTarget.hidden = false; 2006 if (changed) { 2007 this.currentTarget.value = newValue; 2008 2009 const data = { 2010 change: { 2011 field: this.currentTarget, 2012 oldValue, 2013 newValue, 2014 }, 2015 }; 2016 2017 this.emit("change", data); 2018 } 2019 } 2020 2021 /** 2022 * Cancel an edit. 2023 */ 2024 cancelEdit() { 2025 if (!this.isEditing) { 2026 return; 2027 } 2028 if (this.currentTarget) { 2029 this.currentTarget.hidden = false; 2030 } 2031 2032 this.textbox.hidden = true; 2033 } 2034 2035 /** 2036 * Stop edit mode and apply changes. 2037 */ 2038 blur() { 2039 if (this.isEditing) { 2040 this.completeEdit(); 2041 } 2042 } 2043 2044 /** 2045 * Copies various styles from one node to another. 2046 * 2047 * @param {Node} source 2048 * The node to copy styles from. 2049 * @param {Node} destination [description] 2050 * The node to copy styles to. 2051 */ 2052 copyStyles(source, destination) { 2053 const style = source.ownerDocument.defaultView.getComputedStyle(source); 2054 const props = [ 2055 "borderTopWidth", 2056 "borderRightWidth", 2057 "borderBottomWidth", 2058 "borderLeftWidth", 2059 "fontFamily", 2060 "fontSize", 2061 "fontWeight", 2062 "height", 2063 "marginTop", 2064 "marginRight", 2065 "marginBottom", 2066 "marginLeft", 2067 "marginInlineStart", 2068 "marginInlineEnd", 2069 ]; 2070 2071 for (const prop of props) { 2072 destination.style[prop] = style[prop]; 2073 } 2074 2075 // We need to set the label width to 100% to work around a XUL flex bug. 2076 destination.style.width = "100%"; 2077 } 2078 2079 /** 2080 * Destroys all editors in the current document. 2081 */ 2082 destroy() { 2083 if (this.textbox) { 2084 this.textbox.removeEventListener("keydown", this.onKeydown); 2085 this.textbox.remove(); 2086 } 2087 2088 if (this.root) { 2089 this.root.removeEventListener(this.onTriggerEvent, this.onTrigger); 2090 this.root.ownerDocument.removeEventListener("blur", this.completeEdit); 2091 } 2092 2093 this._textbox = this.root = this.selectors = this.onTab = null; 2094 this.currentTarget = this.currentValue = null; 2095 2096 this.emit("destroyed"); 2097 } 2098 }