mirror of
https://github.com/apache/superset.git
synced 2026-06-01 05:39:17 +00:00
1537 lines
46 KiB
TypeScript
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: {} };
|