diff --git a/package.json b/package.json index 0f8f3307..611d15fc 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ }, "dependencies": { "classnames": "2.x", - "@rc-component/select": "~1.0.7", + "@rc-component/select": "~1.1.0", "@rc-component/tree": "~1.0.1", "@rc-component/util": "^1.2.1" }, diff --git a/src/TreeSelect.tsx b/src/TreeSelect.tsx index d6d2929d..df71504b 100644 --- a/src/TreeSelect.tsx +++ b/src/TreeSelect.tsx @@ -34,11 +34,19 @@ import type { FieldNames, LegacyDataNode, } from './interface'; +import useSearchConfig from './hooks/useSearchConfig'; export type SemanticName = 'input' | 'prefix' | 'suffix'; export type PopupSemantic = 'item' | 'itemTitle'; +export interface SearchConfig { + searchValue?: string; + onSearch?: (value: string) => void; + autoClearSearchValue?: boolean; + filterTreeNode?: boolean | ((inputValue: string, treeNode: DataNode) => boolean); + treeNodeFilterProp?: string; +} export interface TreeSelectProps - extends Omit { + extends Omit { prefixCls?: string; id?: string; children?: React.ReactNode; @@ -54,12 +62,18 @@ export interface TreeSelectProps void; // >>> Search + showSearch?: boolean | SearchConfig; + /** @deprecated Use `showSearch.searchValue` instead */ searchValue?: string; - /** @deprecated Use `searchValue` instead */ + /** @deprecated Use `showSearch.searchValue` instead */ inputValue?: string; + /** @deprecated Use `showSearch.onSearch` instead */ onSearch?: (value: string) => void; + /** @deprecated Use `showSearch.autoClearSearchValue` instead */ autoClearSearchValue?: boolean; + /** @deprecated Use `showSearch.filterTreeNode` instead */ filterTreeNode?: boolean | ((inputValue: string, treeNode: DataNode) => boolean); + /** @deprecated Use `showSearch.treeNodeFilterProp` instead */ treeNodeFilterProp?: string; // >>> Select @@ -127,12 +141,7 @@ const TreeSelect = React.forwardRef((props, ref) onDeselect, // Search - searchValue, - inputValue, - onSearch, - autoClearSearchValue = true, - filterTreeNode, - treeNodeFilterProp = 'value', + showSearch, // Selector showCheckedStrategy, @@ -193,6 +202,15 @@ const TreeSelect = React.forwardRef((props, ref) const mergedLabelInValue = treeCheckStrictly || labelInValue; const mergedMultiple = mergedCheckable || multiple; + const [mergedShowSearch, searchConfig] = useSearchConfig(showSearch, props); + const { + searchValue, + onSearch, + autoClearSearchValue = true, + filterTreeNode, + treeNodeFilterProp = 'value', + } = searchConfig; + const [internalValue, setInternalValue] = useMergedState(defaultValue, { value }); // `multiple` && `!treeCheckable` should be show all @@ -219,7 +237,7 @@ const TreeSelect = React.forwardRef((props, ref) // =========================== Search =========================== const [mergedSearchValue, setSearchValue] = useMergedState('', { - value: searchValue !== undefined ? searchValue : inputValue, + value: searchValue, postState: search => search || '', }); @@ -725,6 +743,8 @@ const TreeSelect = React.forwardRef((props, ref) displayValues={cachedDisplayValues} onDisplayValuesChange={onDisplayValuesChange} // >>> Search + {...searchConfig} + showSearch={mergedShowSearch} searchValue={mergedSearchValue} onSearch={onInternalSearch} // >>> Options diff --git a/src/hooks/useSearchConfig.ts b/src/hooks/useSearchConfig.ts new file mode 100644 index 00000000..3d30fd9e --- /dev/null +++ b/src/hooks/useSearchConfig.ts @@ -0,0 +1,39 @@ +import type { SearchConfig, TreeSelectProps } from '@/TreeSelect'; +import * as React from 'react'; + +// Convert `showSearch` to unique config +export default function useSearchConfig( + showSearch: boolean | SearchConfig, + props: TreeSelectProps, +) { + const { + searchValue, + inputValue, + onSearch, + autoClearSearchValue, + filterTreeNode, + treeNodeFilterProp, + } = props; + return React.useMemo<[boolean | undefined, SearchConfig]>(() => { + const isObject = typeof showSearch === 'object'; + + const searchConfig: SearchConfig = { + searchValue: searchValue ?? inputValue, + onSearch, + autoClearSearchValue, + filterTreeNode, + treeNodeFilterProp, + ...(isObject ? showSearch : {}), + }; + + return [isObject ? true : showSearch, searchConfig]; + }, [ + showSearch, + searchValue, + inputValue, + onSearch, + autoClearSearchValue, + filterTreeNode, + treeNodeFilterProp, + ]); +} diff --git a/tests/Select.spec.tsx b/tests/Select.spec.tsx index 510f213a..b61abf46 100644 --- a/tests/Select.spec.tsx +++ b/tests/Select.spec.tsx @@ -1,4 +1,4 @@ -import { render } from '@testing-library/react'; +import { render, fireEvent } from '@testing-library/react'; import { mount } from 'enzyme'; import KeyCode from '@rc-component/util/lib/KeyCode'; import React from 'react'; @@ -709,4 +709,147 @@ describe('TreeSelect.basic', () => { expect(itemTitle).toHaveStyle(customStyles.popup.itemTitle); expect(item).toHaveStyle(customStyles.popup.item); }); + + describe('combine showSearch', () => { + const treeData = [ + { key: '0', value: 'a', title: '0 label' }, + { + key: '1', + value: 'b', + title: '1 label', + children: [ + { key: '10', value: 'ba', title: '10 label' }, + { key: '11', value: 'bb', title: '11 label' }, + ], + }, + ]; + it('searchValue and onSearch', () => { + const currentSearchFn = jest.fn(); + const legacySearchFn = jest.fn(); + const { container } = render( + <> +
+ +
+
+ +
+ , + ); + const legacyInput = container.querySelector('#test1 input'); + const currentInput = container.querySelector('#test2 input'); + fireEvent.change(legacyInput, { target: { value: '2' } }); + fireEvent.change(currentInput, { target: { value: '2' } }); + expect(currentSearchFn).toHaveBeenCalledWith('2'); + expect(legacySearchFn).toHaveBeenCalledWith('2'); + }); + it('treeNodeFilterProp and autoClearSearchValue', () => { + const { container } = render( + <> +
+ +
+
+ +
+ , + ); + const legacyInput = container.querySelector('#test1 input'); + const currentInput = container.querySelector('#test2 input'); + fireEvent.change(legacyInput, { target: { value: 'a' } }); + fireEvent.change(currentInput, { target: { value: 'a' } }); + const legacyItem = container.querySelector( + '#test1 .rc-tree-select-tree-title', + ); + const currentItem = container.querySelector( + '#test2 .rc-tree-select-tree-title', + ); + + expect(legacyInput).toHaveValue('a'); + expect(currentInput).toHaveValue('a'); + fireEvent.click(legacyItem); + fireEvent.click(currentItem); + const valueSpan = container.querySelectorAll( + ' .rc-tree-select-selection-item', + ); + + expect(valueSpan[0]).toHaveTextContent('0 label'); + expect(valueSpan[1]).toHaveTextContent('0 label'); + }); + it('filterTreeNode', () => { + const { container } = render( + <> +
+ node.value === inputValue} + /> +
+
+ node.value === inputValue, + }} + open + treeDefaultExpandAll + treeData={treeData} + /> +
+ , + ); + const legacyInput = container.querySelector('#test1 input'); + const currentInput = container.querySelector('#test2 input'); + fireEvent.change(legacyInput, { target: { value: 'bb' } }); + fireEvent.change(currentInput, { target: { value: 'bb' } }); + const options = container.querySelectorAll(' .rc-tree-select-tree-title'); + + expect(options).toHaveLength(4); + }); + it.each([ + // [description, props, shouldExist] + ['showSearch=false ', { showSearch: false }, false], + ['showSearch=undefined ', {}, false], + ['showSearch=true', { showSearch: true }, true], + ])('%s', (_, props: { showSearch?: boolean; mode?: 'tags' }, shouldExist) => { + const { container } = render( + , + ); + const inputNode = container.querySelector('input'); + if (shouldExist) { + expect(inputNode).not.toHaveAttribute('readonly'); + } else { + expect(inputNode).toHaveAttribute('readonly'); + } + }); + }); });