Skip to content

Commit 7c003b5

Browse files
authored
fix(5994): timeMachine download complete csv (#6062)
* fix(5994): have timeMachine submit and download query be identical, and have saem error notifcation to user. Then have download query post-network call processing be different, to avoid truncation. * chore(5994): regression test for old timeMachine, can at least download the file with contents * chore(5994): make controller private prop not have preceeding underscore * feat(5994): switch to using direct download to the filesystem, triggered by application of the appropriate headers. * fix(5994): fix downloads explorer test * chore(5994): code clean of naming and error messaging
1 parent a50d0b6 commit 7c003b5

File tree

5 files changed

+289
-86
lines changed

5 files changed

+289
-86
lines changed

cypress/e2e/shared/explorer.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import {Organization} from '../../../src/types'
22
import {points} from '../../support/commands'
33

44
describe('DataExplorer', () => {
5+
let route: string
6+
57
beforeEach(() => {
68
cy.flush()
79
cy.signin()
@@ -15,7 +17,8 @@ describe('DataExplorer', () => {
1517
cy.get('@org').then(({id}: Organization) => {
1618
cy.createMapVariable(id)
1719
cy.fixture('routes').then(({orgs, explorer}) => {
18-
cy.visit(`${orgs}/${id}${explorer}`)
20+
route = `${orgs}/${id}${explorer}`
21+
cy.visit(route)
1922
cy.getByTestID('tree-nav').should('be.visible')
2023
})
2124
})
@@ -909,4 +912,54 @@ describe('DataExplorer', () => {
909912
})
910913
})
911914
})
915+
916+
describe('download csv', () => {
917+
// docs for how to test form submission as file download:
918+
// https://github.com/cypress-io/cypress-example-recipes/blob/cc13866e55bd28e1d1323ba6d498d85204f292b5/examples/testing-dom__download/cypress/e2e/form-submission-spec.cy.js
919+
const downloadsDirectory = Cypress.config('downloadsFolder')
920+
921+
const validateCsv = (csv: string, rowCnt: number) => {
922+
const numHeaderRows = 4
923+
cy.wrap(csv)
924+
.then(doc => doc.trim().split('\n'))
925+
.then(list => {
926+
expect(list.length).to.equal(rowCnt + numHeaderRows)
927+
})
928+
}
929+
930+
beforeEach(() => {
931+
cy.writeData(points(20))
932+
cy.task('deleteDownloads', {dirPath: downloadsDirectory})
933+
cy.getByTestID('switch-to-script-editor').should('be.visible').click()
934+
})
935+
936+
it('can download a file', () => {
937+
cy.intercept('POST', '/api/v2/query?*', req => {
938+
req.redirect(route)
939+
}).as('query')
940+
941+
cy.getByTestID('time-machine--bottom').within(() => {
942+
cy.getByTestID('flux-editor', {timeout: 30000}).should('be.visible')
943+
.monacoType(`from(bucket: "defbuck")
944+
|> range(start: -10h)`)
945+
cy.getByTestID('time-machine--download-csv')
946+
.should('be.visible')
947+
.click()
948+
})
949+
950+
cy.wait('@query')
951+
.its('request')
952+
.then(req => {
953+
cy.request(req)
954+
.then(({body, headers}) => {
955+
expect(headers).to.have.property(
956+
'content-type',
957+
'text/csv; charset=utf-8'
958+
)
959+
return Promise.resolve(body)
960+
})
961+
.then(csv => validateCsv(csv, 1))
962+
})
963+
})
964+
})
912965
})
Lines changed: 54 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,93 @@
11
// Libraries
22
import React, {PureComponent} from 'react'
3-
import {connect} from 'react-redux'
3+
import {connect, ConnectedProps} from 'react-redux'
44

55
// Components
66
import {Button, ComponentStatus, IconFont} from '@influxdata/clockface'
77

8-
// Utils
9-
import {downloadTextFile} from 'src/shared/utils/download'
10-
import {getActiveTimeMachine} from 'src/timeMachine/selectors'
11-
import {createDateTimeFormatter} from 'src/utils/datetime/formatters'
8+
// Selectors and Actions
9+
import {getActiveQuery} from 'src/timeMachine/selectors'
10+
import {runDownloadQuery} from 'src/timeMachine/actions/queries'
1211

1312
// Types
1413
import {AppState} from 'src/types'
1514

16-
interface StateProps {
17-
files: string[] | null
15+
// Worker
16+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
17+
// @ts-ignore
18+
import downloadWorker from 'worker-plugin/loader!../workers/downloadHelper'
19+
20+
type Props = ConnectedProps<typeof connector>
21+
22+
interface State {
23+
browserSupportsDownload: boolean
1824
}
1925

20-
class CSVExportButton extends PureComponent<StateProps, {}> {
26+
class CSVExportButton extends PureComponent<Props, State> {
27+
constructor(props) {
28+
super(props)
29+
this.state = {browserSupportsDownload: false}
30+
31+
if ('serviceWorker' in navigator) {
32+
navigator.serviceWorker.register(downloadWorker).then(
33+
() => this.setState({browserSupportsDownload: true}),
34+
function (err) {
35+
console.error(
36+
'Feature not available, because ServiceWorker registration failed: ',
37+
err
38+
)
39+
}
40+
)
41+
}
42+
}
43+
2144
public render() {
45+
if (!this.state.browserSupportsDownload) {
46+
return null
47+
}
48+
2249
return (
2350
<Button
2451
titleText={this.titleText}
2552
text="CSV"
2653
icon={IconFont.Download_New}
2754
onClick={this.handleClick}
28-
status={this.buttonStatus}
55+
status={
56+
this.props.disabled
57+
? ComponentStatus.Disabled
58+
: ComponentStatus.Default
59+
}
60+
testID="time-machine--download-csv"
2961
/>
3062
)
3163
}
3264

33-
private get buttonStatus(): ComponentStatus {
34-
const {files} = this.props
35-
36-
if (files) {
37-
return ComponentStatus.Default
38-
}
39-
40-
return ComponentStatus.Disabled
41-
}
42-
4365
private get titleText(): string {
44-
const {files} = this.props
66+
const {disabled} = this.props
4567

46-
if (files) {
68+
if (!disabled) {
4769
return 'Download query results as a .CSV file'
4870
}
4971

5072
return 'Create a query in order to download results as .CSV'
5173
}
5274

5375
private handleClick = () => {
54-
const {files} = this.props
55-
const formatter = createDateTimeFormatter('YYYY-MM-DD HH:mm')
56-
const csv = files.join('\n\n')
57-
const now = formatter.format(new Date()).replace(/[:\s]+/gi, '_')
58-
const filename = `${now} InfluxDB Data`
59-
60-
downloadTextFile(csv, filename, '.csv', 'text/csv')
76+
this.props.download()
6177
}
6278
}
6379

6480
const mstp = (state: AppState) => {
65-
const {
66-
queryResults: {files},
67-
} = getActiveTimeMachine(state)
81+
const activeQueryText = getActiveQuery(state).text
82+
const disabled = activeQueryText === ''
83+
84+
return {disabled}
85+
}
6886

69-
return {files}
87+
const mdtp = {
88+
download: runDownloadQuery,
7089
}
7190

72-
export default connect<StateProps>(mstp)(CSVExportButton)
91+
const connector = connect(mstp, mdtp)
92+
93+
export default connector(CSVExportButton)

src/shared/copy/notifications/categories/dashboard.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,8 @@ export const copyQueryFailure = (cellName: string = ''): Notification => {
140140
message: `There was an error copying the query${fromCellName}. Please try again.`,
141141
}
142142
}
143+
144+
export const csvDownloadFailure = (): Notification => ({
145+
...defaultErrorNotification,
146+
message: 'Failed to download csv.',
147+
})
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/*
2+
Background:
3+
* the traditional approaches to file downloads will load the data first into browser memory.
4+
* this includes:
5+
* reading the response.body ReadableStream
6+
* converting to a file Blob
7+
* there is a web API for FileSystem writes, but it's not implemented for Firefox.
8+
* https://developer.mozilla.org/en-US/docs/Web/API/File_and_Directory_Entries_API/Firefox_support
9+
* Firefox focused on uploadable streams from the fs. The result == firefox's web API does not support our use case.
10+
11+
In order to trigger a direct to filesystem download, bypassing the browser memory, we needed:
12+
* have a response header:
13+
* `Content-Disposition`
14+
* which the server attaches in response to a request header `Prefer: return-download`
15+
* have a form submission request:
16+
* the XMLhttpRequest does not directly download, even with the appropriate header.
17+
* cannot use the `a` tag download feature, as that is only GET requests
18+
* a form submit is build into the browser to allow downloads. **this is the only approach**
19+
20+
To get the form submit to have a http request which looked like our normal query requests:
21+
* we used a service worker, which can be an interceptor/listener for form submissions.
22+
* forms themselves do not allow headers to be attached.
23+
* then modify the headers, and parse the body to the appropriate format
24+
*/
25+
26+
self.addEventListener('fetch', function (event: any) {
27+
const contentType = (event.request.headers as Headers).get('Content-Type')
28+
if (
29+
new URL(event.request.url).pathname == '/api/v2/query' &&
30+
contentType == 'application/x-www-form-urlencoded'
31+
) {
32+
const headers = new Headers()
33+
for (const [headerType, headerValue] of event.request.headers) {
34+
switch (headerType) {
35+
case 'content-type':
36+
headers.append('Content-Type', 'application/json')
37+
break
38+
case 'accept':
39+
headers.append('Accept', '*/*')
40+
break
41+
default:
42+
headers.append(headerType, headerValue)
43+
}
44+
}
45+
headers.append('Prefer', 'return-download')
46+
47+
return event.respondWith(
48+
(async () => {
49+
const body = (await event.request.formData()).get('data')
50+
const request = new Request(event.request.url, {
51+
body,
52+
headers,
53+
method: event.request.method,
54+
mode: 'cors',
55+
credentials: event.request.credentials,
56+
signal: event.request.signal,
57+
cache: event.request.cache,
58+
referrer: event.request.referrer,
59+
referrerPolicy: event.request.referrerPolicy,
60+
redirect: event.request.redirect,
61+
keepalive: true,
62+
})
63+
return fetch(request)
64+
})()
65+
)
66+
} else {
67+
event.respondWith(fetch(event.request))
68+
}
69+
})

0 commit comments

Comments
 (0)