react-table
Version:
Hooks for building lightweight, fast and extendable datagrids for React
595 lines (509 loc) • 16.1 kB
JavaScript
import React from 'react'
//
import {
linkColumnStructure,
flattenColumns,
assignColumnAccessor,
unpreparedAccessWarning,
makeHeaderGroups,
decorateColumn,
} from '../utils'
import {
useGetLatest,
reduceHooks,
actions,
loopHooks,
makePropGetter,
makeRenderer,
} from '../publicUtils'
import makeDefaultPluginHooks from '../makeDefaultPluginHooks'
import { useColumnVisibility } from './useColumnVisibility'
const defaultInitialState = {}
const defaultColumnInstance = {}
const defaultReducer = (state, action, prevState) => state
const defaultGetSubRows = (row, index) => row.subRows || []
const defaultGetRowId = (row, index, parent) =>
`${parent ? [parent.id, index].join('.') : index}`
const defaultUseControlledState = d => d
function applyDefaults(props) {
const {
initialState = defaultInitialState,
defaultColumn = defaultColumnInstance,
getSubRows = defaultGetSubRows,
getRowId = defaultGetRowId,
stateReducer = defaultReducer,
useControlledState = defaultUseControlledState,
...rest
} = props
return {
...rest,
initialState,
defaultColumn,
getSubRows,
getRowId,
stateReducer,
useControlledState,
}
}
export const useTable = (props, ...plugins) => {
// Apply default props
props = applyDefaults(props)
// Add core plugins
plugins = [useColumnVisibility, ...plugins]
// Create the table instance
let instanceRef = React.useRef({})
// Create a getter for the instance (helps avoid a lot of potential memory leaks)
const getInstance = useGetLatest(instanceRef.current)
// Assign the props, plugins and hooks to the instance
Object.assign(getInstance(), {
...props,
plugins,
hooks: makeDefaultPluginHooks(),
})
// Allow plugins to register hooks as early as possible
plugins.filter(Boolean).forEach(plugin => {
plugin(getInstance().hooks)
})
// Consume all hooks and make a getter for them
const getHooks = useGetLatest(getInstance().hooks)
getInstance().getHooks = getHooks
delete getInstance().hooks
// Allow useOptions hooks to modify the options coming into the table
Object.assign(
getInstance(),
reduceHooks(getHooks().useOptions, applyDefaults(props))
)
const {
data,
columns: userColumns,
initialState,
defaultColumn,
getSubRows,
getRowId,
stateReducer,
useControlledState,
} = getInstance()
// Setup user reducer ref
const getStateReducer = useGetLatest(stateReducer)
// Build the reducer
const reducer = React.useCallback(
(state, action) => {
// Detect invalid actions
if (!action.type) {
console.info({ action })
throw new Error('Unknown Action 👆')
}
// Reduce the state from all plugin reducers
return [
...getHooks().stateReducers,
// Allow the user to add their own state reducer(s)
...(Array.isArray(getStateReducer())
? getStateReducer()
: [getStateReducer()]),
].reduce(
(s, handler) => handler(s, action, state, getInstance()) || s,
state
)
},
[getHooks, getStateReducer, getInstance]
)
// Start the reducer
const [reducerState, dispatch] = React.useReducer(reducer, undefined, () =>
reducer(initialState, { type: actions.init })
)
// Allow the user to control the final state with hooks
const state = reduceHooks(
[...getHooks().useControlledState, useControlledState],
reducerState,
{ instance: getInstance() }
)
Object.assign(getInstance(), {
state,
dispatch,
})
// Decorate All the columns
const columns = React.useMemo(
() =>
linkColumnStructure(
reduceHooks(getHooks().columns, userColumns, {
instance: getInstance(),
})
),
[
getHooks,
getInstance,
userColumns,
// eslint-disable-next-line react-hooks/exhaustive-deps
...reduceHooks(getHooks().columnsDeps, [], { instance: getInstance() }),
]
)
getInstance().columns = columns
// Get the flat list of all columns and allow hooks to decorate
// those columns (and trigger this memoization via deps)
let allColumns = React.useMemo(
() =>
reduceHooks(getHooks().allColumns, flattenColumns(columns), {
instance: getInstance(),
}).map(assignColumnAccessor),
[
columns,
getHooks,
getInstance,
// eslint-disable-next-line react-hooks/exhaustive-deps
...reduceHooks(getHooks().allColumnsDeps, [], {
instance: getInstance(),
}),
]
)
getInstance().allColumns = allColumns
// Access the row model using initial columns
const [rows, flatRows, rowsById] = React.useMemo(() => {
let rows = []
let flatRows = []
const rowsById = {}
const allColumnsQueue = [...allColumns]
while (allColumnsQueue.length) {
const column = allColumnsQueue.shift()
accessRowsForColumn({
data,
rows,
flatRows,
rowsById,
column,
getRowId,
getSubRows,
accessValueHooks: getHooks().accessValue,
getInstance,
})
}
return [rows, flatRows, rowsById]
}, [allColumns, data, getRowId, getSubRows, getHooks, getInstance])
Object.assign(getInstance(), {
rows,
initialRows: [...rows],
flatRows,
rowsById,
// materializedColumns,
})
loopHooks(getHooks().useInstanceAfterData, getInstance())
// Get the flat list of all columns AFTER the rows
// have been access, and allow hooks to decorate
// those columns (and trigger this memoization via deps)
let visibleColumns = React.useMemo(
() =>
reduceHooks(getHooks().visibleColumns, allColumns, {
instance: getInstance(),
}).map(d => decorateColumn(d, defaultColumn)),
[
getHooks,
allColumns,
getInstance,
defaultColumn,
// eslint-disable-next-line react-hooks/exhaustive-deps
...reduceHooks(getHooks().visibleColumnsDeps, [], {
instance: getInstance(),
}),
]
)
// Combine new visible columns with all columns
allColumns = React.useMemo(() => {
const columns = [...visibleColumns]
allColumns.forEach(column => {
if (!columns.find(d => d.id === column.id)) {
columns.push(column)
}
})
return columns
}, [allColumns, visibleColumns])
getInstance().allColumns = allColumns
if (process.env.NODE_ENV !== 'production') {
const duplicateColumns = allColumns.filter((column, i) => {
return allColumns.findIndex(d => d.id === column.id) !== i
})
if (duplicateColumns.length) {
console.info(allColumns)
throw new Error(
`Duplicate columns were found with ids: "${duplicateColumns
.map(d => d.id)
.join(', ')}" in the columns array above`
)
}
}
// Make the headerGroups
const headerGroups = React.useMemo(
() =>
reduceHooks(
getHooks().headerGroups,
makeHeaderGroups(visibleColumns, defaultColumn),
getInstance()
),
[
getHooks,
visibleColumns,
defaultColumn,
getInstance,
// eslint-disable-next-line react-hooks/exhaustive-deps
...reduceHooks(getHooks().headerGroupsDeps, [], {
instance: getInstance(),
}),
]
)
getInstance().headerGroups = headerGroups
// Get the first level of headers
const headers = React.useMemo(
() => (headerGroups.length ? headerGroups[0].headers : []),
[headerGroups]
)
getInstance().headers = headers
// Provide a flat header list for utilities
getInstance().flatHeaders = headerGroups.reduce(
(all, headerGroup) => [...all, ...headerGroup.headers],
[]
)
loopHooks(getHooks().useInstanceBeforeDimensions, getInstance())
// Filter columns down to visible ones
const visibleColumnsDep = visibleColumns
.filter(d => d.isVisible)
.map(d => d.id)
.sort()
.join('_')
visibleColumns = React.useMemo(
() => visibleColumns.filter(d => d.isVisible),
// eslint-disable-next-line react-hooks/exhaustive-deps
[visibleColumns, visibleColumnsDep]
)
getInstance().visibleColumns = visibleColumns
// Header Visibility is needed by this point
const [
totalColumnsMinWidth,
totalColumnsWidth,
totalColumnsMaxWidth,
] = calculateHeaderWidths(headers)
getInstance().totalColumnsMinWidth = totalColumnsMinWidth
getInstance().totalColumnsWidth = totalColumnsWidth
getInstance().totalColumnsMaxWidth = totalColumnsMaxWidth
loopHooks(getHooks().useInstance, getInstance())
// Each materialized header needs to be assigned a render function and other
// prop getter properties here.
;[...getInstance().flatHeaders, ...getInstance().allColumns].forEach(
column => {
// Give columns/headers rendering power
column.render = makeRenderer(getInstance(), column)
// Give columns/headers a default getHeaderProps
column.getHeaderProps = makePropGetter(getHooks().getHeaderProps, {
instance: getInstance(),
column,
})
// Give columns/headers a default getFooterProps
column.getFooterProps = makePropGetter(getHooks().getFooterProps, {
instance: getInstance(),
column,
})
}
)
getInstance().headerGroups = React.useMemo(
() =>
headerGroups.filter((headerGroup, i) => {
// Filter out any headers and headerGroups that don't have visible columns
headerGroup.headers = headerGroup.headers.filter(column => {
const recurse = headers =>
headers.filter(column => {
if (column.headers) {
return recurse(column.headers)
}
return column.isVisible
}).length
if (column.headers) {
return recurse(column.headers)
}
return column.isVisible
})
// Give headerGroups getRowProps
if (headerGroup.headers.length) {
headerGroup.getHeaderGroupProps = makePropGetter(
getHooks().getHeaderGroupProps,
{ instance: getInstance(), headerGroup, index: i }
)
headerGroup.getFooterGroupProps = makePropGetter(
getHooks().getFooterGroupProps,
{ instance: getInstance(), headerGroup, index: i }
)
return true
}
return false
}),
[headerGroups, getInstance, getHooks]
)
getInstance().footerGroups = [...getInstance().headerGroups].reverse()
// The prepareRow function is absolutely necessary and MUST be called on
// any rows the user wishes to be displayed.
getInstance().prepareRow = React.useCallback(
row => {
row.getRowProps = makePropGetter(getHooks().getRowProps, {
instance: getInstance(),
row,
})
// Build the visible cells for each row
row.allCells = allColumns.map(column => {
const value = row.values[column.id]
const cell = {
column,
row,
value,
}
// Give each cell a getCellProps base
cell.getCellProps = makePropGetter(getHooks().getCellProps, {
instance: getInstance(),
cell,
})
// Give each cell a renderer function (supports multiple renderers)
cell.render = makeRenderer(getInstance(), column, {
row,
cell,
value,
})
return cell
})
row.cells = visibleColumns.map(column =>
row.allCells.find(cell => cell.column.id === column.id)
)
// need to apply any row specific hooks (useExpanded requires this)
loopHooks(getHooks().prepareRow, row, { instance: getInstance() })
},
[getHooks, getInstance, allColumns, visibleColumns]
)
getInstance().getTableProps = makePropGetter(getHooks().getTableProps, {
instance: getInstance(),
})
getInstance().getTableBodyProps = makePropGetter(
getHooks().getTableBodyProps,
{
instance: getInstance(),
}
)
loopHooks(getHooks().useFinalInstance, getInstance())
return getInstance()
}
function calculateHeaderWidths(headers, left = 0) {
let sumTotalMinWidth = 0
let sumTotalWidth = 0
let sumTotalMaxWidth = 0
let sumTotalFlexWidth = 0
headers.forEach(header => {
let { headers: subHeaders } = header
header.totalLeft = left
if (subHeaders && subHeaders.length) {
const [
totalMinWidth,
totalWidth,
totalMaxWidth,
totalFlexWidth,
] = calculateHeaderWidths(subHeaders, left)
header.totalMinWidth = totalMinWidth
header.totalWidth = totalWidth
header.totalMaxWidth = totalMaxWidth
header.totalFlexWidth = totalFlexWidth
} else {
header.totalMinWidth = header.minWidth
header.totalWidth = Math.min(
Math.max(header.minWidth, header.width),
header.maxWidth
)
header.totalMaxWidth = header.maxWidth
header.totalFlexWidth = header.canResize ? header.totalWidth : 0
}
if (header.isVisible) {
left += header.totalWidth
sumTotalMinWidth += header.totalMinWidth
sumTotalWidth += header.totalWidth
sumTotalMaxWidth += header.totalMaxWidth
sumTotalFlexWidth += header.totalFlexWidth
}
})
return [sumTotalMinWidth, sumTotalWidth, sumTotalMaxWidth, sumTotalFlexWidth]
}
function accessRowsForColumn({
data,
rows,
flatRows,
rowsById,
column,
getRowId,
getSubRows,
accessValueHooks,
getInstance,
}) {
// Access the row's data column-by-column
// We do it this way so we can incrementally add materialized
// columns after the first pass and avoid excessive looping
const accessRow = (originalRow, rowIndex, depth = 0, parent, parentRows) => {
// Keep the original reference around
const original = originalRow
const id = getRowId(originalRow, rowIndex, parent)
let row = rowsById[id]
// If the row hasn't been created, let's make it
if (!row) {
row = {
id,
original,
index: rowIndex,
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
row.cells.map = unpreparedAccessWarning
row.cells.filter = unpreparedAccessWarning
row.cells.forEach = unpreparedAccessWarning
row.cells[0].getCellProps = unpreparedAccessWarning
// Create the cells and values
row.values = {}
// Push this row into the parentRows array
parentRows.push(row)
// Keep track of every row in a flat array
flatRows.push(row)
// Also keep track of every row by its ID
rowsById[id] = row
// Get the original subrows
row.originalSubRows = getSubRows(originalRow, rowIndex)
// Then recursively access them
if (row.originalSubRows) {
const subRows = []
row.originalSubRows.forEach((d, i) =>
accessRow(d, i, depth + 1, row, subRows)
)
// Keep the new subRows array on the row
row.subRows = subRows
}
} else if (row.subRows) {
// If the row exists, then it's already been accessed
// Keep recursing, but don't worry about passing the
// accumlator array (those rows already exist)
row.originalSubRows.forEach((d, i) => accessRow(d, i, depth + 1, row))
}
// If the column has an accessor, use it to get a value
if (column.accessor) {
row.values[column.id] = column.accessor(
originalRow,
rowIndex,
row,
parentRows,
data
)
}
// Allow plugins to manipulate the column value
row.values[column.id] = reduceHooks(
accessValueHooks,
row.values[column.id],
{
row,
column,
instance: getInstance(),
},
true
)
}
data.forEach((originalRow, rowIndex) =>
accessRow(originalRow, rowIndex, 0, undefined, rows)
)
}