mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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>
This commit is contained in:
parent
92231cc42e
commit
da41cd645a
@ -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>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,16 +1,18 @@
|
|||||||
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 {
|
|||||||
/** 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}>
|
||||||
return (
|
{row.cells.map((cell: Cell, index: number) => (
|
||||||
<div {...getTableProps()} className={tableStyles.table}>
|
<TableCell
|
||||||
<CustomScrollbar hideVerticalTrack={true}>
|
key={index}
|
||||||
{!noHeader && (
|
field={data.fields[index]}
|
||||||
<div>
|
tableStyles={tableStyles}
|
||||||
{headerGroups.map((headerGroup: HeaderGroup) => (
|
cell={cell}
|
||||||
<div className={tableStyles.thead} {...headerGroup.getHeaderGroupProps()} ref={ref}>
|
onCellClick={onCellClick}
|
||||||
{headerGroup.headers.map((column: Column, index: number) =>
|
/>
|
||||||
renderHeaderCell(column, tableStyles.headerCell, data.fields[index])
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
<FixedSizeList
|
},
|
||||||
height={height - headerRowMeasurements.height}
|
[prepareRow, rows]
|
||||||
itemCount={rows.length}
|
);
|
||||||
itemSize={tableStyles.rowHeight}
|
|
||||||
width={totalWidth ?? width}
|
return (
|
||||||
style={{ overflow: 'hidden auto' }}
|
<div {...getTableProps()} className={tableStyles.table}>
|
||||||
>
|
<CustomScrollbar hideVerticalTrack={true}>
|
||||||
{RenderRow}
|
<div style={{ width: `${totalColumnsWidth}px` }}>
|
||||||
</FixedSizeList>
|
{!noHeader && (
|
||||||
</CustomScrollbar>
|
<div>
|
||||||
</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>
|
||||||
|
</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);
|
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 })
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
|||||||
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(
|
|||||||
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 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
// 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> {
|
|||||||
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} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
//// 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)
|
|||||||
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',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
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 {
|
|||||||
|
|
||||||
export const defaults: Options = {
|
export const defaults: Options = {
|
||||||
showHeader: true,
|
showHeader: true,
|
||||||
|
resizable: false,
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user