瀏覽代碼

fix: [TreeSelect] fix expandedKeys is not completely controlled and add filterExpandedKeys parameter for onSearch #328 (#644)

fix: [TreeSelect] fix expandedKeys is not completely controlled and add filterExpandedKeys parameter for onSearch #328

Co-authored-by: chenyuling <[email protected]>
boomboomchen 3 年之前
父節點
當前提交
d6001c29ee

+ 58 - 2
content/input/treeselect/index-en-US.md

@@ -824,6 +824,62 @@ import { TreeSelect } from '@douyinfe/semi-ui';
 };
 ```
 
+### Controlled Expansion with Search
+When `expandedKeys` is passed in, it is a controlled expansion component, which can be used with `onExpand`. When the expansion is controlled, if the `filterTreeNode` is turned on and the search is performed, the node will no longer be automatically expanded. At this time, the expansion of the node is completely controlled by the `expandedKeys`. You can use the parameter `filteredExpandedKeys` (version: >= 2.6.0) of `onSearch` to realize the search expansion effect when the expansion is controlled.
+
+```jsx live=true hideInDSM
+import React, { useState } from 'react';
+import { TreeSelect } from '@douyinfe/semi-ui';
+
+() => {
+    const [expandedKeys, setExpandedKeys] = useState([]);
+    const treeData = [
+        {
+            label: 'Asia',
+            value: 'Asia',
+            key: '0',
+            children: [
+                {
+                    label: 'China',
+                    value: 'China',
+                    key: '0-0',
+                    children: [
+                        {
+                            label: 'Beijing',
+                            value: 'Beijing',
+                            key: '0-0-0',
+                        },
+                        {
+                            label: 'Shanghai',
+                            value: 'Shanghai',
+                            key: '0-0-1',
+                        },
+                    ],
+                },
+            ],
+        },
+        {
+            label: 'North America',
+            value: 'North America',
+            key: '1',
+        }
+    ];
+    return (
+        <TreeSelect
+            style={{ width: 300 }}
+            dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
+            treeData={treeData}
+            filterTreeNode
+            expandedKeys={expandedKeys}
+            onExpand={expandedKeys => setExpandedKeys(expandedKeys)}
+            onSearch={(inputValue, filteredExpandedKeys) => {
+                setExpandedKeys([...filteredExpandedKeys, ...expandedKeys]);
+            }}
+        />
+    );
+};
+```
+
 ### Virtualized TreeSelect
 If you need to render large sets of tree structured data, you could use virtualized tree. In virtualized mode, animation / motion is disabled for better performance. 
 
@@ -1289,8 +1345,8 @@ function Demo() {
 | onChange                 | Callback function when the tree node is selected, return the value property of data | Function                           | -           | -       |
 | onChangeWithObject        | Toggle whether to return all properties in an option as a return value. When set to true, onChange turn to Function(node, e)   | boolean                     | false   | 1.0.0 |
 | onExpand                 | Callback function when expand or collapse a node                                    | function(expandedKeys:array, {expanded: bool, node})              | -           | -       |
-| onLoad | Callback function when a node is loaded | (loadedKeys: Set< string >, treeNode: TreeNode) => void | - | 1.32.0|
-| onSearch                 | Callback function when search value changes                                         | function(sugInput: string)                                        | -           | -       |
+| onLoad | Callback function when a node is loaded | (loadedKeys: Set<string\>, treeNode: TreeNode) => void | - | 1.32.0|
+| onSearch                 | Callback function when search value changes. `filteredExpandedKeys` represents the key of the node expanded due to search or value/defaultValue, which can be used when expandedKeys is controlled                                         | function(sugInput: string, filteredExpandedKeys: string[])                                        | -           | filteredExpandedKeys is supported in 2.6.0       |
 | onSelect                 | Callback function when selected, return the key property of data                    | function(selectedKey:string, selected: bool, selectedNode: TreeNode)                      | -           | -       |
 | onVisibleChange     | A callback triggered when the pop-up layer is displayed/hidden   | function(isVisible:boolean) |     |   1.4.0  |
 

+ 65 - 2
content/input/treeselect/index.md

@@ -788,6 +788,69 @@ import { TreeSelect } from '@douyinfe/semi-ui';
 };
 ```
 
