diff --git a/web-console/src/utils/general.tsx b/web-console/src/utils/general.tsx index 90378326bc1..94c3427ea89 100644 --- a/web-console/src/utils/general.tsx +++ b/web-console/src/utils/general.tsx @@ -248,8 +248,19 @@ export function sortWithPrefixSuffix(things: string[], prefix: string[], suffix: // ---------------------------- export function downloadFile(text: string, type: string, fileName: string): void { + let blobType: string = ''; + switch (type) { + case 'json': + blobType = 'application/json'; + break; + case 'tsv': + blobType = 'text/tab-separated-values'; + break; + default: // csv + blobType = `text/${type}`; + } const blob = new Blob([text], { - type: `text/${type}` + type: blobType }); FileSaver.saveAs(blob, fileName); } diff --git a/web-console/src/views/sql-view/__snapshots__/sql-view.spec.tsx.snap b/web-console/src/views/sql-view/__snapshots__/sql-view.spec.tsx.snap old mode 100755 new mode 100644 index a2592b38df4..737e9930aeb --- a/web-console/src/views/sql-view/__snapshots__/sql-view.spec.tsx.snap +++ b/web-console/src/views/sql-view/__snapshots__/sql-view.spec.tsx.snap @@ -18,6 +18,7 @@ exports[`sql view matches snapshot 1`] = ` > Last query took 0.00 seconds + + + + + `; diff --git a/web-console/src/views/sql-view/sql-control/sql-control.scss b/web-console/src/views/sql-view/sql-control/sql-control.scss index 95c798b9258..7b544242520 100644 --- a/web-console/src/views/sql-view/sql-control/sql-control.scss +++ b/web-console/src/views/sql-view/sql-control/sql-control.scss @@ -40,16 +40,17 @@ bottom: 0; height: 30px; - button { - margin-right: 15px; + .query-elapsed { + padding: 5px; + position: absolute; + right: 25px; } - .query-elapsed { + .download-button { position: absolute; - right: 10px; + right: 0px; } } - } .ace_tooltip { @@ -69,3 +70,7 @@ font-size: 18px; } } + +.bp3-menu.download-format-menu { + min-width: 80px; +} diff --git a/web-console/src/views/sql-view/sql-control/sql-control.spec.tsx b/web-console/src/views/sql-view/sql-control/sql-control.spec.tsx index 1ac07bee1a9..a044aa42c31 100644 --- a/web-console/src/views/sql-view/sql-control/sql-control.spec.tsx +++ b/web-console/src/views/sql-view/sql-control/sql-control.spec.tsx @@ -28,6 +28,7 @@ describe('sql control', () => { onRun={(query, context, wrapQuery) => {}} onExplain={(sqlQuery, context) => {}} queryElapsed={2} + onDownload={() => {}} />; const { container } = render(sqlControl); diff --git a/web-console/src/views/sql-view/sql-control/sql-control.tsx b/web-console/src/views/sql-view/sql-control/sql-control.tsx index 44730b9c9d3..58486a3bc18 100644 --- a/web-console/src/views/sql-view/sql-control/sql-control.tsx +++ b/web-console/src/views/sql-view/sql-control/sql-control.tsx @@ -21,7 +21,7 @@ import { ButtonGroup, Intent, IResizeEntry, Menu, - MenuItem, + MenuItem, NavbarGroup, Popover, Position, ResizeSensor } from '@blueprintjs/core'; @@ -57,6 +57,7 @@ export interface SqlControlProps extends React.Props { onRun: (query: string, context: Record, wrapQuery: boolean) => void; onExplain: (sqlQuery: string, context: Record) => void; queryElapsed: number | null; + onDownload: (format: string) => void; } export interface SqlControlState { @@ -313,9 +314,14 @@ export class SqlControl extends React.PureComponent + onDownload('csv')} /> + onDownload('tsv')} /> + onDownload('json')}/> + ; // Set the key in the AceEditor to force a rebind and prevent an error that happens otherwise return
@@ -364,6 +370,15 @@ export class SqlControl extends React.PureComponent } + { + queryElapsed && + +
; } diff --git a/web-console/src/views/sql-view/sql-view.tsx b/web-console/src/views/sql-view/sql-view.tsx index 43f3262085c..d1320808347 100644 --- a/web-console/src/views/sql-view/sql-view.tsx +++ b/web-console/src/views/sql-view/sql-view.tsx @@ -26,6 +26,7 @@ import { QueryPlanDialog } from '../../dialogs'; import { BasicQueryExplanation, decodeRune, + downloadFile, HeaderRows, localStorageGet, LocalStorageKeys, localStorageSet, parseQueryPlan, @@ -169,6 +170,42 @@ export class SqlView extends React.PureComponent { localStorageSet(LocalStorageKeys.QUERY_VIEW_PANE_SIZE, String(secondaryPaneSize)); } + formatStr(s: string | number, format: 'csv' | 'tsv') { + if (format === 'csv') { + // remove line break, single quote => double quote, handle ',' + return `"${s.toString().replace(/(?:\r\n|\r|\n)/g, ' ').replace(/"/g, '""')}"`; + } else { // tsv + // remove line break, single quote => double quote, \t => '' + return `${s.toString().replace(/(?:\r\n|\r|\n)/g, ' ').replace(/\t/g, '').replace(/"/g, '""')}`; + } + } + + onDownload = (format: string) => { + const { result } = this.state; + if (!result) return; + let data: string = ''; + let seperator: string = ''; + const lineBreak = '\n'; + + if (format === 'csv' || format === 'tsv') { + seperator = format === 'csv' ? ',' : '\t'; + data = result.header.map(str => this.formatStr(str, format)).join(seperator) + lineBreak; + data += result.rows.map(r => r.map(cell => this.formatStr(cell, format)).join(seperator)).join(lineBreak); + } else { // json + data = result.rows.map(r => { + const outputObject: Record = {}; + for (let k = 0; k < r.length; k++) { + const newName = result.header[k]; + if (newName) { + outputObject[newName] = r[k]; + } + } + return JSON.stringify(outputObject); + }).join(lineBreak); + } + downloadFile(data, format, 'query_result.' + format); + } + renderExplainDialog() { const {explainDialogOpen, explainResult, loadingExplain, explainError} = this.state; if (!loadingExplain && explainDialogOpen) { @@ -226,6 +263,7 @@ export class SqlView extends React.PureComponent { this.explainQueryManager.runQuery({ queryString, context }); }} queryElapsed={queryElapsed} + onDownload={this.onDownload} />