/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; const EventEmitter = require("resource://devtools/shared/event-emitter.js"); loader.lazyRequireGetter( this, ["clearNamedTimeout", "setNamedTimeout"], "resource://devtools/client/shared/widgets/view-helpers.js", true ); loader.lazyRequireGetter( this, "naturalSortCaseInsensitive", "resource://devtools/shared/natural-sort.js", true ); loader.lazyGetter(this, "standardSessionString", () => { const l10n = new Localization(["devtools/client/storage.ftl"], true); return l10n.formatValueSync("storage-expires-session"); }); const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js"); const HTML_NS = "http://www.w3.org/1999/xhtml"; const AFTER_SCROLL_DELAY = 100; // Different types of events emitted by the Various components of the // TableWidget. const EVENTS = { CELL_EDIT: "cell-edit", COLUMN_SORTED: "column-sorted", COLUMN_TOGGLED: "column-toggled", FIELDS_EDITABLE: "fields-editable", HEADER_CONTEXT_MENU: "header-context-menu", ROW_EDIT: "row-edit", ROW_CONTEXT_MENU: "row-context-menu", ROW_REMOVED: "row-removed", ROW_SELECTED: "row-selected", ROW_UPDATED: "row-updated", TABLE_CLEARED: "table-cleared", TABLE_FILTERED: "table-filtered", SCROLL_END: "scroll-end", }; Object.defineProperty(this, "EVENTS", { value: EVENTS, enumerable: true, writable: false, }); /** * A table widget with various features like resizble/toggleable columns, * sorting, keyboard navigation etc. * * @param {Node} node * The container element for the table widget. * @param {object} options * - initialColumns: map of key vs display name for initial columns of * the table. See @setupColumns for more info. * - uniqueId: the column which will be the unique identifier of each * entry in the table. Default: name. * - wrapTextInElements: Don't ever use 'value' attribute on labels. * Default: false. * - emptyText: Localization ID for the text to display when there are * no entries in the table to display. * - highlightUpdated: true to highlight the changed/added row. * - removableColumns: Whether columns are removeable. If set to false, * the context menu in the headers will not appear. * - firstColumn: key of the first column that should appear. * - cellContextMenuId: ID of a element to be set as a * context menu of every cell. */ function TableWidget(node, options = {}) { EventEmitter.decorate(this); this.document = node.ownerDocument; this.window = this.document.defaultView; this._parent = node; const { initialColumns, emptyText, uniqueId, highlightUpdated, removableColumns, firstColumn, wrapTextInElements, cellContextMenuId, l10n, } = options; this.emptyText = emptyText || ""; this.uniqueId = uniqueId || "name"; this.wrapTextInElements = wrapTextInElements || false; this.firstColumn = firstColumn || ""; this.highlightUpdated = highlightUpdated || false; this.removableColumns = removableColumns !== false; this.cellContextMenuId = cellContextMenuId; this.l10n = l10n; this.tbody = this.document.createXULElement("hbox"); this.tbody.className = "table-widget-body theme-body"; this.tbody.setAttribute("flex", "1"); this.tbody.setAttribute("tabindex", "0"); this._parent.appendChild(this.tbody); this.afterScroll = this.afterScroll.bind(this); this.tbody.addEventListener("scroll", this.onScroll.bind(this)); // Prepare placeholder this.placeholder = this.document.createElement("div"); this.placeholder.className = "plain table-widget-empty-text"; this._parent.appendChild(this.placeholder); this.setPlaceholder(this.emptyText); this.items = new Map(); this.columns = new Map(); // Setup the column headers context menu to allow users to hide columns at // will. if (this.removableColumns) { this.onPopupCommand = this.onPopupCommand.bind(this); this.setupHeadersContextMenu(); } if (initialColumns) { this.setColumns(initialColumns, uniqueId); } this.bindSelectedRow = id => { this.selectedRow = id; }; this.on(EVENTS.ROW_SELECTED, this.bindSelectedRow); this.onChange = this.onChange.bind(this); this.onEditorDestroyed = this.onEditorDestroyed.bind(this); this.onEditorTab = this.onEditorTab.bind(this); this.onKeydown = this.onKeydown.bind(this); this.onMousedown = this.onMousedown.bind(this); this.onRowRemoved = this.onRowRemoved.bind(this); this.document.addEventListener("keydown", this.onKeydown); this.document.addEventListener("mousedown", this.onMousedown); } TableWidget.prototype = { items: null, editBookmark: null, scrollIntoViewOnUpdate: null, /** * Return true if the table body has a scrollbar. */ get hasScrollbar() { return this.tbody.scrollHeight > this.tbody.clientHeight; }, /** * Getter for the headers context menu popup id. */ get headersContextMenu() { if (this.menupopup) { return this.menupopup.id; } return null; }, /** * Select the row corresponding to the json object `id` */ set selectedRow(id) { for (const column of this.columns.values()) { if (id || id === "") { column.selectRow(id[this.uniqueId] || id); } else { column.selectedRow = null; column.selectRow(null); } } }, /** * Is a row currently selected? * * @return {Boolean} * true or false. */ get hasSelectedRow() { return ( this.columns.get(this.uniqueId) && this.columns.get(this.uniqueId).selectedRow ); }, /** * Returns the json object corresponding to the selected row. */ get selectedRow() { return this.items.get(this.columns.get(this.uniqueId).selectedRow); }, /** * Selects the row at index `index`. */ set selectedIndex(index) { for (const column of this.columns.values()) { column.selectRowAt(index); } }, /** * Returns the index of the selected row. */ get selectedIndex() { return this.columns.get(this.uniqueId).selectedIndex; }, /** * Returns the index of the selected row disregarding hidden rows. */ get visibleSelectedIndex() { const column = this.firstVisibleColumn; const cells = column.visibleCellNodes; for (let i = 0; i < cells.length; i++) { if (cells[i].classList.contains("theme-selected")) { return i; } } return -1; }, /** * Returns the first visible column. */ get firstVisibleColumn() { for (const column of this.columns.values()) { if (column._private) { continue; } if (column.column.clientHeight > 0) { return column; } } return null; }, /** * returns all editable columns. */ get editableColumns() { const filter = columns => { columns = [...columns].filter(col => { if (col.clientWidth === 0) { return false; } const cell = col.querySelector(".table-widget-cell"); for (const selector of this._editableFieldsEngine.selectors) { if (cell.matches(selector)) { return true; } } return false; }); return columns; }; const columns = this._parent.querySelectorAll(".table-widget-column"); return filter(columns); }, /** * Emit all cell edit events. */ onChange(data) { const changedField = data.change.field; const colName = changedField.parentNode.id; const column = this.columns.get(colName); const uniqueId = column.table.uniqueId; const itemIndex = column.cellNodes.indexOf(changedField); const items = {}; for (const [name, col] of this.columns) { items[name] = col.cellNodes[itemIndex].value; } const change = { host: this.host, key: uniqueId, field: colName, oldValue: data.change.oldValue, newValue: data.change.newValue, items, }; // A rows position in the table can change as the result of an edit. In // order to ensure that the correct row is highlighted after an edit we // save the uniqueId in editBookmark. this.editBookmark = colName === uniqueId ? change.newValue : items[uniqueId]; this.emit(EVENTS.CELL_EDIT, change); }, onEditorDestroyed() { this._editableFieldsEngine = null; }, /** * Called by the inplace editor when Tab / Shift-Tab is pressed in edit-mode. * Because tables are live any row, column, cell or table can be added, * deleted or moved by deleting and adding e.g. a row again. * * This presents various challenges when navigating via the keyboard so please * keep this in mind whenever editing this method. * * @param {Event} event * Keydown event */ onEditorTab(event) { const textbox = event.target; const editor = this._editableFieldsEngine; if (textbox.id !== editor.INPUT_ID) { return; } const column = textbox.parentNode; // Changing any value can change the position of the row depending on which // column it is currently sorted on. In addition to this, the table cell may // have been edited and had to be recreated when the user has pressed tab or // shift+tab. Both of these situations require us to recover our target, // select the appropriate row and move the textbox on to the next cell. if (editor.changePending) { // We need to apply a change, which can mean that the position of cells // within the table can change. Because of this we need to wait for // EVENTS.ROW_EDIT and then move the textbox. this.once(EVENTS.ROW_EDIT, uniqueId => { let columnObj; const cols = this.editableColumns; let rowIndex = this.visibleSelectedIndex; const colIndex = cols.indexOf(column); let newIndex; // If the row has been deleted we should bail out. if (!uniqueId) { return; } // Find the column we need to move to. if (event.shiftKey) { // Navigate backwards on shift tab. if (colIndex === 0) { if (rowIndex === 0) { return; } newIndex = cols.length - 1; } else { newIndex = colIndex - 1; } } else if (colIndex === cols.length - 1) { const id = cols[0].id; columnObj = this.columns.get(id); const maxRowIndex = columnObj.visibleCellNodes.length - 1; if (rowIndex === maxRowIndex) { return; } newIndex = 0; } else { newIndex = colIndex + 1; } const newcol = cols[newIndex]; columnObj = this.columns.get(newcol.id); // Select the correct row even if it has moved due to sorting. const dataId = editor.currentTarget.getAttribute("data-id"); if (this.items.get(dataId)) { this.emit(EVENTS.ROW_SELECTED, dataId); } else { this.emit(EVENTS.ROW_SELECTED, uniqueId); } // EVENTS.ROW_SELECTED may have changed the selected row so let's save // the result in rowIndex. rowIndex = this.visibleSelectedIndex; // Edit the appropriate cell. const cells = columnObj.visibleCellNodes; const cell = cells[rowIndex]; editor.edit(cell); // Remove flash-out class... it won't have been auto-removed because the // cell was hidden for editing. cell.classList.remove("flash-out"); }); } // Begin cell edit. We always do this so that we can begin editing even in // the case that the previous edit will cause the row to move. const cell = this.getEditedCellOnTab(event, column); editor.edit(cell); // Prevent default input tabbing behaviour event.preventDefault(); }, /** * Get the cell that will be edited next on tab / shift tab and highlight the * appropriate row. Edits etc. are not taken into account. * * This is used to tab from one field to another without editing and makes the * editor much more responsive. * * @param {Event} event * Keydown event */ getEditedCellOnTab(event, column) { let cell = null; const cols = this.editableColumns; const rowIndex = this.visibleSelectedIndex; const colIndex = cols.indexOf(column); const maxCol = cols.length - 1; const maxRow = this.columns.get(column.id).visibleCellNodes.length - 1; if (event.shiftKey) { // Navigate backwards on shift tab. if (colIndex === 0) { if (rowIndex === 0) { this._editableFieldsEngine.completeEdit(); return null; } column = cols[cols.length - 1]; const cells = this.columns.get(column.id).visibleCellNodes; cell = cells[rowIndex - 1]; const rowId = cell.getAttribute("data-id"); this.emit(EVENTS.ROW_SELECTED, rowId); } else { column = cols[colIndex - 1]; const cells = this.columns.get(column.id).visibleCellNodes; cell = cells[rowIndex]; } } else if (colIndex === maxCol) { // If in the rightmost column on the last row stop editing. if (rowIndex === maxRow) { this._editableFieldsEngine.completeEdit(); return null; } // If in the rightmost column of a row then move to the first column of // the next row. column = cols[0]; const cells = this.columns.get(column.id).visibleCellNodes; cell = cells[rowIndex + 1]; const rowId = cell.getAttribute("data-id"); this.emit(EVENTS.ROW_SELECTED, rowId); } else { // Navigate forwards on tab. column = cols[colIndex + 1]; const cells = this.columns.get(column.id).visibleCellNodes; cell = cells[rowIndex]; } return cell; }, /** * Reset the editable fields engine if the currently edited row is removed. * * @param {String} event * The event name "event-removed." * @param {Object} row * The values from the removed row. */ onRowRemoved(row) { if (!this._editableFieldsEngine || !this._editableFieldsEngine.isEditing) { return; } const removedKey = row[this.uniqueId]; const column = this.columns.get(this.uniqueId); if (removedKey in column.items) { return; } // The target is lost so we need to hide the remove the textbox from the DOM // and reset the target nodes. this.onEditorTargetLost(); }, /** * Cancel an edit because the edit target has been lost. */ onEditorTargetLost() { const editor = this._editableFieldsEngine; if (!editor || !editor.isEditing) { return; } editor.cancelEdit(); }, /** * Keydown event handler for the table. Used for keyboard navigation amongst * rows. */ onKeydown(event) { // If we are in edit mode bail out. if (this._editableFieldsEngine && this._editableFieldsEngine.isEditing) { return; } // We need to get the first *visible* selected cell. Some columns are hidden // e.g. because they contain a unique compound key for cookies that is never // displayed in the UI. To do this we get all selected cells and filter out // any that are hidden. const selectedCells = [ ...this.tbody.querySelectorAll(".theme-selected"), ].filter(cell => cell.clientWidth > 0); // Select the first visible selected cell. const selectedCell = selectedCells[0]; if (!selectedCell) { return; } let colName; let column; let visibleCells; let index; let cell; switch (event.keyCode) { case KeyCodes.DOM_VK_UP: event.preventDefault(); colName = selectedCell.parentNode.id; column = this.columns.get(colName); visibleCells = column.visibleCellNodes; index = visibleCells.indexOf(selectedCell); if (index > 0) { index--; } else { index = visibleCells.length - 1; } cell = visibleCells[index]; this.emit(EVENTS.ROW_SELECTED, cell.getAttribute("data-id")); break; case KeyCodes.DOM_VK_DOWN: event.preventDefault(); colName = selectedCell.parentNode.id; column = this.columns.get(colName); visibleCells = column.visibleCellNodes; index = visibleCells.indexOf(selectedCell); if (index === visibleCells.length - 1) { index = 0; } else { index++; } cell = visibleCells[index]; this.emit(EVENTS.ROW_SELECTED, cell.getAttribute("data-id")); break; } }, /** * Close any editors if the area "outside the table" is clicked. In reality, * the table covers the whole area but there are labels filling the top few * rows. This method clears any inline editors if an area outside a textbox or * label is clicked. */ onMousedown({ target }) { const localName = target.localName; if (localName === "input" || !this._editableFieldsEngine) { return; } // Force any editor fields to hide due to XUL focus quirks. this._editableFieldsEngine.blur(); }, /** * Make table fields editable. * * @param {String|Array} editableColumns * An array or comma separated list of editable column names. */ makeFieldsEditable(editableColumns) { const selectors = []; if (typeof editableColumns === "string") { editableColumns = [editableColumns]; } for (const id of editableColumns) { selectors.push("#" + id + " .table-widget-cell"); } for (const [name, column] of this.columns) { if (!editableColumns.includes(name)) { column.column.setAttribute("readonly", ""); } } if (this._editableFieldsEngine) { this._editableFieldsEngine.selectors = selectors; this._editableFieldsEngine.items = this.items; } else { this._editableFieldsEngine = new EditableFieldsEngine({ root: this.tbody, onTab: this.onEditorTab, onTriggerEvent: "dblclick", selectors, items: this.items, }); this._editableFieldsEngine.on("change", this.onChange); this._editableFieldsEngine.on("destroyed", this.onEditorDestroyed); this.on(EVENTS.ROW_REMOVED, this.onRowRemoved); this.on(EVENTS.TABLE_CLEARED, this._editableFieldsEngine.cancelEdit); this.emit(EVENTS.FIELDS_EDITABLE, this._editableFieldsEngine); } }, destroy() { this.off(EVENTS.ROW_SELECTED, this.bindSelectedRow); this.off(EVENTS.ROW_REMOVED, this.onRowRemoved); this.document.removeEventListener("keydown", this.onKeydown); this.document.removeEventListener("mousedown", this.onMousedown); if (this._editableFieldsEngine) { this.off(EVENTS.TABLE_CLEARED, this._editableFieldsEngine.cancelEdit); this._editableFieldsEngine.off("change", this.onChange); this._editableFieldsEngine.off("destroyed", this.onEditorDestroyed); this._editableFieldsEngine.destroy(); this._editableFieldsEngine = null; } if (this.menupopup) { this.menupopup.removeEventListener("command", this.onPopupCommand); this.menupopup.remove(); } }, /** * Sets the localization ID of the description to be shown when the table is empty. * * @param {String} l10nID * The ID of the localization string. * @param {String} learnMoreURL * A URL referring to a website with further information related to * the data shown in the table widget. */ setPlaceholder(l10nID, learnMoreURL) { if (learnMoreURL) { let placeholderLink = this.placeholder.firstElementChild; if (!placeholderLink) { placeholderLink = this.document.createElement("a"); placeholderLink.setAttribute("target", "_blank"); placeholderLink.setAttribute("data-l10n-name", "learn-more-link"); this.placeholder.appendChild(placeholderLink); } placeholderLink.setAttribute("href", learnMoreURL); } else { // Remove link element if no learn more URL is given this.placeholder.firstElementChild?.remove(); } this.l10n.setAttributes(this.placeholder, l10nID); }, /** * Prepares the context menu for the headers of the table columns. This * context menu allows users to toggle various columns, only with an exception * of the unique columns and when only two columns are visible in the table. */ setupHeadersContextMenu() { let popupset = this.document.getElementsByTagName("popupset")[0]; if (!popupset) { popupset = this.document.createXULElement("popupset"); this.document.documentElement.appendChild(popupset); } this.menupopup = this.document.createXULElement("menupopup"); this.menupopup.id = "table-widget-column-select"; this.menupopup.addEventListener("command", this.onPopupCommand); popupset.appendChild(this.menupopup); this.populateMenuPopup(); }, /** * Populates the header context menu with the names of the columns along with * displaying which columns are hidden or visible. * * @param {Array} privateColumns=[] * An array of column names that should never appear in the table. This * allows us to e.g. have an invisible compound primary key for a * table's rows. */ populateMenuPopup(privateColumns = []) { if (!this.menupopup) { return; } while (this.menupopup.firstChild) { this.menupopup.firstChild.remove(); } for (const column of this.columns.values()) { if (privateColumns.includes(column.id)) { continue; } const menuitem = this.document.createXULElement("menuitem"); menuitem.setAttribute("label", column.header.getAttribute("value")); menuitem.setAttribute("data-id", column.id); menuitem.setAttribute("type", "checkbox"); menuitem.setAttribute("checked", !column.hidden); if (column.id == this.uniqueId) { menuitem.setAttribute("disabled", "true"); } this.menupopup.appendChild(menuitem); } const checked = this.menupopup.querySelectorAll("menuitem[checked]"); if (checked.length == 2) { checked[checked.length - 1].setAttribute("disabled", "true"); } }, /** * Event handler for the `command` event on the column headers context menu */ onPopupCommand(event) { const item = event.originalTarget; let checked = !!item.getAttribute("checked"); const id = item.getAttribute("data-id"); this.emit(EVENTS.HEADER_CONTEXT_MENU, id, checked); checked = this.menupopup.querySelectorAll("menuitem[checked]"); const disabled = this.menupopup.querySelectorAll("menuitem[disabled]"); if (checked.length == 2) { checked[checked.length - 1].setAttribute("disabled", "true"); } else if (disabled.length > 1) { disabled[disabled.length - 1].removeAttribute("disabled"); } }, /** * Creates the columns in the table. Without calling this method, data cannot * be inserted into the table unless `initialColumns` was supplied. * * @param {Object} columns * A key value pair representing the columns of the table. Where the * key represents the id of the column and the value is the displayed * label in the header of the column. * @param {String} sortOn * The id of the column on which the table will be initially sorted on. * @param {Array} hiddenColumns * Ids of all the columns that are hidden by default. * @param {Array} privateColumns=[] * An array of column names that should never appear in the table. This * allows us to e.g. have an invisible compound primary key for a * table's rows. */ setColumns( columns, sortOn = this.sortedOn, hiddenColumns = [], privateColumns = [] ) { for (const column of this.columns.values()) { column.destroy(); } this.columns.clear(); if (!(sortOn in columns)) { sortOn = null; } if (!(this.firstColumn in columns)) { this.firstColumn = null; } if (this.firstColumn) { this.columns.set( this.firstColumn, new Column(this, this.firstColumn, columns[this.firstColumn]) ); } for (const id in columns) { if (!sortOn) { sortOn = id; } if (this.firstColumn && id == this.firstColumn) { continue; } this.columns.set(id, new Column(this, id, columns[id])); if (hiddenColumns.includes(id) || privateColumns.includes(id)) { // Hide the column. this.columns.get(id).toggleColumn(); if (privateColumns.includes(id)) { this.columns.get(id).private = true; } } } this.sortedOn = sortOn; this.sortBy(this.sortedOn); this.populateMenuPopup(privateColumns); }, /** * Returns true if the passed string or the row json object corresponds to the * selected item in the table. */ isSelected(item) { if (typeof item == "object") { item = item[this.uniqueId]; } return this.selectedRow && item == this.selectedRow[this.uniqueId]; }, /** * Selects the row corresponding to the `id` json. */ selectRow(id) { this.selectedRow = id; }, /** * Selects the next row. Cycles over to the first row if last row is selected */ selectNextRow() { for (const column of this.columns.values()) { column.selectNextRow(); } }, /** * Selects the previous row. Cycles over to the last row if first row is * selected. */ selectPreviousRow() { for (const column of this.columns.values()) { column.selectPreviousRow(); } }, /** * Clears any selected row. */ clearSelection() { this.selectedIndex = -1; }, /** * Adds a row into the table. * * @param {object} item * The object from which the key-value pairs will be taken and added * into the row. This object can have any arbitarary key value pairs, * but only those will be used whose keys match to the ids of the * columns. * @param {boolean} suppressFlash * true to not flash the row while inserting the row. */ push(item, suppressFlash) { if (!this.sortedOn || !this.columns) { console.error("Can't insert item without defining columns first"); return; } if (this.items.has(item[this.uniqueId])) { this.update(item); return; } if (this.editBookmark && !this.items.has(this.editBookmark)) { // Key has been updated... update bookmark. this.editBookmark = item[this.uniqueId]; } const index = this.columns.get(this.sortedOn).push(item); for (const [key, column] of this.columns) { if (key != this.sortedOn) { column.insertAt(item, index); } column.updateZebra(); } this.items.set(item[this.uniqueId], item); this.tbody.removeAttribute("empty"); if (!suppressFlash) { this.emit(EVENTS.ROW_UPDATED, item[this.uniqueId]); } this.emit(EVENTS.ROW_EDIT, item[this.uniqueId]); }, /** * Removes the row associated with the `item` object. */ remove(item) { if (typeof item != "object") { item = this.items.get(item); } if (!item) { return; } const removed = this.items.delete(item[this.uniqueId]); if (!removed) { return; } for (const column of this.columns.values()) { column.remove(item); column.updateZebra(); } if (this.items.size === 0) { this.selectedRow = null; this.tbody.setAttribute("empty", "empty"); } this.emit(EVENTS.ROW_REMOVED, item); }, /** * Updates the items in the row corresponding to the `item` object previously * used to insert the row using `push` method. The linking is done via the * `uniqueId` key's value. */ update(item) { const oldItem = this.items.get(item[this.uniqueId]); if (!oldItem) { return; } this.items.set(item[this.uniqueId], item); let changed = false; for (const column of this.columns.values()) { if (item[column.id] != oldItem[column.id]) { column.update(item); changed = true; } } if (changed) { this.emit(EVENTS.ROW_UPDATED, item[this.uniqueId]); this.emit(EVENTS.ROW_EDIT, item[this.uniqueId]); } }, /** * Removes all of the rows from the table. */ clear() { this.items.clear(); for (const column of this.columns.values()) { column.clear(); } this.tbody.setAttribute("empty", "empty"); this.setPlaceholder(this.emptyText); this.selectedRow = null; this.emit(EVENTS.TABLE_CLEARED, this); }, /** * Sorts the table by a given column. * * @param {string} column * The id of the column on which the table should be sorted. */ sortBy(column) { this.emit(EVENTS.COLUMN_SORTED, column); this.sortedOn = column; if (!this.items.size) { return; } // First sort the column to "sort by" explicitly. const sortedItems = this.columns.get(column).sort([...this.items.values()]); // Then, sort all the other columns (id !== column) only based on the // sortedItems provided by the first sort. // Each column keeps track of the fact that it is the "sort by" column or // not, so this will not shuffle the items and will just make sure each // column displays the correct value. for (const [id, col] of this.columns) { if (id !== column) { col.sort(sortedItems); } } }, /** * Filters the table based on a specific value * * @param {String} value: The filter value * @param {Array} ignoreProps: Props to ignore while filtering */ filterItems(value, ignoreProps = []) { if (this.filteredValue == value) { return; } if (this._editableFieldsEngine) { this._editableFieldsEngine.completeEdit(); } this.filteredValue = value; if (!value) { this.emit(EVENTS.TABLE_FILTERED, []); return; } // Shouldn't be case-sensitive value = value.toLowerCase(); const itemsToHide = [...this.items.keys()]; // Loop through all items and hide unmatched items for (const [id, val] of this.items) { for (const prop in val) { const column = this.columns.get(prop); if (ignoreProps.includes(prop) || column.hidden) { continue; } const propValue = val[prop].toString().toLowerCase(); if (propValue.includes(value)) { itemsToHide.splice(itemsToHide.indexOf(id), 1); break; } } } this.emit(EVENTS.TABLE_FILTERED, itemsToHide); }, /** * Calls the afterScroll function when the user has stopped scrolling */ onScroll() { clearNamedTimeout("table-scroll"); setNamedTimeout("table-scroll", AFTER_SCROLL_DELAY, this.afterScroll); }, /** * Emits the "scroll-end" event when the whole table is scrolled */ afterScroll() { const maxScrollTop = this.tbody.scrollHeight - this.tbody.clientHeight; // Emit scroll-end event when 9/10 of the table is scrolled if (this.tbody.scrollTop >= 0.9 * maxScrollTop) { this.emit("scroll-end"); } }, }; TableWidget.EVENTS = EVENTS; module.exports.TableWidget = TableWidget; /** * A single column object in the table. * * @param {TableWidget} table * The table object to which the column belongs. * @param {string} id * Id of the column. * @param {String} header * The displayed string on the column's header. */ function Column(table, id, header) { // By default cells are visible in the UI. this._private = false; this.tbody = table.tbody; this.document = table.document; this.window = table.window; this.id = id; this.uniqueId = table.uniqueId; this.wrapTextInElements = table.wrapTextInElements; this.table = table; this.cells = []; this.items = {}; this.highlightUpdated = table.highlightUpdated; this.column = this.document.createElementNS(HTML_NS, "div"); this.column.id = id; this.column.className = "table-widget-column"; this.tbody.appendChild(this.column); this.splitter = this.document.createXULElement("splitter"); this.splitter.className = "devtools-side-splitter"; this.tbody.appendChild(this.splitter); this.header = this.document.createXULElement("label"); this.header.className = "devtools-toolbar table-widget-column-header"; this.header.setAttribute("value", header); this.column.appendChild(this.header); if (table.headersContextMenu) { this.header.setAttribute("context", table.headersContextMenu); } this.toggleColumn = this.toggleColumn.bind(this); this.table.on(EVENTS.HEADER_CONTEXT_MENU, this.toggleColumn); this.onColumnSorted = this.onColumnSorted.bind(this); this.table.on(EVENTS.COLUMN_SORTED, this.onColumnSorted); this.onRowUpdated = this.onRowUpdated.bind(this); this.table.on(EVENTS.ROW_UPDATED, this.onRowUpdated); this.onTableFiltered = this.onTableFiltered.bind(this); this.table.on(EVENTS.TABLE_FILTERED, this.onTableFiltered); this.onClick = this.onClick.bind(this); this.onMousedown = this.onMousedown.bind(this); this.column.addEventListener("click", this.onClick); this.column.addEventListener("mousedown", this.onMousedown); } Column.prototype = { // items is a cell-id to cell-index map. It is basically a reverse map of the // this.cells object and is used to quickly reverse lookup a cell by its id // instead of looping through the cells array. This reverse map is not kept // upto date in sync with the cells array as updating it is in itself a loop // through all the cells of the columns. Thus update it on demand when it goes // out of sync with this.cells. items: null, // _itemsDirty is a flag which becomes true when this.items goes out of sync // with this.cells _itemsDirty: null, selectedRow: null, cells: null, /** * Gets whether the table is sorted on this column or not. * 0 - not sorted. * 1 - ascending order * 2 - descending order */ get sorted() { return this._sortState || 0; }, /** * Returns a boolean indicating whether the column is hidden. */ get hidden() { return this.column.hidden; }, /** * Get the private state of the column (visibility in the UI). */ get private() { return this._private; }, /** * Set the private state of the column (visibility in the UI). * * @param {Boolean} state * Private (true or false) */ set private(state) { this._private = state; }, /** * Sets the sorted value */ set sorted(value) { if (!value) { this.header.removeAttribute("sorted"); } else { this.header.setAttribute( "sorted", value == 1 ? "ascending" : "descending" ); } this._sortState = value; }, /** * Gets the selected row in the column. */ get selectedIndex() { if (!this.selectedRow) { return -1; } return this.items[this.selectedRow]; }, get cellNodes() { return [...this.column.querySelectorAll(".table-widget-cell")]; }, get visibleCellNodes() { const editor = this.table._editableFieldsEngine; const nodes = this.cellNodes.filter(node => { // If the cell is currently being edited we should class it as visible. if (editor && editor.currentTarget === node) { return true; } return node.clientWidth !== 0; }); return nodes; }, /** * Called when the column is sorted by. * * @param {string} column * The id of the column being sorted by. */ onColumnSorted(column) { if (column != this.id) { this.sorted = 0; return; } else if (this.sorted == 0 || this.sorted == 2) { this.sorted = 1; } else { this.sorted = 2; } this.updateZebra(); }, onTableFiltered(itemsToHide) { this._updateItems(); if (!this.cells) { return; } for (const cell of this.cells) { cell.hidden = false; } for (const id of itemsToHide) { this.cells[this.items[id]].hidden = true; } this.updateZebra(); }, /** * Called when a row is updated e.g. a cell is changed. This means that * for a new row this method will be called once for each column. If a single * cell is changed this method will be called just once. * * @param {string} event * The event name of the event. i.e. EVENTS.ROW_UPDATED * @param {string} id * The unique id of the object associated with the row. */ onRowUpdated(id) { this._updateItems(); if (this.highlightUpdated && this.items[id] != null) { if (this.table.scrollIntoViewOnUpdate) { const cell = this.cells[this.items[id]]; // When a new row is created this method is called once for each column // as each cell is updated. We can only scroll to cells if they are // visible. We check for visibility and once we find the first visible // cell in a row we scroll it into view and reset the // scrollIntoViewOnUpdate flag. if (cell.label.clientHeight > 0) { cell.scrollIntoView(); this.table.scrollIntoViewOnUpdate = null; } } if (this.table.editBookmark) { // A rows position in the table can change as the result of an edit. In // order to ensure that the correct row is highlighted after an edit we // save the uniqueId in editBookmark. Here we send the signal that the // row has been edited and that the row needs to be selected again. this.table.emit(EVENTS.ROW_SELECTED, this.table.editBookmark); this.table.editBookmark = null; } this.cells[this.items[id]].flash(); } this.updateZebra(); }, destroy() { this.table.off(EVENTS.COLUMN_SORTED, this.onColumnSorted); this.table.off(EVENTS.HEADER_CONTEXT_MENU, this.toggleColumn); this.table.off(EVENTS.ROW_UPDATED, this.onRowUpdated); this.table.off(EVENTS.TABLE_FILTERED, this.onTableFiltered); this.column.removeEventListener("click", this.onClick); this.column.removeEventListener("mousedown", this.onMousedown); this.splitter.remove(); this.column.remove(); this.cells = null; this.items = null; this.selectedRow = null; }, /** * Selects the row at the `index` index */ selectRowAt(index) { if (this.selectedRow != null) { this.cells[this.items[this.selectedRow]].classList.remove( "theme-selected" ); } const cell = this.cells[index]; if (cell) { cell.classList.add("theme-selected"); this.selectedRow = cell.id; } else { this.selectedRow = null; } }, /** * Selects the row with the object having the `uniqueId` value as `id` */ selectRow(id) { this._updateItems(); this.selectRowAt(this.items[id]); }, /** * Selects the next row. Cycles to first if last row is selected. */ selectNextRow() { this._updateItems(); let index = this.items[this.selectedRow] + 1; if (index == this.cells.length) { index = 0; } this.selectRowAt(index); }, /** * Selects the previous row. Cycles to last if first row is selected. */ selectPreviousRow() { this._updateItems(); let index = this.items[this.selectedRow] - 1; if (index == -1) { index = this.cells.length - 1; } this.selectRowAt(index); }, /** * Pushes the `item` object into the column. If this column is sorted on, * then inserts the object at the right position based on the column's id * key's value. * * @returns {number} * The index of the currently pushed item. */ push(item) { const value = item[this.id]; if (this.sorted) { let index; if (this.sorted == 1) { index = this.cells.findIndex(element => { return ( naturalSortCaseInsensitive( value, element.value, standardSessionString ) === -1 ); }); } else { index = this.cells.findIndex(element => { return ( naturalSortCaseInsensitive( value, element.value, standardSessionString ) === 1 ); }); } index = index >= 0 ? index : this.cells.length; if (index < this.cells.length) { this._itemsDirty = true; } this.items[item[this.uniqueId]] = index; this.cells.splice(index, 0, new Cell(this, item, this.cells[index])); return index; } this.items[item[this.uniqueId]] = this.cells.length; return this.cells.push(new Cell(this, item)) - 1; }, /** * Inserts the `item` object at the given `index` index in the table. */ insertAt(item, index) { if (index < this.cells.length) { this._itemsDirty = true; } this.items[item[this.uniqueId]] = index; this.cells.splice(index, 0, new Cell(this, item, this.cells[index])); this.updateZebra(); }, /** * Event handler for the command event coming from the header context menu. * Toggles the column if it was requested by the user. * When called explicitly without parameters, it toggles the corresponding * column. * * @param {string} event * The name of the event. i.e. EVENTS.HEADER_CONTEXT_MENU * @param {string} id * Id of the column to be toggled * @param {string} checked * true if the column is visible */ toggleColumn(id, checked) { if (!arguments.length) { // Act like a toggling method when called with no params id = this.id; checked = this.column.hidden; } if (id != this.id) { return; } if (checked) { this.column.hidden = false; this.tbody.insertBefore(this.splitter, this.column.nextSibling); } else { this.column.hidden = true; this.splitter.remove(); } }, /** * Removes the corresponding item from the column and hide the last visible * splitter with CSS, so we do not add splitter elements for hidden columns. */ remove(item) { this._updateItems(); const index = this.items[item[this.uniqueId]]; if (index == null) { return; } if (index < this.cells.length) { this._itemsDirty = true; } this.cells[index].destroy(); this.cells.splice(index, 1); delete this.items[item[this.uniqueId]]; }, /** * Updates the corresponding item from the column. */ update(item) { this._updateItems(); const index = this.items[item[this.uniqueId]]; if (index == null) { return; } this.cells[index].value = item[this.id]; }, /** * Updates the `this.items` cell-id vs cell-index map to be in sync with * `this.cells`. */ _updateItems() { if (!this._itemsDirty) { return; } for (let i = 0; i < this.cells.length; i++) { this.items[this.cells[i].id] = i; } this._itemsDirty = false; }, /** * Clears the current column */ clear() { this.cells = []; this.items = {}; this._itemsDirty = false; while (this.header.nextSibling) { this.header.nextSibling.remove(); } }, /** * Sorts the given items and returns the sorted list if the table was sorted * by this column. */ sort(items) { // Only sort the array if we are sorting based on this column if (this.sorted == 1) { items.sort((a, b) => { const val1 = Node.isInstance(a[this.id]) ? a[this.id].textContent : a[this.id]; const val2 = Node.isInstance(b[this.id]) ? b[this.id].textContent : b[this.id]; return naturalSortCaseInsensitive(val1, val2, standardSessionString); }); } else if (this.sorted > 1) { items.sort((a, b) => { const val1 = Node.isInstance(a[this.id]) ? a[this.id].textContent : a[this.id]; const val2 = Node.isInstance(b[this.id]) ? b[this.id].textContent : b[this.id]; return naturalSortCaseInsensitive(val2, val1, standardSessionString); }); } if (this.selectedRow) { this.cells[this.items[this.selectedRow]].classList.remove( "theme-selected" ); } this.items = {}; // Otherwise, just use the sorted array passed to update the cells value. for (const [i, item] of items.entries()) { // See Bug 1706679 (Intermittent) // Sometimes we would reach the situation in which we were trying to sort // and item that was no longer available in the TableWidget. // We should find exactly what is triggering it. if (!this.cells[i]) { continue; } this.items[item[this.uniqueId]] = i; this.cells[i].value = item[this.id]; this.cells[i].id = item[this.uniqueId]; } if (this.selectedRow) { this.cells[this.items[this.selectedRow]].classList.add("theme-selected"); } this._itemsDirty = false; this.updateZebra(); return items; }, updateZebra() { this._updateItems(); let i = 0; for (const cell of this.cells) { if (!cell.hidden) { i++; } const even = !(i % 2); cell.classList.toggle("even", even); } }, /** * Click event handler for the column. Used to detect click on header for * for sorting. */ onClick(event) { const target = event.originalTarget; if (target.nodeType !== target.ELEMENT_NODE || target == this.column) { return; } if (event.button == 0 && target == this.header) { this.table.sortBy(this.id); } }, /** * Mousedown event handler for the column. Used to select rows. */ onMousedown(event) { const target = event.originalTarget; if ( target.nodeType !== target.ELEMENT_NODE || target == this.column || target == this.header ) { return; } if (event.button == 0) { const closest = target.closest("[data-id]"); if (!closest) { return; } const dataid = closest.getAttribute("data-id"); this.table.emit(EVENTS.ROW_SELECTED, dataid); } }, }; /** * A single cell in a column * * @param {Column} column * The column object to which the cell belongs. * @param {object} item * The object representing the row. It contains a key value pair * representing the column id and its associated value. The value * can be a DOMNode that is appended or a string value. * @param {Cell} nextCell * The cell object which is next to this cell. null if this cell is last * cell of the column */ function Cell(column, item, nextCell) { const document = column.document; this.wrapTextInElements = column.wrapTextInElements; this.label = document.createXULElement("label"); this.label.setAttribute("crop", "end"); this.label.className = "plain table-widget-cell"; if (nextCell) { column.column.insertBefore(this.label, nextCell.label); } else { column.column.appendChild(this.label); } if (column.table.cellContextMenuId) { this.label.setAttribute("context", column.table.cellContextMenuId); this.label.addEventListener("contextmenu", () => { // Make the ID of the clicked cell available as a property on the table. // It's then available for the popupshowing or command handler. column.table.contextMenuRowId = this.id; }); } this.value = item[column.id]; this.id = item[column.uniqueId]; } Cell.prototype = { set id(value) { this._id = value; this.label.setAttribute("data-id", value); }, get id() { return this._id; }, get hidden() { return this.label.hidden; }, set hidden(value) { this.label.hidden = value; }, set value(value) { this._value = value; if (value == null) { this.label.setAttribute("value", ""); return; } if (this.wrapTextInElements && !Node.isInstance(value)) { const span = this.label.ownerDocument.createElementNS(HTML_NS, "span"); span.textContent = value; value = span; } if (Node.isInstance(value)) { this.label.removeAttribute("value"); while (this.label.firstChild) { this.label.firstChild.remove(); } this.label.appendChild(value); } else { this.label.setAttribute("value", value + ""); } }, get value() { return this._value; }, get classList() { return this.label.classList; }, /** * Flashes the cell for a brief time. This when done for with cells in all * columns, makes it look like the row is being highlighted/flashed. */ flash() { if (!this.label.parentNode) { return; } this.label.classList.remove("flash-out"); // Cause a reflow so that the animation retriggers on adding back the class let a = this.label.parentNode.offsetWidth; // eslint-disable-line const onAnimEnd = () => { this.label.classList.remove("flash-out"); this.label.removeEventListener("animationend", onAnimEnd); }; this.label.addEventListener("animationend", onAnimEnd); this.label.classList.add("flash-out"); }, focus() { this.label.focus(); }, scrollIntoView() { this.label.scrollIntoView(false); }, destroy() { this.label.remove(); this.label = null; }, }; /** * Simple widget to make nodes matching a CSS selector editable. * * @param {Object} options * An object with the following format: * { * // The node that will act as a container for the editor e.g. a * // div or table. * root: someNode, * * // The onTab event to be handled by the caller. * onTab: function(event) { ... } * * // Optional event used to trigger the editor. By default this is * // dblclick. * onTriggerEvent: "dblclick", * * // Array or comma separated string of CSS Selectors matching * // elements that are to be made editable. * selectors: [ * "#name .table-widget-cell", * "#value .table-widget-cell" * ] * } */ function EditableFieldsEngine(options) { EventEmitter.decorate(this); if (!Array.isArray(options.selectors)) { options.selectors = [options.selectors]; } this.root = options.root; this.selectors = options.selectors; this.onTab = options.onTab; this.onTriggerEvent = options.onTriggerEvent || "dblclick"; this.items = options.items; this.edit = this.edit.bind(this); this.cancelEdit = this.cancelEdit.bind(this); this.destroy = this.destroy.bind(this); this.onTrigger = this.onTrigger.bind(this); this.root.addEventListener(this.onTriggerEvent, this.onTrigger); } EditableFieldsEngine.prototype = { INPUT_ID: "inlineEditor", get changePending() { return this.isEditing && this.textbox.value !== this.currentValue; }, get isEditing() { return this.root && !this.textbox.hidden; }, get textbox() { if (!this._textbox) { const doc = this.root.ownerDocument; this._textbox = doc.createElementNS(HTML_NS, "input"); this._textbox.id = this.INPUT_ID; this.onKeydown = this.onKeydown.bind(this); this._textbox.addEventListener("keydown", this.onKeydown); this.completeEdit = this.completeEdit.bind(this); doc.addEventListener("blur", this.completeEdit); } return this._textbox; }, /** * Called when a trigger event is detected (default is dblclick). * * @param {EventTarget} target * Calling event's target. */ onTrigger({ target }) { this.edit(target); }, /** * Handle keydowns when in edit mode: * - revert the value and close the textbox. * - apply the value and close the textbox. * - Handled by the consumer's `onTab` callback. * - Handled by the consumer's `onTab` callback. * * @param {Event} event * The calling event. */ onKeydown(event) { if (!this.textbox) { return; } switch (event.keyCode) { case KeyCodes.DOM_VK_ESCAPE: this.cancelEdit(); event.preventDefault(); break; case KeyCodes.DOM_VK_RETURN: this.completeEdit(); break; case KeyCodes.DOM_VK_TAB: if (this.onTab) { this.onTab(event); } break; } }, /** * Overlay the target node with an edit field. * * @param {Node} target * Dom node to be edited. */ edit(target) { if (!target) { return; } // Some item names and values are not parsable by the client or server so should not be // editable. const name = target.getAttribute("data-id"); const item = this.items.get(name); if ("isValueEditable" in item && !item.isValueEditable) { return; } target.scrollIntoView(false); target.focus(); if (!target.matches(this.selectors.join(","))) { return; } // If we are actively editing something complete the edit first. if (this.isEditing) { this.completeEdit(); } this.copyStyles(target, this.textbox); target.parentNode.insertBefore(this.textbox, target); this.currentTarget = target; this.textbox.value = this.currentValue = target.value; target.hidden = true; this.textbox.hidden = false; this.textbox.focus(); this.textbox.select(); }, completeEdit() { if (!this.isEditing) { return; } const oldValue = this.currentValue; const newValue = this.textbox.value; const changed = oldValue !== newValue; this.textbox.hidden = true; if (!this.currentTarget) { return; } this.currentTarget.hidden = false; if (changed) { this.currentTarget.value = newValue; const data = { change: { field: this.currentTarget, oldValue, newValue, }, }; this.emit("change", data); } }, /** * Cancel an edit. */ cancelEdit() { if (!this.isEditing) { return; } if (this.currentTarget) { this.currentTarget.hidden = false; } this.textbox.hidden = true; }, /** * Stop edit mode and apply changes. */ blur() { if (this.isEditing) { this.completeEdit(); } }, /** * Copies various styles from one node to another. * * @param {Node} source * The node to copy styles from. * @param {Node} destination [description] * The node to copy styles to. */ copyStyles(source, destination) { const style = source.ownerDocument.defaultView.getComputedStyle(source); const props = [ "borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth", "fontFamily", "fontSize", "fontWeight", "height", "marginTop", "marginRight", "marginBottom", "marginLeft", "marginInlineStart", "marginInlineEnd", ]; for (const prop of props) { destination.style[prop] = style[prop]; } // We need to set the label width to 100% to work around a XUL flex bug. destination.style.width = "100%"; }, /** * Destroys all editors in the current document. */ destroy() { if (this.textbox) { this.textbox.removeEventListener("keydown", this.onKeydown); this.textbox.remove(); } if (this.root) { this.root.removeEventListener(this.onTriggerEvent, this.onTrigger); this.root.ownerDocument.removeEventListener("blur", this.completeEdit); } this._textbox = this.root = this.selectors = this.onTab = null; this.currentTarget = this.currentValue = null; this.emit("destroyed"); }, };