Commit da41cd64 by Hugo Häggmark Committed by GitHub

ReactTable: adds possibility to resize columns (#23365)

* Refactor: adds one form of column resize to React-Table

* Refactor: resizing works

* Refactor: adds onColumnResize

* Refactor: fixes so sorting is not invoked when resizing

* Refactor: fixes styles for resizer

* Refactor: removes callback call

* Refactor: changes after comments

* Refactor: updates code according to new api

* Improved styling

* fix

* Refactor: adds back resizable panel option and defaults to false

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
parent 92231cc4
......@@ -20,11 +20,14 @@ export const BackgroundColoredCell: FC<TableCellProps> = props => {
const styles: CSSProperties = {
background: `linear-gradient(120deg, ${bgColor2}, ${displayValue.color})`,
borderRadius: '0px',
color: 'white',
height: tableStyles.cellHeight,
padding: tableStyles.cellPadding,
};
return <div style={styles}>{formattedValueToString(displayValue)}</div>;
return (
<div className={tableStyles.tableCell} style={styles}>
{formattedValueToString(displayValue)}
</div>
);
};
import React, { FC, memo, useMemo } from 'react';
import { DataFrame, Field } from '@grafana/data';
import { Cell, Column, HeaderGroup, useBlockLayout, useSortBy, useTable } from 'react-table';
import { Cell, Column, HeaderGroup, useBlockLayout, useResizeColumns, useSortBy, useTable } from 'react-table';
import { FixedSizeList } from 'react-window';
import useMeasure from 'react-use/lib/useMeasure';
import { getColumns, getTableRows, getTextAlign } from './utils';
import { useTheme } from '../../themes';
import { TableFilterActionCallback } from './types';
import { getTableStyles } from './styles';
import { ColumnResizeActionCallback, TableFilterActionCallback } from './types';
import { getTableStyles, TableStyles } from './styles';
import { TableCell } from './TableCell';
import { Icon } from '../Icon/Icon';
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
const COLUMN_MIN_WIDTH = 150;
export interface Props {
data: DataFrame;
width: number;
......@@ -18,90 +20,123 @@ export interface Props {
/** Minimal column width specified in pixels */
columnMinWidth?: number;
noHeader?: boolean;
resizable?: boolean;
onCellClick?: TableFilterActionCallback;
onColumnResize?: ColumnResizeActionCallback;
}
export const Table: FC<Props> = memo(({ data, height, onCellClick, width, columnMinWidth, noHeader }) => {
const theme = useTheme();
const [ref, headerRowMeasurements] = useMeasure();
const tableStyles = getTableStyles(theme);
const memoizedColumns = useMemo(() => getColumns(data, width, columnMinWidth ?? 150), [data, width, columnMinWidth]);
const memoizedData = useMemo(() => getTableRows(data), [data]);
export const Table: FC<Props> = memo(
({ data, height, onCellClick, width, columnMinWidth = COLUMN_MIN_WIDTH, noHeader, resizable = false }) => {
const theme = useTheme();
const [ref, headerRowMeasurements] = useMeasure();
const tableStyles = getTableStyles(theme);
const memoizedColumns = useMemo(() => getColumns(data, width, columnMinWidth), [data, width, columnMinWidth]);
const memoizedData = useMemo(() => getTableRows(data), [data]);
const { getTableProps, headerGroups, rows, prepareRow } = useTable(
{
columns: memoizedColumns,
data: memoizedData,
},
useSortBy,
useBlockLayout
);
const defaultColumn = React.useMemo(
() => ({
minWidth: memoizedColumns.reduce((minWidth, column) => {
if (column.width) {
const width = typeof column.width === 'string' ? parseInt(column.width, 10) : column.width;
return Math.min(minWidth, width);
}
return minWidth;
}, columnMinWidth),
}),
[columnMinWidth, memoizedColumns]
);
const RenderRow = React.useCallback(
({ index, style }) => {
const row = rows[index];
prepareRow(row);
return (
<div {...row.getRowProps({ style })} className={tableStyles.row}>
{row.cells.map((cell: Cell, index: number) => (
<TableCell
key={index}
field={data.fields[index]}
tableStyles={tableStyles}
cell={cell}
onCellClick={onCellClick}
/>
))}
</div>
);
},
[prepareRow, rows]
);
const options: any = useMemo(
() => ({
columns: memoizedColumns,
data: memoizedData,
disableResizing: !resizable,
defaultColumn,
}),
[memoizedColumns, memoizedData, resizable, defaultColumn]
);
let totalWidth = 0;
const { getTableProps, headerGroups, rows, prepareRow, totalColumnsWidth } = useTable(
options,
useBlockLayout,
useResizeColumns,
useSortBy
);
for (const headerGroup of headerGroups) {
for (const header of headerGroup.headers) {
totalWidth += header.width as number;
}
}
const RenderRow = React.useCallback(
({ index, style }) => {
const row = rows[index];
prepareRow(row);
return (
<div {...row.getRowProps({ style })} className={tableStyles.row}>
{row.cells.map((cell: Cell, index: number) => (
<TableCell
key={index}
field={data.fields[index]}
tableStyles={tableStyles}
cell={cell}
onCellClick={onCellClick}
/>
))}
</div>
);
},
[prepareRow, rows]
);
return (
<div {...getTableProps()} className={tableStyles.table}>
<CustomScrollbar hideVerticalTrack={true}>
{!noHeader && (
<div>
{headerGroups.map((headerGroup: HeaderGroup) => (
<div className={tableStyles.thead} {...headerGroup.getHeaderGroupProps()} ref={ref}>
{headerGroup.headers.map((column: Column, index: number) =>
renderHeaderCell(column, tableStyles.headerCell, data.fields[index])
)}
return (
<div {...getTableProps()} className={tableStyles.table}>
<CustomScrollbar hideVerticalTrack={true}>
<div style={{ width: `${totalColumnsWidth}px` }}>
{!noHeader && (
<div>
{headerGroups.map((headerGroup: HeaderGroup) => {
return (
<div className={tableStyles.thead} {...headerGroup.getHeaderGroupProps()} ref={ref}>
{headerGroup.headers.map((column: Column, index: number) =>
renderHeaderCell(column, tableStyles, data.fields[index])
)}
</div>
);
})}
</div>
))}
)}
<FixedSizeList
height={height - headerRowMeasurements.height}
itemCount={rows.length}
itemSize={tableStyles.rowHeight}
width={'100%'}
style={{ overflow: 'hidden auto' }}
>
{RenderRow}
</FixedSizeList>
</div>
)}
<FixedSizeList
height={height - headerRowMeasurements.height}
itemCount={rows.length}
itemSize={tableStyles.rowHeight}
width={totalWidth ?? width}
style={{ overflow: 'hidden auto' }}
>
{RenderRow}
</FixedSizeList>
</CustomScrollbar>
</div>
);
});
</CustomScrollbar>
</div>
);
}
);
Table.displayName = 'Table';
function renderHeaderCell(column: any, tableStyles: TableStyles, field?: Field) {
const headerProps = column.getHeaderProps();
if (column.canResize) {
headerProps.style.userSelect = column.isResizing ? 'none' : 'auto'; // disables selecting text while resizing
}
function renderHeaderCell(column: any, className: string, field?: Field) {
const headerProps = column.getHeaderProps(column.getSortByToggleProps());
headerProps.style.textAlign = getTextAlign(field);
return (
<div className={className} {...headerProps}>
{column.render('Header')}
{column.isSorted && (column.isSortedDesc ? <Icon name="caret-down" /> : <Icon name="caret-up" />)}
<div className={tableStyles.headerCell} {...headerProps}>
{column.canSort && (
<div {...column.getSortByToggleProps()}>
{column.render('Header')}
{column.isSorted && (column.isSortedDesc ? <Icon name="caret-down" /> : <Icon name="caret-up" />)}
</div>
)}
{!column.canSort && <div>{column.render('Header')}</div>}
{column.canResize && <div {...column.getResizerProps()} className={tableStyles.resizeHandle} />}
</div>
);
}
......@@ -32,7 +32,7 @@ export const TableCell: FC<Props> = ({ cell, field, tableStyles, onCellClick })
}
return (
<div {...cellProps} onClick={onClick}>
<div {...cellProps} onClick={onClick} className={tableStyles.tableCellWrapper}>
{cell.render('Cell', { field, tableStyles })}
</div>
);
......
......@@ -11,14 +11,18 @@ export interface TableStyles {
thead: string;
headerCell: string;
tableCell: string;
tableCellWrapper: string;
row: string;
theme: GrafanaTheme;
resizeHandle: string;
}
export const getTableStyles = stylesFactory(
(theme: GrafanaTheme): TableStyles => {
const colors = theme.colors;
const headerBg = theme.isLight ? colors.gray98 : colors.gray15;
const headerBg = colors.panelBorder;
const headerBorderColor = theme.isLight ? colors.gray70 : colors.gray05;
const resizerColor = theme.isLight ? colors.blue77 : colors.blue95;
const padding = 6;
const lineHeight = theme.typography.lineHeight.md;
const bodyFontSize = 14;
......@@ -41,23 +45,55 @@ export const getTableStyles = stylesFactory(
overflow-y: auto;
overflow-x: hidden;
background: ${headerBg};
position: relative;
`,
headerCell: css`
padding: ${padding}px 10px;
cursor: pointer;
white-space: nowrap;
color: ${colors.blue};
border-right: 1px solid ${headerBorderColor};
&:last-child {
border-right: none;
}
`,
row: css`
label: row;
border-bottom: 1px solid ${headerBg};
`,
tableCellWrapper: css`
border-right: 1px solid ${headerBg};
&:last-child {
border-right: none;
}
`,
tableCell: css`
padding: ${padding}px 10px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
`,
resizeHandle: css`
label: resizeHandle;
cursor: col-resize !important;
display: inline-block;
border-right: 2px solid ${resizerColor};
opacity: 0;
transition: opacity 0.2s ease-in-out;
width: 10px;
height: 100%;
position: absolute;
right: 0;
top: 0;
z-index: ${theme.zIndex.dropdown};
touch-action: none;
&:hover {
opacity: 1;
}
`,
};
}
);
......@@ -23,6 +23,7 @@ export interface TableRow {
}
export type TableFilterActionCallback = (key: string, value: string) => void;
export type ColumnResizeActionCallback = (field: Field, width: number) => void;
export interface TableCellProps extends CellProps<any> {
tableStyles: TableStyles;
......
// Libraries
import React, { Component } from 'react';
// Types
import { Table } from '@grafana/ui';
import { PanelProps } from '@grafana/data';
import { Field, FieldMatcherID, PanelProps } from '@grafana/data';
import { Options } from './types';
interface Props extends PanelProps<Options> {}
......@@ -13,13 +11,39 @@ export class TablePanel extends Component<Props> {
super(props);
}
onColumnResize = (field: Field, width: number) => {
const current = this.props.fieldConfig;
const matcherId = FieldMatcherID.byName;
const prop = 'width';
const overrides = current.overrides.filter(
o => o.matcher.id !== matcherId || o.matcher.options !== field.name || o.properties[0].id !== prop
);
overrides.push({
matcher: { id: matcherId, options: field.name },
properties: [{ isCustom: true, id: prop, value: width }],
});
this.props.onFieldConfigChange({
...current,
overrides,
});
};
render() {
const { data, height, width, options } = this.props;
const {
data,
height,
width,
options: { showHeader, resizable },
} = this.props;
if (data.series.length < 1) {
return <div>No Table Data...</div>;
}
return <Table height={height - 16} width={width} data={data.series[0]} noHeader={!options.showHeader} />;
return (
<Table height={height - 16} width={width} data={data.series[0]} noHeader={!showHeader} resizable={resizable} />
);
}
}
//// Libraries
import _ from 'lodash';
import React, { PureComponent } from 'react';
// Types
import { PanelEditorProps } from '@grafana/data';
import { LegacyForms } from '@grafana/ui';
......
......@@ -52,4 +52,11 @@ export const plugin = new PanelPlugin<Options, CustomFieldConfig>(TablePanel)
name: 'Show header',
description: "To display table's header or not to display",
});
})
.setPanelOptions(builder => {
builder.addBooleanSwitch({
path: 'resizable',
name: 'Resizable',
description: 'Toggles if table columns are resizable or not',
});
});
export interface Options {
showHeader: boolean;
resizable: boolean;
}
export interface CustomFieldConfig {
......@@ -9,4 +10,5 @@ export interface CustomFieldConfig {
export const defaults: Options = {
showHeader: true,
resizable: false,
};
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment