From da41cd645ad805ee7aca2e364a6342575a32fedb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Tue, 7 Apr 2020 15:20:27 +0200 Subject: [PATCH] ReactTable: adds possibility to resize columns (#23365) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../components/Table/BackgroundColorCell.tsx | 7 +- .../grafana-ui/src/components/Table/Table.tsx | 183 +++++++++++------- .../src/components/Table/TableCell.tsx | 2 +- .../grafana-ui/src/components/Table/styles.ts | 38 +++- .../grafana-ui/src/components/Table/types.ts | 1 + .../app/plugins/panel/table2/TablePanel.tsx | 34 +++- .../plugins/panel/table2/TablePanelEditor.tsx | 2 - public/app/plugins/panel/table2/module.tsx | 7 + public/app/plugins/panel/table2/types.ts | 2 + 9 files changed, 191 insertions(+), 85 deletions(-) diff --git a/packages/grafana-ui/src/components/Table/BackgroundColorCell.tsx b/packages/grafana-ui/src/components/Table/BackgroundColorCell.tsx index 14bf6bc0577..dc76c362125 100644 --- a/packages/grafana-ui/src/components/Table/BackgroundColorCell.tsx +++ b/packages/grafana-ui/src/components/Table/BackgroundColorCell.tsx @@ -20,11 +20,14 @@ export const BackgroundColoredCell: FC = props => { const styles: CSSProperties = { background: `linear-gradient(120deg, ${bgColor2}, ${displayValue.color})`, - borderRadius: '0px', color: 'white', height: tableStyles.cellHeight, padding: tableStyles.cellPadding, }; - return
{formattedValueToString(displayValue)}
; + return ( +
+ {formattedValueToString(displayValue)} +
+ ); }; diff --git a/packages/grafana-ui/src/components/Table/Table.tsx b/packages/grafana-ui/src/components/Table/Table.tsx index 2f8fe133d16..317f7e35133 100644 --- a/packages/grafana-ui/src/components/Table/Table.tsx +++ b/packages/grafana-ui/src/components/Table/Table.tsx @@ -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 = 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 = 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 ( -
- {row.cells.map((cell: Cell, index: number) => ( - - ))} -
- ); - }, - [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 ( -
- - {!noHeader && ( -
- {headerGroups.map((headerGroup: HeaderGroup) => ( -
- {headerGroup.headers.map((column: Column, index: number) => - renderHeaderCell(column, tableStyles.headerCell, data.fields[index]) - )} -
+ const RenderRow = React.useCallback( + ({ index, style }) => { + const row = rows[index]; + prepareRow(row); + return ( +
+ {row.cells.map((cell: Cell, index: number) => ( + ))}
- )} - - {RenderRow} - - -
- ); -}); + ); + }, + [prepareRow, rows] + ); + + return ( +
+ +
+ {!noHeader && ( +
+ {headerGroups.map((headerGroup: HeaderGroup) => { + return ( +
+ {headerGroup.headers.map((column: Column, index: number) => + renderHeaderCell(column, tableStyles, data.fields[index]) + )} +
+ ); + })} +
+ )} + + {RenderRow} + +
+
+
+ ); + } +); + +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 ( -
- {column.render('Header')} - {column.isSorted && (column.isSortedDesc ? : )} +
+ {column.canSort && ( +
+ {column.render('Header')} + {column.isSorted && (column.isSortedDesc ? : )} +
+ )} + {!column.canSort &&
{column.render('Header')}
} + {column.canResize &&
}
); } diff --git a/packages/grafana-ui/src/components/Table/TableCell.tsx b/packages/grafana-ui/src/components/Table/TableCell.tsx index 551f667c4d8..1911bb466a0 100644 --- a/packages/grafana-ui/src/components/Table/TableCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableCell.tsx @@ -32,7 +32,7 @@ export const TableCell: FC = ({ cell, field, tableStyles, onCellClick }) } return ( -
+
{cell.render('Cell', { field, tableStyles })}
); diff --git a/packages/grafana-ui/src/components/Table/styles.ts b/packages/grafana-ui/src/components/Table/styles.ts index 2e98877434d..63850aa0a77 100644 --- a/packages/grafana-ui/src/components/Table/styles.ts +++ b/packages/grafana-ui/src/components/Table/styles.ts @@ -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; + } + `, }; } ); diff --git a/packages/grafana-ui/src/components/Table/types.ts b/packages/grafana-ui/src/components/Table/types.ts index 0b778b59929..ebc43b7fcd9 100644 --- a/packages/grafana-ui/src/components/Table/types.ts +++ b/packages/grafana-ui/src/components/Table/types.ts @@ -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 { tableStyles: TableStyles; diff --git a/public/app/plugins/panel/table2/TablePanel.tsx b/public/app/plugins/panel/table2/TablePanel.tsx index 981b6073f6f..a30bc755733 100644 --- a/public/app/plugins/panel/table2/TablePanel.tsx +++ b/public/app/plugins/panel/table2/TablePanel.tsx @@ -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 {} @@ -13,13 +11,39 @@ export class TablePanel extends Component { 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
No Table Data...
; } - return ; + return ( +
+ ); } } diff --git a/public/app/plugins/panel/table2/TablePanelEditor.tsx b/public/app/plugins/panel/table2/TablePanelEditor.tsx index 3da3585ae7d..fec7d93cc62 100644 --- a/public/app/plugins/panel/table2/TablePanelEditor.tsx +++ b/public/app/plugins/panel/table2/TablePanelEditor.tsx @@ -1,7 +1,5 @@ //// Libraries -import _ from 'lodash'; import React, { PureComponent } from 'react'; - // Types import { PanelEditorProps } from '@grafana/data'; import { LegacyForms } from '@grafana/ui'; diff --git a/public/app/plugins/panel/table2/module.tsx b/public/app/plugins/panel/table2/module.tsx index db413959907..d3aacc6d635 100644 --- a/public/app/plugins/panel/table2/module.tsx +++ b/public/app/plugins/panel/table2/module.tsx @@ -52,4 +52,11 @@ export const plugin = new PanelPlugin(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', + }); }); diff --git a/public/app/plugins/panel/table2/types.ts b/public/app/plugins/panel/table2/types.ts index d927f72e07a..fff20eedb9d 100644 --- a/public/app/plugins/panel/table2/types.ts +++ b/public/app/plugins/panel/table2/types.ts @@ -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, };