diff --git a/README.md b/README.md index 2ea7eedc..38a8010b 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,6 @@ React TreeSelect Component - ## Development ``` @@ -55,76 +54,77 @@ online example: https://tree-select-react-component.vercel.app/ ### TreeSelect props -| name | description | type | default | -|----------|----------------|----------|--------------| -|className | additional css class of root dom node | String | '' | -|prefixCls | prefix class | String | '' | -|animation | dropdown animation name. only support slide-up now | String | '' | -|transitionName | dropdown css animation name | String | '' | -|choiceTransitionName | css animation name for selected items at multiple mode | String | '' | -|dropdownMatchSelectWidth | whether dropdown's with is same with select. Default set `min-width` same as input | bool | true | -|dropdownClassName | additional className applied to dropdown | String | - | -|dropdownStyle | additional style applied to dropdown | Object | {} | -|onDropdownVisibleChange | control dropdown visible | function | `() => { return true; }` | -|notFoundContent | specify content to show when no result matches. | String | 'Not Found' | -|showSearch | whether show search input in single mode | bool | true | -|allowClear | whether allowClear | bool | false | -|maxTagTextLength | max tag text length to show | number | - | -|maxTagCount | max tag count to show | number | - | -|maxTagPlaceholder | placeholder for omitted values | ReactNode/function(omittedValues) | - | -|multiple | whether multiple select (true when enable treeCheckable) | bool | false | -|disabled | whether disabled select | bool | false | -|searchValue | work with `onSearch` to make search value controlled. | string | '' | -|defaultValue | initial selected treeNode(s) | same as value type | - | -|value | current selected treeNode(s). | normal: String/Array. labelInValue: {value:String,label:React.Node}/Array<{value,label}>. treeCheckStrictly(halfChecked default false): {value:String,label:React.Node, halfChecked}/Array<{value,label,halfChecked}>. | - | -|labelInValue| whether to embed label in value, see above value type | Bool | false | -|onChange | called when select treeNode or input value change | function(value, label(null), extra) | - | -|onSelect | called when select treeNode | function(value, node, extra) | - | -|onSearch | called when input changed | function | - | -|onTreeExpand | called when tree node expand | function(expandedKeys) | - | -|onPopupScroll | called when popup scroll | function(event) | - | -|showCheckedStrategy | `TreeSelect.SHOW_ALL`: show all checked treeNodes (Include parent treeNode). `TreeSelect.SHOW_PARENT`: show checked treeNodes (Just show parent treeNode). Default just show child. | enum{TreeSelect.SHOW_ALL, TreeSelect.SHOW_PARENT, TreeSelect.SHOW_CHILD } | TreeSelect.SHOW_CHILD | -|treeIcon | show tree icon | bool | false | -|treeLine | show tree line | bool | false | -|treeDefaultExpandAll | default expand all treeNode | bool | false | -|treeDefaultExpandedKeys | default expanded treeNode keys | Array | - | -|treeExpandedKeys | set tree expanded keys | Array | - | -|treeExpandAction | Tree open logic, optional: false \| `click` \| `doubleClick`, same as `expandAction` of `rc-tree` | string \| boolean | `click` | -|treeCheckable | whether tree show checkbox (select callback will not fire) | bool | false | -|treeCheckStrictly | check node precisely, parent and children nodes are not associated| bool | false | -|filterTreeNode | whether filter treeNodes by input value. default filter by treeNode's treeNodeFilterProp prop's value | bool/Function(inputValue:string, treeNode:TreeNode) | Function | -|treeNodeFilterProp | which prop value of treeNode will be used for filter if filterTreeNode return true | String | 'value' | -|treeNodeLabelProp | which prop value of treeNode will render as content of select | String | 'title' | -|treeData | treeNodes data Array, if set it then you need not to construct children TreeNode. (value should be unique across the whole array) | array<{value,label,children, [disabled,selectable]}> | [] | -|treeDataSimpleMode | enable simple mode of treeData.(treeData should be like this: [{id:1, pId:0, value:'1', label:"test1",...},...], `pId` is parent node's id) | bool/object{id:'id', pId:'pId', rootPId:null} | false | -|treeTitleRender | Custom render nodes | (nodeData: OptionType) => ReactNode | -|loadData | load data asynchronously | function(node) | - | -|getPopupContainer | container which popup select menu rendered into | function(trigger:Node):Node | function(){return document.body;} | -|autoClearSearchValue | auto clear search input value when multiple select is selected/deselected | boolean | true | -| suffixIcon | specify the select arrow icon | ReactNode \| (props: TreeProps) => ReactNode | - | -| clearIcon | specify the clear icon | ReactNode \| (props: TreeProps) => ReactNode | - | -| removeIcon | specify the remove icon | ReactNode \| (props: TreeProps) => ReactNode | - | -|switcherIcon| specify the switcher icon | ReactNode \| (props: TreeProps) => ReactNode | - | -|virtual| Disable virtual when `false` | false | - | - +| name | description | type | default | +| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------- | +| className | additional css class of root dom node | String | '' | +| prefixCls | prefix class | String | '' | +| animation | dropdown animation name. only support slide-up now | String | '' | +| transitionName | dropdown css animation name | String | '' | +| choiceTransitionName | css animation name for selected items at multiple mode | String | '' | +| dropdownMatchSelectWidth | whether dropdown's with is same with select. Default set `min-width` same as input | bool | true | +| dropdownClassName | additional className applied to dropdown | String | - | +| dropdownStyle | additional style applied to dropdown | Object | {} | +| onDropdownVisibleChange | control dropdown visible | function | `() => { return true; }` | +| notFoundContent | specify content to show when no result matches. | String | 'Not Found' | +| showSearch | whether show search input in single mode | bool | true | +| allowClear | whether allowClear | bool | false | +| maxTagTextLength | max tag text length to show | number | - | +| maxTagCount | max tag count to show | number | - | +| maxCount | Limit the maximum number of items that can be selected in multiple mode | number | - | +| maxTagPlaceholder | placeholder for omitted values | ReactNode/function(omittedValues) | - | +| multiple | whether multiple select (true when enable treeCheckable) | bool | false | +| disabled | whether disabled select | bool | false | +| searchValue | work with `onSearch` to make search value controlled. | string | '' | +| defaultValue | initial selected treeNode(s) | same as value type | - | +| value | current selected treeNode(s). | normal: String/Array. labelInValue: {value:String,label:React.Node}/Array<{value,label}>. treeCheckStrictly(halfChecked default false): {value:String,label:React.Node, halfChecked}/Array<{value,label,halfChecked}>. | - | +| labelInValue | whether to embed label in value, see above value type | Bool | false | +| onChange | called when select treeNode or input value change | function(value, label(null), extra) | - | +| onSelect | called when select treeNode | function(value, node, extra) | - | +| onSearch | called when input changed | function | - | +| onTreeExpand | called when tree node expand | function(expandedKeys) | - | +| onPopupScroll | called when popup scroll | function(event) | - | +| showCheckedStrategy | `TreeSelect.SHOW_ALL`: show all checked treeNodes (Include parent treeNode). `TreeSelect.SHOW_PARENT`: show checked treeNodes (Just show parent treeNode). Default just show child. | enum{TreeSelect.SHOW_ALL, TreeSelect.SHOW_PARENT, TreeSelect.SHOW_CHILD } | TreeSelect.SHOW_CHILD | +| treeIcon | show tree icon | bool | false | +| treeLine | show tree line | bool | false | +| treeDefaultExpandAll | default expand all treeNode | bool | false | +| treeDefaultExpandedKeys | default expanded treeNode keys | Array | - | +| treeExpandedKeys | set tree expanded keys | Array | - | +| treeExpandAction | Tree open logic, optional: false \| `click` \| `doubleClick`, same as `expandAction` of `rc-tree` | string \| boolean | `click` | +| treeCheckable | whether tree show checkbox (select callback will not fire) | bool | false | +| treeCheckStrictly | check node precisely, parent and children nodes are not associated | bool | false | +| filterTreeNode | whether filter treeNodes by input value. default filter by treeNode's treeNodeFilterProp prop's value | bool/Function(inputValue:string, treeNode:TreeNode) | Function | +| treeNodeFilterProp | which prop value of treeNode will be used for filter if filterTreeNode return true | String | 'value' | +| treeNodeLabelProp | which prop value of treeNode will render as content of select | String | 'title' | +| treeData | treeNodes data Array, if set it then you need not to construct children TreeNode. (value should be unique across the whole array) | array<{value,label,children, [disabled,selectable]}> | [] | +| treeDataSimpleMode | enable simple mode of treeData.(treeData should be like this: [{id:1, pId:0, value:'1', label:"test1",...},...], `pId` is parent node's id) | bool/object{id:'id', pId:'pId', rootPId:null} | false | +| treeTitleRender | Custom render nodes | (nodeData: OptionType) => ReactNode | +| loadData | load data asynchronously | function(node) | - | +| getPopupContainer | container which popup select menu rendered into | function(trigger:Node):Node | function(){return document.body;} | +| autoClearSearchValue | auto clear search input value when multiple select is selected/deselected | boolean | true | +| suffixIcon | specify the select arrow icon | ReactNode \| (props: TreeProps) => ReactNode | - | +| clearIcon | specify the clear icon | ReactNode \| (props: TreeProps) => ReactNode | - | +| removeIcon | specify the remove icon | ReactNode \| (props: TreeProps) => ReactNode | - | +| switcherIcon | specify the switcher icon | ReactNode \| (props: TreeProps) => ReactNode | - | +| virtual | Disable virtual when `false` | false | - | ### TreeNode props -> note: you'd better to use `treeData` instead of using TreeNode. -| name | description | type | default | -|----------|----------------|----------|--------------| -|disabled | disable treeNode | bool | false | -|key | it's value must be unique across the tree's all TreeNode, you must set it | String | - | -|value | default as treeNodeFilterProp (be unique across the tree's all TreeNode) | String | '' | -|title | tree/subTree's title | String/element | '---' | -|isLeaf | whether it's leaf node | bool | false | +> note: you'd better to use `treeData` instead of using TreeNode. +| name | description | type | default | +| -------- | ------------------------------------------------------------------------- | -------------- | ------- | +| disabled | disable treeNode | bool | false | +| key | it's value must be unique across the tree's all TreeNode, you must set it | String | - | +| value | default as treeNodeFilterProp (be unique across the tree's all TreeNode) | String | '' | +| title | tree/subTree's title | String/element | '---' | +| isLeaf | whether it's leaf node | bool | false | ## note + 1. Optimization tips(when there are large amounts of data, like more than 5000 nodes) - - Do not Expand all nodes. - - Recommend not exist many `TreeSelect` components in a page at the same time. - - Recommend not use `treeCheckable` mode, or use `treeCheckStrictly`. + - Do not Expand all nodes. + - Recommend not exist many `TreeSelect` components in a page at the same time. + - Recommend not use `treeCheckable` mode, or use `treeCheckStrictly`. 2. In `treeCheckable` mode, It has the same effect when click `x`(node in Selection box) or uncheck in the treeNode(in dropdown panel), but the essence is not the same. So, even if both of them trigger `onChange` method, but the parameters (the third parameter) are different. (中文:在`treeCheckable`模式下,已选择节点上的`x`删除操作、和相应 treeNode 节点上 checkbox 的 uncheck 操作,最终效果相同,但本质不一样。前者跟弹出的 tree 组件可以“毫无关系”(例如 dropdown 没展开过,tree 也就没渲染好),而后者是 tree 组件上的节点 uncheck 事件。所以、即便两者都会触发`onChange`方法、但它们的参数(第三个参数)是不同的。) ## Test Case @@ -137,4 +137,4 @@ http://localhost:8000/node_modules/rc-server/node_modules/node-jscover/lib/front ## License -rc-tree-select is released under the MIT license. \ No newline at end of file +rc-tree-select is released under the MIT license. diff --git a/docs/demo/mutiple-with-maxCount.md b/docs/demo/mutiple-with-maxCount.md new file mode 100644 index 00000000..2df96692 --- /dev/null +++ b/docs/demo/mutiple-with-maxCount.md @@ -0,0 +1,8 @@ +--- +title: mutiple-with-maxCount +nav: + title: Demo + path: /demo +--- + + diff --git a/examples/mutiple-with-maxCount.tsx b/examples/mutiple-with-maxCount.tsx new file mode 100644 index 00000000..41a9b77e --- /dev/null +++ b/examples/mutiple-with-maxCount.tsx @@ -0,0 +1,94 @@ +import React, { useState } from 'react'; +import TreeSelect from '../src'; + +export default () => { + const [value, setValue] = useState(['1']); + const [checkValue, setCheckValue] = useState(['1']); + + const treeData = [ + { + key: '1', + value: '1', + title: '1', + children: [ + { + key: '1-1', + value: '1-1', + title: '1-1', + }, + { + key: '1-2', + value: '1-2', + title: '1-2', + }, + { + key: '1-3', + value: '1-3', + title: '1-3', + }, + ], + }, + { + key: '2', + value: '2', + title: '2', + }, + { + key: '3', + value: '3', + title: '3', + }, + { + key: '4', + value: '4', + title: '4', + }, + ]; + + const onChange = (val: string[]) => { + setValue(val); + }; + + const onCheckChange = (val: string[]) => { + setCheckValue(val); + }; + + return ( + <> +

