blob: 9230b24a97e906e65a0dc00fa9c229d41342bd8f [file] [log] [blame]
/**
* 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, { useState, useMemo, useCallback } from 'react';
import { ColumnInstance, DefaultSortTypes, ColumnWithLooseAccessor } from 'react-table';
import { extent as d3Extent, max as d3Max } from 'd3-array';
import { FaSort, FaSortUp as FaSortAsc, FaSortDown as FaSortDesc } from 'react-icons/fa';
import { t, tn, DataRecordValue, DataRecord, GenericDataType } from '@superset-ui/core';
import { TableChartTransformedProps, DataColumnMeta } from './types';
import DataTable, {
DataTableProps,
SearchInputProps,
SelectPageSizeRendererProps,
SizeOption,
} from './DataTable';
import Styles from './Styles';
import formatValue from './utils/formatValue';
import { PAGE_SIZE_OPTIONS } from './controlPanel';
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 {
height,
width,
data,
columns: columnsMeta,
alignPositiveNegative = false,
colorPositiveNegative = false,
includeSearch = false,
pageSize = 0,
showCellBars = true,
emitFilter = false,
sortDesc = false,
onChangeFilter,
filters: initialFilters,
sticky = true, // whether to use sticky header
} = props;
const [filters, setFilters] = useState(initialFilters);
// only take relevant page size options
const pageSizeOptions = useMemo(
() => PAGE_SIZE_OPTIONS.filter(([n]) => n <= 2 * data.length) as SizeOption[],
[data.length],
);
const getValueRange = useCallback(
function getValueRange(key: string) {
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;
},
[alignPositiveNegative, data],
);
const isActiveFilterValue = useCallback(
function isActiveFilterValue(key: string, val: DataRecordValue) {
return !!filters && filters[key]?.includes(val);
},
[filters],
);
const toggleFilter = useCallback(
function toggleFilter(key: string, val: DataRecordValue) {
const updatedFilters = { ...(filters || {}) };
if (filters && isActiveFilterValue(key, val)) {
updatedFilters[key] = filters[key].filter((x: DataRecordValue) => x !== val);
} else {
updatedFilters[key] = [...(filters?.[key] || []), val];
}
setFilters(updatedFilters);
if (onChangeFilter) {
onChangeFilter(updatedFilters);
}
},
[filters, isActiveFilterValue, onChangeFilter],
);
const getColumnConfigs = useCallback(
(column: DataColumnMeta, i: number): ColumnWithLooseAccessor<D> => {
const { key, label, dataType } = column;
let className = '';
if (dataType === GenericDataType.NUMERIC) {
className += ' dt-metric';
} else if (emitFilter) {
className += ' dt-is-filter';
}
const valueRange = showCellBars && getValueRange(key);
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 }: { column: ColumnInstance<D>; value: DataRecordValue }) => {
const [isHtml, text, customClassName] = formatValue(column, value);
const style = {
background: valueRange
? cellBar({
value: value as number,
valueRange,
alignPositiveNegative,
colorPositiveNegative,
})
: undefined,
};
const html = isHtml ? { __html: text } : undefined;
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} ${customClassName || ''} ${
isActiveFilterValue(key, value) ? ' dt-is-active-filter' : ''
}`,
style,
};
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, title, onClick, style }) => (
<th
title={title}
className={col.isSorted ? `${className || ''} is-sorted` : className}
style={style}
onClick={onClick}
>
{label}
<SortIcon column={col} />
</th>
),
sortDescFirst: sortDesc,
sortType: getSortTypeByDataType(dataType),
};
},
[
alignPositiveNegative,
colorPositiveNegative,
emitFilter,
getValueRange,
isActiveFilterValue,
showCellBars,
sortDesc,
toggleFilter,
],
);
const columns = useMemo(() => columnsMeta.map(getColumnConfigs), [columnsMeta, getColumnConfigs]);
return (
<Styles>
<DataTable<D>
columns={columns}
data={data}
tableClassName="table table-striped table-condensed"
pageSize={pageSize}
pageSizeOptions={pageSizeOptions}
width={width}
height={height}
// 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>
);
}