/** * 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, useEffect, useRef, } from 'react'; import { ColumnInstance, ColumnWithLooseAccessor, DefaultSortTypes, Row, } from 'react-table'; import { extent as d3Extent, max as d3Max } from 'd3-array'; import { CaretUpOutlined, CaretDownOutlined, ColumnHeightOutlined, } from '@ant-design/icons'; import cx from 'classnames'; import { DataRecord, DataRecordValue, DTTM_ALIAS, ensureIsArray, getSelectedText, getTimeFormatterForGranularity, BinaryQueryObjectFilterClause, extractTextFromHTML, } from '@superset-ui/core'; import { styled, css, useTheme, SupersetTheme, } from '@apache-superset/core/theme'; import { t, tn } from '@apache-superset/core/translation'; import { GenericDataType } from '@apache-superset/core/common'; import { Input, Space, RawAntdSelect as Select, Dropdown, Tooltip, } from '@superset-ui/core/components'; import { CheckOutlined, InfoCircleOutlined, DownOutlined, MinusCircleOutlined, PlusCircleOutlined, TableOutlined, } from '@ant-design/icons'; import { isEmpty, debounce, isEqual } from 'lodash'; import { ColorFormatters, getTextColorForBackground, ObjectFormattingEnum, ColorSchemeEnum, } from '@superset-ui/chart-controls'; import { DataColumnMeta, SearchOption, SortByItem, TableChartTransformedProps, } from './types'; import DataTable, { DataTableProps, SearchInputProps, SelectPageSizeRendererProps, SizeOption, } from './DataTable'; import Styles from './Styles'; import { formatColumnValue } from './utils/formatValue'; import { PAGE_SIZE_OPTIONS, SERVER_PAGE_SIZE_OPTIONS } from './consts'; import { updateTableOwnState } from './DataTable/utils/externalAPIs'; import getScrollBarSize from './DataTable/utils/getScrollBarSize'; import DateWithFormatter from './utils/DateWithFormatter'; 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; } /** * Sanitize a column identifier for use in HTML id attributes and CSS selectors. * Replaces characters that are invalid in CSS selectors with safe alternatives. * * Note: The returned value should be prefixed with a string (e.g., "header-") * to ensure it forms a valid HTML ID (IDs cannot start with a digit). * * Exported for testing. */ export function sanitizeHeaderId(columnId: string): string { return ( columnId // Semantic replacements first: preserve meaning in IDs for readability // (e.g., '%pct_nice' → 'percentpct_nice' instead of '_pct_nice') .replace(/%/g, 'percent') .replace(/#/g, 'hash') .replace(/△/g, 'delta') // Generic sanitization for remaining special characters .replace(/\s+/g, '_') .replace(/[^a-zA-Z0-9_-]/g, '_') .replace(/_+/g, '_') // Collapse consecutive underscores .replace(/^_+|_+$/g, '') // Trim leading/trailing underscores ); } /** * 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, theme, }: { value: number; colorPositiveNegative: boolean; theme: SupersetTheme; }) { if (!colorPositiveNegative) { return `${theme.colorFill}`; } if (value < 0) { return `${theme.colorError}50`; } return `${theme.colorSuccess}50`; } function SortIcon({ column }: { column: ColumnInstance }) { const { isSorted, isSortedDesc } = column; let sortIcon = ; if (isSorted) { sortIcon = isSortedDesc ? : ; } return sortIcon; } /** * Label that is visually hidden but accessible */ const VisuallyHidden = styled.label` position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; `; function SearchInput({ count, value, onChange, onBlur, inputRef, }: SearchInputProps) { return ( {t('Search')} ); } function SelectPageSize({ options, current, onChange, }: SelectPageSizeRendererProps) { const { Option } = Select; return ( {t('Select page size')} {t('Show')}{' '} id="pageSizeSelect" value={current} onChange={value => onChange(value)} size="small" css={(theme: SupersetTheme) => css` width: ${theme.sizeUnit * 18}px; `} aria-label={t('Show entries per page')} > {options.map(option => { const [size, text] = Array.isArray(option) ? option : [option, option]; return ( ); })} {' '} {t('entries per page')} ); } const getNoResultsMessage = (filter: string) => filter ? t('No matching records found') : t('No records found'); export default function TableChart( props: TableChartTransformedProps & { sticky?: DataTableProps['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, hasServerPageLengthChanged, serverPageLength, slice_id, columnLabelToNameMap = {}, } = props; const comparisonColumns = useMemo( () => [ { key: 'all', label: t('Display all') }, { key: '#', label: '#' }, { key: '△', label: '△' }, { key: '%', label: '%' }, ], [], ); const timestampFormatter = useCallback( (value: DataRecordValue) => isRawRecords ? String(value ?? '') : getTimeFormatterForGranularity(timeGrain)( value as number | Date | null | undefined, ), [timeGrain, isRawRecords], ); const [tableSize, setTableSize] = useState({ 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([]); // recalculated totals to display when the search filter is applied (client-side pagination) const [displayedTotals, setDisplayedTotals] = useState(totals); const theme = useTheme(); useEffect(() => { setDisplayedTotals(totals); }, [totals]); // only take relevant page size options const pageSizeOptions = useMemo(() => { const getServerPagination = (n: number) => n <= rowCount; return ( serverPagination ? SERVER_PAGE_SIZE_OPTIONS : 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) { const nums = data ?.map(row => row?.[key]) .filter(value => typeof value === 'number') as number[]; if (nums.length > 0) { return ( alignPositiveNegative ? [0, d3Max(nums.map(Math.abs))] : d3Extent(nums) ) as ValueRange; } return null; }, [data], ); const isActiveFilterValue = useCallback( function isActiveFilterValue(key: string, val: DataRecordValue) { if (!filters || !filters[key]) return false; return filters[key].some(filterVal => { if (filterVal === val) return true; // DateWithFormatter extends Date — compare by time value // since memoization cache misses can create new instances if (filterVal instanceof Date && val instanceof Date) { return filterVal.getTime() === val.getTime(); } return false; }); }, [filters], ); const getCrossFilterDataMask = useCallback( (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 => { // Resolve adhoc column labels back to original column names // so that cross-filters work on the receiving chart const resolvedCol = columnLabelToNameMap[col] ?? col; const val = ensureIsArray(updatedFilters?.[col]); if (!val.length) return { col: resolvedCol, op: 'IS NULL' as const, }; return { col: resolvedCol, op: 'IN' as const, val: val.map(el => el instanceof Date ? el.getTime() : el!, ), grain: resolvedCol === 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), }; }, [ filters, isActiveFilterValue, timestampFormatter, timeGrain, columnLabelToNameMap, ], ); const toggleFilter = useCallback( function toggleFilter(key: string, val: DataRecordValue) { if (!emitCrossFilters) { return; } setDataMask(getCrossFilterDataMask(key, val).dataMask); }, [emitCrossFilters, getCrossFilterDataMask, setDataMask], ); const getSharedStyle = useCallback( (column: DataColumnMeta): CSSProperties => { const { isNumeric, config = {} } = column; const textAlign = config.horizontalAlign || (isNumeric && !isUsingTimeComparison ? 'right' : 'left'); return { textAlign, }; }, [isUsingTimeComparison], ); const comparisonLabels = useMemo(() => [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 = useMemo(() => { if (onContextMenu && !isRawRecords) { return ( value: D, cellPoint: { key: string; value: DataRecordValue; isMetric?: boolean; }, clientX: number, clientY: number, ) => { const drillToDetailFilters: BinaryQueryObjectFilterClause[] = []; filteredColumnsMeta.forEach(col => { if (!col.isMetric) { let dataRecordValue = value[col.key]; dataRecordValue = extractTextFromHTML(dataRecordValue); 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: extractTextFromHTML(cellPoint.value), }, ], groupbyFieldName: 'groupby', }, }); }; } return undefined; }, [ onContextMenu, isRawRecords, filteredColumnsMeta, getCrossFilterDataMask, ]); const getHeaderColumns = useCallback( (columnsMeta: DataColumnMeta[], enableTimeComparison?: boolean) => { const resultMap: Record = {}; 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; }, [comparisonLabels], ); 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 ( { setShowComparisonDropdown(flag); }} menu={{ multiple: true, onClick: handleOnClick, onBlur: handleOnBlur, selectedKeys: selectedComparisonColumns, items: [ { key: 'all', label: (
{t( 'Select columns that will be displayed in the table. You can multiselect columns.', )}
), type: 'group', children: comparisonColumns.map( (column: { key: string; label: string }) => ({ key: column.key, label: ( <> {column.label} {selectedComparisonColumns.includes(column.key) && ( )} ), }), ), }, ], }} trigger={['click']} >
); }; // Compute visible columns before groupHeaderColumns to ensure index consistency. // This filters out columns with config.visible === false. const visibleColumnsMeta = useMemo( () => filteredColumnsMeta.filter(col => col.config?.visible !== false), [filteredColumnsMeta], ); // Use visibleColumnsMeta for groupHeaderColumns to ensure indices match the actual // table columns. This fixes header misalignment when columns are filtered. const groupHeaderColumns = useMemo( () => getHeaderColumns(visibleColumnsMeta, isUsingTimeComparison), [visibleColumnsMeta, getHeaderColumns, isUsingTimeComparison], ); const renderGroupingHeaders = (): JSX.Element => { // TODO: Make use of ColumnGroup to render the aditional headers const headers: any = []; let currentColumnIndex = 0; // Sort entries by their first column index to ensure correct left-to-right order. // Object.entries() maintains insertion order, but when columns are filtered, // the first occurrence of each metric might not match the visual column order. const sortedEntries = Object.entries(groupHeaderColumns || {}).sort( (a, b) => a[1][0] - b[1][0], ); sortedEntries.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. // Use visibleColumnsMeta to ensure consistent indexing with the actual table columns. const firstColumnInGroup = visibleColumnsMeta[startPosition]; const originalLabel = firstColumnInGroup ? columnsMeta.find(col => col.key === firstColumnInGroup.key) ?.originalLabel || key : key; // Add placeholder for columns before this header for (let i = currentColumnIndex; i < startPosition; i += 1) { headers.push( , ); } // Add the current header headers.push( {originalLabel} {hideComparisonKeys.includes(key) ? ( setHideComparisonKeys( hideComparisonKeys.filter(k => k !== key), ) } /> ) : ( setHideComparisonKeys([...hideComparisonKeys, key]) } /> )} , ); // Update the current column index currentColumnIndex = startPosition + colSpan; }); return ( {headers} ); }; const getColumnConfigs = useCallback( ( column: DataColumnMeta, i: number, ): ColumnWithLooseAccessor & { columnKey: string; } => { const { key, label: originalLabel, dataType, isMetric, isPercentMetric, config = {}, description, } = 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 = Array.isArray(columnColorFormatters) && columnColorFormatters.length > 0; const hasBasicColorFormatters = isUsingTimeComparison && Array.isArray(basicColorFormatters) && basicColorFormatters.length > 0; const generalShowCellBars = config.showCellBars === undefined ? showCellBars : config.showCellBars; const valueRange = !hasBasicColorFormatters && generalShowCellBars && (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'; } } // Cache sanitized header ID to avoid recomputing it multiple times const headerId = sanitizeHeaderId(column.originalLabel ?? column.key); 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. columnKey: key, accessor: ((datum: D) => datum[key]) as never, Cell: ({ value, row }: { value: DataRecordValue; row: Row }) => { const [isHtml, text] = formatColumnValue(column, value, row.original); const html = isHtml && allowRenderHtml ? { __html: text } : undefined; let backgroundColor; let color; let backgroundColorCellBar; let valueRangeFlag = true; 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) { const applyFormatter = ( formatter: ColorFormatters[number], valueToFormat: any, ) => { const formatterResult = formatter.getColorFromValue(valueToFormat); if (!formatterResult) return; if ( formatter.objectFormatting === ObjectFormattingEnum.TEXT_COLOR || formatter.toTextColor ) { color = formatterResult; } else if ( formatter.objectFormatting === ObjectFormattingEnum.CELL_BAR ) { if (generalShowCellBars) backgroundColorCellBar = formatterResult.slice(0, -2); } else { backgroundColor = formatterResult; valueRangeFlag = false; } }; columnColorFormatters .filter(formatter => { if (formatter.columnFormatting) { return formatter.columnFormatting === column.key; } return formatter.column === column.key; }) .forEach(formatter => { let valueToFormat; if (formatter.columnFormatting) { valueToFormat = row.original[formatter.column]; } else { valueToFormat = value; } applyFormatter(formatter, valueToFormat); }); columnColorFormatters .filter( formatter => formatter.columnFormatting === ObjectFormattingEnum.ENTIRE_ROW, ) .forEach(formatter => applyFormatter(formatter, row.original[formatter.column]), ); } 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 rowSurfaceColor = row.index % 2 === 0 ? theme.colorBgLayout : theme.colorBgBase; const resolvedTextColor = getTextColorForBackground( { backgroundColor, color }, rowSurfaceColor, ); const StyledCell = styled.td` text-align: ${sharedStyle.textAlign}; white-space: ${value instanceof Date ? 'nowrap' : undefined}; position: relative; font-weight: ${color ? `${theme.fontWeightBold}` : `${theme.fontWeightNormal}`}; background: ${backgroundColor || undefined}; padding-left: ${column.isChildColumn ? `${theme.sizeUnit * 5}px` : `${theme.sizeUnit}px`}; `; const cellBarStyles = css` position: absolute; height: 100%; display: block; top: 0; ${valueRange && typeof value === 'number' && valueRangeFlag && ` width: ${`${cellWidth({ value: value as number, valueRange, alignPositiveNegative, })}%`}; left: ${`${cellOffset({ value: value as number, valueRange, alignPositiveNegative, })}%`}; background-color: ${ (backgroundColorCellBar && `${backgroundColorCellBar}99`) || cellBackground({ value: value as number, colorPositiveNegative, theme, }) }; `} `; let arrowStyles = css` color: ${basicColorFormatters && basicColorFormatters[row.index][originKey]?.arrowColor === ColorSchemeEnum.Green ? theme.colorSuccess : theme.colorError}; margin-right: ${theme.sizeUnit}px; `; if ( basicColorColumnFormatters && basicColorColumnFormatters?.length > 0 ) { arrowStyles = css` color: ${basicColorColumnFormatters[row.index][column.key] ?.arrowColor === ColorSchemeEnum.Green ? theme.colorSuccess : theme.colorError}; margin-right: ${theme.sizeUnit}px; `; } const cellProps = { 'aria-labelledby': `header-${headerId}`, 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 || (value instanceof DateWithFormatter && value.input == null) ? 'dt-is-null' : '', isActiveFilterValue(key, value) ? ' dt-is-active-filter' : '', ].join(' '), style: resolvedTextColor ? ({ color: resolvedTextColor } as CSSProperties) : undefined, tabIndex: 0, }; if (html) { if (truncateLongCells) { return (
); } // Safe: HTML is sanitized via formatColumnValue // eslint-disable-next-line react/no-danger return ; } // If cellProps renders textContent already, then we don't have to // render `Cell`. This saves some time for large tables. return ( {valueRange && (
)} {truncateLongCells ? (
{arrow && {arrow}} {text}
) : ( <> {arrow && {arrow}} {text} )} ); }, Header: ({ column: col, onClick, style, onDragStart, onDrop }) => ( ) => { // 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
) : null}
{displayLabel}
), Footer: displayedTotals ? ( i === 0 ? (
{t('Summary')}
) : ( {formatColumnValue(column, displayedTotals[key])[1]} ) ) : undefined, sortDescFirst: sortDesc, sortType: getSortTypeByDataType(dataType), }; }, [ getSharedStyle, defaultAlignPN, defaultColorPN, columnColorFormatters, isUsingTimeComparison, basicColorFormatters, showCellBars, isRawRecords, getValueRange, emitCrossFilters, comparisonLabels, displayedTotals, theme, sortDesc, groupHeaderColumns, allowRenderHtml, basicColorColumnFormatters, isActiveFilterValue, toggleFilter, handleContextMenu, allowRearrangeColumns, ], ); const columns = useMemo( () => visibleColumnsMeta.map(getColumnConfigs), [visibleColumnsMeta, getColumnConfigs], ); const [searchOptions, setSearchOptions] = useState([]); const handleFilteredDataChange = useCallback( (rows: Row[], searchText?: string) => { if (!totals || serverPagination) { return; } if (!searchText?.trim()) { setDisplayedTotals(totals); return; } const updatedTotals: Record = { ...totals }; filteredColumnsMeta.forEach(column => { if (column.isMetric || column.isPercentMetric) { const aggregatedValue = rows.reduce((acc, row) => { const rawValue = row.original?.[column.key]; const numValue = Number(String(rawValue ?? '').replace(/,/g, '')); return Number.isFinite(numValue) ? acc + numValue : acc; }, 0); updatedTotals[column.key] = aggregatedValue; } }); setDisplayedTotals(updatedTotals as D); }, [filteredColumnsMeta, serverPagination, totals], ); useEffect(() => { const options = ( columns as unknown as ColumnWithLooseAccessor & { columnKey: string; sortType?: string; }[] ) .filter(col => col?.sortType === 'alphanumeric') .map(column => ({ value: column.columnKey, label: column.columnKey, })); if (!isEqual(options, searchOptions)) { setSearchOptions(options || []); } }, [columns, searchOptions]); const handleServerPaginationChange = useCallback( (pageNumber: number, pageSize: number) => { const modifiedOwnState = { ...serverPaginationData, currentPage: pageNumber, pageSize, }; updateTableOwnState(setDataMask, modifiedOwnState); }, [serverPaginationData, setDataMask], ); useEffect(() => { if (hasServerPageLengthChanged) { const modifiedOwnState = { ...serverPaginationData, currentPage: 0, pageSize: serverPageLength, }; updateTableOwnState(setDataMask, modifiedOwnState); } }, [ hasServerPageLengthChanged, serverPageLength, serverPaginationData, 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; const handleSortByChange = useCallback( (sortBy: SortByItem[]) => { if (!serverPagination) return; const modifiedOwnState = { ...serverPaginationData, sortBy, }; updateTableOwnState(setDataMask, modifiedOwnState); }, [serverPagination, serverPaginationData, setDataMask], ); const handleSearch = (searchText: string) => { const modifiedOwnState = { ...serverPaginationData, searchColumn: serverPaginationData?.searchColumn || searchOptions[0]?.value, searchText, currentPage: 0, // Reset to first page when searching }; updateTableOwnState(setDataMask, modifiedOwnState); }; const debouncedSearch = debounce(handleSearch, 800); const handleChangeSearchCol = (searchCol: string) => { if (!isEqual(searchCol, serverPaginationData?.searchColumn)) { const modifiedOwnState = { ...serverPaginationData, searchColumn: searchCol, searchText: '', }; updateTableOwnState(setDataMask, modifiedOwnState); } }; // collect client-side filtered rows for export & push snapshot to ownState (guarded) const [clientViewRows, setClientViewRows] = useState([]); const exportColumns = useMemo( () => visibleColumnsMeta.map(col => ({ key: col.key, label: col.config?.customColumnName || col.originalLabel || col.key, })), [visibleColumnsMeta], ); // Use a ref to store previous clientViewRows and exportColumns for robust change detection const prevClientViewRef = useRef<{ rows: DataRecord[]; columns: typeof exportColumns; } | null>(null); useEffect(() => { if (serverPagination) return; // only for client-side mode const prev = prevClientViewRef.current; const rowsChanged = !prev || !isEqual(prev.rows, clientViewRows); const columnsChanged = !prev || !isEqual(prev.columns, exportColumns); if (rowsChanged || columnsChanged) { prevClientViewRef.current = { rows: clientViewRows, columns: exportColumns, }; updateTableOwnState(setDataMask, { ...serverPaginationData, clientView: { rows: clientViewRows, columns: exportColumns, count: clientViewRows.length, }, }); } }, [ clientViewRows, exportColumns, serverPagination, setDataMask, serverPaginationData, ]); return ( 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)} initialSearchText={serverPaginationData?.searchText || ''} sortByFromParent={serverPaginationData?.sortBy || []} searchInputId={`${slice_id}-search`} // 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 } handleSortByChange={handleSortByChange} onSearchColChange={handleChangeSearchCol} manualSearch={serverPagination} onSearchChange={debouncedSearch} searchOptions={searchOptions} onFilteredDataChange={handleFilteredDataChange} onFilteredRowsChange={setClientViewRows} /> ); }