From c6edef3034f706d3731be68be54d7e22a5f41774 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Thu, 14 Sep 2023 17:40:33 +0200 Subject: [PATCH] feat: Added a export csv/json button to data tables where export is available (#17402) --- .../nodes/DataTable/DataTableExport.tsx | 213 ++++++++++++++++-- package.json | 2 + pnpm-lock.yaml | 20 +- 3 files changed, 214 insertions(+), 21 deletions(-) diff --git a/frontend/src/queries/nodes/DataTable/DataTableExport.tsx b/frontend/src/queries/nodes/DataTable/DataTableExport.tsx index 1de315c2c90..db3a26d62ab 100644 --- a/frontend/src/queries/nodes/DataTable/DataTableExport.tsx +++ b/frontend/src/queries/nodes/DataTable/DataTableExport.tsx @@ -1,12 +1,17 @@ +import Papa from 'papaparse' import { LemonButton, LemonButtonWithDropdown } from 'lib/lemon-ui/LemonButton' import { IconExport } from 'lib/lemon-ui/icons' import { triggerExport } from 'lib/components/ExportButton/exporter' import { ExporterFormat } from '~/types' import { DataNode, DataTableNode } from '~/queries/schema' -import { defaultDataTableColumns } from '~/queries/nodes/DataTable/utils' -import { isEventsQuery, isPersonsNode } from '~/queries/utils' +import { defaultDataTableColumns, extractExpressionComment } from '~/queries/nodes/DataTable/utils' +import { isEventsQuery, isHogQLQuery, isPersonsNode } from '~/queries/utils' import { getPersonsEndpoint } from '~/queries/query' import { ExportWithConfirmation } from '~/queries/nodes/DataTable/ExportWithConfirmation' +import { DataTableRow, dataTableLogic } from './dataTableLogic' +import { useValues } from 'kea' +import { LemonDivider, lemonToast } from '@posthog/lemon-ui' +import { asDisplay } from 'scenes/persons/person-utils' const EXPORT_MAX_LIMIT = 10000 @@ -39,18 +44,148 @@ function startDownload(query: DataTableNode, onlySelectedColumns: boolean): void }) } +const columnDisallowList = ['person.$delete', '*'] +const getCsvTableData = (dataTableRows: DataTableRow[], columns: string[], query: DataTableNode): string[][] => { + if (isPersonsNode(query.source)) { + const filteredColumns = columns.filter((n) => !columnDisallowList.includes(n)) + + const csvData = dataTableRows.map((n) => { + const record = n.result as Record | undefined + const recordWithPerson = { ...(record ?? {}), person: record?.name } + + return filteredColumns.map((n) => recordWithPerson[n]) + }) + + return [filteredColumns, ...csvData] + } + + if (isEventsQuery(query.source)) { + const filteredColumns = columns + .filter((n) => !columnDisallowList.includes(n)) + .map((n) => extractExpressionComment(n)) + + const csvData = dataTableRows.map((n) => { + return columns + .map((col, colIndex) => { + if (columnDisallowList.includes(col)) { + return null + } + + if (col === 'person') { + return asDisplay(n.result?.[colIndex]) + } + + return n.result?.[colIndex] + }) + .filter(Boolean) + }) + + return [filteredColumns, ...csvData] + } + + if (isHogQLQuery(query.source)) { + return [columns, ...dataTableRows.map((n) => (n.result as any[]) ?? [])] + } + + return [] +} + +const getJsonTableData = ( + dataTableRows: DataTableRow[], + columns: string[], + query: DataTableNode +): Record[] => { + if (isPersonsNode(query.source)) { + const filteredColumns = columns.filter((n) => !columnDisallowList.includes(n)) + + return dataTableRows.map((n) => { + const record = n.result as Record | undefined + const recordWithPerson = { ...(record ?? {}), person: record?.name } + + return filteredColumns.reduce((acc, cur) => { + acc[cur] = recordWithPerson[cur] + return acc + }, {} as Record) + }) + } + + if (isEventsQuery(query.source)) { + return dataTableRows.map((n) => { + return columns.reduce((acc, col, colIndex) => { + if (columnDisallowList.includes(col)) { + return acc + } + + if (col === 'person') { + acc[col] = asDisplay(n.result?.[colIndex]) + return acc + } + + const colName = extractExpressionComment(col) + + acc[colName] = n.result?.[colIndex] + + return acc + }, {} as Record) + }) + } + + if (isHogQLQuery(query.source)) { + return dataTableRows.map((n) => { + const data = n.result ?? {} + return columns.reduce((acc, cur, index) => { + acc[cur] = data[index] + return acc + }, {} as Record) + }) + } + + return [] +} + +function copyTableToCsv(dataTableRows: DataTableRow[], columns: string[], query: DataTableNode): void { + try { + const tableData = getCsvTableData(dataTableRows, columns, query) + + const csv = Papa.unparse(tableData) + + navigator.clipboard.writeText(csv).then(() => { + lemonToast.success('Table copied to clipboard!') + }) + } catch { + lemonToast.error('Copy failed!') + } +} + +function copyTableToJson(dataTableRows: DataTableRow[], columns: string[], query: DataTableNode): void { + try { + const tableData = getJsonTableData(dataTableRows, columns, query) + + const json = JSON.stringify(tableData, null, 4) + + navigator.clipboard.writeText(json).then(() => { + lemonToast.success('Table copied to clipboard!') + }) + } catch { + lemonToast.error('Copy failed!') + } +} + interface DataTableExportProps { query: DataTableNode setQuery?: (query: DataTableNode) => void } export function DataTableExport({ query }: DataTableExportProps): JSX.Element | null { + const { dataTableRows, columnsInResponse, columnsInQuery, queryWithDefaults } = useValues(dataTableLogic) + const source: DataNode = query.source const filterCount = (isEventsQuery(source) || isPersonsNode(source) ? source.properties?.length || 0 : 0) + (isEventsQuery(source) && source.event ? 1 : 0) + (isPersonsNode(source) && source.search ? 1 : 0) const canExportAllColumns = isEventsQuery(source) || isPersonsNode(source) + const showExportClipboardButtons = isPersonsNode(source) || isEventsQuery(source) || isHogQLQuery(source) return ( , - ].concat( - canExportAllColumns - ? [ - startDownload(query, false)} - actor={isPersonsNode(query.source) ? 'persons' : 'events'} - limit={EXPORT_MAX_LIMIT} - > - - Export all columns - - , - ] - : [] - ), + ] + .concat( + canExportAllColumns + ? [ + startDownload(query, false)} + actor={isPersonsNode(query.source) ? 'persons' : 'events'} + limit={EXPORT_MAX_LIMIT} + > + + Export all columns + + , + ] + : [] + ) + .concat( + showExportClipboardButtons + ? [ + , + { + if (dataTableRows) { + copyTableToCsv( + dataTableRows, + columnsInResponse ?? columnsInQuery, + queryWithDefaults + ) + } + }} + > + Copy CSV to clipboard + , + { + if (dataTableRows) { + copyTableToJson( + dataTableRows, + columnsInResponse ?? columnsInQuery, + queryWithDefaults + ) + } + }} + > + Copy JSON to clipboard + , + ] + : [] + ), }} type="secondary" icon={} diff --git a/package.json b/package.json index 4aca9698d80..3f8131541f4 100644 --- a/package.json +++ b/package.json @@ -125,6 +125,7 @@ "kea-window-values": "^3.0.0", "md5": "^2.3.0", "monaco-editor": "^0.39.0", + "papaparse": "^5.4.1", "posthog-js": "1.78.5", "posthog-js-lite": "2.0.0-alpha5", "prettier": "^2.8.8", @@ -206,6 +207,7 @@ "@types/jest-image-snapshot": "^6.1.0", "@types/md5": "^2.3.0", "@types/node": "^18.11.9", + "@types/papaparse": "^5.3.8", "@types/pixelmatch": "^5.2.4", "@types/pngjs": "^6.0.1", "@types/query-selector-shadow-dom": "^1.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89ff0ef5e1a..301601dff1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: '6.1' +lockfileVersion: '6.0' settings: autoInstallPeers: true @@ -194,6 +194,9 @@ dependencies: monaco-editor: specifier: ^0.39.0 version: 0.39.0 + papaparse: + specifier: ^5.4.1 + version: 5.4.1 posthog-js: specifier: 1.78.5 version: 1.78.5 @@ -432,6 +435,9 @@ devDependencies: '@types/node': specifier: ^18.11.9 version: 18.11.9 + '@types/papaparse': + specifier: ^5.3.8 + version: 5.3.8 '@types/pixelmatch': specifier: ^5.2.4 version: 5.2.4 @@ -6212,6 +6218,12 @@ packages: resolution: {integrity: sha512-sn7L+qQ6RLPdXRoiaE7bZ/Ek+o4uICma/lBFPyJEKDTPTBP1W8u0c4baj3EiS4DiqLs+Hk+KUGvMVJtAw3ePJg==} dev: false + /@types/papaparse@5.3.8: + resolution: {integrity: sha512-ArKIEOOWULbhi53wkAiRy1ze4wvrTfhpAj7Yfzva+EkmX2sV8PpFB+xqzJfzXNzK4me95FJH9QZt5NXFVGzOoQ==} + dependencies: + '@types/node': 18.11.9 + dev: true + /@types/parse-json@4.0.0: resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==} dev: true @@ -12993,7 +13005,7 @@ packages: dependencies: universalify: 2.0.0 optionalDependencies: - graceful-fs: 4.2.10 + graceful-fs: 4.2.11 /jsprim@2.0.2: resolution: {integrity: sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==} @@ -14265,6 +14277,10 @@ packages: resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} dev: true + /papaparse@5.4.1: + resolution: {integrity: sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw==} + dev: false + /param-case@3.0.4: resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} dependencies: