mirror of
https://github.com/apache/superset.git
synced 2026-04-10 20:06:13 +00:00
511 lines
15 KiB
TypeScript
511 lines
15 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 React, { CSSProperties, useCallback, useMemo } from 'react';
|
|
import {
|
|
ColumnInstance,
|
|
ColumnWithLooseAccessor,
|
|
DefaultSortTypes,
|
|
} 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 {
|
|
DataRecord,
|
|
DataRecordValue,
|
|
DTTM_ALIAS,
|
|
ensureIsArray,
|
|
GenericDataType,
|
|
getTimeFormatterForGranularity,
|
|
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';
|
|
|
|
type ValueRange = [number, number];
|
|
|
|
/**
|
|
* 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 to render columns as horizontal bar chart
|
|
*/
|
|
function cellBar({
|
|
value,
|
|
valueRange,
|
|
colorPositiveNegative = false,
|
|
alignPositiveNegative,
|
|
}: {
|
|
value: number;
|
|
valueRange: ValueRange;
|
|
colorPositiveNegative: boolean;
|
|
alignPositiveNegative: boolean;
|
|
}) {
|
|
const [minValue, maxValue] = valueRange;
|
|
const r = colorPositiveNegative && value < 0 ? 150 : 0;
|
|
if (alignPositiveNegative) {
|
|
const perc = Math.abs(Math.round((value / maxValue) * 100));
|
|
// The 0.01 to 0.001 is a workaround for what appears to be a
|
|
// CSS rendering bug on flat, transparent colors
|
|
return (
|
|
`linear-gradient(to right, rgba(${r},0,0,0.2), rgba(${r},0,0,0.2) ${perc}%, ` +
|
|
`rgba(0,0,0,0.01) ${perc}%, rgba(0,0,0,0.001) 100%)`
|
|
);
|
|
}
|
|
const posExtent = Math.abs(Math.max(maxValue, 0));
|
|
const negExtent = Math.abs(Math.min(minValue, 0));
|
|
const tot = posExtent + negExtent;
|
|
const perc1 = Math.round(
|
|
(Math.min(negExtent + value, negExtent) / tot) * 100,
|
|
);
|
|
const perc2 = Math.round((Math.abs(value) / tot) * 100);
|
|
// The 0.01 to 0.001 is a workaround for what appears to be a
|
|
// CSS rendering bug on flat, transparent colors
|
|
return (
|
|
`linear-gradient(to right, rgba(0,0,0,0.01), rgba(0,0,0,0.001) ${perc1}%, ` +
|
|
`rgba(${r},0,0,0.2) ${perc1}%, rgba(${r},0,0,0.2) ${perc1 + perc2}%, ` +
|
|
`rgba(0,0,0,0.01) ${perc1 + perc2}%, rgba(0,0,0,0.001) 100%)`
|
|
);
|
|
}
|
|
|
|
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
|
|
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">
|
|
{t('page_size.show')}{' '}
|
|
<select
|
|
className="form-control input-sm"
|
|
value={current}
|
|
onBlur={() => {}}
|
|
onChange={e => {
|
|
onChange(Number((e.target as HTMLSelectElement).value));
|
|
}}
|
|
>
|
|
{options.map(option => {
|
|
const [size, text] = Array.isArray(option)
|
|
? option
|
|
: [option, option];
|
|
return (
|
|
<option key={size} value={size}>
|
|
{text}
|
|
</option>
|
|
);
|
|
})}
|
|
</select>{' '}
|
|
{t('page_size.entries')}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
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,
|
|
emitFilter = false,
|
|
sortDesc = false,
|
|
filters,
|
|
sticky = true, // whether to use sticky header
|
|
columnColorFormatters,
|
|
} = props;
|
|
const timestampFormatter = useCallback(
|
|
value => getTimeFormatterForGranularity(timeGrain)(value),
|
|
[timeGrain],
|
|
);
|
|
|
|
const handleChange = useCallback(
|
|
(filters: { [x: string]: DataRecordValue[] }) => {
|
|
if (!emitFilter) {
|
|
return;
|
|
}
|
|
|
|
const groupBy = Object.keys(filters);
|
|
const groupByValues = Object.values(filters);
|
|
const labelElements: string[] = [];
|
|
groupBy.forEach(col => {
|
|
const isTimestamp = col === DTTM_ALIAS;
|
|
const filterValues = ensureIsArray(filters?.[col]);
|
|
if (filterValues.length) {
|
|
const valueLabels = filterValues.map(value =>
|
|
isTimestamp ? timestampFormatter(value) : value,
|
|
);
|
|
labelElements.push(`${valueLabels.join(', ')}`);
|
|
}
|
|
});
|
|
setDataMask({
|
|
extraFormData: {
|
|
filters:
|
|
groupBy.length === 0
|
|
? []
|
|
: groupBy.map(col => {
|
|
const val = ensureIsArray(filters?.[col]);
|
|
if (!val.length)
|
|
return {
|
|
col,
|
|
op: 'IS NULL',
|
|
};
|
|
return {
|
|
col,
|
|
op: 'IN',
|
|
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: filters && Object.keys(filters).length ? filters : null,
|
|
},
|
|
});
|
|
},
|
|
[emitFilter, setDataMask],
|
|
);
|
|
|
|
// 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],
|
|
);
|
|
|
|
function getEmitTarget(col: string) {
|
|
const meta = columnsMeta?.find(x => x.key === col);
|
|
return meta?.config?.emitTarget || col;
|
|
}
|
|
|
|
const toggleFilter = useCallback(
|
|
function toggleFilter(key: string, val: DataRecordValue) {
|
|
let updatedFilters = { ...(filters || {}) };
|
|
const target = getEmitTarget(key);
|
|
if (filters && isActiveFilterValue(target, val)) {
|
|
updatedFilters = {};
|
|
} else {
|
|
updatedFilters = {
|
|
[target]: [val],
|
|
};
|
|
}
|
|
if (
|
|
Array.isArray(updatedFilters[target]) &&
|
|
updatedFilters[target].length === 0
|
|
) {
|
|
delete updatedFilters[target];
|
|
}
|
|
handleChange(updatedFilters);
|
|
},
|
|
[filters, handleChange, isActiveFilterValue],
|
|
);
|
|
|
|
const getSharedStyle = (column: DataColumnMeta): CSSProperties => {
|
|
const { isNumeric, config = {} } = column;
|
|
const textAlign = config.horizontalAlign
|
|
? config.horizontalAlign
|
|
: isNumeric
|
|
? 'right'
|
|
: 'left';
|
|
return {
|
|
textAlign,
|
|
};
|
|
};
|
|
|
|
const getColumnConfigs = useCallback(
|
|
(column: DataColumnMeta, i: number): ColumnWithLooseAccessor<D> => {
|
|
const { key, label, isNumeric, dataType, isMetric, config = {} } = column;
|
|
const isFilter = !isNumeric && emitFilter;
|
|
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 hasColumnColorFormatters =
|
|
isNumeric &&
|
|
Array.isArray(columnColorFormatters) &&
|
|
columnColorFormatters.length > 0;
|
|
|
|
const valueRange =
|
|
!hasColumnColorFormatters &&
|
|
(config.showCellBars === undefined
|
|
? showCellBars
|
|
: config.showCellBars) &&
|
|
(isMetric || isRawRecords) &&
|
|
getValueRange(key, alignPositiveNegative);
|
|
|
|
let className = '';
|
|
if (isFilter) {
|
|
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 }: { value: DataRecordValue }) => {
|
|
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 = formatter.getColorFromValue(
|
|
value as number,
|
|
);
|
|
if (formatterResult) {
|
|
backgroundColor = formatterResult;
|
|
}
|
|
});
|
|
}
|
|
|
|
const cellProps = {
|
|
// show raw number in title in case of numeric values
|
|
title: typeof value === 'number' ? String(value) : undefined,
|
|
onClick:
|
|
emitFilter && !valueRange
|
|
? () => toggleFilter(key, value)
|
|
: undefined,
|
|
className: [
|
|
className,
|
|
value == null ? 'dt-is-null' : '',
|
|
isActiveFilterValue(key, value) ? ' dt-is-active-filter' : '',
|
|
].join(' '),
|
|
style: {
|
|
...sharedStyle,
|
|
background:
|
|
backgroundColor ||
|
|
(valueRange
|
|
? cellBar({
|
|
value: value as number,
|
|
valueRange,
|
|
alignPositiveNegative,
|
|
colorPositiveNegative,
|
|
})
|
|
: undefined),
|
|
},
|
|
};
|
|
if (html) {
|
|
// eslint-disable-next-line react/no-danger
|
|
return <td {...cellProps} dangerouslySetInnerHTML={html} />;
|
|
}
|
|
// If cellProps renderes textContent already, then we don't have to
|
|
// render `Cell`. This saves some time for large tables.
|
|
return <td {...cellProps}>{text}</td>;
|
|
},
|
|
Header: ({ column: col, onClick, style }) => (
|
|
<th
|
|
title="Shift + Click to sort by multiple columns"
|
|
className={[className, col.isSorted ? 'is-sorted' : ''].join(' ')}
|
|
style={{
|
|
...sharedStyle,
|
|
...style,
|
|
}}
|
|
onClick={onClick}
|
|
>
|
|
{/* can't use `columnWidth &&` because it may also be zero */}
|
|
{config.columnWidth ? (
|
|
// column width hint
|
|
<div
|
|
style={{
|
|
width: columnWidth,
|
|
height: 0.01,
|
|
}}
|
|
/>
|
|
) : null}
|
|
<div
|
|
css={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
<span>{label}</span>
|
|
<SortIcon column={col} />
|
|
</div>
|
|
</th>
|
|
),
|
|
Footer: totals ? (
|
|
i === 0 ? (
|
|
<th>{t('Totals')}</th>
|
|
) : (
|
|
<td style={sharedStyle}>
|
|
<strong>{formatColumnValue(column, totals[key])[1]}</strong>
|
|
</td>
|
|
)
|
|
) : undefined,
|
|
sortDescFirst: sortDesc,
|
|
sortType: getSortTypeByDataType(dataType),
|
|
};
|
|
},
|
|
[
|
|
defaultAlignPN,
|
|
defaultColorPN,
|
|
emitFilter,
|
|
getValueRange,
|
|
isActiveFilterValue,
|
|
isRawRecords,
|
|
showCellBars,
|
|
sortDesc,
|
|
toggleFilter,
|
|
totals,
|
|
columnColorFormatters,
|
|
],
|
|
);
|
|
|
|
const columns = useMemo(
|
|
() => columnsMeta.map(getColumnConfigs),
|
|
[columnsMeta, getColumnConfigs],
|
|
);
|
|
|
|
const handleServerPaginationChange = (
|
|
pageNumber: number,
|
|
pageSize: number,
|
|
) => {
|
|
updateExternalFormData(setDataMask, pageNumber, pageSize);
|
|
};
|
|
|
|
return (
|
|
<Styles>
|
|
<DataTable<D>
|
|
columns={columns}
|
|
data={data}
|
|
rowCount={rowCount}
|
|
tableClassName="table table-striped table-condensed"
|
|
pageSize={pageSize}
|
|
serverPaginationData={serverPaginationData}
|
|
pageSizeOptions={pageSizeOptions}
|
|
width={width}
|
|
height={height}
|
|
serverPagination={serverPagination}
|
|
onServerPaginationChange={handleServerPaginationChange}
|
|
// 9 page items in > 340px works well even for 100+ pages
|
|
maxPageItemCount={width > 340 ? 9 : 7}
|
|
noResults={(filter: string) =>
|
|
t(filter ? 'No matching records found' : 'No records found')
|
|
}
|
|
searchInput={includeSearch && SearchInput}
|
|
selectPageSize={pageSize !== null && SelectPageSize}
|
|
// not in use in Superset, but needed for unit tests
|
|
sticky={sticky}
|
|
/>
|
|
</Styles>
|
|
);
|
|
}
|