Skip to content

Commit

Permalink
[feat]: add a gio-cascader component (#469)
Browse files Browse the repository at this point in the history
* feat: wip cascader

* feat: 级联选择器

* fix: classname typo

* chore: remove useless interface key

* feat: wip cascader

* feat: 级联选择器

* fix: classname typo

* chore: remove useless interface key

* feat: all features now works well

* chore: delete file

* chore: something update

* chore: 小优化

* test: write test cases

* test: write test cases

* test: write test cases

* docs: add docs

* chore: rewrite test case

* chore: 适配依赖改动

* chore: remove rc-dropdown dependency

* chore: 修复类型错误引发的打包问题

* chore: 修复类型错误引发的打包问题

* chore: 修复意外改动

* chore: something update

* style: update

* chore: set default placeholder

* chore: 小优化

* fix: revert alert less

* fix: menu 改为绝对定位,处理beforeSelect reject 的情况

* docs: fix typo
  • Loading branch information
iahu authored Nov 14, 2020
1 parent 58ecd5b commit bf59a0e
Show file tree
Hide file tree
Showing 25 changed files with 1,461 additions and 21 deletions.
1 change: 1 addition & 0 deletions packages/components/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ module.exports = {
// registers babel.config.js with jest
transform: {
'^.+\\.js$': 'babel-jest',
'^.+\\.tsx?$': 'babel-jest',
},

// explicitly include any node libs using ESM modules
Expand Down
182 changes: 182 additions & 0 deletions packages/components/src/components/cascader/__test__/cascader.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { act } from 'react-dom/test-utils';
import { mount } from 'enzyme';
import { waitFor } from '@testing-library/react';
import React from 'react';

import { NodeData } from '../menu-item';
import Cascader from '..';

const menu = [
{ label: 'a', value: 'a' },
{ label: 'b', value: 'b', children: [{ label: 'b-1', value: 'b-1' }] },
];

describe('<Cascader />', () => {
it('should render a DOM', () => {
const wrapper = mount(<Cascader className="test-cls" title="bar" placeholder="title ph" />);
expect(wrapper.find('.gio-cascader')).toHaveLength(1);

expect(wrapper.find('.gio-cascader.test-cls')).toHaveLength(1);

expect(wrapper.find('.gio-cascader-title input').getElement().props.value).toBe('bar');

expect(wrapper.find('.gio-cascader-title input').getElement().props.placeholder).toBe('title ph');

wrapper.setProps({ prefixCls: 'foo' });
expect(wrapper.find('.foo')).toHaveLength(1);
});

it('should popup a searchable menu overlayer', async () => {
const map = {} as { [key: string]: EventListenerOrEventListenerObject };
document.addEventListener = jest.fn((event, cb) => {
map[event] = cb;
});
const wrapper = mount(<Cascader visible keyword="a" searchPlaceholder="search" dataSource={menu} />);

expect(document.querySelectorAll('.gio-dropdown.gio-cascader-dropdown')).toHaveLength(1);
expect(wrapper.find('.cascader-menu-header input').getElement().props.value).toBe('a');
expect(wrapper.find('.cascader-menu-list').text()).toBe('a');

act(() => {
wrapper.setProps({ keyword: 'b' });
});
await waitFor(() => {
expect(wrapper.find('.cascader-menu-list').text()).toBe('b');
});
act(() => {
wrapper.setProps({ keyword: '' });
});
await waitFor(() => {
expect(wrapper.find('.cascader-menu-list').text()).toBe('ab');
});

act(() => {
(map.click as any)({
target: { classList: { contains: (cls: string) => cls === 'gio-cascader-dropdown' } },
});
});

await waitFor(() => {
expect(document.querySelectorAll('.gio-dropdown-hidden')).toHaveLength(1);
});
});

it('should render sub-menu', async () => {
const dataSource = [{ label: 'a', value: 1, children: [] as NodeData[] }];
const wrapper = mount(<Cascader dataSource={dataSource} visible trigger="click" />);

act(() => {
wrapper.find('.cascader-menu-item .cascader-menu-item-inner').simulate('click', { currentTarget: {} });
});
await waitFor(() => {
expect(wrapper.find('.cascader-menu-list').text()).toEqual('a');
});

wrapper.setProps({ dataSource: [{ label: 'a', value: 1, children: [{ label: 'b', value: 2 }] }] });
act(() => {
wrapper.find('.cascader-menu-item .cascader-menu-item-inner').at(0).simulate('click', { currentTarget: {} });
});

await waitFor(() => {
expect(wrapper.find('.cascader-menu-list').text()).toEqual('ab');
});
});

it('should select a value', async () => {
const onSelect = jest.fn();
const wrapper = mount(<Cascader visible dataSource={menu} onSelect={onSelect} trigger="click" />);

act(() => {
wrapper.find('.cascader-menu-item .cascader-menu-item-inner').at(0).simulate('click', { currentTarget: {} });
});

await waitFor(() => {
expect(onSelect).toBeCalled();
});

wrapper.setProps({ selectAny: true });
act(() => {
wrapper.find('.cascader-menu-item .cascader-menu-item-inner').at(1).simulate('keyup', { key: 'Enter' });
});
await waitFor(() => {
expect((wrapper.find('.gio-cascader-title input').getDOMNode() as HTMLInputElement).value).toEqual('b');
});
});

it('can trigger a sub-menu', () => {
const wrapper = mount(<Cascader visible dataSource={menu} trigger="click" />);

act(() => {
wrapper
.find('.cascader-menu-item .cascader-menu-item-inner')
.at(1)
.simulate('click', { currentTarget: { offsetLeft: 0, offsetTop: 0 } });
});
waitFor(() => {
expect(wrapper.find('.cascader-menu-list').text()).toBe('abb-1');
});
});

it('can use keyboard to trigger sub-menu', () => {
const wrapper = mount(<Cascader visible dataSource={menu} />);

act(() => {
wrapper.find('.cascader-menu-item .cascader-menu-item-inner').at(1).simulate('keydown', { key: ' ' });
wrapper.find('.cascader-menu-item .cascader-menu-item-inner').at(1).simulate('keyup', { key: ' ' });
});
waitFor(() => {
expect(wrapper.find('.cascader-menu-list').text()).toBe('abb-1');
});
});

it('can deep search a word', async () => {
const dataSource = [{ label: 'foo', value: 1, children: [{ label: 'bar', value: 2 }] }];
const wrapper = mount(<Cascader visible deepSearch dataSource={dataSource} trigger="click" />);

expect(wrapper.find('.cascader-menu-list').text()).toEqual('foo');

act(() => {
wrapper.find('.cascader-menu-header input').simulate('change', { target: { value: 'f' } });
});
await waitFor(() => {
expect(wrapper.find('.cascader-menu-list').text()).toEqual('foo');
});

// deep search childNode
act(() => {
wrapper.find('.cascader-menu-header input').simulate('change', { target: { value: 'b' } });
});
await waitFor(() => {
expect(wrapper.find('.cascader-menu-list').text()).toEqual('foo');
});

// open a sub-menu
act(() => {
wrapper.find('.cascader-menu-item .cascader-menu-item-inner').at(0).simulate('click', { currentTarget: {} });
});
await waitFor(() => {
expect(wrapper.find('.cascader-menu-list').text()).toEqual('foobar');
});
});

it('can triggered by mouseEnter', async () => {
const wrapper = mount(<Cascader trigger="hover" dataSource={menu} visible />);

act(() => {
wrapper.find('.cascader-menu-item .cascader-menu-item-inner').at(1).simulate('mouseEnter', { currentTarget: {} });
});

await waitFor(() => {
// expect(wrapper.find('.cascader-menu-item')).toHaveLength(3);
expect(wrapper.find('.cascader-menu-list').text()).toEqual('abb-1');
});
});

it('can render menu-item by user', () => {
const wrapper = mount(
<Cascader dataSource={menu} visible onRender={(t: NodeData) => <span className="custom-item">{t.label}</span>} />
);

expect(wrapper.find('.custom-item')).toHaveLength(2);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { NodeData } from '../menu-item';
import { isHit, dataFilter } from '../helper';

describe('isHit', () => {
it('should match word', () => {
const label = 'hello test';
expect(isHit(label, '', false)).toBeTruthy();
expect(isHit(label, 'hello', false)).toBeTruthy();
expect(isHit(label, 'xml', false)).toBeFalsy();
});
});

describe('dataFilter', () => {
it('should match word by a parttern', () => {
const data = [
{ label: 'hello', value: 1 },
{ label: 'nihao', value: 2, children: [] as NodeData[] },
];
expect(dataFilter(data, (null as unknown) as RegExp, true)).toHaveLength(2);
expect(dataFilter(data, /h/i, true)).toHaveLength(2);
expect(dataFilter(data, /hello/i, true)).toHaveLength(1);
});
});
21 changes: 21 additions & 0 deletions packages/components/src/components/cascader/__test__/menu.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { mount } from 'enzyme';
import React from 'react';

import { NodeData } from '../menu-item';
import Menu from '../menu';

describe('<Menu />', () => {
it('should render menu-item', async () => {
const menu = [{ label: 'a', value: 1 }];
const wrapper = mount(<Menu dataSource={menu} />);

expect(wrapper.find('.cascader-menu .cascader-menu-item')).toHaveLength(1);
});

it('should ignore empty node data', async () => {
const menu = [] as NodeData[];
const wrapper = mount(<Menu depth={1} dataSource={menu} />);

expect(wrapper.find('.cascader-menu')).toHaveLength(0);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { mount } from 'enzyme';
import React from 'react';
import { act } from 'react-dom/test-utils';
import SearchBar from '../search-bar';

describe('<SearchBar />', () => {
it('can input', async () => {
const wrapper = mount(<SearchBar />);
act(() => {
wrapper.find('input').simulate('change', { target: { value: '123' } });
});

expect((wrapper.find('input').getDOMNode() as HTMLInputElement).value).toBe('123');

wrapper.setProps({ lazySearch: true });

act(() => {
wrapper.find('input').simulate('change', { target: { value: '234' } });
wrapper.find('input').simulate('keyup', { key: 'Enter' });
});
expect((wrapper.find('input').getDOMNode() as HTMLInputElement).value).toBe('234');
});

it('should be lazy change', () => {
const fn = jest.fn();
const wrapper = mount(<SearchBar lazySearch onSearch={fn} />);

act(() => {
wrapper.find('input').simulate('change', { target: { value: '1' } });
});
expect(fn).not.toBeCalled();

act(() => {
wrapper.find('input').simulate('keyup', { key: 'Enter' });
});
expect(fn).toBeCalled();
});
});
59 changes: 59 additions & 0 deletions packages/components/src/components/cascader/helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useEffect, useState } from 'react';
import isEmpty from 'lodash/isEmpty';

import { NodeData } from './menu-item';

export const withPrefix = (prefix?: string) => (value?: string, sep = '-') => {
return [prefix, value].filter((s) => !!s).join(sep);
};

export const makeSearchParttern = (word = '', ignoreCase = true) => {
// 过滤掉正则表达式的特殊字符
const escapedWord = word.replace(/([()[\]{}.?+*^$|\\<>!])/g, '\\$1');
return new RegExp(escapedWord, ignoreCase ? 'gi' : 'g');
};

export const isHit = (label: string, word: string, ignoreCase = true) => {
if (!word) {
return true;
}
return label.search(makeSearchParttern(word, ignoreCase)) >= 0;
};

export const useDynamicData = <T>(originDataSource: T) => {
const [dataSource, setDataSource] = useState(originDataSource);

useEffect(() => {
setDataSource(originDataSource);
}, [originDataSource]);

return [dataSource, setDataSource] as const;
};

export const toInt = (s: string | React.ReactText) => (s ? parseInt(s as string, 10) : 0);

export const deepFilter = (d: NodeData, parttern: RegExp) => {
const currentMatch = d.label.match(parttern);
if (currentMatch) {
return true;
}
if (isEmpty(d.children)) {
return false;
}

const someChildMatch = d.children?.some((c) => deepFilter(c, parttern)) as boolean;

return someChildMatch;
};

export const dataFilter = (data: NodeData[], parttern: RegExp, deepSearch = false) => {
if (!parttern) {
return data;
}

if (!deepSearch) {
return data.filter((d) => d.label.match(parttern));
}

return data.filter((d) => deepFilter(d, parttern));
};
Loading

1 comment on commit bf59a0e

@vercel
Copy link

@vercel vercel bot commented on bf59a0e Nov 14, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.