diff --git a/README.md b/README.md index 2bfae54..28b03ea 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ import { - [Basic](https://codesandbox.io/s/github/tannerlinsley/react-table/tree/master/examples/basic) - [Sorting - Client Side](https://codesandbox.io/s/github/tannerlinsley/react-table/tree/master/examples/sorting-client-side) -- [Filtering - Client Side`](https://codesandbox.io/s/github/tannerlinsley/react-table/tree/master/examples/filtering-client-side) +- [Filtering - Client Side](https://codesandbox.io/s/github/tannerlinsley/react-table/tree/master/examples/filtering-client-side) # Concepts @@ -286,9 +286,6 @@ The following options are supported via the main options object passed to `useTa - The default column object for every column passed to React Table. - Column-specific properties will override the properties in this object, eg. `{ ...defaultColumn, ...userColumn }` - This is particularly useful for adding global column properties. For instance, when using the `useFilters` plugin hook, add a default `Filter` renderer for every column, eg.`{ Filter: MyDefaultFilterComponent }` -- `useColumns: Function` - - Optional - - This hook overrides the internal `useColumns` hooks used by `useTable`. You probably never want to override this unless you are testing or developing new features for React Table - `useRows: Function` - Optional - This hook overrides the internal `useRows` hooks used by `useTable`. You probably never want to override this unless you are testing or developing new features for React Table @@ -512,6 +509,11 @@ function MyTable({ columns, data }) { `useSortBy` is the hook that implements **row sorting**. It also support multi-sort (keyboard required). +- Multi-sort is enabled by default +- To sort the table via UI, attach the props generated from each column's `getSortByToggleProps()`, then click any of those elements. +- To multi-sort the table via UI, hold `shift` while clicking on any of those same elements that have the props from `getSortByToggleProps()` attached. +- To programmatically sort (or multi-sort) any column, use the `toggleSortBy` method located on the instance or each individual column. + ### Table Options The following options are supported via the main options object passed to `useTable(options)` @@ -540,14 +542,20 @@ The following options are supported via the main options object passed to `useTa ### `Column` Options +The following options are supported on any `Column` object passed to the `columns` options in `useTable()` + +- `disableSorting: Bool` + - Optional + - Defualts to `false` + - If set to `true`, the sorting for this column will be disabled - `sortDescFirst: Bool` - Optional - Defaults to `false` - - If true, the first sort direction for this column will be descending instead of ascending + - If set to `true`, the first sort direction for this column will be descending instead of ascending - `sortInverted: Bool` - Optional - Defaults to `false` - - If true, the underlying sorting direction will be inverted, but the UI will not. + - If set to `true`, the underlying sorting direction will be inverted, but the UI will not. - This may be useful in situations where positive and negative connotation is inverted, eg. a Golfing score where a lower score is considered more positive than a higher one. - `sortType: String | Function` - If a **function** is passed, it must be **memoized** @@ -557,79 +565,95 @@ The following options are supported via the main options object passed to `useTa - If a `function` is passed, it will be used. - For mor information on sort types, see [Sorting](TODO) -### Instance Variables - -The following values are provided to the table `instance`: - -- `rows: Array` - - An array of **sorted** rows. - -### Example - -```js -const state = useTableState({ sortBy: [{ id: 'firstName', desc: true }] }) - -const { rows } = useTable( - { - // state[0].sortBy === [{ id: 'firstName', desc: true }] - state, - }, - useSortBy -) -``` - -## `useGroupBy` - -- Plugin Hook -- Optional - -`useGroupBy` is the hook that implements **row grouping and aggregation**. - -### Table Options - -The following options are supported via the main options object passed to `useTable(options)` - -- `state[0].groupBy: Array` - - Must be **memoized** - - An array of groupBy ID strings, controlling which columns are used to calculate row grouping and aggregation. This information is stored in state since the table is allowed to manipulate the groupBy through user interaction. -- `groupByFn: Function` - - Must be **memoized** - - Defaults to [`defaultGroupByFn`](TODO) - - This function is responsible for grouping rows based on the `state.groupBy` keys provided. It's very rare you would need to customize this function. -- `manualGroupBy: Bool` - - Enables groupBy detection and functionality, but does not automatically perform row grouping. - - Turn this on if you wish to implement your own row grouping outside of the table (eg. server-side or manual row grouping/nesting) -- `disableGrouping: Bool` - - Disables groupBy for the entire table. -- `aggregations: Object` - - Must be **memoized** - - Allows overriding or adding additional aggregation functions for use when grouping/aggregating row values. If an aggregation key isn't found on this object, it will default to using the [built-in aggregation functions](TODO) - ### `Instance` Properties The following values are provided to the table `instance`: - `rows: Array` - - An array of **grouped and aggregated** rows. + - An array of **sorted** rows. +- `preSortedRows: Array` + - The array of rows that were originally sorted. +- `toggleSortBy: Function(ColumnID: String, descending: Bool, isMulti: Bool) => void` + - This function can be used to programmatically toggle the sorting for any specific column + +### `Column` Properties + +The following properties are available on every `Column` object returned by the table instance. + +- `canSortBy: Bool` + - Denotes whether a column is sortable or not depending on if it has a valid accessor/data model or is manually disabled via an option. +- `toggleSortBy: Function(descending, multi) => void` + - This function can be used to programmatically toggle the sorting for this column. + - This function is similar to the `instance`-level `toggleSortBy`, however, passing a columnID is not required since it is located on a `Column` object already. +- `getSortByToggleProps: Function(props) => props` + - **Required** + - This function is used to resolve any props needed for this column's UI that is responsible for toggling the sort direction when the user clicks it. + - You can use the `getSortByToggleProps` hook to extend its functionality. + - Custom props may be passed. **NOTE: Custom props may override built-in sortBy props, so be careful!** +- `sorted: Boolean` + - Denotes whether this column is currently being sorted +- `sortedIndex: Int` + - If the column is currently sorted, this integer will be the index in the `sortBy` array from state that corresponds to this column. + - If this column is not sorted, the index will always be `-1` +- `sortedDesc: Bool` + - If the column is currently sorted, this denotes whether the column's sort direction is descending or not. + - If `true`, the column is sorted `descending` + - If `false`, the column is sorted `ascending` + - If `undefined`, the column is not currently being sorted. ### Example ```js -const state = useTableState({ groupBy: ['firstName'] }) +function Table({ columns, data }) { + // Set some default sorting state + const state = useTableState({ sortBy: [{ id: 'firstName', desc: true }] }) -const aggregations = React.useMemo(() => ({ - customSum: (values, rows) => values.reduce((sum, next) => sum + next, 0), -})) + const { getTableProps, headerGroups, rows, prepareRow } = useTable( + { + columns, + data, + }, + useSortBy // Use the sortBy hook + ) -const { rows } = useTable( - { - state, // state[0].groupBy === ['firstName'] - manualGroupBy: false, - disableGrouping: false, - aggregations, - }, - useGroupBy -) + return ( + + + {headerGroups.map(headerGroup => ( + + {headerGroup.headers.map(column => ( + // Add the sorting props to control sorting. For this example + // we can add them into the header props + + ))} + + ))} + + + {rows.map( + (row, i) => + prepareRow(row) || ( + + {row.cells.map(cell => { + return + })} + + ) + )} + +
+ {column.render('Header')} + + {/* Add a sort direction indicator */} + + {column.sorted ? (column.sortedDesc ? ' 🔽' : ' 🔼') : ''} + + {/* Add a sort index indicator */} + ({column.sorted ? column.sortedIndex + 1 : ''}) + +
{cell.render('Cell')}
+ ) +} ``` ## `useFilters` @@ -663,6 +687,27 @@ The following options are supported via the main options object passed to `useTa - Allows overriding or adding additional filter types for columns to use. If a column's filter type isn't found on this object, it will default to using the [built-in filter types](TODO). - For mor information on filter types, see [Filtering](TODO) +### `Column` Options + +The following options are supported on any `Column` object passed to the `columns` options in `useTable()` + +- `Filter: Function | React.Component => JSX` + - **Required** + - Receives the table instance and column model as props + - Must return valid JSX + - This function (or component) is used to render this column's filter UI, eg. +- `disableFilters: Bool` + - Optional + - If set to `true`, will disable filtering for this column +- `filter: String | Function` + - Optional + - Defaults to [`text`](TODO) + - The resolved function from the this string/function will be used to filter the this column's data. + - If a `string` is passed, the function with that name located on either the custom `filterTypes` option or the built-in filtering types object will be used. If + - If a `function` is passed, it will be used directly. + - For mor information on filter types, see [Filtering](TODO) + - If a **function** is passed, it must be **memoized** + ### `Instance` Properties The following values are provided to the table `instance`: @@ -677,6 +722,20 @@ The following values are provided to the table `instance`: - `setAllFilters: Function(filtersObject) => void` - An instance-level function used to update the values for **all** filters on the table, all at once. +### `Column` Properties + +The following properties are available on every `Column` object returned by the table instance. + +- `canFilter: Bool` + - Denotes whether a column is filterable or not depending on if it has a valid accessor/data model or is manually disabled via an option. +- `setFilter: Function(filterValue) => void` + - 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` + - 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. + ### Example ```js @@ -723,6 +782,100 @@ const { rows } = useTable( ) ``` +## `useGroupBy` + +- Plugin Hook +- Optional + +`useGroupBy` is the hook that implements **row grouping and aggregation**. + +- Each column's `getGroupByToggleProps()` function can be used to generate the props needed to make a clickable UI element that will toggle the grouping on or off for a specific column. +- Instance and column-level `toggleGroupBy` functions are also made available for programmatic grouping. + +### Table Options + +The following options are supported via the main options object passed to `useTable(options)` + +- `state[0].groupBy: Array` + - Must be **memoized** + - An array of groupBy ID strings, controlling which columns are used to calculate row grouping and aggregation. This information is stored in state since the table is allowed to manipulate the groupBy through user interaction. +- `groupByFn: Function` + - Must be **memoized** + - Defaults to [`defaultGroupByFn`](TODO) + - This function is responsible for grouping rows based on the `state.groupBy` keys provided. It's very rare you would need to customize this function. +- `manualGroupBy: Bool` + - Enables groupBy detection and functionality, but does not automatically perform row grouping. + - Turn this on if you wish to implement your own row grouping outside of the table (eg. server-side or manual row grouping/nesting) +- `disableGrouping: Bool` + - Disables groupBy for the entire table. +- `aggregations: Object` + - Must be **memoized** + - Allows overriding or adding additional aggregation functions for use when grouping/aggregating row values. If an aggregation key isn't found on this object, it will default to using the [built-in aggregation functions](TODO) + +### `Column` Options + +The following options are supported on any `Column` object passed to the `columns` options in `useTable()` + +- `Aggregated: Function | React.Component => JSX` + - Optional + - Defaults to this column's `Cell` formatter + - Receives the table instance and cell model as props + - Must return valid JSX + - This function (or component) formats this column's value when it is being grouped and aggregated, eg. If this column was showing the number of visits for a user to a website and it was currently being grouped to show an **average** of the values, the `Aggregated` function for this column could format that value to `1,000 Avg. Visits` +- `disableGrouping: Boolean` + - Defaults to `true` + - If `true`, this column is able to be grouped. + +### `Instance` Properties + +The following values are provided to the table `instance`: + +- `rows: Array` + - An array of **grouped and aggregated** rows. +- `preGroupedRows: Array` + - The array of rows originally used to create the grouped rows. +- `toggleGroupBy: Function(columnID: String, ?set: Bool) => void` + - This function can be used to programmatically set or toggle the groupBy state for a specific column. + +### `Column` Properties + +The following properties are available on every `Column` object returned by the table instance. + +- `canGroupBy: Boolean` + - If `true`, this column is able to be grouped. + - This is resolved from the column having a valid accessor / data model, and not being manually disabled via other `useGroupBy` related options +- `grouped: Boolean` + - If `true`, this column is currently being grouped +- `groupedIndex: Int` + - If this column is currently being grouped, this integer is the index of this column's ID in the table state's `groupBy` array. +- `toggleGroupBy: Function(?set: Bool) => void` + - This function can be used to programmatically set or toggle the groupBy state fo this column. +- `getGroupByToggleProps: Function(props) => props` + - **Required** + - This function is used to resolve any props needed for this column's UI that is responsible for toggling grouping when the user clicks it. + - You can use the `getGroupByToggleProps` hook to extend its functionality. + - Custom props may be passed. **NOTE: Custom props may override built-in sortBy props, so be careful!** + +### Example + +```js +const state = useTableState({ groupBy: ['firstName'] }) + +const aggregations = React.useMemo(() => ({ + customSum: (values, rows) => values.reduce((sum, next) => sum + next, 0), +})) + +const { rows } = useTable( + { + state, // state[0].groupBy === ['firstName'] + manualGroupBy: false, + disableGrouping: false, + aggregations, + }, + useGroupBy +) +``` + ## `useExpanded` - Plugin Hook diff --git a/src/hooks/useColumns.js b/src/hooks/useColumns.js deleted file mode 100755 index 7e79cff..0000000 --- a/src/hooks/useColumns.js +++ /dev/null @@ -1,229 +0,0 @@ -import { useMemo } from 'react' -import PropTypes from 'prop-types' - -import { getBy } from '../utils' - -const propTypes = { - // General - columns: PropTypes.arrayOf( - PropTypes.shape({ - Cell: PropTypes.any, - Header: PropTypes.any, - }) - ), - defaultColumn: PropTypes.any, -} - -// Find the depth of the columns -function findMaxDepth(columns, depth = 0) { - return columns.reduce((prev, curr) => { - if (curr.columns) { - return Math.max(prev, findMaxDepth(curr.columns, depth + 1)) - } - return depth - }, 0) -} - -function decorateColumn(column, defaultColumn, parent, depth, index) { - // Apply the defaultColumn - column = { ...defaultColumn, ...column } - - // First check for string accessor - let { id, accessor, Header } = column - - if (typeof accessor === 'string') { - id = id || accessor - const accessorString = accessor - accessor = row => getBy(row, accessorString) - } - - if (!id && typeof Header === 'string') { - id = Header - } - - if (!id && column.columns) { - console.error(column) - throw new Error('A column ID (or unique "Header" value) is required!') - } - - if (!id) { - console.error(column) - throw new Error('A column ID (or string accessor) is required!') - } - - column = { - Header: ({ id }) => id, - Cell: ({ value }) => value, - show: true, - ...column, - id, - accessor, - parent, - depth, - index, - } - - return column -} - -// Build the visible columns, headers and flat column list -function decorateColumnTree(columns, defaultColumn, parent, depth = 0) { - return columns.map((column, columnIndex) => { - column = decorateColumn(column, defaultColumn, parent, depth, columnIndex) - if (column.columns) { - column.columns = decorateColumnTree( - column.columns, - defaultColumn, - column, - depth + 1 - ) - } - return column - }) -} - -// Build the header groups from the bottom up -function makeHeaderGroups(columns, maxDepth, defaultColumn) { - const headerGroups = [] - - const removeChildColumns = column => { - delete column.columns - if (column.parent) { - removeChildColumns(column.parent) - } - } - columns.forEach(removeChildColumns) - - const buildGroup = (columns, depth = 0) => { - const headerGroup = { - headers: [], - } - - const parentColumns = [] - - const hasParents = columns.some(col => col.parent) - - columns.forEach(column => { - const isFirst = !parentColumns.length - let latestParentColumn = [...parentColumns].reverse()[0] - - // If the column has a parent, add it if necessary - if (column.parent) { - if (isFirst || latestParentColumn.originalID !== column.parent.id) { - parentColumns.push({ - ...column.parent, - originalID: column.parent.id, - id: [column.parent.id, parentColumns.length].join('_'), - }) - } - } else if (hasParents) { - // If other columns have parents, add a place holder if necessary - const placeholderColumn = decorateColumn( - { - originalID: [column.id, 'placeholder', maxDepth - depth].join('_'), - id: [ - column.id, - 'placeholder', - maxDepth - depth, - parentColumns.length, - ].join('_'), - }, - defaultColumn - ) - if ( - isFirst || - latestParentColumn.originalID !== placeholderColumn.originalID - ) { - parentColumns.push(placeholderColumn) - } - } - - // Establish the new columns[] relationship on the parent - if (column.parent || hasParents) { - latestParentColumn = [...parentColumns].reverse()[0] - latestParentColumn.columns = latestParentColumn.columns || [] - if (!latestParentColumn.columns.includes(column)) { - latestParentColumn.columns.push(column) - } - } - - headerGroup.headers.push(column) - }) - - headerGroups.push(headerGroup) - - if (parentColumns.length) { - buildGroup(parentColumns) - } - } - - buildGroup(columns) - - return headerGroups.reverse() -} - -export const useColumns = props => { - const { - debug, - columns: userColumns, - defaultColumn = {}, - state: [{ groupBy }], - } = props - - PropTypes.checkPropTypes(propTypes, props, 'property', 'useColumns') - - const { columns, headerGroups, headers } = useMemo(() => { - if (debug) console.info('getColumns') - - // Decorate All the columns - let columnTree = decorateColumnTree(userColumns, defaultColumn) - - // Get the flat list of all columns - let columns = flattenBy(columnTree, 'columns') - - columns = [ - ...groupBy.map(g => columns.find(col => col.id === g)), - ...columns.filter(col => !groupBy.includes(col.id)), - ] - - // Get headerGroups - const headerGroups = makeHeaderGroups( - columns, - findMaxDepth(columnTree), - defaultColumn - ) - - const headers = flattenBy(headerGroups, 'headers') - - return { - columns, - headerGroups, - headers, - } - }, [debug, defaultColumn, groupBy, userColumns]) - - return { - ...props, - columns, - headerGroups, - headers, - } - - function flattenBy(columns, childKey) { - const flatColumns = [] - - const recurse = columns => { - columns.forEach(d => { - if (!d[childKey]) { - flatColumns.push(d) - } else { - recurse(d[childKey]) - } - }) - } - - recurse(columns) - - return flatColumns - } -} diff --git a/src/hooks/useRows.js b/src/hooks/useRows.js deleted file mode 100755 index aa32f5d..0000000 --- a/src/hooks/useRows.js +++ /dev/null @@ -1,66 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' - -const propTypes = { - subRowsKey: PropTypes.string, -} - -export const useRows = props => { - PropTypes.checkPropTypes(propTypes, props, 'property', 'useRows') - - const { debug, columns, subRowsKey = 'subRows', data } = props - - const accessedRows = React.useMemo(() => { - if (debug) console.info('getAccessedRows') - - // Access the row's data - const accessRow = (originalRow, i, depth = 0) => { - // Keep the original reference around - const original = originalRow - - // Process any subRows - const subRows = originalRow[subRowsKey] - ? originalRow[subRowsKey].map((d, i) => accessRow(d, i, depth + 1)) - : undefined - - const row = { - original, - index: i, - path: [i], // 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 - - // Create the cells and values - row.values = {} - columns.forEach(column => { - row.values[column.id] = column.accessor - ? column.accessor(originalRow, i, { subRows, depth, data }) - : undefined - }) - - return row - } - - // Use the resolved data - return data.map((d, i) => accessRow(d, i)) - }, [debug, data, subRowsKey, columns]) - - return { - ...props, - rows: accessedRows, - } -} diff --git a/src/hooks/useTable.js b/src/hooks/useTable.js index 0bd2183..e8e5433 100755 --- a/src/hooks/useTable.js +++ b/src/hooks/useTable.js @@ -1,14 +1,30 @@ +import React from 'react' import PropTypes from 'prop-types' // -import { applyHooks, applyPropHooks, mergeProps, flexRender } from '../utils' +import { + applyHooks, + applyPropHooks, + mergeProps, + flexRender, + decorateColumnTree, + makeHeaderGroups, + findMaxDepth, +} from '../utils' import { useTableState } from './useTableState' -import { useColumns } from './useColumns' import { useRows } from './useRows' const propTypes = { // General data: PropTypes.array.isRequired, + columns: PropTypes.arrayOf( + PropTypes.shape({ + Cell: PropTypes.any, + Header: PropTypes.any, + }) + ).isRequired, + defaultColumn: PropTypes.object, + subRowsKey: PropTypes.string, debug: PropTypes.bool, } @@ -23,8 +39,10 @@ export const useTable = (props, ...plugins) => { let { data, state: userState, - useColumns: userUseColumns = useColumns, useRows: userUseRows = useRows, + columns: userColumns, + defaultColumn = {}, + subRowsKey = 'subRows', debug, } = props @@ -62,11 +80,100 @@ export const useTable = (props, ...plugins) => { if (debug) console.time('hooks') // Loop through plugins to build the api out - api = [userUseColumns, userUseRows, ...plugins] + api = [userUseRows, ...plugins] .filter(Boolean) .reduce((prev, next) => next(prev), api) if (debug) console.timeEnd('hooks') + // Compute columns, headerGroups and headers + const columnInfo = React.useMemo( + () => { + if (debug) console.info('buildColumns/headerGroup/headers') + // Decorate All the columns + let columnTree = decorateColumnTree(userColumns, defaultColumn) + + // Get the flat list of all columns + let columns = flattenBy(columnTree, 'columns') + + // Allow hooks to decorate columns + if (debug) console.time('hooks.columnsBeforeHeaderGroups') + columns = applyHooks(api.hooks.columnsBeforeHeaderGroups, columns, api) + if (debug) console.timeEnd('hooks.columnsBeforeHeaderGroups') + + // Make the headerGroups + const headerGroups = makeHeaderGroups( + columns, + findMaxDepth(columnTree), + defaultColumn + ) + + const headers = flattenBy(headerGroups, 'headers') + + return { + columns, + headerGroups, + headers, + } + }, + [api, debug, defaultColumn, userColumns] + ) + + // Place the columns, headerGroups and headers on the api + Object.assign(api, columnInfo) + + // Access the row model + api.rows = React.useMemo( + () => { + if (debug) console.info('getAccessedRows') + + // Access the row's data + const accessRow = (originalRow, i, depth = 0) => { + // Keep the original reference around + const original = originalRow + + // Process any subRows + const subRows = originalRow[subRowsKey] + ? originalRow[subRowsKey].map((d, i) => accessRow(d, i, depth + 1)) + : undefined + + const row = { + original, + index: i, + path: [i], // 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 + + // Create the cells and values + row.values = {} + api.columns.forEach(column => { + row.values[column.id] = column.accessor + ? column.accessor(originalRow, i, { subRows, depth, data }) + : undefined + }) + + return row + } + + // Use the resolved data + return data.map((d, i) => accessRow(d, i)) + }, + [debug, data, subRowsKey, api.columns] + ) + // Determine column visibility api.columns.forEach(column => { column.visible = @@ -215,3 +322,21 @@ export const useTable = (props, ...plugins) => { return api } + +function flattenBy(columns, childKey) { + const flatColumns = [] + + const recurse = columns => { + columns.forEach(d => { + if (!d[childKey]) { + flatColumns.push(d) + } else { + recurse(d[childKey]) + } + }) + } + + recurse(columns) + + return flatColumns +} diff --git a/src/index.js b/src/index.js index 3dc2797..1ba62d5 100755 --- a/src/index.js +++ b/src/index.js @@ -1,8 +1,6 @@ import * as utils from './utils' export { utils } export { useTable } from './hooks/useTable' -export { useColumns } from './hooks/useColumns' -export { useRows } from './hooks/useRows' export { useTableState, defaultState } from './hooks/useTableState' export { useExpanded } from './plugin-hooks/useExpanded' export { useFilters } from './plugin-hooks/useFilters' diff --git a/src/plugin-hooks/useFilters.js b/src/plugin-hooks/useFilters.js index f3ce423..4f3d8e5 100755 --- a/src/plugin-hooks/useFilters.js +++ b/src/plugin-hooks/useFilters.js @@ -14,14 +14,11 @@ const propTypes = { // General columns: PropTypes.arrayOf( PropTypes.shape({ - filterFn: PropTypes.func, - filterAll: PropTypes.bool, - canFilter: PropTypes.bool, + disableFilters: PropTypes.bool, Filter: PropTypes.any, }) ), - filterFn: PropTypes.func, manualFilters: PropTypes.bool, } @@ -99,12 +96,12 @@ export const useFilters = props => { hooks.columns.push(columns => { columns.forEach(column => { - const { id, accessor, canFilter } = column + const { id, accessor, disableFilters: columnDisableFilters } = column // Determine if a column is filterable column.canFilter = accessor ? getFirstDefined( - canFilter, + columnDisableFilters, disableFilters === true ? false : undefined, true ) @@ -143,13 +140,12 @@ export const useFilters = props => { // Find the filters column const column = columns.find(d => d.id === columnID) - column.preFilteredRows = filteredSoFar - - // Don't filter hidden columns or columns that have had their filters disabled - if (!column || column.filterable === false) { + if (!column) { return filteredSoFar } + column.preFilteredRows = filteredSoFar + const filterMethod = getFilterMethod( column.filter, userFilterTypes || {}, diff --git a/src/plugin-hooks/useGroupBy.js b/src/plugin-hooks/useGroupBy.js index 5db93b5..4e89d8f 100755 --- a/src/plugin-hooks/useGroupBy.js +++ b/src/plugin-hooks/useGroupBy.js @@ -20,12 +20,13 @@ const propTypes = { columns: PropTypes.arrayOf( PropTypes.shape({ aggregate: PropTypes.func, - canGroupBy: PropTypes.bool, + disableGrouping: PropTypes.bool, Aggregated: PropTypes.any, }) ), groupByFn: PropTypes.func, manualGrouping: PropTypes.bool, + disableGrouping: PropTypes.bool, aggregations: PropTypes.object, } @@ -44,13 +45,23 @@ export const useGroupBy = props => { state: [{ groupBy }, setState], } = props + // Sort grouped columns to the start of the column list + // before the headers are built + hooks.columnsBeforeHeaderGroups.push(columns => { + return [ + ...groupBy.map(g => columns.find(col => col.id === g)), + ...columns.filter(col => !groupBy.includes(col.id)), + ] + }) + columns.forEach(column => { - const { id, accessor, canGroupBy } = column + const { id, accessor, disableGrouping: columnDisableGrouping } = column column.grouped = groupBy.includes(id) + column.groupedIndex = groupBy.indexOf(id) column.canGroupBy = accessor ? getFirstDefined( - canGroupBy, + columnDisableGrouping, disableGrouping === true ? false : undefined, true ) @@ -115,81 +126,78 @@ export const useGroupBy = props => { hooks.columns.push(addGroupByToggleProps) hooks.headers.push(addGroupByToggleProps) - const groupedRows = useMemo(() => { - if (manualGroupBy || !groupBy.length) { - return rows - } - if (debug) console.info('getGroupedRows') - // Find the columns that can or are aggregating - - // Uses each column to aggregate rows into a single value - const aggregateRowsToValues = rows => { - const values = {} - columns.forEach(column => { - const columnValues = rows.map(d => d.values[column.id]) - let aggregate = - userAggregations[column.aggregate] || - aggregations[column.aggregate] || - column.aggregate - if (typeof aggregate === 'function') { - values[column.id] = aggregate(columnValues, rows) - } else if (aggregate) { - throw new Error( - `Invalid aggregate "${aggregate}" passed to column with ID: "${ - column.id - }"` - ) - } else { - values[column.id] = columnValues[0] - } - }) - return values - } - - // Recursively group the data - const groupRecursively = (rows, groupBy, depth = 0) => { - // This is the last level, just return the rows - if (depth >= groupBy.length) { + const groupedRows = useMemo( + () => { + if (manualGroupBy || !groupBy.length) { return rows } + if (debug) console.info('getGroupedRows') + // Find the columns that can or are aggregating - // Group the rows together for this level - let groupedRows = Object.entries(groupByFn(rows, groupBy[depth])).map( - ([groupByVal, subRows], index) => { - // Recurse to sub rows before aggregation - subRows = groupRecursively(subRows, groupBy, depth + 1) - - const values = aggregateRowsToValues(subRows) - - const row = { - groupByID: groupBy[depth], - groupByVal, - values, - subRows, - depth, - index, + // Uses each column to aggregate rows into a single value + const aggregateRowsToValues = rows => { + const values = {} + columns.forEach(column => { + const columnValues = rows.map(d => d.values[column.id]) + let aggregate = + userAggregations[column.aggregate] || + aggregations[column.aggregate] || + column.aggregate + if (typeof aggregate === 'function') { + values[column.id] = aggregate(columnValues, rows) + } else if (aggregate) { + throw new Error( + `Invalid aggregate "${aggregate}" passed to column with ID: "${ + column.id + }"` + ) + } else { + values[column.id] = columnValues[0] } - return row + }) + return values + } + + // Recursively group the data + const groupRecursively = (rows, groupBy, depth = 0) => { + // This is the last level, just return the rows + if (depth >= groupBy.length) { + return rows } - ) - return groupedRows - } + // Group the rows together for this level + let groupedRows = Object.entries(groupByFn(rows, groupBy[depth])).map( + ([groupByVal, subRows], index) => { + // Recurse to sub rows before aggregation + subRows = groupRecursively(subRows, groupBy, depth + 1) - // Assign the new data - return groupRecursively(rows, groupBy) - }, [ - manualGroupBy, - groupBy, - debug, - rows, - columns, - userAggregations, - groupByFn, - ]) + const values = aggregateRowsToValues(subRows) + + const row = { + groupByID: groupBy[depth], + groupByVal, + values, + subRows, + depth, + index, + } + return row + } + ) + + return groupedRows + } + + // Assign the new data + return groupRecursively(rows, groupBy) + }, + [manualGroupBy, groupBy, debug, rows, columns, userAggregations, groupByFn] + ) return { ...props, + toggleGroupBy, rows: groupedRows, + preGroupedRows: rows, } } diff --git a/src/plugin-hooks/useSortBy.js b/src/plugin-hooks/useSortBy.js index b04b385..a8ab546 100755 --- a/src/plugin-hooks/useSortBy.js +++ b/src/plugin-hooks/useSortBy.js @@ -22,6 +22,7 @@ const propTypes = { PropTypes.shape({ sortType: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), sortDescFirst: PropTypes.bool, + disableSorting: PropTypes.bool, }) ), orderByFn: PropTypes.func, @@ -67,10 +68,10 @@ export const useSortBy = props => { } columns.forEach(column => { - const { accessor, canSortBy } = column + const { accessor, disableSorting: columnDisableSorting } = column column.canSortBy = accessor ? getFirstDefined( - canSortBy, + columnDisableSorting, disableSorting === true ? false : undefined, true ) @@ -78,7 +79,7 @@ export const useSortBy = props => { }) // Updates sorting based on a columnID, desc flag and multi flag - const toggleSortByID = (columnID, desc, multi) => { + const toggleSortBy = (columnID, desc, multi) => { return setState(old => { const { sortBy } = old @@ -167,7 +168,7 @@ export const useSortBy = props => { columns.forEach(column => { if (column.canSortBy) { column.toggleSortBy = (desc, multi) => - toggleSortByID(column.id, desc, multi) + toggleSortBy(column.id, desc, multi) } }) return columns @@ -279,6 +280,8 @@ export const useSortBy = props => { return { ...props, + toggleSortBy, rows: sortedRows, + preSortedRows: rows, } } diff --git a/src/utils.js b/src/utils.js index 2493a44..3534a6b 100755 --- a/src/utils.js +++ b/src/utils.js @@ -1,5 +1,153 @@ import React from 'react' +// Find the depth of the columns +export function findMaxDepth(columns, depth = 0) { + return columns.reduce((prev, curr) => { + if (curr.columns) { + return Math.max(prev, findMaxDepth(curr.columns, depth + 1)) + } + return depth + }, 0) +} + +export function decorateColumn(column, defaultColumn, parent, depth, index) { + // Apply the defaultColumn + column = { ...defaultColumn, ...column } + + // First check for string accessor + let { id, accessor, Header } = column + + if (typeof accessor === 'string') { + id = id || accessor + const accessorString = accessor + accessor = row => getBy(row, accessorString) + } + + if (!id && typeof Header === 'string') { + id = Header + } + + if (!id && column.columns) { + console.error(column) + throw new Error('A column ID (or unique "Header" value) is required!') + } + + if (!id) { + console.error(column) + throw new Error('A column ID (or string accessor) is required!') + } + + column = { + Header: ({ id }) => id, + Cell: ({ value }) => value, + show: true, + ...column, + id, + accessor, + parent, + depth, + index, + } + + return column +} + +// Build the visible columns, headers and flat column list +export function decorateColumnTree(columns, defaultColumn, parent, depth = 0) { + return columns.map((column, columnIndex) => { + column = decorateColumn(column, defaultColumn, parent, depth, columnIndex) + if (column.columns) { + column.columns = decorateColumnTree( + column.columns, + defaultColumn, + column, + depth + 1 + ) + } + return column + }) +} + +// Build the header groups from the bottom up +export function makeHeaderGroups(columns, maxDepth, defaultColumn) { + const headerGroups = [] + + const removeChildColumns = column => { + delete column.columns + if (column.parent) { + removeChildColumns(column.parent) + } + } + columns.forEach(removeChildColumns) + + const buildGroup = (columns, depth = 0) => { + const headerGroup = { + headers: [], + } + + const parentColumns = [] + + const hasParents = columns.some(col => col.parent) + + columns.forEach(column => { + const isFirst = !parentColumns.length + let latestParentColumn = [...parentColumns].reverse()[0] + + // If the column has a parent, add it if necessary + if (column.parent) { + if (isFirst || latestParentColumn.originalID !== column.parent.id) { + parentColumns.push({ + ...column.parent, + originalID: column.parent.id, + id: [column.parent.id, parentColumns.length].join('_'), + }) + } + } else if (hasParents) { + // If other columns have parents, add a place holder if necessary + const placeholderColumn = decorateColumn( + { + originalID: [column.id, 'placeholder', maxDepth - depth].join('_'), + id: [ + column.id, + 'placeholder', + maxDepth - depth, + parentColumns.length, + ].join('_'), + }, + defaultColumn + ) + if ( + isFirst || + latestParentColumn.originalID !== placeholderColumn.originalID + ) { + parentColumns.push(placeholderColumn) + } + } + + // Establish the new columns[] relationship on the parent + if (column.parent || hasParents) { + latestParentColumn = [...parentColumns].reverse()[0] + latestParentColumn.columns = latestParentColumn.columns || [] + if (!latestParentColumn.columns.includes(column)) { + latestParentColumn.columns.push(column) + } + } + + headerGroup.headers.push(column) + }) + + headerGroups.push(headerGroup) + + if (parentColumns.length) { + buildGroup(parentColumns) + } + } + + buildGroup(columns) + + return headerGroups.reverse() +} + export function getBy(obj, path, def) { if (!path) { return obj