Skip to content

Commit

Permalink
fix(cascader): fix click clear to trigger onChange multiple times (#2736
Browse files Browse the repository at this point in the history
)

* fix(cascader): fix click clear to trigger onChange multiple times

n

* chore: update _common

* feat(cascader): add valueDisplay and label api

* chore(cascader): update _common

* test(cascader): update test snap

* test(cascader): update test example

* fix(Cascader): add watch options to checked

* test: update test

* test: update test snap

* perf: optimization code

* test(cascader): update test example

---------

Co-authored-by: Heising <heising@travelconnect.cn>
  • Loading branch information
HaixingOoO and Heising authored Feb 4, 2024
1 parent 3594baf commit 1718fbe
Show file tree
Hide file tree
Showing 9 changed files with 805 additions and 23 deletions.
2 changes: 1 addition & 1 deletion src/_common
35 changes: 33 additions & 2 deletions src/cascader/Cascader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ import { useCascaderContext } from './hooks';
import { cascaderDefaultProps } from './defaultProps';
import { StyledProps } from '../common';
import useDefaultProps from '../hooks/useDefaultProps';
import parseTNode, { parseContentTNode } from '../_util/parseTNode';

export interface CascaderProps extends TdCascaderProps, StyledProps {}

const Cascader: React.FC<CascaderProps> = (originalProps) => {
const props = useDefaultProps<CascaderProps>(originalProps, cascaderDefaultProps);

/**
* global user props, config, data
*/
Expand All @@ -31,7 +33,7 @@ const Cascader: React.FC<CascaderProps> = (originalProps) => {
const COMPONENT_NAME = `${classPrefix}-cascader`;

// 拿到全局状态的上下文
const { cascaderContext, isFilterable } = useCascaderContext(props);
const { cascaderContext, isFilterable, innerValue, getCascaderItems } = useCascaderContext(props);

const displayValue = useMemo(
() => (props.multiple ? getMultipleContent(cascaderContext) : getSingleContent(cascaderContext)),
Expand Down Expand Up @@ -62,6 +64,33 @@ const Cascader: React.FC<CascaderProps> = (originalProps) => {
);
};

// render label
const renderLabel = () => {
const label = parseTNode(props.label);
if (props.multiple) return label;
if (!label) return null;
return <div className={`${classPrefix}-tag-input__prefix`}>{label}</div>;
};

// render valueDisplay
const valueDisplayParams = useMemo(() => {
const arrayValue = innerValue instanceof Array ? innerValue : [innerValue];
const displayValue =
props.multiple && props.minCollapsedNum ? arrayValue.slice(0, props.minCollapsedNum) : innerValue;
const options = getCascaderItems(arrayValue);

return {
value: innerValue,
selectedOptions: options,
onClose: (index: number) => {
handleRemoveTagEffect(cascaderContext, index, props.onRemove);
},
displayValue,
};
}, [cascaderContext, innerValue, props.multiple, props.minCollapsedNum, props.onRemove, getCascaderItems]);

const renderValueDisplay = () => parseContentTNode(props.valueDisplay, valueDisplayParams);

const { setVisible, visible, inputVal, setInputVal } = cascaderContext;
return (
<SelectInput
Expand All @@ -81,6 +110,8 @@ const Cascader: React.FC<CascaderProps> = (originalProps) => {
disabled={props.disabled}
status={props.status}
tips={props.tips}
label={renderLabel()}
valueDisplay={renderValueDisplay()}
suffix={props.suffix}
suffixIcon={renderSuffixIcon()}
popupProps={{
Expand All @@ -102,7 +133,7 @@ const Cascader: React.FC<CascaderProps> = (originalProps) => {
props?.selectInputProps?.onInputChange?.(value, ctx);
}}
onTagChange={(val: TagInputValue, ctx) => {
if (ctx.trigger === 'enter') {
if (ctx.trigger === 'enter' || ctx.trigger === 'clear') {
return;
}
handleRemoveTagEffect(cascaderContext, ctx.index, props.onRemove);
Expand Down
139 changes: 139 additions & 0 deletions src/cascader/__tests__/cascader.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { render, fireEvent, mockTimeout, vi, userEvent, mockDelay } from '@test/utils';
import React, { useState } from 'react';
import Cascader, { CascaderPanel } from '../index';
import Tag from '../../tag';

const options = [
{
Expand Down Expand Up @@ -32,6 +33,49 @@ const options = [
value: '2',
},
];

const AVATAR = 'https://tdesign.gtimg.com/site/avatar.jpg';

const optionsData = [
{
label: '选项一',
value: '1',
children: [
{
label: '子选项一',
value: '1.1',
avatar: AVATAR,
},
{
label: '子选项二',
value: '1.2',
avatar: AVATAR,
},
{
label: '子选项三',
value: '1.3',
avatar: AVATAR,
},
],
},
{
label: '选项二',
value: '2',
children: [
{
label: '子选项一',
value: '2.1',
avatar: AVATAR,
},
{
label: '子选项二',
value: '2.2',
avatar: AVATAR,
},
],
},
];

const popupSelector = '.t-popup';

// TODO
Expand Down Expand Up @@ -237,6 +281,101 @@ describe('Cascader 组件测试', () => {
await mockTimeout(() => fireEvent.click(getByText(labelText)));
expect(document.querySelector('.t-input__inner')).toHaveValue(labelText);
});

test('multiple Clicking the clear all button only triggers onChange once', async () => {
const onChange = vi.fn();
const onRemove = vi.fn();
const { container } = render(
<Cascader value={['1.1']} options={optionsData} multiple onChange={onChange} onRemove={onRemove} clearable />,
);
fireEvent.mouseEnter(container.querySelector('.t-input'));
await mockDelay();
// 点击清除按钮只触发一次onChange,不触发onRemove
expect(container.querySelector('.t-tag-input__suffix-clear')).toBeTruthy();
fireEvent.click(container.querySelector('.t-tag-input__suffix-clear'));
expect(onChange).toHaveBeenCalledTimes(1);
expect(onRemove).toHaveBeenCalledTimes(0);
// 点击tag onRemove,触发一次onRemove,触发两次onChange
fireEvent.click(container.querySelectorAll('.t-tag__icon-close')[0]);
expect(onRemove).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledTimes(2);
});

test('render label', async () => {
const label = '单选:';
const { getByText } = render(<Cascader label={label} value={'1.1'} options={optionsData} clearable />);
expect(getByText(label)).toBeInTheDocument();
});

test('render single valueDisplay', async () => {
const SingleValueDisplay = ({ value, selectedOptions }: any) =>
value && (
<div className="valueDisplay">
<img
src={selectedOptions?.[0]?.avatar}
style={{
width: '16px',
height: '16px',
marginTop: '2px',
verticalAlign: '-4px',
marginRight: '4px',
}}
/>
<span>{selectedOptions?.[0]?.label}</span>
<span>({value})</span>
</div>
);
const { container } = render(
<Cascader
value={'2.2'}
label="单选:"
options={optionsData}
valueDisplay={<SingleValueDisplay />}
clearable
></Cascader>,
);
expect(container.querySelector('.valueDisplay')).toBeInTheDocument();
});

test('render multiple valueDisplay', async () => {
const MultipleValueDisplay = ({ value, selectedOptions, onClose }: any) =>
value && value.length
? selectedOptions.map((option, index) => (
<Tag key={option.value} closable onClose={() => onClose(index)}>
<img
src={option.avatar}
style={{
width: '16px',
height: '16px',
marginTop: '2px',
verticalAlign: '-4px',
marginRight: '4px',
}}
/>
<span>{option.label}</span>
<span className="options-value">({option.value})</span>
</Tag>
))
: null;
const { container } = render(
<Cascader
value={['1.3', '2.1', '2.2']}
label="多选:"
options={optionsData}
valueDisplay={<MultipleValueDisplay />}
multiple
clearable
></Cascader>,
);
const optionsValue = container.querySelectorAll('.options-value');
expect(optionsValue[0]).toHaveTextContent('(1.3)');
expect(optionsValue[1]).toHaveTextContent('(2.1)');
expect(optionsValue[2]).toHaveTextContent('(2.2)');
fireEvent.click(container.querySelector('.t-input'));
await mockDelay();
expect(document.querySelectorAll('.t-is-checked')).toHaveLength(2);
expect(document.querySelectorAll('.t-is-checked')[1].children[0]).toHaveAttribute('checked');
});
});

describe('Cascader Panel 组件测试', () => {
Expand Down
111 changes: 111 additions & 0 deletions src/cascader/_example/value-display.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import React from 'react';
import { Space, Cascader, Tag } from 'tdesign-react';

const SingleValueDisplay = ({ value, selectedOptions }) =>
value && (
<div>
<img
src={selectedOptions?.[0]?.avatar}
style={{
width: '16px',
height: '16px',
marginTop: '2px',
verticalAlign: '-4px',
marginRight: '4px',
}}
/>
<span>{selectedOptions?.[0]?.label}</span>
<span>({value})</span>
</div>
);

const MultipleValueDisplay = ({ value, selectedOptions, onClose }) =>
value && value.length
? selectedOptions.map((option, index) => (
<Tag key={option.value} closable onClose={() => onClose(index)}>
<img
src={option.avatar}
style={{
width: '16px',
height: '16px',
marginTop: '2px',
verticalAlign: '-4px',
marginRight: '4px',
}}
/>
<span>{option.label}</span>
<span>({option.value})</span>
</Tag>
))
: null;

export default function Example() {
const [value1, setValue1] = React.useState('2.2');
const [value2, setValue2] = React.useState(['1.3', '2.1', '2.2']);

const AVATAR = 'https://tdesign.gtimg.com/site/avatar.jpg';

const optionsData = [
{
label: '选项一',
value: '1',
children: [
{
label: '子选项一',
value: '1.1',
avatar: AVATAR,
},
{
label: '子选项二',
value: '1.2',
avatar: AVATAR,
},
{
label: '子选项三',
value: '1.3',
avatar: AVATAR,
},
],
},
{
label: '选项二',
value: '2',
children: [
{
label: '子选项一',
value: '2.1',
avatar: AVATAR,
},
{
label: '子选项二',
value: '2.2',
avatar: AVATAR,
},
],
},
];

return (
<Space direction="vertical">
<Cascader
value={value1}
label="单选:"
options={optionsData}
valueDisplay={<SingleValueDisplay />}
onChange={(val) => setValue1(val)}
clearable
></Cascader>

<Cascader
value={value2}
label="多选:"
options={optionsData}
valueDisplay={<MultipleValueDisplay />}
onChange={(val) => setValue2(val)}
clearable
multiple
style={{ width: '500px' }}
></Cascader>
</Space>
);
}
27 changes: 13 additions & 14 deletions src/cascader/core/effect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,22 +143,21 @@ export function handleRemoveTagEffect(
const newValue = cloneDeep(value) as [];
const res = newValue.splice(index, 1);
const node = treeStore.getNodes(res[0])[0];

setValue(newValue, 'uncheck', node.getModel());

const checked = node.setChecked(!node.isChecked());
// 处理不同数据类型
const resValue =
valueType === 'single'
? checked
: checked.map((val) =>
treeStore
.getNode(val)
.getPath()
.map((item) => item.value),
);

setValue(resValue, 'uncheck', node.getModel());
if (valueType === 'single') {
setValue(newValue, 'uncheck', node.getModel());
} else {
// 处理不同数据类型
const resValue = checked.map((val) =>
treeStore
.getNode(val)
.getPath()
.map((item) => item.value),
);
setValue(resValue, 'uncheck', node.getModel());
}

if (isFunction(onRemove)) {
onRemove({ value: checked, node: node as any });
}
Expand Down
Loading

0 comments on commit 1718fbe

Please sign in to comment.