UNPKG

@bigfishtv/cockpit

Version:

560 lines (508 loc) 19.1 kB
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> ) } }