From c1b99408f09e2be3ea3bd9c302d0e2e51b2284b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BC=80=E6=9C=97?= Date: Mon, 2 Sep 2019 17:02:18 +0800 Subject: [PATCH] feat: VirtualSelectBox (#65) * feat: VirtualSelectBox * add test * format code * add test * fix * virtual use pan type --- .storybook/config.ts | 2 +- .../content/components/virtual-select-box.mdx | 163 +++++++++ src/components/DropdownButton/index.test.tsx | 4 +- src/components/DropdownButton/index.tsx | 8 +- src/components/Icon/style.scss | 8 +- src/components/SubMenu/index.test.tsx | 2 +- src/components/VirtualList/index.test.tsx | 22 +- src/components/VirtualList/index.tsx | 24 +- .../VirtualSelectBox/index.test.tsx | 108 ++++++ src/components/VirtualSelectBox/index.tsx | 334 ++++++++++++++++++ src/components/VirtualSelectBox/style.scss | 104 ++++++ src/components/index.tsx | 14 +- src/interface.tsx | 86 +++-- src/utils/__tests__/elastic-query.test.ts | 16 + src/utils/elastic-query.ts | 61 ++++ .../utils/get-mock-datas.ts | 9 +- src/utils/index.ts | 6 + src/utils/sleep.ts | 1 + stories/VirtualSelectBox.stories.tsx | 104 ++++++ stories/demos/AsyncVirtualList.tsx | 10 +- 20 files changed, 1021 insertions(+), 65 deletions(-) create mode 100644 docs/content/components/virtual-select-box.mdx create mode 100644 src/components/VirtualSelectBox/index.test.tsx create mode 100644 src/components/VirtualSelectBox/index.tsx create mode 100644 src/components/VirtualSelectBox/style.scss create mode 100644 src/utils/__tests__/elastic-query.test.ts create mode 100644 src/utils/elastic-query.ts rename stories/utils/getMockDatas.ts => src/utils/get-mock-datas.ts (60%) create mode 100644 src/utils/sleep.ts create mode 100644 stories/VirtualSelectBox.stories.tsx diff --git a/.storybook/config.ts b/.storybook/config.ts index 4bda3e91..130dff28 100644 --- a/.storybook/config.ts +++ b/.storybook/config.ts @@ -5,7 +5,7 @@ import './style.scss'; addDecorator ( withOptions ({ - name: 'wizard ui', + name: 'WIZARD UI', url: 'https://github.com/xsky-fe/wizard-ui', sidebarAnimations: true, // stories 根据字母,数组小到大排序,根据执行顺序排序 diff --git a/docs/content/components/virtual-select-box.mdx b/docs/content/components/virtual-select-box.mdx new file mode 100644 index 00000000..cc927860 --- /dev/null +++ b/docs/content/components/virtual-select-box.mdx @@ -0,0 +1,163 @@ +--- +title: VirtualSelectBox 滚动下拉 +date: 2019-08-28 +author: wangkailang +--- +在大量异步数据中选择需要的数据,支持滚动加载和搜索。 + +## 限制条件 + +异步 `API` 支持获取部分数据(分页),query 的格式如下: +```js isShow +{ + // 取 10 条数据 + limit: 10, + // 从第 20 条数据开始取 + offset: 20 +} +``` +表示从第 20 条数据开始取10 条数据。 + +## 基本用法 +- `fetchData` 异步数据 `API`, `Promise` 返回数据结构没有严格要求,通用模拟结构如下: +```js isShow +const fetchData = () => new Promise((resolve, reject) => { + setTimeout(() => { + resolve({ + response: { + resNames: [xxx], + paging: { + totalCount: xxx + } + } + }); + reject({ + error: xxx, + }); + }, time); +}); +``` +- `item` 选中项,允许为空对象 `{}` 或者 `""` + +具体使用: +```js isShow + +``` + +## 代码演示 + +### 空数组 +```jsx +() => { + const limit = 30; + const getEmptyDatas = () => new Promise(resolve => { + setTimeout(() => { + resolve({ + response: { + resNames: [], + paging: { + totalCount: 0 + } + } + }); + }, 500); + }); + async function fetchEmptyDatas(isReloading, dQuery = {}) { + const actionResult = await getEmptyDatas(dQuery); + const items = actionResult.response.resNames; + const totalCount = actionResult.response.paging.totalCount; + const query = { + ...dQuery, + limit, + offset: 0, + }; + return { + query, + items, + totalCount, + } + } + + return ( + + ); +} +``` +### 有数据 +```jsx +() => { + const limit = 30; + const TOTAL = 180; + const getDatas = query => new Promise(resolve => { + setTimeout(() => { + const { limit = 0, offset = 0 } = query; + let rlt = []; + if (offset <= TOTAL) { + const len = Math.min(limit, TOTAL - offset); + for (let i = 0; len - i > 0; i++) { + rlt.push({ id: offset + i, name: `list-${offset + i}` }); + } + } + resolve({ + response: { + resNames: rlt, + paging: { + totalCount: TOTAL + } + } + }); + }, 500); + }); + async function fetchDatas(isReloading, dQuery = {}) { + const actionResult = await getDatas(dQuery); + const items = actionResult.response.resNames; + const totalCount = actionResult.response.paging.totalCount; + const query = { + ...dQuery, + limit, + offset: 0, + }; + return { + query, + items, + totalCount, + } + } + const [item, setItem] = React.useState({ id: 1, name: 'list-1' }); + const onSelect = React.useCallback(async (item) => { + setItem(item); + }, [item, setItem]); + + const [clear, setClear] = React.useState(true); + + return ( +
+
+ setClear(!clear)}> + 允许清除 + +
+ +
+ ) +} +``` + +## API +```jsx previewOnly + +``` + + + diff --git a/src/components/DropdownButton/index.test.tsx b/src/components/DropdownButton/index.test.tsx index 9b394b5d..35bca257 100644 --- a/src/components/DropdownButton/index.test.tsx +++ b/src/components/DropdownButton/index.test.tsx @@ -9,7 +9,7 @@ describe('Dropdown', () => { const classNames = [ '.dropdown.btn-group.btn-group-lg', '.dropdown.btn-group.btn-group-sm', - '.dropdown.btn-group.btn-group-xs' + '.dropdown.btn-group.btn-group-xs', ]; sizes.forEach((size, index) => { const dropdown = mount(); @@ -48,4 +48,4 @@ describe('Dropdown', () => { const dropdown = mount(); expect(dropdown.find('.dropdown-menu.dropdown-menu-right').length).toBe(1); }); -}); \ No newline at end of file +}); diff --git a/src/components/DropdownButton/index.tsx b/src/components/DropdownButton/index.tsx index 860cf1cd..fea029ee 100644 --- a/src/components/DropdownButton/index.tsx +++ b/src/components/DropdownButton/index.tsx @@ -2,7 +2,11 @@ import * as React from 'react'; import * as PropTypes from 'prop-types'; import { DropdownButton as BootstrapDropdownButton, MenuItem, ButtonGroup } from 'react-bootstrap'; import SubMenu from '../SubMenu'; -import { DropdownButtonMenuItem, DropdownButtonProps, DefaultDropdownButtonProps } from '../../interface'; +import { + DropdownButtonMenuItem, + DropdownButtonProps, + DefaultDropdownButtonProps, +} from '../../interface'; import { cloneDeep } from 'lodash'; import './style.scss'; @@ -139,7 +143,7 @@ DropdownButton.propTypes = { title: PropTypes.oneOfType([PropTypes.element, PropTypes.string]), }; -const defaultProps: DefaultDropdownButtonProps = { componentClass: ButtonGroup, } +const defaultProps: DefaultDropdownButtonProps = { componentClass: ButtonGroup }; DropdownButton.defaultProps = defaultProps; diff --git a/src/components/Icon/style.scss b/src/components/Icon/style.scss index 00f81ebb..fb0a1f4b 100644 --- a/src/components/Icon/style.scss +++ b/src/components/Icon/style.scss @@ -1,12 +1,12 @@ @import '../../style/variables.scss'; -svg { - &.icon { +.icon { + svg { display: inline-block; stroke-width: 0; stroke: currentColor; fill: currentColor; } &.primary { - color: $purple-normal + color: $purple-normal; } -} \ No newline at end of file +} diff --git a/src/components/SubMenu/index.test.tsx b/src/components/SubMenu/index.test.tsx index f9f9248f..c8ce11f6 100644 --- a/src/components/SubMenu/index.test.tsx +++ b/src/components/SubMenu/index.test.tsx @@ -7,4 +7,4 @@ describe('SubMenu', () => { const subMenu = mount(); expect(subMenu.find('ul.dropdown-menu').length).toBe(1); }); -}); \ No newline at end of file +}); diff --git a/src/components/VirtualList/index.test.tsx b/src/components/VirtualList/index.test.tsx index b1ca9700..5e633ac5 100644 --- a/src/components/VirtualList/index.test.tsx +++ b/src/components/VirtualList/index.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import renderer from 'react-test-renderer'; import VirtualList from './index'; -import { VirtualRowArgs } from '../../interface'; +import { VirtualRowArgs, VirtualItem } from '../../interface'; import AsyncVirtualList from '../../../stories/demos/AsyncVirtualList'; function createNodeMock(element: React.ReactElement) { @@ -16,7 +16,7 @@ function createNodeMock(element: React.ReactElement) { } const snapOptions = { createNodeMock }; -const rowRenderer = (i: VirtualRowArgs) =>
item
; +const rowRenderer = (i: VirtualRowArgs) =>
item
; describe('VirtualList', () => { it('render without crush', () => { @@ -92,21 +92,11 @@ describe('VirtualList', () => { }); it('scrolling equal row height', () => { - const list = renderer - .create( - , - snapOptions, - ) - .toJSON(); + const list = renderer.create(, snapOptions).toJSON(); expect(list).toMatchSnapshot(); - }) + }); it('scrolling dynamic row height', () => { - const list = renderer - .create( - , - snapOptions, - ) - .toJSON(); + const list = renderer.create(, snapOptions).toJSON(); expect(list).toMatchSnapshot(); - }) + }); }); diff --git a/src/components/VirtualList/index.tsx b/src/components/VirtualList/index.tsx index 18bb5bc5..1f41dcc8 100644 --- a/src/components/VirtualList/index.tsx +++ b/src/components/VirtualList/index.tsx @@ -2,7 +2,13 @@ import React from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; import { sum, isFunction } from 'lodash'; -import { VirtualListState, VirtualAnchorItem, VirtualListDefaultProps, VirtualListProps } from '../../interface'; +import { + VirtualListState, + VirtualAnchorItem, + VirtualListDefaultProps, + VirtualListProps, + VirtualItem, +} from '../../interface'; import CSS from 'csstype'; import './style.scss'; @@ -21,7 +27,7 @@ function getHeight(el: HTMLDivElement) { return height + marginTop + marginBottom; } -const defaultProps: VirtualListDefaultProps = { +const defaultProps: VirtualListDefaultProps = { height: '100%', data: [], runwayItems: RUNWAY_ITEMS, @@ -32,7 +38,7 @@ const defaultProps: VirtualListDefaultProps = { debug: true, }; -export default class VirtualList extends React.Component { +export default class VirtualList extends React.Component, VirtualListState> { static propTypes = { /** 行高 */ rowHeight: PropTypes.oneOfType([PropTypes.func, PropTypes.number]).isRequired, @@ -62,7 +68,7 @@ export default class VirtualList extends React.Component) { super(props); this.holder = React.createRef(); this.list = React.createRef(); @@ -117,7 +123,7 @@ export default class VirtualList extends React.Component) { const { isEstimate, debug, data, isReloading } = this.props; const { startIndex } = this.state; @@ -178,7 +184,7 @@ export default class VirtualList extends React.Component { this.handleResize(this.props.data); }; - handleResize = (data: object[], flushCache?: boolean) => { + handleResize = (data: T[], flushCache?: boolean) => { this.recomputeRowHeight(data, flushCache); this.handleScroll(); }; @@ -186,7 +192,7 @@ export default class VirtualList extends React.Component { + recomputeRowHeight = (nextData: T[], flushCache: boolean = true) => { if (flushCache) { this.heightCache = []; } @@ -315,7 +321,7 @@ export default class VirtualList extends React.Component {}; + +const resName = 'list'; +const limit = 30; +function getEmptyDatas(query: Query) { + return getMockDatas(query, 0, resName); +} +function getDatas(query: Query) { + return getMockDatas(query, 180, resName); +} + +const fetchEmptyDatas = async (isReloading: boolean, dQuery: Query = {}) => { + let resNamePlural = `${resName}s`; + const query = { + ...dQuery, + limit, + offset: 0, + }; + const actionResult = await getEmptyDatas(dQuery); + const items = get(actionResult, `response.${resNamePlural}`, []); + const totalCount = get(actionResult, 'response.paging.totalCount'); + return { + query, + items, + totalCount, + } +} +const fetchDatas = async (isReloading: boolean, dQuery: Query = {}) => { + let resNamePlural = `${resName}s`; + const query = { + ...dQuery, + limit, + offset: 0, + }; + const actionResult = await getDatas(dQuery); + const items = get(actionResult, `response.${resNamePlural}`, []); + const totalCount = get(actionResult, 'response.paging.totalCount'); + return { + query, + items, + totalCount, + } +} + +describe('VirtualSelectBox', () => { + it('render with empty data', async () => { + const picker = mount( + , + ); + const node = picker.find('.SelectBox'); + expect(node.length).toBe(1); + node.find('Glyphicon').simulate('click'); + expect(picker.find('.SelectBox__search').exists()).toBeTruthy(); + // 数据加载中 + expect(picker.find('.VirtualList__loader').exists()).toBeTruthy(); + await sleep(500); + picker.update(); + // 暂无数据 + expect(picker.find('.VirtualList__placeholder').exists()).toBeTruthy(); + }); + it('render with async datas', async () => { + type Data = { id?: number; name: string }; + const picker = mount( + + item={{ id: 1, name: `${resName}-1` }} + fetchData={fetchDatas} + onSelect={noOp} + clear + />, + ); + const node = picker.find('.SelectBox'); + // default value + expect( + picker + .find('.SelectBox__btn > span') + .at(0) + .props().children, + ).toBe('list-1'); + expect(picker.find('.icon-close').exists()).toBeTruthy(); + node.find('Glyphicon').simulate('click'); + await sleep(500); + picker.update(); + // 存在 onClick 操作 + expect(picker.find('.VirtualList > .SelectBox__item').at(1).props().onClick).not.toBeUndefined; + // 默认第二个高亮 + expect( + picker + .find('.VirtualList > .SelectBox__item') + .at(1) + .hasClass('active'), + ).toEqual(true); + // 选择第三个 + picker + .find('.VirtualList > .SelectBox__item') + .at(2) + .simulate('click'); + picker.update(); + // 展开自动隐藏 + expect(picker.find('.SelectBox__outer').exists()).toBeFalsy(); + }); +}); diff --git a/src/components/VirtualSelectBox/index.tsx b/src/components/VirtualSelectBox/index.tsx new file mode 100644 index 00000000..867c5f3b --- /dev/null +++ b/src/components/VirtualSelectBox/index.tsx @@ -0,0 +1,334 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Panel, FormControl, Glyphicon } from 'react-bootstrap'; +import { get, debounce, isEmpty } from 'lodash'; +import classNames from 'classnames'; +import VirtualList from '../VirtualList'; +import { + Query, + VirtualRowArgs, + VirtualSelectBoxDefaultProps, + VirtualSelectBoxProps, + VirtualSelectBoxState, + VirtualItem, +} from '../../interface'; +import Icon from '../Icon'; +import './style.scss'; + +const limit = 30; +const maxHeight = 210; +const defaultProps: VirtualSelectBoxDefaultProps = { + rowHeight: 30, + isBtn: true, + disabled: false, + placeholder: '请选择', + query: {}, + defaultItem: {}, +}; + +class VirtualSelectBox extends React.Component, VirtualSelectBoxState> { + static propTypes = { + /** + * 选中资源项 + */ + item: PropTypes.object.isRequired, + /** + * 获取异步数据的函数 + */ + fetchData: PropTypes.func.isRequired, + /** + * 选择函数 + */ + onSelect: PropTypes.func, + /** + * 是否禁用操作 + */ + disabled: PropTypes.bool, + /** + * 每项 select 的行高 + */ + rowHeight: PropTypes.number, + /** + * 格式化 onSelect 输出数据 + */ + formatOption: PropTypes.func, + /** + * 是否使用 button 格式 UI + */ + isBtn: PropTypes.bool, + /** + * 默认展示文案 + */ + placeholder: PropTypes.string, + /** + * async query + */ + query: PropTypes.object, + /** + * 清除 + */ + clear: PropTypes.bool, + }; + static defaultProps = defaultProps; + debounceFetch: () => Promise; + isMount: boolean; + wrapper: React.RefObject; + constructor(props: VirtualSelectBoxProps) { + super(props); + this.debounceFetch = debounce(this.fetchResource, 200).bind(this); + this.toggleMenu = this.toggleMenu.bind(this); + this.handleSearchChange = this.handleSearchChange.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + this.handleClickOutside = this.handleClickOutside.bind(this); + this.renderItem = this.renderItem.bind(this); + this.renderLabel = this.renderLabel.bind(this); + this.wrapper = React.createRef(); + this.isMount = true; + this.state = { + search: '', + items: [], + query: { + ...props.query, + limit, + offset: 0, + }, + isFetching: false, + totalCount: 0, + isOpen: false, + isReloading: false, + }; + } + + componentDidMount() { + this.toggleClickOutsideEvent(true); + this.handleQueryChange(this.state.query); + } + + componentWillUnmount() { + this.isMount = false; + this.toggleClickOutsideEvent(false); + } + + handleQueryChange = async (query: Query) => { + this.setState({ isFetching: true }); + const { fetchData } = this.props; + const { items, totalCount, error } = await fetchData(false, query); + if (error) { + this.setState({ error, isFetching: false }); + } else { + const newState = { + query, + isFetching: false, + totalCount, + }; + this.setState((prevState: VirtualSelectBoxState) => { + return { + ...newState, + items: prevState.items.concat(items), + }; + }); + } + }; + + async fetchResource() { + const { fetchData, query } = this.props; + const { search } = this.state; + this.setState({ isFetching: true, isReloading: true }); + const formats = await fetchData(true, query, search); + this.setState({ + ...formats, + isFetching: false, + isReloading: false, + }); + } + + blockEvent(event: any) { + event.stopPropagation(); + event.preventDefault(); + } + + toggleMenu(isOpen?: boolean) { + this.setState(prevState => ({ + isOpen: typeof isOpen === 'boolean' ? isOpen : !prevState.isOpen, + })); + } + + toggleClickOutsideEvent(enabled: boolean) { + if (enabled) { + if (!window.addEventListener && window['attachEvent']) { + window['attachEvent']('click', this.handleClickOutside); + } else { + window.addEventListener('click', event => this.handleClickOutside(event)); + } + } else { + if (!window.removeEventListener && window['detachEvent']) { + window['detachEvent']('click', this.handleClickOutside); + } else { + window.removeEventListener('click', event => this.handleClickOutside(event)); + } + } + } + + handleClickOutside(event: any) { + // IE when component unmount, element already is null but event still triggered + if (this.wrapper && this.wrapper.current && !this.wrapper.current.contains(event.target)) { + this.toggleMenu(false); + } + } + + handleSearchChange(event: any) { + this.setState({ search: event.target.value }, this.debounceFetch); + } + + handleKeyDown(event: React.KeyboardEvent) { + switch (event.keyCode) { + case 27: // escape + this.toggleMenu(false); + break; + default: + return; + } + this.blockEvent(event); + } + clear = () => { + const { onSelect, defaultItem } = this.props; + if (onSelect) { + onSelect(defaultItem); + } + }; + + renderLabel(item?: T) { + const { placeholder } = this.props; + let nameKey = 'name'; + let title = get(item, nameKey) || placeholder; + + return ( + + {title} + + ); + } + + renderItem({ item, style, index }: VirtualRowArgs) { + if (!item) { + return; + } + const { onSelect, rowHeight, formatOption } = this.props; + const label = this.renderLabel(item); + const activeKey = typeof item === 'object' ? 'item.id' : 'item'; + const activeItem = typeof item === 'object' ? get(item, 'id') : item; + const className = classNames('SelectBox__item text-truncate', { + active: get(this.props, activeKey) === activeItem, + }); + if (onSelect) { + const onClick = (event: React.MouseEvent) => { + this.blockEvent(event); + let formatItem = item; + if (formatOption) { + formatItem = formatOption(item); + } + onSelect(formatItem); + this.setState({ isOpen: false }); + }; + return ( +
+ {label} +
+ ); + } + return ( +
+ {label} +
+ ); + } + + renderSearch() { + return ( + + ); + } + + renderList() { + const { items, query, totalCount, isFetching, isReloading } = this.state; + const { rowHeight = 30 } = this.props; + // 30 是 “没有更多信息” 占据的高度, 只有有值的时候设置才有意义 + const extraHeight = items.length > 0 ? 30 : 0; + let height; + // 重新获取数据时,只保留 “数据加载中” 占用的高度 + if (!items.length || (isFetching && isReloading)) { + height = 30; + } else { + height = Math.min(maxHeight, rowHeight * items.length + extraHeight); + } + + return ( + + ); + } + + renderOuter() { + return ( +
+ {/* + // react-bootstrap 跟 @types/react-bootstrap 不兼容 + // @ts-ignore */} + {this.renderList()} +
+ ); + } + + render() { + const { item, disabled, className, isBtn, clear } = this.props; + const { isOpen } = this.state; + const btnClassName = classNames('SelectBox__btn text-truncate', { + 'is-open': isOpen, + 'btn btn-sm btn-default': isBtn, + }); + const selectBoxClass = classNames('SelectBox', { + disabled: disabled, + [`${className || ''}`]: true, + }); + return ( + this.toggleMenu()} + ref={this.wrapper} + > + + {this.renderLabel(item)} {isBtn && } + {clear && !isEmpty(item) && } + + {isOpen && this.renderOuter()} + + ); + } +} + +export default VirtualSelectBox; diff --git a/src/components/VirtualSelectBox/style.scss b/src/components/VirtualSelectBox/style.scss new file mode 100644 index 00000000..c7320628 --- /dev/null +++ b/src/components/VirtualSelectBox/style.scss @@ -0,0 +1,104 @@ +@import '../../style/variables.scss'; + +.SelectBox { + position: relative; + &.disabled { + pointer-events: none; + cursor: not-allowed; + opacity: 0.65; + } +} + +.SelectBox__btn { + transition: all 0.08s; + &, + &:focus { + border-color: transparent; + background-color: transparent; + } + &.is-open, + &:hover { + background-color: #fff; + border-color: #ccc; + } + max-width: 250px; + padding-right: 40px; + .glyphicon { + position: absolute; + color: $gray-medium-11; + right: 8px; + top: 5px; + } + .icon-close { + position: absolute; + right: 25px; + top: 2px; + color: $gray-medium-11; + &:hover { + color: $purple-normal; + } + cursor: pointer; + svg { + width: 10px; + } + } +} + +.SelectBox__outer { + position: absolute; + z-index: 999; + left: 0; + width: 250px; + /* 仿Github的select-menu */ + border: 1px solid rgba(27, 31, 35, 0.15); + border-radius: 3px; + box-shadow: 0 3px 12px rgba(27, 31, 35, 0.15); + > .panel { + margin: 0; + border: none !important; + > .panel-body { + padding: 0; + } + > .panel-heading { + padding: 7px; + } + } +} + +.SelectBox__list { + max-height: 300px; + overflow-y: auto; +} + +.SelectBox__search { + font-size: 13px; +} + +.SelectBox__item { + display: block; + padding: 5px 10px; + font-size: 13px; + &:hover { + cursor: pointer; + background-color: $gray-lighter-4; + } +} + +.SelectBox__item.active, +.SelectBox__item.active:hover, +.SelectBox__item.active:focus { + color: $gray-darker-3; + background-color: $gray-lighter-4; +} + +.VirtualList__placeholder, +.VirtualList__loader { + padding-top: 5px; + padding-left: 10px; +} + +form { + .SelectBox__outer { + top: 100%; + } +} \ No newline at end of file diff --git a/src/components/index.tsx b/src/components/index.tsx index e5bd8dd4..1276b855 100644 --- a/src/components/index.tsx +++ b/src/components/index.tsx @@ -19,7 +19,18 @@ import DatePicker from './DatePicker'; import Panel from './Panel'; import UsageBar from './UsageBar'; import VirtualList from './VirtualList'; -import { Col, Clearfix, MenuItem, Navbar, NavDropdown, NavItem, Row, Well, PanelGroup } from 'react-bootstrap'; +import VirtualSelectBox from './VirtualSelectBox'; +import { + Col, + Clearfix, + MenuItem, + Navbar, + NavDropdown, + NavItem, + Row, + Well, + PanelGroup, +} from 'react-bootstrap'; export { Alert, @@ -52,4 +63,5 @@ export { Panel, PanelGroup, VirtualList, + VirtualSelectBox, }; diff --git a/src/interface.tsx b/src/interface.tsx index 94a65fa4..f5a0eeeb 100644 --- a/src/interface.tsx +++ b/src/interface.tsx @@ -3,6 +3,19 @@ import { SelectCallback, Sizes } from 'react-bootstrap'; import { Moment } from 'moment'; import CSS from 'csstype'; +export interface Query { + offset?: number; + limit?: number; + q?: string; +} + +export interface FetchResponse { + response?: { + [res: string]: T | T[]; + }; + error?: string; +} + export interface BadgeProps { count?: number | string; showZero?: boolean; @@ -127,13 +140,15 @@ export interface SubMenuProps { children: React.ReactNode; } -export type DropdownButtonMenuItem = { - key?: string | number; - children?: DropdownButtonMenuItem[]; - title: string; - eventKey?: string; - 'data-action'?: string; -} | string; +export type DropdownButtonMenuItem = + | { + key?: string | number; + children?: DropdownButtonMenuItem[]; + title: string; + eventKey?: string; + 'data-action'?: string; + } + | string; export interface DefaultDropdownButtonProps { componentClass: any; @@ -239,16 +254,15 @@ export interface InputDropdownProps { input?: any; meta?: any; } -export interface Query { - offset: number; - limit: number; -} -export interface VirtualRowArgs { +export type VirtualItem = { + id?: number; +} | string | number +export interface VirtualRowArgs { index: number; - item: object; - prevItem: object | null; - nextItem: object | null; - style: CSS.Properties + item: T; + prevItem: T | null; + nextItem: T | null; + style: CSS.Properties; } export interface VirtualAnchorItem { index: number; @@ -258,9 +272,9 @@ export interface VirtualListState { startIndex: number; endIndex: number; } -export interface VirtualListDefaultProps { +export interface VirtualListDefaultProps { height?: number | string; - data: any[], + data: T[]; runwayItems?: number; runwayItemsOppsite?: number; loader?: React.ReactNode; @@ -268,11 +282,11 @@ export interface VirtualListDefaultProps { noMoreHint?: React.ReactNode | boolean; debug?: boolean; } -export interface VirtualListProps extends VirtualListDefaultProps { +export interface VirtualListProps extends VirtualListDefaultProps { query?: Query; onQueryChange?: (query: Query) => Promise; rowHeight?: number | ((item: object) => number); - rowRenderer: (item: VirtualRowArgs) => React.ReactNode | Element; + rowRenderer: (item: VirtualRowArgs) => React.ReactNode | Element; isFetching?: boolean; isReloading?: boolean; noMore?: boolean; @@ -280,3 +294,35 @@ export interface VirtualListProps extends VirtualListDefaultProps { className?: string; isEstimate?: boolean; } + +export interface VirtualSelectBoxDefaultProps { + rowHeight: number; + isBtn: boolean; + disabled: boolean; + placeholder: string; + query: Query; + defaultItem: T; +} +export interface VirtualSelectBoxProps extends VirtualSelectBoxDefaultProps { + fetchData: (isReloading: boolean, query: Query, search?: string) => Promise<{ + query: Query; + items: T[]; + totalCount: number; + error?: string; + }>; + item?: T; + className?: string; + clear?: boolean; + onSelect?: (item: T) => void; + formatOption?: (item: T) => T; +} +export interface VirtualSelectBoxState { + search: string; + items: T[]; + query: Query; + isFetching: boolean; + totalCount: number; + isOpen: boolean; + isReloading: boolean; + error?: string; +} diff --git a/src/utils/__tests__/elastic-query.test.ts b/src/utils/__tests__/elastic-query.test.ts new file mode 100644 index 00000000..afdafec2 --- /dev/null +++ b/src/utils/__tests__/elastic-query.test.ts @@ -0,0 +1,16 @@ +import elasticQuery from '../elastic-query'; + +describe('elastic query', () => { + it('can parse query string to array', () => { + const str = 'passive:true AND name:asdf'; + expect(elasticQuery.toArr(str)).toEqual([ + { type: 'passive', value: 'true' }, + { type: 'name', value: 'asdf' }, + ]); + }); + + it('can format arr to query string', () => { + const arr = [{ type: 'passive', value: 'true' }, { type: 'name', value: 'asdf' }]; + expect(elasticQuery.toStr(arr)).toBe('passive:true AND name:asdf'); + }); +}); diff --git a/src/utils/elastic-query.ts b/src/utils/elastic-query.ts new file mode 100644 index 00000000..d9bba83a --- /dev/null +++ b/src/utils/elastic-query.ts @@ -0,0 +1,61 @@ +import lodash from 'lodash'; +type ValueType = string | number; +type ArrType = { + type: string + value: ValueType +} +/** + * asdf%20AND%20passive%3Dtrue => [{ type: 'text', value: 'asdf' }, { type: passive, value: 'true' }] + */ +function toArr(str?: string) { + if (!str) return []; + // 'a AND b AND type(D OR C) OR t'.split(reg) + // => ["a", "b", "type(D OR C)", "t"] + // eslint-disable-next-line + const reg = / (?:AND|OR) (?![^\(]*\))/; + const items = str.split(reg) + + return items.filter(item => item.trim()).map(item => { + if (item.includes(':')) { + let itemFormat = item; + if (item.includes('!')) { + itemFormat = item.replace(/!|\(|\)/g, '').replace(':', ':!'); + } + const [type, ...rest] = itemFormat.split(':'); + return { type, value: rest.join(':') }; + } else { + return { type: 'text', value: item }; + } + }); +} + +/** + * [{ type: 'text', value: 'asdf' }, { type: passive, value: 'true' }] => asdf%20AND%20passive%3Dtrue + */ +function toStr(arr: ArrType[]) { + if (!(arr instanceof Array)) return ''; + const forward: ValueType[] = []; + const reverse: ValueType[] = []; + arr.forEach(({ type, value }) => { + if (type === 'text') { + forward.push(value); + } else if (typeof value === 'string' && value.includes('!')) { + return reverse.push(`(!(${type}:${value.replace(/^!/, '')}))`); + } else { + forward.push(`${type}:${value}`); + } + // ts config noImplicitReturns true check + return false; + }); + const items = lodash.compact([ + forward.join(' AND '), + lodash.isEmpty(forward) ? reverse.join(' OR ') : + lodash.isEmpty(reverse) ? '' : `(${reverse.join(' OR ')})` + ]) + return items.join(' AND '); +} + +export default { + toStr, + toArr, +}; diff --git a/stories/utils/getMockDatas.ts b/src/utils/get-mock-datas.ts similarity index 60% rename from stories/utils/getMockDatas.ts rename to src/utils/get-mock-datas.ts index 26cf5cf1..e03d3540 100644 --- a/stories/utils/getMockDatas.ts +++ b/src/utils/get-mock-datas.ts @@ -1,6 +1,7 @@ -function createDatas(query: { limit: number, offset: number }, totalCount: number, resName: string) { - const { limit, offset } = query; - console.log('limit:', limit, 'offset:', offset, 'total:', totalCount); +import { Query } from '../../src/interface'; +function createDatas(query: Query, totalCount: number, resName: string) { + const { limit = 0, offset = 0 } = query; + console.log('query:', query, 'total:', totalCount); let rlt = []; if (offset <= totalCount) { const len = Math.min(limit, totalCount - offset); @@ -18,7 +19,7 @@ function createDatas(query: { limit: number, offset: number }, totalCount: numbe } } -export default function getMockDatas(query: { limit: number, offset: number }, totalCount: number, resName: string) { +export default function getMockDatas(query: Query, totalCount: number, resName: string) { return new Promise((resolve) => { setTimeout(() => { const datas= createDatas(query, totalCount, resName); diff --git a/src/utils/index.ts b/src/utils/index.ts index 85d8eaa3..267d4892 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -2,10 +2,16 @@ import bulk from './bulk'; import xbytes from './xbytes'; import getBemClass from './bem-class'; import uuid from './uuid'; +import elasticQuery from './elastic-query'; +import sleep from './sleep'; +import getMockDatas from './get-mock-datas'; export { bulk, xbytes, getBemClass, uuid, + elasticQuery, + sleep, + getMockDatas, } \ No newline at end of file diff --git a/src/utils/sleep.ts b/src/utils/sleep.ts new file mode 100644 index 00000000..247a8f9e --- /dev/null +++ b/src/utils/sleep.ts @@ -0,0 +1 @@ +export default (time: number) => new Promise(resolve => setTimeout(resolve, time)); diff --git a/stories/VirtualSelectBox.stories.tsx b/stories/VirtualSelectBox.stories.tsx new file mode 100644 index 00000000..92f1a1a5 --- /dev/null +++ b/stories/VirtualSelectBox.stories.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { VirtualSelectBox } from '../src'; +import { Query } from '../src/interface'; +import { elasticQuery, getMockDatas } from '../src/utils'; +import { get, isEmpty, concat } from 'lodash'; + +const resName = "list"; +const limit = 30; +function getEmptyDatas(query: Query){ + return getMockDatas(query, 0, resName); +} + +function getDatas(query: Query) { + return getMockDatas(query, 180, resName); +} + +const AsyncWithES = ({ clear } : { clear?: boolean }) => { + const [item, setItem] = React.useState({ id: 1, name: `${resName}-1` } as object); + const onSelect = React.useCallback(async (item: object) => { + setItem(item); + }, [item, setItem]); + const fetchFormatDatas = async (isReloading: boolean, dQuery: Query = {}, search?: string) => { + let nameKey = 'name'; + let extraQuery: Query = {}; + let resNamePlural = `${resName}s`; + let query = dQuery; + if (!isReloading) { + query = { ...extraQuery, ...dQuery }; + } else { + let qArr = elasticQuery.toArr(get(dQuery, 'q', '')); + if (search) { + qArr.push({ + type: nameKey, + value: search, + }); + } + if (!isEmpty(extraQuery)) { + qArr = concat(qArr, elasticQuery.toArr(extraQuery.q)); + } + // 查询的时候重置 query + query = { + ...dQuery, + ...extraQuery, + limit, + offset: 0, + q: elasticQuery.toStr(qArr), + }; + } + + const actionResult = await getDatas(query); + const items = get(actionResult, `response.${resNamePlural}`, []); + const totalCount = get(actionResult, 'response.paging.totalCount'); + const error = get(actionResult, 'error'); + return { + query, + items, + totalCount, + error + } + } + return ( + + ) +} + +const fetchEmptyDatas = async (isReloading: boolean, dQuery: Query = {}) => { + let resNamePlural = `${resName}s`; + const query = { + ...dQuery, + limit, + offset: 0, + }; + const actionResult = await getEmptyDatas(query); + const items = get(actionResult, `response.${resNamePlural}`, []); + const totalCount = get(actionResult, 'response.paging.totalCount'); + return { + query, + items, + totalCount, + } +} + +storiesOf('DATA SHOW | VirtualSelectBox', module) + .add('empty object data', () => ( + + )) + .add('empty string data', () => ( + + item="" + defaultItem="" + fetchData={fetchEmptyDatas} + /> + )) + .add('async datas', () => ) + .add('async datas with clear', () => ) \ No newline at end of file diff --git a/stories/demos/AsyncVirtualList.tsx b/stories/demos/AsyncVirtualList.tsx index 05d75966..9bcb722d 100644 --- a/stories/demos/AsyncVirtualList.tsx +++ b/stories/demos/AsyncVirtualList.tsx @@ -1,17 +1,17 @@ import React from 'react'; import { VirtualList } from '../../src'; -import getMockDatas from '../utils/getMockDatas'; +import { getMockDatas } from '../../src/utils'; import { get } from 'lodash'; -import { Query, VirtualRowArgs } from '../../src/interface' +import { Query, VirtualRowArgs, VirtualItem } from '../../src/interface' const resName = "list"; function getDatas(query: Query) { return getMockDatas(query, 180, resName); } -export const rowRenderer = (i: VirtualRowArgs) =>
{resName}-{i.index + 1}
; +export const rowRenderer = (i: VirtualRowArgs) =>
{resName}-{i.index + 1}
; -const rowRendererRandomHeight = (i: VirtualRowArgs) =>
{resName}-{i.index + 1}
; +const rowRendererRandomHeight = (i: VirtualRowArgs) =>
{resName}-{i.index + 1}
; export default (props: { random?: boolean }) => { const [fetching, setFetch] = React.useState(false); @@ -28,7 +28,7 @@ export default (props: { random?: boolean }) => { const existLists = document.querySelectorAll('.VirtualList > *'); setCount(existLists ? existLists.length : 0); }, [datas]); - const handleQueryChange = async (query: { limit: number, offset: number }) => { + const handleQueryChange = async (query: Query) => { setFetch(true); const actionResult: any = await getDatas(query); setFetch(false);