diff --git a/examples/basic.tsx b/examples/basic.tsx index 4a3b124b..1ee03c6f 100644 --- a/examples/basic.tsx +++ b/examples/basic.tsx @@ -253,6 +253,8 @@ class Demo extends React.Component {

check select

{ - console.log('Max Tag Rest Value:', valueList); + // console.log('Max Tag Rest Value:', valueList); return `${valueList.length} rest...`; }} /> diff --git a/examples/debug.tsx b/examples/debug.tsx index fc5086dc..c5548151 100644 --- a/examples/debug.tsx +++ b/examples/debug.tsx @@ -1,18 +1,35 @@ -/* eslint-disable react/no-array-index-key */ +/* eslint-disable */ import React from 'react'; import TreeSelect from '../src'; import '../assets/index.less'; -export default () => { - const [treeData, setTreeData] = React.useState([]); +const { TreeNode, SHOW_ALL, SHOW_CHILD } = TreeSelect; +const SelectNode = TreeNode; - React.useEffect(() => { - setTimeout(() => { - console.clear(); - setTreeData([{ value: 'light', title: 'bamboo' }]); - }, 1000); - }, []); +const treeData = [ + { key: '0', value: '0', title: 'label0' }, + { key: '1', value: '1', title: 'label1' }, +]; - return ; -}; +const children = [ + , + , +]; + +const createSelect = props => ; + +// export default () => ( +// +// ); + +export default () => + createSelect({ + maxTagCount: 1, + value: ['0', 'not exist'], + }); diff --git a/package.json b/package.json index f4ec0813..4a31d646 100644 --- a/package.json +++ b/package.json @@ -68,8 +68,8 @@ "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", - "rc-select": "~13.2.1", - "rc-tree": "~5.3.0", + "rc-select": "~14.0.0-alpha.8", + "rc-tree": "~5.3.3", "rc-util": "^5.16.1" } } diff --git a/src/Context.tsx b/src/Context.tsx deleted file mode 100644 index 62aa25c0..00000000 --- a/src/Context.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import * as React from 'react'; -import type { IconType } from 'rc-tree/lib/interface'; -import type { FlattenDataNode, Key, LegacyDataNode, RawValueType } from './interface'; -import type { SkipType } from './hooks/useKeyValueMapping'; - -interface ContextProps { - checkable: boolean | React.ReactNode; - checkedKeys: Key[]; - halfCheckedKeys: Key[]; - treeExpandedKeys: Key[]; - treeDefaultExpandedKeys: Key[]; - onTreeExpand: (keys: Key[]) => void; - treeDefaultExpandAll: boolean; - treeIcon: IconType; - showTreeIcon: boolean; - switcherIcon: IconType; - treeLine: boolean; - treeNodeFilterProp: string; - treeLoadedKeys: Key[]; - treeMotion: any; - loadData: (treeNode: LegacyDataNode) => Promise; - onTreeLoad: (loadedKeys: Key[]) => void; - - // Cache help content. These can be generated by parent component. - // Let's reuse this. - getEntityByKey: (key: Key, skipType?: SkipType, ignoreDisabledCheck?: boolean) => FlattenDataNode; - getEntityByValue: ( - value: RawValueType, - skipType?: SkipType, - ignoreDisabledCheck?: boolean, - ) => FlattenDataNode; -} - -export const SelectContext = React.createContext(null); diff --git a/src/LegacyContext.tsx b/src/LegacyContext.tsx new file mode 100644 index 00000000..61347e6f --- /dev/null +++ b/src/LegacyContext.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import type { DataEntity, IconType } from 'rc-tree/lib/interface'; +import type { Key, LegacyDataNode, RawValueType } from './interface'; + +interface LegacyContextProps { + checkable: boolean | React.ReactNode; + checkedKeys: Key[]; + halfCheckedKeys: Key[]; + treeExpandedKeys: Key[]; + treeDefaultExpandedKeys: Key[]; + onTreeExpand: (keys: Key[]) => void; + treeDefaultExpandAll: boolean; + treeIcon: IconType; + showTreeIcon: boolean; + switcherIcon: IconType; + treeLine: boolean; + treeNodeFilterProp: string; + treeLoadedKeys: Key[]; + treeMotion: any; + loadData: (treeNode: LegacyDataNode) => Promise; + onTreeLoad: (loadedKeys: Key[]) => void; + + keyEntities: Record>; +} + +const LegacySelectContext = React.createContext(null); + +export default LegacySelectContext; diff --git a/src/OptionList.tsx b/src/OptionList.tsx index e793e395..279c23c9 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -2,11 +2,14 @@ import * as React from 'react'; import KeyCode from 'rc-util/lib/KeyCode'; import useMemo from 'rc-util/lib/hooks/useMemo'; import type { RefOptionListProps } from 'rc-select/lib/OptionList'; +import { useBaseProps } from 'rc-select'; import type { TreeProps } from 'rc-tree'; import Tree from 'rc-tree'; import type { EventDataNode, ScrollTo } from 'rc-tree/lib/interface'; -import type { FlattenDataNode, RawValueType, DataNode, TreeDataNode, Key } from './interface'; -import { SelectContext } from './Context'; +import type { TreeDataNode, Key } from './interface'; +import LegacyContext from './LegacyContext'; +import TreeSelectContext from './TreeSelectContext'; +import { getAllKeys, isCheckDisabled } from './utils/valueUtil'; const HIDDEN_STYLE = { width: 0, @@ -25,53 +28,14 @@ interface TreeEventInfo { checked?: boolean; } -export interface OptionListProps { - prefixCls: string; - id: string; - options: OptionsType; - flattenOptions: FlattenDataNode[]; - height: number; - itemHeight: number; - virtual?: boolean; - values: Set; - multiple: boolean; - open: boolean; - defaultActiveFirstOption?: boolean; - notFoundContent?: React.ReactNode; - menuItemSelectedIcon?: any; - childrenAsData: boolean; - searchValue: string; - - onSelect: (value: RawValueType, option: { selected: boolean }) => void; - onToggleOpen: (open?: boolean) => void; - /** Tell Select that some value is now active to make accessibility work */ - onActiveValue: (value: RawValueType, index: number) => void; - onScroll: React.UIEventHandler; - - onMouseEnter: () => void; -} - type ReviseRefOptionListProps = Omit & { scrollTo: ScrollTo }; -const OptionList: React.RefForwardingComponent< - ReviseRefOptionListProps, - OptionListProps -> = (props, ref) => { - const { - prefixCls, - height, - itemHeight, - virtual, - options, - flattenOptions, - multiple, - searchValue, - onSelect, - onToggleOpen, - open, - notFoundContent, - onMouseEnter, - } = props; +const OptionList: React.RefForwardingComponent = (_, ref) => { + const { prefixCls, multiple, searchValue, toggleOpen, open, notFoundContent } = useBaseProps(); + + const { virtual, listHeight, listItemHeight, treeData, fieldNames, onSelect } = + React.useContext(TreeSelectContext); + const { checkable, checkedKeys, @@ -89,46 +53,34 @@ const OptionList: React.RefForwardingComponent< treeLoadedKeys, treeMotion, onTreeLoad, - - getEntityByKey, - getEntityByValue, - } = React.useContext(SelectContext); + keyEntities, + } = React.useContext(LegacyContext); const treeRef = React.useRef(); - const memoOptions = useMemo( - () => options, - [open, options], + const memoTreeData = useMemo( + () => treeData, + [open, treeData], (prev, next) => next[0] && prev[1] !== next[1], ); // ========================== Values ========================== - const valueKeys = React.useMemo( - () => - checkedKeys.map(val => { - // We should keep disabled value entity here - const entity = getEntityByValue(val, undefined, true); - return entity ? entity.key : null; - }), - [checkedKeys, getEntityByValue], - ); - const mergedCheckedKeys = React.useMemo(() => { if (!checkable) { return null; } return { - checked: valueKeys, + checked: checkedKeys, halfChecked: halfCheckedKeys, }; - }, [valueKeys, halfCheckedKeys, checkable]); + }, [checkable, checkedKeys, halfCheckedKeys]); // ========================== Scroll ========================== React.useEffect(() => { // Single mode should scroll to current key - if (open && !multiple && valueKeys.length) { - treeRef.current?.scrollTo({ key: valueKeys[0] }); + if (open && !multiple && checkedKeys.length) { + treeRef.current?.scrollTo({ key: checkedKeys[0] }); } }, [open]); @@ -150,11 +102,11 @@ const OptionList: React.RefForwardingComponent< return [...treeExpandedKeys]; } return searchValue ? searchExpandedKeys : expandedKeys; - }, [expandedKeys, searchExpandedKeys, lowerSearchValue, treeExpandedKeys]); + }, [expandedKeys, searchExpandedKeys, treeExpandedKeys, searchValue]); React.useEffect(() => { if (searchValue) { - setSearchExpandedKeys(flattenOptions.map(o => o.key)); + setSearchExpandedKeys(getAllKeys(treeData, fieldNames)); } }, [searchValue]); @@ -172,22 +124,25 @@ const OptionList: React.RefForwardingComponent< event.preventDefault(); }; - const onInternalSelect = (_: Key[], { node: { key } }: TreeEventInfo) => { - const entity = getEntityByKey(key, checkable ? 'checkbox' : 'select'); - if (entity !== null) { - onSelect(entity.data.value, { - selected: !checkedKeys.includes(entity.data.value), - }); + const onInternalSelect = (__: React.Key[], info: TreeEventInfo) => { + const { node } = info; + + if (checkable && isCheckDisabled(node)) { + return; } + onSelect(node.key, { + selected: !checkedKeys.includes(node.key), + }); + if (!multiple) { - onToggleOpen(false); + toggleOpen(false); } }; // ========================= Keyboard ========================= const [activeKey, setActiveKey] = React.useState(null); - const activeEntity = getEntityByKey(activeKey); + const activeEntity = keyEntities[activeKey]; React.useImperativeHandle(ref, () => ({ scrollTo: treeRef.current?.scrollTo, @@ -204,7 +159,7 @@ const OptionList: React.RefForwardingComponent< // >>> Select item case KeyCode.ENTER: { - const { selectable, value } = activeEntity?.data.node || {}; + const { selectable, value } = activeEntity?.node || {}; if (selectable !== false) { onInternalSelect(null, { node: { key: activeKey }, @@ -216,7 +171,7 @@ const OptionList: React.RefForwardingComponent< // >>> Close case KeyCode.ESC: { - onToggleOpen(false); + toggleOpen(false); } } }, @@ -224,7 +179,7 @@ const OptionList: React.RefForwardingComponent< })); // ========================== Render ========================== - if (memoOptions.length === 0) { + if (memoTreeData.length === 0) { return (
{notFoundContent} @@ -232,7 +187,9 @@ const OptionList: React.RefForwardingComponent< ); } - const treeProps: Partial = {}; + const treeProps: Partial = { + fieldNames, + }; if (treeLoadedKeys) { treeProps.loadedKeys = treeLoadedKeys; } @@ -241,10 +198,10 @@ const OptionList: React.RefForwardingComponent< } return ( -
+
{activeEntity && open && ( - {activeEntity.data.value} + {activeEntity.node.value} )} @@ -252,9 +209,9 @@ const OptionList: React.RefForwardingComponent< ref={treeRef} focusable={false} prefixCls={`${prefixCls}-tree`} - treeData={memoOptions as TreeDataNode[]} - height={height} - itemHeight={itemHeight} + treeData={memoTreeData as TreeDataNode[]} + height={listHeight} + itemHeight={listItemHeight} virtual={virtual} multiple={multiple} icon={treeIcon} @@ -267,7 +224,7 @@ const OptionList: React.RefForwardingComponent< checkable={checkable} checkStrictly checkedKeys={mergedCheckedKeys} - selectedKeys={!checkable ? valueKeys : []} + selectedKeys={!checkable ? checkedKeys : []} defaultExpandAll={treeDefaultExpandAll} {...treeProps} // Proxy event out @@ -282,9 +239,7 @@ const OptionList: React.RefForwardingComponent< ); }; -const RefOptionList = React.forwardRef>( - OptionList, -); +const RefOptionList = React.forwardRef(OptionList); RefOptionList.displayName = 'OptionList'; export default RefOptionList; diff --git a/src/TreeSelect.tsx b/src/TreeSelect.tsx index d92fc201..7d2dc6ef 100644 --- a/src/TreeSelect.tsx +++ b/src/TreeSelect.tsx @@ -1,8 +1,730 @@ -import generate, { TreeSelectProps } from './generate'; +import * as React from 'react'; +import { BaseSelect } from 'rc-select'; +import type { IconType } from 'rc-tree/lib/interface'; +import type { + BaseSelectRef, + BaseSelectPropsWithoutPrivate, + BaseSelectProps, + SelectProps, +} from 'rc-select'; +import { conductCheck } from 'rc-tree/lib/utils/conductUtil'; +import useId from 'rc-select/lib/hooks/useId'; +import useMergedState from 'rc-util/lib/hooks/useMergedState'; import OptionList from './OptionList'; +import TreeNode from './TreeNode'; +import { formatStrategyValues, SHOW_ALL, SHOW_PARENT, SHOW_CHILD } from './utils/strategyUtil'; +import type { CheckedStrategy } from './utils/strategyUtil'; +import TreeSelectContext from './TreeSelectContext'; +import type { TreeSelectContextProps } from './TreeSelectContext'; +import LegacyContext from './LegacyContext'; +import useTreeData from './hooks/useTreeData'; +import { toArray, fillFieldNames, isNil } from './utils/valueUtil'; +import useCache from './hooks/useCache'; +import useRefFunc from './hooks/useRefFunc'; +import useDataEntities from './hooks/useDataEntities'; +import { fillAdditionalInfo, fillLegacyProps } from './utils/legacyUtil'; +import useCheckedKeys from './hooks/useCheckedKeys'; +import useFilterTreeData from './hooks/useFilterTreeData'; +import warningProps from './utils/warningPropsUtil'; +import warning from 'rc-util/lib/warning'; -const TreeSelect = generate({ prefixCls: 'rc-tree-select', optionList: OptionList as any }); +export type OnInternalSelect = (value: RawValueType, info: { selected: boolean }) => void; -export { TreeSelectProps }; +export type RawValueType = string | number; -export default TreeSelect; +export interface LabeledValueType { + key?: React.Key; + value?: RawValueType; + label?: React.ReactNode; + /** Only works on `treeCheckStrictly` */ + halfChecked?: boolean; +} + +export type SelectSource = 'option' | 'selection' | 'input' | 'clear'; + +export type ValueType = RawValueType | LabeledValueType | (RawValueType | LabeledValueType)[]; + +/** @deprecated This is only used for legacy compatible. Not works on new code. */ +export interface LegacyCheckedNode { + pos: string; + node: React.ReactElement; + children?: LegacyCheckedNode[]; +} + +export interface ChangeEventExtra { + /** @deprecated Please save prev value by control logic instead */ + preValue: LabeledValueType[]; + triggerValue: RawValueType; + /** @deprecated Use `onSelect` or `onDeselect` instead. */ + selected?: boolean; + /** @deprecated Use `onSelect` or `onDeselect` instead. */ + checked?: boolean; + + // Not sure if exist user still use this. We have to keep but not recommend user to use + /** @deprecated This prop not work as react node anymore. */ + triggerNode: React.ReactElement; + /** @deprecated This prop not work as react node anymore. */ + allCheckedNodes: LegacyCheckedNode[]; +} + +export interface FieldNames { + value?: string; + label?: string; + children?: string; +} + +export interface InternalFieldName extends Omit { + _title: string[]; +} + +export interface SimpleModeConfig { + id?: React.Key; + pId?: React.Key; + rootPId?: React.Key; +} + +export interface BaseOptionType { + disabled?: boolean; + checkable?: boolean; + disableCheckbox?: boolean; + children?: BaseOptionType[]; + [name: string]: any; +} + +export interface DefaultOptionType extends BaseOptionType { + value?: RawValueType; + title?: React.ReactNode; + label?: React.ReactNode; + key?: React.Key; + children?: DefaultOptionType[]; +} + +export interface LegacyDataNode extends DefaultOptionType { + props: any; +} +export interface TreeSelectProps + extends Omit { + prefixCls?: string; + id?: string; + + // >>> Value + value?: ValueType; + defaultValue?: ValueType; + onChange?: (value: ValueType, labelList: React.ReactNode[], extra: ChangeEventExtra) => void; + + // >>> Search + searchValue?: string; + /** @deprecated Use `searchValue` instead */ + inputValue?: string; + onSearch?: (value: string) => void; + autoClearSearchValue?: boolean; + filterTreeNode?: boolean | ((inputValue: string, treeNode: DefaultOptionType) => boolean); + treeNodeFilterProp?: string; + + // >>> Select + onSelect?: SelectProps['onSelect']; + onDeselect?: SelectProps['onDeselect']; + + // >>> Selector + showCheckedStrategy?: CheckedStrategy; + treeNodeLabelProp?: string; + + // >>> Field Names + fieldNames?: FieldNames; + + // >>> Mode + multiple?: boolean; + treeCheckable?: boolean | React.ReactNode; + treeCheckStrictly?: boolean; + labelInValue?: boolean; + + // >>> Data + treeData?: OptionType[]; + treeDataSimpleMode?: boolean | SimpleModeConfig; + loadData?: (dataNode: LegacyDataNode) => Promise; + treeLoadedKeys?: React.Key[]; + onTreeLoad?: (loadedKeys: React.Key[]) => void; + + // >>> Expanded + treeDefaultExpandAll?: boolean; + treeExpandedKeys?: React.Key[]; + treeDefaultExpandedKeys?: React.Key[]; + onTreeExpand?: (expandedKeys: React.Key[]) => void; + + // >>> Options + virtual?: boolean; + listHeight?: number; + listItemHeight?: number; + onDropdownVisibleChange?: (open: boolean) => void; + + // >>> Tree + treeLine?: boolean; + treeIcon?: IconType; + showTreeIcon?: boolean; + switcherIcon?: IconType; + treeMotion?: any; +} + +function isRawValue(value: RawValueType | LabeledValueType): value is RawValueType { + return !value || typeof value !== 'object'; +} + +const TreeSelect = React.forwardRef((props, ref) => { + const { + id, + prefixCls = 'rc-tree-select', + + // Value + value, + defaultValue, + onChange, + onSelect, + onDeselect, + + // Search + searchValue, + inputValue, + onSearch, + autoClearSearchValue = true, + filterTreeNode, + treeNodeFilterProp = 'value', + + // Selector + showCheckedStrategy = SHOW_CHILD, + treeNodeLabelProp, + + // Mode + multiple, + treeCheckable, + treeCheckStrictly, + labelInValue, + + // FieldNames + fieldNames, + + // Data + treeDataSimpleMode, + treeData, + children, + loadData, + treeLoadedKeys, + onTreeLoad, + + // Expanded + treeDefaultExpandAll, + treeExpandedKeys, + treeDefaultExpandedKeys, + onTreeExpand, + + // Options + virtual, + listHeight = 200, + listItemHeight = 20, + onDropdownVisibleChange, + + // Tree + treeLine, + treeIcon, + showTreeIcon, + switcherIcon, + treeMotion, + } = props; + + const mergedId = useId(id); + const treeConduction = treeCheckable && !treeCheckStrictly; + const mergedCheckable: boolean = !!(treeCheckable || treeCheckStrictly); + const mergedLabelInValue = treeCheckStrictly || labelInValue; + const mergedMultiple = mergedCheckable || multiple; + + // ========================== Warning =========================== + if (process.env.NODE_ENV !== 'production') { + warningProps(props); + } + + // ========================= FieldNames ========================= + const mergedFieldNames: InternalFieldName = React.useMemo( + () => fillFieldNames(fieldNames), + /* eslint-disable react-hooks/exhaustive-deps */ + [JSON.stringify(fieldNames)], + /* eslint-enable react-hooks/exhaustive-deps */ + ); + + // =========================== Search =========================== + const [mergedSearchValue, setSearchValue] = useMergedState('', { + value: searchValue !== undefined ? searchValue : inputValue, + postState: search => search || '', + }); + + const onInternalSearch: BaseSelectProps['onSearch'] = searchText => { + setSearchValue(searchText); + onSearch?.(searchText); + }; + + // ============================ Data ============================ + // `useTreeData` only do convert of `children` or `simpleMode`. + // Else will return origin `treeData` for perf consideration. + // Do not do anything to loop the data. + const mergedTreeData = useTreeData(treeData, children, treeDataSimpleMode); + + const { keyEntities, valueEntities } = useDataEntities(mergedTreeData, mergedFieldNames); + + /** Get `missingRawValues` which not exist in the tree yet */ + const splitRawValues = React.useCallback( + (newRawValues: RawValueType[]) => { + const missingRawValues = []; + const existRawValues = []; + + // Keep missing value in the cache + newRawValues.forEach(val => { + if (valueEntities.has(val)) { + existRawValues.push(val); + } else { + missingRawValues.push(val); + } + }); + + return { missingRawValues, existRawValues }; + }, + [valueEntities], + ); + + // Filtered Tree + const filteredTreeData = useFilterTreeData(mergedTreeData, mergedSearchValue, { + fieldNames: mergedFieldNames, + treeNodeFilterProp, + filterTreeNode, + }); + + // =========================== Label ============================ + const getLabel = React.useCallback( + (item: DefaultOptionType) => { + if (item) { + if (treeNodeLabelProp) { + return item[treeNodeLabelProp]; + } + + // Loop from fieldNames + const { _title: titleList } = mergedFieldNames; + + for (let i = 0; i < titleList.length; i += 1) { + const title = item[titleList[i]]; + if (title !== undefined) { + return title; + } + } + } + }, + [mergedFieldNames, treeNodeLabelProp], + ); + + // ========================= Wrap Value ========================= + const toLabeledValues = React.useCallback((draftValues: ValueType) => { + const values = toArray(draftValues); + + return values.map(val => { + if (isRawValue(val)) { + return { value: val }; + } + return val; + }); + }, []); + + const convert2LabelValues = React.useCallback( + (draftValues: ValueType) => { + const values = toLabeledValues(draftValues); + + return values.map(item => { + let { label: rawLabel } = item; + const { value: rawValue, halfChecked: rawHalfChecked } = item; + + let rawDisabled: boolean | undefined; + + const entity = valueEntities.get(rawValue); + + // Fill missing label & status + if (entity) { + rawLabel = rawLabel ?? getLabel(entity.node); + rawDisabled = entity.node.disabled; + } + + return { + label: rawLabel, + value: rawValue, + halfChecked: rawHalfChecked, + disabled: rawDisabled, + }; + }); + }, + [valueEntities, getLabel, toLabeledValues], + ); + + // =========================== Values =========================== + const [internalValue, setInternalValue] = useMergedState(defaultValue, { value }); + + const rawMixedLabeledValues = React.useMemo( + () => toLabeledValues(internalValue), + [toLabeledValues, internalValue], + ); + + // Split value into full check and half check + const [rawLabeledValues, rawHalfLabeledValues] = React.useMemo(() => { + const fullCheckValues: LabeledValueType[] = []; + const halfCheckValues: LabeledValueType[] = []; + + rawMixedLabeledValues.forEach(item => { + if (item.halfChecked) { + halfCheckValues.push(item); + } else { + fullCheckValues.push(item); + } + }); + + return [fullCheckValues, halfCheckValues]; + }, [rawMixedLabeledValues]); + + // const [mergedValues] = useCache(rawLabeledValues); + const rawValues = React.useMemo( + () => rawLabeledValues.map(item => item.value), + [rawLabeledValues], + ); + + // Convert value to key. Will fill missed keys for conduct check. + const [rawCheckedValues, rawHalfCheckedValues] = useCheckedKeys( + rawLabeledValues, + rawHalfLabeledValues, + treeConduction, + keyEntities, + ); + + // Convert rawCheckedKeys to check strategy related values + const displayValues = React.useMemo(() => { + // Collect keys which need to show + const displayKeys = formatStrategyValues( + rawCheckedValues, + showCheckedStrategy, + keyEntities, + mergedFieldNames, + ); + + // Convert to value and filled with label + const values = displayKeys.map(key => keyEntities[key]?.node?.[mergedFieldNames.value] ?? key); + const rawDisplayValues = convert2LabelValues(values); + + const firstVal = rawDisplayValues[0]; + + if (!mergedMultiple && firstVal && isNil(firstVal.value) && isNil(firstVal.label)) { + return []; + } + + return rawDisplayValues.map(item => ({ + ...item, + label: item.label ?? item.value, + })); + }, [ + mergedFieldNames, + mergedMultiple, + rawCheckedValues, + convert2LabelValues, + showCheckedStrategy, + keyEntities, + ]); + + const [cachedDisplayValues] = useCache(displayValues); + + // =========================== Change =========================== + const triggerChange = useRefFunc( + ( + newRawValues: RawValueType[], + extra: { triggerValue?: RawValueType; selected?: boolean }, + source: SelectSource, + ) => { + const labeledValues = convert2LabelValues(newRawValues); + setInternalValue(labeledValues); + + // Clean up if needed + if (autoClearSearchValue) { + setSearchValue(''); + } + + // Generate rest parameters is costly, so only do it when necessary + if (onChange) { + let eventValues: RawValueType[] = newRawValues; + if (treeConduction) { + const formattedKeyList = formatStrategyValues( + newRawValues, + showCheckedStrategy, + keyEntities, + mergedFieldNames, + ); + eventValues = formattedKeyList.map(key => { + const entity = valueEntities.get(key); + return entity ? entity.node[mergedFieldNames.value] : key; + }); + } + + const { triggerValue, selected } = extra || { + triggerValue: undefined, + selected: undefined, + }; + + let returnRawValues: (LabeledValueType | RawValueType)[] = eventValues; + + // We need fill half check back + if (treeCheckStrictly) { + const halfValues = rawHalfLabeledValues.filter(item => !eventValues.includes(item.value)); + + returnRawValues = [...returnRawValues, ...halfValues]; + } + + const returnLabeledValues = convert2LabelValues(returnRawValues); + const additionalInfo = { + // [Legacy] Always return as array contains label & value + preValue: rawLabeledValues, + triggerValue, + } as ChangeEventExtra; + + // [Legacy] Fill legacy data if user query. + // This is expansive that we only fill when user query + // https://github.com/react-component/tree-select/blob/fe33eb7c27830c9ac70cd1fdb1ebbe7bc679c16a/src/Select.jsx + let showPosition = true; + if (treeCheckStrictly || (source === 'selection' && !selected)) { + showPosition = false; + } + + fillAdditionalInfo( + additionalInfo, + triggerValue, + newRawValues, + mergedTreeData, + showPosition, + mergedFieldNames, + ); + + if (mergedCheckable) { + additionalInfo.checked = selected; + } else { + additionalInfo.selected = selected; + } + + const returnValues = mergedLabelInValue + ? returnLabeledValues + : returnLabeledValues.map(item => item.value); + + onChange( + mergedMultiple ? returnValues : returnValues[0], + mergedLabelInValue ? null : returnLabeledValues.map(item => item.label), + additionalInfo, + ); + } + }, + ); + + // ========================== Options =========================== + /** Trigger by option list */ + const onOptionSelect = React.useCallback( + (selectedKey: React.Key, { selected, source }: { selected: boolean; source: SelectSource }) => { + const entity = keyEntities[selectedKey]; + const node = entity?.node; + const selectedValue = node?.[mergedFieldNames.value] ?? selectedKey; + + // Never be falsy but keep it safe + if (!mergedMultiple) { + // Single mode always set value + triggerChange([selectedValue], { selected: true, triggerValue: selectedValue }, 'option'); + } else { + let newRawValues = selected + ? [...rawValues, selectedValue] + : rawCheckedValues.filter(v => v !== selectedValue); + + // Add keys if tree conduction + if (treeConduction) { + // Should keep missing values + const { missingRawValues, existRawValues } = splitRawValues(newRawValues); + const keyList = existRawValues.map(val => valueEntities.get(val).key); + + // Conduction by selected or not + let checkedKeys: React.Key[]; + if (selected) { + ({ checkedKeys } = conductCheck(keyList, true, keyEntities)); + } else { + ({ checkedKeys } = conductCheck( + keyList, + { checked: false, halfCheckedKeys: rawHalfCheckedValues }, + keyEntities, + )); + } + + // Fill back of keys + newRawValues = [ + ...missingRawValues, + ...checkedKeys.map(key => keyEntities[key].node[mergedFieldNames.value]), + ]; + } + triggerChange(newRawValues, { selected, triggerValue: selectedValue }, source || 'option'); + } + + // Trigger select event + if (selected || !mergedMultiple) { + onSelect?.(selectedValue, fillLegacyProps(node)); + } else { + onDeselect?.(selectedValue, fillLegacyProps(node)); + } + }, + [ + splitRawValues, + valueEntities, + keyEntities, + mergedFieldNames, + mergedMultiple, + rawValues, + triggerChange, + treeConduction, + onSelect, + onDeselect, + rawCheckedValues, + rawHalfCheckedValues, + ], + ); + + // ========================== Dropdown ========================== + const onInternalDropdownVisibleChange = React.useCallback( + (open: boolean) => { + if (onDropdownVisibleChange) { + const legacyParam = {}; + + Object.defineProperty(legacyParam, 'documentClickClose', { + get() { + warning(false, 'Second param of `onDropdownVisibleChange` has been removed.'); + return false; + }, + }); + + (onDropdownVisibleChange as any)(open, legacyParam); + } + }, + [onDropdownVisibleChange], + ); + + // ====================== Display Change ======================== + const onDisplayValuesChange = useRefFunc( + (newValues, info) => { + const newRawValues = newValues.map(item => item.value); + + if (info.type === 'clear') { + triggerChange(newRawValues, {}, 'selection'); + return; + } + + // TreeSelect only have multiple mode which means display change only has remove + if (info.values.length) { + onOptionSelect(info.values[0].value, { selected: false, source: 'selection' }); + } + }, + ); + + // ========================== Context =========================== + const treeSelectContext = React.useMemo( + () => ({ + virtual, + listHeight, + listItemHeight, + treeData: filteredTreeData, + fieldNames: mergedFieldNames, + onSelect: onOptionSelect, + }), + [virtual, listHeight, listItemHeight, filteredTreeData, mergedFieldNames, onOptionSelect], + ); + + // ======================= Legacy Context ======================= + const legacyContext = React.useMemo( + () => ({ + checkable: mergedCheckable, + loadData, + treeLoadedKeys, + onTreeLoad, + checkedKeys: rawCheckedValues, + halfCheckedKeys: rawHalfCheckedValues, + treeDefaultExpandAll, + treeExpandedKeys, + treeDefaultExpandedKeys, + onTreeExpand, + treeIcon, + treeMotion, + showTreeIcon, + switcherIcon, + treeLine, + treeNodeFilterProp, + keyEntities, + }), + [ + mergedCheckable, + loadData, + treeLoadedKeys, + onTreeLoad, + rawCheckedValues, + rawHalfCheckedValues, + treeDefaultExpandAll, + treeExpandedKeys, + treeDefaultExpandedKeys, + onTreeExpand, + treeIcon, + treeMotion, + showTreeIcon, + switcherIcon, + treeLine, + treeNodeFilterProp, + keyEntities, + ], + ); + + // =========================== Render =========================== + return ( + + + >> MISC + id={mergedId} + prefixCls={prefixCls} + mode={mergedMultiple ? 'multiple' : undefined} + // >>> Display Value + displayValues={cachedDisplayValues} + onDisplayValuesChange={onDisplayValuesChange} + // >>> Search + searchValue={mergedSearchValue} + onSearch={onInternalSearch} + // >>> Options + OptionList={OptionList} + emptyOptions={!mergedTreeData.length} + onDropdownVisibleChange={onInternalDropdownVisibleChange} + /> + + + ); +}); + +// Assign name for Debug +if (process.env.NODE_ENV !== 'production') { + TreeSelect.displayName = 'TreeSelect'; +} + +const GenericTreeSelect = TreeSelect as unknown as (< + Values extends BaseOptionType | DefaultOptionType = DefaultOptionType, +>( + props: React.PropsWithChildren> & { + ref?: React.Ref; + }, +) => React.ReactElement) & { + TreeNode: typeof TreeNode; + SHOW_ALL: typeof SHOW_ALL; + SHOW_PARENT: typeof SHOW_PARENT; + SHOW_CHILD: typeof SHOW_CHILD; +}; + +GenericTreeSelect.TreeNode = TreeNode; +GenericTreeSelect.SHOW_ALL = SHOW_ALL; +GenericTreeSelect.SHOW_PARENT = SHOW_PARENT; +GenericTreeSelect.SHOW_CHILD = SHOW_CHILD; + +export default GenericTreeSelect; diff --git a/src/TreeSelectContext.ts b/src/TreeSelectContext.ts new file mode 100644 index 00000000..2157044d --- /dev/null +++ b/src/TreeSelectContext.ts @@ -0,0 +1,15 @@ +import * as React from 'react'; +import type { DefaultOptionType, InternalFieldName, OnInternalSelect } from './TreeSelect'; + +export interface TreeSelectContextProps { + virtual?: boolean; + listHeight: number; + listItemHeight: number; + treeData: DefaultOptionType[]; + fieldNames: InternalFieldName; + onSelect: OnInternalSelect; +} + +const TreeSelectContext = React.createContext(null as any); + +export default TreeSelectContext; diff --git a/src/generate.tsx b/src/generate.tsx deleted file mode 100644 index 64d06c6c..00000000 --- a/src/generate.tsx +++ /dev/null @@ -1,634 +0,0 @@ -import * as React from 'react'; -import { useMemo } from 'react'; -import type { SelectProps, RefSelectProps, GenerateConfig } from 'rc-select/lib/generate'; -import generateSelector from 'rc-select/lib/generate'; -import { getLabeledValue } from 'rc-select/lib/utils/valueUtil'; -import { convertDataToEntities } from 'rc-tree/lib/utils/treeUtil'; -import { conductCheck } from 'rc-tree/lib/utils/conductUtil'; -import type { IconType } from 'rc-tree/lib/interface'; -import omit from 'rc-util/lib/omit'; -import type { FilterFunc } from 'rc-select/lib/interface/generator'; -import { INTERNAL_PROPS_MARK } from 'rc-select/lib/interface/generator'; -import useMergedState from 'rc-util/lib/hooks/useMergedState'; -import warning from 'rc-util/lib/warning'; -import TreeNode from './TreeNode'; -import type { - Key, - DefaultValueType, - DataNode, - LabelValueType, - SimpleModeConfig, - RawValueType, - ChangeEventExtra, - LegacyDataNode, - SelectSource, - FlattenDataNode, - FieldNames, -} from './interface'; -import { - flattenOptions, - filterOptions, - isValueDisabled, - findValueOption, - addValue, - removeValue, - getRawValueLabeled, - toArray, - fillFieldNames, -} from './utils/valueUtil'; -import warningProps from './utils/warningPropsUtil'; -import { SelectContext } from './Context'; -import useTreeData from './hooks/useTreeData'; -import useKeyValueMap from './hooks/useKeyValueMap'; -import useKeyValueMapping from './hooks/useKeyValueMapping'; -import type { CheckedStrategy } from './utils/strategyUtil'; -import { formatStrategyKeys, SHOW_ALL, SHOW_PARENT, SHOW_CHILD } from './utils/strategyUtil'; -import { fillAdditionalInfo } from './utils/legacyUtil'; -import useSelectValues from './hooks/useSelectValues'; - -const OMIT_PROPS: (keyof TreeSelectProps)[] = [ - 'expandedKeys' as any, - 'treeData', - 'treeCheckable', - 'showCheckedStrategy', - 'searchPlaceholder', - 'treeLine', - 'treeIcon', - 'showTreeIcon', - 'switcherIcon', - 'treeNodeFilterProp', - 'filterTreeNode', - 'dropdownPopupAlign', - 'treeDefaultExpandAll', - 'treeCheckStrictly', - 'treeExpandedKeys', - 'treeLoadedKeys', - 'treeMotion', - 'onTreeExpand', - 'onTreeLoad', - 'labelRender', - 'loadData', - 'treeDataSimpleMode', - 'treeNodeLabelProp', - 'treeDefaultExpandedKeys', -]; - -export interface TreeSelectProps - extends Omit< - SelectProps, - | 'onChange' - | 'mode' - | 'menuItemSelectedIcon' - | 'dropdownAlign' - | 'backfill' - | 'getInputElement' - | 'optionLabelProp' - | 'tokenSeparators' - | 'filterOption' - | 'fieldNames' - > { - multiple?: boolean; - showArrow?: boolean; - showSearch?: boolean; - open?: boolean; - defaultOpen?: boolean; - value?: ValueType; - defaultValue?: ValueType; - disabled?: boolean; - - placeholder?: React.ReactNode; - /** @deprecated Use `searchValue` instead */ - inputValue?: string; - searchValue?: string; - autoClearSearchValue?: boolean; - - maxTagPlaceholder?: (omittedValues: LabelValueType[]) => React.ReactNode; - - fieldNames?: FieldNames; - loadData?: (dataNode: LegacyDataNode) => Promise; - treeNodeFilterProp?: string; - treeNodeLabelProp?: string; - treeDataSimpleMode?: boolean | SimpleModeConfig; - treeExpandedKeys?: Key[]; - treeDefaultExpandedKeys?: Key[]; - treeLoadedKeys?: Key[]; - treeCheckable?: boolean | React.ReactNode; - treeCheckStrictly?: boolean; - showCheckedStrategy?: CheckedStrategy; - treeDefaultExpandAll?: boolean; - treeData?: DataNode[]; - treeLine?: boolean; - treeIcon?: IconType; - showTreeIcon?: boolean; - switcherIcon?: IconType; - treeMotion?: any; - children?: React.ReactNode; - - filterTreeNode?: boolean | FilterFunc; - dropdownPopupAlign?: any; - - // Event - onSearch?: (value: string) => void; - onChange?: (value: ValueType, labelList: React.ReactNode[], extra: ChangeEventExtra) => void; - onTreeExpand?: (expandedKeys: Key[]) => void; - onTreeLoad?: (loadedKeys: Key[]) => void; - onDropdownVisibleChange?: (open: boolean) => void; - - // Legacy - /** `searchPlaceholder` has been removed since search box has been merged into input box */ - searchPlaceholder?: React.ReactNode; - - /** @private This is not standard API since we only used in `rc-cascader`. Do not use in your production */ - labelRender?: (entity: FlattenDataNode, value: RawValueType) => React.ReactNode; -} - -export default function generate(config: { - prefixCls: string; - optionList: GenerateConfig['components']['optionList']; -}) { - const { prefixCls, optionList } = config; - - const RefSelect = generateSelector({ - prefixCls, - components: { - optionList, - }, - // Not use generate since we will handle ourself - convertChildrenToData: () => null, - flattenOptions, - // Handle `optionLabelProp` in TreeSelect component - getLabeledValue: getLabeledValue as any, - filterOptions, - isValueDisabled, - findValueOption, - omitDOMProps: (props: TreeSelectProps) => omit(props, OMIT_PROPS), - }); - - RefSelect.displayName = 'Select'; - - // ================================================================================= - // = Tree = - // ================================================================================= - const RefTreeSelect = React.forwardRef((props, ref) => { - const { - fieldNames, - multiple, - treeCheckable, - treeCheckStrictly, - showCheckedStrategy = 'SHOW_CHILD', - labelInValue, - loadData, - treeLoadedKeys, - treeNodeFilterProp = 'value', - treeNodeLabelProp, - treeDataSimpleMode, - treeData, - treeExpandedKeys, - treeDefaultExpandedKeys, - treeDefaultExpandAll, - children, - treeIcon, - showTreeIcon, - switcherIcon, - treeLine, - treeMotion, - filterTreeNode, - dropdownPopupAlign, - onChange, - onTreeExpand, - onTreeLoad, - onDropdownVisibleChange, - onSelect, - onDeselect, - labelRender, - } = props; - const mergedCheckable: React.ReactNode | boolean = treeCheckable || treeCheckStrictly; - const mergedMultiple = multiple || mergedCheckable; - const treeConduction = treeCheckable && !treeCheckStrictly; - const mergedLabelInValue = treeCheckStrictly || labelInValue; - - // ======================= Tree Data ======================= - // FieldNames - const mergedFieldNames = fillFieldNames(fieldNames, true); - - // Legacy both support `label` or `title` if not set. - // We have to fallback to function to handle this - const getTreeNodeTitle = (node: DataNode): React.ReactNode => { - if (!treeData) { - return node.title; - } - - if (mergedFieldNames?.label) { - return node[mergedFieldNames.label]; - } - - return node.label || node.title; - }; - - const getTreeNodeLabelProp = (entity: FlattenDataNode, val: RawValueType): React.ReactNode => { - if (labelRender) { - return labelRender(entity, val); - } - - // Skip since entity not exist - if (!entity) { - return undefined; - } - - const { node } = entity.data; - - if (treeNodeLabelProp) { - return node[treeNodeLabelProp]; - } - - return getTreeNodeTitle(node); - }; - - const mergedTreeData = useTreeData(treeData, children, { - getLabelProp: getTreeNodeTitle, - simpleMode: treeDataSimpleMode, - fieldNames: mergedFieldNames, - }); - - const flattedOptions = useMemo(() => flattenOptions(mergedTreeData), [mergedTreeData]); - const [cacheKeyMap, cacheValueMap] = useKeyValueMap(flattedOptions); - const [getEntityByKey, getEntityByValue] = useKeyValueMapping(cacheKeyMap, cacheValueMap); - - // Only generate keyEntities for check conduction when is `treeCheckable` - const { keyEntities: conductKeyEntities } = useMemo(() => { - if (treeConduction) { - return convertDataToEntities(mergedTreeData as any); - } - return { keyEntities: null }; - }, [mergedTreeData, treeCheckable, treeCheckStrictly]); - - // ========================== Ref ========================== - const selectRef = React.useRef(null); - - React.useImperativeHandle(ref, () => ({ - scrollTo: selectRef.current.scrollTo, - focus: selectRef.current.focus, - blur: selectRef.current.blur, - - /** @private Internal usage. It's save to remove if `rc-cascader` not use it any longer */ - getEntityByValue, - })); - - // ========================= Value ========================= - const [value, setValue] = useMergedState(props.defaultValue, { - value: props.value, - }); - - /** Get `missingRawValues` which not exist in the tree yet */ - const splitRawValues = (newRawValues: RawValueType[]) => { - const missingRawValues = []; - const existRawValues = []; - - // Keep missing value in the cache - newRawValues.forEach(val => { - if (getEntityByValue(val)) { - existRawValues.push(val); - } else { - missingRawValues.push(val); - } - }); - - return { missingRawValues, existRawValues }; - }; - - const [rawValues, rawHalfCheckedKeys]: [RawValueType[], RawValueType[]] = useMemo(() => { - const valueHalfCheckedKeys: RawValueType[] = []; - const newRawValues: RawValueType[] = []; - - toArray(value).forEach(item => { - if (item && typeof item === 'object' && 'value' in item) { - if (item.halfChecked && treeCheckStrictly) { - const entity = getEntityByValue(item.value); - valueHalfCheckedKeys.push(entity ? entity.key : item.value); - } else { - newRawValues.push(item.value); - } - } else { - newRawValues.push(item as RawValueType); - } - }); - - // We need do conduction of values - if (treeConduction) { - const { missingRawValues, existRawValues } = splitRawValues(newRawValues); - const keyList = existRawValues.map(val => getEntityByValue(val).key); - - const { checkedKeys, halfCheckedKeys } = conductCheck(keyList, true, conductKeyEntities); - return [ - [...missingRawValues, ...checkedKeys.map(key => getEntityByKey(key).data.value)], - halfCheckedKeys, - ]; - } - return [newRawValues, valueHalfCheckedKeys]; - }, [ - value, - flattedOptions, - mergedMultiple, - mergedLabelInValue, - treeCheckable, - treeCheckStrictly, - ]); - - const selectValues = useSelectValues(rawValues, { - treeConduction, - value, - showCheckedStrategy, - conductKeyEntities, - getEntityByValue, - getEntityByKey, - getLabelProp: getTreeNodeLabelProp, - }); - - const triggerChange = ( - newRawValues: RawValueType[], - extra: { triggerValue: RawValueType; selected: boolean }, - source: SelectSource, - ) => { - setValue(mergedMultiple ? newRawValues : newRawValues[0]); - if (onChange) { - let eventValues: RawValueType[] = newRawValues; - if (treeConduction && showCheckedStrategy !== 'SHOW_ALL') { - const keyList = newRawValues.map(val => { - const entity = getEntityByValue(val); - return entity ? entity.key : val; - }); - const formattedKeyList = formatStrategyKeys( - keyList, - showCheckedStrategy, - conductKeyEntities, - ); - - eventValues = formattedKeyList.map(key => { - const entity = getEntityByKey(key); - return entity ? entity.data.value : key; - }); - } - - const { triggerValue, selected } = extra || { - triggerValue: undefined, - selected: undefined, - }; - - let returnValues = mergedLabelInValue - ? getRawValueLabeled(eventValues, value, getEntityByValue, getTreeNodeLabelProp) - : eventValues; - - // We need fill half check back - if (treeCheckStrictly) { - const halfValues = rawHalfCheckedKeys - .map(key => { - const entity = getEntityByKey(key); - return entity ? entity.data.value : key; - }) - .filter(val => !eventValues.includes(val)); - - returnValues = [ - ...(returnValues as LabelValueType[]), - ...getRawValueLabeled(halfValues, value, getEntityByValue, getTreeNodeLabelProp), - ]; - } - - const additionalInfo = { - // [Legacy] Always return as array contains label & value - preValue: selectValues, - triggerValue, - } as ChangeEventExtra; - - // [Legacy] Fill legacy data if user query. - // This is expansive that we only fill when user query - // https://github.com/react-component/tree-select/blob/fe33eb7c27830c9ac70cd1fdb1ebbe7bc679c16a/src/Select.jsx - let showPosition = true; - if (treeCheckStrictly || (source === 'selection' && !selected)) { - showPosition = false; - } - - fillAdditionalInfo( - additionalInfo, - triggerValue, - newRawValues, - mergedTreeData, - showPosition, - ); - - if (mergedCheckable) { - additionalInfo.checked = selected; - } else { - additionalInfo.selected = selected; - } - - onChange( - mergedMultiple ? returnValues : returnValues[0], - mergedLabelInValue - ? null - : eventValues.map(val => { - const entity = getEntityByValue(val); - return entity ? entity.data.title : null; - }), - additionalInfo, - ); - } - }; - - const onInternalSelect = ( - selectValue: RawValueType, - option: DataNode, - source: SelectSource, - ) => { - const eventValue = mergedLabelInValue ? selectValue : selectValue; - - if (!mergedMultiple) { - // Single mode always set value - triggerChange([selectValue], { selected: true, triggerValue: selectValue }, source); - } else { - let newRawValues = addValue(rawValues, selectValue); - - // Add keys if tree conduction - if (treeConduction) { - // Should keep missing values - const { missingRawValues, existRawValues } = splitRawValues(newRawValues); - const keyList = existRawValues.map(val => getEntityByValue(val).key); - const { checkedKeys } = conductCheck(keyList, true, conductKeyEntities); - newRawValues = [ - ...missingRawValues, - ...checkedKeys.map(key => getEntityByKey(key).data.value), - ]; - } - - triggerChange(newRawValues, { selected: true, triggerValue: selectValue }, source); - } - - if (onSelect) { - onSelect(eventValue, option); - } - }; - - const onInternalDeselect = ( - selectValue: RawValueType, - option: DataNode, - source: SelectSource, - ) => { - const eventValue = mergedLabelInValue ? selectValue : selectValue; - - let newRawValues = removeValue(rawValues, selectValue); - - // Remove keys if tree conduction - if (treeConduction) { - const { missingRawValues, existRawValues } = splitRawValues(newRawValues); - const keyList = existRawValues.map(val => getEntityByValue(val).key); - const { checkedKeys } = conductCheck( - keyList, - { checked: false, halfCheckedKeys: rawHalfCheckedKeys }, - conductKeyEntities, - ); - newRawValues = [ - ...missingRawValues, - ...checkedKeys.map(key => getEntityByKey(key).data.value), - ]; - } - - triggerChange(newRawValues, { selected: false, triggerValue: selectValue }, source); - - if (onDeselect) { - onDeselect(eventValue, option); - } - }; - - const onInternalClear = () => { - triggerChange([], null, 'clear'); - }; - - // ========================= Open ========================== - const onInternalDropdownVisibleChange = React.useCallback( - (open: boolean) => { - if (onDropdownVisibleChange) { - const legacyParam = {}; - - Object.defineProperty(legacyParam, 'documentClickClose', { - get() { - warning(false, 'Second param of `onDropdownVisibleChange` has been removed.'); - return false; - }, - }); - - (onDropdownVisibleChange as any)(open, legacyParam); - } - }, - [onDropdownVisibleChange], - ); - - // ======================== Warning ======================== - if (process.env.NODE_ENV !== 'production') { - warningProps(props); - } - - // ======================== Render ========================= - // We pass some props into select props style - const selectProps: Partial> = { - optionLabelProp: null, - optionFilterProp: treeNodeFilterProp, - dropdownAlign: dropdownPopupAlign, - internalProps: { - mark: INTERNAL_PROPS_MARK, - onClear: onInternalClear, - skipTriggerChange: true, - skipTriggerSelect: true, - onRawSelect: onInternalSelect, - onRawDeselect: onInternalDeselect, - }, - }; - - if ('filterTreeNode' in props) { - selectProps.filterOption = filterTreeNode; - } - - const selectContext = React.useMemo( - () => ({ - checkable: mergedCheckable, - loadData, - treeLoadedKeys, - onTreeLoad, - checkedKeys: rawValues, - halfCheckedKeys: rawHalfCheckedKeys, - treeDefaultExpandAll, - treeExpandedKeys, - treeDefaultExpandedKeys, - onTreeExpand, - treeIcon, - treeMotion, - showTreeIcon, - switcherIcon, - treeLine, - treeNodeFilterProp, - getEntityByKey, - getEntityByValue, - }), - [ - mergedCheckable, - loadData, - treeLoadedKeys, - onTreeLoad, - rawValues, - rawHalfCheckedKeys, - treeDefaultExpandAll, - treeExpandedKeys, - treeDefaultExpandedKeys, - onTreeExpand, - treeIcon, - treeMotion, - showTreeIcon, - switcherIcon, - treeLine, - treeNodeFilterProp, - getEntityByKey, - getEntityByValue, - ], - ); - - return ( - - - - ); - }); - - RefTreeSelect.displayName = 'TreeSelect'; - - // ================================================================================= - // = Generic = - // ================================================================================= - const TreeSelect = RefTreeSelect as any as (( - props: React.PropsWithChildren> & { - ref?: React.Ref; - }, - ) => React.ReactElement) & { - TreeNode: typeof TreeNode; - SHOW_ALL: typeof SHOW_ALL; - SHOW_PARENT: typeof SHOW_PARENT; - SHOW_CHILD: typeof SHOW_CHILD; - }; - - TreeSelect.TreeNode = TreeNode; - TreeSelect.SHOW_ALL = SHOW_ALL; - TreeSelect.SHOW_PARENT = SHOW_PARENT; - TreeSelect.SHOW_CHILD = SHOW_CHILD; - - return TreeSelect; -} diff --git a/src/hooks/useCache.ts b/src/hooks/useCache.ts new file mode 100644 index 00000000..db939e5f --- /dev/null +++ b/src/hooks/useCache.ts @@ -0,0 +1,34 @@ +import * as React from 'react'; +import type { LabeledValueType, RawValueType } from '../TreeSelect'; + +/** + * This function will try to call requestIdleCallback if available to save performance. + * No need `getLabel` here since already fetch on `rawLabeledValue`. + */ +export default (values: LabeledValueType[]): [LabeledValueType[]] => { + const cacheRef = React.useRef({ + valueLabels: new Map(), + }); + + return React.useMemo(() => { + const { valueLabels } = cacheRef.current; + const valueLabelsCache = new Map(); + + const filledValues = values.map(item => { + const { value } = item; + const mergedLabel = item.label ?? valueLabels.get(value); + + // Save in cache + valueLabelsCache.set(value, mergedLabel); + + return { + ...item, + label: mergedLabel, + }; + }); + + cacheRef.current.valueLabels = valueLabelsCache; + + return [filledValues]; + }, [values]); +}; diff --git a/src/hooks/useCheckedKeys.ts b/src/hooks/useCheckedKeys.ts new file mode 100644 index 00000000..de9a97d1 --- /dev/null +++ b/src/hooks/useCheckedKeys.ts @@ -0,0 +1,27 @@ +import * as React from 'react'; +import type { DataEntity } from 'rc-tree/lib/interface'; +import { conductCheck } from 'rc-tree/lib/utils/conductUtil'; +import type { LabeledValueType, RawValueType } from '../TreeSelect'; + +export default ( + rawLabeledValues: LabeledValueType[], + rawHalfCheckedValues: LabeledValueType[], + treeConduction: boolean, + keyEntities: Record, +) => + React.useMemo(() => { + let checkedKeys: RawValueType[] = rawLabeledValues.map(({ value }) => value); + let halfCheckedKeys: RawValueType[] = rawHalfCheckedValues.map(({ value }) => value); + + const missingValues = checkedKeys.filter(key => !keyEntities[key]); + + if (treeConduction) { + ({ checkedKeys, halfCheckedKeys } = conductCheck(checkedKeys, true, keyEntities)); + } + + return [ + // Checked keys should fill with missing keys which should de-duplicated + Array.from(new Set([...missingValues, ...checkedKeys])), + // Half checked keys + halfCheckedKeys]; + }, [rawLabeledValues, rawHalfCheckedValues, treeConduction, keyEntities]); diff --git a/src/hooks/useDataEntities.ts b/src/hooks/useDataEntities.ts new file mode 100644 index 00000000..6e77011b --- /dev/null +++ b/src/hooks/useDataEntities.ts @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { convertDataToEntities } from 'rc-tree/lib/utils/treeUtil'; +import type { DataEntity } from 'rc-tree/lib/interface'; +import type { FieldNames, RawValueType } from '../TreeSelect'; +import warning from 'rc-util/lib/warning'; +import { isNil } from '../utils/valueUtil'; + +export default (treeData: any, fieldNames: FieldNames) => + React.useMemo<{ keyEntities: Record }>(() => { + const collection = convertDataToEntities(treeData, { + fieldNames, + initWrapper: wrapper => ({ + ...wrapper, + valueEntities: new Map(), + }), + processEntity: (entity, wrapper: any) => { + const val = entity.node[fieldNames.value]; + + // Check if exist same value + if (process.env.NODE_ENV !== 'production') { + const key = entity.node.key; + + warning(!isNil(val), 'TreeNode `value` is invalidate: undefined'); + warning(!wrapper.valueEntities.has(val), `Same \`value\` exist in the tree: ${val}`); + warning( + !key || String(key) === String(val), + `\`key\` or \`value\` with TreeNode must be the same or you can remove one of them. key: ${key}, value: ${val}.`, + ); + } + wrapper.valueEntities.set(val, entity); + }, + }); + + return collection; + }, [treeData, fieldNames]) as ReturnType & { + valueEntities: Map; + }; diff --git a/src/hooks/useFilterTreeData.ts b/src/hooks/useFilterTreeData.ts new file mode 100644 index 00000000..016c573a --- /dev/null +++ b/src/hooks/useFilterTreeData.ts @@ -0,0 +1,61 @@ +import * as React from 'react'; +import type { DefaultOptionType, InternalFieldName, TreeSelectProps } from '../TreeSelect'; +import { fillLegacyProps } from '../utils/legacyUtil'; + +type GetFuncType = T extends boolean ? never : T; +type FilterFn = GetFuncType; + +export default ( + treeData: DefaultOptionType[], + searchValue: string, + { + treeNodeFilterProp, + filterTreeNode, + fieldNames, + }: { + fieldNames: InternalFieldName; + treeNodeFilterProp: string; + filterTreeNode: TreeSelectProps['filterTreeNode']; + }, +) => { + const { children: fieldChildren } = fieldNames; + + return React.useMemo(() => { + if (!searchValue || filterTreeNode === false) { + return treeData; + } + + let filterOptionFunc: FilterFn; + if (typeof filterTreeNode === 'function') { + filterOptionFunc = filterTreeNode; + } else { + const upperStr = searchValue.toUpperCase(); + filterOptionFunc = (_, dataNode) => { + const value = dataNode[treeNodeFilterProp]; + + return String(value).toUpperCase().includes(upperStr); + }; + } + + function dig(list: DefaultOptionType[], keepAll: boolean = false) { + return list + .map(dataNode => { + const children = dataNode[fieldChildren]; + + const match = keepAll || filterOptionFunc(searchValue, fillLegacyProps(dataNode)); + const childList = dig(children || [], match); + + if (match || childList.length) { + return { + ...dataNode, + [fieldChildren]: childList, + }; + } + return null; + }) + .filter(node => node); + } + + return dig(treeData); + }, [treeData, searchValue, fieldChildren, treeNodeFilterProp, filterTreeNode]); +}; diff --git a/src/hooks/useKeyValueMap.ts b/src/hooks/useKeyValueMap.ts deleted file mode 100644 index 09a7415c..00000000 --- a/src/hooks/useKeyValueMap.ts +++ /dev/null @@ -1,21 +0,0 @@ -import * as React from 'react'; -import type { FlattenDataNode, Key, RawValueType } from '../interface'; - -/** - * Return cached Key Value map with DataNode. - * Only re-calculate when `flattenOptions` changed. - */ -export default function useKeyValueMap(flattenOptions: FlattenDataNode[]) { - return React.useMemo(() => { - const cacheKeyMap: Map = new Map(); - const cacheValueMap: Map = new Map(); - - // Cache options by key - flattenOptions.forEach((dataNode: FlattenDataNode) => { - cacheKeyMap.set(dataNode.key, dataNode); - cacheValueMap.set(dataNode.data.value, dataNode); - }); - - return [cacheKeyMap, cacheValueMap]; - }, [flattenOptions]); -} diff --git a/src/hooks/useKeyValueMapping.ts b/src/hooks/useKeyValueMapping.ts deleted file mode 100644 index 4882cd30..00000000 --- a/src/hooks/useKeyValueMapping.ts +++ /dev/null @@ -1,56 +0,0 @@ -import * as React from 'react'; -import type { FlattenDataNode, Key, RawValueType } from '../interface'; - -export type SkipType = null | 'select' | 'checkbox'; - -export function isDisabled(dataNode: FlattenDataNode, skipType: SkipType): boolean { - if (!dataNode) { - return true; - } - - const { disabled, disableCheckbox } = dataNode.data.node; - - switch (skipType) { - case 'checkbox': - return disabled || disableCheckbox; - - default: - return disabled; - } -} - -export default function useKeyValueMapping( - cacheKeyMap: Map, - cacheValueMap: Map, -): [ - (key: Key, skipType?: SkipType, ignoreDisabledCheck?: boolean) => FlattenDataNode, - (value: RawValueType, skipType?: SkipType, ignoreDisabledCheck?: boolean) => FlattenDataNode, -] { - const getEntityByKey = React.useCallback( - (key: Key, skipType: SkipType = 'select', ignoreDisabledCheck?: boolean) => { - const dataNode = cacheKeyMap.get(key); - - if (!ignoreDisabledCheck && isDisabled(dataNode, skipType)) { - return null; - } - - return dataNode; - }, - [cacheKeyMap], - ); - - const getEntityByValue = React.useCallback( - (value: RawValueType, skipType: SkipType = 'select', ignoreDisabledCheck?: boolean) => { - const dataNode = cacheValueMap.get(value); - - if (!ignoreDisabledCheck && isDisabled(dataNode, skipType)) { - return null; - } - - return dataNode; - }, - [cacheValueMap], - ); - - return [getEntityByKey, getEntityByValue]; -} diff --git a/src/hooks/useRefFunc.ts b/src/hooks/useRefFunc.ts new file mode 100644 index 00000000..720f972c --- /dev/null +++ b/src/hooks/useRefFunc.ts @@ -0,0 +1,16 @@ +import * as React from 'react'; + +/** + * Same as `React.useCallback` but always return a memoized function + * but redirect to real function. + */ +export default function useRefFunc any>(callback: T): T { + const funcRef = React.useRef(); + funcRef.current = callback; + + const cacheFn = React.useCallback((...args: any[]) => { + return funcRef.current(...args); + }, []); + + return cacheFn as any; +} diff --git a/src/hooks/useSelectValues.ts b/src/hooks/useSelectValues.ts deleted file mode 100644 index b2e55003..00000000 --- a/src/hooks/useSelectValues.ts +++ /dev/null @@ -1,59 +0,0 @@ -import * as React from 'react'; -import type { DefaultValueType } from 'rc-select/lib/interface/generator'; -import type { DataEntity } from 'rc-tree/lib/interface'; -import type { RawValueType, FlattenDataNode, Key, LabelValueType } from '../interface'; -import type { SkipType } from './useKeyValueMapping'; -import { getRawValueLabeled } from '../utils/valueUtil'; -import type { CheckedStrategy } from '../utils/strategyUtil'; -import { formatStrategyKeys } from '../utils/strategyUtil'; - -interface Config { - treeConduction: boolean; - /** Current `value` of TreeSelect */ - value: DefaultValueType; - showCheckedStrategy: CheckedStrategy; - conductKeyEntities: Record; - getEntityByKey: (key: Key, skipType?: SkipType, ignoreDisabledCheck?: boolean) => FlattenDataNode; - getEntityByValue: ( - value: RawValueType, - skipType?: SkipType, - ignoreDisabledCheck?: boolean, - ) => FlattenDataNode; - getLabelProp: (entity: FlattenDataNode, value: RawValueType) => React.ReactNode; -} - -/** Return */ -export default function useSelectValues( - rawValues: RawValueType[], - { - value, - getEntityByValue, - getEntityByKey, - treeConduction, - showCheckedStrategy, - conductKeyEntities, - getLabelProp, - }: Config, -): LabelValueType[] { - return React.useMemo(() => { - let mergedRawValues = rawValues; - - if (treeConduction) { - const rawKeys = formatStrategyKeys( - rawValues.map(val => { - const entity = getEntityByValue(val); - return entity ? entity.key : val; - }), - showCheckedStrategy, - conductKeyEntities, - ); - - mergedRawValues = rawKeys.map(key => { - const entity = getEntityByKey(key); - return entity ? entity.data.value : key; - }); - } - - return getRawValueLabeled(mergedRawValues, value, getEntityByValue, getLabelProp); - }, [rawValues, value, treeConduction, showCheckedStrategy, getEntityByValue]); -} diff --git a/src/hooks/useTreeData.ts b/src/hooks/useTreeData.ts index 3102bf87..26ffbebd 100644 --- a/src/hooks/useTreeData.ts +++ b/src/hooks/useTreeData.ts @@ -1,15 +1,7 @@ import * as React from 'react'; -import warning from 'rc-util/lib/warning'; -import type { - DataNode, - InternalDataEntity, - SimpleModeConfig, - RawValueType, - FieldNames, -} from '../interface'; +import type { DataNode, SimpleModeConfig } from '../interface'; import { convertChildrenToData } from '../utils/legacyUtil'; - -const MAX_WARNING_TIMES = 10; +import type { DefaultOptionType } from '../TreeSelect'; function parseSimpleTreeData( treeData: DataNode[], @@ -47,70 +39,6 @@ function parseSimpleTreeData( return rootNodeList; } -/** - * Format `treeData` with `value` & `key` which is used for calculation - */ -function formatTreeData( - treeData: DataNode[], - getLabelProp: (node: DataNode) => React.ReactNode, - fieldNames: FieldNames, -): InternalDataEntity[] { - let warningTimes = 0; - const valueSet = new Set(); - - // Field names - const { value: fieldValue, children: fieldChildren } = fieldNames; - - function dig(dataNodes: DataNode[]) { - return (dataNodes || []).map(node => { - const { key, children, ...restProps } = node; - - const value = node[fieldValue]; - const mergedValue = fieldValue in node ? value : key; - - const dataNode: InternalDataEntity = { - ...restProps, - key: key !== null && key !== undefined ? key : mergedValue, - value: mergedValue, - title: getLabelProp(node), - node, - }; - - // Check `key` & `value` and warning user - if (process.env.NODE_ENV !== 'production') { - if ( - key !== null && - key !== undefined && - value !== undefined && - String(key) !== String(value) && - warningTimes < MAX_WARNING_TIMES - ) { - warningTimes += 1; - warning( - false, - `\`key\` or \`value\` with TreeNode must be the same or you can remove one of them. key: ${key}, value: ${value}.`, - ); - } - - warning( - value !== undefined || key !== undefined, - 'TreeNode `value` is invalidate: undefined', - ); - warning(!valueSet.has(value), `Same \`value\` exist in the tree: ${value}`); - valueSet.add(value); - } - - if (node[fieldChildren] !== undefined) { - dataNode.children = dig(node[fieldChildren]); - } - - return dataNode; - }); - } - - return dig(treeData); -} - /** * Convert `treeData` or `children` into formatted `treeData`. * Will not re-calculate if `treeData` or `children` not change. @@ -118,46 +46,20 @@ function formatTreeData( export default function useTreeData( treeData: DataNode[], children: React.ReactNode, - { - getLabelProp, - simpleMode, - fieldNames, - }: { - getLabelProp: (node: DataNode) => React.ReactNode; - simpleMode: boolean | SimpleModeConfig; - fieldNames: FieldNames; - }, -): InternalDataEntity[] { - const cacheRef = React.useRef<{ - treeData?: DataNode[]; - children?: React.ReactNode; - formatTreeData?: InternalDataEntity[]; - }>({}); - - if (treeData) { - cacheRef.current.formatTreeData = - cacheRef.current.treeData === treeData - ? cacheRef.current.formatTreeData - : formatTreeData( - simpleMode - ? parseSimpleTreeData(treeData, { - id: 'id', - pId: 'pId', - rootPId: null, - ...(simpleMode !== true ? simpleMode : {}), - }) - : treeData, - getLabelProp, - fieldNames, - ); - - cacheRef.current.treeData = treeData; - } else { - cacheRef.current.formatTreeData = - cacheRef.current.children === children - ? cacheRef.current.formatTreeData - : formatTreeData(convertChildrenToData(children), getLabelProp, fieldNames); - } + simpleMode: boolean | SimpleModeConfig, +): DefaultOptionType[] { + return React.useMemo(() => { + if (treeData) { + return simpleMode + ? parseSimpleTreeData(treeData, { + id: 'id', + pId: 'pId', + rootPId: null, + ...(simpleMode !== true ? simpleMode : {}), + }) + : treeData; + } - return cacheRef.current.formatTreeData; + return convertChildrenToData(children); + }, [children, simpleMode, treeData]); } diff --git a/src/utils/legacyUtil.tsx b/src/utils/legacyUtil.tsx index 878c53c2..328fd4f4 100644 --- a/src/utils/legacyUtil.tsx +++ b/src/utils/legacyUtil.tsx @@ -5,11 +5,11 @@ import type { DataNode, LegacyDataNode, ChangeEventExtra, - InternalDataEntity, RawValueType, LegacyCheckedNode, } from '../interface'; import TreeNode from '../TreeNode'; +import type { DefaultOptionType, FieldNames } from '../TreeSelect'; export function convertChildrenToData(nodes: React.ReactNode): DataNode[] { return toArray(nodes) @@ -40,7 +40,6 @@ export function convertChildrenToData(nodes: React.ReactNode): DataNode[] { } export function fillLegacyProps(dataNode: DataNode): LegacyDataNode { - // Skip if not dataNode exist if (!dataNode) { return dataNode as LegacyDataNode; } @@ -66,23 +65,29 @@ export function fillAdditionalInfo( extra: ChangeEventExtra, triggerValue: RawValueType, checkedValues: RawValueType[], - treeData: InternalDataEntity[], + treeData: DefaultOptionType[], showPosition: boolean, + fieldNames: FieldNames, ) { let triggerNode: React.ReactNode = null; let nodeList: LegacyCheckedNode[] = null; function generateMap() { - function dig(list: InternalDataEntity[], level = '0', parentIncluded = false) { + function dig(list: DefaultOptionType[], level = '0', parentIncluded = false) { return list - .map((dataNode, index) => { + .map((option, index) => { const pos = `${level}-${index}`; - const included = checkedValues.includes(dataNode.value); - const children = dig(dataNode.children || [], pos, included); - const node = {children.map(child => child.node)}; + const value = option[fieldNames.value]; + const included = checkedValues.includes(value); + const children = dig(option[fieldNames.children] || [], pos, included); + const node = ( + )}> + {children.map(child => child.node)} + + ); // Link with trigger node - if (triggerValue === dataNode.value) { + if (triggerValue === value) { triggerNode = node; } diff --git a/src/utils/strategyUtil.ts b/src/utils/strategyUtil.ts index 2ac256b6..4eacece8 100644 --- a/src/utils/strategyUtil.ts +++ b/src/utils/strategyUtil.ts @@ -1,5 +1,7 @@ +import type * as React from 'react'; +import type { InternalFieldName } from '../TreeSelect'; import type { DataEntity } from 'rc-tree/lib/interface'; -import type { RawValueType, Key, DataNode } from '../interface'; +import type { RawValueType, Key } from '../interface'; import { isCheckDisabled } from './valueUtil'; export const SHOW_ALL = 'SHOW_ALL'; @@ -8,22 +10,23 @@ export const SHOW_CHILD = 'SHOW_CHILD'; export type CheckedStrategy = typeof SHOW_ALL | typeof SHOW_PARENT | typeof SHOW_CHILD; -export function formatStrategyKeys( - keys: Key[], +export function formatStrategyValues( + values: React.Key[], strategy: CheckedStrategy, keyEntities: Record, + fieldNames: InternalFieldName, ): RawValueType[] { - const keySet = new Set(keys); + const valueSet = new Set(values); if (strategy === SHOW_CHILD) { - return keys.filter((key) => { + return values.filter(key => { const entity = keyEntities[key]; if ( entity && entity.children && entity.children.every( - ({ node }) => isCheckDisabled(node) || keySet.has((node as DataNode).key), + ({ node }) => isCheckDisabled(node) || valueSet.has(node[fieldNames.value]), ) ) { return false; @@ -32,15 +35,15 @@ export function formatStrategyKeys( }); } if (strategy === SHOW_PARENT) { - return keys.filter((key) => { + return values.filter(key => { const entity = keyEntities[key]; const parent = entity ? entity.parent : null; - if (parent && !isCheckDisabled(parent.node) && keySet.has((parent.node as DataNode).key)) { + if (parent && !isCheckDisabled(parent.node) && valueSet.has(parent.key)) { return false; } return true; }); } - return keys; + return values; } diff --git a/src/utils/valueUtil.ts b/src/utils/valueUtil.ts index 2d43e9a7..b414b409 100644 --- a/src/utils/valueUtil.ts +++ b/src/utils/valueUtil.ts @@ -1,21 +1,6 @@ -import { flattenTreeData } from 'rc-tree/lib/utils/treeUtil'; -import type { FlattenNode } from 'rc-tree/lib/interface'; -import type { FilterFunc } from 'rc-select/lib/interface/generator'; -import type { - FlattenDataNode, - Key, - RawValueType, - DataNode, - DefaultValueType, - LabelValueType, - LegacyDataNode, - FieldNames, - InternalDataEntity, -} from '../interface'; -import { fillLegacyProps } from './legacyUtil'; -import type { SkipType } from '../hooks/useKeyValueMapping'; - -type CompatibleDataNode = Omit; +import type * as React from 'react'; +import type { DataNode, FieldNames } from '../interface'; +import type { DefaultOptionType, InternalFieldName } from '../TreeSelect'; export function toArray(value: T | T[]): T[] { if (Array.isArray(value)) { @@ -24,228 +9,43 @@ export function toArray(value: T | T[]): T[] { return value !== undefined ? [value] : []; } -/** - * Fill `fieldNames` with default field names. - * - * @param fieldNames passed props - * @param skipTitle Skip if no need fill `title`. This is useful since we have 2 name as same title level - * @returns - */ -export function fillFieldNames(fieldNames?: FieldNames, skipTitle: boolean = false) { +export function fillFieldNames(fieldNames?: FieldNames) { const { label, value, children } = fieldNames || {}; - const filledNames: FieldNames = { - value: value || 'value', + const mergedValue = value || 'value'; + + return { + _title: label ? [label] : ['title', 'label'], + value: mergedValue, + key: mergedValue, children: children || 'children', }; - - if (!skipTitle || label) { - filledNames.label = label || 'label'; - } - - return filledNames; -} - -export function findValueOption(values: RawValueType[], options: CompatibleDataNode[]): DataNode[] { - const optionMap: Map = new Map(); - - options.forEach(flattenItem => { - const { data, value } = flattenItem; - optionMap.set(value, data.node); - }); - - return values.map(val => fillLegacyProps(optionMap.get(val))); -} - -export function isValueDisabled(value: RawValueType, options: CompatibleDataNode[]): boolean { - const option = findValueOption([value], options)[0]; - if (option) { - return option.disabled; - } - - return false; } export function isCheckDisabled(node: DataNode) { - return node.disabled || node.disableCheckbox || node.checkable === false; + return !node || node.disabled || node.disableCheckbox || node.checkable === false; } -interface TreeDataNode extends InternalDataEntity { - key: Key; - children?: TreeDataNode[]; -} - -function getLevel({ parent }: FlattenNode): number { - let level = 0; - let current = parent; - - while (current) { - current = current.parent; - level += 1; - } - - return level; -} +/** Loop fetch all the keys exist in the tree */ +export function getAllKeys(treeData: DefaultOptionType[], fieldNames: InternalFieldName) { + const keys: React.Key[] = []; -/** - * Before reuse `rc-tree` logic, we need to add key since TreeSelect use `value` instead of `key`. - */ -export function flattenOptions(options: any): FlattenDataNode[] { - const typedOptions = options as InternalDataEntity[]; - - // Add missing key - function fillKey(list: InternalDataEntity[]): TreeDataNode[] { - return (list || []).map(node => { - const { value, key, children } = node; - - const clone: TreeDataNode = { - ...node, - key: 'key' in node ? key : value, - }; + function dig(list: DefaultOptionType[]) { + list.forEach(item => { + keys.push(item[fieldNames.value]); + const children = item[fieldNames.children]; if (children) { - clone.children = fillKey(children); + dig(children); } - - return clone; }); } - const flattenList = flattenTreeData(fillKey(typedOptions), true, null); - - const cacheMap = new Map(); - const flattenDateNodeList: (FlattenDataNode & { parentKey?: React.Key })[] = flattenList.map( - option => { - const { data, key, value } = option as any as Omit & { - value: RawValueType; - data: InternalDataEntity; - }; - - const flattenNode = { - key, - value, - data, - level: getLevel(option), - parentKey: option.parent?.data.key, - }; - - cacheMap.set(key, flattenNode); - - return flattenNode; - }, - ); - - // Fill parent - flattenDateNodeList.forEach(flattenNode => { - // eslint-disable-next-line no-param-reassign - flattenNode.parent = cacheMap.get(flattenNode.parentKey); - }); - - return flattenDateNodeList; -} - -function getDefaultFilterOption(optionFilterProp: string) { - return (searchValue: string, dataNode: LegacyDataNode) => { - const value = dataNode[optionFilterProp]; - - return String(value).toLowerCase().includes(String(searchValue).toLowerCase()); - }; -} - -/** Filter options and return a new options by the search text */ -export function filterOptions( - searchValue: string, - options: DataNode[], - { - optionFilterProp, - filterOption, - }: { - optionFilterProp: string; - filterOption: boolean | FilterFunc; - }, -): DataNode[] { - if (filterOption === false) { - return options; - } - - let filterOptionFunc: FilterFunc; - if (typeof filterOption === 'function') { - filterOptionFunc = filterOption; - } else { - filterOptionFunc = getDefaultFilterOption(optionFilterProp); - } - - function dig(list: DataNode[], keepAll: boolean = false) { - return list - .map(dataNode => { - const { children } = dataNode; + dig(treeData); - const match = keepAll || filterOptionFunc(searchValue, fillLegacyProps(dataNode)); - const childList = dig(children || [], match); - - if (match || childList.length) { - return { - ...dataNode, - children: childList, - }; - } - return null; - }) - .filter(node => node); - } - - return dig(options); -} - -export function getRawValueLabeled( - values: RawValueType[], - prevValue: DefaultValueType, - getEntityByValue: ( - value: RawValueType, - skipType?: SkipType, - ignoreDisabledCheck?: boolean, - ) => FlattenDataNode, - getLabelProp: (entity: FlattenDataNode, val: RawValueType) => React.ReactNode, -): LabelValueType[] { - const valueMap = new Map(); - - toArray(prevValue).forEach(item => { - if (item && typeof item === 'object' && 'value' in item) { - valueMap.set(item.value, item); - } - }); - - return values.map(val => { - const item: LabelValueType = { value: val }; - const entity = getEntityByValue(val, 'select', true); - - // Always try to get the value by entity even it's enpty - let label = getLabelProp(entity, val); - if (label === undefined) { - label = val; - } - - if (valueMap.has(val)) { - const labeledValue = valueMap.get(val); - item.label = 'label' in labeledValue ? labeledValue.label : label; - if ('halfChecked' in labeledValue) { - item.halfChecked = labeledValue.halfChecked; - } - } else { - item.label = label; - } - - return item; - }); + return keys; } -export function addValue(rawValues: RawValueType[], value: RawValueType) { - const values = new Set(rawValues); - values.add(value); - return Array.from(values); -} -export function removeValue(rawValues: RawValueType[], value: RawValueType) { - const values = new Set(rawValues); - values.delete(value); - return Array.from(values); +export function isNil(val: any) { + return val === null || val === undefined; } diff --git a/src/utils/warningPropsUtil.ts b/src/utils/warningPropsUtil.ts index 0078ec10..9743b946 100644 --- a/src/utils/warningPropsUtil.ts +++ b/src/utils/warningPropsUtil.ts @@ -2,7 +2,7 @@ import warning from 'rc-util/lib/warning'; import type { TreeSelectProps } from '../TreeSelect'; import { toArray } from './valueUtil'; -function warningProps(props: TreeSelectProps) { +function warningProps(props: TreeSelectProps & { searchPlaceholder?: string }) { const { searchPlaceholder, treeCheckStrictly, diff --git a/tests/Select.SearchInput.spec.js b/tests/Select.SearchInput.spec.js index 4127f184..3b6861c3 100644 --- a/tests/Select.SearchInput.spec.js +++ b/tests/Select.SearchInput.spec.js @@ -48,7 +48,7 @@ describe('TreeSelect.SearchInput', () => { />, ); - expect(wrapper.find('NodeList').props().expandedKeys).toEqual(['bamboo', 'light']); + expect(wrapper.find('NodeList').prop('expandedKeys')).toEqual(['bamboo', 'light']); function search(value) { wrapper diff --git a/tests/Select.checkable.spec.js b/tests/Select.checkable.spec.js index 23e2a31b..d5d55e90 100644 --- a/tests/Select.checkable.spec.js +++ b/tests/Select.checkable.spec.js @@ -117,7 +117,7 @@ describe('TreeSelect.checkable', () => { onChange={this.handleChange} disabled={disabled} /> - this.switch(e.target.checked)} id="checkbox" />{' '} + this.switch(e.target.checked)} id="checkbox" /> 禁用
); diff --git a/tests/Select.internal.spec.tsx b/tests/Select.internal.spec.tsx deleted file mode 100644 index 5180eff3..00000000 --- a/tests/Select.internal.spec.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import { mount } from 'enzyme'; -import TreeSelect from '../src'; - -describe('TreeSelect.InternalAPI', () => { - it('labelRender', () => { - const wrapper = mount( - { - let current = entity; - const nodes = []; - - while (current) { - nodes.unshift(current.data.node.label); - current = current.parent; - } - - return nodes.join('>'); - }} - />, - ); - expect(wrapper.find('.rc-tree-select-selection-item').text()).toEqual('Bamboo>Light>Little'); - }); -}); diff --git a/tests/Select.multiple.spec.js b/tests/Select.multiple.spec.js index 2fb38350..93012bd2 100644 --- a/tests/Select.multiple.spec.js +++ b/tests/Select.multiple.spec.js @@ -262,4 +262,21 @@ describe('TreeSelect.multiple', () => { expect(wrapper.render()).toMatchSnapshot(); }); + + it('not exist value should can be remove', () => { + const onChange = jest.fn(); + const onDeselect = jest.fn(); + const wrapper = mount( + , + ); + wrapper.clearSelection(0); + + expect(onChange).toHaveBeenCalledWith([], expect.anything(), expect.anything()); + expect(onDeselect).toHaveBeenCalledWith('not-exist', undefined); + }); }); diff --git a/tests/Select.props.spec.js b/tests/Select.props.spec.js index 724cfc14..6ba15fd8 100644 --- a/tests/Select.props.spec.js +++ b/tests/Select.props.spec.js @@ -38,7 +38,7 @@ describe('TreeSelect.props', () => { }); describe('filterTreeNode', () => { - it('function', () => { + it('as function', () => { function filterTreeNode(input, child) { return String(child.props.title).indexOf(input) !== -1; } @@ -488,7 +488,7 @@ describe('TreeSelect.props', () => { }); describe('single', () => { - it('click on tree', () => { + it('click on tree node', () => { const onSelect = jest.fn(); const onDeselect = jest.fn(); const wrapper = createDeselectWrapper({ diff --git a/tests/Select.spec.js b/tests/Select.spec.js index 128e1299..5fce19aa 100644 --- a/tests/Select.spec.js +++ b/tests/Select.spec.js @@ -1,4 +1,3 @@ -/* eslint-disable no-undef react/no-multi-comp */ import React from 'react'; import { mount } from 'enzyme'; import KeyCode from 'rc-util/lib/KeyCode'; @@ -327,7 +326,7 @@ describe('TreeSelect.basic', () => { wrapper.selectNode(); wrapper.clearAll(); - expect(wrapper.find('Select').props().value).toHaveLength(0); + expect(wrapper.find('BaseSelect').prop('displayValues')).toHaveLength(0); }); it('has inputValue prop', () => { @@ -355,7 +354,7 @@ describe('TreeSelect.basic', () => { wrapper.openSelect(); wrapper.selectNode(); wrapper.clearAll(); - expect(wrapper.find('Select').props().value).toHaveLength(0); + expect(wrapper.find('BaseSelect').prop('displayValues')).toHaveLength(0); }); }); diff --git a/tests/Select.tree.spec.js b/tests/Select.tree.spec.js index a1151277..ec89d8f3 100644 --- a/tests/Select.tree.spec.js +++ b/tests/Select.tree.spec.js @@ -146,23 +146,4 @@ describe('TreeSelect.tree', () => { expect(wrapper.exists('.bamboo-light')).toBeTruthy(); }); - - // https://github.com/ant-design/ant-design/issues/32806 - it('OptionList should get empty children instead of list', () => { - const wrapper = mount( - , - ); - - const options = wrapper.find('OptionList').prop('options'); - expect(options[0].children).toBeFalsy(); - }); }); diff --git a/tests/__snapshots__/Select.checkable.spec.js.snap b/tests/__snapshots__/Select.checkable.spec.js.snap index 043055b2..80018305 100644 --- a/tests/__snapshots__/Select.checkable.spec.js.snap +++ b/tests/__snapshots__/Select.checkable.spec.js.snap @@ -101,7 +101,6 @@ exports[`TreeSelect.checkable uncheck remove by selector not treeCheckStrictly 1 style="width: 0px;" > +
@@ -984,7 +981,6 @@ exports[`TreeSelect.basic search nodes filter node but not remove then 1`] = ` class="rc-tree-select-selection-search" > +