diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index c49d49f281138..afe28a8fdee95 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -1,5 +1,5 @@ import {Str} from 'expensify-common'; -import React, {useCallback, useRef, useState} from 'react'; +import React, {useRef, useState} from 'react'; import {InteractionManager} from 'react-native'; import type {ValueOf} from 'type-fest'; import ConfirmModal from '@components/ConfirmModal'; @@ -16,29 +16,41 @@ import { validateAttachment, validateImageForCorruption, } from '@libs/fileDownload/FileUtils'; +import type {ValidateAttachmentOptions} from '@libs/fileDownload/FileUtils'; import convertHeicImage from '@libs/fileDownload/heicConverter'; +import Log from '@libs/Log'; import CONST from '@src/CONST'; import type {FileObject} from '@src/types/utils/Attachment'; import useLocalize from './useLocalize'; import useThemeStyles from './useThemeStyles'; +const DEFAULT_IS_VALIDATING_RECEIPTS = true; + type ErrorObject = { error: ValueOf; fileExtension?: string; }; +type ValidationOptions = { + isValidatingReceipts?: boolean; +}; + const sortFilesByOriginalOrder = (files: FileObject[], orderMap: Map) => { return files.sort((a, b) => (orderMap.get(a.uri ?? '') ?? 0) - (orderMap.get(b.uri ?? '') ?? 0)); }; -function useFilesValidation(proceedWithFilesAction: (files: FileObject[], dataTransferItems: DataTransferItem[]) => void, isValidatingReceipts = true) { +function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransferItems: DataTransferItem[]) => void) { const styles = useThemeStyles(); const {translate} = useLocalize(); + + const [isValidatingFiles, setIsValidatingFiles] = useState(false); + const [isValidatingReceipts, setIsValidatingReceipts] = useState(); + const [isValidatingMultipleFiles, setIsValidatingMultipleFiles] = useState(false); + const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); const [fileError, setFileError] = useState | null>(null); const [pdfFilesToRender, setPdfFilesToRender] = useState([]); const [validFilesToUpload, setValidFilesToUpload] = useState([] as FileObject[]); - const [isValidatingMultipleFiles, setIsValidatingMultipleFiles] = useState(false); const [invalidFileExtension, setInvalidFileExtension] = useState(''); const [errorQueue, setErrorQueue] = useState([]); const [currentErrorIndex, setCurrentErrorIndex] = useState(0); @@ -51,14 +63,14 @@ function useFilesValidation(proceedWithFilesAction: (files: FileObject[], dataTr const collectedErrors = useRef([]); const originalFileOrder = useRef>(new Map()); - const updateFileOrderMapping = useCallback((oldFile: FileObject | undefined, newFile: FileObject) => { + const updateFileOrderMapping = (oldFile: FileObject | undefined, newFile: FileObject) => { const originalIndex = originalFileOrder.current.get(oldFile?.uri ?? ''); if (originalIndex !== undefined) { originalFileOrder.current.set(newFile.uri ?? '', originalIndex); } - }, []); + }; - const deduplicateErrors = useCallback((errors: ErrorObject[]) => { + const deduplicateErrors = (errors: ErrorObject[]) => { const uniqueErrors = new Set(); return errors.filter((error) => { const key = `${error.error}-${error.fileExtension ?? ''}`; @@ -68,14 +80,16 @@ function useFilesValidation(proceedWithFilesAction: (files: FileObject[], dataTr uniqueErrors.add(key); return true; }); - }, []); + }; - const resetValidationState = useCallback(() => { + const resetValidationState = () => { + setIsValidatingFiles(false); + setIsValidatingReceipts(undefined); + setIsValidatingMultipleFiles(false); setIsErrorModalVisible(false); setPdfFilesToRender([]); setIsLoaderVisible(false); setValidFilesToUpload([]); - setIsValidatingMultipleFiles(false); setFileError(null); setInvalidFileExtension(''); setErrorQueue([]); @@ -86,22 +100,22 @@ function useFilesValidation(proceedWithFilesAction: (files: FileObject[], dataTr dataTransferItemList.current = []; collectedErrors.current = []; originalFileOrder.current.clear(); - }, [setIsLoaderVisible]); + }; - const hideModalAndReset = useCallback(() => { + const hideModalAndReset = () => { setIsErrorModalVisible(false); // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { resetValidationState(); }); - }, [resetValidationState]); + }; const setErrorAndOpenModal = (error: ValueOf) => { setFileError(error); setIsErrorModalVisible(true); }; - const isValidFile = (originalFile: FileObject, item: DataTransferItem | undefined, isCheckingMultipleFiles?: boolean) => { + const isValidFile = (originalFile: FileObject, item: DataTransferItem | undefined, validationOptions: ValidateAttachmentOptions) => { if (item && item.kind === 'file' && 'webkitGetAsEntry' in item) { const entry = item.webkitGetAsEntry(); @@ -114,7 +128,7 @@ function useFilesValidation(proceedWithFilesAction: (files: FileObject[], dataTr return normalizeFileObject(originalFile) .then((normalizedFile) => validateImageForCorruption(normalizedFile).then(() => { - const error = validateAttachment(normalizedFile, isCheckingMultipleFiles, isValidatingReceipts); + const error = validateAttachment(normalizedFile, validationOptions); if (error) { const errorData = { error, @@ -143,7 +157,7 @@ function useFilesValidation(proceedWithFilesAction: (files: FileObject[], dataTr }); }; - const checkIfAllValidatedAndProceed = useCallback(() => { + const checkIfAllValidatedAndProceed = () => { if (!validatedPDFs.current || !validFiles.current) { return; } @@ -170,12 +184,12 @@ function useFilesValidation(proceedWithFilesAction: (files: FileObject[], dataTr } } else if (validFiles.current.length > 0) { const sortedFiles = sortFilesByOriginalOrder(validFiles.current, originalFileOrder.current); - proceedWithFilesAction(sortedFiles, dataTransferItemList.current); + onFilesValidated(sortedFiles, dataTransferItemList.current); resetValidationState(); } - }, [deduplicateErrors, pdfFilesToRender.length, proceedWithFilesAction, resetValidationState]); + }; - const validateAndResizeFiles = (files: FileObject[], items: DataTransferItem[]) => { + const validateAndResizeFiles = (files: FileObject[], items: DataTransferItem[], validationOptions?: ValidationOptions) => { // Early return for empty files if (files.length === 0) { return; @@ -188,7 +202,13 @@ function useFilesValidation(proceedWithFilesAction: (files: FileObject[], dataTr originalFileOrder.current.set(file.uri ?? '', index); }); - Promise.all(files.map((file, index) => isValidFile(file, items.at(index), files.length > 1).then((isValid) => (isValid ? file : null)))) + Promise.all( + files.map((file, index) => + isValidFile(file, items.at(index), {isValidatingMultipleFiles: files.length > 1, isValidatingReceipts: validationOptions?.isValidatingReceipts ?? isValidatingReceipts}).then( + (isValid) => (isValid ? file : null), + ), + ), + ) .then((validationResults) => { const filteredResults = validationResults.filter((result): result is FileObject => result !== null); const pdfsToLoad = filteredResults.filter((file) => Str.isPDF(file.name ?? '')); @@ -258,14 +278,27 @@ function useFilesValidation(proceedWithFilesAction: (files: FileObject[], dataTr } } else if (processedFiles.length > 0) { const sortedFiles = sortFilesByOriginalOrder(processedFiles, originalFileOrder.current); - proceedWithFilesAction(sortedFiles, dataTransferItemList.current); + onFilesValidated(sortedFiles, dataTransferItemList.current); resetValidationState(); } } }); }; - const validateFiles = (files: FileObject[], items?: DataTransferItem[]) => { + const validateFiles = (files: FileObject[], items?: DataTransferItem[], validationOptions?: ValidationOptions) => { + if (isValidatingFiles) { + Log.warn('Files are already being validated. Please wait for the current validation to complete before calling `validateFiles` again.'); + return; + } + + setIsValidatingFiles(true); + + const validationOptionsWithDefaults = { + ...validationOptions, + isValidatingReceipts: validationOptions?.isValidatingReceipts ?? DEFAULT_IS_VALIDATING_RECEIPTS, + }; + setIsValidatingReceipts(validationOptionsWithDefaults.isValidatingReceipts); + if (files.length > 1) { setIsValidatingMultipleFiles(true); } @@ -276,11 +309,11 @@ function useFilesValidation(proceedWithFilesAction: (files: FileObject[], dataTr } setErrorAndOpenModal(CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED); } else { - validateAndResizeFiles(files, items ?? []); + validateAndResizeFiles(files, items ?? [], validationOptionsWithDefaults); } }; - const onConfirm = () => { + const onConfirmError = () => { if (fileError === CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED) { setIsErrorModalVisible(false); validateAndResizeFiles(filesToValidate.current, dataTransferItemList.current); @@ -304,18 +337,18 @@ function useFilesValidation(proceedWithFilesAction: (files: FileObject[], dataTr const sortedFiles = sortFilesByOriginalOrder(validFilesToUpload, originalFileOrder.current); // If we're validating attachments we need to use InteractionManager to ensure // the error modal is dismissed before opening the attachment modal - if (!isValidatingReceipts && fileError) { + if (isValidatingReceipts === false && fileError) { setIsErrorModalVisible(false); // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { if (sortedFiles.length !== 0) { - proceedWithFilesAction(sortedFiles, dataTransferItemList.current); + onFilesValidated(sortedFiles, dataTransferItemList.current); } resetValidationState(); }); } else { if (sortedFiles.length !== 0) { - proceedWithFilesAction(sortedFiles, dataTransferItemList.current); + onFilesValidated(sortedFiles, dataTransferItemList.current); } hideModalAndReset(); } @@ -334,7 +367,7 @@ function useFilesValidation(proceedWithFilesAction: (files: FileObject[], dataTr }} onPassword={() => { validatedPDFs.current.push(file); - if (isValidatingReceipts) { + if (isValidatingReceipts === true) { collectedErrors.current.push({error: CONST.FILE_VALIDATION_ERRORS.PROTECTED_FILE}); } else { validFiles.current.push(file); @@ -350,11 +383,11 @@ function useFilesValidation(proceedWithFilesAction: (files: FileObject[], dataTr )) : undefined; - const getModalPrompt = useCallback(() => { + const getModalPrompt = () => { if (!fileError) { return ''; } - const prompt = getFileValidationErrorText(fileError, {fileType: invalidFileExtension}, isValidatingReceipts).reason; + const prompt = getFileValidationErrorText(fileError, {fileType: invalidFileExtension}, isValidatingReceipts === true).reason; if (fileError === CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE_MULTIPLE || fileError === CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE) { return ( @@ -364,12 +397,12 @@ function useFilesValidation(proceedWithFilesAction: (files: FileObject[], dataTr ); } return prompt; - }, [fileError, invalidFileExtension, isValidatingReceipts, translate]); + }; const ErrorModal = ( => { }); }; -const validateAttachment = (file: FileObject, isCheckingMultipleFiles?: boolean, isValidatingReceipt?: boolean) => { - const maxFileSize = isValidatingReceipt ? CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE : CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE; +type ValidateAttachmentOptions = { + isValidatingReceipts?: boolean; + isValidatingMultipleFiles?: boolean; +}; + +const validateAttachment = (file: FileObject, validationOptions?: ValidateAttachmentOptions) => { + const maxFileSize = validationOptions?.isValidatingReceipts ? CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE : CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE; - if (isValidatingReceipt && !isValidReceiptExtension(file)) { - return isCheckingMultipleFiles ? CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE_MULTIPLE : CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE; + if (validationOptions?.isValidatingReceipts && !isValidReceiptExtension(file)) { + return validationOptions?.isValidatingMultipleFiles ? CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE_MULTIPLE : CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE; } if (!Str.isImage(file.name ?? '') && !hasHeicOrHeifExtension(file) && (file?.size ?? 0) > maxFileSize) { - return isCheckingMultipleFiles ? CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE_MULTIPLE : CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE; + return validationOptions?.isValidatingMultipleFiles ? CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE_MULTIPLE : CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE; } - if (isValidatingReceipt && (file?.size ?? 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { + if (validationOptions?.isValidatingReceipts && (file?.size ?? 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { return CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL; } @@ -777,3 +783,5 @@ export { cleanFileObject, cleanFileObjectName, }; + +export type {ValidateAttachmentOptions}; diff --git a/src/pages/home/report/ReportActionCompose/useAttachmentUploadValidation.ts b/src/pages/home/report/ReportActionCompose/useAttachmentUploadValidation.ts index 2dfe00ac83130..3add5f32a82a1 100644 --- a/src/pages/home/report/ReportActionCompose/useAttachmentUploadValidation.ts +++ b/src/pages/home/report/ReportActionCompose/useAttachmentUploadValidation.ts @@ -116,7 +116,7 @@ function useAttachmentUploadValidation({ ); }; - const {validateFiles, PDFValidationComponent, ErrorModal} = useFilesValidation(onFilesValidated, false); + const {validateFiles, PDFValidationComponent, ErrorModal} = useFilesValidation(onFilesValidated); const validateAttachments = useCallback( ({dragEvent, files}: {dragEvent?: DragEvent; files?: FileObject | FileObject[]}) => { @@ -161,7 +161,7 @@ function useAttachmentUploadValidation({ const filteredItems = dataTransferItems && validIndices.length > 0 ? validIndices.map((index) => dataTransferItems.at(index) ?? ({} as DataTransferItem)) : undefined; attachmentUploadType.current = 'attachment'; - validateFiles(fileObjects, filteredItems); + validateFiles(fileObjects, filteredItems, {isValidatingReceipts: false}); }, [isAttachmentPreviewActive, validateFiles], ); @@ -187,7 +187,7 @@ function useAttachmentUploadValidation({ } attachmentUploadType.current = 'receipt'; - validateFiles(files, items); + validateFiles(files, items, {isValidatingReceipts: true}); }, [policy, shouldAddOrReplaceReceipt, transactionID, validateFiles], ); diff --git a/tests/unit/FileUtilsTest.ts b/tests/unit/FileUtilsTest.ts index f1b3e05d47f65..6bd4be9397899 100644 --- a/tests/unit/FileUtilsTest.ts +++ b/tests/unit/FileUtilsTest.ts @@ -47,13 +47,13 @@ describe('FileUtils', () => { describe('validateAttachment', () => { it('should not return FILE_TOO_SMALL when validating small attachment', () => { const file = createMockFile('file.csv', CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE - 1); - const error = FileUtils.validateAttachment(file, false, false); + const error = FileUtils.validateAttachment(file, {isValidatingMultipleFiles: false, isValidatingReceipts: false}); expect(error).not.toBe(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL); }); it('should return FILE_TOO_SMALL when validating small receipt', () => { const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE - 1); - const error = FileUtils.validateAttachment(file, false, true); + const error = FileUtils.validateAttachment(file, {isValidatingMultipleFiles: false, isValidatingReceipts: true}); expect(error).toBe(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL); }); @@ -65,31 +65,31 @@ describe('FileUtils', () => { it('should return FILE_TOO_LARGE_MULTIPLE when checking multiple files', () => { const file = createMockFile('file.pdf', CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE + 1); - const error = FileUtils.validateAttachment(file, true); + const error = FileUtils.validateAttachment(file, {isValidatingMultipleFiles: true, isValidatingReceipts: false}); expect(error).toBe(CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE_MULTIPLE); }); it('should return WRONG_FILE_TYPE for invalid receipt extension', () => { const file = createMockFile('receipt.exe', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); - const error = FileUtils.validateAttachment(file, false, true); + const error = FileUtils.validateAttachment(file, {isValidatingMultipleFiles: false, isValidatingReceipts: true}); expect(error).toBe(CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE); }); it('should prioritize WRONG_FILE_TYPE over FILE_TOO_LARGE for receipts', () => { const file = createMockFile('receipt.exe', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE + 10); - const error = FileUtils.validateAttachment(file, false, true); + const error = FileUtils.validateAttachment(file, {isValidatingMultipleFiles: false, isValidatingReceipts: true}); expect(error).toBe(CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE); }); it('should return WRONG_FILE_TYPE_MULTIPLE when checking multiple invalid receipt files', () => { const file = createMockFile('receipt.exe', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE + 10); - const error = FileUtils.validateAttachment(file, true, true); + const error = FileUtils.validateAttachment(file, {isValidatingMultipleFiles: true, isValidatingReceipts: true}); expect(error).toBe(CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE_MULTIPLE); }); it('should return empty string for valid image receipt', () => { const file = createMockFile('receipt.jpg', CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE - 1); - const error = FileUtils.validateAttachment(file, false, true); + const error = FileUtils.validateAttachment(file, {isValidatingMultipleFiles: false, isValidatingReceipts: true}); expect(error).toBe(''); }); });