diff --git a/packages/react-bootstrap-table2-example/examples/column-filter/multi-select-filter-default-value.js b/packages/react-bootstrap-table2-example/examples/column-filter/multi-select-filter-default-value.js
new file mode 100644
index 0000000..2e2b0e9
--- /dev/null
+++ b/packages/react-bootstrap-table2-example/examples/column-filter/multi-select-filter-default-value.js
@@ -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]
+ })
+}];
+
+
+`;
+export default () => (
+
+
+ { sourceCode }
+
+);
diff --git a/packages/react-bootstrap-table2-example/examples/column-filter/multi-select-filter.js b/packages/react-bootstrap-table2-example/examples/column-filter/multi-select-filter.js
new file mode 100644
index 0000000..ac67a69
--- /dev/null
+++ b/packages/react-bootstrap-table2-example/examples/column-filter/multi-select-filter.js
@@ -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
+ })
+}];
+
+
+`;
+export default () => (
+
+
+ { sourceCode }
+
+);
diff --git a/packages/react-bootstrap-table2-example/stories/index.js b/packages/react-bootstrap-table2-example/stories/index.js
index 77dc4c9..c48dbe9 100644
--- a/packages/react-bootstrap-table2-example/stories/index.js
+++ b/packages/react-bootstrap-table2-example/stories/index.js
@@ -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', () => )
.add('Select Filter with Default Value', () => )
.add('Select Filter with Comparator', () => )
+ .add('MultiSelect Filter', () => )
+ .add('MultiSelect Filter with Default Value', () => )
.add('Number Filter', () => )
.add('Number Filter with Default Value', () => )
.add('Date Filter', () => )
diff --git a/packages/react-bootstrap-table2-filter/index.js b/packages/react-bootstrap-table2-filter/index.js
index fe60268..6d9368a 100644
--- a/packages/react-bootstrap-table2-filter/index.js
+++ b/packages/react-bootstrap-table2-filter/index.js
@@ -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
diff --git a/packages/react-bootstrap-table2-filter/src/components/multiselect.js b/packages/react-bootstrap-table2-filter/src/components/multiselect.js
new file mode 100644
index 0000000..9630bbd
--- /dev/null
+++ b/packages/react-bootstrap-table2-filter/src/components/multiselect.js
@@ -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((
+
+ ));
+ }
+ Object.keys(options).forEach(key =>
+ optionTags.push()
+ );
+ 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 (
+
+ );
+ }
+}
+
+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;
diff --git a/packages/react-bootstrap-table2-filter/src/const.js b/packages/react-bootstrap-table2-filter/src/const.js
index ccb4d78..e685640 100644
--- a/packages/react-bootstrap-table2-filter/src/const.js
+++ b/packages/react-bootstrap-table2-filter/src/const.js
@@ -1,6 +1,7 @@
export const FILTER_TYPE = {
TEXT: 'TEXT',
SELECT: 'SELECT',
+ MULTISELECT: 'MULTISELECT',
NUMBER: 'NUMBER',
DATE: 'DATE'
};
diff --git a/packages/react-bootstrap-table2-filter/src/filter.js b/packages/react-bootstrap-table2-filter/src/filter.js
index a65d52a..73d84c4 100644
--- a/packages/react-bootstrap-table2-filter/src/filter.js
+++ b/packages/react-bootstrap-table2-filter/src/filter.js
@@ -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;
diff --git a/packages/react-bootstrap-table2-filter/src/wrapper.js b/packages/react-bootstrap-table2-filter/src/wrapper.js
index c2b24e8..0c8fab7 100644
--- a/packages/react-bootstrap-table2-filter/src/wrapper.js
+++ b/packages/react-bootstrap-table2-filter/src/wrapper.js
@@ -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 };
diff --git a/packages/react-bootstrap-table2-filter/test/components/multiselect.test.js b/packages/react-bootstrap-table2-filter/test/components/multiselect.test.js
new file mode 100644
index 0000000..a4e214a
--- /dev/null
+++ b/packages/react-bootstrap-table2-filter/test/components/multiselect.test.js
@@ -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(
+
+ );
+ 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(
+
+ );
+ 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(
+
+ );
+ 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(
+
+ );
+ 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(
+
+ );
+ });
+
+ 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(
+
+ );
+ });
+
+ 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(
+
+ );
+ 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(
+
+ );
+ 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(
+
+ );
+ 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(
+
+ );
+ 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(
+
+ );
+ 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(
+
+ );
+ 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();
+ });
+ });
+});