-
Notifications
You must be signed in to change notification settings - Fork 8.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Lens] CSV Export for Lens #83430
[Lens] CSV Export for Lens #83430
Changes from 21 commits
078e554
5d789d6
965bb69
456d14a
8f917b1
851b98f
08d75d0
12ac1e0
88eefc6
ce68a93
88c2a8d
27678f4
52f606f
237664e
4de7ae8
6e88319
f851e69
3dad985
5749f32
4674a83
3da4fab
4cada29
1fbdeef
084a22f
cb173b0
ca868b3
0e7770c
80eac9f
c3941a1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
<!-- Do not edit this file. It is automatically generated by API Documenter. --> | ||
|
||
[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [CSV\_MIME\_TYPE](./kibana-plugin-plugins-data-public.csv_mime_type.md) | ||
|
||
## CSV\_MIME\_TYPE variable | ||
|
||
<b>Signature:</b> | ||
|
||
```typescript | ||
CSV_MIME_TYPE = "text/plain;charset=utf-8" | ||
``` |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
<!-- Do not edit this file. It is automatically generated by API Documenter. --> | ||
|
||
[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [exportAsCSVs](./kibana-plugin-plugins-data-public.exportascsvs.md) | ||
|
||
## exportAsCSVs() function | ||
|
||
<b>Signature:</b> | ||
|
||
```typescript | ||
export declare function exportAsCSVs(filename: string, datatables: Record<string, Datatable> | undefined, options: CSVOptions): Record<string, { | ||
content: string; | ||
type: string; | ||
}> | undefined; | ||
``` | ||
|
||
## Parameters | ||
|
||
| Parameter | Type | Description | | ||
| --- | --- | --- | | ||
| filename | <code>string</code> | filename to use (either as is, or as prefix for multiple CSVs) for the files to download | | ||
| datatables | <code>Record<string, Datatable> | undefined</code> | data (as a dictionary of Datatable) to be translated into CSVs. It can contain multiple tables. | | ||
| options | <code>CSVOptions</code> | set of options for the exporter | | ||
|
||
<b>Returns:</b> | ||
|
||
`Record<string, { | ||
content: string; | ||
type: string; | ||
}> | undefined` | ||
|
||
A dictionary of files to download: the key is the filename and the value the CSV string | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,117 @@ | ||
/* | ||
* Licensed to Elasticsearch B.V. under one or more contributor | ||
* license agreements. See the NOTICE file distributed with | ||
* this work for additional information regarding copyright | ||
* ownership. Elasticsearch B.V. licenses this file to you under | ||
* the Apache License, Version 2.0 (the "License"); you may | ||
* not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, | ||
* software distributed under the License is distributed on an | ||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | ||
* KIND, either express or implied. See the License for the | ||
* specific language governing permissions and limitations | ||
* under the License. | ||
*/ | ||
|
||
import { Datatable } from 'src/plugins/expressions'; | ||
import { FieldFormat } from '../../common/field_formats'; | ||
import { CSV_MIME_TYPE, exportAsCSVs } from './export_csv'; | ||
|
||
function getDefaultOptions() { | ||
const formatFactory = jest.fn(); | ||
formatFactory.mockReturnValue({ convert: (v: unknown) => `Formatted_${v}` } as FieldFormat); | ||
return { | ||
csvSeparator: ',', | ||
quoteValues: true, | ||
formatFactory, | ||
}; | ||
} | ||
|
||
function getDataTable({ | ||
multipleLayers, | ||
multipleColumns, | ||
}: { multipleLayers?: boolean; multipleColumns?: boolean } = {}): Record<string, Datatable> { | ||
const datatables: Record<string, Datatable> = { | ||
layer1: { | ||
type: 'datatable', | ||
columns: [{ id: 'col1', name: 'columnOne', meta: { type: 'string' } }], | ||
rows: [{ col1: 'value' }], | ||
}, | ||
}; | ||
if (multipleColumns) { | ||
datatables.layer1.columns.push({ id: 'col2', name: 'columnTwo', meta: { type: 'number' } }); | ||
datatables.layer1.rows[0].col2 = 5; | ||
} | ||
if (multipleLayers) { | ||
datatables.layer2 = { | ||
type: 'datatable', | ||
columns: [{ id: 'col1', name: 'columnOne', meta: { type: 'string' } }], | ||
rows: [{ col1: 'value' }], | ||
}; | ||
} | ||
return datatables; | ||
} | ||
|
||
describe('CSV exporter', () => { | ||
test('should do nothing with no data', () => { | ||
expect(exportAsCSVs('noData', undefined, getDefaultOptions())).toStrictEqual(undefined); | ||
}); | ||
|
||
test('should not break with empty data', () => { | ||
expect(exportAsCSVs('emptyFile', {}, getDefaultOptions())).toStrictEqual({}); | ||
}); | ||
|
||
test('should export formatted values by default', () => { | ||
expect(exportAsCSVs('oneCSV', getDataTable(), getDefaultOptions())).toStrictEqual({ | ||
'oneCSV.csv': { content: 'columnOne\r\n"Formatted_value"\r\n', type: CSV_MIME_TYPE }, | ||
}); | ||
}); | ||
|
||
test('should not quote values when requested', () => { | ||
return expect( | ||
exportAsCSVs('oneCSV', getDataTable(), { ...getDefaultOptions(), quoteValues: false }) | ||
).toStrictEqual({ | ||
'oneCSV.csv': { content: 'columnOne\r\nFormatted_value\r\n', type: CSV_MIME_TYPE }, | ||
}); | ||
}); | ||
|
||
test('should use raw values when requested', () => { | ||
expect( | ||
exportAsCSVs('oneCSV', getDataTable(), { ...getDefaultOptions(), raw: true }) | ||
).toStrictEqual({ | ||
'oneCSV.csv': { content: 'columnOne\r\nvalue\r\n', type: CSV_MIME_TYPE }, | ||
}); | ||
}); | ||
|
||
test('should use separator for multiple columns', () => { | ||
expect( | ||
exportAsCSVs('oneCSV', getDataTable({ multipleColumns: true }), getDefaultOptions()) | ||
).toStrictEqual({ | ||
'oneCSV.csv': { | ||
content: 'columnOne,columnTwo\r\n"Formatted_value","Formatted_5"\r\n', | ||
type: CSV_MIME_TYPE, | ||
}, | ||
}); | ||
}); | ||
|
||
test('should support multiple layers', () => { | ||
expect( | ||
exportAsCSVs('twoCSVs', getDataTable({ multipleLayers: true }), getDefaultOptions()) | ||
).toStrictEqual({ | ||
'twoCSVs-1.csv': { content: 'columnOne\r\n"Formatted_value"\r\n', type: CSV_MIME_TYPE }, | ||
'twoCSVs-2.csv': { content: 'columnOne\r\n"Formatted_value"\r\n', type: CSV_MIME_TYPE }, | ||
}); | ||
}); | ||
|
||
test('should escape values', () => { | ||
const datatables = getDataTable(); | ||
datatables.layer1.rows[0].col1 = '"value"'; | ||
expect(exportAsCSVs('oneCSV', datatables, getDefaultOptions())).toStrictEqual({ | ||
'oneCSV.csv': { content: 'columnOne\r\n"Formatted_""value"""\r\n', type: CSV_MIME_TYPE }, | ||
}); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,119 @@ | ||
/* | ||
* Licensed to Elasticsearch B.V. under one or more contributor | ||
* license agreements. See the NOTICE file distributed with | ||
* this work for additional information regarding copyright | ||
* ownership. Elasticsearch B.V. licenses this file to you under | ||
* the Apache License, Version 2.0 (the "License"); you may | ||
* not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, | ||
* software distributed under the License is distributed on an | ||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | ||
* KIND, either express or implied. See the License for the | ||
* specific language governing permissions and limitations | ||
* under the License. | ||
*/ | ||
|
||
// Inspired by the inspector CSV exporter | ||
|
||
import { FormatFactory } from 'src/plugins/data/common/field_formats/utils'; | ||
import { Datatable } from 'src/plugins/expressions'; | ||
import { DownloadableContent } from 'src/plugins/share/public/'; | ||
|
||
const LINE_FEED_CHARACTER = '\r\n'; | ||
const nonAlphaNumRE = /[^a-zA-Z0-9]/; | ||
const allDoubleQuoteRE = /"/g; | ||
export const CSV_MIME_TYPE = 'text/plain;charset=utf-8'; | ||
|
||
// TODO: enhance this later on | ||
function escape(val: object | string, quoteValues: boolean) { | ||
if (val != null && typeof val === 'object') { | ||
val = val.valueOf(); | ||
} | ||
|
||
val = String(val); | ||
|
||
if (quoteValues && nonAlphaNumRE.test(val)) { | ||
val = `"${val.replace(allDoubleQuoteRE, '""')}"`; | ||
} | ||
|
||
return val; | ||
} | ||
|
||
interface CSVOptions { | ||
csvSeparator: string; | ||
quoteValues: boolean; | ||
formatFactory: FormatFactory; | ||
raw?: boolean; | ||
} | ||
|
||
function buildCSV( | ||
{ columns, rows }: Datatable, | ||
{ csvSeparator, quoteValues, formatFactory, raw }: Omit<CSVOptions, 'asString'> | ||
) { | ||
// Build the header row by its names | ||
const header = columns.map((col) => escape(col.name, quoteValues)); | ||
|
||
const formatters = columns.reduce<Record<string, ReturnType<FormatFactory>>>( | ||
(memo, { id, meta }) => { | ||
memo[id] = formatFactory(meta?.params); | ||
return memo; | ||
}, | ||
{} | ||
); | ||
|
||
// Convert the array of row objects to an array of row arrays | ||
const csvRows = rows.map((row) => { | ||
return columns.map((column) => | ||
escape(raw ? row[column.id] : formatters[column.id].convert(row[column.id]), quoteValues) | ||
); | ||
}); | ||
|
||
return ( | ||
[header, ...csvRows].map((row) => row.join(csvSeparator)).join(LINE_FEED_CHARACTER) + | ||
LINE_FEED_CHARACTER | ||
); // Add \r\n after last line | ||
} | ||
|
||
/** | ||
* | ||
* @param filename - filename to use (either as is, or as prefix for multiple CSVs) for the files to download | ||
* @param datatables - data (as a dictionary of Datatable) to be translated into CSVs. It can contain multiple tables. | ||
* @param options - set of options for the exporter | ||
* | ||
* @returns A dictionary of files to download: the key is the filename and the value the CSV string | ||
*/ | ||
export function exportAsCSVs( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. lets change this function so it doesn't download the file but just returns csv as a string, this way we can move the function to common and make it usable from server. the download aspect of it should be moved to some other place (possibly There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You proposing splitting this into two There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes, two functions as converting to csv can be common and downloading can be used for other things beside csv as well. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i am wondering if this function should be kept inside lens for now. how often will we want to convert multiple datatables at once ? also i am wondering if the reference to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mind that the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As we move down the path of creating these various utility functions for datatables, I think it's important to have some consistency in how you interact with them. For any datatable utility function, I'd expect an interface similar to the following: type SomeFn = (table: Datatable, options?: Record<string, whatever>) => Promise<Something> | Something;
For the sake of consistency, I would vote to make this function operate on a single table just like our other datatable helpers.
I think the concept of a filename is only here because the file download functionality was extracted from this function originally. IMHO it would make more sense to remove it and handle it when calling the download helper from the This would make the interface something like: exportAsCsv(table: Datatable, options: CSVOptions) => string; Then Lens would handle calling it multiple times (for multiple tables), assigning a filename, and passing the output to the download helper. To me this feels like the best way to make these utilities as decoupled as possible, and keeps the lens-specific stuff in lens. (And of course we can always revisit down the road if some of the lens logic is needed for a bunch of other apps) |
||
filename: string, | ||
datatables: Record<string, Datatable> | undefined, | ||
options: CSVOptions | ||
) { | ||
if (datatables == null) { | ||
return; | ||
} | ||
// build a csv for datatable layer | ||
const csvs = Object.keys(datatables) | ||
.filter((layerId) => { | ||
return ( | ||
datatables[layerId].columns.length && | ||
datatables[layerId].rows.length && | ||
datatables[layerId].rows.every((row) => Object.keys(row).length) | ||
); | ||
}) | ||
.reduce<Record<string, string>>((memo, layerId) => { | ||
memo[layerId] = buildCSV(datatables[layerId], options); | ||
return memo; | ||
}, {}); | ||
|
||
const layerIds = Object.keys(csvs); | ||
|
||
return layerIds.reduce<Record<string, Exclude<DownloadableContent, Blob>>>((memo, layerId, i) => { | ||
const content = csvs[layerId]; | ||
const postFix = layerIds.length > 1 ? `-${i + 1}` : ''; | ||
memo[`${filename}${postFix}.csv`] = { content, type: CSV_MIME_TYPE }; | ||
return memo; | ||
}, {}); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
/* | ||
* Licensed to Elasticsearch B.V. under one or more contributor | ||
* license agreements. See the NOTICE file distributed with | ||
* this work for additional information regarding copyright | ||
* ownership. Elasticsearch B.V. licenses this file to you under | ||
* the Apache License, Version 2.0 (the "License"); you may | ||
* not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, | ||
* software distributed under the License is distributed on an | ||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | ||
* KIND, either express or implied. See the License for the | ||
* specific language governing permissions and limitations | ||
* under the License. | ||
*/ | ||
|
||
export * from './export_csv'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we make my proposed changes to |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -212,6 +212,12 @@ export { | |
FieldFormat, | ||
} from '../common'; | ||
|
||
/** | ||
* Exporters (CSV) | ||
*/ | ||
|
||
export * from './exports'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same comment re: using named exports from top-level |
||
|
||
/* | ||
* Index patterns: | ||
*/ | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
lets rename this one to
datatableToCSV
and export it