TreeView.mjs (23529B)
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 5 /* eslint no-shadow: ["error", { "allow": ["open", "parent"] }] */ 6 7 import React from "resource://devtools/client/shared/vendor/react.mjs"; 8 import ReactDOM from "resource://devtools/client/shared/vendor/react-dom.mjs"; 9 import PropTypes from "resource://devtools/client/shared/vendor/react-prop-types.mjs"; 10 import * as dom from "resource://devtools/client/shared/vendor/react-dom-factories.mjs"; 11 12 import { ObjectProvider } from "resource://devtools/client/shared/components/tree/ObjectProvider.mjs"; 13 import TreeRowClass from "resource://devtools/client/shared/components/tree/TreeRow.mjs"; 14 import TreeHeaderClass from "resource://devtools/client/shared/components/tree/TreeHeader.mjs"; 15 16 import { scrollIntoView } from "resource://devtools/client/shared/scroll.mjs"; 17 18 const { cloneElement, Component, createFactory, createRef } = React; 19 const { findDOMNode } = ReactDOM; 20 21 const TreeRow = createFactory(TreeRowClass); 22 const TreeHeader = createFactory(TreeHeaderClass); 23 24 const SUPPORTED_KEYS = [ 25 "ArrowUp", 26 "ArrowDown", 27 "ArrowLeft", 28 "ArrowRight", 29 "End", 30 "Home", 31 "Enter", 32 " ", 33 "Escape", 34 ]; 35 36 // Use a function in order to avoid shared Set/Array between each instance! 37 function getDefaultProps() { 38 return { 39 object: null, 40 renderRow: null, 41 provider: ObjectProvider, 42 expandedNodes: new Set(), 43 selected: null, 44 defaultSelectFirstNode: true, 45 active: null, 46 expandableStrings: true, 47 bucketLargeArrays: false, 48 maxStringLength: 50, 49 columns: [], 50 }; 51 } 52 53 /** 54 * This component represents a tree view with expandable/collapsible nodes. 55 * The tree is rendered using <table> element where every node is represented 56 * by <tr> element. The tree is one big table where nodes (rows) are properly 57 * indented from the left to mimic hierarchical structure of the data. 58 * 59 * The tree can have arbitrary number of columns and so, might be use 60 * as an expandable tree-table UI widget as well. By default, there is 61 * one column for node label and one for node value. 62 * 63 * The tree is maintaining its (presentation) state, which consists 64 * from list of expanded nodes and list of columns. 65 * 66 * Complete data provider interface: 67 * var TreeProvider = { 68 * getChildren: function(object); 69 * hasChildren: function(object); 70 * getLabel: function(object, colId); 71 * getLevel: function(object); // optional 72 * getValue: function(object, colId); 73 * getKey: function(object); 74 * getType: function(object); 75 * } 76 * 77 * Complete tree decorator interface: 78 * var TreeDecorator = { 79 * getRowClass: function(object); 80 * getCellClass: function(object, colId); 81 * getHeaderClass: function(colId); 82 * renderValue: function(object, colId); 83 * renderRow: function(object); 84 * renderCell: function(object, colId); 85 * renderLabelCell: function(object); 86 * } 87 */ 88 class TreeView extends Component { 89 // The only required property (not set by default) is the input data 90 // object that is used to populate the tree. 91 static get propTypes() { 92 return { 93 // The input data object. 94 object: PropTypes.any, 95 className: PropTypes.string, 96 label: PropTypes.string, 97 // Data provider (see also the interface above) 98 provider: PropTypes.shape({ 99 getChildren: PropTypes.func, 100 hasChildren: PropTypes.func, 101 getLabel: PropTypes.func, 102 getValue: PropTypes.func, 103 getKey: PropTypes.func, 104 getLevel: PropTypes.func, 105 getType: PropTypes.func, 106 }).isRequired, 107 // Tree decorator (see also the interface above) 108 decorator: PropTypes.shape({ 109 getRowClass: PropTypes.func, 110 getCellClass: PropTypes.func, 111 getHeaderClass: PropTypes.func, 112 renderValue: PropTypes.func, 113 renderRow: PropTypes.func, 114 renderCell: PropTypes.func, 115 renderLabelCell: PropTypes.func, 116 }), 117 // Custom tree row (node) renderer 118 renderRow: PropTypes.func, 119 // Custom cell renderer 120 renderCell: PropTypes.func, 121 // Custom value renderer 122 renderValue: PropTypes.func, 123 // Custom tree label (including a toggle button) renderer 124 renderLabelCell: PropTypes.func, 125 // Set of expanded nodes 126 expandedNodes: PropTypes.object, 127 // Selected node 128 selected: PropTypes.string, 129 // Select first node by default 130 defaultSelectFirstNode: PropTypes.bool, 131 // The currently active (keyboard) item, if any such item exists. 132 active: PropTypes.string, 133 // Custom filtering callback 134 onFilter: PropTypes.func, 135 // Custom sorting callback 136 onSort: PropTypes.func, 137 // Enable bucketing for large arrays 138 bucketLargeArrays: PropTypes.bool, 139 // Custom row click callback 140 onClickRow: PropTypes.func, 141 // Row context menu event handler 142 onContextMenuRow: PropTypes.func, 143 // Tree context menu event handler 144 onContextMenuTree: PropTypes.func, 145 // A header is displayed if set to true 146 header: PropTypes.bool, 147 // Long string is expandable by a toggle button 148 expandableStrings: PropTypes.bool, 149 // Length at which a string is considered long 150 maxStringLength: PropTypes.number, 151 // Array of columns 152 columns: PropTypes.arrayOf( 153 PropTypes.shape({ 154 id: PropTypes.string.isRequired, 155 title: PropTypes.string, 156 width: PropTypes.string, 157 }) 158 ), 159 }; 160 } 161 162 static get defaultProps() { 163 return getDefaultProps(); 164 } 165 166 static subPath(path, subKey) { 167 return path + "/" + String(subKey).replace(/[\\/]/g, "\\$&"); 168 } 169 170 /** 171 * Creates a set with the paths of the nodes that should be expanded by default 172 * according to the passed options. 173 * 174 * @param {object} The root node of the tree. 175 * @param {object} [optional] An object with the following optional parameters: 176 * - maxLevel: nodes nested deeper than this level won't be expanded. 177 * - maxNodes: maximum number of nodes that can be expanded. The traversal is 178 breadth-first, so expanding nodes nearer to the root will be preferred. 179 Sibling nodes will either be all expanded or none expanded. 180 * } 181 */ 182 static getExpandedNodes( 183 rootObj, 184 { maxLevel = Infinity, maxNodes = Infinity } = {} 185 ) { 186 const expandedNodes = new Set(); 187 const queue = [ 188 { 189 object: rootObj, 190 level: 1, 191 path: "", 192 }, 193 ]; 194 while (queue.length) { 195 const { object, level, path } = queue.shift(); 196 if (Object(object) !== object) { 197 continue; 198 } 199 const keys = Object.keys(object); 200 if (expandedNodes.size + keys.length > maxNodes) { 201 // Avoid having children half expanded. 202 break; 203 } 204 for (const key of keys) { 205 const nodePath = TreeView.subPath(path, key); 206 expandedNodes.add(nodePath); 207 if (level < maxLevel) { 208 queue.push({ 209 object: object[key], 210 level: level + 1, 211 path: nodePath, 212 }); 213 } 214 } 215 } 216 return expandedNodes; 217 } 218 219 constructor(props) { 220 super(props); 221 222 this.state = { 223 expandedNodes: props.expandedNodes, 224 columns: ensureDefaultColumn(props.columns), 225 selected: props.selected, 226 active: props.active, 227 lastSelectedIndex: props.defaultSelectFirstNode ? 0 : null, 228 mouseDown: false, 229 }; 230 231 this.treeRef = createRef(); 232 233 this.toggle = this.toggle.bind(this); 234 this.isExpanded = this.isExpanded.bind(this); 235 this.onFocus = this.onFocus.bind(this); 236 this.onKeyDown = this.onKeyDown.bind(this); 237 this.onClickRow = this.onClickRow.bind(this); 238 this.getSelectedRow = this.getSelectedRow.bind(this); 239 this.selectRow = this.selectRow.bind(this); 240 this.activateRow = this.activateRow.bind(this); 241 this.isSelected = this.isSelected.bind(this); 242 this.onFilter = this.onFilter.bind(this); 243 this.onSort = this.onSort.bind(this); 244 this.getMembers = this.getMembers.bind(this); 245 this.renderRows = this.renderRows.bind(this); 246 } 247 248 // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 249 UNSAFE_componentWillReceiveProps(nextProps) { 250 const { expandedNodes, selected } = nextProps; 251 const state = { 252 expandedNodes, 253 lastSelectedIndex: this.getSelectedRowIndex(), 254 }; 255 256 if (selected) { 257 state.selected = selected; 258 } 259 260 this.setState(Object.assign({}, this.state, state)); 261 } 262 263 shouldComponentUpdate(nextProps, nextState) { 264 const { 265 expandedNodes, 266 columns, 267 selected, 268 active, 269 lastSelectedIndex, 270 mouseDown, 271 } = this.state; 272 273 return ( 274 expandedNodes !== nextState.expandedNodes || 275 columns !== nextState.columns || 276 selected !== nextState.selected || 277 active !== nextState.active || 278 lastSelectedIndex !== nextState.lastSelectedIndex || 279 mouseDown === nextState.mouseDown 280 ); 281 } 282 283 componentDidUpdate() { 284 const selected = this.getSelectedRow(); 285 if (selected || this.state.active) { 286 return; 287 } 288 289 const rows = this.visibleRows; 290 if (rows.length === 0) { 291 return; 292 } 293 294 // Only select a row if there is a previous lastSelected Index 295 // This mostly happens when the treeview is loaded the first time 296 if (this.state.lastSelectedIndex !== null) { 297 this.selectRow( 298 rows[Math.min(this.state.lastSelectedIndex, rows.length - 1)], 299 { alignTo: "top" } 300 ); 301 } 302 } 303 304 /** 305 * Get rows that are currently visible. Some rows can be filtered and made 306 * invisible, in which case, when navigating around the tree we need to 307 * ignore the ones that are not reachable by the user. 308 */ 309 get visibleRows() { 310 return this.rows.filter(row => { 311 const rowEl = findDOMNode(row); 312 return rowEl?.offsetParent; 313 }); 314 } 315 316 // Node expand/collapse 317 318 toggle(nodePath) { 319 const nodes = this.state.expandedNodes; 320 if (this.isExpanded(nodePath)) { 321 nodes.delete(nodePath); 322 } else { 323 nodes.add(nodePath); 324 } 325 326 // Compute new state and update the tree. 327 this.setState( 328 Object.assign({}, this.state, { 329 expandedNodes: nodes, 330 }) 331 ); 332 } 333 334 isExpanded(nodePath) { 335 return this.state.expandedNodes.has(nodePath); 336 } 337 338 // Event Handlers 339 340 onFocus(_event) { 341 if (this.state.mouseDown) { 342 return; 343 } 344 // Set focus to the first element, if none is selected or activated 345 // This is needed because keyboard navigation won't work without an element being selected 346 this.componentDidUpdate(); 347 } 348 349 // eslint-disable-next-line complexity 350 onKeyDown(event) { 351 const keyEligibleForFirstLetterNavigation = event.key.length === 1; 352 if ( 353 (!SUPPORTED_KEYS.includes(event.key) && 354 !keyEligibleForFirstLetterNavigation) || 355 event.shiftKey || 356 event.ctrlKey || 357 event.metaKey || 358 event.altKey 359 ) { 360 return; 361 } 362 363 const row = this.getSelectedRow(); 364 if (!row) { 365 return; 366 } 367 368 const rows = this.visibleRows; 369 const index = rows.indexOf(row); 370 const { hasChildren, open } = row.props.member; 371 372 switch (event.key) { 373 case "ArrowRight": 374 if (hasChildren) { 375 if (open) { 376 const firstChildRow = this.rows 377 .slice(index + 1) 378 .find(r => r.props.member.level > row.props.member.level); 379 if (firstChildRow) { 380 this.selectRow(firstChildRow, { alignTo: "bottom" }); 381 } 382 } else { 383 this.toggle(this.state.selected); 384 } 385 } 386 break; 387 case "ArrowLeft": 388 if (hasChildren && open) { 389 this.toggle(this.state.selected); 390 } else { 391 const parentRow = rows 392 .slice(0, index) 393 .reverse() 394 .find(r => r.props.member.level < row.props.member.level); 395 if (parentRow) { 396 this.selectRow(parentRow, { alignTo: "top" }); 397 } 398 } 399 break; 400 case "ArrowDown": { 401 const nextRow = rows[index + 1]; 402 if (nextRow) { 403 this.selectRow(nextRow, { alignTo: "bottom" }); 404 } 405 break; 406 } 407 case "ArrowUp": { 408 const previousRow = rows[index - 1]; 409 if (previousRow) { 410 this.selectRow(previousRow, { alignTo: "top" }); 411 } 412 break; 413 } 414 case "Home": { 415 const firstRow = rows[0]; 416 417 if (firstRow) { 418 this.selectRow(firstRow, { alignTo: "top" }); 419 } 420 break; 421 } 422 case "End": { 423 const lastRow = rows[rows.length - 1]; 424 if (lastRow) { 425 this.selectRow(lastRow, { alignTo: "bottom" }); 426 } 427 break; 428 } 429 case "Enter": 430 case " ": 431 // On space or enter make selected row active. This means keyboard 432 // focus handling is passed on to the tree row itself. 433 if (this.treeRef.current === document.activeElement) { 434 event.stopPropagation(); 435 event.preventDefault(); 436 if (this.state.active !== this.state.selected) { 437 this.activateRow(this.state.selected); 438 } 439 440 return; 441 } 442 break; 443 case "Escape": 444 event.stopPropagation(); 445 if (this.state.active != null) { 446 this.activateRow(null); 447 } 448 break; 449 } 450 451 if (keyEligibleForFirstLetterNavigation) { 452 const next = rows 453 .slice(index + 1) 454 .find(r => r.props.member.name.startsWith(event.key)); 455 if (next) { 456 this.selectRow(next, { alignTo: "bottom" }); 457 } 458 } 459 460 // Focus should always remain on the tree container itself. 461 this.treeRef.current.focus(); 462 event.preventDefault(); 463 } 464 465 onClickRow(nodePath, event) { 466 const onClickRow = this.props.onClickRow; 467 const row = this.visibleRows.find(r => r.props.member.path === nodePath); 468 469 // Call custom click handler and bail out if it returns true. 470 if ( 471 onClickRow && 472 onClickRow.call(this, nodePath, event, row.props.member) 473 ) { 474 return; 475 } 476 477 event.stopPropagation(); 478 479 const cell = event.target.closest("td"); 480 if (cell && cell.classList.contains("treeLabelCell")) { 481 this.toggle(nodePath); 482 } 483 484 this.selectRow(row, { preventAutoScroll: true }); 485 } 486 487 onContextMenu(member, event) { 488 const onContextMenuRow = this.props.onContextMenuRow; 489 if (onContextMenuRow) { 490 onContextMenuRow.call(this, member, event); 491 } 492 } 493 494 getSelectedRow() { 495 const rows = this.visibleRows; 496 if (!this.state.selected || rows.length === 0) { 497 return null; 498 } 499 return rows.find(row => this.isSelected(row.props.member.path)); 500 } 501 502 getSelectedRowIndex() { 503 const row = this.getSelectedRow(); 504 if (!row) { 505 return this.props.defaultSelectFirstNode ? 0 : null; 506 } 507 508 return this.visibleRows.indexOf(row); 509 } 510 511 _scrollIntoView(row, options = {}) { 512 const treeEl = this.treeRef.current; 513 if (!treeEl || !row) { 514 return; 515 } 516 517 const { props: { member: { path } = {} } = {} } = row; 518 if (!path) { 519 return; 520 } 521 522 const element = treeEl.ownerDocument.getElementById(path); 523 if (!element) { 524 return; 525 } 526 527 scrollIntoView(element, { ...options }); 528 } 529 530 selectRow(row, options = {}) { 531 const { props: { member: { path } = {} } = {} } = row; 532 if (this.isSelected(path)) { 533 return; 534 } 535 536 if (this.state.active != null) { 537 const treeEl = this.treeRef.current; 538 if (treeEl && treeEl !== treeEl.ownerDocument.activeElement) { 539 treeEl.focus(); 540 } 541 } 542 543 if (!options.preventAutoScroll) { 544 this._scrollIntoView(row, options); 545 } 546 547 this.setState({ 548 ...this.state, 549 selected: path, 550 active: null, 551 }); 552 } 553 554 activateRow(active) { 555 this.setState({ 556 ...this.state, 557 active, 558 }); 559 } 560 561 isSelected(nodePath) { 562 return nodePath === this.state.selected; 563 } 564 565 isActive(nodePath) { 566 return nodePath === this.state.active; 567 } 568 569 // Filtering & Sorting 570 571 /** 572 * Filter out nodes that don't correspond to the current filter. 573 * 574 * @return {boolean} true if the node should be visible otherwise false. 575 */ 576 onFilter(object) { 577 const onFilter = this.props.onFilter; 578 return onFilter ? onFilter(object) : true; 579 } 580 581 onSort(parent, children) { 582 const onSort = this.props.onSort; 583 return onSort ? onSort(parent, children) : children; 584 } 585 586 // Members 587 588 /** 589 * Return children node objects (so called 'members') for given 590 * parent object. 591 */ 592 getMembers(parent, level, path) { 593 // Strings don't have children. Note that 'long' strings are using 594 // the expander icon (+/-) to display the entire original value, 595 // but there are no child items. 596 if (typeof parent == "string") { 597 return []; 598 } 599 600 const { expandableStrings, provider, bucketLargeArrays, maxStringLength } = 601 this.props; 602 let children = provider.getChildren(parent, { bucketLargeArrays }) || []; 603 604 // If the return value is non-array, the children 605 // are being loaded asynchronously. 606 if (!Array.isArray(children)) { 607 return children; 608 } 609 610 children = this.onSort(parent, children) || children; 611 612 return children.map(child => { 613 const key = provider.getKey(child); 614 const nodePath = TreeView.subPath(path, key); 615 const type = provider.getType(child); 616 let hasChildren = provider.hasChildren(child); 617 618 // Value with no column specified is used for optimization. 619 // The row is re-rendered only if this value changes. 620 // Value for actual column is get when a cell is rendered. 621 const value = provider.getValue(child); 622 623 if (expandableStrings && isLongString(value, maxStringLength)) { 624 hasChildren = true; 625 } 626 627 // Return value is a 'member' object containing meta-data about 628 // tree node. It describes node label, value, type, etc. 629 return { 630 // An object associated with this node. 631 object: child, 632 // A label for the child node 633 name: provider.getLabel(child), 634 // Data type of the child node (used for CSS customization) 635 type, 636 // Class attribute computed from the type. 637 rowClass: "treeRow-" + type, 638 // Level of the child within the hierarchy (top == 0) 639 level: provider.getLevel ? provider.getLevel(child, level) : level, 640 // True if this node has children. 641 hasChildren, 642 // Value associated with this node (as provided by the data provider) 643 value, 644 // True if the node is expanded. 645 open: this.isExpanded(nodePath), 646 // Node path 647 path: nodePath, 648 // True if the node is hidden (used for filtering) 649 hidden: !this.onFilter(child), 650 // True if the node is selected with keyboard 651 selected: this.isSelected(nodePath), 652 // True if the node is activated with keyboard 653 active: this.isActive(nodePath), 654 }; 655 }); 656 } 657 658 /** 659 * Render tree rows/nodes. 660 */ 661 renderRows(parent, level = 0, path = "") { 662 let rows = []; 663 const decorator = this.props.decorator; 664 let renderRow = this.props.renderRow || TreeRow; 665 666 // Get children for given parent node, iterate over them and render 667 // a row for every one. Use row template (a component) from properties. 668 // If the return value is non-array, the children are being loaded 669 // asynchronously. 670 const members = this.getMembers(parent, level, path); 671 if (!Array.isArray(members)) { 672 return members; 673 } 674 675 members.forEach(member => { 676 if (decorator?.renderRow) { 677 renderRow = decorator.renderRow(member.object) || renderRow; 678 } 679 680 const props = Object.assign({}, this.props, { 681 key: `${member.path}-${member.active ? "active" : "inactive"}`, 682 member, 683 columns: this.state.columns, 684 id: member.path, 685 ref: row => row && this.rows.push(row), 686 onClick: this.onClickRow.bind(this, member.path), 687 onContextMenu: this.onContextMenu.bind(this, member), 688 }); 689 690 // Render single row. 691 rows.push(renderRow(props)); 692 693 // If a child node is expanded render its rows too. 694 if (member.hasChildren && member.open) { 695 const childRows = this.renderRows( 696 member.object, 697 level + 1, 698 member.path 699 ); 700 701 // If children needs to be asynchronously fetched first, 702 // set 'loading' property to the parent row. Otherwise 703 // just append children rows to the array of all rows. 704 if (!Array.isArray(childRows)) { 705 const lastIndex = rows.length - 1; 706 props.member.loading = true; 707 rows[lastIndex] = cloneElement(rows[lastIndex], props); 708 } else { 709 rows = rows.concat(childRows); 710 } 711 } 712 }); 713 714 return rows; 715 } 716 717 render() { 718 const root = this.props.object; 719 const classNames = ["treeTable"]; 720 this.rows = []; 721 722 const { className, onContextMenuTree } = this.props; 723 // Use custom class name from props. 724 if (className) { 725 classNames.push(...className.split(" ")); 726 } 727 728 // Alright, let's render all tree rows. The tree is one big <table>. 729 let rows = this.renderRows(root, 0, ""); 730 731 // This happens when the view needs to do initial asynchronous 732 // fetch for the root object. The tree might provide a hook API 733 // for rendering animated spinner (just like for tree nodes). 734 if (!Array.isArray(rows)) { 735 rows = []; 736 } 737 738 const props = Object.assign({}, this.props, { 739 columns: this.state.columns, 740 }); 741 742 return dom.table( 743 { 744 className: classNames.join(" "), 745 role: "tree", 746 ref: this.treeRef, 747 tabIndex: 0, 748 onFocus: this.onFocus, 749 onKeyDown: this.onKeyDown, 750 onContextMenu: onContextMenuTree && onContextMenuTree.bind(this), 751 onMouseDown: () => this.setState({ mouseDown: true }), 752 onMouseUp: () => this.setState({ mouseDown: false }), 753 onClick: () => { 754 // Focus should always remain on the tree container itself. 755 this.treeRef.current.focus(); 756 }, 757 onBlur: event => { 758 if (this.state.active != null) { 759 const { relatedTarget } = event; 760 if (!this.treeRef.current.contains(relatedTarget)) { 761 this.activateRow(null); 762 } 763 } 764 }, 765 "aria-label": this.props.label || "", 766 "aria-activedescendant": this.state.selected, 767 cellPadding: 0, 768 cellSpacing: 0, 769 }, 770 TreeHeader(props), 771 dom.tbody( 772 { 773 role: "presentation", 774 tabIndex: -1, 775 }, 776 rows 777 ) 778 ); 779 } 780 } 781 782 // Helpers 783 784 /** 785 * There should always be at least one column (the one with toggle buttons) 786 * and this function ensures that it's true. 787 */ 788 function ensureDefaultColumn(columns) { 789 if (!columns) { 790 columns = []; 791 } 792 793 const defaultColumn = columns.filter(col => col.id == "default"); 794 if (defaultColumn.length) { 795 return columns; 796 } 797 798 // The default column is usually the first one. 799 return [{ id: "default" }, ...columns]; 800 } 801 802 function isLongString(value, maxStringLength) { 803 return typeof value == "string" && value.length > maxStringLength; 804 } 805 806 // Exports from this module 807 export default TreeView;