/* eslint-disable max-lines-per-function */ import React, { MouseEvent, KeyboardEvent } from 'react'; import cls from 'classnames'; import PropTypes from 'prop-types'; import ConfigContext, { ContextValue } from '../configProvider/context'; import TreeFoundation, { TreeAdapter } from '@douyinfe/semi-foundation/tree/foundation'; import { convertDataToEntities, flattenTreeData, calcExpandedKeysForValues, calcMotionKeys, convertJsonToData, findKeysForValues, calcCheckedKeys, calcExpandedKeys, filterTreeData, normalizeValue, updateKeys, calcDisabledKeys } from '@douyinfe/semi-foundation/tree/treeUtil'; import { cssClasses, strings } from '@douyinfe/semi-foundation/tree/constants'; import BaseComponent from '../_base/baseComponent'; import { isEmpty, isEqual, get, isFunction } from 'lodash'; import { cloneDeep } from './treeUtil'; import Input from '../input/index'; import { FixedSizeList as VirtualList } from 'react-window'; import AutoSizer from './autoSizer'; import TreeContext from './treeContext'; import TreeNode from './treeNode'; import NodeList from './nodeList'; import LocaleConsumer from '../locale/localeConsumer'; import '@douyinfe/semi-foundation/tree/tree.scss'; import { IconSearch } from '@douyinfe/semi-icons'; import { Locale as LocaleObject } from '../locale/interface'; import { TreeProps, TreeState, TreeNodeProps, TreeNodeData, FlattenNode, KeyEntity, OptionProps, ScrollData, } from './interface'; import CheckboxGroup from '../checkbox/checkboxGroup'; export * from './interface'; export type { AutoSizerProps } from './autoSizer'; const prefixcls = cssClasses.PREFIX; class Tree extends BaseComponent { static contextType = ConfigContext; static propTypes = { blockNode: PropTypes.bool, className: PropTypes.string, showClear: PropTypes.bool, defaultExpandAll: PropTypes.bool, defaultExpandedKeys: PropTypes.array, defaultValue: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), directory: PropTypes.bool, disabled: PropTypes.bool, emptyContent: PropTypes.node, expandAll: PropTypes.bool, expandedKeys: PropTypes.array, filterTreeNode: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]), icon: PropTypes.node, onChangeWithObject: PropTypes.bool, motion: PropTypes.bool, multiple: PropTypes.bool, onChange: PropTypes.func, onExpand: PropTypes.func, onSearch: PropTypes.func, onSelect: PropTypes.func, onContextMenu: PropTypes.func, onDoubleClick: PropTypes.func, searchClassName: PropTypes.string, searchPlaceholder: PropTypes.string, searchStyle: PropTypes.object, selectedKey: PropTypes.string, showFilteredOnly: PropTypes.bool, style: PropTypes.object, treeData: PropTypes.arrayOf( PropTypes.shape({ key: PropTypes.string.isRequired, value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), label: PropTypes.any, isLeaf: PropTypes.bool, }) ), treeDataSimpleJson: PropTypes.object, treeNodeFilterProp: PropTypes.string, value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.array, PropTypes.object]), virtualize: PropTypes.object, autoExpandParent: PropTypes.bool, expandAction: PropTypes.oneOf(strings.EXPAND_ACTION), searchRender: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]), renderLabel: PropTypes.func, renderFullLabel: PropTypes.func, leafOnly: PropTypes.bool, loadedKeys: PropTypes.array, loadData: PropTypes.func, onLoad: PropTypes.func, disableStrictly: PropTypes.bool, draggable: PropTypes.bool, autoExpandWhenDragEnter: PropTypes.bool, hideDraggingNode: PropTypes.bool, renderDraggingNode: PropTypes.func, onDragEnd: PropTypes.func, onDragEnter: PropTypes.func, onDragLeave: PropTypes.func, onDragOver: PropTypes.func, onDragStart: PropTypes.func, onDrop: PropTypes.func, labelEllipsis: PropTypes.bool, checkRelation: PropTypes.string, 'aria-label': PropTypes.string, preventScroll: PropTypes.bool, }; static defaultProps = { showClear: true, disabled: false, blockNode: true, multiple: false, filterTreeNode: false, autoExpandParent: false, treeNodeFilterProp: 'label', defaultExpandAll: false, expandAll: false, onChangeWithObject: false, motion: true, leafOnly: false, showFilteredOnly: false, expandAction: false, disableStrictly: false, draggable: false, autoExpandWhenDragEnter: true, checkRelation: 'related', }; static TreeNode: typeof TreeNode; inputRef: React.RefObject; optionsRef: React.RefObject; dragNode: any; onNodeClick: any; onMotionEnd: any; context: ContextValue; virtualizedListRef: React.RefObject; constructor(props: TreeProps) { super(props); this.state = { inputValue: '', keyEntities: {}, treeData: [], flattenNodes: [], selectedKeys: [], checkedKeys: new Set(), halfCheckedKeys: new Set(), realCheckedKeys: new Set([]), motionKeys: new Set([]), motionType: 'hide', expandedKeys: new Set(props.expandedKeys), filteredKeys: new Set(), filteredExpandedKeys: new Set(), filteredShownKeys: new Set(), prevProps: null, loadedKeys: new Set(), loadingKeys: new Set(), cachedFlattenNodes: undefined, cachedKeyValuePairs: {}, disabledKeys: new Set(), dragging: false, dragNodesKeys: new Set(), dragOverNodeKey: null, dropPosition: null, }; this.inputRef = React.createRef(); this.optionsRef = React.createRef(); this.foundation = new TreeFoundation(this.adapter); this.dragNode = null; this.virtualizedListRef = React.createRef(); } /** * Process of getDerivedStateFromProps was inspired by rc-tree * https://github.com/react-component/tree */ static getDerivedStateFromProps(props: TreeProps, prevState: TreeState) { const { prevProps } = prevState; let treeData; let keyEntities = prevState.keyEntities || {}; let valueEntities = prevState.cachedKeyValuePairs || {}; const isSeaching = Boolean(props.filterTreeNode && prevState.inputValue && prevState.inputValue.length); const newState: Partial = { prevProps: props, }; const isExpandControlled = 'expandedKeys' in props; // Accept a props field as a parameter to determine whether to update the field const needUpdate = (name: string) => { const firstInProps = !prevProps && name in props; const nameHasChange = prevProps && !isEqual(prevProps[name], props[name]); return firstInProps || nameHasChange; }; // Determine whether treeData has changed const needUpdateData = () => { const firstInProps = !prevProps && 'treeData' in props; const treeDataHasChange = prevProps && prevProps.treeData !== props.treeData; return firstInProps || treeDataHasChange; }; // Update the data of tree in state if (needUpdate('treeData') || (props.draggable && needUpdateData())) { // eslint-disable-next-line prefer-destructuring treeData = props.treeData; newState.treeData = treeData; const entitiesMap = convertDataToEntities(treeData); newState.keyEntities = { ...entitiesMap.keyEntities, }; keyEntities = newState.keyEntities; newState.cachedKeyValuePairs = { ...entitiesMap.valueEntities }; valueEntities = newState.cachedKeyValuePairs; } else if (needUpdate('treeDataSimpleJson')) { // Convert treeDataSimpleJson to treeData treeData = convertJsonToData(props.treeDataSimpleJson); newState.treeData = treeData; const entitiesMap = convertDataToEntities(treeData); newState.keyEntities = { ...entitiesMap.keyEntities, }; keyEntities = newState.keyEntities; newState.cachedKeyValuePairs = { ...entitiesMap.valueEntities }; valueEntities = newState.cachedKeyValuePairs; } // If treeData keys changes, we won't show animation if (treeData && props.motion) { if (prevProps && props.motion) { newState.motionKeys = new Set([]); newState.motionType = null; } } const dataUpdated = needUpdate('treeDataSimpleJson') || needUpdate('treeData'); const expandAllWhenDataChange = dataUpdated && props.expandAll; if (!isSeaching) { // Update expandedKeys if (needUpdate('expandedKeys') || (prevProps && needUpdate('autoExpandParent'))) { newState.expandedKeys = calcExpandedKeys( props.expandedKeys, keyEntities, props.autoExpandParent || !prevProps ); // only show animation when treeData does not change if (prevProps && props.motion && !treeData) { const { motionKeys, motionType } = calcMotionKeys( prevState.expandedKeys, newState.expandedKeys, keyEntities ); newState.motionKeys = new Set(motionKeys); newState.motionType = motionType; if (motionType === 'hide') { // cache flatten nodes: expandedKeys changed may not be triggered by interaction newState.cachedFlattenNodes = cloneDeep(prevState.flattenNodes); } } } else if ((!prevProps && (props.defaultExpandAll || props.expandAll)) || expandAllWhenDataChange) { newState.expandedKeys = new Set(Object.keys(keyEntities)); } else if (!prevProps && props.defaultExpandedKeys) { newState.expandedKeys = calcExpandedKeys(props.defaultExpandedKeys, keyEntities); } else if (!prevProps && props.defaultValue) { newState.expandedKeys = calcExpandedKeysForValues( props.defaultValue, keyEntities, props.multiple, valueEntities ); } else if (!prevProps && props.value) { newState.expandedKeys = calcExpandedKeysForValues( props.value, keyEntities, props.multiple, valueEntities ); } else if ((!isExpandControlled && dataUpdated) && props.value) { // 当 treeData 已经设置具体的值,并且设置了 props.loadData ,则认为 treeData 的更新是因为 loadData 导致的 // 如果是因为 loadData 导致 treeData改变, 此时在这里重新计算 key 会导致为未选中的展开项目被收起 // 所以此时不需要重新计算 expandedKeys,因为在点击展开按钮时候已经把被展开的项添加到 expandedKeys 中 // When treeData has a specific value and props.loadData is set, it is considered that the update of treeData is caused by loadData // If the treeData is changed because of loadData, recalculating the key here will cause the unselected expanded items to be collapsed // So there is no need to recalculate expandedKeys at this time, because the expanded item has been added to expandedKeys when the expand button is clicked if (!(prevState.treeData && prevState.treeData?.length > 0 && props.loadData)) { newState.expandedKeys = calcExpandedKeysForValues( props.value, keyEntities, props.multiple, valueEntities ); } } if (!newState.expandedKeys) { delete newState.expandedKeys; } // Update flattenNodes if (treeData || newState.expandedKeys) { const flattenNodes = flattenTreeData( treeData || prevState.treeData, newState.expandedKeys || prevState.expandedKeys ); newState.flattenNodes = flattenNodes; } } else { let filteredState; // treeData changed while searching if (treeData) { // Get filter data filteredState = filterTreeData({ treeData, inputValue: prevState.inputValue, filterTreeNode: props.filterTreeNode, filterProps: props.treeNodeFilterProp, showFilteredOnly: props.showFilteredOnly, keyEntities: newState.keyEntities, prevExpandedKeys: [...prevState.filteredExpandedKeys], }); newState.flattenNodes = filteredState.flattenNodes; newState.motionKeys = new Set([]); newState.filteredKeys = filteredState.filteredKeys; newState.filteredShownKeys = filteredState.filteredShownKeys; newState.filteredExpandedKeys = filteredState.filteredExpandedKeys; } // expandedKeys changed while searching if (props.expandedKeys) { newState.filteredExpandedKeys = calcExpandedKeys( props.expandedKeys, keyEntities, props.autoExpandParent || !prevProps ); if (prevProps && props.motion) { const prevKeys = prevState ? prevState.filteredExpandedKeys : new Set([]); // only show animation when treeData does not change if (!treeData) { const motionResult = calcMotionKeys( prevKeys, newState.filteredExpandedKeys, keyEntities ); let { motionKeys } = motionResult; const { motionType } = motionResult; if (props.showFilteredOnly) { motionKeys = motionKeys.filter(key => prevState.filteredShownKeys.has(key)); } if (motionType === 'hide') { // cache flatten nodes: expandedKeys changed may not be triggered by interaction newState.cachedFlattenNodes = cloneDeep(prevState.flattenNodes); } newState.motionKeys = new Set(motionKeys); newState.motionType = motionType; } } newState.flattenNodes = flattenTreeData( treeData || prevState.treeData, newState.filteredExpandedKeys || prevState.filteredExpandedKeys, props.showFilteredOnly && prevState.filteredShownKeys ); } } // Handle single selection and multiple selection in controlled mode const withObject = props.onChangeWithObject; const isMultiple = props.multiple; if (!isMultiple) { // When getting single selection, the selected node if (needUpdate('value')) { newState.selectedKeys = findKeysForValues( // In both cases whether withObject is turned on, the value is standardized to string normalizeValue(props.value, withObject), valueEntities, isMultiple ); } else if (!prevProps && props.defaultValue) { newState.selectedKeys = findKeysForValues( normalizeValue(props.defaultValue, withObject), valueEntities, isMultiple ); } else if (treeData) { // If `treeData` changed, we also need check it if (props.value) { newState.selectedKeys = findKeysForValues( normalizeValue(props.value, withObject) || '', valueEntities, isMultiple ); } } } else { let checkedKeyValues; // Get the selected node during multiple selection if (needUpdate('value')) { checkedKeyValues = findKeysForValues( normalizeValue(props.value, withObject), valueEntities, isMultiple ); } else if (!prevProps && props.defaultValue) { checkedKeyValues = findKeysForValues( normalizeValue(props.defaultValue, withObject), valueEntities, isMultiple ); } else if (treeData) { // If `treeData` changed, we also need check it if (props.value) { checkedKeyValues = findKeysForValues( normalizeValue(props.value, withObject) || [], valueEntities, isMultiple ); } else { checkedKeyValues = updateKeys(prevState.checkedKeys, keyEntities); } } if (checkedKeyValues) { if (props.checkRelation === 'unRelated') { newState.realCheckedKeys = new Set(checkedKeyValues); } else if (props.checkRelation === 'related') { const { checkedKeys, halfCheckedKeys } = calcCheckedKeys(checkedKeyValues, keyEntities); newState.checkedKeys = checkedKeys; newState.halfCheckedKeys = halfCheckedKeys; } } } // update loadedKeys if (needUpdate('loadedKeys')) { newState.loadedKeys = new Set(props.loadedKeys); } // update disableStrictly if (treeData && props.disableStrictly && props.checkRelation === 'related') { newState.disabledKeys = calcDisabledKeys(keyEntities); } return newState; } get adapter(): TreeAdapter { const filterAdapter: Pick = { updateInputValue: value => { this.setState({ inputValue: value }); }, focusInput: () => { const { preventScroll } = this.props; if (this.inputRef && this.inputRef.current) { (this.inputRef.current as any).focus({ preventScroll }); } }, }; return { ...super.adapter, ...filterAdapter, updateState: states => { this.setState({ ...states } as TreeState); }, notifyExpand: (expandedKeys, { expanded: bool, node }) => { this.props.onExpand && this.props.onExpand([...expandedKeys], { expanded: bool, node }); if (bool && this.props.loadData) { this.onNodeLoad(node); } }, notifySelect: (selectKey, bool, node) => { this.props.onSelect && this.props.onSelect(selectKey, bool, node); }, notifyChange: value => { this.props.onChange && this.props.onChange(value); }, notifySearch: input => { this.props.onSearch && this.props.onSearch(input); }, notifyRightClick: (e, node) => { this.props.onContextMenu && this.props.onContextMenu(e, node); }, notifyDoubleClick: (e, node) => { this.props.onDoubleClick && this.props.onDoubleClick(e, node); }, cacheFlattenNodes: bool => { this.setState({ cachedFlattenNodes: bool ? cloneDeep(this.state.flattenNodes) : undefined }); }, setDragNode: treeNode => { this.dragNode = treeNode; }, }; } search = (value: string) => { this.foundation.handleInputChange(value); }; scrollTo = (scrollData: ScrollData) => { const { key, align = 'center' } = scrollData; const { flattenNodes } = this.state; if (key) { const index = flattenNodes?.findIndex((node) => { return node.key === key; }); index >= 0 && (this.virtualizedListRef.current as any)?.scrollToItem(index, align); } } renderInput() { const { searchClassName, searchStyle, searchRender, searchPlaceholder, showClear } = this.props; const inputcls = cls(`${prefixcls}-input`); const { inputValue } = this.state; const inputProps = { value: inputValue, className: inputcls, onChange: (value: string) => this.search(value), prefix: , showClear, placeholder: searchPlaceholder, }; const wrapperCls = cls(`${prefixcls}-search-wrapper`, searchClassName); return (
{(locale: LocaleObject) => { inputProps.placeholder = searchPlaceholder || get(locale, 'searchPlaceholder'); if (isFunction(searchRender)) { return searchRender({ ...inputProps }); } if (searchRender === false) { return null; } return ( ); }}
); } renderEmpty = () => { const { emptyContent } = this.props; if (emptyContent) { return ; } else { return ( {(locale: LocaleObject) => } ); } }; onNodeSelect = (e: MouseEvent | KeyboardEvent, treeNode: TreeNodeProps) => { this.foundation.handleNodeSelect(e, treeNode); }; onNodeLoad = (data: TreeNodeData) => ( new Promise(resolve => { // We need to get the latest state of loading/loaded keys this.setState(({ loadedKeys = new Set([]), loadingKeys = new Set([]) }) => ( this.foundation.handleNodeLoad(loadedKeys, loadingKeys, data, resolve) )); }) ); onNodeCheck = (e: MouseEvent | KeyboardEvent, treeNode: TreeNodeProps) => { this.foundation.handleNodeSelect(e, treeNode); }; onNodeExpand = (e: MouseEvent | KeyboardEvent, treeNode: TreeNodeProps) => { this.foundation.handleNodeExpand(e, treeNode); }; onNodeRightClick = (e: MouseEvent, treeNode: TreeNodeProps) => { this.foundation.handleNodeRightClick(e, treeNode); }; onNodeDoubleClick = (e: MouseEvent, treeNode: TreeNodeProps) => { this.foundation.handleNodeDoubleClick(e, treeNode); }; onNodeDragStart = (e: React.DragEvent, treeNode: TreeNodeProps) => { this.foundation.handleNodeDragStart(e, treeNode); }; onNodeDragEnter = (e: React.DragEvent, treeNode: TreeNodeProps) => { this.foundation.handleNodeDragEnter(e, treeNode, this.dragNode); }; onNodeDragOver = (e: React.DragEvent, treeNode: TreeNodeProps) => { this.foundation.handleNodeDragOver(e, treeNode, this.dragNode); }; onNodeDragLeave = (e: React.DragEvent, treeNode: TreeNodeProps) => { this.foundation.handleNodeDragLeave(e, treeNode); }; onNodeDragEnd = (e: React.DragEvent, treeNode: TreeNodeProps) => { this.foundation.handleNodeDragEnd(e, treeNode); }; onNodeDrop = (e: React.DragEvent, treeNode: TreeNodeProps) => { this.foundation.handleNodeDrop(e, treeNode, this.dragNode); }; getTreeNodeRequiredProps = () => { const { expandedKeys, selectedKeys, checkedKeys, halfCheckedKeys, keyEntities, filteredKeys } = this.state; return { expandedKeys: expandedKeys || new Set(), selectedKeys: selectedKeys || [], checkedKeys: checkedKeys || new Set(), halfCheckedKeys: halfCheckedKeys || new Set(), filteredKeys: filteredKeys || new Set(), keyEntities, }; }; getTreeNodeKey = (treeNode: TreeNodeData) => { const { data } = treeNode; const { key } = data; return key; }; renderTreeNode = (treeNode: FlattenNode, ind?: number, style?: React.CSSProperties) => { const { data } = treeNode; const { key } = data; const treeNodeProps = this.foundation.getTreeNodeProps(key); if (!treeNodeProps) { return null; } return ; }; itemKey = (index: number, data: KeyEntity) => { // Find the item at the specified index. const item = data[index]; // Return a value that uniquely identifies this item. return item.key; }; renderNodeList() { const { flattenNodes, cachedFlattenNodes, motionKeys, motionType } = this.state; const { virtualize, motion } = this.props; const { direction } = this.context; if (isEmpty(flattenNodes)) { return undefined; } if (!virtualize || isEmpty(virtualize)) { return ( ); } const option = ({ index, style, data }: OptionProps) => ( this.renderTreeNode(data[index], index, style) ); return ( {({ height, width }: { width: string | number; height: string | number }) => ( {option} )} ); } render() { const { keyEntities, motionKeys, motionType, inputValue, filteredKeys, dragOverNodeKey, dropPosition, checkedKeys, realCheckedKeys, } = this.state; const { blockNode, className, style, filterTreeNode, disabled, icon, directory, multiple, showFilteredOnly, motion, expandAction, loadData, renderLabel, draggable, renderFullLabel, labelEllipsis, virtualize, checkRelation, } = this.props; const wrapperCls = cls(`${prefixcls}-wrapper`, className); const listCls = cls(`${prefixcls}-option-list`, { [`${prefixcls}-option-list-block`]: blockNode, }); const searchNoRes = Boolean(inputValue) && !filteredKeys.size; const noData = isEmpty(keyEntities) || (showFilteredOnly && searchNoRes); const ariaAttr = { role: noData ? 'none' : 'tree' }; if (ariaAttr.role === 'tree') { ariaAttr['aria-multiselectable'] = multiple ? true : false; } return (
{filterTreeNode ? this.renderInput() : null}
{noData ? this.renderEmpty() : (multiple ? ( {this.renderNodeList()} ) : this.renderNodeList() )}
); } } Tree.TreeNode = TreeNode; export default Tree;