/* 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"; // Make this available to both AMD and CJS environments define(function (require, exports, module) { const { cloneElement, Component, createFactory, createRef, } = require("devtools/client/shared/vendor/react"); const { findDOMNode } = require("devtools/client/shared/vendor/react-dom"); const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); const dom = require("devtools/client/shared/vendor/react-dom-factories"); // Reps const { ObjectProvider, } = require("devtools/client/shared/components/tree/ObjectProvider"); const TreeRow = createFactory( require("devtools/client/shared/components/tree/TreeRow") ); const TreeHeader = createFactory( require("devtools/client/shared/components/tree/TreeHeader") ); const { scrollIntoView } = require("devtools/client/shared/scroll"); const SUPPORTED_KEYS = [ "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "End", "Home", "Enter", " ", "Escape", ]; const defaultProps = { object: null, renderRow: null, provider: ObjectProvider, expandedNodes: new Set(), selected: null, defaultSelectFirstNode: true, active: null, expandableStrings: true, columns: [], }; /** * This component represents a tree view with expandable/collapsible nodes. * The tree is rendered using element where every node is represented * by element. The tree is one big table where nodes (rows) are properly * indented from the left to mimic hierarchical structure of the data. * * The tree can have arbitrary number of columns and so, might be use * as an expandable tree-table UI widget as well. By default, there is * one column for node label and one for node value. * * The tree is maintaining its (presentation) state, which consists * from list of expanded nodes and list of columns. * * Complete data provider interface: * var TreeProvider = { * getChildren: function(object); * hasChildren: function(object); * getLabel: function(object, colId); * getLevel: function(object); // optional * getValue: function(object, colId); * getKey: function(object); * getType: function(object); * } * * Complete tree decorator interface: * var TreeDecorator = { * getRowClass: function(object); * getCellClass: function(object, colId); * getHeaderClass: function(colId); * renderValue: function(object, colId); * renderRow: function(object); * renderCell: function(object, colId); * renderLabelCell: function(object); * } */ class TreeView extends Component { // The only required property (not set by default) is the input data // object that is used to populate the tree. static get propTypes() { return { // The input data object. object: PropTypes.any, className: PropTypes.string, label: PropTypes.string, // Data provider (see also the interface above) provider: PropTypes.shape({ getChildren: PropTypes.func, hasChildren: PropTypes.func, getLabel: PropTypes.func, getValue: PropTypes.func, getKey: PropTypes.func, getLevel: PropTypes.func, getType: PropTypes.func, }).isRequired, // Tree decorator (see also the interface above) decorator: PropTypes.shape({ getRowClass: PropTypes.func, getCellClass: PropTypes.func, getHeaderClass: PropTypes.func, renderValue: PropTypes.func, renderRow: PropTypes.func, renderCell: PropTypes.func, renderLabelCell: PropTypes.func, }), // Custom tree row (node) renderer renderRow: PropTypes.func, // Custom cell renderer renderCell: PropTypes.func, // Custom value renderer renderValue: PropTypes.func, // Custom tree label (including a toggle button) renderer renderLabelCell: PropTypes.func, // Set of expanded nodes expandedNodes: PropTypes.object, // Selected node selected: PropTypes.string, // Select first node by default defaultSelectFirstNode: PropTypes.bool, // The currently active (keyboard) item, if any such item exists. active: PropTypes.string, // Custom filtering callback onFilter: PropTypes.func, // Custom sorting callback onSort: PropTypes.func, // Custom row click callback onClickRow: PropTypes.func, // Row context menu event handler onContextMenuRow: PropTypes.func, // Tree context menu event handler onContextMenuTree: PropTypes.func, // A header is displayed if set to true header: PropTypes.bool, // Long string is expandable by a toggle button expandableStrings: PropTypes.bool, // Array of columns columns: PropTypes.arrayOf( PropTypes.shape({ id: PropTypes.string.isRequired, title: PropTypes.string, width: PropTypes.string, }) ), }; } static get defaultProps() { return defaultProps; } static subPath(path, subKey) { return path + "/" + String(subKey).replace(/[\\/]/g, "\\$&"); } /** * Creates a set with the paths of the nodes that should be expanded by default * according to the passed options. * @param {Object} The root node of the tree. * @param {Object} [optional] An object with the following optional parameters: * - maxLevel: nodes nested deeper than this level won't be expanded. * - maxNodes: maximum number of nodes that can be expanded. The traversal is breadth-first, so expanding nodes nearer to the root will be preferred. Sibling nodes will either be all expanded or none expanded. * } */ static getExpandedNodes( rootObj, { maxLevel = Infinity, maxNodes = Infinity } = {} ) { const expandedNodes = new Set(); const queue = [ { object: rootObj, level: 1, path: "", }, ]; while (queue.length) { const { object, level, path } = queue.shift(); if (Object(object) !== object) { continue; } const keys = Object.keys(object); if (expandedNodes.size + keys.length > maxNodes) { // Avoid having children half expanded. break; } for (const key of keys) { const nodePath = TreeView.subPath(path, key); expandedNodes.add(nodePath); if (level < maxLevel) { queue.push({ object: object[key], level: level + 1, path: nodePath, }); } } } return expandedNodes; } constructor(props) { super(props); this.state = { expandedNodes: props.expandedNodes, columns: ensureDefaultColumn(props.columns), selected: props.selected, active: props.active, lastSelectedIndex: props.defaultSelectFirstNode ? 0 : null, mouseDown: false, }; this.treeRef = createRef(); this.toggle = this.toggle.bind(this); this.isExpanded = this.isExpanded.bind(this); this.onFocus = this.onFocus.bind(this); this.onKeyDown = this.onKeyDown.bind(this); this.onClickRow = this.onClickRow.bind(this); this.getSelectedRow = this.getSelectedRow.bind(this); this.selectRow = this.selectRow.bind(this); this.activateRow = this.activateRow.bind(this); this.isSelected = this.isSelected.bind(this); this.onFilter = this.onFilter.bind(this); this.onSort = this.onSort.bind(this); this.getMembers = this.getMembers.bind(this); this.renderRows = this.renderRows.bind(this); } // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 UNSAFE_componentWillReceiveProps(nextProps) { const { expandedNodes, selected } = nextProps; const state = { expandedNodes, lastSelectedIndex: this.getSelectedRowIndex(), }; if (selected) { state.selected = selected; } this.setState(Object.assign({}, this.state, state)); } shouldComponentUpdate(nextProps, nextState) { const { expandedNodes, columns, selected, active, lastSelectedIndex, mouseDown, } = this.state; return ( expandedNodes !== nextState.expandedNodes || columns !== nextState.columns || selected !== nextState.selected || active !== nextState.active || lastSelectedIndex !== nextState.lastSelectedIndex || mouseDown === nextState.mouseDown ); } componentDidUpdate() { const selected = this.getSelectedRow(); if (selected || this.state.active) { return; } const rows = this.visibleRows; if (rows.length === 0) { return; } // Only select a row if there is a previous lastSelected Index // This mostly happens when the treeview is loaded the first time if (this.state.lastSelectedIndex !== null) { this.selectRow( rows[Math.min(this.state.lastSelectedIndex, rows.length - 1)], { alignTo: "top" } ); } } /** * Get rows that are currently visible. Some rows can be filtered and made * invisible, in which case, when navigating around the tree we need to * ignore the ones that are not reachable by the user. */ get visibleRows() { return this.rows.filter(row => { const rowEl = findDOMNode(row); return rowEl?.offsetParent; }); } // Node expand/collapse toggle(nodePath) { const nodes = this.state.expandedNodes; if (this.isExpanded(nodePath)) { nodes.delete(nodePath); } else { nodes.add(nodePath); } // Compute new state and update the tree. this.setState( Object.assign({}, this.state, { expandedNodes: nodes, }) ); } isExpanded(nodePath) { return this.state.expandedNodes.has(nodePath); } // Event Handlers onFocus(_event) { if (this.state.mouseDown) { return; } // Set focus to the first element, if none is selected or activated // This is needed because keyboard navigation won't work without an element being selected this.componentDidUpdate(); } // eslint-disable-next-line complexity onKeyDown(event) { const keyEligibleForFirstLetterNavigation = event.key.length === 1; if ( (!SUPPORTED_KEYS.includes(event.key) && !keyEligibleForFirstLetterNavigation) || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey ) { return; } const row = this.getSelectedRow(); if (!row) { return; } const rows = this.visibleRows; const index = rows.indexOf(row); const { hasChildren, open } = row.props.member; switch (event.key) { case "ArrowRight": if (hasChildren) { if (open) { const firstChildRow = this.rows .slice(index + 1) .find(r => r.props.member.level > row.props.member.level); if (firstChildRow) { this.selectRow(firstChildRow, { alignTo: "bottom" }); } } else { this.toggle(this.state.selected); } } break; case "ArrowLeft": if (hasChildren && open) { this.toggle(this.state.selected); } else { const parentRow = rows .slice(0, index) .reverse() .find(r => r.props.member.level < row.props.member.level); if (parentRow) { this.selectRow(parentRow, { alignTo: "top" }); } } break; case "ArrowDown": const nextRow = rows[index + 1]; if (nextRow) { this.selectRow(nextRow, { alignTo: "bottom" }); } break; case "ArrowUp": const previousRow = rows[index - 1]; if (previousRow) { this.selectRow(previousRow, { alignTo: "top" }); } break; case "Home": const firstRow = rows[0]; if (firstRow) { this.selectRow(firstRow, { alignTo: "top" }); } break; case "End": const lastRow = rows[rows.length - 1]; if (lastRow) { this.selectRow(lastRow, { alignTo: "bottom" }); } break; case "Enter": case " ": // On space or enter make selected row active. This means keyboard // focus handling is passed on to the tree row itself. if (this.treeRef.current === document.activeElement) { event.stopPropagation(); event.preventDefault(); if (this.state.active !== this.state.selected) { this.activateRow(this.state.selected); } return; } break; case "Escape": event.stopPropagation(); if (this.state.active != null) { this.activateRow(null); } break; } if (keyEligibleForFirstLetterNavigation) { const next = rows .slice(index + 1) .find(r => r.props.member.name.startsWith(event.key)); if (next) { this.selectRow(next, { alignTo: "bottom" }); } } // Focus should always remain on the tree container itself. this.treeRef.current.focus(); event.preventDefault(); } onClickRow(nodePath, event) { const onClickRow = this.props.onClickRow; const row = this.visibleRows.find(r => r.props.member.path === nodePath); // Call custom click handler and bail out if it returns true. if ( onClickRow && onClickRow.call(this, nodePath, event, row.props.member) ) { return; } event.stopPropagation(); const cell = event.target.closest("td"); if (cell && cell.classList.contains("treeLabelCell")) { this.toggle(nodePath); } this.selectRow(row, { preventAutoScroll: true }); } onContextMenu(member, event) { const onContextMenuRow = this.props.onContextMenuRow; if (onContextMenuRow) { onContextMenuRow.call(this, member, event); } } getSelectedRow() { const rows = this.visibleRows; if (!this.state.selected || rows.length === 0) { return null; } return rows.find(row => this.isSelected(row.props.member.path)); } getSelectedRowIndex() { const row = this.getSelectedRow(); if (!row) { return this.props.defaultSelectFirstNode ? 0 : null; } return this.visibleRows.indexOf(row); } _scrollIntoView(row, options = {}) { const treeEl = this.treeRef.current; if (!treeEl || !row) { return; } const { props: { member: { path } = {} } = {} } = row; if (!path) { return; } const element = treeEl.ownerDocument.getElementById(path); if (!element) { return; } scrollIntoView(element, { ...options }); } selectRow(row, options = {}) { const { props: { member: { path } = {} } = {} } = row; if (this.isSelected(path)) { return; } if (this.state.active != null) { const treeEl = this.treeRef.current; if (treeEl && treeEl !== treeEl.ownerDocument.activeElement) { treeEl.focus(); } } if (!options.preventAutoScroll) { this._scrollIntoView(row, options); } this.setState({ ...this.state, selected: path, active: null, }); } activateRow(active) { this.setState({ ...this.state, active, }); } isSelected(nodePath) { return nodePath === this.state.selected; } isActive(nodePath) { return nodePath === this.state.active; } // Filtering & Sorting /** * Filter out nodes that don't correspond to the current filter. * @return {Boolean} true if the node should be visible otherwise false. */ onFilter(object) { const onFilter = this.props.onFilter; return onFilter ? onFilter(object) : true; } onSort(parent, children) { const onSort = this.props.onSort; return onSort ? onSort(parent, children) : children; } // Members /** * Return children node objects (so called 'members') for given * parent object. */ getMembers(parent, level, path) { // Strings don't have children. Note that 'long' strings are using // the expander icon (+/-) to display the entire original value, // but there are no child items. if (typeof parent == "string") { return []; } const { expandableStrings, provider } = this.props; let children = provider.getChildren(parent) || []; // If the return value is non-array, the children // are being loaded asynchronously. if (!Array.isArray(children)) { return children; } children = this.onSort(parent, children) || children; return children.map(child => { const key = provider.getKey(child); const nodePath = TreeView.subPath(path, key); const type = provider.getType(child); let hasChildren = provider.hasChildren(child); // Value with no column specified is used for optimization. // The row is re-rendered only if this value changes. // Value for actual column is get when a cell is rendered. const value = provider.getValue(child); if (expandableStrings && isLongString(value)) { hasChildren = true; } // Return value is a 'member' object containing meta-data about // tree node. It describes node label, value, type, etc. return { // An object associated with this node. object: child, // A label for the child node name: provider.getLabel(child), // Data type of the child node (used for CSS customization) type, // Class attribute computed from the type. rowClass: "treeRow-" + type, // Level of the child within the hierarchy (top == 0) level: provider.getLevel ? provider.getLevel(child, level) : level, // True if this node has children. hasChildren, // Value associated with this node (as provided by the data provider) value, // True if the node is expanded. open: this.isExpanded(nodePath), // Node path path: nodePath, // True if the node is hidden (used for filtering) hidden: !this.onFilter(child), // True if the node is selected with keyboard selected: this.isSelected(nodePath), // True if the node is activated with keyboard active: this.isActive(nodePath), }; }); } /** * Render tree rows/nodes. */ renderRows(parent, level = 0, path = "") { let rows = []; const decorator = this.props.decorator; let renderRow = this.props.renderRow || TreeRow; // Get children for given parent node, iterate over them and render // a row for every one. Use row template (a component) from properties. // If the return value is non-array, the children are being loaded // asynchronously. const members = this.getMembers(parent, level, path); if (!Array.isArray(members)) { return members; } members.forEach(member => { if (decorator?.renderRow) { renderRow = decorator.renderRow(member.object) || renderRow; } const props = Object.assign({}, this.props, { key: `${member.path}-${member.active ? "active" : "inactive"}`, member, columns: this.state.columns, id: member.path, ref: row => row && this.rows.push(row), onClick: this.onClickRow.bind(this, member.path), onContextMenu: this.onContextMenu.bind(this, member), }); // Render single row. rows.push(renderRow(props)); // If a child node is expanded render its rows too. if (member.hasChildren && member.open) { const childRows = this.renderRows( member.object, level + 1, member.path ); // If children needs to be asynchronously fetched first, // set 'loading' property to the parent row. Otherwise // just append children rows to the array of all rows. if (!Array.isArray(childRows)) { const lastIndex = rows.length - 1; props.member.loading = true; rows[lastIndex] = cloneElement(rows[lastIndex], props); } else { rows = rows.concat(childRows); } } }); return rows; } render() { const root = this.props.object; const classNames = ["treeTable"]; this.rows = []; const { className, onContextMenuTree } = this.props; // Use custom class name from props. if (className) { classNames.push(...className.split(" ")); } // Alright, let's render all tree rows. The tree is one big
. let rows = this.renderRows(root, 0, ""); // This happens when the view needs to do initial asynchronous // fetch for the root object. The tree might provide a hook API // for rendering animated spinner (just like for tree nodes). if (!Array.isArray(rows)) { rows = []; } const props = Object.assign({}, this.props, { columns: this.state.columns, }); return dom.table( { className: classNames.join(" "), role: "tree", ref: this.treeRef, tabIndex: 0, onFocus: this.onFocus, onKeyDown: this.onKeyDown, onContextMenu: onContextMenuTree && onContextMenuTree.bind(this), onMouseDown: () => this.setState({ mouseDown: true }), onMouseUp: () => this.setState({ mouseDown: false }), onClick: () => { // Focus should always remain on the tree container itself. this.treeRef.current.focus(); }, onBlur: event => { if (this.state.active != null) { const { relatedTarget } = event; if (!this.treeRef.current.contains(relatedTarget)) { this.activateRow(null); } } }, "aria-label": this.props.label || "", "aria-activedescendant": this.state.selected, cellPadding: 0, cellSpacing: 0, }, TreeHeader(props), dom.tbody( { role: "presentation", tabIndex: -1, }, rows ) ); } } // Helpers /** * There should always be at least one column (the one with toggle buttons) * and this function ensures that it's true. */ function ensureDefaultColumn(columns) { if (!columns) { columns = []; } const defaultColumn = columns.filter(col => col.id == "default"); if (defaultColumn.length) { return columns; } // The default column is usually the first one. return [{ id: "default" }, ...columns]; } function isLongString(value) { return typeof value == "string" && value.length > 50; } // Exports from this module module.exports = TreeView; });