Commit 6a34eb2d by Torkel Ödegaard Committed by GitHub

Merge pull request #15886 from ryantxu/table-editor

Add a basic table editor to grafana/ui
parents a1b20c8c cb51f13e
......@@ -24,6 +24,7 @@
"jquery": "^3.2.1",
"lodash": "^4.17.10",
"moment": "^2.22.2",
"papaparse": "^4.6.3",
"react": "^16.6.3",
"react-color": "^2.17.0",
"react-custom-scrollbars": "^4.2.1",
......@@ -46,6 +47,7 @@
"@types/jquery": "^1.10.35",
"@types/lodash": "^4.14.119",
"@types/node": "^10.12.18",
"@types/papaparse": "^4.5.9",
"@types/react": "^16.7.6",
"@types/react-custom-scrollbars": "^4.0.5",
"@types/react-test-renderer": "^16.0.3",
......
import React from 'react';
import { storiesOf } from '@storybook/react';
import TableInputCSV from './TableInputCSV';
import { action } from '@storybook/addon-actions';
import { TableData } from '../../types/data';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
const TableInputStories = storiesOf('UI/Table/Input', module);
TableInputStories.addDecorator(withCenteredStory);
TableInputStories.add('default', () => {
return (
<div style={{ width: '90%', height: '90vh' }}>
<TableInputCSV
text={'a,b,c\n1,2,3'}
onTableParsed={(table: TableData, text: string) => {
console.log('Table', table, text);
action('Table')(table, text);
}}
/>
</div>
);
});
import React from 'react';
import renderer from 'react-test-renderer';
import TableInputCSV from './TableInputCSV';
import { TableData } from '../../types/data';
describe('TableInputCSV', () => {
it('renders correctly', () => {
const tree = renderer
.create(
<TableInputCSV
text={'a,b,c\n1,2,3'}
onTableParsed={(table: TableData, text: string) => {
// console.log('Table:', table, 'from:', text);
}}
/>
)
.toJSON();
//expect(tree).toMatchSnapshot();
expect(tree).toBeDefined();
});
});
import React from 'react';
import debounce from 'lodash/debounce';
import { parseCSV, TableParseOptions, TableParseDetails } from '../../utils/processTableData';
import { TableData } from '../../types/data';
import { AutoSizer } from 'react-virtualized';
interface Props {
options?: TableParseOptions;
text: string;
onTableParsed: (table: TableData, text: string) => void;
}
interface State {
text: string;
table: TableData;
details: TableParseDetails;
}
/**
* Expects the container div to have size set and will fill it 100%
*/
class TableInputCSV extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
// Shoud this happen in onComponentMounted?
const { text, options, onTableParsed } = props;
const details = {};
const table = parseCSV(text, options, details);
this.state = {
text,
table,
details,
};
onTableParsed(table, text);
}
readCSV = debounce(() => {
const details = {};
const table = parseCSV(this.state.text, this.props.options, details);
this.setState({ table, details });
}, 150);
componentDidUpdate(prevProps: Props, prevState: State) {
const { text } = this.state;
if (text !== prevState.text || this.props.options !== prevProps.options) {
this.readCSV();
}
// If the props text has changed, replace our local version
if (this.props.text !== prevProps.text && this.props.text !== text) {
this.setState({ text: this.props.text });
}
if (this.state.table !== prevState.table) {
this.props.onTableParsed(this.state.table, this.state.text);
}
}
onFooterClicked = (event: any) => {
console.log('Errors', this.state);
const message = this.state.details
.errors!.map(err => {
return err.message;
})
.join('\n');
alert('CSV Parsing Errors:\n' + message);
};
onTextChange = (event: any) => {
this.setState({ text: event.target.value });
};
render() {
const { table, details } = this.state;
const hasErrors = details.errors && details.errors.length > 0;
const footerClassNames = hasErrors ? 'gf-table-input-csv-err' : '';
return (
<AutoSizer>
{({ height, width }) => (
<div className="gf-table-input-csv" style={{ width, height }}>
<textarea placeholder="Enter CSV here..." value={this.state.text} onChange={this.onTextChange} />
<footer onClick={this.onFooterClicked} className={footerClassNames}>
Rows:{table.rows.length}, Columns:{table.columns.length} &nbsp;
{hasErrors ? <i className="fa fa-exclamation-triangle" /> : <i className="fa fa-check-circle" />}
</footer>
</div>
)}
</AutoSizer>
);
}
}
export default TableInputCSV;
.gf-table-input-csv {
position: relative;
}
.gf-table-input-csv textarea {
height: 100%;
width: 100%;
resize: none;
}
.gf-table-input-csv footer {
position: absolute;
bottom: 15px;
right: 15px;
border: 1px solid #222;
background: #ccc;
padding: 1px 4px;
font-size: 80%;
cursor: pointer;
}
.gf-table-input-csv footer.gf-table-input-csv-err {
background: yellow;
}
@import 'CustomScrollbar/CustomScrollbar';
@import 'DeleteButton/DeleteButton';
@import 'ThresholdsEditor/ThresholdsEditor';
@import 'Table/TableInputCSV';
@import 'Tooltip/Tooltip';
@import 'Select/Select';
@import 'PanelOptionsGroup/PanelOptionsGroup';
......
......@@ -53,7 +53,7 @@ export interface TimeSeriesVMs {
length: number;
}
interface Column {
export interface Column {
text: string;
title?: string;
type?: string;
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`processTableData basic processing should generate a header and fix widths 1`] = `
Object {
"columnMap": Object {},
"columns": Array [
Object {
"text": "Column 1",
},
Object {
"text": "Column 2",
},
Object {
"text": "Column 3",
},
],
"rows": Array [
Array [
1,
null,
null,
],
Array [
2,
3,
4,
],
Array [
5,
6,
null,
],
],
"type": "table",
}
`;
exports[`processTableData basic processing should read header and two rows 1`] = `
Object {
"columnMap": Object {},
"columns": Array [
Object {
"text": "a",
},
Object {
"text": "b",
},
Object {
"text": "c",
},
],
"rows": Array [
Array [
1,
2,
3,
],
Array [
4,
5,
6,
],
],
"type": "table",
}
`;
import { parseCSV } from './processTableData';
describe('processTableData', () => {
describe('basic processing', () => {
it('should read header and two rows', () => {
const text = 'a,b,c\n1,2,3\n4,5,6';
expect(parseCSV(text)).toMatchSnapshot();
});
it('should generate a header and fix widths', () => {
const text = '1\n2,3,4\n5,6';
const table = parseCSV(text, {
headerIsFirstLine: false,
});
expect(table.rows.length).toBe(3);
expect(table).toMatchSnapshot();
});
});
});
import { TableData, Column } from '../types/index';
import Papa, { ParseError, ParseMeta } from 'papaparse';
// Subset of all parse options
export interface TableParseOptions {
headerIsFirstLine?: boolean; // Not a papa-parse option
delimiter?: string; // default: ","
newline?: string; // default: "\r\n"
quoteChar?: string; // default: '"'
encoding?: string; // default: ""
comments?: boolean | string; // default: false
}
export interface TableParseDetails {
meta?: ParseMeta;
errors?: ParseError[];
}
/**
* This makes sure the header and all rows have equal length.
*
* @param table (immutable)
* @returns a new table that has equal length rows, or the same
* table if no changes were needed
*/
export function matchRowSizes(table: TableData): TableData {
const { rows } = table;
let { columns } = table;
let sameSize = true;
let size = columns.length;
rows.forEach(row => {
if (size !== row.length) {
sameSize = false;
size = Math.max(size, row.length);
}
});
if (sameSize) {
return table;
}
// Pad Columns
if (size !== columns.length) {
const diff = size - columns.length;
columns = [...columns];
for (let i = 0; i < diff; i++) {
columns.push({
text: 'Column ' + (columns.length + 1),
});
}
}
// Pad Rows
const fixedRows: any[] = [];
rows.forEach(row => {
const diff = size - row.length;
if (diff > 0) {
row = [...row];
for (let i = 0; i < diff; i++) {
row.push(null);
}
}
fixedRows.push(row);
});
return {
columns,
rows: fixedRows,
type: table.type,
columnMap: table.columnMap,
};
}
function makeColumns(values: any[]): Column[] {
return values.map((value, index) => {
if (!value) {
value = 'Column ' + (index + 1);
}
return {
text: value.toString().trim(),
};
});
}
/**
* Convert CSV text into a valid TableData object
*
* @param text
* @param options
* @param details, if exists the result will be filled with debugging details
*/
export function parseCSV(text: string, options?: TableParseOptions, details?: TableParseDetails): TableData {
const results = Papa.parse(text, { ...options, dynamicTyping: true, skipEmptyLines: true });
const { data, meta, errors } = results;
// Fill the parse details for debugging
if (details) {
details.errors = errors;
details.meta = meta;
}
if (!data || data.length < 1) {
// Show a more reasonable warning on empty input text
if (details && !text) {
errors.length = 0;
errors.push({
code: 'empty',
message: 'Empty input text',
type: 'warning',
row: 0,
});
details.errors = errors;
}
return {
columns: [],
rows: [],
type: 'table',
columnMap: {},
};
}
// Assume the first line is the header unless the config says its not
const headerIsNotFirstLine = options && options.headerIsFirstLine === false;
const header = headerIsNotFirstLine ? [] : results.data.shift();
return matchRowSizes({
columns: makeColumns(header),
rows: results.data,
type: 'table',
columnMap: {},
});
}
......@@ -1801,6 +1801,13 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-8.10.40.tgz#4314888d5cd537945d73e9ce165c04cc550144a4"
integrity sha512-RRSjdwz63kS4u7edIwJUn8NqKLLQ6LyqF/X4+4jp38MBT3Vwetewi2N4dgJEshLbDwNgOJXNYoOwzVZUSSLhkQ==
"@types/papaparse@^4.5.9":
version "4.5.9"
resolved "https://registry.yarnpkg.com/@types/papaparse/-/papaparse-4.5.9.tgz#ff887bd362f57cd0c87320d2de38ac232bb55e81"
integrity sha512-8Pmxp2IEd/y58tOIsiZkCbAkcKI7InYVpwZFVKJyweCVnqnVahKXVjfSo6gvxUVykQsJvtWB+s6Kc60znVfQVw==
dependencies:
"@types/node" "*"
"@types/prop-types@*":
version "15.5.8"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.5.8.tgz#8ae4e0ea205fe95c3901a5a1df7f66495e3a56ce"
......@@ -12993,6 +13000,11 @@ pako@~1.0.5:
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.8.tgz#6844890aab9c635af868ad5fecc62e8acbba3ea4"
integrity sha512-6i0HVbUfcKaTv+EG8ZTr75az7GFXcLYk9UyLEg7Notv/Ma+z/UG3TCoz6GiNeOrn1E/e63I0X/Hpw18jHOTUnA==
papaparse@^4.6.3:
version "4.6.3"
resolved "https://registry.yarnpkg.com/papaparse/-/papaparse-4.6.3.tgz#742e5eaaa97fa6c7e1358d2934d8f18f44aee781"
integrity sha512-LRq7BrHC2kHPBYSD50aKuw/B/dGcg29omyJbKWY3KsYUZU69RKwaBHu13jGmCYBtOc4odsLCrFyk6imfyNubJQ==
parallel-transform@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/parallel-transform/-/parallel-transform-1.1.0.tgz#d410f065b05da23081fcd10f28854c29bda33b06"
......
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