// Copyright (c) 2022 Uber Technologies, Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. import React, {Component, createRef} from 'react'; import {ScrollSync, AutoSizer, OnScrollParams, GridProps, Index} from 'react-virtualized'; import styled, {withTheme} from 'styled-components'; import classnames from 'classnames'; import {createSelector} from 'reselect'; import get from 'lodash/get'; import debounce from 'lodash/debounce'; import OptionDropdown from './option-dropdown'; import Grid from './grid'; import Button from './button'; import {ArrowUp, ArrowDown, VertThreeDots} from 'components/common/icons'; import {parseFieldValue} from 'utils/data-utils'; import {adjustCellsToContainer} from './cell-size'; import {ALL_FIELD_TYPES, SORT_ORDER} from 'constants/default-settings'; import FieldTokenFactory from 'components/common/field-token'; import {DataContainerInterface} from 'utils/table-utils/data-container-interface'; const defaultHeaderRowHeight = 55; const defaultRowHeight = 32; const overscanColumnCount = 10; const overscanRowCount = 10; const fieldToAlignRight = { [ALL_FIELD_TYPES.integer]: true, [ALL_FIELD_TYPES.real]: true }; export const Container = styled.div` display: flex; font-size: 11px; flex-grow: 1; color: ${props => props.theme.dataTableTextColor}; width: 100%; .ReactVirtualized__Grid:focus, .ReactVirtualized__Grid:active { outline: 0; } .cell { &::-webkit-scrollbar { display: none; } } *:focus { outline: 0; } .results-table-wrapper { position: relative; min-height: 100%; max-height: 100%; display: flex; flex-direction: row; flex-grow: 1; overflow: hidden; border-top: none; .scroll-in-ui-thread::after { content: ''; height: 100%; left: 0; position: absolute; pointer-events: none; top: 0; width: 100%; } .grid-row { position: relative; display: flex; flex-direction: row; } .grid-column { display: flex; flex-direction: column; flex: 1 1 auto; } .pinned-grid-container { flex: 0 0 75px; z-index: 10; position: absolute; left: 0; top: 0; border-right: 2px solid ${props => props.theme.pinnedGridBorderColor}; } .header-grid { overflow: hidden !important; } .even-row { background-color: ${props => props.theme.evenRowBackground}; } .odd-row { background-color: ${props => props.theme.oddRowBackground}; } .cell, .header-cell { width: 100%; height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: flex-start; text-align: center; overflow: hidden; .n-sort-idx { font-size: 9px; } } .cell { border-bottom: 1px solid ${props => props.theme.cellBorderColor}; border-right: 1px solid ${props => props.theme.cellBorderColor}; white-space: nowrap; overflow: auto; padding: 0 ${props => props.theme.cellPaddingSide}px; font-size: ${props => props.theme.cellFontSize}px; .result-link { text-decoration: none; } } .cell.end-cell, .header-cell.end-cell { border-right: none; padding-right: ${props => props.theme.cellPaddingSide + props.theme.edgeCellPaddingSide}px; } .cell.first-cell, .header-cell.first-cell { padding-left: ${props => props.theme.cellPaddingSide + props.theme.edgeCellPaddingSide}px; } .cell.bottom-cell { border-bottom: none; } .cell.align-right { align-items: flex-end; } .header-cell { border-bottom: 1px solid ${props => props.theme.headerCellBorderColor}; border-top: 1px solid ${props => props.theme.headerCellBorderColor}; padding-top: ${props => props.theme.headerPaddingTop}px; padding-right: 0; padding-bottom: ${props => props.theme.headerPaddingBottom}px; padding-left: ${props => props.theme.cellPaddingSide}px; align-items: center; justify-content: space-between; display: flex; flex-direction: row; background-color: ${props => props.theme.headerCellBackground}; &:hover { .more { color: ${props => props.theme.headerCellIconColor}; } } .n-sort-idx { font-size: 9px; } .details { font-weight: 500; display: flex; flex-direction: column; justify-content: flex-start; height: 100%; overflow: hidden; flex-grow: 1; .col-name { display: flex; align-items: center; justify-content: space-between; cursor: pointer; .col-name__left { display: flex; align-items: center; overflow: hidden; svg { margin-left: 6px; } } .col-name__name { overflow: hidden; white-space: nowrap; } } } .more { color: transparent; margin-left: 5px; } } } :focus { outline: none; } `; const defaultColumnWidth = 200; const columnWidthFunction = (columns, cellSizeCache, ghost?) => ({index}) => { return (columns[index] || {}).ghost ? ghost : cellSizeCache[columns[index]] || defaultColumnWidth; }; interface GetRowCellProps { dataContainer: DataContainerInterface; columns: (string & {ghost?: boolean})[]; column: string; colMeta; rowIndex: number; sortOrder?: number[] | null; } /* * This is an accessor method used to generalize getting a cell from a data row */ const getRowCell = ({ dataContainer, columns, column, colMeta, rowIndex, sortOrder }: GetRowCellProps) => { const rowIdx = sortOrder && sortOrder.length ? get(sortOrder, rowIndex) : rowIndex; const {type} = colMeta[column]; let value = dataContainer.valueAt(rowIdx, columns.indexOf(column)); if (value === undefined) value = 'Err'; return parseFieldValue(value, type); }; interface TableSectionProps { classList?: { header: string; rows: string; }; isPinned?: boolean; columns: (string & {ghost?: boolean})[]; headerGridProps?; fixedWidth?: number; fixedHeight?: number; onScroll?: (params: OnScrollParams) => void; scrollTop?: number; dataGridProps: { rowHeight: number | ((params: Index) => number); rowCount: number; } & Partial; columnWidth?; setGridRef?: Function; headerCellRender?; dataCellRender?; scrollLeft?: number; } export const TableSection = ({ classList, isPinned, columns, headerGridProps, fixedWidth, fixedHeight = undefined, onScroll, scrollTop, dataGridProps, columnWidth, setGridRef = undefined, headerCellRender, dataCellRender, scrollLeft = 0 }: TableSectionProps) => ( {({width, height}) => { const gridDimension = { columnCount: columns.length, columnWidth, width: fixedWidth || width }; const dataGridHeight = fixedHeight || height; return ( <>
); }}
); type CellSizeCache = {[id: string]: number}; interface DataTableProps { cellSizeCache?: CellSizeCache; pinnedColumns?: string[]; columns: (string & {ghost?: boolean})[]; fixedWidth?: number; theme?: any; dataContainer: DataContainerInterface; fixedHeight?: number; colMeta: { [id: string]: { name: string; type: string; }; }; sortColumn?: {[id: string]: string}; sortTableColumn: (id: string, mode?: string) => void; pinTableColumn: (id: string) => void; copyTableColumn: (id: string) => void; sortOrder?: number[] | null; } interface DataTableState { cellSizeCache?: CellSizeCache; moreOptionsColumn?; ghost?; } DataTableFactory.deps = [FieldTokenFactory]; function DataTableFactory(FieldToken: ReturnType) { class DataTable extends Component { static defaultProps = { dataContainer: null, pinnedColumns: [], colMeta: {}, cellSizeCache: {}, sortColumn: {}, fixedWidth: null, fixedHeight: null, theme: {} }; state: DataTableState = { cellSizeCache: {}, moreOptionsColumn: null }; componentDidMount() { window.addEventListener('resize', this.scaleCellsToWidth); this.scaleCellsToWidth(); } componentDidUpdate(prevProps) { if ( this.props.cellSizeCache !== prevProps.cellSizeCache || this.props.pinnedColumns !== prevProps.pinnedColumns ) { this.scaleCellsToWidth(); } } componentWillUnmount() { window.removeEventListener('resize', this.scaleCellsToWidth); // fix Warning: Can't perform a React state update on an unmounted component this.setState = () => { return; }; } root = createRef(); columns = (props: DataTableProps) => props.columns; pinnedColumns = (props: DataTableProps) => props.pinnedColumns; unpinnedColumns = createSelector(this.columns, this.pinnedColumns, (columns, pinnedColumns) => !Array.isArray(pinnedColumns) ? columns : columns.filter(c => !pinnedColumns.includes(c)) ); toggleMoreOptions = moreOptionsColumn => this.setState({ moreOptionsColumn: this.state.moreOptionsColumn === moreOptionsColumn ? null : moreOptionsColumn }); getCellSizeCache = () => { const {cellSizeCache: propsCache = {}, fixedWidth, pinnedColumns = []} = this.props; const unpinnedColumns = this.unpinnedColumns(this.props); const width = fixedWidth ? fixedWidth : this.root.current ? this.root.current.clientWidth : 0; // pin column border is 2 pixel vs 1 pixel const adjustWidth = pinnedColumns.length ? width - 1 : width; const {cellSizeCache, ghost} = adjustCellsToContainer( adjustWidth, propsCache, pinnedColumns, unpinnedColumns ) as {cellSizeCache: {}; ghost: number | null | undefined}; return { cellSizeCache, ghost }; }; doScaleCellsToWidth = () => { this.setState(this.getCellSizeCache()); }; scaleCellsToWidth = debounce(this.doScaleCellsToWidth, 300); renderHeaderCell = ( columns: (string & {ghost?: boolean})[], isPinned: boolean, props: DataTableProps, toggleMoreOptions, moreOptionsColumn ) => { // eslint-disable-next-line react/display-name return cellInfo => { const {columnIndex, key, style} = cellInfo; const {colMeta, sortColumn = {}, sortTableColumn, pinTableColumn, copyTableColumn} = props; const column = columns[columnIndex]; const isGhost = column.ghost; const isSorted = sortColumn[column]; const firstCell = columnIndex === 0; return (
{ e.shiftKey ? sortTableColumn(column) : null; }} onDoubleClick={() => sortTableColumn(column)} title={column} > {isGhost ? (
) : ( <>
{colMeta[column].name}
sortTableColumn(column, mode)} sortMode={sortColumn && sortColumn[column]} pinTableColumn={() => pinTableColumn(column)} copyTableColumn={() => copyTableColumn(column)} isSorted={isSorted} isPinned={isPinned} />
)}
); }; }; renderDataCell = (columns, isPinned, props: DataTableProps) => { return cellInfo => { const {columnIndex, key, style, rowIndex} = cellInfo; const {dataContainer, colMeta} = props; const column = columns[columnIndex]; const isGhost = column.ghost; const rowCell = isGhost ? '' : getRowCell({...props, column, rowIndex}); const type = isGhost ? null : colMeta[column].type; const lastRowIndex = dataContainer ? dataContainer.numRows() - 1 : 0; const endCell = columnIndex === columns.length - 1; const firstCell = columnIndex === 0; const bottomCell = rowIndex === lastRowIndex; const alignRight = fieldToAlignRight[Number(type)]; const cell = (
{`${rowCell}${endCell ? '\n' : '\t'}`}
); return cell; }; }; render() { const { dataContainer, pinnedColumns = [], theme = {}, fixedWidth, fixedHeight = 0 } = this.props; const unpinnedColumns = this.unpinnedColumns(this.props); const {cellSizeCache = {}, moreOptionsColumn, ghost} = this.state; const unpinnedColumnsGhost = ghost ? [...unpinnedColumns, {ghost: true} as string & {ghost: boolean}] : unpinnedColumns; const pinnedColumnsWidth = pinnedColumns.reduce( (acc, val) => acc + get(cellSizeCache, val, 0), 0 ); const hasPinnedColumns = Boolean(pinnedColumns.length); const {headerRowHeight = defaultHeaderRowHeight, rowHeight = defaultRowHeight} = theme; const headerGridProps = { cellSizeCache, className: 'header-grid', height: headerRowHeight, rowCount: 1, rowHeight: headerRowHeight }; const dataGridProps = { cellSizeCache, overscanColumnCount, overscanRowCount, rowCount: dataContainer ? dataContainer.numRows() : 0, rowHeight }; return ( {Object.keys(cellSizeCache).length && ( {({onScroll, scrollLeft, scrollTop}) => { return (
{hasPinnedColumns && (
onScroll({...args, scrollLeft})} scrollTop={scrollTop} dataGridProps={dataGridProps} columnWidth={columnWidthFunction(pinnedColumns, cellSizeCache)} headerCellRender={this.renderHeaderCell( pinnedColumns, true, this.props, this.toggleMoreOptions, moreOptionsColumn )} dataCellRender={this.renderDataCell(pinnedColumns, true, this.props)} />
)}
); }}
)}
); } } return withTheme(DataTable); } export default DataTableFactory;