Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PORTALS-2795 - Support virtualizing TanStack tables #1485

Merged
merged 5 commits into from
Jan 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/synapse-react-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@
"@tanstack/query-core": "5.22.2",
"@tanstack/react-query": "5.22.2",
"@tanstack/react-query-devtools": "5.24.0",
"@tanstack/react-table": "^8.20.5",
"@tanstack/react-table": "^8.20.6",
"@tanstack/react-virtual": "^3.11.1",
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tanstack/react-virtual is very small, and was easy to figure out how to get it to work with @tanstack/react-table compared to react-window, since examples exist in the react-table docs.

We're also removing another virtualization library embedded in react-base-table, so while I don't want to have multiple dependencies that do the same thing, I think this is progress.

"@upsetjs/react": "^1.11.0",
"animate.css": "^4.1.1",
"bootstrap": "^4.6.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,59 @@
import { flexRender, Row, Table } from '@tanstack/react-table'
import { useMemo } from 'react'
import { flexRender, Table } from '@tanstack/react-table'
import {
StyledTableContainer,
StyledTableContainerProps,
} from '../styled/StyledTableContainer'
import { MemoizedTableBody, TableBody } from './TableBody'
import { MemoizedTableBody, TableBody, TableBodyProps } from './TableBody'
import {
getColumnSizeCssVariable,
getHeaderSizeCssVariable,
} from './TanStackTableUtils'
import { StyledTanStackTableSlotProps, StyledTanStackTableSlots } from './types'

type StyledTanStackTableProps<T = unknown> = {
table: Table<T>
export type StyledTanStackTableProps<TData = unknown, TRowType = Row<TData>> = {
table: Table<TData>
styledTableContainerProps?: StyledTableContainerProps
fullWidth?: boolean
}
slots?: StyledTanStackTableSlots<TData, TRowType>
slotProps?: StyledTanStackTableSlotProps<TData, TRowType>
Comment on lines +18 to +19
Copy link
Collaborator Author

@nickgros nickgros Jan 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In an effort to reduce duplicate code, I added slots/slotsProps props to StyledTanStackTable. I borrowed heavily from MUI's slots design pattern since it seems to be a straightforward API for to overriding/customizing components wrapped by a complex component.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is my first exposure to the slots design pattern, and I can see the utility. I think it was a good call to copy this pattern in this implementation

} & Pick<TableBodyProps<TData, TRowType>, 'rows' | 'rowTransform'>

