From 10563505393eb0d3410ab3b346198f2db2ee98e3 Mon Sep 17 00:00:00 2001 From: Erica Hinkle Date: Fri, 1 Aug 2025 13:33:46 -0400 Subject: [PATCH] [FEATURE] Bar Chart CSV export functionality Signed-off-by: Erica Hinkle --- barchart/package.json | 8 +- barchart/src/BarChart.ts | 15 ++- barchart/src/BarChartExportAction.tsx | 63 ++++++++++ barchart/src/CSVExportUtils.ts | 173 ++++++++++++++++++++++++++ barchart/src/index.ts | 1 + package-lock.json | 82 +++++++++++- 6 files changed, 328 insertions(+), 14 deletions(-) create mode 100644 barchart/src/BarChartExportAction.tsx create mode 100644 barchart/src/CSVExportUtils.ts diff --git a/barchart/package.json b/barchart/package.json index 12f4566f..4a745fc1 100644 --- a/barchart/package.json +++ b/barchart/package.json @@ -23,13 +23,15 @@ "main": "lib/cjs/index.js", "module": "lib/index.js", "types": "lib/index.d.ts", + "dependencies": { + "@perses-dev/components": "0.52.0-beta.1", + "@perses-dev/core": "0.52.0-beta.1", + "@perses-dev/plugin-system": "0.52.0-beta.1" + }, "peerDependencies": { "@emotion/react": "^11.7.1", "@emotion/styled": "^11.6.0", "@hookform/resolvers": "^3.2.0", - "@perses-dev/components": "^0.51.0-rc.1", - "@perses-dev/core": "^0.51.0-rc.1", - "@perses-dev/plugin-system": "^0.51.0-rc.1", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "echarts": "5.5.0", diff --git a/barchart/src/BarChart.ts b/barchart/src/BarChart.ts index 25beba75..fab1b4e0 100644 --- a/barchart/src/BarChart.ts +++ b/barchart/src/BarChart.ts @@ -15,18 +15,17 @@ import { PanelPlugin } from '@perses-dev/plugin-system'; import { createInitialBarChartOptions, BarChartOptions } from './bar-chart-model'; import { BarChartOptionsEditorSettings } from './BarChartOptionsEditorSettings'; import { BarChartPanel, BarChartPanelProps } from './BarChartPanel'; +import { BarChartExportAction } from './BarChartExportAction'; -/** - * The core BarChart panel plugin for Perses. - */ export const BarChart: PanelPlugin = { PanelComponent: BarChartPanel, - panelOptionsEditorComponents: [ + supportedQueryTypes: ['TimeSeriesQuery'], + panelOptionsEditorComponents: [{ label: 'Settings', content: BarChartOptionsEditorSettings }], + createInitialOptions: createInitialBarChartOptions, + actions: [ { - label: 'Settings', - content: BarChartOptionsEditorSettings, + component: BarChartExportAction, + location: 'header', }, ], - supportedQueryTypes: ['TimeSeriesQuery'], - createInitialOptions: createInitialBarChartOptions, }; diff --git a/barchart/src/BarChartExportAction.tsx b/barchart/src/BarChartExportAction.tsx new file mode 100644 index 00000000..1f59be85 --- /dev/null +++ b/barchart/src/BarChartExportAction.tsx @@ -0,0 +1,63 @@ +// Copyright 2023 The Perses Authors +// Licensed 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 React, { useCallback, useMemo } from 'react'; +import { IconButton, Tooltip } from '@mui/material'; +import DownloadIcon from 'mdi-material-ui/Download'; +import { BarChartPanelProps } from './BarChartPanel'; +import { extractExportableData, isExportableData, sanitizeFilename, exportDataAsCSV } from './CSVExportUtils'; + +export const BarChartExportAction: React.FC = ({ queryResults, definition }) => { + const exportableData = useMemo(() => { + return extractExportableData(queryResults); + }, [queryResults]); + + const canExport = isExportableData(exportableData); + + const handleExport = useCallback(() => { + if (!exportableData || !canExport) return; + + try { + const title = definition?.spec?.display?.name || 'Bar Chart Data'; + + const csvBlob = exportDataAsCSV({ + data: exportableData, + }); + + const baseFilename = sanitizeFilename(title); + const filename = `${baseFilename}_data.csv`; + + const link = document.createElement('a'); + link.href = URL.createObjectURL(csvBlob); + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(link.href); + } catch (error) { + console.error('Bar chart export failed:', error); + } + }, [exportableData, canExport, definition]); + + if (!canExport) { + return null; + } + + return ( + + + + + + ); +}; diff --git a/barchart/src/CSVExportUtils.ts b/barchart/src/CSVExportUtils.ts new file mode 100644 index 00000000..a39872db --- /dev/null +++ b/barchart/src/CSVExportUtils.ts @@ -0,0 +1,173 @@ +// Copyright 2023 The Perses Authors +// Licensed 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 interface BarDataPoint { + value: unknown; +} + +export interface DataSeries { + name?: string; + formattedName?: string; + legendName?: string; + displayName?: string; + legend?: string; + labels?: Record; + values: Array<[number | string, unknown]> | BarDataPoint[]; +} + +export interface ExportableData { + series: DataSeries[]; + metadata?: Record; +} + +export const isExportableData = (data: unknown): data is ExportableData => { + if (!data || typeof data !== 'object') return false; + const candidate = data as Record; + return Array.isArray(candidate.series) && candidate.series.length > 0; +}; + +export interface QueryDataInput { + data?: unknown; + error?: unknown; +} + +export const extractExportableData = (queryResults: QueryDataInput[]): ExportableData | undefined => { + if (!queryResults || queryResults.length === 0) return undefined; + + const allSeries: DataSeries[] = []; + let metadata: ExportableData['metadata'] = undefined; + + queryResults.forEach((query) => { + if (query?.data && typeof query.data === 'object' && 'series' in query.data) { + const data = query.data as ExportableData; + if (data.series && Array.isArray(data.series) && data.series.length > 0) { + allSeries.push(...data.series); + if (!metadata && data.metadata) { + metadata = data.metadata; + } + } + } + }); + + if (allSeries.length > 0) { + return { + series: allSeries, + metadata, + }; + } + + return undefined; +}; + +export const sanitizeFilename = (filename: string): string => { + return filename + .replace(/[<>:"/\\|?*]/g, ' ') + .trim() + .split(/\s+/) + .filter((word) => word.length > 0) + .map((word, index) => { + if (index === 0) { + return word.toLowerCase(); + } + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); + }) + .join(''); +}; + +export const escapeCsvValue = (value: unknown): string => { + if (value === null || value === undefined) { + return ''; + } + + const stringValue = String(value); + + if ( + stringValue.includes(',') || + stringValue.includes('"') || + stringValue.includes('\n') || + stringValue.includes('\r') + ) { + return `"${stringValue.replace(/"/g, '""')}"`; + } + + return stringValue; +}; + +export interface ExportDataOptions { + data: ExportableData; +} + +export const exportDataAsCSV = ({ data }: ExportDataOptions): Blob => { + if (!isExportableData(data)) { + console.warn('No valid data found to export to CSV.'); + return new Blob([''], { type: 'text/csv;charset=utf-8' }); + } + + const seriesData: Array<{ label: string; value: number | null }> = []; + + for (let i = 0; i < data.series.length; i++) { + const series = data.series[i]; + + if (!series) { + continue; + } + + if (!Array.isArray(series.values) || series.values.length === 0) { + continue; + } + + let aggregatedValue: number | null = null; + + for (let j = 0; j < series.values.length; j++) { + const entry = series.values[j]; + let value: unknown; + + if (Array.isArray(entry) && entry.length >= 2) { + value = entry[1]; + } else if (typeof entry === 'object' && entry !== null && 'value' in entry) { + const dataPoint = entry as BarDataPoint; + value = dataPoint.value; + } else { + continue; + } + + if (value !== null && value !== undefined && !isNaN(Number(value))) { + aggregatedValue = Number(value); + break; + } + } + + seriesData.push({ + label: series.name || `Series ${i + 1}`, + value: aggregatedValue, + }); + } + + if (seriesData.length === 0) { + console.warn('No valid series data found to export to CSV.'); + return new Blob([''], { type: 'text/csv;charset=utf-8' }); + } + + let csvString = 'Label,Value\n'; + + for (let index = 0; index < seriesData.length; index++) { + const item = seriesData[index]; + if (!item) continue; + csvString += `${escapeCsvValue(item.label)},${escapeCsvValue(item.value)}`; + if (index < seriesData.length - 1) { + csvString += '\n'; + } + } + + return new Blob([csvString], { type: 'text/csv;charset=utf-8' }); +}; diff --git a/barchart/src/index.ts b/barchart/src/index.ts index 09d0eead..19fa32f7 100644 --- a/barchart/src/index.ts +++ b/barchart/src/index.ts @@ -16,3 +16,4 @@ export * from './BarChart'; export * from './BarChartOptionsEditorSettings'; export { getPluginModule } from './getPluginModule'; export * from './utils'; +export * from './CSVExportUtils'; diff --git a/package-lock.json b/package-lock.json index 8dc9ca21..56d31fe0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -77,13 +77,15 @@ "barchart": { "name": "@perses-dev/bar-chart-plugin", "version": "0.8.0", + "dependencies": { + "@perses-dev/components": "0.52.0-beta.1", + "@perses-dev/core": "0.52.0-beta.1", + "@perses-dev/plugin-system": "0.52.0-beta.1" + }, "peerDependencies": { "@emotion/react": "^11.7.1", "@emotion/styled": "^11.6.0", "@hookform/resolvers": "^3.2.0", - "@perses-dev/components": "^0.51.0-rc.1", - "@perses-dev/core": "^0.51.0-rc.1", - "@perses-dev/plugin-system": "^0.51.0-rc.1", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "echarts": "5.5.0", @@ -94,6 +96,80 @@ "use-resize-observer": "^9.0.0" } }, + "barchart/node_modules/@perses-dev/components": { + "version": "0.52.0-beta.1", + "resolved": "https://registry.npmjs.org/@perses-dev/components/-/components-0.52.0-beta.1.tgz", + "integrity": "sha512-Bm8oM9rC2GR3SZhieeSau/mkopOjfxZB3mmubLDkTBBRb/M39X+YEkS4XidkHt+HnSMl5bRhGNqxM8RWxKcm5A==", + "license": "Apache-2.0", + "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.4.0", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3", + "@codemirror/lang-json": "^6.0.1", + "@fontsource/lato": "^4.5.10", + "@mui/x-date-pickers": "^7.23.1", + "@perses-dev/core": "0.52.0-beta.1", + "@tanstack/react-table": "^8.20.5", + "@uiw/react-codemirror": "^4.19.1", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", + "echarts": "5.5.0", + "immer": "^10.1.1", + "lodash": "^4.17.21", + "mathjs": "^10.6.4", + "mdi-material-ui": "^7.9.2", + "notistack": "^3.0.2", + "react-colorful": "^5.6.1", + "react-error-boundary": "^3.1.4", + "react-hook-form": "^7.51.3", + "react-virtuoso": "^4.12.2" + }, + "peerDependencies": { + "@mui/material": "^6.1.10", + "react": "^17.0.2 || ^18.0.0", + "react-dom": "^17.0.2 || ^18.0.0" + } + }, + "barchart/node_modules/@perses-dev/core": { + "version": "0.52.0-beta.1", + "resolved": "https://registry.npmjs.org/@perses-dev/core/-/core-0.52.0-beta.1.tgz", + "integrity": "sha512-4RhldTO0PtY72n0c2/pDkpVuN91VtDSGZXherojiF9RMPW+MaYgnWXWPOrMv7iujqgSm97D564RVdNWb/xc55w==", + "license": "Apache-2.0", + "dependencies": { + "date-fns": "^4.1.0", + "lodash": "^4.17.21", + "mathjs": "^10.6.4", + "numbro": "^2.3.6", + "zod": "^3.21.4" + }, + "peerDependencies": { + "react": "^17.0.2 || ^18.0.0", + "react-dom": "^17.0.2 || ^18.0.0" + } + }, + "barchart/node_modules/@perses-dev/plugin-system": { + "version": "0.52.0-beta.1", + "resolved": "https://registry.npmjs.org/@perses-dev/plugin-system/-/plugin-system-0.52.0-beta.1.tgz", + "integrity": "sha512-m5AX5bwd2HJcL9NKU/Y+mo+7GzRTTuthrDsIQcawqXMp2TXlBkrgP59vupTxQxb59f9fgf76/X4knhWFmGodOA==", + "license": "Apache-2.0", + "dependencies": { + "@module-federation/enhanced": "^0.14.3", + "@perses-dev/components": "0.52.0-beta.1", + "@perses-dev/core": "0.52.0-beta.1", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", + "immer": "^10.1.1", + "react-hook-form": "^7.46.1", + "use-immer": "^0.11.0", + "use-query-params": "^2.1.2", + "zod": "^3.22.2" + }, + "peerDependencies": { + "@mui/material": "^6.1.10", + "@tanstack/react-query": "^4.39.1", + "react": "^17.0.2 || ^18.0.0", + "react-dom": "^17.0.2 || ^18.0.0" + } + }, "datasourcevariable": { "name": "@perses-dev/datasource-variable-plugin", "version": "0.2.0",