Browse Source

Merge pull request #15886 from ryantxu/table-editor

Add a basic table editor to grafana/ui
Torkel Ödegaard 7 years ago
parent
commit
6a34eb2d9a

+ 2 - 0
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",

+ 25 - 0
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 (
+    <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>
+  );
+});

+ 22 - 0
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(
+        <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();
+  });
+});

+ 95 - 0
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<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;

+ 24 - 0
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;
+}

+ 1 - 0
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';

+ 1 - 1
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;

+ 66 - 0
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",
+}
+`;

+ 20 - 0
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();
+    });
+  });
+});

+ 133 - 0
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: {},
+  });
+}

+ 12 - 0
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"