multiple with maxCount

+ + +

checkable with maxCount

+ + +

checkable with maxCount and treeCheckStrictly

+ + + ); +}; diff --git a/package.json b/package.json index 89bf0997..ecc3e84a 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "@babel/runtime": "^7.25.7", "classnames": "2.x", "rc-select": "~14.16.2", - "rc-tree": "~5.10.1", + "rc-tree": "~5.11.0", "rc-util": "^5.43.0" }, "devDependencies": { diff --git a/src/OptionList.tsx b/src/OptionList.tsx index bbfa37f9..b49e552e 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -2,14 +2,16 @@ import { useBaseProps } from 'rc-select'; import type { RefOptionListProps } from 'rc-select/lib/OptionList'; import type { TreeProps } from 'rc-tree'; import Tree from 'rc-tree'; +import { UnstableContext } from 'rc-tree'; import type { EventDataNode, ScrollTo } from 'rc-tree/lib/interface'; import KeyCode from 'rc-util/lib/KeyCode'; import useMemo from 'rc-util/lib/hooks/useMemo'; import * as React from 'react'; import LegacyContext from './LegacyContext'; import TreeSelectContext from './TreeSelectContext'; -import type { Key, SafeKey } from './interface'; +import type { DataNode, Key, SafeKey } from './interface'; import { getAllKeys, isCheckDisabled } from './utils/valueUtil'; +import { useEvent } from 'rc-util'; const HIDDEN_STYLE = { width: 0, @@ -45,6 +47,8 @@ const OptionList: React.ForwardRefRenderFunction = (_, treeExpandAction, treeTitleRender, onPopupScroll, + displayValues, + isOverMaxCount, } = React.useContext(TreeSelectContext); const { @@ -76,6 +80,11 @@ 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) { @@ -154,6 +163,10 @@ const OptionList: React.ForwardRefRenderFunction = (_, // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchValue]); + const nodeDisabled = useEvent((node: DataNode) => { + return isOverMaxCount && !memoRawValues.includes(node[fieldNames.value]); + }); + // ========================== Get First Selectable Node ========================== const getFirstMatchingNode = (nodes: EventDataNode[]): EventDataNode | null => { for (const node of nodes) { @@ -221,8 +234,9 @@ const OptionList: React.ForwardRefRenderFunction = (_, // >>> Select item case KeyCode.ENTER: { if (activeEntity) { + const isNodeDisabled = nodeDisabled(activeEntity.node); const { selectable, value, disabled } = activeEntity?.node || {}; - if (selectable !== false && !disabled) { + if (selectable !== false && !disabled && !isNodeDisabled) { onInternalSelect(null, { node: { key: activeKey }, selected: !checkedKeys.includes(value), @@ -276,42 +290,43 @@ const OptionList: React.ForwardRefRenderFunction = (_, {activeEntity.node.value} )} - - + + + ); }; diff --git a/src/TreeSelect.tsx b/src/TreeSelect.tsx index db63f710..c46f49a8 100644 --- a/src/TreeSelect.tsx +++ b/src/TreeSelect.tsx @@ -72,6 +72,7 @@ export interface TreeSelectProps>> Data treeData?: OptionType[]; @@ -136,6 +137,7 @@ const TreeSelect = React.forwardRef((props, ref) treeCheckable, treeCheckStrictly, labelInValue, + maxCount, // FieldNames fieldNames, @@ -413,6 +415,20 @@ const TreeSelect = React.forwardRef((props, ref) extra: { triggerValue?: SafeKey; selected?: boolean }, source: SelectSource, ) => { + const formattedKeyList = formatStrategyValues( + newRawValues, + mergedShowCheckedStrategy, + keyEntities, + 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); @@ -425,12 +441,6 @@ const TreeSelect = React.forwardRef((props, ref) if (onChange) { let eventValues: SafeKey[] = newRawValues; if (treeConduction) { - const formattedKeyList = formatStrategyValues( - newRawValues, - mergedShowCheckedStrategy, - keyEntities, - mergedFieldNames, - ); eventValues = formattedKeyList.map(key => { const entity = valueEntities.get(key); return entity ? entity.node[mergedFieldNames.value] : key; @@ -558,6 +568,7 @@ const TreeSelect = React.forwardRef((props, ref) onDeselect, rawCheckedValues, rawHalfCheckedValues, + maxCount, ], ); @@ -596,8 +607,11 @@ const TreeSelect = React.forwardRef((props, ref) }); // ========================== Context =========================== - const treeSelectContext = React.useMemo( - () => ({ + const isOverMaxCount = + mergedMultiple && maxCount !== undefined && cachedDisplayValues?.length >= maxCount; + + const treeSelectContext = React.useMemo(() => { + return { virtual, dropdownMatchSelectWidth, listHeight, @@ -609,21 +623,25 @@ const TreeSelect = React.forwardRef((props, ref) treeExpandAction, treeTitleRender, onPopupScroll, - }), - [ - virtual, - dropdownMatchSelectWidth, - listHeight, - listItemHeight, - listItemScrollOffset, - filteredTreeData, - mergedFieldNames, - onOptionSelect, - treeExpandAction, - treeTitleRender, - onPopupScroll, - ], - ); + displayValues: cachedDisplayValues, + isOverMaxCount, + }; + }, [ + virtual, + dropdownMatchSelectWidth, + listHeight, + listItemHeight, + listItemScrollOffset, + filteredTreeData, + mergedFieldNames, + onOptionSelect, + treeExpandAction, + treeTitleRender, + onPopupScroll, + maxCount, + cachedDisplayValues, + mergedMultiple, + ]); // ======================= Legacy Context ======================= const legacyContext = React.useMemo( diff --git a/src/TreeSelectContext.ts b/src/TreeSelectContext.ts index b0aff525..2d335e4b 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 } from './interface'; +import type { DataNode, FieldNames, Key, LabeledValueType } from './interface'; export interface TreeSelectContextProps { virtual?: boolean; @@ -14,6 +14,8 @@ export interface TreeSelectContextProps { treeExpandAction?: ExpandAction; treeTitleRender?: (node: any) => React.ReactNode; onPopupScroll?: React.UIEventHandler; + displayValues?: LabeledValueType[]; + isOverMaxCount?: boolean; } const TreeSelectContext = React.createContext(null as any); diff --git a/tests/Select.SearchInput.spec.js b/tests/Select.SearchInput.spec.js index 77fced28..cd2d15c3 100644 --- a/tests/Select.SearchInput.spec.js +++ b/tests/Select.SearchInput.spec.js @@ -221,14 +221,22 @@ describe('TreeSelect.SearchInput', () => { const input = getByRole('combobox'); fireEvent.change(input, { target: { value: '1' } }); fireEvent.keyDown(input, { keyCode: KeyCode.ENTER }); + fireEvent.keyUp(input, { keyCode: KeyCode.ENTER }); expect(onSelect).toHaveBeenCalledWith('1', expect.anything()); onSelect.mockReset(); // Search disabled node and press enter, should not select fireEvent.change(input, { target: { value: '2' } }); fireEvent.keyDown(input, { keyCode: KeyCode.ENTER }); + fireEvent.keyUp(input, { keyCode: KeyCode.ENTER }); expect(onSelect).not.toHaveBeenCalled(); onSelect.mockReset(); + + // Search and press enter, should select first matched non-disabled node + fireEvent.change(input, { target: { value: '3' } }); + fireEvent.keyDown(input, { keyCode: KeyCode.ENTER }); + fireEvent.keyUp(input, { keyCode: KeyCode.ENTER }); + expect(onSelect).toHaveBeenCalledWith('3', expect.anything()); }); it('should not select node when no matches found', () => { diff --git a/tests/Select.maxCount.spec.tsx b/tests/Select.maxCount.spec.tsx new file mode 100644 index 00000000..79fe48b8 --- /dev/null +++ b/tests/Select.maxCount.spec.tsx @@ -0,0 +1,374 @@ +import { render, fireEvent, within } from '@testing-library/react'; +import KeyCode from 'rc-util/lib/KeyCode'; +import { keyDown, keyUp } from './util'; +import React from 'react'; +import TreeSelect from '../src'; + +describe('TreeSelect.maxCount', () => { + const treeData = [ + { key: '0', value: '0', title: '0 label' }, + { key: '1', value: '1', title: '1 label' }, + { key: '2', value: '2', title: '2 label' }, + { key: '3', value: '3', title: '3 label' }, + ]; + + const renderTreeSelect = (props?: any) => { + return render(); + }; + + const selectOptions = (container, optionTexts) => { + const dropdownList = container.querySelector('.rc-tree-select-dropdown'); + optionTexts.forEach(text => { + fireEvent.click(within(dropdownList).getByText(text)); + }); + }; + + it('should disable unselected options when selection reaches maxCount', () => { + const { container } = renderTreeSelect(); + + selectOptions(container, ['0 label', '1 label']); + + // Check if third and fourth options are disabled + const dropdownList = container.querySelector('.rc-tree-select-dropdown') as HTMLElement; + const option3 = within(dropdownList).getByText('2 label'); + const option4 = within(dropdownList).getByText('3 label'); + + expect(option3.closest('div')).toHaveClass('rc-tree-select-tree-treenode-disabled'); + expect(option4.closest('div')).toHaveClass('rc-tree-select-tree-treenode-disabled'); + }); + + it('should allow deselecting options after reaching maxCount', () => { + const { container } = renderTreeSelect(); + const dropdownList = container.querySelector('.rc-tree-select-dropdown') as HTMLElement; + + selectOptions(container, ['0 label', '1 label']); + + // Try selecting third option, should be disabled + const option3 = within(dropdownList).getByText('2 label'); + fireEvent.click(option3); + expect(option3.closest('div')).toHaveClass('rc-tree-select-tree-treenode-disabled'); + + // Deselect first option + fireEvent.click(within(dropdownList).getByText('0 label')); + expect(within(dropdownList).queryByText('0 label')).toBeInTheDocument(); + + // Now should be able to select third option + fireEvent.click(option3); + expect(option3.closest('div')).not.toHaveClass('rc-tree-select-tree-treenode-disabled'); + }); + + it('should not trigger onChange when trying to select beyond maxCount', () => { + const handleChange = jest.fn(); + const { container } = renderTreeSelect({ onChange: handleChange }); + + selectOptions(container, ['0 label', '1 label']); + expect(handleChange).toHaveBeenCalledTimes(2); + + // Try selecting third option + const dropdownList = container.querySelector('.rc-tree-select-dropdown') as HTMLElement; + fireEvent.click(within(dropdownList).getByText('2 label')); + expect(handleChange).toHaveBeenCalledTimes(2); // Should not increase + }); + + it('should not affect deselection operations when maxCount is reached', () => { + const handleChange = jest.fn(); + const { container } = renderTreeSelect({ onChange: handleChange }); + + selectOptions(container, ['0 label', '1 label']); + expect(handleChange).toHaveBeenCalledTimes(2); + + // Deselect first option + const dropdownList = container.querySelector('.rc-tree-select-dropdown') as HTMLElement; + fireEvent.click(within(dropdownList).getByText('0 label')); + expect(handleChange).toHaveBeenCalledTimes(3); + + // Should be able to select third option + fireEvent.click(within(dropdownList).getByText('2 label')); + expect(handleChange).toHaveBeenCalledTimes(4); + }); + + it('should not allow any selection when maxCount is 0', () => { + const handleChange = jest.fn(); + const { container } = renderTreeSelect({ maxCount: 0, onChange: handleChange }); + + selectOptions(container, ['0 label', '1 label']); + expect(handleChange).not.toHaveBeenCalled(); + }); + + it('should not limit selection when maxCount is greater than number of options', () => { + const handleChange = jest.fn(); + const { container } = renderTreeSelect({ maxCount: 5, onChange: handleChange }); + + selectOptions(container, ['0 label', '1 label', '2 label', '3 label']); + expect(handleChange).toHaveBeenCalledTimes(4); + }); + + it('should respect maxCount when checking parent node in treeCheckable mode', () => { + const treeData = [ + { + key: '0', + value: '0', + title: 'parent', + children: [ + { key: '0-0', value: '0-0', title: 'child 1' }, + { key: '0-1', value: '0-1', title: 'child 2' }, + { key: '0-2', value: '0-2', title: 'child 3' }, + ], + }, + ]; + + const handleChange = jest.fn(); + const { container } = render( + , + ); + + // Try to check parent node which would select all children + const checkbox = container.querySelector('.rc-tree-select-tree-checkbox'); + fireEvent.click(checkbox); + + // onChange should not be called since it would exceed maxCount + expect(handleChange).not.toHaveBeenCalled(); + + // Parent node should still be unchecked + expect(checkbox).not.toHaveClass('rc-tree-select-tree-checkbox-checked'); + }); +}); + +describe('TreeSelect.maxCount keyboard operations', () => { + const treeData = [ + { key: '0', value: '0', title: '0 label' }, + { key: '1', value: '1', title: '1 label' }, + { key: '2', value: '2', title: '2 label' }, + ]; + + it('keyboard operations should not exceed maxCount limit', () => { + const onSelect = jest.fn(); + const { container } = render( + , + ); + + const input = container.querySelector('input'); + + keyDown(input, KeyCode.ENTER); + keyUp(input, KeyCode.ENTER); + + expect(onSelect).toHaveBeenCalledWith('0', expect.anything()); + + keyDown(input, KeyCode.DOWN); + keyDown(input, KeyCode.ENTER); + keyUp(input, KeyCode.ENTER); + + expect(onSelect).toHaveBeenCalledWith('1', expect.anything()); + + keyDown(input, KeyCode.DOWN); + keyDown(input, KeyCode.ENTER); + keyUp(input, KeyCode.ENTER); + }); + + it('when maxCount is reached, the option should be disabled', () => { + const { container } = render( + , + ); + + // verify that the third option is disabled + expect(container.querySelector('.rc-tree-select-tree-treenode-disabled')?.textContent).toBe( + '2 label', + ); + }); + + it('should be able to unselect after reaching maxCount', () => { + const { container } = render( + , + ); + + const input = container.querySelector('input'); + + // cancel first selection + keyDown(input, KeyCode.ENTER); + keyUp(input, KeyCode.ENTER); + + // verify only two options are selected + expect(container.querySelectorAll('.rc-tree-select-tree-treenode-selected')).toHaveLength(2); + }); +}); + +describe('TreeSelect.maxCount with different strategies', () => { + const treeData = [ + { + key: '0', + value: '0', + title: 'parent', + children: [ + { key: '0-0', value: '0-0', title: 'child 1' }, + { key: '0-1', value: '0-1', title: 'child 2' }, + { key: '0-2', value: '0-2', title: 'child 3' }, + ], + }, + ]; + + it('should respect maxCount with SHOW_PARENT strategy', () => { + const handleChange = jest.fn(); + const { container } = render( + , + ); + + // Select parent node - should work as it only shows as one option + const parentCheckbox = within(container).getByText('parent'); + fireEvent.click(parentCheckbox); + expect(handleChange).toHaveBeenCalledTimes(1); + }); + + it('should respect maxCount with SHOW_CHILD strategy', () => { + const handleChange = jest.fn(); + const { container } = render( + , + ); + + // Select parent node - should not work as it would show three children + const parentCheckbox = within(container).getByText('parent'); + fireEvent.click(parentCheckbox); + expect(handleChange).not.toHaveBeenCalled(); + + // Select individual children - should work until maxCount + const childCheckboxes = within(container).getAllByText(/child/); + fireEvent.click(childCheckboxes[0]); // first child + fireEvent.click(childCheckboxes[1]); // second child + expect(handleChange).toHaveBeenCalledTimes(2); + + // Try to select third child - should not work + 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', () => { + const treeData = [ + { + key: '0', + value: '0', + title: 'parent', + children: [ + { key: '0-0', value: '0-0', title: 'child 1' }, + { key: '0-1', value: '0-1', title: 'child 2' }, + ], + }, + ]; + + it('should count parent and children separately when treeCheckStrictly is true', () => { + const handleChange = jest.fn(); + const { container } = render( + , + ); + + // Select parent and one child - should work as they are counted separately + const parentCheckbox = within(container).getByText('parent'); + const checkboxes = within(container).getAllByText(/child/); + fireEvent.click(parentCheckbox); + fireEvent.click(checkboxes[0]); // first child + expect(handleChange).toHaveBeenCalledTimes(2); + + // Try to select second child - should not work as maxCount is reached + fireEvent.click(checkboxes[1]); + expect(handleChange).toHaveBeenCalledTimes(2); + }); + + it('should allow deselecting when maxCount is reached', () => { + const handleChange = jest.fn(); + const { container } = render( + , + ); + + const parentCheckbox = within(container).getByText('parent'); + const checkboxes = within(container).getAllByText(/child/); + + // Select parent and first child + fireEvent.click(parentCheckbox); + fireEvent.click(checkboxes[0]); + expect(handleChange).toHaveBeenCalledTimes(2); + + // Deselect parent + fireEvent.click(parentCheckbox); + expect(handleChange).toHaveBeenCalledTimes(3); + + // Now should be able to select second child + fireEvent.click(checkboxes[1]); + expect(handleChange).toHaveBeenCalledTimes(4); + }); +}); diff --git a/tests/Select.multiple.spec.js b/tests/Select.multiple.spec.js index eba8a620..5f2cebca 100644 --- a/tests/Select.multiple.spec.js +++ b/tests/Select.multiple.spec.js @@ -1,5 +1,5 @@ /* eslint-disable no-undef */ -import { render } from '@testing-library/react'; +import { render, fireEvent, within } from '@testing-library/react'; import { mount } from 'enzyme'; import KeyCode from 'rc-util/lib/KeyCode'; import React from 'react'; @@ -32,7 +32,10 @@ describe('TreeSelect.multiple', () => { it('remove by backspace key', () => { const wrapper = mount(createSelect({ defaultValue: ['0', '1'] })); - wrapper.find('input').first().simulate('keyDown', { which: KeyCode.BACKSPACE, key: 'Backspace' }); + wrapper + .find('input') + .first() + .simulate('keyDown', { which: KeyCode.BACKSPACE, key: 'Backspace' }); expect(wrapper.getSelection()).toHaveLength(1); expect(wrapper.getSelection(0).text()).toBe('label0'); }); @@ -59,9 +62,15 @@ describe('TreeSelect.multiple', () => { } } const wrapper = mount(); - wrapper.find('input').first().simulate('keyDown', { which: KeyCode.BACKSPACE, key: 'Backspace' }); + wrapper + .find('input') + .first() + .simulate('keyDown', { which: KeyCode.BACKSPACE, key: 'Backspace' }); wrapper.selectNode(1); - wrapper.find('input').first().simulate('keyDown', { which: KeyCode.BACKSPACE, key: 'Backspace' }); + wrapper + .find('input') + .first() + .simulate('keyDown', { which: KeyCode.BACKSPACE, key: 'Backspace' }); expect(wrapper.getSelection()).toHaveLength(1); expect(wrapper.getSelection(0).text()).toBe('label0'); }); @@ -337,9 +346,7 @@ describe('TreeSelect.multiple', () => { />, ); - const values = Array.from( - container.querySelectorAll('.rc-tree-select-selection-item-content'), - ); //.map(ele => ele.textContent); + const values = Array.from(container.querySelectorAll('.rc-tree-select-selection-item-content')); //.map(ele => ele.textContent); expect(values).toHaveLength(0); @@ -347,5 +354,4 @@ describe('TreeSelect.multiple', () => { expect(placeholder).toBeTruthy(); expect(placeholder.textContent).toBe('Fake placeholder'); }); - }); diff --git a/tests/__snapshots__/Select.checkable.spec.tsx.snap b/tests/__snapshots__/Select.checkable.spec.tsx.snap index d13b495b..7ee92e82 100644 --- a/tests/__snapshots__/Select.checkable.spec.tsx.snap +++ b/tests/__snapshots__/Select.checkable.spec.tsx.snap @@ -142,7 +142,6 @@ exports[`TreeSelect.checkable uncheck remove by selector not treeCheckStrictly 1