From b64dca649ecffa93e1059ca0e45d57903b489a15 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Mon, 4 Nov 2024 14:20:05 -0600 Subject: [PATCH] Add UTF-8 BOM to CSV exports --- .../AccountTransactions.test.tsx | 22 +++++++------------ .../AccountTransactions.tsx | 20 +++++------------ .../Layout/Header/Actions/Actions.test.tsx | 2 +- .../Layout/Header/Actions/Actions.tsx | 2 +- 4 files changed, 16 insertions(+), 30 deletions(-) diff --git a/src/components/Reports/FinancialAccountsReport/AccountTransactions/AccountTransactions.test.tsx b/src/components/Reports/FinancialAccountsReport/AccountTransactions/AccountTransactions.test.tsx index 6dfec3a26..1c774fcd6 100644 --- a/src/components/Reports/FinancialAccountsReport/AccountTransactions/AccountTransactions.test.tsx +++ b/src/components/Reports/FinancialAccountsReport/AccountTransactions/AccountTransactions.test.tsx @@ -5,6 +5,7 @@ import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; import { fireEvent, render, waitFor } from '@testing-library/react'; import { Settings } from 'luxon'; import { SnackbarProvider } from 'notistack'; +import { buildURI } from 'react-csv/lib/core'; import { I18nextProvider } from 'react-i18next'; import TestRouter from '__tests__/util/TestRouter'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; @@ -25,6 +26,8 @@ import { AccountTransactions } from './AccountTransactions'; import { financialAccountEntriesMock } from './AccountTransactionsMocks'; import { FinancialAccountEntriesQuery } from './financialAccountTransactions.generated'; +jest.mock('react-csv/lib/core'); + const accountListId = 'account-list-1'; const financialAccountId = 'financialAccountId'; const defaultSearchTerm = ''; @@ -291,6 +294,9 @@ describe('Financial Account Transactions', () => { }); it('should export CSV', async () => { + const mockBlobUrl = 'blob:'; + buildURI.mockReturnValue(mockBlobUrl); + const { getByRole } = render(); await waitFor(() => { @@ -318,24 +324,12 @@ describe('Financial Account Transactions', () => { ['8/8/2024', 'description2', 'category1Name', '15008', ''], ['8/7/2024', 'description3', 'category2Name', '', '36.2'], ]; - - const csvContent = - 'data:text/csv;charset=utf-8,' + - csvContentArray - .map((row) => - row - .map((field) => `"${String(field).replace(/"/g, '""')}"`) - .join(','), - ) - .join('\n'); + expect(buildURI).toHaveBeenCalledWith(csvContentArray, true); await waitFor(() => { expect(createElementSpy).toHaveBeenCalledWith('a'); expect(appendChildSpy).toHaveBeenCalled(); - expect(setAttributeSpy).toHaveBeenCalledWith( - 'href', - encodeURI(csvContent), - ); + expect(setAttributeSpy).toHaveBeenCalledWith('href', mockBlobUrl); expect(clickSpy).toHaveBeenCalled(); expect(removeChildSpy).toHaveBeenCalled(); }); diff --git a/src/components/Reports/FinancialAccountsReport/AccountTransactions/AccountTransactions.tsx b/src/components/Reports/FinancialAccountsReport/AccountTransactions/AccountTransactions.tsx index a2b70c3a3..e73a0baa9 100644 --- a/src/components/Reports/FinancialAccountsReport/AccountTransactions/AccountTransactions.tsx +++ b/src/components/Reports/FinancialAccountsReport/AccountTransactions/AccountTransactions.tsx @@ -3,6 +3,7 @@ import React, { useContext, useEffect, useMemo } from 'react'; import { Box, CircularProgress } from '@mui/material'; import { styled } from '@mui/material/styles'; import { DateTime } from 'luxon'; +import { buildURI } from 'react-csv/lib/core'; import { useTranslation } from 'react-i18next'; import { Panel } from 'pages/accountLists/[accountListId]/reports/helpers'; import { headerHeight } from 'src/components/Shared/Header/ListHeader'; @@ -115,10 +116,10 @@ export const AccountTransactions: React.FC = () => { t('Outflow'), t('Inflow'), ]; - const convertDataToArray = data.financialAccountEntries.entries.reduce( - (array, entry) => { + const csvLines = data.financialAccountEntries.entries.reduce( + (csvLines, entry) => { return [ - ...array, + ...csvLines, [ entry.entryDate ? dateFormatShort(DateTime.fromISO(entry.entryDate), locale) @@ -138,20 +139,11 @@ export const AccountTransactions: React.FC = () => { ); // Convert Array to CSV format - const csvContent = - 'data:text/csv;charset=utf-8,' + - convertDataToArray - .map((row) => - row - .map((field) => `"${String(field).replace(/"/g, '""')}"`) - .join(','), - ) - .join('\n'); + const csvBlob = buildURI(csvLines, true); // Create a link and trigger download - const encodedUri = encodeURI(csvContent); const link = document.createElement('a'); - link.setAttribute('href', encodedUri); + link.setAttribute('href', csvBlob); link.setAttribute('download', `${appName}-entries-export${dateRange}.csv`); document.body.appendChild(link); link.click(); diff --git a/src/components/Reports/FourteenMonthReports/Layout/Header/Actions/Actions.test.tsx b/src/components/Reports/FourteenMonthReports/Layout/Header/Actions/Actions.test.tsx index eec8190d9..f48fbafc7 100644 --- a/src/components/Reports/FourteenMonthReports/Layout/Header/Actions/Actions.test.tsx +++ b/src/components/Reports/FourteenMonthReports/Layout/Header/Actions/Actions.test.tsx @@ -31,7 +31,7 @@ describe('FourteenMonthReportActions', () => { userEvent.click(getByRole('button', { name: 'Print' })); expect(getByRole('link', { name: 'Export' })).toHaveAttribute( 'href', - 'data:text/csv;charset=utf-8,', + 'data:text/csv;charset=utf-8,\uFEFF', ); }); diff --git a/src/components/Reports/FourteenMonthReports/Layout/Header/Actions/Actions.tsx b/src/components/Reports/FourteenMonthReports/Layout/Header/Actions/Actions.tsx index e9dc89a61..1b4539219 100644 --- a/src/components/Reports/FourteenMonthReports/Layout/Header/Actions/Actions.tsx +++ b/src/components/Reports/FourteenMonthReports/Layout/Header/Actions/Actions.tsx @@ -34,7 +34,7 @@ export const FourteenMonthReportActions: React.FC< // This has to be a useEffect instead of a useMemo to prevent hydration errors because the // server isn't able to calculate a blob URL. useEffect(() => { - const csvBlob = buildURI(csvData); + const csvBlob = buildURI(csvData, true); setCsvBlob(csvBlob); return () => URL.revokeObjectURL(csvBlob);