diff options
Diffstat (limited to 'devtools/client/shared/components/tree/tree-view.js')
-rw-r--r-- | devtools/client/shared/components/tree/tree-view.js | 352 |
1 files changed, 352 insertions, 0 deletions
diff --git a/devtools/client/shared/components/tree/tree-view.js b/devtools/client/shared/components/tree/tree-view.js new file mode 100644 index 000000000..9fae9addb --- /dev/null +++ b/devtools/client/shared/components/tree/tree-view.js @@ -0,0 +1,352 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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) { + // ReactJS + const React = require("devtools/client/shared/vendor/react"); + + // Reps + const { ObjectProvider } = require("./object-provider"); + const TreeRow = React.createFactory(require("./tree-row")); + const TreeHeader = React.createFactory(require("./tree-header")); + + // Shortcuts + const DOM = React.DOM; + const PropTypes = React.PropTypes; + + /** + * This component represents a tree view with expandable/collapsible nodes. + * The tree is rendered using <table> element where every node is represented + * by <tr> 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); + * 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); + * } + */ + let TreeView = React.createClass({ + displayName: "TreeView", + + // The only required property (not set by default) is the input data + // object that is used to puputate the tree. + propTypes: { + // The input data object. + object: PropTypes.any, + className: 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, + 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 renderef + renderValue: PropTypes.func, + // Custom tree label (including a toggle button) renderer + renderLabelCell: PropTypes.func, + // Set of expanded nodes + expandedNodes: PropTypes.object, + // Custom filtering callback + onFilter: PropTypes.func, + // Custom sorting callback + onSort: PropTypes.func, + // A header is displayed if set to true + header: PropTypes.bool, + // Array of columns + columns: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.string.isRequired, + title: PropTypes.string, + width: PropTypes.string + })) + }, + + getDefaultProps: function () { + return { + object: null, + renderRow: null, + provider: ObjectProvider, + expandedNodes: new Set(), + columns: [] + }; + }, + + getInitialState: function () { + return { + expandedNodes: this.props.expandedNodes, + columns: ensureDefaultColumn(this.props.columns) + }; + }, + + // Node expand/collapse + + toggle: function (nodePath) { + let 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: function (nodePath) { + return this.state.expandedNodes.has(nodePath); + }, + + // Event Handlers + + onClickRow: function (nodePath, event) { + event.stopPropagation(); + this.toggle(nodePath); + }, + + // 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: function (object) { + let onFilter = this.props.onFilter; + return onFilter ? onFilter(object) : true; + }, + + onSort: function (parent, children) { + let onSort = this.props.onSort; + return onSort ? onSort(parent, children) : children; + }, + + // Members + + /** + * Return children node objects (so called 'members') for given + * parent object. + */ + getMembers: function (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 []; + } + + let provider = this.props.provider; + 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 => { + let key = provider.getKey(child); + let nodePath = path + "/" + key; + let 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. + let value = provider.getValue(child); + + if (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: type, + // Class attribute computed from the type. + rowClass: "treeRow-" + type, + // Level of the child within the hierarchy (top == 0) + level: level, + // True if this node has children. + hasChildren: hasChildren, + // Value associated with this node (as provided by the data provider) + value: 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) + }; + }); + }, + + /** + * Render tree rows/nodes. + */ + renderRows: function (parent, level = 0, path = "") { + let rows = []; + let 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. + let members = this.getMembers(parent, level, path); + if (!Array.isArray(members)) { + return members; + } + + members.forEach(member => { + if (decorator && decorator.renderRow) { + renderRow = decorator.renderRow(member.object) || renderRow; + } + + let props = Object.assign({}, this.props, { + key: member.path, + member: member, + columns: this.state.columns, + onClick: this.onClickRow.bind(this, member.path) + }); + + // Render single row. + rows.push(renderRow(props)); + + // If a child node is expanded render its rows too. + if (member.hasChildren && member.open) { + let 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)) { + let lastIndex = rows.length - 1; + props.member.loading = true; + rows[lastIndex] = React.cloneElement(rows[lastIndex], props); + } else { + rows = rows.concat(childRows); + } + } + }); + + return rows; + }, + + render: function () { + let root = this.props.object; + let classNames = ["treeTable"]; + + // Use custom class name from props. + let className = this.props.className; + if (className) { + classNames.push(...className.split(" ")); + } + + // Alright, let's render all tree rows. The tree is one big <table>. + 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 = []; + } + + let props = Object.assign({}, this.props, { + columns: this.state.columns + }); + + return ( + DOM.table({ + className: classNames.join(" "), + cellPadding: 0, + cellSpacing: 0}, + TreeHeader(props), + DOM.tbody({}, + 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 = []; + } + + let 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; +}); |