superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx (906 lines of code) (raw):
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
CSSProperties,
useCallback,
useLayoutEffect,
useMemo,
useState,
MouseEvent,
KeyboardEvent as ReactKeyboardEvent,
} from 'react';
import {
ColumnInstance,
ColumnWithLooseAccessor,
DefaultSortTypes,
Row,
} from 'react-table';
import { extent as d3Extent, max as d3Max } from 'd3-array';
import { FaSort } from '@react-icons/all-files/fa/FaSort';
import { FaSortDown as FaSortDesc } from '@react-icons/all-files/fa/FaSortDown';
import { FaSortUp as FaSortAsc } from '@react-icons/all-files/fa/FaSortUp';
import cx from 'classnames';
import {
DataRecord,
DataRecordValue,
DTTM_ALIAS,
ensureIsArray,
GenericDataType,
getSelectedText,
getTimeFormatterForGranularity,
BinaryQueryObjectFilterClause,
styled,
css,
t,
tn,
useTheme,
} from '@superset-ui/core';
import { Dropdown, Menu, Tooltip } from '@superset-ui/chart-controls';
import {
CheckOutlined,
InfoCircleOutlined,
DownOutlined,
MinusCircleOutlined,
PlusCircleOutlined,
TableOutlined,
} from '@ant-design/icons';
import { isEmpty } from 'lodash';
import {
ColorSchemeEnum,
DataColumnMeta,
TableChartTransformedProps,
} from './types';
import DataTable, {
DataTableProps,
SearchInputProps,
SelectPageSizeRendererProps,
SizeOption,
} from './DataTable';
import Styles from './Styles';
import { formatColumnValue } from './utils/formatValue';
import { PAGE_SIZE_OPTIONS } from './consts';
import { updateExternalFormData } from './DataTable/utils/externalAPIs';
import getScrollBarSize from './DataTable/utils/getScrollBarSize';
type ValueRange = [number, number];
interface TableSize {
width: number;
height: number;
}
const ACTION_KEYS = {
enter: 'Enter',
spacebar: 'Spacebar',
space: ' ',
};
/**
* Return sortType based on data type
*/
function getSortTypeByDataType(dataType: GenericDataType): DefaultSortTypes {
if (dataType === GenericDataType.Temporal) {
return 'datetime';
}
if (dataType === GenericDataType.String) {
return 'alphanumeric';
}
return 'basic';
}
/**
* Cell background width calculation for horizontal bar chart
*/
function cellWidth({
value,
valueRange,
alignPositiveNegative,
}: {
value: number;
valueRange: ValueRange;
alignPositiveNegative: boolean;
}) {
const [minValue, maxValue] = valueRange;
if (alignPositiveNegative) {
const perc = Math.abs(Math.round((value / maxValue) * 100));
return perc;
}
const posExtent = Math.abs(Math.max(maxValue, 0));
const negExtent = Math.abs(Math.min(minValue, 0));
const tot = posExtent + negExtent;
const perc2 = Math.round((Math.abs(value) / tot) * 100);
return perc2;
}
/**
* Cell left margin (offset) calculation for horizontal bar chart elements
* when alignPositiveNegative is not set
*/
function cellOffset({
value,
valueRange,
alignPositiveNegative,
}: {
value: number;
valueRange: ValueRange;
alignPositiveNegative: boolean;
}) {
if (alignPositiveNegative) {
return 0;
}
const [minValue, maxValue] = valueRange;
const posExtent = Math.abs(Math.max(maxValue, 0));
const negExtent = Math.abs(Math.min(minValue, 0));
const tot = posExtent + negExtent;
return Math.round((Math.min(negExtent + value, negExtent) / tot) * 100);
}
/**
* Cell background color calculation for horizontal bar chart
*/
function cellBackground({
value,
colorPositiveNegative = false,
}: {
value: number;
colorPositiveNegative: boolean;
}) {
const r = colorPositiveNegative && value < 0 ? 150 : 0;
return `rgba(${r},0,0,0.2)`;
}
function SortIcon<D extends object>({ column }: { column: ColumnInstance<D> }) {
const { isSorted, isSortedDesc } = column;
let sortIcon = <FaSort />;
if (isSorted) {
sortIcon = isSortedDesc ? <FaSortDesc /> : <FaSortAsc />;
}
return sortIcon;
}
function SearchInput({ count, value, onChange }: SearchInputProps) {
return (
<span className="dt-global-filter">
{t('Search')}{' '}
<input
aria-label={t('Search %s records', count)}
className="form-control input-sm"
placeholder={tn('search.num_records', count)}
value={value}
onChange={onChange}
/>
</span>
);
}
function SelectPageSize({
options,
current,
onChange,
}: SelectPageSizeRendererProps) {
return (
<span
className="dt-select-page-size form-inline"
role="group"
aria-label={t('Select page size')}
>
<label htmlFor="pageSizeSelect" className="sr-only">
{t('Select page size')}
</label>
{t('Show')}{' '}
<select
id="pageSizeSelect"
className="form-control input-sm"
value={current}
onChange={e => {
onChange(Number((e.target as HTMLSelectElement).value));
}}
aria-label={t('Show entries per page')}
>
{options.map(option => {
const [size, text] = Array.isArray(option)
? option
: [option, option];
return (
<option key={size} value={size}>
{text}
</option>
);
})}
</select>{' '}
{t('entries per page')}
</span>
);
}
const getNoResultsMessage = (filter: string) =>
filter ? t('No matching records found') : t('No records found');
export default function TableChart<D extends DataRecord = DataRecord>(
props: TableChartTransformedProps<D> & {
sticky?: DataTableProps<D>['sticky'];
},
) {
const {
timeGrain,
height,
width,
data,
totals,
isRawRecords,
rowCount = 0,
columns: columnsMeta,
alignPositiveNegative: defaultAlignPN = false,
colorPositiveNegative: defaultColorPN = false,
includeSearch = false,
pageSize = 0,
serverPagination = false,
serverPaginationData,
setDataMask,
showCellBars = true,
sortDesc = false,
filters,
sticky = true, // whether to use sticky header
columnColorFormatters,
allowRearrangeColumns = false,
allowRenderHtml = true,
onContextMenu,
emitCrossFilters,
isUsingTimeComparison,
basicColorFormatters,
basicColorColumnFormatters,
} = props;
const comparisonColumns = [
{ key: 'all', label: t('Display all') },
{ key: '#', label: '#' },
{ key: '△', label: '△' },
{ key: '%', label: '%' },
];
const timestampFormatter = useCallback(
value => getTimeFormatterForGranularity(timeGrain)(value),
[timeGrain],
);
const [tableSize, setTableSize] = useState<TableSize>({
width: 0,
height: 0,
});
// keep track of whether column order changed, so that column widths can too
const [columnOrderToggle, setColumnOrderToggle] = useState(false);
const [showComparisonDropdown, setShowComparisonDropdown] = useState(false);
const [selectedComparisonColumns, setSelectedComparisonColumns] = useState([
comparisonColumns[0].key,
]);
const [hideComparisonKeys, setHideComparisonKeys] = useState<string[]>([]);
const theme = useTheme();
// only take relevant page size options
const pageSizeOptions = useMemo(() => {
const getServerPagination = (n: number) => n <= rowCount;
return PAGE_SIZE_OPTIONS.filter(([n]) =>
serverPagination ? getServerPagination(n) : n <= 2 * data.length,
) as SizeOption[];
}, [data.length, rowCount, serverPagination]);
const getValueRange = useCallback(
function getValueRange(key: string, alignPositiveNegative: boolean) {
if (typeof data?.[0]?.[key] === 'number') {
const nums = data.map(row => row[key]) as number[];
return (
alignPositiveNegative
? [0, d3Max(nums.map(Math.abs))]
: d3Extent(nums)
) as ValueRange;
}
return null;
},
[data],
);
const isActiveFilterValue = useCallback(
function isActiveFilterValue(key: string, val: DataRecordValue) {
return !!filters && filters[key]?.includes(val);
},
[filters],
);
const getCrossFilterDataMask = (key: string, value: DataRecordValue) => {
let updatedFilters = { ...(filters || {}) };
if (filters && isActiveFilterValue(key, value)) {
updatedFilters = {};
} else {
updatedFilters = {
[key]: [value],
};
}
if (
Array.isArray(updatedFilters[key]) &&
updatedFilters[key].length === 0
) {
delete updatedFilters[key];
}
const groupBy = Object.keys(updatedFilters);
const groupByValues = Object.values(updatedFilters);
const labelElements: string[] = [];
groupBy.forEach(col => {
const isTimestamp = col === DTTM_ALIAS;
const filterValues = ensureIsArray(updatedFilters?.[col]);
if (filterValues.length) {
const valueLabels = filterValues.map(value =>
isTimestamp ? timestampFormatter(value) : value,
);
labelElements.push(`${valueLabels.join(', ')}`);
}
});
return {
dataMask: {
extraFormData: {
filters:
groupBy.length === 0
? []
: groupBy.map(col => {
const val = ensureIsArray(updatedFilters?.[col]);
if (!val.length)
return {
col,
op: 'IS NULL' as const,
};
return {
col,
op: 'IN' as const,
val: val.map(el =>
el instanceof Date ? el.getTime() : el!,
),
grain: col === DTTM_ALIAS ? timeGrain : undefined,
};
}),
},
filterState: {
label: labelElements.join(', '),
value: groupByValues.length ? groupByValues : null,
filters:
updatedFilters && Object.keys(updatedFilters).length
? updatedFilters
: null,
},
},
isCurrentValueSelected: isActiveFilterValue(key, value),
};
};
const toggleFilter = useCallback(
function toggleFilter(key: string, val: DataRecordValue) {
if (!emitCrossFilters) {
return;
}
setDataMask(getCrossFilterDataMask(key, val).dataMask);
},
[emitCrossFilters, getCrossFilterDataMask, setDataMask],
);
const getSharedStyle = (column: DataColumnMeta): CSSProperties => {
const { isNumeric, config = {} } = column;
const textAlign =
config.horizontalAlign ||
(isNumeric && !isUsingTimeComparison ? 'right' : 'left');
return {
textAlign,
};
};
const comparisonLabels = [t('Main'), '#', '△', '%'];
const filteredColumnsMeta = useMemo(() => {
if (!isUsingTimeComparison) {
return columnsMeta;
}
const allColumns = comparisonColumns[0].key;
const main = comparisonLabels[0];
const showAllColumns = selectedComparisonColumns.includes(allColumns);
return columnsMeta.filter(({ label, key }) => {
// Extract the key portion after the space, assuming the format is always "label key"
const keyPortion = key.substring(label.length);
const isKeyHidded = hideComparisonKeys.includes(keyPortion);
const isLableMain = label === main;
return (
isLableMain ||
(!isKeyHidded &&
(!comparisonLabels.includes(label) ||
showAllColumns ||
selectedComparisonColumns.includes(label)))
);
});
}, [
columnsMeta,
comparisonColumns,
comparisonLabels,
isUsingTimeComparison,
hideComparisonKeys,
selectedComparisonColumns,
]);
const handleContextMenu =
onContextMenu && !isRawRecords
? (
value: D,
cellPoint: {
key: string;
value: DataRecordValue;
isMetric?: boolean;
},
clientX: number,
clientY: number,
) => {
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
filteredColumnsMeta.forEach(col => {
if (!col.isMetric) {
const dataRecordValue = value[col.key];
drillToDetailFilters.push({
col: col.key,
op: '==',
val: dataRecordValue as string | number | boolean,
formattedVal: formatColumnValue(col, dataRecordValue)[1],
});
}
});
onContextMenu(clientX, clientY, {
drillToDetail: drillToDetailFilters,
crossFilter: cellPoint.isMetric
? undefined
: getCrossFilterDataMask(cellPoint.key, cellPoint.value),
drillBy: cellPoint.isMetric
? undefined
: {
filters: [
{
col: cellPoint.key,
op: '==',
val: cellPoint.value as string | number | boolean,
},
],
groupbyFieldName: 'groupby',
},
});
}
: undefined;
const getHeaderColumns = (
columnsMeta: DataColumnMeta[],
enableTimeComparison?: boolean,
) => {
const resultMap: Record<string, number[]> = {};
if (!enableTimeComparison) {
return resultMap;
}
columnsMeta.forEach((element, index) => {
// Check if element's label is one of the comparison labels
if (comparisonLabels.includes(element.label)) {
// Extract the key portion after the space, assuming the format is always "label key"
const keyPortion = element.key.substring(element.label.length);
// If the key portion is not in the map, initialize it with the current index
if (!resultMap[keyPortion]) {
resultMap[keyPortion] = [index];
} else {
// Add the index to the existing array
resultMap[keyPortion].push(index);
}
}
});
return resultMap;
};
const renderTimeComparisonDropdown = (): JSX.Element => {
const allKey = comparisonColumns[0].key;
const handleOnClick = (data: any) => {
const { key } = data;
// Toggle 'All' key selection
if (key === allKey) {
setSelectedComparisonColumns([allKey]);
} else if (selectedComparisonColumns.includes(allKey)) {
setSelectedComparisonColumns([key]);
} else {
// Toggle selection for other keys
setSelectedComparisonColumns(
selectedComparisonColumns.includes(key)
? selectedComparisonColumns.filter(k => k !== key) // Deselect if already selected
: [...selectedComparisonColumns, key],
); // Select if not already selected
}
};
const handleOnBlur = () => {
if (selectedComparisonColumns.length === 3) {
setSelectedComparisonColumns([comparisonColumns[0].key]);
}
};
return (
<Dropdown
placement="bottomRight"
visible={showComparisonDropdown}
onVisibleChange={(flag: boolean) => {
setShowComparisonDropdown(flag);
}}
overlay={
<Menu
multiple
onClick={handleOnClick}
onBlur={handleOnBlur}
selectedKeys={selectedComparisonColumns}
>
<div
css={css`
max-width: 242px;
padding: 0 ${theme.gridUnit * 2}px;
color: ${theme.colors.grayscale.base};
font-size: ${theme.typography.sizes.s}px;
`}
>
{t(
'Select columns that will be displayed in the table. You can multiselect columns.',
)}
</div>
{comparisonColumns.map(column => (
<Menu.Item key={column.key}>
<span
css={css`
color: ${theme.colors.grayscale.dark2};
`}
>
{column.label}
</span>
<span
css={css`
float: right;
font-size: ${theme.typography.sizes.s}px;
`}
>
{selectedComparisonColumns.includes(column.key) && (
<CheckOutlined />
)}
</span>
</Menu.Item>
))}
</Menu>
}
trigger={['click']}
>
<span>
<TableOutlined /> <DownOutlined />
</span>
</Dropdown>
);
};
const renderGroupingHeaders = (): JSX.Element => {
// TODO: Make use of ColumnGroup to render the aditional headers
const headers: any = [];
let currentColumnIndex = 0;
Object.entries(groupHeaderColumns || {}).forEach(([key, value]) => {
// Calculate the number of placeholder columns needed before the current header
const startPosition = value[0];
const colSpan = value.length;
// Retrieve the originalLabel from the first column in this group
const originalLabel = columnsMeta[value[0]]?.originalLabel || key;
// Add placeholder <th> for columns before this header
for (let i = currentColumnIndex; i < startPosition; i += 1) {
headers.push(
<th
key={`placeholder-${i}`}
style={{ borderBottom: 0 }}
aria-label={`Header-${i}`}
/>,
);
}
// Add the current header <th>
headers.push(
<th key={`header-${key}`} colSpan={colSpan} style={{ borderBottom: 0 }}>
{originalLabel}
<span
css={css`
float: right;
& svg {
color: ${theme.colors.grayscale.base} !important;
}
`}
>
{hideComparisonKeys.includes(key) ? (
<PlusCircleOutlined
onClick={() =>
setHideComparisonKeys(
hideComparisonKeys.filter(k => k !== key),
)
}
/>
) : (
<MinusCircleOutlined
onClick={() =>
setHideComparisonKeys([...hideComparisonKeys, key])
}
/>
)}
</span>
</th>,
);
// Update the current column index
currentColumnIndex = startPosition + colSpan;
});
return (
<tr
css={css`
th {
border-right: 2px solid ${theme.colors.grayscale.light2};
}
th:first-child {
border-left: none;
}
th:last-child {
border-right: none;
}
`}
>
{headers}
</tr>
);
};
const groupHeaderColumns = useMemo(
() => getHeaderColumns(filteredColumnsMeta, isUsingTimeComparison),
[filteredColumnsMeta, isUsingTimeComparison],
);
const getColumnConfigs = useCallback(
(column: DataColumnMeta, i: number): ColumnWithLooseAccessor<D> => {
const {
key,
label: originalLabel,
isNumeric,
dataType,
isMetric,
isPercentMetric,
config = {},
} = column;
const label = config.customColumnName || originalLabel;
let displayLabel = label;
const isComparisonColumn = ['#', '△', '%', t('Main')].includes(
column.label,
);
if (isComparisonColumn) {
if (column.label === t('Main')) {
displayLabel = config.customColumnName || column.originalLabel || '';
} else if (config.customColumnName) {
displayLabel =
config.displayTypeIcon !== false
? `${column.label} ${config.customColumnName}`
: config.customColumnName;
} else if (config.displayTypeIcon === false) {
displayLabel = '';
}
}
const columnWidth = Number.isNaN(Number(config.columnWidth))
? config.columnWidth
: Number(config.columnWidth);
// inline style for both th and td cell
const sharedStyle: CSSProperties = getSharedStyle(column);
const alignPositiveNegative =
config.alignPositiveNegative === undefined
? defaultAlignPN
: config.alignPositiveNegative;
const colorPositiveNegative =
config.colorPositiveNegative === undefined
? defaultColorPN
: config.colorPositiveNegative;
const { truncateLongCells } = config;
const hasColumnColorFormatters =
isNumeric &&
Array.isArray(columnColorFormatters) &&
columnColorFormatters.length > 0;
const hasBasicColorFormatters =
isUsingTimeComparison &&
Array.isArray(basicColorFormatters) &&
basicColorFormatters.length > 0;
const valueRange =
!hasBasicColorFormatters &&
!hasColumnColorFormatters &&
(config.showCellBars === undefined
? showCellBars
: config.showCellBars) &&
(isMetric || isRawRecords || isPercentMetric) &&
getValueRange(key, alignPositiveNegative);
let className = '';
if (emitCrossFilters && !isMetric) {
className += ' dt-is-filter';
}
if (!isMetric && !isPercentMetric) {
className += ' right-border-only';
} else if (comparisonLabels.includes(label)) {
const groupinHeader = key.substring(label.length);
const columnsUnderHeader = groupHeaderColumns[groupinHeader] || [];
if (i === columnsUnderHeader[columnsUnderHeader.length - 1]) {
className += ' right-border-only';
}
}
return {
id: String(i), // to allow duplicate column keys
// must use custom accessor to allow `.` in column names
// typing is incorrect in current version of `@types/react-table`
// so we ask TS not to check.
accessor: ((datum: D) => datum[key]) as never,
Cell: ({ value, row }: { value: DataRecordValue; row: Row<D> }) => {
const [isHtml, text] = formatColumnValue(column, value);
const html = isHtml && allowRenderHtml ? { __html: text } : undefined;
let backgroundColor;
let arrow = '';
const originKey = column.key.substring(column.label.length).trim();
if (!hasColumnColorFormatters && hasBasicColorFormatters) {
backgroundColor =
basicColorFormatters[row.index][originKey]?.backgroundColor;
arrow =
column.label === comparisonLabels[0]
? basicColorFormatters[row.index][originKey]?.mainArrow
: '';
}
if (hasColumnColorFormatters) {
columnColorFormatters!
.filter(formatter => formatter.column === column.key)
.forEach(formatter => {
const formatterResult =
value || value === 0
? formatter.getColorFromValue(value as number)
: false;
if (formatterResult) {
backgroundColor = formatterResult;
}
});
}
if (
basicColorColumnFormatters &&
basicColorColumnFormatters?.length > 0
) {
backgroundColor =
basicColorColumnFormatters[row.index][column.key]
?.backgroundColor || backgroundColor;
arrow =
column.label === comparisonLabels[0]
? basicColorColumnFormatters[row.index][column.key]?.mainArrow
: '';
}
const StyledCell = styled.td`
text-align: ${sharedStyle.textAlign};
white-space: ${value instanceof Date ? 'nowrap' : undefined};
position: relative;
background: ${backgroundColor || undefined};
padding-left: ${column.isChildColumn
? `${theme.gridUnit * 5}px`
: `${theme.gridUnit}px`};
`;
const cellBarStyles = css`
position: absolute;
height: 100%;
display: block;
top: 0;
${valueRange &&
`
width: ${`${cellWidth({
value: value as number,
valueRange,
alignPositiveNegative,
})}%`};
left: ${`${cellOffset({
value: value as number,
valueRange,
alignPositiveNegative,
})}%`};
background-color: ${cellBackground({
value: value as number,
colorPositiveNegative,
})};
`}
`;
let arrowStyles = css`
color: ${basicColorFormatters &&
basicColorFormatters[row.index][originKey]?.arrowColor ===
ColorSchemeEnum.Green
? theme.colors.success.base
: theme.colors.error.base};
margin-right: ${theme.gridUnit}px;
`;
if (
basicColorColumnFormatters &&
basicColorColumnFormatters?.length > 0
) {
arrowStyles = css`
color: ${basicColorColumnFormatters[row.index][column.key]
?.arrowColor === ColorSchemeEnum.Green
? theme.colors.success.base
: theme.colors.error.base};
margin-right: ${theme.gridUnit}px;
`;
}
const cellProps = {
'aria-labelledby': `header-${column.key}`,
role: 'cell',
// show raw number in title in case of numeric values
title: typeof value === 'number' ? String(value) : undefined,
onClick:
emitCrossFilters && !valueRange && !isMetric
? () => {
// allow selecting text in a cell
if (!getSelectedText()) {
toggleFilter(key, value);
}
}
: undefined,
onContextMenu: (e: MouseEvent) => {
if (handleContextMenu) {
e.preventDefault();
e.stopPropagation();
handleContextMenu(
row.original,
{ key, value, isMetric },
e.nativeEvent.clientX,
e.nativeEvent.clientY,
);
}
},
className: [
className,
value == null ? 'dt-is-null' : '',
isActiveFilterValue(key, value) ? ' dt-is-active-filter' : '',
].join(' '),
tabIndex: 0,
};
if (html) {
if (truncateLongCells) {
// eslint-disable-next-line react/no-danger
return (
<StyledCell {...cellProps}>
<div
className="dt-truncate-cell"
style={columnWidth ? { width: columnWidth } : undefined}
dangerouslySetInnerHTML={html}
/>
</StyledCell>
);
}
// eslint-disable-next-line react/no-danger
return <StyledCell {...cellProps} dangerouslySetInnerHTML={html} />;
}
// If cellProps renders textContent already, then we don't have to
// render `Cell`. This saves some time for large tables.
return (
<StyledCell {...cellProps}>
{valueRange && (
<div
/* The following classes are added to support custom CSS styling */
className={cx(
'cell-bar',
typeof value === 'number' && value < 0
? 'negative'
: 'positive',
)}
css={cellBarStyles}
role="presentation"
/>
)}
{truncateLongCells ? (
<div
className="dt-truncate-cell"
style={columnWidth ? { width: columnWidth } : undefined}
>
{arrow && <span css={arrowStyles}>{arrow}</span>}
{text}
</div>
) : (
<>
{arrow && <span css={arrowStyles}>{arrow}</span>}
{text}
</>
)}
</StyledCell>
);
},
Header: ({ column: col, onClick, style, onDragStart, onDrop }) => (
<th
id={`header-${column.key}`}
title={t('Shift + Click to sort by multiple columns')}
className={[className, col.isSorted ? 'is-sorted' : ''].join(' ')}
style={{
...sharedStyle,
...style,
}}
onKeyDown={(e: ReactKeyboardEvent<HTMLElement>) => {
// programatically sort column on keypress
if (Object.values(ACTION_KEYS).includes(e.key)) {
col.toggleSortBy();
}
}}
role="columnheader button"
onClick={onClick}
data-column-name={col.id}
{...(allowRearrangeColumns && {
draggable: 'true',
onDragStart,
onDragOver: e => e.preventDefault(),
onDragEnter: e => e.preventDefault(),
onDrop,
})}
tabIndex={0}
>
{/* can't use `columnWidth &&` because it may also be zero */}
{config.columnWidth ? (
// column width hint
<div
style={{
width: columnWidth,
height: 0.01,
}}
/>
) : null}
<div
data-column-name={col.id}
css={{
display: 'inline-flex',
alignItems: 'flex-end',
}}
>
<span data-column-name={col.id}>{displayLabel}</span>
<SortIcon column={col} />
</div>
</th>
),
Footer: totals ? (
i === 0 ? (
<th key={`footer-summary-${i}`}>
<div
css={css`
display: flex;
align-items: center;
& svg {
margin-left: ${theme.gridUnit}px;
color: ${theme.colors.grayscale.dark1} !important;
}
`}
>
{t('Summary')}
<Tooltip
overlay={t(
'Show total aggregations of selected metrics. Note that row limit does not apply to the result.',
)}
>
<InfoCircleOutlined />
</Tooltip>
</div>
</th>
) : (
<td key={`footer-total-${i}`} style={sharedStyle}>
<strong>{formatColumnValue(column, totals[key])[1]}</strong>
</td>
)
) : undefined,
sortDescFirst: sortDesc,
sortType: getSortTypeByDataType(dataType),
};
},
[
defaultAlignPN,
defaultColorPN,
emitCrossFilters,
getValueRange,
isActiveFilterValue,
isRawRecords,
showCellBars,
sortDesc,
toggleFilter,
totals,
columnColorFormatters,
columnOrderToggle,
],
);
const visibleColumnsMeta = useMemo(
() => filteredColumnsMeta.filter(col => col.config?.visible !== false),
[filteredColumnsMeta],
);
const columns = useMemo(
() => visibleColumnsMeta.map(getColumnConfigs),
[visibleColumnsMeta, getColumnConfigs],
);
const handleServerPaginationChange = useCallback(
(pageNumber: number, pageSize: number) => {
updateExternalFormData(setDataMask, pageNumber, pageSize);
},
[setDataMask],
);
const handleSizeChange = useCallback(
({ width, height }: { width: number; height: number }) => {
setTableSize({ width, height });
},
[],
);
useLayoutEffect(() => {
// After initial load the table should resize only when the new sizes
// Are not only scrollbar updates, otherwise, the table would twitch
const scrollBarSize = getScrollBarSize();
const { width: tableWidth, height: tableHeight } = tableSize;
// Table is increasing its original size
if (
width - tableWidth > scrollBarSize ||
height - tableHeight > scrollBarSize
) {
handleSizeChange({
width: width - scrollBarSize,
height: height - scrollBarSize,
});
} else if (
tableWidth - width > scrollBarSize ||
tableHeight - height > scrollBarSize
) {
// Table is decreasing its original size
handleSizeChange({
width,
height,
});
}
}, [width, height, handleSizeChange, tableSize]);
const { width: widthFromState, height: heightFromState } = tableSize;
return (
<Styles>
<DataTable<D>
columns={columns}
data={data}
rowCount={rowCount}
tableClassName="table table-striped table-condensed"
pageSize={pageSize}
serverPaginationData={serverPaginationData}
pageSizeOptions={pageSizeOptions}
width={widthFromState}
height={heightFromState}
serverPagination={serverPagination}
onServerPaginationChange={handleServerPaginationChange}
onColumnOrderChange={() => setColumnOrderToggle(!columnOrderToggle)}
// 9 page items in > 340px works well even for 100+ pages
maxPageItemCount={width > 340 ? 9 : 7}
noResults={getNoResultsMessage}
searchInput={includeSearch && SearchInput}
selectPageSize={pageSize !== null && SelectPageSize}
// not in use in Superset, but needed for unit tests
sticky={sticky}
renderGroupingHeaders={
!isEmpty(groupHeaderColumns) ? renderGroupingHeaders : undefined
}
renderTimeComparisonDropdown={
isUsingTimeComparison ? renderTimeComparisonDropdown : undefined
}
/>
</Styles>
);
}