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 => { ...@@ -20,11 +20,14 @@ export const BackgroundColoredCell: FC<TableCellProps> = props => {
const styles: CSSProperties = { const styles: CSSProperties = {
background: `linear-gradient(120deg, ${bgColor2}, ${displayValue.color})`, background: `linear-gradient(120deg, ${bgColor2}, ${displayValue.color})`,
borderRadius: '0px',
color: 'white', color: 'white',
height: tableStyles.cellHeight, height: tableStyles.cellHeight,
padding: tableStyles.cellPadding, 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 React, { FC, memo, useMemo } from 'react';
import { DataFrame, Field } from '@grafana/data'; 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 { FixedSizeList } from 'react-window';
import useMeasure from 'react-use/lib/useMeasure'; import useMeasure from 'react-use/lib/useMeasure';
import { getColumns, getTableRows, getTextAlign } from './utils'; import { getColumns, getTableRows, getTextAlign } from './utils';
import { useTheme } from '../../themes'; import { useTheme } from '../../themes';
import { TableFilterActionCallback } from './types'; import { ColumnResizeActionCallback, TableFilterActionCallback } from './types';
import { getTableStyles } from './styles'; import { getTableStyles, TableStyles } from './styles';
import { TableCell } from './TableCell'; import { TableCell } from './TableCell';
import { Icon } from '../Icon/Icon'; import { Icon } from '../Icon/Icon';
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar'; import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
const COLUMN_MIN_WIDTH = 150;
export interface Props { export interface Props {
data: DataFrame; data: DataFrame;
width: number; width: number;
...@@ -18,90 +20,123 @@ export interface Props { ...@@ -18,90 +20,123 @@ export interface Props {
/** Minimal column width specified in pixels */ /** Minimal column width specified in pixels */
columnMinWidth?: number; columnMinWidth?: number;
noHeader?: boolean; noHeader?: boolean;
resizable?: boolean;
onCellClick?: TableFilterActionCallback; onCellClick?: TableFilterActionCallback;
onColumnResize?: ColumnResizeActionCallback;
} }
export const Table: FC<Props> = memo(({ data, height, onCellClick, width, columnMinWidth, noHeader }) => { export const Table: FC<Props> = memo(
const theme = useTheme(); ({ data, height, onCellClick, width, columnMinWidth = COLUMN_MIN_WIDTH, noHeader, resizable = false }) => {
const [ref, headerRowMeasurements] = useMeasure(); const theme = useTheme();
const tableStyles = getTableStyles(theme); const [ref, headerRowMeasurements] = useMeasure();
const memoizedColumns = useMemo(() => getColumns(data, width, columnMinWidth ?? 150), [data, width, columnMinWidth]); const tableStyles = getTableStyles(theme);
const memoizedData = useMemo(() => getTableRows(data), [data]); const memoizedColumns = useMemo(() => getColumns(data, width, columnMinWidth), [data, width, columnMinWidth]);
const memoizedData = useMemo(() => getTableRows(data), [data]);
const { getTableProps, headerGroups, rows, prepareRow } = useTable( const defaultColumn = React.useMemo(
{ () => ({
columns: memoizedColumns, minWidth: memoizedColumns.reduce((minWidth, column) => {
data: memoizedData, if (column.width) {
}, const width = typeof column.width === 'string' ? parseInt(column.width, 10) : column.width;
useSortBy, return Math.min(minWidth, width);
useBlockLayout }
); return minWidth;
}, columnMinWidth),
}),
[columnMinWidth, memoizedColumns]
);
const RenderRow = React.useCallback( const options: any = useMemo(
({ index, style }) => { () => ({
const row = rows[index]; columns: memoizedColumns,
prepareRow(row); data: memoizedData,
return ( disableResizing: !resizable,
<div {...row.getRowProps({ style })} className={tableStyles.row}> defaultColumn,
{row.cells.map((cell: Cell, index: number) => ( }),
<TableCell [memoizedColumns, memoizedData, resizable, defaultColumn]
key={index} );
field={data.fields[index]}
tableStyles={tableStyles}
cell={cell}
onCellClick={onCellClick}
/>
))}
</div>
);
},
[prepareRow, rows]
);
let totalWidth = 0; const { getTableProps, headerGroups, rows, prepareRow, totalColumnsWidth } = useTable(
options,
useBlockLayout,
useResizeColumns,
useSortBy
);
for (const headerGroup of headerGroups) { const RenderRow = React.useCallback(
for (const header of headerGroup.headers) { ({ index, style }) => {
totalWidth += header.width as number; 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 ( return (
<div {...getTableProps()} className={tableStyles.table}> <div {...getTableProps()} className={tableStyles.table}>
<CustomScrollbar hideVerticalTrack={true}> <CustomScrollbar hideVerticalTrack={true}>
{!noHeader && ( <div style={{ width: `${totalColumnsWidth}px` }}>
<div> {!noHeader && (
{headerGroups.map((headerGroup: HeaderGroup) => ( <div>
<div className={tableStyles.thead} {...headerGroup.getHeaderGroupProps()} ref={ref}> {headerGroups.map((headerGroup: HeaderGroup) => {
{headerGroup.headers.map((column: Column, index: number) => return (
renderHeaderCell(column, tableStyles.headerCell, data.fields[index]) <div className={tableStyles.thead} {...headerGroup.getHeaderGroupProps()} ref={ref}>
)} {headerGroup.headers.map((column: Column, index: number) =>
renderHeaderCell(column, tableStyles, data.fields[index])
)}
</div>
);
})}
</div> </div>
))} )}
<FixedSizeList
height={height - headerRowMeasurements.height}
itemCount={rows.length}
itemSize={tableStyles.rowHeight}
width={'100%'}
style={{ overflow: 'hidden auto' }}
>
{RenderRow}
</FixedSizeList>
</div> </div>
)} </CustomScrollbar>
<FixedSizeList </div>
height={height - headerRowMeasurements.height} );
itemCount={rows.length} }
itemSize={tableStyles.rowHeight} );
width={totalWidth ?? width}
style={{ overflow: 'hidden auto' }} Table.displayName = 'Table';
>
{RenderRow} function renderHeaderCell(column: any, tableStyles: TableStyles, field?: Field) {
</FixedSizeList> const headerProps = column.getHeaderProps();
</CustomScrollbar> if (column.canResize) {
</div> 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); headerProps.style.textAlign = getTextAlign(field);
return ( return (
<div className={className} {...headerProps}> <div className={tableStyles.headerCell} {...headerProps}>
{column.render('Header')} {column.canSort && (
{column.isSorted && (column.isSortedDesc ? <Icon name="caret-down" /> : <Icon name="caret-up" />)} <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> </div>
); );
} }
...@@ -32,7 +32,7 @@ export const TableCell: FC<Props> = ({ cell, field, tableStyles, onCellClick }) ...@@ -32,7 +32,7 @@ export const TableCell: FC<Props> = ({ cell, field, tableStyles, onCellClick })
} }
return ( return (
<div {...cellProps} onClick={onClick}> <div {...cellProps} onClick={onClick} className={tableStyles.tableCellWrapper}>
{cell.render('Cell', { field, tableStyles })} {cell.render('Cell', { field, tableStyles })}
</div> </div>
); );
......
...@@ -11,14 +11,18 @@ export interface TableStyles { ...@@ -11,14 +11,18 @@ export interface TableStyles {
thead: string; thead: string;
headerCell: string; headerCell: string;
tableCell: string; tableCell: string;
tableCellWrapper: string;
row: string; row: string;
theme: GrafanaTheme; theme: GrafanaTheme;
resizeHandle: string;
} }
export const getTableStyles = stylesFactory( export const getTableStyles = stylesFactory(
(theme: GrafanaTheme): TableStyles => { (theme: GrafanaTheme): TableStyles => {
const colors = theme.colors; 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 padding = 6;
const lineHeight = theme.typography.lineHeight.md; const lineHeight = theme.typography.lineHeight.md;
const bodyFontSize = 14; const bodyFontSize = 14;
...@@ -41,23 +45,55 @@ export const getTableStyles = stylesFactory( ...@@ -41,23 +45,55 @@ export const getTableStyles = stylesFactory(
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
background: ${headerBg}; background: ${headerBg};
position: relative;
`, `,
headerCell: css` headerCell: css`
padding: ${padding}px 10px; padding: ${padding}px 10px;
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
color: ${colors.blue}; color: ${colors.blue};
border-right: 1px solid ${headerBorderColor};
&:last-child {
border-right: none;
}
`, `,
row: css` row: css`
label: row; label: row;
border-bottom: 1px solid ${headerBg}; border-bottom: 1px solid ${headerBg};
`, `,
tableCellWrapper: css`
border-right: 1px solid ${headerBg};
&:last-child {
border-right: none;
}
`,
tableCell: css` tableCell: css`
padding: ${padding}px 10px; padding: ${padding}px 10px;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
overflow: hidden; 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 { ...@@ -23,6 +23,7 @@ export interface TableRow {
} }
export type TableFilterActionCallback = (key: string, value: string) => void; export type TableFilterActionCallback = (key: string, value: string) => void;
export type ColumnResizeActionCallback = (field: Field, width: number) => void;
export interface TableCellProps extends CellProps<any> { export interface TableCellProps extends CellProps<any> {
tableStyles: TableStyles; tableStyles: TableStyles;
......
// Libraries
import React, { Component } from 'react'; import React, { Component } from 'react';
// Types
import { Table } from '@grafana/ui'; import { Table } from '@grafana/ui';
import { PanelProps } from '@grafana/data'; import { Field, FieldMatcherID, PanelProps } from '@grafana/data';
import { Options } from './types'; import { Options } from './types';
interface Props extends PanelProps<Options> {} interface Props extends PanelProps<Options> {}
...@@ -13,13 +11,39 @@ export class TablePanel extends Component<Props> { ...@@ -13,13 +11,39 @@ export class TablePanel extends Component<Props> {
super(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() { render() {
const { data, height, width, options } = this.props; const {
data,
height,
width,
options: { showHeader, resizable },
} = this.props;
if (data.series.length < 1) { if (data.series.length < 1) {
return <div>No Table Data...</div>; 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 //// Libraries
import _ from 'lodash';
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
// Types // Types
import { PanelEditorProps } from '@grafana/data'; import { PanelEditorProps } from '@grafana/data';
import { LegacyForms } from '@grafana/ui'; import { LegacyForms } from '@grafana/ui';
......
...@@ -52,4 +52,11 @@ export const plugin = new PanelPlugin<Options, CustomFieldConfig>(TablePanel) ...@@ -52,4 +52,11 @@ export const plugin = new PanelPlugin<Options, CustomFieldConfig>(TablePanel)
name: 'Show header', name: 'Show header',
description: "To display table's header or not to display", 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 { export interface Options {
showHeader: boolean; showHeader: boolean;
resizable: boolean;
} }
export interface CustomFieldConfig { export interface CustomFieldConfig {
...@@ -9,4 +10,5 @@ export interface CustomFieldConfig { ...@@ -9,4 +10,5 @@ export interface CustomFieldConfig {
export const defaults: Options = { export const defaults: Options = {
showHeader: true, 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