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