mirror of https://github.com/grafana/grafana
Merge pull request #15886 from ryantxu/table-editor
Add a basic table editor to grafana/uipull/15923/head
commit
6a34eb2d9a
@ -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> |
||||||
|
); |
||||||
|
}); |
||||||
@ -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(); |
||||||
|
}); |
||||||
|
}); |
||||||
@ -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} |
||||||
|
{hasErrors ? <i className="fa fa-exclamation-triangle" /> : <i className="fa fa-check-circle" />} |
||||||
|
</footer> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</AutoSizer> |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export default TableInputCSV; |
||||||
@ -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; |
||||||
|
} |
||||||
@ -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", |
||||||
|
} |
||||||
|
`; |
||||||
@ -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(); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
@ -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: {}, |
||||||
|
}); |
||||||
|
} |
||||||
Loading…
Reference in new issue