Merge pull request #438 from Ignalion/multiselectv2

Multiselectv2
This commit is contained in:
Allen 2018-07-31 16:29:19 +08:00 committed by GitHub
commit 01cf69392f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 683 additions and 2 deletions

View File

@ -0,0 +1,69 @@
import React from 'react';
import BootstrapTable from 'react-bootstrap-table-next';
import filterFactory, { multiSelectFilter } from 'react-bootstrap-table2-filter';
import Code from 'components/common/code-block';
import { productsQualityGenerator } from 'utils/common';
const products = productsQualityGenerator(6);
const selectOptions = {
0: 'good',
1: 'Bad',
2: 'unknown'
};
const columns = [{
dataField: 'id',
text: 'Product ID'
}, {
dataField: 'name',
text: 'Product Name'
}, {
dataField: 'quality',
text: 'Product Quailty',
formatter: cell => selectOptions[cell],
filter: multiSelectFilter({
options: selectOptions,
defaultValue: [0, 2]
})
}];
const sourceCode = `\
import BootstrapTable from 'react-bootstrap-table-next';
import filterFactory, { selectFilter } from 'react-bootstrap-table2-filter';
const selectOptions = {
0: 'good',
1: 'Bad',
2: 'unknown'
};
const columns = [{
dataField: 'id',
text: 'Product ID'
}, {
dataField: 'name',
text: 'Product Name'
}, {
dataField: 'quality',
text: 'Product Quailty',
formatter: cell => selectOptions[cell],
filter: selectFilter({
options: selectOptions,
defaultValue: [0, 2]
})
}];
<BootstrapTable keyField='id' data={ products } columns={ columns } filter={ filterFactory() } />
`;
export default () => (
<div>
<BootstrapTable
keyField="id"
data={ products }
columns={ columns }
filter={ filterFactory() }
/>
<Code>{ sourceCode }</Code>
</div>
);

View File

@ -0,0 +1,67 @@
import React from 'react';
import BootstrapTable from 'react-bootstrap-table-next';
import filterFactory, { multiSelectFilter } from 'react-bootstrap-table2-filter';
import Code from 'components/common/code-block';
import { productsQualityGenerator } from 'utils/common';
const products = productsQualityGenerator(6);
const selectOptions = {
0: 'good',
1: 'Bad',
2: 'unknown'
};
const columns = [{
dataField: 'id',
text: 'Product ID'
}, {
dataField: 'name',
text: 'Product Name'
}, {
dataField: 'quality',
text: 'Product Quailty',
formatter: cell => selectOptions[cell],
filter: multiSelectFilter({
options: selectOptions
})
}];
const sourceCode = `\
import BootstrapTable from 'react-bootstrap-table-next';
import filterFactory, { selectFilter } from 'react-bootstrap-table2-filter';
const selectOptions = {
0: 'good',
1: 'Bad',
2: 'unknown'
};
const columns = [{
dataField: 'id',
text: 'Product ID'
}, {
dataField: 'name',
text: 'Product Name'
}, {
dataField: 'quality',
text: 'Product Quailty',
formatter: cell => selectOptions[cell],
filter: selectFilter({
options: selectOptions
})
}];
<BootstrapTable keyField='id' data={ products } columns={ columns } filter={ filterFactory() } />
`;
export default () => (
<div>
<BootstrapTable
keyField="id"
data={ products }
columns={ columns }
filter={ filterFactory() }
/>
<Code>{ sourceCode }</Code>
</div>
);

View File

@ -45,6 +45,8 @@ import CustomFilterValue from 'examples/column-filter/custom-filter-value';
import SelectFilter from 'examples/column-filter/select-filter';
import SelectFilterWithDefaultValue from 'examples/column-filter/select-filter-default-value';
import SelectFilterComparator from 'examples/column-filter/select-filter-like-comparator';
import MultiSelectFilter from 'examples/column-filter/multi-select-filter';
import MultiSelectFilterDefaultValue from 'examples/column-filter/multi-select-filter-default-value';
import CustomSelectFilter from 'examples/column-filter/custom-select-filter';
import NumberFilter from 'examples/column-filter/number-filter';
import NumberFilterWithDefaultValue from 'examples/column-filter/number-filter-default-value';
@ -178,6 +180,8 @@ storiesOf('Column Filter', module)
.add('Select Filter', () => <SelectFilter />)
.add('Select Filter with Default Value', () => <SelectFilterWithDefaultValue />)
.add('Select Filter with Comparator', () => <SelectFilterComparator />)
.add('MultiSelect Filter', () => <MultiSelectFilter />)
.add('MultiSelect Filter with Default Value', () => <MultiSelectFilterDefaultValue />)
.add('Number Filter', () => <NumberFilter />)
.add('Number Filter with Default Value', () => <NumberFilterWithDefaultValue />)
.add('Date Filter', () => <DateFilter />)

