Skip to content

Commit 1056350

Browse files
committed
[FEATURE] Bar Chart CSV export functionality
Signed-off-by: Erica Hinkle <[email protected]>
1 parent 079aa03 commit 1056350

File tree

6 files changed

+328
-14
lines changed

6 files changed

+328
-14
lines changed

barchart/package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,15 @@
2323
"main": "lib/cjs/index.js",
2424
"module": "lib/index.js",
2525
"types": "lib/index.d.ts",
26+
"dependencies": {
27+
"@perses-dev/components": "0.52.0-beta.1",
28+
"@perses-dev/core": "0.52.0-beta.1",
29+
"@perses-dev/plugin-system": "0.52.0-beta.1"
30+
},
2631
"peerDependencies": {
2732
"@emotion/react": "^11.7.1",
2833
"@emotion/styled": "^11.6.0",
2934
"@hookform/resolvers": "^3.2.0",
30-
"@perses-dev/components": "^0.51.0-rc.1",
31-
"@perses-dev/core": "^0.51.0-rc.1",
32-
"@perses-dev/plugin-system": "^0.51.0-rc.1",
3335
"date-fns": "^4.1.0",
3436
"date-fns-tz": "^3.2.0",
3537
"echarts": "5.5.0",

barchart/src/BarChart.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,17 @@ import { PanelPlugin } from '@perses-dev/plugin-system';
1515
import { createInitialBarChartOptions, BarChartOptions } from './bar-chart-model';
1616
import { BarChartOptionsEditorSettings } from './BarChartOptionsEditorSettings';
1717
import { BarChartPanel, BarChartPanelProps } from './BarChartPanel';
18+
import { BarChartExportAction } from './BarChartExportAction';
1819

19-
/**
20-
* The core BarChart panel plugin for Perses.
21-
*/
2220
export const BarChart: PanelPlugin<BarChartOptions, BarChartPanelProps> = {
2321
PanelComponent: BarChartPanel,
24-
panelOptionsEditorComponents: [
22+
supportedQueryTypes: ['TimeSeriesQuery'],
23+
panelOptionsEditorComponents: [{ label: 'Settings', content: BarChartOptionsEditorSettings }],
24+
createInitialOptions: createInitialBarChartOptions,
25+
actions: [
2526
{
26-
label: 'Settings',
27-
content: BarChartOptionsEditorSettings,
27+
component: BarChartExportAction,
28+
location: 'header',
2829
},
2930
],
30-
supportedQueryTypes: ['TimeSeriesQuery'],
31-
createInitialOptions: createInitialBarChartOptions,
3231
};
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Copyright 2023 The Perses Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
import React, { useCallback, useMemo } from 'react';
15+
import { IconButton, Tooltip } from '@mui/material';
16+
import DownloadIcon from 'mdi-material-ui/Download';
17+
import { BarChartPanelProps } from './BarChartPanel';
18+
import { extractExportableData, isExportableData, sanitizeFilename, exportDataAsCSV } from './CSVExportUtils';
19+
20+
export const BarChartExportAction: React.FC<BarChartPanelProps> = ({ queryResults, definition }) => {
21+
const exportableData = useMemo(() => {
22+
return extractExportableData(queryResults);
23+
}, [queryResults]);
24+
25+
const canExport = isExportableData(exportableData);
26+
27+
const handleExport = useCallback(() => {
28+
if (!exportableData || !canExport) return;
29+
30+
try {
31+
const title = definition?.spec?.display?.name || 'Bar Chart Data';
32+
33+
const csvBlob = exportDataAsCSV({
34+
data: exportableData,
35+
});
36+
37+
const baseFilename = sanitizeFilename(title);
38+
const filename = `${baseFilename}_data.csv`;
39+
40+
const link = document.createElement('a');
41+
link.href = URL.createObjectURL(csvBlob);
42+
link.download = filename;
43+
document.body.appendChild(link);
44+
link.click();
45+
document.body.removeChild(link);
46+
URL.revokeObjectURL(link.href);
47+
} catch (error) {
48+
console.error('Bar chart export failed:', error);
49+
}
50+
}, [exportableData, canExport, definition]);
51+
52+
if (!canExport) {
53+
return null;
54+
}
55+
56+
return (
57+
<Tooltip title="Export as CSV">
58+
<IconButton size="small" onClick={handleExport} aria-label="Export bar chart data as CSV">
59+
<DownloadIcon fontSize="inherit" />
60+
</IconButton>
61+
</Tooltip>
62+
);
63+
};

barchart/src/CSVExportUtils.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
// Copyright 2023 The Perses Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
export interface BarDataPoint {
15+
value: unknown;
16+
}
17+
18+
export interface DataSeries {
19+
name?: string;
20+
formattedName?: string;
21+
legendName?: string;
22+
displayName?: string;
23+
legend?: string;
24+
labels?: Record<string, string>;
25+
values: Array<[number | string, unknown]> | BarDataPoint[];
26+
}
27+
28+
export interface ExportableData {
29+
series: DataSeries[];
30+
metadata?: Record<string, unknown>;
31+
}
32+
33+
export const isExportableData = (data: unknown): data is ExportableData => {
34+
if (!data || typeof data !== 'object') return false;
35+
const candidate = data as Record<string, unknown>;
36+
return Array.isArray(candidate.series) && candidate.series.length > 0;
37+
};
38+
39+
export interface QueryDataInput {
40+
data?: unknown;
41+
error?: unknown;
42+
}
43+
44+
export const extractExportableData = (queryResults: QueryDataInput[]): ExportableData | undefined => {
45+
if (!queryResults || queryResults.length === 0) return undefined;
46+
47+
const allSeries: DataSeries[] = [];
48+
let metadata: ExportableData['metadata'] = undefined;
49+
50+
queryResults.forEach((query) => {
51+
if (query?.data && typeof query.data === 'object' && 'series' in query.data) {
52+
const data = query.data as ExportableData;
53+
if (data.series && Array.isArray(data.series) && data.series.length > 0) {
54+
allSeries.push(...data.series);
55+
if (!metadata && data.metadata) {
56+
metadata = data.metadata;
57+
}
58+
}
59+
}
60+
});
61+
62+
if (allSeries.length > 0) {
63+
return {
64+
series: allSeries,
65+
metadata,
66+
};
67+
}
68+
69+
return undefined;
70+
};
71+
72+
export const sanitizeFilename = (filename: string): string => {
73+
return filename
74+
.replace(/[<>:"/\\|?*]/g, ' ')
75+
.trim()
76+
.split(/\s+/)
77+
.filter((word) => word.length > 0)
78+
.map((word, index) => {
79+
if (index === 0) {
80+
return word.toLowerCase();
81+
}
82+
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
83+
})
84+
.join('');
85+
};
86+
87+
export const escapeCsvValue = (value: unknown): string => {
88+
if (value === null || value === undefined) {
89+
return '';
90+
}
91+
92+
const stringValue = String(value);
93+
94+
if (
95+
stringValue.includes(',') ||
96+
stringValue.includes('"') ||
97+
stringValue.includes('\n') ||
98+
stringValue.includes('\r')
99+
) {
100+
return `"${stringValue.replace(/"/g, '""')}"`;
101+
}
102+
103+
return stringValue;
104+
};
105+
106+
export interface ExportDataOptions {
107+
data: ExportableData;
108+
}
109+
110+
export const exportDataAsCSV = ({ data }: ExportDataOptions): Blob => {
111+
if (!isExportableData(data)) {
112+
console.warn('No valid data found to export to CSV.');
113+
return new Blob([''], { type: 'text/csv;charset=utf-8' });
114+
}
115+
116+
const seriesData: Array<{ label: string; value: number | null }> = [];
117+
118+
for (let i = 0; i < data.series.length; i++) {
119+
const series = data.series[i];
120+
121+
if (!series) {
122+
continue;
123+
}
124+
125+
if (!Array.isArray(series.values) || series.values.length === 0) {
126+
continue;
127+
}
128+
129+
let aggregatedValue: number | null = null;
130+
131+
for (let j = 0; j < series.values.length; j++) {
132+
const entry = series.values[j];
133+
let value: unknown;
134+
135+
if (Array.isArray(entry) && entry.length >= 2) {
136+
value = entry[1];
137+
} else if (typeof entry === 'object' && entry !== null && 'value' in entry) {
138+
const dataPoint = entry as BarDataPoint;
139+
value = dataPoint.value;
140+
} else {
141+
continue;
142+
}
143+
144+
if (value !== null && value !== undefined && !isNaN(Number(value))) {
145+
aggregatedValue = Number(value);
146+
break;
147+
}
148+
}
149+
150+
seriesData.push({
151+
label: series.name || `Series ${i + 1}`,
152+
value: aggregatedValue,
153+
});
154+
}
155+
156+
if (seriesData.length === 0) {
157+
console.warn('No valid series data found to export to CSV.');
158+
return new Blob([''], { type: 'text/csv;charset=utf-8' });
159+
}
160+
161+
let csvString = 'Label,Value\n';
162+
163+
for (let index = 0; index < seriesData.length; index++) {
164+
const item = seriesData[index];
165+
if (!item) continue;
166+
csvString += `${escapeCsvValue(item.label)},${escapeCsvValue(item.value)}`;
167+
if (index < seriesData.length - 1) {
168+
csvString += '\n';
169+
}
170+
}
171+
172+
return new Blob([csvString], { type: 'text/csv;charset=utf-8' });
173+
};

barchart/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ export * from './BarChart';
1616
export * from './BarChartOptionsEditorSettings';
1717
export { getPluginModule } from './getPluginModule';
1818
export * from './utils';
19+
export * from './CSVExportUtils';

package-lock.json

Lines changed: 79 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)