+### 开启搜索的展开受控
+传入 `expandedKeys` 时即为展开受控组件,可以配合 `onExpand` 使用。当展开受控时,如果开启 `filterTreeNode` 并进行搜索是不会再自动展开节点的,此时,节点的展开完全由 `expandedKeys` 来控制。你可以利用 `onSearch` 的入参 `filteredExpandedKeys`(version: >= 2.6.0) 来实现展开受控时的搜索展开效果。
+
+```jsx live=true hideInDSM
+import React, { useState } from 'react';
+import { TreeSelect } from '@douyinfe/semi-ui';
+
+() => {
+    const [expandedKeys, setExpandedKeys] = useState([]);
+    const treeData = [
+        {
+            label: '亚洲',
+            value: 'Asia',
+            key: '0',
+            children: [
+                {
+                    label: '中国',
+                    value: 'China',
+                    key: '0-0',
+                    children: [
+                        {
+                            label: '北京',
+                            value: 'Beijing',
+                            key: '0-0-0',
+                        },
+                        {
+                            label: '上海',
+                            value: 'Shanghai',
+                            key: '0-0-1',
+                        },
+                    ],
+                },
+                {
+                    label: '日本',
+                    value: 'Japan',
+                    key: '0-1',
+                },
+            ],
+        },
+        {
+            label: '北美洲',
+            value: 'North America',
+            key: '1',
+        }
+    ];
+    return (
+        <TreeSelect
+            style={{ width: 300 }}
+            dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
+            treeData={treeData}
+            filterTreeNode
+            expandedKeys={expandedKeys}
+            onExpand={expandedKeys => {
+                setExpandedKeys(expandedKeys);
+            }}
+            onSearch={(inputValue, filteredExpandedKeys) => {
+                setExpandedKeys([...filteredExpandedKeys, ...expandedKeys]);
+            }}
+        />
+    );
+};
+```
+
 ### 虚拟化
 列表虚拟化,用于大量树节点的情况。开启后,动画效果将被关闭。
 
