/** * 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 React, { CSSProperties, useCallback, useLayoutEffect, useMemo, useState, MouseEvent, } 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, } from '@superset-ui/core'; import { 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({ column }: { column: ColumnInstance }) { const { isSorted, isSortedDesc } = column; let sortIcon = ; if (isSorted) { sortIcon = isSortedDesc ? : ; } return sortIcon; } function SearchInput({ count, value, onChange }: SearchInputProps) { return ( {t('Search')}{' '} ); } function SelectPageSize({ options, current, onChange, }: SelectPageSizeRendererProps) { return ( {t('page_size.show')}{' '} {' '} {t('page_size.entries')} ); } const getNoResultsMessage = (filter: string) => filter ? t('No matching records found') : t('No records found'); export default function TableChart( props: TableChartTransformedProps & { sticky?: DataTableProps['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, onContextMenu, emitCrossFilters, } = props; const timestampFormatter = useCallback( value => getTimeFormatterForGranularity(timeGrain)(value), [timeGrain], ); const [tableSize, setTableSize] = useState({ width: 0, height: 0, }); // keep track of whether column order changed, so that column widths can too const [columnOrderToggle, setColumnOrderToggle] = useState(false); // 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 ? config.horizontalAlign : isNumeric ? 'right' : 'left'; return { textAlign, }; }; const handleContextMenu = onContextMenu && !isRawRecords ? ( value: D, cellPoint: { key: string; value: DataRecordValue; isMetric?: boolean; }, clientX: number, clientY: number, ) => { const drillToDetailFilters: BinaryQueryObjectFilterClause[] = []; columnsMeta.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 getColumnConfigs = useCallback( (column: DataColumnMeta, i: number): ColumnWithLooseAccessor => { const { key, label, isNumeric, dataType, isMetric, isPercentMetric, config = {}, } = column; 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 valueRange = !hasColumnColorFormatters && (config.showCellBars === undefined ? showCellBars : config.showCellBars) && (isMetric || isRawRecords || isPercentMetric) && getValueRange(key, alignPositiveNegative); let className = ''; if (emitCrossFilters && !isMetric) { className += ' dt-is-filter'; } 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 }) => { const [isHtml, text] = formatColumnValue(column, value); const html = isHtml ? { __html: text } : undefined; let backgroundColor; 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; } }); } const StyledCell = styled.td` text-align: ${sharedStyle.textAlign}; white-space: ${value instanceof Date ? 'nowrap' : undefined}; position: relative; background: ${backgroundColor || undefined}; `; 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, })}; `} `; const cellProps = { // 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(' '), }; if (html) { if (truncateLongCells) { // eslint-disable-next-line react/no-danger return (
); } // eslint-disable-next-line react/no-danger return ; } // If cellProps renders textContent already, then we don't have to // render `Cell`. This saves some time for large tables. return ( {valueRange && (
)} {truncateLongCells ? (
{text}
) : ( text )} ); }, Header: ({ column: col, onClick, style, onDragStart, onDrop }) => ( ) => { // programatically sort column on keypress if (Object.values(ACTION_KEYS).includes(e.key)) { col.toggleSortBy(); } }} onClick={onClick} data-column-name={col.id} {...(allowRearrangeColumns && { draggable: 'true', onDragStart, onDragOver: e => e.preventDefault(), onDragEnter: e => e.preventDefault(), onDrop, })} > {/* can't use `columnWidth &&` because it may also be zero */} {config.columnWidth ? ( // column width hint
) : null}
{label}
), Footer: totals ? ( i === 0 ? ( {t('Totals')} ) : ( {formatColumnValue(column, totals[key])[1]} ) ) : undefined, sortDescFirst: sortDesc, sortType: getSortTypeByDataType(dataType), }; }, [ defaultAlignPN, defaultColorPN, emitCrossFilters, getValueRange, isActiveFilterValue, isRawRecords, showCellBars, sortDesc, toggleFilter, totals, columnColorFormatters, columnOrderToggle, ], ); const columns = useMemo( () => columnsMeta.map(getColumnConfigs), [columnsMeta, 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 twicth 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 ( 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} /> ); }