Files
superset2/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx
Geido a9a892945e fix: Position of arrows in Table chart (#18739)
* Fix arrow position

* Improve position

* Add right position

* Improve styling
2022-02-22 15:42:05 +02:00

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>
);
}