@@ -1269,8 +1332,8 @@ function Demo() {
 | onChangeWithObject | 是否将选中项 option 的其他属性作为回调。设为 true 时,onChange 的入参类型Function(node\|node[], e) 此时如果是受控,也需要把 value 设置成 object,且必须含有 value 的键值;defaultValue同理。 | boolean | false | 1.0.0 |
 | onExpand | 展示节点时调用 | function(expandedKeys:array, {expanded: bool, node}) | - | - |
 | onFocus | 聚焦时的回调 | function(event) | - | - |
-| onLoad | 节点加载完毕时触发的回调 | (loadedKeys: Set< string >, treeNode: TreeNode) => void |- |  1.32.0|
-| onSearch | 文本框值变化时回调 | function(sugInput: string) | - | - |
+| onLoad | 节点加载完毕时触发的回调 | (loadedKeys: Set<string\>, treeNode: TreeNode) => void |- |  1.32.0|
+| onSearch | 文本框值变化时回调。 入参 filteredExpandedKeys 表示因为搜索或 value/defaultValue 而展开的节点的 key, 可以配合 expandedKeys 受控时使用 | function(sugInput: string, filteredExpandedKeys: string[]) | - | filteredExpandedKeys 在 2.6.0 中新增 |
 | onSelect | 被选中时调用,返回值为当前事件选项的key值 | function(selectedKey:string, selected: bool, selectedNode: TreeNode) | - | - |
 | onVisibleChange     | 弹出层展示/隐藏时触发的回调   | function(isVisible:boolean) |     |   1.4.0  |
 

+ 26 - 19
packages/semi-foundation/treeSelect/foundation.ts

@@ -91,7 +91,6 @@ export interface BasicTreeSelectProps extends Pick<BasicTreeProps,
 | 'treeNodeFilterProp'
 | 'value'
 | 'onExpand'
-| 'onSearch'
 | 'expandAll'
 | 'disableStrictly'
 | 'aria-label'
@@ -133,6 +132,7 @@ export interface BasicTreeSelectProps extends Pick<BasicTreeProps,
     getPopupContainer?: () => HTMLElement;
     // triggerRender?: (props: BasicTriggerRenderProps) => any;
     onBlur?: (e: any) => void;
+    onSearch?: (sunInput: string, filteredExpandedKeys: string[]) => void;
     onChange?: BasicOnChange;
     onFocus?: (e: any) => void;
     onVisibleChange?: (isVisible: boolean) => void;
@@ -175,7 +175,7 @@ export interface TreeSelectAdapter<P = Record<string, any>, S = Record<string, a
     rePositionDropdown: () => void;
     updateState: (states: Partial<BasicTreeSelectInnerData>) => void;
     notifySelect: (selectedKeys: string, selected: boolean, selectedNode: BasicTreeNodeData) => void;
-    notifySearch: (input: string) => void;
+    notifySearch: (input: string, filteredExpandedKeys: string[]) => void;
     cacheFlattenNodes: (bool: boolean) => void;
     openMenu: () => void;
     closeMenu: (cb?: () => void) => void;
@@ -302,7 +302,9 @@ export default class TreeSelectFoundation<P = Record<string, any>, S = Record<st
         const isSearching = Boolean(inputValue);
         const treeNodeProps: BasicTreeNodeProps = {
             eventKey: key,
-            expanded: isSearching ? filteredExpandedKeys.has(key) : expandedKeys.has(key),
+            expanded: isSearching && !this._isExpandControlled()
+                ? filteredExpandedKeys.has(key)
+                : expandedKeys.has(key),
             selected: selectedKeys.includes(key),
             checked: realChecked,
             halfChecked: realHalfChecked,
@@ -515,14 +517,16 @@ export default class TreeSelectFoundation<P = Record<string, any>, S = Record<st
     }
 
     clearInput() {
-        const { expandedKeys, selectedKeys, keyEntities, treeData } = this.getStates();
+        const { flattenNodes, expandedKeys, selectedKeys, keyEntities, treeData } = this.getStates();
+        const newExpandedKeys: Set<string> = new Set(expandedKeys);
+        const isExpandControlled = this._isExpandControlled();
         const expandedOptsKeys = findAncestorKeys(selectedKeys, keyEntities);
-        expandedOptsKeys.forEach(item => expandedKeys.add(item));
-        const flattenNodes = flattenTreeData(treeData, expandedKeys);
+        expandedOptsKeys.forEach(item => newExpandedKeys.add(item));
+        const newFlattenNodes = flattenTreeData(treeData, newExpandedKeys);
 
         this._adapter.updateState({
-            expandedKeys,
-            flattenNodes,
+            expandedKeys: isExpandControlled ? expandedKeys : newExpandedKeys,
+            flattenNodes: isExpandControlled ? flattenNodes : newFlattenNodes,
             inputValue: '',
             motionKeys: new Set([]),
             filteredKeys: new Set([]),
@@ -534,16 +538,17 @@ export default class TreeSelectFoundation<P = Record<string, any>, S = Record<st
     handleInputChange(sugInput: string) {
         // Input is used as controlled component
         this._adapter.updateInputValue(sugInput);
-        const { expandedKeys, selectedKeys, keyEntities, treeData } = this.getStates();
+        const { flattenNodes, expandedKeys, selectedKeys, keyEntities, treeData } = this.getStates();
         const { showFilteredOnly, filterTreeNode, treeNodeFilterProp } = this.getProps();
+        const newExpandedKeys: Set<string> = new Set(expandedKeys);
         let filteredOptsKeys: string[] = [];
         let expandedOptsKeys = [];
-        let flattenNodes = [];
+        let newFlattenNodes = [];
         let filteredShownKeys = new Set([]);
         if (!sugInput) {
             expandedOptsKeys = findAncestorKeys(selectedKeys, keyEntities);
-            expandedOptsKeys.forEach(item => expandedKeys.add(item));
-            flattenNodes = flattenTreeData(treeData, expandedKeys);
+            expandedOptsKeys.forEach(item => newExpandedKeys.add(item));
+            newFlattenNodes = flattenTreeData(treeData, newExpandedKeys);
         } else {
             filteredOptsKeys = Object.values(keyEntities)
                 .filter((item: BasicKeyEntity) => {
@@ -554,15 +559,16 @@ export default class TreeSelectFoundation<P = Record<string, any>, S = Record<st
             expandedOptsKeys = findAncestorKeys(filteredOptsKeys, keyEntities, false);
             const shownChildKeys = findDescendantKeys(filteredOptsKeys, keyEntities, true);
             filteredShownKeys = new Set([...shownChildKeys, ...expandedOptsKeys]);
-            flattenNodes = flattenTreeData(treeData, new Set(expandedOptsKeys), showFilteredOnly && filteredShownKeys);
+            newFlattenNodes = flattenTreeData(treeData, new Set(expandedOptsKeys), showFilteredOnly && filteredShownKeys);
         }
-        this._adapter.notifySearch(sugInput);
+        const newFilteredExpandedKeys = new Set(expandedOptsKeys);
+        this._adapter.notifySearch(sugInput, Array.from(newFilteredExpandedKeys));
         this._adapter.updateState({
-            expandedKeys,
-            flattenNodes,
+            expandedKeys: this._isExpandControlled() ? expandedKeys : newExpandedKeys,
+            flattenNodes: this._isExpandControlled() ? flattenNodes : newFlattenNodes,
             motionKeys: new Set([]),
             filteredKeys: new Set(filteredOptsKeys),
-            filteredExpandedKeys: new Set(expandedOptsKeys),
+            filteredExpandedKeys: newFilteredExpandedKeys,
             filteredShownKeys,
         });
     }
@@ -724,7 +730,8 @@ export default class TreeSelectFoundation<P = Record<string, any>, S = Record<st
             return;
         }
 
-        if (isSearching) {
+        const isExpandControlled = this._isExpandControlled();
+        if (isSearching && !isExpandControlled) {
             this.handleNodeExpandInSearch(e, treeNode);
             return;
         }
@@ -742,7 +749,7 @@ export default class TreeSelectFoundation<P = Record<string, any>, S = Record<st
         }
         this._adapter.cacheFlattenNodes(motionType === 'hide' && this._isAnimated());
 
-        if (!this._isExpandControlled()) {
+        if (!isExpandControlled) {
             const flattenNodes = flattenTreeData(treeData, expandedKeys);
             const motionKeys = this._isAnimated() ? getMotionKeys(eventKey, expandedKeys, keyEntities) : [];
             const newState = {

+ 1 - 0
packages/semi-ui/tree/interface.ts

@@ -131,6 +131,7 @@ export interface NodeListProps {
     motionKeys: Set<string>;
     motionType: string;
     flattenList: FlattenNode[] | undefined;
+    searchTargetIsDeep?: boolean;
     renderTreeNode: (treeNode: FlattenNode, ind?: number, style?: React.CSSProperties) => ReactNode;
 }
 export type TransitionNodes<T> = Array<T | Array<T>>;

+ 2 - 2
packages/semi-ui/tree/nodeList.tsx

@@ -59,9 +59,9 @@ export default class NodeList extends PureComponent<NodeListProps, NodeListState
     };
 
     render() {
-        const { flattenNodes, motionType, renderTreeNode } = this.props;
+        const { flattenNodes, motionType, searchTargetIsDeep, renderTreeNode } = this.props;
         const { transitionNodes } = this.state;
-        const mapData = transitionNodes.length ? transitionNodes : flattenNodes;
+        const mapData = transitionNodes.length && !searchTargetIsDeep ? transitionNodes : flattenNodes;
         const options = mapData.map(treeNode => {
             const isMotionNode = Array.isArray(treeNode);
             if (isMotionNode && !(treeNode as FlattenNode[]).length) {

+ 28 - 0
packages/semi-ui/treeSelect/__test__/treeSelect.test.js

@@ -552,6 +552,17 @@ describe('TreeSelect', () => {
         searchWrapper.find('input').simulate('change', event);
         expect(spyOnSearch.calledOnce).toBe(true);
         expect(spyOnSearch.calledWithMatch(searchValue)).toBe(true);
+
+        /* Check the input parameters of onSearch */
+        searchValue = '北京';
+        event = { target: { value: searchValue } };
+        searchWrapper.find('input').simulate('change', event);
+        expect(spyOnSearch.callCount).toBe(2);
+        const firstCall = spyOnSearch.getCall(1);
+        const args = firstCall.args;
+        expect(args[0]).toEqual('北京');
+        expect(args[1].includes('yazhou')).toEqual(true);
+        expect(args[1].includes('zhongguo')).toEqual(true);
     });
 
     it('filterTreeNode shows correct result', () => {
@@ -937,4 +948,21 @@ describe('TreeSelect', () => {
         ).toEqual(0);
     });
 
+    it('expandedKeys controlled + filterTreeNode', () => {
+        const spyOnExpand = sinon.spy(() => { });
+        const treeSelect = getTreeSelect({
+            expandedKeys: [],
+            onExpand: spyOnExpand,
+            filterTreeNode: true,
+        });
+        const searchWrapper = treeSelect.find(`.${BASE_CLASS_PREFIX}-tree-search-wrapper`);
+        const searchValue = '北京';
+        const event = { target: { value: searchValue } };
+        searchWrapper.find('input').simulate('change', event);
+        expect(spyOnExpand.callCount).toBe(0);
+        /* filter won't impact on the expansion of node when expandedKeys is controlled */
+        const topNode = treeSelect.find(`.${BASE_CLASS_PREFIX}-tree-option.${BASE_CLASS_PREFIX}-tree-option-level-1`);
+        expect(topNode.at(0).hasClass(`${BASE_CLASS_PREFIX}-tree-option-collapsed`)).toEqual(true);
+        expect(topNode.at(1).hasClass(`${BASE_CLASS_PREFIX}-tree-option-collapsed`)).toEqual(true);
+    });
 })

+ 55 - 2
packages/semi-ui/treeSelect/_story/treeSelect.stories.js

@@ -1,10 +1,11 @@
 import React, { useState } from 'react';
-import { Icon, Button, Form, Popover, Tag } from '../../index';
+import { Icon, Button, Form, Popover, Tag, Typography } from '../../index';
 import TreeSelect from '../index';
 import { flattenDeep } from 'lodash';
 import CustomTrigger from './CustomTrigger';
 import { IconCreditCard } from '@douyinfe/semi-icons';
 const TreeNode = TreeSelect.TreeNode;
+const { Title } = Typography;
 
 export default {
   title: 'TreeSelect',
@@ -1406,4 +1407,56 @@ export const CheckRelationDemo = () => {
       />
     </>
   );
-};
+};
+
+export const SearchableAndExpandedKeys = () => {
+  const [expandedKeys1, setExpandedKeys1] = useState([]);
+  const [expandedKeys2, setExpandedKeys2] = useState([]);
+  const [expandedKeys3, setExpandedKeys3] = useState([]);
+  return (
+      <>
+          <Title heading={6}>expandedKeys 受控</Title>
+          <TreeSelect
+              style={{ width: 300, marginBottom: 30 }}
+              dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
+              treeData={treeData2}
+              expandedKeys={expandedKeys1}
+              defaultValue='beijing'
+              onExpand={v => {
+                  console.log('onExpand value: ', v);
+                  setExpandedKeys1(v);
+              }}
+          />
+          <Title heading={6}>expandedKeys 受控 + 开启搜索</Title>
+          <TreeSelect
+              style={{ width: 300, marginBottom: 30 }}
+              dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
+              treeData={treeData2}
+              filterTreeNode
+              defaultValue='beijing'
+              expandedKeys={expandedKeys2}
+              onExpand={v => {
+                  console.log('onExpand value: ', v);
+                  setExpandedKeys2(v);
+              }}
+          />
+          <Title heading={6}>expandedKeys 受控 + 开启搜索 + 搜索时更新 expandedKeys</Title>
+          <TreeSelect
+              style={{ width: 300, marginBottom: 30 }}
+              dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
+              treeData={treeData2}
+              filterTreeNode
+              expandedKeys={expandedKeys3}
+              defaultValue='beijing'
+              onExpand={v => {
+                  console.log('onExpand value: ', v);
+                  setExpandedKeys3(v)
+              }}
+              onSearch={(input, filterExpandedKeys) => {
+                  console.log('onExpand filterExpandedKeys: ', filterExpandedKeys);
+                  setExpandedKeys3(filterExpandedKeys);
+              }}
+          />
+      </>
+  )
+}

+ 11 - 3
packages/semi-ui/treeSelect/index.tsx

@@ -561,8 +561,8 @@ class TreeSelect extends BaseComponent<TreeSelectProps, TreeSelectState> {
             notifySelect: ((selectKey, bool, node) => {
                 this.props.onSelect && this.props.onSelect(selectKey, bool, node);
             }),
-            notifySearch: input => {
-                this.props.onSearch && this.props.onSearch(input);
+            notifySearch: (input, filteredExpandedKeys) => {
+                this.props.onSearch && this.props.onSearch(input, filteredExpandedKeys);
             },
             cacheFlattenNodes: bool => {
                 this._flattenNodes = bool ? cloneDeep(this.state.flattenNodes) : null;
@@ -1232,9 +1232,10 @@ class TreeSelect extends BaseComponent<TreeSelectProps, TreeSelectState> {
     };
 
     renderNodeList = () => {
-        const { flattenNodes, motionKeys, motionType } = this.state;
+        const { flattenNodes, motionKeys, motionType, filteredKeys } = this.state;
         const { direction } = this.context;
         const { virtualize, motionExpand } = this.props;
+        const isExpandControlled = 'expandedKeys' in this.props;
         if (!virtualize || isEmpty(virtualize)) {
             return (
                 <NodeList
@@ -1242,6 +1243,13 @@ class TreeSelect extends BaseComponent<TreeSelectProps, TreeSelectState> {
                     flattenList={this._flattenNodes}
                     motionKeys={motionExpand ? motionKeys : new Set([])}
                     motionType={motionType}
+                    // When motionKeys is empty, but filteredKeys is not empty (that is, the search hits), this situation should be distinguished from ordinary motionKeys
+                    searchTargetIsDeep={
+                        isExpandControlled &&
+                        motionExpand &&
+                        isEmpty(motionKeys) &&
+                        !isEmpty(filteredKeys)
+                    }
                     onMotionEnd={this.onMotionEnd}
                     renderTreeNode={this.renderTreeNode}
                 />