/** * 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 { 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 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'; type ClickCallback = ( e: MouseEvent, value: unknown, filters: Record, pivotData: InstanceType, ) => void; type HeaderClickCallback = ( e: MouseEvent, value: string, filters: Record, pivotData: InstanceType, isSubtotal: boolean, isGrandTotal: boolean, ) => void; interface TableOptions { rowTotals?: boolean; colTotals?: boolean; rowSubTotals?: boolean; colSubTotals?: boolean; clickCallback?: ClickCallback; clickColumnHeaderCallback?: HeaderClickCallback; clickRowHeaderCallback?: HeaderClickCallback; highlightHeaderCellsOnHover?: boolean; omittedHighlightHeaderGroups?: string[]; highlightedHeaderCells?: Record; cellColorFormatters?: Record; dateFormatters?: Record string) | undefined>; cellBackgroundColor?: string; cellTextColor?: string; activeHeaderBackgroundColor?: string; } interface SubtotalDisplay { displayOnTop: boolean; enabled?: boolean; hideOnExpand: boolean; } interface SubtotalOptions { arrowCollapsed?: ReactNode; arrowExpanded?: ReactNode; colSubtotalDisplay?: Partial; rowSubtotalDisplay?: Partial; } interface TableRendererProps { cols: string[]; rows: string[]; aggregatorName: string; tableOptions?: TableOptions; subtotalOptions?: SubtotalOptions; namesMapping?: Record; onContextMenu: ( e: MouseEvent, colKey?: string[], rowKey?: string[], filters?: Record, ) => void; allowRenderHtml?: boolean; [key: string]: unknown; } interface PivotSettings { pivotData: InstanceType; colAttrs: string[]; rowAttrs: string[]; colKeys: string[][]; rowKeys: string[][]; rowTotals: boolean; colTotals: boolean; arrowCollapsed: ReactNode; arrowExpanded: ReactNode; colSubtotalDisplay: SubtotalDisplay; rowSubtotalDisplay: SubtotalDisplay; cellCallbacks: Record void>>; rowTotalCallbacks: Record void>; colTotalCallbacks: Record void>; grandTotalCallback: ((e: MouseEvent) => void) | null; namesMapping: Record; allowRenderHtml?: boolean; visibleRowKeys?: string[][]; visibleColKeys?: string[][]; maxRowVisible?: number; maxColVisible?: number; rowAttrSpans?: number[][]; colAttrSpans?: number[][]; } const parseLabel = (value: unknown): string | number => { if (typeof value === 'string') { if (value === 'metric') return t('metric'); return value; } if (typeof value === 'number') { return value; } return String(value); }; function displayCell(value: unknown, allowRenderHtml?: boolean): ReactNode { if (allowRenderHtml && typeof value === 'string') { return safeHtmlSpan(value); } return parseLabel(value); } function displayHeaderCell( needToggle: boolean, ArrowIcon: ReactNode, onArrowClick: ((e: MouseEvent) => void) | null, value: unknown, namesMapping: Record, allowRenderHtml?: boolean, ): ReactNode { const name = namesMapping[String(value)] || value; const parsedLabel = parseLabel(name); const labelContent = allowRenderHtml && typeof parsedLabel === 'string' ? safeHtmlSpan(parsedLabel) : parsedLabel; return needToggle ? ( {ArrowIcon} {labelContent} ) : ( labelContent ); } 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; } function sortHierarchicalObject( obj: Record, objSort: string, rowPartialOnTop: boolean | undefined, ): Map { // Performs a recursive sort of nested object structures. Sorts objects based on // their currentVal property. The function preserves the hierarchical structure // while sorting each level according to the specified criteria. const sortedKeys = Object.keys(obj).sort((a, b) => { const valA = obj[a].currentVal || 0; const valB = obj[b].currentVal || 0; if (rowPartialOnTop) { if (obj[a].currentVal !== undefined && obj[b].currentVal === undefined) { return -1; } if (obj[b].currentVal !== undefined && obj[a].currentVal === undefined) { return 1; } } return objSort === 'asc' ? valA - valB : valB - valA; }); const result = new Map(); sortedKeys.forEach(key => { const value = obj[key]; if (typeof value === 'object' && !Array.isArray(value)) { result.set( key, sortHierarchicalObject( value as Record, objSort, rowPartialOnTop, ), ); } else { result.set(key, value); } }); return result; } function convertToArray( obj: Map, rowEnabled: boolean | undefined, rowPartialOnTop: boolean | undefined, maxRowIndex: number, parentKeys: string[] = [], result: string[][] = [], flag = false, ): string[][] { // Recursively flattens a hierarchical Map structure into an array of key paths. // Handles different rendering scenarios based on row grouping configurations and // depth limitations. The function supports complex hierarchy flattening with let updatedFlag = flag; const keys = Array.from(obj.keys()); const getValue = (key: string) => obj.get(key); keys.forEach(key => { if (key === 'currentVal') { return; } const value = getValue(key); if (rowEnabled && rowPartialOnTop && parentKeys.length < maxRowIndex - 1) { result.push(parentKeys.length > 0 ? [...parentKeys, key] : [key]); updatedFlag = true; } if (typeof value === 'object' && value !== null && !Array.isArray(value)) { convertToArray( value as Map, rowEnabled, rowPartialOnTop, maxRowIndex, [...parentKeys, key], result, ); } if ( parentKeys.length >= maxRowIndex - 1 || (rowEnabled && !rowPartialOnTop) ) { if (!updatedFlag) { result.push(parentKeys.length > 0 ? [...parentKeys, key] : [key]); return; } } if (parentKeys.length === 0 && maxRowIndex === 1) { result.push([key]); } }); return result; } export function TableRenderer(props: TableRendererProps) { // Use the original props argument directly rather than spreading/re-memoizing. // Spreading `...rest` into a memoized object produces a new reference every // render, which defeats downstream memos and forces `getBasePivotSettings` // (and a fresh `PivotData`) to recompute on every state update. const { cols, rows, aggregatorName, tableOptions = {}, subtotalOptions, namesMapping: namesMappingProp, onContextMenu, allowRenderHtml, } = props; const [collapsedRows, setCollapsedRows] = useState>( {}, ); const [collapsedCols, setCollapsedCols] = useState>( {}, ); const [sortingOrder, setSortingOrder] = useState([]); const [activeSortColumn, setActiveSortColumn] = useState(null); const [sortedRowKeys, setSortedRowKeys] = useState(null); const sortCacheRef = useRef(new Map()); 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]; } } 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 } = tableOptions; return (e: MouseEvent) => clickCallback?.(e, value, filters, pivotData); }, [cols, rows, tableOptions], ); 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]; const value = values[i]; // Subtotal/grand-total handlers may pass an empty `values` array, so // guard against undefined entries rather than leaking them through. if (attr != null && value != null) { filters[attr] = value; } } 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(); setCollapsedRows(state => ({ ...state, [flatRowKey]: !state[flatRowKey], })); }, [], ); const toggleColKey = useCallback( (flatColKey: string) => (e: MouseEvent) => { e.stopPropagation(); setCollapsedCols(state => ({ ...state, [flatColKey]: !state[flatColKey], })); }, [], ); 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 // -1's for repeated values and the span number otherwise. const spans = []; // Index of the last new value const li = Array(numAttrs).map(() => 0); let lv: (string | null)[] = Array(numAttrs).map(() => null); for (let i = 0; i < attrArr.length; i += 1) { // Keep increasing span values as long as the last keys are the same. For // the rest, record spans of 1. Update the indices too. const cv = attrArr[i]; const ent = []; let depth = 0; const limit = Math.min(lv.length, cv.length); while (depth < limit && lv[depth] === cv[depth]) { ent.push(-1); spans[li[depth]][depth] += 1; depth += 1; } while (depth < cv.length) { li[depth] = i; ent.push(1); depth += 1; } spans.push(ent); lv = cv; } return spans; }, []); 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, 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, }); 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 (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, ); }); }); // 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, [], []); } } 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( () => getBasePivotSettings(), [getBasePivotSettings], ); // Reset sort state and cache when structural props change. Scoping this to // an effect (instead of running inside the memo) prevents the cache from // being wiped on unrelated state updates like collapse/expand. useEffect(() => { sortCacheRef.current.clear(); 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) { newDirection = sortingOrder[columnIndex] === 'asc' ? 'desc' : 'asc'; } const { rowEnabled, rowPartialOnTop } = pivotData.subtotals as { rowEnabled?: boolean; rowPartialOnTop?: boolean; }; newSortingOrder[columnIndex] = newDirection; // Include the target column's flat key so different visible-column sets // with the same length don't collide in the cache. const sortTargetKey = flatKey(visColKeys[columnIndex] ?? []); const cacheKey = `${columnIndex}-${visColKeys.length}-${sortTargetKey}-${rowEnabled}-${rowPartialOnTop}-${newDirection}`; let newRowKeys; if (sortCacheRef.current.has(cacheKey)) { const cachedRowKeys = sortCacheRef.current.get(cacheKey); newRowKeys = cachedRowKeys; } else { const groups = getAggregatedData( pivotData, visColKeys[columnIndex], rowPartialOnTop, ); const computedSortedRowKeys = sortAndCacheData( groups, newDirection, rowEnabled, rowPartialOnTop, maxRowIndex, ); sortCacheRef.current.set(cacheKey, computedSortedRowKeys); newRowKeys = computedSortedRowKeys; } setSortedRowKeys(newRowKeys!); setSortingOrder(newSortingOrder); setActiveSortColumn(columnIndex); }, [activeSortColumn, sortingOrder, getAggregatedData, sortAndCacheData], ); 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: settingsRowAttrs, colAttrs: settingsColAttrs, colKeys: settingsColKeys, visibleColKeys: settingsVisibleColKeys, colAttrSpans, rowTotals, arrowExpanded, arrowCollapsed, colSubtotalDisplay: settingsColSubtotalDisplay, maxColVisible, pivotData, namesMapping, allowRenderHtml: settingsAllowRenderHtml, } = settings; const { highlightHeaderCellsOnHover, omittedHighlightHeaderGroups = [], highlightedHeaderCells, cellColorFormatters, dateFormatters, cellBackgroundColor = supersetTheme.colorBgBase, activeHeaderBackgroundColor = supersetTheme.colorPrimaryBg, } = tableOptions; if (!settingsVisibleColKeys || !colAttrSpans) { return null; } const spaceCell = attrIdx === 0 && settingsRowAttrs.length !== 0 ? ( ) : null; 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 = 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) } /> ); }; // Coerce numeric timestamp strings to numbers so temporal formatters // (which typically expect an epoch) render correctly. const rawHeaderCellValue = colKey[attrIdx]; const headerCellFormatterValue = typeof rawHeaderCellValue === 'string' && rawHeaderCellValue.trim() !== '' && Number.isFinite(Number(rawHeaderCellValue)) ? Number(rawHeaderCellValue) : rawHeaderCellValue; const headerCellFormattedValue = dateFormatters?.[attrName]?.(headerCellFormatterValue) ?? rawHeaderCellValue; const isActiveHeader = colLabelClass.includes('active'); const { backgroundColor, color } = getCellColor( [attrName], headerCellFormattedValue, cellColorFormatters, isActiveHeader ? activeHeaderBackgroundColor : cellBackgroundColor, ); const colHeaderStyle = { backgroundColor, ...(color ? { color } : {}), }; 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, cellBackgroundColor = supersetTheme.colorBgBase, cellTextColor = supersetTheme.colorPrimaryText, activeHeaderBackgroundColor = supersetTheme.colorPrimaryBg, } = 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) { valueCellClassName += ' hoverable'; } handleContextMenu = (e: MouseEvent) => onContextMenu(e, undefined, rowKey, { [settingsRowAttrs[i]]: r, }); } if ( highlightedHeaderCells && Array.isArray(highlightedHeaderCells[settingsRowAttrs[i]]) && highlightedHeaderCells[settingsRowAttrs[i]].includes(r) ) { valueCellClassName += ' active'; } 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; // Coerce numeric timestamp strings to numbers so temporal formatters // (which typically expect an epoch) render correctly. const headerFormatterValue = typeof r === 'string' && r.trim() !== '' && Number.isFinite(Number(r)) ? Number(r) : r; const headerCellFormattedValue = dateFormatters?.[settingsRowAttrs[i]]?.(headerFormatterValue) ?? r; const isActiveHeader = valueCellClassName.includes('active'); const { backgroundColor, color } = getCellColor( [settingsRowAttrs[i]], headerCellFormattedValue, cellColorFormatters, isActiveHeader ? activeHeaderBackgroundColor : cellBackgroundColor, ); const rowHeaderStyle = { backgroundColor, ...(color ? { color } : {}), }; return ( {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]; const { backgroundColor, color } = getCellColor( keys, aggValue, cellColorFormatters, cellBackgroundColor, ); const style = agg.isSubtotal ? { backgroundColor, fontWeight: 'bold', color: color ?? cellTextColor, } : { backgroundColor, color: color ?? cellTextColor }; 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)} ); } 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(aggregatorName), })} ); const totalValueCells = settingsVisibleColKeys.map((colKey: string[]) => { const flatColKey = flatKey(colKey); const agg = pivotData.getAggregator([], colKey); const aggValue = agg.value(); return ( onContextMenu(e, colKey, undefined)} style={{ padding: '5px' }} > {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)} ); } const totalCells = [totalLabelCell, ...totalValueCells, grandTotalCell]; return ( {totalCells} ); }, [ clickHeaderHandler, rows, tableOptions.clickRowHeaderCallback, aggregatorName, onContextMenu, allowRenderHtml, ], ); return ( {colAttrs.map((c: string, j: number) => renderColHeaderRow(c, j, pivotSettings), )} {rowAttrs.length !== 0 && renderRowHeaderRow(pivotSettings)} {visibleRowKeys.map((r: string[], i: number) => renderTableRow(r, i, pivotSettings), )} {colTotals && renderTotalsRow(pivotSettings)}
); } TableRenderer.propTypes = { ...PivotData.propTypes, tableOptions: PropTypes.object, onContextMenu: PropTypes.func, }; TableRenderer.defaultProps = { ...PivotData.defaultProps, tableOptions: {} };