| /** |
| * 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, |
| useEffect, |
| } 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, |
| getSelectedText, |
| getTimeFormatterForGranularity, |
| BinaryQueryObjectFilterClause, |
| styled, |
| css, |
| t, |
| tn, |
| useTheme, |
| SupersetTheme, |
| } from '@superset-ui/core'; |
| import { GenericDataType } from '@apache-superset/core/api/core'; |
| import { |
| Input, |
| Space, |
| RawAntdSelect as Select, |
| Dropdown, |
| Tooltip, |
| } from '@superset-ui/core/components'; |
| import { |
| CheckOutlined, |
| InfoCircleOutlined, |
| DownOutlined, |
| MinusCircleOutlined, |
| PlusCircleOutlined, |
| TableOutlined, |
| } from '@ant-design/icons'; |
| import { isEmpty, debounce, isEqual } from 'lodash'; |
| import { |
| ColorSchemeEnum, |
| DataColumnMeta, |
| SearchOption, |
| SortByItem, |
| TableChartTransformedProps, |
| } from './types'; |
| import DataTable, { |
| DataTableProps, |
| SearchInputProps, |
| SelectPageSizeRendererProps, |
| SizeOption, |
| } from './DataTable'; |
| import Styles from './Styles'; |
| import { formatColumnValue } from './utils/formatValue'; |
| import { PAGE_SIZE_OPTIONS, SERVER_PAGE_SIZE_OPTIONS } from './consts'; |
| import { updateTableOwnState } from './DataTable/utils/externalAPIs'; |
| import getScrollBarSize from './DataTable/utils/getScrollBarSize'; |
| import DateWithFormatter from './utils/DateWithFormatter'; |
| |
| 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, |
| theme, |
| }: { |
| value: number; |
| colorPositiveNegative: boolean; |
| theme: SupersetTheme; |
| }) { |
| if (!colorPositiveNegative) { |
| return `${theme.colorFillSecondary}50`; |
| } |
| |
| if (value < 0) { |
| return `${theme.colorError}50`; |
| } |
| |
| return `${theme.colorSuccess}50`; |
| } |
| |
| 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, |
| onBlur, |
| inputRef, |
| }: SearchInputProps) { |
| return ( |
| <Space direction="horizontal" size={4} className="dt-global-filter"> |
| {t('Search')} |
| <Input |
| aria-label={t('Search %s records', count)} |
| placeholder={tn('%s record', '%s records...', count, count)} |
| value={value} |
| onChange={onChange} |
| onBlur={onBlur} |
| ref={inputRef} |
| /> |
| </Space> |
| ); |
| } |
| |
| function SelectPageSize({ |
| options, |
| current, |
| onChange, |
| }: SelectPageSizeRendererProps) { |
| const { Option } = Select; |
| |
| return ( |
| <> |
| <label htmlFor="pageSizeSelect" className="sr-only"> |
| {t('Select page size')} |
| </label> |
| {t('Show')}{' '} |
| <Select<number> |
| id="pageSizeSelect" |
| value={current} |
| onChange={value => onChange(value)} |
| size="small" |
| css={(theme: SupersetTheme) => css` |
| width: ${theme.sizeUnit * 18}px; |
| `} |
| aria-label={t('Show entries per page')} |
| > |
| {options.map(option => { |
| const [size, text] = Array.isArray(option) |
| ? option |
| : [option, option]; |
| return ( |
| <Option key={size} value={Number(size)}> |
| {text} |
| </Option> |
| ); |
| })} |
| </Select>{' '} |
| {t('entries per page')} |
| </> |
| ); |
| } |
| |
| 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, |
| hasServerPageLengthChanged, |
| serverPageLength, |
| slice_id, |
| } = 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 ( |
| serverPagination ? SERVER_PAGE_SIZE_OPTIONS : 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) { |
| const nums = data |
| ?.map(row => row?.[key]) |
| .filter(value => typeof value === 'number') as number[]; |
| if (data && nums.length === data.length) { |
| 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" |
| open={showComparisonDropdown} |
| onOpenChange={(flag: boolean) => { |
| setShowComparisonDropdown(flag); |
| }} |
| menu={{ |
| multiple: true, |
| onClick: handleOnClick, |
| onBlur: handleOnBlur, |
| selectedKeys: selectedComparisonColumns, |
| items: [ |
| { |
| key: 'all', |
| label: ( |
| <div |
| css={css` |
| max-width: 242px; |
| padding: 0 ${theme.sizeUnit * 2}px; |
| color: ${theme.colorText}; |
| font-size: ${theme.fontSizeSM}px; |
| `} |
| > |
| {t( |
| 'Select columns that will be displayed in the table. You can multiselect columns.', |
| )} |
| </div> |
| ), |
| type: 'group', |
| children: comparisonColumns.map( |
| (column: { key: string; label: string }) => ({ |
| key: column.key, |
| label: ( |
| <> |
| <span |
| css={css` |
| color: ${theme.colorText}; |
| `} |
| > |
| {column.label} |
| </span> |
| <span |
| css={css` |
| float: right; |
| font-size: ${theme.fontSizeSM}px; |
| `} |
| > |
| {selectedComparisonColumns.includes(column.key) && ( |
| <CheckOutlined /> |
| )} |
| </span> |
| </> |
| ), |
| }), |
| ), |
| }, |
| ], |
| }} |
| 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 firstColumnInGroup = filteredColumnsMeta[startPosition]; |
| const originalLabel = firstColumnInGroup |
| ? columnsMeta.find(col => col.key === firstColumnInGroup.key) |
| ?.originalLabel || key |
| : 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.colorIcon} !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: 1px solid ${theme.colorSplit}; |
| } |
| 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> & { |
| columnKey: string; |
| } => { |
| const { |
| key, |
| label: originalLabel, |
| 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 = |
| 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. |
| columnKey: key, |
| 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` |
| color: ${theme.colorText}; |
| text-align: ${sharedStyle.textAlign}; |
| white-space: ${value instanceof Date ? 'nowrap' : undefined}; |
| position: relative; |
| background: ${backgroundColor || undefined}; |
| padding-left: ${column.isChildColumn |
| ? `${theme.sizeUnit * 5}px` |
| : `${theme.sizeUnit}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, |
| theme, |
| })}; |
| `} |
| `; |
| |
| let arrowStyles = css` |
| color: ${basicColorFormatters && |
| basicColorFormatters[row.index][originKey]?.arrowColor === |
| ColorSchemeEnum.Green |
| ? theme.colorSuccess |
| : theme.colorError}; |
| margin-right: ${theme.sizeUnit}px; |
| `; |
| |
| if ( |
| basicColorColumnFormatters && |
| basicColorColumnFormatters?.length > 0 |
| ) { |
| arrowStyles = css` |
| color: ${basicColorColumnFormatters[row.index][column.key] |
| ?.arrowColor === ColorSchemeEnum.Green |
| ? theme.colorSuccess |
| : theme.colorError}; |
| margin-right: ${theme.sizeUnit}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 || |
| (value instanceof DateWithFormatter && value.input == 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.originalLabel}`} |
| 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.sizeUnit}px; |
| color: ${theme.colorBorder} !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, |
| theme, |
| ], |
| ); |
| |
| const visibleColumnsMeta = useMemo( |
| () => filteredColumnsMeta.filter(col => col.config?.visible !== false), |
| [filteredColumnsMeta], |
| ); |
| |
| const columns = useMemo( |
| () => visibleColumnsMeta.map(getColumnConfigs), |
| [visibleColumnsMeta, getColumnConfigs], |
| ); |
| |
| const [searchOptions, setSearchOptions] = useState<SearchOption[]>([]); |
| |
| useEffect(() => { |
| const options = ( |
| columns as unknown as ColumnWithLooseAccessor & |
| { |
| columnKey: string; |
| sortType?: string; |
| }[] |
| ) |
| .filter(col => col?.sortType === 'alphanumeric') |
| .map(column => ({ |
| value: column.columnKey, |
| label: column.columnKey, |
| })); |
| |
| if (!isEqual(options, searchOptions)) { |
| setSearchOptions(options || []); |
| } |
| }, [columns]); |
| |
| const handleServerPaginationChange = useCallback( |
| (pageNumber: number, pageSize: number) => { |
| const modifiedOwnState = { |
| ...serverPaginationData, |
| currentPage: pageNumber, |
| pageSize, |
| }; |
| updateTableOwnState(setDataMask, modifiedOwnState); |
| }, |
| [setDataMask], |
| ); |
| |
| useEffect(() => { |
| if (hasServerPageLengthChanged) { |
| const modifiedOwnState = { |
| ...serverPaginationData, |
| currentPage: 0, |
| pageSize: serverPageLength, |
| }; |
| updateTableOwnState(setDataMask, modifiedOwnState); |
| } |
| }, []); |
| |
| 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; |
| |
| const handleSortByChange = useCallback( |
| (sortBy: SortByItem[]) => { |
| if (!serverPagination) return; |
| const modifiedOwnState = { |
| ...serverPaginationData, |
| sortBy, |
| }; |
| updateTableOwnState(setDataMask, modifiedOwnState); |
| }, |
| [setDataMask, serverPagination], |
| ); |
| |
| const handleSearch = (searchText: string) => { |
| const modifiedOwnState = { |
| ...(serverPaginationData || {}), |
| searchColumn: |
| serverPaginationData?.searchColumn || searchOptions[0]?.value, |
| searchText, |
| currentPage: 0, // Reset to first page when searching |
| }; |
| updateTableOwnState(setDataMask, modifiedOwnState); |
| }; |
| |
| const debouncedSearch = debounce(handleSearch, 800); |
| |
| const handleChangeSearchCol = (searchCol: string) => { |
| if (!isEqual(searchCol, serverPaginationData?.searchColumn)) { |
| const modifiedOwnState = { |
| ...(serverPaginationData || {}), |
| searchColumn: searchCol, |
| searchText: '', |
| }; |
| updateTableOwnState(setDataMask, modifiedOwnState); |
| } |
| }; |
| |
| 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)} |
| initialSearchText={serverPaginationData?.searchText || ''} |
| sortByFromParent={serverPaginationData?.sortBy || []} |
| searchInputId={`${slice_id}-search`} |
| // 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 |
| } |
| handleSortByChange={handleSortByChange} |
| onSearchColChange={handleChangeSearchCol} |
| manualSearch={serverPagination} |
| onSearchChange={debouncedSearch} |
| searchOptions={searchOptions} |
| /> |
| </Styles> |
| ); |
| } |