diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json
index 6c28c8abbd3..aec32fd9282 100644
--- a/packages/grafana-ui/package.json
+++ b/packages/grafana-ui/package.json
@@ -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",
diff --git a/packages/grafana-ui/src/components/Table/TableInputCSV.story.tsx b/packages/grafana-ui/src/components/Table/TableInputCSV.story.tsx
new file mode 100644
index 00000000000..604eefca7a2
--- /dev/null
+++ b/packages/grafana-ui/src/components/Table/TableInputCSV.story.tsx
@@ -0,0 +1,25 @@
+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 (
+
+
{
+ console.log('Table', table, text);
+ action('Table')(table, text);
+ }}
+ />
+
+ );
+});
diff --git a/packages/grafana-ui/src/components/Table/TableInputCSV.test.tsx b/packages/grafana-ui/src/components/Table/TableInputCSV.test.tsx
new file mode 100644
index 00000000000..9e2dfbf80c3
--- /dev/null
+++ b/packages/grafana-ui/src/components/Table/TableInputCSV.test.tsx
@@ -0,0 +1,22 @@
+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(
+ {
+ // console.log('Table:', table, 'from:', text);
+ }}
+ />
+ )
+ .toJSON();
+ //expect(tree).toMatchSnapshot();
+ expect(tree).toBeDefined();
+ });
+});
diff --git a/packages/grafana-ui/src/components/Table/TableInputCSV.tsx b/packages/grafana-ui/src/components/Table/TableInputCSV.tsx
new file mode 100644
index 00000000000..14bc68550c3
--- /dev/null
+++ b/packages/grafana-ui/src/components/Table/TableInputCSV.tsx
@@ -0,0 +1,95 @@
+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 {
+ 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 (
+
+ {({ height, width }) => (
+
+
+
+
+ )}
+
+ );
+ }
+}
+
+export default TableInputCSV;
diff --git a/packages/grafana-ui/src/components/Table/_TableInputCSV.scss b/packages/grafana-ui/src/components/Table/_TableInputCSV.scss
new file mode 100644
index 00000000000..c819d636fc5
--- /dev/null
+++ b/packages/grafana-ui/src/components/Table/_TableInputCSV.scss
@@ -0,0 +1,24 @@
+.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;
+}
diff --git a/packages/grafana-ui/src/components/index.scss b/packages/grafana-ui/src/components/index.scss
index a475a7ccc1b..56e20bd78e7 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/TableInputCSV';
@import 'Tooltip/Tooltip';
@import 'Select/Select';
@import 'PanelOptionsGroup/PanelOptionsGroup';
diff --git a/packages/grafana-ui/src/types/data.ts b/packages/grafana-ui/src/types/data.ts
index 1e4ccba3948..e7e0bdc2b4e 100644
--- a/packages/grafana-ui/src/types/data.ts
+++ b/packages/grafana-ui/src/types/data.ts
@@ -53,7 +53,7 @@ export interface TimeSeriesVMs {
length: number;
}
-interface Column {
+export interface Column {
text: string;
title?: string;
type?: string;
diff --git a/packages/grafana-ui/src/utils/__snapshots__/processTableData.test.ts.snap b/packages/grafana-ui/src/utils/__snapshots__/processTableData.test.ts.snap
new file mode 100644
index 00000000000..42e25cb38e4
--- /dev/null
+++ b/packages/grafana-ui/src/utils/__snapshots__/processTableData.test.ts.snap
@@ -0,0 +1,66 @@
+// 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",
+}
+`;
diff --git a/packages/grafana-ui/src/utils/processTableData.test.ts b/packages/grafana-ui/src/utils/processTableData.test.ts
new file mode 100644
index 00000000000..fc2545b1706
--- /dev/null
+++ b/packages/grafana-ui/src/utils/processTableData.test.ts
@@ -0,0 +1,20 @@
+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();
+ });
+ });
+});
diff --git a/packages/grafana-ui/src/utils/processTableData.ts b/packages/grafana-ui/src/utils/processTableData.ts
new file mode 100644
index 00000000000..b9fd0519565
--- /dev/null
+++ b/packages/grafana-ui/src/utils/processTableData.ts
@@ -0,0 +1,133 @@
+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: {},
+ });
+}
diff --git a/yarn.lock b/yarn.lock
index e2e4b544195..0a3513595a8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"