move to grafana/ui

This commit is contained in:
ryan
2019-03-07 16:52:20 -08:00
parent 39be5959b8
commit d7b1fd75e3
9 changed files with 409 additions and 382 deletions
@@ -0,0 +1,186 @@
// Libraries
import React, { Component, ReactNode } from 'react';
import {
Table,
SortDirectionType,
SortIndicator,
Column,
TableHeaderProps,
TableCellProps,
Index,
} from 'react-virtualized';
import { Themeable } from '../../types/theme';
import { sortTableData } from '../../utils/processTimeSeries';
// Types
import { TableData, InterpolateFunction } from '../../types/index';
import { TableRenderer } from './renderer';
// Made to match the existing (untyped) settings in the angular table
export interface ColumnStyle {
pattern?: string;
alias?: string;
colorMode?: string;
colors?: any[];
decimals?: number;
thresholds?: any[];
type?: 'date' | 'number' | 'string' | 'hidden';
unit?: string;
dateFormat?: string;
sanitize?: boolean;
mappingType?: any;
valueMaps?: any;
rangeMaps?: any;
link?: any;
linkUrl?: any;
linkTooltip?: any;
linkTargetBlank?: boolean;
preserveFormat?: boolean;
}
interface Props extends Themeable {
data?: TableData;
showHeader: boolean;
styles: ColumnStyle[];
replaceVariables: InterpolateFunction;
width: number;
height: number;
}
interface State {
sortBy?: number;
sortDirection?: SortDirectionType;
data?: TableData;
}
export class DataTable extends Component<Props, State> {
renderer: TableRenderer;
static defaultProps = {
showHeader: true,
};
constructor(props: Props) {
super(props);
this.state = {
data: props.data,
};
this.renderer = this.createRenderer();
}
componentDidUpdate(prevProps: Props, prevState: State) {
const { data, styles } = this.props;
const { sortBy, sortDirection } = this.state;
const dataChanged = data !== prevProps.data;
// Update the renderer if options change
if (dataChanged || styles !== prevProps.styles) {
this.renderer = this.createRenderer();
}
// Update the data when data or sort changes
if (dataChanged || sortBy !== prevState.sortBy || sortDirection !== prevState.sortDirection) {
this.setState({ data: sortTableData(data, sortBy, sortDirection === 'DESC') });
}
}
// styles: ColumnStyle[],
// schema: Column[],
// rowGetter: (info: Index) => any[], // matches the table rowGetter
// replaceVariables: InterpolateFunction,
// isUTC?: boolean, // TODO? get UTC from props?
// theme?: GrafanaThemeType | undefined,
createRenderer(): TableRenderer {
const { styles, replaceVariables, theme } = this.props;
const { data } = this.state;
return new TableRenderer({
styles,
schema: data ? data.columns : [],
rowGetter: this.rowGetter,
replaceVariables,
isUTC: false,
theme: theme.type,
});
}
rowGetter = ({ index }: Index) => {
return this.state.data!.rows[index];
};
doSort = (info: any) => {
let dir = info.sortDirection;
let sort = info.sortBy;
if (sort !== this.state.sortBy) {
dir = 'DESC';
} else if (dir === 'DESC') {
dir = 'ASC';
} else {
sort = null;
}
this.setState({ sortBy: sort, sortDirection: dir });
};
headerRenderer = (header: TableHeaderProps): ReactNode => {
const dataKey = header.dataKey as any; // types say string, but it is number!
const { data, sortBy, sortDirection } = this.state;
const col = data!.columns[dataKey];
return (
<div>
{col.text} {sortBy === dataKey && <SortIndicator sortDirection={sortDirection} />}
</div>
);
};
cellRenderer = (cell: TableCellProps) => {
const { columnIndex, rowIndex } = cell;
const row = this.state.data!.rows[rowIndex];
const val = row[columnIndex];
return this.renderer.renderCell(columnIndex, rowIndex, val);
};
render() {
const { width, height, showHeader } = this.props;
const { data } = this.props;
if (!data) {
return <div>NO Data</div>;
}
return (
<Table
disableHeader={!showHeader}
headerHeight={30}
height={height}
overscanRowCount={10}
rowHeight={30}
rowGetter={this.rowGetter}
rowCount={data.rows.length}
sort={this.doSort}
width={width}
>
{data.columns.map((col, index) => {
return (
<Column
key={index}
dataKey={index}
headerRenderer={this.headerRenderer}
cellRenderer={this.cellRenderer}
width={150}
minWidth={50}
flexGrow={1}
/>
);
})}
</Table>
);
}
}
export default DataTable;
@@ -0,0 +1,392 @@
import _ from 'lodash';
import TableModel from 'app/core/table_model';
import { getColorDefinitionByName } from '@grafana/ui';
import { ScopedVars } from '@grafana/ui/src/types';
import moment from 'moment';
import { TableRenderer } from './renderer';
import { Index } from 'react-virtualized';
import { ColumnStyle } from './DataTable';
// TODO: this is commented out with *x* describe!
// Essentially all the elements need to replace the <td> with <div>
xdescribe('when rendering table', () => {
const SemiDarkOrange = getColorDefinitionByName('semi-dark-orange');
describe('given 13 columns', () => {
const table = new TableModel();
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' },
];
table.rows = [
[1388556366666, 1230, 40, undefined, '', '', 'my.host.com', 'host1', ['value1', 'value2'], 1, 2, 1, 2],
];
const styles: 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&param_1=$__cell_1&param_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)'],
},
];
// const sanitize = value => {
// return 'sanitized';
// };
const replaceVariables = (value: any, scopedVars: ScopedVars | undefined) => {
if (scopedVars) {
// For testing variables replacement in link
_.each(scopedVars, (val, key) => {
value = value.replace('$' + key, val.value);
});
}
return value;
};
const rowGetter = ({ index }: Index) => table.rows[index];
const renderer = new TableRenderer({
styles,
schema: table.columns,
rowGetter,
replaceVariables,
});
it('time column should be formated', () => {
const html = renderer.renderCell(0, 0, 1388556366666);
expect(html).toBe('<td>2014-01-01T06:06:06Z</td>');
});
it('time column with epoch as string should be formatted', () => {
const html = renderer.renderCell(0, 0, '1388556366666');
expect(html).toBe('<td>2014-01-01T06:06:06Z</td>');
});
it('time column with RFC2822 date as string should be formatted', () => {
const html = renderer.renderCell(0, 0, 'Sat, 01 Dec 2018 01:00:00 GMT');
expect(html).toBe('<td>2018-12-01T01:00:00Z</td>');
});
it('time column with ISO date as string should be formatted', () => {
const html = renderer.renderCell(0, 0, '2018-12-01T01:00:00Z');
expect(html).toBe('<td>2018-12-01T01:00:00Z</td>');
});
it('undefined time column should be rendered as -', () => {
const html = renderer.renderCell(0, 0, undefined);
expect(html).toBe('<td>-</td>');
});
it('null time column should be rendered as -', () => {
const html = renderer.renderCell(0, 0, null);
expect(html).toBe('<td>-</td>');
});
it('number column with unit specified should ignore style unit', () => {
const html = renderer.renderCell(5, 0, 1230);
expect(html).toBe('<td>1.23 kbps</td>');
});
it('number column should be formated', () => {
const html = renderer.renderCell(1, 0, 1230);
expect(html).toBe('<td>1.230 s</td>');
});
it('number style should ignore string values', () => {
const html = renderer.renderCell(1, 0, 'asd');
expect(html).toBe('<td>asd</td>');
});
it('colored cell should have style (handles HEX color values)', () => {
const html = renderer.renderCell(2, 0, 40);
expect(html).toBe('<td style="color:#00ff00">40.0</td>');
});
it('colored cell should have style (handles named color values', () => {
const html = renderer.renderCell(2, 0, 55);
expect(html).toBe(`<td style="color:${SemiDarkOrange.variants.dark}">55.0</td>`);
});
it('colored cell should have style handles(rgb color values)', () => {
const html = renderer.renderCell(2, 0, 85);
expect(html).toBe('<td style="color:rgb(1,0,0)">85.0</td>');
});
it('unformated undefined should be rendered as string', () => {
const html = renderer.renderCell(3, 0, 'value');
expect(html).toBe('<td>value</td>');
});
it('string style with escape html should return escaped html', () => {
const html = renderer.renderCell(4, 0, '&breaking <br /> the <br /> row');
expect(html).toBe('<td>&amp;breaking &lt;br /&gt; the &lt;br /&gt; row</td>');
});
it('undefined formater should return escaped html', () => {
const html = renderer.renderCell(3, 0, '&breaking <br /> the <br /> row');
expect(html).toBe('<td>&amp;breaking &lt;br /&gt; the &lt;br /&gt; row</td>');
});
it('undefined value should render as -', () => {
const html = renderer.renderCell(3, 0, undefined);
expect(html).toBe('<td></td>');
});
it('sanitized value should render as', () => {
const html = renderer.renderCell(6, 0, 'text <a href="http://google.com">link</a>');
expect(html).toBe('<td>sanitized</td>');
});
it('Time column title should be Timestamp', () => {
expect(table.columns[0].title).toBe('Timestamp');
});
it('Value column title should be Val', () => {
expect(table.columns[1].title).toBe('Val');
});
it('Colored column title should be Colored', () => {
expect(table.columns[2].title).toBe('Colored');
});
it('link should render as', () => {
const html = renderer.renderCell(7, 0, 'host1');
const expectedHtml = `
<td class="table-panel-cell-link">
<a href="/dashboard?param=host1&param_1=1230&param_2=40"
target="_blank" data-link-tooltip data-original-title="host1 1230 my.host.com" data-placement="right">
host1
</a>
</td>
`;
expect(normalize(html + '')).toBe(normalize(expectedHtml));
});
it('Array column should not use number as formatter', () => {
const html = renderer.renderCell(8, 0, ['value1', 'value2']);
expect(html).toBe('<td>value1, value2</td>');
});
it('numeric value should be mapped to text', () => {
const html = renderer.renderCell(9, 0, 1);
expect(html).toBe('<td>on</td>');
});
it('string numeric value should be mapped to text', () => {
const html = renderer.renderCell(9, 0, '0');
expect(html).toBe('<td>off</td>');
});
it('string value should be mapped to text', () => {
const html = renderer.renderCell(9, 0, 'HELLO WORLD');
expect(html).toBe('<td>HELLO GRAFANA</td>');
});
it('array column value should be mapped to text', () => {
const html = renderer.renderCell(9, 0, ['value1', 'value2']);
expect(html).toBe('<td>value3, value4</td>');
});
it('value should be mapped to text (range)', () => {
const html = renderer.renderCell(10, 0, 2);
expect(html).toBe('<td>on</td>');
});
it('value should be mapped to text (range)', () => {
const html = renderer.renderCell(10, 0, 5);
expect(html).toBe('<td>off</td>');
});
it('array column value should not be mapped to text', () => {
const html = renderer.renderCell(10, 0, ['value1', 'value2']);
expect(html).toBe('<td>value1, value2</td>');
});
it('value should be mapped to text and colored cell should have style', () => {
const html = renderer.renderCell(11, 0, 1);
expect(html).toBe(`<td style="color:${SemiDarkOrange.variants.dark}">on</td>`);
});
it('value should be mapped to text and colored cell should have style', () => {
const html = renderer.renderCell(11, 0, '1');
expect(html).toBe(`<td style="color:${SemiDarkOrange.variants.dark}">on</td>`);
});
it('value should be mapped to text and colored cell should have style', () => {
const html = renderer.renderCell(11, 0, 0);
expect(html).toBe('<td style="color:#00ff00">off</td>');
});
it('value should be mapped to text and colored cell should have style', () => {
const html = renderer.renderCell(11, 0, '0');
expect(html).toBe('<td style="color:#00ff00">off</td>');
});
it('value should be mapped to text and colored cell should have style', () => {
const html = renderer.renderCell(11, 0, '2.1');
expect(html).toBe('<td style="color:rgb(1,0,0)">2.1</td>');
});
it('value should be mapped to text (range) and colored cell should have style', () => {
const html = renderer.renderCell(12, 0, 0);
expect(html).toBe('<td style="color:#00ff00">0</td>');
});
it('value should be mapped to text (range) and colored cell should have style', () => {
const html = renderer.renderCell(12, 0, 1);
expect(html).toBe('<td style="color:#00ff00">on</td>');
});
it('value should be mapped to text (range) and colored cell should have style', () => {
const html = renderer.renderCell(12, 0, 4);
expect(html).toBe(`<td style="color:${SemiDarkOrange.variants.dark}">off</td>`);
});
it('value should be mapped to text (range) and colored cell should have style', () => {
const html = renderer.renderCell(12, 0, '7.1');
expect(html).toBe('<td style="color:rgb(1,0,0)">7.1</td>');
});
});
});
function normalize(str: string) {
return str.replace(/\s+/gm, ' ').trim();
}
@@ -0,0 +1,332 @@
// Libraries
import _ from 'lodash';
import moment from 'moment';
import React, { CSSProperties, ReactNode } from 'react';
import { sanitize } from 'app/core/utils/text';
// Types
import kbn from 'app/core/utils/kbn';
import { getValueFormat, getColorFromHexRgbOrName, GrafanaThemeType, InterpolateFunction, Column } from '@grafana/ui';
import { Index } from 'react-virtualized';
import { ColumnStyle } from './DataTable';
type CellFormatter = (v: any, style?: ColumnStyle) => string | undefined;
interface ColumnInfo {
header: string;
accessor: string; // the field name
style?: ColumnStyle;
hidden?: boolean;
formatter: CellFormatter;
filterable?: boolean;
}
interface RendererOptions {
styles: ColumnStyle[];
schema: Column[];
rowGetter: (info: Index) => any[]; // matches the table rowGetter
replaceVariables: InterpolateFunction;
isUTC?: boolean; // TODO? get UTC from props?
theme?: GrafanaThemeType | undefined;
}
export class TableRenderer {
columns: ColumnInfo[];
colorState: any;
constructor(private options: RendererOptions) {
const { schema, styles } = options;
this.colorState = {};
if (!schema) {
this.columns = [];
return;
}
this.columns = options.schema.map((col, index) => {
let title = col.text;
let style; // ColumnStyle
// Find the style based on the text
for (let i = 0; i < styles.length; i++) {
const s = styles[i];
const regex = kbn.stringToJsRegex(s.pattern);
if (title.match(regex)) {
style = s;
if (s.alias) {
title = title.replace(regex, s.alias);
}
break;
}
}
return {
header: title,
accessor: col.text, // unique?
style: style,
formatter: this.createColumnFormatter(col, style),
};
});
}
getColorForValue(value: any, style: ColumnStyle) {
if (!style.thresholds) {
return null;
}
for (let i = style.thresholds.length; i > 0; i--) {
if (value >= style.thresholds[i - 1]) {
return getColorFromHexRgbOrName(style.colors![i], this.options.theme);
}
}
return getColorFromHexRgbOrName(_.first(style.colors), this.options.theme);
}
defaultCellFormatter(v: any, style?: ColumnStyle): string {
if (v === null || v === void 0 || v === undefined) {
return '';
}
if (_.isArray(v)) {
v = v.join(', ');
}
if (style && style.sanitize) {
return sanitize(v);
} else {
return _.escape(v);
}
}
createColumnFormatter(header: Column, style?: ColumnStyle): CellFormatter {
if (!style) {
return this.defaultCellFormatter;
}
if (style.type === 'hidden') {
return v => {
return undefined;
};
}
if (style.type === 'date') {
return v => {
if (v === undefined || v === null) {
return '-';
}
if (_.isArray(v)) {
v = v[0];
}
let date = moment(v);
if (this.options.isUTC) {
date = date.utc();
}
return date.format(style.dateFormat);
};
}
if (style.type === 'string') {
return v => {
if (_.isArray(v)) {
v = v.join(', ');
}
const mappingType = style.mappingType || 0;
if (mappingType === 1 && style.valueMaps) {
for (let i = 0; i < style.valueMaps.length; i++) {
const map = style.valueMaps[i];
if (v === null) {
if (map.value === 'null') {
return map.text;
}
continue;
}
// Allow both numeric and string values to be mapped
if ((!_.isString(v) && Number(map.value) === Number(v)) || map.value === v) {
this.setColorState(v, style);
return this.defaultCellFormatter(map.text, style);
}
}
}
if (mappingType === 2 && style.rangeMaps) {
for (let i = 0; i < style.rangeMaps.length; i++) {
const map = style.rangeMaps[i];
if (v === null) {
if (map.from === 'null' && map.to === 'null') {
return map.text;
}
continue;
}
if (Number(map.from) <= Number(v) && Number(map.to) >= Number(v)) {
this.setColorState(v, style);
return this.defaultCellFormatter(map.text, style);
}
}
}
if (v === null || v === void 0) {
return '-';
}
this.setColorState(v, style);
return this.defaultCellFormatter(v, style);
};
}
if (style.type === 'number') {
const valueFormatter = getValueFormat(style.unit || header.unit);
return v => {
if (v === null || v === void 0) {
return '-';
}
if (_.isString(v) || _.isArray(v)) {
return this.defaultCellFormatter(v, style);
}
this.setColorState(v, style);
return valueFormatter(v, style.decimals, null);
};
}
return value => {
return this.defaultCellFormatter(value, style);
};
}
setColorState(value: any, style: ColumnStyle) {
if (!style.colorMode) {
return;
}
if (value === null || value === void 0 || _.isArray(value)) {
return;
}
if (_.isNaN(value)) {
return;
}
const numericValue = Number(value);
this.colorState[style.colorMode] = this.getColorForValue(numericValue, style);
}
renderRowVariables(rowIndex: number) {
const scopedVars: any = {};
const row = this.options.rowGetter({ index: rowIndex });
for (let i = 0; i < row.length; i++) {
scopedVars[`__cell_${i}`] = { value: row[i] };
}
return scopedVars;
}
renderCell(columnIndex: number, rowIndex: number, value: any): ReactNode {
const column = this.columns[columnIndex];
if (column.formatter) {
value = column.formatter(value, column.style);
}
const style: CSSProperties = {};
const cellClasses = [];
let cellClass = '';
if (this.colorState.cell) {
style.backgroundColor = this.colorState.cell;
style.color = 'white';
this.colorState.cell = null;
} else if (this.colorState.value) {
style.color = this.colorState.value;
this.colorState.value = null;
}
if (value === undefined) {
style.display = 'none';
column.hidden = true;
} else {
column.hidden = false;
}
if (column.style && column.style.preserveFormat) {
cellClasses.push('table-panel-cell-pre');
}
let columnHtml: JSX.Element;
if (column.style && column.style.link) {
// Render cell as link
const { replaceVariables } = this.options;
const scopedVars = this.renderRowVariables(rowIndex);
scopedVars['__cell'] = { value: value };
const cellLink = replaceVariables(column.style.linkUrl, scopedVars, encodeURIComponent);
const cellLinkTooltip = replaceVariables(column.style.linkTooltip, scopedVars);
const cellTarget = column.style.linkTargetBlank ? '_blank' : '';
cellClasses.push('table-panel-cell-link');
columnHtml = (
<a
href={cellLink}
target={cellTarget}
data-link-tooltip
data-original-title={cellLinkTooltip}
data-placement="right"
>
{value}
</a>
);
} else {
columnHtml = <span>{value}</span>;
}
let filterLink: JSX.Element | null = null;
if (column.filterable) {
cellClasses.push('table-panel-cell-filterable');
filterLink = (
<span>
<a
className="table-panel-filter-link"
data-link-tooltip
data-original-title="Filter out value"
data-placement="bottom"
data-row={rowIndex}
data-column={columnIndex}
data-operator="!="
>
<i className="fa fa-search-minus" />
</a>
<a
className="table-panel-filter-link"
data-link-tooltip
data-original-title="Filter for value"
data-placement="bottom"
data-row={rowIndex}
data-column={columnIndex}
data-operator="="
>
<i className="fa fa-search-plus" />
</a>
</span>
);
}
if (cellClasses.length) {
cellClass = cellClasses.join(' ');
}
style.width = '100%';
style.height = '100%';
columnHtml = (
<div className={cellClass} style={style}>
{columnHtml}
{filterLink}
</div>
);
return columnHtml;
}
}
@@ -4,7 +4,7 @@ import isNumber from 'lodash/isNumber';
import { colors } from './colors';
// Types
import { TimeSeries, TimeSeriesVMs, NullValueMode, TimeSeriesValue } from '../types';
import { TimeSeries, TableData, TimeSeriesVMs, NullValueMode, TimeSeriesValue } from '../types';
interface Options {
timeSeries: TimeSeries[];
@@ -173,3 +173,24 @@ export function processTimeSeries({ timeSeries, nullValueMode }: Options): TimeS
return vmSeries;
}
export function sortTableData(data?: TableData, sortIndex?: number, reverse = false): TableData {
if (data && 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;
}