@bigfishtv/cockpit
Version:
560 lines (508 loc) • 19.1 kB
JavaScript
import PropTypes from 'prop-types'
import React, { Component } from 'react'
import ReactDOM from 'react-dom'
import ImmutablePropTypes from 'react-immutable-proptypes'
import Immutable from 'immutable'
import deepEqual from 'deep-equal'
import keyCode from 'keycode'
import { flatten, flattenWithPath, flattenWithoutCollapsed, getChildByIdImmutable } from '../../utils/treeUtils'
import { isCtrlKeyPressed, isShiftKeyPressed } from '../../utils/selectKeyUtils'
import * as DragTypes from '../../constants/DragTypes'
import TreeItem from '../tree/TreeItem'
import DefaultTreeCell from '../tree/TreeCell'
import DefaultTreeDragLayer from '../tree/TreeDragLayer'
// this is referenced in TreeItem -- it needs to be provided as a prop for react-dnd decorator in order for it to be optionally replaced by tree props
const treeItemSource = {
beginDrag(props, monitor, component) {
props.beginDrag(props.id)
return { id: props.id, index: props.index }
},
endDrag(props, monitor, component) {
if (!monitor.didDrop()) {
props.endDrag()
}
},
}
// this is referenced in TreeItem -- it needs to be provided as a prop for react-dnd decorator in order for it to be optionally replaced by tree props
let expandTimeout = null
const EDGE_SIZE = 10
const PARENT_BELOW_EDGE_SIZE = 50
const treeItemTarget = {
drop(props, monitor, component) {
if (!props.reorderable) return
// goes through all drop targets and resets their forceExpand state so they don't randomly open upon drag if hover expanded
for (let key in monitor.internalMonitor.registry.handlers) {
if (key.charAt(0) == 'T') {
const item = monitor.internalMonitor.registry.handlers[key].component
if (item.state && item.state.forceExpand) {
item.props.onCollapse()
item.setState({ forceExpand: false })
}
}
}
if (monitor.isOver({ shallow: true })) {
const draggedId = monitor.getItem().id
const targetId = props.id
const position = component.state.position
props.endDrag(draggedId, targetId, position)
}
},
hover(props, monitor, component) {
if (!props.reorderable) return
const isOverCurrent = monitor.isOver({ shallow: true })
if (isOverCurrent) {
const ownId = props.id
const draggedId = monitor.getItem().id
if (draggedId === ownId || component.props.selected) return
const boundingRect = ReactDOM.findDOMNode(component).getBoundingClientRect()
const clientOffset = monitor.getClientOffset()
const offsetY = clientOffset.y - boundingRect.top
const rowHeight = boundingRect.bottom - boundingRect.top
const bottomEdgeSize =
!props.collapsed && props.children && props.children.size > 0 ? PARENT_BELOW_EDGE_SIZE : EDGE_SIZE
if (clientOffset.y > boundingRect.top && clientOffset.y < boundingRect.bottom) {
if (
clientOffset.y > boundingRect.top + EDGE_SIZE &&
clientOffset.y < boundingRect.top + rowHeight - bottomEdgeSize
) {
if (component.props.collapsed && component.state.position != 'into') {
if (expandTimeout !== null) clearTimeout(expandTimeout)
expandTimeout = setTimeout(() => {
component.setState({ forceExpand: true })
}, 1000)
}
component.setState({ position: 'into' })
} else if (offsetY < EDGE_SIZE) {
component.setState({ position: 'above' })
} else {
component.setState({ position: 'below' })
}
}
}
},
}
export default class Tree extends Component {
static propTypes = {
/** Tree data provided as an array of objects. Objects can contain key 'children' which contains the same data structure, repeat recursively */
value: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]),
/** Whether or not component needs to handle its own state e.g. selectedIds & collapsedIds */
uncontrolled: PropTypes.bool,
/** Whether or not to convert data to Immutable data -- changes what callback functions give */
immutable: PropTypes.bool,
/** Allow selection of multiple cells */
multiselect: PropTypes.bool,
/** Allow cells to be reordable */
reorderable: PropTypes.bool,
/** Enable stickSelect -- as if CTRL key were held down */
stickySelect: PropTypes.bool,
/** Wrap cells in breadcrumbs for easy navigation -- passed on to TreeItem */
breadcrumbs: PropTypes.bool,
/** Only applicable if component is controlled */
selectedIds: PropTypes.arrayOf(PropTypes.number),
/** Only applicable if component is controlled */
collapsedIds: PropTypes.arrayOf(PropTypes.number),
/** See Constants/dragTypes */
dropTargetType: PropTypes.string,
/** ReactDnD drag target functions, has defaults */
treeItemTarget: PropTypes.oneOfType([PropTypes.objectOf(PropTypes.func), PropTypes.func]),
/** ReactDnD drag source functions, has defaults */
treeItemSource: PropTypes.oneOfType([PropTypes.objectOf(PropTypes.func), PropTypes.func]),
/** Called on tree structure change e.g. re-order, delete. (Will return immutable data if prop is set) */
onChange: PropTypes.func,
/** Called on cell select, ie. double click. (Will return immutable data if prop is set) */
onSelectItem: PropTypes.func,
/** Called on cell selection, probably only useful if controlled. (Will return immutable data if prop is set) */
onSelectionChange: PropTypes.func,
/** Called on cell (de)collapse, probably only useful if controlled. (Will return immutable data if prop is set) */
onCollapseChange: PropTypes.func,
/** Is a combination of selectedItems and collapsedItems, probably only useful if controlled. (Will return immutable data if prop is set) */
onCombinationChange: PropTypes.func,
}
static defaultProps = {
value: [],
uncontrolled: false,
immutable: false,
multiselect: true,
reorderable: true,
stickySelect: true,
breadcrumbs: true,
selectedIds: [],
collapsedIds: [],
dropTargetType: DragTypes.TREE_ITEM,
dragTargetType: DragTypes.TREE_ITEM,
treeItemTarget,
treeItemSource,
TreeCell: DefaultTreeCell,
TreeDragLayer: DefaultTreeDragLayer,
onChange: () => console.warn('[Tree] no onChange prop provided'),
onSelectItem: () => console.warn('[Tree] no onSelectItem prop provided'),
onSelectionChange: () => console.warn('[Tree] no onSelectionChange prop provided'),
onCollapseChange: () => console.warn('[Tree] no onCollapseChange prop provided'),
onSelectionChange: () => console.warn('[Tree] no onSelectionChange prop provided'),
onCombinationChange: () => console.warn('[Tree] no onCombinationChange prop provided'),
}
constructor(props) {
super()
this.lastSelectedId = null
this.lastSelectedList = []
this.dragging = false
this.handleKeyDown = this.handleKeyDown.bind(this)
this.state = {
InflatedTree: props.immutable ? props.value : Immutable.fromJS(props.value),
selectedIds: props.selectedIds,
collapsedIds: props.collapsedIds,
}
}
componentDidMount() {
window.addEventListener('keydown', this.handleKeyDown)
}
componentWillUnmount() {
window.removeEventListener('keydown', this.handleKeyDown)
}
componentWillReceiveProps(nextProps) {
const treeEqual = this.props.immutable
? nextProps.value == this.props.value
: deepEqual(nextProps.value, this.props.value)
if (!treeEqual) {
this.setState({ InflatedTree: this.props.immutable ? nextProps.value : Immutable.fromJS(nextProps.value) })
}
if (!this.props.uncontrolled) {
this.setState({
selectedIds: nextProps.selectedIds,
collapsedIds: nextProps.collapsedIds,
})
}
}
handleKeyDown(event) {
const { immutable } = this.props
const { InflatedTree, selectedIds, collapsedIds } = this.state
if (!InflatedTree) return
const data = flattenWithoutCollapsed(InflatedTree.toJS(), collapsedIds)
if (!data || !data.length) return
const key = keyCode(event)
const activeElement = document.activeElement
if (!activeElement || (activeElement.nodeName !== 'INPUT' && activeElement.nodeName !== 'TEXTAREA')) {
if (key === 'up') {
event.preventDefault()
const index =
this.lastSelectedId === null
? data.length - 1
: data.reduce((value, row, i) => (row.id === this.lastSelectedId ? i - 1 : value), null)
const row = data[index <= 0 ? 0 : index]
this.handleSelect(immutable ? Immutable.Map(row) : row)
} else if (key === 'down') {
event.preventDefault()
const index =
this.lastSelectedId === null
? 0
: data.reduce((value, row, i) => (row.id === this.lastSelectedId ? i + 1 : value), null)
const row = data[index >= data.length - 1 ? data.length - 1 : index]
this.handleSelect(immutable ? Immutable.Map(row) : row)
} else if ((key === 'left' || key === 'right') && selectedIds.length) {
event.preventDefault()
const newCollapsedIds = collapsedIds.slice()
data.map(item => {
if (selectedIds.indexOf(item.id) >= 0) {
if (collapsedIds.indexOf(item.id) < 0) newCollapsedIds.push(item.id)
else newCollapsedIds.splice(collapsedIds.indexOf(item.id), 1)
}
})
if (!this.props.uncontrolled) {
this.props.onCollapseChange(newCollapsedIds)
} else {
this.setState({ collapsedIds: newCollapsedIds })
}
} else if (key === 'esc') {
event.preventDefault()
this.handleDeselectAll()
} else if (key === 'a' && isCtrlKeyPressed()) {
event.preventDefault()
this.handleSelectAll(data)
} else if (key === 'enter' && selectedIds.length === 1) {
const row = data.filter(item => item.id == selectedIds[0])[0]
this.handleDoubleClick(immutable ? Immutable.Map(row) : row)
}
}
}
beginDrag = id => {
if (!this.props.reorderable) return
this.dragging = true
const selectedIds = this.state.selectedIds.indexOf(id) >= 0 ? this.state.selectedIds : [id]
this.preDragCollapsedIds = this.state.collapsedIds.slice()
const collapsedIds = [...selectedIds, ...this.state.collapsedIds]
if (!this.props.uncontrolled) {
this.props.onCombinationChange({ collapsedIds, selectedIds })
} else {
this.setState({ selectedIds, collapsedIds })
}
}
endDrag = (draggedId, targetId, position) => {
if (!this.props.reorderable) return
this.dragging = false
// don't do anything if the dragged item ends up over an item being dragged
// or the drag is cancelled
if (!targetId || this.state.selectedIds.indexOf(targetId) > -1) {
if (!this.props.uncontrolled) {
this.props.onCollapseChange(this.preDragCollapsedIds)
} else {
this.setState({ collapsedIds: this.preDragCollapsedIds })
}
return
}
let newTree = this.state.InflatedTree
let FlatTree = flattenWithPath(newTree.toJS())
// find items matching selected ids
let draggedItems = FlatTree.filter(item => this.state.selectedIds.indexOf(item.item.id) >= 0)
// remove children from the dragged items list and order items in reverse order
draggedItems = draggedItems
.filter(item => {
return (
draggedItems.filter(item2 => {
if (item2.path.length < item.path.length) {
if (item2.path.join('') == item.path.slice(0, item2.path.length).join('')) {
return true
}
}
return false
}).length == 0
)
})
.sort((a, b) => {
a = a.path
b = b.path
const len = Math.min(a.length, b.length)
for (let i = 0; i < len; i++) {
if (a[i] < b[i]) {
return 1
} else if (a[i] > b[i]) {
return -1
}
}
return 0
})
draggedItems.forEach(draggedItem => {
newTree = newTree.deleteIn(draggedItem.path)
})
FlatTree = flattenWithPath(newTree.toJS())
const targetItem = getChildByIdImmutable(targetId, newTree)
const draggedItem = getChildByIdImmutable(draggedId, newTree)
const draggedParentId = (draggedItem && draggedItem.get('parent_id')) || null
const targetParentId = (targetItem && targetItem.get('parent_id')) || null
const targetPath = FlatTree.filter(item => item.item.id === targetId)[0].path
const targetParentPath = targetParentId ? FlatTree.filter(item => item.item.id === targetParentId)[0].path : null
const targetIndex = targetPath[targetPath.length - 1]
let children = null
let changes = []
let index = 0
switch (position) {
case 'into':
if (targetId == draggedParentId) {
break
}
draggedItems = draggedItems.reverse()
if (targetItem.get('children')) {
draggedItems.map((draggedItem, i) => {
index = targetItem.get('children').size + i
newTree = newTree.setIn(targetPath.concat(['children', index]), Immutable.fromJS(draggedItem.item))
changes.push({
id: draggedItem.item.id,
parent_id: targetId,
index,
})
})
} else {
newTree = newTree.setIn(
targetPath.concat(['children']),
Immutable.fromJS(draggedItems.map(draggedItem => draggedItem.item))
)
draggedItems.map((draggedItem, i) => {
changes.push({
id: draggedItem.item.id,
parent_id: targetId,
index: i,
})
})
}
break
case 'above':
if (!targetParentId) {
draggedItems.map((draggedItem, i) => {
newTree = newTree.splice(targetIndex, 0, Immutable.fromJS(draggedItem.item))
changes.push({
id: draggedItem.item.id,
parent_id: null,
index: targetIndex,
})
})
} else {
draggedItems.map((draggedItem, i) => {
children = newTree
.getIn(targetParentPath.concat(['children']))
.splice(targetIndex, 0, Immutable.fromJS(draggedItem.item))
newTree = newTree.setIn(targetParentPath.concat(['children']), children)
changes.push({
id: draggedItem.item.id,
parent_id: targetParentId,
index: targetIndex,
})
})
}
break
case 'below':
if (!targetParentId) {
draggedItems.map((draggedItem, i) => {
newTree = newTree.splice(targetIndex + 1, 0, Immutable.fromJS(draggedItem.item))
changes.push({
id: draggedItem.item.id,
parent_id: null,
index: targetIndex + 1,
})
})
} else {
draggedItems.map((draggedItem, i) => {
children = newTree
.getIn(targetParentPath.concat(['children']))
.splice(targetIndex + 1, 0, Immutable.fromJS(draggedItem.item))
newTree = newTree.setIn(targetParentPath.concat(['children']), children)
changes.push({
id: draggedItem.item.id,
parent_id: targetParentId,
index: targetIndex + 1,
})
})
}
break
}
if (!this.props.uncontrolled) {
this.props.onCollapseChange(this.preDragCollapsedIds)
this.props.onChange(this.props.immutable ? newTree : newTree.toJS(), changes)
} else {
this.setState({ collapsedIds: this.preDragCollapsedIds, InflatedTree: newTree })
}
}
handleSelect(item) {
const immutable = Immutable.Map.isMap(item)
const { multiselect, stickySelect } = this.props
let { selectedIds, InflatedTree } = this.state
const id = immutable ? item.get('id') : item.id
if (!multiselect || (!isCtrlKeyPressed() && !isShiftKeyPressed())) {
selectedIds = selectedIds.length === 1 && selectedIds[0] === id && !stickySelect ? [] : [id]
} else if (isCtrlKeyPressed()) {
if (selectedIds.indexOf(id) >= 0) {
selectedIds = selectedIds.filter(_id => _id !== id)
} else {
selectedIds = [...selectedIds, id]
}
} else if (isShiftKeyPressed()) {
const tree = flatten(immutable ? InflatedTree.toJS() : InflatedTree)
const lastIndex = tree.indexOf(tree.filter(item => item.id === this.lastSelectedId)[0])
const nextIndex = tree.indexOf(tree.filter(item => item.id === id)[0])
const lower = Math.min(lastIndex, nextIndex)
const upper = Math.max(lastIndex, nextIndex)
const ids = tree.filter((item, i) => i >= lower && i <= upper).map(item => item.id)
selectedIds = [...this.lastSelectedList, ...ids]
}
// remove duplicate ids
selectedIds = selectedIds.filter((val, i) => selectedIds.indexOf(val) === i)
this.lastSelectedId = id
this.lastSelectedList = selectedIds.slice()
if (this.props.uncontrolled) {
this.setState({ selectedIds })
}
this.props.onSelectionChange(selectedIds)
}
handleCollapse(item) {
const immutable = Immutable.Map.isMap(item)
const id = immutable ? item.get('id') : item.id
let collapsedIds = this.state.collapsedIds
if (collapsedIds.indexOf(id) >= 0) collapsedIds.splice(collapsedIds.indexOf(id), 1)
else collapsedIds.push(id)
if (!this.props.uncontrolled) {
this.props.onCollapseChange(collapsedIds)
} else {
this.setState({ collapsedIds })
}
}
handleDoubleClick(item) {
if (Immutable.Map.isMap(item) && !this.props.immutable) item = item.toJS()
else if (!Immutable.Map.isMap(item) && this.props.immutable) item = Immutable.Map(item)
this.props.onSelectItem && this.props.onSelectItem(item)
}
handleSelectAll(data = null) {
const { InflatedTree, collapsedIds } = this.state
data = data || flattenWithoutCollapsed(InflatedTree.toJS(), collapsedIds)
const selectedIds = data.map(item => item.id)
this.lastSelectedList = selectedIds.slice()
if (this.props.uncontrolled) {
this.setState({ selectedIds })
}
this.props.onSelectionChange(selectedIds)
}
handleDeselectAll() {
const selectedIds = []
this.lastSelectedList = []
if (this.props.uncontrolled) {
this.setState({ selectedIds })
}
this.props.onSelectionChange(selectedIds)
}
renderList(list) {
if (!list) return null
return list.map((item, key) => this.renderItem(item, key))
}
treeItemTarget() {
if (typeof this.props.treeItemTarget == 'function') {
return this.props.treeItemTarget()
} else {
return this.props.treeItemTarget
}
}
treeItemSource() {
if (typeof this.props.treeItemSource == 'function') {
return this.props.treeItemSource()
} else {
return this.props.treeItemSource
}
}
renderItem(item, key) {
const id = item.get('id')
const selected = this.state.selectedIds.indexOf(id) >= 0 || (this.state.selectedIds.length === 0 && id === null)
const collapsed = this.state.collapsedIds.indexOf(id) >= 0
const { children, ...rest } = item.toObject()
const showIndicator = children && children.size > 0
const { TreeCell, dropTargetType, dragTargetType, reorderable, breadcrumbs } = this.props
return (
<TreeItem
key={key}
TreeCell={TreeCell}
{...rest}
dropTargetType={dropTargetType}
dragTargetType={dragTargetType}
treeItemTarget={this.treeItemTarget()}
treeItemSource={this.treeItemSource()}
beginDrag={this.beginDrag}
endDrag={this.endDrag}
onClick={() => this.handleSelect(item)}
onDoubleClick={() => this.handleDoubleClick(item)}
selected={selected}
collapsed={collapsed}
reorderable={reorderable}
breadcrumbs={breadcrumbs}
onCollapse={() => this.handleCollapse(item)}
showIndicator={showIndicator}>
{showIndicator && this.renderList(item.get('children'))}
</TreeItem>
)
}
render() {
const { TreeDragLayer } = this.props
const { InflatedTree, selectedIds } = this.state
const nestedTree = this.renderList(InflatedTree)
return (
<div className="tree">
<div className="tree-items">{nestedTree}</div>
<TreeDragLayer selectedIds={selectedIds} InflatedTree={InflatedTree} dragging={this.dragging} />
</div>
)
}
}