diff --git a/.storybook/config.js b/.storybook/config.js index b089cdd..65c4816 100644 --- a/.storybook/config.js +++ b/.storybook/config.js @@ -1,5 +1,6 @@ import { configure } from '@kadira/storybook'; import '../node_modules/bootstrap/dist/css/bootstrap.min.css'; +import '../node_modules/react-select/dist/react-select.min.css'; function loadStories() { require('../stories'); diff --git a/package.json b/package.json index 0ef5578..3335ee4 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "react": "^15.1.0", "react-dom": "^15.3.2", "react-redux": "^4.4.5", + "react-select": "^1.0.0-rc.1", "redux": "^3.4.0", "redux-actions": "^0.11.0", "reselect": "^2.5.3" diff --git a/src/Filter.js b/src/Filter.js index 293cad9..9677fd6 100644 --- a/src/Filter.js +++ b/src/Filter.js @@ -1,8 +1,10 @@ import React, { Component, PropTypes } from 'react'; +import { Creatable } from 'react-select'; const propTypes = { - value: PropTypes.string, + value: PropTypes.array, onChange: PropTypes.func.isRequired, + options: PropTypes.array.isRequired, }; class Filter extends Component { @@ -10,13 +12,25 @@ class Filter extends Component { const { value, onChange, + options, } = this.props; return ( - onChange(e.target.value)} + `Search for '${txt}'`} + onChange={(selected) => onChange(selected)} + newOptionCreator={({ label, labelKey, valueKey }) => { + const option = {}; + option[valueKey] = label.toLowerCase(); + option[labelKey] = label.toLowerCase(); + option.textFilter = true; + option.className = 'Select-create-option-placeholder'; + return option; + }} value={value} - placeholder="Filter data with any text" + multi style={{ margin: '1rem 0 1rem 0', }} diff --git a/src/reducer.js b/src/reducer.js index 7bd71ef..cbee1aa 100644 --- a/src/reducer.js +++ b/src/reducer.js @@ -15,7 +15,7 @@ import { const defaultState = (configs = {}) => ({ page: 0, pageSize: configs.defaultPageSize || 5, - filter: '', + filter: [], sortKey: null, direction: null, selectAll: false, @@ -72,7 +72,7 @@ const behaviours = { [TABLE_FILTER_CHANGED]: (state, { payload }) => ({ ...state, page: 0, - filter: payload.filter.toLowerCase(), + filter: payload.filter, }), [TABLE_SELECT_ALL_CHANGED]: (state) => ({ ...state, diff --git a/src/selectors.js b/src/selectors.js index 20dcd98..f764dcd 100644 --- a/src/selectors.js +++ b/src/selectors.js @@ -9,17 +9,30 @@ function paginate(rows, { page, pageSize }) { return rows.slice(start, start + pageSize); } -function filter(rows = [], textFilter, columns) { - if (!textFilter) { +function filter(rows = [], filters = [], columns) { + if (filters.length === 0) { return rows.slice(0); } - return _.filter(rows, (row) => _.some(columns, (column) => { + + // apply text filter across all columns + let filteredRows = _.filter(rows, row => _.some(columns, (column) => { if (!column.filterable) { return false; } const normalized = String(_.get(row, column.key)).toLowerCase(); - return normalized.indexOf(textFilter) > -1; + return _.every(filters, f => !f.textFilter || normalized.indexOf(f.value) > -1); + })); + + // apply value filters on taggable columns + filteredRows = _.filter(filteredRows, row => _.every(columns, column => { + if (!column.taggable) { + return true; + } + const value = _.get(row, column.key); + return _.every(filters, f => !f.valueFilter || f.key !== column.key || f.value === value); })); + + return filteredRows; } function sort(rows, { sortKey, direction }) { @@ -71,6 +84,49 @@ export default (tableName) => { (initialData, textFilter, columns) => filter(initialData, textFilter, columns) ); + const getFilterOptions = createSelector( + getInitialData, + getColumns, + (initialData, columns) => { + const options = []; + const values = {}; + const columnMap = _.keyBy(columns, 'key'); + + initialData.forEach(row => { + columns.forEach(column => { + if (!column.taggable) { + return; + } + if (!values[column.key]) { + values[column.key] = []; + } + const columnValues = values[column.key]; + const value = _.get(row, column.key); + if (!columnValues.includes(value)) { + columnValues.push(value); + } + }); + }); + + _.forOwn(values, (columnValues, key) => { + columnValues.forEach(value => { + let labelValue = value; + if (_.isBoolean(value)) { + labelValue = value ? 'Yes' : 'No'; + } + options.push({ + label: `${columnMap[key].header}:${labelValue}`, + valueFilter: true, + value, + key, + }); + }); + }); + + return options; + } + ); + const getPageInfo = createSelector( getPage, getPageSize, @@ -151,5 +207,6 @@ export default (tableName) => { getSelectedRows, getSelectAll, getPrimaryKey, + getFilterOptions, }; }; diff --git a/src/sematable.js b/src/sematable.js index 19d11ac..f109746 100644 --- a/src/sematable.js +++ b/src/sematable.js @@ -21,12 +21,13 @@ const propTypes = { isInitialized: PropTypes.bool.isRequired, visibleRows: PropTypes.array, - filter: PropTypes.string, + filter: PropTypes.array, sortInfo: PropTypes.object, pageInfo: PropTypes.object, selectAll: PropTypes.bool, selectedRows: PropTypes.array, primaryKey: PropTypes.string, + filterOptions: PropTypes.array, onPageChange: PropTypes.func.isRequired, onPageSizeChange: PropTypes.func.isRequired, @@ -71,6 +72,7 @@ const sematable = (tableName, TableComponent, columns, configs = {}) => { selectAll: selectors.getSelectAll(state), selectedRows: selectors.getSelectedRows(state), primaryKey: selectors.getPrimaryKey(state), + filterOptions: selectors.getFilterOptions(state), }; }; @@ -109,6 +111,7 @@ const sematable = (tableName, TableComponent, columns, configs = {}) => { selectAll, selectedRows, primaryKey, + filterOptions, /* actions */ onPageChange, @@ -164,6 +167,7 @@ const sematable = (tableName, TableComponent, columns, configs = {}) => {
{showFilter && onFilterChange(f)} />}
diff --git a/stories/UsersTable.js b/stories/UsersTable.js index ebfe88c..2dccdb9 100644 --- a/stories/UsersTable.js +++ b/stories/UsersTable.js @@ -1,5 +1,6 @@ import React, { Component } from 'react'; import sematable, { Table } from '../src'; +import YesNo from './YesNo.js'; export const USERS_TABLE = 'usersTable'; const columns = [ @@ -7,6 +8,7 @@ const columns = [ { key: 'firstName', header: 'First name', filterable: true, sortable: true }, { key: 'lastName', header: 'Last name', filterable: true, sortable: true }, { key: 'status', header: 'Status', taggable: true }, + { key: 'confirmed', header: 'Confirmed', taggable: true, Component: YesNo }, ]; class UsersTable extends Component { diff --git a/stories/YesNo.js b/stories/YesNo.js new file mode 100644 index 0000000..0935f3c --- /dev/null +++ b/stories/YesNo.js @@ -0,0 +1,10 @@ +import React, { PropTypes } from 'react'; + +const YesNo = ({ children }) => ( + {children ? 'Yes' : 'No'} +); + +YesNo.propTypes = { + children: PropTypes.bool.isRequired, +}; +export default YesNo; diff --git a/stories/index.js b/stories/index.js index 263a9ec..89d70b4 100644 --- a/stories/index.js +++ b/stories/index.js @@ -14,12 +14,14 @@ const users = [ firstName: 'John', lastName: 'Doe', status: 'UNKNOWN', + confirmed: true, }, { id: 2, firstName: 'Bob', lastName: 'McBobber', status: 'ACTIVE', + confirmed: false, }, ];