Skip to content

Commit c97fb32

Browse files
feat: Universal CSV exports (PostHog#10644)
* Added new version of the exporter using API based exporting Co-authored-by: Paul D'Ambra <[email protected]>
1 parent a6cc07e commit c97fb32

40 files changed

+916
-484
lines changed

.github/workflows/docker-image-test.yml

+1
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ jobs:
165165
uses: cypress-io/github-action@v2
166166
with:
167167
config-file: cypress.e2e.config.ts
168+
config: retries=2
168169
spec: ${{ matrix.specs }}
169170

170171
- name: Archive test screenshots

cypress.e2e.config.ts

-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ export default defineConfig({
2929
projectId: 'twojfp',
3030
viewportWidth: 1200,
3131
viewportHeight: 1080,
32-
retries: 2,
3332
trashAssetsBeforeRuns: true,
3433
e2e: {
3534
// We've imported your old cypress plugins here.

cypress/e2e/exports.js

+6-5
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ describe('Exporting Insights', () => {
1010
req.reply(
1111
decideResponse({
1212
'export-dashboard-insights': true,
13+
ASYNC_EXPORT_CSV_FOR_LIVE_EVENTS: true,
1314
})
1415
)
1516
)
@@ -28,14 +29,14 @@ describe('Exporting Insights', () => {
2829
})
2930

3031
it('Export an Insight to png', () => {
31-
cy.get('[data-attr=more-button]').click()
32-
cy.get('[data-attr=export-button]').click()
32+
cy.get('.page-buttons [data-attr=more-button]').click()
33+
cy.get('.Popup [data-attr=export-button]').click()
3334
cy.get('[data-attr=export-button-png]').click()
3435

35-
const expecteFileName = 'export-pageview-count.png'
36+
const expectedFileName = 'export-pageview-count.png'
3637
cy.task('compareToReferenceImage', {
37-
source: expecteFileName,
38-
reference: `../data/exports/${expecteFileName}`,
38+
source: expectedFileName,
39+
reference: `../data/exports/${expectedFileName}`,
3940
diffThreshold: 0.01,
4041
})
4142
})

frontend/src/lib/components/ExportButton/ExportButton.tsx

+28-49
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,25 @@
11
import React from 'react'
2+
import { ExporterFormat } from '~/types'
23
import { LemonButton, LemonButtonProps, LemonButtonWithPopup } from '../LemonButton'
3-
import { useActions, useValues } from 'kea'
4-
import { ExporterFormat, exporterLogic } from './exporterLogic'
54
import { LemonDivider } from '../LemonDivider'
6-
import { insightLogic } from 'scenes/insights/insightLogic'
7-
import { InsightLogicProps, InsightShortId } from '~/types'
8-
9-
interface ExportButtonProps extends Pick<LemonButtonProps, 'icon' | 'type' | 'fullWidth'> {
10-
dashboardId?: number
11-
insightShortId?: InsightShortId
5+
import { triggerExport, TriggerExportProps } from './exporter'
6+
7+
export interface ExportButtonItem {
8+
title?: string
9+
export_format: ExporterFormat
10+
export_context?: TriggerExportProps['export_context']
11+
dashboard?: number
12+
insight?: number
1213
}
1314

14-
export function ExportButton({ dashboardId, insightShortId, ...buttonProps }: ExportButtonProps): JSX.Element {
15-
const insightLogicProps: InsightLogicProps = {
16-
dashboardItemId: insightShortId,
17-
doNotLoad: true,
18-
}
19-
20-
const { supportsCsvExport, csvExportUrl, insight } = useValues(insightLogic(insightLogicProps))
21-
22-
const { exportItem } = useActions(exporterLogic({ dashboardId, insightId: insight?.id }))
23-
const { exportInProgress } = useValues(exporterLogic({ dashboardId, insightId: insight?.id }))
24-
25-
const supportedFormats: ExporterFormat[] = []
26-
27-
if (dashboardId || insightShortId) {
28-
supportedFormats.push(ExporterFormat.PNG)
29-
}
30-
if (supportsCsvExport) {
31-
supportedFormats.push(ExporterFormat.CSV)
32-
}
33-
34-
const onExportItemClick = (exportFormat: ExporterFormat): void => {
35-
// NOTE: Once we standardise the exporting code in the backend this can be removed
36-
if (exportFormat === ExporterFormat.CSV) {
37-
window.open(csvExportUrl, '_blank')
38-
return
39-
}
40-
41-
exportItem(exportFormat)
42-
}
15+
export interface ExportButtonProps extends Pick<LemonButtonProps, 'icon' | 'type' | 'fullWidth'> {
16+
items: ExportButtonItem[]
17+
}
4318

19+
export function ExportButton({ items, ...buttonProps }: ExportButtonProps): JSX.Element {
4420
return (
4521
<LemonButtonWithPopup
4622
type="stealth"
47-
loading={exportInProgress}
4823
data-attr="export-button"
4924
{...buttonProps}
5025
popup={{
@@ -54,17 +29,21 @@ export function ExportButton({ dashboardId, insightShortId, ...buttonProps }: Ex
5429
<>
5530
<h5>File type</h5>
5631
<LemonDivider />
57-
{supportedFormats.map((format) => (
58-
<LemonButton
59-
key={format}
60-
fullWidth
61-
type="stealth"
62-
onClick={() => onExportItemClick(format)}
63-
data-attr={`export-button-${format.split('/').pop()}`}
64-
>
65-
.{format.split('/').pop()}
66-
</LemonButton>
67-
))}
32+
{items.map(({ title, ...triggerExportProps }, i) => {
33+
const exportFormatExtension = triggerExportProps.export_format.split('/').pop()
34+
35+
return (
36+
<LemonButton
37+
key={i}
38+
fullWidth
39+
type="stealth"
40+
onClick={() => triggerExport(triggerExportProps)}
41+
data-attr={`export-button-${exportFormatExtension}`}
42+
>
43+
{title ? title : `.${exportFormatExtension}`}
44+
</LemonButton>
45+
)
46+
})}
6847
</>
6948
),
7049
}}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import api from 'lib/api'
2+
import { delay } from 'lib/utils'
3+
import posthog from 'posthog-js'
4+
import { ExportedAssetType, ExporterFormat } from '~/types'
5+
import { lemonToast } from '../lemonToast'
6+
7+
const POLL_DELAY_MS = 1000
8+
const MAX_PNG_POLL = 10
9+
const MAX_CSV_POLL = 60
10+
11+
async function downloadExportedAsset(asset: ExportedAssetType): Promise<void> {
12+
const downloadUrl = api.exports.determineExportUrl(asset.id)
13+
const res = await api.getRaw(downloadUrl)
14+
const blobObject = await res.blob()
15+
const blob = window.URL.createObjectURL(blobObject)
16+
const anchor = document.createElement('a')
17+
anchor.style.display = 'none'
18+
anchor.href = blob
19+
anchor.download = asset.filename
20+
document.body.appendChild(anchor)
21+
anchor.click()
22+
window.URL.revokeObjectURL(blob)
23+
}
24+
25+
export type TriggerExportProps = Pick<ExportedAssetType, 'export_format' | 'dashboard' | 'insight' | 'export_context'>
26+
27+
export async function triggerExport(asset: TriggerExportProps): Promise<void> {
28+
const poller = new Promise(async (resolve, reject) => {
29+
const trackingProperties = {
30+
export_format: asset.export_format,
31+
dashboard: asset.dashboard,
32+
insight: asset.insight,
33+
export_context: asset.export_context,
34+
total_time_ms: 0,
35+
}
36+
const startTime = performance.now()
37+
38+
try {
39+
let exportedAsset = await api.exports.create({
40+
export_format: asset.export_format,
41+
dashboard: asset.dashboard,
42+
insight: asset.insight,
43+
export_context: asset.export_context,
44+
})
45+
46+
if (!exportedAsset.id) {
47+
reject('Missing export_id from response')
48+
return
49+
}
50+
51+
let attempts = 0
52+
53+
const maxPoll = asset.export_format === ExporterFormat.CSV ? MAX_CSV_POLL : MAX_PNG_POLL
54+
while (attempts < maxPoll) {
55+
attempts++
56+
57+
if (exportedAsset.has_content) {
58+
await downloadExportedAsset(exportedAsset)
59+
60+
trackingProperties.total_time_ms = performance.now() - startTime
61+
posthog.capture('export succeeded', trackingProperties)
62+
63+
resolve('Export complete')
64+
return
65+
}
66+
67+
await delay(POLL_DELAY_MS)
68+
69+
exportedAsset = await api.exports.get(exportedAsset.id)
70+
}
71+
72+
reject('Content not loaded in time...')
73+
} catch (e: any) {
74+
trackingProperties.total_time_ms = performance.now() - startTime
75+
posthog.capture('export failed', trackingProperties)
76+
reject(`Export failed: ${JSON.stringify(e)}`)
77+
}
78+
})
79+
await lemonToast.promise(poller, {
80+
pending: 'Export started...',
81+
success: 'Export complete!',
82+
error: 'Export failed!',
83+
})
84+
}

frontend/src/lib/components/ExportButton/exporterLogic.ts

-130
This file was deleted.

0 commit comments

Comments
 (0)