blob: 86dca4357594f2811ece07cc01084de2ccc8fd40 [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 { List } from 'immutable';
import JSONbig from 'json-bigint';
import React, { PureComponent } from 'react';
import JSONTree from 'react-json-tree';
import {
Column,
Grid,
ScrollSync,
SortDirection,
SortDirectionType,
SortIndicator,
Table,
} from 'react-virtualized';
import { getMultipleTextDimensions, t, styled } from '@superset-ui/core';
import { Tooltip } from 'src/common/components/Tooltip';
import Button from '../Button';
import CopyToClipboard from '../CopyToClipboard';
import ModalTrigger from '../ModalTrigger';
function safeJsonObjectParse(
data: unknown,
): null | unknown[] | Record<string, unknown> {
// First perform a cheap proxy to avoid calling JSON.parse on data that is clearly not a
// JSON object or array
if (
typeof data !== 'string' ||
['{', '['].indexOf(data.substring(0, 1)) === -1
) {
return null;
}
// We know `data` is a string starting with '{' or '[', so try to parse it as a valid object
try {
const jsonData = JSON.parse(data);
if (jsonData && typeof jsonData === 'object') {
return jsonData;
}
return null;
} catch (_) {
return null;
}
}
const SCROLL_BAR_HEIGHT = 15;
const GRID_POSITION_ADJUSTMENT = 4;
const JSON_TREE_THEME = {
scheme: 'monokai',
author: 'wimer hazenberg (http://www.monokai.nl)',
base00: '#272822',
base01: '#383830',
base02: '#49483e',
base03: '#75715e',
base04: '#a59f85',
base05: '#f8f8f2',
base06: '#f5f4f1',
base07: '#f9f8f5',
base08: '#f92672',
base09: '#fd971f',
base0A: '#f4bf75',
base0B: '#a6e22e',
base0C: '#a1efe4',
base0D: '#66d9ef',
base0E: '#ae81ff',
base0F: '#cc6633',
};
const StyledFilterableTable = styled.div`
overflow-x: auto;
margin-top: ${({ theme }) => theme.gridUnit * 2}px;
`;
// when more than MAX_COLUMNS_FOR_TABLE are returned, switch from table to grid view
export const MAX_COLUMNS_FOR_TABLE = 50;
type CellDataType = string | number | null;
type Datum = Record<string, CellDataType>;
interface FilterableTableProps {
orderedColumnKeys: string[];
data: Record<string, unknown>[];
height: number;
filterText: string;
headerHeight: number;
overscanColumnCount: number;
overscanRowCount: number;
rowHeight: number;
striped: boolean;
expandedColumns: string[];
}
interface FilterableTableState {
sortBy?: string;
sortDirection: SortDirectionType;
fitted: boolean;
}
export default class FilterableTable extends PureComponent<
FilterableTableProps,
FilterableTableState
> {
static defaultProps = {
filterText: '',
headerHeight: 32,
overscanColumnCount: 10,
overscanRowCount: 10,
rowHeight: 32,
striped: true,
expandedColumns: [],
};
list: List<Datum>;
complexColumns: Record<string, boolean>;
widthsForColumnsByKey: Record<string, number>;
totalTableWidth: number;
totalTableHeight: number;
container: React.RefObject<HTMLDivElement>;
constructor(props: FilterableTableProps) {
super(props);
this.list = List(this.formatTableData(props.data));
this.addJsonModal = this.addJsonModal.bind(this);
this.getCellContent = this.getCellContent.bind(this);
this.renderGridCell = this.renderGridCell.bind(this);
this.renderGridCellHeader = this.renderGridCellHeader.bind(this);
this.renderGrid = this.renderGrid.bind(this);
this.renderTableCell = this.renderTableCell.bind(this);
this.renderTableHeader = this.renderTableHeader.bind(this);
this.sortResults = this.sortResults.bind(this);
this.renderTable = this.renderTable.bind(this);
this.rowClassName = this.rowClassName.bind(this);
this.sort = this.sort.bind(this);
// columns that have complex type and were expanded into sub columns
this.complexColumns = props.orderedColumnKeys.reduce(
(obj, key) => ({
...obj,
[key]: props.expandedColumns.some(name => name.startsWith(`${key}.`)),
}),
{},
);
this.widthsForColumnsByKey = this.getWidthsForColumns();
this.totalTableWidth = props.orderedColumnKeys
.map(key => this.widthsForColumnsByKey[key])
.reduce((curr, next) => curr + next);
this.totalTableHeight = props.height;
this.state = {
sortDirection: SortDirection.ASC,
fitted: false,
};
this.container = React.createRef();
}
componentDidMount() {
this.fitTableToWidthIfNeeded();
}
getDatum(list: List<Datum>, index: number) {
return list.get(index % list.size);
}
getWidthsForColumns() {
const PADDING = 40; // accounts for cell padding and width of sorting icon
const widthsByColumnKey = {};
const cellContent = [].concat(
...this.props.orderedColumnKeys.map(key => {
const cellContentList = this.list.map((data: Datum) =>
this.getCellContent({ cellData: data[key], columnKey: key }),
) as List<string | JSX.Element>;
return cellContentList.push(key).toJS();
}),
);
const colWidths = getMultipleTextDimensions({
className: 'cell-text-for-measuring',
texts: cellContent,
}).map(dimension => dimension.width);
this.props.orderedColumnKeys.forEach((key, index) => {
// we can't use Math.max(...colWidths.slice(...)) here since the number
// of elements might be bigger than the number of allowed arguments in a
// Javascript function
widthsByColumnKey[key] =
colWidths
.slice(
index * (this.list.size + 1),
(index + 1) * (this.list.size + 1),
)
.reduce((a, b) => Math.max(a, b)) + PADDING;
});
return widthsByColumnKey;
}
getCellContent({
cellData,
columnKey,
}: {
cellData: CellDataType;
columnKey: string;
}): string | JSX.Element {
if (cellData === null) {
return <i className="text-muted">NULL</i>;
}
const content = String(cellData);
const firstCharacter = content.substring(0, 1);
let truncated;
if (firstCharacter === '[') {
truncated = '[…]';
} else if (firstCharacter === '{') {
truncated = '{…}';
} else {
truncated = '';
}
return this.complexColumns[columnKey] ? truncated : content;
}
formatTableData(data: Record<string, unknown>[]): Datum[] {
return data.map(row => {
const newRow = {};
Object.entries(row).forEach(([key, val]) => {
if (['string', 'number'].indexOf(typeof val) >= 0) {
newRow[key] = val;
} else {
newRow[key] = val === null ? null : JSONbig.stringify(val);
}
});
return newRow;
});
}
hasMatch(text: string, row: Datum) {
const values: string[] = [];
Object.keys(row).forEach(key => {
if (row.hasOwnProperty(key)) {
const cellValue = row[key];
if (typeof cellValue === 'string') {
values.push(cellValue.toLowerCase());
} else if (
cellValue !== null &&
typeof cellValue.toString === 'function'
) {
values.push(cellValue.toString());
}
}
});
const lowerCaseText = text.toLowerCase();
return values.some(v => v.includes(lowerCaseText));
}
rowClassName({ index }: { index: number }) {
let className = '';
if (this.props.striped) {
className = index % 2 === 0 ? 'even-row' : 'odd-row';
}
return className;
}
sort({
sortBy,
sortDirection,
}: {
sortBy: string;
sortDirection: SortDirectionType;
}) {
this.setState({ sortBy, sortDirection });
}
fitTableToWidthIfNeeded() {
const containerWidth = this.container.current?.clientWidth ?? 0;
if (this.totalTableWidth < containerWidth) {
// fit table width if content doesn't fill the width of the container
this.totalTableWidth = containerWidth;
}
this.setState({ fitted: true });
}
addJsonModal(
node: React.ReactNode,
jsonObject: Record<string, unknown> | unknown[],
jsonString: CellDataType,
) {
return (
<ModalTrigger
modalBody={<JSONTree data={jsonObject} theme={JSON_TREE_THEME} />}
modalFooter={
<Button>
<CopyToClipboard shouldShowText={false} text={jsonString} />
</Button>
}
modalTitle={t('Cell content')}
triggerNode={node}
/>
);
}
sortResults(sortBy: string, descending: boolean) {
return (a: Datum, b: Datum) => {
const aValue = a[sortBy];
const bValue = b[sortBy];
if (aValue === bValue) {
// equal items sort equally
return 0;
}
if (aValue === null) {
// nulls sort after anything else
return 1;
}
if (bValue === null) {
return -1;
}
if (descending) {
return aValue < bValue ? 1 : -1;
}
return aValue < bValue ? -1 : 1;
};
}
renderTableHeader({
dataKey,
label,
sortBy,
sortDirection,
}: {
dataKey: string;
label: string;
sortBy: string;
sortDirection: SortDirectionType;
}) {
const className =
this.props.expandedColumns.indexOf(label) > -1
? 'header-style-disabled'
: 'header-style';
return (
<Tooltip id="header-tooltip" title={label}>
<div className={className}>
{label}
{sortBy === dataKey && (
<SortIndicator sortDirection={sortDirection} />
)}
</div>
</Tooltip>
);
}
renderGridCellHeader({
columnIndex,
key,
style,
}: {
columnIndex: number;
key: string;
style: React.CSSProperties;
}) {
const label = this.props.orderedColumnKeys[columnIndex];
const className =
this.props.expandedColumns.indexOf(label) > -1
? 'header-style-disabled'
: 'header-style';
return (
<Tooltip key={key} id="header-tooltip" title={label}>
<div
style={{
...style,
top:
typeof style.top === 'number'
? style.top - GRID_POSITION_ADJUSTMENT
: style.top,
}}
className={`${className} grid-cell grid-header-cell`}
>
{label}
</div>
</Tooltip>
);
}
renderGridCell({
columnIndex,
key,
rowIndex,
style,
}: {
columnIndex: number;
key: string;
rowIndex: number;
style: React.CSSProperties;
}) {
const columnKey = this.props.orderedColumnKeys[columnIndex];
const cellData = this.list.get(rowIndex)[columnKey];
const content = this.getCellContent({ cellData, columnKey });
const cellNode = (
<div
key={key}
style={{
...style,
top:
typeof style.top === 'number'
? style.top - GRID_POSITION_ADJUSTMENT
: style.top,
}}
className={`grid-cell ${this.rowClassName({ index: rowIndex })}`}
>
{content}
</div>
);
const jsonObject = safeJsonObjectParse(cellData);
if (jsonObject) {
return this.addJsonModal(cellNode, jsonObject, cellData);
}
return cellNode;
}
renderGrid() {
const {
orderedColumnKeys,
overscanColumnCount,
overscanRowCount,
rowHeight,
} = this.props;
let { height } = this.props;
let totalTableHeight = height;
if (
this.container.current &&
this.totalTableWidth > this.container.current.clientWidth
) {
// exclude the height of the horizontal scroll bar from the height of the table
// and the height of the table container if the content overflows
height -= SCROLL_BAR_HEIGHT;
totalTableHeight -= SCROLL_BAR_HEIGHT;
}
const getColumnWidth = ({ index }: { index: number }) =>
this.widthsForColumnsByKey[orderedColumnKeys[index]];
// fix height of filterable table
return (
<StyledFilterableTable>
<ScrollSync>
{({ onScroll, scrollTop }) => (
<div
style={{ height }}
className="filterable-table-container Table"
data-test="filterable-table-container"
ref={this.container}
>
<div className="LeftColumn">
<Grid
cellRenderer={this.renderGridCellHeader}
columnCount={orderedColumnKeys.length}
columnWidth={getColumnWidth}
height={rowHeight}
rowCount={1}
rowHeight={rowHeight}
scrollTop={scrollTop}
width={this.totalTableWidth}
/>
</div>
<div className="RightColumn">
<Grid
cellRenderer={this.renderGridCell}
columnCount={orderedColumnKeys.length}
columnWidth={getColumnWidth}
height={totalTableHeight - rowHeight}
onScroll={onScroll}
overscanColumnCount={overscanColumnCount}
overscanRowCount={overscanRowCount}
rowCount={this.list.size}
rowHeight={rowHeight}
width={this.totalTableWidth}
/>
</div>
</div>
)}
</ScrollSync>
</StyledFilterableTable>
);
}
renderTableCell({
cellData,
columnKey,
}: {
cellData: CellDataType;
columnKey: string;
}) {
const cellNode = this.getCellContent({ cellData, columnKey });
const jsonObject = safeJsonObjectParse(cellData);
if (jsonObject) {
return this.addJsonModal(cellNode, jsonObject, cellData);
}
return cellNode;
}
renderTable() {
const { sortBy, sortDirection } = this.state;
const {
filterText,
headerHeight,
orderedColumnKeys,
overscanRowCount,
rowHeight,
} = this.props;
let sortedAndFilteredList: List<Datum> = this.list;
// filter list
if (filterText) {
sortedAndFilteredList = this.list.filter((row: Datum) =>
this.hasMatch(filterText, row),
) as List<Datum>;
}
// sort list
if (sortBy) {
sortedAndFilteredList = sortedAndFilteredList.sort(
this.sortResults(sortBy, sortDirection === SortDirection.DESC),
) as List<Datum>;
}
let { height } = this.props;
let totalTableHeight = height;
if (
this.container.current &&
this.totalTableWidth > this.container.current.clientWidth
) {
// exclude the height of the horizontal scroll bar from the height of the table
// and the height of the table container if the content overflows
height -= SCROLL_BAR_HEIGHT;
totalTableHeight -= SCROLL_BAR_HEIGHT;
}
const rowGetter = ({ index }: { index: number }) =>
this.getDatum(sortedAndFilteredList, index);
return (
<StyledFilterableTable
style={{ height }}
className="filterable-table-container"
ref={this.container}
>
{this.state.fitted && (
<Table
ref="Table"
headerHeight={headerHeight}
height={totalTableHeight}
overscanRowCount={overscanRowCount}
rowClassName={this.rowClassName}
rowHeight={rowHeight}
rowGetter={rowGetter}
rowCount={sortedAndFilteredList.size}
sort={this.sort}
sortBy={sortBy}
sortDirection={sortDirection}
width={this.totalTableWidth}
>
{orderedColumnKeys.map(columnKey => (
<Column
cellRenderer={({ cellData }) =>
this.renderTableCell({ cellData, columnKey })
}
dataKey={columnKey}
disableSort={false}
headerRenderer={this.renderTableHeader}
width={this.widthsForColumnsByKey[columnKey]}
label={columnKey}
key={columnKey}
/>
))}
</Table>
)}
</StyledFilterableTable>
);
}
render() {
if (this.props.orderedColumnKeys.length > MAX_COLUMNS_FOR_TABLE) {
return this.renderGrid();
}
return this.renderTable();
}
}