Files
superset2/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx
2026-05-11 10:19:05 -07:00

1537 lines
46 KiB
TypeScript

/**
* 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<string, string>,
pivotData: InstanceType<typeof PivotData>,
) => void;
type HeaderClickCallback = (
e: MouseEvent,
value: string,
filters: Record<string, string>,
pivotData: InstanceType<typeof PivotData>,
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<string, unknown[]>;
cellColorFormatters?: Record<string, ColorFormatters>;
dateFormatters?: Record<string, ((val: unknown) => string) | undefined>;
cellBackgroundColor?: string;
cellTextColor?: string;
activeHeaderBackgroundColor?: string;
}
interface SubtotalDisplay {
displayOnTop: boolean;
enabled?: boolean;
hideOnExpand: boolean;
}
interface SubtotalOptions {
arrowCollapsed?: ReactNode;
arrowExpanded?: ReactNode;
colSubtotalDisplay?: Partial<SubtotalDisplay>;
rowSubtotalDisplay?: Partial<SubtotalDisplay>;
}
interface TableRendererProps {
cols: string[];
rows: string[];
aggregatorName: string;
tableOptions?: TableOptions;
subtotalOptions?: SubtotalOptions;
namesMapping?: Record<string, string>;
onContextMenu: (
e: MouseEvent,
colKey?: string[],
rowKey?: string[],
filters?: Record<string, string>,
) => void;
allowRenderHtml?: boolean;
[key: string]: unknown;
}
interface PivotSettings {
pivotData: InstanceType<typeof PivotData>;
colAttrs: string[];
rowAttrs: string[];
colKeys: string[][];
rowKeys: string[][];
rowTotals: boolean;
colTotals: boolean;
arrowCollapsed: ReactNode;
arrowExpanded: ReactNode;
colSubtotalDisplay: SubtotalDisplay;
rowSubtotalDisplay: SubtotalDisplay;
cellCallbacks: Record<string, Record<string, (e: MouseEvent) => void>>;
rowTotalCallbacks: Record<string, (e: MouseEvent) => void>;
colTotalCallbacks: Record<string, (e: MouseEvent) => void>;
grandTotalCallback: ((e: MouseEvent) => void) | null;
namesMapping: Record<string, string>;
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<HTMLSpanElement>) => void) | null,
value: unknown,
namesMapping: Record<string, string>,
allowRenderHtml?: boolean,
): ReactNode {
const name = namesMapping[String(value)] || value;
const parsedLabel = parseLabel(name);
const labelContent =
allowRenderHtml && typeof parsedLabel === 'string'
? safeHtmlSpan(parsedLabel)
: parsedLabel;
return needToggle ? (
<span className="toggle-wrapper">
<span
role="button"
tabIndex={0}
className="toggle"
onClick={onArrowClick || undefined}
>
{ArrowIcon}
</span>
<span className="toggle-val">{labelContent}</span>
</span>
) : (
labelContent
);
}
export function getCellColor(
keys: string[],
aggValue: string | number | null,
cellColorFormatters: Record<string, ColorFormatters> | 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<string, HierarchicalNode>,
objSort: string,
rowPartialOnTop: boolean | undefined,
): Map<string, unknown> {
// 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<string, unknown>();
sortedKeys.forEach(key => {
const value = obj[key];
if (typeof value === 'object' && !Array.isArray(value)) {
result.set(
key,
sortHierarchicalObject(
value as Record<string, HierarchicalNode>,
objSort,
rowPartialOnTop,
),
);
} else {
result.set(key, value);
}
});
return result;
}
function convertToArray(
obj: Map<string, unknown>,
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<string, unknown>,
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<Record<string, boolean>>(
{},
);
const [collapsedCols, setCollapsedCols] = useState<Record<string, boolean>>(
{},
);
const [sortingOrder, setSortingOrder] = useState<string[]>([]);
const [activeSortColumn, setActiveSortColumn] = useState<number | null>(null);
const [sortedRowKeys, setSortedRowKeys] = useState<string[][] | null>(null);
const sortCacheRef = useRef(new Map<string, string[][]>());
const clickHandler = useCallback(
(
pivotData: InstanceType<typeof PivotData>,
rowValues: string[],
colValues: string[],
) => {
const colAttrs = cols;
const rowAttrs = rows;
const value = pivotData.getAggregator(rowValues, colValues).value();
const filters: Record<string, string> = {};
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<typeof PivotData>,
values: string[],
attrs: string[],
attrIdx: number,
callback: HeaderClickCallback | undefined,
isSubtotal = false,
isGrandTotal = false,
) => {
const filters: Record<string, string> = {};
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<HTMLSpanElement>) => {
// Collapse an entire attribute.
e.stopPropagation();
const keyLen = attrIdx + 1;
const collapsed = allKeys
.filter((k: string[]) => k.length === keyLen)
.map(flatKey);
const updates: Record<string, boolean> = {};
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<HTMLSpanElement>) => {
// 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<string, boolean> = {};
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<HTMLSpanElement>) => {
e.stopPropagation();
setCollapsedRows(state => ({
...state,
[flatRowKey]: !state[flatRowKey],
}));
},
[],
);
const toggleColKey = useCallback(
(flatColKey: string) => (e: MouseEvent<HTMLSpanElement>) => {
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<typeof PivotData>,
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<string, HierarchicalNode> = {};
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<string, HierarchicalNode>, key: string) =>
(acc[key] ??= {}) as Record<string, HierarchicalNode>,
groups,
);
parent[rowKey.at(-1)!] = { currentVal: aggValue as number };
} else {
rowKey.reduce(
(acc: Record<string, HierarchicalNode>, key: string) => {
acc[key] = acc[key] || { currentVal: 0 };
(acc[key] as HierarchicalNode).currentVal = aggValue as number;
return acc[key] as Record<string, HierarchicalNode>;
},
groups,
);
}
});
return groups;
},
[],
);
const sortAndCacheData = useCallback(
(
groups: Record<string, HierarchicalNode>,
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' | 'arrowExpanded'>
> &
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<string, unknown>, {
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<string, (e: MouseEvent) => void>
> = {};
const rowTotalCallbacks: Record<string, (e: MouseEvent) => void> = {};
const colTotalCallbacks: Record<string, (e: MouseEvent) => 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<string, boolean>,
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<typeof PivotData>,
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 ? (
<th
key="padding"
colSpan={settingsRowAttrs.length}
rowSpan={settingsColAttrs.length}
aria-hidden="true"
/>
) : 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 = (
<th key="label" className="pvtAxisLabel">
{displayHeaderCell(
needToggle,
subArrow,
arrowClickHandle,
attrName,
namesMapping,
settingsAllowRenderHtml,
)}
</th>
);
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 (
<ColumnHeightOutlined
onClick={() =>
sortData(
key,
settingsVisibleColKeys,
pivotData,
maxRowIndex,
)
}
/>
);
}
const SortIcon =
sortingOrder[key] === 'asc' ? CaretUpOutlined : CaretDownOutlined;
return (
<SortIcon
onClick={() =>
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(
<th
className={colLabelClass}
key={`colKey-${flatColKey}`}
style={colHeaderStyle}
colSpan={colSpan}
rowSpan={rowSpan}
onClick={clickHeaderHandler(
pivotData,
colKey,
cols,
attrIdx,
tableOptions.clickColumnHeaderCallback,
)}
onContextMenu={handleContextMenu}
>
{displayHeaderCell(
needToggle,
collapsedCols[flatColKey] ? arrowCollapsed : arrowExpanded,
onArrowClick,
headerCellFormattedValue,
namesMapping,
settingsAllowRenderHtml,
)}
<span
role="button"
tabIndex={0}
// Prevents event bubbling to avoid conflict with column header click handlers
// Ensures sort operation executes without triggering cross-filtration
onClick={e => {
e.stopPropagation();
}}
aria-label={
activeSortColumn === i
? `Sorted by ${columnName} ${sortingOrder[i] === 'asc' ? 'ascending' : 'descending'}`
: undefined
}
>
{visibleSortIcon && getSortIcon(i)}
</span>
</th>,
);
} else if (attrIdx === colKey.length) {
const rowSpan = settingsColAttrs.length - colKey.length + rowIncrSpan;
attrValueCells.push(
<th
className={`${colLabelClass} pvtSubtotalLabel`}
key={`colKeyBuffer-${flatKey(colKey)}`}
colSpan={colSpan}
rowSpan={rowSpan}
onClick={clickHeaderHandler(
pivotData,
colKey,
cols,
attrIdx,
tableOptions.clickColumnHeaderCallback,
true,
)}
>
{t('Subtotal')}
</th>,
);
}
// The next colSpan columns will have the same value anyway...
i += colSpan;
}
const totalCell =
attrIdx === 0 && rowTotals ? (
<th
key="total"
className="pvtTotalLabel"
rowSpan={
settingsColAttrs.length + Math.min(settingsRowAttrs.length, 1)
}
onClick={clickHeaderHandler(
pivotData,
[],
cols,
attrIdx,
tableOptions.clickColumnHeaderCallback,
false,
true,
)}
>
{t('Total (%(aggregatorName)s)', {
aggregatorName: t(aggregatorName),
})}
</th>
) : null;
const cells = [spaceCell, attrNameCell, ...attrValueCells, totalCell];
return <tr key={`colAttr-${attrIdx}`}>{cells}</tr>;
},
[
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 (
<tr key="rowHdr">
{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 (
<th className="pvtAxisLabel" key={`rowAttr-${i}`}>
{displayHeaderCell(
needLabelToggle,
subArrow,
arrowClickHandle,
r,
namesMapping,
settingsAllowRenderHtml,
)}
</th>
);
})}
<th
className="pvtTotalLabel"
key="padding"
onClick={clickHeaderHandler(
pivotData,
[],
rows,
0,
tableOptions.clickRowHeaderCallback,
false,
true,
)}
>
{settingsColAttrs.length === 0
? t('Total (%(aggregatorName)s)', {
aggregatorName: t(aggregatorName),
})
: null}
</th>
</tr>
);
},
[
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 (
<th
key={`rowKeyLabel-${i}`}
className={valueCellClassName}
style={rowHeaderStyle}
rowSpan={rowSpan}
colSpan={colSpan}
onClick={clickHeaderHandler(
pivotData,
rowKey,
rows,
i,
tableOptions.clickRowHeaderCallback,
)}
onContextMenu={handleContextMenu}
>
{displayHeaderCell(
needRowToggle,
collapsedRows[flatRowKeySlice] ? arrowCollapsed : arrowExpanded,
onArrowClick,
headerCellFormattedValue,
namesMapping,
settingsAllowRenderHtml,
)}
</th>
);
}
return null;
});
const attrValuePaddingCell =
rowKey.length < settingsRowAttrs.length ? (
<th
className="pvtRowLabel pvtSubtotalLabel"
key="rowKeyBuffer"
colSpan={settingsRowAttrs.length - rowKey.length + colIncrSpan}
rowSpan={1}
onClick={clickHeaderHandler(
pivotData,
rowKey,
rows,
rowKey.length,
tableOptions.clickRowHeaderCallback,
true,
)}
>
{t('Subtotal')}
</th>
) : 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 (
<td
role="gridcell"
className="pvtVal"
key={`pvtVal-${flatColKey}`}
onClick={rowClickHandlers[flatColKey]}
onContextMenu={e => onContextMenu(e, colKey, rowKey)}
style={style}
>
{displayCell(agg.format(aggValue, agg), settingsAllowRenderHtml)}
</td>
);
});
let totalCell = null;
if (rowTotals) {
const agg = pivotData.getAggregator(rowKey, []);
const aggValue = agg.value();
totalCell = (
<td
role="gridcell"
key="total"
className="pvtTotal"
onClick={rowTotalCallbacks[flatRowKey]}
onContextMenu={e => onContextMenu(e, undefined, rowKey)}
>
{displayCell(agg.format(aggValue, agg), settingsAllowRenderHtml)}
</td>
);
}
const rowCells = [
...attrValueCells,
attrValuePaddingCell,
...valueCells,
totalCell,
];
return <tr key={`keyRow-${flatRowKey}`}>{rowCells}</tr>;
},
[
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 = (
<th
key="label"
className="pvtTotalLabel pvtRowTotalLabel"
colSpan={
settingsRowAttrs.length + Math.min(settingsColAttrs.length, 1)
}
onClick={clickHeaderHandler(
pivotData,
[],
rows,
0,
tableOptions.clickRowHeaderCallback,
false,
true,
)}
>
{t('Total (%(aggregatorName)s)', {
aggregatorName: t(aggregatorName),
})}
</th>
);
const totalValueCells = settingsVisibleColKeys.map((colKey: string[]) => {
const flatColKey = flatKey(colKey);
const agg = pivotData.getAggregator([], colKey);
const aggValue = agg.value();
return (
<td
role="gridcell"
className="pvtTotal pvtRowTotal"
key={`total-${flatColKey}`}
onClick={colTotalCallbacks[flatColKey]}
onContextMenu={e => onContextMenu(e, colKey, undefined)}
style={{ padding: '5px' }}
>
{displayCell(agg.format(aggValue, agg), allowRenderHtml)}
</td>
);
});
let grandTotalCell = null;
if (rowTotals) {
const agg = pivotData.getAggregator([], []);
const aggValue = agg.value();
grandTotalCell = (
<td
role="gridcell"
key="total"
className="pvtGrandTotal pvtRowTotal"
onClick={grandTotalCallback || undefined}
onContextMenu={e => onContextMenu(e, undefined, undefined)}
>
{displayCell(agg.format(aggValue, agg), allowRenderHtml)}
</td>
);
}
const totalCells = [totalLabelCell, ...totalValueCells, grandTotalCell];
return (
<tr key="total" className="pvtRowTotals">
{totalCells}
</tr>
);
},
[
clickHeaderHandler,
rows,
tableOptions.clickRowHeaderCallback,
aggregatorName,
onContextMenu,
allowRenderHtml,
],
);
return (
<Styles isDashboardEditMode={isDashboardEditMode()}>
<table className="pvtTable" role="grid">
<thead>
{colAttrs.map((c: string, j: number) =>
renderColHeaderRow(c, j, pivotSettings),
)}
{rowAttrs.length !== 0 && renderRowHeaderRow(pivotSettings)}
</thead>
<tbody>
{visibleRowKeys.map((r: string[], i: number) =>
renderTableRow(r, i, pivotSettings),
)}
{colTotals && renderTotalsRow(pivotSettings)}
</tbody>
</table>
</Styles>
);
}
TableRenderer.propTypes = {
...PivotData.propTypes,
tableOptions: PropTypes.object,
onContextMenu: PropTypes.func,
};
TableRenderer.defaultProps = { ...PivotData.defaultProps, tableOptions: {} };