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 = {
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
@ -1,16 +1,18 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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])
|
||||
)}
|
||||
</div>
|
||||
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>
|
||||
)}
|
||||
<FixedSizeList
|
||||
height={height - headerRowMeasurements.height}
|
||||
itemCount={rows.length}
|
||||
itemSize={tableStyles.rowHeight}
|
||||
width={totalWidth ?? width}
|
||||
style={{ overflow: 'hidden auto' }}
|
||||
>
|
||||
{RenderRow}
|
||||
</FixedSizeList>
|
||||
</CustomScrollbar>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
);
|
||||
},
|
||||
[prepareRow, rows]
|
||||
);
|
||||
|
||||
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>
|
||||
</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;
|
||||
|
@ -1,9 +1,7 @@
|
||||
// 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} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,5 @@
|
||||
//// 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',
|
||||
});
|
||||
});
|
||||
|
@ -1,5 +1,6 @@
|
||||
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,
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user