Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/components/ExportDownloadStatusModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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_<exportName>_<uniqueID>`, so skip the timestamp suffix.
fileDownload(translate, addEncryptedAuthTokenToURL(url, encryptedAuthToken ?? '', true), fileName, '', isMobileSafari(), undefined, undefined, undefined, false, false);
};

useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -98,6 +98,7 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool
reportIDList: [report.reportID],
transactionIDList,
policyID: policy?.id,
exportName,
});

showConfirmModal({
Expand Down Expand Up @@ -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,
});

Expand Down
7 changes: 4 additions & 3 deletions src/hooks/useExportActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ type UseExportActionsParams = {
type UseExportActionsReturn = {
exportActionEntries: Record<string, DropdownOption<ValueOf<typeof CONST.REPORT.SECONDARY_ACTIONS>> & Pick<PopoverMenuItem, 'backButtonText' | 'rightIcon'>>;
secondaryExportActions: Array<ValueOf<string>>;
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;
};
Expand Down Expand Up @@ -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;
Expand All @@ -134,6 +134,7 @@ function useExportActions({reportID, policy, onPDFModalOpen}: UseExportActionsPa
reportIDList: [moneyRequestReport.reportID],
transactionIDList,
policyID,
exportName,
});
};

Expand Down Expand Up @@ -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),
};
}

Expand Down
10 changes: 8 additions & 2 deletions src/hooks/useSearchBulkActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -592,6 +592,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
reportIDList: [],
transactionIDList: [],
policyID,
exportName,
},
true,
);
Expand All @@ -605,6 +606,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
reportIDList: isGroupExport ? [] : selectedTransactionReportIDs,
transactionIDList: isGroupExport ? [] : selectedTransactionsKeys,
policyID,
exportName,
},
true,
);
Expand Down Expand Up @@ -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;
Expand All @@ -683,6 +687,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
transactionIDList: selectedTransactionsKeys,
isBasicExport: exportParameters.isBasicExport,
exportColumnLabels: exportParameters.exportColumnLabels,
exportName,
});
setActiveExportID(exportID);
return;
Expand All @@ -701,6 +706,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
transactionIDList: isGroupExport ? [] : selectedTransactionsKeys,
isBasicExport: exportParameters.isBasicExport,
exportColumnLabels: exportParameters.exportColumnLabels,
exportName,
},
() => {
didFail = true;
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/hooks/useSelectedTransactionsActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
}) {
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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...',
Expand Down
1 change: 1 addition & 0 deletions src/libs/API/parameters/ExportSearchItemsToCSVParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ type ExportSearchItemsToCSVParams = {
transactionIDList: string[];
isBasicExport: boolean;
exportColumnLabels: string;
exportName: string;
};

export default ExportSearchItemsToCSVParams;
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ type ExportSearchWithTemplateParams = {
reportIDList: string[];
transactionIDList: string[];
policyID: string | undefined;
exportName: string;
};

export default ExportSearchWithTemplateParams;
22 changes: 18 additions & 4 deletions src/libs/actions/Search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1200,7 +1201,7 @@ function rejectMoneyRequestsOnSearch(
type Params = Record<string, ExportSearchItemsToCSVParams>;

function exportSearchItemsToCSV(
{query, jsonQuery, reportIDList, transactionIDList, isBasicExport, exportColumnLabels}: ExportSearchItemsToCSVParams,
{query, jsonQuery, reportIDList, transactionIDList, isBasicExport, exportColumnLabels, exportName}: ExportSearchItemsToCSVParams,
onDownloadFailed: () => void,
translate: LocalizedTranslate,
) {
Expand Down Expand Up @@ -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;

Expand All @@ -1276,6 +1288,7 @@ function queueExportSearchItemsToCSV({query, jsonQuery, reportIDList, transactio
transactionIDList,
isBasicExport,
exportColumnLabels,
exportName,
exportID,
}) as QueueExportSearchItemsToCSVParams;

Expand All @@ -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();
Expand Down Expand Up @@ -1317,6 +1330,7 @@ function queueExportSearchWithTemplate(
reportIDList,
transactionIDList,
policyID,
exportName,
...(shouldTrackExportProgress ? {exportID} : {}),
}) as QueueExportSearchWithTemplateParams;

Expand Down
6 changes: 5 additions & 1 deletion src/libs/fileDownload/DownloadUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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(() => {
Expand Down
10 changes: 10 additions & 0 deletions src/libs/fileDownload/FileUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,15 @@ function cleanFileName(fileName: string): string {
return fileName.replaceAll(/[^a-zA-Z0-9\-._]/g, '_');
}

/**
* Builds a standardized export filename: `expensify_<exportName>_<uniqueID>.<extension>`.
* 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);

Expand Down Expand Up @@ -926,6 +935,7 @@ export {
getFileName,
getFileType,
cleanFileName,
getExportFileName,
appendTimeToFileName,
ANDROID_SAFE_FILE_NAME_LENGTH,
truncateFileNameToSafeLengthOnAndroid,
Expand Down
16 changes: 9 additions & 7 deletions src/libs/fileDownload/index.android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,15 @@ function hasAndroidPermission(): Promise<boolean> {
/**
* Handling the download
*/
function handleDownload(translate: LocalizedTranslate, url: string, fileName?: string, successMessage?: string, shouldUnlink = true): Promise<void> {
function handleDownload(translate: LocalizedTranslate, url: string, fileName?: string, successMessage?: string, shouldUnlink = true, appendTimestamp = true): Promise<void> {
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://');

Expand Down Expand Up @@ -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<void> => {
const postDownloadFile = (translate: LocalizedTranslate, url: string, fileName?: string, formData?: FormData, onDownloadFailed?: () => void, appendTimestamp = true): Promise<void> => {
const fetchOptions: RequestInit = {
method: 'POST',
body: formData,
Expand All @@ -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);
})
Expand Down Expand Up @@ -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);
})
Expand Down
Loading
Loading