浏览代码

merge with master

ryan 6 年之前
父节点
当前提交
4783ea629b

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

+ 3 - 3
packages/grafana-ui/src/components/ColorPicker/ColorInput.tsx

@@ -39,7 +39,7 @@ class ColorInput extends React.PureComponent<ColorInputProps, ColorInputState> {
     this.props.onChange(color);
   };
 
-  handleChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
+  onChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
     const newColor = tinycolor(event.currentTarget.value);
 
     this.setState({
@@ -51,7 +51,7 @@ class ColorInput extends React.PureComponent<ColorInputProps, ColorInputState> {
     }
   };
 
-  handleBlur = () => {
+  onBlur = () => {
     const newColor = tinycolor(this.state.value);
 
     if (!newColor.isValid()) {
@@ -84,7 +84,7 @@ class ColorInput extends React.PureComponent<ColorInputProps, ColorInputState> {
             flexGrow: 1,
           }}
         >
-          <input className="gf-form-input" value={value} onChange={this.handleChange} onBlur={this.handleBlur} />
+          <input className="gf-form-input" value={value} onChange={this.onChange} onBlur={this.onBlur} />
         </div>
       </div>
     );

+ 2 - 2
packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx

@@ -15,7 +15,7 @@ export const colorPickerFactory = <T extends ColorPickerProps>(
     static displayName = displayName;
     pickerTriggerRef = createRef<HTMLDivElement>();
 
-    handleColorChange = (color: string) => {
+    onColorChange = (color: string) => {
       const { onColorChange, onChange } = this.props;
       const changeHandler = (onColorChange || onChange) as ColorPickerChangeHandler;
 
@@ -25,7 +25,7 @@ export const colorPickerFactory = <T extends ColorPickerProps>(
     render() {
       const popoverElement = React.createElement(popover, {
         ...this.props,
-        onChange: this.handleColorChange,
+        onChange: this.onColorChange,
       });
       const { theme, children } = this.props;
 

+ 4 - 4
packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx

@@ -60,7 +60,7 @@ export class ColorPickerPopover<T extends CustomPickersDescriptor> extends React
     changeHandler(getColorFromHexRgbOrName(color, theme.type));
   };
 
-  handleTabChange = (tab: PickerType | keyof T) => {
+  onTabChange = (tab: PickerType | keyof T) => {
     return () => this.setState({ activePicker: tab });
   };
 
@@ -104,7 +104,7 @@ export class ColorPickerPopover<T extends CustomPickersDescriptor> extends React
       <>
         {Object.keys(customPickers).map(key => {
           return (
-            <div className={this.getTabClassName(key)} onClick={this.handleTabChange(key)} key={key}>
+            <div className={this.getTabClassName(key)} onClick={this.onTabChange(key)} key={key}>
               {customPickers[key].name}
             </div>
           );
@@ -119,10 +119,10 @@ export class ColorPickerPopover<T extends CustomPickersDescriptor> extends React
     return (
       <div className={`ColorPickerPopover ColorPickerPopover--${colorPickerTheme}`}>
         <div className="ColorPickerPopover__tabs">
-          <div className={this.getTabClassName('palette')} onClick={this.handleTabChange('palette')}>
+          <div className={this.getTabClassName('palette')} onClick={this.onTabChange('palette')}>
             Colors
           </div>
-          <div className={this.getTabClassName('spectrum')} onClick={this.handleTabChange('spectrum')}>
+          <div className={this.getTabClassName('spectrum')} onClick={this.onTabChange('spectrum')}>
             Custom
           </div>
           {this.renderCustomPickerTabs()}

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

+ 62 - 0
packages/grafana-ui/src/utils/__snapshots__/processTableData.test.ts.snap

@@ -0,0 +1,62 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`processTableData basic processing should generate a header and fix widths 1`] = `
+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,
+    ],
+  ],
+}
+`;
+
+exports[`processTableData basic processing should read header and two rows 1`] = `
+Object {
+  "columns": Array [
+    Object {
+      "text": "a",
+    },
+    Object {
+      "text": "b",
+    },
+    Object {
+      "text": "c",
+    },
+  ],
+  "rows": Array [
+    Array [
+      1,
+      2,
+      3,
+    ],
+    Array [
+      4,
+      5,
+      6,
+    ],
+  ],
+}
+`;

+ 1 - 0
packages/grafana-ui/src/utils/index.ts

@@ -1,4 +1,5 @@
 export * from './processTimeSeries';
+export * from './processTableData';
 export * from './valueFormats/valueFormats';
 export * from './colors';
 export * from './namedColorsPalette';

+ 20 - 1
packages/grafana-ui/src/utils/processTimeSeries.test.ts → packages/grafana-ui/src/utils/processTableData.test.ts

@@ -1,4 +1,23 @@
-import { toTableData } from './processTimeSeries';
+import { parseCSV, toTableData } 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();
+    });
+  });
+});
 
 describe('toTableData', () => {
   it('converts timeseries to table skipping nulls', () => {

+ 165 - 0
packages/grafana-ui/src/utils/processTableData.ts

@@ -0,0 +1,165 @@
+import { TableData, Column, TimeSeries } 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,
+  };
+}
+
+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: [],
+    };
+  }
+
+  // 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,
+  });
+}
+
+function convertTimeSeriesToTableData(timeSeries: TimeSeries): TableData {
+  return {
+    columns: [
+      {
+        text: timeSeries.target || 'Value',
+        unit: timeSeries.unit,
+      },
+      {
+        text: 'Time',
+        type: 'time',
+      },
+    ],
+    rows: timeSeries.datapoints,
+  };
+}
+
+export const isTableData = (data: any): data is TableData => data && data.hasOwnProperty('columns');
+
+export const toTableData = (results?: any[]): TableData[] => {
+  if (!results) {
+    return [];
+  }
+
+  return results
+    .filter(d => !!d)
+    .map(data => {
+      if (data.hasOwnProperty('columns')) {
+        return data as TableData;
+      }
+      if (data.hasOwnProperty('datapoints')) {
+        return convertTimeSeriesToTableData(data);
+      }
+      // TODO, try to convert JSON to table?
+      console.warn('Can not convert', data);
+      throw new Error('Unsupported data format');
+    });
+};

+ 2 - 40
packages/grafana-ui/src/utils/processTimeSeries.ts

@@ -4,7 +4,7 @@ import isNumber from 'lodash/isNumber';
 import { colors } from './colors';
 
 // Types
-import { TimeSeriesVMs, NullValueMode, TimeSeriesValue, TableData, TimeSeries } from '../types';
+import { TimeSeriesVMs, NullValueMode, TimeSeriesValue, TableData } from '../types';
 
 interface Options {
   data: TableData[];
@@ -13,7 +13,7 @@ interface Options {
   nullValueMode: NullValueMode;
 }
 
-// NOTE -- this should be refactored into a TableData utility file.
+// NOTE: this should move to processTableData.ts
 // I left it as is so the merge changes are more clear.
 export function processTimeSeries({ data, xColumn, yColumn, nullValueMode }: Options): TimeSeriesVMs {
   const vmSeries = data.map((item, index) => {
@@ -192,41 +192,3 @@ export function processTimeSeries({ data, xColumn, yColumn, nullValueMode }: Opt
 
   return vmSeries;
 }
-
-function convertTimeSeriesToTableData(timeSeries: TimeSeries): TableData {
-  return {
-    columns: [
-      {
-        text: timeSeries.target || 'Value',
-        unit: timeSeries.unit,
-      },
-      {
-        text: 'Time',
-        type: 'time',
-      },
-    ],
-    rows: timeSeries.datapoints,
-  };
-}
-
-export const isTableData = (data: any): data is TableData => data && data.hasOwnProperty('columns');
-
-export const toTableData = (results?: any[]): TableData[] => {
-  if (!results) {
-    return [];
-  }
-
-  return results
-    .filter(d => !!d)
-    .map(data => {
-      if (data.hasOwnProperty('columns')) {
-        return data as TableData;
-      }
-      if (data.hasOwnProperty('datapoints')) {
-        return convertTimeSeriesToTableData(data);
-      }
-      // TODO, try to convert JSON to table?
-      console.warn('Can not convert', data);
-      throw new Error('Unsupported data format');
-    });
-};

+ 2 - 2
public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx

@@ -37,7 +37,7 @@ export const ToggleButton: FC<ToggleButtonProps> = ({
   tooltip,
   onChange,
 }) => {
-  const handleChange = event => {
+  const onClick = event => {
     event.stopPropagation();
     if (onChange) {
       onChange(value);
@@ -46,7 +46,7 @@ export const ToggleButton: FC<ToggleButtonProps> = ({
 
   const btnClassName = `btn ${className} ${selected ? 'active' : ''}`;
   const button = (
-    <button className={btnClassName} onClick={handleChange}>
+    <button className={btnClassName} onClick={onClick}>
       <span>{children}</span>
     </button>
   );

+ 1 - 1
scripts/cli/utils/useSpinner.ts

@@ -4,7 +4,7 @@ type FnToSpin<T> = (options: T) => Promise<void>;
 
 export const useSpinner = <T>(spinnerLabel: string, fn: FnToSpin<T>, killProcess = true) => {
   return async (options: T) => {
-    const spinner = new ora(spinnerLabel);
+    const spinner = ora(spinnerLabel);
     spinner.start();
     try {
       await fn(options);

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