diff --git a/.storybook/config.js b/.storybook/config.js index 8dbb998..cdcfebe 100644 --- a/.storybook/config.js +++ b/.storybook/config.js @@ -13,6 +13,8 @@ import Readme from '../README.md' import Simple from '../stories/Simple.js' import ServerSide from '../stories/ServerSide.js' import SubComponents from '../stories/SubComponents.js' +import Pivoting from '../stories/Pivoting.js' +import PivotingSubComponents from '../stories/PivotingSubComponents.js' // configure(() => { storiesOf('1. Docs') @@ -31,4 +33,6 @@ configure(() => { .add('Client-side Data', Simple) .add('Server-side Data', ServerSide) .add('Sub Components', SubComponents) + .add('Pivoting & Aggregation', Pivoting) + .add('Pivoting & Aggregation w/ Sub Components', PivotingSubComponents) }, module) diff --git a/README.md b/README.md index fe639ca..58efc86 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,12 @@ ## Features -- Lightweight at 4kb (and just 2kb more for styles) -- Fully customizable JSX and callbacks for everything -- Supports both Client-side & Server-side pagination and sorting +- Lightweight at 7kb (and just 2kb more for styles) +- Fully customizable JSX templating +- Supports both Client-side & Server-side pagination and multi-sorting +- Column Pivoting & Aggregation - Minimal design & easily themeable +- Fully controllable via optional props and callbacks - "Why I wrote React Table and the problems it has solved for Nozzle.io by Tanner Linsley ## Demo @@ -28,7 +30,8 @@ - [Columns](#columns) - [Styles](#styles) - [Header Groups](#header-groups) -- [Sub Tables & Components](#sub-tables-components) +- [Pivoting & Aggregation](#pivoting-aggregation) +- [Sub Tables & Sub Components](#sub-tables-sub-components) - [Server-side Data](#server-side-data) - [Fully Controlled Component](#fully-controlled-component) - [Multi-sort](#multi-sort) @@ -128,9 +131,9 @@ These are all of the available props (and their default values) for the main ` +/> ``` ## Columns @@ -213,7 +216,37 @@ const columns = [{ }] ``` -## Sub Tables & Components +## Pivoting & Aggregation +Pivoting the table will group records together based on their accessed values and allow the rows in that group to be expanded underneath it. +To pivot, pass an array of `columnID`'s to `pivotBy`. Remember, a column's `id` is either the one that you assign it (when using a custom accessors) or its `accessor` string. +```javascript + +``` + +Naturally when grouping rows together, you may want to aggregate the rows inside it into the grouped column. No aggregation is done by default, however, it is very simple to aggregate any pivoted columns: +```javascript +// In this example, we use lodash to sum and average the values, but you can use whatever you want to aggregate. +const columns = [{ + header: 'Age', + accessor: 'age', + aggregate: (values, rows) => _.round(_.mean(values)), + render: row => { + // You can even render the cell differently if it's an aggregated cell + return {row.aggregated ? `${row.value} (avg)` : row.value} + } +}, { + header: 'Visits', + accessor: 'visits', + aggregate: (values, rows) => _.sum(values) +}] +``` + +Pivoted columns can be sorted just like regular columns, but not independently of each other. For instance, if you click to sort the pivot column in ascending order, it will sort by each pivot recursively in ascending order together. + +## Sub Tables & Sub Components By adding a `SubComponent` props, you can easily add an expansion level to all root-level rows: ```javascript {...}} // Called when the page index is changed by the user 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. - onExpand={(index, event) => {...}} // Called when an expander is clicked. Use this to manage `visibleSubComponents` + 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` /> ``` diff --git a/react-table.css.zip b/react-table.css.zip new file mode 100644 index 0000000..5fc4196 Binary files /dev/null and b/react-table.css.zip differ diff --git a/react-table.js.zip b/react-table.js.zip new file mode 100644 index 0000000..cda8814 Binary files /dev/null and b/react-table.js.zip differ diff --git a/src/index.js b/src/index.js index 5b5cdd2..22faa32 100644 --- a/src/index.js +++ b/src/index.js @@ -14,23 +14,36 @@ export const ReactTableDefaults = { pageSizeOptions: [5, 10, 20, 25, 50, 100], defaultPageSize: 20, showPageJump: true, - expanderColumnWidth: 30, + expanderColumnWidth: 35, // Controlled State Overrides // page // pageSize // sorting - // visibleSubComponents // Controlled State Callbacks - onExpand: undefined, + onExpandSubComponent: undefined, onPageChange: undefined, onPageSizeChange: undefined, onSortingChange: undefined, + // Pivoting + pivotBy: [], + pivotColumnWidth: 200, + pivotValKey: '_pivotVal', + pivotIDKey: '_pivotID', + subRowsKey: '_subRows', + + // Pivoting State Overrides + // expandedRows: {}, + + // Pivoting State Callbacks + onExpandRow: undefined, + // General Callbacks onChange: () => null, onTrClick: () => null, + // Classes className: '-striped -highlight', tableClassName: '', @@ -42,6 +55,7 @@ export const ReactTableDefaults = { thGroupClassName: '', tdClassName: '', paginationClassName: '', + // Styles style: {}, tableStyle: {}, @@ -52,6 +66,7 @@ export const ReactTableDefaults = { thStyle: {}, tdStyle: {}, paginationStyle: {}, + // Global Column Defaults column: { sortable: true, @@ -64,6 +79,7 @@ export const ReactTableDefaults = { headerInnerStyle: {}, minWidth: 100 }, + // Text previousText: 'Previous', nextText: 'Next', @@ -71,6 +87,7 @@ export const ReactTableDefaults = { pageText: 'Page', ofText: 'of', rowsText: 'rows', + // Components TableComponent: _.makeTemplateComponent('rt-table'), TheadComponent: _.makeTemplateComponent('rt-thead'), @@ -91,12 +108,12 @@ export const ReactTableDefaults = { ) }, TdComponent: _.makeTemplateComponent('rt-td'), - ExpanderComponent: ({isExpanded, toggle, ...rest}) => { + ExpanderComponent: ({isExpanded, ...rest}) => { return (
+ >•
) }, PaginationComponent: Pagination, @@ -120,15 +137,70 @@ export default React.createClass({ return { page: 0, pageSize: this.props.defaultPageSize || 10, - sorting: false, - visibleSubComponents: [] + sorting: [], + expandedRows: {} } }, + getResolvedState (props, state) { + const resolvedProps = { + ...this.state, + ...state, + ...this.props, + ...props + } + return resolvedProps + }, + + componentWillMount () { + this.setStateWithData(this.getDataModel()) + }, + componentDidMount () { this.fireOnChange() }, + componentWillReceiveProps (nextProps, nextState) { + const oldState = this.getResolvedState() + const newState = this.getResolvedState(nextProps, nextState) + // Props that trigger a data update + if ( + oldState.data !== newState.data || + oldState.columns !== newState.columns || + oldState.pivotBy !== newState.pivotBy || + oldState.sorting !== newState.sorting + ) { + this.setState(this.getDataModel(nextProps, nextState)) + } + }, + + setStateWithData (newState, cb) { + const oldState = this.getResolvedState() + const newResolvedState = this.getResolvedState({}, newState) + if ( + oldState.sorting !== newResolvedState.sorting + ) { + Object.assign(newState, this.getDataModel({}, newState)) + } + return this.setState(newState, cb) + }, + + shouldComponentUpdate (nextProps, nextState) { + const oldState = this.getResolvedState() + const newState = this.getResolvedState(nextProps, nextState) + // State changes that trigger a render + if ( + oldState.sorting !== newState.sorting || + oldState.resolvedData !== newState.resolvedData || + oldState.page !== newState.page || + oldState.pageSize !== newState.pageSize || + oldState.expandedRows !== newState.expandedRows + ) { + return true + } + return false + }, + render () { const resolvedProps = this.getResolvedState() const { @@ -142,8 +214,6 @@ export default React.createClass({ trStyle, thClassname, thStyle, - data, - columns, theadClassName, tbodyClassName, tbodyStyle, @@ -156,12 +226,18 @@ export default React.createClass({ expanderColumnWidth, manual, loadingText, - onExpand, // State - visibleSubComponents, loading, pageSize, page, + sorting, + // Pivoting State + pivotBy, + pivotValKey, + pivotIDKey, + subRowsKey, + expandedRows, + onExpandRow, // Components TableComponent, TheadComponent, @@ -173,85 +249,324 @@ export default React.createClass({ ExpanderComponent, PaginationComponent, LoadingComponent, - SubComponent + SubComponent, + columnPercentage, + // Data model + pivotColumn, + resolvedData, + allVisibleColumns, + headerGroups, + standardColumns, + allDecoratedColumns, + hasHeaderGroups, + pages } = resolvedProps - // Build Columns - const decoratedColumns = [] - const headerGroups = [] - let currentSpan = [] - - // Determine Header Groups - let hasHeaderGroups = false - columns.forEach(column => { - if (column.columns) { - hasHeaderGroups = true - } - }) - - // A convenience function to add a header and reset the currentSpan - const addHeader = (columns, column = {}) => { - headerGroups.push(Object.assign({}, column, { - columns: columns - })) - currentSpan = [] - } - - // Build the columns and headers - const visibleColumns = columns.filter(d => _.getFirstDefined(d.show, true)) - visibleColumns.forEach((column, i) => { - if (column.columns) { - const nestedColumns = column.columns.filter(d => _.getFirstDefined(d.show, true)) - nestedColumns.forEach(nestedColumn => { - decoratedColumns.push(this.makeDecoratedColumn(nestedColumn)) - }) - if (hasHeaderGroups) { - if (currentSpan.length > 0) { - addHeader(currentSpan) - } - addHeader(_.takeRight(decoratedColumns, nestedColumns.length), column) - } - } else { - decoratedColumns.push(this.makeDecoratedColumn(column)) - currentSpan.push(_.last(decoratedColumns)) - } - }) - - const columnPercentage = 100 / decoratedColumns.length - - if (hasHeaderGroups && currentSpan.length > 0) { - addHeader(currentSpan) - } - - const sorting = this.getSorting(decoratedColumns) - const accessedData = data.map((d, i) => { - const row = { - __original: d, - __index: i - } - decoratedColumns.forEach(column => { - row[column.id] = column.accessor(d) - }) - return row - }) - const resolvedData = manual ? accessedData : this.sortData(accessedData, sorting) - - // Normalize state - const pagesLength = this.getPagesLength() - // Pagination const startRow = pageSize * page const endRow = startRow + pageSize const pageRows = manual ? resolvedData : resolvedData.slice(startRow, endRow) const minRows = this.getMinRows() - const padRows = pagesLength > 1 ? _.range(pageSize - pageRows.length) + const padRows = pages > 1 ? _.range(pageSize - pageRows.length) : minRows ? _.range(Math.max(minRows - pageRows.length, 0)) : [] - const canPrevious = page > 0 - const canNext = page + 1 < pagesLength + const recurseRowsViewIndex = (rows, path = [], index = -1) => { + rows.forEach((row, i) => { + index++ + row._viewIndex = index + const newPath = path.concat([i]) + if (row[subRowsKey] && _.get(expandedRows, newPath)) { + index = recurseRowsViewIndex(row[subRowsKey], newPath, index) + } + }) + return index + } - const rowWidth = (SubComponent ? expanderColumnWidth : 0) + _.sum(decoratedColumns.map(d => d.minWidth)) + recurseRowsViewIndex(pageRows) + + const canPrevious = page > 0 + const canNext = page + 1 < pages + + const rowWidth = (SubComponent ? expanderColumnWidth : 0) + _.sum(allVisibleColumns.map(d => d.minWidth)) + + let rowIndex = -1 + + const makeHeaderGroup = () => ( + + + {pivotBy.length ? ( + + ) : SubComponent ? ( + + ) : null} + {headerGroups.map((column, i) => { + return ( + d.minWidth))}px` + }))} + > + {typeof column.header === 'function' ? ( + + ) : column.header} + + ) + })} + + + ) + + const makeHeader = () => { + const pivotSort = pivotColumn && sorting.find(d => d.id === pivotColumn.id) + return ( + + + {pivotBy.length ? ( + { + pivotColumn.sortable && this.sortColumn(pivotColumn.pivotColumns, e.shiftKey) + }} + > + {pivotColumn.pivotColumns.map((column, i) => { + return ( + + {typeof column.header === 'function' ? ( + + ) : column.header} + {i < pivotColumn.pivotColumns.length - 1 && ( + + )} + + ) + })} + + ) : SubComponent ? ( + + ) : null} + {standardColumns.map(makeHeaderGroupColumn)} + + + ) + } + const makeHeaderGroupColumn = (column, i) => { + const sort = sorting.find(d => d.id === column.id) + const show = typeof column.show === 'function' ? column.show() : column.show + return ( + { + column.sortable && this.sortColumn(column, e.shiftKey) + }} + > + {typeof column.header === 'function' ? ( + + ) : column.header} + + ) + } + + const makePageRow = (row, i, path = []) => { + const rowInfo = { + row: row.__original, + rowValues: row, + index: row.__index, + viewIndex: ++rowIndex, + level: path.length, + nestingPath: path.concat([i]), + aggregated: !!row[subRowsKey], + subRows: row[subRowsKey] + } + const isExpanded = _.get(expandedRows, rowInfo.nestingPath) + const rowPivotColumn = allDecoratedColumns.find(d => d.id === row[pivotIDKey]) + const PivotCell = rowPivotColumn && rowPivotColumn.pivotRender + return ( + + onTrClick(rowInfo.row, event)} + className={classnames(trClassName, trClassCallback(rowInfo), row._viewIndex % 2 ? '-even' : '-odd')} + style={Object.assign({}, trStyle, trStyleCallback(rowInfo))} + > + {(pivotBy.length || SubComponent) && ( + { + if (onExpandRow) { + return onExpandRow(rowInfo.nestingPath, e) + } + let newExpandedRows = _.clone(expandedRows) + if (isExpanded) { + return this.setStateWithData({ + expandedRows: _.set(newExpandedRows, rowInfo.nestingPath, false) + }) + } + return this.setStateWithData({ + expandedRows: _.set(newExpandedRows, rowInfo.nestingPath, {}) + }) + }} + > + {rowInfo.subRows ? ( + + + {rowPivotColumn && rowPivotColumn.pivotRender ? ( + + ) : {row[pivotValKey]} ({rowInfo.subRows.length})} + + ) : SubComponent ? ( + + + + ) : null} + + )} + {standardColumns.map((column, i2) => { + const Cell = column.render + const show = typeof column.show === 'function' ? column.show() : column.show + return ( + + {typeof Cell === 'function' ? ( + + ) : typeof Cell !== 'undefined' ? Cell + : rowInfo.rowValues[column.id]} + + ) + })} + + {( + rowInfo.subRows && + isExpanded && + rowInfo.subRows.map((d, i) => makePageRow(d, i, rowInfo.nestingPath)) + )} + {SubComponent && !rowInfo.subRows && isExpanded && SubComponent(rowInfo)} + + ) + } + + const makePadRow = (row, i) => { + return ( + + {SubComponent && ( + + )} + {standardColumns.map((column, i2) => { + const show = typeof column.show === 'function' ? column.show() : column.show + return ( + +   + + ) + })} + + ) + } return (
- {hasHeaderGroups && ( - - - {SubComponent && ( - - )} - {headerGroups.map((column, i) => { - return ( - d.minWidth))}px` - }))} - > - {typeof column.header === 'function' ? ( - - ) : column.header} - - ) - })} - - - )} - - - {SubComponent && ( - - )} - {decoratedColumns.map((column, i) => { - const sort = sorting.find(d => d.id === column.id) - const show = typeof column.show === 'function' ? column.show() : column.show - return ( - { - column.sortable && this.sortColumn(column, e.shiftKey) - }} - > - {typeof column.header === 'function' ? ( - - ) : column.header} - - ) - })} - - + {hasHeaderGroups && makeHeaderGroup()} + {makeHeader()} - {pageRows.map((row, i) => { - const rowInfo = { - row: row.__original, - rowValues: row, - index: row.__index, - viewIndex: i - } - const visibleSubComponentIndex = visibleSubComponents.indexOf(i) - const isExpanded = visibleSubComponentIndex > -1 - return ( - - onTrClick(rowInfo.row, event)} - className={classnames(trClassName, trClassCallback(rowInfo))} - style={Object.assign({}, trStyle, trStyleCallback(rowInfo))} - > - {SubComponent && ( - { - if (onExpand) { - return onExpand(i, e) - } - if (isExpanded) { - return this.setState({ - visibleSubComponents: [ - /* eslint-disable*/ - ...visibleSubComponents.slice(0, visibleSubComponentIndex - 1), - ...visibleSubComponents.slice(visibleSubComponentIndex + 1) - /* eslint-enable*/ - ] - }) - } - this.setState({ - visibleSubComponents: [ - /* eslint-disable*/ - ...visibleSubComponents, - i - /* eslint-enable*/ - ] - }) - }} - > - - - )} - {decoratedColumns.map((column, i2) => { - const Cell = column.render - const show = typeof column.show === 'function' ? column.show() : column.show - return ( - - {typeof Cell === 'function' ? ( - - ) : typeof Cell !== 'undefined' ? Cell - : rowInfo.rowValues[column.id]} - - ) - })} - - {SubComponent && isExpanded ? ( - SubComponent(rowInfo) - ) : null} - - ) - })} - {padRows.map((row, i) => { - return ( - - {SubComponent && ( - - )} - {decoratedColumns.map((column, i2) => { - const show = typeof column.show === 'function' ? column.show() : column.show - return ( - -   - - ) - })} - - ) - })} + {pageRows.map((d, i) => makePageRow(d, i))} + {padRows.map(makePadRow)} {showPagination && ( { + if (column.columns) { + hasHeaderGroups = true + } + }) + + // Build Header Groups + const headerGroups = [] + let currentSpan = [] + + // A convenience function to add a header and reset the currentSpan + const addHeader = (columns, column = {}) => { + headerGroups.push(Object.assign({}, column, { + columns: columns + })) + currentSpan = [] + } + + // Decorate the columns + const decorateAndAddToAll = (col) => { + const decoratedColumn = this.makeDecoratedColumn(col) + allDecoratedColumns.push(decoratedColumn) + return decoratedColumn + } + let allDecoratedColumns = [] + const decoratedColumns = columns.map((column, i) => { + if (column.columns) { + return { + ...column, + columns: column.columns.map(decorateAndAddToAll) + } + } else { + decorateAndAddToAll(column) + } + }) + + // Build the visible columns and headers and flat column list + let visibleColumns = decoratedColumns.slice() + let allVisibleColumns = [] + + visibleColumns = visibleColumns.map((column, i) => { + if (column.columns) { + const visibleSubColumns = column.columns.filter(d => pivotBy.indexOf(d.id) > -1 ? false : _.getFirstDefined(d.show, true)) + return { + ...column, + columns: visibleSubColumns + } + } + return column + }) + + visibleColumns = visibleColumns.filter(column => { + return column.columns ? column.columns.length : pivotBy.indexOf(column.id) > -1 ? false : _.getFirstDefined(column.show, true) + }) + + // Build allVisible columns and HeaderGroups + visibleColumns.forEach((column, i) => { + if (column.columns) { + allVisibleColumns = allVisibleColumns.concat(column.columns) + if (currentSpan.length > 0) { + addHeader(currentSpan) + } + addHeader(column.columns, column) + return + } + allVisibleColumns.push(column) + currentSpan.push(column) + }) + if (hasHeaderGroups && currentSpan.length > 0) { + addHeader(currentSpan) + } + + // Move the pivot columns into a single column if needed + if (pivotBy.length) { + const pivotColumns = [] + for (var i = 0; i < allDecoratedColumns.length; i++) { + if (pivotBy.indexOf(allDecoratedColumns[i].id) > -1) { + pivotColumns.push(allDecoratedColumns[i]) + } + } + allVisibleColumns.unshift({ + ...pivotColumns[0], + pivotColumns + }) + } + + // Determine the flex percentage for each column + const columnPercentage = 100 / allVisibleColumns.length + + // Access the data + let accessedData = data.map((d, i) => { + const row = { + __original: d, + __index: i + } + allDecoratedColumns.forEach(column => { + row[column.id] = column.accessor(d) + }) + return row + }) + + // If pivoting, recursively group the data + const aggregate = (rows) => { + const aggregationValues = {} + aggregatingColumns.forEach(column => { + const values = rows.map(d => d[column.id]) + aggregationValues[column.id] = column.aggregate(values, rows) + }) + return aggregationValues + } + let standardColumns = pivotBy.length ? allVisibleColumns.slice(1) : allVisibleColumns + const aggregatingColumns = standardColumns.filter(d => d.aggregate) + let pivotColumn + if (pivotBy.length) { + pivotColumn = allVisibleColumns[0] + const groupRecursively = (rows, keys, i = 0) => { + // This is the last level, just return the rows + if (i === keys.length) { + return rows + } + // Group the rows together for this level + let groupedRows = Object.entries( + _.groupBy(rows, keys[i])) + .map(([key, value]) => { + return { + [pivotIDKey]: keys[i], + [pivotValKey]: key, + [keys[i]]: key, + [subRowsKey]: value + } + } + ) + // Recurse into the subRows + groupedRows = groupedRows.map(rowGroup => { + let subRows = groupRecursively(rowGroup[subRowsKey], keys, i + 1) + return { + ...rowGroup, + [subRowsKey]: subRows, + ...aggregate(subRows) + } + }) + return groupedRows + } + accessedData = groupRecursively(accessedData, pivotBy) + } + + const resolvedSorting = sorting.length ? sorting : this.getInitSorting(allDecoratedColumns) + + // Resolve the data from either manual data or sorted data + const resolvedData = manual ? accessedData : this.sortData(accessedData, resolvedSorting) + return { - ...this.state, - ...this.props, - pages: this.getPagesLength(), - sorting: this.getSorting() + columnPercentage, + pivotColumn, + resolvedData, + allVisibleColumns, + headerGroups, + standardColumns, + allDecoratedColumns, + hasHeaderGroups, + pages: manual ? pages : Math.ceil(resolvedData.length / pageSize) } }, + fireOnChange () { this.props.onChange(this.getResolvedState(), this) }, @@ -536,7 +817,7 @@ export default React.createClass({ }] }, sortData (data, sorting) { - return _.orderBy(data, sorting.map(sort => { + const sorted = _.sortBy(data, sorting.map(sort => { return row => { if (row[sort.id] === null || row[sort.id] === undefined) { return -Infinity @@ -544,6 +825,16 @@ export default React.createClass({ return typeof row[sort.id] === 'string' ? row[sort.id].toLowerCase() : row[sort.id] } }), sorting.map(d => d.asc ? 'asc' : 'desc')) + + return sorted.map(row => { + if (!row[this.props.subRowsKey]) { + return row + } + return { + ...row, + [this.props.subRowsKey]: this.sortData(row[this.props.subRowsKey], sorting) + } + }) }, makeDecoratedColumn (column) { const dcol = Object.assign({}, this.props.column, column) @@ -566,13 +857,6 @@ export default React.createClass({ return dcol }, - getSorting (columns) { - return this.props.sorting || (this.state.sorting && this.state.sorting.length ? this.state.sorting : this.getInitSorting(columns)) - }, - getPagesLength () { - return this.props.manual ? this.props.pages - : Math.ceil(this.props.data.length / this.getStateOrProp('pageSize')) - }, getMinRows () { return _.getFirstDefined(this.props.minRows, this.getStateOrProp('pageSize')) }, @@ -583,8 +867,8 @@ export default React.createClass({ if (onPageChange) { return onPageChange(page) } - this.setState({ - visibleSubComponents: [], + this.setStateWithData({ + expandedRows: {}, page }, () => { this.fireOnChange() @@ -602,7 +886,7 @@ export default React.createClass({ return onPageSizeChange(newPageSize, newPage) } - this.setState({ + this.setStateWithData({ pageSize: newPageSize, page: newPage }, () => { @@ -610,45 +894,79 @@ export default React.createClass({ }) }, sortColumn (column, additive) { + const { sorting } = this.getResolvedState() const { onSortingChange } = this.props if (onSortingChange) { return onSortingChange(column, additive) } - const existingSorting = this.getSorting() - let sorting = _.clone(this.state.sorting || []) - const existingIndex = sorting.findIndex(d => d.id === column.id) - if (existingIndex > -1) { - const existing = sorting[existingIndex] - if (existing.asc) { - existing.asc = false + let newSorting = _.clone(sorting || []) + if (_.isArray(column)) { + const existingIndex = newSorting.findIndex(d => d.id === column[0].id) + if (existingIndex > -1) { + const existing = newSorting[existingIndex] + if (existing.asc) { + column.forEach((d, i) => { + newSorting[existingIndex + i].asc = false + }) + } else { + if (additive) { + newSorting.splice(existingIndex, column.length) + } else { + column.forEach((d, i) => { + newSorting[existingIndex + i].asc = true + }) + } + } if (!additive) { - sorting = [existing] + newSorting = newSorting.slice(existingIndex, column.length) } } else { if (additive) { - sorting.splice(existingIndex, 1) + newSorting = newSorting.concat(column.map(d => ({ + id: d.id, + asc: true + }))) } else { - existing.asc = true - sorting = [existing] + newSorting = column.map(d => ({ + id: d.id, + asc: true + })) } } } else { - if (additive) { - sorting.push({ - id: column.id, - asc: true - }) + const existingIndex = newSorting.findIndex(d => d.id === column.id) + if (existingIndex > -1) { + const existing = newSorting[existingIndex] + if (existing.asc) { + existing.asc = false + if (!additive) { + newSorting = [existing] + } + } else { + if (additive) { + newSorting.splice(existingIndex, 1) + } else { + existing.asc = true + newSorting = [existing] + } + } } else { - sorting = [{ - id: column.id, - asc: true - }] + if (additive) { + newSorting.push({ + id: column.id, + asc: true + }) + } else { + newSorting = [{ + id: column.id, + asc: true + }] + } } } - const page = (existingIndex === 0 || (!existingSorting.length && sorting.length) || !additive) ? 0 : this.state.page - this.setState({ - page, - sorting + this.setStateWithData({ + page: ((!sorting.length && newSorting.length) || !additive) ? 0 : this.state.page, + sorting: newSorting }, () => { this.fireOnChange() }) diff --git a/src/index.styl b/src/index.styl index 2f60d4d..27c5c4e 100644 --- a/src/index.styl +++ b/src/index.styl @@ -56,6 +56,8 @@ $expandSize = 7px border-right:1px solid alpha(black, .02) &:last-child border-right:0 + .rt-pivot + cursor: pointer .rt-tr-group display: flex flex-direction: column @@ -79,29 +81,35 @@ $expandSize = 7px border:0 !important opacity: 0 !important - .rt-expander-wrap - display:flex - align-items: center - justify-content: center - cursor: pointer - .rt-expander - width: 0 - height: 0 - border-left: ($expandSize * .72) solid transparent - border-right: ($expandSize * .72) solid transparent - border-top: $expandSize solid alpha(black, .8) - transition: all .3s $easeOutBack - transform: rotate(-90deg) - &.-open - transform: rotate(0deg) + display: inline-block + position:relative + margin: 0 + color: transparent + margin: 0 10px + &:after + content: '' + position: absolute + width: 0 + height: 0 + top:50% + left:50% + transform: translate(-50%, -50%) rotate(-90deg) + border-left: ($expandSize * .72) solid transparent + border-right: ($expandSize * .72) solid transparent + border-top: $expandSize solid alpha(black, .8) + transition: all .3s $easeOutBack + cursor: pointer + &.-open:after + transform: translate(-50%, -50%) rotate(0deg) &.-striped - .rt-tr-group:nth-child(even) + .rt-tr.-odd background: alpha(black, .03) &.-highlight - .rt-tr-group:hover - background: alpha(black, .05) + .rt-tbody + .rt-tr:hover + background: alpha(black, .05) .-pagination z-index: 1 diff --git a/src/pagination.js b/src/pagination.js index 8257545..6200ef3 100644 --- a/src/pagination.js +++ b/src/pagination.js @@ -17,7 +17,7 @@ export default React.createClass({ this.setState({page: nextProps.page}) }, getSafePage (page) { - return Math.min(Math.max(page, 0), this.props.pagesLength - 1) + return Math.min(Math.max(page, 0), this.props.pages - 1) }, changePage (page) { page = this.getSafePage(page) @@ -32,7 +32,7 @@ export default React.createClass({ render () { const { // Computed - pagesLength, + pages, // Props page, showPageSizeOptions, @@ -85,7 +85,7 @@ export default React.createClass({ ) : ( {page + 1} - )} {this.props.ofText} {pagesLength} + )} {this.props.ofText} {pages} {showPageSizeOptions && ( diff --git a/src/utils.js b/src/utils.js index c7eeee6..c1a60fa 100644 --- a/src/utils.js +++ b/src/utils.js @@ -3,41 +3,45 @@ import classnames from 'classnames' // export default { get, + set, takeRight, last, - orderBy, + sortBy, range, - clone, remove, + clone, getFirstDefined, sum, makeTemplateComponent, - prefixAll + prefixAll, + groupBy, + isArray } -function remove (a, b) { - return a.filter(function (o, i) { - var r = b(o) - if (r) { - a.splice(i, 1) - return true - } - return false - }) -} - -function get (a, b) { - if (isArray(b)) { - b = b.join('.') +function get (obj, path, def) { + if (!path) { + return obj } - return b - .replace('[', '.').replace(']', '') - .split('.') - .reduce( - function (obj, property) { - return obj[property] - }, a - ) + const pathObj = makePathArray(path) + let val + try { + val = pathObj.reduce((current, pathPart) => current[pathPart], obj) + } catch (e) {} + return typeof val !== 'undefined' ? val : def +} + +function set (obj = {}, path, value) { + const keys = makePathArray(path) + let keyPart + let cursor = obj + while ((keyPart = keys.shift()) && keys.length) { + if (!cursor[keyPart]) { + cursor[keyPart] = {} + } + cursor = cursor[keyPart] + } + cursor[keyPart] = value + return obj } function takeRight (arr, n) { @@ -57,7 +61,7 @@ function range (n) { return arr } -function orderBy (arr, funcs, dirs) { +function sortBy (arr, funcs, dirs) { return arr.sort((a, b) => { for (let i = 0; i < funcs.length; i++) { const comp = funcs[i] @@ -75,21 +79,28 @@ function orderBy (arr, funcs, dirs) { }) } -function clone (a) { - return JSON.parse(JSON.stringify(a, function (key, value) { - if (typeof value === 'function') { - return value.toString() +function remove (a, b) { + return a.filter(function (o, i) { + var r = b(o) + if (r) { + a.splice(i, 1) + return true } - return value - })) + return false + }) } -// ######################################################################## -// Helpers -// ######################################################################## - -function isArray (a) { - return Array.isArray(a) +function clone (a) { + try { + return JSON.parse(JSON.stringify(a, (key, value) => { + if (typeof value === 'function') { + return value.toString() + } + return value + })) + } catch (e) { + return a + } } function getFirstDefined (...args) { @@ -120,3 +131,39 @@ function makeTemplateComponent (compClass) { function prefixAll (obj) { return obj } + +function groupBy (xs, key) { + return xs.reduce((rv, x, i) => { + const resKey = typeof key === 'function' ? key(x, i) : x[key] + rv[resKey] = rv[resKey] || [] + rv[resKey].push(x) + return rv + }, {}) +} + +function isArray (a) { + return Array.isArray(a) +} + +// ######################################################################## +// Non-exported Helpers +// ######################################################################## + +function makePathArray (obj) { + return flattenDeep(obj) + .join('.') + .replace('[', '.') + .replace(']', '') + .split('.') +} + +function flattenDeep (arr, newArr = []) { + if (!isArray(arr)) { + newArr.push(arr) + } else { + for (var i = 0; i < arr.length; i++) { + flattenDeep(arr[i], newArr) + } + } + return newArr +} diff --git a/stories/Pivoting.js b/stories/Pivoting.js new file mode 100644 index 0000000..2045ea7 --- /dev/null +++ b/stories/Pivoting.js @@ -0,0 +1,120 @@ +import React from 'react' +import _ from 'lodash' +import namor from 'namor' + +import CodeHighlight from './components/codeHighlight' +import ReactTable from '../lib/index' + +export default () => { + const data = _.map(_.range(10000), d => { + return { + firstName: namor.generate({ words: 1, numLen: 0 }), + lastName: namor.generate({ words: 1, numLen: 0 }), + age: Math.floor(Math.random() * 30), + visits: Math.floor(Math.random() * 100) + } + }) + + const columns = [{ + header: 'Name', + columns: [{ + header: 'First Name', + accessor: 'firstName' + }, { + header: 'Last Name', + id: 'lastName', + accessor: d => d.lastName + }] + }, { + header: 'Info', + columns: [{ + header: 'Age', + accessor: 'age', + aggregate: vals => _.round(_.mean(vals)), + render: row => { + return {row.aggregated ? `${row.value} (avg)` : row.value} + } + }, { + header: 'Visits', + accessor: 'visits', + aggregate: vals => _.sum(vals) + }] + }] + + return ( +
+
+ +
+
+
+ Tip: Hold shift when sorting to multi-sort! +
+ {() => getCode()} +
+ ) +} + +function getCode () { + return ` +const columns = [{ + header: 'Name', + columns: [{ + header: 'First Name', + accessor: 'firstName' + }, { + header: 'Last Name', + id: 'lastName', + accessor: d => d.lastName + }] +}, { + header: 'Info', + columns: [{ + header: 'Age', + accessor: 'age' + }] +}] + +export default ( + { + 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)} +
+ ) + }} + /> +
+ ) + }} + /> +) + ` +} diff --git a/stories/PivotingSubComponents.js b/stories/PivotingSubComponents.js new file mode 100644 index 0000000..73b9ec4 --- /dev/null +++ b/stories/PivotingSubComponents.js @@ -0,0 +1,138 @@ +import React from 'react' +import _ from 'lodash' +import namor from 'namor' + +import CodeHighlight from './components/codeHighlight' +import ReactTable from '../lib/index' + +export default () => { + const data = _.map(_.range(10000), d => { + return { + firstName: namor.generate({ words: 1, numLen: 0 }), + lastName: namor.generate({ words: 1, numLen: 0 }), + age: Math.floor(Math.random() * 30), + visits: Math.floor(Math.random() * 100) + } + }) + + const columns = [{ + header: 'Name', + columns: [{ + header: 'First Name', + accessor: 'firstName' + }, { + header: 'Last Name', + id: 'lastName', + accessor: d => d.lastName + }] + }, { + header: 'Info', + columns: [{ + header: 'Age', + accessor: 'age', + aggregate: vals => _.round(_.mean(vals)), + render: row => { + return {row.aggregated ? `${row.value} (avg)` : row.value} + } + }, { + header: 'Visits', + accessor: 'visits', + aggregate: vals => _.sum(vals) + }] + }] + + return ( +
+
+ { + 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! +
+ {() => getCode()} +
+ ) +} + +function getCode () { + return ` +const columns = [{ + header: 'Name', + columns: [{ + header: 'First Name', + accessor: 'firstName' + }, { + header: 'Last Name', + id: 'lastName', + accessor: d => d.lastName + }] +}, { + header: 'Info', + columns: [{ + header: 'Age', + accessor: 'age' + }] +}] + +export default ( + { + 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)} +
+ ) + }} + /> +
+ ) + }} + /> +) + ` +} diff --git a/stories/ServerSide.js b/stories/ServerSide.js index 477a25d..4699d63 100644 --- a/stories/ServerSide.js +++ b/stories/ServerSide.js @@ -48,6 +48,7 @@ const ServerSide = React.createClass({ } }, fetchData (state, instance) { + console.log(state, instance) // Whenever the table model changes, or the user sorts or changes pages, this method gets called and passed the current table model. // 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})