/* Copyright (c) 2018-2020 Uber Technologies, Inc. This source code is licensed under the MIT license found in the LICENSE file in the root directory of this source tree. */ // @flow import * as React from 'react'; import {VariableSizeGrid} from 'react-window'; import AutoSizer from 'react-virtualized-auto-sizer'; import { Button, SHAPE as BUTTON_SHAPES, SIZE as BUTTON_SIZES, KIND as BUTTON_KINDS, } from '../button/index.js'; import {useStyletron} from '../styles/index.js'; import {Tooltip, PLACEMENT} from '../tooltip/index.js'; import {SORT_DIRECTIONS} from './constants.js'; import HeaderCell from './header-cell.js'; import MeasureColumnWidths from './measure-column-widths.js'; import type { ColumnT, DataTablePropsT, RowT, SortDirectionsT, RowActionT, } from './types.js'; import {LocaleContext} from '../locale/index.js'; // consider pulling this out to a prop if useful. const HEADER_ROW_HEIGHT = 48; type HeaderContextT = {| columns: ColumnT<>[], columnHighlightIndex: number, emptyMessage: string | React.ComponentType<{||}>, filters: $PropertyType, loading: boolean, loadingMessage: string | React.ComponentType<{||}>, isScrollingX: boolean, isSelectable: boolean, isSelectedAll: boolean, isSelectedIndeterminate: boolean, measuredWidths: number[], onMouseEnter: number => void, onMouseLeave: () => void, onResize: (columnIndex: number, delta: number) => void, onSelectMany: () => void, onSelectNone: () => void, onSort: number => void, resizableColumnWidths: boolean, rowActions: RowActionT[], rowHeight: number, rowHighlightIndex: number, rows: RowT[], scrollLeft: number, sortIndex: number, sortDirection: SortDirectionsT, tableHeight: number, widths: number[], |}; type CellPlacementPropsT = { columnIndex: number, rowIndex: number, style: { position: string, height: number, width: number, top: number, left: number, }, data: { columns: ColumnT<>[], columnHighlightIndex: number, isSelectable: boolean, isRowSelected: (string | number) => boolean, onRowMouseEnter: (number, RowT) => void, onSelectOne: RowT => void, rowHighlightIndex: number, rows: RowT[], textQuery: string, }, }; function CellPlacement({columnIndex, rowIndex, data, style}) { const [css, theme] = useStyletron(); // ignores the table header row if (rowIndex === 0) { return null; } let backgroundColor = theme.colors.backgroundPrimary; if ( (rowIndex % 2 && columnIndex === data.columnHighlightIndex) || rowIndex === data.rowHighlightIndex ) { backgroundColor = theme.colors.backgroundTertiary; } else if (rowIndex % 2 || columnIndex === data.columnHighlightIndex) { backgroundColor = theme.colors.backgroundSecondary; } const Cell = data.columns[columnIndex].renderCell; const value = data.columns[columnIndex].mapDataToValue( data.rows[rowIndex - 1].data, ); return (
data.onRowMouseEnter(rowIndex, data.rows[rowIndex - 1]) } > data.onSelectOne(data.rows[rowIndex - 1]) : undefined } isSelected={data.isRowSelected(data.rows[rowIndex - 1].id)} textQuery={data.textQuery} />
); } function compareCellPlacement(prevProps, nextProps) { // header cells are not rendered through this component if (prevProps.rowIndex === 0) { return true; } if ( prevProps.data.columns !== nextProps.data.columns || prevProps.data.rows !== nextProps.data.rows || prevProps.style !== nextProps.style ) { return false; } if ( prevProps.data.isSelectable === nextProps.data.isSelectable && prevProps.data.columnHighlightIndex === nextProps.data.columnHighlightIndex && prevProps.data.rowHighlightIndex === nextProps.data.rowHighlightIndex && prevProps.data.textQuery === nextProps.data.textQuery && prevProps.data.isRowSelected === nextProps.data.isRowSelected ) { return true; } // at this point we know that the rowHighlightIndex or the columnHighlightIndex has changed. // row does not need to re-render if not transitioning _from_ or _to_ highlighted // also ensures that all cells are invalidated on column-header hover if ( prevProps.rowIndex !== prevProps.data.rowHighlightIndex && prevProps.rowIndex !== nextProps.data.rowHighlightIndex && prevProps.data.columnHighlightIndex === nextProps.data.columnHighlightIndex && prevProps.data.isRowSelected === nextProps.data.isRowSelected ) { return true; } // similar to the row highlight optimization, do not update the cell if not in the previously // highlighted column or next highlighted. if ( prevProps.columnIndex !== prevProps.data.columnHighlightIndex && prevProps.columnIndex !== nextProps.data.columnHighlightIndex && prevProps.data.rowHighlightIndex === nextProps.data.rowHighlightIndex && prevProps.data.isRowSelected === nextProps.data.isRowSelected ) { return true; } return false; } const CellPlacementMemo = React.memo( CellPlacement, compareCellPlacement, ); CellPlacementMemo.displayName = 'CellPlacement'; const HeaderContext = React.createContext({ columns: [], columnHighlightIndex: -1, emptyMessage: '', filters: new Map(), loading: false, loadingMessage: '', isScrollingX: false, isSelectable: false, isSelectedAll: false, isSelectedIndeterminate: false, measuredWidths: [], onMouseEnter: () => {}, onMouseLeave: () => {}, onResize: () => {}, onSelectMany: () => {}, onSelectNone: () => {}, onSort: () => {}, resizableColumnWidths: false, rowActions: [], rowHeight: 0, rowHighlightIndex: -1, rows: [], scrollLeft: 0, sortIndex: -1, sortDirection: null, tableHeight: 0, widths: [], }); HeaderContext.displayName = 'HeaderContext'; type HeaderProps = {| columnTitle: string, hoverIndex: number, index: number, isSortable: boolean, isSelectable: boolean, isSelectedAll: boolean, isSelectedIndeterminate: boolean, onMouseEnter: number => void, onMouseLeave: () => void, onResize: (columnIndex: number, delta: number) => void, onResizeIndexChange: (columnIndex: number) => void, onSelectMany: () => void, onSelectNone: () => void, onSort: () => void, resizableColumnWidths: boolean, resizeIndex: number, resizeMaxWidth: number, resizeMinWidth: number, sortIndex: number, sortDirection: SortDirectionsT, tableHeight: number, |}; function Header(props: HeaderProps) { const [css, theme] = useStyletron(); const [startResizePos, setStartResizePos] = React.useState(0); const [endResizePos, setEndResizePos] = React.useState(0); // eslint-disable-next-line flowtype/no-weak-types const headerCellRef = React.useRef(null); const RULER_OFFSET = 2; const isResizingThisColumn = props.resizeIndex === props.index; const isResizing = props.resizeIndex >= 0; function getPositionX(el) { if (__BROWSER__) { const rect = el.getBoundingClientRect(); return rect.left + window.scrollX; } return 0; } React.useLayoutEffect(() => { function handleMouseMove(event: MouseEvent) { if (isResizingThisColumn) { event.preventDefault(); if (headerCellRef.current) { const left = getPositionX(headerCellRef.current); const width = event.clientX - left - 5; const max = Math.ceil(props.resizeMaxWidth); const min = Math.ceil(props.resizeMinWidth); if (min === max) { return; } if (width >= min && width <= max) { setEndResizePos(event.clientX - RULER_OFFSET); } if (width < min) { setEndResizePos(left + min - RULER_OFFSET); } if (width > max) { setEndResizePos(max - width - RULER_OFFSET); } } } } function handleMouseUp(event: MouseEvent) { props.onResize(props.index, endResizePos - startResizePos); props.onResizeIndexChange(-1); setStartResizePos(0); setEndResizePos(0); } if (__BROWSER__) { if (isResizingThisColumn) { document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); } } return () => { if (__BROWSER__) { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); } }; }, [ isResizingThisColumn, setEndResizePos, setStartResizePos, setEndResizePos, props.onResize, props.onResizeIndexChange, props.index, endResizePos, startResizePos, headerCellRef.current, ]); return ( { if (!isResizing) { props.onMouseEnter(props.index); } }} onMouseLeave={() => { if (!isResizing) { props.onMouseLeave(); } }} onSelectAll={props.onSelectMany} onSelectNone={props.onSelectNone} onSort={props.onSort} sortDirection={ props.sortIndex === props.index ? props.sortDirection : null } title={props.columnTitle} /> {props.resizableColumnWidths && (
{ props.onResizeIndexChange(props.index); const x = getPositionX(event.target); setStartResizePos(x); setEndResizePos(x); }} className={css({ backgroundColor: isResizingThisColumn ? theme.colors.contentPrimary : null, cursor: 'ew-resize', position: 'absolute', height: '100%', width: '3px', ':hover': { backgroundColor: theme.colors.contentPrimary, }, })} style={{ right: `${(RULER_OFFSET + endResizePos - startResizePos) * -1}px`, }} > {isResizingThisColumn && (
)}
)} ); } function Headers(props: {||}) { const [css, theme] = useStyletron(); const ctx = React.useContext(HeaderContext); const [resizeIndex, setResizeIndex] = React.useState(-1); return (
sum + w, 0)}px`, height: `${HEADER_ROW_HEIGHT}px`, display: 'flex', // this feels bad.. the absolutely positioned children elements // stack on top of this element with the layer component. zIndex: 2, })} > {ctx.columns.map((column, columnIndex) => { const activeFilter = ctx.filters ? ctx.filters.get(column.title) : null; return ( { return (

filter applied to {column.title}

{activeFilter && (

{activeFilter.description}

)}
); }} >
ctx.onSort(columnIndex)} resizableColumnWidths={ctx.resizableColumnWidths} resizeIndex={resizeIndex} resizeMinWidth={ctx.measuredWidths[columnIndex]} resizeMaxWidth={column.maxWidth || Infinity} sortIndex={ctx.sortIndex} sortDirection={ctx.sortDirection} tableHeight={ctx.tableHeight} />
); })}
); } function LoadingOrEmptyMessage(props) { const [css, theme] = useStyletron(); return (

{typeof props.children === 'function' ? props.children() : String(props.children)}

); } // replaces the content of the virtualized window with contents. in this case, // we are prepending a table header row before the table rows (children to the fn). const InnerTableElement = React.forwardRef< {|children: React.Node, style: {[string]: mixed}|}, HTMLDivElement, >((props, ref) => { const [, theme] = useStyletron(); const ctx = React.useContext(HeaderContext); // no need to render the cells until the columns have been measured if (!ctx.widths.filter(Boolean).length) { return null; } const RENDERING = 0; const LOADING = 1; const EMPTY = 2; let viewState = RENDERING; if (ctx.loading) { viewState = LOADING; } else if (ctx.rows.length === 0) { viewState = EMPTY; } return (
{viewState === LOADING && ( {ctx.loadingMessage} )} {viewState === EMPTY && ( {ctx.emptyMessage} )} {viewState === RENDERING && props.children} {ctx.rowActions && Boolean(ctx.rowActions.length) && ctx.rowHighlightIndex > 0 && !ctx.isScrollingX && (
{ctx.rowActions.map(rowAction => { const RowActionIcon = rowAction.renderIcon; return ( ); })}
)}
); }); InnerTableElement.displayName = 'InnerTableElement'; export function Unstable_DataTable(props: DataTablePropsT) { const [, theme] = useStyletron(); const locale = React.useContext(LocaleContext); const rowHeight = props.rowHeight || 36; const gridRef = React.useRef(null); const [measuredWidths, setMeasuredWidths] = React.useState( props.columns.map(() => 0), ); const [resizeDeltas, setResizeDeltas] = React.useState( props.columns.map(() => 0), ); const resetAfterColumnIndex = React.useCallback( columnIndex => { if (gridRef.current) { // $FlowFixMe trigger react-window to layout the elements again gridRef.current.resetAfterColumnIndex(columnIndex, true); } }, [gridRef.current], ); const handleWidthsChange = React.useCallback( nextWidths => { setMeasuredWidths(nextWidths); resetAfterColumnIndex(0); }, [setMeasuredWidths, resetAfterColumnIndex], ); const handleColumnResize = React.useCallback( (columnIndex, delta) => { setResizeDeltas(prev => { prev[columnIndex] = Math.max(prev[columnIndex] + delta, 0); return [...prev]; }); resetAfterColumnIndex(columnIndex); }, [setResizeDeltas, resetAfterColumnIndex], ); const normalizedWidths = React.useMemo(() => { const sum = ns => ns.reduce((s, n) => s + n, 0); const resizedWidths = measuredWidths.map( (w, i) => Math.floor(w) + Math.floor(resizeDeltas[i]), ); if (gridRef.current) { // minus 2 to account for the border stroke width // $FlowFixMe const domWidth = gridRef.current.props.width - 2; const measuredWidth = sum(resizedWidths); // $FlowFixMe const offsetWidth = gridRef.current._outerRef.offsetWidth; // $FlowFixMe const clientWidth = gridRef.current._outerRef.clientWidth; // sub 2 for border width const scrollbar = offsetWidth - clientWidth - 2; const remainder = domWidth - measuredWidth - scrollbar; const padding = Math.floor(remainder / measuredWidths.length); if (padding > 0) { const result = []; // -1 so that we loop over all but the last item for (let i = 0; i < resizedWidths.length - 1; i++) { result.push(resizedWidths[i] + padding); } result.push(domWidth - sum(result)); return result; } } return resizedWidths; }, [measuredWidths, resizeDeltas]); const [scrollLeft, setScrollLeft] = React.useState(0); const [isScrollingX, setIsScrollingX] = React.useState(false); const [recentlyScrolledX, setRecentlyScrolledX] = React.useState(false); React.useLayoutEffect(() => { if (recentlyScrolledX !== isScrollingX) { setIsScrollingX(recentlyScrolledX); } if (recentlyScrolledX) { const timeout = setTimeout(() => { setRecentlyScrolledX(false); }, 200); return () => clearTimeout(timeout); } }, [recentlyScrolledX]); const handleScroll = React.useCallback( params => { setScrollLeft(params.scrollLeft); if (params.scrollLeft !== scrollLeft) { setRecentlyScrolledX(true); } }, [scrollLeft, setScrollLeft, setRecentlyScrolledX], ); const sortedIndices = React.useMemo(() => { let toSort = props.rows.map((r, i) => [r, i]); const index = props.sortIndex; if (index !== null && index !== undefined && index !== -1) { const sortFn = props.columns[index].sortFn; const getValue = row => props.columns[index].mapDataToValue(row.data); if (props.sortDirection === SORT_DIRECTIONS.DESC) { toSort.sort((a, b) => sortFn(getValue(a[0]), getValue(b[0]))); } else if (props.sortDirection === SORT_DIRECTIONS.ASC) { toSort.sort((a, b) => sortFn(getValue(b[0]), getValue(a[0]))); } } return toSort.map(el => el[1]); }, [props.sortIndex, props.sortDirection, props.columns, props.rows]); const textQuery = React.useMemo(() => props.textQuery || '', [ props.textQuery, ]); const filteredIndices = React.useMemo(() => { const set = new Set(props.rows.map((_, idx) => idx)); Array.from(props.filters || new Set(), f => f).forEach( ([title, filter]) => { const columnIndex = props.columns.findIndex(c => c.title === title); const column = props.columns[columnIndex]; if (!column) { return; } const filterFn = column.buildFilter(filter); Array.from(set).forEach(idx => { if (!filterFn(column.mapDataToValue(props.rows[idx].data))) { set.delete(idx); } }); }, ); if (textQuery) { const stringishColumnIndices = []; for (let i = 0; i < props.columns.length; i++) { if (props.columns[i].textQueryFilter) { stringishColumnIndices.push(i); } } Array.from(set).forEach(idx => { const matches = stringishColumnIndices.some(cdx => { const column = props.columns[cdx]; const textQueryFilter = column.textQueryFilter; if (textQueryFilter) { return textQueryFilter( textQuery, column.mapDataToValue(props.rows[idx].data), ); } return false; }); if (!matches) { set.delete(idx); } }); } return set; }, [props.filters, textQuery, props.columns, props.rows]); const rows = React.useMemo(() => { const result = sortedIndices .filter(idx => filteredIndices.has(idx)) .map(idx => props.rows[idx]); if (props.onIncludedRowsChange) { props.onIncludedRowsChange(result); } return result; }, [sortedIndices, filteredIndices, props.onIncludedRowsChange, props.rows]); const isSelectable = props.batchActions ? !!props.batchActions.length : false; const isSelectedAll = React.useMemo(() => { if (!props.selectedRowIds) { return false; } return !!rows.length && props.selectedRowIds.size >= rows.length; }, [props.selectedRowIds, rows.length]); const isSelectedIndeterminate = React.useMemo(() => { if (!props.selectedRowIds) { return false; } return ( !!props.selectedRowIds.size && props.selectedRowIds.size < rows.length ); }, [props.selectedRowIds, rows.length]); const isRowSelected = React.useCallback( id => { if (props.selectedRowIds) { return props.selectedRowIds.has(id); } return false; }, [props.selectedRowIds], ); const handleSelectMany = React.useCallback(() => { if (props.onSelectMany) { props.onSelectMany(rows); } }, [rows, props.onSelectMany]); const handleSelectNone = React.useCallback(() => { if (props.onSelectNone) { props.onSelectNone(); } }, [props.onSelectNone]); const handleSelectOne = React.useCallback( row => { if (props.onSelectOne) { props.onSelectOne(row); } }, [props.onSelectOne], ); const handleSort = React.useCallback( columnIndex => { if (props.onSort) { props.onSort(columnIndex); } }, [props.onSort], ); const [columnHighlightIndex, setColumnHighlightIndex] = React.useState(-1); const [rowHighlightIndex, setRowHighlightIndex] = React.useState(-1); function handleRowHighlightIndexChange(nextIndex) { setRowHighlightIndex(nextIndex); if (gridRef.current) { if (nextIndex >= 0) { // $FlowFixMe - unable to get react-window types gridRef.current.scrollToItem({rowIndex: nextIndex}); } if (props.onRowHighlightChange) { props.onRowHighlightChange(nextIndex, rows[nextIndex - 1]); } } } const handleRowMouseEnter = React.useCallback( nextIndex => { setColumnHighlightIndex(-1); if (nextIndex !== rowHighlightIndex) { handleRowHighlightIndexChange(nextIndex); } }, [rowHighlightIndex], ); function handleColumnHeaderMouseEnter(columnIndex) { setColumnHighlightIndex(columnIndex); handleRowHighlightIndexChange(-1); } function handleColumnHeaderMouseLeave() { setColumnHighlightIndex(-1); } React.useEffect(() => { if (typeof props.rowHighlightIndex === 'number') { handleRowHighlightIndexChange(props.rowHighlightIndex); } }, [props.rowHighlightIndex]); const itemData = React.useMemo(() => { return { columnHighlightIndex, rowHighlightIndex, isRowSelected, isSelectable, onRowMouseEnter: handleRowMouseEnter, onSelectOne: handleSelectOne, columns: props.columns, rows, textQuery, }; }, [ handleRowMouseEnter, columnHighlightIndex, isRowSelected, isSelectable, rowHighlightIndex, rows, props.columns, handleSelectOne, textQuery, ]); return ( {({height, width}) => ( normalizedWidths[columnIndex]} height={height - 2} // plus one to account for additional header row rowCount={rows.length + 1} rowHeight={rowIndex => rowIndex === 0 ? HEADER_ROW_HEIGHT : rowHeight } width={width - 2} itemData={itemData} onScroll={handleScroll} style={{ ...theme.borders.border200, borderColor: theme.colors.borderOpaque, }} direction={theme.direction === 'rtl' ? 'rtl' : 'ltr'} > {CellPlacementMemo} )} ); } declare var __DEV__: boolean; declare var __NODE__: boolean; declare var __BROWSER__: boolean;