From 6193365974ef7d1eac1eef06d648fb3098c1879e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Thu, 5 Dec 2024 10:35:49 +0800 Subject: [PATCH 01/13] feat: supplement maxCount logic for complicated cases --- examples/mutiple-with-maxCount.tsx | 17 +++++++-- src/OptionList.tsx | 56 +++++++++++++++++++++++++++++- src/TreeSelect.tsx | 3 ++ src/TreeSelectContext.ts | 3 ++ 4 files changed, 76 insertions(+), 3 deletions(-) diff --git a/examples/mutiple-with-maxCount.tsx b/examples/mutiple-with-maxCount.tsx index 41a9b77e..ca192a37 100644 --- a/examples/mutiple-with-maxCount.tsx +++ b/examples/mutiple-with-maxCount.tsx @@ -20,6 +20,20 @@ export default () => { key: '1-2', value: '1-2', title: '1-2', + disabled: true, + children: [ + { + key: '1-2-1', + value: '1-2-1', + title: '1-2-1', + disabled: true, + }, + { + key: '1-2-2', + value: '1-2-2', + title: '1-2-2', + }, + ], }, { key: '1-3', @@ -70,8 +84,7 @@ export default () => { multiple treeCheckable // showCheckedStrategy="SHOW_ALL" - showCheckedStrategy="SHOW_PARENT" - // showCheckedStrategy="SHOW_CHILD" + // showCheckedStrategy="SHOW_PARENT" maxCount={4} treeData={treeData} onChange={onChange} diff --git a/src/OptionList.tsx b/src/OptionList.tsx index b49e552e..2b853c58 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -12,6 +12,8 @@ import TreeSelectContext from './TreeSelectContext'; import type { DataNode, Key, SafeKey } from './interface'; import { getAllKeys, isCheckDisabled } from './utils/valueUtil'; import { useEvent } from 'rc-util'; +import { formatStrategyValues } from './utils/strategyUtil'; +import { conductCheck } from 'rc-tree/lib/utils/conductUtil'; const HIDDEN_STYLE = { width: 0, @@ -49,6 +51,8 @@ const OptionList: React.ForwardRefRenderFunction = (_, onPopupScroll, displayValues, isOverMaxCount, + maxCount, + showCheckedStrategy, } = React.useContext(TreeSelectContext); const { @@ -164,7 +168,57 @@ const OptionList: React.ForwardRefRenderFunction = (_, }, [searchValue]); const nodeDisabled = useEvent((node: DataNode) => { - return isOverMaxCount && !memoRawValues.includes(node[fieldNames.value]); + // Always enable selected nodes + if (checkedKeys.includes(node[fieldNames.value])) { + return false; + } + + // Get all selectable keys under current node considering conduction rules + const getSelectableKeys = (nodes: DataNode[]) => { + const keys: Key[] = []; + const traverse = (n: DataNode) => { + if (!n.disabled) { + keys.push(n[fieldNames.value]); + // Only traverse children if node is not disabled + if (Array.isArray(n.children)) { + n.children.forEach(traverse); + } + } + }; + nodes.forEach(traverse); + return keys; + }; + + const selectableNodeValues = getSelectableKeys([node]); + + // Simulate checked state after selecting current node + const simulatedCheckedKeys = [...checkedKeys, ...selectableNodeValues]; + + const { checkedKeys: conductedKeys } = conductCheck(simulatedCheckedKeys, true, keyEntities); + + // Calculate display keys based on strategy + const simulatedDisplayKeys = formatStrategyValues( + conductedKeys as SafeKey[], + showCheckedStrategy, + keyEntities, + fieldNames, + ); + + const currentDisplayKeys = formatStrategyValues( + checkedKeys as SafeKey[], + showCheckedStrategy, + keyEntities, + fieldNames, + ); + + const newDisplayValuesCount = simulatedDisplayKeys.length - currentDisplayKeys.length; + + // Check if selecting this node would exceed maxCount + if (isOverMaxCount || memoRawValues.length + newDisplayValuesCount > maxCount) { + return true; + } + + return false; }); // ========================== Get First Selectable Node ========================== diff --git a/src/TreeSelect.tsx b/src/TreeSelect.tsx index c46f49a8..8014d157 100644 --- a/src/TreeSelect.tsx +++ b/src/TreeSelect.tsx @@ -625,6 +625,8 @@ const TreeSelect = React.forwardRef((props, ref) onPopupScroll, displayValues: cachedDisplayValues, isOverMaxCount, + maxCount, + showCheckedStrategy: mergedShowCheckedStrategy, }; }, [ virtual, @@ -641,6 +643,7 @@ const TreeSelect = React.forwardRef((props, ref) maxCount, cachedDisplayValues, mergedMultiple, + mergedShowCheckedStrategy, ]); // ======================= Legacy Context ======================= diff --git a/src/TreeSelectContext.ts b/src/TreeSelectContext.ts index 2d335e4b..f3ceb1cf 100644 --- a/src/TreeSelectContext.ts +++ b/src/TreeSelectContext.ts @@ -1,6 +1,7 @@ import * as React from 'react'; import type { ExpandAction } from 'rc-tree/lib/Tree'; import type { DataNode, FieldNames, Key, LabeledValueType } from './interface'; +import { CheckedStrategy } from './utils/strategyUtil'; export interface TreeSelectContextProps { virtual?: boolean; @@ -16,6 +17,8 @@ export interface TreeSelectContextProps { onPopupScroll?: React.UIEventHandler; displayValues?: LabeledValueType[]; isOverMaxCount?: boolean; + maxCount?: number; + showCheckedStrategy?: CheckedStrategy; } const TreeSelectContext = React.createContext(null as any); From c96aee23016c823d637dbf98178718274f620ece Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Thu, 5 Dec 2024 12:33:56 +0800 Subject: [PATCH 02/13] chore: remove unreachable logic --- src/TreeSelect.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/TreeSelect.tsx b/src/TreeSelect.tsx index 8014d157..aeb089bd 100644 --- a/src/TreeSelect.tsx +++ b/src/TreeSelect.tsx @@ -422,13 +422,6 @@ const TreeSelect = React.forwardRef((props, ref) mergedFieldNames, ); - // if multiple and maxCount is set, check if exceed maxCount - if (mergedMultiple && maxCount !== undefined) { - if (formattedKeyList.length > maxCount) { - return; - } - } - const labeledValues = convert2LabelValues(newRawValues); setInternalValue(labeledValues); From c8b2c9242b3c419b73d15f0796eded03b624a072 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Fri, 6 Dec 2024 16:36:07 +0800 Subject: [PATCH 03/13] feat: supplement maxCount logic for complicated cases --- examples/mutiple-with-maxCount.tsx | 2 - src/OptionList.tsx | 91 ++++++++++---------- src/TreeSelect.tsx | 4 +- src/TreeSelectContext.ts | 1 - tests/Select.maxCount.spec.tsx | 129 +++++++++++++++++++++++++++++ 5 files changed, 179 insertions(+), 48 deletions(-) diff --git a/examples/mutiple-with-maxCount.tsx b/examples/mutiple-with-maxCount.tsx index ca192a37..738861d0 100644 --- a/examples/mutiple-with-maxCount.tsx +++ b/examples/mutiple-with-maxCount.tsx @@ -77,7 +77,6 @@ export default () => { maxCount={3} treeData={treeData} /> -

checkable with maxCount

{ onChange={onChange} value={value} /> -

checkable with maxCount and treeCheckStrictly

= (_, treeExpandAction, treeTitleRender, onPopupScroll, - displayValues, isOverMaxCount, maxCount, showCheckedStrategy, @@ -84,11 +83,6 @@ const OptionList: React.ForwardRefRenderFunction = (_, (prev, next) => next[0] && prev[1] !== next[1], ); - const memoRawValues = React.useMemo( - () => (displayValues || []).map(v => v.value), - [displayValues], - ); - // ========================== Values ========================== const mergedCheckedKeys = React.useMemo(() => { if (!checkable) { @@ -167,58 +161,69 @@ const OptionList: React.ForwardRefRenderFunction = (_, // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchValue]); + const disabledCacheRef = React.useRef(new Map()); + const lastCheckedKeysRef = React.useRef([]); + const lastMaxCountRef = React.useRef(null); + + const resetCache = React.useCallback(() => { + disabledCacheRef.current.clear(); + lastCheckedKeysRef.current = [...checkedKeys]; + lastMaxCountRef.current = maxCount; + }, [checkedKeys, maxCount]); + + React.useEffect(() => { + resetCache(); + }, [checkedKeys, maxCount]); + + const getSelectableKeys = (targetNode: DataNode, fieldNames: FieldNames): Key[] => { + const keys = [targetNode[fieldNames.value]]; + if (!Array.isArray(targetNode.children)) { + return keys; + } + + return targetNode.children.reduce((acc, child) => { + if (!child.disabled) { + acc.push(...getSelectableKeys(child, fieldNames)); + } + return acc; + }, keys); + }; + const nodeDisabled = useEvent((node: DataNode) => { - // Always enable selected nodes - if (checkedKeys.includes(node[fieldNames.value])) { + const nodeValue = node[fieldNames.value]; + + if (checkedKeys.includes(nodeValue)) { return false; } - // Get all selectable keys under current node considering conduction rules - const getSelectableKeys = (nodes: DataNode[]) => { - const keys: Key[] = []; - const traverse = (n: DataNode) => { - if (!n.disabled) { - keys.push(n[fieldNames.value]); - // Only traverse children if node is not disabled - if (Array.isArray(n.children)) { - n.children.forEach(traverse); - } - } - }; - nodes.forEach(traverse); - return keys; - }; + if (isOverMaxCount) { + return true; + } - const selectableNodeValues = getSelectableKeys([node]); + const cacheKey = `${nodeValue}-${checkedKeys.join(',')}-${maxCount}`; - // Simulate checked state after selecting current node - const simulatedCheckedKeys = [...checkedKeys, ...selectableNodeValues]; + // check cache + if (disabledCacheRef.current.has(cacheKey)) { + return disabledCacheRef.current.get(cacheKey); + } + // calculate disabled state + const selectableNodeKeys = getSelectableKeys(node, fieldNames); + const simulatedCheckedKeys = [...checkedKeys, ...selectableNodeKeys]; const { checkedKeys: conductedKeys } = conductCheck(simulatedCheckedKeys, true, keyEntities); - - // Calculate display keys based on strategy - const simulatedDisplayKeys = formatStrategyValues( + const simulatedDisplayValues = formatStrategyValues( conductedKeys as SafeKey[], showCheckedStrategy, keyEntities, fieldNames, ); - const currentDisplayKeys = formatStrategyValues( - checkedKeys as SafeKey[], - showCheckedStrategy, - keyEntities, - fieldNames, - ); + const isDisabled = simulatedDisplayValues.length > maxCount; - const newDisplayValuesCount = simulatedDisplayKeys.length - currentDisplayKeys.length; - - // Check if selecting this node would exceed maxCount - if (isOverMaxCount || memoRawValues.length + newDisplayValuesCount > maxCount) { - return true; - } + // update cache + disabledCacheRef.current.set(cacheKey, isDisabled); - return false; + return isDisabled; }); // ========================== Get First Selectable Node ========================== diff --git a/src/TreeSelect.tsx b/src/TreeSelect.tsx index aeb089bd..7eeb7729 100644 --- a/src/TreeSelect.tsx +++ b/src/TreeSelect.tsx @@ -422,6 +422,8 @@ const TreeSelect = React.forwardRef((props, ref) mergedFieldNames, ); + console.log('triggerChange'); + const labeledValues = convert2LabelValues(newRawValues); setInternalValue(labeledValues); @@ -616,7 +618,6 @@ const TreeSelect = React.forwardRef((props, ref) treeExpandAction, treeTitleRender, onPopupScroll, - displayValues: cachedDisplayValues, isOverMaxCount, maxCount, showCheckedStrategy: mergedShowCheckedStrategy, @@ -634,7 +635,6 @@ const TreeSelect = React.forwardRef((props, ref) treeTitleRender, onPopupScroll, maxCount, - cachedDisplayValues, mergedMultiple, mergedShowCheckedStrategy, ]); diff --git a/src/TreeSelectContext.ts b/src/TreeSelectContext.ts index f3ceb1cf..c1a7f042 100644 --- a/src/TreeSelectContext.ts +++ b/src/TreeSelectContext.ts @@ -15,7 +15,6 @@ export interface TreeSelectContextProps { treeExpandAction?: ExpandAction; treeTitleRender?: (node: any) => React.ReactNode; onPopupScroll?: React.UIEventHandler; - displayValues?: LabeledValueType[]; isOverMaxCount?: boolean; maxCount?: number; showCheckedStrategy?: CheckedStrategy; diff --git a/tests/Select.maxCount.spec.tsx b/tests/Select.maxCount.spec.tsx index 79fe48b8..31a9a68b 100644 --- a/tests/Select.maxCount.spec.tsx +++ b/tests/Select.maxCount.spec.tsx @@ -372,3 +372,132 @@ describe('TreeSelect.maxCount with treeCheckStrictly', () => { expect(handleChange).toHaveBeenCalledTimes(4); }); }); + +describe('TreeSelect.maxCount with complex scenarios', () => { + const complexTreeData = [ + { + key: 'asia', + value: 'asia', + title: 'Asia', + children: [ + { + key: 'china', + value: 'china', + title: 'China', + children: [ + { key: 'beijing', value: 'beijing', title: 'Beijing' }, + { key: 'shanghai', value: 'shanghai', title: 'Shanghai' }, + { key: 'guangzhou', value: 'guangzhou', title: 'Guangzhou' }, + ], + }, + { + key: 'japan', + value: 'japan', + title: 'Japan', + children: [ + { key: 'tokyo', value: 'tokyo', title: 'Tokyo' }, + { key: 'osaka', value: 'osaka', title: 'Osaka' }, + ], + }, + ], + }, + { + key: 'europe', + value: 'europe', + title: 'Europe', + children: [ + { + key: 'uk', + value: 'uk', + title: 'United Kingdom', + children: [ + { key: 'london', value: 'london', title: 'London' }, + { key: 'manchester', value: 'manchester', title: 'Manchester' }, + ], + }, + { + key: 'france', + value: 'france', + title: 'France', + disabled: true, + children: [ + { key: 'paris', value: 'paris', title: 'Paris' }, + { key: 'lyon', value: 'lyon', title: 'Lyon' }, + ], + }, + ], + }, + ]; + + it('should handle complex tree structure with maxCount correctly', () => { + const handleChange = jest.fn(); + const { getByRole } = render( + , + ); + + const container = getByRole('tree'); + + // 选择一个顶层节点 + const asiaNode = within(container).getByText('Asia'); + fireEvent.click(asiaNode); + expect(handleChange).not.toHaveBeenCalled(); // 不应该触发,因为会超过 maxCount + + // 选择叶子节点 + const beijingNode = within(container).getByText('Beijing'); + const shanghaiNode = within(container).getByText('Shanghai'); + const tokyoNode = within(container).getByText('Tokyo'); + const londonNode = within(container).getByText('London'); + + fireEvent.click(beijingNode); + fireEvent.click(shanghaiNode); + fireEvent.click(tokyoNode); + expect(handleChange).toHaveBeenCalledTimes(3); + + // 尝试选择第四个节点,应该被阻止 + fireEvent.click(londonNode); + expect(handleChange).toHaveBeenCalledTimes(3); + + // 验证禁用状态 + expect(londonNode.closest('div')).toHaveClass('rc-tree-select-tree-treenode-disabled'); + }); + + it('should handle maxCount with mixed selection strategies', () => { + const handleChange = jest.fn(); + + const { getByRole } = render( + , + ); + + const container = getByRole('tree'); + + const tokyoNode = within(container).getByText('Tokyo'); + fireEvent.click(tokyoNode); + + // because UK node will show two children, so it will trigger one change + expect(handleChange).toHaveBeenCalledTimes(1); + + const beijingNode = within(container).getByText('Beijing'); + fireEvent.click(beijingNode); + + // should not trigger change + expect(handleChange).toHaveBeenCalledTimes(1); + expect(beijingNode.closest('div')).toHaveClass('rc-tree-select-tree-treenode-disabled'); + }); +}); From ffe87e51cce0b7f988a2e63529c922ad08a8183a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Fri, 6 Dec 2024 16:40:30 +0800 Subject: [PATCH 04/13] fix: lint fix --- src/OptionList.tsx | 6 +++--- src/TreeSelectContext.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 2d5aecda..61810ddc 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -175,15 +175,15 @@ const OptionList: React.ForwardRefRenderFunction = (_, resetCache(); }, [checkedKeys, maxCount]); - const getSelectableKeys = (targetNode: DataNode, fieldNames: FieldNames): Key[] => { - const keys = [targetNode[fieldNames.value]]; + const getSelectableKeys = (targetNode: DataNode, names: FieldNames): Key[] => { + const keys = [targetNode[names.value]]; if (!Array.isArray(targetNode.children)) { return keys; } return targetNode.children.reduce((acc, child) => { if (!child.disabled) { - acc.push(...getSelectableKeys(child, fieldNames)); + acc.push(...getSelectableKeys(child, names)); } return acc; }, keys); diff --git a/src/TreeSelectContext.ts b/src/TreeSelectContext.ts index c1a7f042..82480262 100644 --- a/src/TreeSelectContext.ts +++ b/src/TreeSelectContext.ts @@ -1,6 +1,6 @@ import * as React from 'react'; import type { ExpandAction } from 'rc-tree/lib/Tree'; -import type { DataNode, FieldNames, Key, LabeledValueType } from './interface'; +import type { DataNode, FieldNames, Key } from './interface'; import { CheckedStrategy } from './utils/strategyUtil'; export interface TreeSelectContextProps { From 07b7c3216b1d032f6cd98210ab4b772886bf8c1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Fri, 6 Dec 2024 16:44:00 +0800 Subject: [PATCH 05/13] chore: remove console.log --- src/TreeSelect.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/TreeSelect.tsx b/src/TreeSelect.tsx index 7eeb7729..7fa057a1 100644 --- a/src/TreeSelect.tsx +++ b/src/TreeSelect.tsx @@ -422,8 +422,6 @@ const TreeSelect = React.forwardRef((props, ref) mergedFieldNames, ); - console.log('triggerChange'); - const labeledValues = convert2LabelValues(newRawValues); setInternalValue(labeledValues); From 8f476756de6687e1c3b9574e197e4e5f2fceefb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Mon, 9 Dec 2024 13:38:42 +0800 Subject: [PATCH 06/13] chore: remove conductCheck --- src/OptionList.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 61810ddc..a23a793e 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -13,7 +13,6 @@ import type { DataNode, FieldNames, Key, SafeKey } from './interface'; import { getAllKeys, isCheckDisabled } from './utils/valueUtil'; import { useEvent } from 'rc-util'; import { formatStrategyValues } from './utils/strategyUtil'; -import { conductCheck } from 'rc-tree/lib/utils/conductUtil'; const HIDDEN_STYLE = { width: 0, @@ -210,9 +209,8 @@ const OptionList: React.ForwardRefRenderFunction = (_, // calculate disabled state const selectableNodeKeys = getSelectableKeys(node, fieldNames); const simulatedCheckedKeys = [...checkedKeys, ...selectableNodeKeys]; - const { checkedKeys: conductedKeys } = conductCheck(simulatedCheckedKeys, true, keyEntities); const simulatedDisplayValues = formatStrategyValues( - conductedKeys as SafeKey[], + simulatedCheckedKeys as SafeKey[], showCheckedStrategy, keyEntities, fieldNames, From 9ff318146455080e5430023e0fbcff2b02e5019a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Fri, 20 Dec 2024 16:55:03 +0800 Subject: [PATCH 07/13] chore: change limit maxCount --- examples/mutiple-with-maxCount.tsx | 1 - src/OptionList.tsx | 83 +++++++++++++----------------- src/TreeSelect.tsx | 13 ++--- src/TreeSelectContext.ts | 5 +- 4 files changed, 44 insertions(+), 58 deletions(-) diff --git a/examples/mutiple-with-maxCount.tsx b/examples/mutiple-with-maxCount.tsx index 738861d0..ba6c062a 100644 --- a/examples/mutiple-with-maxCount.tsx +++ b/examples/mutiple-with-maxCount.tsx @@ -80,7 +80,6 @@ export default () => {

checkable with maxCount

= (_, treeExpandAction, treeTitleRender, onPopupScroll, - isOverMaxCount, - maxCount, + leftMaxCount, showCheckedStrategy, } = React.useContext(TreeSelectContext); @@ -160,33 +159,19 @@ const OptionList: React.ForwardRefRenderFunction = (_, // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchValue]); - const disabledCacheRef = React.useRef(new Map()); - const lastCheckedKeysRef = React.useRef([]); - const lastMaxCountRef = React.useRef(null); + // const getSelectableKeys = (targetNode: DataNode, names: FieldNames): Key[] => { + // const keys = [targetNode[names.value]]; + // if (!Array.isArray(targetNode.children)) { + // return keys; + // } - const resetCache = React.useCallback(() => { - disabledCacheRef.current.clear(); - lastCheckedKeysRef.current = [...checkedKeys]; - lastMaxCountRef.current = maxCount; - }, [checkedKeys, maxCount]); - - React.useEffect(() => { - resetCache(); - }, [checkedKeys, maxCount]); - - const getSelectableKeys = (targetNode: DataNode, names: FieldNames): Key[] => { - const keys = [targetNode[names.value]]; - if (!Array.isArray(targetNode.children)) { - return keys; - } - - return targetNode.children.reduce((acc, child) => { - if (!child.disabled) { - acc.push(...getSelectableKeys(child, names)); - } - return acc; - }, keys); - }; + // return targetNode.children.reduce((acc, child) => { + // if (!child.disabled) { + // acc.push(...getSelectableKeys(child, names)); + // } + // return acc; + // }, keys); + // }; const nodeDisabled = useEvent((node: DataNode) => { const nodeValue = node[fieldNames.value]; @@ -195,33 +180,35 @@ const OptionList: React.ForwardRefRenderFunction = (_, return false; } - if (isOverMaxCount) { - return true; + if (leftMaxCount === null) { + return false; } - const cacheKey = `${nodeValue}-${checkedKeys.join(',')}-${maxCount}`; + // const cacheKey = `${nodeValue}-${checkedKeys.join(',')}-${maxCount}`; - // check cache - if (disabledCacheRef.current.has(cacheKey)) { - return disabledCacheRef.current.get(cacheKey); - } + // // check cache + // if (disabledCacheRef.current.has(cacheKey)) { + // return disabledCacheRef.current.get(cacheKey); + // } - // calculate disabled state - const selectableNodeKeys = getSelectableKeys(node, fieldNames); - const simulatedCheckedKeys = [...checkedKeys, ...selectableNodeKeys]; - const simulatedDisplayValues = formatStrategyValues( - simulatedCheckedKeys as SafeKey[], - showCheckedStrategy, - keyEntities, - fieldNames, - ); + // // calculate disabled state + // const selectableNodeKeys = getSelectableKeys(node, fieldNames); + // const simulatedCheckedKeys = [...checkedKeys, ...selectableNodeKeys]; + // const simulatedDisplayValues = formatStrategyValues( + // simulatedCheckedKeys as SafeKey[], + // showCheckedStrategy, + // keyEntities, + // fieldNames, + // ); + + // const isDisabled = simulatedDisplayValues.length > maxCount; - const isDisabled = simulatedDisplayValues.length > maxCount; + // // update cache + // disabledCacheRef.current.set(cacheKey, isDisabled); - // update cache - disabledCacheRef.current.set(cacheKey, isDisabled); + // return isDisabled; - return isDisabled; + return false; }); // ========================== Get First Selectable Node ========================== diff --git a/src/TreeSelect.tsx b/src/TreeSelect.tsx index 7fa057a1..fcfafa92 100644 --- a/src/TreeSelect.tsx +++ b/src/TreeSelect.tsx @@ -422,6 +422,11 @@ const TreeSelect = React.forwardRef((props, ref) mergedFieldNames, ); + // Not allow pass with `maxCount` + if (maxCount && formattedKeyList.length > maxCount) { + return; + } + const labeledValues = convert2LabelValues(newRawValues); setInternalValue(labeledValues); @@ -600,9 +605,6 @@ const TreeSelect = React.forwardRef((props, ref) }); // ========================== Context =========================== - const isOverMaxCount = - mergedMultiple && maxCount !== undefined && cachedDisplayValues?.length >= maxCount; - const treeSelectContext = React.useMemo(() => { return { virtual, @@ -616,8 +618,7 @@ const TreeSelect = React.forwardRef((props, ref) treeExpandAction, treeTitleRender, onPopupScroll, - isOverMaxCount, - maxCount, + leftMaxCount: maxCount ? maxCount - cachedDisplayValues.length : null, showCheckedStrategy: mergedShowCheckedStrategy, }; }, [ @@ -633,7 +634,7 @@ const TreeSelect = React.forwardRef((props, ref) treeTitleRender, onPopupScroll, maxCount, - mergedMultiple, + cachedDisplayValues.length, mergedShowCheckedStrategy, ]); diff --git a/src/TreeSelectContext.ts b/src/TreeSelectContext.ts index 82480262..a87e5144 100644 --- a/src/TreeSelectContext.ts +++ b/src/TreeSelectContext.ts @@ -1,7 +1,7 @@ import * as React from 'react'; import type { ExpandAction } from 'rc-tree/lib/Tree'; import type { DataNode, FieldNames, Key } from './interface'; -import { CheckedStrategy } from './utils/strategyUtil'; +import type { CheckedStrategy } from './utils/strategyUtil'; export interface TreeSelectContextProps { virtual?: boolean; @@ -15,8 +15,7 @@ export interface TreeSelectContextProps { treeExpandAction?: ExpandAction; treeTitleRender?: (node: any) => React.ReactNode; onPopupScroll?: React.UIEventHandler; - isOverMaxCount?: boolean; - maxCount?: number; + leftMaxCount?: number | null; showCheckedStrategy?: CheckedStrategy; } From b14a46bac50891bab699fe1843fa78a2d6065772 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Fri, 20 Dec 2024 17:05:24 +0800 Subject: [PATCH 08/13] chore: update maxCount logic --- src/OptionList.tsx | 2 ++ src/TreeSelect.tsx | 13 ++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index b1da115b..af070c2c 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -180,6 +180,8 @@ const OptionList: React.ForwardRefRenderFunction = (_, return false; } + console.log('--->', node); + if (leftMaxCount === null) { return false; } diff --git a/src/TreeSelect.tsx b/src/TreeSelect.tsx index fcfafa92..a9d8a643 100644 --- a/src/TreeSelect.tsx +++ b/src/TreeSelect.tsx @@ -408,6 +408,17 @@ const TreeSelect = React.forwardRef((props, ref) const [cachedDisplayValues] = useCache(displayValues); + // ========================== MaxCount ========================== + const mergedMaxCount = React.useMemo(() => { + if ( + mergedMultiple && + (mergedShowCheckedStrategy === 'SHOW_CHILD' || treeCheckStrictly || !treeCheckable) + ) { + return maxCount; + } + return null; + }, [maxCount, mergedMultiple, treeCheckStrictly, mergedShowCheckedStrategy, treeCheckable]); + // =========================== Change =========================== const triggerChange = useRefFunc( ( @@ -423,7 +434,7 @@ const TreeSelect = React.forwardRef((props, ref) ); // Not allow pass with `maxCount` - if (maxCount && formattedKeyList.length > maxCount) { + if (mergedMaxCount && formattedKeyList.length > mergedMaxCount) { return; } From 3daaa8d4deeb39166f61fa15310ab7631ab41536 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Fri, 20 Dec 2024 17:36:20 +0800 Subject: [PATCH 09/13] chore: add warning --- src/OptionList.tsx | 9 +++++++-- src/TreeSelect.tsx | 8 +++++++- src/TreeSelectContext.ts | 10 +++++++--- src/utils/warningPropsUtil.ts | 11 ++++++++++- 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index af070c2c..4684d3de 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -49,7 +49,8 @@ const OptionList: React.ForwardRefRenderFunction = (_, treeTitleRender, onPopupScroll, leftMaxCount, - showCheckedStrategy, + leafCountOnly, + valueEntities, } = React.useContext(TreeSelectContext); const { @@ -180,12 +181,16 @@ const OptionList: React.ForwardRefRenderFunction = (_, return false; } - console.log('--->', node); + // console.log('--->', node); if (leftMaxCount === null) { return false; } + if (!leafCountOnly && leftMaxCount <= 0) { + return true; + } + // const cacheKey = `${nodeValue}-${checkedKeys.join(',')}-${maxCount}`; // // check cache diff --git a/src/TreeSelect.tsx b/src/TreeSelect.tsx index a9d8a643..24fc532f 100644 --- a/src/TreeSelect.tsx +++ b/src/TreeSelect.tsx @@ -226,6 +226,7 @@ const TreeSelect = React.forwardRef((props, ref) const mergedTreeData = useTreeData(treeData, children, treeDataSimpleMode); const { keyEntities, valueEntities } = useDataEntities(mergedTreeData, mergedFieldNames); + console.log('-->', valueEntities); /** Get `missingRawValues` which not exist in the tree yet */ const splitRawValues = React.useCallback( @@ -630,7 +631,9 @@ const TreeSelect = React.forwardRef((props, ref) treeTitleRender, onPopupScroll, leftMaxCount: maxCount ? maxCount - cachedDisplayValues.length : null, - showCheckedStrategy: mergedShowCheckedStrategy, + leafCountOnly: + mergedShowCheckedStrategy === 'SHOW_CHILD' && !treeCheckStrictly && !!treeCheckable, + valueEntities, }; }, [ virtual, @@ -647,6 +650,9 @@ const TreeSelect = React.forwardRef((props, ref) maxCount, cachedDisplayValues.length, mergedShowCheckedStrategy, + treeCheckStrictly, + treeCheckable, + valueEntities, ]); // ======================= Legacy Context ======================= diff --git a/src/TreeSelectContext.ts b/src/TreeSelectContext.ts index a87e5144..fcb2eba0 100644 --- a/src/TreeSelectContext.ts +++ b/src/TreeSelectContext.ts @@ -1,7 +1,7 @@ import * as React from 'react'; import type { ExpandAction } from 'rc-tree/lib/Tree'; import type { DataNode, FieldNames, Key } from './interface'; -import type { CheckedStrategy } from './utils/strategyUtil'; +import type useDataEntities from './hooks/useDataEntities'; export interface TreeSelectContextProps { virtual?: boolean; @@ -15,8 +15,12 @@ export interface TreeSelectContextProps { treeExpandAction?: ExpandAction; treeTitleRender?: (node: any) => React.ReactNode; onPopupScroll?: React.UIEventHandler; - leftMaxCount?: number | null; - showCheckedStrategy?: CheckedStrategy; + + // For `maxCount` usage + leftMaxCount: number | null; + /** When `true`, only take leaf node as count, or take all as count with `maxCount` limitation */ + leafCountOnly: boolean; + valueEntities: ReturnType['valueEntities']; } const TreeSelectContext = React.createContext(null as any); diff --git a/src/utils/warningPropsUtil.ts b/src/utils/warningPropsUtil.ts index 9743b946..adcadefb 100644 --- a/src/utils/warningPropsUtil.ts +++ b/src/utils/warningPropsUtil.ts @@ -10,6 +10,8 @@ function warningProps(props: TreeSelectProps & { searchPlaceholder?: string }) { labelInValue, value, multiple, + showCheckedStrategy, + maxCount, } = props; warning(!searchPlaceholder, '`searchPlaceholder` has been removed.'); @@ -20,7 +22,7 @@ function warningProps(props: TreeSelectProps & { searchPlaceholder?: string }) { if (labelInValue || treeCheckStrictly) { warning( - toArray(value).every((val) => val && typeof val === 'object' && 'value' in val), + toArray(value).every(val => val && typeof val === 'object' && 'value' in val), 'Invalid prop `value` supplied to `TreeSelect`. You should use { label: string, value: string | number } or [{ label: string, value: string | number }] instead.', ); } @@ -33,6 +35,13 @@ function warningProps(props: TreeSelectProps & { searchPlaceholder?: string }) { } else { warning(!Array.isArray(value), '`value` should not be array when `TreeSelect` is single mode.'); } + + if (maxCount && (showCheckedStrategy === 'SHOW_ALL' || showCheckedStrategy === 'SHOW_PARENT')) { + warning( + false, + '`maxCount` not work with `showCheckedStrategy=SHOW_ALL` or `showCheckedStrategy=SHOW_PARENT`.', + ); + } } export default warningProps; From faa432beff43ab529057657fef630dea048b0b0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 23 Dec 2024 14:08:24 +0800 Subject: [PATCH 10/13] chore: cache cal --- src/OptionList.tsx | 76 ++++++++++++++++++++++------------------------ src/TreeSelect.tsx | 1 - 2 files changed, 37 insertions(+), 40 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 4684d3de..3c0e4434 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -160,19 +160,38 @@ const OptionList: React.ForwardRefRenderFunction = (_, // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchValue]); - // const getSelectableKeys = (targetNode: DataNode, names: FieldNames): Key[] => { - // const keys = [targetNode[names.value]]; - // if (!Array.isArray(targetNode.children)) { - // return keys; - // } - - // return targetNode.children.reduce((acc, child) => { - // if (!child.disabled) { - // acc.push(...getSelectableKeys(child, names)); - // } - // return acc; - // }, keys); - // }; + // ========================= Disabled ========================= + const disabledCacheRef = React.useRef>(new Map()); + + // Clear cache if `leftMaxCount` changed + React.useEffect(() => { + if (leftMaxCount) { + disabledCacheRef.current.clear(); + } + }, [leftMaxCount]); + + function getDisabledWithCache(node: DataNode) { + const value = node[fieldNames.value]; + if (!disabledCacheRef.current.has(value)) { + const entity = valueEntities.get(value); + const isLeaf = (entity.children || []).length === 0; + + if (!isLeaf) { + const checkableChildren = entity.children.filter( + childTreeNode => + !childTreeNode.node.disabled && + !childTreeNode.node.disableCheckbox && + !checkedKeys.includes(childTreeNode.node[fieldNames.value]), + ); + + const checkableChildrenCount = checkableChildren.length; + disabledCacheRef.current.set(value, checkableChildrenCount > leftMaxCount); + } else { + disabledCacheRef.current.set(value, false); + } + } + return disabledCacheRef.current.get(value); + } const nodeDisabled = useEvent((node: DataNode) => { const nodeValue = node[fieldNames.value]; @@ -181,39 +200,18 @@ const OptionList: React.ForwardRefRenderFunction = (_, return false; } - // console.log('--->', node); - if (leftMaxCount === null) { return false; } - if (!leafCountOnly && leftMaxCount <= 0) { + if (leftMaxCount <= 0) { return true; } - // const cacheKey = `${nodeValue}-${checkedKeys.join(',')}-${maxCount}`; - - // // check cache - // if (disabledCacheRef.current.has(cacheKey)) { - // return disabledCacheRef.current.get(cacheKey); - // } - - // // calculate disabled state - // const selectableNodeKeys = getSelectableKeys(node, fieldNames); - // const simulatedCheckedKeys = [...checkedKeys, ...selectableNodeKeys]; - // const simulatedDisplayValues = formatStrategyValues( - // simulatedCheckedKeys as SafeKey[], - // showCheckedStrategy, - // keyEntities, - // fieldNames, - // ); - - // const isDisabled = simulatedDisplayValues.length > maxCount; - - // // update cache - // disabledCacheRef.current.set(cacheKey, isDisabled); - - // return isDisabled; + // This is a low performance calculation + if (leafCountOnly && leftMaxCount) { + return getDisabledWithCache(node); + } return false; }); diff --git a/src/TreeSelect.tsx b/src/TreeSelect.tsx index 24fc532f..31044b37 100644 --- a/src/TreeSelect.tsx +++ b/src/TreeSelect.tsx @@ -226,7 +226,6 @@ const TreeSelect = React.forwardRef((props, ref) const mergedTreeData = useTreeData(treeData, children, treeDataSimpleMode); const { keyEntities, valueEntities } = useDataEntities(mergedTreeData, mergedFieldNames); - console.log('-->', valueEntities); /** Get `missingRawValues` which not exist in the tree yet */ const splitRawValues = React.useCallback( From b23bfc2072b01c3a5a03a96d7a9685a76858e747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Mon, 23 Dec 2024 15:41:49 +0800 Subject: [PATCH 11/13] fix: optimize null check logic --- src/OptionList.tsx | 3 +-- src/TreeSelect.tsx | 2 +- tests/Select.maxCount.spec.tsx | 27 --------------------------- 3 files changed, 2 insertions(+), 30 deletions(-) diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 3c0e4434..d69aec40 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -9,10 +9,9 @@ import useMemo from 'rc-util/lib/hooks/useMemo'; import * as React from 'react'; import LegacyContext from './LegacyContext'; import TreeSelectContext from './TreeSelectContext'; -import type { DataNode, FieldNames, Key, SafeKey } from './interface'; +import type { DataNode, Key, SafeKey } from './interface'; import { getAllKeys, isCheckDisabled } from './utils/valueUtil'; import { useEvent } from 'rc-util'; -import { formatStrategyValues } from './utils/strategyUtil'; const HIDDEN_STYLE = { width: 0, diff --git a/src/TreeSelect.tsx b/src/TreeSelect.tsx index 31044b37..1a8a938d 100644 --- a/src/TreeSelect.tsx +++ b/src/TreeSelect.tsx @@ -629,7 +629,7 @@ const TreeSelect = React.forwardRef((props, ref) treeExpandAction, treeTitleRender, onPopupScroll, - leftMaxCount: maxCount ? maxCount - cachedDisplayValues.length : null, + leftMaxCount: maxCount === undefined ? null : maxCount - cachedDisplayValues.length, leafCountOnly: mergedShowCheckedStrategy === 'SHOW_CHILD' && !treeCheckStrictly && !!treeCheckable, valueEntities, diff --git a/tests/Select.maxCount.spec.tsx b/tests/Select.maxCount.spec.tsx index 31a9a68b..1f9c74e9 100644 --- a/tests/Select.maxCount.spec.tsx +++ b/tests/Select.maxCount.spec.tsx @@ -271,33 +271,6 @@ describe('TreeSelect.maxCount with different strategies', () => { fireEvent.click(childCheckboxes[2]); expect(handleChange).toHaveBeenCalledTimes(2); }); - - it('should respect maxCount with SHOW_ALL strategy', () => { - const handleChange = jest.fn(); - const { container } = render( - , - ); - - // Select parent node - should not work as it would show both parent and children - const parentCheckbox = within(container).getByText('parent'); - fireEvent.click(parentCheckbox); - expect(handleChange).not.toHaveBeenCalled(); - - // Select individual children - const childCheckboxes = within(container).getAllByText(/child/); - fireEvent.click(childCheckboxes[0]); - fireEvent.click(childCheckboxes[1]); - expect(handleChange).toHaveBeenCalledTimes(2); - }); }); describe('TreeSelect.maxCount with treeCheckStrictly', () => { From c4b2e13de48ca8df571dd9f4e54ee2cfd8a8fb99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Tue, 24 Dec 2024 10:46:05 +0800 Subject: [PATCH 12/13] chore: enhance warning message --- src/utils/warningPropsUtil.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/utils/warningPropsUtil.ts b/src/utils/warningPropsUtil.ts index adcadefb..11c07d64 100644 --- a/src/utils/warningPropsUtil.ts +++ b/src/utils/warningPropsUtil.ts @@ -36,10 +36,14 @@ function warningProps(props: TreeSelectProps & { searchPlaceholder?: string }) { warning(!Array.isArray(value), '`value` should not be array when `TreeSelect` is single mode.'); } - if (maxCount && (showCheckedStrategy === 'SHOW_ALL' || showCheckedStrategy === 'SHOW_PARENT')) { + if ( + maxCount && + ((showCheckedStrategy === 'SHOW_ALL' && !treeCheckStrictly) || + showCheckedStrategy === 'SHOW_PARENT') + ) { warning( false, - '`maxCount` not work with `showCheckedStrategy=SHOW_ALL` or `showCheckedStrategy=SHOW_PARENT`.', + '`maxCount` not work with `showCheckedStrategy=SHOW_ALL` (when `treeCheckStrictly=false`) or `showCheckedStrategy=SHOW_PARENT`.', ); } } From d8661d6be332bb90af4cb1abad06b6d99da94c89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=B1=AA?= <1844749591@qq.com> Date: Tue, 24 Dec 2024 11:31:14 +0800 Subject: [PATCH 13/13] test: add warnings for maxCount --- tests/Select.warning.spec.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/Select.warning.spec.js b/tests/Select.warning.spec.js index 7bbde15e..780c1ec0 100644 --- a/tests/Select.warning.spec.js +++ b/tests/Select.warning.spec.js @@ -71,4 +71,25 @@ describe('TreeSelect.warning', () => { 'Warning: Second param of `onDropdownVisibleChange` has been removed.', ); }); + + it('warns on using maxCount with showCheckedStrategy=SHOW_ALL when treeCheckStrictly=false', () => { + mount(); + expect(spy).toHaveBeenCalledWith( + 'Warning: `maxCount` not work with `showCheckedStrategy=SHOW_ALL` (when `treeCheckStrictly=false`) or `showCheckedStrategy=SHOW_PARENT`.', + ); + }); + + it('warns on using maxCount with showCheckedStrategy=SHOW_PARENT', () => { + mount(); + expect(spy).toHaveBeenCalledWith( + 'Warning: `maxCount` not work with `showCheckedStrategy=SHOW_ALL` (when `treeCheckStrictly=false`) or `showCheckedStrategy=SHOW_PARENT`.', + ); + }); + + it('does not warn on using maxCount with showCheckedStrategy=SHOW_ALL when treeCheckStrictly=true', () => { + mount(); + expect(spy).not.toHaveBeenCalledWith( + 'Warning: `maxCount` not work with `showCheckedStrategy=SHOW_ALL` (when `treeCheckStrictly=false`) or `showCheckedStrategy=SHOW_PARENT`.', + ); + }); });