diff --git a/packages/grafana-ui/src/components/Table/Table.story.tsx b/packages/grafana-ui/src/components/Table/Table.story.tsx new file mode 100644 index 00000000000..03765730343 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/Table.story.tsx @@ -0,0 +1,99 @@ +// import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { Table } from './Table'; +import { getTheme } from '../../themes'; + +import { migratedTestTable, migratedTestStyles, simpleTable } from './examples'; +import { ScopedVars, TableData, GrafanaThemeType } from '../../types/index'; +import { withFullSizeStory } from '../../utils/storybook/withFullSizeStory'; +import { number, boolean } from '@storybook/addon-knobs'; + +const replaceVariables = (value: string, scopedVars?: ScopedVars) => { + if (scopedVars) { + // For testing variables replacement in link + for (const key in scopedVars) { + const val = scopedVars[key]; + value = value.replace('$' + key, val.value); + } + } + return value; +}; + +export function columnIndexToLeter(column: number) { + const A = 'A'.charCodeAt(0); + const c1 = Math.floor(column / 26); + const c2 = column % 26; + if (c1 > 0) { + return String.fromCharCode(A + c1 - 1) + String.fromCharCode(A + c2); + } + return String.fromCharCode(A + c2); +} + +export function makeDummyTable(columnCount: number, rowCount: number): TableData { + return { + columns: Array.from(new Array(columnCount), (x, i) => { + return { + text: columnIndexToLeter(i), + }; + }), + rows: Array.from(new Array(rowCount), (x, rowId) => { + const suffix = (rowId + 1).toString(); + return Array.from(new Array(columnCount), (x, colId) => columnIndexToLeter(colId) + suffix); + }), + type: 'table', + columnMap: {}, + }; +} + +storiesOf('Alpha/Table', module) + .add('Basic Table', () => { + // NOTE: This example does not seem to survice rotate & + // Changing fixed headers... but the next one does? + // perhaps `simpleTable` is static and reused? + + const showHeader = boolean('Show Header', true); + const fixedHeader = boolean('Fixed Header', true); + const fixedColumns = number('Fixed Columns', 0, { min: 0, max: 50, step: 1, range: false }); + const rotate = boolean('Rotate', false); + + return withFullSizeStory(Table, { + styles: [], + data: simpleTable, + replaceVariables, + showHeader, + fixedHeader, + fixedColumns, + rotate, + theme: getTheme(GrafanaThemeType.Light), + }); + }) + .add('Variable Size', () => { + const columnCount = number('Column Count', 15, { min: 2, max: 50, step: 1, range: false }); + const rowCount = number('Row Count', 20, { min: 0, max: 100, step: 1, range: false }); + + const showHeader = boolean('Show Header', true); + const fixedHeader = boolean('Fixed Header', true); + const fixedColumns = number('Fixed Columns', 1, { min: 0, max: 50, step: 1, range: false }); + const rotate = boolean('Rotate', false); + + return withFullSizeStory(Table, { + styles: [], + data: makeDummyTable(columnCount, rowCount), + replaceVariables, + showHeader, + fixedHeader, + fixedColumns, + rotate, + theme: getTheme(GrafanaThemeType.Light), + }); + }) + .add('Test Config (migrated)', () => { + return withFullSizeStory(Table, { + styles: migratedTestStyles, + data: migratedTestTable, + replaceVariables, + showHeader: true, + rotate: true, + theme: getTheme(GrafanaThemeType.Light), + }); + }); diff --git a/packages/grafana-ui/src/components/Table/Table.tsx b/packages/grafana-ui/src/components/Table/Table.tsx new file mode 100644 index 00000000000..a551d6d221d --- /dev/null +++ b/packages/grafana-ui/src/components/Table/Table.tsx @@ -0,0 +1,287 @@ +// Libraries +import _ from 'lodash'; +import React, { Component, ReactElement } from 'react'; +import { + SortDirectionType, + SortIndicator, + MultiGrid, + CellMeasurerCache, + CellMeasurer, + GridCellProps, +} from 'react-virtualized'; +import { Themeable } from '../../types/theme'; + +import { sortTableData } from '../../utils/processTableData'; + +import { TableData, InterpolateFunction } from '@grafana/ui'; +import { + TableCellBuilder, + ColumnStyle, + getCellBuilder, + TableCellBuilderOptions, + simpleCellBuilder, +} from './TableCellBuilder'; +import { stringToJsRegex } from '../../utils/index'; + +export interface Props extends Themeable { + data: TableData; + + showHeader: boolean; + fixedHeader: boolean; + fixedColumns: number; + rotate: boolean; + styles: ColumnStyle[]; + + replaceVariables: InterpolateFunction; + width: number; + height: number; + isUTC?: boolean; +} + +interface State { + sortBy?: number; + sortDirection?: SortDirectionType; + data: TableData; +} + +interface ColumnRenderInfo { + header: string; + builder: TableCellBuilder; +} + +interface DataIndex { + column: number; + row: number; // -1 is the header! +} + +export class Table extends Component { + renderer: ColumnRenderInfo[]; + measurer: CellMeasurerCache; + scrollToTop = false; + + static defaultProps = { + showHeader: true, + fixedHeader: true, + fixedColumns: 0, + rotate: false, + }; + + constructor(props: Props) { + super(props); + + this.state = { + data: props.data, + }; + + this.renderer = this.initColumns(props); + this.measurer = new CellMeasurerCache({ + defaultHeight: 30, + defaultWidth: 150, + }); + } + + componentDidUpdate(prevProps: Props, prevState: State) { + const { data, styles, showHeader } = this.props; + const { sortBy, sortDirection } = this.state; + const dataChanged = data !== prevProps.data; + const configsChanged = + showHeader !== prevProps.showHeader || + this.props.rotate !== prevProps.rotate || + this.props.fixedColumns !== prevProps.fixedColumns || + this.props.fixedHeader !== prevProps.fixedHeader; + + // Reset the size cache + if (dataChanged || configsChanged) { + this.measurer.clearAll(); + } + + // Update the renderer if options change + // We only *need* do to this if the header values changes, but this does every data update + if (dataChanged || styles !== prevProps.styles) { + this.renderer = this.initColumns(this.props); + } + + // Update the data when data or sort changes + if (dataChanged || sortBy !== prevState.sortBy || sortDirection !== prevState.sortDirection) { + this.scrollToTop = true; + this.setState({ data: sortTableData(data, sortBy, sortDirection === 'DESC') }); + } + } + + /** Given the configuration, setup how each column gets rendered */ + initColumns(props: Props): ColumnRenderInfo[] { + const { styles, data } = props; + + return data.columns.map((col, index) => { + let title = col.text; + let style: ColumnStyle | null = null; // ColumnStyle + + // Find the style based on the text + for (let i = 0; i < styles.length; i++) { + const s = styles[i]; + const regex = stringToJsRegex(s.pattern); + if (title.match(regex)) { + style = s; + if (s.alias) { + title = title.replace(regex, s.alias); + } + break; + } + } + + return { + header: title, + builder: getCellBuilder(col, style, this.props), + }; + }); + } + + //---------------------------------------------------------------------- + //---------------------------------------------------------------------- + + doSort = (columnIndex: number) => { + let sort: any = this.state.sortBy; + let dir = this.state.sortDirection; + if (sort !== columnIndex) { + dir = 'DESC'; + sort = columnIndex; + } else if (dir === 'DESC') { + dir = 'ASC'; + } else { + sort = null; + } + this.setState({ sortBy: sort, sortDirection: dir }); + }; + + /** Converts the grid coordinates to TableData coordinates */ + getCellRef = (rowIndex: number, columnIndex: number): DataIndex => { + const { showHeader, rotate } = this.props; + const rowOffset = showHeader ? -1 : 0; + + if (rotate) { + return { column: rowIndex, row: columnIndex + rowOffset }; + } else { + return { column: columnIndex, row: rowIndex + rowOffset }; + } + }; + + onCellClick = (rowIndex: number, columnIndex: number) => { + const { row, column } = this.getCellRef(rowIndex, columnIndex); + if (row < 0) { + this.doSort(column); + } else { + const values = this.state.data.rows[row]; + const value = values[column]; + console.log('CLICK', value, row); + } + }; + + headerBuilder = (cell: TableCellBuilderOptions): ReactElement<'div'> => { + const { data, sortBy, sortDirection } = this.state; + const { columnIndex, rowIndex, style } = cell.props; + const { column } = this.getCellRef(rowIndex, columnIndex); + + let col = data.columns[column]; + const sorting = sortBy === column; + if (!col) { + col = { + text: '??' + columnIndex + '???', + }; + } + + return ( +
this.onCellClick(rowIndex, columnIndex)}> + {col.text} + {sorting && } +
+ ); + }; + + getTableCellBuilder = (column: number): TableCellBuilder => { + const render = this.renderer[column]; + if (render && render.builder) { + return render.builder; + } + return simpleCellBuilder; // the default + }; + + cellRenderer = (props: GridCellProps): React.ReactNode => { + const { rowIndex, columnIndex, key, parent } = props; + const { row, column } = this.getCellRef(rowIndex, columnIndex); + const { data } = this.state; + + const isHeader = row < 0; + const rowData = isHeader ? data.columns : data.rows[row]; + const value = rowData ? rowData[column] : ''; + const builder = isHeader ? this.headerBuilder : this.getTableCellBuilder(column); + + return ( + + {builder({ + value, + row: rowData, + column: data.columns[column], + table: this, + props, + })} + + ); + }; + + render() { + const { showHeader, fixedHeader, fixedColumns, rotate, width, height } = this.props; + const { data } = this.state; + + let columnCount = data.columns.length; + let rowCount = data.rows.length + (showHeader ? 1 : 0); + + let fixedColumnCount = Math.min(fixedColumns, columnCount); + let fixedRowCount = showHeader && fixedHeader ? 1 : 0; + + if (rotate) { + const temp = columnCount; + columnCount = rowCount; + rowCount = temp; + + fixedRowCount = 0; + fixedColumnCount = Math.min(fixedColumns, rowCount) + (showHeader && fixedHeader ? 1 : 0); + } + + // Called after sort or the data changes + const scroll = this.scrollToTop ? 1 : -1; + const scrollToRow = rotate ? -1 : scroll; + const scrollToColumn = rotate ? scroll : -1; + if (this.scrollToTop) { + this.scrollToTop = false; + } + + return ( + + ); + } +} + +export default Table; diff --git a/packages/grafana-ui/src/components/Table/TableCellBuilder.tsx b/packages/grafana-ui/src/components/Table/TableCellBuilder.tsx new file mode 100644 index 00000000000..b8cb91053d8 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/TableCellBuilder.tsx @@ -0,0 +1,291 @@ +// Libraries +import _ from 'lodash'; +import React, { ReactElement } from 'react'; +import { GridCellProps } from 'react-virtualized'; +import { Table, Props } from './Table'; +import moment from 'moment'; +import { ValueFormatter } from '../../utils/index'; +import { GrafanaTheme } from '../../types/theme'; +import { getValueFormat, getColorFromHexRgbOrName, Column } from '@grafana/ui'; +import { InterpolateFunction } from '../../types/panel'; + +export interface TableCellBuilderOptions { + value: any; + column?: Column; + row?: any[]; + table?: Table; + className?: string; + props: GridCellProps; +} + +export type TableCellBuilder = (cell: TableCellBuilderOptions) => ReactElement<'div'>; + +/** Simplest cell that just spits out the value */ +export const simpleCellBuilder: TableCellBuilder = (cell: TableCellBuilderOptions) => { + const { props, value, className } = cell; + const { style } = props; + + return ( +
+ {value} +
+ ); +}; + +// *************************************************************************** +// HERE BE DRAGONS!!! +// *************************************************************************** +// +// The following code has been migrated blindy two times from the angular +// table panel. I don't understand all the options nor do I know if they +// are correct! +// +// *************************************************************************** + +// Made to match the existing (untyped) settings in the angular table +export interface ColumnStyle { + pattern: string; + + alias?: string; + colorMode?: 'cell' | 'value'; + colors?: any[]; + decimals?: number; + thresholds?: any[]; + type?: 'date' | 'number' | 'string' | 'hidden'; + unit?: string; + dateFormat?: string; + sanitize?: boolean; // not used in react + mappingType?: any; + valueMaps?: any; + rangeMaps?: any; + + link?: any; + linkUrl?: any; + linkTooltip?: any; + linkTargetBlank?: boolean; + + preserveFormat?: boolean; +} + +// private mapper:ValueMapper, +// private style:ColumnStyle, +// private theme:GrafanaTheme, +// private column:Column, +// private replaceVariables: InterpolateFunction, +// private fmt?:ValueFormatter) { + +export function getCellBuilder(schema: Column, style: ColumnStyle | null, props: Props): TableCellBuilder { + if (!style) { + return simpleCellBuilder; + } + + if (style.type === 'hidden') { + // TODO -- for hidden, we either need to: + // 1. process the Table and remove hidden fields + // 2. do special math to pick the right column skipping hidden fields + throw new Error('hidden not supported!'); + } + + if (style.type === 'date') { + return new CellBuilderWithStyle( + (v: any) => { + if (v === undefined || v === null) { + return '-'; + } + + if (_.isArray(v)) { + v = v[0]; + } + let date = moment(v); + if (false) { + // TODO?????? this.props.isUTC) { + date = date.utc(); + } + return date.format(style.dateFormat); + }, + style, + props.theme, + schema, + props.replaceVariables + ).build; + } + + if (style.type === 'string') { + return new CellBuilderWithStyle( + (v: any) => { + if (_.isArray(v)) { + v = v.join(', '); + } + return v; + }, + style, + props.theme, + schema, + props.replaceVariables + ).build; + // TODO!!!! all the mapping stuff!!!! + } + + if (style.type === 'number') { + const valueFormatter = getValueFormat(style.unit || schema.unit || 'none'); + return new CellBuilderWithStyle( + (v: any) => { + if (v === null || v === void 0) { + return '-'; + } + return v; + }, + style, + props.theme, + schema, + props.replaceVariables, + valueFormatter + ).build; + } + + return simpleCellBuilder; +} + +type ValueMapper = (value: any) => any; + +// Runs the value through a formatter and adds colors to the cell properties +class CellBuilderWithStyle { + constructor( + private mapper: ValueMapper, + private style: ColumnStyle, + private theme: GrafanaTheme, + private column: Column, + private replaceVariables: InterpolateFunction, + private fmt?: ValueFormatter + ) { + // + console.log('COLUMN', column.text, theme); + } + + getColorForValue = (value: any): string | null => { + const { thresholds, colors } = this.style; + if (!thresholds || !colors) { + return null; + } + + for (let i = thresholds.length; i > 0; i--) { + if (value >= thresholds[i - 1]) { + return getColorFromHexRgbOrName(colors[i], this.theme.type); + } + } + return getColorFromHexRgbOrName(_.first(colors), this.theme.type); + }; + + build = (cell: TableCellBuilderOptions) => { + let { props } = cell; + let value = this.mapper(cell.value); + + if (_.isNumber(value)) { + if (this.fmt) { + value = this.fmt(value, this.style.decimals); + } + + // For numeric values set the color + const { colorMode } = this.style; + if (colorMode) { + const color = this.getColorForValue(Number(value)); + if (color) { + if (colorMode === 'cell') { + props = { + ...props, + style: { + ...props.style, + backgroundColor: color, + color: 'white', + }, + }; + } else if (colorMode === 'value') { + props = { + ...props, + style: { + ...props.style, + color: color, + }, + }; + } + } + } + } + + const cellClasses = []; + if (this.style.preserveFormat) { + cellClasses.push('table-panel-cell-pre'); + } + + if (this.style.link) { + // Render cell as link + const { row } = cell; + + const scopedVars: any = {}; + if (row) { + for (let i = 0; i < row.length; i++) { + scopedVars[`__cell_${i}`] = { value: row[i] }; + } + } + scopedVars['__cell'] = { value: value }; + + const cellLink = this.replaceVariables(this.style.linkUrl, scopedVars, encodeURIComponent); + const cellLinkTooltip = this.replaceVariables(this.style.linkTooltip, scopedVars); + const cellTarget = this.style.linkTargetBlank ? '_blank' : ''; + + cellClasses.push('table-panel-cell-link'); + value = ( + + {value} + + ); + } + + // ??? I don't think this will still work! + if (this.column.filterable) { + cellClasses.push('table-panel-cell-filterable'); + value = ( + <> + {value} + + + + + + + + + + ); + } + + let className; + if (cellClasses.length) { + className = cellClasses.join(' '); + } + + return simpleCellBuilder({ value, props, className }); + }; +} diff --git a/packages/grafana-ui/src/components/Table/_Table.scss b/packages/grafana-ui/src/components/Table/_Table.scss new file mode 100644 index 00000000000..d9fb2dafe6c --- /dev/null +++ b/packages/grafana-ui/src/components/Table/_Table.scss @@ -0,0 +1,80 @@ +// .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; + + border-right: 2px solid $body-bg; + border-bottom: 2px solid $body-bg; +} + +.gf-table-fixed-column { + border-right: 1px solid #ccc; +} diff --git a/packages/grafana-ui/src/components/Table/examples.ts b/packages/grafana-ui/src/components/Table/examples.ts new file mode 100644 index 00000000000..2d08e5cdf0c --- /dev/null +++ b/packages/grafana-ui/src/components/Table/examples.ts @@ -0,0 +1,167 @@ +import { TableData } from '../../types/data'; +import { ColumnStyle } from './TableCellBuilder'; + +import { getColorDefinitionByName } from '@grafana/ui'; + +const SemiDarkOrange = getColorDefinitionByName('semi-dark-orange'); + +export const migratedTestTable = { + type: 'table', + columns: [ + { text: 'Time' }, + { text: 'Value' }, + { text: 'Colored' }, + { text: 'Undefined' }, + { text: 'String' }, + { text: 'United', unit: 'bps' }, + { text: 'Sanitized' }, + { text: 'Link' }, + { text: 'Array' }, + { text: 'Mapping' }, + { text: 'RangeMapping' }, + { text: 'MappingColored' }, + { text: 'RangeMappingColored' }, + ], + rows: [[1388556366666, 1230, 40, undefined, '', '', 'my.host.com', 'host1', ['value1', 'value2'], 1, 2, 1, 2]], +} as TableData; + +export const migratedTestStyles: ColumnStyle[] = [ + { + pattern: 'Time', + type: 'date', + alias: 'Timestamp', + }, + { + pattern: '/(Val)ue/', + type: 'number', + unit: 'ms', + decimals: 3, + alias: '$1', + }, + { + pattern: 'Colored', + type: 'number', + unit: 'none', + decimals: 1, + colorMode: 'value', + thresholds: [50, 80], + colors: ['#00ff00', SemiDarkOrange.name, 'rgb(1,0,0)'], + }, + { + pattern: 'String', + type: 'string', + }, + { + pattern: 'String', + type: 'string', + }, + { + pattern: 'United', + type: 'number', + unit: 'ms', + decimals: 2, + }, + { + pattern: 'Sanitized', + type: 'string', + sanitize: true, + }, + { + pattern: 'Link', + type: 'string', + link: true, + linkUrl: '/dashboard?param=$__cell¶m_1=$__cell_1¶m_2=$__cell_2', + linkTooltip: '$__cell $__cell_1 $__cell_6', + linkTargetBlank: true, + }, + { + pattern: 'Array', + type: 'number', + unit: 'ms', + decimals: 3, + }, + { + pattern: 'Mapping', + type: 'string', + mappingType: 1, + valueMaps: [ + { + value: '1', + text: 'on', + }, + { + value: '0', + text: 'off', + }, + { + value: 'HELLO WORLD', + text: 'HELLO GRAFANA', + }, + { + value: 'value1, value2', + text: 'value3, value4', + }, + ], + }, + { + pattern: 'RangeMapping', + type: 'string', + mappingType: 2, + rangeMaps: [ + { + from: '1', + to: '3', + text: 'on', + }, + { + from: '3', + to: '6', + text: 'off', + }, + ], + }, + { + pattern: 'MappingColored', + type: 'string', + mappingType: 1, + valueMaps: [ + { + value: '1', + text: 'on', + }, + { + value: '0', + text: 'off', + }, + ], + colorMode: 'value', + thresholds: [1, 2], + colors: ['#00ff00', SemiDarkOrange.name, 'rgb(1,0,0)'], + }, + { + pattern: 'RangeMappingColored', + type: 'string', + mappingType: 2, + rangeMaps: [ + { + from: '1', + to: '3', + text: 'on', + }, + { + from: '3', + to: '6', + text: 'off', + }, + ], + colorMode: 'value', + thresholds: [2, 5], + colors: ['#00ff00', SemiDarkOrange.name, 'rgb(1,0,0)'], + }, +]; + +export const simpleTable = { + type: 'table', + columns: [{ text: 'First' }, { text: 'Second' }, { text: 'Third' }], + rows: [[701, 205, 305], [702, 206, 301], [703, 207, 304]], +}; diff --git a/packages/grafana-ui/src/components/index.scss b/packages/grafana-ui/src/components/index.scss index 56e20bd78e7..ffd11febc29 100644 --- a/packages/grafana-ui/src/components/index.scss +++ b/packages/grafana-ui/src/components/index.scss @@ -1,6 +1,7 @@ @import 'CustomScrollbar/CustomScrollbar'; @import 'DeleteButton/DeleteButton'; @import 'ThresholdsEditor/ThresholdsEditor'; +@import 'Table/Table'; @import 'Table/TableInputCSV'; @import 'Tooltip/Tooltip'; @import 'Select/Select'; diff --git a/packages/grafana-ui/src/utils/processTableData.ts b/packages/grafana-ui/src/utils/processTableData.ts index b9fd0519565..d65e42827e0 100644 --- a/packages/grafana-ui/src/utils/processTableData.ts +++ b/packages/grafana-ui/src/utils/processTableData.ts @@ -1,7 +1,10 @@ -import { TableData, Column } from '../types/index'; - +// Libraries +import isNumber from 'lodash/isNumber'; import Papa, { ParseError, ParseMeta } from 'papaparse'; +// Types +import { TableData, Column } from '../types'; + // Subset of all parse options export interface TableParseOptions { headerIsFirstLine?: boolean; // Not a papa-parse option @@ -131,3 +134,24 @@ export function parseCSV(text: string, options?: TableParseOptions, details?: Ta columnMap: {}, }); } + +export function sortTableData(data: TableData, sortIndex?: number, reverse = false): TableData { + if (isNumber(sortIndex)) { + const copy = { + ...data, + rows: [...data.rows].sort((a, b) => { + a = a[sortIndex]; + b = b[sortIndex]; + // Sort null or undefined separately from comparable values + return +(a == null) - +(b == null) || +(a > b) || -(a < b); + }), + }; + + if (reverse) { + copy.rows.reverse(); + } + + return copy; + } + return data; +} diff --git a/packages/grafana-ui/src/utils/storybook/withFullSizeStory.tsx b/packages/grafana-ui/src/utils/storybook/withFullSizeStory.tsx new file mode 100644 index 00000000000..98c3ccb2e7f --- /dev/null +++ b/packages/grafana-ui/src/utils/storybook/withFullSizeStory.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { AutoSizer } from 'react-virtualized'; + +/** This will add full size with & height properties */ +export const withFullSizeStory = (component: React.ComponentType, props: any) => ( +
+ + {({ width, height }) => ( + <> + {React.createElement(component, { + ...props, + width, + height, + })} + + )} + +
+); diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index 078443b019a..dba6478baab 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -23,6 +23,7 @@ import * as pluginsListPanel from 'app/plugins/panel/pluginlist/module'; import * as alertListPanel from 'app/plugins/panel/alertlist/module'; import * as heatmapPanel from 'app/plugins/panel/heatmap/module'; import * as tablePanel from 'app/plugins/panel/table/module'; +import * as table2Panel from 'app/plugins/panel/table2/module'; import * as singlestatPanel from 'app/plugins/panel/singlestat/module'; import * as gettingStartedPanel from 'app/plugins/panel/gettingstarted/module'; import * as gaugePanel from 'app/plugins/panel/gauge/module'; @@ -53,6 +54,7 @@ const builtInPlugins = { 'app/plugins/panel/alertlist/module': alertListPanel, 'app/plugins/panel/heatmap/module': heatmapPanel, 'app/plugins/panel/table/module': tablePanel, + 'app/plugins/panel/table2/module': table2Panel, 'app/plugins/panel/singlestat/module': singlestatPanel, 'app/plugins/panel/gettingstarted/module': gettingStartedPanel, 'app/plugins/panel/gauge/module': gaugePanel, diff --git a/public/app/plugins/panel/table/renderer.ts b/public/app/plugins/panel/table/renderer.ts index 6b6189f7482..bbc0f3f9ea6 100644 --- a/public/app/plugins/panel/table/renderer.ts +++ b/public/app/plugins/panel/table/renderer.ts @@ -1,6 +1,7 @@ import _ from 'lodash'; import moment from 'moment'; import { getValueFormat, getColorFromHexRgbOrName, GrafanaThemeType, stringToJsRegex } from '@grafana/ui'; +import { ColumnStyle } from '@grafana/ui/src/components/Table/TableCellBuilder'; export class TableRenderer { formatters: any[]; @@ -50,7 +51,7 @@ export class TableRenderer { } } - getColorForValue(value, style) { + getColorForValue(value, style: ColumnStyle) { if (!style.thresholds) { return null; } @@ -62,7 +63,7 @@ export class TableRenderer { return getColorFromHexRgbOrName(_.first(style.colors), this.theme); } - defaultCellFormatter(v, style) { + defaultCellFormatter(v, style: ColumnStyle) { if (v === null || v === void 0 || v === undefined) { return ''; } @@ -189,7 +190,7 @@ export class TableRenderer { }; } - setColorState(value, style) { + setColorState(value, style: ColumnStyle) { if (!style.colorMode) { return; } diff --git a/public/app/plugins/panel/table2/README.md b/public/app/plugins/panel/table2/README.md new file mode 100644 index 00000000000..98f2c13f75c --- /dev/null +++ b/public/app/plugins/panel/table2/README.md @@ -0,0 +1,9 @@ +# Table Panel - Native Plugin + +The Table Panel is **included** with Grafana. + +The table panel is very flexible, supporting both multiple modes for time series as well as for table, annotation and raw JSON data. It also provides date formatting and value formatting and coloring options. + +Check out the [Table Panel Showcase in the Grafana Playground](http://play.grafana.org/dashboard/db/table-panel-showcase) or read more about it here: + +[http://docs.grafana.org/reference/table_panel/](http://docs.grafana.org/reference/table_panel/) diff --git a/public/app/plugins/panel/table2/TablePanel.tsx b/public/app/plugins/panel/table2/TablePanel.tsx new file mode 100644 index 00000000000..a7cd84f8ecb --- /dev/null +++ b/public/app/plugins/panel/table2/TablePanel.tsx @@ -0,0 +1,29 @@ +// Libraries +import React, { Component } from 'react'; + +// Types +import { PanelProps, ThemeContext } from '@grafana/ui'; +import { Options } from './types'; +import Table from '@grafana/ui/src/components/Table/Table'; + +interface Props extends PanelProps {} + +export class TablePanel extends Component { + constructor(props: Props) { + super(props); + } + + render() { + const { panelData, options } = this.props; + + if (!panelData || !panelData.tableData) { + return
No Table Data...
; + } + + return ( + + {theme => } + + ); + } +} diff --git a/public/app/plugins/panel/table2/TablePanelEditor.tsx b/public/app/plugins/panel/table2/TablePanelEditor.tsx new file mode 100644 index 00000000000..ca64cc5e9d0 --- /dev/null +++ b/public/app/plugins/panel/table2/TablePanelEditor.tsx @@ -0,0 +1,55 @@ +//// Libraries +import _ from 'lodash'; +import React, { PureComponent } from 'react'; + +// Types +import { PanelEditorProps, Switch, FormField } from '@grafana/ui'; +import { Options } from './types'; + +export class TablePanelEditor extends PureComponent> { + onToggleShowHeader = () => { + this.props.onOptionsChange({ ...this.props.options, showHeader: !this.props.options.showHeader }); + }; + + onToggleFixedHeader = () => { + this.props.onOptionsChange({ ...this.props.options, fixedHeader: !this.props.options.fixedHeader }); + }; + + onToggleRotate = () => { + this.props.onOptionsChange({ ...this.props.options, rotate: !this.props.options.rotate }); + }; + + onFixedColumnsChange = ({ target }) => { + this.props.onOptionsChange({ ...this.props.options, fixedColumns: target.value }); + }; + + render() { + const { showHeader, fixedHeader, rotate, fixedColumns } = this.props.options; + + return ( +
+
+
Header
+ + +
+ +
+
Display
+ + +
+
+ ); + } +} diff --git a/public/app/plugins/panel/table2/img/icn-table-panel.svg b/public/app/plugins/panel/table2/img/icn-table-panel.svg new file mode 100644 index 00000000000..83097e259dc --- /dev/null +++ b/public/app/plugins/panel/table2/img/icn-table-panel.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/app/plugins/panel/table2/module.tsx b/public/app/plugins/panel/table2/module.tsx new file mode 100644 index 00000000000..d93e7911074 --- /dev/null +++ b/public/app/plugins/panel/table2/module.tsx @@ -0,0 +1,9 @@ +import { ReactPanelPlugin } from '@grafana/ui'; + +import { TablePanelEditor } from './TablePanelEditor'; +import { TablePanel } from './TablePanel'; +import { Options, defaults } from './types'; + +export const reactPanel = new ReactPanelPlugin(TablePanel); +reactPanel.setEditor(TablePanelEditor); +reactPanel.setDefaults(defaults); diff --git a/public/app/plugins/panel/table2/plugin.json b/public/app/plugins/panel/table2/plugin.json new file mode 100644 index 00000000000..4fa7728bd55 --- /dev/null +++ b/public/app/plugins/panel/table2/plugin.json @@ -0,0 +1,19 @@ +{ + "type": "panel", + "name": "React Table", + "id": "table2", + "state": "alpha", + + "dataFormats": ["table"], + + "info": { + "author": { + "name": "Grafana Project", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/icn-table-panel.svg", + "large": "img/icn-table-panel.svg" + } + } +} diff --git a/public/app/plugins/panel/table2/types.ts b/public/app/plugins/panel/table2/types.ts new file mode 100644 index 00000000000..d58c58810ef --- /dev/null +++ b/public/app/plugins/panel/table2/types.ts @@ -0,0 +1,35 @@ +import { ColumnStyle } from '@grafana/ui/src/components/Table/TableCellBuilder'; + +export interface Options { + showHeader: boolean; + fixedHeader: boolean; + fixedColumns: number; + rotate: boolean; + + styles: ColumnStyle[]; +} + +export const defaults: Options = { + showHeader: true, + fixedHeader: true, + fixedColumns: 0, + rotate: false, + styles: [ + { + type: 'date', + pattern: 'Time', + alias: 'Time', + dateFormat: 'YYYY-MM-DD HH:mm:ss', + }, + { + unit: 'short', + type: 'number', + alias: '', + decimals: 2, + colors: ['rgba(245, 54, 54, 0.9)', 'rgba(237, 129, 40, 0.89)', 'rgba(50, 172, 45, 0.97)'], + colorMode: null, + pattern: '/.*/', + thresholds: [], + }, + ], +};