Dashboards: Fix UTF-8 characters not working with excel downloads by replacing download for excel with excel compatibility mode. (#110099)
* convert to utf-16le * Fix tests, remove download for excel * update i18n * Update copy * update test
This commit is contained in:
@@ -14,14 +14,14 @@ interface Props {
|
||||
dataFrames: DataFrame[];
|
||||
transformationOptions: Array<SelectableValue<DataTransformerID>>;
|
||||
selectedDataFrame: number | DataTransformerID;
|
||||
downloadForExcel: boolean;
|
||||
onDataFrameChange: (item: SelectableValue<DataTransformerID | number>) => void;
|
||||
toggleDownloadForExcel: () => void;
|
||||
data?: DataFrame[];
|
||||
hasTransformations?: boolean;
|
||||
formattedDataDescription?: string;
|
||||
onOptionsChange?: (options: GetDataOptions) => void;
|
||||
actions?: React.ReactNode;
|
||||
excelCompatibilityMode: boolean;
|
||||
toggleExcelCompatibilityMode: () => void;
|
||||
}
|
||||
|
||||
export const InspectDataOptions = ({
|
||||
@@ -35,8 +35,8 @@ export const InspectDataOptions = ({
|
||||
transformationOptions,
|
||||
selectedDataFrame,
|
||||
onDataFrameChange,
|
||||
downloadForExcel,
|
||||
toggleDownloadForExcel,
|
||||
excelCompatibilityMode,
|
||||
toggleExcelCompatibilityMode,
|
||||
}: Props) => {
|
||||
const styles = useStyles2(getPanelInspectorStyles2);
|
||||
|
||||
@@ -82,10 +82,6 @@ export const InspectDataOptions = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (downloadForExcel) {
|
||||
parts.push(t('dashboard.inspect-data.excel-header', 'Excel header'));
|
||||
}
|
||||
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
@@ -147,13 +143,17 @@ export const InspectDataOptions = ({
|
||||
</Field>
|
||||
)}
|
||||
<Field
|
||||
label={t('dashboard.inspect-data.download-excel-label', 'Download for Excel')}
|
||||
label={t('dashboard.inspect-data.excel-compatibility-mode-label', 'Download for Excel')}
|
||||
description={t(
|
||||
'dashboard.inspect-data.download-excel-description',
|
||||
'Adds header to CSV for use with Excel'
|
||||
'dashboard.inspect-data.excel-compatibility-mode-description',
|
||||
"Generates a CSV file that's compatible with most Excel versions"
|
||||
)}
|
||||
>
|
||||
<Switch id="excel-toggle" value={downloadForExcel} onChange={toggleDownloadForExcel} />
|
||||
<Switch
|
||||
id="excel-compatibility-mode-toggle"
|
||||
value={excelCompatibilityMode}
|
||||
onChange={toggleExcelCompatibilityMode}
|
||||
/>
|
||||
</Field>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -48,7 +48,7 @@ interface State {
|
||||
dataFrameIndex: number;
|
||||
transformationOptions: Array<SelectableValue<DataTransformerID>>;
|
||||
transformedData: DataFrame[];
|
||||
downloadForExcel: boolean;
|
||||
excelCompatibilityMode: boolean;
|
||||
}
|
||||
|
||||
export class InspectDataTab extends PureComponent<Props, State> {
|
||||
@@ -61,7 +61,7 @@ export class InspectDataTab extends PureComponent<Props, State> {
|
||||
transformId: DataTransformerID.noop,
|
||||
transformationOptions: buildTransformationOptions(),
|
||||
transformedData: props.data ?? [],
|
||||
downloadForExcel: false,
|
||||
excelCompatibilityMode: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ export class InspectDataTab extends PureComponent<Props, State> {
|
||||
reportInteraction('grafana_logs_download_clicked', { app: this.props.app, format: 'csv' });
|
||||
}
|
||||
|
||||
downloadDataFrameAsCsv(dataFrame, dataName, { useExcelHeader: this.state.downloadForExcel }, transformId);
|
||||
downloadDataFrameAsCsv(dataFrame, dataName, {}, transformId, this.state.excelCompatibilityMode);
|
||||
}
|
||||
|
||||
onExportLogsAsTxt = () => {
|
||||
@@ -167,9 +167,9 @@ export class InspectDataTab extends PureComponent<Props, State> {
|
||||
});
|
||||
};
|
||||
|
||||
onToggleDownloadForExcel = () => {
|
||||
onToggleExcelCompatibilityMode = () => {
|
||||
this.setState((prevState) => ({
|
||||
downloadForExcel: !prevState.downloadForExcel,
|
||||
excelCompatibilityMode: !prevState.excelCompatibilityMode,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -244,7 +244,7 @@ export class InspectDataTab extends PureComponent<Props, State> {
|
||||
|
||||
render() {
|
||||
const { isLoading, options, data, formattedDataDescription, onOptionsChange, hasTransformations } = this.props;
|
||||
const { dataFrameIndex, transformationOptions, selectedDataFrame, downloadForExcel } = this.state;
|
||||
const { dataFrameIndex, transformationOptions, selectedDataFrame, excelCompatibilityMode } = this.state;
|
||||
const styles = getPanelInspectorStyles();
|
||||
|
||||
if (isLoading) {
|
||||
@@ -282,11 +282,11 @@ export class InspectDataTab extends PureComponent<Props, State> {
|
||||
dataFrames={dataFrames}
|
||||
transformationOptions={transformationOptions}
|
||||
selectedDataFrame={selectedDataFrame}
|
||||
downloadForExcel={downloadForExcel}
|
||||
formattedDataDescription={formattedDataDescription}
|
||||
onOptionsChange={onOptionsChange}
|
||||
onDataFrameChange={this.onDataFrameChange}
|
||||
toggleDownloadForExcel={this.onToggleDownloadForExcel}
|
||||
excelCompatibilityMode={excelCompatibilityMode}
|
||||
toggleExcelCompatibilityMode={this.onToggleExcelCompatibilityMode}
|
||||
actions={this.renderActions(dataFrames, hasLogs, hasTraces, hasServiceGraph)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -25,11 +25,11 @@ describe('inspector download', () => {
|
||||
],
|
||||
},
|
||||
data: {
|
||||
values: [[100], ['a'], [1]],
|
||||
values: [[100], ['Åäö中文العربية'], [1]],
|
||||
},
|
||||
};
|
||||
|
||||
it.each([[dataFrameFromJSON(json), 'test', '"time","name","value"\r\n100,a,1']])(
|
||||
it.each([[dataFrameFromJSON(json), 'test', '"time","name","value"\r\n100,Åäö中文العربية,1']])(
|
||||
'should, when logsModel is %s and title is %s, resolve in %s',
|
||||
async (dataFrame, title, expected) => {
|
||||
downloadDataFrameAsCsv(dataFrame, title);
|
||||
@@ -39,22 +39,23 @@ describe('inspector download', () => {
|
||||
const text = await blob.text();
|
||||
|
||||
// By default the BOM character should not be included
|
||||
expect(await hasBOM(blob)).toBe(false);
|
||||
expect(await getBomType(blob)).toBeUndefined();
|
||||
expect(text).toEqual(expected);
|
||||
expect(filename).toEqual(`${title}-data-${dateTimeFormat(1400000000000)}.csv`);
|
||||
}
|
||||
);
|
||||
|
||||
it('should include the BOM character when useExcelHeader is true', async () => {
|
||||
downloadDataFrameAsCsv(dataFrameFromJSON(json), 'test', { useExcelHeader: true });
|
||||
it('should use \t as the delimiter and the file should be utf16le if excelCompatibilityMode is true', async () => {
|
||||
downloadDataFrameAsCsv(dataFrameFromJSON(json), 'test', undefined, undefined, true);
|
||||
|
||||
const call = (saveAs as unknown as jest.Mock).mock.calls[0];
|
||||
const blob = call[0];
|
||||
const filename = call[1];
|
||||
const text = await blob.text();
|
||||
|
||||
expect(await hasBOM(blob)).toBe(true);
|
||||
expect(text).toEqual('sep=,\r\n"time","name","value"\r\n100,a,1');
|
||||
expect(await getBomType(blob)).toBe('utf-16le');
|
||||
expect(blob.type).toBe('text/csv;charset=utf-16le');
|
||||
expect(text).toEqual('"time"\t"name"\t"value"\r\n100\tÅäö中文العربية\t1');
|
||||
expect(filename).toEqual(`test-data-${dateTimeFormat(1400000000000)}.csv`);
|
||||
});
|
||||
});
|
||||
@@ -127,18 +128,28 @@ describe('inspector download', () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function hasBOM(blob: Blob) {
|
||||
async function getBomType(blob: Blob): Promise<'utf-8' | 'utf-16le' | undefined> {
|
||||
const reader = new FileReader();
|
||||
return new Promise<boolean>((resolve, reject) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
reader.onload = (event: ProgressEvent<FileReader>) => {
|
||||
if (event.target?.result instanceof ArrayBuffer) {
|
||||
const arr = new Uint8Array(event.target.result);
|
||||
resolve(arr[0] === 0xef && arr[1] === 0xbb && arr[2] === 0xbf); // Check for UTF-8 BOM
|
||||
// UTF-8: EF BB BF
|
||||
if (arr.length >= 3 && arr[0] === 0xef && arr[1] === 0xbb && arr[2] === 0xbf) {
|
||||
resolve('utf-8');
|
||||
return;
|
||||
}
|
||||
// UTF-16 LE: FF FE
|
||||
if (arr.length >= 2 && arr[0] === 0xff && arr[1] === 0xfe) {
|
||||
resolve('utf-16le');
|
||||
return;
|
||||
}
|
||||
resolve(undefined);
|
||||
} else {
|
||||
reject(new Error('Unexpected FileReader result type'));
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsArrayBuffer(blob.slice(0, 3)); // Read only the first 3 bytes
|
||||
reader.readAsArrayBuffer(blob.slice(0, 3)); // Read first 3 bytes (covers UTF-8 and UTF-16LE)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -54,14 +54,32 @@ export function downloadDataFrameAsCsv(
|
||||
dataFrame: DataFrame,
|
||||
title: string,
|
||||
csvConfig?: CSVConfig,
|
||||
transformId: DataTransformerID = DataTransformerID.noop
|
||||
transformId: DataTransformerID = DataTransformerID.noop,
|
||||
excelCompatibilityMode = false
|
||||
) {
|
||||
const dataFrameCsv = toCSV([dataFrame], csvConfig);
|
||||
const bomChar = csvConfig?.useExcelHeader ? String.fromCharCode(0xfeff) : '';
|
||||
let blob;
|
||||
|
||||
const blob = new Blob([bomChar, dataFrameCsv], {
|
||||
type: 'text/csv;charset=utf-8',
|
||||
});
|
||||
if (excelCompatibilityMode) {
|
||||
/**
|
||||
* This compatibility mode creates a utf16le csv file that uses \t as the delimiter.
|
||||
* This is to fix an issue where excel does not recognize the BOM indicating UTF-8 when the SEP= meta data header is present.
|
||||
* Without the SEP= metadata header excel will try to use the system list separator.
|
||||
* If the CSV was created on a system where the separator was ',' it will not work on a system where the separator is ';'
|
||||
* This is common on locales where ',' is the decimal separator.
|
||||
*
|
||||
* When excel opens a utf16le csv file it will no longer try to use the system list separator, and instead use \t as the separator.
|
||||
*/
|
||||
const dataFrameCsv = toCSV([dataFrame], { ...csvConfig, useExcelHeader: false, delimiter: '\t' });
|
||||
const utf16le = new Uint16Array(Array.from('\ufeff' + dataFrameCsv).map((char) => char.charCodeAt(0)));
|
||||
blob = new Blob([utf16le], {
|
||||
type: 'text/csv;charset=utf-16le',
|
||||
});
|
||||
} else {
|
||||
const dataFrameCsv = toCSV([dataFrame], csvConfig);
|
||||
blob = new Blob([dataFrameCsv], {
|
||||
type: 'text/csv;charset=utf-8',
|
||||
});
|
||||
}
|
||||
|
||||
const transformation = transformId !== DataTransformerID.noop ? '-as-' + transformId.toLocaleLowerCase() : '';
|
||||
const fileName = `${title}-data${transformation}-${dateTimeFormat(new Date())}.csv`;
|
||||
|
||||
@@ -4936,12 +4936,11 @@
|
||||
"dataframe-aria-label": "Select dataframe",
|
||||
"dataframe-label": "Show data frame",
|
||||
"download-csv": "Download CSV",
|
||||
"download-excel-description": "Adds header to CSV for use with Excel",
|
||||
"download-excel-label": "Download for Excel",
|
||||
"download-logs": "Download logs",
|
||||
"download-service": "Download service graph",
|
||||
"download-traces": "Download traces",
|
||||
"excel-header": "Excel header",
|
||||
"excel-compatibility-mode-description": "Generates a CSV file that's compatible with most Excel versions",
|
||||
"excel-compatibility-mode-label": "Download for Excel",
|
||||
"formatted": "Formatted data",
|
||||
"formatted-data-description": "Table data is formatted with options defined in the Field and Override tabs.",
|
||||
"formatted-data-label": "Formatted data",
|
||||
|
||||
Reference in New Issue
Block a user