From 590afb3e6104fc9a2c938ac069bf1c65c71b4e24 Mon Sep 17 00:00:00 2001 From: Aaron Schwartz Date: Tue, 28 Mar 2017 16:01:52 -0700 Subject: [PATCH] Add column filtering. --- .storybook/config.js | 2 + .storybook/fonts.css | 4 + README.md | 23 +++- src/defaultProps.js | 12 +- src/index.js | 203 ++++++++++++++++++++++++------ src/index.styl | 6 + src/lifecycle.js | 22 +++- src/methods.js | 80 +++++++++--- stories/Filtering.js | 206 +++++++++++++++++++++++++++++++ stories/PivotingSubComponents.js | 8 +- stories/ServerSide.js | 26 +++- stories/SubComponents.js | 3 +- 12 files changed, 520 insertions(+), 75 deletions(-) create mode 100644 stories/Filtering.js diff --git a/.storybook/config.js b/.storybook/config.js index 631949c..8a1117d 100644 --- a/.storybook/config.js +++ b/.storybook/config.js @@ -24,6 +24,7 @@ import FunctionalRendering from '../stories/FunctionalRendering.js' import CustomExpanderPosition from '../stories/CustomExpanderPosition.js' import NoDataText from '../stories/NoDataText.js' import Footers from '../stories/Footers.js' +import Filtering from '../stories/Filtering.js' // configure(() => { storiesOf('1. Docs') @@ -53,4 +54,5 @@ configure(() => { .add('Custom Expander Position', CustomExpanderPosition) .add('Custom "No Data" Text', NoDataText) .add('Footers', Footers) + .add('Custom Filtering', Filtering) }, module) diff --git a/.storybook/fonts.css b/.storybook/fonts.css index 87a94a1..eb7aca3 100644 --- a/.storybook/fonts.css +++ b/.storybook/fonts.css @@ -11,3 +11,7 @@ h1 { margin-bottom: 15px; margin-top: 5px; } + +p { + margin-bottom: 15px; +} \ No newline at end of file diff --git a/README.md b/README.md index c307e70..6a2f3c1 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,9 @@ These are all of the available props (and their default values) for the main ` (row[filter.id] == filter.value), // Controlled State Overrides (see Fully Controlled Component section) page: undefined, @@ -154,6 +157,7 @@ These are all of the available props (and their default values) for the main ` ({}), getTheadTrProps: () => ({}), getTheadThProps: () => ({}), + getTheadFilterProps: () => ({}), + getTheadFilterTrProps: () => ({}), + getTheadFilterThProps: () => ({}), getTbodyProps: () => ({}), getTrGroupProps: () => ({}), getTrProps: () => ({}), @@ -215,7 +222,8 @@ These are all of the available props (and their default values) for the main ` ({}) + getFooterProps: () => ({}), + filterMethod: undefined }, // Text @@ -292,8 +300,13 @@ Or just define them as props footer: 'Header Name' or JSX eg. ({data, column}) =>
Header Name
, footerClassName: '', // Set the classname of the `td` element of the column's footer footerStyle: {}, // Set the style of the `td` element of the column's footer - getFooterProps: (state, rowInfo, column, instance) => ({}) // a function that returns props to decorate the `td` element of the column's footer + getFooterProps: (state, rowInfo, column, instance) => ({}) // A function that returns props to decorate the `td` element of the column's footer + // Filtering + filterMethod: (filter, row, column) => {return true} // A function returning a boolean that specifies the filtering logic for the column + // filter == an object specifying which filter is being applied. Format: {id: [the filter column's id], value: [the value the user typed in the filter field]} + // row == the row of data supplied to the table + // column == the column that the filter is on }] ``` @@ -532,7 +545,7 @@ By adding a `SubComponent` props, you can easily add an expansion level to all r ## Server-side Data -If you want to handle pagination, and sorting on the server, `react-table` makes it easy on you. +If you want to handle pagination, sorting, and filtering on the server, `react-table` makes it easy on you. 1. Feed React Table `data` from somewhere dynamic. eg. `state`, a redux store, etc... 1. Add `manual` as a prop. This informs React Table that you'll be handling sorting and pagination server-side @@ -555,7 +568,8 @@ If you want to handle pagination, and sorting on the server, `react-table` makes Axios.post('mysite.com/data', { page: state.page, pageSize: state.pageSize, - sorting: state.sorting + sorting: state.sorting, + filtering: state.filtering }) .then((res) => { // Update react-table @@ -601,6 +615,7 @@ Here are the props and their corresponding callbacks that control the state of t onPageSizeChange={(pageSize, pageIndex) => {...}} // Called when the pageSize is changed by the user. The resolve page is also sent to maintain approximate position in the data onSortingChange={(column, shiftKey) => {...}} // Called when a sortable column header is clicked with the column itself and if the shiftkey was held. If the column is a pivoted column, `column` will be an array of columns onExpandRow={(index, event) => {...}} // Called when an expander is clicked. Use this to manage `expandedRows` + onFilteringChange={(column, event) => {...}} // Called when a user enters a value into a filter input field. The event is the onChange event of the input field. /> ``` diff --git a/src/defaultProps.js b/src/defaultProps.js index 2cb49b8..9f31769 100644 --- a/src/defaultProps.js +++ b/src/defaultProps.js @@ -21,6 +21,9 @@ export default { collapseOnDataChange: true, freezeWhenExpanded: false, defaultSorting: [], + showFilters: false, + defaultFiltering: [], + defaultFilterMethod: (filter, row, column) => (row[filter.id] == filter.value), // Controlled State Overrides // page: undefined, @@ -32,6 +35,7 @@ export default { onPageChange: undefined, onPageSizeChange: undefined, onSortingChange: undefined, + onFilteringChange: undefined, // Pivoting pivotBy: undefined, @@ -62,6 +66,9 @@ export default { getTheadProps: emptyObj, getTheadTrProps: emptyObj, getTheadThProps: emptyObj, + getTheadFilterProps: emptyObj, + getTheadFilterTrProps: emptyObj, + getTheadFilterThProps: emptyObj, getTbodyProps: emptyObj, getTrGroupProps: emptyObj, getTrProps: emptyObj, @@ -92,7 +99,8 @@ export default { footer: undefined, footerClassName: '', footerStyle: {}, - getFooterProps: emptyObj + getFooterProps: emptyObj, + filterMethod: undefined }, // Text @@ -142,7 +150,7 @@ export default { {'-active': loading}, className )} - {...rest} + {...rest} >
{loadingText} diff --git a/src/index.js b/src/index.js index ac803cf..75e6114 100644 --- a/src/index.js +++ b/src/index.js @@ -26,6 +26,9 @@ export default React.createClass({ getTheadProps, getTheadTrProps, getTheadThProps, + getTheadFilterProps, + getTheadFilterTrProps, + getTheadFilterThProps, getTbodyProps, getTrGroupProps, getTrProps, @@ -41,11 +44,13 @@ export default React.createClass({ manual, loadingText, noDataText, + showFilters, // State loading, pageSize, page, sorting, + filtering, pages, // Pivoting State pivotValKey, @@ -82,7 +87,7 @@ export default React.createClass({ const minRows = this.getMinRows() const padRows = pages > 1 ? _.range(pageSize - pageRows.length) : minRows ? _.range(Math.max(minRows - pageRows.length, 0)) - : [] + : [] const hasColumnFooter = allVisibleColumns.some(d => d.footer) @@ -360,6 +365,142 @@ export default React.createClass({ ) } + const makeFilters = () => { + const theadFilterProps = _.splitProps(getTheadFilterProps(finalState, undefined, undefined, this)) + const theadFilterTrProps = _.splitProps(getTheadFilterTrProps(finalState, undefined, undefined, this)) + return ( + + + {allVisibleColumns.map(makeFilter)} + + + ) + } + + const makeFilter = (column, i) => { + const width = _.getFirstDefined(column.width, column.minWidth) + const maxWidth = _.getFirstDefined(column.width, column.maxWidth) + const theadFilterThProps = _.splitProps(getTheadFilterThProps(finalState, undefined, column, this)) + const columnHeaderProps = _.splitProps(column.getHeaderProps(finalState, undefined, column, this)) + + const classes = [ + column.headerClassName, + theadFilterThProps.className, + columnHeaderProps.className + ] + + const styles = { + ...column.headerStyle, + ...theadFilterThProps.style, + ...columnHeaderProps.style + } + + const rest = { + ...theadFilterThProps.rest, + ...columnHeaderProps.rest + } + + if (column.expander) { + if (column.pivotColumns) { + const pivotCols = []; + for (let i = 0; i < column.pivotColumns.length; i++) { + const col = column.pivotColumns[i]; + const filter = filtering.find(filter => filter.id == col.id) + pivotCols.push( + + 0 ? '#f3f3f3' : '#fff' + }} + value={filter ? filter.value : ""} + disabled={i > 0} + onChange={(event) => this.filterColumn(col, event)} + /> + + ) + if (i < column.pivotColumns.length - 1) { + pivotCols.push() + } + } + return ( + + {pivotCols} + + ) + } + return ( + + ) + } + + const filter = filtering.find(filter => filter.id == column.id) + + return ( + + this.filterColumn(column, event)} + /> + + ) + } + const makePageRow = (row, i, path = []) => { const rowInfo = { row: row.__original, @@ -443,24 +584,24 @@ export default React.createClass({ onClick={onTdClick} > {rowInfo.subRows ? ( - + - {column && column.pivotRender ? ( - - ) : {row[pivotValKey]} ({rowInfo.subRows.length})} + {column && column.pivotRender ? ( + + ) : {row[pivotValKey]} ({rowInfo.subRows.length})} - ) : SubComponent ? ( - + ) : SubComponent ? ( + - ) : null} + ) : null} ) } @@ -526,7 +667,6 @@ export default React.createClass({ const makePadRow = (row, i) => { const trGroupProps = getTrGroupProps(finalState, undefined, undefined, this) const trProps = _.splitProps(getTrProps(finalState, undefined, undefined, this)) - const tdProps = _.splitProps(getTdProps(finalState, undefined, undefined, this)) return ( - {SubComponent && ( - - )} {allVisibleColumns.map((column, i2) => { const show = typeof column.show === 'function' ? column.show() : column.show const width = _.getFirstDefined(column.width, column.minWidth) @@ -735,6 +861,7 @@ export default React.createClass({ > {hasHeaderGroups ? makeHeaderGroups() : null} {makeHeaders()} + {showFilters ? makeFilters() : null} {showPagination ? ( - - ) : null} + + ) : null} {!pageRows.length && ( { + .map(([key, value]) => { return { [pivotIDKey]: keys[i], [pivotValKey]: key, @@ -200,7 +200,7 @@ export default { [subRowsKey]: value } } - ) + ) // Recurse into the subRows groupedRows = groupedRows.map(rowGroup => { let subRows = groupRecursively(rowGroup[subRowsKey], keys, i + 1) @@ -233,12 +233,16 @@ export default { const { manual, sorting, - resolvedData + filtering, + showFilters, + defaultFilterMethod, + resolvedData, + allVisibleColumns } = resolvedState // Resolve the data from either manual data or sorted data return { - sortedData: manual ? resolvedData : this.sortData(resolvedData, sorting) + sortedData: manual ? resolvedData : this.sortData(resolvedData, sorting, showFilters, filtering, defaultFilterMethod, allVisibleColumns) } }, @@ -251,11 +255,28 @@ export default { getStateOrProp (key) { return _.getFirstDefined(this.state[key], this.props[key]) }, - sortData (data, sorting) { - if (!sorting.length) { - return data + sortData (data, sorting, showFilters, filtering, defaultFilterMethod, allVisibleColumns) { + let filteredData = data + + if (showFilters && filtering.length) { + filteredData = filtering.reduce( + (filteredSoFar, nextFilter) => { + return filteredSoFar.filter( + (row) => { + const column = allVisibleColumns.find(x => x.id == nextFilter.id) || {} + const filterMethod = column.filterMethod || defaultFilterMethod + return filterMethod(nextFilter, row, column) + }) + } + , filteredData + ) } - const sorted = _.orderBy(data, sorting.map(sort => { + + if (!sorting.length) { + return filteredData + } + + const sorted = _.orderBy(filteredData, sorting.map(sort => { return row => { if (row[sort.id] === null || row[sort.id] === undefined) { return -Infinity @@ -281,23 +302,23 @@ export default { // User actions onPageChange (page) { - const { onPageChange, collapseOnPageChange } = this.props + const {onPageChange, collapseOnPageChange} = this.props if (onPageChange) { return onPageChange(page) } - const newState = { page } + const newState = {page} if (collapseOnPageChange) { newState.expandedRows = {} } this.setStateWithData( newState - , () => { - this.fireOnChange() - }) + , () => { + this.fireOnChange() + }) }, onPageSizeChange (newPageSize) { - const { onPageSizeChange } = this.props - const { pageSize, page } = this.getResolvedState() + const {onPageSizeChange} = this.props + const {pageSize, page} = this.getResolvedState() // Normalize the page to display const currentRow = pageSize * page @@ -315,8 +336,8 @@ export default { }) }, sortColumn (column, additive) { - const { sorting } = this.getResolvedState() - const { onSortingChange } = this.props + const {sorting} = this.getResolvedState() + const {onSortingChange} = this.props if (onSortingChange) { return onSortingChange(column, additive) } @@ -398,5 +419,30 @@ export default { }, () => { this.fireOnChange() }) + }, + filterColumn (column, event) { + const {filtering} = this.getResolvedState() + const {onFilteringChange} = this.props + + if (onFilteringChange) { + return onFilteringChange(column, event) + } + + // Remove old filter first if it exists + const newFiltering = (filtering || []).filter(x => x.id != column.id) + + if (event.target.value != "") { + newFiltering.push({ + id: column.id, + value: event.target.value + }) + } + + this.setStateWithData({ + page: 0, + filtering: newFiltering + }, () => { + this.fireOnChange() + }) } } diff --git a/stories/Filtering.js b/stories/Filtering.js new file mode 100644 index 0000000..82caafc --- /dev/null +++ b/stories/Filtering.js @@ -0,0 +1,206 @@ +import React from 'react' +import _ from 'lodash' +import namor from 'namor' + +import CodeHighlight from './components/codeHighlight' +import ReactTable from '../src/index' + +class Filtering extends React.Component { + + constructor(props) { + super(props) + + const data = _.map(_.range(5553), d => { + return { + firstName: namor.generate({words: 1, numLen: 0}), + lastName: namor.generate({words: 1, numLen: 0}), + age: Math.floor(Math.random() * 30) + } + }) + + this.state = { + tableOptions: { + loading: false, + showPagination: true, + showPageSizeOptions: true, + showPageJump: true, + collapseOnSortingChange: true, + collapseOnPageChange: true, + collapseOnDataChange: true, + freezeWhenExpanded: false, + showFilters: true + }, + data: data + } + + this.setTableOption = this.setTableOption.bind(this); + } + + render() { + const columns = [{ + header: 'Name', + columns: [{ + header: 'First Name', + accessor: 'firstName', + filterMethod: (filter, row) => (row[filter.id].startsWith(filter.value) && row[filter.id].endsWith(filter.value)) + }, { + header: 'Last Name', + id: 'lastName', + accessor: d => d.lastName, + filterMethod: (filter, row) => (row[filter.id].includes(filter.value)) + }] + }, { + header: 'Info', + columns: [{ + header: 'Age', + accessor: 'age' + }] + }] + + return ( +
+
+

Table Options

+ + + { + Object.keys(this.state.tableOptions).map(optionKey => { + const optionValue = this.state.tableOptions[optionKey]; + return ( + + + + + ) + }) + } + +
{optionKey} + +
+
+
+ ((row[filter.id] + "").startsWith(filter.value))} + {...this.state.tableOptions} + SubComponent={(row) => { + return ( +
+ You can put any component you want here, even another React Table! +
+
+ { + return ( +
+ It even has access to the row data: + {() => JSON.stringify(row, null, 2)} +
+ ) + }} + /> +
+ ) + }} + /> +
+
+
+ Tip: Hold shift when sorting to multi-sort! +
+
+

Custom Filters In This Example

+

The default filter for all columns of a table if it is not specified in the configuration is set to be an exact match. Example: age == "23".

+

This example overrides the default filter behavior by setting the defaultFilterMethod table option to match on values that start with the filter text. Example: age.startsWith("2")

+

Each column can also be customized with the column filterMethod option:

+

In this example the firstName column filters on the value starting with and ending with the filter value.

+

In this example the lastName column filters on the value including the filter value anywhere in its text.

+
+ {() => this.getCode()} +
+ ) + } + + setTableOption(event) { + const target = event.target; + const value = target.type === 'checkbox' ? target.checked : target.value; + const name = target.name; + this.setState({ + tableOptions: { + ...this.state.tableOptions, + [name]: value + } + }) + } + + getCode() { + return ` +const columns = [{ + header: 'Name', + columns: [{ + header: 'First Name', + accessor: 'firstName', + filterMethod: (filter, row) => (row[filter.id].startsWith(filter.value) && row[filter.id].endsWith(filter.value)) + }, { + header: 'Last Name', + id: 'lastName', + accessor: d => d.lastName, + filterMethod: (filter, row) => (row[filter.id].includes(filter.value)) + }] +}, { + header: 'Info', + columns: [{ + header: 'Age', + accessor: 'age' + }] +}] + +export default ( + ((row[filter.id] + \"\").startsWith(filter.value))} + {...otherOptions} + SubComponent={(row) => { + return ( +
+ You can put any component you want here, even another React Table! +
+
+ { + return ( +
+ It even has access to the row data: + {() => JSON.stringify(row, null, 2)} +
+ ) + }} + /> +
+ ) + }} + /> +) + ` + } +} + +export default () => diff --git a/stories/PivotingSubComponents.js b/stories/PivotingSubComponents.js index f2f5bf5..c216942 100644 --- a/stories/PivotingSubComponents.js +++ b/stories/PivotingSubComponents.js @@ -33,7 +33,8 @@ export default () => { aggregate: vals => _.round(_.mean(vals)), render: row => { return {row.aggregated ? `${row.value} (avg)` : row.value} - } + }, + filterMethod: (filter, row) => (filter.value == `${row[filter.id]} (avg)`) }, { header: 'Visits', accessor: 'visits', @@ -50,6 +51,7 @@ export default () => { defaultPageSize={10} className='-striped -highlight' pivotBy={['firstName', 'lastName']} + showFilters={true} SubComponent={(row) => { return (
@@ -104,7 +106,8 @@ const columns = [{ aggregate: vals => _.round(_.mean(vals)), render: row => { return {row.aggregated ? \`\${row.value} (avg)\` : row.value} - } + }, + filterMethod: (filter, row) => (filter.value == \`\${row[filter.id]} (avg)\`) }, { header: 'Visits', accessor: 'visits', @@ -119,6 +122,7 @@ return ( defaultPageSize={10} className='-striped -highlight' pivotBy={['firstName', 'lastName']} + showFilters={true} SubComponent={(row) => { return (
diff --git a/stories/ServerSide.js b/stories/ServerSide.js index 1bbbcc1..4a7c8d1 100644 --- a/stories/ServerSide.js +++ b/stories/ServerSide.js @@ -14,23 +14,34 @@ const rawData = _.map(_.range(3424), d => { }) // Now let's mock the server. It's job is simple: use the table model to sort and return the page data -const requestData = (pageSize, page, sorting) => { +const requestData = (pageSize, page, sorting, filtering) => { return new Promise((resolve, reject) => { // On the server, you'll likely use SQL or noSQL or some other query language to do this. // For this mock, we'll just use lodash - const sortedData = _.orderBy(rawData, sorting.map(sort => { + let filteredData = rawData; + if (filtering.length) { + filteredData = filtering.reduce( + (filteredSoFar, nextFilter) => { + return filteredSoFar.filter( + (row) => { + return (row[nextFilter.id]+"").includes(nextFilter.value) + }) + } + , filteredData) + } + const sortedData = _.orderBy(filteredData, sorting.map(sort => { return row => { if (row[sort.id] === null || row[sort.id] === undefined) { return -Infinity } return typeof row[sort.id] === 'string' ? row[sort.id].toLowerCase() : row[sort.id] } - }), sorting.map(d => d.asc ? 'asc' : 'desc')) + }), sorting.map(d => d.desc ? 'desc' : 'asc')) // Be sure to send back the rows to be displayed and any other pertinent information, like how many pages there are total. const res = { rows: sortedData.slice(pageSize * page, (pageSize * page) + pageSize), - pages: Math.ceil(rawData.length / pageSize) + pages: Math.ceil(filteredData.length / pageSize) } // Here we'll simulate a server response with 500ms of delay. @@ -52,7 +63,7 @@ const ServerSide = React.createClass({ // You can set the `loading` prop of the table to true to use the built-in one or show you're own loading bar if you want. this.setState({loading: true}) // Request the data however you want. Here, we'll use our mocked service we created earlier - requestData(state.pageSize, state.page, state.sorting) + requestData(state.pageSize, state.page, state.sorting, state.filtering) .then((res) => { console.log(res.rows) // Now just get the rows of data to your React Table (and update anything else like total pages or loading) @@ -82,6 +93,7 @@ const ServerSide = React.createClass({ }]} manual // Forces table not to paginate or sort automatically, so we can handle it server-side defaultPageSize={10} + showFilters={true} data={this.state.data} // Set the rows to be displayed pages={this.state.pages} // Display the total number of pages loading={this.state.loading} // Display the loading overlay when we need it @@ -124,7 +136,8 @@ export default React.createClass({ Axios.post('mysite.com/data', { pageSize: state.pageSize, page: state.page, - sorting: state.sorting + sorting: state.sorting, + filtering: state.filtering }) .then((res) => { // Now update your state! @@ -151,6 +164,7 @@ export default React.createClass({ }]} manual // Forces table not to paginate or sort automatically, so we can handle it server-side defaultPageSize={10} + showFilters={true} data={this.state.data} // Set the rows to be displayed pages={this.state.pages} // Display the total number of pages loading={this.state.loading} // Display the loading overlay when we need it diff --git a/stories/SubComponents.js b/stories/SubComponents.js index 9d52f95..51f0c42 100644 --- a/stories/SubComponents.js +++ b/stories/SubComponents.js @@ -27,7 +27,8 @@ class SubComponents extends React.Component { collapseOnSortingChange: true, collapseOnPageChange: true, collapseOnDataChange: true, - freezeWhenExpanded: false + freezeWhenExpanded: false, + showFilters: false }, data: data }