/* -*- 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 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); * 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
. 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; });