From d9a4b6bd852e686d7a4ea4315d7b5f088b69ee0e Mon Sep 17 00:00:00 2001 From: tannerlinsley Date: Thu, 15 Aug 2019 14:16:52 -0600 Subject: [PATCH] refactor: improve renderer function ergonomics The renderer function for headers, columns, cells, aggregates, filters, etc used to mix properties from all of those contexts, including rows. Now thow contexts are located on their own reserved properties, eg. `Cell: ({ cell: { value}, row, column, ...instance }) => value` BREAKING CHANGE: The renderer function for headers, columns, cells, aggregates, filters, etc used --- README.md | 22 +-- examples/filtering/src/App.js | 31 ++++- src/hooks/useTable.js | 160 ++++++++++++---------- src/plugin-hooks/tests/useFilters.test.js | 4 +- src/plugin-hooks/tests/useGroupBy.test.js | 12 +- src/plugin-hooks/tests/useSortBy.test.js | 2 +- src/plugin-hooks/useFilters.js | 131 +++++++++++------- src/utils.js | 2 +- 8 files changed, 212 insertions(+), 152 deletions(-) diff --git a/README.md b/README.md index 158ba69..4808cbd 100644 --- a/README.md +++ b/README.md @@ -351,13 +351,13 @@ The following options are supported on any column object you can pass to `column - The data model for hidden columns is still calculated including sorting, filters, and grouping. - `Header: String | Function | React.Component => JSX` - Optional - - Defaults to `({ id }) => id` + - Defaults to `() => null` - Receives the table instance and column model as props - Must either be a **string or return valid JSX** - If a function/component is passed, it will be used for formatting the header value, eg. You can use a `Header` function to dynamically format the header using any table or column state. - `Cell: Function | React.Component => JSX` - Optional - - Defaults to `({ value }) => value` + - Defaults to `({ cell: { value } }) => value` - Receives the table instance and cell model as props - Must return valid JSX - This function (or component) is primarily used for formatting the column value, eg. If your column accessor returns a date object, you can use a `Cell` function to format that date to a readable format. @@ -424,7 +424,8 @@ The following properties are available on every `Column` object returned by the - `visible: Boolean` - The resolved visible state for the column, derived from the column's `show` property - `render: Function(type: String | Function | Component, ?props)` - - This function is used to render content in context of a column. + - This function is used to render content with the added context of a column. + - The entire table `instance` will be passed to the renderer with the addition of a `column` property, containing a reference to the column - If `type` is a string, will render using the `column[type]` renderer. React Table ships with default `Header` renderers. Other renderers like `Filter` are available via hooks like `useFilters`. - If a function or component is passed instead of a string, it will be be passed the table instance and column model as props and is expected to return any valid JSX. - `getHeaderProps: Function(?props)` @@ -479,7 +480,8 @@ The following additional properties are available on every `Cell` object returne - Custom props may be passed. **NOTE: Custom props will override built-in table props, so be careful!** - `render: Function(type: String | Function | Component, ?props)` - **Required** - - This function is used to render content in context of a cell. + - This function is used to render content with the added context of a cell. + - The entire table `instance` will be passed to the renderer with the addition of `column`, `row` and `cell` properties, containing a reference to each respective item. - If `type` is a string, will render using the `column[type]` renderer. React Table ships with a default `Cell` renderer. Other renderers like `Aggregated` are available via hooks like `useFilters`. - If a function or component is passed instead of a string, it will be be passed the table instance and cell model as props and is expected to return any valid JSX. @@ -797,7 +799,7 @@ The following properties are available on every `Column` object returned by the - An column-level function used to update the filter value for this column - `filterValue: any` - The current filter value for this column, resolved from the table state's `filters` object -- `preFilteredRows: Array` +- `preFilteredColumnRows: Array` - The array of rows that were originally passed to this columns filter **before** they were filtered. - This array of rows can be useful if building faceted filter options. @@ -1064,7 +1066,7 @@ function App() { // then sum any of those counts if they are // aggregated further aggregate: ['sum', 'count'], - Aggregated: ({ value }) => `${value} Names`, + Aggregated: ({ cell: { value } }) => `${value} Names`, }, { Header: 'Last Name', @@ -1074,7 +1076,7 @@ function App() { // being aggregated, then sum those counts if // they are aggregated further aggregate: ['sum', 'uniqueCount'], - Aggregated: ({ value }) => `${value} Unique Names`, + Aggregated: ({ cell: { value } }) => `${value} Unique Names`, }, ], }, @@ -1086,14 +1088,14 @@ function App() { accessor: 'age', // Aggregate the average age of visitors aggregate: 'average', - Aggregated: ({ value }) => `${value} (avg)`, + Aggregated: ({ cell: { value } }) => `${value} (avg)`, }, { Header: 'Visits', accessor: 'visits', // Aggregate the sum of all visits aggregate: 'sum', - Aggregated: ({ value }) => `${value} (total)`, + Aggregated: ({ cell: { value } }) => `${value} (total)`, }, { Header: 'Status', @@ -1104,7 +1106,7 @@ function App() { accessor: 'progress', // Use our custom roundedMedian aggregator aggregate: roundedMedian, - Aggregated: ({ value }) => `${value} (med)`, + Aggregated: ({ cell: { value } }) => `${value} (med)`, }, ], }, diff --git a/examples/filtering/src/App.js b/examples/filtering/src/App.js index d510680..80d31f6 100644 --- a/examples/filtering/src/App.js +++ b/examples/filtering/src/App.js @@ -36,14 +36,16 @@ const Styles = styled.div` ` // Define a default UI for filtering -function DefaultColumnFilter({ filterValue, setFilter }) { +function DefaultColumnFilter({ filterValue, preFilteredRows, setFilter }) { + const count = preFilteredRows.length + return ( { setFilter(e.target.value || undefined) // Set undefined to remove the filter entirely }} - placeholder="Search..." + placeholder={`Search ${count} records...`} /> ) } @@ -88,6 +90,7 @@ function SelectColumnFilter({ filterValue, setFilter, preFilteredRows, id }) { function SliderColumnFilter({ filterValue, setFilter, preFilteredRows, id }) { // Calculate the min and max // using the preFilteredRows + const [min, max] = React.useMemo( () => { let min = 0 @@ -120,7 +123,25 @@ function SliderColumnFilter({ filterValue, setFilter, preFilteredRows, id }) { // This is a custom UI for our 'between' or number range // filter. It uses two number boxes and filters rows to // ones that have values between the two -function NumberRangeColumnFilter({ filterValue = [], setFilter }) { +function NumberRangeColumnFilter({ + filterValue = [], + preFilteredRows, + setFilter, + id, +}) { + const [min, max] = React.useMemo( + () => { + let min = 0 + let max = 0 + preFilteredRows.forEach(row => { + min = Math.min(row.values[id], min) + max = Math.max(row.values[id], max) + }) + return [min, max] + }, + [id, preFilteredRows] + ) + return (
[val ? parseInt(val, 10) : undefined, old[1]]) }} - placeholder="Min" + placeholder={`Min (${min})`} style={{ width: '70px', marginRight: '0.5rem', @@ -148,7 +169,7 @@ function NumberRangeColumnFilter({ filterValue = [], setFilter }) { const val = e.target.value setFilter((old = []) => [old[0], val ? parseInt(val, 10) : undefined]) }} - placeholder="Max" + placeholder={`Max (${max})`} style={{ width: '70px', marginLeft: '0.5rem', diff --git a/src/hooks/useTable.js b/src/hooks/useTable.js index 50470ce..dd4d684 100755 --- a/src/hooks/useTable.js +++ b/src/hooks/useTable.js @@ -102,27 +102,30 @@ export const useTable = (props, ...plugins) => { ]) // Allow hooks to decorate columns (and trigger this memoization via deps) - columns = React.useMemo(() => { - if (process.env.NODE_ENV === 'development' && debug) - console.time('hooks.columnsBeforeHeaderGroups') - const newColumns = applyHooks( - instanceRef.current.hooks.columnsBeforeHeaderGroups, + columns = React.useMemo( + () => { + if (process.env.NODE_ENV === 'development' && debug) + console.time('hooks.columnsBeforeHeaderGroups') + const newColumns = applyHooks( + instanceRef.current.hooks.columnsBeforeHeaderGroups, + columns, + instanceRef.current + ) + if (process.env.NODE_ENV === 'development' && debug) + console.timeEnd('hooks.columnsBeforeHeaderGroups') + return newColumns + }, + [ columns, - instanceRef.current - ) - if (process.env.NODE_ENV === 'development' && debug) - console.timeEnd('hooks.columnsBeforeHeaderGroups') - return newColumns - }, [ - columns, - debug, - // eslint-disable-next-line react-hooks/exhaustive-deps - ...applyHooks( - instanceRef.current.hooks.columnsBeforeHeaderGroupsDeps, - [], - instanceRef.current - ), - ]) + debug, + // eslint-disable-next-line react-hooks/exhaustive-deps + ...applyHooks( + instanceRef.current.hooks.columnsBeforeHeaderGroupsDeps, + [], + instanceRef.current + ), + ] + ) // Make the headerGroups const headerGroups = React.useMemo( @@ -141,69 +144,72 @@ export const useTable = (props, ...plugins) => { }) // Access the row model - const [rows, rowPaths, flatRows] = React.useMemo(() => { - if (process.env.NODE_ENV === 'development' && debug) - console.time('getAccessedRows') + const [rows, rowPaths, flatRows] = React.useMemo( + () => { + if (process.env.NODE_ENV === 'development' && debug) + console.time('getAccessedRows') - let flatRows = 0 - const rowPaths = [] + let flatRows = 0 + const rowPaths = [] - // Access the row's data - const accessRow = (originalRow, i, depth = 0, parentPath = []) => { - // Keep the original reference around - const original = originalRow + // Access the row's data + const accessRow = (originalRow, i, depth = 0, parentPath = []) => { + // Keep the original reference around + const original = originalRow - // Make the new path for the row - const path = [...parentPath, i] + // Make the new path for the row + const path = [...parentPath, i] - flatRows++ - rowPaths.push(path.join('.')) + flatRows++ + rowPaths.push(path.join('.')) - // Process any subRows - const subRows = originalRow[subRowsKey] - ? originalRow[subRowsKey].map((d, i) => - accessRow(d, i, depth + 1, path) + // Process any subRows + const subRows = originalRow[subRowsKey] + ? originalRow[subRowsKey].map((d, i) => + accessRow(d, i, depth + 1, path) + ) + : [] + + const row = { + original, + index: i, + path, // used to create a key for each row even if not nested + subRows, + depth, + cells: [{}], // This is a dummy cell + } + + // Override common array functions (and the dummy cell's getCellProps function) + // to show an error if it is accessed without calling prepareRow + const unpreparedAccessWarning = () => { + throw new Error( + 'React-Table: You have not called prepareRow(row) one or more rows you are attempting to render.' ) - : [] + } + row.cells.map = unpreparedAccessWarning + row.cells.filter = unpreparedAccessWarning + row.cells.forEach = unpreparedAccessWarning + row.cells[0].getCellProps = unpreparedAccessWarning - const row = { - original, - index: i, - path, // used to create a key for each row even if not nested - subRows, - depth, - cells: [{}], // This is a dummy cell + // Create the cells and values + row.values = {} + instanceRef.current.columns.forEach(column => { + row.values[column.id] = column.accessor + ? column.accessor(originalRow, i, { subRows, depth, data }) + : undefined + }) + + return row } - // Override common array functions (and the dummy cell's getCellProps function) - // to show an error if it is accessed without calling prepareRow - const unpreparedAccessWarning = () => { - throw new Error( - 'React-Table: You have not called prepareRow(row) one or more rows you are attempting to render.' - ) - } - row.cells.map = unpreparedAccessWarning - row.cells.filter = unpreparedAccessWarning - row.cells.forEach = unpreparedAccessWarning - row.cells[0].getCellProps = unpreparedAccessWarning - - // Create the cells and values - row.values = {} - instanceRef.current.columns.forEach(column => { - row.values[column.id] = column.accessor - ? column.accessor(originalRow, i, { subRows, depth, data }) - : undefined - }) - - return row - } - - // Use the resolved data - const accessedData = data.map((d, i) => accessRow(d, i)) - if (process.env.NODE_ENV === 'development' && debug) - console.timeEnd('getAccessedRows') - return [accessedData, rowPaths, flatRows] - }, [debug, data, subRowsKey]) + // Use the resolved data + const accessedData = data.map((d, i) => accessRow(d, i)) + if (process.env.NODE_ENV === 'development' && debug) + console.timeEnd('getAccessedRows') + return [accessedData, rowPaths, flatRows] + }, + [debug, data, subRowsKey] + ) instanceRef.current.rows = rows instanceRef.current.rowPaths = rowPaths @@ -237,7 +243,7 @@ export const useTable = (props, ...plugins) => { return flexRender(Comp, { ...instanceRef.current, - ...column, + column, ...userProps, }) } @@ -361,7 +367,9 @@ export const useTable = (props, ...plugins) => { return flexRender(Comp, { ...instanceRef.current, - ...cell, + column, + row, + cell, ...userProps, }) } diff --git a/src/plugin-hooks/tests/useFilters.test.js b/src/plugin-hooks/tests/useFilters.test.js index 8bdd748..faf821a 100644 --- a/src/plugin-hooks/tests/useFilters.test.js +++ b/src/plugin-hooks/tests/useFilters.test.js @@ -39,8 +39,8 @@ const data = [ ] const defaultColumn = { - Cell: ({ value, column: { id } }) => `${id}: ${value}`, - Filter: ({ filterValue, setFilter }) => ( + Cell: ({ cell: { value }, column: { id } }) => `${id}: ${value}`, + Filter: ({ column: { filterValue, setFilter } }) => ( { diff --git a/src/plugin-hooks/tests/useGroupBy.test.js b/src/plugin-hooks/tests/useGroupBy.test.js index 127af19..1043b1e 100644 --- a/src/plugin-hooks/tests/useGroupBy.test.js +++ b/src/plugin-hooks/tests/useGroupBy.test.js @@ -40,7 +40,7 @@ const data = [ ] const defaultColumn = { - Cell: ({ value, column: { id } }) => `${id}: ${value}`, + Cell: ({ cell: { value }, column: { id } }) => `${id}: ${value}`, Filter: ({ filterValue, setFilter }) => ( `${value} Names`, + Aggregated: ({ cell: { value } }) => `${value} Names`, }, { Header: 'Last Name', accessor: 'lastName', aggregate: ['sum', 'uniqueCount'], - Aggregated: ({ value }) => `${value} Unique Names`, + Aggregated: ({ cell: { value } }) => `${value} Unique Names`, }, ], }, @@ -157,13 +157,13 @@ function App() { Header: 'Age', accessor: 'age', aggregate: 'average', - Aggregated: ({ value }) => `${value} (avg)`, + Aggregated: ({ cell: { value } }) => `${value} (avg)`, }, { Header: 'Visits', accessor: 'visits', aggregate: 'sum', - Aggregated: ({ value }) => `${value} (total)`, + Aggregated: ({ cell: { value } }) => `${value} (total)`, }, { Header: 'Status', @@ -173,7 +173,7 @@ function App() { Header: 'Profile Progress', accessor: 'progress', aggregate: roundedMedian, - Aggregated: ({ value }) => `${value} (med)`, + Aggregated: ({ cell: { value } }) => `${value} (med)`, }, ], }, diff --git a/src/plugin-hooks/tests/useSortBy.test.js b/src/plugin-hooks/tests/useSortBy.test.js index 1631748..2e4cc2d 100644 --- a/src/plugin-hooks/tests/useSortBy.test.js +++ b/src/plugin-hooks/tests/useSortBy.test.js @@ -31,7 +31,7 @@ const data = [ ] const defaultColumn = { - Cell: ({ value, column: { id } }) => `${id}: ${value}`, + Cell: ({ cell: { value }, column: { id } }) => `${id}: ${value}`, } function Table({ columns, data }) { diff --git a/src/plugin-hooks/useFilters.js b/src/plugin-hooks/useFilters.js index 8aa63f0..4325161 100755 --- a/src/plugin-hooks/useFilters.js +++ b/src/plugin-hooks/useFilters.js @@ -11,7 +11,6 @@ defaultState.filters = {} addActions('setFilter', 'setAllFilters') const propTypes = { - // General columns: PropTypes.arrayOf( PropTypes.shape({ disableFilters: PropTypes.bool, @@ -41,9 +40,15 @@ function useMain(instance) { state: [{ filters }, setState], } = instance + const preFilteredRows = rows + const setFilter = (id, updater) => { const column = columns.find(d => d.id === id) + if (!column) { + throw new Error(`React-Table: Could not find a column with id: ${id}`) + } + const filterMethod = getFilterMethod( column.filter, userFilterTypes || {}, @@ -124,74 +129,98 @@ function useMain(instance) { // cache for each row group (top-level rows, and each row's recursive subrows) // This would make multi-filtering a lot faster though. Too far? - const filteredRows = React.useMemo(() => { - if (manualFilters || !Object.keys(filters).length) { - return rows - } + const filteredRows = React.useMemo( + () => { + if (manualFilters || !Object.keys(filters).length) { + return rows + } - if (process.env.NODE_ENV === 'development' && debug) - console.info('getFilteredRows') + if (process.env.NODE_ENV === 'development' && debug) + console.info('getFilteredRows') - // Filters top level and nested rows - const filterRows = rows => { - let filteredRows = rows + // Filters top level and nested rows + const filterRows = (rows, depth = 0) => { + let filteredRows = rows - filteredRows = Object.entries(filters).reduce( - (filteredSoFar, [columnID, filterValue]) => { - // Find the filters column - const column = columns.find(d => d.id === columnID) + filteredRows = Object.entries(filters).reduce( + (filteredSoFar, [columnID, filterValue]) => { + // Find the filters column + const column = columns.find(d => d.id === columnID) - if (!column) { - return filteredSoFar - } + if (depth === 0) { + column.preFilteredRows = filteredSoFar + } - column.preFilteredRows = filteredSoFar + if (!column) { + return filteredSoFar + } - const filterMethod = getFilterMethod( - column.filter, - userFilterTypes || {}, - filterTypes - ) - - if (!filterMethod) { - console.warn( - `Could not find a valid 'column.filter' for column with the ID: ${column.id}.` + const filterMethod = getFilterMethod( + column.filter, + userFilterTypes || {}, + filterTypes ) - return filteredSoFar - } - // Pass the rows, id, filterValue and column to the filterMethod - // to get the filtered rows back - return filterMethod(filteredSoFar, columnID, filterValue, column) - }, - rows + if (!filterMethod) { + console.warn( + `Could not find a valid 'column.filter' for column with the ID: ${ + column.id + }.` + ) + return filteredSoFar + } + + // Pass the rows, id, filterValue and column to the filterMethod + // to get the filtered rows back + return filterMethod(filteredSoFar, columnID, filterValue, column) + }, + rows + ) + + // Apply the filter to any subRows + // We technically could do this recursively in the above loop, + // but that would severely hinder the API for the user, since they + // would be required to do that recursion in some scenarios + filteredRows = filteredRows.map(row => { + if (!row.subRows) { + return row + } + return { + ...row, + subRows: + row.subRows && row.subRows.length > 0 + ? filterRows(row.subRows, depth + 1) + : row.subRows, + } + }) + + return filteredRows + } + + const filteredRows = filterRows(rows) + + // Now that each filtered column has it's partially filtered rows, + // lets assign the final filtered rows to all of the other columns + const nonFilteredColumns = columns.filter( + column => !Object.keys(filters).includes(column.id) ) - // Apply the filter to any subRows - // We technically could do this recursively in the above loop, - // but that would severely hinder the API for the user, since they - // would be required to do that recursion in some scenarios - filteredRows = filteredRows.map(row => { - if (!row.subRows) { - return row - } - return { - ...row, - subRows: filterRows(row.subRows), - } + // This essentially enables faceted filter options to be built easily + // using every column's preFilteredRows value + nonFilteredColumns.forEach(column => { + column.preFilteredRows = filteredRows }) return filteredRows - } - - return filterRows(rows) - }, [manualFilters, filters, debug, rows, columns, userFilterTypes]) + }, + [manualFilters, filters, debug, rows, columns, userFilterTypes] + ) return { ...instance, setFilter, setAllFilters, - preFilteredRows: rows, + preFilteredRows, rows: filteredRows, } } diff --git a/src/utils.js b/src/utils.js index 51ab106..cb61c3d 100755 --- a/src/utils.js +++ b/src/utils.js @@ -39,7 +39,7 @@ export function decorateColumn(column, defaultColumn, parent, depth, index) { column = { Header: () => null, - Cell: ({ value = '' }) => value, + Cell: ({ cell: { value = '' } }) => value, show: true, ...column, id,