View File

@ -1,5 +1,6 @@
import TextFilter from './src/components/text';
import SelectFilter from './src/components/select';
import MultiSelectFilter from './src/components/multiselect';
import NumberFilter from './src/components/number';
import DateFilter from './src/components/date';
import wrapperFactory from './src/wrapper';
@ -25,6 +26,11 @@ export const selectFilter = (props = {}) => ({
props
});
export const multiSelectFilter = (props = {}) => ({
Filter: MultiSelectFilter,
props
});
export const numberFilter = (props = {}) => ({
Filter: NumberFilter,
props

View File

@ -0,0 +1,155 @@
/* eslint react/require-default-props: 0 */
/* eslint no-return-assign: 0 */
/* eslint react/no-unused-prop-types: 0 */
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { LIKE, EQ } from '../comparison';
import { FILTER_TYPE } from '../const';
function optionsEquals(currOpts, prevOpts) {
const keys = Object.keys(currOpts);
for (let i = 0; i < keys.length; i += 1) {
if (currOpts[keys[i]] !== prevOpts[keys[i]]) {
return false;
}
}
return Object.keys(currOpts).length === Object.keys(prevOpts).length;
}
const getSelections = container =>
Array.from(container.selectedOptions).map(item => item.value);
class MultiSelectFilter extends Component {
constructor(props) {
super(props);
this.filter = this.filter.bind(this);
const isSelected = props.defaultValue.map(item => props.options[item]).length > 0;
this.state = { isSelected };
}
componentDidMount() {
const { column, onFilter, getFilter } = this.props;
const value = getSelections(this.selectInput);
if (value && value.length > 0) {
onFilter(column, FILTER_TYPE.MULTISELECT)(value);
}
// export onFilter function to allow users to access
if (getFilter) {
getFilter((filterVal) => {
this.setState(() => ({ isSelected: filterVal.length > 0 }));
this.selectInput.value = filterVal;
onFilter(column, FILTER_TYPE.MULTISELECT)(filterVal);
});
}
}
componentDidUpdate(prevProps) {
let needFilter = false;
if (this.props.defaultValue !== prevProps.defaultValue) {
needFilter = true;
} else if (!optionsEquals(this.props.options, prevProps.options)) {
needFilter = true;
}
if (needFilter) {
const value = this.selectInput.value;
if (value) {
this.props.onFilter(this.props.column, FILTER_TYPE.MULTISELECT)(value);
}
}
}
getOptions() {
const optionTags = [];
const { options, placeholder, column, withoutEmptyOption } = this.props;
if (!withoutEmptyOption) {
optionTags.push((
<option key="-1" value="">{ placeholder || `Select ${column.text}...` }</option>
));
}
Object.keys(options).forEach(key =>
optionTags.push(<option key={ key } value={ key }>{ options[key] }</option>)
);
return optionTags;
}
cleanFiltered() {
const value = (this.props.defaultValue !== undefined) ? this.props.defaultValue : [];
this.setState(() => ({ isSelected: value.length > 0 }));
this.selectInput.value = value;
this.props.onFilter(this.props.column, FILTER_TYPE.MULTISELECT)(value);
}
applyFilter(value) {
this.selectInput.value = value;
this.setState(() => ({ isSelected: value.length > 0 }));
this.props.onFilter(this.props.column, FILTER_TYPE.MULTISELECT)(value);
}
filter(e) {
const value = getSelections(e.target);
this.setState(() => ({ isSelected: value.length > 0 }));
this.props.onFilter(this.props.column, FILTER_TYPE.MULTISELECT)(value);
}
render() {
const {
style,
className,
defaultValue,
onFilter,
column,
options,
comparator,
withoutEmptyOption,
caseSensitive,
getFilter,
...rest
} = this.props;
const selectClass =
`filter select-filter form-control ${className} ${this.state.isSelected ? '' : 'placeholder-selected'}`;
return (
<select
{ ...rest }
ref={ n => this.selectInput = n }
style={ style }
multiple
className={ selectClass }
onChange={ this.filter }
onClick={ e => e.stopPropagation() }
defaultValue={ defaultValue !== undefined ? defaultValue : '' }
>
{ this.getOptions() }
</select>
);
}
}
MultiSelectFilter.propTypes = {
onFilter: PropTypes.func.isRequired,
column: PropTypes.object.isRequired,
options: PropTypes.object.isRequired,
comparator: PropTypes.oneOf([LIKE, EQ]),
placeholder: PropTypes.string,
style: PropTypes.object,
className: PropTypes.string,
withoutEmptyOption: PropTypes.bool,
defaultValue: PropTypes.array,
caseSensitive: PropTypes.bool,
getFilter: PropTypes.func
};
MultiSelectFilter.defaultProps = {
defaultValue: [],
className: '',
withoutEmptyOption: false,
comparator: EQ,
caseSensitive: true
};
export default MultiSelectFilter;

View File

@ -1,6 +1,7 @@
export const FILTER_TYPE = {
TEXT: 'TEXT',
SELECT: 'SELECT',
MULTISELECT: 'MULTISELECT',
NUMBER: 'NUMBER',
DATE: 'DATE'
};

View File

@ -187,6 +187,22 @@ export const filterByDate = _ => (
});
};
export const filterByArray = _ => (
data,
dataField,
{ filterVal, comparator }
) => (
data.filter((row) => {
const cell = _.get(row, dataField);
let cellStr = _.isDefined(cell) ? cell.toString() : '';
if (comparator === EQ) {
return filterVal.indexOf(cellStr) !== -1;
}
cellStr = cellStr.toLocaleUpperCase();
return filterVal.some(item => cellStr.indexOf(item.toLocaleUpperCase()) !== -1);
})
);
export const filterFactory = _ => (filterType) => {
let filterFn;
switch (filterType) {
@ -194,6 +210,9 @@ export const filterFactory = _ => (filterType) => {
case FILTER_TYPE.SELECT:
filterFn = filterByText(_);
break;
case FILTER_TYPE.MULTISELECT:
filterFn = filterByArray(_);
break;
case FILTER_TYPE.NUMBER:
filterFn = filterByNumber(_);
break;

View File

@ -53,12 +53,18 @@ export default (Base, {
const currFilters = Object.assign({}, store.filters);
const { dataField, filter } = column;
if (!_.isDefined(filterVal) || filterVal === '') {
const needClearFilters = !_.isDefined(filterVal) || filterVal === '' ||
filterVal.length === 0 || (filterVal.length === 1 && filterVal[0] === '');
if (needClearFilters) {
delete currFilters[dataField];
} else {
// select default comparator is EQ, others are LIKE
const {
comparator = (filterType === FILTER_TYPE.SELECT ? EQ : LIKE),
comparator = (
(filterType === FILTER_TYPE.SELECT) || (
filterType === FILTER_TYPE.MULTISELECT) ? EQ : LIKE
),
caseSensitive = false
} = filter.props;
currFilters[dataField] = { filterVal, filterType, comparator, caseSensitive };

View File

@ -0,0 +1,354 @@
import 'jsdom-global/register';
import React from 'react';
import sinon from 'sinon';
import { mount } from 'enzyme';
import MultiSelectFilter from '../../src/components/multiselect';
import { FILTER_TYPE } from '../../src/const';
describe('Multi Select Filter', () => {
let wrapper;
let instance;
// onFilter(x)(y) = filter result
const onFilter = sinon.stub();
const onFilterFirstReturn = sinon.stub();
const column = {
dataField: 'quality',
text: 'Product Quality'
};
const options = {
0: 'Bad',
1: 'Good',
2: 'Unknown'
};
afterEach(() => {
onFilter.reset();
onFilterFirstReturn.reset();
onFilter.returns(onFilterFirstReturn);
});
describe('initialization', () => {
beforeEach(() => {
wrapper = mount(
<MultiSelectFilter onFilter={ onFilter } column={ column } options={ options } />
);
instance = wrapper.instance();
});
it('should have correct state', () => {
expect(instance.state.isSelected).toBeFalsy();
});
it('should rendering component successfully', () => {
expect(wrapper).toHaveLength(1);
expect(wrapper.find('select')).toHaveLength(1);
expect(wrapper.find('.select-filter')).toHaveLength(1);
expect(wrapper.find('.placeholder-selected')).toHaveLength(1);
});
it('should rendering select options correctly', () => {
const select = wrapper.find('select');
expect(select.find('option')).toHaveLength(Object.keys(options).length + 1);
expect(select.childAt(0).text()).toEqual(`Select ${column.text}...`);
Object.keys(options).forEach((key, i) => {
expect(select.childAt(i + 1).prop('value')).toEqual(key);
expect(select.childAt(i + 1).text()).toEqual(options[key]);
});
});
});
describe('when defaultValue is defined', () => {
let defaultValue;
describe('and it is valid', () => {
beforeEach(() => {
defaultValue = ['0'];
wrapper = mount(
<MultiSelectFilter
onFilter={ onFilter }
column={ column }
options={ options }
defaultValue={ defaultValue }
/>
);
instance = wrapper.instance();
});
it('should have correct state', () => {
expect(instance.state.isSelected).toBeTruthy();
});
it('should rendering component successfully', () => {
expect(wrapper).toHaveLength(1);
expect(wrapper.find('.placeholder-selected')).toHaveLength(0);
});
it('should calling onFilter on componentDidMount', () => {
expect(onFilter.calledOnce).toBeTruthy();
expect(onFilter.calledWith(column, FILTER_TYPE.MULTISELECT)).toBeTruthy();
expect(onFilterFirstReturn.calledOnce).toBeTruthy();
expect(onFilterFirstReturn.calledWith(defaultValue)).toBeTruthy();
});
});
});
describe('when props.getFilter is defined', () => {
let programmaticallyFilter;
const filterValue = ['foo'];
const getFilter = (filter) => {
programmaticallyFilter = filter;
};
beforeEach(() => {
wrapper = mount(
<MultiSelectFilter
onFilter={ onFilter }
column={ column }
options={ options }
getFilter={ getFilter }
/>
);
instance = wrapper.instance();
programmaticallyFilter(filterValue);
});
it('should do onFilter correctly when exported function was executed', () => {
expect(onFilter.calledOnce).toBeTruthy();
expect(onFilter.calledWith(column, FILTER_TYPE.MULTISELECT)).toBeTruthy();
expect(onFilterFirstReturn.calledOnce).toBeTruthy();
expect(onFilterFirstReturn.calledWith(filterValue)).toBeTruthy();
});
it('should setState correctly when exported function was executed', () => {
expect(instance.state.isSelected).toBeTruthy();
});
});
describe('when placeholder is defined', () => {
const placeholder = 'test';
beforeEach(() => {
wrapper = mount(
<MultiSelectFilter
onFilter={ onFilter }
column={ column }
options={ options }
placeholder={ placeholder }
/>
);
instance = wrapper.instance();
});
it('should rendering component successfully', () => {
expect(wrapper).toHaveLength(1);
const select = wrapper.find('select');
expect(select.childAt(0).text()).toEqual(placeholder);
});
});
describe('when style is defined', () => {
const style = { backgroundColor: 'red' };
beforeEach(() => {
wrapper = mount(
<MultiSelectFilter
onFilter={ onFilter }
column={ column }
options={ options }
style={ style }
/>
);
});
it('should rendering component successfully', () => {
expect(wrapper).toHaveLength(1);
expect(wrapper.find('select').prop('style')).toEqual(style);
});
});
describe('when withoutEmptyOption is defined', () => {
beforeEach(() => {
wrapper = mount(
<MultiSelectFilter
onFilter={ onFilter }
column={ column }
options={ options }
withoutEmptyOption
/>
);
});
it('should rendering select without default empty option', () => {
const select = wrapper.find('select');
expect(select.find('option')).toHaveLength(Object.keys(options).length);
});
});
describe('componentDidUpdate', () => {
let prevProps;
describe('when props.defaultValue is diff from prevProps.defaultValue', () => {
const defaultValue = ['0'];
beforeEach(() => {
wrapper = mount(
<MultiSelectFilter
onFilter={ onFilter }
column={ column }
options={ options }
defaultValue={ defaultValue }
/>
);
prevProps = {
column,
options,
defaultValue: ['1']
};
instance = wrapper.instance();
instance.componentDidUpdate(prevProps);
});
it('should update', () => {
expect(onFilter.callCount).toBe(2);
expect(onFilter.calledWith(column, FILTER_TYPE.MULTISELECT)).toBeTruthy();
expect(onFilterFirstReturn.callCount).toBe(2);
expect(onFilterFirstReturn.calledWith(instance.props.defaultValue)).toBeTruthy();
});
});
describe('when props.options is diff from prevProps.options', () => {
const defaultValue = ['0'];
beforeEach(() => {
wrapper = mount(
<MultiSelectFilter
onFilter={ onFilter }
column={ column }
options={ {
...options,
3: 'Best'
} }
defaultValue={ defaultValue }
/>
);
prevProps = {
column,
options
};
instance = wrapper.instance();
instance.componentDidUpdate(prevProps);
});
it('should update', () => {
expect(onFilter.callCount).toBe(2);
expect(onFilter.calledWith(column, FILTER_TYPE.MULTISELECT)).toBeTruthy();
expect(onFilterFirstReturn.callCount).toBe(2);
expect(onFilterFirstReturn.calledWith(instance.props.defaultValue)).toBeTruthy();
});
});
});
describe('cleanFiltered', () => {
describe('when props.defaultValue is defined', () => {
const defaultValue = ['0'];
beforeEach(() => {
wrapper = mount(
<MultiSelectFilter
onFilter={ onFilter }
column={ column }
options={ options }
defaultValue={ defaultValue }
/>
);
instance = wrapper.instance();
instance.cleanFiltered();
});
it('should setting state correctly', () => {
expect(instance.state.isSelected).toBeTruthy();
});
it('should calling onFilter correctly', () => {
expect(onFilter.callCount).toBe(2);
expect(onFilter.calledWith(column, FILTER_TYPE.MULTISELECT)).toBeTruthy();
expect(onFilterFirstReturn.callCount).toBe(2);
expect(onFilterFirstReturn.calledWith(defaultValue)).toBeTruthy();
});
});
describe('when props.defaultValue is not defined', () => {
beforeEach(() => {
wrapper = mount(
<MultiSelectFilter
onFilter={ onFilter }
column={ column }
options={ options }
/>
);
instance = wrapper.instance();
instance.cleanFiltered();
});
it('should setting state correctly', () => {
expect(instance.state.isSelected).toBeFalsy();
});
it('should calling onFilter correctly', () => {
expect(onFilter.callCount).toBe(1);
expect(onFilterFirstReturn.callCount).toBe(1);
});
});
});
describe('applyFilter', () => {
const values = ['2'];
beforeEach(() => {
wrapper = mount(
<MultiSelectFilter onFilter={ onFilter } column={ column } options={ options } />
);
instance = wrapper.instance();
instance.applyFilter(values);
});
it('should setting state correctly', () => {
expect(instance.state.isSelected).toBeTruthy();
});
it('should calling onFilter correctly', () => {
expect(onFilter.calledOnce).toBeTruthy();
expect(onFilter.calledWith(column, FILTER_TYPE.MULTISELECT)).toBeTruthy();
expect(onFilterFirstReturn.calledOnce).toBeTruthy();
expect(onFilterFirstReturn.calledWith(values)).toBeTruthy();
});
});
describe('filter', () => {
const event = { target: { selectedOptions: [{ value: 'tester' }] } };
beforeEach(() => {
wrapper = mount(
<MultiSelectFilter onFilter={ onFilter } column={ column } options={ options } />
);
instance = wrapper.instance();
instance.filter(event);
});
it('should setting state correctly', () => {
expect(instance.state.isSelected).toBeTruthy();
});
it('should calling onFilter correctly', () => {
expect(onFilter.calledOnce).toBeTruthy();
expect(onFilter.calledWith(column, FILTER_TYPE.MULTISELECT)).toBeTruthy();
expect(onFilterFirstReturn.calledOnce).toBeTruthy();
expect(onFilterFirstReturn.calledWith(
event.target.selectedOptions.map(item => item.value))).toBeTruthy();
});
});
});