UNPKG

react-table-v6

Version:

A fast, lightweight, opinionated table and datagrid built on React

680 lines (598 loc) 20.7 kB
import React from 'react' import _ from './utils' export default Base => class extends Base { getResolvedState (props, state) { const resolvedState = { ..._.compactObject(this.state), ..._.compactObject(this.props), ..._.compactObject(state), ..._.compactObject(props), } return resolvedState } getDataModel (newState, dataChanged) { const { columns, pivotBy = [], data, resolveData, pivotIDKey, pivotValKey, subRowsKey, aggregatedKey, nestingLevelKey, originalKey, indexKey, groupedByPivotKey, SubComponent, } = newState // Determine Header Groups let hasHeaderGroups = false columns.forEach(column => { if (column.columns) { hasHeaderGroups = true } }) let columnsWithExpander = [...columns] let expanderColumn = columns.find( col => col.expander || (col.columns && col.columns.some(col2 => col2.expander)) ) // The actual expander might be in the columns field of a group column if (expanderColumn && !expanderColumn.expander) { expanderColumn = expanderColumn.columns.find(col => col.expander) } // If we have SubComponent's we need to make sure we have an expander column if (SubComponent && !expanderColumn) { expanderColumn = { expander: true } columnsWithExpander = [expanderColumn, ...columnsWithExpander] } const makeDecoratedColumn = (column, parentColumn) => { let dcol if (column.expander) { dcol = { ...this.props.column, ...this.props.expanderDefaults, ...column, } } else { dcol = { ...this.props.column, ...column, } } // Ensure minWidth is not greater than maxWidth if set if (dcol.maxWidth < dcol.minWidth) { dcol.minWidth = dcol.maxWidth } if (parentColumn) { dcol.parentColumn = parentColumn } // First check for string accessor if (typeof dcol.accessor === 'string') { dcol.id = dcol.id || dcol.accessor const accessorString = dcol.accessor dcol.accessor = row => _.get(row, accessorString) return dcol } // Fall back to functional accessor (but require an ID) if (dcol.accessor && !dcol.id) { console.warn(dcol) throw new Error( 'A column id is required if using a non-string accessor for column above.' ) } // Fall back to an undefined accessor if (!dcol.accessor) { dcol.accessor = () => undefined } return dcol } const allDecoratedColumns = [] // Decorate the columns const decorateAndAddToAll = (column, parentColumn) => { const decoratedColumn = makeDecoratedColumn(column, parentColumn) allDecoratedColumns.push(decoratedColumn) return decoratedColumn } const decoratedColumns = columnsWithExpander.map(column => { if (column.columns) { return { ...column, columns: column.columns.map(d => decorateAndAddToAll(d, column)), } } return decorateAndAddToAll(column) }) // Build the visible columns, headers and flat column list let visibleColumns = decoratedColumns.slice() let allVisibleColumns = [] visibleColumns = visibleColumns.map(column => { 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 => column.columns ? column.columns.length : pivotBy.indexOf(column.id) > -1 ? false : _.getFirstDefined(column.show, true) ) // Find any custom pivot location const pivotIndex = visibleColumns.findIndex(col => col.pivot) // Handle Pivot Columns if (pivotBy.length) { // Retrieve the pivot columns in the correct pivot order const pivotColumns = [] pivotBy.forEach(pivotID => { const found = allDecoratedColumns.find(d => d.id === pivotID) if (found) { pivotColumns.push(found) } }) const PivotParentColumn = pivotColumns.reduce( (prev, current) => prev && prev === current.parentColumn && current.parentColumn, pivotColumns[0].parentColumn ) let PivotGroupHeader = hasHeaderGroups && PivotParentColumn.Header PivotGroupHeader = PivotGroupHeader || (() => <strong>Pivoted</strong>) let pivotColumnGroup = { Header: PivotGroupHeader, columns: pivotColumns.map(col => ({ ...this.props.pivotDefaults, ...col, pivoted: true, })), } // Place the pivotColumns back into the visibleColumns if (pivotIndex >= 0) { pivotColumnGroup = { ...visibleColumns[pivotIndex], ...pivotColumnGroup, } visibleColumns.splice(pivotIndex, 1, pivotColumnGroup) } else { visibleColumns.unshift(pivotColumnGroup) } } // Build Header Groups const headerGroups = [] let currentSpan = [] // A convenience function to add a header and reset the currentSpan const addHeader = (columns, column) => { headerGroups.push({ ...this.props.column, ...column, columns, }) currentSpan = [] } // Build flast list of allVisibleColumns and HeaderGroups visibleColumns.forEach(column => { 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) } // Access the data const accessRow = (d, i, level = 0) => { const row = { [originalKey]: d, [indexKey]: i, [subRowsKey]: d[subRowsKey], [nestingLevelKey]: level, } allDecoratedColumns.forEach(column => { if (column.expander) return row[column.id] = column.accessor(d) }) if (row[subRowsKey]) { row[subRowsKey] = row[subRowsKey].map((d, i) => accessRow(d, i, level + 1)) } return row } // // If the data hasn't changed, just use the cached data let resolvedData = this.resolvedData // If the data has changed, run the data resolver and cache the result if (!this.resolvedData || dataChanged) { resolvedData = resolveData(data) this.resolvedData = resolvedData } // Use the resolved data resolvedData = resolvedData.map((d, i) => accessRow(d, i)) // TODO: Make it possible to fabricate nested rows without pivoting const aggregatingColumns = allVisibleColumns.filter(d => !d.expander && d.aggregate) // 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 } if (pivotBy.length) { 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]) => ({ [pivotIDKey]: keys[i], [pivotValKey]: key, [keys[i]]: key, [subRowsKey]: value, [nestingLevelKey]: i, [groupedByPivotKey]: true, })) // Recurse into the subRows groupedRows = groupedRows.map(rowGroup => { const subRows = groupRecursively(rowGroup[subRowsKey], keys, i + 1) return { ...rowGroup, [subRowsKey]: subRows, [aggregatedKey]: true, ...aggregate(subRows), } }) return groupedRows } resolvedData = groupRecursively(resolvedData, pivotBy) } return { ...newState, resolvedData, allVisibleColumns, headerGroups, allDecoratedColumns, hasHeaderGroups, } } getSortedData (resolvedState) { const { manual, sorted, filtered, defaultFilterMethod, resolvedData, allVisibleColumns, allDecoratedColumns, } = resolvedState const sortMethodsByColumnID = {} allDecoratedColumns.filter(col => col.sortMethod).forEach(col => { sortMethodsByColumnID[col.id] = col.sortMethod }) // Resolve the data from either manual data or sorted data return { sortedData: manual ? resolvedData : this.sortData( this.filterData(resolvedData, filtered, defaultFilterMethod, allVisibleColumns), sorted, sortMethodsByColumnID ), } } fireFetchData () { this.props.onFetchData(this.getResolvedState(), this) } getPropOrState (key) { return _.getFirstDefined(this.props[key], this.state[key]) } getStateOrProp (key) { return _.getFirstDefined(this.state[key], this.props[key]) } filterData (data, filtered, defaultFilterMethod, allVisibleColumns) { let filteredData = data if (filtered.length) { filteredData = filtered.reduce((filteredSoFar, nextFilter) => { const column = allVisibleColumns.find(x => x.id === nextFilter.id) // Don't filter hidden columns or columns that have had their filters disabled if (!column || column.filterable === false) { return filteredSoFar } const filterMethod = column.filterMethod || defaultFilterMethod // If 'filterAll' is set to true, pass the entire dataset to the filter method if (column.filterAll) { return filterMethod(nextFilter, filteredSoFar, column) } return filteredSoFar.filter(row => filterMethod(nextFilter, row, column)) }, filteredData) // Apply the filter to the subrows if we are pivoting, and then // filter any rows without subcolumns because it would be strange to show filteredData = filteredData .map(row => { if (!row[this.props.subRowsKey]) { return row } return { ...row, [this.props.subRowsKey]: this.filterData( row[this.props.subRowsKey], filtered, defaultFilterMethod, allVisibleColumns ), } }) .filter(row => { if (!row[this.props.subRowsKey]) { return true } return row[this.props.subRowsKey].length > 0 }) } return filteredData } sortData (data, sorted, sortMethodsByColumnID = {}) { if (!sorted.length) { return data } const sortedData = (this.props.orderByMethod || _.orderBy)( data, sorted.map(sort => { // Support custom sorting methods for each column if (sortMethodsByColumnID[sort.id]) { return (a, b) => sortMethodsByColumnID[sort.id](a[sort.id], b[sort.id], sort.desc) } return (a, b) => this.props.defaultSortMethod(a[sort.id], b[sort.id], sort.desc) }), sorted.map(d => !d.desc), this.props.indexKey ) sortedData.forEach(row => { if (!row[this.props.subRowsKey]) { return } row[this.props.subRowsKey] = this.sortData( row[this.props.subRowsKey], sorted, sortMethodsByColumnID ) }) return sortedData } getMinRows () { return _.getFirstDefined(this.props.minRows, this.getStateOrProp('pageSize')) } // User actions onPageChange (page) { const { onPageChange, collapseOnPageChange } = this.props const newState = { page } if (collapseOnPageChange) { newState.expanded = {} } this.setStateWithData(newState, () => onPageChange && onPageChange(page)) } onPageSizeChange (newPageSize) { const { onPageSizeChange } = this.props const { pageSize, page } = this.getResolvedState() // Normalize the page to display const currentRow = pageSize * page const newPage = Math.floor(currentRow / newPageSize) this.setStateWithData( { pageSize: newPageSize, page: newPage, }, () => onPageSizeChange && onPageSizeChange(newPageSize, newPage) ) } sortColumn (column, additive) { const { sorted, skipNextSort, defaultSortDesc } = this.getResolvedState() const firstSortDirection = Object.prototype.hasOwnProperty.call(column, 'defaultSortDesc') ? column.defaultSortDesc : defaultSortDesc const secondSortDirection = !firstSortDirection // we can't stop event propagation from the column resize move handlers // attached to the document because of react's synthetic events // so we have to prevent the sort function from actually sorting // if we click on the column resize element within a header. if (skipNextSort) { this.setStateWithData({ skipNextSort: false, }) return } const { onSortedChange } = this.props let newSorted = _.clone(sorted || []).map(d => { d.desc = _.isSortingDesc(d) return d }) if (!_.isArray(column)) { // Single-Sort const existingIndex = newSorted.findIndex(d => d.id === column.id) if (existingIndex > -1) { const existing = newSorted[existingIndex] if (existing.desc === secondSortDirection) { if (additive) { newSorted.splice(existingIndex, 1) } else { existing.desc = firstSortDirection newSorted = [existing] } } else { existing.desc = secondSortDirection if (!additive) { newSorted = [existing] } } } else if (additive) { newSorted.push({ id: column.id, desc: firstSortDirection, }) } else { newSorted = [ { id: column.id, desc: firstSortDirection, }, ] } } else { // Multi-Sort const existingIndex = newSorted.findIndex(d => d.id === column[0].id) // Existing Sorted Column if (existingIndex > -1) { const existing = newSorted[existingIndex] if (existing.desc === secondSortDirection) { if (additive) { newSorted.splice(existingIndex, column.length) } else { column.forEach((d, i) => { newSorted[existingIndex + i].desc = firstSortDirection }) } } else { column.forEach((d, i) => { newSorted[existingIndex + i].desc = secondSortDirection }) } if (!additive) { newSorted = newSorted.slice(existingIndex, column.length) } // New Sort Column } else if (additive) { newSorted = newSorted.concat( column.map(d => ({ id: d.id, desc: firstSortDirection, })) ) } else { newSorted = column.map(d => ({ id: d.id, desc: firstSortDirection, })) } } this.setStateWithData( { page: (!sorted.length && newSorted.length) || !additive ? 0 : this.state.page, sorted: newSorted, }, () => onSortedChange && onSortedChange(newSorted, column, additive) ) } filterColumn (column, value) { const { filtered } = this.getResolvedState() const { onFilteredChange } = this.props // Remove old filter first if it exists const newFiltering = (filtered || []).filter(x => x.id !== column.id) if (value !== '') { newFiltering.push({ id: column.id, value, }) } this.setStateWithData( { filtered: newFiltering, }, () => onFilteredChange && onFilteredChange(newFiltering, column, value) ) } resizeColumnStart (event, column, isTouch) { event.stopPropagation() const parentWidth = event.target.parentElement.getBoundingClientRect().width let pageX if (isTouch) { pageX = event.changedTouches[0].pageX } else { pageX = event.pageX } this.trapEvents = true this.setStateWithData( { currentlyResizing: { id: column.id, startX: pageX, parentWidth, }, }, () => { if (isTouch) { document.addEventListener('touchmove', this.resizeColumnMoving) document.addEventListener('touchcancel', this.resizeColumnEnd) document.addEventListener('touchend', this.resizeColumnEnd) } else { document.addEventListener('mousemove', this.resizeColumnMoving) document.addEventListener('mouseup', this.resizeColumnEnd) document.addEventListener('mouseleave', this.resizeColumnEnd) } } ) } resizeColumnMoving (event) { event.stopPropagation() const { onResizedChange } = this.props const { resized, currentlyResizing } = this.getResolvedState() // Delete old value const newResized = resized.filter(x => x.id !== currentlyResizing.id) let pageX if (event.type === 'touchmove') { pageX = event.changedTouches[0].pageX } else if (event.type === 'mousemove') { pageX = event.pageX } // Set the min size to 10 to account for margin and border or else the // group headers don't line up correctly const newWidth = Math.max( currentlyResizing.parentWidth + pageX - currentlyResizing.startX, 11 ) newResized.push({ id: currentlyResizing.id, value: newWidth, }) this.setStateWithData( { resized: newResized, }, () => onResizedChange && onResizedChange(newResized, event) ) } resizeColumnEnd (event) { event.stopPropagation() const isTouch = event.type === 'touchend' || event.type === 'touchcancel' if (isTouch) { document.removeEventListener('touchmove', this.resizeColumnMoving) document.removeEventListener('touchcancel', this.resizeColumnEnd) document.removeEventListener('touchend', this.resizeColumnEnd) } // If its a touch event clear the mouse one's as well because sometimes // the mouseDown event gets called as well, but the mouseUp event doesn't document.removeEventListener('mousemove', this.resizeColumnMoving) document.removeEventListener('mouseup', this.resizeColumnEnd) document.removeEventListener('mouseleave', this.resizeColumnEnd) // The touch events don't propagate up to the sorting's onMouseDown event so // no need to prevent it from happening or else the first click after a touch // event resize will not sort the column. if (!isTouch) { this.setStateWithData({ skipNextSort: true, currentlyResizing: false, }) } } }