Commit e9079c3f by Peter Holmberg Committed by GitHub

grafana/ui: New table component (#20991)

* first working example

* Support sorting, adding types while waiting for official ones

* using react-window for windowing

* styles via emotion

* sizing

* set an offset for the table

* change table export

* fixing table cell widths

* Explore: Use new table component in explore (#21031)

* Explore: Use new table component in explore

* enable oncellclick

* only let filterable columns be clickable, refactor renderrow

* remove explore table

* Keep using old merge tables logic

* prettier

* remove unused typings file

* fixing tests

* Fixed explore table issue

* NewTable: Updated styles

* Fixed unit test

* Updated TableModel

* Minor update to explore table height

* typing
parent 841cffbe
......@@ -46,7 +46,6 @@
"@types/react-grid-layout": "0.16.7",
"@types/react-redux": "7.1.2",
"@types/react-select": "2.0.15",
"@types/react-table": "6.8.5",
"@types/react-test-renderer": "16.9.0",
"@types/react-transition-group": "2.0.16",
"@types/react-virtualized": "9.18.12",
......@@ -246,7 +245,6 @@
"react-popper": "1.3.3",
"react-redux": "7.1.1",
"react-sizeme": "2.5.2",
"react-table": "6.9.2",
"react-transition-group": "2.6.1",
"react-use": "12.8.0",
"react-virtualized": "9.21.0",
......
......@@ -50,6 +50,7 @@
"react-highlight-words": "0.11.0",
"react-popper": "1.3.3",
"react-storybook-addon-props-combinations": "1.1.0",
"react-table": "7.0.0-rc.4",
"react-transition-group": "2.6.1",
"react-virtualized": "9.21.0",
"slate": "0.47.8",
......
import React, { useMemo } from 'react';
import { DataFrame, GrafanaTheme } from '@grafana/data';
// @ts-ignore
import { useBlockLayout, useSortBy, useTable } from 'react-table';
import { FixedSizeList } from 'react-window';
import { css } from 'emotion';
import { stylesFactory, useTheme, selectThemeVariant as stv } from '../../themes';
export interface Props {
data: DataFrame;
width: number;
height: number;
onCellClick?: (key: string, value: string) => void;
}
const getTableData = (data: DataFrame) => {
const tableData = [];
for (let i = 0; i < data.length; i++) {
const row: { [key: string]: string | number } = {};
for (let j = 0; j < data.fields.length; j++) {
const prop = data.fields[j].name;
row[prop] = data.fields[j].values.get(i);
}
tableData.push(row);
}
return tableData;
};
const getColumns = (data: DataFrame) => {
return data.fields.map(field => {
return {
Header: field.name,
accessor: field.name,
field: field,
};
});
};
const getTableStyles = stylesFactory((theme: GrafanaTheme, columnWidth: number) => {
const colors = theme.colors;
const headerBg = stv({ light: colors.gray6, dark: colors.dark7 }, theme.type);
const padding = 5;
return {
cellHeight: padding * 2 + 14 * 1.5 + 2,
tableHeader: css`
padding: ${padding}px 10px;
background: ${headerBg};
cursor: pointer;
white-space: nowrap;
color: ${colors.blue};
border-bottom: 2px solid ${colors.bodyBg};
`,
tableCell: css`
display: 'table-cell';
padding: ${padding}px 10px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
width: ${columnWidth}px;
border-right: 2px solid ${colors.bodyBg};
border-bottom: 2px solid ${colors.bodyBg};
`,
};
});
const renderCell = (cell: any, columnWidth: number, cellStyles: string, onCellClick?: any) => {
const filterable = cell.column.field.config.filterable;
const style = {
cursor: `${filterable && onCellClick ? 'pointer' : 'default'}`,
};
return (
<div
className={cellStyles}
{...cell.getCellProps()}
onClick={filterable ? () => onCellClick(cell.column.Header, cell.value) : undefined}
style={style}
>
{cell.render('Cell')}
</div>
);
};
export const NewTable = ({ data, height, onCellClick, width }: Props) => {
const theme = useTheme();
const columnWidth = Math.floor(width / data.fields.length);
const tableStyles = getTableStyles(theme, columnWidth);
const { getTableProps, headerGroups, rows, prepareRow } = useTable(
{
columns: useMemo(() => getColumns(data), [data]),
data: useMemo(() => getTableData(data), [data]),
},
useSortBy,
useBlockLayout
);
const RenderRow = React.useCallback(
({ index, style }) => {
const row = rows[index];
prepareRow(row);
return (
<div {...row.getRowProps({ style })}>
{row.cells.map((cell: any) => renderCell(cell, columnWidth, tableStyles.tableCell, onCellClick))}
</div>
);
},
[prepareRow, rows]
);
return (
<div {...getTableProps()}>
<div>
{headerGroups.map((headerGroup: any) => (
<div {...headerGroup.getHeaderGroupProps()} style={{ display: 'table-row' }}>
{headerGroup.headers.map((column: any) => (
<div
className={tableStyles.tableHeader}
{...column.getHeaderProps(column.getSortByToggleProps())}
style={{ display: 'table-cell', width: `${columnWidth}px` }}
>
{column.render('Header')}
<span>{column.isSorted ? (column.isSortedDesc ? ' 🔽' : ' 🔼') : ''}</span>
</div>
))}
</div>
))}
</div>
<FixedSizeList height={height} itemCount={rows.length} itemSize={tableStyles.cellHeight} width={width}>
{RenderRow}
</FixedSizeList>
</div>
);
};
// .ReactVirtualized__Table {
// }
// .ReactVirtualized__Table__Grid {
// }
.ReactVirtualized__Table__headerRow {
font-weight: 700;
display: flex;
flex-direction: row;
align-items: left;
}
.ReactVirtualized__Table__row {
display: flex;
flex-direction: row;
align-items: center;
border-bottom: 2px solid $body-bg;
}
.ReactVirtualized__Table__headerTruncatedText {
display: inline-block;
max-width: 100%;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.ReactVirtualized__Table__headerColumn,
.ReactVirtualized__Table__rowColumn {
margin-right: 10px;
min-width: 0px;
}
.ReactVirtualized__Table__headerColumn:first-of-type,
.ReactVirtualized__Table__rowColumn:first-of-type {
margin-left: 10px;
}
.ReactVirtualized__Table__sortableHeaderColumn {
cursor: pointer;
}
.ReactVirtualized__Table__sortableHeaderIconContainer {
align-items: center;
}
.ReactVirtualized__Table__sortableHeaderIcon {
flex: 0 0 24px;
height: 1em;
width: 1em;
fill: currentColor;
}
.gf-table-header {
padding: 3px 10px;
background: $list-item-bg;
border-top: 2px solid $body-bg;
border-bottom: 2px solid $body-bg;
cursor: pointer;
white-space: nowrap;
color: $blue;
}
.gf-table-cell {
padding: 3px 10px;
background: $page-gradient;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
border-right: 2px solid $body-bg;
border-bottom: 2px solid $body-bg;
}
.gf-table-fixed-column {
border-right: 1px solid #ccc;
}
......@@ -9,7 +9,6 @@
@import 'PanelOptionsGroup/PanelOptionsGroup';
@import 'RefreshPicker/RefreshPicker';
@import 'Select/Select';
@import 'Table/Table';
@import 'Table/TableInputCSV';
@import 'ThresholdsEditor/ThresholdsEditor';
@import 'TimePicker/TimeOfDayPicker';
......
......@@ -46,7 +46,7 @@ export { QueryField } from './QueryField/QueryField';
// Renderless
export { SetInterval } from './SetInterval/SetInterval';
export { Table } from './Table/Table';
export { NewTable as Table } from './Table/NewTable';
export { TableInputCSV } from './Table/TableInputCSV';
// Visualizations
......
......@@ -315,7 +315,7 @@ export class Explore extends React.PureComponent<ExploreProps> {
/>
)}
{mode === ExploreMode.Metrics && (
<TableContainer exploreId={exploreId} onClickCell={this.onClickFilterLabel} />
<TableContainer width={width} exploreId={exploreId} onClickCell={this.onClickFilterLabel} />
)}
{mode === ExploreMode.Logs && (
<LogsContainer
......
import _ from 'lodash';
import React, { PureComponent } from 'react';
import ReactTable, { RowInfo } from 'react-table';
import TableModel from 'app/core/table_model';
const EMPTY_TABLE = new TableModel();
// Identify columns that contain values
const VALUE_REGEX = /^[Vv]alue #\d+/;
interface TableProps {
data: TableModel;
loading: boolean;
onClickCell?: (columnKey: string, rowValue: string) => void;
}
function prepareRows(rows: any[], columnNames: string[]) {
return rows.map(cells => _.zipObject(columnNames, cells));
}
export default class Table extends PureComponent<TableProps> {
getCellProps = (state: any, rowInfo: RowInfo, column: any) => {
return {
onClick: (e: React.SyntheticEvent) => {
// Only handle click on link, not the cell
if (e.target) {
const link = e.target as HTMLElement;
if (link.className === 'link') {
const columnKey = column.Header().props.title;
const rowValue = rowInfo.row[columnKey];
this.props.onClickCell?.(columnKey, rowValue);
}
}
},
};
};
render() {
const { data, loading } = this.props;
const tableModel = data || EMPTY_TABLE;
const columnNames = tableModel.columns.map(({ text }) => text);
const columns = tableModel.columns.map(({ filterable, text }) => ({
Header: () => <span title={text}>{text}</span>,
accessor: text,
className: VALUE_REGEX.test(text) ? 'text-right' : '',
show: text !== 'Time',
Cell: (row: any) => (
<span className={filterable ? 'link' : ''} title={text + ': ' + row.value}>
{typeof row.value === 'string' ? row.value : JSON.stringify(row.value)}
</span>
),
}));
const noDataText = data ? 'The queries returned no data for a table.' : '';
return (
<ReactTable
columns={columns}
data={tableModel.rows}
getTdProps={this.getCellProps}
loading={loading}
minRows={0}
noDataText={noDataText}
resolveData={data => prepareRows(data, columnNames)}
showPagination={Boolean(data)}
/>
);
}
}
import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import { Collapse } from '@grafana/ui';
import { DataFrame } from '@grafana/data';
import { Table, Collapse } from '@grafana/ui';
import { ExploreId, ExploreItemState } from 'app/types/explore';
import { StoreState } from 'app/types';
import { toggleTable } from './state/actions';
import Table from './Table';
import TableModel from 'app/core/table_model';
interface TableContainerProps {
exploreId: ExploreId;
loading: boolean;
width: number;
onClickCell: (key: string, value: string) => void;
showingTable: boolean;
tableResult?: TableModel;
tableResult?: DataFrame;
toggleTable: typeof toggleTable;
}
......@@ -24,12 +22,27 @@ export class TableContainer extends PureComponent<TableContainerProps> {
this.props.toggleTable(this.props.exploreId, this.props.showingTable);
};
getTableHeight() {
const { tableResult } = this.props;
if (!tableResult || tableResult.length === 0) {
return 200;
}
// tries to estimate table height
return Math.max(Math.min(600, tableResult.length * 35) + 35);
}
render() {
const { loading, onClickCell, showingTable, tableResult } = this.props;
const { loading, onClickCell, showingTable, tableResult, width } = this.props;
const height = this.getTableHeight();
const paddingWidth = 16;
const tableWidth = width - paddingWidth;
return (
<Collapse label="Table" loading={loading} collapsible isOpen={showingTable} onToggle={this.onClickTableButton}>
{tableResult && <Table data={tableResult} loading={loading} onClickCell={onClickCell} />}
{tableResult && <Table data={tableResult} width={tableWidth} height={height} onCellClick={onClickCell} />}
</Collapse>
);
}
......@@ -40,7 +53,7 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string }
// @ts-ignore
const item: ExploreItemState = explore[exploreId];
const { loading: loadingInState, showingTable, tableResult } = item;
const loading = tableResult && tableResult.rows.length > 0 ? false : loadingInState;
const loading = tableResult && tableResult.length > 0 ? false : loadingInState;
return { loading, showingTable, tableResult };
}
......
......@@ -24,8 +24,7 @@ import { Reducer } from 'redux';
import { ActionOf } from 'app/core/redux/actionCreatorFactory';
import { updateLocation } from 'app/core/actions/location';
import { serializeStateToUrlParam } from 'app/core/utils/explore';
import TableModel from 'app/core/table_model';
import { DataSourceApi, DataQuery, LogsDedupStrategy, dateTime, LoadingState } from '@grafana/data';
import { DataSourceApi, DataQuery, LogsDedupStrategy, dateTime, LoadingState, toDataFrame } from '@grafana/data';
describe('Explore item reducer', () => {
describe('scanning', () => {
......@@ -174,12 +173,23 @@ describe('Explore item reducer', () => {
describe('when toggleTableAction is dispatched', () => {
it('then it should set correct state', () => {
const table = toDataFrame({
name: 'logs',
fields: [
{
name: 'time',
type: 'number',
values: [1, 2],
},
],
});
reducerTester()
.givenReducer(itemReducer, { tableResult: {} })
.givenReducer(itemReducer, { tableResult: table })
.whenActionIsDispatched(toggleTableAction({ exploreId: ExploreId.left }))
.thenStateShouldEqual({ showingTable: true, tableResult: {} })
.thenStateShouldEqual({ showingTable: true, tableResult: table })
.whenActionIsDispatched(toggleTableAction({ exploreId: ExploreId.left }))
.thenStateShouldEqual({ showingTable: false, tableResult: new TableModel() });
.thenStateShouldEqual({ showingTable: false, tableResult: null });
});
});
});
......
......@@ -62,7 +62,6 @@ import {
import { reducerFactory, ActionOf } from 'app/core/redux';
import { updateLocation } from 'app/core/actions/location';
import { LocationUpdate } from '@grafana/runtime';
import TableModel from 'app/core/table_model';
import { ResultProcessor } from '../utils/ResultProcessor';
export const DEFAULT_RANGE = {
......@@ -448,7 +447,7 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
return { ...state, showingTable };
}
return { ...state, showingTable, tableResult: new TableModel() };
return { ...state, showingTable, tableResult: null };
},
})
.addMapper({
......
......@@ -131,21 +131,23 @@ describe('ResultProcessor', () => {
it('then it should return correct table result', () => {
const { resultProcessor } = testContext();
const theResult = resultProcessor.getTableResult();
const resultDataFrame = toDataFrame(
new TableModel({
columns: [
{ text: 'value', type: 'number' },
{ text: 'time', type: 'time' },
{ text: 'message', type: 'string' },
],
rows: [
[4, 100, 'this is a message'],
[5, 200, 'second message'],
[6, 300, 'third'],
],
type: 'table',
})
);
expect(theResult).toEqual({
columnMap: {},
columns: [
{ text: 'value', type: 'number', filterable: undefined },
{ text: 'time', type: 'time', filterable: undefined },
{ text: 'message', type: 'string', filterable: undefined },
],
rows: [
[4, 100, 'this is a message'],
[5, 200, 'second message'],
[6, 300, 'third'],
],
type: 'table',
});
expect(theResult).toEqual(resultDataFrame);
});
});
......
import { LogsModel, GraphSeriesXY, DataFrame, FieldType, TimeZone } from '@grafana/data';
import { LogsModel, GraphSeriesXY, DataFrame, FieldType, TimeZone, toDataFrame } from '@grafana/data';
import { ExploreItemState, ExploreMode } from 'app/types/explore';
import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
import { sortLogsResult, refreshIntervalToSortOrder } from 'app/core/utils/explore';
......@@ -34,7 +33,7 @@ export class ResultProcessor {
);
}
getTableResult(): TableModel | null {
getTableResult(): DataFrame | null {
if (this.state.mode !== ExploreMode.Metrics) {
return null;
}
......@@ -75,7 +74,8 @@ export class ResultProcessor {
});
});
return mergeTablesIntoModel(new TableModel(), ...tables);
const mergedTable = mergeTablesIntoModel(new TableModel(), ...tables);
return toDataFrame(mergedTable);
}
getLogsResult(): LogsModel | null {
......
......@@ -2,29 +2,27 @@
import React, { Component } from 'react';
// Types
import { ThemeContext } from '@grafana/ui';
import { Table } from '@grafana/ui';
import { PanelProps } from '@grafana/data';
import { Options } from './types';
import Table from '@grafana/ui/src/components/Table/Table';
interface Props extends PanelProps<Options> {}
// So that the table does not go all the way to the edge of the panel chrome
const paddingBottom = 35;
export class TablePanel extends Component<Props> {
constructor(props: Props) {
super(props);
}
render() {
const { data, options } = this.props;
const { data, height, width } = this.props;
if (data.series.length < 1) {
return <div>No Table Data...</div>;
}
return (
<ThemeContext.Consumer>
{theme => <Table {...this.props} {...options} theme={theme} data={data.series[0]} />}
</ThemeContext.Consumer>
);
return <Table height={height - paddingBottom} width={width} data={data.series[0]} />;
}
}
......@@ -13,10 +13,10 @@ import {
LogsDedupStrategy,
AbsoluteTimeRange,
GraphSeriesXY,
DataFrame,
} from '@grafana/data';
import { Emitter } from 'app/core/core';
import TableModel from 'app/core/table_model';
export enum ExploreMode {
Metrics = 'Metrics',
......@@ -130,7 +130,7 @@ export interface ExploreItemState {
/**
* Table model that combines all query table results into a single table.
*/
tableResult?: TableModel;
tableResult?: DataFrame;
/**
* React keys for rendering of QueryRows
......
// DEPENDENCIES
@import '../../node_modules/react-table/react-table.css';
// MIXINS
@import 'mixins/mixins';
@import 'mixins/animations';
......
......@@ -4286,13 +4286,6 @@
dependencies:
"@types/react" "*"
"@types/react-table@6.8.5":
version "6.8.5"
resolved "https://registry.yarnpkg.com/@types/react-table/-/react-table-6.8.5.tgz#deb2bf2fcedcfb81e9020edbb7df0d8459ca348b"
integrity sha512-ueCsAadG1IwuuAZM+MWf2SoxbccSWweyQa9YG6xGN5cOVK3SayPOJW4MsUHGpY0V/Q+iZWgohpasliiao29O6g==
dependencies:
"@types/react" "*"
"@types/react-test-renderer@*":
version "16.9.1"
resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-16.9.1.tgz#9d432c46c515ebe50c45fa92c6fb5acdc22e39c4"
......@@ -15446,7 +15439,6 @@ npm@6.13.4:
cmd-shim "^3.0.3"
columnify "~1.5.4"
config-chain "^1.1.12"
debuglog "*"
detect-indent "~5.0.0"
detect-newline "^2.1.0"
dezalgo "~1.0.3"
......@@ -15461,7 +15453,6 @@ npm@6.13.4:
has-unicode "~2.0.1"
hosted-git-info "^2.8.5"
iferr "^1.0.2"
imurmurhash "*"
infer-owner "^1.0.4"
inflight "~1.0.6"
inherits "^2.0.4"
......@@ -15480,14 +15471,8 @@ npm@6.13.4:
libnpx "^10.2.0"
lock-verify "^2.1.0"
lockfile "^1.0.4"
lodash._baseindexof "*"
lodash._baseuniq "~4.6.0"
lodash._bindcallback "*"
lodash._cacheindexof "*"
lodash._createcache "*"
lodash._getnative "*"
lodash.clonedeep "~4.5.0"
lodash.restparam "*"
lodash.union "~4.6.0"
lodash.uniq "~4.5.0"
lodash.without "~4.4.0"
......@@ -18200,12 +18185,10 @@ react-syntax-highlighter@^8.0.1:
prismjs "^1.8.4"
refractor "^2.4.1"
react-table@6.9.2:
version "6.9.2"
resolved "https://registry.yarnpkg.com/react-table/-/react-table-6.9.2.tgz#6a59adfeb8d5deced288241ed1c7847035b5ec5f"
integrity sha512-sTbNHU8Um0xRtmCd1js873HXnXaMWeBwZoiljuj0l1d44eaqjKyYPK/3HCBbJg1yeE2O5pQJ3Km0tlm9niNL9w==
dependencies:
classnames "^2.2.5"
react-table@7.0.0-rc.4:
version "7.0.0-rc.4"
resolved "https://registry.yarnpkg.com/react-table/-/react-table-7.0.0-rc.4.tgz#88bc61747821f3c3bbbfc7e1a4a088cbe94ed9ee"
integrity sha512-NOYmNmAIvQ9sSZd5xMNSthqiZ/o5h8h28MhFQFSxCu5u3v9J8PNh7x9wYMnk737MTjoKCZWIZT/dMFCPItXzEg==
react-test-renderer@16.9.0:
version "16.9.0"
......
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