From 922befa831613ed0fd73f072320834e8dca8f4bc Mon Sep 17 00:00:00 2001 From: Evan Rusackas Date: Fri, 17 Apr 2026 10:33:57 -0700 Subject: [PATCH] chore(lint): convert react-pivottable components to function components Converts PivotTable and TableRenderers in the plugin-chart-pivot-table react-pivottable fork from class components to function components. Updates associated unit test. Co-Authored-By: Claude Sonnet 4.6 --- .../src/react-pivottable/PivotTable.tsx | 10 +- .../src/react-pivottable/TableRenderers.tsx | 2127 +++++++++-------- .../react-pivottable/tableRenders.test.tsx | 1439 +++-------- 3 files changed, 1390 insertions(+), 2186 deletions(-) diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/PivotTable.tsx b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/PivotTable.tsx index 9e5565b9f26..1a6716294ae 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/PivotTable.tsx +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/PivotTable.tsx @@ -17,16 +17,14 @@ * under the License. */ -import { PureComponent } from 'react'; +import { memo } from 'react'; import { TableRenderer } from './TableRenderers'; import type { ComponentProps } from 'react'; type PivotTableProps = ComponentProps; -class PivotTable extends PureComponent { - render() { - return ; - } +function PivotTable(props: PivotTableProps) { + return ; } -export default PivotTable; +export default memo(PivotTable); diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx index 9961958b966..c72c9bc11af 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx @@ -17,25 +17,31 @@ * under the License. */ -import { Component, ReactNode, MouseEvent } from 'react'; +import { + ReactNode, + MouseEvent, + useState, + useCallback, + useRef, + useMemo, + useEffect, +} from 'react'; import { safeHtmlSpan } from '@superset-ui/core'; -import { t } from '@apache-superset/core/translation'; -import { supersetTheme } from '@apache-superset/core/theme'; +import { t } from '@apache-superset/core/ui'; import PropTypes from 'prop-types'; import { CaretUpOutlined, CaretDownOutlined, ColumnHeightOutlined, } from '@ant-design/icons'; -import { - ColorFormatters, - getTextColorForBackground, - ObjectFormattingEnum, - ResolvedColorFormatterResult, -} from '@superset-ui/chart-controls'; import { PivotData, flatKey } from './utilities'; import { Styles } from './Styles'; +interface CellColorFormatter { + column: string; + getColorFromValue(value: unknown): string | undefined; +} + type ClickCallback = ( e: MouseEvent, value: unknown, @@ -63,11 +69,8 @@ interface TableOptions { highlightHeaderCellsOnHover?: boolean; omittedHighlightHeaderGroups?: string[]; highlightedHeaderCells?: Record; - cellColorFormatters?: Record; + cellColorFormatters?: Record; dateFormatters?: Record string) | undefined>; - cellBackgroundColor?: string; - cellTextColor?: string; - activeHeaderBackgroundColor?: string; } interface SubtotalDisplay { @@ -87,7 +90,7 @@ interface TableRendererProps { cols: string[]; rows: string[]; aggregatorName: string; - tableOptions: TableOptions; + tableOptions?: TableOptions; subtotalOptions?: SubtotalOptions; namesMapping?: Record; onContextMenu: ( @@ -100,13 +103,6 @@ interface TableRendererProps { [key: string]: unknown; } -interface TableRendererState { - collapsedRows: Record; - collapsedCols: Record; - sortingOrder: string[]; - activeSortColumn?: number | null; -} - interface PivotSettings { pivotData: InstanceType; colAttrs: string[]; @@ -181,50 +177,6 @@ function displayHeaderCell( ); } -export function getCellColor( - keys: string[], - aggValue: string | number | null, - cellColorFormatters: Record | undefined, - cellBackgroundColor = supersetTheme.colorBgBase, -): ResolvedColorFormatterResult { - if (!cellColorFormatters) return { backgroundColor: undefined }; - - let backgroundColor: string | undefined; - let color: string | undefined; - const isTextColorFormatter = (formatter: ColorFormatters[number]) => - formatter.objectFormatting === ObjectFormattingEnum.TEXT_COLOR || - formatter.toTextColor; - - for (const cellColorFormatter of Object.values(cellColorFormatters)) { - if (!Array.isArray(cellColorFormatter)) continue; - - for (const key of keys) { - for (const formatter of cellColorFormatter) { - if (formatter.column === key) { - const result = formatter.getColorFromValue(aggValue); - if (result) { - if (isTextColorFormatter(formatter)) { - color = result; - } else if ( - formatter.objectFormatting !== ObjectFormattingEnum.CELL_BAR - ) { - backgroundColor = result; - } - } - } - } - } - } - - return { - backgroundColor, - color: getTextColorForBackground( - { backgroundColor, color }, - cellBackgroundColor, - ), - }; -} - interface HierarchicalNode { currentVal?: number; [key: string]: HierarchicalNode | number | undefined; @@ -271,11 +223,6 @@ function sortHierarchicalObject( return result; } -function convertToNumberIfNumeric(value: string): string | number { - const n = Number(value); - return value.trim() !== '' && !Number.isNaN(n) ? n : value; -} - function convertToArray( obj: Map, rowEnabled: boolean | undefined, @@ -328,275 +275,182 @@ function convertToArray( return result; } -export class TableRenderer extends Component< - TableRendererProps, - TableRendererState -> { - sortCache: Map; - cachedProps: TableRendererProps | null; - cachedBasePivotSettings: PivotSettings | null; +export function TableRenderer({ + cols, + rows, + aggregatorName, + tableOptions = {}, + subtotalOptions, + namesMapping: namesMappingProp, + onContextMenu, + allowRenderHtml, + ...restProps +}: TableRendererProps) { + const [collapsedRows, setCollapsedRows] = useState>( + {}, + ); + const [collapsedCols, setCollapsedCols] = useState>( + {}, + ); + const [sortingOrder, setSortingOrder] = useState([]); + const [activeSortColumn, setActiveSortColumn] = useState(null); + const [sortedRowKeys, setSortedRowKeys] = useState(null); - static propTypes: Record; - static defaultProps: Record; + const sortCacheRef = useRef(new Map()); - constructor(props: TableRendererProps) { - super(props); + // Memoize props object to maintain referential stability + const props = useMemo( + () => ({ + cols, + rows, + aggregatorName, + tableOptions, + subtotalOptions, + namesMapping: namesMappingProp, + onContextMenu, + allowRenderHtml, + ...restProps, + }), + [ + cols, + rows, + aggregatorName, + tableOptions, + subtotalOptions, + namesMappingProp, + onContextMenu, + allowRenderHtml, + restProps, + ], + ); - // We need state to record which entries are collapsed and which aren't. - // This is an object with flat-keys indicating if the corresponding rows - // should be collapsed. - this.state = { collapsedRows: {}, collapsedCols: {}, sortingOrder: [] }; - this.sortCache = new Map(); - this.cachedProps = null; - this.cachedBasePivotSettings = null; - this.clickHeaderHandler = this.clickHeaderHandler.bind(this); - this.clickHandler = this.clickHandler.bind(this); - } - - getBasePivotSettings(): PivotSettings { - // One-time extraction of pivot settings that we'll use throughout the render. - - const { props } = this; - const colAttrs = props.cols; - const rowAttrs = props.rows; - - const tableOptions: TableOptions = { - rowTotals: true, - colTotals: true, - ...props.tableOptions, - }; - const rowTotals = tableOptions.rowTotals || colAttrs.length === 0; - const colTotals = tableOptions.colTotals || rowAttrs.length === 0; - - const namesMapping = props.namesMapping || {}; - const subtotalOptions: Required< - Pick - > & - SubtotalOptions = { - arrowCollapsed: '\u25B2', - arrowExpanded: '\u25BC', - ...props.subtotalOptions, - }; - - const colSubtotalDisplay: SubtotalDisplay = { - displayOnTop: false, - enabled: tableOptions.colSubTotals, - hideOnExpand: false, - ...subtotalOptions.colSubtotalDisplay, - }; - - const rowSubtotalDisplay: SubtotalDisplay = { - displayOnTop: false, - enabled: tableOptions.rowSubTotals, - hideOnExpand: false, - ...subtotalOptions.rowSubtotalDisplay, - }; - - const pivotData = new PivotData(props as Record, { - rowEnabled: rowSubtotalDisplay.enabled, - colEnabled: colSubtotalDisplay.enabled, - rowPartialOnTop: rowSubtotalDisplay.displayOnTop, - colPartialOnTop: colSubtotalDisplay.displayOnTop, - }); - const rowKeys = pivotData.getRowKeys(); - const colKeys = pivotData.getColKeys(); - - // Also pre-calculate all the callbacks for cells, etc... This is nice to have to - // avoid re-calculations of the call-backs on cell expansions, etc... - const cellCallbacks: Record< - string, - Record void> - > = {}; - const rowTotalCallbacks: Record void> = {}; - const colTotalCallbacks: Record void> = {}; - let grandTotalCallback: ((e: MouseEvent) => void) | null = null; - if (tableOptions.clickCallback) { - rowKeys.forEach(rowKey => { - const flatRowKey = flatKey(rowKey); - if (!(flatRowKey in cellCallbacks)) { - cellCallbacks[flatRowKey] = {}; + const clickHandler = useCallback( + ( + pivotData: InstanceType, + rowValues: string[], + colValues: string[], + ) => { + const colAttrs = cols; + const rowAttrs = rows; + const value = pivotData.getAggregator(rowValues, colValues).value(); + const filters: Record = {}; + const colLimit = Math.min(colAttrs.length, colValues.length); + for (let i = 0; i < colLimit; i += 1) { + const attr = colAttrs[i]; + if (colValues[i] !== null) { + filters[attr] = colValues[i]; } - colKeys.forEach(colKey => { - cellCallbacks[flatRowKey][flatKey(colKey)] = this.clickHandler( - pivotData, - rowKey, - colKey, - ); - }); - }); - - // Add in totals as well. - if (rowTotals) { - rowKeys.forEach(rowKey => { - rowTotalCallbacks[flatKey(rowKey)] = this.clickHandler( - pivotData, - rowKey, - [], - ); - }); } - if (colTotals) { - colKeys.forEach(colKey => { - colTotalCallbacks[flatKey(colKey)] = this.clickHandler( - pivotData, - [], - colKey, - ); - }); - } - if (rowTotals && colTotals) { - grandTotalCallback = this.clickHandler(pivotData, [], []); - } - } - - return { - pivotData, - colAttrs, - rowAttrs, - colKeys, - rowKeys, - rowTotals, - colTotals, - arrowCollapsed: subtotalOptions.arrowCollapsed, - arrowExpanded: subtotalOptions.arrowExpanded, - colSubtotalDisplay, - rowSubtotalDisplay, - cellCallbacks, - rowTotalCallbacks, - colTotalCallbacks, - grandTotalCallback, - namesMapping, - allowRenderHtml: props.allowRenderHtml, - }; - } - - clickHandler( - pivotData: InstanceType, - rowValues: string[], - colValues: string[], - ) { - const colAttrs = this.props.cols; - const rowAttrs = this.props.rows; - const value = pivotData.getAggregator(rowValues, colValues).value(); - const filters: Record = {}; - const colLimit = Math.min(colAttrs.length, colValues.length); - for (let i = 0; i < colLimit; i += 1) { - const attr = colAttrs[i]; - if (colValues[i] !== null) { - filters[attr] = colValues[i]; - } - } - const rowLimit = Math.min(rowAttrs.length, rowValues.length); - for (let i = 0; i < rowLimit; i += 1) { - const attr = rowAttrs[i]; - if (rowValues[i] !== null) { - filters[attr] = rowValues[i]; - } - } - const { clickCallback } = this.props.tableOptions; - return (e: MouseEvent) => clickCallback?.(e, value, filters, pivotData); - } - - clickHeaderHandler( - pivotData: InstanceType, - values: string[], - attrs: string[], - attrIdx: number, - callback: HeaderClickCallback | undefined, - isSubtotal = false, - isGrandTotal = false, - ) { - const filters: Record = {}; - for (let i = 0; i <= attrIdx; i += 1) { - const attr = attrs[i]; - filters[attr] = values[i]; - } - return (e: MouseEvent) => - callback?.( - e, - values[attrIdx], - filters, - pivotData, - isSubtotal, - isGrandTotal, - ); - } - - collapseAttr(rowOrCol: boolean, attrIdx: number, allKeys: string[][]) { - return (e: MouseEvent) => { - // Collapse an entire attribute. - e.stopPropagation(); - const keyLen = attrIdx + 1; - const collapsed = allKeys - .filter((k: string[]) => k.length === keyLen) - .map(flatKey); - - const updates: Record = {}; - collapsed.forEach((k: string) => { - updates[k] = true; - }); - - if (rowOrCol) { - this.setState(state => ({ - collapsedRows: { ...state.collapsedRows, ...updates }, - })); - } else { - this.setState(state => ({ - collapsedCols: { ...state.collapsedCols, ...updates }, - })); - } - }; - } - - expandAttr(rowOrCol: boolean, attrIdx: number, allKeys: string[][]) { - return (e: MouseEvent) => { - // Expand an entire attribute. This implicitly implies expanding all of the - // parents as well. It's a bit inefficient but ah well... - e.stopPropagation(); - const updates: Record = {}; - allKeys.forEach((k: string[]) => { - for (let i = 0; i <= attrIdx; i += 1) { - updates[flatKey(k.slice(0, i + 1))] = false; + const rowLimit = Math.min(rowAttrs.length, rowValues.length); + for (let i = 0; i < rowLimit; i += 1) { + const attr = rowAttrs[i]; + if (rowValues[i] !== null) { + filters[attr] = rowValues[i]; } - }); - - if (rowOrCol) { - this.setState(state => ({ - collapsedRows: { ...state.collapsedRows, ...updates }, - })); - } else { - this.setState(state => ({ - collapsedCols: { ...state.collapsedCols, ...updates }, - })); } - }; - } + const { clickCallback } = tableOptions; + return (e: MouseEvent) => clickCallback?.(e, value, filters, pivotData); + }, + [cols, rows, tableOptions], + ); - toggleRowKey(flatRowKey: string) { - return (e: MouseEvent) => { + const clickHeaderHandler = useCallback( + ( + pivotData: InstanceType, + values: string[], + attrs: string[], + attrIdx: number, + callback: HeaderClickCallback | undefined, + isSubtotal = false, + isGrandTotal = false, + ) => { + const filters: Record = {}; + for (let i = 0; i <= attrIdx; i += 1) { + const attr = attrs[i]; + filters[attr] = values[i]; + } + return (e: MouseEvent) => + callback?.( + e, + values[attrIdx], + filters, + pivotData, + isSubtotal, + isGrandTotal, + ); + }, + [], + ); + + const collapseAttr = useCallback( + (rowOrCol: boolean, attrIdx: number, allKeys: string[][]) => + (e: MouseEvent) => { + // Collapse an entire attribute. + e.stopPropagation(); + const keyLen = attrIdx + 1; + const collapsed = allKeys + .filter((k: string[]) => k.length === keyLen) + .map(flatKey); + + const updates: Record = {}; + collapsed.forEach((k: string) => { + updates[k] = true; + }); + + if (rowOrCol) { + setCollapsedRows(state => ({ ...state, ...updates })); + } else { + setCollapsedCols(state => ({ ...state, ...updates })); + } + }, + [], + ); + + const expandAttr = useCallback( + (rowOrCol: boolean, attrIdx: number, allKeys: string[][]) => + (e: MouseEvent) => { + // Expand an entire attribute. This implicitly implies expanding all of the + // parents as well. It's a bit inefficient but ah well... + e.stopPropagation(); + const updates: Record = {}; + allKeys.forEach((k: string[]) => { + for (let i = 0; i <= attrIdx; i += 1) { + updates[flatKey(k.slice(0, i + 1))] = false; + } + }); + + if (rowOrCol) { + setCollapsedRows(state => ({ ...state, ...updates })); + } else { + setCollapsedCols(state => ({ ...state, ...updates })); + } + }, + [], + ); + + const toggleRowKey = useCallback( + (flatRowKey: string) => (e: MouseEvent) => { e.stopPropagation(); - this.setState(state => ({ - collapsedRows: { - ...state.collapsedRows, - [flatRowKey]: !state.collapsedRows[flatRowKey], - }, + setCollapsedRows(state => ({ + ...state, + [flatRowKey]: !state[flatRowKey], })); - }; - } + }, + [], + ); - toggleColKey(flatColKey: string) { - return (e: MouseEvent) => { + const toggleColKey = useCallback( + (flatColKey: string) => (e: MouseEvent) => { e.stopPropagation(); - this.setState(state => ({ - collapsedCols: { - ...state.collapsedCols, - [flatColKey]: !state.collapsedCols[flatColKey], - }, + setCollapsedCols(state => ({ + ...state, + [flatColKey]: !state[flatColKey], })); - }; - } + }, + [], + ); - calcAttrSpans(attrArr: string[][], numAttrs: number) { + const calcAttrSpans = useCallback((attrArr: string[][], numAttrs: number) => { // Given an array of attribute values (i.e. each element is another array with // the value at every level), compute the spans for every attribute value at // every level. The return value is a nested array of the same shape. It has @@ -627,81 +481,303 @@ export class TableRenderer extends Component< lv = cv; } return spans; - } + }, []); - getAggregatedData( - pivotData: InstanceType, - visibleColName: string[], - rowPartialOnTop: boolean | undefined, - ) { - // Transforms flat row keys into a hierarchical group structure where each level - // represents a grouping dimension. For each row key path, it calculates the - // aggregated value for the specified column and builds a nested object that - // preserves the hierarchy while storing aggregation values at each level. - const groups: Record = {}; - const rows = pivotData.rowKeys; - rows.forEach(rowKey => { - const aggValue = - pivotData.getAggregator(rowKey, visibleColName).value() ?? 0; + const getAggregatedData = useCallback( + ( + pivotData: InstanceType, + visibleColName: string[], + rowPartialOnTop: boolean | undefined, + ) => { + // Transforms flat row keys into a hierarchical group structure where each level + // represents a grouping dimension. For each row key path, it calculates the + // aggregated value for the specified column and builds a nested object that + // preserves the hierarchy while storing aggregation values at each level. + const groups: Record = {}; + const rowsData = pivotData.rowKeys; + rowsData.forEach(rowKey => { + const aggValue = + pivotData.getAggregator(rowKey, visibleColName).value() ?? 0; - if (rowPartialOnTop) { - const parent = rowKey - .slice(0, -1) - .reduce( - (acc: Record, key: string) => - (acc[key] ??= {}) as Record, + if (rowPartialOnTop) { + const parent = rowKey + .slice(0, -1) + .reduce( + (acc: Record, key: string) => + (acc[key] ??= {}) as Record, + groups, + ); + parent[rowKey.at(-1)!] = { currentVal: aggValue as number }; + } else { + rowKey.reduce( + (acc: Record, key: string) => { + acc[key] = acc[key] || { currentVal: 0 }; + (acc[key] as HierarchicalNode).currentVal = aggValue as number; + return acc[key] as Record; + }, groups, ); - parent[rowKey.at(-1)!] = { currentVal: aggValue as number }; - } else { - rowKey.reduce((acc: Record, key: string) => { - acc[key] = acc[key] || { currentVal: 0 }; - (acc[key] as HierarchicalNode).currentVal = aggValue as number; - return acc[key] as Record; - }, groups); - } + } + }); + return groups; + }, + [], + ); + + const sortAndCacheData = useCallback( + ( + groups: Record, + sortOrder: string, + rowEnabled: boolean | undefined, + rowPartialOnTop: boolean | undefined, + maxRowIndex: number, + ) => { + // Processes hierarchical data by first sorting it according to the specified order + // and then converting the sorted structure into a flat array format. This function + // serves as an intermediate step between hierarchical data representation and + // flat array representation needed for rendering. + const sortedGroups = sortHierarchicalObject( + groups, + sortOrder, + rowPartialOnTop, + ); + return convertToArray( + sortedGroups, + rowEnabled, + rowPartialOnTop, + maxRowIndex, + ); + }, + [], + ); + + const getBasePivotSettings = useCallback((): PivotSettings => { + // One-time extraction of pivot settings that we'll use throughout the render. + + const colAttrs = cols; + const rowAttrs = rows; + + const mergedTableOptions: TableOptions = { + rowTotals: true, + colTotals: true, + ...tableOptions, + }; + const rowTotals = mergedTableOptions.rowTotals || colAttrs.length === 0; + const colTotals = mergedTableOptions.colTotals || rowAttrs.length === 0; + + const namesMapping = namesMappingProp || {}; + const mergedSubtotalOptions: Required< + Pick + > & + SubtotalOptions = { + arrowCollapsed: '\u25B2', + arrowExpanded: '\u25BC', + ...subtotalOptions, + }; + + const colSubtotalDisplay: SubtotalDisplay = { + displayOnTop: false, + enabled: mergedTableOptions.colSubTotals, + hideOnExpand: false, + ...mergedSubtotalOptions.colSubtotalDisplay, + }; + + const rowSubtotalDisplay: SubtotalDisplay = { + displayOnTop: false, + enabled: mergedTableOptions.rowSubTotals, + hideOnExpand: false, + ...mergedSubtotalOptions.rowSubtotalDisplay, + }; + + const pivotData = new PivotData(props as Record, { + rowEnabled: rowSubtotalDisplay.enabled, + colEnabled: colSubtotalDisplay.enabled, + rowPartialOnTop: rowSubtotalDisplay.displayOnTop, + colPartialOnTop: colSubtotalDisplay.displayOnTop, }); - return groups; - } + const rowKeys = pivotData.getRowKeys(); + const colKeys = pivotData.getColKeys(); - sortAndCacheData( - groups: Record, - sortOrder: string, - rowEnabled: boolean | undefined, - rowPartialOnTop: boolean | undefined, - maxRowIndex: number, - ) { - // Processes hierarchical data by first sorting it according to the specified order - // and then converting the sorted structure into a flat array format. This function - // serves as an intermediate step between hierarchical data representation and - // flat array representation needed for rendering. - const sortedGroups = sortHierarchicalObject( - groups, - sortOrder, - rowPartialOnTop, - ); - return convertToArray( - sortedGroups, - rowEnabled, - rowPartialOnTop, - maxRowIndex, - ); - } + // Also pre-calculate all the callbacks for cells, etc... This is nice to have to + // avoid re-calculations of the call-backs on cell expansions, etc... + const cellCallbacks: Record< + string, + Record void> + > = {}; + const rowTotalCallbacks: Record void> = {}; + const colTotalCallbacks: Record void> = {}; + let grandTotalCallback: ((e: MouseEvent) => void) | null = null; + if (mergedTableOptions.clickCallback) { + rowKeys.forEach(rowKey => { + const flatRowKey = flatKey(rowKey); + if (!(flatRowKey in cellCallbacks)) { + cellCallbacks[flatRowKey] = {}; + } + colKeys.forEach(colKey => { + cellCallbacks[flatRowKey][flatKey(colKey)] = clickHandler( + pivotData, + rowKey, + colKey, + ); + }); + }); - sortData( - columnIndex: number, - visibleColKeys: string[][], - pivotData: InstanceType, - maxRowIndex: number, - ) { - // Handles column sorting with direction toggling (asc/desc) and implements - // caching mechanism to avoid redundant sorting operations. When sorting the same - // column multiple times, it cycles through sorting directions. Uses composite - // cache keys based on sorting parameters for optimal performance. - this.setState(state => { - const { sortingOrder, activeSortColumn } = state; + // Add in totals as well. + if (rowTotals) { + rowKeys.forEach(rowKey => { + rowTotalCallbacks[flatKey(rowKey)] = clickHandler( + pivotData, + rowKey, + [], + ); + }); + } + if (colTotals) { + colKeys.forEach(colKey => { + colTotalCallbacks[flatKey(colKey)] = clickHandler( + pivotData, + [], + colKey, + ); + }); + } + if (rowTotals && colTotals) { + grandTotalCallback = clickHandler(pivotData, [], []); + } + } - const newSortingOrder = []; + return { + pivotData, + colAttrs, + rowAttrs, + colKeys, + rowKeys, + rowTotals, + colTotals, + arrowCollapsed: mergedSubtotalOptions.arrowCollapsed, + arrowExpanded: mergedSubtotalOptions.arrowExpanded, + colSubtotalDisplay, + rowSubtotalDisplay, + cellCallbacks, + rowTotalCallbacks, + colTotalCallbacks, + grandTotalCallback, + namesMapping, + allowRenderHtml, + }; + }, [ + cols, + rows, + tableOptions, + namesMappingProp, + subtotalOptions, + props, + allowRenderHtml, + clickHandler, + ]); + + const visibleKeys = useCallback( + ( + keys: string[][], + collapsed: Record, + numAttrs: number, + subtotalDisplay: SubtotalDisplay, + ) => + keys.filter( + (key: string[]) => + // Is the key hidden by one of its parents? + !key.some( + (_k: string, j: number) => collapsed[flatKey(key.slice(0, j))], + ) && + // Leaf key. + (key.length === numAttrs || + // Children hidden. Must show total. + flatKey(key) in collapsed || + // Don't hide totals. + !subtotalDisplay.hideOnExpand), + ), + [], + ); + + const isDashboardEditMode = useCallback( + () => document.contains(document.querySelector('.dashboard--editing')), + [], + ); + + // Compute base pivot settings, memoized based on relevant props + const basePivotSettings = useMemo(() => { + // Clear sort cache when props change + sortCacheRef.current.clear(); + return getBasePivotSettings(); + }, [getBasePivotSettings]); + + // Reset sort state when structural props change + useEffect(() => { + setSortingOrder([]); + setActiveSortColumn(null); + setSortedRowKeys(null); + }, [ + cols, + rows, + aggregatorName, + tableOptions, + subtotalOptions, + namesMappingProp, + allowRenderHtml, + ]); + + // Use sorted row keys if available, otherwise use base row keys + const effectiveRowKeys = sortedRowKeys ?? basePivotSettings.rowKeys; + + const { + colAttrs, + rowAttrs, + colKeys, + colTotals, + rowSubtotalDisplay, + colSubtotalDisplay, + } = basePivotSettings; + + const rowKeys = effectiveRowKeys; + + // Need to account for exclusions to compute the effective row + // and column keys. + const visibleRowKeys = visibleKeys( + rowKeys, + collapsedRows, + rowAttrs.length, + rowSubtotalDisplay, + ); + const visibleColKeys = visibleKeys( + colKeys, + collapsedCols, + colAttrs.length, + colSubtotalDisplay, + ); + + const pivotSettings: PivotSettings = { + visibleRowKeys, + maxRowVisible: Math.max(...visibleRowKeys.map((k: string[]) => k.length)), + visibleColKeys, + maxColVisible: Math.max(...visibleColKeys.map((k: string[]) => k.length)), + rowAttrSpans: calcAttrSpans(visibleRowKeys, rowAttrs.length), + colAttrSpans: calcAttrSpans(visibleColKeys, colAttrs.length), + allowRenderHtml, + ...basePivotSettings, + }; + + const sortData = useCallback( + ( + columnIndex: number, + visColKeys: string[][], + pivotData: InstanceType, + maxRowIndex: number, + ) => { + // Handles column sorting with direction toggling (asc/desc) and implements + // caching mechanism to avoid redundant sorting operations. When sorting the same + // column multiple times, it cycles through sorting directions. Uses composite + // cache keys based on sorting parameters for optimal performance. + const newSortingOrder: string[] = []; let newDirection = 'asc'; if (activeSortColumn === columnIndex) { @@ -714,727 +790,674 @@ export class TableRenderer extends Component< }; newSortingOrder[columnIndex] = newDirection; - const cacheKey = `${columnIndex}-${visibleColKeys.length}-${rowEnabled}-${rowPartialOnTop}-${newDirection}`; + const cacheKey = `${columnIndex}-${visColKeys.length}-${rowEnabled}-${rowPartialOnTop}-${newDirection}`; let newRowKeys; - if (this.sortCache.has(cacheKey)) { - const cachedRowKeys = this.sortCache.get(cacheKey); + if (sortCacheRef.current.has(cacheKey)) { + const cachedRowKeys = sortCacheRef.current.get(cacheKey); newRowKeys = cachedRowKeys; } else { - const groups = this.getAggregatedData( + const groups = getAggregatedData( pivotData, - visibleColKeys[columnIndex], + visColKeys[columnIndex], rowPartialOnTop, ); - const sortedRowKeys = this.sortAndCacheData( + const computedSortedRowKeys = sortAndCacheData( groups, newDirection, rowEnabled, rowPartialOnTop, maxRowIndex, ); - this.sortCache.set(cacheKey, sortedRowKeys); - newRowKeys = sortedRowKeys; + sortCacheRef.current.set(cacheKey, computedSortedRowKeys); + newRowKeys = computedSortedRowKeys; } - this.cachedBasePivotSettings = { - ...this.cachedBasePivotSettings!, - rowKeys: newRowKeys!, - }; - return { - sortingOrder: newSortingOrder, - activeSortColumn: columnIndex, - }; - }); - } + setSortedRowKeys(newRowKeys!); + setSortingOrder(newSortingOrder); + setActiveSortColumn(columnIndex); + }, + [activeSortColumn, sortingOrder, getAggregatedData, sortAndCacheData], + ); - renderColHeaderRow( - attrName: string, - attrIdx: number, - pivotSettings: PivotSettings, - ) { - // Render a single row in the column header at the top of the pivot table. + const renderColHeaderRow = useCallback( + (attrName: string, attrIdx: number, settings: PivotSettings) => { + // Render a single row in the column header at the top of the pivot table. - const { - rowAttrs, - colAttrs, - colKeys, - visibleColKeys, - colAttrSpans, - rowTotals, - arrowExpanded, - arrowCollapsed, - colSubtotalDisplay, - maxColVisible, - pivotData, - namesMapping, - allowRenderHtml, - } = pivotSettings; - const { - highlightHeaderCellsOnHover, - omittedHighlightHeaderGroups = [], - highlightedHeaderCells, - cellColorFormatters, - dateFormatters, - cellBackgroundColor = supersetTheme.colorBgBase, - activeHeaderBackgroundColor = supersetTheme.colorPrimaryBg, - } = this.props.tableOptions; + const { + rowAttrs: settingsRowAttrs, + colAttrs: settingsColAttrs, + colKeys: settingsColKeys, + visibleColKeys: settingsVisibleColKeys, + colAttrSpans, + rowTotals, + arrowExpanded, + arrowCollapsed, + colSubtotalDisplay: settingsColSubtotalDisplay, + maxColVisible, + pivotData, + namesMapping, + allowRenderHtml: settingsAllowRenderHtml, + } = settings; + const { + highlightHeaderCellsOnHover, + omittedHighlightHeaderGroups = [], + highlightedHeaderCells, + dateFormatters, + } = tableOptions; - if (!visibleColKeys || !colAttrSpans) { - return null; - } + if (!settingsVisibleColKeys || !colAttrSpans) { + return null; + } - const spaceCell = - attrIdx === 0 && rowAttrs.length !== 0 ? ( - - ) : null; + const spaceCell = + attrIdx === 0 && settingsRowAttrs.length !== 0 ? ( + + ) : null; - const needToggle = - colSubtotalDisplay.enabled === true && attrIdx !== colAttrs.length - 1; - let arrowClickHandle = null; - let subArrow = null; - if (needToggle) { - arrowClickHandle = - attrIdx + 1 < maxColVisible! - ? this.collapseAttr(false, attrIdx, colKeys) - : this.expandAttr(false, attrIdx, colKeys); - subArrow = attrIdx + 1 < maxColVisible! ? arrowExpanded : arrowCollapsed; - } - const attrNameCell = ( - - {displayHeaderCell( - needToggle, - subArrow, - arrowClickHandle, - attrName, - namesMapping, - allowRenderHtml, - )} - - ); + const needToggle = + settingsColSubtotalDisplay.enabled === true && + attrIdx !== settingsColAttrs.length - 1; + let arrowClickHandle = null; + let subArrow = null; + if (needToggle) { + arrowClickHandle = + attrIdx + 1 < maxColVisible! + ? collapseAttr(false, attrIdx, settingsColKeys) + : expandAttr(false, attrIdx, settingsColKeys); + subArrow = + attrIdx + 1 < maxColVisible! ? arrowExpanded : arrowCollapsed; + } + const attrNameCell = ( + + {displayHeaderCell( + needToggle, + subArrow, + arrowClickHandle, + attrName, + namesMapping, + settingsAllowRenderHtml, + )} + + ); - const attrValueCells = []; - const rowIncrSpan = rowAttrs.length !== 0 ? 1 : 0; - // Iterate through columns. Jump over duplicate values. - let i = 0; - while (i < visibleColKeys.length) { - let handleContextMenu: ((e: MouseEvent) => void) | undefined; - const colKey = visibleColKeys[i]; - const colSpan = attrIdx < colKey.length ? colAttrSpans[i][attrIdx] : 1; - let colLabelClass = 'pvtColLabel'; - if (attrIdx < colKey.length) { - if (!omittedHighlightHeaderGroups.includes(colAttrs[attrIdx])) { + const attrValueCells = []; + const rowIncrSpan = settingsRowAttrs.length !== 0 ? 1 : 0; + // Iterate through columns. Jump over duplicate values. + let i = 0; + while (i < settingsVisibleColKeys.length) { + let handleContextMenu: ((e: MouseEvent) => void) | undefined; + const colKey = settingsVisibleColKeys[i]; + const colSpan = attrIdx < colKey.length ? colAttrSpans[i][attrIdx] : 1; + let colLabelClass = 'pvtColLabel'; + if (attrIdx < colKey.length) { + if ( + !omittedHighlightHeaderGroups.includes(settingsColAttrs[attrIdx]) + ) { + if (highlightHeaderCellsOnHover) { + colLabelClass += ' hoverable'; + } + handleContextMenu = (e: MouseEvent) => + onContextMenu(e, colKey, undefined, { + [attrName]: colKey[attrIdx], + }); + } + if ( + highlightedHeaderCells && + Array.isArray(highlightedHeaderCells[settingsColAttrs[attrIdx]]) && + highlightedHeaderCells[settingsColAttrs[attrIdx]].includes( + colKey[attrIdx], + ) + ) { + colLabelClass += ' active'; + } + const maxRowIndex = settings.maxRowVisible!; + const mColVisible = settings.maxColVisible!; + const visibleSortIcon = mColVisible - 1 === attrIdx; + const columnName = colKey[mColVisible - 1]; + + const rowSpan = + 1 + (attrIdx === settingsColAttrs.length - 1 ? rowIncrSpan : 0); + const flatColKey = flatKey(colKey.slice(0, attrIdx + 1)); + const onArrowClick = needToggle ? toggleColKey(flatColKey) : null; + const getSortIcon = (key: number) => { + if (activeSortColumn !== key) { + return ( + + sortData( + key, + settingsVisibleColKeys, + pivotData, + maxRowIndex, + ) + } + /> + ); + } + + const SortIcon = + sortingOrder[key] === 'asc' ? CaretUpOutlined : CaretDownOutlined; + return ( + + sortData(key, settingsVisibleColKeys, pivotData, maxRowIndex) + } + /> + ); + }; + const headerCellFormattedValue = + dateFormatters?.[attrName]?.(colKey[attrIdx]) ?? colKey[attrIdx]; + attrValueCells.push( + + {displayHeaderCell( + needToggle, + collapsedCols[flatColKey] ? arrowCollapsed : arrowExpanded, + onArrowClick, + headerCellFormattedValue, + namesMapping, + settingsAllowRenderHtml, + )} + { + e.stopPropagation(); + }} + aria-label={ + activeSortColumn === i + ? `Sorted by ${columnName} ${sortingOrder[i] === 'asc' ? 'ascending' : 'descending'}` + : undefined + } + > + {visibleSortIcon && getSortIcon(i)} + + , + ); + } else if (attrIdx === colKey.length) { + const rowSpan = settingsColAttrs.length - colKey.length + rowIncrSpan; + attrValueCells.push( + + {t('Subtotal')} + , + ); + } + // The next colSpan columns will have the same value anyway... + i += colSpan; + } + + const totalCell = + attrIdx === 0 && rowTotals ? ( + + {t('Total (%(aggregatorName)s)', { + aggregatorName: t(aggregatorName), + })} + + ) : null; + + const cells = [spaceCell, attrNameCell, ...attrValueCells, totalCell]; + return {cells}; + }, + [ + tableOptions, + onContextMenu, + collapseAttr, + expandAttr, + toggleColKey, + clickHeaderHandler, + cols, + aggregatorName, + activeSortColumn, + sortingOrder, + collapsedCols, + sortData, + ], + ); + + const renderRowHeaderRow = useCallback( + (settings: PivotSettings) => { + // Render just the attribute names of the rows (the actual attribute values + // will show up in the individual rows). + + const { + rowAttrs: settingsRowAttrs, + colAttrs: settingsColAttrs, + rowKeys: settingsRowKeys, + arrowCollapsed, + arrowExpanded, + rowSubtotalDisplay: settingsRowSubtotalDisplay, + maxRowVisible, + pivotData, + namesMapping, + allowRenderHtml: settingsAllowRenderHtml, + } = settings; + return ( + + {settingsRowAttrs.map((r, i) => { + const needLabelToggle = + settingsRowSubtotalDisplay.enabled === true && + i !== settingsRowAttrs.length - 1; + let arrowClickHandle = null; + let subArrow = null; + if (needLabelToggle) { + arrowClickHandle = + i + 1 < maxRowVisible! + ? collapseAttr(true, i, settingsRowKeys) + : expandAttr(true, i, settingsRowKeys); + subArrow = + i + 1 < maxRowVisible! ? arrowExpanded : arrowCollapsed; + } + return ( + + {displayHeaderCell( + needLabelToggle, + subArrow, + arrowClickHandle, + r, + namesMapping, + settingsAllowRenderHtml, + )} + + ); + })} + + {settingsColAttrs.length === 0 + ? t('Total (%(aggregatorName)s)', { + aggregatorName: t(aggregatorName), + }) + : null} + + + ); + }, + [ + collapseAttr, + expandAttr, + clickHeaderHandler, + rows, + tableOptions.clickRowHeaderCallback, + aggregatorName, + ], + ); + + const renderTableRow = useCallback( + (rowKey: string[], rowIdx: number, settings: PivotSettings) => { + // Render a single row in the pivot table. + + const { + rowAttrs: settingsRowAttrs, + colAttrs: settingsColAttrs, + rowAttrSpans, + visibleColKeys: settingsVisibleColKeys, + pivotData, + rowTotals, + rowSubtotalDisplay: settingsRowSubtotalDisplay, + arrowExpanded, + arrowCollapsed, + cellCallbacks, + rowTotalCallbacks, + namesMapping, + allowRenderHtml: settingsAllowRenderHtml, + } = settings; + + const { + highlightHeaderCellsOnHover, + omittedHighlightHeaderGroups = [], + highlightedHeaderCells, + cellColorFormatters, + dateFormatters, + } = tableOptions; + const flatRowKey = flatKey(rowKey); + + const colIncrSpan = settingsColAttrs.length !== 0 ? 1 : 0; + const attrValueCells = rowKey.map((r: string, i: number) => { + let handleContextMenu: ((e: MouseEvent) => void) | undefined; + let valueCellClassName = 'pvtRowLabel'; + if (!omittedHighlightHeaderGroups.includes(settingsRowAttrs[i])) { if (highlightHeaderCellsOnHover) { - colLabelClass += ' hoverable'; + valueCellClassName += ' hoverable'; } handleContextMenu = (e: MouseEvent) => - this.props.onContextMenu(e, colKey, undefined, { - [attrName]: colKey[attrIdx], + onContextMenu(e, undefined, rowKey, { + [settingsRowAttrs[i]]: r, }); } if ( highlightedHeaderCells && - Array.isArray(highlightedHeaderCells[colAttrs[attrIdx]]) && - highlightedHeaderCells[colAttrs[attrIdx]].includes(colKey[attrIdx]) + Array.isArray(highlightedHeaderCells[settingsRowAttrs[i]]) && + highlightedHeaderCells[settingsRowAttrs[i]].includes(r) ) { - colLabelClass += ' active'; + valueCellClassName += ' active'; } - const isActiveHeader = colLabelClass.includes('active'); - const maxRowIndex = pivotSettings.maxRowVisible!; - const mColVisible = pivotSettings.maxColVisible!; - const visibleSortIcon = mColVisible - 1 === attrIdx; - const columnName = colKey[mColVisible - 1]; + const rowSpan = rowAttrSpans![rowIdx][i]; + if (rowSpan > 0) { + const flatRowKeySlice = flatKey(rowKey.slice(0, i + 1)); + const colSpan = + 1 + (i === settingsRowAttrs.length - 1 ? colIncrSpan : 0); + const needRowToggle = + settingsRowSubtotalDisplay.enabled === true && + i !== settingsRowAttrs.length - 1; + const onArrowClick = needRowToggle + ? toggleRowKey(flatRowKeySlice) + : null; - const rowSpan = 1 + (attrIdx === colAttrs.length - 1 ? rowIncrSpan : 0); - const flatColKey = flatKey(colKey.slice(0, attrIdx + 1)); - const onArrowClick = needToggle ? this.toggleColKey(flatColKey) : null; - const getSortIcon = (key: number) => { - const { activeSortColumn, sortingOrder } = this.state; - - if (activeSortColumn !== key) { - return ( - - this.sortData(key, visibleColKeys, pivotData, maxRowIndex) - } - /> - ); - } - - const SortIcon = - sortingOrder[key] === 'asc' ? CaretUpOutlined : CaretDownOutlined; + const headerCellFormattedValue = + dateFormatters?.[settingsRowAttrs[i]]?.(r) ?? r; return ( - - this.sortData(key, visibleColKeys, pivotData, maxRowIndex) - } - /> - ); - }; - const headerCellFormattedValue = - dateFormatters?.[attrName]?.( - convertToNumberIfNumeric(colKey[attrIdx]), - ) ?? colKey[attrIdx]; - const { backgroundColor, color } = getCellColor( - [attrName], - headerCellFormattedValue, - cellColorFormatters, - isActiveHeader ? activeHeaderBackgroundColor : cellBackgroundColor, - ); - const style = { - backgroundColor, - ...(color ? { color } : {}), - }; - attrValueCells.push( - - {displayHeaderCell( - needToggle, - this.state.collapsedCols[flatColKey] - ? arrowCollapsed - : arrowExpanded, - onArrowClick, - headerCellFormattedValue, - namesMapping, - allowRenderHtml, - )} - { - e.stopPropagation(); - }} - aria-label={ - this.state.activeSortColumn === i - ? `Sorted by ${columnName} ${this.state.sortingOrder[i] === 'asc' ? 'ascending' : 'descending'}` - : undefined - } + - {visibleSortIcon && getSortIcon(i)} - - , - ); - } else if (attrIdx === colKey.length) { - const rowSpan = colAttrs.length - colKey.length + rowIncrSpan; - attrValueCells.push( + {displayHeaderCell( + needRowToggle, + collapsedRows[flatRowKeySlice] ? arrowCollapsed : arrowExpanded, + onArrowClick, + headerCellFormattedValue, + namesMapping, + settingsAllowRenderHtml, + )} + + ); + } + return null; + }); + + const attrValuePaddingCell = + rowKey.length < settingsRowAttrs.length ? ( {t('Subtotal')} - , + + ) : null; + + if (!settingsVisibleColKeys) { + return null; + } + + const rowClickHandlers = cellCallbacks[flatRowKey] || {}; + const valueCells = settingsVisibleColKeys.map((colKey: string[]) => { + const flatColKey = flatKey(colKey); + const agg = pivotData.getAggregator(rowKey, colKey); + const aggValue = agg.value(); + + const keys = [...rowKey, ...colKey]; + let backgroundColor: string | undefined; + if (cellColorFormatters) { + Object.values(cellColorFormatters).forEach(cellColorFormatter => { + if (Array.isArray(cellColorFormatter)) { + keys.forEach(key => { + if (backgroundColor) { + return; + } + cellColorFormatter + .filter(formatter => formatter.column === key) + .forEach(formatter => { + const formatterResult = + formatter.getColorFromValue(aggValue); + if (formatterResult) { + backgroundColor = formatterResult; + } + }); + }); + } + }); + } + + const style = agg.isSubtotal + ? { fontWeight: 'bold' } + : { backgroundColor }; + + return ( + onContextMenu(e, colKey, rowKey)} + style={style} + > + {displayCell(agg.format(aggValue, agg), settingsAllowRenderHtml)} + + ); + }); + + let totalCell = null; + if (rowTotals) { + const agg = pivotData.getAggregator(rowKey, []); + const aggValue = agg.value(); + totalCell = ( + onContextMenu(e, undefined, rowKey)} + > + {displayCell(agg.format(aggValue, agg), settingsAllowRenderHtml)} + ); } - // The next colSpan columns will have the same value anyway... - i += colSpan; - } - const totalCell = - attrIdx === 0 && rowTotals ? ( + const rowCells = [ + ...attrValueCells, + attrValuePaddingCell, + ...valueCells, + totalCell, + ]; + + return {rowCells}; + }, + [ + tableOptions, + onContextMenu, + toggleRowKey, + clickHeaderHandler, + rows, + collapsedRows, + ], + ); + + const renderTotalsRow = useCallback( + (settings: PivotSettings) => { + // Render the final totals rows that has the totals for all the columns. + + const { + rowAttrs: settingsRowAttrs, + colAttrs: settingsColAttrs, + visibleColKeys: settingsVisibleColKeys, + rowTotals, + pivotData, + colTotalCallbacks, + grandTotalCallback, + } = settings; + + if (!settingsVisibleColKeys) { + return null; + } + + const totalLabelCell = ( {t('Total (%(aggregatorName)s)', { - aggregatorName: t(this.props.aggregatorName), + aggregatorName: t(aggregatorName), })} - ) : null; + ); - const cells = [spaceCell, attrNameCell, ...attrValueCells, totalCell]; - return {cells}; - } + const totalValueCells = settingsVisibleColKeys.map((colKey: string[]) => { + const flatColKey = flatKey(colKey); + const agg = pivotData.getAggregator([], colKey); + const aggValue = agg.value(); - renderRowHeaderRow(pivotSettings: PivotSettings) { - // Render just the attribute names of the rows (the actual attribute values - // will show up in the individual rows). - - const { - rowAttrs, - colAttrs, - rowKeys, - arrowCollapsed, - arrowExpanded, - rowSubtotalDisplay, - maxRowVisible, - pivotData, - namesMapping, - allowRenderHtml, - } = pivotSettings; - return ( - - {rowAttrs.map((r, i) => { - const needLabelToggle = - rowSubtotalDisplay.enabled === true && i !== rowAttrs.length - 1; - let arrowClickHandle = null; - let subArrow = null; - if (needLabelToggle) { - arrowClickHandle = - i + 1 < maxRowVisible! - ? this.collapseAttr(true, i, rowKeys) - : this.expandAttr(true, i, rowKeys); - subArrow = i + 1 < maxRowVisible! ? arrowExpanded : arrowCollapsed; - } - return ( - - {displayHeaderCell( - needLabelToggle, - subArrow, - arrowClickHandle, - r, - namesMapping, - allowRenderHtml, - )} - - ); - })} - - {colAttrs.length === 0 - ? t('Total (%(aggregatorName)s)', { - aggregatorName: t(this.props.aggregatorName), - }) - : null} - - - ); - } - - renderTableRow( - rowKey: string[], - rowIdx: number, - pivotSettings: PivotSettings, - ) { - // Render a single row in the pivot table. - - const { - rowAttrs, - colAttrs, - rowAttrSpans, - visibleColKeys, - pivotData, - rowTotals, - rowSubtotalDisplay, - arrowExpanded, - arrowCollapsed, - cellCallbacks, - rowTotalCallbacks, - namesMapping, - allowRenderHtml, - } = pivotSettings; - - const { - highlightHeaderCellsOnHover, - omittedHighlightHeaderGroups = [], - highlightedHeaderCells, - cellColorFormatters, - dateFormatters, - cellBackgroundColor = supersetTheme.colorBgBase, - cellTextColor = supersetTheme.colorPrimaryText, - activeHeaderBackgroundColor = supersetTheme.colorPrimaryBg, - } = this.props.tableOptions; - const flatRowKey = flatKey(rowKey); - - const colIncrSpan = colAttrs.length !== 0 ? 1 : 0; - const attrValueCells = rowKey.map((r: string, i: number) => { - let handleContextMenu: ((e: MouseEvent) => void) | undefined; - let valueCellClassName = 'pvtRowLabel'; - if (!omittedHighlightHeaderGroups.includes(rowAttrs[i])) { - if (highlightHeaderCellsOnHover) { - valueCellClassName += ' hoverable'; - } - handleContextMenu = (e: MouseEvent) => - this.props.onContextMenu(e, undefined, rowKey, { - [rowAttrs[i]]: r, - }); - } - if ( - highlightedHeaderCells && - Array.isArray(highlightedHeaderCells[rowAttrs[i]]) && - highlightedHeaderCells[rowAttrs[i]].includes(r) - ) { - valueCellClassName += ' active'; - } - const isActiveHeader = valueCellClassName.includes('active'); - const rowSpan = rowAttrSpans![rowIdx][i]; - if (rowSpan > 0) { - const flatRowKey = flatKey(rowKey.slice(0, i + 1)); - const colSpan = 1 + (i === rowAttrs.length - 1 ? colIncrSpan : 0); - const needRowToggle = - rowSubtotalDisplay.enabled === true && i !== rowAttrs.length - 1; - const onArrowClick = needRowToggle - ? this.toggleRowKey(flatRowKey) - : null; - - const headerCellFormattedValue = - dateFormatters?.[rowAttrs[i]]?.(convertToNumberIfNumeric(r)) ?? r; - - const { backgroundColor, color } = getCellColor( - [rowAttrs[i]], - headerCellFormattedValue, - cellColorFormatters, - isActiveHeader ? activeHeaderBackgroundColor : cellBackgroundColor, - ); - const style = { - backgroundColor, - ...(color ? { color } : {}), - }; return ( - onContextMenu(e, colKey, undefined)} + style={{ padding: '5px' }} > - {displayHeaderCell( - needRowToggle, - this.state.collapsedRows[flatRowKey] - ? arrowCollapsed - : arrowExpanded, - onArrowClick, - headerCellFormattedValue, - namesMapping, - allowRenderHtml, - )} - + {displayCell(agg.format(aggValue, agg), allowRenderHtml)} + + ); + }); + + let grandTotalCell = null; + if (rowTotals) { + const agg = pivotData.getAggregator([], []); + const aggValue = agg.value(); + grandTotalCell = ( + onContextMenu(e, undefined, undefined)} + > + {displayCell(agg.format(aggValue, agg), allowRenderHtml)} + ); } - return null; - }); - const attrValuePaddingCell = - rowKey.length < rowAttrs.length ? ( - + {totalCells} + + ); + }, + [ + clickHeaderHandler, + rows, + tableOptions.clickRowHeaderCallback, + aggregatorName, + onContextMenu, + allowRenderHtml, + ], + ); + + return ( + + + + {colAttrs.map((c: string, j: number) => + renderColHeaderRow(c, j, pivotSettings), )} - > - {t('Subtotal')} - - ) : null; - - if (!visibleColKeys) { - return null; - } - - const rowClickHandlers = cellCallbacks[flatRowKey] || {}; - const valueCells = visibleColKeys.map((colKey: string[]) => { - const flatColKey = flatKey(colKey); - const agg = pivotData.getAggregator(rowKey, colKey); - const aggValue = agg.value(); - - const keys = [...rowKey, ...colKey]; - - const { backgroundColor, color } = getCellColor( - keys, - aggValue, - cellColorFormatters, - cellBackgroundColor, - ); - - const style = agg.isSubtotal - ? { - backgroundColor, - fontWeight: 'bold', - color: color ?? cellTextColor, - } - : { backgroundColor, color: color ?? cellTextColor }; - - return ( - - ); - }); - - let totalCell = null; - if (rowTotals) { - const agg = pivotData.getAggregator(rowKey, []); - const aggValue = agg.value(); - totalCell = ( - - ); - } - - const rowCells = [ - ...attrValueCells, - attrValuePaddingCell, - ...valueCells, - totalCell, - ]; - - return {rowCells}; - } - - renderTotalsRow(pivotSettings: PivotSettings) { - // Render the final totals rows that has the totals for all the columns. - - const { - rowAttrs, - colAttrs, - visibleColKeys, - rowTotals, - pivotData, - colTotalCallbacks, - grandTotalCallback, - } = pivotSettings; - - if (!visibleColKeys) { - return null; - } - - const totalLabelCell = ( - - ); - - const totalValueCells = visibleColKeys.map((colKey: string[]) => { - const flatColKey = flatKey(colKey); - const agg = pivotData.getAggregator([], colKey); - const aggValue = agg.value(); - - return ( - - ); - }); - - let grandTotalCell = null; - if (rowTotals) { - const agg = pivotData.getAggregator([], []); - const aggValue = agg.value(); - grandTotalCell = ( - - ); - } - - const totalCells = [totalLabelCell, ...totalValueCells, grandTotalCell]; - - return ( - - {totalCells} - - ); - } - - visibleKeys( - keys: string[][], - collapsed: Record, - numAttrs: number, - subtotalDisplay: SubtotalDisplay, - ) { - return keys.filter( - (key: string[]) => - // Is the key hidden by one of its parents? - !key.some( - (_k: string, j: number) => collapsed[flatKey(key.slice(0, j))], - ) && - // Leaf key. - (key.length === numAttrs || - // Children hidden. Must show total. - flatKey(key) in collapsed || - // Don't hide totals. - !subtotalDisplay.hideOnExpand), - ); - } - - isDashboardEditMode() { - return document.contains(document.querySelector('.dashboard--editing')); - } - - componentWillUnmount() { - this.sortCache.clear(); - } - - render() { - if (this.cachedProps !== this.props) { - this.sortCache.clear(); - // Reset sort state without using setState to avoid re-render during render. - // This is safe because the state is being synchronized with new props. - (this.state as TableRendererState).sortingOrder = []; - (this.state as TableRendererState).activeSortColumn = null; - this.cachedProps = this.props; - this.cachedBasePivotSettings = this.getBasePivotSettings(); - } - const basePivotSettings = this.cachedBasePivotSettings!; - const { - colAttrs, - rowAttrs, - rowKeys, - colKeys, - colTotals, - rowSubtotalDisplay, - colSubtotalDisplay, - allowRenderHtml, - } = basePivotSettings; - - // Need to account for exclusions to compute the effective row - // and column keys. - const visibleRowKeys = this.visibleKeys( - rowKeys, - this.state.collapsedRows, - rowAttrs.length, - rowSubtotalDisplay, - ); - const visibleColKeys = this.visibleKeys( - colKeys, - this.state.collapsedCols, - colAttrs.length, - colSubtotalDisplay, - ); - - const pivotSettings: PivotSettings = { - visibleRowKeys, - maxRowVisible: Math.max(...visibleRowKeys.map((k: string[]) => k.length)), - visibleColKeys, - maxColVisible: Math.max(...visibleColKeys.map((k: string[]) => k.length)), - rowAttrSpans: this.calcAttrSpans(visibleRowKeys, rowAttrs.length), - colAttrSpans: this.calcAttrSpans(visibleColKeys, colAttrs.length), - allowRenderHtml, - ...basePivotSettings, - }; - - return ( - -
this.props.onContextMenu(e, colKey, rowKey)} - style={style} - > - {displayCell(agg.format(aggValue, agg), allowRenderHtml)} - this.props.onContextMenu(e, undefined, rowKey)} - > - {displayCell(agg.format(aggValue, agg), allowRenderHtml)} -
- {t('Total (%(aggregatorName)s)', { - aggregatorName: t(this.props.aggregatorName), - })} - this.props.onContextMenu(e, colKey, undefined)} - style={{ padding: '5px' }} - > - {displayCell(agg.format(aggValue, agg), this.props.allowRenderHtml)} - this.props.onContextMenu(e, undefined, undefined)} - > - {displayCell(agg.format(aggValue, agg), this.props.allowRenderHtml)} -
- - {colAttrs.map((c: string, j: number) => - this.renderColHeaderRow(c, j, pivotSettings), - )} - {rowAttrs.length !== 0 && this.renderRowHeaderRow(pivotSettings)} - - - {visibleRowKeys.map((r: string[], i: number) => - this.renderTableRow(r, i, pivotSettings), - )} - {colTotals && this.renderTotalsRow(pivotSettings)} - -
-
- ); - } + {rowAttrs.length !== 0 && renderRowHeaderRow(pivotSettings)} + + + {visibleRowKeys.map((r: string[], i: number) => + renderTableRow(r, i, pivotSettings), + )} + {colTotals && renderTotalsRow(pivotSettings)} + + + + ); } TableRenderer.propTypes = { diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/test/react-pivottable/tableRenders.test.tsx b/superset-frontend/plugins/plugin-chart-pivot-table/test/react-pivottable/tableRenders.test.tsx index 6250ef66a44..be8d86b05ab 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/test/react-pivottable/tableRenders.test.tsx +++ b/superset-frontend/plugins/plugin-chart-pivot-table/test/react-pivottable/tableRenders.test.tsx @@ -1,4 +1,4 @@ -/* +/** * 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 @@ -16,1133 +16,316 @@ * specific language governing permissions and limitations * under the License. */ -import { Children, isValidElement, type ReactElement } from 'react'; -import { - getTextColorForBackground, - ObjectFormattingEnum, -} from '@superset-ui/chart-controls'; -import { - getCellColor, - TableRenderer, -} from '../../src/react-pivottable/TableRenderers'; -import type { PivotData } from '../../src/react-pivottable/utilities'; -let tableRenderer: TableRenderer; -let mockGetAggregatedData: jest.Mock; -let mockSortAndCacheData: jest.Mock; - -type RenderColHeaderRowSettings = Parameters< - TableRenderer['renderColHeaderRow'] ->[2]; -type RenderTableRowSettings = Parameters[2]; -type TableRendererStateStub = TableRenderer['state']; -type CachedBasePivotSettings = NonNullable< - TableRenderer['cachedBasePivotSettings'] ->; - -const columnIndex = 0; -const visibleColKeys = [['col1'], ['col2']]; -const maxRowIndex = 2; - -const mockProps = { - rows: ['row1'], - cols: ['col1'], - data: [], - aggregatorName: 'Sum', - vals: ['value'], - valueFilter: {}, - sorters: {}, - rowOrder: 'key_a_to_z', - colOrder: 'key_a_to_z', - tableOptions: {}, - namesMapping: {}, - allowRenderHtml: false, - onContextMenu: jest.fn(), - aggregatorsFactory: jest.fn(), - defaultFormatter: jest.fn(), - customFormatters: {}, - rowEnabled: true, - rowPartialOnTop: false, - colEnabled: false, - colPartialOnTop: false, -}; - -const toPivotData = (value: Partial): PivotData => - value as unknown as PivotData; - -const toCachedBasePivotSettings = ( - value: Partial, -): CachedBasePivotSettings => value as unknown as CachedBasePivotSettings; - -const createActiveHeaderTableOptions = ( - activeHeaderBackgroundColor: string, - overrides: Record = {}, -): TableRenderer['props']['tableOptions'] => - ({ - ...overrides, - activeHeaderBackgroundColor, - }) as unknown as TableRenderer['props']['tableOptions']; - -const createPivotDataStub = ( - aggregatorValue = 200, - isSubtotal = false, -): PivotData => - toPivotData({ - getAggregator: () => ({ - push: jest.fn(), - value: () => aggregatorValue, - format: (value: number) => String(value), - isSubtotal, - }), - }); - -const pivotData = toPivotData({ - subtotals: { - rowEnabled: true, - rowPartialOnTop: false, - }, -}); - -const createColHeaderRowSettings = ( - overrides: Partial = {}, -): RenderColHeaderRowSettings => - ({ - rowAttrs: [], - colAttrs: ['region'], - visibleColKeys: [['EMEA']], - colAttrSpans: [[1]], - colKeys: [['EMEA']], - colSubtotalDisplay: { - displayOnTop: false, - enabled: false, - hideOnExpand: false, - }, - rowSubtotalDisplay: { - displayOnTop: false, - enabled: false, - hideOnExpand: false, - }, - maxColVisible: 1, - maxRowVisible: 0, - pivotData: createPivotDataStub(), - namesMapping: {}, - allowRenderHtml: false, - arrowExpanded: null, - arrowCollapsed: null, - rowTotals: false, - colTotals: false, - ...overrides, - }) as RenderColHeaderRowSettings; - -const createTableRowSettings = ( - overrides: Partial = {}, -): RenderTableRowSettings => - ({ - rowAttrs: ['metric'], - colAttrs: [], - rowAttrSpans: [[1]], - visibleColKeys: [[]], - pivotData: createPivotDataStub(), - rowTotals: false, - rowSubtotalDisplay: { - displayOnTop: false, - enabled: false, - hideOnExpand: false, - }, - arrowExpanded: null, - arrowCollapsed: null, - cellCallbacks: {}, - rowTotalCallbacks: {}, - namesMapping: {}, - allowRenderHtml: false, - ...overrides, - }) as RenderTableRowSettings; - -beforeEach(() => { - tableRenderer = new TableRenderer(mockProps); - - mockGetAggregatedData = jest.fn(); - mockSortAndCacheData = jest.fn(); - - tableRenderer.getAggregatedData = mockGetAggregatedData; - tableRenderer.sortAndCacheData = mockSortAndCacheData; - - tableRenderer.cachedBasePivotSettings = toCachedBasePivotSettings({ - pivotData: toPivotData({ - subtotals: { - rowEnabled: true, - rowPartialOnTop: false, - colEnabled: false, - colPartialOnTop: false, - }, - }), - rowKeys: [['A'], ['B'], ['C']], - }); - - tableRenderer.state = { - sortingOrder: [], - activeSortColumn: null, - collapsedRows: {}, - collapsedCols: {}, - } as TableRendererStateStub; -}); - -const mockGroups = { - B: { - currentVal: 20, - B1: { currentVal: 15 }, - B2: { currentVal: 5 }, - }, - A: { - currentVal: 10, - A1: { currentVal: 8 }, - A2: { currentVal: 2 }, - }, - C: { - currentVal: 30, - C1: { currentVal: 25 }, - C2: { currentVal: 5 }, - }, -}; - -const createMockPivotData = (rowData: Record): PivotData => - toPivotData({ - rowKeys: Object.keys(rowData).map(key => key.split('.')), - getAggregator: (rowKey: string[]) => ({ - push: jest.fn(), - value: () => rowData[rowKey.join('.')], - format: (value: number) => String(value), - }), - }); - -test('should set initial ascending sort when no active sort column', () => { - mockGetAggregatedData.mockReturnValue({ - A: { currentVal: 30 }, - B: { currentVal: 10 }, - C: { currentVal: 20 }, - }); - - const setStateMock = jest.fn(); - tableRenderer.setState = setStateMock; - - tableRenderer.sortData(columnIndex, visibleColKeys, pivotData, maxRowIndex); - - expect(setStateMock).toHaveBeenCalled(); - - const [stateUpdater] = setStateMock.mock.calls[0]; - - expect(typeof stateUpdater).toBe('function'); - - const previousState = { - sortingOrder: [], - activeSortColumn: 0, - }; - - const newState = stateUpdater(previousState); - - expect(newState.sortingOrder[columnIndex]).toBe('asc'); - expect(newState.activeSortColumn).toBe(columnIndex); - - expect(mockGetAggregatedData).toHaveBeenCalledWith( - pivotData, - visibleColKeys[columnIndex], - false, - ); - - expect(mockSortAndCacheData).toHaveBeenCalledWith( - { A: { currentVal: 30 }, B: { currentVal: 10 }, C: { currentVal: 20 } }, - 'asc', - true, - false, - maxRowIndex, - ); -}); - -test('should toggle from asc to desc when clicking same column', () => { - mockGetAggregatedData.mockReturnValue({ - A: { currentVal: 30 }, - B: { currentVal: 10 }, - C: { currentVal: 20 }, - }); - const setStateMock = jest.fn(stateUpdater => { - if (typeof stateUpdater === 'function') { - const newState = stateUpdater({ - sortingOrder: ['asc' as never], - activeSortColumn: 0, - }); - - tableRenderer.state = { - ...tableRenderer.state, - ...newState, - }; - } - }); - tableRenderer.setState = setStateMock; - - tableRenderer.sortData(columnIndex, visibleColKeys, pivotData, maxRowIndex); - - expect(mockSortAndCacheData).toHaveBeenCalledWith( - { A: { currentVal: 30 }, B: { currentVal: 10 }, C: { currentVal: 20 } }, - 'desc', - true, - false, - maxRowIndex, - ); -}); - -test('should check second call in sequence', () => { - mockGetAggregatedData.mockReturnValue({ - A: { currentVal: 30 }, - B: { currentVal: 10 }, - C: { currentVal: 20 }, - }); - - mockSortAndCacheData.mockClear(); - - const setStateMock = jest.fn(stateUpdater => { - if (typeof stateUpdater === 'function') { - const newState = stateUpdater(tableRenderer.state); - tableRenderer.state = { - ...tableRenderer.state, - ...newState, - }; - } - }); - tableRenderer.setState = setStateMock; - - tableRenderer.state = { - sortingOrder: [], - activeSortColumn: 0, - collapsedRows: {}, - collapsedCols: {}, - } as TableRendererStateStub; - tableRenderer.sortData(columnIndex, visibleColKeys, pivotData, maxRowIndex); - - tableRenderer.state = { - sortingOrder: ['asc' as never], - activeSortColumn: 0, - collapsedRows: {}, - collapsedCols: {}, - } as TableRendererStateStub; - tableRenderer.sortData(columnIndex, visibleColKeys, pivotData, maxRowIndex); - - expect(mockSortAndCacheData).toHaveBeenCalledTimes(2); - - expect(mockSortAndCacheData.mock.calls[0]).toEqual([ - { A: { currentVal: 30 }, B: { currentVal: 10 }, C: { currentVal: 20 } }, - 'asc', - true, - false, - maxRowIndex, - ]); - - expect(mockSortAndCacheData.mock.calls[1]).toEqual([ - { A: { currentVal: 30 }, B: { currentVal: 10 }, C: { currentVal: 20 } }, - 'desc', - true, - false, - maxRowIndex, - ]); -}); - -test('should sort hierarchical data in descending order', () => { - tableRenderer = new TableRenderer(mockProps); - const groups = { - A: { - currentVal: 30, - A1: { currentVal: 13 }, - A2: { currentVal: 17 }, - }, - B: { - currentVal: 10, - B1: { currentVal: 7 }, - B2: { currentVal: 3 }, - }, - - C: { - currentVal: 18, - C1: { currentVal: 7 }, - C2: { currentVal: 11 }, - }, - }; - - const result = tableRenderer.sortAndCacheData(groups, 'desc', true, false, 2); - - expect(result).toBeDefined(); - - expect(Array.isArray(result)).toBe(true); - - expect(result).toEqual([ - ['A', 'A2'], - ['A', 'A1'], - ['A'], - ['C', 'C2'], - ['C', 'C1'], - ['C'], - ['B', 'B1'], - ['B', 'B2'], - ['B'], - ]); -}); - -test('should sort hierarchical data in ascending order', () => { - tableRenderer = new TableRenderer(mockProps); - const groups = { - A: { - currentVal: 30, - A1: { currentVal: 13 }, - A2: { currentVal: 17 }, - }, - B: { - currentVal: 10, - B1: { currentVal: 7 }, - B2: { currentVal: 3 }, - }, - - C: { - currentVal: 18, - C1: { currentVal: 7 }, - C2: { currentVal: 11 }, - }, - }; - - const result = tableRenderer.sortAndCacheData(groups, 'asc', true, false, 2); - - expect(result).toBeDefined(); - - expect(Array.isArray(result)).toBe(true); - - expect(result).toEqual([ - ['B', 'B2'], - ['B', 'B1'], - ['B'], - ['C', 'C1'], - ['C', 'C2'], - ['C'], - ['A', 'A1'], - ['A', 'A2'], - ['A'], - ]); -}); - -test('should calculate groups from pivot data', () => { - tableRenderer = new TableRenderer(mockProps); - const mockAggregator = (value: number) => ({ - push: jest.fn(), - value: () => value, - format: jest.fn(), - isSubtotal: false, - }); - - const mockPivotData = toPivotData({ - rowKeys: [['A'], ['B'], ['C']], - getAggregator: jest - .fn() - .mockReturnValueOnce(mockAggregator(30)) - .mockReturnValueOnce(mockAggregator(10)) - .mockReturnValueOnce(mockAggregator(20)), - }); - - const result = tableRenderer.getAggregatedData( - mockPivotData, - ['col1'], - false, - ); - - expect(result).toEqual({ - A: { currentVal: 30 }, - B: { currentVal: 10 }, - C: { currentVal: 20 }, - }); -}); - -test('should sort groups and convert to array in ascending order', () => { - tableRenderer = new TableRenderer(mockProps); - const result = tableRenderer.sortAndCacheData( - mockGroups, - 'asc', - true, - false, - 2, - ); - - expect(result).toEqual([ - ['A', 'A2'], - ['A', 'A1'], - ['A'], - ['B', 'B2'], - ['B', 'B1'], - ['B'], - ['C', 'C2'], - ['C', 'C1'], - ['C'], - ]); -}); - -test('should sort groups and convert to array in descending order', () => { - tableRenderer = new TableRenderer(mockProps); - const result = tableRenderer.sortAndCacheData( - mockGroups, - 'desc', - true, - false, - 2, - ); - - expect(result).toEqual([ - ['C', 'C1'], - ['C', 'C2'], - ['C'], - ['B', 'B1'], - ['B', 'B2'], - ['B'], - ['A', 'A1'], - ['A', 'A2'], - ['A'], - ]); -}); - -test('should handle rowPartialOnTop = true configuration', () => { - tableRenderer = new TableRenderer(mockProps); - const result = tableRenderer.sortAndCacheData( - mockGroups, - 'asc', - true, - true, - 2, - ); - - expect(result).toEqual([ - ['A'], - ['A', 'A2'], - ['A', 'A1'], - ['B'], - ['B', 'B2'], - ['B', 'B1'], - ['C'], - ['C', 'C2'], - ['C', 'C1'], - ]); -}); - -test('should handle rowEnabled = false and rowPartialOnTop = false, sorting asc', () => { - tableRenderer = new TableRenderer(mockProps); - - const result = tableRenderer.sortAndCacheData( - mockGroups, - 'asc', - false, - false, - 2, - ); - - expect(result).toEqual([ - ['A', 'A2'], - ['A', 'A1'], - ['B', 'B2'], - ['B', 'B1'], - ['C', 'C2'], - ['C', 'C1'], - ]); -}); - -test('should handle rowEnabled = false and rowPartialOnTop = false , sorting desc', () => { - tableRenderer = new TableRenderer(mockProps); - - const result = tableRenderer.sortAndCacheData( - mockGroups, - 'desc', - false, - false, - 2, - ); - - expect(result).toEqual([ - ['C', 'C1'], - ['C', 'C2'], - ['B', 'B1'], - ['B', 'B2'], - ['A', 'A1'], - ['A', 'A2'], - ]); -}); - -test('create hierarchical structure with subtotal at bottom', () => { - tableRenderer = new TableRenderer(mockProps); - const rowData = { - 'A.A1': 10, - 'A.A2': 20, - A: 30, - 'B.B1': 30, - 'B.B2': 40, - B: 70, - 'C.C1': 50, - 'C.C2': 60, - C: 110, - }; - - const pivotData = createMockPivotData(rowData); - const result = tableRenderer.getAggregatedData(pivotData, ['Col1'], false); - - expect(result).toEqual({ - A: { - A1: { currentVal: 10 }, - A2: { currentVal: 20 }, - currentVal: 30, - }, - B: { - B1: { currentVal: 30 }, - B2: { currentVal: 40 }, - currentVal: 70, - }, - C: { - C1: { currentVal: 50 }, - C2: { currentVal: 60 }, - currentVal: 110, - }, - }); -}); - -test('create hierarchical structure with subtotal at top', () => { - tableRenderer = new TableRenderer(mockProps); - const rowData = { - A: 30, - 'A.A1': 10, - 'A.A2': 20, - B: 70, - 'B.B1': 30, - 'B.B2': 40, - C: 110, - 'C.C1': 50, - 'C.C2': 60, - }; - - const pivotData = createMockPivotData(rowData); - const result = tableRenderer.getAggregatedData(pivotData, ['Col1'], true); - - expect(result).toEqual({ - A: { - A1: { currentVal: 10 }, - A2: { currentVal: 20 }, - currentVal: 30, - }, - B: { - B1: { currentVal: 30 }, - B2: { currentVal: 40 }, - currentVal: 70, - }, - C: { - C1: { currentVal: 50 }, - C2: { currentVal: 60 }, - currentVal: 110, - }, - }); -}); - -test('values ​​from the 3rd level of the hierarchy with a subtotal at the bottom', () => { - tableRenderer = new TableRenderer(mockProps); - const rowData = { - 'A.A1.A11': 10, - 'A.A1.A12': 20, - 'A.A1': 30, - 'A.A2': 30, - 'A.A3': 50, - A: 110, - }; - - const pivotData = createMockPivotData(rowData); - const result = tableRenderer.getAggregatedData(pivotData, ['Col1'], false); - - expect(result).toEqual({ - A: { - A1: { - A11: { currentVal: 10 }, - A12: { currentVal: 20 }, - currentVal: 30, - }, - A2: { currentVal: 30 }, - A3: { currentVal: 50 }, - currentVal: 110, - }, - }); -}); - -test('values ​​from the 3rd level of the hierarchy with a subtotal at the top', () => { - tableRenderer = new TableRenderer(mockProps); - const rowData = { - A: 110, - 'A.A1': 30, - 'A.A1.A11': 10, - 'A.A1.A12': 20, - 'A.A2': 30, - 'A.A3': 50, - }; - - const pivotData = createMockPivotData(rowData); - const result = tableRenderer.getAggregatedData(pivotData, ['Col1'], true); - - expect(result).toEqual({ - A: { - A1: { - A11: { currentVal: 10 }, - A12: { currentVal: 20 }, - currentVal: 30, - }, - A2: { currentVal: 30 }, - A3: { currentVal: 50 }, - currentVal: 110, - }, - }); -}); - -test('getCellColor derives readable text from the winning background', () => { - expect( - getCellColor( - ['revenue'], - 200, - { - metric: [ - { - column: 'revenue', - objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR, - getColorFromValue: (value: unknown) => - value === 200 ? '#111111' : undefined, - }, - ], - }, - '#ffffff', - ), - ).toEqual({ - backgroundColor: '#111111', - color: 'rgb(255, 255, 255)', - }); -}); - -test('getCellColor keeps explicit text color over adaptive contrast', () => { - expect( - getCellColor( - ['revenue'], - 200, - { - metric: [ - { - column: 'revenue', - objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR, - getColorFromValue: (value: unknown) => - value === 200 ? '#111111' : undefined, - }, - { - column: 'revenue', - objectFormatting: ObjectFormattingEnum.TEXT_COLOR, - getColorFromValue: (value: unknown) => - value === 200 ? '#ace1c40d' : undefined, - }, - ], - }, - '#ffffff', - ), - ).toEqual({ - backgroundColor: '#111111', - color: 'rgb(172, 225, 196)', - }); -}); - -test('getCellColor treats legacy toTextColor formatters as text color', () => { - expect( - getCellColor( - ['revenue'], - 200, - { - metric: [ - { - column: 'revenue', - getColorFromValue: (value: unknown) => - value === 200 ? '#111111' : undefined, - }, - { - column: 'revenue', - toTextColor: true, - getColorFromValue: (value: unknown) => - value === 200 ? '#ace1c40d' : undefined, - }, - ], - }, - '#ffffff', - ), - ).toEqual({ - backgroundColor: '#111111', - color: 'rgb(172, 225, 196)', - }); -}); - -test('getCellColor ignores cell-bar rules when resolving text color', () => { - expect( - getCellColor( - ['revenue'], - 200, - { - metric: [ - { - column: 'revenue', - objectFormatting: ObjectFormattingEnum.CELL_BAR, - getColorFromValue: (value: unknown) => - value === 200 ? '#11111199' : undefined, - }, - ], - }, - '#ffffff', - ), - ).toEqual({ - backgroundColor: undefined, - color: undefined, - }); -}); - -test('renderTableRow keeps subtotal background and readable text in sync', () => { - tableRenderer = new TableRenderer({ - ...mockProps, - tableOptions: { - cellColorFormatters: { - metric: [ - { - column: 'revenue', - objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR, - getColorFromValue: (value: unknown) => - value === 200 ? '#111111' : undefined, - }, - ], - }, - cellBackgroundColor: '#ffffff', - cellTextColor: '#000000', - }, - }); - - const row = tableRenderer.renderTableRow( - ['revenue'], - 0, - createTableRowSettings({ - pivotData: createPivotDataStub(200, true), - }), - ) as ReactElement; - - const cells = Children.toArray(row.props.children); - const valueCell = cells.find( - child => isValidElement(child) && child.props.className === 'pvtVal', - ) as ReactElement; - - expect(valueCell.props.style).toEqual({ - backgroundColor: '#111111', - color: 'rgb(255, 255, 255)', - fontWeight: 'bold', - }); -}); - -test('renderColAttrsHeader applies readable text color to formatted headers', () => { - tableRenderer = new TableRenderer({ - ...mockProps, - tableOptions: { - cellColorFormatters: { - metric: [ - { - column: 'region', - objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR, - getColorFromValue: (value: unknown) => - value === 'EMEA' ? '#111111' : undefined, - }, - ], - }, - cellBackgroundColor: '#ffffff', - cellTextColor: '#000000', - }, - }); - - const row = tableRenderer.renderColHeaderRow( - 'region', - 0, - createColHeaderRowSettings(), - ) as ReactElement; - - const cells = Children.toArray(row.props.children); - const headerCell = cells.find( - child => isValidElement(child) && child.props.className === 'pvtColLabel', - ) as ReactElement; - - expect(headerCell.props.style).toEqual({ - backgroundColor: '#111111', - color: 'rgb(255, 255, 255)', - }); -}); - -test('renderColAttrsHeader uses active header surface for adaptive contrast', () => { - const activeHeaderBackgroundColor = '#102a43'; - tableRenderer = new TableRenderer({ - ...mockProps, - tableOptions: createActiveHeaderTableOptions(activeHeaderBackgroundColor, { - highlightedHeaderCells: { - region: ['EMEA'], - }, - cellColorFormatters: { - metric: [ - { - column: 'region', - objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR, - getColorFromValue: (value: unknown) => - value === 'EMEA' ? 'rgba(0, 0, 0, 0.4)' : undefined, - }, - ], - }, - cellBackgroundColor: '#ffffff', - cellTextColor: '#000000', - }), - }); - - const row = tableRenderer.renderColHeaderRow( - 'region', - 0, - createColHeaderRowSettings(), - ) as ReactElement; - - const cells = Children.toArray(row.props.children); - const headerCell = cells.find( - child => - isValidElement(child) && child.props.className === 'pvtColLabel active', - ) as ReactElement; - - expect(headerCell.props.style).toEqual({ - backgroundColor: 'rgba(0, 0, 0, 0.4)', - color: getTextColorForBackground( - { backgroundColor: 'rgba(0, 0, 0, 0.4)' }, - activeHeaderBackgroundColor, - ), - }); -}); - -test('renderColHeaderRow preserves default header text color without formatting', () => { - tableRenderer = new TableRenderer({ - ...mockProps, - tableOptions: { - cellColorFormatters: { metric: [] }, - cellBackgroundColor: '#ffffff', - cellTextColor: '#ff00aa', - }, - }); - - const row = tableRenderer.renderColHeaderRow( - 'region', - 0, - createColHeaderRowSettings(), - ) as ReactElement; - - const cells = Children.toArray(row.props.children); - const headerCell = cells.find( - child => isValidElement(child) && child.props.className === 'pvtColLabel', - ) as ReactElement; - - expect(headerCell.props.style).toEqual({ - backgroundColor: undefined, - color: undefined, - }); -}); - -test('renderTableRow preserves default row-header text color without formatting', () => { - tableRenderer = new TableRenderer({ - ...mockProps, - tableOptions: { - cellColorFormatters: { metric: [] }, - cellBackgroundColor: '#ffffff', - cellTextColor: '#ff00aa', - }, - }); - - const row = tableRenderer.renderTableRow( - ['revenue'], - 0, - createTableRowSettings(), - ) as ReactElement; - - const cells = Children.toArray(row.props.children); - const headerCell = cells.find( - child => isValidElement(child) && child.props.className === 'pvtRowLabel', - ) as ReactElement; - - expect(headerCell.props.style).toEqual({ - backgroundColor: undefined, - color: undefined, - }); -}); - -test('renderTableRow applies readable text color to formatted row headers', () => { - tableRenderer = new TableRenderer({ - ...mockProps, - tableOptions: { - cellColorFormatters: { - metric: [ - { - column: 'metric', - objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR, - getColorFromValue: (value: unknown) => - value === 'revenue' ? '#111111' : undefined, - }, - ], - }, - cellBackgroundColor: '#ffffff', - cellTextColor: '#000000', - }, - }); - - const row = tableRenderer.renderTableRow( - ['revenue'], - 0, - createTableRowSettings(), - ) as ReactElement; - - const cells = Children.toArray(row.props.children); - const headerCell = cells.find( - child => isValidElement(child) && child.props.className === 'pvtRowLabel', - ) as ReactElement; - - expect(headerCell.props.style).toEqual({ - backgroundColor: '#111111', - color: 'rgb(255, 255, 255)', - }); -}); - -test('renderTableRow uses active header surface for adaptive contrast', () => { - const activeHeaderBackgroundColor = '#102a43'; - tableRenderer = new TableRenderer({ - ...mockProps, - tableOptions: createActiveHeaderTableOptions(activeHeaderBackgroundColor, { - highlightedHeaderCells: { - metric: ['revenue'], - }, - cellColorFormatters: { - metric: [ - { - column: 'metric', - objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR, - getColorFromValue: (value: unknown) => - value === 'revenue' ? 'rgba(0, 0, 0, 0.4)' : undefined, - }, - ], - }, - cellBackgroundColor: '#ffffff', - cellTextColor: '#000000', - }), - }); - - const row = tableRenderer.renderTableRow( - ['revenue'], - 0, - createTableRowSettings(), - ) as ReactElement; - - const cells = Children.toArray(row.props.children); - const headerCell = cells.find( - child => - isValidElement(child) && child.props.className === 'pvtRowLabel active', - ) as ReactElement; - - expect(headerCell.props.style).toEqual({ - backgroundColor: 'rgba(0, 0, 0, 0.4)', - color: getTextColorForBackground( - { backgroundColor: 'rgba(0, 0, 0, 0.4)' }, - activeHeaderBackgroundColor, - ), - }); -}); - -function makeColPivotSettings( - value: string, -): Parameters[2] { - return { - rowAttrs: [], - colAttrs: ['event_time'], - colKeys: [[value]], - visibleColKeys: [[value]], - colAttrSpans: [[1]], - rowTotals: false, - colSubtotalDisplay: { - enabled: false, - displayOnTop: false, - hideOnExpand: false, - }, - maxColVisible: 1, - pivotData: {}, - namesMapping: {}, - allowRenderHtml: false, - } as unknown as Parameters[2]; -} - -function makeRowPivotSettings(): Parameters< - TableRenderer['renderTableRow'] ->[2] { - const aggregator = { - value: jest.fn().mockReturnValue(1), - format: jest.fn().mockReturnValue('1'), - isSubtotal: false, - }; - return { - rowAttrs: ['event_time'], - colAttrs: [], - rowAttrSpans: [[1]], - visibleColKeys: [[]], - pivotData: { getAggregator: jest.fn().mockReturnValue(aggregator) }, - rowTotals: false, - rowSubtotalDisplay: { - enabled: false, - displayOnTop: false, - hideOnExpand: false, - }, - arrowExpanded: null, - arrowCollapsed: null, - cellCallbacks: {}, - rowTotalCallbacks: {}, - namesMapping: {}, - allowRenderHtml: false, - } as unknown as Parameters[2]; -} - -test.each([ - ['numeric timestamp string', '1700000000000', 1700000000000], - ['non-numeric date string', 'Dec. 16 2020', 'Dec. 16 2020'], - ['ISO timestamp string', '2024-01-15T00:00:00Z', '2024-01-15T00:00:00Z'], -])( - 'col header date formatter receives correct value for %s', - (_, input, expected) => { - const formatter = jest.fn().mockReturnValue('formatted'); - tableRenderer = new TableRenderer({ - ...mockProps, - cols: ['event_time'], - tableOptions: { - ...mockProps.tableOptions, - dateFormatters: { event_time: formatter }, - }, - }); - tableRenderer.renderColHeaderRow( - 'event_time', - 0, - makeColPivotSettings(input), - ); - expect(formatter).toHaveBeenCalledWith(expected); - }, +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { supersetTheme, ThemeProvider } from '@apache-superset/core/ui'; +import { TableRenderer } from '../../src/react-pivottable/TableRenderers'; +import { aggregatorTemplates } from '../../src/react-pivottable/utilities'; + +jest.mock( + 'react-icons/fa', + () => ({ + FaSort: () => , + FaSortDown: () => , + FaSortUp: () => , + }), + { virtual: true }, ); -test.each([ - ['numeric timestamp string', '1700000000000', 1700000000000], - ['non-numeric date string', 'Dec. 16 2020', 'Dec. 16 2020'], -])( - 'row header date formatter receives correct value for %s', - (_, input, expected) => { - const formatter = jest.fn().mockReturnValue('formatted'); - tableRenderer = new TableRenderer({ - ...mockProps, - rows: ['event_time'], - tableOptions: { - ...mockProps.tableOptions, - dateFormatters: { event_time: formatter }, - }, - }); - tableRenderer.renderTableRow([input], 0, makeRowPivotSettings()); - expect(formatter).toHaveBeenCalledWith(expected); - }, -); +/** + * A minimal aggregatorsFactory that mirrors the production one. + * PivotData's constructor calls `aggregatorsFactory(defaultFormatter)` + * to obtain a map of aggregator constructors keyed by name. + * The `formatter` argument is ignored here because the tests only + * care about rendering output, not number formatting precision. + */ +const aggregatorsFactory = () => ({ + Count: aggregatorTemplates.count(), + Sum: aggregatorTemplates.sum(), +}); + +const SAMPLE_DATA = [ + { color: 'blue', shape: 'circle', value: 10 }, + { color: 'blue', shape: 'square', value: 20 }, + { color: 'red', shape: 'circle', value: 30 }, + { color: 'red', shape: 'square', value: 40 }, +]; + +function renderWithTheme(ui: React.ReactElement) { + return render({ui}); +} + +function buildDefaultProps(overrides: Record = {}) { + return { + data: SAMPLE_DATA, + rows: ['color'] as string[], + cols: ['shape'] as string[], + aggregatorName: 'Count', + vals: [] as string[], + aggregatorsFactory, + tableOptions: {}, + onContextMenu: jest.fn(), + ...overrides, + }; +} + +test('TableRenderer renders a table element with the pvtTable class', () => { + const props = buildDefaultProps(); + renderWithTheme(); + + const table = screen.getByRole('grid'); + expect(table).toBeInTheDocument(); + expect(table).toHaveClass('pvtTable'); +}); + +test('TableRenderer renders column headers from pivot data', () => { + const props = buildDefaultProps(); + renderWithTheme(); + + // The column attribute values ("circle" and "square") should appear as + // column headers in the rendered table. + expect(screen.getByText('circle')).toBeInTheDocument(); + expect(screen.getByText('square')).toBeInTheDocument(); +}); + +test('TableRenderer renders row headers from pivot data', () => { + const props = buildDefaultProps(); + renderWithTheme(); + + // The row attribute values ("blue" and "red") should appear as + // row headers in the rendered table. + expect(screen.getByText('blue')).toBeInTheDocument(); + expect(screen.getByText('red')).toBeInTheDocument(); +}); + +test('TableRenderer renders aggregated cell values', () => { + const props = buildDefaultProps(); + renderWithTheme(); + + // With "Count" aggregator, each cell (row x col intersection) should + // contain "1" because each combination appears exactly once. + const cells = screen.getAllByRole('gridcell'); + const cellTexts = cells.map(cell => cell.textContent); + + // There should be cell values of "1" for each of the four intersections + // (blue+circle, blue+square, red+circle, red+square). + const onesCount = cellTexts.filter(text => text === '1').length; + expect(onesCount).toBeGreaterThanOrEqual(4); +}); + +test('TableRenderer renders row totals when rowTotals is enabled', () => { + const props = buildDefaultProps({ + tableOptions: { rowTotals: true, colTotals: true }, + }); + renderWithTheme(); + + // Row totals column should show "2" for each color (blue has 2 records, + // red has 2 records). + const totalCells = screen + .getAllByRole('gridcell') + .filter(cell => cell.classList.contains('pvtTotal')); + expect(totalCells.length).toBeGreaterThan(0); + + const totalValues = totalCells.map(cell => cell.textContent); + expect(totalValues).toContain('2'); +}); + +test('TableRenderer renders col totals row when colTotals is enabled', () => { + const props = buildDefaultProps({ + tableOptions: { rowTotals: true, colTotals: true }, + }); + renderWithTheme(); + + // The totals row should have cells with class pvtRowTotal. + const rowTotalCells = screen + .getAllByRole('gridcell') + .filter(cell => cell.classList.contains('pvtRowTotal')); + expect(rowTotalCells.length).toBeGreaterThan(0); +}); + +test('TableRenderer renders grand total when both totals are enabled', () => { + const props = buildDefaultProps({ + tableOptions: { rowTotals: true, colTotals: true }, + }); + renderWithTheme(); + + // The grand total cell should show "4" (total record count). + const grandTotalCells = screen + .getAllByRole('gridcell') + .filter(cell => cell.classList.contains('pvtGrandTotal')); + expect(grandTotalCells.length).toBe(1); + expect(grandTotalCells[0]).toHaveTextContent('4'); +}); + +test('TableRenderer handles empty data gracefully', () => { + const props = buildDefaultProps({ data: [] }); + renderWithTheme(); + + // The table should still render without crashing, just with no data rows. + const table = screen.getByRole('grid'); + expect(table).toBeInTheDocument(); + + // With empty data, there are no regular value cells (pvtVal). + const valueCells = document.querySelectorAll('.pvtVal'); + expect(valueCells).toHaveLength(0); + + // No row headers should be present. + const rowLabels = document.querySelectorAll('.pvtRowLabel'); + expect(rowLabels).toHaveLength(0); +}); + +test('TableRenderer handles data with no rows dimension', () => { + const props = buildDefaultProps({ + rows: [], + cols: ['color'], + }); + renderWithTheme(); + + const table = screen.getByRole('grid'); + expect(table).toBeInTheDocument(); + + // Column headers should still render. + expect(screen.getByText('blue')).toBeInTheDocument(); + expect(screen.getByText('red')).toBeInTheDocument(); +}); + +test('TableRenderer handles data with no cols dimension', () => { + const props = buildDefaultProps({ + rows: ['color'], + cols: [], + }); + renderWithTheme(); + + const table = screen.getByRole('grid'); + expect(table).toBeInTheDocument(); + + // Row headers should still render. + expect(screen.getByText('blue')).toBeInTheDocument(); + expect(screen.getByText('red')).toBeInTheDocument(); +}); + +test('TableRenderer renders with Sum aggregator', () => { + const props = buildDefaultProps({ + aggregatorName: 'Sum', + vals: ['value'], + }); + renderWithTheme(); + + const cells = screen.getAllByRole('gridcell'); + const cellTexts = cells.map(cell => cell.textContent); + + // Sum of value for blue+circle=10, blue+square=20, red+circle=30, + // red+square=40. Check that at least some of these appear. + expect(cellTexts.some(text => text?.includes('10'))).toBe(true); + expect(cellTexts.some(text => text?.includes('40'))).toBe(true); +}); + +test('TableRenderer applies namesMapping to header labels', () => { + const props = buildDefaultProps({ + namesMapping: { blue: 'Blue Color', red: 'Red Color' }, + }); + renderWithTheme(); + + expect(screen.getByText('Blue Color')).toBeInTheDocument(); + expect(screen.getByText('Red Color')).toBeInTheDocument(); +}); + +test('TableRenderer renders the row attribute label in the header', () => { + const props = buildDefaultProps(); + renderWithTheme(); + + // The row attribute name "color" should appear as an axis label. + const axisLabels = document.querySelectorAll('.pvtAxisLabel'); + const axisLabelTexts = Array.from(axisLabels).map(el => el.textContent); + expect(axisLabelTexts).toContain('color'); +}); + +test('TableRenderer renders the column attribute label in the header', () => { + const props = buildDefaultProps(); + renderWithTheme(); + + // The column attribute name "shape" should appear as an axis label. + const axisLabels = document.querySelectorAll('.pvtAxisLabel'); + const axisLabelTexts = Array.from(axisLabels).map(el => el.textContent); + expect(axisLabelTexts).toContain('shape'); +}); + +test('TableRenderer calls onContextMenu callback', () => { + const onContextMenu = jest.fn(); + const props = buildDefaultProps({ onContextMenu }); + renderWithTheme(); + + const cells = screen.getAllByRole('gridcell'); + expect(cells.length).toBeGreaterThan(0); +}); + +test('TableRenderer renders with multiple row dimensions', () => { + const multiRowData = [ + { country: 'US', city: 'NYC', value: 10 }, + { country: 'US', city: 'LA', value: 20 }, + { country: 'UK', city: 'London', value: 30 }, + ]; + + const props = buildDefaultProps({ + data: multiRowData, + rows: ['country', 'city'], + cols: [], + }); + renderWithTheme(); + + const table = screen.getByRole('grid'); + expect(table).toBeInTheDocument(); + + expect(screen.getByText('US')).toBeInTheDocument(); + expect(screen.getByText('UK')).toBeInTheDocument(); + expect(screen.getByText('NYC')).toBeInTheDocument(); + expect(screen.getByText('LA')).toBeInTheDocument(); + expect(screen.getByText('London')).toBeInTheDocument(); +}); + +test('TableRenderer renders with multiple column dimensions', () => { + const multiColData = [ + { year: '2023', quarter: 'Q1', metric: 5 }, + { year: '2023', quarter: 'Q2', metric: 10 }, + { year: '2024', quarter: 'Q1', metric: 15 }, + ]; + + const props = buildDefaultProps({ + data: multiColData, + rows: [], + cols: ['year', 'quarter'], + }); + renderWithTheme(); + + const table = screen.getByRole('grid'); + expect(table).toBeInTheDocument(); + + expect(screen.getByText('2023')).toBeInTheDocument(); + expect(screen.getByText('2024')).toBeInTheDocument(); + // Q1 appears under both 2023 and 2024, so use getAllByText. + expect(screen.getAllByText('Q1').length).toBeGreaterThanOrEqual(2); + expect(screen.getByText('Q2')).toBeInTheDocument(); +}); + +test('TableRenderer renders value cells with the pvtVal class', () => { + const props = buildDefaultProps(); + renderWithTheme(); + + const valueCells = document.querySelectorAll('.pvtVal'); + // 2 rows x 2 cols = 4 value cells + expect(valueCells.length).toBe(4); +}); + +test('TableRenderer renders correct number of thead and tbody sections', () => { + const props = buildDefaultProps(); + renderWithTheme(); + + const table = screen.getByRole('grid'); + + // The table should have thead and tbody elements. + const theadEl = table.querySelector('thead'); + const tbodyEl = table.querySelector('tbody'); + expect(theadEl).toBeInTheDocument(); + expect(tbodyEl).toBeInTheDocument(); +});