mirror of
https://github.com/mozilla/gecko-dev.git
synced 2024-10-20 16:55:40 +00:00
8c5caa6670
MozReview-Commit-ID: G2G0xY2S2Ij --HG-- extra : rebase_source : 100169a07499f2399dfc7ab2ff556cdf52aa4013
363 lines
11 KiB
JavaScript
363 lines
11 KiB
JavaScript
/* -*- 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,
|
|
// 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
|
|
}))
|
|
},
|
|
|
|
getDefaultProps: function () {
|
|
return {
|
|
object: null,
|
|
renderRow: null,
|
|
provider: ObjectProvider,
|
|
expandedNodes: new Set(),
|
|
expandableStrings: true,
|
|
columns: []
|
|
};
|
|
},
|
|
|
|
getInitialState: function () {
|
|
return {
|
|
expandedNodes: this.props.expandedNodes,
|
|
columns: ensureDefaultColumn(this.props.columns)
|
|
};
|
|
},
|
|
|
|
componentWillReceiveProps: function (nextProps) {
|
|
let { expandedNodes } = nextProps;
|
|
this.setState(Object.assign({}, this.state, {
|
|
expandedNodes,
|
|
}));
|
|
},
|
|
|
|
// 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 { 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 => {
|
|
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 (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: 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;
|
|
});
|