diff --git a/examples/mutiple-with-maxCount.tsx b/examples/mutiple-with-maxCount.tsx index 41a9b77e..ba6c062a 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', @@ -63,21 +77,17 @@ export default () => { maxCount={3} treeData={treeData} /> -

checkable with maxCount

-

checkable with maxCount and treeCheckStrictly

= (_, treeExpandAction, treeTitleRender, onPopupScroll, - displayValues, - isOverMaxCount, + leftMaxCount, + leafCountOnly, + valueEntities, } = React.useContext(TreeSelectContext); const { @@ -80,11 +81,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) { @@ -163,8 +159,60 @@ const OptionList: React.ForwardRefRenderFunction = (_, // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchValue]); + // ========================= 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) => { - return isOverMaxCount && !memoRawValues.includes(node[fieldNames.value]); + const nodeValue = node[fieldNames.value]; + + if (checkedKeys.includes(nodeValue)) { + return false; + } + + if (leftMaxCount === null) { + return false; + } + + if (leftMaxCount <= 0) { + return true; + } + + // This is a low performance calculation + if (leafCountOnly && leftMaxCount) { + return getDisabledWithCache(node); + } + + return false; }); // ========================== Get First Selectable Node ========================== diff --git a/src/TreeSelect.tsx b/src/TreeSelect.tsx index c46f49a8..1a8a938d 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( ( @@ -422,11 +433,9 @@ 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; - } + // Not allow pass with `maxCount` + if (mergedMaxCount && formattedKeyList.length > mergedMaxCount) { + return; } const labeledValues = convert2LabelValues(newRawValues); @@ -607,9 +616,6 @@ const TreeSelect = React.forwardRef((props, ref) }); // ========================== Context =========================== - const isOverMaxCount = - mergedMultiple && maxCount !== undefined && cachedDisplayValues?.length >= maxCount; - const treeSelectContext = React.useMemo(() => { return { virtual, @@ -623,8 +629,10 @@ const TreeSelect = React.forwardRef((props, ref) treeExpandAction, treeTitleRender, onPopupScroll, - displayValues: cachedDisplayValues, - isOverMaxCount, + leftMaxCount: maxCount === undefined ? null : maxCount - cachedDisplayValues.length, + leafCountOnly: + mergedShowCheckedStrategy === 'SHOW_CHILD' && !treeCheckStrictly && !!treeCheckable, + valueEntities, }; }, [ virtual, @@ -639,8 +647,11 @@ const TreeSelect = React.forwardRef((props, ref) treeTitleRender, onPopupScroll, maxCount, - cachedDisplayValues, - mergedMultiple, + cachedDisplayValues.length, + mergedShowCheckedStrategy, + treeCheckStrictly, + treeCheckable, + valueEntities, ]); // ======================= Legacy Context ======================= diff --git a/src/TreeSelectContext.ts b/src/TreeSelectContext.ts index 2d335e4b..fcb2eba0 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 type { DataNode, FieldNames, Key } from './interface'; +import type useDataEntities from './hooks/useDataEntities'; export interface TreeSelectContextProps { virtual?: boolean; @@ -14,8 +15,12 @@ export interface TreeSelectContextProps { treeExpandAction?: ExpandAction; treeTitleRender?: (node: any) => React.ReactNode; onPopupScroll?: React.UIEventHandler; - displayValues?: LabeledValueType[]; - isOverMaxCount?: boolean; + + // 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..11c07d64 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,17 @@ 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' && !treeCheckStrictly) || + showCheckedStrategy === 'SHOW_PARENT') + ) { + warning( + false, + '`maxCount` not work with `showCheckedStrategy=SHOW_ALL` (when `treeCheckStrictly=false`) or `showCheckedStrategy=SHOW_PARENT`.', + ); + } } export default warningProps; diff --git a/tests/Select.maxCount.spec.tsx b/tests/Select.maxCount.spec.tsx index 79fe48b8..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', () => { @@ -372,3 +345,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'); + }); +}); 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`.', + ); + }); });