-
Notifications
You must be signed in to change notification settings - Fork 758
Sources Tree View #157
Changes from all commits
9446c47
e630fc2
953144a
b4cc9ed
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
public/js/lib/tree.js |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,70 +1,156 @@ | ||
"use strict"; | ||
|
||
const React = require("react"); | ||
const { DOM: dom, PropTypes } = React; | ||
const { bindActionCreators } = require("redux"); | ||
const { connect } = require("react-redux"); | ||
const classnames = require("classnames"); | ||
const ImPropTypes = require("react-immutable-proptypes"); | ||
const ManagedTree = React.createFactory(require("./util/ManagedTree")); | ||
const { Set } = require("immutable"); | ||
const actions = require("../actions"); | ||
const { getSelectedSource } = require("../selectors"); | ||
const { DOM: dom } = React; | ||
const Isvg = React.createFactory(require("react-inlinesvg")); | ||
const { getSelectedSource, getSources } = require("../selectors"); | ||
const { | ||
createNode, nodeHasChildren, nodeName, | ||
nodeContents, nodePath, createParentMap, | ||
addToTree | ||
} = require("../util/sources-tree.js"); | ||
|
||
require("./Sources.css"); | ||
|
||
function isSelected(selectedSource, source) { | ||
return selectedSource && selectedSource.get("actor") == source.get("actor"); | ||
} | ||
// This is inline because it's much faster. We need to revisit how we | ||
// load SVGs, at least for components that render them several times. | ||
let Arrow = (props) => { | ||
return dom.span( | ||
props, | ||
dom.svg( | ||
{ viewBox: "0 0 16 16" }, | ||
dom.path({ d: "M8 13.4c-.5 0-.9-.2-1.2-.6L.4 5.2C0 4.7-.1 4.3.2 3.7S1 3 1.6 3h12.8c.6 0 1.2.1 1.4.7.3.6.2 1.1-.2 1.6l-6.4 7.6c-.3.4-.7.5-1.2.5z" }) // eslint-disable-line max-len | ||
) | ||
); | ||
}; | ||
Arrow = React.createFactory(Arrow); | ||
|
||
function renderSource({ source, selectSource, selectedSource }) { | ||
const pathname = source.get("pathname"); | ||
const selectedClass = isSelected(selectedSource, source) ? "selected" : ""; | ||
let SourcesTree = React.createClass({ | ||
propTypes: { | ||
sources: ImPropTypes.map.isRequired, | ||
selectSource: PropTypes.func.isRequired | ||
}, | ||
|
||
return dom.li( | ||
{ onClick: () => selectSource(source.toJS()), | ||
className: `source-item ${selectedClass}`, | ||
style: { paddingLeft: "20px" }, | ||
key: source.get("url") }, | ||
makeInitialState(props) { | ||
const tree = createNode("root", "", []); | ||
for (let source of props.sources.valueSeq()) { | ||
addToTree(tree, source); | ||
} | ||
|
||
Isvg({ src: "images/angle-brackets.svg" }), | ||
dom.span({ className: "label" }, pathname) | ||
); | ||
} | ||
return { sourceTree: tree, | ||
parentMap: createParentMap(tree), | ||
focusedItem: null }; | ||
}, | ||
|
||
/** | ||
* Takes a sources object indexed by actor and | ||
* returns a sources object indexed by source domain. | ||
* | ||
* @returns Object | ||
*/ | ||
function groupSourcesByDomain(sources) { | ||
return sources.valueSeq() | ||
.filter(source => !!source.get("url")) | ||
.groupBy(source => (new URL(source.get("url"))).hostname); | ||
} | ||
getInitialState() { | ||
return this.makeInitialState(this.props); | ||
}, | ||
|
||
componentWillReceiveProps(nextProps) { | ||
if (nextProps.sources !== this.props.sources) { | ||
if (nextProps.sources.size === 0) { | ||
this.setState(this.makeInitialState(nextProps)); | ||
return; | ||
} | ||
|
||
const next = Set(nextProps.sources.valueSeq()); | ||
const prev = Set(this.props.sources.valueSeq()); | ||
const newSet = next.subtract(prev); | ||
|
||
const tree = this.state.sourceTree; | ||
for (let source of newSet) { | ||
addToTree(tree, source); | ||
} | ||
|
||
this.setState({ sourceTree: tree, | ||
parentMap: createParentMap(tree) }); | ||
} | ||
}, | ||
|
||
focusItem(item) { | ||
this.setState({ focusedItem: item }); | ||
}, | ||
|
||
selectItem(item) { | ||
if (!nodeHasChildren(item)) { | ||
this.props.selectSource(nodeContents(item).toJS()); | ||
} | ||
}, | ||
|
||
renderItem(item, depth, focused, _, expanded, { setExpanded }) { | ||
const arrow = Arrow({ | ||
className: classnames( | ||
"arrow", | ||
{ expanded: expanded, | ||
hidden: !nodeHasChildren(item) } | ||
), | ||
onClick: e => { | ||
e.stopPropagation(); | ||
setExpanded(item, !expanded); | ||
} | ||
}); | ||
|
||
return dom.div( | ||
{ className: classnames("node", { focused }), | ||
style: { marginLeft: depth * 15 + "px" }, | ||
onClick: () => this.selectItem(item), | ||
onDoubleClick: e => { | ||
setExpanded(item, !expanded); | ||
} }, | ||
arrow, | ||
nodeName(item) | ||
); | ||
}, | ||
|
||
render() { | ||
const { focusedItem, sourceTree, parentMap } = this.state; | ||
|
||
const tree = ManagedTree({ | ||
getParent: item => { | ||
return parentMap.get(item); | ||
}, | ||
getChildren: item => { | ||
if (nodeHasChildren(item)) { | ||
return nodeContents(item); | ||
} | ||
return []; | ||
}, | ||
getRoots: () => nodeContents(sourceTree), | ||
getKey: (item, i) => nodePath(item), | ||
itemHeight: 30, | ||
autoExpandDepth: 2, | ||
onFocus: this.focusItem, | ||
renderItem: this.renderItem | ||
}); | ||
|
||
return dom.div({ | ||
className: "sources-list", | ||
onKeyDown: e => { | ||
if (e.keyCode === 13 && focusedItem) { | ||
this.selectItem(focusedItem); | ||
} | ||
} | ||
}, tree); | ||
} | ||
}); | ||
SourcesTree = React.createFactory(SourcesTree); | ||
|
||
function Sources({ sources, selectSource, selectedSource }) { | ||
const sourcesByDomain = groupSourcesByDomain(sources); | ||
|
||
return dom.div({ className: "sources-panel" }, | ||
dom.div( | ||
{ className: "sources-header" } | ||
), | ||
dom.ul( | ||
{ className: "sources-list" }, | ||
sourcesByDomain.keySeq().map((domain) => { | ||
return dom.li({ key: domain, className: "domain" }, | ||
Isvg({ src: "images/globe.svg" }), | ||
dom.span({ className: "label" }, domain), | ||
dom.ul(null, | ||
sourcesByDomain.get(domain).map(source => renderSource({ | ||
source, selectSource, selectedSource })) | ||
) | ||
); | ||
}) | ||
) | ||
return dom.div( | ||
{ className: "sources-panel" }, | ||
dom.div({ className: "sources-header" }), | ||
SourcesTree({ sources, selectSource }) | ||
); | ||
} | ||
|
||
module.exports = connect( | ||
state => ({ selectedSource: getSelectedSource(state) }), | ||
state => ({ selectedSource: getSelectedSource(state), | ||
sources: getSources(state) }), | ||
dispatch => bindActionCreators(actions, dispatch) | ||
)(Sources); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,58 @@ | ||
"use strict"; | ||
|
||
const React = require("react"); | ||
const Tree = React.createFactory(require("../../lib/tree")); | ||
|
||
let ManagedTree = React.createClass({ | ||
propTypes: Tree.propTypes, | ||
|
||
getInitialState() { | ||
return { expanded: new WeakMap(), | ||
focusedItem: null }; | ||
}, | ||
|
||
setExpanded(item, expanded) { | ||
const e = this.state.expanded; | ||
e.set(item, expanded); | ||
this.setState({ expanded: e }); | ||
|
||
if (expanded && this.props.onExpand) { | ||
this.props.onExpand(item); | ||
} else if (!expanded && this.props.onCollapse) { | ||
this.props.onCollapse(item); | ||
} | ||
}, | ||
|
||
focusItem(item) { | ||
if (this.state.focused !== item) { | ||
this.setState({ focusedItem: item }); | ||
|
||
if (this.props.onFocus) { | ||
this.props.onFocus(item); | ||
} | ||
} | ||
}, | ||
|
||
render() { | ||
const { expanded, focusedItem } = this.state; | ||
|
||
const props = Object.assign({}, this.props, { | ||
isExpanded: item => expanded.get(item), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think some code comments here would be really helpful on why this ManagedTree is needed and how some of the handlers are flowing through. It's a little hard to un-pack what is going on here, even though I know you mentioned to me briefly that you needed this. I'm finding the props on here to be particularly confusing. It somewhat breaks the metaphor in my head of how props get passed in from above, because this component is essentially shadowing the the parent's props. The only two properties that are overwritten are the I don't know if things could be refactored a little bit to make it clearer what's doing what... It could just be a matter of being more vigorous with comments for some context. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's essentially a higher-order component. It's quite useful to do this sort of stuff in React, but how we use it definitely should be documented. The Tree component that we use has no component state (well, right now I think it does track the window height but I want to remove that). It doesn't keep track of the current focused item, or which nodes are expanded. It's all up to the consumer to handle that. It's nice to do it that way for several reasons: it makes tests easier to write, and it allows the consumer to store that info somewhere else like redux if they want to. But it puts a lot of burden on the user, especially if they are trying to learn how to use the tree. This is a common problem with components: who manages the state? A good technique is to offer two variants of the same component: one that doesn't manage the state, and one that does (here, we are calling it "managed"). So we're just wrapping the Tree widget (so we can't change the prop names, we are passing all of that into the Tree) and handling the necessary state for it to work. In practice, React has more going on than just "props passed down, events go up". For ease of use it's local component state is really nice. Also, there's really no reason it has to be separated from Redux state. At some point React should allow the consumer to "mount" the internal state of any component somewhere else; you can read about it in this React issue. Anywhere, we're still figuring out the best patterns here and we'll definitely document them more. |
||
focused: focusedItem, | ||
|
||
onExpand: item => this.setExpanded(item, true), | ||
onCollapse: item => this.setExpanded(item, false), | ||
onFocus: this.focusItem, | ||
|
||
renderItem: (...args) => { | ||
return this.props.renderItem(...args, { | ||
setExpanded: this.setExpanded | ||
}); | ||
} | ||
}); | ||
|
||
return Tree(props); | ||
} | ||
}); | ||
|
||
module.exports = ManagedTree; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FWIW, I didn't do this prematurely; I was using inline-svg before but it clearly slowed down the tree because it does an AJAX request for every single SVG, even if it's the same one. The library does not support caching, so I implemented it myself but still hit another problem. I think when you expanded a node, the arrows would not be in the children for a split second and it would "flash" into appearance, bumping the text to the right.
Anyway, this was very straight-forward and immediately worked really well. We may want to look into a SVG->React conversion tool.