UNPKG

wix-style-react

Version:
840 lines (758 loc) • 25.8 kB
import React, { Component, memo } from 'react'; import PropTypes from 'prop-types'; import SortByArrowUp from 'wix-ui-icons-common/system/SortByArrowUp'; import SortByArrowDown from 'wix-ui-icons-common/system/SortByArrowDown'; import { Animator } from 'wix-animations'; import classNames from 'classnames'; import defaultTo from 'lodash/defaultTo'; import { VariableSizeList as List } from 'react-window'; import { ScrollSyncPane } from 'react-scroll-sync'; import { TooltipCommonProps } from '../../common/PropTypes/TooltipCommon'; import { WixStyleReactContext } from '../../WixStyleReactProvider/context'; import { st, classes } from './DataTable.st.css'; import InfiniteScroll from '../../utils/InfiniteScroll'; import InfoIcon from '../../InfoIcon'; import { virtualRowsAreEqual, getStickyColumnStyle } from './DataTable.utils'; const getDefaultRowVerticalPadding = reducedSpacingAndImprovedLayout => reducedSpacingAndImprovedLayout ? 'small' : 'medium'; export const DataTableHeader = props => { const { dataHook, horizontalScroll, leftShadowVisible, rightShadowVisible, stickyColumns, rowVerticalPadding, } = props; const wrapWithHorizontalScroll = table => ( <div className={classNames(classes.scrollWrapper, { [classes.leftShadowVisible]: !!leftShadowVisible, [classes.rightShadowVisible]: !!rightShadowVisible, [classes.withStickyColumns]: !!stickyColumns, })} > <ScrollSyncPane>{table}</ScrollSyncPane> </div> ); const table = ( <div data-hook={dataHook} className={classNames({ [classes.tableHeaderScrollContent]: horizontalScroll, })} > <WixStyleReactContext.Consumer> {({ reducedSpacingAndImprovedLayout }) => ( <table style={{ width: props.width }} className={st(classes.table, { reducedSpacingAndImprovedLayout, rowVerticalPadding: rowVerticalPadding || getDefaultRowVerticalPadding(reducedSpacingAndImprovedLayout), })} > <TableHeader reducedSpacingAndImprovedLayout={reducedSpacingAndImprovedLayout} {...props} /> </table> )} </WixStyleReactContext.Consumer> </div> ); return horizontalScroll ? wrapWithHorizontalScroll(table) : table; }; DataTableHeader.propTypes = { width: PropTypes.string, }; class DataTable extends React.Component { constructor(props) { super(props); let state = { selectedRows: new Map(), }; if (props.infiniteScroll) { state = { ...state, ...this.createInitialScrollingState(props) }; } this.state = state; this.contentRef = React.createRef(); if (props.horizontalScroll && 'ResizeObserver' in window) { this.contentResizeObserver = new ResizeObserver( this._updateScrollShadows, ); } } componentDidMount() { const { contentResizeObserver, contentRef } = this; if (contentResizeObserver && contentRef.current) { contentResizeObserver.observe(contentRef.current); } } componentWillUnmount() { const { contentResizeObserver, contentRef } = this; if (contentResizeObserver && contentRef.current) { contentResizeObserver.unobserve(contentRef.current); } } get style() { return classes; } UNSAFE_componentWillReceiveProps(nextProps) { let isLoadingMore = false; if (this.props.infiniteScroll && nextProps.data !== this.props.data) { if (nextProps.data instanceof Array && this.props.data instanceof Array) { isLoadingMore = true; const lastPage = this.calcLastPage(nextProps); const currentPage = this.state.currentPage < lastPage ? this.state.currentPage + 1 : this.state.currentPage; this.setState({ lastPage, currentPage }); } if (!isLoadingMore) { this.setState(this.createInitialScrollingState(nextProps)); } } } _updateScrollShadows = () => { const { onUpdateScrollShadows } = this.props; if (!onUpdateScrollShadows) { return; } const { scrollLeft, scrollWidth, clientWidth } = this.contentRef.current; const leftShadowVisible = scrollLeft > 0; const rightShadowVisible = scrollWidth - scrollLeft > clientWidth; onUpdateScrollShadows(leftShadowVisible, rightShadowVisible); }; createInitialScrollingState(props) { return { currentPage: 0, lastPage: this.calcLastPage(props) }; } render() { const { virtualized, data, showHeaderWhenEmpty, horizontalScroll, infiniteScroll, itemsPerPage, } = this.props; if (!data.length && !showHeaderWhenEmpty) { return null; } if (virtualized) { return this.renderVirtualizedTable(data); } const rowsToRender = infiniteScroll ? data.slice(0, (this.state.currentPage + 1) * itemsPerPage) : data; let table = this.renderTable(rowsToRender); if (horizontalScroll) { table = this.wrapWithHorizontalScroll(table); } if (infiniteScroll) { table = this.wrapWithInfiniteScroll(table); } return table; } wrapWithInfiniteScroll = table => { return ( <InfiniteScroll ref={this.props.infiniteScrollRef} pageStart={0} loadMore={this.loadMore} data={this.props.data} hasMore={ this.state.currentPage < this.state.lastPage || this.props.hasMore } loader={this.props.loader} useWindow={this.props.useWindow} scrollElement={this.props.scrollElement} initialLoad={this.props.initialLoad} > {table} </InfiniteScroll> ); }; wrapWithHorizontalScroll = table => { const { leftShadowVisible, rightShadowVisible, stickyColumns } = this.props; return ( <div className={classNames(this.style.scrollWrapper, { [this.style.leftShadowVisible]: !!leftShadowVisible, [this.style.rightShadowVisible]: !!rightShadowVisible, [this.style.withStickyColumns]: !!stickyColumns, })} > <ScrollSyncPane>{table}</ScrollSyncPane> </div> ); }; renderTable = rowsToRender => { const { dataHook, showLastRowDivider, horizontalScroll, rowVerticalPadding, removeRowDetailsPadding, } = this.props; const style = { width: this.props.width }; return ( <div data-hook={dataHook} {...(horizontalScroll ? { className: classes.tableBodyScrollContent, ref: this.contentRef, onScroll: this._updateScrollShadows, } : undefined)} > <WixStyleReactContext.Consumer> {({ reducedSpacingAndImprovedLayout }) => ( <table id={this.props.id} style={style} className={st(classes.table, { showLastRowDivider, reducedSpacingAndImprovedLayout, removeRowDetailsPadding, rowVerticalPadding: rowVerticalPadding || getDefaultRowVerticalPadding(reducedSpacingAndImprovedLayout), })} > {!this.props.hideHeader && ( <TableHeader reducedSpacingAndImprovedLayout={ reducedSpacingAndImprovedLayout } {...this.props} /> )} {this.renderBody(rowsToRender)} </table> )} </WixStyleReactContext.Consumer> </div> ); }; renderBody = rows => ( <tbody> {rows.map((rowData, index) => this.renderRow(rowData, index))} </tbody> ); onRowClick = (rowData, rowNum) => { const { onRowClick, rowDetails, isRowDisabled } = this.props; onRowClick && !isRowDisabled(rowData) && onRowClick(rowData, rowNum); rowDetails && rowDetails(rowData, rowNum) && this.toggleRowDetails(rowData); }; renderRow = (rowData, rowNum, style) => { const { onRowClick, onMouseEnterRow, onMouseLeaveRow, rowDataHook, dynamicRowClass, isRowHighlight, rowDetails, rowClass, columns, selectedRowsIds, isRowSelected, isRowDisabled, } = this.props; const rowClasses = [rowClass]; const key = defaultTo(rowData.id, rowNum); const optionalRowProps = {}; const handlers = [ { rowEventHandler: this.onRowClick, eventHandler: 'onClick' }, { rowEventHandler: onMouseEnterRow, eventHandler: 'onMouseEnter' }, { rowEventHandler: onMouseLeaveRow, eventHandler: 'onMouseLeave' }, ]; handlers.forEach(({ rowEventHandler, eventHandler }) => { if (rowEventHandler) { optionalRowProps[eventHandler] = event => { if (event.isDefaultPrevented()) { return; } rowEventHandler(rowData, rowNum); }; } }); if (onRowClick && !isRowDisabled(rowData)) { rowClasses.push(this.style.clickableDataRow); } if (rowDetails) { rowClasses.push(this.style.animatedDataRow); } if (rowDataHook) { if (typeof rowDataHook === 'string') { optionalRowProps['data-hook'] = rowDataHook; } else { optionalRowProps['data-hook'] = rowDataHook(rowData, rowNum); } } if (dynamicRowClass) { rowClasses.push(dynamicRowClass(rowData, rowNum)); } if (isRowHighlight && isRowHighlight(rowData, rowNum)) { rowClasses.push(this.style.highlight); } if ( isRowSelected ? isRowSelected(rowData, rowNum) : selectedRowsIds && selectedRowsIds.includes(key) ) { rowClasses.push(this.style.selected); } optionalRowProps.className = classNames(rowClasses); const rowsToRender = [ <WixStyleReactContext.Consumer key={key}> {({ reducedSpacingAndImprovedLayout }) => ( <tr data-table-row="dataTableRow" style={style} key={key} {...optionalRowProps} data-animated={!!rowDetails ? true : null} data-clickable={onRowClick && !isRowDisabled(rowData) ? true : null} > {columns.map((column, colNum) => this.renderCell( rowData, column, rowNum, colNum, reducedSpacingAndImprovedLayout, ), )} </tr> )} </WixStyleReactContext.Consumer>, ]; if (rowDetails) { const showDetails = !!this.state.selectedRows.get(rowData); rowsToRender.push( <tr key={`${key}_details`} className={this.style.rowDetails}> <td data-hook={`${rowNum}_details`} className={classNames( this.style.details, showDetails ? this.style.active : '', )} colSpan={columns.length} > <div className={this.style.rowDetailsInner}> <Animator show={showDetails} height> {rowDetails(rowData, rowNum)} </Animator> </div> </td> </tr>, ); } return rowsToRender; }; getCellClasses = ({ column, colNum, rowData }) => { const { stickyColumns, rowDetails } = this.props; const { selectedRows } = this.state; return classNames({ [this.style.important]: column.important, [this.style.alignStart]: column.align === 'start', [this.style.alignCenter]: column.align === 'center', [this.style.alignEnd]: column.align === 'end', [this.style.sticky]: colNum < stickyColumns, [this.style.lastSticky]: colNum === stickyColumns - 1, [this.style.stickyActionCell]: column.stickyActionCell, [this.style.hasRowDetails]: rowDetails, [this.style.rowDetailsExtended]: !!selectedRows.get(rowData) && rowDetails(rowData), }); }; renderCell = ( rowData, column, rowNum, colNum, reducedSpacingAndImprovedLayout, ) => { const { virtualized, stickyColumns, columns, hideHeader } = this.props; const width = (virtualized || rowNum === 0) && hideHeader ? column.width : undefined; const style = typeof column.style === 'function' ? column.style(column, rowData, rowNum) : column.style; const stickyColumnStyle = colNum < stickyColumns ? getStickyColumnStyle(columns, column, reducedSpacingAndImprovedLayout) : undefined; const cellClasses = this.getCellClasses({ column, colNum, rowData, }); return ( <td key={colNum} style={{ ...style, ...stickyColumnStyle, }} width={width} className={cellClasses} onClick={ column.onCellClick ? event => column.onCellClick(column, rowData, rowNum, event) : undefined } > {column.render && column.render(rowData, rowNum)} </td> ); }; calcLastPage = ({ data, itemsPerPage }) => Math.ceil(data.length / itemsPerPage) - 1; loadMore = () => { if (this.state.currentPage < this.state.lastPage) { this.setState({ currentPage: this.state.currentPage + 1 }); } else { this.props.loadMore && this.props.loadMore(); } }; toggleRowDetails = selectedRow => { const { selectedRows } = this.state; const allowMultipleRowDetails = this.props.allowMultiDetailsExpansion && !this.props.virtualized; const newSelectedRows = new Map([ ...(allowMultipleRowDetails ? [...selectedRows] : []), [selectedRow, !selectedRows.get(selectedRow)], ]); this.setState({ selectedRows: newSelectedRows }); }; renderVirtualizedRow = ({ data, index, style }) => this.renderRow(data.data[index], index, style)[0]; renderVirtualizedMemoizedRow = memo( this.renderVirtualizedRow, virtualRowsAreEqual, ); getVirtualRowHeight = () => this.props.virtualizedLineHeight; virtualizedTableElementWithRefForward = React.forwardRef((props, ref) => this.renderVirtualizedTableElement({ ...props, ref }), ); renderVirtualizedTableElement = ({ children, ...rest }) => { return ( <table {...rest}> {<TableHeader {...this.props} />} {children} </table> ); }; renderVirtualizedTable = () => { const { dataHook, data, virtualizedTableHeight, virtualizedListRef, } = this.props; return ( <div data-hook={dataHook}> <List ref={virtualizedListRef} className={classNames(this.style.table, this.style.virtualized)} height={virtualizedTableHeight} itemCount={data.length} itemData={this.props} width={'100%'} itemSize={this.getVirtualRowHeight} outerElementType={this.virtualizedTableElementWithRefForward} innerElementType={'tbody'} > {this.renderVirtualizedMemoizedRow} </List> </div> ); }; } class TableHeader extends Component { static propTypes = { onSortClick: PropTypes.func, thPadding: PropTypes.string, thHeight: PropTypes.string, thFontSize: PropTypes.string, thBorder: PropTypes.string, thColor: PropTypes.string, thOpacity: PropTypes.string, thLetterSpacing: PropTypes.string, thBoxShadow: PropTypes.string, columns: PropTypes.array, skin: PropTypes.oneOf(['standard', 'neutral']), leftShadowVisible: PropTypes.bool, rightShadowVisible: PropTypes.bool, }; get style() { return classes; } renderSortingArrow = (sortDescending, colNum) => { if (sortDescending === undefined) { return null; } const Arrow = sortDescending ? SortByArrowDown : SortByArrowUp; return ( <span data-hook={`${colNum}_title`} className={this.style.sortArrow}> <Arrow height={12} data-hook={sortDescending ? 'sort_arrow_dec' : 'sort_arrow_asc'} /> </span> ); }; renderInfoTooltip = (tooltipProps, colNum) => { if (tooltipProps === undefined) { return null; } const { content, ...otherTooltipProps } = tooltipProps; return ( <InfoIcon content={content} tooltipProps={otherTooltipProps} dataHook={`${colNum}_info_tooltip`} className={this.style.infoTooltipWrapper} /> ); }; renderHeaderCell = (column, colNum) => { const { stickyColumns, columns, reducedSpacingAndImprovedLayout, } = this.props; const stickyColumnStyle = colNum < stickyColumns ? getStickyColumnStyle(columns, column, reducedSpacingAndImprovedLayout) : undefined; const style = { width: column.width, padding: this.props.thPadding, height: this.props.thHeight, fontSize: this.props.thFontSize, border: this.props.thBorder, boxShadow: this.props.thBoxShadow, color: this.props.thColor, opacity: this.props.thOpacity, letterSpacing: this.props.thLetterSpacing, cursor: column.sortable === undefined ? 'auto' : 'pointer', ...stickyColumnStyle, }; const optionalHeaderCellProps = {}; if (column.sortable) { optionalHeaderCellProps.onClick = () => this.props.onSortClick && this.props.onSortClick(column, colNum); } return ( <th style={style} key={colNum} className={classNames(this.style.thText, { [this.style.thSkinStandard]: !this.props.skin || this.props.skin === 'standard', [this.style.thSkinNeutral]: this.props.skin === 'neutral', [this.style.sticky]: colNum < stickyColumns, [this.style.lastSticky]: colNum === stickyColumns - 1, })} {...optionalHeaderCellProps} > <div className={classNames(this.style.thContainer, { [this.style.alignStart]: !column.align || column.align === 'start', [this.style.alignCenter]: column.align === 'center', [this.style.alignEnd]: column.align === 'end', })} > {column.title} {this.renderSortingArrow(column.sortDescending, colNum)} {this.renderInfoTooltip(column.infoTooltipProps, colNum)} </div> </th> ); }; render() { return ( <thead> <tr>{this.props.columns.map(this.renderHeaderCell)}</tr> </thead> ); } } function validateData(props, propName) { if (props[propName]) { if ( props[propName].constructor && props[propName].constructor.name && props[propName].constructor.name.toLowerCase().indexOf('array') > -1 ) { return null; } else { return Error('Data element must be an array type'); } } return null; } DataTable.defaultProps = { data: [], columns: [], selectedRowsIds: [], isRowSelected: null, showHeaderWhenEmpty: false, infiniteScroll: false, itemsPerPage: 20, width: '100%', loadMore: null, hasMore: false, initialLoad: true, loader: <div className="loader">Loading ...</div>, scrollElement: null, useWindow: true, showLastRowDivider: true, virtualizedLineHeight: 60, skin: 'standard', horizontalScroll: false, stickyColumns: 0, isRowDisabled: () => false, removeRowDetailsPadding: false, }; DataTable.propTypes = { dataHook: PropTypes.string, /** An id to pass to the table */ id: PropTypes.string, /** The data to display. (If data.id exists then it will be used as the React key value for each row, otherwise, the rowIndex will be used) */ data: validateData, /** Configuration of the table's columns. See table below */ columns: PropTypes.arrayOf( PropTypes.shape({ title: PropTypes.oneOfType([PropTypes.node, PropTypes.string]).isRequired, render: PropTypes.func.isRequired, sortable: PropTypes.bool, infoTooltipProps: PropTypes.shape(TooltipCommonProps), sortDescending: PropTypes.bool, align: PropTypes.oneOf(['start', 'center', 'end']), important: PropTypes.bool, style: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), stickyActionCell: PropTypes.bool, }), ).isRequired, /** Should the table show the header when data is empty */ showHeaderWhenEmpty: PropTypes.bool, /** A string data-hook to apply to all table body rows. or a func which calculates the data-hook for each row - Signature: `(rowData, rowNum) => string` */ rowDataHook: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), /** A class to apply to all table body rows */ rowClass: PropTypes.string, /** A func that gets row data and returns a class(es) to apply to that specific row */ dynamicRowClass: PropTypes.func, /** A func that gets row data and returns boolean if row is selected or not */ isRowSelected: PropTypes.func, /** A func that gets row data and returns boolean if row is highlighted or not */ isRowHighlight: PropTypes.func, /** A callback method to be called on row click. Signature: `onRowClick(rowData, rowNum)` */ onRowClick: PropTypes.func, /** A callback method to be called on row mouse enter. Signature: `onMouseEnterRow(rowData, rowNum)` */ onMouseEnterRow: PropTypes.func, /** A callback method to be called on row mouse leave. Signature: `onMouseLeaveRow(rowData, rowNum)` */ onMouseLeaveRow: PropTypes.func, /** If true, table will not render all data to begin with, but will gradually render the data as the user scrolls */ infiniteScroll: PropTypes.bool, /** If infiniteScroll is on, this prop will determine how many rows will be rendered on each load */ itemsPerPage: PropTypes.number, /** The width of the fixed table. Can be in percentages or pixels. */ width: PropTypes.string, /** Table styling. Supports `standard` and `neutral`. */ skin: PropTypes.oneOf(['standard', 'neutral']), /** A callback when more items are requested by the user. */ loadMore: PropTypes.func, /** Whether there are more items to be loaded. Event listeners are removed if false. */ hasMore: PropTypes.bool, /** The loader to show when loading more items. */ loader: PropTypes.node, /** Add scroll listeners to the window, or else, the component's parentNode. */ useWindow: PropTypes.bool, /** Add scroll listeners to specified DOM Object. */ scrollElement: PropTypes.object, /** Indicates whether to invoke `loadMore` on the initial rendering. */ initialLoad: PropTypes.bool, /** Table cell vertical padding: * - `large`: 24px * - `medium`: 18px * - `small`: with the feature toggle: 15px, without the feature toggle: 12px * - `tiny`: 12px * */ rowVerticalPadding: PropTypes.oneOf(['tiny', 'small', 'medium', 'large']), /** this prop is deprecated and should not be used * @deprecated */ thPadding: PropTypes.string, /** this prop is deprecated and should not be used * @deprecated */ thHeight: PropTypes.string, /** this prop is deprecated and should not be used * @deprecated */ thFontSize: PropTypes.string, /** this prop is deprecated and should not be used * @deprecated */ thBorder: PropTypes.string, /** this prop is deprecated and should not be used * @deprecated */ thColor: PropTypes.string, /** this prop is deprecated and should not be used * @deprecated */ thOpacity: PropTypes.string, /** this prop is deprecated and should not be used * @deprecated */ thBoxShadow: PropTypes.string, /** this prop is deprecated and should not be used * @deprecated */ thLetterSpacing: PropTypes.string, /** Function that returns React component that will be rendered in row details section. Example: `rowDetails={(row, rowNum) => <MyRowDetailsComponent {...row} />}` */ rowDetails: PropTypes.func, /** Removes row details padding */ removeRowDetailsPadding: PropTypes.bool, /** Allows to open multiple row details */ allowMultiDetailsExpansion: PropTypes.bool, /** Should we hide the header of the table. */ hideHeader: PropTypes.bool, /** A flag specifying weather to show a divider after the last row */ showLastRowDivider: PropTypes.bool, /** ++EXPERIMENTAL++ virtualize the table scrolling for long list items */ virtualized: PropTypes.bool, /** ++EXPERIMENTAL++ Set virtualized table height */ virtualizedTableHeight: PropTypes.number, /** ++EXPERIMENTAL++ Set virtualized table row height */ virtualizedLineHeight: PropTypes.number, /** ++EXPERIMENTAL++ Set ref on virtualized List containing table rows */ virtualizedListRef: PropTypes.any, /** array of selected ids in the table. Note that `isRowSelected` prop provides greater selection logic flexibility and is recommended to use instead. */ selectedRowsIds: PropTypes.arrayOf( PropTypes.oneOfType([PropTypes.string, PropTypes.number]), ), /** A callback function called on each column title click. Signature `onSortClick(colData, colNum)` */ onSortClick: PropTypes.func, /** a function which will be called for every row in `data` to specify if it should appear as disabled. Example: `isRowDisabled={(rowData) => !rowData.isEnabled}` */ isRowDisabled: PropTypes.func, /* Horizontal scroll support props. */ horizontalScroll: PropTypes.bool, stickyColumns: PropTypes.number, leftShadowVisible: PropTypes.bool, rightShadowVisible: PropTypes.bool, onUpdateScrollShadows: PropTypes.func, }; DataTable.displayName = 'DataTable'; export default DataTable;