/**
* Component that renders a styled table using @tanstack/react-table. Pass your table instance into the component and
* it will render the table with the appropriate headers and rows.
*/
export default function StyledTanStackTable<
TData = unknown,
TRowType = Row<TData>,
>(props: StyledTanStackTableProps<TData, TRowType>) {
const {
table,
styledTableContainerProps,
fullWidth = true,
slots = {},
slotProps = {},
} = props
const {
Thead = 'thead',
Th = 'th',
Table = 'table',
...tableBodySlots
} = slots
const {
Table: tableSlotProps = {},
Thead: theadSlotProps = {},
Th: thSlotProps = {},
...tableBodySlotProps
} = slotProps

export default function StyledTanStackTable<T = unknown>(
props: StyledTanStackTableProps<T>,
) {
const { table, styledTableContainerProps, fullWidth = true } = props
const tableBodyProps: TableBodyProps<TData, TRowType> = {
table,
slots: tableBodySlots,
slotProps: tableBodySlotProps,
rows: props.rows!,
rowTransform: props.rowTransform!,
}

/**
* Instead of calling `column.getSize()` on every render for every header
Expand Down Expand Up @@ -51,19 +85,28 @@ export default function StyledTanStackTable<T = unknown>(

return (
<StyledTableContainer {...styledTableContainerProps}>
<table style={{ ...columnSizeVars, width: tableWidth }}>
<thead>
<Table
{...tableSlotProps}
style={{
...columnSizeVars,
width: tableWidth,
...tableSlotProps['style'],
}}
>
<Thead {...theadSlotProps}>
{table.getHeaderGroups().map(headerGroup => {
return (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th
<Th
key={header.id}
colSpan={header.colSpan}
{...thSlotProps}
style={{
width: `calc(var(${getHeaderSizeCssVariable(
header.id,
)}) * 1px)`,
...thSlotProps['style'],
}}
>
{header.isPlaceholder
Expand All @@ -81,14 +124,14 @@ export default function StyledTanStackTable<T = unknown>(
onTouchStart={header.getResizeHandler()}
/>
)}
</th>
</Th>
))}
</tr>
)
})}
</thead>
<TableBodyElement table={table} />
</table>
</Thead>
<TableBodyElement<TData, TRowType> {...tableBodyProps} />
</Table>
</StyledTableContainer>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { VirtualItem, Virtualizer } from '@tanstack/react-virtual'
import { noop } from 'lodash-es'
import React, { useContext } from 'react'
import { mergeSlotProps } from '../../utils/slots/SlotUtils'
import StyledTanStackTable, {
StyledTanStackTableProps,
} from './StyledTanStackTable'

import { TrProps } from './types'

type StyledVirtualTanStackTableProps<T = unknown> = Omit<
StyledTanStackTableProps<T, VirtualItem>,
'slots' | 'rows' | 'rowTransform'
> & {
rowVirtualizer: Virtualizer<any, any>
onTableContainerScroll?: (target: EventTarget) => void
}

// Context to pass the row virtualizer to the Tr component
const RowVirtualizerContext = React.createContext<
Virtualizer<any, any> | undefined
>(undefined)

/**
* A table row component modified to be used with @tanstack/react-virtual.
*/
export function VirtualizedTr<TData = unknown>(
props: TrProps<TData, VirtualItem>,
) {
const { row: virtualItem, tableRow: _tableRow, ...rest } = props
const rowVirtualizer = useContext(RowVirtualizerContext)
return (
<tr
data-index={virtualItem.index} // needed for dynamic row height measurement
ref={node => rowVirtualizer?.measureElement(node)} //measure dynamic row height
{...rest}
style={{
...rest.style,
display: 'flex',
position: 'absolute',
transform: `translateY(${virtualItem.start}px)`, //this should always be a `style` as it changes on scroll
width: '100%',
}}
/>
)
}

/**
* Stylized table component that is compatible with @tanstack/react-table and @tanstack/react-virtual to display a
* virtualized table, i.e. a table that uses a virtualizer to only render the DOM nodes that contain data that is visible
* within the viewport. For cases where the table rows do not need to be virtualized, use {@link StyledTanStackTable}.
*/
export default function StyledVirtualTanStackTable<T = unknown>(
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New component that adds the styles / component overrides needed for virtualization to work

props: StyledVirtualTanStackTableProps<T>,
) {
const {
table,
styledTableContainerProps,
fullWidth = true,
rowVirtualizer,
slotProps = {},
onTableContainerScroll = noop,
} = props

const virtualRows = rowVirtualizer.getVirtualItems()

return (
<RowVirtualizerContext.Provider value={rowVirtualizer}>
<StyledTanStackTable<T, VirtualItem>
table={table}
rows={virtualRows}
rowTransform={row => table.getRowModel().rows[row.index]}
fullWidth={fullWidth}
styledTableContainerProps={{
...styledTableContainerProps,
style: {
overflow: 'auto', //our scrollable table container
position: 'relative', //needed for sticky header
...styledTableContainerProps?.style,
},
sx: {
'thead > tr': {
display: 'flex',
width: '100%',
},
},
onScroll: e => onTableContainerScroll(e.target),
}}
{...styledTableContainerProps}
slots={{
Tr: VirtualizedTr,
}}
slotProps={mergeSlotProps(slotProps, {
Table: {
style: {
display: 'grid',
},
},
Thead: {
style: {
display: 'grid',
position: 'sticky',
top: 0,
zIndex: 1,
},
},
Th: {
style: {
display: 'flex',
},
},
Tbody: {
style: {
display: 'grid',
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uses a grid layout, but still uses semantic table elements for a11y

height: `${rowVirtualizer.getTotalSize()}px`, //tells scrollbar how big the table is
position: 'relative', //needed for absolute positioning of rows
},
},
})}
/>
</RowVirtualizerContext.Provider>
)
}
Original file line number Diff line number Diff line change
@@ -1,52 +1,74 @@
import { styled } from '@mui/material'
import { Row, Table } from '@tanstack/react-table'
import { identity } from 'lodash-es'
import { memo } from 'react'
import { Cell, flexRender, Table } from '@tanstack/react-table'
import { getColumnSizeCssVariable } from './TanStackTableUtils'
import ExpandableTableDataCell from '../SynapseTable/ExpandableTableDataCell'
import { Skeleton } from '@mui/material'

function CellRenderer<T = unknown>(cell: Cell<T, unknown>) {
const getWrapInExpandableTd =
cell.getContext().table.options.meta?.getWrapInExpandableTd
const wrapInExpandableTd =
getWrapInExpandableTd && getWrapInExpandableTd(cell)
const TableDataCellElement = wrapInExpandableTd
? ExpandableTableDataCell
: 'td'

const renderPlaceholderData =
cell.getContext().table.options.meta?.renderPlaceholderData
import { getSlotProps } from '../../utils/slots/SlotUtils'
import { TableCellRenderer as DefaultTableCellRenderer } from './TableCellRenderer'
import {
TableBodyPropsRowOverride,
TableBodySlotProps,
TableBodySlots,
TrOwnerState,
} from './types'

return (
<TableDataCellElement
key={cell.id}
style={{
width: `calc(var(${getColumnSizeCssVariable(cell.column.id)}) * 1px)`,
textAlign: cell.column.columnDef.meta?.textAlign,
}}
>
{renderPlaceholderData ? (
<p>
<Skeleton width={'80%'} height={'20px'} />
</p>
) : (
flexRender(cell.column.columnDef.cell, cell.getContext())
)}
</TableDataCellElement>
)
}
// Simple wrapper around 'tr' to prevent forwarding invalid HTML attributes
const TableRow = styled('tr', {
shouldForwardProp: prop => prop !== 'row' && prop !== 'tableRow',
})({})

type TableBodyProps<T = unknown> = {
table: Table<T>
}
export type TableBodyProps<TData = unknown, TRowType = Row<TData>> = {
/** The table instance */
table: Table<TData>
slots?: TableBodySlots<TData, TRowType>
slotProps?: TableBodySlotProps<TData, TRowType>
Comment on lines +22 to +23
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add slots

} & TableBodyPropsRowOverride<TData, TRowType>

/**
* A table body component for use with @tanstack/react-table. This component renders the rows of the table.
* @param props
* @constructor
*/
export function TableBody<TData = unknown, TRowType = Row<TData>>(
props: TableBodyProps<TData, TRowType>,
) {
const { table, slots = {}, slotProps = {} } = props

// By default, use TanStack Table Rows and the identity function as a transform.
// This can be overridden e.g. to accomplish row virtualization.
const {
rows = table.getRowModel().rows as TRowType[],
rowTransform = identity,
} = props
Comment on lines +36 to +41
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For instance, when we create a virtual table, rows will be the virtualized items that should be rendered (e.g. a sliding window of indices), and rowTransform will be a function that transforms a virtualized item to the corresponding row data


const {
Tbody = 'tbody',
Tr = TableRow,
TableCellRenderer = DefaultTableCellRenderer<TData>,
} = slots
const { Tbody: tbodySlotProps = {}, Tr: _trSlotProps = {} } = slotProps

export function TableBody<T = unknown>(props: TableBodyProps<T>) {
const { table } = props
return (
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id}>{row.getVisibleCells().map(CellRenderer)}</tr>
))}
</tbody>
<Tbody {...tbodySlotProps}>
{rows.map((row, index) => {
const tableRow: Row<TData> | undefined = rowTransform(row)

const trOwnerState: TrOwnerState<TData, TRowType> = { row, tableRow }
const trSlotProps = getSlotProps(_trSlotProps, trOwnerState)

return (
<Tr
key={tableRow?.id ?? index}
{...trSlotProps}
row={row}
tableRow={tableRow}
>
{tableRow?.getVisibleCells().map((props, index) => (
<TableCellRenderer key={index} {...props} />
))}
</Tr>
)
})}
</Tbody>
)
}

Expand Down
Loading
Loading