diff --git a/src/components/ExportDownloadStatusModal.tsx b/src/components/ExportDownloadStatusModal.tsx index a6f6857570e1..32a4800e3a14 100644 --- a/src/components/ExportDownloadStatusModal.tsx +++ b/src/components/ExportDownloadStatusModal.tsx @@ -75,7 +75,7 @@ function ExportDownloadStatusModal({exportID, isVisible, onClose, failedBody}: E const isCSV = fileName.endsWith('.csv'); const secureType = isCSV ? CONST.SECURE_DOWNLOAD_TYPE.CSV_EXPORT : CONST.SECURE_DOWNLOAD_TYPE.PDF_REPORT; const url = buildSecureDownloadURL({baseURL, secureType, fileName, downloadName: fileName, email: currentUserLogin}); - fileDownload(translate, addEncryptedAuthTokenToURL(url, encryptedAuthToken ?? '', true), fileName, '', isMobileSafari()); + fileDownload(translate, addEncryptedAuthTokenToURL(url, encryptedAuthToken ?? '', true), fileName, '', isMobileSafari(), undefined, undefined, undefined, undefined, false); }; useEffect(() => { diff --git a/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx b/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx index 0aba7a8cc9e5..32178b725f86 100644 --- a/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx +++ b/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx @@ -83,7 +83,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; @@ -101,6 +101,7 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool reportIDList: [report.reportID], transactionIDList, policyID: policy?.id, + exportName, }, true, ); @@ -147,7 +148,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 bb6458450466..686cc4ac1032 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; @@ -104,7 +104,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; @@ -122,6 +122,7 @@ function useExportActions({reportID, policy, onPDFModalOpen}: UseExportActionsPa reportIDList: [moneyRequestReport.reportID], transactionIDList, policyID, + exportName, }, true, ); @@ -203,7 +204,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 6542f13dd9c2..34115de04225 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -579,7 +579,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) { @@ -611,6 +611,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { reportIDList: [], transactionIDList: [], policyID, + exportName, }, true, ); @@ -624,6 +625,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { reportIDList: isGroupExport ? [] : selectedTransactionReportIDs, transactionIDList: isGroupExport ? [] : selectedTransactionsKeys, policyID, + exportName, }, true, ); @@ -699,6 +701,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return; } + const exportName = translate(isBasicExport ? 'export.basicExport' : 'export.currentView'); + if (areAllMatchingItemsSelected) { if (selectedTransactionsKeys.length === 0 || status == null || !hash) { return; @@ -712,6 +716,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { transactionIDList: selectedTransactionsKeys, isBasicExport: exportParameters.isBasicExport, exportColumnLabels: exportParameters.exportColumnLabels, + exportName, }); trackExport(exportID); return; @@ -730,6 +735,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { transactionIDList: isGroupExport ? [] : selectedTransactionsKeys, isBasicExport: exportParameters.isBasicExport, exportColumnLabels: exportParameters.exportColumnLabels, + exportName, }, () => { didFail = true; @@ -1548,8 +1554,12 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { } } + const isGroupedSearch = !!getValidGroupBy(queryJSON?.groupBy); + exportOptions.push({ - text: translate('export.basicExport'), + // Group by exports dont have a basicExport, at the same time the backend expects isBasicExport to be true for grouped exports, so we just rename the option here + // Fixing here https://github.com/Expensify/Expensify/issues/652978 + text: translate(isGroupedSearch ? 'export.currentView' : 'export.basicExport'), icon: expensifyIcons.Table, onSelected: () => { handleBasicExport(); @@ -1558,7 +1568,6 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { shouldCallAfterModalHide: true, }); - const isGroupedSearch = !!getValidGroupBy(queryJSON?.groupBy); if (!isGroupedSearch) { exportOptions.push({ text: translate('export.currentView'), @@ -1580,7 +1589,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 e44d25ec723f..57491b320ada 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -74,7 +74,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; }) { @@ -415,7 +415,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/de.ts b/src/languages/de.ts index d6b21b17b432..81c456163cd4 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -9817,7 +9817,7 @@ Hier ist ein *Testbeleg*, um dir zu zeigen, wie es funktioniert:`, expenseLevelExport: 'Alle Daten – Ausgabenebene', exportInProgress: 'Export wird ausgeführt', conciergeWillSend: 'Concierge wird dir die Datei in Kürze senden.', - currentView: 'Aktuelle Ansicht exportieren', + currentView: 'Aktuelle Ansicht', }, exportDownload: { preparingTitle: 'Preparing download...', diff --git a/src/languages/en.ts b/src/languages/en.ts index dfee7a40a572..f76aade9ea17 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -9886,7 +9886,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 3cf44f511a81..9d6b94da693f 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -9970,7 +9970,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/languages/fr.ts b/src/languages/fr.ts index 100ae66362dd..21c1f52b9bfc 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -9849,7 +9849,7 @@ Voici un *reçu test* pour vous montrer comment ça fonctionne :`, expenseLevelExport: 'Toutes les données - niveau dépense', exportInProgress: 'Export en cours', conciergeWillSend: 'Concierge vous enverra le fichier sous peu.', - currentView: 'Exporter la vue actuelle', + currentView: 'Vue actuelle', }, exportDownload: { preparingTitle: 'Preparing download...', diff --git a/src/languages/it.ts b/src/languages/it.ts index 6b800283c667..eea5e6946fde 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -9805,7 +9805,7 @@ Ecco una *ricevuta di prova* per mostrarti come funziona:`, expenseLevelExport: 'Tutti i dati - livello spesa', exportInProgress: 'Esportazione in corso', conciergeWillSend: 'Concierge ti invierà il file a breve.', - currentView: 'Esporta vista corrente', + currentView: 'Vista corrente', }, exportDownload: { preparingTitle: 'Preparing download...', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index f6e8fcdb7d43..a9abe266357c 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -9680,7 +9680,7 @@ ${reportName}`, expenseLevelExport: 'すべてのデータ - 経費レベル', exportInProgress: 'エクスポート処理中', conciergeWillSend: 'Conciergeがまもなくファイルを送信します。', - currentView: '現在のビューをエクスポート', + currentView: '現在のビュー', }, exportDownload: { preparingTitle: 'Preparing download...', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index fdce0dab3fa8..a6c08f01c926 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -9775,7 +9775,7 @@ Hier is een *proefbon* om je te laten zien hoe het werkt:`, expenseLevelExport: 'Alle gegevens - uitgaveniveau', exportInProgress: 'Export bezig', conciergeWillSend: 'Concierge stuurt je het bestand zo meteen.', - currentView: 'Huidige weergave exporteren', + currentView: 'Huidige weergave', }, exportDownload: { preparingTitle: 'Preparing download...', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 19adc3a569c9..72de10541934 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -9756,7 +9756,7 @@ Oto *paragon testowy*, żeby pokazać Ci, jak to działa:`, expenseLevelExport: 'Wszystkie dane – poziom wydatku', exportInProgress: 'Trwa eksport', conciergeWillSend: 'Concierge wkrótce wyśle Ci plik.', - currentView: 'Eksportuj bieżący widok', + currentView: 'Bieżący widok', }, exportDownload: { preparingTitle: 'Preparing download...', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index ee7c031187fd..82f0f5cd326f 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -9760,7 +9760,7 @@ Aqui está um *comprovante de teste* para mostrar como funciona:`, expenseLevelExport: 'Todos os dados - nível de despesa', exportInProgress: 'Exportação em andamento', conciergeWillSend: 'O Concierge enviará o arquivo para você em breve.', - currentView: 'Exportar visão atual', + currentView: 'Visualização atual', }, exportDownload: { preparingTitle: 'Preparing download...', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 4cee399155db..4622e0789711 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -9497,7 +9497,7 @@ ${reportName}`, expenseLevelExport: '所有数据 - 报销级别', exportInProgress: '导出进行中', conciergeWillSend: 'Concierge 将很快把文件发送给你。', - currentView: '导出当前视图', + currentView: '当前视图', }, exportDownload: { preparingTitle: 'Preparing download...', 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/Report/index.ts b/src/libs/actions/Report/index.ts index e10017de6f4e..a97cdf884ec8 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -69,6 +69,7 @@ import getEnvironment from '@libs/Environment/getEnvironment'; import type EnvironmentType from '@libs/Environment/getEnvironment/types'; import {getMicroSecondOnyxErrorWithTranslationKey, getMicroSecondTranslationErrorWithTranslationKey} from '@libs/ErrorUtils'; import fileDownload from '@libs/fileDownload'; +import {getExportFileName} from '@libs/fileDownload/FileUtils'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import HttpUtils from '@libs/HttpUtils'; import Log from '@libs/Log'; @@ -6108,7 +6109,18 @@ function exportReportToCSV({reportID, transactionIDList}: ExportReportCSVParams, } } - fileDownload(translate, ApiUtils.getCommandURL({command: WRITE_COMMANDS.EXPORT_REPORT_TO_CSV}), 'Expensify.csv', '', false, formData, CONST.NETWORK.METHOD.POST, onDownloadFailed); + fileDownload( + translate, + ApiUtils.getCommandURL({command: WRITE_COMMANDS.EXPORT_REPORT_TO_CSV}), + getExportFileName(translate('export.basicExport'), rand64()), + '', + false, + formData, + CONST.NETWORK.METHOD.POST, + onDownloadFailed, + undefined, + false, + ); } async function exportReportToPDF({reportID}: ExportReportPDFParams) { diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 670949c31365..ebffd0c9b212 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -25,6 +25,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'; @@ -1231,7 +1232,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, ) { @@ -1278,10 +1279,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, + undefined, + 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; @@ -1307,6 +1319,7 @@ function queueExportSearchItemsToCSV({query, jsonQuery, reportIDList, transactio transactionIDList, isBasicExport, exportColumnLabels, + exportName, exportID, }) as QueueExportSearchItemsToCSVParams; @@ -1316,7 +1329,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(); @@ -1348,6 +1361,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..ddeabfbd4468 100644 --- a/src/libs/fileDownload/DownloadUtils.ts +++ b/src/libs/fileDownload/DownloadUtils.ts @@ -38,6 +38,10 @@ const fetchFileDownload: FileDownload = ( formData = undefined, requestType = 'get', onDownloadFailed?: () => void, + // `shouldUnlink` is part of the cross-platform FileDownload signature but has no meaning on web, so it is intentionally unused here. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + shouldUnlink = false, + appendTimestamp = true, ) => { const resolvedUrl = tryResolveUrlFromApiRoot(url); @@ -72,7 +76,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/ExportDownloadStatusModalTest.tsx b/tests/unit/ExportDownloadStatusModalTest.tsx index e90e30294755..bb860bbd1fe5 100644 --- a/tests/unit/ExportDownloadStatusModalTest.tsx +++ b/tests/unit/ExportDownloadStatusModalTest.tsx @@ -118,7 +118,19 @@ describe('ExportDownloadStatusModal', () => { await waitForBatchedUpdatesWithAct(); const expectedURLPart = `secure?secureType=csvexport&filename=${encodeURIComponent(CSV_FILE_NAME)}&downloadName=${encodeURIComponent(CSV_FILE_NAME)}`; - expect(mockFileDownload).toHaveBeenCalledWith(expect.anything(), expect.stringContaining(expectedURLPart), CSV_FILE_NAME, expect.anything(), expect.anything()); + // shouldUnlink (arg 9) is left undefined so the platform default cleans up the temp file; appendTimestamp (arg 10) is false so the OS-recorded download time isn't duplicated in the name. + expect(mockFileDownload).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining(expectedURLPart), + CSV_FILE_NAME, + expect.anything(), + expect.anything(), + undefined, + undefined, + undefined, + undefined, + false, + ); }); it('auto-downloads PDF on ready state transition with pdfreport secureType', async () => { @@ -128,7 +140,19 @@ describe('ExportDownloadStatusModal', () => { await waitForBatchedUpdatesWithAct(); const expectedURLPart = `secure?secureType=pdfreport&filename=${encodeURIComponent(PDF_FILE_NAME)}&downloadName=${encodeURIComponent(PDF_FILE_NAME)}`; - expect(mockFileDownload).toHaveBeenCalledWith(expect.anything(), expect.stringContaining(expectedURLPart), PDF_FILE_NAME, expect.anything(), expect.anything()); + // shouldUnlink (arg 9) is left undefined so the platform default cleans up the temp file; appendTimestamp (arg 10) is false so the OS-recorded download time isn't duplicated in the name. + expect(mockFileDownload).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining(expectedURLPart), + PDF_FILE_NAME, + expect.anything(), + expect.anything(), + undefined, + undefined, + undefined, + undefined, + false, + ); }); it('shows ready state with a Download button and no Close button', async () => { 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 e4f89de1f090..8511d0672c1d 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); diff --git a/tests/unit/hooks/useExportActionsTest.ts b/tests/unit/hooks/useExportActionsTest.ts index a777797b7b68..f5c1731c2f32 100644 --- a/tests/unit/hooks/useExportActionsTest.ts +++ b/tests/unit/hooks/useExportActionsTest.ts @@ -9,6 +9,7 @@ const mockClearExportDownload = jest.mocked(clearExportDownload); const REPORT_ID = 'report1'; const POLICY_ID = 'policy1'; +const EXPORT_NAME = 'Test Template'; type ExportDownloadStatusModalProps = {exportID: string; onClose: () => void}; @@ -104,7 +105,7 @@ describe('useExportActions - template export status modal', () => { const {result} = renderHook(() => useExportActions({reportID: REPORT_ID})); act(() => { - result.current.beginExportWithTemplate('Test Template', 'csv', ['1', '2'], POLICY_ID); + result.current.beginExportWithTemplate('Test Template', 'csv', ['1', '2'], EXPORT_NAME, POLICY_ID); }); expect(mockQueueExportSearchWithTemplate).toHaveBeenCalledWith( @@ -115,6 +116,7 @@ describe('useExportActions - template export status modal', () => { reportIDList: [REPORT_ID], transactionIDList: ['1', '2'], policyID: POLICY_ID, + exportName: EXPORT_NAME, }, true, ); @@ -127,7 +129,7 @@ describe('useExportActions - template export status modal', () => { const {result} = renderHook(() => useExportActions({reportID: REPORT_ID})); act(() => { - result.current.beginExportWithTemplate('Test Template', 'csv', ['1'], POLICY_ID); + result.current.beginExportWithTemplate('Test Template', 'csv', ['1'], EXPORT_NAME, POLICY_ID); }); expect(mockQueueExportSearchWithTemplate).not.toHaveBeenCalled(); @@ -139,7 +141,7 @@ describe('useExportActions - template export status modal', () => { const {result} = renderHook(() => useExportActions({reportID: REPORT_ID})); act(() => { - result.current.beginExportWithTemplate('Test Template', 'csv', ['1'], POLICY_ID); + result.current.beginExportWithTemplate('Test Template', 'csv', ['1'], EXPORT_NAME, POLICY_ID); }); const modal: ReactElement | null = result.current.exportDownloadStatusModal; expect(modal?.props.exportID).toBe('mock-export-id');