tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 }