diff --git a/src/components/ExportDownloadStatusModal.tsx b/src/components/ExportDownloadStatusModal.tsx index 67e0e130345b..623ab4d0981f 100644 --- a/src/components/ExportDownloadStatusModal.tsx +++ b/src/components/ExportDownloadStatusModal.tsx @@ -70,7 +70,8 @@ function ExportDownloadStatusModal({exportID, isVisible, onClose, failedBody}: E const isCSV = fileName.endsWith('.csv'); const secureType = isCSV ? 'csvexport' : 'pdfreport'; const url = `${baseURL}secure?secureType=${secureType}&filename=${encodeURIComponent(fileName)}&downloadName=${encodeURIComponent(fileName)}&email=${encodeURIComponent(currentUserLogin)}`; - fileDownload(translate, addEncryptedAuthTokenToURL(url, encryptedAuthToken ?? '', true), fileName, '', isMobileSafari()); + // The backend already names the file `Expensify__`, so skip the timestamp suffix. + fileDownload(translate, addEncryptedAuthTokenToURL(url, encryptedAuthToken ?? '', true), fileName, '', isMobileSafari(), undefined, undefined, undefined, false, false); }; useEffect(() => { diff --git a/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx b/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx index 84e20254a316..790bc22603e6 100644 --- a/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx +++ b/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx @@ -81,7 +81,7 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool const transactionsWithoutPendingDelete = transactions.filter((t) => !isTransactionPendingDelete(t)); - const beginExportWithTemplate = (templateName: string, templateType: string, transactionIDList: string[]) => { + const beginExportWithTemplate = (templateName: string, templateType: string, transactionIDList: string[], exportName: string) => { if (isOffline) { setOfflineModalVisible(true); return; @@ -98,6 +98,7 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool reportIDList: [report.reportID], transactionIDList, policyID: policy?.id, + exportName, }); showConfirmModal({ @@ -153,7 +154,7 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool onExportFailed: () => setIsDownloadErrorModalVisible(true), onExportOffline: () => setOfflineModalVisible(true), policy, - beginExportWithTemplate: (templateName, templateType, transactionIDList) => beginExportWithTemplate(templateName, templateType, transactionIDList), + beginExportWithTemplate: (templateName, templateType, transactionIDList, exportName) => beginExportWithTemplate(templateName, templateType, transactionIDList, exportName), onDeleteSelected, }); diff --git a/src/hooks/useExportActions.ts b/src/hooks/useExportActions.ts index 7dce009065f3..06e12970698e 100644 --- a/src/hooks/useExportActions.ts +++ b/src/hooks/useExportActions.ts @@ -36,7 +36,7 @@ type UseExportActionsParams = { type UseExportActionsReturn = { exportActionEntries: Record> & Pick>; secondaryExportActions: Array>; - beginExportWithTemplate: (templateName: string, templateType: string, transactionIDList: string[], policyID?: string) => void; + beginExportWithTemplate: (templateName: string, templateType: string, transactionIDList: string[], exportName: string, policyID?: string) => void; showOfflineModal: () => void; showDownloadErrorModal: () => void; }; @@ -110,7 +110,7 @@ function useExportActions({reportID, policy, onPDFModalOpen}: UseExportActionsPa }); }; - const beginExportWithTemplate = (templateName: string, templateType: string, transactionIDList: string[], policyID?: string) => { + const beginExportWithTemplate = (templateName: string, templateType: string, transactionIDList: string[], exportName: string, policyID?: string) => { if (isOffline) { showOfflineModal(); return; @@ -134,6 +134,7 @@ function useExportActions({reportID, policy, onPDFModalOpen}: UseExportActionsPa reportIDList: [moneyRequestReport.reportID], transactionIDList, policyID, + exportName, }); }; @@ -212,7 +213,7 @@ function useExportActions({reportID, policy, onPDFModalOpen}: UseExportActionsPa value: template.templateName, description: template.description, sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.EXPORT_FILE, - onSelected: () => beginExportWithTemplate(template.templateName, template.type, transactionIDs, template.policyID), + onSelected: () => beginExportWithTemplate(template.templateName, template.type, transactionIDs, template.name, template.policyID), }; } diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 5377ab8f69cb..3698025c09e8 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -560,7 +560,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(selectedTransactionsKeys); const beginExportWithTemplate = useCallback( - (templateName: string, templateType: string, policyID: string | undefined) => { + (templateName: string, templateType: string, policyID: string | undefined, exportName: string) => { const emptyReports = selectedReports?.filter((selectedReport) => { if (!selectedReport) { @@ -592,6 +592,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { reportIDList: [], transactionIDList: [], policyID, + exportName, }, true, ); @@ -605,6 +606,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { reportIDList: isGroupExport ? [] : selectedTransactionReportIDs, transactionIDList: isGroupExport ? [] : selectedTransactionsKeys, policyID, + exportName, }, true, ); @@ -670,6 +672,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return; } + const exportName = translate(isBasicExport ? 'export.basicExport' : 'export.currentView'); + if (areAllMatchingItemsSelected) { if (selectedTransactionsKeys.length === 0 || status == null || !hash) { return; @@ -683,6 +687,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { transactionIDList: selectedTransactionsKeys, isBasicExport: exportParameters.isBasicExport, exportColumnLabels: exportParameters.exportColumnLabels, + exportName, }); setActiveExportID(exportID); return; @@ -701,6 +706,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { transactionIDList: isGroupExport ? [] : selectedTransactionsKeys, isBasicExport: exportParameters.isBasicExport, exportColumnLabels: exportParameters.exportColumnLabels, + exportName, }, () => { didFail = true; @@ -1492,7 +1498,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { icon: isStandardTemplate ? expensifyIcons.Table : expensifyIcons.TablePencil, description: template.description, onSelected: () => { - beginExportWithTemplate(template.templateName, template.type, template.policyID); + beginExportWithTemplate(template.templateName, template.type, template.policyID, template.name); }, shouldCloseModalOnSelect: true, shouldCallAfterModalHide: true, diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index 73ba73b70ac7..461dc1c56230 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -73,7 +73,7 @@ function useSelectedTransactionsActions({ onExportFailed?: () => void; onExportOffline?: () => void; policy?: Policy; - beginExportWithTemplate: (templateName: string, templateType: string, transactionIDList: string[], policyID?: string) => void; + beginExportWithTemplate: (templateName: string, templateType: string, transactionIDList: string[], exportName: string, policyID?: string) => void; isOnSearch?: boolean; onDeleteSelected?: (handleDeleteTransactions: () => void, handleDeleteTransactionsWithNavigation: (backToRoute?: Route) => void) => void | Promise; }) { @@ -411,7 +411,7 @@ function useSelectedTransactionsActions({ text: template.name, icon: isStandardTemplate ? expensifyIcons.Table : expensifyIcons.TablePencil, description: template.description, - onSelected: () => beginExportWithTemplate(template.templateName, template.type, selectedTransactionIDs, template.policyID), + onSelected: () => beginExportWithTemplate(template.templateName, template.type, selectedTransactionIDs, template.name, template.policyID), }); } return exportOptions; diff --git a/src/languages/en.ts b/src/languages/en.ts index cafffa8acc4d..c483bf948c5d 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -9722,7 +9722,7 @@ const translations = { }, export: { basicExport: 'Basic export', - currentView: 'Export current view', + currentView: 'Current view', reportLevelExport: 'All Data - report level', expenseLevelExport: 'All Data - expense level', exportInProgress: 'Export in progress', diff --git a/src/languages/es.ts b/src/languages/es.ts index 76ba22019233..abffb25c4a31 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -9819,7 +9819,7 @@ ${amount} para ${merchant} - ${date}`, expenseLevelExport: 'Todos los datos - a nivel de gasto', exportInProgress: 'Exportación en curso', conciergeWillSend: 'Concierge te enviará el archivo en breve.', - currentView: 'Exportar vista actual', + currentView: 'Vista actual', }, exportDownload: { preparingTitle: 'Preparando descarga...', diff --git a/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts b/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts index 0d321bf01e48..b7309bf30f30 100644 --- a/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts +++ b/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts @@ -7,6 +7,7 @@ type ExportSearchItemsToCSVParams = { transactionIDList: string[]; isBasicExport: boolean; exportColumnLabels: string; + exportName: string; }; export default ExportSearchItemsToCSVParams; diff --git a/src/libs/API/parameters/ExportSearchWithTemplateParams.ts b/src/libs/API/parameters/ExportSearchWithTemplateParams.ts index 17b24ab4c3f1..4fd69ce08433 100644 --- a/src/libs/API/parameters/ExportSearchWithTemplateParams.ts +++ b/src/libs/API/parameters/ExportSearchWithTemplateParams.ts @@ -7,6 +7,7 @@ type ExportSearchWithTemplateParams = { reportIDList: string[]; transactionIDList: string[]; policyID: string | undefined; + exportName: string; }; export default ExportSearchWithTemplateParams; diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 42f554d962b2..77fb3c856987 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -26,6 +26,7 @@ import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import {getCommandURL} from '@libs/ApiUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import fileDownload from '@libs/fileDownload'; +import {getExportFileName} from '@libs/fileDownload/FileUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; @@ -1200,7 +1201,7 @@ function rejectMoneyRequestsOnSearch( type Params = Record; function exportSearchItemsToCSV( - {query, jsonQuery, reportIDList, transactionIDList, isBasicExport, exportColumnLabels}: ExportSearchItemsToCSVParams, + {query, jsonQuery, reportIDList, transactionIDList, isBasicExport, exportColumnLabels, exportName}: ExportSearchItemsToCSVParams, onDownloadFailed: () => void, translate: LocalizedTranslate, ) { @@ -1247,10 +1248,21 @@ function exportSearchItemsToCSV( } } - return fileDownload(translate, getCommandURL({command: WRITE_COMMANDS.EXPORT_SEARCH_ITEMS_TO_CSV}), 'Expensify.csv', '', false, formData, CONST.NETWORK.METHOD.POST, onDownloadFailed); + return fileDownload( + translate, + getCommandURL({command: WRITE_COMMANDS.EXPORT_SEARCH_ITEMS_TO_CSV}), + getExportFileName(exportName, rand64()), + '', + false, + formData, + CONST.NETWORK.METHOD.POST, + onDownloadFailed, + false, + false, + ); } -function queueExportSearchItemsToCSV({query, jsonQuery, reportIDList, transactionIDList, isBasicExport, exportColumnLabels}: ExportSearchItemsToCSVParams): string { +function queueExportSearchItemsToCSV({query, jsonQuery, reportIDList, transactionIDList, isBasicExport, exportColumnLabels, exportName}: ExportSearchItemsToCSVParams): string { const exportID = rand64(); const onyxKey = `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${exportID}` as const; @@ -1276,6 +1288,7 @@ function queueExportSearchItemsToCSV({query, jsonQuery, reportIDList, transactio transactionIDList, isBasicExport, exportColumnLabels, + exportName, exportID, }) as QueueExportSearchItemsToCSVParams; @@ -1285,7 +1298,7 @@ function queueExportSearchItemsToCSV({query, jsonQuery, reportIDList, transactio } function queueExportSearchWithTemplate( - {templateName, templateType, jsonQuery, reportIDList, transactionIDList, policyID}: ExportSearchWithTemplateParams, + {templateName, templateType, jsonQuery, reportIDList, transactionIDList, policyID, exportName}: ExportSearchWithTemplateParams, shouldTrackExportProgress = false, ): string { const exportID = rand64(); @@ -1317,6 +1330,7 @@ function queueExportSearchWithTemplate( reportIDList, transactionIDList, policyID, + exportName, ...(shouldTrackExportProgress ? {exportID} : {}), }) as QueueExportSearchWithTemplateParams; diff --git a/src/libs/fileDownload/DownloadUtils.ts b/src/libs/fileDownload/DownloadUtils.ts index cb64cfdf03ed..5c71f7a3b95c 100644 --- a/src/libs/fileDownload/DownloadUtils.ts +++ b/src/libs/fileDownload/DownloadUtils.ts @@ -38,6 +38,9 @@ const fetchFileDownload: FileDownload = ( formData = undefined, requestType = 'get', onDownloadFailed?: () => void, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + shouldUnlink = false, + appendTimestamp = true, ) => { const resolvedUrl = tryResolveUrlFromApiRoot(url); @@ -72,7 +75,8 @@ const fetchFileDownload: FileDownload = ( .then((blob) => { // Create blob link to download const href = URL.createObjectURL(new Blob([blob])); - const completeFileName = appendTimeToFileName(fileName ?? getFileName(url)); + const resolvedFileName = fileName ?? getFileName(url); + const completeFileName = appendTimestamp ? appendTimeToFileName(resolvedFileName) : resolvedFileName; createDownloadLink(href, completeFileName); }) .catch(() => { diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index 9a8523e4c731..185cc57d96fb 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -197,6 +197,15 @@ function cleanFileName(fileName: string): string { return fileName.replaceAll(/[^a-zA-Z0-9\-._]/g, '_'); } +/** + * Builds a standardized export filename: `expensify__.`. + * The export name is sanitized and the whole name is lowercased so it is safe and consistent + * to use as a filename, and the unique id keeps filenames distinct without relying on a timestamp. + */ +function getExportFileName(exportName: string, uniqueID: string, extension = 'csv'): string { + return `expensify_${cleanFileName(exportName)}_${uniqueID}.${extension}`.toLowerCase(); +} + function appendTimeToFileName(fileName: string): string { const file = splitExtensionFromFileName(fileName); @@ -926,6 +935,7 @@ export { getFileName, getFileType, cleanFileName, + getExportFileName, appendTimeToFileName, ANDROID_SAFE_FILE_NAME_LENGTH, truncateFileNameToSafeLengthOnAndroid, diff --git a/src/libs/fileDownload/index.android.ts b/src/libs/fileDownload/index.android.ts index 6a476d0ed9d9..ddd5e604ea34 100644 --- a/src/libs/fileDownload/index.android.ts +++ b/src/libs/fileDownload/index.android.ts @@ -36,14 +36,15 @@ function hasAndroidPermission(): Promise { /** * Handling the download */ -function handleDownload(translate: LocalizedTranslate, url: string, fileName?: string, successMessage?: string, shouldUnlink = true): Promise { +function handleDownload(translate: LocalizedTranslate, url: string, fileName?: string, successMessage?: string, shouldUnlink = true, appendTimestamp = true): Promise { return new Promise((resolve) => { const dirs = RNFetchBlob.fs.dirs; // Android files will download to Download directory const path = dirs.DownloadDir; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Disabling this line for safeness as nullish coalescing works only if the value is undefined or null, and since fileName can be an empty string we want to default to `FileUtils.getFileName(url)` - const attachmentName = appendTimeToFileName(fileName || getFileName(url)); + const resolvedFileName = fileName || getFileName(url); + const attachmentName = appendTimestamp ? appendTimeToFileName(resolvedFileName) : resolvedFileName; const isLocalFile = url.startsWith('file://'); @@ -97,7 +98,7 @@ function handleDownload(translate: LocalizedTranslate, url: string, fileName?: s }); } -const postDownloadFile = (translate: LocalizedTranslate, url: string, fileName?: string, formData?: FormData, onDownloadFailed?: () => void): Promise => { +const postDownloadFile = (translate: LocalizedTranslate, url: string, fileName?: string, formData?: FormData, onDownloadFailed?: () => void, appendTimestamp = true): Promise => { const fetchOptions: RequestInit = { method: 'POST', body: formData, @@ -115,7 +116,8 @@ const postDownloadFile = (translate: LocalizedTranslate, url: string, fileName?: return response.text(); }) .then((fileData) => { - const finalFileName = appendTimeToFileName(fileName ?? 'Expensify'); + const resolvedFileName = fileName ?? 'Expensify'; + const finalFileName = appendTimestamp ? appendTimeToFileName(resolvedFileName) : resolvedFileName; const downloadPath = `${RNFS.DownloadDirectoryPath}/${finalFileName}`; return RNFS.writeFile(downloadPath, fileData, 'utf8').then(() => downloadPath); }) @@ -145,15 +147,15 @@ const postDownloadFile = (translate: LocalizedTranslate, url: string, fileName?: /** * Checks permission and downloads the file for Android */ -const fileDownload: FileDownload = (translate, url, fileName, successMessage, _, formData, requestType, onDownloadFailed, shouldUnlink) => +const fileDownload: FileDownload = (translate, url, fileName, successMessage, _, formData, requestType, onDownloadFailed, shouldUnlink, appendTimestamp = true) => new Promise((resolve) => { hasAndroidPermission() .then((hasPermission) => { if (hasPermission) { if (requestType === CONST.NETWORK.METHOD.POST) { - return postDownloadFile(translate, url, fileName, formData, onDownloadFailed); + return postDownloadFile(translate, url, fileName, formData, onDownloadFailed, appendTimestamp); } - return handleDownload(translate, url, fileName, successMessage, shouldUnlink); + return handleDownload(translate, url, fileName, successMessage, shouldUnlink, appendTimestamp); } showPermissionErrorAlert(translate); }) diff --git a/src/libs/fileDownload/index.ios.ts b/src/libs/fileDownload/index.ios.ts index 1157c175ac53..1f2f568201d7 100644 --- a/src/libs/fileDownload/index.ios.ts +++ b/src/libs/fileDownload/index.ios.ts @@ -45,7 +45,7 @@ function downloadFile(fileUrl: string, fileName: string) { }).fetch('GET', fileUrl); } -const postDownloadFile = (translate: LocalizedTranslate, url: string, fileName?: string, formData?: FormData, onDownloadFailed?: () => void) => { +const postDownloadFile = (translate: LocalizedTranslate, url: string, fileName?: string, formData?: FormData, onDownloadFailed?: () => void, appendTimestamp = true) => { const fetchOptions: RequestInit = { method: 'POST', body: formData, @@ -63,7 +63,8 @@ const postDownloadFile = (translate: LocalizedTranslate, url: string, fileName?: return response.text(); }) .then((fileData) => { - const finalFileName = appendTimeToFileName(fileName ?? 'Expensify'); + const resolvedFileName = fileName ?? 'Expensify'; + const finalFileName = appendTimestamp ? appendTimeToFileName(resolvedFileName) : resolvedFileName; const expensifyDir = `${RNFS.DocumentDirectoryPath}/Expensify`; const localPath = `${expensifyDir}/${finalFileName}`; return RNFS.mkdir(expensifyDir).then(() => { @@ -125,12 +126,13 @@ function downloadVideo(fileUrl: string, fileName: string): Promise +const fileDownload: FileDownload = (translate, fileUrl, fileName, successMessage, _, formData, requestType, onDownloadFailed, shouldUnlink, appendTimestamp = true) => new Promise((resolve) => { let fileDownloadPromise; const fileType = getFileType(fileUrl); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Disabling this line for safeness as nullish coalescing works only if the value is undefined or null, and since fileName can be an empty string we want to default to `FileUtils.getFileName(url)` - const attachmentName = appendTimeToFileName(fileName || getFileName(fileUrl)); + const resolvedFileName = fileName || getFileName(fileUrl); + const attachmentName = appendTimestamp ? appendTimeToFileName(resolvedFileName) : resolvedFileName; switch (fileType) { case CONST.ATTACHMENT_FILE_TYPE.IMAGE: @@ -141,7 +143,7 @@ const fileDownload: FileDownload = (translate, fileUrl, fileName, successMessage break; default: if (requestType === CONST.NETWORK.METHOD.POST) { - fileDownloadPromise = postDownloadFile(translate, fileUrl, fileName, formData, onDownloadFailed); + fileDownloadPromise = postDownloadFile(translate, fileUrl, fileName, formData, onDownloadFailed, appendTimestamp); break; } diff --git a/src/libs/fileDownload/index.ts b/src/libs/fileDownload/index.ts index 3612d9455a1d..2969a6530806 100644 --- a/src/libs/fileDownload/index.ts +++ b/src/libs/fileDownload/index.ts @@ -13,6 +13,8 @@ const fileDownload: FileDownload = ( formData = undefined, requestType = 'get', onDownloadFailed?: () => void, -) => fetchFileDownload(translate, url, fileName, successMessage, shouldOpenExternalLink, formData, requestType, onDownloadFailed); + shouldUnlink = false, + appendTimestamp = true, +) => fetchFileDownload(translate, url, fileName, successMessage, shouldOpenExternalLink, formData, requestType, onDownloadFailed, shouldUnlink, appendTimestamp); export default fileDownload; diff --git a/src/libs/fileDownload/types.ts b/src/libs/fileDownload/types.ts index e8efbe131f31..038ef0f2e17e 100644 --- a/src/libs/fileDownload/types.ts +++ b/src/libs/fileDownload/types.ts @@ -12,6 +12,7 @@ type FileDownload = ( requestType?: RequestType, onDownloadFailed?: () => void, shouldUnlink?: boolean, + appendTimestamp?: boolean, ) => Promise; type ImageResolution = {width: number; height: number}; type GetImageResolution = (url: File | Asset) => Promise; diff --git a/tests/unit/FileUtilsTest.ts b/tests/unit/FileUtilsTest.ts index 1859b8fae032..bd81ad06bf69 100644 --- a/tests/unit/FileUtilsTest.ts +++ b/tests/unit/FileUtilsTest.ts @@ -6,6 +6,7 @@ import { ANDROID_SAFE_FILE_NAME_LENGTH, appendTimeToFileName, canvasFallback, + getExportFileName, getFileValidationErrorText, getImageDimensionsAfterResize, splitExtensionFromFileName, @@ -98,6 +99,20 @@ describe('FileUtils', () => { }); }); + describe('getExportFileName', () => { + it('builds the expensify__ filename, sanitizes and lowercases the export name', () => { + expect(getExportFileName('Current view', '123abc')).toEqual('expensify_current_view_123abc.csv'); + }); + + it('replaces every illegal character in the export name with an underscore', () => { + expect(getExportFileName('All Data - expense level', 'abc')).toEqual('expensify_all_data_-_expense_level_abc.csv'); + }); + + it('honors a custom extension', () => { + expect(getExportFileName('Current view', 'abc', 'xlsx')).toEqual('expensify_current_view_abc.xlsx'); + }); + }); + describe('canvasFallback', () => { const mockCreateImageBitmap = jest.fn(); const mockCanvas = { diff --git a/tests/unit/SearchActionsTest.ts b/tests/unit/SearchActionsTest.ts index 7c5f3bb228c5..6a693b800bc1 100644 --- a/tests/unit/SearchActionsTest.ts +++ b/tests/unit/SearchActionsTest.ts @@ -41,6 +41,7 @@ describe('queueExportSearchItemsToCSV', () => { transactionIDList: [], isBasicExport: true, exportColumnLabels: '{}', + exportName: 'Basic export', }); expect(typeof exportID).toBe('string'); @@ -76,6 +77,7 @@ describe('queueExportSearchWithTemplate', () => { reportIDList: [], transactionIDList: [], policyID: 'policy123', + exportName: 'Test Template', }, true, ); @@ -108,6 +110,7 @@ describe('queueExportSearchWithTemplate', () => { reportIDList: [], transactionIDList: [], policyID: 'policy123', + exportName: 'Test Template', }); const finalParameters = mockWrite.mock.calls.at(-1)?.at(1);