superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx (906 lines of code) (raw):

/** * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ import { CSSProperties, useCallback, useLayoutEffect, useMemo, useState, MouseEvent, KeyboardEvent as ReactKeyboardEvent, } from 'react'; import { ColumnInstance, ColumnWithLooseAccessor, DefaultSortTypes, Row, } from 'react-table'; import { extent as d3Extent, max as d3Max } from 'd3-array'; import { FaSort } from '@react-icons/all-files/fa/FaSort'; import { FaSortDown as FaSortDesc } from '@react-icons/all-files/fa/FaSortDown'; import { FaSortUp as FaSortAsc } from '@react-icons/all-files/fa/FaSortUp'; import cx from 'classnames'; import { DataRecord, DataRecordValue, DTTM_ALIAS, ensureIsArray, GenericDataType, getSelectedText, getTimeFormatterForGranularity, BinaryQueryObjectFilterClause, styled, css, t, tn, useTheme, } from '@superset-ui/core'; import { Dropdown, Menu, Tooltip } from '@superset-ui/chart-controls'; import { CheckOutlined, InfoCircleOutlined, DownOutlined, MinusCircleOutlined, PlusCircleOutlined, TableOutlined, } from '@ant-design/icons'; import { isEmpty } from 'lodash'; import { ColorSchemeEnum, DataColumnMeta, TableChartTransformedProps, } from './types'; import DataTable, { DataTableProps, SearchInputProps, SelectPageSizeRendererProps, SizeOption, } from './DataTable'; import Styles from './Styles'; import { formatColumnValue } from './utils/formatValue'; import { PAGE_SIZE_OPTIONS } from './consts'; import { updateExternalFormData } from './DataTable/utils/externalAPIs'; import getScrollBarSize from './DataTable/utils/getScrollBarSize'; type ValueRange = [number, number]; interface TableSize { width: number; height: number; } const ACTION_KEYS = { enter: 'Enter', spacebar: 'Spacebar', space: ' ', }; /** * Return sortType based on data type */ function getSortTypeByDataType(dataType: GenericDataType): DefaultSortTypes { if (dataType === GenericDataType.Temporal) { return 'datetime'; } if (dataType === GenericDataType.String) { return 'alphanumeric'; } return 'basic'; } /** * Cell background width calculation for horizontal bar chart */ function cellWidth({ value, valueRange, alignPositiveNegative, }: { value: number; valueRange: ValueRange; alignPositiveNegative: boolean; }) { const [minValue, maxValue] = valueRange; if (alignPositiveNegative) { const perc = Math.abs(Math.round((value / maxValue) * 100)); return perc; } const posExtent = Math.abs(Math.max(maxValue, 0)); const negExtent = Math.abs(Math.min(minValue, 0)); const tot = posExtent + negExtent; const perc2 = Math.round((Math.abs(value) / tot) * 100); return perc2; } /** * Cell left margin (offset) calculation for horizontal bar chart elements * when alignPositiveNegative is not set */ function cellOffset({ value, valueRange, alignPositiveNegative, }: { value: number; valueRange: ValueRange; alignPositiveNegative: boolean; }) { if (alignPositiveNegative) { return 0; } const [minValue, maxValue] = valueRange; const posExtent = Math.abs(Math.max(maxValue, 0)); const negExtent = Math.abs(Math.min(minValue, 0)); const tot = posExtent + negExtent; return Math.round((Math.min(negExtent + value, negExtent) / tot) * 100); } /** * Cell background color calculation for horizontal bar chart */ function cellBackground({ value, colorPositiveNegative = false, }: { value: number; colorPositiveNegative: boolean; }) { const r = colorPositiveNegative && value < 0 ? 150 : 0; return `rgba(${r},0,0,0.2)`; } function SortIcon<D extends object>({ column }: { column: ColumnInstance<D> }) { const { isSorted, isSortedDesc } = column; let sortIcon = <FaSort />; if (isSorted) { sortIcon = isSortedDesc ? <FaSortDesc /> : <FaSortAsc />; } return sortIcon; } function SearchInput({ count, value, onChange }: SearchInputProps) { return ( <span className="dt-global-filter"> {t('Search')}{' '} <input aria-label={t('Search %s records', count)} className="form-control input-sm" placeholder={tn('search.num_records', count)} value={value} onChange={onChange} /> </span> ); } function SelectPageSize({ options, current, onChange, }: SelectPageSizeRendererProps) { return ( <span className="dt-select-page-size form-inline" role="group" aria-label={t('Select page size')} > <label htmlFor="pageSizeSelect" className="sr-only"> {t('Select page size')} </label> {t('Show')}{' '} <select id="pageSizeSelect" className="form-control input-sm" value={current} onChange={e => { onChange(Number((e.target as HTMLSelectElement).value)); }} aria-label={t('Show entries per page')} > {options.map(option => { const [size, text] = Array.isArray(option) ? option : [option, option]; return ( <option key={size} value={size}> {text} </option> ); })} </select>{' '} {t('entries per page')} </span> ); } const getNoResultsMessage = (filter: string) => filter ? t('No matching records found') : t('No records found'); export default function TableChart<D extends DataRecord = DataRecord>( props: TableChartTransformedProps<D> & { sticky?: DataTableProps<D>['sticky']; }, ) { const { timeGrain, height, width, data, totals, isRawRecords, rowCount = 0, columns: columnsMeta, alignPositiveNegative: defaultAlignPN = false, colorPositiveNegative: defaultColorPN = false, includeSearch = false, pageSize = 0, serverPagination = false, serverPaginationData, setDataMask, showCellBars = true, sortDesc = false, filters, sticky = true, // whether to use sticky header columnColorFormatters, allowRearrangeColumns = false, allowRenderHtml = true, onContextMenu, emitCrossFilters, isUsingTimeComparison, basicColorFormatters, basicColorColumnFormatters, } = props; const comparisonColumns = [ { key: 'all', label: t('Display all') }, { key: '#', label: '#' }, { key: '△', label: '△' }, { key: '%', label: '%' }, ]; const timestampFormatter = useCallback( value => getTimeFormatterForGranularity(timeGrain)(value), [timeGrain], ); const [tableSize, setTableSize] = useState<TableSize>({ width: 0, height: 0, }); // keep track of whether column order changed, so that column widths can too const [columnOrderToggle, setColumnOrderToggle] = useState(false); const [showComparisonDropdown, setShowComparisonDropdown] = useState(false); const [selectedComparisonColumns, setSelectedComparisonColumns] = useState([ comparisonColumns[0].key, ]); const [hideComparisonKeys, setHideComparisonKeys] = useState<string[]>([]); const theme = useTheme(); // only take relevant page size options const pageSizeOptions = useMemo(() => { const getServerPagination = (n: number) => n <= rowCount; return PAGE_SIZE_OPTIONS.filter(([n]) => serverPagination ? getServerPagination(n) : n <= 2 * data.length, ) as SizeOption[]; }, [data.length, rowCount, serverPagination]); const getValueRange = useCallback( function getValueRange(key: string, alignPositiveNegative: boolean) { if (typeof data?.[0]?.[key] === 'number') { const nums = data.map(row => row[key]) as number[]; return ( alignPositiveNegative ? [0, d3Max(nums.map(Math.abs))] : d3Extent(nums) ) as ValueRange; } return null; }, [data], ); const isActiveFilterValue = useCallback( function isActiveFilterValue(key: string, val: DataRecordValue) { return !!filters && filters[key]?.includes(val); }, [filters], ); const getCrossFilterDataMask = (key: string, value: DataRecordValue) => { let updatedFilters = { ...(filters || {}) }; if (filters && isActiveFilterValue(key, value)) { updatedFilters = {}; } else { updatedFilters = { [key]: [value], }; } if ( Array.isArray(updatedFilters[key]) && updatedFilters[key].length === 0 ) { delete updatedFilters[key]; } const groupBy = Object.keys(updatedFilters); const groupByValues = Object.values(updatedFilters); const labelElements: string[] = []; groupBy.forEach(col => { const isTimestamp = col === DTTM_ALIAS; const filterValues = ensureIsArray(updatedFilters?.[col]); if (filterValues.length) { const valueLabels = filterValues.map(value => isTimestamp ? timestampFormatter(value) : value, ); labelElements.push(`${valueLabels.join(', ')}`); } }); return { dataMask: { extraFormData: { filters: groupBy.length === 0 ? [] : groupBy.map(col => { const val = ensureIsArray(updatedFilters?.[col]); if (!val.length) return { col, op: 'IS NULL' as const, }; return { col, op: 'IN' as const, val: val.map(el => el instanceof Date ? el.getTime() : el!, ), grain: col === DTTM_ALIAS ? timeGrain : undefined, }; }), }, filterState: { label: labelElements.join(', '), value: groupByValues.length ? groupByValues : null, filters: updatedFilters && Object.keys(updatedFilters).length ? updatedFilters : null, }, }, isCurrentValueSelected: isActiveFilterValue(key, value), }; }; const toggleFilter = useCallback( function toggleFilter(key: string, val: DataRecordValue) { if (!emitCrossFilters) { return; } setDataMask(getCrossFilterDataMask(key, val).dataMask); }, [emitCrossFilters, getCrossFilterDataMask, setDataMask], ); const getSharedStyle = (column: DataColumnMeta): CSSProperties => { const { isNumeric, config = {} } = column; const textAlign = config.horizontalAlign || (isNumeric && !isUsingTimeComparison ? 'right' : 'left'); return { textAlign, }; }; const comparisonLabels = [t('Main'), '#', '△', '%']; const filteredColumnsMeta = useMemo(() => { if (!isUsingTimeComparison) { return columnsMeta; } const allColumns = comparisonColumns[0].key; const main = comparisonLabels[0]; const showAllColumns = selectedComparisonColumns.includes(allColumns); return columnsMeta.filter(({ label, key }) => { // Extract the key portion after the space, assuming the format is always "label key" const keyPortion = key.substring(label.length); const isKeyHidded = hideComparisonKeys.includes(keyPortion); const isLableMain = label === main; return ( isLableMain || (!isKeyHidded && (!comparisonLabels.includes(label) || showAllColumns || selectedComparisonColumns.includes(label))) ); }); }, [ columnsMeta, comparisonColumns, comparisonLabels, isUsingTimeComparison, hideComparisonKeys, selectedComparisonColumns, ]); const handleContextMenu = onContextMenu && !isRawRecords ? ( value: D, cellPoint: { key: string; value: DataRecordValue; isMetric?: boolean; }, clientX: number, clientY: number, ) => { const drillToDetailFilters: BinaryQueryObjectFilterClause[] = []; filteredColumnsMeta.forEach(col => { if (!col.isMetric) { const dataRecordValue = value[col.key]; drillToDetailFilters.push({ col: col.key, op: '==', val: dataRecordValue as string | number | boolean, formattedVal: formatColumnValue(col, dataRecordValue)[1], }); } }); onContextMenu(clientX, clientY, { drillToDetail: drillToDetailFilters, crossFilter: cellPoint.isMetric ? undefined : getCrossFilterDataMask(cellPoint.key, cellPoint.value), drillBy: cellPoint.isMetric ? undefined : { filters: [ { col: cellPoint.key, op: '==', val: cellPoint.value as string | number | boolean, }, ], groupbyFieldName: 'groupby', }, }); } : undefined; const getHeaderColumns = ( columnsMeta: DataColumnMeta[], enableTimeComparison?: boolean, ) => { const resultMap: Record<string, number[]> = {}; if (!enableTimeComparison) { return resultMap; } columnsMeta.forEach((element, index) => { // Check if element's label is one of the comparison labels if (comparisonLabels.includes(element.label)) { // Extract the key portion after the space, assuming the format is always "label key" const keyPortion = element.key.substring(element.label.length); // If the key portion is not in the map, initialize it with the current index if (!resultMap[keyPortion]) { resultMap[keyPortion] = [index]; } else { // Add the index to the existing array resultMap[keyPortion].push(index); } } }); return resultMap; }; const renderTimeComparisonDropdown = (): JSX.Element => { const allKey = comparisonColumns[0].key; const handleOnClick = (data: any) => { const { key } = data; // Toggle 'All' key selection if (key === allKey) { setSelectedComparisonColumns([allKey]); } else if (selectedComparisonColumns.includes(allKey)) { setSelectedComparisonColumns([key]); } else { // Toggle selection for other keys setSelectedComparisonColumns( selectedComparisonColumns.includes(key) ? selectedComparisonColumns.filter(k => k !== key) // Deselect if already selected : [...selectedComparisonColumns, key], ); // Select if not already selected } }; const handleOnBlur = () => { if (selectedComparisonColumns.length === 3) { setSelectedComparisonColumns([comparisonColumns[0].key]); } }; return ( <Dropdown placement="bottomRight" visible={showComparisonDropdown} onVisibleChange={(flag: boolean) => { setShowComparisonDropdown(flag); }} overlay={ <Menu multiple onClick={handleOnClick} onBlur={handleOnBlur} selectedKeys={selectedComparisonColumns} > <div css={css` max-width: 242px; padding: 0 ${theme.gridUnit * 2}px; color: ${theme.colors.grayscale.base}; font-size: ${theme.typography.sizes.s}px; `} > {t( 'Select columns that will be displayed in the table. You can multiselect columns.', )} </div> {comparisonColumns.map(column => ( <Menu.Item key={column.key}> <span css={css` color: ${theme.colors.grayscale.dark2}; `} > {column.label} </span> <span css={css` float: right; font-size: ${theme.typography.sizes.s}px; `} > {selectedComparisonColumns.includes(column.key) && ( <CheckOutlined /> )} </span> </Menu.Item> ))} </Menu> } trigger={['click']} > <span> <TableOutlined /> <DownOutlined /> </span> </Dropdown> ); }; const renderGroupingHeaders = (): JSX.Element => { // TODO: Make use of ColumnGroup to render the aditional headers const headers: any = []; let currentColumnIndex = 0; Object.entries(groupHeaderColumns || {}).forEach(([key, value]) => { // Calculate the number of placeholder columns needed before the current header const startPosition = value[0]; const colSpan = value.length; // Retrieve the originalLabel from the first column in this group const originalLabel = columnsMeta[value[0]]?.originalLabel || key; // Add placeholder <th> for columns before this header for (let i = currentColumnIndex; i < startPosition; i += 1) { headers.push( <th key={`placeholder-${i}`} style={{ borderBottom: 0 }} aria-label={`Header-${i}`} />, ); } // Add the current header <th> headers.push( <th key={`header-${key}`} colSpan={colSpan} style={{ borderBottom: 0 }}> {originalLabel} <span css={css` float: right; & svg { color: ${theme.colors.grayscale.base} !important; } `} > {hideComparisonKeys.includes(key) ? ( <PlusCircleOutlined onClick={() => setHideComparisonKeys( hideComparisonKeys.filter(k => k !== key), ) } /> ) : ( <MinusCircleOutlined onClick={() => setHideComparisonKeys([...hideComparisonKeys, key]) } /> )} </span> </th>, ); // Update the current column index currentColumnIndex = startPosition + colSpan; }); return ( <tr css={css` th { border-right: 2px solid ${theme.colors.grayscale.light2}; } th:first-child { border-left: none; } th:last-child { border-right: none; } `} > {headers} </tr> ); }; const groupHeaderColumns = useMemo( () => getHeaderColumns(filteredColumnsMeta, isUsingTimeComparison), [filteredColumnsMeta, isUsingTimeComparison], ); const getColumnConfigs = useCallback( (column: DataColumnMeta, i: number): ColumnWithLooseAccessor<D> => { const { key, label: originalLabel, isNumeric, dataType, isMetric, isPercentMetric, config = {}, } = column; const label = config.customColumnName || originalLabel; let displayLabel = label; const isComparisonColumn = ['#', '△', '%', t('Main')].includes( column.label, ); if (isComparisonColumn) { if (column.label === t('Main')) { displayLabel = config.customColumnName || column.originalLabel || ''; } else if (config.customColumnName) { displayLabel = config.displayTypeIcon !== false ? `${column.label} ${config.customColumnName}` : config.customColumnName; } else if (config.displayTypeIcon === false) { displayLabel = ''; } } const columnWidth = Number.isNaN(Number(config.columnWidth)) ? config.columnWidth : Number(config.columnWidth); // inline style for both th and td cell const sharedStyle: CSSProperties = getSharedStyle(column); const alignPositiveNegative = config.alignPositiveNegative === undefined ? defaultAlignPN : config.alignPositiveNegative; const colorPositiveNegative = config.colorPositiveNegative === undefined ? defaultColorPN : config.colorPositiveNegative; const { truncateLongCells } = config; const hasColumnColorFormatters = isNumeric && Array.isArray(columnColorFormatters) && columnColorFormatters.length > 0; const hasBasicColorFormatters = isUsingTimeComparison && Array.isArray(basicColorFormatters) && basicColorFormatters.length > 0; const valueRange = !hasBasicColorFormatters && !hasColumnColorFormatters && (config.showCellBars === undefined ? showCellBars : config.showCellBars) && (isMetric || isRawRecords || isPercentMetric) && getValueRange(key, alignPositiveNegative); let className = ''; if (emitCrossFilters && !isMetric) { className += ' dt-is-filter'; } if (!isMetric && !isPercentMetric) { className += ' right-border-only'; } else if (comparisonLabels.includes(label)) { const groupinHeader = key.substring(label.length); const columnsUnderHeader = groupHeaderColumns[groupinHeader] || []; if (i === columnsUnderHeader[columnsUnderHeader.length - 1]) { className += ' right-border-only'; } } return { id: String(i), // to allow duplicate column keys // must use custom accessor to allow `.` in column names // typing is incorrect in current version of `@types/react-table` // so we ask TS not to check. accessor: ((datum: D) => datum[key]) as never, Cell: ({ value, row }: { value: DataRecordValue; row: Row<D> }) => { const [isHtml, text] = formatColumnValue(column, value); const html = isHtml && allowRenderHtml ? { __html: text } : undefined; let backgroundColor; let arrow = ''; const originKey = column.key.substring(column.label.length).trim(); if (!hasColumnColorFormatters && hasBasicColorFormatters) { backgroundColor = basicColorFormatters[row.index][originKey]?.backgroundColor; arrow = column.label === comparisonLabels[0] ? basicColorFormatters[row.index][originKey]?.mainArrow : ''; } if (hasColumnColorFormatters) { columnColorFormatters! .filter(formatter => formatter.column === column.key) .forEach(formatter => { const formatterResult = value || value === 0 ? formatter.getColorFromValue(value as number) : false; if (formatterResult) { backgroundColor = formatterResult; } }); } if ( basicColorColumnFormatters && basicColorColumnFormatters?.length > 0 ) { backgroundColor = basicColorColumnFormatters[row.index][column.key] ?.backgroundColor || backgroundColor; arrow = column.label === comparisonLabels[0] ? basicColorColumnFormatters[row.index][column.key]?.mainArrow : ''; } const StyledCell = styled.td` text-align: ${sharedStyle.textAlign}; white-space: ${value instanceof Date ? 'nowrap' : undefined}; position: relative; background: ${backgroundColor || undefined}; padding-left: ${column.isChildColumn ? `${theme.gridUnit * 5}px` : `${theme.gridUnit}px`}; `; const cellBarStyles = css` position: absolute; height: 100%; display: block; top: 0; ${valueRange && ` width: ${`${cellWidth({ value: value as number, valueRange, alignPositiveNegative, })}%`}; left: ${`${cellOffset({ value: value as number, valueRange, alignPositiveNegative, })}%`}; background-color: ${cellBackground({ value: value as number, colorPositiveNegative, })}; `} `; let arrowStyles = css` color: ${basicColorFormatters && basicColorFormatters[row.index][originKey]?.arrowColor === ColorSchemeEnum.Green ? theme.colors.success.base : theme.colors.error.base}; margin-right: ${theme.gridUnit}px; `; if ( basicColorColumnFormatters && basicColorColumnFormatters?.length > 0 ) { arrowStyles = css` color: ${basicColorColumnFormatters[row.index][column.key] ?.arrowColor === ColorSchemeEnum.Green ? theme.colors.success.base : theme.colors.error.base}; margin-right: ${theme.gridUnit}px; `; } const cellProps = { 'aria-labelledby': `header-${column.key}`, role: 'cell', // show raw number in title in case of numeric values title: typeof value === 'number' ? String(value) : undefined, onClick: emitCrossFilters && !valueRange && !isMetric ? () => { // allow selecting text in a cell if (!getSelectedText()) { toggleFilter(key, value); } } : undefined, onContextMenu: (e: MouseEvent) => { if (handleContextMenu) { e.preventDefault(); e.stopPropagation(); handleContextMenu( row.original, { key, value, isMetric }, e.nativeEvent.clientX, e.nativeEvent.clientY, ); } }, className: [ className, value == null ? 'dt-is-null' : '', isActiveFilterValue(key, value) ? ' dt-is-active-filter' : '', ].join(' '), tabIndex: 0, }; if (html) { if (truncateLongCells) { // eslint-disable-next-line react/no-danger return ( <StyledCell {...cellProps}> <div className="dt-truncate-cell" style={columnWidth ? { width: columnWidth } : undefined} dangerouslySetInnerHTML={html} /> </StyledCell> ); } // eslint-disable-next-line react/no-danger return <StyledCell {...cellProps} dangerouslySetInnerHTML={html} />; } // If cellProps renders textContent already, then we don't have to // render `Cell`. This saves some time for large tables. return ( <StyledCell {...cellProps}> {valueRange && ( <div /* The following classes are added to support custom CSS styling */ className={cx( 'cell-bar', typeof value === 'number' && value < 0 ? 'negative' : 'positive', )} css={cellBarStyles} role="presentation" /> )} {truncateLongCells ? ( <div className="dt-truncate-cell" style={columnWidth ? { width: columnWidth } : undefined} > {arrow && <span css={arrowStyles}>{arrow}</span>} {text} </div> ) : ( <> {arrow && <span css={arrowStyles}>{arrow}</span>} {text} </> )} </StyledCell> ); }, Header: ({ column: col, onClick, style, onDragStart, onDrop }) => ( <th id={`header-${column.key}`} title={t('Shift + Click to sort by multiple columns')} className={[className, col.isSorted ? 'is-sorted' : ''].join(' ')} style={{ ...sharedStyle, ...style, }} onKeyDown={(e: ReactKeyboardEvent<HTMLElement>) => { // programatically sort column on keypress if (Object.values(ACTION_KEYS).includes(e.key)) { col.toggleSortBy(); } }} role="columnheader button" onClick={onClick} data-column-name={col.id} {...(allowRearrangeColumns && { draggable: 'true', onDragStart, onDragOver: e => e.preventDefault(), onDragEnter: e => e.preventDefault(), onDrop, })} tabIndex={0} > {/* can't use `columnWidth &&` because it may also be zero */} {config.columnWidth ? ( // column width hint <div style={{ width: columnWidth, height: 0.01, }} /> ) : null} <div data-column-name={col.id} css={{ display: 'inline-flex', alignItems: 'flex-end', }} > <span data-column-name={col.id}>{displayLabel}</span> <SortIcon column={col} /> </div> </th> ), Footer: totals ? ( i === 0 ? ( <th key={`footer-summary-${i}`}> <div css={css` display: flex; align-items: center; & svg { margin-left: ${theme.gridUnit}px; color: ${theme.colors.grayscale.dark1} !important; } `} > {t('Summary')} <Tooltip overlay={t( 'Show total aggregations of selected metrics. Note that row limit does not apply to the result.', )} > <InfoCircleOutlined /> </Tooltip> </div> </th> ) : ( <td key={`footer-total-${i}`} style={sharedStyle}> <strong>{formatColumnValue(column, totals[key])[1]}</strong> </td> ) ) : undefined, sortDescFirst: sortDesc, sortType: getSortTypeByDataType(dataType), }; }, [ defaultAlignPN, defaultColorPN, emitCrossFilters, getValueRange, isActiveFilterValue, isRawRecords, showCellBars, sortDesc, toggleFilter, totals, columnColorFormatters, columnOrderToggle, ], ); const visibleColumnsMeta = useMemo( () => filteredColumnsMeta.filter(col => col.config?.visible !== false), [filteredColumnsMeta], ); const columns = useMemo( () => visibleColumnsMeta.map(getColumnConfigs), [visibleColumnsMeta, getColumnConfigs], ); const handleServerPaginationChange = useCallback( (pageNumber: number, pageSize: number) => { updateExternalFormData(setDataMask, pageNumber, pageSize); }, [setDataMask], ); const handleSizeChange = useCallback( ({ width, height }: { width: number; height: number }) => { setTableSize({ width, height }); }, [], ); useLayoutEffect(() => { // After initial load the table should resize only when the new sizes // Are not only scrollbar updates, otherwise, the table would twitch const scrollBarSize = getScrollBarSize(); const { width: tableWidth, height: tableHeight } = tableSize; // Table is increasing its original size if ( width - tableWidth > scrollBarSize || height - tableHeight > scrollBarSize ) { handleSizeChange({ width: width - scrollBarSize, height: height - scrollBarSize, }); } else if ( tableWidth - width > scrollBarSize || tableHeight - height > scrollBarSize ) { // Table is decreasing its original size handleSizeChange({ width, height, }); } }, [width, height, handleSizeChange, tableSize]); const { width: widthFromState, height: heightFromState } = tableSize; return ( <Styles> <DataTable<D> columns={columns} data={data} rowCount={rowCount} tableClassName="table table-striped table-condensed" pageSize={pageSize} serverPaginationData={serverPaginationData} pageSizeOptions={pageSizeOptions} width={widthFromState} height={heightFromState} serverPagination={serverPagination} onServerPaginationChange={handleServerPaginationChange} onColumnOrderChange={() => setColumnOrderToggle(!columnOrderToggle)} // 9 page items in > 340px works well even for 100+ pages maxPageItemCount={width > 340 ? 9 : 7} noResults={getNoResultsMessage} searchInput={includeSearch && SearchInput} selectPageSize={pageSize !== null && SelectPageSize} // not in use in Superset, but needed for unit tests sticky={sticky} renderGroupingHeaders={ !isEmpty(groupHeaderColumns) ? renderGroupingHeaders : undefined } renderTimeComparisonDropdown={ isUsingTimeComparison ? renderTimeComparisonDropdown : undefined } /> </Styles> ); }